diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9f634f4bf..212ef9d44 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -26,7 +26,6 @@ on: jobs: analyze: - if: github.event.pull_request.draft == false name: Analyze if: github.event.pull_request.draft == false runs-on: ubuntu-latest diff --git a/runatlantis.io/docs/server-configuration.md b/runatlantis.io/docs/server-configuration.md index e3b57b42e..8f95a2a1b 100644 --- a/runatlantis.io/docs/server-configuration.md +++ b/runatlantis.io/docs/server-configuration.md @@ -873,6 +873,9 @@ and set `--autoplan-modules` to `false`. 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 (eg. `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. diff --git a/server/controllers/events/events_controller_e2e_test.go b/server/controllers/events/events_controller_e2e_test.go index b368b0edc..c00abec00 100644 --- a/server/controllers/events/events_controller_e2e_test.go +++ b/server/controllers/events/events_controller_e2e_test.go @@ -1177,6 +1177,7 @@ func setupE2E(t *testing.T, repoDir string, opt setupOption) (events_controllers "", "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl", false, + false, statsScope, logger, terraformClient, @@ -1327,6 +1328,7 @@ func setupE2E(t *testing.T, repoDir string, opt setupOption) (events_controllers e2ePullReqStatusFetcher, projectCommandBuilder, projectCommandRunner, + silenceNoProjects, ) stateCommandRunner := events.NewStateCommandRunner( diff --git a/server/events/apply_command_runner.go b/server/events/apply_command_runner.go index 8a17f2a03..6dc89bb30 100644 --- a/server/events/apply_command_runner.go +++ b/server/events/apply_command_runner.go @@ -124,14 +124,34 @@ func (a *ApplyCommandRunner) Run(ctx *command.Context, cmd *CommentCommand) { // 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 apply in.") + ctx.Log.Info("determined there was no project to run plan in") if !a.silenceVCSStatusNoProjects { - // If there were no projects modified, we set successful commit statuses - // with 0/0 projects 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 := a.commitStatusUpdater.UpdateCombinedCount(baseRepo, pull, models.SuccessCommitStatus, command.Apply, 0, 0); err != nil { - ctx.Log.Warn("unable to update commit status: %s", err) + if cmd.IsForSpecificProject() { + // With a specific apply, just reset the status so it's not stuck in pending state + pullStatus, err := a.Backend.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(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(baseRepo, pull, models.SuccessCommitStatus, command.Apply, 0, 0); err != nil { + ctx.Log.Warn("unable to update commit status: %s", err) + } } } return diff --git a/server/events/apply_command_runner_test.go b/server/events/apply_command_runner_test.go index d87e323ef..f6ff1a21d 100644 --- a/server/events/apply_command_runner_test.go +++ b/server/events/apply_command_runner_test.go @@ -6,13 +6,16 @@ import ( "github.com/google/go-github/v49/github" . "github.com/petergtz/pegomock" + "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/command" + "github.com/runatlantis/atlantis/server/events/mocks/matchers" "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" + . "github.com/runatlantis/atlantis/testing" ) func TestApplyCommandRunner_IsLocked(t *testing.T) { @@ -73,3 +76,140 @@ func TestApplyCommandRunner_IsLocked(t *testing.T) { }) } } + +func TestApplyCommandRunner_IsSilenced(t *testing.T) { + RegisterMockTestingT(t) + + cases := []struct { + Description string + Matched bool + Targeted bool + VCSStatusSilence bool + PrevApplyStored bool // stores a 1/1 passing apply in the backend + 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 := db.New(tmp) + Ok(t, err) + + vcsClient := setup(t, func(tc *TestConfig) { + tc.SilenceNoProjects = true + tc.silenceVCSStatusNoProjects = c.VCSStatusSilence + tc.backend = db + }) + + scopeNull, _, _ := metrics.NewLoggingScope(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(AnyRepo(), AnyInt(), AnyString(), AnyString()) + if c.ExpVCSStatusSet { + commitUpdater.VerifyWasCalledOnce().UpdateCombinedCount( + matchers.AnyModelsRepo(), + matchers.AnyModelsPullRequest(), + matchers.EqModelsCommitStatus(models.SuccessCommitStatus), + matchers.EqCommandName(command.Apply), + EqInt(c.ExpVCSStatusSucc), + EqInt(c.ExpVCSStatusTotal), + ) + } else { + commitUpdater.VerifyWasCalled(Never()).UpdateCombinedCount( + matchers.AnyModelsRepo(), + matchers.AnyModelsPullRequest(), + matchers.AnyModelsCommitStatus(), + matchers.EqCommandName(command.Apply), + AnyInt(), + AnyInt(), + ) + } + }) + } +} diff --git a/server/events/command_runner_test.go b/server/events/command_runner_test.go index 6d7b63e44..b30bbd91c 100644 --- a/server/events/command_runner_test.go +++ b/server/events/command_runner_test.go @@ -16,12 +16,14 @@ package events_test import ( "errors" "fmt" + "reflect" "regexp" "strings" "testing" "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" @@ -70,21 +72,35 @@ var importCommandRunner *events.ImportCommandRunner var preWorkflowHooksCommandRunner events.PreWorkflowHooksCommandRunner var postWorkflowHooksCommandRunner events.PostWorkflowHooksCommandRunner +func AnyRepo() models.Repo { + RegisterMatcher(NewAnyMatcher(reflect.TypeOf(models.Repo{}))) + return models.Repo{} +} + type TestConfig struct { - parallelPoolSize int - SilenceNoProjects bool - StatusName string - discardApprovalOnPlan bool + parallelPoolSize int + SilenceNoProjects bool + silenceVCSStatusNoPlans bool + silenceVCSStatusNoProjects bool + StatusName string + discardApprovalOnPlan bool + backend locking.Backend } func setup(t *testing.T, options ...func(testConfig *TestConfig)) *vcsmocks.MockClient { RegisterMockTestingT(t) + // create an empty DB + tmp := t.TempDir() + defaultBoltDB, err := db.New(tmp) + Ok(t, err) + testConfig := &TestConfig{ parallelPoolSize: 1, SilenceNoProjects: false, StatusName: "atlantis-test", discardApprovalOnPlan: false, + backend: defaultBoltDB, } for _, op := range options { @@ -103,9 +119,6 @@ func setup(t *testing.T, options ...func(testConfig *TestConfig)) *vcsmocks.Mock pendingPlanFinder = mocks.NewMockPendingPlanFinder() commitUpdater = mocks.NewMockCommitStatusUpdater() pullReqStatusFetcher = vcsmocks.NewMockPullReqStatusFetcher() - tmp := t.TempDir() - defaultBoltDB, err := db.New(tmp) - Ok(t, err) drainer = &events.Drainer{} deleteLockCommand = mocks.NewMockDeleteLockCommand() @@ -113,7 +126,7 @@ func setup(t *testing.T, options ...func(testConfig *TestConfig)) *vcsmocks.Mock lockingLocker = lockingmocks.NewMockLocker() dbUpdater = &events.DBUpdater{ - Backend: defaultBoltDB, + Backend: testConfig.backend, } pullUpdater = &events.PullUpdater{ @@ -133,13 +146,13 @@ func setup(t *testing.T, options ...func(testConfig *TestConfig)) *vcsmocks.Mock commitUpdater, projectCommandRunner, testConfig.parallelPoolSize, - false, + testConfig.silenceVCSStatusNoProjects, false, ) planCommandRunner = events.NewPlanCommandRunner( - false, - false, + testConfig.silenceVCSStatusNoPlans, + testConfig.silenceVCSStatusNoProjects, vcsClient, pendingPlanFinder, workingDir, @@ -152,7 +165,7 @@ func setup(t *testing.T, options ...func(testConfig *TestConfig)) *vcsmocks.Mock autoMerger, testConfig.parallelPoolSize, testConfig.SilenceNoProjects, - defaultBoltDB, + testConfig.backend, lockingLocker, testConfig.discardApprovalOnPlan, pullReqStatusFetcher, @@ -168,10 +181,10 @@ func setup(t *testing.T, options ...func(testConfig *TestConfig)) *vcsmocks.Mock autoMerger, pullUpdater, dbUpdater, - defaultBoltDB, + testConfig.backend, testConfig.parallelPoolSize, testConfig.SilenceNoProjects, - false, + testConfig.silenceVCSStatusNoProjects, pullReqStatusFetcher, ) @@ -182,7 +195,7 @@ func setup(t *testing.T, options ...func(testConfig *TestConfig)) *vcsmocks.Mock pullUpdater, dbUpdater, testConfig.SilenceNoProjects, - false, + testConfig.silenceVCSStatusNoProjects, vcsClient, ) @@ -205,6 +218,7 @@ func setup(t *testing.T, options ...func(testConfig *TestConfig)) *vcsmocks.Mock pullReqStatusFetcher, projectCommandBuilder, projectCommandRunner, + testConfig.SilenceNoProjects, ) commentCommandRunnerByCmd := map[command.Name]events.CommentCommandRunner{ @@ -242,7 +256,7 @@ func setup(t *testing.T, options ...func(testConfig *TestConfig)) *vcsmocks.Mock Drainer: drainer, PreWorkflowHooksCommandRunner: preWorkflowHooksCommandRunner, PostWorkflowHooksCommandRunner: postWorkflowHooksCommandRunner, - PullStatusFetcher: defaultBoltDB, + PullStatusFetcher: testConfig.backend, } return vcsClient @@ -383,6 +397,28 @@ func TestRunCommentCommandPlan_NoProjects_SilenceEnabled(t *testing.T) { ) } +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(testdata.GithubRepo, testdata.Pull.Num)).ThenReturn(&pull, nil) + When(eventParsing.ParseGithubPull(&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(matchers.AnyModelsRepo(), AnyInt(), AnyString(), AnyString()) + commitUpdater.VerifyWasCalledOnce().UpdateCombinedCount( + matchers.AnyModelsRepo(), + matchers.AnyModelsPullRequest(), + matchers.EqModelsCommitStatus(models.SuccessCommitStatus), + matchers.EqCommandName(command.Plan), + EqInt(0), + EqInt(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) @@ -438,6 +474,19 @@ func TestRunCommentCommandUnlock_NoProjects_SilenceEnabled(t *testing.T) { vcsClient.VerifyWasCalled(Never()).CreateComment(matchers.AnyModelsRepo(), AnyInt(), AnyString(), AnyString()) } +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(testdata.GithubRepo, testdata.Pull.Num)).ThenReturn(&pull, nil) + When(eventParsing.ParseGithubPull(&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(matchers.AnyModelsRepo(), AnyInt(), AnyString(), AnyString()) +} + 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") diff --git a/server/events/import_command_runner.go b/server/events/import_command_runner.go index 695856872..51fbe3498 100644 --- a/server/events/import_command_runner.go +++ b/server/events/import_command_runner.go @@ -10,12 +10,14 @@ func NewImportCommandRunner( pullReqStatusFetcher vcs.PullReqStatusFetcher, prjCmdBuilder ProjectImportCommandBuilder, prjCmdRunner ProjectImportCommandRunner, + SilenceNoProjects bool, ) *ImportCommandRunner { return &ImportCommandRunner{ pullUpdater: pullUpdater, pullReqStatusFetcher: pullReqStatusFetcher, prjCmdBuilder: prjCmdBuilder, prjCmdRunner: prjCmdRunner, + SilenceNoProjects: SilenceNoProjects, } } @@ -24,6 +26,7 @@ type ImportCommandRunner struct { pullReqStatusFetcher vcs.PullReqStatusFetcher prjCmdBuilder ProjectImportCommandBuilder prjCmdRunner ProjectImportCommandRunner + SilenceNoProjects bool } func (v *ImportCommandRunner) Run(ctx *command.Context, cmd *CommentCommand) { @@ -48,6 +51,10 @@ func (v *ImportCommandRunner) Run(ctx *command.Context, cmd *CommentCommand) { 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. diff --git a/server/events/import_command_runner_test.go b/server/events/import_command_runner_test.go index a9a6c23fa..1acaecbfd 100644 --- a/server/events/import_command_runner_test.go +++ b/server/events/import_command_runner_test.go @@ -18,9 +18,11 @@ func TestImportCommandRunner_Run(t *testing.T) { tests := []struct { name string + silenced bool pullReqStatus models.PullReqStatus projectCmds []command.ProjectContext expComment string + expNoComment bool }{ { name: "success with zero projects", @@ -40,10 +42,22 @@ func TestImportCommandRunner_Run(t *testing.T) { 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}, + Mergeable: true, + }, + projectCmds: []command.ProjectContext{}, + silenced: true, + expNoComment: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - vcsClient := setup(t) + vcsClient := setup(t, func(tc *TestConfig) { + tc.SilenceNoProjects = tt.silenced + }) scopeNull, _, _ := metrics.NewLoggingScope(logger, "atlantis") modelPull := models.PullRequest{BaseRepo: testdata.GithubRepo, State: models.OpenPullState, Num: testdata.Pull.Num} @@ -63,7 +77,11 @@ func TestImportCommandRunner_Run(t *testing.T) { importCommandRunner.Run(ctx, cmd) Assert(t, ctx.PullRequestStatus.Mergeable == true, "PullRequestStatus must be set for import_requirements") - vcsClient.VerifyWasCalledOnce().CreateComment(testdata.GithubRepo, modelPull.Num, tt.expComment, "import") + if tt.expNoComment { + vcsClient.VerifyWasCalled(Never()).CreateComment(AnyRepo(), AnyInt(), AnyString(), AnyString()) + } else { + vcsClient.VerifyWasCalledOnce().CreateComment(testdata.GithubRepo, modelPull.Num, tt.expComment, "import") + } }) } } diff --git a/server/events/plan_command_runner.go b/server/events/plan_command_runner.go index a2f6bd1ef..f648f1a5c 100644 --- a/server/events/plan_command_runner.go +++ b/server/events/plan_command_runner.go @@ -202,12 +202,32 @@ func (p *PlanCommandRunner) run(ctx *command.Context, cmd *CommentCommand) { if len(projectCmds) == 0 && p.SilenceNoProjects { ctx.Log.Info("determined there was no project to run plan in") if !p.silenceVCSStatusNoProjects { - // If there were no projects modified, 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. - ctx.Log.Debug("setting VCS status to success with no projects found") - if err := p.commitStatusUpdater.UpdateCombinedCount(baseRepo, pull, models.SuccessCommitStatus, command.Plan, 0, 0); err != nil { - ctx.Log.Warn("unable to update commit status: %s", err) + 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(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) + } 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(baseRepo, pull, models.SuccessCommitStatus, command.Plan, 0, 0); err != nil { + ctx.Log.Warn("unable to update commit status: %s", err) + } } } return diff --git a/server/events/plan_command_runner_test.go b/server/events/plan_command_runner_test.go new file mode 100644 index 000000000..799dd54b2 --- /dev/null +++ b/server/events/plan_command_runner_test.go @@ -0,0 +1,151 @@ +package events_test + +import ( + "testing" + + . "github.com/petergtz/pegomock" + "github.com/runatlantis/atlantis/server/core/db" + "github.com/runatlantis/atlantis/server/events" + "github.com/runatlantis/atlantis/server/events/command" + "github.com/runatlantis/atlantis/server/events/mocks/matchers" + "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" + . "github.com/runatlantis/atlantis/testing" +) + +func TestPlanCommandRunner_IsSilenced(t *testing.T) { + RegisterMockTestingT(t) + + cases := []struct { + Description string + Matched bool + Targeted bool + VCSStatusSilence bool + PrevPlanStored bool // stores a 1/1 passing plan in the backend + 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 do anything", + VCSStatusSilence: true, + ExpVCSStatusSet: false, + 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 := db.New(tmp) + Ok(t, err) + + vcsClient := setup(t, func(tc *TestConfig) { + tc.SilenceNoProjects = true + tc.silenceVCSStatusNoProjects = c.VCSStatusSilence + tc.backend = db + }) + + scopeNull, _, _ := metrics.NewLoggingScope(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", + 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} + }) + + planCommandRunner.Run(ctx, cmd) + + timesComment := 1 + if c.ExpSilenced { + timesComment = 0 + } + + vcsClient.VerifyWasCalled(Times(timesComment)).CreateComment(AnyRepo(), AnyInt(), AnyString(), AnyString()) + if c.ExpVCSStatusSet { + commitUpdater.VerifyWasCalledOnce().UpdateCombinedCount( + matchers.AnyModelsRepo(), + matchers.AnyModelsPullRequest(), + matchers.EqModelsCommitStatus(models.SuccessCommitStatus), + matchers.EqCommandName(command.Plan), + EqInt(c.ExpVCSStatusSucc), + EqInt(c.ExpVCSStatusTotal), + ) + } else { + commitUpdater.VerifyWasCalled(Never()).UpdateCombinedCount( + matchers.AnyModelsRepo(), + matchers.AnyModelsPullRequest(), + matchers.AnyModelsCommitStatus(), + matchers.EqCommandName(command.Plan), + AnyInt(), + AnyInt(), + ) + } + }) + } +} diff --git a/server/events/project_command_builder.go b/server/events/project_command_builder.go index d1068da2d..1069a8048 100644 --- a/server/events/project_command_builder.go +++ b/server/events/project_command_builder.go @@ -53,6 +53,7 @@ func NewInstrumentedProjectCommandBuilder( AutoDetectModuleFiles string, AutoplanFileList string, RestrictFileList bool, + SilenceNoProjects bool, scope tally.Scope, logger logging.SimpleLogging, terraformClient terraform.Client, @@ -79,6 +80,7 @@ func NewInstrumentedProjectCommandBuilder( AutoDetectModuleFiles, AutoplanFileList, RestrictFileList, + SilenceNoProjects, scope, logger, terraformClient, @@ -103,6 +105,7 @@ func NewProjectCommandBuilder( AutoDetectModuleFiles string, AutoplanFileList string, RestrictFileList bool, + SilenceNoProjects bool, scope tally.Scope, logger logging.SimpleLogging, terraformClient terraform.Client, @@ -120,6 +123,7 @@ func NewProjectCommandBuilder( AutoDetectModuleFiles: AutoDetectModuleFiles, AutoplanFileList: AutoplanFileList, RestrictFileList: RestrictFileList, + SilenceNoProjects: SilenceNoProjects, ProjectCommandContextBuilder: NewProjectCommandContextBuilder( policyChecksSupported, commentBuilder, @@ -202,6 +206,7 @@ type DefaultProjectCommandBuilder struct { AutoplanFileList string EnableDiffMarkdownFormat bool RestrictFileList bool + SilenceNoProjects bool TerraformExecutor terraform.Client } @@ -561,7 +566,11 @@ func (p *DefaultProjectCommandBuilder) getCfg(ctx *command.Context, projectName } } if len(projectsCfg) == 0 { - err = fmt.Errorf("no project with name %q is defined in %s", projectName, repoCfgFile) + if p.SilenceNoProjects && len(repoConfig.Projects) > 0 { + ctx.Log.Debug("no project with name %q found but silencing the error", projectName) + } else { + err = fmt.Errorf("no project with name %q is defined in %s", projectName, repoCfgFile) + } return } return @@ -718,6 +727,12 @@ func (p *DefaultProjectCommandBuilder) buildProjectCommandCtx(ctx *command.Conte )...) } } 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( diff --git a/server/events/project_command_builder_internal_test.go b/server/events/project_command_builder_internal_test.go index 13a05aac2..675f40de6 100644 --- a/server/events/project_command_builder_internal_test.go +++ b/server/events/project_command_builder_internal_test.go @@ -662,6 +662,7 @@ projects: "", "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl", false, + false, statsScope, logger, terraformClient, @@ -871,6 +872,7 @@ projects: "", "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl", false, + false, statsScope, logger, terraformClient, @@ -1111,6 +1113,7 @@ workflows: "", "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl", false, + false, statsScope, logger, terraformClient, @@ -1168,6 +1171,126 @@ workflows: } } +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]interface{}{ + "project1": map[string]interface{}{ + "main.tf": nil, + }, + "modules": map[string]interface{}{ + "module": map[string]interface{}{ + "main.tf": nil, + }, + }, + }) + + workingDir := NewMockWorkingDir() + When(workingDir.Clone(matchers.AnyLoggingSimpleLogging(), matchers.AnyModelsRepo(), matchers.AnyModelsPullRequest(), AnyString())).ThenReturn(tmp, false, nil) + vcsClient := vcsmocks.NewMockClient() + When(vcsClient.GetModifiedFiles(matchers.AnyModelsRepo(), matchers.AnyModelsPullRequest())).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{ + AllowRepoCfg: false, + MergeableReq: false, + ApprovedReq: false, + UnDivergedReq: 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, _, _ := metrics.NewLoggingScope(logging.NewNoopLogger(t), "atlantis") + + terraformClient := mocks.NewMockClient() + + builder := NewProjectCommandBuilder( + false, + parser, + &DefaultProjectFinder{}, + vcsClient, + workingDir, + NewDefaultWorkingDirLocker(), + globalCfg, + &DefaultPendingPlanFinder{}, + &CommentParser{ExecutableName: "atlantis"}, + false, + false, + "", + "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl", + false, + true, + statsScope, + logger, + 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{ + Mergeable: true, + }, + }, cmd, "", "", []string{}, tmp, "project1", "myworkspace", true) + Equals(t, c.expLen, len(ctxs)) + Ok(t, err) + }) + } + }) + } +} + func mustVersion(v string) *version.Version { vers, err := version.NewVersion(v) if err != nil { diff --git a/server/events/project_command_builder_test.go b/server/events/project_command_builder_test.go index a0f9b458d..626978140 100644 --- a/server/events/project_command_builder_test.go +++ b/server/events/project_command_builder_test.go @@ -165,6 +165,7 @@ projects: "", "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl", false, + false, scope, logger, terraformClient, @@ -195,6 +196,7 @@ func TestDefaultProjectCommandBuilder_BuildSinglePlanApplyCommand(t *testing.T) Description string AtlantisYAML string Cmd events.CommentCommand + Silenced bool ExpCommentArgs []string ExpWorkspace string ExpDir string @@ -203,6 +205,7 @@ func TestDefaultProjectCommandBuilder_BuildSinglePlanApplyCommand(t *testing.T) ExpApplyReqs []string ExpParallelApply bool ExpParallelPlan bool + ExpNoProjects bool }{ { Description: "no atlantis.yaml", @@ -366,6 +369,22 @@ projects: `, 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{ @@ -438,6 +457,7 @@ projects: "", "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl", false, + c.Silenced, scope, logger, terraformClient, @@ -459,6 +479,10 @@ projects: 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) @@ -615,6 +639,7 @@ projects: "", "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl", true, + false, scope, logger, terraformClient, @@ -800,6 +825,7 @@ projects: "", "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl", false, + false, scope, logger, terraformClient, @@ -898,6 +924,7 @@ func TestDefaultProjectCommandBuilder_BuildMultiApply(t *testing.T) { "", "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl", false, + false, scope, logger, terraformClient, @@ -987,6 +1014,7 @@ projects: "", "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl", false, + false, scope, logger, terraformClient, @@ -1071,6 +1099,7 @@ func TestDefaultProjectCommandBuilder_EscapeArgs(t *testing.T) { "", "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl", false, + false, scope, logger, terraformClient, @@ -1237,6 +1266,7 @@ projects: "", "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl", false, + false, scope, logger, terraformClient, @@ -1330,6 +1360,7 @@ parallel_plan: true`, "", "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl", false, + false, scope, logger, terraformClient, @@ -1393,6 +1424,7 @@ func TestDefaultProjectCommandBuilder_WithPolicyCheckEnabled_BuildAutoplanComman "", "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl", false, + false, scope, logger, terraformClient, @@ -1479,6 +1511,7 @@ func TestDefaultProjectCommandBuilder_BuildVersionCommand(t *testing.T) { "", "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl", false, + false, scope, logger, terraformClient, diff --git a/server/server.go b/server/server.go index 29f9265f1..407467820 100644 --- a/server/server.go +++ b/server/server.go @@ -567,6 +567,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { userConfig.AutoplanModulesFromProjects, userConfig.AutoplanFileList, userConfig.RestrictFileList, + userConfig.SilenceNoProjects, statsScope, logger, terraformClient, @@ -730,6 +731,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { pullReqStatusFetcher, projectCommandBuilder, instrumentedProjectCmdRunner, + userConfig.SilenceNoProjects, ) stateCommandRunner := events.NewStateCommandRunner(