Files
atlantis/server/controllers/events/events_controller_e2e_test.go
renovate[bot] b3b3f5ab43 fix(deps): update module github.com/google/go-github/v68 to v71 in go.mod (main) (#5522)
Signed-off-by: RB <7775707+nitrocode@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: RB <7775707+nitrocode@users.noreply.github.com>
2025-04-11 01:52:03 -05:00

1927 lines
62 KiB
Go

package events_test
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"testing"
"github.com/google/go-github/v71/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/config"
"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/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"
. "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")
}
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(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
for _, allowCommand := range allowCommands {
if allowCommand == command.Apply {
disableApply = false
break
}
}
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)
boltdb, err := db.New(dataDir)
Ok(t, err)
backend := boltdb
lockingClient := locking.NewClient(boltdb)
noOpLocker := locking.NewNoOpLocker()
applyLocker = locking.NewApplyClient(boltdb, 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, _, _ := metrics.NewLoggingScope(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)
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,
},
}
dbUpdater := &events.DBUpdater{
Backend: backend,
}
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,
dbUpdater,
pullUpdater,
policyCheckCommandRunner,
autoMerger,
parallelPoolSize,
silenceNoProjects,
boltdb,
lockingClient,
discardApprovalOnPlan,
e2ePullReqStatusFetcher,
)
applyCommandRunner := events.NewApplyCommandRunner(
e2eVCSClient,
false,
applyLocker,
e2eStatusUpdater,
projectCommandBuilder,
projectCommandRunner,
autoMerger,
pullUpdater,
dbUpdater,
boltdb,
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: backend,
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,
Backend: backend,
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.Replace(string(requestJSON), "c31fd9ea6f557ad2ea659944c3844a059b83bc5d", headSHA, -1)
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")