diff --git a/runatlantis.io/docs/repo-level-atlantis-yaml.md b/runatlantis.io/docs/repo-level-atlantis-yaml.md index b0fbc2c7c8..14e4414eb3 100644 --- a/runatlantis.io/docs/repo-level-atlantis-yaml.md +++ b/runatlantis.io/docs/repo-level-atlantis-yaml.md @@ -418,6 +418,14 @@ autodiscover: Autodiscover can also be configured to skip over directories that match a path glob (as defined by the [doublestar path matching package](https://pkg.go.dev/github.com/bmatcuk/doublestar/v4)). +When `ignore_paths` is set, it applies to: + +- Automatic project discovery during autoplan and `atlantis plan` (without `-d`) +- `atlantis apply` (without `-d`) when filtering pending plans +- All targeted `-d` commands (`plan`, `apply`, `import`, `state rm`, etc.) when autodiscovery is enabled, if the path has no explicit project configuration + +This makes `ignore_paths` useful for **multi-instance setups** where each Atlantis instance manages a different directory subtree. For example, one instance can ignore `environments/prod/**` while another ignores `environments/nonprod/**`, preventing cross-instance interference on targeted commands. + ### Custom Backend Config See [Custom Workflow Use Cases: Custom Backend Config](custom-workflows.md#custom-backend-config) diff --git a/runatlantis.io/docs/server-side-repo-config.md b/runatlantis.io/docs/server-side-repo-config.md index bb422533b0..675fb2b539 100644 --- a/runatlantis.io/docs/server-side-repo-config.md +++ b/runatlantis.io/docs/server-side-repo-config.md @@ -100,7 +100,9 @@ repos: # If any part of this setting is set here, it overrides the entire setting in the repo config. autodiscover: mode: auto - # Optionally ignore some paths for autodiscovery by a glob path + # Optionally ignore some paths for autodiscovery by a glob path. + # When autodiscovery is enabled, also applies to all targeted -d commands + # (plan, apply, import, etc.) when the path has no explicit project configuration. ignore_paths: - foo/* diff --git a/server/controllers/api_controller.go b/server/controllers/api_controller.go index fbf953838f..c947d04133 100644 --- a/server/controllers/api_controller.go +++ b/server/controllers/api_controller.go @@ -79,15 +79,29 @@ func (a *APIRequest) getCommands(ctx *command.Context, cmdName command.Name, cmd } cmds := make([]command.ProjectContext, 0) + keptCommentCommands := make([]*events.CommentCommand, 0) + ignoredCommands := 0 + nonIgnoredCommands := 0 for _, commentCommand := range cc { projectCmds, err := cmdBuilder(ctx, commentCommand) if err != nil { - return nil, nil, fmt.Errorf("failed to build command: %v", err) + if events.IsIgnoredTargetedDir(err) { + ignoredCommands++ + continue + } + return nil, nil, fmt.Errorf("failed to build command: %w", err) + } + nonIgnoredCommands++ + for _, projectCmd := range projectCmds { + cmds = append(cmds, projectCmd) + keptCommentCommands = append(keptCommentCommands, commentCommand) } - cmds = append(cmds, projectCmds...) + } + if ignoredCommands > 0 && nonIgnoredCommands == 0 { + return nil, nil, events.ErrIgnoredTargetedDir } - return cmds, cc, nil + return cmds, keptCommentCommands, nil } func (a *APIController) apiReportError(w http.ResponseWriter, code int, err error) { @@ -117,7 +131,9 @@ func (a *APIController) Plan(w http.ResponseWriter, r *http.Request) { a.apiReportError(w, http.StatusInternalServerError, err) return } - defer a.Locker.UnlockByPull(ctx.HeadRepo.FullName, ctx.Pull.Num) // nolint: errcheck + if !ctx.CommandSkipped { + defer a.Locker.UnlockByPull(ctx.HeadRepo.FullName, ctx.Pull.Num) // nolint: errcheck + } if result.HasErrors() { code = http.StatusInternalServerError } @@ -147,11 +163,20 @@ func (a *APIController) Apply(w http.ResponseWriter, r *http.Request) { } // We must first make the plan for all projects - _, err = a.apiPlan(request, ctx) + result, err := a.apiPlan(request, ctx) if err != nil { a.apiReportError(w, http.StatusInternalServerError, err) return } + if ctx.CommandSkipped { + response, err := json.Marshal(result) + if err != nil { + a.apiReportError(w, http.StatusInternalServerError, err) + return + } + a.respond(w, logging.Warn, code, "%s", string(response)) + return + } defer a.Locker.UnlockByPull(ctx.HeadRepo.FullName, ctx.Pull.Num) // nolint: errcheck // The API apply endpoint runs plan first. Refresh PR status afterward so @@ -159,7 +184,7 @@ func (a *APIController) Apply(w http.ResponseWriter, r *http.Request) { a.populatePullRequestStatus(ctx) // We can now prepare and run the apply step - result, err := a.apiApply(request, ctx) + result, err = a.apiApply(request, ctx) if err != nil { a.apiReportError(w, http.StatusInternalServerError, err) return @@ -248,6 +273,10 @@ func (a *APIController) apiSetup(ctx *command.Context, cmdName command.Name) err func (a *APIController) apiPlan(request *APIRequest, ctx *command.Context) (*command.Result, error) { cmds, cc, err := request.getCommands(ctx, command.Plan, a.ProjectCommandBuilder.BuildPlanCommands) + if events.IsIgnoredTargetedDir(err) { + ctx.CommandSkipped = true + return &command.Result{ProjectResults: []command.ProjectResult{}}, nil + } if err != nil { return nil, err } @@ -296,6 +325,10 @@ func (a *APIController) apiPlan(request *APIRequest, ctx *command.Context) (*com func (a *APIController) apiApply(request *APIRequest, ctx *command.Context) (*command.Result, error) { cmds, cc, err := request.getCommands(ctx, command.Apply, a.ProjectCommandBuilder.BuildApplyCommands) + if events.IsIgnoredTargetedDir(err) { + ctx.CommandSkipped = true + return &command.Result{ProjectResults: []command.ProjectResult{}}, nil + } if err != nil { return nil, err } diff --git a/server/controllers/api_controller_test.go b/server/controllers/api_controller_test.go index d0a7921cec..babb74f682 100644 --- a/server/controllers/api_controller_test.go +++ b/server/controllers/api_controller_test.go @@ -237,6 +237,134 @@ func TestAPIController_Plan_PreWorkflowHooksReceiveCorrectCommand(t *testing.T) command.Plan, capturedCmd.Name.String(), capturedCmd.Name) } +func TestAPIController_Plan_SkipsIgnoredPathsWithoutShiftingHookCommands(t *testing.T) { + ac, projectCommandBuilder, projectCommandRunner := setup(t) + preWorkflowHooksRunner := ac.PreWorkflowHooksCommandRunner.(*MockPreWorkflowHooksCommandRunner) + + When(projectCommandBuilder.BuildPlanCommands(Any[*command.Context](), Any[*events.CommentCommand]())).Then(func(args []Param) ReturnValues { + commentCommand := args[1].(*events.CommentCommand) + if commentCommand.RepoRelDir == "ignored" { + return ReturnValues{[]command.ProjectContext{}, events.ErrIgnoredTargetedDir} + } + return ReturnValues{[]command.ProjectContext{{ + CommandName: command.Plan, + RepoRelDir: commentCommand.RepoRelDir, + Workspace: commentCommand.Workspace, + }}, nil} + }) + + body, _ := json.Marshal(controllers.APIRequest{ + Repository: "Repo", + Ref: "main", + Type: "Gitlab", + Paths: []struct { + Directory string + Workspace string + }{ + { + Directory: "ignored", + Workspace: "ignored-workspace", + }, + { + Directory: "kept", + Workspace: "kept-workspace", + }, + }, + }) + + req, _ := http.NewRequest("POST", "", bytes.NewBuffer(body)) + req.Header.Set(atlantisTokenHeader, atlantisToken) + w := httptest.NewRecorder() + ac.Plan(w, req) + ResponseContains(t, w, http.StatusOK, "") + + projectCommandRunner.VerifyWasCalledOnce().Plan(Any[command.ProjectContext]()) + _, capturedCmd := preWorkflowHooksRunner.VerifyWasCalledOnce(). + RunPreHooks(Any[*command.Context](), Any[*events.CommentCommand]()). + GetCapturedArguments() + + Equals(t, "kept", capturedCmd.RepoRelDir) + Equals(t, "kept-workspace", capturedCmd.Workspace) +} + +func TestAPIController_Plan_AllIgnoredPathsNoOp(t *testing.T) { + ac, projectCommandBuilder, projectCommandRunner := setup(t, func(config *apiControllerTestConfig) { + config.allowUnlockByPull = false + }) + commitStatusUpdater := ac.CommitStatusUpdater.(*MockCommitStatusUpdater) + + When(projectCommandBuilder.BuildPlanCommands(Any[*command.Context](), Any[*events.CommentCommand]())). + ThenReturn([]command.ProjectContext{}, events.ErrIgnoredTargetedDir) + + body, _ := json.Marshal(controllers.APIRequest{ + Repository: "Repo", + Ref: "main", + Type: "Gitlab", + Paths: []struct { + Directory string + Workspace string + }{ + { + Directory: "ignored", + Workspace: "default", + }, + }, + }) + + req, _ := http.NewRequest("POST", "", bytes.NewBuffer(body)) + req.Header.Set(atlantisTokenHeader, atlantisToken) + w := httptest.NewRecorder() + ac.Plan(w, req) + ResponseContains(t, w, http.StatusOK, "") + + projectCommandRunner.VerifyWasCalled(Never()).Plan(Any[command.ProjectContext]()) + commitStatusUpdater.VerifyWasCalled(Never()).UpdateCombined( + Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[models.CommitStatus](), Any[command.Name]()) + commitStatusUpdater.VerifyWasCalled(Never()).UpdateCombinedCount( + Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[models.CommitStatus](), Any[command.Name](), Any[models.ProjectCounts]()) +} + +func TestAPIController_Apply_AllIgnoredPathsNoOp(t *testing.T) { + ac, projectCommandBuilder, projectCommandRunner := setup(t, func(config *apiControllerTestConfig) { + config.allowUnlockByPull = false + }) + commitStatusUpdater := ac.CommitStatusUpdater.(*MockCommitStatusUpdater) + + When(projectCommandBuilder.BuildPlanCommands(Any[*command.Context](), Any[*events.CommentCommand]())). + ThenReturn([]command.ProjectContext{}, events.ErrIgnoredTargetedDir) + When(projectCommandBuilder.BuildApplyCommands(Any[*command.Context](), Any[*events.CommentCommand]())). + ThenReturn([]command.ProjectContext{}, events.ErrIgnoredTargetedDir) + + body, _ := json.Marshal(controllers.APIRequest{ + Repository: "Repo", + Ref: "main", + Type: "Gitlab", + Paths: []struct { + Directory string + Workspace string + }{ + { + Directory: "ignored", + Workspace: "default", + }, + }, + }) + + req, _ := http.NewRequest("POST", "", bytes.NewBuffer(body)) + req.Header.Set(atlantisTokenHeader, atlantisToken) + w := httptest.NewRecorder() + ac.Apply(w, req) + ResponseContains(t, w, http.StatusOK, "") + + projectCommandRunner.VerifyWasCalled(Never()).Plan(Any[command.ProjectContext]()) + projectCommandRunner.VerifyWasCalled(Never()).Apply(Any[command.ProjectContext]()) + projectCommandBuilder.VerifyWasCalled(Never()).BuildApplyCommands(Any[*command.Context](), Any[*events.CommentCommand]()) + commitStatusUpdater.VerifyWasCalled(Never()).UpdateCombined( + Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[models.CommitStatus](), Any[command.Name]()) + commitStatusUpdater.VerifyWasCalled(Never()).UpdateCombinedCount( + Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[models.CommitStatus](), Any[command.Name](), Any[models.ProjectCounts]()) +} + // TestAPIController_Apply_PreWorkflowHooksReceiveCorrectCommand verifies that when // calling the Apply API endpoint, the pre-workflow hooks receive a CommentCommand // with Name set to command.Apply for the apply phase (and command.Plan for the @@ -476,12 +604,25 @@ func TestAPIController_ListLocksEmpty(t *testing.T) { Equals(t, expected, result) } -func setup(t *testing.T) (controllers.APIController, *MockProjectCommandBuilder, *MockProjectCommandRunner) { +type apiControllerTestConfig struct { + allowUnlockByPull bool +} + +func setup(t *testing.T, options ...func(*apiControllerTestConfig)) (controllers.APIController, *MockProjectCommandBuilder, *MockProjectCommandRunner) { RegisterMockTestingT(t) + config := &apiControllerTestConfig{ + allowUnlockByPull: true, + } + for _, option := range options { + option(config) + } + gmockCtrl := gomock.NewController(t) locker := NewMockLocker(gmockCtrl) - // Allow incidental calls to UnlockByPull (called internally during plan/apply operations) - locker.EXPECT().UnlockByPull(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes() + if config.allowUnlockByPull { + // Allow incidental calls to UnlockByPull (called internally during plan/apply operations) + locker.EXPECT().UnlockByPull(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes() + } logger := logging.NewNoopLogger(t) parser := NewMockEventParsing() repoAllowlistChecker, err := events.NewRepoAllowlistChecker("*") diff --git a/server/events/apply_command_runner.go b/server/events/apply_command_runner.go index b81641f31d..e2877a2049 100644 --- a/server/events/apply_command_runner.go +++ b/server/events/apply_command_runner.go @@ -75,6 +75,25 @@ func (a *ApplyCommandRunner) Run(ctx *command.Context, cmd *CommentCommand) { baseRepo := ctx.Pull.BaseRepo pull := ctx.Pull + var projectCmds []command.ProjectContext + var projectCmdsBuilt bool + var projectCmdsErr error + if cmd.IsForSpecificProject() { + ctx.PullRequestStatus, err = a.pullReqStatusFetcher.FetchPullStatus(ctx.Log, pull) + if err != nil { + // On error we continue the request with mergeable assumed false. + // We want to continue because not all apply's will need this status, + // only if they rely on the mergeability requirement. + // All PullRequestStatus fields are set to false by default when error. + ctx.Log.Warn("unable to get pull request status: %s. Continuing with mergeable and approved assumed false", err) + } + projectCmds, projectCmdsErr = a.prjCmdBuilder.BuildApplyCommands(ctx, cmd) + projectCmdsBuilt = true + if MarkCommandSkippedIfIgnoredTargetedDir(ctx, cmd.CommandName(), projectCmdsErr) { + return + } + } + locked, err := a.IsLocked() if err != nil { ctx.Log.Err("checking global apply lock: %s", err) @@ -106,27 +125,30 @@ func (a *ApplyCommandRunner) Run(ctx *command.Context, cmd *CommentCommand) { return } - // Get the mergeable status before we set any build statuses of our own. - // We do this here because when we set a "Pending" status, if users have - // required the Atlantis status checks to pass, then we've now changed - // the mergeability status of the pull request. - // This sets the approved, mergeable, and sqlocked status in the context. - ctx.PullRequestStatus, err = a.pullReqStatusFetcher.FetchPullStatus(ctx.Log, pull) - if err != nil { - // On error we continue the request with mergeable assumed false. - // We want to continue because not all apply's will need this status, - // only if they rely on the mergeability requirement. - // All PullRequestStatus fields are set to false by default when error. - ctx.Log.Warn("unable to get pull request status: %s. Continuing with mergeable and approved assumed false", err) + if !projectCmdsBuilt { + // Get the mergeable status before we set any build statuses of our own. + // We do this here because when we set a "Pending" status, if users have + // required the Atlantis status checks to pass, then we've now changed + // the mergeability status of the pull request. + // This sets the approved, mergeable, and sqlocked status in the context. + ctx.PullRequestStatus, err = a.pullReqStatusFetcher.FetchPullStatus(ctx.Log, pull) + if err != nil { + // On error we continue the request with mergeable assumed false. + // We want to continue because not all apply's will need this status, + // only if they rely on the mergeability requirement. + // All PullRequestStatus fields are set to false by default when error. + ctx.Log.Warn("unable to get pull request status: %s. Continuing with mergeable and approved assumed false", err) + } + projectCmds, projectCmdsErr = a.prjCmdBuilder.BuildApplyCommands(ctx, cmd) + if MarkCommandSkippedIfIgnoredTargetedDir(ctx, cmd.CommandName(), projectCmdsErr) { + return + } } - - var projectCmds []command.ProjectContext - projectCmds, err = a.prjCmdBuilder.BuildApplyCommands(ctx, cmd) - if err != nil { + if projectCmdsErr != nil { if statusErr := a.commitStatusUpdater.UpdateCombined(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.FailedCommitStatus, cmd.CommandName()); statusErr != nil { ctx.Log.Warn("unable to update commit status: %s", statusErr) } - a.pullUpdater.updatePull(ctx, cmd, command.Result{Error: err}) + a.pullUpdater.updatePull(ctx, cmd, command.Result{Error: projectCmdsErr}) return } @@ -164,6 +186,9 @@ func (a *ApplyCommandRunner) Run(ctx *command.Context, cmd *CommentCommand) { } return } + if len(projectCmds) > 0 { + a.updatePendingCommitStatus(ctx) + } result := runProjectCmdsWithCancellationTracker(ctx, projectCmds, a.cancellationTracker, a.parallelPoolSize, a.isParallelEnabled(projectCmds), a.prjCmdRunner.Apply) ctx.CommandHasErrors = result.HasErrors() @@ -186,6 +211,20 @@ func (a *ApplyCommandRunner) Run(ctx *command.Context, cmd *CommentCommand) { } } +func (a *ApplyCommandRunner) updatePendingCommitStatus(ctx *command.Context) { + if a.silenceVCSStatusNoProjects { + ctx.Log.Debug("silence enabled - not setting pending VCS status") + return + } + if err := a.commitStatusUpdater.UpdateCombined(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.PendingCommitStatus, command.Apply); err != nil { + ctx.Log.Warn("unable to update commit status: %s", err) + } +} + +func (a *ApplyCommandRunner) ShouldSkipPreWorkflowHooks(ctx *command.Context, cmd *CommentCommand) bool { + return MarkCommandSkippedIfIgnoredTarget(ctx, command.Apply, cmd, a.prjCmdBuilder) +} + func (a *ApplyCommandRunner) IsLocked() (bool, error) { lock, err := a.locker.CheckApplyLock() diff --git a/server/events/apply_command_runner_test.go b/server/events/apply_command_runner_test.go index dbcd833f4c..fd468c7375 100644 --- a/server/events/apply_command_runner_test.go +++ b/server/events/apply_command_runner_test.go @@ -248,6 +248,57 @@ func TestApplyCommandRunner_IsSilenced(t *testing.T) { } } +func TestApplyCommandRunner_IgnoredTargetedDirNoOp(t *testing.T) { + cases := []struct { + description string + setup func(*TestConfig) + }{ + { + description: "global lock backend errors", + setup: func(tc *TestConfig) { + tc.applyLockCheckerErr = errors.New("lock backend down") + }, + }, + { + description: "global apply lock is active", + setup: func(tc *TestConfig) { + tc.applyLockCheckerReturn = locking.ApplyCommandLock{Locked: true} + }, + }, + } + + for _, c := range cases { + t.Run(c.description, func(t *testing.T) { + RegisterMockTestingT(t) + vcsClient := setup(t, c.setup) + scopeNull := metricstest.NewLoggingScope(t, logging.NewNoopLogger(t), "atlantis") + modelPull := models.PullRequest{BaseRepo: testdata.GithubRepo, State: models.OpenPullState, Num: testdata.Pull.Num} + cmd := &events.CommentCommand{Name: command.Apply, RepoRelDir: "ignored"} + ctx := &command.Context{ + User: testdata.User, + Log: logging.NewNoopLogger(t), + Scope: scopeNull, + Pull: modelPull, + HeadRepo: testdata.GithubRepo, + Trigger: command.CommentTrigger, + } + + When(projectCommandBuilder.BuildApplyCommands(ctx, cmd)).ThenReturn([]command.ProjectContext{}, events.ErrIgnoredTargetedDir) + + applyCommandRunner.Run(ctx, cmd) + Assert(t, ctx.CommandSkipped, "expected ignored targeted dir to mark the command skipped") + + vcsClient.VerifyWasCalled(Never()).CreateComment( + Any[logging.SimpleLogging](), Any[models.Repo](), Any[int](), Any[string](), Any[string]()) + commitUpdater.VerifyWasCalled(Never()).UpdateCombined( + Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[models.CommitStatus](), Any[command.Name]()) + commitUpdater.VerifyWasCalled(Never()).UpdateCombinedCount( + Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[models.CommitStatus](), Any[command.Name](), Any[models.ProjectCounts]()) + projectCommandRunner.VerifyWasCalled(Never()).Apply(Any[command.ProjectContext]()) + }) + } +} + func TestApplyCommandRunner_ExecutionOrder(t *testing.T) { logger := logging.NewNoopLogger(t) RegisterMockTestingT(t) diff --git a/server/events/approve_policies_command_runner.go b/server/events/approve_policies_command_runner.go index 128ee0497c..fb707b18ac 100644 --- a/server/events/approve_policies_command_runner.go +++ b/server/events/approve_policies_command_runner.go @@ -48,11 +48,10 @@ func (a *ApprovePoliciesCommandRunner) Run(ctx *command.Context, cmd *CommentCom baseRepo := ctx.Pull.BaseRepo pull := ctx.Pull - if err := a.commitStatusUpdater.UpdateCombined(ctx.Log, baseRepo, pull, models.PendingCommitStatus, command.PolicyCheck); err != nil { - ctx.Log.Warn("unable to update commit status: %s", err) - } - projectCmds, err := a.prjCmdBuilder.BuildApprovePoliciesCommands(ctx, cmd) + if MarkCommandSkippedIfIgnoredTargetedDir(ctx, cmd.CommandName(), err) { + return + } if err != nil { if statusErr := a.commitStatusUpdater.UpdateCombined(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.FailedCommitStatus, command.PolicyCheck); statusErr != nil { ctx.Log.Warn("unable to update commit status: %s", statusErr) @@ -61,6 +60,10 @@ func (a *ApprovePoliciesCommandRunner) Run(ctx *command.Context, cmd *CommentCom return } + if err := a.commitStatusUpdater.UpdateCombined(ctx.Log, baseRepo, pull, models.PendingCommitStatus, command.PolicyCheck); err != nil { + ctx.Log.Warn("unable to update commit status: %s", err) + } + if len(projectCmds) == 0 && a.SilenceNoProjects { ctx.Log.Info("determined there was no project to run approve_policies in") if !a.silenceVCSStatusNoProjects { @@ -92,6 +95,10 @@ func (a *ApprovePoliciesCommandRunner) Run(ctx *command.Context, cmd *CommentCom a.updateCommitStatus(ctx, pullStatus) } +func (a *ApprovePoliciesCommandRunner) ShouldSkipPreWorkflowHooks(ctx *command.Context, cmd *CommentCommand) bool { + return MarkCommandSkippedIfIgnoredTarget(ctx, cmd.CommandName(), cmd, a.prjCmdBuilder) +} + func (a *ApprovePoliciesCommandRunner) updateCommitStatus(ctx *command.Context, pullStatus models.PullStatus) { var numSuccess int var numErrored int diff --git a/server/events/command/context.go b/server/events/command/context.go index 7ccf923091..d07cc2d406 100644 --- a/server/events/command/context.go +++ b/server/events/command/context.go @@ -55,4 +55,12 @@ type Context struct { // Set true if there were any errors during the command execution CommandHasErrors bool + + // Set true if the command was intentionally skipped without executing work. + CommandSkipped bool + + // PreferLocalRepoCfgForTargetedIgnore makes targeted ignore checks read a + // cloned repo config before falling back to VCS content. This is used after + // pre-workflow hooks may have generated or updated atlantis.yaml. + PreferLocalRepoCfgForTargetedIgnore bool } diff --git a/server/events/command_runner.go b/server/events/command_runner.go index 94eb585b40..3249f06e28 100644 --- a/server/events/command_runner.go +++ b/server/events/command_runner.go @@ -69,6 +69,10 @@ type CommentCommandRunner interface { Run(*command.Context, *CommentCommand) } +type PreWorkflowHooksSkipper interface { + ShouldSkipPreWorkflowHooks(*command.Context, *CommentCommand) bool +} + func buildCommentCommandRunner( cmdRunner *DefaultCommandRunner, cmdName command.Name, @@ -84,6 +88,16 @@ func buildCommentCommandRunner( return runner } +func shouldSkipPreWorkflowHooks(ctx *command.Context, cmdRunner CommentCommandRunner, cmd *CommentCommand) bool { + skipper, ok := cmdRunner.(PreWorkflowHooksSkipper) + return ok && skipper.ShouldSkipPreWorkflowHooks(ctx, cmd) +} + +func preWorkflowHooksConfigured(runner PreWorkflowHooksCommandRunner, ctx *command.Context) bool { + checker, ok := runner.(PreWorkflowHooksConfiguredChecker) + return ok && checker.HasPreWorkflowHooks(ctx) +} + // DefaultCommandRunner is the first step when processing a comment command. type DefaultCommandRunner struct { VCSClient vcs.Client `validate:"required"` @@ -178,7 +192,7 @@ func (c *DefaultCommandRunner) RunAutoplanCommand(baseRepo models.Repo, headRepo PullStatus: status, Trigger: command.AutoTrigger, } - if !c.validateCtxAndComment(ctx, command.Autoplan) { + if !c.validateCtxAndComment(ctx, command.Autoplan, true) { return } if c.DisableAutoplan { @@ -199,15 +213,9 @@ func (c *DefaultCommandRunner) RunAutoplanCommand(baseRepo models.Repo, headRepo Name: command.Autoplan, } - // Only set pending status if silence is not enabled - // The PlanCommandRunner will handle the final status decision based on project results - if !c.SilenceVCSStatusNoProjects { - // Update the combined plan commit status to pending - if err := c.CommitStatusUpdater.UpdateCombined(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.PendingCommitStatus, command.Plan); err != nil { - ctx.Log.Warn("unable to update plan commit status: %s", err) - } - } else { - ctx.Log.Debug("silence enabled - not setting pending VCS status") + cmdRunner := buildCommentCommandRunner(c, command.Plan) + if shouldSkipPreWorkflowHooks(ctx, cmdRunner, cmd) { + return } preWorkflowHooksErr := c.PreWorkflowHooksCommandRunner.RunPreHooks(ctx, cmd) @@ -222,16 +230,8 @@ func (c *DefaultCommandRunner) RunAutoplanCommand(baseRepo models.Repo, headRepo ctx.Log.Warn("Unable to create comment about pre-workflow hook failure: %s", err) } - // Update the plan or apply commit status to failed - switch cmd.Name { - case command.Plan: - if err := c.CommitStatusUpdater.UpdateCombined(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.FailedCommitStatus, command.Plan); err != nil { - ctx.Log.Warn("Unable to update plan commit status: %s", err) - } - case command.Apply: - if err := c.CommitStatusUpdater.UpdateCombined(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.FailedCommitStatus, command.Apply); err != nil { - ctx.Log.Warn("Unable to update apply commit status: %s", err) - } + if err := c.CommitStatusUpdater.UpdateCombined(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.FailedCommitStatus, command.Plan); err != nil { + ctx.Log.Warn("Unable to update plan commit status: %s", err) } return @@ -417,6 +417,45 @@ func (c *DefaultCommandRunner) checkVarFilesInPlanCommandAllowlisted(cmd *Commen return c.VarFileAllowlistChecker.Check(cmd.Flags) } +func (c *DefaultCommandRunner) validateCommentCommand(ctx *command.Context, baseRepo models.Repo, pullNum int, user models.User, cmd *CommentCommand, shouldComment bool) bool { + // Check if the user who commented has the permissions to execute the 'plan' or 'apply' commands + if c.TeamAllowlistChecker != nil && c.TeamAllowlistChecker.HasRules() { + err := c.fetchUserTeams(ctx.Log, baseRepo, &user) + if err != nil { + c.Logger.Err("Unable to fetch user teams: %s", err) + return false + } + directUserTeams := append([]string(nil), user.Teams...) + + ok, err := c.checkUserPermissions(baseRepo, &user, cmd.Name.String()) + if err != nil { + c.Logger.Err("Unable to check user permissions: %s", err) + return false + } + if !ok { + if shouldComment { + c.commentUserDoesNotHavePermissions(baseRepo, pullNum, user, cmd) + } + return false + } + c.addPolicyCheckHierarchyTeamsForPlan(baseRepo, &user, cmd.Name, directUserTeams) + ctx.User = user + } + + // Check if the provided var files in a 'plan' command are allowlisted + if err := c.checkVarFilesInPlanCommandAllowlisted(cmd); err != nil { + if shouldComment { + errMsg := fmt.Sprintf("```\n%s\n```", err.Error()) + if commentErr := c.VCSClient.CreateComment(c.Logger, baseRepo, pullNum, errMsg, ""); commentErr != nil { + c.Logger.Err("unable to comment on pull request: %s", commentErr) + } + } + return false + } + + return true +} + // RunCommentCommand executes the command. // We take in a pointer for maybeHeadRepo because for some events there isn't // enough data to construct the Repo model and callers might want to wait until @@ -442,36 +481,6 @@ func (c *DefaultCommandRunner) RunCommentCommand(baseRepo models.Repo, maybeHead timer := scope.Timer(metrics.ExecutionTimeMetric).Start() defer timer.Stop() - // Check if the user who commented has the permissions to execute the 'plan' or 'apply' commands - if c.TeamAllowlistChecker != nil && c.TeamAllowlistChecker.HasRules() { - err := c.fetchUserTeams(log, baseRepo, &user) - if err != nil { - c.Logger.Err("Unable to fetch user teams: %s", err) - return - } - directUserTeams := append([]string(nil), user.Teams...) - - ok, err := c.checkUserPermissions(baseRepo, &user, cmd.Name.String()) - if err != nil { - c.Logger.Err("Unable to check user permissions: %s", err) - return - } - if !ok { - c.commentUserDoesNotHavePermissions(baseRepo, pullNum, user, cmd) - return - } - c.addPolicyCheckHierarchyTeamsForPlan(baseRepo, &user, cmd.Name, directUserTeams) - } - - // Check if the provided var files in a 'plan' command are allowlisted - if err := c.checkVarFilesInPlanCommandAllowlisted(cmd); err != nil { - errMsg := fmt.Sprintf("```\n%s\n```", err.Error()) - if commentErr := c.VCSClient.CreateComment(c.Logger, baseRepo, pullNum, errMsg, ""); commentErr != nil { - c.Logger.Err("unable to comment on pull request: %s", commentErr) - } - return - } - headRepo, pull, err := c.ensureValidRepoMetadata(baseRepo, maybeHeadRepo, maybePull, user, pullNum, log) if err != nil { return @@ -496,30 +505,32 @@ func (c *DefaultCommandRunner) RunCommentCommand(baseRepo models.Repo, maybeHead TeamAllowlistChecker: c.TeamAllowlistChecker, } - if !c.validateCtxAndComment(ctx, cmd.Name) { + if !c.validateCtxAndComment(ctx, cmd.Name, true) { return } - // Only set pending status if silence is not enabled - // The command runners will handle the final status decision based on project results - if !c.SilenceVCSStatusNoProjects { - // Update the combined plan or apply commit status to pending - switch cmd.Name { - case command.Plan: - if err := c.CommitStatusUpdater.UpdateCombined(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.PendingCommitStatus, command.Plan); err != nil { - ctx.Log.Warn("unable to update plan commit status: %s", err) - } - case command.Apply: - if err := c.CommitStatusUpdater.UpdateCombined(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.PendingCommitStatus, command.Apply); err != nil { - ctx.Log.Warn("unable to update apply commit status: %s", err) - } - } - } else { - ctx.Log.Debug("silence enabled - not setting pending VCS status") + cmdRunner := buildCommentCommandRunner(c, cmd.CommandName()) + targetInitiallyIgnored := shouldSkipPreWorkflowHooks(ctx, cmdRunner, cmd) + if targetInitiallyIgnored { + ctx.CommandSkipped = false } - preWorkflowHooksErr := c.PreWorkflowHooksCommandRunner.RunPreHooks(ctx, cmd) + if !c.validateCommentCommand(ctx, baseRepo, pullNum, user, cmd, !targetInitiallyIgnored) { + return + } + preWorkflowHooksMayUpdateRepo := preWorkflowHooksConfigured(c.PreWorkflowHooksCommandRunner, ctx) + preWorkflowHooksErr := c.PreWorkflowHooksCommandRunner.RunPreHooks(ctx, cmd) + if targetInitiallyIgnored { + ctx.CommandSkipped = false + if !preWorkflowHooksMayUpdateRepo { + return + } + ctx.PreferLocalRepoCfgForTargetedIgnore = true + if shouldSkipPreWorkflowHooks(ctx, cmdRunner, cmd) { + return + } + } if preWorkflowHooksErr != nil { if c.FailOnPreWorkflowHookError { ctx.Log.Err("'fail-on-pre-workflow-hook-error' set, so not running %s command.", cmd.Name.String()) @@ -548,9 +559,10 @@ func (c *DefaultCommandRunner) RunCommentCommand(baseRepo models.Repo, maybeHead ctx.Log.Err("'fail-on-pre-workflow-hook-error' not set so running %s command.", cmd.Name.String()) } - cmdRunner := buildCommentCommandRunner(c, cmd.CommandName()) - cmdRunner.Run(ctx, cmd) + if ctx.CommandSkipped { + return + } c.PostWorkflowHooksCommandRunner.RunPostHooks(ctx, cmd) // nolint: errcheck } @@ -671,9 +683,9 @@ func (c *DefaultCommandRunner) fetchUserTeams(logger logging.SimpleLogging, repo return nil } -func (c *DefaultCommandRunner) validateCtxAndComment(ctx *command.Context, commandName command.Name) bool { +func (c *DefaultCommandRunner) validateCtxAndComment(ctx *command.Context, commandName command.Name, shouldComment bool) bool { if !c.AllowForkPRs && ctx.HeadRepo.Owner != ctx.Pull.BaseRepo.Owner { - if c.SilenceForkPRErrors { + if c.SilenceForkPRErrors || !shouldComment { return false } ctx.Log.Info("command was run on a fork pull request which is disallowed") @@ -685,8 +697,10 @@ func (c *DefaultCommandRunner) validateCtxAndComment(ctx *command.Context, comma if ctx.Pull.State != models.OpenPullState && commandName != command.Unlock { ctx.Log.Info("command was run on closed pull request") - if err := c.VCSClient.CreateComment(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull.Num, "Atlantis commands can't be run on closed pull requests", ""); err != nil { - ctx.Log.Err("unable to comment: %s", err) + if shouldComment { + if err := c.VCSClient.CreateComment(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull.Num, "Atlantis commands can't be run on closed pull requests", ""); err != nil { + ctx.Log.Err("unable to comment: %s", err) + } } return false } diff --git a/server/events/command_runner_test.go b/server/events/command_runner_test.go index 31b6e3e35f..6092afea99 100644 --- a/server/events/command_runner_test.go +++ b/server/events/command_runner_test.go @@ -59,6 +59,7 @@ var lockingLocker *lockingmocks.MockLocker var applyCommandRunner *events.ApplyCommandRunner var unlockCommandRunner *events.UnlockCommandRunner var importCommandRunner *events.ImportCommandRunner +var stateCommandRunner *events.StateCommandRunner var preWorkflowHooksCommandRunner events.PreWorkflowHooksCommandRunner var postWorkflowHooksCommandRunner events.PostWorkflowHooksCommandRunner var cancellationTracker *mocks.MockCancellationTracker @@ -77,6 +78,21 @@ type TestConfig struct { applyLockCheckerErr error } +type configuredPreWorkflowHooksCommandRunner struct { + hasHooks bool + err error + calls int +} + +func (r *configuredPreWorkflowHooksCommandRunner) RunPreHooks(_ *command.Context, _ *events.CommentCommand) error { + r.calls++ + return r.err +} + +func (r *configuredPreWorkflowHooksCommandRunner) HasPreWorkflowHooks(_ *command.Context) bool { + return r.hasHooks +} + func setup(t *testing.T, options ...func(testConfig *TestConfig)) *vcsmocks.MockClient { RegisterMockTestingT(t) @@ -226,6 +242,12 @@ func setup(t *testing.T, options ...func(testConfig *TestConfig)) *vcsmocks.Mock testConfig.SilenceNoProjects, ) + stateCommandRunner = events.NewStateCommandRunner( + pullUpdater, + projectCommandBuilder, + projectCommandRunner, + ) + commentCommandRunnerByCmd := map[command.Name]events.CommentCommandRunner{ command.Plan: planCommandRunner, command.Apply: applyCommandRunner, @@ -233,6 +255,7 @@ func setup(t *testing.T, options ...func(testConfig *TestConfig)) *vcsmocks.Mock command.Unlock: unlockCommandRunner, command.Version: versionCommandRunner, command.Import: importCommandRunner, + command.State: stateCommandRunner, } preWorkflowHooksCommandRunner = mocks.NewMockPreWorkflowHooksCommandRunner() @@ -463,8 +486,305 @@ func TestRunCommentCommandApply_NoProjects_SilenceEnabled(t *testing.T) { ch.RunCommentCommand(testdata.GithubRepo, nil, nil, testdata.User, testdata.Pull.Num, &events.CommentCommand{Name: command.Apply}) vcsClient.VerifyWasCalled(Never()).CreateComment( Any[logging.SimpleLogging](), Any[models.Repo](), Any[int](), Any[string](), Any[string]()) - commitUpdater.VerifyWasCalledOnce().UpdateCombined( - Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Eq(models.PendingCommitStatus), Eq(command.Apply)) + commitUpdater.VerifyWasCalledOnce().UpdateCombinedCount( + Any[logging.SimpleLogging](), + Any[models.Repo](), + Any[models.PullRequest](), + Eq[models.CommitStatus](models.SuccessCommitStatus), + Eq[command.Name](command.Apply), + Eq(models.ProjectCounts{}), + ) +} + +func TestRunCommentCommand_IgnoredTargetedDirNoOp(t *testing.T) { + cases := []struct { + name string + commandName command.Name + subName string + }{ + { + name: "plan", + commandName: command.Plan, + }, + { + name: "apply", + commandName: command.Apply, + }, + { + name: "approve_policies", + commandName: command.ApprovePolicies, + }, + { + name: "import", + commandName: command.Import, + }, + { + name: "version", + commandName: command.Version, + }, + { + name: "state rm", + commandName: command.State, + subName: "rm", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + vcsClient := setup(t) + ch.FailOnPreWorkflowHookError = true + pull := &github.PullRequest{State: github.Ptr("open")} + modelPull := models.PullRequest{BaseRepo: testdata.GithubRepo, State: models.OpenPullState, Num: testdata.Pull.Num} + cmd := &events.CommentCommand{Name: c.commandName, SubName: c.subName, RepoRelDir: "ignored"} + + When(githubGetter.GetPullRequest(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(testdata.Pull.Num))).ThenReturn(pull, nil) + When(eventParsing.ParseGithubPull(Any[logging.SimpleLogging](), Eq(pull))).ThenReturn(modelPull, modelPull.BaseRepo, testdata.GithubRepo, nil) + When(projectCommandBuilder.ShouldIgnoreTargetedDir(Any[*command.Context](), Any[*events.CommentCommand]())).ThenReturn(true) + When(preWorkflowHooksCommandRunner.RunPreHooks(Any[*command.Context](), Any[*events.CommentCommand]())).ThenReturn(errors.New("err")) + + ch.RunCommentCommand(testdata.GithubRepo, nil, nil, testdata.User, testdata.Pull.Num, cmd) + + preWorkflowHooksCommandRunner.(*mocks.MockPreWorkflowHooksCommandRunner).VerifyWasCalledOnce().RunPreHooks(Any[*command.Context](), Any[*events.CommentCommand]()) + vcsClient.VerifyWasCalled(Never()).CreateComment( + Any[logging.SimpleLogging](), Any[models.Repo](), Any[int](), Any[string](), Any[string]()) + commitUpdater.VerifyWasCalled(Never()).UpdateCombined( + Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[models.CommitStatus](), Any[command.Name]()) + commitUpdater.VerifyWasCalled(Never()).UpdateCombinedCount( + Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[models.CommitStatus](), Any[command.Name](), Any[models.ProjectCounts]()) + postWorkflowHooksCommandRunner.(*mocks.MockPostWorkflowHooksCommandRunner).VerifyWasCalled(Never()).RunPostHooks(Any[*command.Context](), Any[*events.CommentCommand]()) + }) + } +} + +func TestRunCommentCommand_IgnoredTargetedDirSkipsValidationComments(t *testing.T) { + cases := []struct { + name string + cmd *events.CommentCommand + modelPull models.PullRequest + headRepo models.Repo + setup func(t *testing.T, vcsClient *vcsmocks.MockClient) + verify func(t *testing.T, vcsClient *vcsmocks.MockClient) + }{ + { + name: "team allowlist", + cmd: &events.CommentCommand{Name: command.Plan, RepoRelDir: "ignored"}, + setup: func(t *testing.T, vcsClient *vcsmocks.MockClient) { + checker, err := command.NewTeamAllowlistChecker("allowed-team:apply") + Ok(t, err) + ch.TeamAllowlistChecker = checker + When(vcsClient.GetTeamNamesForUser(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(testdata.User))).ThenReturn([]string{"blocked-team"}, nil) + }, + verify: func(t *testing.T, vcsClient *vcsmocks.MockClient) { + vcsClient.VerifyWasCalledOnce().GetTeamNamesForUser(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(testdata.User)) + }, + }, + { + name: "var file allowlist", + cmd: &events.CommentCommand{Name: command.Plan, RepoRelDir: "ignored", Flags: []string{"-var-file", "/tmp/outside.tfvars"}}, + setup: func(t *testing.T, vcsClient *vcsmocks.MockClient) { + checker, err := events.NewVarFileAllowlistChecker("") + Ok(t, err) + ch.VarFileAllowlistChecker = checker + }, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + vcsClient := setup(t) + if c.modelPull.BaseRepo.FullName == "" { + c.modelPull = models.PullRequest{BaseRepo: testdata.GithubRepo, State: models.OpenPullState, Num: testdata.Pull.Num} + } + if c.headRepo.FullName == "" { + c.headRepo = testdata.GithubRepo + } + + if c.setup != nil { + c.setup(t, vcsClient) + } + + pull := &github.PullRequest{State: github.Ptr("open")} + When(githubGetter.GetPullRequest(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(testdata.Pull.Num))).ThenReturn(pull, nil) + When(eventParsing.ParseGithubPull(Any[logging.SimpleLogging](), Eq(pull))).ThenReturn(c.modelPull, c.modelPull.BaseRepo, c.headRepo, nil) + When(projectCommandBuilder.ShouldIgnoreTargetedDir(Any[*command.Context](), Any[*events.CommentCommand]())).ThenReturn(true) + + ch.RunCommentCommand(testdata.GithubRepo, nil, nil, testdata.User, testdata.Pull.Num, c.cmd) + + if c.verify != nil { + c.verify(t, vcsClient) + } + preWorkflowHooksCommandRunner.(*mocks.MockPreWorkflowHooksCommandRunner).VerifyWasCalled(Never()).RunPreHooks(Any[*command.Context](), Any[*events.CommentCommand]()) + vcsClient.VerifyWasCalled(Never()).CreateComment( + Any[logging.SimpleLogging](), Any[models.Repo](), Any[int](), Any[string](), Any[string]()) + commitUpdater.VerifyWasCalled(Never()).UpdateCombined( + Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[models.CommitStatus](), Any[command.Name]()) + commitUpdater.VerifyWasCalled(Never()).UpdateCombinedCount( + Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[models.CommitStatus](), Any[command.Name](), Any[models.ProjectCounts]()) + postWorkflowHooksCommandRunner.(*mocks.MockPostWorkflowHooksCommandRunner).VerifyWasCalled(Never()).RunPostHooks(Any[*command.Context](), Any[*events.CommentCommand]()) + }) + } +} + +func TestRunCommentCommand_IgnoredTargetedDirValidatesPullBeforeIgnoreCheck(t *testing.T) { + cases := []struct { + name string + modelPull models.PullRequest + headRepo models.Repo + setup func() + wantComment string + }{ + { + name: "fork pull request", + modelPull: models.PullRequest{ + BaseRepo: testdata.GithubRepo, + State: models.OpenPullState, + Num: testdata.Pull.Num, + }, + headRepo: models.Repo{ + Owner: "forkrepo", + FullName: "forkrepo/atlantis", + }, + wantComment: fmt.Sprintf("Atlantis commands can't be run on fork pull requests. To enable, set --%s or, to disable this message, set --%s", "allow-fork-prs-flag", ""), + }, + { + name: "closed pull request", + modelPull: models.PullRequest{ + BaseRepo: testdata.GithubRepo, + State: models.ClosedPullState, + Num: testdata.Pull.Num, + }, + headRepo: testdata.GithubRepo, + wantComment: "Atlantis commands can't be run on closed pull requests", + }, + { + name: "base branch mismatch", + modelPull: models.PullRequest{ + BaseRepo: testdata.GithubRepo, + State: models.OpenPullState, + Num: testdata.Pull.Num, + BaseBranch: "release", + }, + headRepo: testdata.GithubRepo, + setup: func() { + ch.GlobalCfg.Repos[0].BranchRegex = regexp.MustCompile("^main$") + }, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + vcsClient := setup(t) + if c.setup != nil { + c.setup() + } + + pull := &github.PullRequest{State: github.Ptr("open")} + When(githubGetter.GetPullRequest(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(testdata.Pull.Num))).ThenReturn(pull, nil) + When(eventParsing.ParseGithubPull(Any[logging.SimpleLogging](), Eq(pull))).ThenReturn(c.modelPull, c.modelPull.BaseRepo, c.headRepo, nil) + When(projectCommandBuilder.ShouldIgnoreTargetedDir(Any[*command.Context](), Any[*events.CommentCommand]())).ThenReturn(true) + + ch.RunCommentCommand(testdata.GithubRepo, nil, nil, testdata.User, testdata.Pull.Num, &events.CommentCommand{Name: command.Plan, RepoRelDir: "ignored"}) + + projectCommandBuilder.VerifyWasCalled(Never()).ShouldIgnoreTargetedDir(Any[*command.Context](), Any[*events.CommentCommand]()) + preWorkflowHooksCommandRunner.(*mocks.MockPreWorkflowHooksCommandRunner).VerifyWasCalled(Never()).RunPreHooks(Any[*command.Context](), Any[*events.CommentCommand]()) + if c.wantComment == "" { + vcsClient.VerifyWasCalled(Never()).CreateComment( + Any[logging.SimpleLogging](), Any[models.Repo](), Any[int](), Any[string](), Any[string]()) + } else { + vcsClient.VerifyWasCalledOnce().CreateComment( + Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(c.modelPull.Num), Eq(c.wantComment), Eq("")) + } + }) + } +} + +func TestRunCommentCommand_IgnoredTargetedDirNoHooksDoesNotUseLocalConfig(t *testing.T) { + setup(t) + pull := &github.PullRequest{State: github.Ptr("open")} + modelPull := models.PullRequest{BaseRepo: testdata.GithubRepo, State: models.OpenPullState, Num: testdata.Pull.Num} + cmd := &events.CommentCommand{Name: command.Apply, RepoRelDir: "ignored"} + ignoreChecks := 0 + + When(githubGetter.GetPullRequest(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(testdata.Pull.Num))).ThenReturn(pull, nil) + When(eventParsing.ParseGithubPull(Any[logging.SimpleLogging](), Eq(pull))).ThenReturn(modelPull, modelPull.BaseRepo, testdata.GithubRepo, nil) + When(projectCommandBuilder.ShouldIgnoreTargetedDir(Any[*command.Context](), Any[*events.CommentCommand]())).Then(func(args []Param) ReturnValues { + ignoreChecks++ + return ReturnValues{ignoreChecks == 1} + }) + + ch.RunCommentCommand(testdata.GithubRepo, nil, nil, testdata.User, testdata.Pull.Num, cmd) + + Equals(t, 1, ignoreChecks) + preWorkflowHooksCommandRunner.(*mocks.MockPreWorkflowHooksCommandRunner).VerifyWasCalledOnce().RunPreHooks(Any[*command.Context](), Eq(cmd)) + projectCommandBuilder.VerifyWasCalled(Never()).BuildApplyCommands(Any[*command.Context](), Any[*events.CommentCommand]()) + projectCommandRunner.VerifyWasCalled(Never()).Apply(Any[command.ProjectContext]()) +} + +func TestRunCommentCommand_IgnoredTargetedDirPreHooksCanGenerateExplicitConfig(t *testing.T) { + setup(t) + configuredPreHooks := &configuredPreWorkflowHooksCommandRunner{hasHooks: true} + preWorkflowHooksCommandRunner = configuredPreHooks + ch.PreWorkflowHooksCommandRunner = configuredPreHooks + pull := &github.PullRequest{State: github.Ptr("open")} + modelPull := models.PullRequest{BaseRepo: testdata.GithubRepo, State: models.OpenPullState, Num: testdata.Pull.Num} + cmd := &events.CommentCommand{Name: command.Plan, RepoRelDir: "ignored"} + projectCtx := command.ProjectContext{ + CommandName: command.Plan, + RepoRelDir: "ignored", + Workspace: events.DefaultWorkspace, + BaseRepo: testdata.GithubRepo, + Pull: modelPull, + } + ignoreChecks := 0 + + When(githubGetter.GetPullRequest(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(testdata.Pull.Num))).ThenReturn(pull, nil) + When(eventParsing.ParseGithubPull(Any[logging.SimpleLogging](), Eq(pull))).ThenReturn(modelPull, modelPull.BaseRepo, testdata.GithubRepo, nil) + When(projectCommandBuilder.ShouldIgnoreTargetedDir(Any[*command.Context](), Any[*events.CommentCommand]())).Then(func(args []Param) ReturnValues { + ignoreChecks++ + return ReturnValues{ignoreChecks == 1} + }) + When(projectCommandBuilder.BuildPlanCommands(Any[*command.Context](), Eq(cmd))).ThenReturn([]command.ProjectContext{projectCtx}, nil) + When(projectCommandRunner.Plan(projectCtx)).ThenReturn(command.ProjectCommandOutput{PlanSuccess: &models.PlanSuccess{}}) + + ch.RunCommentCommand(testdata.GithubRepo, nil, nil, testdata.User, testdata.Pull.Num, cmd) + + Equals(t, 1, configuredPreHooks.calls) + projectCommandBuilder.VerifyWasCalledOnce().BuildPlanCommands(Any[*command.Context](), Eq(cmd)) + projectCommandRunner.VerifyWasCalledOnce().Plan(projectCtx) +} + +func TestRunCommentCommand_IgnoredTargetedDirNonFatalPreHookErrorCanGenerateExplicitConfig(t *testing.T) { + setup(t) + configuredPreHooks := &configuredPreWorkflowHooksCommandRunner{hasHooks: true, err: errors.New("hook error")} + preWorkflowHooksCommandRunner = configuredPreHooks + ch.PreWorkflowHooksCommandRunner = configuredPreHooks + pull := &github.PullRequest{State: github.Ptr("open")} + modelPull := models.PullRequest{BaseRepo: testdata.GithubRepo, State: models.OpenPullState, Num: testdata.Pull.Num} + cmd := &events.CommentCommand{Name: command.Plan, RepoRelDir: "ignored"} + projectCtx := command.ProjectContext{ + CommandName: command.Plan, + RepoRelDir: "ignored", + Workspace: events.DefaultWorkspace, + BaseRepo: testdata.GithubRepo, + Pull: modelPull, + } + ignoreChecks := 0 + + When(githubGetter.GetPullRequest(Any[logging.SimpleLogging](), Eq(testdata.GithubRepo), Eq(testdata.Pull.Num))).ThenReturn(pull, nil) + When(eventParsing.ParseGithubPull(Any[logging.SimpleLogging](), Eq(pull))).ThenReturn(modelPull, modelPull.BaseRepo, testdata.GithubRepo, nil) + When(projectCommandBuilder.ShouldIgnoreTargetedDir(Any[*command.Context](), Any[*events.CommentCommand]())).Then(func(args []Param) ReturnValues { + ignoreChecks++ + return ReturnValues{ignoreChecks == 1} + }) + When(projectCommandBuilder.BuildPlanCommands(Any[*command.Context](), Eq(cmd))).ThenReturn([]command.ProjectContext{projectCtx}, nil) + When(projectCommandRunner.Plan(projectCtx)).ThenReturn(command.ProjectCommandOutput{PlanSuccess: &models.PlanSuccess{}}) + + ch.RunCommentCommand(testdata.GithubRepo, nil, nil, testdata.User, testdata.Pull.Num, cmd) + + Equals(t, 1, configuredPreHooks.calls) + Equals(t, 2, ignoreChecks) + projectCommandBuilder.VerifyWasCalledOnce().BuildPlanCommands(Any[*command.Context](), Eq(cmd)) + projectCommandRunner.VerifyWasCalledOnce().Plan(projectCtx) } func TestRunCommentCommandApprovePolicy_NoProjects_SilenceEnabled(t *testing.T) { @@ -874,7 +1194,7 @@ func TestRunAutoplanCommand_FailedPreWorkflowHook_FailOnPreWorkflowHookError_Tru pendingPlanFinder.VerifyWasCalled(Never()).DeletePlans(Any[string]()) // gomock will fail if lockingLocker.UnlockByPull is called unexpectedly (no EXPECT set) commitUpdater.VerifyWasCalledOnce().UpdateCombined(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), - Eq(models.PendingCommitStatus), Eq(command.Plan)) + Eq(models.FailedCommitStatus), Eq(command.Plan)) _, _, _, comment, _ := vcsClient.VerifyWasCalledOnce().CreateComment( Any[logging.SimpleLogging](), Any[models.Repo](), Any[int](), Any[string](), Any[string]()).GetCapturedArguments() diff --git a/server/events/github_app_working_dir.go b/server/events/github_app_working_dir.go index 73274aadff..ac27ab3c88 100644 --- a/server/events/github_app_working_dir.go +++ b/server/events/github_app_working_dir.go @@ -33,6 +33,13 @@ func (g *GithubAppWorkingDir) MergeAgain(logger logging.SimpleLogging, headRepo return g.WorkingDir.MergeAgain(logger, headRepo, p, workspace) } +func (g *GithubAppWorkingDir) CheckoutMergeEnabled() bool { + checkoutMerge, ok := g.WorkingDir.(interface { + CheckoutMergeEnabled() bool + }) + return ok && checkoutMerge.CheckoutMergeEnabled() +} + func (g *GithubAppWorkingDir) fixReposURL(p *models.PullRequest, headRepo *models.Repo) { // Realistically, this is a super brittle way of supporting clones using gh app installation tokens // This URL should be built during Repo creation and the struct should be immutable going forward. diff --git a/server/events/import_command_runner.go b/server/events/import_command_runner.go index e34276db5f..3bc9a81673 100644 --- a/server/events/import_command_runner.go +++ b/server/events/import_command_runner.go @@ -50,6 +50,9 @@ func (v *ImportCommandRunner) Run(ctx *command.Context, cmd *CommentCommand) { var projectCmds []command.ProjectContext projectCmds, err = v.prjCmdBuilder.BuildImportCommands(ctx, cmd) + if MarkCommandSkippedIfIgnoredTargetedDir(ctx, cmd.CommandName(), err) { + return + } if err != nil { ctx.Log.Warn("Error %s", err) } @@ -70,3 +73,7 @@ func (v *ImportCommandRunner) Run(ctx *command.Context, cmd *CommentCommand) { } v.pullUpdater.updatePull(ctx, cmd, result) } + +func (v *ImportCommandRunner) ShouldSkipPreWorkflowHooks(ctx *command.Context, cmd *CommentCommand) bool { + return MarkCommandSkippedIfIgnoredTarget(ctx, cmd.CommandName(), cmd, v.prjCmdBuilder) +} diff --git a/server/events/instrumented_project_command_builder.go b/server/events/instrumented_project_command_builder.go index 2c037c6add..c9660ae338 100644 --- a/server/events/instrumented_project_command_builder.go +++ b/server/events/instrumented_project_command_builder.go @@ -74,8 +74,12 @@ func (b *InstrumentedProjectCommandBuilder) buildAndEmitStats( projectCmds, err := execute() if err != nil { - executionError.Inc(1) - b.Logger.Err("Error building %s commands: %s", command, err) + if IsIgnoredTargetedDir(err) { + executionSuccess.Inc(1) + } else { + executionError.Inc(1) + b.Logger.Err("Error building %s commands: %s", command, err) + } } else { executionSuccess.Inc(1) } diff --git a/server/events/instrumented_project_command_builder_test.go b/server/events/instrumented_project_command_builder_test.go new file mode 100644 index 0000000000..03ac497cd2 --- /dev/null +++ b/server/events/instrumented_project_command_builder_test.go @@ -0,0 +1,79 @@ +// Copyright 2026 The Atlantis Authors +// SPDX-License-Identifier: Apache-2.0 + +package events + +import ( + "testing" + + "github.com/runatlantis/atlantis/server/events/command" + "github.com/runatlantis/atlantis/server/logging" + "github.com/runatlantis/atlantis/server/metrics" + tally "github.com/uber-go/tally/v4" +) + +func TestInstrumentedProjectCommandBuilder_IgnoredTargetedDirIsNotErrorMetric(t *testing.T) { + scope := tally.NewTestScope("builder", nil) + builder := &InstrumentedProjectCommandBuilder{ + ProjectCommandBuilder: fakeInstrumentedProjectCommandBuilder{err: ErrIgnoredTargetedDir}, + Logger: logging.NewNoopLogger(t), + scope: scope, + } + + _, err := builder.BuildPlanCommands(&command.Context{}, &CommentCommand{}) + + if !IsIgnoredTargetedDir(err) { + t.Fatalf("expected ignored targeted dir error, got %v", err) + } + counters := scope.Snapshot().Counters() + if got := counterValue(counters, "builder."+metrics.ExecutionSuccessMetric+"+"); got != 1 { + t.Fatalf("expected success metric to be 1, got %d", got) + } + if got := counterValue(counters, "builder."+metrics.ExecutionErrorMetric+"+"); got != 0 { + t.Fatalf("expected error metric to be 0, got %d", got) + } +} + +func counterValue(counters map[string]tally.CounterSnapshot, name string) int64 { + counter, ok := counters[name] + if !ok { + return 0 + } + return counter.Value() +} + +type fakeInstrumentedProjectCommandBuilder struct { + err error +} + +func (f fakeInstrumentedProjectCommandBuilder) ShouldIgnoreTargetedDir(ctx *command.Context, comment *CommentCommand) bool { + return false +} + +func (f fakeInstrumentedProjectCommandBuilder) BuildAutoplanCommands(ctx *command.Context) ([]command.ProjectContext, error) { + return nil, f.err +} + +func (f fakeInstrumentedProjectCommandBuilder) BuildPlanCommands(ctx *command.Context, comment *CommentCommand) ([]command.ProjectContext, error) { + return nil, f.err +} + +func (f fakeInstrumentedProjectCommandBuilder) BuildApplyCommands(ctx *command.Context, comment *CommentCommand) ([]command.ProjectContext, error) { + return nil, f.err +} + +func (f fakeInstrumentedProjectCommandBuilder) BuildApprovePoliciesCommands(ctx *command.Context, comment *CommentCommand) ([]command.ProjectContext, error) { + return nil, f.err +} + +func (f fakeInstrumentedProjectCommandBuilder) BuildVersionCommands(ctx *command.Context, comment *CommentCommand) ([]command.ProjectContext, error) { + return nil, f.err +} + +func (f fakeInstrumentedProjectCommandBuilder) BuildImportCommands(ctx *command.Context, comment *CommentCommand) ([]command.ProjectContext, error) { + return nil, f.err +} + +func (f fakeInstrumentedProjectCommandBuilder) BuildStateRmCommands(ctx *command.Context, comment *CommentCommand) ([]command.ProjectContext, error) { + return nil, f.err +} diff --git a/server/events/mocks/mock_project_command_builder.go b/server/events/mocks/mock_project_command_builder.go index 4395759917..fdaba5d798 100644 --- a/server/events/mocks/mock_project_command_builder.go +++ b/server/events/mocks/mock_project_command_builder.go @@ -159,6 +159,21 @@ func (mock *MockProjectCommandBuilder) BuildVersionCommands(ctx *command.Context return _ret0, _ret1 } +func (mock *MockProjectCommandBuilder) ShouldIgnoreTargetedDir(ctx *command.Context, comment *events.CommentCommand) bool { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockProjectCommandBuilder().") + } + _params := []pegomock.Param{ctx, comment} + _result := pegomock.GetGenericMockFrom(mock).Invoke("ShouldIgnoreTargetedDir", _params, []reflect.Type{reflect.TypeOf((*bool)(nil)).Elem()}) + var _ret0 bool + if len(_result) != 0 { + if _result[0] != nil { + _ret0 = _result[0].(bool) + } + } + return _ret0 +} + func (mock *MockProjectCommandBuilder) VerifyWasCalledOnce() *VerifierMockProjectCommandBuilder { return &VerifierMockProjectCommandBuilder{ mock: mock, @@ -434,3 +449,38 @@ func (c *MockProjectCommandBuilder_BuildVersionCommands_OngoingVerification) Get } return } + +func (verifier *VerifierMockProjectCommandBuilder) ShouldIgnoreTargetedDir(ctx *command.Context, comment *events.CommentCommand) *MockProjectCommandBuilder_ShouldIgnoreTargetedDir_OngoingVerification { + _params := []pegomock.Param{ctx, comment} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ShouldIgnoreTargetedDir", _params, verifier.timeout) + return &MockProjectCommandBuilder_ShouldIgnoreTargetedDir_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockProjectCommandBuilder_ShouldIgnoreTargetedDir_OngoingVerification struct { + mock *MockProjectCommandBuilder + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockProjectCommandBuilder_ShouldIgnoreTargetedDir_OngoingVerification) GetCapturedArguments() (*command.Context, *events.CommentCommand) { + ctx, comment := c.GetAllCapturedArguments() + return ctx[len(ctx)-1], comment[len(comment)-1] +} + +func (c *MockProjectCommandBuilder_ShouldIgnoreTargetedDir_OngoingVerification) GetAllCapturedArguments() (_param0 []*command.Context, _param1 []*events.CommentCommand) { + _params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(_params) > 0 { + if len(_params) > 0 { + _param0 = make([]*command.Context, len(c.methodInvocations)) + for u, param := range _params[0] { + _param0[u] = param.(*command.Context) + } + } + if len(_params) > 1 { + _param1 = make([]*events.CommentCommand, len(c.methodInvocations)) + for u, param := range _params[1] { + _param1[u] = param.(*events.CommentCommand) + } + } + } + return +} diff --git a/server/events/plan_command_runner.go b/server/events/plan_command_runner.go index 63b242109a..b67522e196 100644 --- a/server/events/plan_command_runner.go +++ b/server/events/plan_command_runner.go @@ -144,6 +144,7 @@ func (p *PlanCommandRunner) runAutoplan(ctx *command.Context) { } return } + p.updatePendingCommitStatus(ctx, command.Plan) // discard previous plans that might not be relevant anymore ctx.Log.Debug("deleting previous plans and locks") @@ -205,13 +206,17 @@ func (p *PlanCommandRunner) run(ctx *command.Context, cmd *CommentCommand) { ctx.Log.Warn("unable to get pull request status: %s. Continuing with mergeable and approved assumed false", err) } + projectCmds, err := p.prjCmdBuilder.BuildPlanCommands(ctx, cmd) + if MarkCommandSkippedIfIgnoredTargetedDir(ctx, command.Plan, err) { + return + } + if p.DiscardApprovalOnPlan { - if err = p.pullUpdater.VCSClient.DiscardReviews(ctx.Log, baseRepo, pull); err != nil { - ctx.Log.Err("failed to remove approvals: %s", err) + if discardErr := p.pullUpdater.VCSClient.DiscardReviews(ctx.Log, baseRepo, pull); discardErr != nil { + ctx.Log.Err("failed to remove approvals: %s", discardErr) } } - projectCmds, err := p.prjCmdBuilder.BuildPlanCommands(ctx, cmd) if err != nil { if statusErr := p.commitStatusUpdater.UpdateCombined(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.FailedCommitStatus, command.Plan); statusErr != nil { ctx.Log.Warn("unable to update commit status: %s", statusErr) @@ -262,8 +267,10 @@ func (p *PlanCommandRunner) run(ctx *command.Context, cmd *CommentCommand) { } return } - projectCmds, policyCheckCmds := p.partitionProjectCmds(ctx, projectCmds) + if len(projectCmds) > 0 { + p.updatePendingCommitStatus(ctx, command.Plan) + } // if the plan is generic, new plans will be generated based on changes // discard previous plans that might not be relevant anymore @@ -328,6 +335,20 @@ func (p *PlanCommandRunner) Run(ctx *command.Context, cmd *CommentCommand) { } } +func (p *PlanCommandRunner) ShouldSkipPreWorkflowHooks(ctx *command.Context, cmd *CommentCommand) bool { + return MarkCommandSkippedIfIgnoredTarget(ctx, command.Plan, cmd, p.prjCmdBuilder) +} + +func (p *PlanCommandRunner) updatePendingCommitStatus(ctx *command.Context, commandName command.Name) { + if p.silenceVCSStatusNoProjects { + ctx.Log.Debug("silence enabled - not setting pending VCS status") + return + } + if err := p.commitStatusUpdater.UpdateCombined(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.PendingCommitStatus, commandName); err != nil { + ctx.Log.Warn("unable to update commit status: %s", err) + } +} + func (p *PlanCommandRunner) updateCommitStatus(ctx *command.Context, pullStatus models.PullStatus, commandName command.Name) { var numSuccess int var numErrored int diff --git a/server/events/plan_command_runner_test.go b/server/events/plan_command_runner_test.go index cb7e7a260f..4fec6357cf 100644 --- a/server/events/plan_command_runner_test.go +++ b/server/events/plan_command_runner_test.go @@ -163,6 +163,37 @@ func TestPlanCommandRunner_IsSilenced(t *testing.T) { } } +func TestPlanCommandRunner_IgnoredTargetedDirNoOp(t *testing.T) { + RegisterMockTestingT(t) + vcsClient := setup(t) + planCommandRunner.DiscardApprovalOnPlan = true + scopeNull := metricstest.NewLoggingScope(t, logging.NewNoopLogger(t), "atlantis") + modelPull := models.PullRequest{BaseRepo: testdata.GithubRepo, State: models.OpenPullState, Num: testdata.Pull.Num} + cmd := &events.CommentCommand{Name: command.Plan, RepoRelDir: "ignored"} + ctx := &command.Context{ + User: testdata.User, + Log: logging.NewNoopLogger(t), + Scope: scopeNull, + Pull: modelPull, + HeadRepo: testdata.GithubRepo, + Trigger: command.CommentTrigger, + } + + When(projectCommandBuilder.BuildPlanCommands(ctx, cmd)).ThenReturn([]command.ProjectContext{}, events.ErrIgnoredTargetedDir) + + planCommandRunner.Run(ctx, cmd) + Assert(t, ctx.CommandSkipped, "expected ignored targeted dir to mark the command skipped") + + vcsClient.VerifyWasCalled(Never()).CreateComment( + Any[logging.SimpleLogging](), Any[models.Repo](), Any[int](), Any[string](), Any[string]()) + commitUpdater.VerifyWasCalled(Never()).UpdateCombined( + Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[models.CommitStatus](), Any[command.Name]()) + commitUpdater.VerifyWasCalled(Never()).UpdateCombinedCount( + Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[models.CommitStatus](), Any[command.Name](), Any[models.ProjectCounts]()) + vcsClient.VerifyWasCalled(Never()).DiscardReviews(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest]()) + projectCommandRunner.VerifyWasCalled(Never()).Plan(Any[command.ProjectContext]()) +} + func TestPlanCommandRunner_ExecutionOrder(t *testing.T) { logger := logging.NewNoopLogger(t) RegisterMockTestingT(t) diff --git a/server/events/pre_workflow_hooks_command_runner.go b/server/events/pre_workflow_hooks_command_runner.go index f5e6f72b43..92d7912366 100644 --- a/server/events/pre_workflow_hooks_command_runner.go +++ b/server/events/pre_workflow_hooks_command_runner.go @@ -28,6 +28,10 @@ type PreWorkflowHooksCommandRunner interface { RunPreHooks(ctx *command.Context, cmd *CommentCommand) error } +type PreWorkflowHooksConfiguredChecker interface { + HasPreWorkflowHooks(ctx *command.Context) bool +} + // DefaultPreWorkflowHooksCommandRunner is the first step when processing a workflow hook commands. type DefaultPreWorkflowHooksCommandRunner struct { VCSClient vcs.Client `validate:"required"` @@ -41,12 +45,7 @@ type DefaultPreWorkflowHooksCommandRunner struct { // RunPreHooks runs pre_workflow_hooks when PR is opened or updated. func (w *DefaultPreWorkflowHooksCommandRunner) RunPreHooks(ctx *command.Context, cmd *CommentCommand) error { - preWorkflowHooks := make([]*valid.WorkflowHook, 0) - for _, repo := range w.GlobalCfg.Repos { - if repo.IDMatches(ctx.Pull.BaseRepo.ID()) && len(repo.PreWorkflowHooks) > 0 { - preWorkflowHooks = append(preWorkflowHooks, repo.PreWorkflowHooks...) - } - } + preWorkflowHooks := w.preWorkflowHooks(ctx) // short circuit any other calls if there are no pre-hooks configured if len(preWorkflowHooks) == 0 { @@ -97,6 +96,20 @@ func (w *DefaultPreWorkflowHooksCommandRunner) RunPreHooks(ctx *command.Context, return nil } +func (w *DefaultPreWorkflowHooksCommandRunner) HasPreWorkflowHooks(ctx *command.Context) bool { + return len(w.preWorkflowHooks(ctx)) > 0 +} + +func (w *DefaultPreWorkflowHooksCommandRunner) preWorkflowHooks(ctx *command.Context) []*valid.WorkflowHook { + preWorkflowHooks := make([]*valid.WorkflowHook, 0) + for _, repo := range w.GlobalCfg.Repos { + if repo.IDMatches(ctx.Pull.BaseRepo.ID()) && len(repo.PreWorkflowHooks) > 0 { + preWorkflowHooks = append(preWorkflowHooks, repo.PreWorkflowHooks...) + } + } + return preWorkflowHooks +} + func (w *DefaultPreWorkflowHooksCommandRunner) runHooks( ctx models.WorkflowHookCommandContext, preWorkflowHooks []*valid.WorkflowHook, diff --git a/server/events/project_command_builder.go b/server/events/project_command_builder.go index 7aed29dca9..40470aadcb 100644 --- a/server/events/project_command_builder.go +++ b/server/events/project_command_builder.go @@ -12,6 +12,7 @@ import ( "sort" "strings" + "github.com/bmatcuk/doublestar/v4" tally "github.com/uber-go/tally/v4" "github.com/runatlantis/atlantis/server/core/config/valid" @@ -38,6 +39,30 @@ const ( DefaultAbortOnExecutionOrderFail = false ) +var ErrIgnoredTargetedDir = errors.New("targeted dir ignored by autodiscover.ignore_paths") + +func IsIgnoredTargetedDir(err error) bool { + return errors.Is(err, ErrIgnoredTargetedDir) +} + +func MarkCommandSkippedIfIgnoredTargetedDir(ctx *command.Context, commandName command.Name, err error) bool { + if !IsIgnoredTargetedDir(err) { + return false + } + ctx.Log.Debug("ignoring targeted %s because the directory matches autodiscover.ignore_paths", commandName.String()) + ctx.CommandSkipped = true + return true +} + +func MarkCommandSkippedIfIgnoredTarget(ctx *command.Context, commandName command.Name, cmd *CommentCommand, ignorer ProjectTargetedDirIgnorer) bool { + if cmd.ProjectName != "" || cmd.RepoRelDir == "" || !ignorer.ShouldIgnoreTargetedDir(ctx, cmd) { + return false + } + ctx.Log.Debug("ignoring targeted %s because the directory matches autodiscover.ignore_paths", commandName.String()) + ctx.CommandSkipped = true + return true +} + func NewInstrumentedProjectCommandBuilder( logger logging.SimpleLogging, policyChecksSupported bool, @@ -152,6 +177,7 @@ func NewProjectCommandBuilder( } type ProjectPlanCommandBuilder interface { + ProjectTargetedDirIgnorer // BuildAutoplanCommands builds project commands that will run plan on // the projects determined to be modified. BuildAutoplanCommands(ctx *command.Context) ([]command.ProjectContext, error) @@ -162,6 +188,7 @@ type ProjectPlanCommandBuilder interface { } type ProjectApplyCommandBuilder interface { + ProjectTargetedDirIgnorer // BuildApplyCommands builds project Apply commands for this ctx and comment. If // comment doesn't specify one project then there may be multiple commands // to be run. @@ -169,11 +196,13 @@ type ProjectApplyCommandBuilder interface { } type ProjectApprovePoliciesCommandBuilder interface { + ProjectTargetedDirIgnorer // BuildApprovePoliciesCommands builds project PolicyCheck commands for this ctx and comment. BuildApprovePoliciesCommands(ctx *command.Context, comment *CommentCommand) ([]command.ProjectContext, error) } type ProjectVersionCommandBuilder interface { + ProjectTargetedDirIgnorer // BuildVersionCommands builds project Version commands for this ctx and comment. If // comment doesn't specify one project then there may be multiple commands // to be run. @@ -181,6 +210,7 @@ type ProjectVersionCommandBuilder interface { } type ProjectImportCommandBuilder interface { + ProjectTargetedDirIgnorer // BuildImportCommands builds project Import commands for this ctx and comment. If // comment doesn't specify one project then there may be multiple commands // to be run. @@ -188,12 +218,17 @@ type ProjectImportCommandBuilder interface { } type ProjectStateCommandBuilder interface { + ProjectTargetedDirIgnorer // BuildStateRmCommands builds project state rm commands for this ctx and comment. If // comment doesn't specify one project then there may be multiple commands // to be run. BuildStateRmCommands(ctx *command.Context, comment *CommentCommand) ([]command.ProjectContext, error) } +type ProjectTargetedDirIgnorer interface { + ShouldIgnoreTargetedDir(ctx *command.Context, comment *CommentCommand) bool +} + //go:generate go tool pegomock generate github.com/runatlantis/atlantis/server/events --package mocks -o mocks/mock_project_command_builder.go ProjectCommandBuilder // ProjectCommandBuilder builds commands that run on individual projects. @@ -412,6 +447,139 @@ func (p *DefaultProjectCommandBuilder) parseRepoCfg(ctx *command.Context, repoDi return repoCfg, true, nil } +// shouldIgnoreTargetedDir checks whether a targeted -d command should be +// silently skipped because the path matches autodiscover.ignore_paths. It only +// applies when autodiscovery is active and the directory has no explicit project +// config in the repo config file. +func (p *DefaultProjectCommandBuilder) shouldIgnoreTargetedDir(ctx *command.Context, repoDir string, repoRelDir string) bool { + if valid.ContainsDirGlobPattern(repoRelDir) { + return false + } + repoCfg, _, err := p.parseRepoCfg(ctx, repoDir) + if err != nil { + return false + } + return p.shouldIgnoreTargetedDirFromCfg(ctx, repoCfg, repoRelDir) +} + +func (p *DefaultProjectCommandBuilder) ShouldIgnoreTargetedDir(ctx *command.Context, cmd *CommentCommand) bool { + if cmd.ProjectName != "" || cmd.RepoRelDir == "" || valid.ContainsDirGlobPattern(cmd.RepoRelDir) { + return false + } + repoCfg, ok := p.repoCfgForTargetedIgnore(ctx, cmd) + if !ok { + return false + } + return p.shouldIgnoreTargetedDirFromCfg(ctx, repoCfg, cmd.RepoRelDir) +} + +func (p *DefaultProjectCommandBuilder) repoCfgForTargetedIgnore(ctx *command.Context, cmd *CommentCommand) (valid.RepoCfg, bool) { + repoRelDir := cmd.RepoRelDir + if ctx.PreferLocalRepoCfgForTargetedIgnore { + if repoCfg, ok, _ := p.repoCfgFromWorkingDir(ctx); ok { + return repoCfg, true + } + } + + if p.needsLocalRepoCfgForTargetedIgnore(ctx) { + ctx.Log.Debug("not checking remote repo config for targeted ignore because merge checkout needs the merged local config") + return valid.RepoCfg{}, false + } + + if p.VCSClient != nil { + repoCfgFile := p.GlobalCfg.RepoConfigFile(ctx.Pull.BaseRepo.ID()) + hasRepoCfg, repoCfgData, err := p.VCSClient.GetFileContent(ctx.Log, ctx.HeadRepo, targetedIgnoreConfigRef(ctx.Pull), repoCfgFile) + if err != nil { + ctx.Log.Debug("unable to fetch %s while checking autodiscover.ignore_paths: %s", repoCfgFile, err) + if !p.VCSClient.SupportsSingleFileDownload(ctx.HeadRepo) && p.shouldIgnoreTargetedDirFromGlobalCfg(ctx, repoRelDir) { + // Do not let global ignore_paths hide an explicit project that only the + // cloned repo config can prove. + if cmd.Name == command.Plan { + return valid.RepoCfg{}, false + } + if repoCfg, ok, missing := p.repoCfgFromWorkingDir(ctx); ok { + return repoCfg, true + } else if !missing { + return valid.RepoCfg{}, false + } + return valid.RepoCfg{}, true + } + return valid.RepoCfg{}, false + } else if hasRepoCfg { + repoCfg, err := p.ParserValidator.ParseRepoCfgData(repoCfgData, p.GlobalCfg, ctx.Pull.BaseRepo.ID(), ctx.Pull.BaseBranch) + if err != nil { + ctx.Log.Debug("unable to parse %s while checking autodiscover.ignore_paths: %s", repoCfgFile, err) + return valid.RepoCfg{}, false + } else { + return repoCfg, true + } + } + return valid.RepoCfg{}, true + } + + return valid.RepoCfg{}, false +} + +func (p *DefaultProjectCommandBuilder) shouldIgnoreTargetedDirFromGlobalCfg(ctx *command.Context, repoRelDir string) bool { + repoCfg := valid.RepoCfg{} + return p.autoDiscoverModeEnabled(ctx, repoCfg) && + p.isAutoDiscoverPathIgnored(ctx, repoCfg, filepath.Clean(repoRelDir)) +} + +func targetedIgnoreConfigRef(pull models.PullRequest) string { + if pull.HeadCommit != "" { + return pull.HeadCommit + } + return pull.HeadBranch +} + +func (p *DefaultProjectCommandBuilder) repoCfgFromWorkingDir(ctx *command.Context) (valid.RepoCfg, bool, bool) { + repoDir, err := p.WorkingDir.GetWorkingDir(ctx.Pull.BaseRepo, ctx.Pull, DefaultWorkspace) + if err != nil { + return valid.RepoCfg{}, false, errors.Is(err, os.ErrNotExist) + } + repoCfg, _, err := p.parseRepoCfg(ctx, repoDir) + if err != nil { + return valid.RepoCfg{}, false, false + } + return repoCfg, true, false +} + +func (p *DefaultProjectCommandBuilder) needsLocalRepoCfgForTargetedIgnore(ctx *command.Context) bool { + return ctx.Pull.Num > 0 && workingDirUsesMergeCheckout(p.WorkingDir) +} + +func workingDirUsesMergeCheckout(workingDir WorkingDir) bool { + checkoutMerge, ok := workingDir.(interface { + CheckoutMergeEnabled() bool + }) + return ok && checkoutMerge.CheckoutMergeEnabled() +} + +func (p *DefaultProjectCommandBuilder) shouldIgnoreTargetedDirFromCfg(ctx *command.Context, repoCfg valid.RepoCfg, repoRelDir string) bool { + if !p.autoDiscoverModeEnabled(ctx, repoCfg) { + return false + } + cleanDir := filepath.Clean(repoRelDir) + for _, proj := range repoCfg.Projects { + if projectDirMatchesTargetedDir(proj.Dir, cleanDir) { + return false + } + } + return p.isAutoDiscoverPathIgnored(ctx, repoCfg, cleanDir) +} + +func projectDirMatchesTargetedDir(projectDir string, cleanTargetDir string) bool { + cleanProjectDir := filepath.Clean(projectDir) + if cleanProjectDir == cleanTargetDir { + return true + } + if !valid.ContainsDirGlobPattern(cleanProjectDir) { + return false + } + return doublestar.MatchUnvalidated(filepath.ToSlash(cleanProjectDir), filepath.ToSlash(cleanTargetDir)) +} + // isAutoDiscoverPathIgnored determines whether this particular path is ignored for the purposes of auto discovery. // Global config ignore_paths takes precedence when explicitly set; otherwise falls through to repo config. func (p *DefaultProjectCommandBuilder) isAutoDiscoverPathIgnored(ctx *command.Context, repoCfg valid.RepoCfg, path string) bool { @@ -671,6 +839,16 @@ func (p *DefaultProjectCommandBuilder) buildProjectPlanCommand(ctx *command.Cont return pcc, err } + repoRelDir := DefaultRepoRelDir + if cmd.RepoRelDir != "" { + repoRelDir = cmd.RepoRelDir + } + + if cmd.ProjectName == "" && p.shouldIgnoreTargetedDir(ctx, defaultRepoDir, repoRelDir) { + ctx.Log.Debug("ignoring targeted plan for dir '%s' due to autodiscover.ignore_paths", repoRelDir) + return pcc, ErrIgnoredTargetedDir + } + if p.RestrictFileList { ctx.Log.Debug("'restrict-file-list' option is set, checking modified files") modifiedFiles, err := p.VCSClient.GetModifiedFiles(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull) @@ -743,11 +921,6 @@ func (p *DefaultProjectCommandBuilder) buildProjectPlanCommand(ctx *command.Cont } } - repoRelDir := DefaultRepoRelDir - if cmd.RepoRelDir != "" { - repoRelDir = cmd.RepoRelDir - } - return p.buildProjectCommandCtx( ctx, command.Plan, @@ -914,6 +1087,16 @@ func (p *DefaultProjectCommandBuilder) buildProjectCommand(ctx *command.Context, } var projCtx []command.ProjectContext + repoRelDir := DefaultRepoRelDir + if cmd.RepoRelDir != "" { + repoRelDir = cmd.RepoRelDir + } + + if p.ShouldIgnoreTargetedDir(ctx, cmd) { + ctx.Log.Debug("ignoring targeted command for dir '%s' due to autodiscover.ignore_paths", repoRelDir) + return projCtx, ErrIgnoredTargetedDir + } + unlockFn, err := p.WorkingDirLocker.TryLock(ctx.Pull.BaseRepo.FullName, ctx.Pull.Num, workspace, DefaultRepoRelDir, cmd.ProjectName, cmd.Name) if err != nil { return projCtx, err @@ -929,9 +1112,9 @@ func (p *DefaultProjectCommandBuilder) buildProjectCommand(ctx *command.Context, return projCtx, err } - repoRelDir := DefaultRepoRelDir - if cmd.RepoRelDir != "" { - repoRelDir = cmd.RepoRelDir + if cmd.ProjectName == "" && p.shouldIgnoreTargetedDir(ctx, repoDir, repoRelDir) { + ctx.Log.Debug("ignoring targeted command for dir '%s' due to autodiscover.ignore_paths", repoRelDir) + return projCtx, ErrIgnoredTargetedDir } return p.buildProjectCommandCtx( diff --git a/server/events/project_command_builder_test.go b/server/events/project_command_builder_test.go index f6af414b64..c4494246a4 100644 --- a/server/events/project_command_builder_test.go +++ b/server/events/project_command_builder_test.go @@ -4,6 +4,7 @@ package events_test import ( + "errors" "os" "path/filepath" "sort" @@ -682,6 +683,1066 @@ projects: } } +// Test that autodiscover.ignore_paths blocks targeted plan/apply -d commands +// when the directory has no explicit project config (global config ignore_paths). +func TestDefaultProjectCommandBuilder_BuildTargetedCommand_IgnorePaths(t *testing.T) { + RegisterMockTestingT(t) + + tmpDir := DirStructure(t, map[string]any{ + "environments": map[string]any{ + "prod": map[string]any{ + "main.tf": nil, + }, + "nonprod": map[string]any{ + "main.tf": nil, + }, + }, + }) + + workingDir := mocks.NewMockWorkingDir() + When(workingDir.Clone(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), + Any[string]())).ThenReturn(tmpDir, nil) + When(workingDir.GetWorkingDir(Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn(tmpDir, nil) + vcsClient := vcsmocks.NewMockClient() + When(vcsClient.GetModifiedFiles(Any[logging.SimpleLogging](), Any[models.Repo](), + Any[models.PullRequest]())).ThenReturn([]string{"environments/prod/main.tf", "environments/nonprod/main.tf"}, nil) + + logger := logging.NewNoopLogger(t) + scope := metricstest.NewLoggingScope(t, logger, "atlantis") + + globalCfgArgs := valid.GlobalCfgArgs{AllowAllRepoSettings: true} + globalCfg := valid.NewGlobalCfgFromArgs(globalCfgArgs) + globalCfg.Repos[0].AutoDiscover = &valid.AutoDiscover{ + Mode: valid.AutoDiscoverEnabledMode, + IgnorePaths: []string{"environments/prod/**"}, + } + + terraformClient := tfclientmocks.NewMockClient() + userConfig := defaultUserConfig + + builder := events.NewProjectCommandBuilder( + false, + &config.ParserValidator{}, + &events.DefaultProjectFinder{}, + vcsClient, + workingDir, + events.NewDefaultWorkingDirLocker(), + globalCfg, + &events.DefaultPendingPlanFinder{}, + &events.CommentParser{ExecutableName: "atlantis"}, + userConfig.SkipCloneNoChanges, + userConfig.EnableRegExpCmd, + userConfig.EnableAutoMerge, + userConfig.EnableParallelPlan, + userConfig.EnableParallelApply, + userConfig.AutoDetectModuleFiles, + userConfig.AutoplanFileList, + userConfig.RestrictFileList, + userConfig.SilenceNoProjects, + userConfig.IncludeGitUntrackedFiles, + userConfig.AutoDiscoverMode, + scope, + terraformClient, + ) + + cmdCtx := &command.Context{Log: logger, Scope: scope} + + // Targeted plan -d to ignored path should return no projects + planCtxs, err := builder.BuildPlanCommands(cmdCtx, &events.CommentCommand{ + Name: command.Plan, + RepoRelDir: "environments/prod", + Workspace: "default", + }) + Assert(t, errors.Is(err, events.ErrIgnoredTargetedDir), "expected ignored targeted dir error, got %v", err) + Equals(t, 0, len(planCtxs)) + + // Targeted plan -d to non-ignored path should succeed + planCtxs, err = builder.BuildPlanCommands(cmdCtx, &events.CommentCommand{ + Name: command.Plan, + RepoRelDir: "environments/nonprod", + Workspace: "default", + }) + Ok(t, err) + Equals(t, 1, len(planCtxs)) + Equals(t, "environments/nonprod", planCtxs[0].RepoRelDir) + + // Targeted apply -d to ignored path should return no projects + applyCtxs, err := builder.BuildApplyCommands(cmdCtx, &events.CommentCommand{ + Name: command.Apply, + RepoRelDir: "environments/prod", + Workspace: "default", + }) + Assert(t, errors.Is(err, events.ErrIgnoredTargetedDir), "expected ignored targeted dir error, got %v", err) + Equals(t, 0, len(applyCtxs)) + + // Targeted apply -d to non-ignored path should succeed + applyCtxs, err = builder.BuildApplyCommands(cmdCtx, &events.CommentCommand{ + Name: command.Apply, + RepoRelDir: "environments/nonprod", + Workspace: "default", + }) + Ok(t, err) + Equals(t, 1, len(applyCtxs)) + Equals(t, "environments/nonprod", applyCtxs[0].RepoRelDir) +} + +func TestDefaultProjectCommandBuilder_BuildTargetedNonPlanCommand_IgnorePathsWithoutWorkingDir(t *testing.T) { + RegisterMockTestingT(t) + + workingDir := mocks.NewMockWorkingDir() + When(workingDir.GetWorkingDir(Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn("", os.ErrNotExist) + vcsClient := vcsmocks.NewMockClient() + + logger := logging.NewNoopLogger(t) + scope := metricstest.NewLoggingScope(t, logger, "atlantis") + + globalCfgArgs := valid.GlobalCfgArgs{AllowAllRepoSettings: true} + globalCfg := valid.NewGlobalCfgFromArgs(globalCfgArgs) + globalCfg.Repos[0].AutoDiscover = &valid.AutoDiscover{ + Mode: valid.AutoDiscoverEnabledMode, + IgnorePaths: []string{"environments/prod/**"}, + } + + terraformClient := tfclientmocks.NewMockClient() + userConfig := defaultUserConfig + + builder := events.NewProjectCommandBuilder( + false, + &config.ParserValidator{}, + &events.DefaultProjectFinder{}, + vcsClient, + workingDir, + events.NewDefaultWorkingDirLocker(), + globalCfg, + &events.DefaultPendingPlanFinder{}, + &events.CommentParser{ExecutableName: "atlantis"}, + userConfig.SkipCloneNoChanges, + userConfig.EnableRegExpCmd, + userConfig.EnableAutoMerge, + userConfig.EnableParallelPlan, + userConfig.EnableParallelApply, + userConfig.AutoDetectModuleFiles, + userConfig.AutoplanFileList, + userConfig.RestrictFileList, + userConfig.SilenceNoProjects, + userConfig.IncludeGitUntrackedFiles, + userConfig.AutoDiscoverMode, + scope, + terraformClient, + ) + + repo := models.Repo{FullName: "runatlantis/atlantis", Owner: "runatlantis", Name: "atlantis"} + cmdCtx := &command.Context{ + Log: logger, + Scope: scope, + Pull: models.PullRequest{BaseRepo: repo, Num: 1, HeadBranch: "feature", BaseBranch: "main"}, + HeadRepo: repo, + } + + cases := []struct { + description string + cmd events.CommentCommand + build func(*command.Context, *events.CommentCommand) ([]command.ProjectContext, error) + }{ + { + description: "apply", + cmd: events.CommentCommand{Name: command.Apply, RepoRelDir: "environments/prod", Workspace: "default"}, + build: builder.BuildApplyCommands, + }, + { + description: "approve policies", + cmd: events.CommentCommand{Name: command.ApprovePolicies, RepoRelDir: "environments/prod", Workspace: "default"}, + build: builder.BuildApprovePoliciesCommands, + }, + { + description: "import", + cmd: events.CommentCommand{Name: command.Import, RepoRelDir: "environments/prod", Workspace: "default"}, + build: builder.BuildImportCommands, + }, + { + description: "version", + cmd: events.CommentCommand{Name: command.Version, RepoRelDir: "environments/prod", Workspace: "default"}, + build: builder.BuildVersionCommands, + }, + { + description: "state rm", + cmd: events.CommentCommand{Name: command.State, SubName: "rm", RepoRelDir: "environments/prod", Workspace: "default"}, + build: builder.BuildStateRmCommands, + }, + } + + for _, c := range cases { + t.Run(c.description, func(t *testing.T) { + ctxs, err := c.build(cmdCtx, &c.cmd) + Assert(t, errors.Is(err, events.ErrIgnoredTargetedDir), "expected ignored targeted dir error, got %v", err) + Equals(t, 0, len(ctxs)) + }) + } +} + +func TestDefaultProjectCommandBuilder_ShouldIgnoreTargetedDirPrefersGeneratedLocalConfig(t *testing.T) { + RegisterMockTestingT(t) + + remoteAtlantisYAML := ` +version: 3 +autodiscover: + mode: enabled + ignore_paths: + - "environments/prod/**" +` + localAtlantisYAML := ` +version: 3 +autodiscover: + mode: enabled + ignore_paths: + - "environments/prod/**" +projects: +- dir: environments/prod +` + tmpDir := DirStructure(t, map[string]any{ + "environments": map[string]any{ + "prod": map[string]any{ + "main.tf": nil, + }, + }, + }) + err := os.WriteFile(filepath.Join(tmpDir, valid.DefaultAtlantisFile), []byte(localAtlantisYAML), 0600) + Ok(t, err) + + workingDir := mocks.NewMockWorkingDir() + When(workingDir.GetWorkingDir(Any[models.Repo](), Any[models.PullRequest](), Eq(events.DefaultWorkspace))).ThenReturn(tmpDir, nil) + vcsClient := vcsmocks.NewMockClient() + When(vcsClient.GetFileContent(Any[logging.SimpleLogging](), Any[models.Repo](), Eq("feature"), Eq(valid.DefaultAtlantisFile))).ThenReturn(true, []byte(remoteAtlantisYAML), nil) + + logger := logging.NewNoopLogger(t) + scope := metricstest.NewLoggingScope(t, logger, "atlantis") + globalCfg := valid.NewGlobalCfgFromArgs(valid.GlobalCfgArgs{AllowAllRepoSettings: true}) + terraformClient := tfclientmocks.NewMockClient() + userConfig := defaultUserConfig + builder := events.NewProjectCommandBuilder( + false, + &config.ParserValidator{}, + &events.DefaultProjectFinder{}, + vcsClient, + workingDir, + events.NewDefaultWorkingDirLocker(), + globalCfg, + &events.DefaultPendingPlanFinder{}, + &events.CommentParser{ExecutableName: "atlantis"}, + userConfig.SkipCloneNoChanges, + userConfig.EnableRegExpCmd, + userConfig.EnableAutoMerge, + userConfig.EnableParallelPlan, + userConfig.EnableParallelApply, + userConfig.AutoDetectModuleFiles, + userConfig.AutoplanFileList, + userConfig.RestrictFileList, + userConfig.SilenceNoProjects, + userConfig.IncludeGitUntrackedFiles, + userConfig.AutoDiscoverMode, + scope, + terraformClient, + ) + baseRepo := models.Repo{Owner: "owner", Name: "repo", FullName: "owner/repo", VCSHost: models.VCSHost{Type: models.Github}} + ctx := &command.Context{ + Log: logger, + Scope: scope, + HeadRepo: baseRepo, + Pull: models.PullRequest{ + Num: 1, + BaseBranch: "main", + HeadBranch: "feature", + BaseRepo: baseRepo, + }, + } + cmd := &events.CommentCommand{Name: command.Plan, RepoRelDir: "environments/prod", Workspace: events.DefaultWorkspace} + + Assert(t, builder.ShouldIgnoreTargetedDir(ctx, cmd), "expected remote config without explicit project to ignore target") + + ctx.PreferLocalRepoCfgForTargetedIgnore = true + Assert(t, !builder.ShouldIgnoreTargetedDir(ctx, cmd), "expected generated local explicit project to prevent ignored-target skip") +} + +func TestDefaultProjectCommandBuilder_ShouldIgnoreTargetedDirUsesHeadCommitForRemoteConfig(t *testing.T) { + RegisterMockTestingT(t) + + atlantisYAML := "version: 3\n" + + "autodiscover:\n" + + " mode: enabled\n" + + " ignore_paths:\n" + + " - \"environments/prod/**\"\n" + + workingDir := mocks.NewMockWorkingDir() + When(workingDir.GetWorkingDir(Any[models.Repo](), Any[models.PullRequest](), Eq(events.DefaultWorkspace))).ThenReturn("", os.ErrNotExist) + vcsClient := vcsmocks.NewMockClient() + When(vcsClient.GetFileContent(Any[logging.SimpleLogging](), Any[models.Repo](), Eq("abc123"), Eq(valid.DefaultAtlantisFile))).ThenReturn(true, []byte(atlantisYAML), nil) + + logger := logging.NewNoopLogger(t) + scope := metricstest.NewLoggingScope(t, logger, "atlantis") + globalCfg := valid.NewGlobalCfgFromArgs(valid.GlobalCfgArgs{AllowAllRepoSettings: true}) + terraformClient := tfclientmocks.NewMockClient() + userConfig := defaultUserConfig + builder := events.NewProjectCommandBuilder( + false, + &config.ParserValidator{}, + &events.DefaultProjectFinder{}, + vcsClient, + workingDir, + events.NewDefaultWorkingDirLocker(), + globalCfg, + &events.DefaultPendingPlanFinder{}, + &events.CommentParser{ExecutableName: "atlantis"}, + userConfig.SkipCloneNoChanges, + userConfig.EnableRegExpCmd, + userConfig.EnableAutoMerge, + userConfig.EnableParallelPlan, + userConfig.EnableParallelApply, + userConfig.AutoDetectModuleFiles, + userConfig.AutoplanFileList, + userConfig.RestrictFileList, + userConfig.SilenceNoProjects, + userConfig.IncludeGitUntrackedFiles, + userConfig.AutoDiscoverMode, + scope, + terraformClient, + ) + baseRepo := models.Repo{Owner: "owner", Name: "repo", FullName: "owner/repo", VCSHost: models.VCSHost{Type: models.Github}} + ctx := &command.Context{ + Log: logger, + Scope: scope, + HeadRepo: baseRepo, + Pull: models.PullRequest{ + Num: 1, + BaseBranch: "main", + HeadBranch: "feature", + HeadCommit: "abc123", + BaseRepo: baseRepo, + }, + } + cmd := &events.CommentCommand{Name: command.Plan, RepoRelDir: "environments/prod", Workspace: events.DefaultWorkspace} + + Assert(t, builder.ShouldIgnoreTargetedDir(ctx, cmd), "expected remote head commit config to ignore target") + vcsClient.VerifyWasCalledOnce().GetFileContent(Any[logging.SimpleLogging](), Eq(baseRepo), Eq("abc123"), Eq(valid.DefaultAtlantisFile)) + vcsClient.VerifyWasCalled(Never()).GetFileContent(Any[logging.SimpleLogging](), Eq(baseRepo), Eq("feature"), Eq(valid.DefaultAtlantisFile)) +} + +func TestDefaultProjectCommandBuilder_ShouldIgnoreTargetedDirRespectsGlobProjectDirs(t *testing.T) { + RegisterMockTestingT(t) + + atlantisYAML := "version: 3\n" + + "autodiscover:\n" + + " mode: enabled\n" + + " ignore_paths:\n" + + " - \"modules/**\"\n" + + "projects:\n" + + "- dir: \"modules/*\"\n" + + workingDir := mocks.NewMockWorkingDir() + vcsClient := vcsmocks.NewMockClient() + When(vcsClient.GetFileContent(Any[logging.SimpleLogging](), Any[models.Repo](), Eq("abc123"), Eq(valid.DefaultAtlantisFile))).ThenReturn(true, []byte(atlantisYAML), nil) + + logger := logging.NewNoopLogger(t) + scope := metricstest.NewLoggingScope(t, logger, "atlantis") + globalCfg := valid.NewGlobalCfgFromArgs(valid.GlobalCfgArgs{AllowAllRepoSettings: true}) + terraformClient := tfclientmocks.NewMockClient() + userConfig := defaultUserConfig + builder := events.NewProjectCommandBuilder( + false, + &config.ParserValidator{}, + &events.DefaultProjectFinder{}, + vcsClient, + workingDir, + events.NewDefaultWorkingDirLocker(), + globalCfg, + &events.DefaultPendingPlanFinder{}, + &events.CommentParser{ExecutableName: "atlantis"}, + userConfig.SkipCloneNoChanges, + userConfig.EnableRegExpCmd, + userConfig.EnableAutoMerge, + userConfig.EnableParallelPlan, + userConfig.EnableParallelApply, + userConfig.AutoDetectModuleFiles, + userConfig.AutoplanFileList, + userConfig.RestrictFileList, + userConfig.SilenceNoProjects, + userConfig.IncludeGitUntrackedFiles, + userConfig.AutoDiscoverMode, + scope, + terraformClient, + ) + baseRepo := models.Repo{Owner: "owner", Name: "repo", FullName: "owner/repo", VCSHost: models.VCSHost{Type: models.Github}} + ctx := &command.Context{ + Log: logger, + Scope: scope, + HeadRepo: baseRepo, + Pull: models.PullRequest{ + Num: 1, + BaseBranch: "main", + HeadBranch: "feature", + HeadCommit: "abc123", + BaseRepo: baseRepo, + }, + } + cmd := &events.CommentCommand{Name: command.Apply, RepoRelDir: "modules/foo", Workspace: events.DefaultWorkspace} + + Assert(t, !builder.ShouldIgnoreTargetedDir(ctx, cmd), "expected glob-configured project dir to prevent ignored-target skip") + vcsClient.VerifyWasCalledOnce().GetFileContent(Any[logging.SimpleLogging](), Eq(baseRepo), Eq("abc123"), Eq(valid.DefaultAtlantisFile)) +} + +func TestDefaultProjectCommandBuilder_ShouldIgnoreTargetedDirFailsOpenWhenRemoteConfigUnknown(t *testing.T) { + RegisterMockTestingT(t) + + staleLocalAtlantisYAML := "version: 3\n" + + "autodiscover:\n" + + " mode: enabled\n" + + " ignore_paths:\n" + + " - \"environments/prod/**\"\n" + tmpDir := DirStructure(t, map[string]any{ + "environments": map[string]any{ + "prod": map[string]any{ + "main.tf": nil, + }, + }, + }) + Ok(t, os.WriteFile(filepath.Join(tmpDir, valid.DefaultAtlantisFile), []byte(staleLocalAtlantisYAML), 0600)) + + workingDir := mocks.NewMockWorkingDir() + When(workingDir.GetWorkingDir(Any[models.Repo](), Any[models.PullRequest](), Eq(events.DefaultWorkspace))).ThenReturn(tmpDir, nil) + vcsClient := vcsmocks.NewMockClient() + When(vcsClient.GetFileContent(Any[logging.SimpleLogging](), Any[models.Repo](), Eq("abc123"), Eq(valid.DefaultAtlantisFile))).ThenReturn(false, []byte{}, errors.New("not implemented")) + + logger := logging.NewNoopLogger(t) + scope := metricstest.NewLoggingScope(t, logger, "atlantis") + globalCfg := valid.NewGlobalCfgFromArgs(valid.GlobalCfgArgs{AllowAllRepoSettings: true}) + terraformClient := tfclientmocks.NewMockClient() + userConfig := defaultUserConfig + builder := events.NewProjectCommandBuilder( + false, + &config.ParserValidator{}, + &events.DefaultProjectFinder{}, + vcsClient, + workingDir, + events.NewDefaultWorkingDirLocker(), + globalCfg, + &events.DefaultPendingPlanFinder{}, + &events.CommentParser{ExecutableName: "atlantis"}, + userConfig.SkipCloneNoChanges, + userConfig.EnableRegExpCmd, + userConfig.EnableAutoMerge, + userConfig.EnableParallelPlan, + userConfig.EnableParallelApply, + userConfig.AutoDetectModuleFiles, + userConfig.AutoplanFileList, + userConfig.RestrictFileList, + userConfig.SilenceNoProjects, + userConfig.IncludeGitUntrackedFiles, + userConfig.AutoDiscoverMode, + scope, + terraformClient, + ) + baseRepo := models.Repo{Owner: "owner", Name: "repo", FullName: "owner/repo", VCSHost: models.VCSHost{Type: models.Github}} + ctx := &command.Context{ + Log: logger, + Scope: scope, + HeadRepo: baseRepo, + Pull: models.PullRequest{ + Num: 1, + BaseBranch: "main", + HeadBranch: "feature", + HeadCommit: "abc123", + BaseRepo: baseRepo, + }, + } + cmd := &events.CommentCommand{Name: command.Plan, RepoRelDir: "environments/prod", Workspace: events.DefaultWorkspace} + + Assert(t, !builder.ShouldIgnoreTargetedDir(ctx, cmd), "expected unknown current repo config to avoid pre-clone skip") + workingDir.VerifyWasCalled(Never()).GetWorkingDir(Any[models.Repo](), Any[models.PullRequest](), Any[string]()) +} + +func TestDefaultProjectCommandBuilder_ShouldIgnoreTargetedDirAllowsAuthoritativeMissingRemoteConfig(t *testing.T) { + RegisterMockTestingT(t) + + workingDir := mocks.NewMockWorkingDir() + When(workingDir.GetWorkingDir(Any[models.Repo](), Any[models.PullRequest](), Eq(events.DefaultWorkspace))).ThenReturn("", os.ErrNotExist) + vcsClient := vcsmocks.NewMockClient() + When(vcsClient.GetFileContent(Any[logging.SimpleLogging](), Any[models.Repo](), Eq("abc123"), Eq(valid.DefaultAtlantisFile))).ThenReturn(false, []byte{}, nil) + + logger := logging.NewNoopLogger(t) + scope := metricstest.NewLoggingScope(t, logger, "atlantis") + globalCfg := valid.NewGlobalCfgFromArgs(valid.GlobalCfgArgs{AllowAllRepoSettings: true}) + globalCfg.Repos[0].AutoDiscover = &valid.AutoDiscover{ + Mode: valid.AutoDiscoverEnabledMode, + IgnorePaths: []string{"environments/prod/**"}, + } + terraformClient := tfclientmocks.NewMockClient() + userConfig := defaultUserConfig + builder := events.NewProjectCommandBuilder( + false, + &config.ParserValidator{}, + &events.DefaultProjectFinder{}, + vcsClient, + workingDir, + events.NewDefaultWorkingDirLocker(), + globalCfg, + &events.DefaultPendingPlanFinder{}, + &events.CommentParser{ExecutableName: "atlantis"}, + userConfig.SkipCloneNoChanges, + userConfig.EnableRegExpCmd, + userConfig.EnableAutoMerge, + userConfig.EnableParallelPlan, + userConfig.EnableParallelApply, + userConfig.AutoDetectModuleFiles, + userConfig.AutoplanFileList, + userConfig.RestrictFileList, + userConfig.SilenceNoProjects, + userConfig.IncludeGitUntrackedFiles, + userConfig.AutoDiscoverMode, + scope, + terraformClient, + ) + baseRepo := models.Repo{Owner: "owner", Name: "repo", FullName: "owner/repo", VCSHost: models.VCSHost{Type: models.Github}} + ctx := &command.Context{ + Log: logger, + Scope: scope, + HeadRepo: baseRepo, + Pull: models.PullRequest{ + Num: 1, + BaseBranch: "main", + HeadBranch: "feature", + HeadCommit: "abc123", + BaseRepo: baseRepo, + }, + } + cmd := &events.CommentCommand{Name: command.Plan, RepoRelDir: "environments/prod", Workspace: events.DefaultWorkspace} + + Assert(t, builder.ShouldIgnoreTargetedDir(ctx, cmd), "expected authoritative missing repo config to allow global ignored-target skip") + workingDir.VerifyWasCalled(Never()).GetWorkingDir(Any[models.Repo](), Any[models.PullRequest](), Any[string]()) +} + +func TestDefaultProjectCommandBuilder_ShouldIgnoreTargetedDirUsesGlobalIgnoreWhenFileDownloadUnsupported(t *testing.T) { + RegisterMockTestingT(t) + + workingDir := mocks.NewMockWorkingDir() + When(workingDir.GetWorkingDir(Any[models.Repo](), Any[models.PullRequest](), Eq(events.DefaultWorkspace))).ThenReturn("", os.ErrNotExist) + vcsClient := vcsmocks.NewMockClient() + When(vcsClient.GetFileContent(Any[logging.SimpleLogging](), Any[models.Repo](), Eq("abc123"), Eq(valid.DefaultAtlantisFile))).ThenReturn(false, []byte{}, errors.New("not implemented")) + When(vcsClient.SupportsSingleFileDownload(Any[models.Repo]())).ThenReturn(false) + + logger := logging.NewNoopLogger(t) + scope := metricstest.NewLoggingScope(t, logger, "atlantis") + globalCfg := valid.NewGlobalCfgFromArgs(valid.GlobalCfgArgs{AllowAllRepoSettings: true}) + globalCfg.Repos[0].AutoDiscover = &valid.AutoDiscover{ + Mode: valid.AutoDiscoverEnabledMode, + IgnorePaths: []string{"environments/prod/**"}, + } + terraformClient := tfclientmocks.NewMockClient() + userConfig := defaultUserConfig + builder := events.NewProjectCommandBuilder( + false, + &config.ParserValidator{}, + &events.DefaultProjectFinder{}, + vcsClient, + workingDir, + events.NewDefaultWorkingDirLocker(), + globalCfg, + &events.DefaultPendingPlanFinder{}, + &events.CommentParser{ExecutableName: "atlantis"}, + userConfig.SkipCloneNoChanges, + userConfig.EnableRegExpCmd, + userConfig.EnableAutoMerge, + userConfig.EnableParallelPlan, + userConfig.EnableParallelApply, + userConfig.AutoDetectModuleFiles, + userConfig.AutoplanFileList, + userConfig.RestrictFileList, + userConfig.SilenceNoProjects, + userConfig.IncludeGitUntrackedFiles, + userConfig.AutoDiscoverMode, + scope, + terraformClient, + ) + baseRepo := models.Repo{Owner: "owner", Name: "repo", FullName: "owner/repo", VCSHost: models.VCSHost{Type: models.AzureDevops}} + ctx := &command.Context{ + Log: logger, + Scope: scope, + HeadRepo: baseRepo, + Pull: models.PullRequest{ + Num: 1, + BaseBranch: "main", + HeadBranch: "feature", + HeadCommit: "abc123", + BaseRepo: baseRepo, + }, + } + cmd := &events.CommentCommand{Name: command.Apply, RepoRelDir: "environments/prod", Workspace: events.DefaultWorkspace} + + Assert(t, builder.ShouldIgnoreTargetedDir(ctx, cmd), "expected global ignored-target skip when VCS file download is unsupported") + workingDir.VerifyWasCalledOnce().GetWorkingDir(Any[models.Repo](), Any[models.PullRequest](), Eq(events.DefaultWorkspace)) +} + +func TestDefaultProjectCommandBuilder_ShouldIgnoreTargetedDirFileDownloadUnsupportedPreservesExplicitLocalProject(t *testing.T) { + RegisterMockTestingT(t) + + atlantisYAML := "version: 3\n" + + "projects:\n" + + "- name: prod-project\n" + + " dir: environments/prod\n" + tmpDir := DirStructure(t, map[string]any{ + "environments": map[string]any{ + "prod": map[string]any{ + "main.tf": nil, + }, + }, + }) + Ok(t, os.WriteFile(filepath.Join(tmpDir, valid.DefaultAtlantisFile), []byte(atlantisYAML), 0600)) + + workingDir := mocks.NewMockWorkingDir() + When(workingDir.GetWorkingDir(Any[models.Repo](), Any[models.PullRequest](), Eq(events.DefaultWorkspace))).ThenReturn(tmpDir, nil) + vcsClient := vcsmocks.NewMockClient() + When(vcsClient.GetFileContent(Any[logging.SimpleLogging](), Any[models.Repo](), Eq("abc123"), Eq(valid.DefaultAtlantisFile))).ThenReturn(false, []byte{}, errors.New("not implemented")) + When(vcsClient.SupportsSingleFileDownload(Any[models.Repo]())).ThenReturn(false) + + logger := logging.NewNoopLogger(t) + scope := metricstest.NewLoggingScope(t, logger, "atlantis") + globalCfg := valid.NewGlobalCfgFromArgs(valid.GlobalCfgArgs{AllowAllRepoSettings: true}) + globalCfg.Repos[0].AutoDiscover = &valid.AutoDiscover{ + Mode: valid.AutoDiscoverEnabledMode, + IgnorePaths: []string{"environments/prod/**"}, + } + terraformClient := tfclientmocks.NewMockClient() + userConfig := defaultUserConfig + builder := events.NewProjectCommandBuilder( + false, + &config.ParserValidator{}, + &events.DefaultProjectFinder{}, + vcsClient, + workingDir, + events.NewDefaultWorkingDirLocker(), + globalCfg, + &events.DefaultPendingPlanFinder{}, + &events.CommentParser{ExecutableName: "atlantis"}, + userConfig.SkipCloneNoChanges, + userConfig.EnableRegExpCmd, + userConfig.EnableAutoMerge, + userConfig.EnableParallelPlan, + userConfig.EnableParallelApply, + userConfig.AutoDetectModuleFiles, + userConfig.AutoplanFileList, + userConfig.RestrictFileList, + userConfig.SilenceNoProjects, + userConfig.IncludeGitUntrackedFiles, + userConfig.AutoDiscoverMode, + scope, + terraformClient, + ) + baseRepo := models.Repo{Owner: "owner", Name: "repo", FullName: "owner/repo", VCSHost: models.VCSHost{Type: models.AzureDevops}} + ctx := &command.Context{ + Log: logger, + Scope: scope, + HeadRepo: baseRepo, + Pull: models.PullRequest{ + Num: 1, + BaseBranch: "main", + HeadBranch: "feature", + HeadCommit: "abc123", + BaseRepo: baseRepo, + }, + } + cmd := &events.CommentCommand{Name: command.Apply, RepoRelDir: "environments/prod", Workspace: events.DefaultWorkspace} + + Assert(t, !builder.ShouldIgnoreTargetedDir(ctx, cmd), "expected local explicit project to prevent ignored-target skip when file download is unsupported") + workingDir.VerifyWasCalledOnce().GetWorkingDir(Any[models.Repo](), Any[models.PullRequest](), Eq(events.DefaultWorkspace)) +} + +func TestDefaultProjectCommandBuilder_ShouldIgnoreTargetedDirFileDownloadUnsupportedFailsOpenForPlan(t *testing.T) { + RegisterMockTestingT(t) + + workingDir := mocks.NewMockWorkingDir() + vcsClient := vcsmocks.NewMockClient() + When(vcsClient.GetFileContent(Any[logging.SimpleLogging](), Any[models.Repo](), Eq("abc123"), Eq(valid.DefaultAtlantisFile))).ThenReturn(false, []byte{}, errors.New("not implemented")) + When(vcsClient.SupportsSingleFileDownload(Any[models.Repo]())).ThenReturn(false) + + logger := logging.NewNoopLogger(t) + scope := metricstest.NewLoggingScope(t, logger, "atlantis") + globalCfg := valid.NewGlobalCfgFromArgs(valid.GlobalCfgArgs{AllowAllRepoSettings: true}) + globalCfg.Repos[0].AutoDiscover = &valid.AutoDiscover{ + Mode: valid.AutoDiscoverEnabledMode, + IgnorePaths: []string{"environments/prod/**"}, + } + terraformClient := tfclientmocks.NewMockClient() + userConfig := defaultUserConfig + builder := events.NewProjectCommandBuilder( + false, + &config.ParserValidator{}, + &events.DefaultProjectFinder{}, + vcsClient, + workingDir, + events.NewDefaultWorkingDirLocker(), + globalCfg, + &events.DefaultPendingPlanFinder{}, + &events.CommentParser{ExecutableName: "atlantis"}, + userConfig.SkipCloneNoChanges, + userConfig.EnableRegExpCmd, + userConfig.EnableAutoMerge, + userConfig.EnableParallelPlan, + userConfig.EnableParallelApply, + userConfig.AutoDetectModuleFiles, + userConfig.AutoplanFileList, + userConfig.RestrictFileList, + userConfig.SilenceNoProjects, + userConfig.IncludeGitUntrackedFiles, + userConfig.AutoDiscoverMode, + scope, + terraformClient, + ) + baseRepo := models.Repo{Owner: "owner", Name: "repo", FullName: "owner/repo", VCSHost: models.VCSHost{Type: models.AzureDevops}} + ctx := &command.Context{ + Log: logger, + Scope: scope, + HeadRepo: baseRepo, + Pull: models.PullRequest{ + Num: 1, + BaseBranch: "main", + HeadBranch: "feature", + HeadCommit: "abc123", + BaseRepo: baseRepo, + }, + } + cmd := &events.CommentCommand{Name: command.Plan, RepoRelDir: "environments/prod", Workspace: events.DefaultWorkspace} + + Assert(t, !builder.ShouldIgnoreTargetedDir(ctx, cmd), "expected plan to avoid pre-clone skip when file download is unsupported") + workingDir.VerifyWasCalled(Never()).GetWorkingDir(Any[models.Repo](), Any[models.PullRequest](), Any[string]()) +} + +func TestDefaultProjectCommandBuilder_ShouldIgnoreTargetedDirMergeCheckoutWithLocalConfigFailsOpen(t *testing.T) { + RegisterMockTestingT(t) + + staleLocalAtlantisYAML := "version: 3\n" + + "autodiscover:\n" + + " mode: enabled\n" + + " ignore_paths:\n" + + " - \"environments/prod/**\"\n" + + logger := logging.NewNoopLogger(t) + scope := metricstest.NewLoggingScope(t, logger, "atlantis") + globalCfg := valid.NewGlobalCfgFromArgs(valid.GlobalCfgArgs{AllowAllRepoSettings: true}) + terraformClient := tfclientmocks.NewMockClient() + vcsClient := vcsmocks.NewMockClient() + + dataDir := t.TempDir() + workingDir := &events.FileWorkspace{ + DataDir: dataDir, + CheckoutMerge: true, + } + baseRepo := models.Repo{Owner: "owner", Name: "repo", FullName: "owner/repo", VCSHost: models.VCSHost{Type: models.Github}} + pull := models.PullRequest{ + Num: 1, + BaseBranch: "main", + HeadBranch: "feature", + BaseRepo: baseRepo, + } + repoDir := filepath.Join(dataDir, "repos", baseRepo.FullName, "1", events.DefaultWorkspace) + Ok(t, os.MkdirAll(repoDir, 0700)) + Ok(t, os.WriteFile(filepath.Join(repoDir, valid.DefaultAtlantisFile), []byte(staleLocalAtlantisYAML), 0600)) + + userConfig := defaultUserConfig + builder := events.NewProjectCommandBuilder( + false, + &config.ParserValidator{}, + &events.DefaultProjectFinder{}, + vcsClient, + workingDir, + events.NewDefaultWorkingDirLocker(), + globalCfg, + &events.DefaultPendingPlanFinder{}, + &events.CommentParser{ExecutableName: "atlantis"}, + userConfig.SkipCloneNoChanges, + userConfig.EnableRegExpCmd, + userConfig.EnableAutoMerge, + userConfig.EnableParallelPlan, + userConfig.EnableParallelApply, + userConfig.AutoDetectModuleFiles, + userConfig.AutoplanFileList, + userConfig.RestrictFileList, + userConfig.SilenceNoProjects, + userConfig.IncludeGitUntrackedFiles, + userConfig.AutoDiscoverMode, + scope, + terraformClient, + ) + ctx := &command.Context{ + Log: logger, + Scope: scope, + HeadRepo: baseRepo, + Pull: pull, + } + cmd := &events.CommentCommand{Name: command.Plan, RepoRelDir: "environments/prod", Workspace: events.DefaultWorkspace} + + Assert(t, !builder.ShouldIgnoreTargetedDir(ctx, cmd), "expected merge checkout to avoid pre-clone skip from stale local config") + vcsClient.VerifyWasCalled(Never()).GetFileContent(Any[logging.SimpleLogging](), Any[models.Repo](), Any[string](), Any[string]()) +} + +func TestDefaultProjectCommandBuilder_ShouldIgnoreTargetedDirMergeCheckoutWithoutLocalConfigFailsOpen(t *testing.T) { + RegisterMockTestingT(t) + + remoteAtlantisYAML := "version: 3\n" + + "autodiscover:\n" + + " mode: enabled\n" + + " ignore_paths:\n" + + " - \"environments/prod/**\"\n" + + logger := logging.NewNoopLogger(t) + scope := metricstest.NewLoggingScope(t, logger, "atlantis") + globalCfg := valid.NewGlobalCfgFromArgs(valid.GlobalCfgArgs{AllowAllRepoSettings: true}) + terraformClient := tfclientmocks.NewMockClient() + vcsClient := vcsmocks.NewMockClient() + When(vcsClient.GetFileContent(Any[logging.SimpleLogging](), Any[models.Repo](), Eq("feature"), Eq(valid.DefaultAtlantisFile))).ThenReturn(true, []byte(remoteAtlantisYAML), nil) + + workingDir := &events.FileWorkspace{ + DataDir: t.TempDir(), + CheckoutMerge: true, + } + userConfig := defaultUserConfig + builder := events.NewProjectCommandBuilder( + false, + &config.ParserValidator{}, + &events.DefaultProjectFinder{}, + vcsClient, + workingDir, + events.NewDefaultWorkingDirLocker(), + globalCfg, + &events.DefaultPendingPlanFinder{}, + &events.CommentParser{ExecutableName: "atlantis"}, + userConfig.SkipCloneNoChanges, + userConfig.EnableRegExpCmd, + userConfig.EnableAutoMerge, + userConfig.EnableParallelPlan, + userConfig.EnableParallelApply, + userConfig.AutoDetectModuleFiles, + userConfig.AutoplanFileList, + userConfig.RestrictFileList, + userConfig.SilenceNoProjects, + userConfig.IncludeGitUntrackedFiles, + userConfig.AutoDiscoverMode, + scope, + terraformClient, + ) + baseRepo := models.Repo{Owner: "owner", Name: "repo", FullName: "owner/repo", VCSHost: models.VCSHost{Type: models.Github}} + ctx := &command.Context{ + Log: logger, + Scope: scope, + HeadRepo: baseRepo, + Pull: models.PullRequest{ + Num: 1, + BaseBranch: "main", + HeadBranch: "feature", + BaseRepo: baseRepo, + }, + } + cmd := &events.CommentCommand{Name: command.Plan, RepoRelDir: "environments/prod", Workspace: events.DefaultWorkspace} + + Assert(t, !builder.ShouldIgnoreTargetedDir(ctx, cmd), "expected merge checkout without local config to avoid pre-clone skip") + vcsClient.VerifyWasCalled(Never()).GetFileContent(Any[logging.SimpleLogging](), Any[models.Repo](), Any[string](), Any[string]()) +} + +// Test that autodiscover.ignore_paths set in repo-level atlantis.yaml blocks +// targeted plan/apply -d commands for non-configured projects. +func TestDefaultProjectCommandBuilder_BuildTargetedCommand_IgnorePathsRepoCfg(t *testing.T) { + RegisterMockTestingT(t) + + atlantisYAML := ` +version: 3 +autodiscover: + mode: enabled + ignore_paths: + - "environments/prod/**" +` + tmpDir := DirStructure(t, map[string]any{ + "environments": map[string]any{ + "prod": map[string]any{ + "main.tf": nil, + }, + "nonprod": map[string]any{ + "main.tf": nil, + }, + }, + }) + err := os.WriteFile(filepath.Join(tmpDir, valid.DefaultAtlantisFile), []byte(atlantisYAML), 0600) + Ok(t, err) + + workingDir := mocks.NewMockWorkingDir() + When(workingDir.Clone(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), + Any[string]())).ThenReturn(tmpDir, nil) + When(workingDir.GetWorkingDir(Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn(tmpDir, nil) + vcsClient := vcsmocks.NewMockClient() + When(vcsClient.GetModifiedFiles(Any[logging.SimpleLogging](), Any[models.Repo](), + Any[models.PullRequest]())).ThenReturn([]string{"environments/prod/main.tf", "environments/nonprod/main.tf"}, nil) + + logger := logging.NewNoopLogger(t) + scope := metricstest.NewLoggingScope(t, logger, "atlantis") + + globalCfgArgs := valid.GlobalCfgArgs{AllowAllRepoSettings: true} + globalCfg := valid.NewGlobalCfgFromArgs(globalCfgArgs) + + terraformClient := tfclientmocks.NewMockClient() + userConfig := defaultUserConfig + + builder := events.NewProjectCommandBuilder( + false, + &config.ParserValidator{}, + &events.DefaultProjectFinder{}, + vcsClient, + workingDir, + events.NewDefaultWorkingDirLocker(), + globalCfg, + &events.DefaultPendingPlanFinder{}, + &events.CommentParser{ExecutableName: "atlantis"}, + userConfig.SkipCloneNoChanges, + userConfig.EnableRegExpCmd, + userConfig.EnableAutoMerge, + userConfig.EnableParallelPlan, + userConfig.EnableParallelApply, + userConfig.AutoDetectModuleFiles, + userConfig.AutoplanFileList, + userConfig.RestrictFileList, + userConfig.SilenceNoProjects, + userConfig.IncludeGitUntrackedFiles, + userConfig.AutoDiscoverMode, + scope, + terraformClient, + ) + + cmdCtx := &command.Context{Log: logger, Scope: scope} + + // Targeted plan -d to ignored path should return no projects + planCtxs, err := builder.BuildPlanCommands(cmdCtx, &events.CommentCommand{ + Name: command.Plan, + RepoRelDir: "environments/prod", + Workspace: "default", + }) + Assert(t, errors.Is(err, events.ErrIgnoredTargetedDir), "expected ignored targeted dir error, got %v", err) + Equals(t, 0, len(planCtxs)) + + // Non-ignored path should work + planCtxs, err = builder.BuildPlanCommands(cmdCtx, &events.CommentCommand{ + Name: command.Plan, + RepoRelDir: "environments/nonprod", + Workspace: "default", + }) + Ok(t, err) + Equals(t, 1, len(planCtxs)) + Equals(t, "environments/nonprod", planCtxs[0].RepoRelDir) + + // Targeted apply -d to ignored path should return no projects + applyCtxs, err := builder.BuildApplyCommands(cmdCtx, &events.CommentCommand{ + Name: command.Apply, + RepoRelDir: "environments/prod", + Workspace: "default", + }) + Assert(t, errors.Is(err, events.ErrIgnoredTargetedDir), "expected ignored targeted dir error, got %v", err) + Equals(t, 0, len(applyCtxs)) + + // Targeted apply -d to non-ignored path should work + applyCtxs, err = builder.BuildApplyCommands(cmdCtx, &events.CommentCommand{ + Name: command.Apply, + RepoRelDir: "environments/nonprod", + Workspace: "default", + }) + Ok(t, err) + Equals(t, 1, len(applyCtxs)) + Equals(t, "environments/nonprod", applyCtxs[0].RepoRelDir) +} + +// Test that targeted -d commands to a path with an explicit project config +// are NOT blocked by ignore_paths. +func TestDefaultProjectCommandBuilder_BuildTargetedCommand_IgnorePathsExplicitProjectAllowed(t *testing.T) { + RegisterMockTestingT(t) + + atlantisYAML := ` +version: 3 +autodiscover: + mode: enabled + ignore_paths: + - "environments/prod/**" +projects: +- name: prod-project + dir: environments/prod +` + tmpDir := DirStructure(t, map[string]any{ + "environments": map[string]any{ + "prod": map[string]any{ + "main.tf": nil, + }, + }, + }) + err := os.WriteFile(filepath.Join(tmpDir, valid.DefaultAtlantisFile), []byte(atlantisYAML), 0600) + Ok(t, err) + + workingDir := mocks.NewMockWorkingDir() + When(workingDir.Clone(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), + Any[string]())).ThenReturn(tmpDir, nil) + When(workingDir.GetWorkingDir(Any[models.Repo](), Any[models.PullRequest](), Any[string]())).ThenReturn(tmpDir, nil) + vcsClient := vcsmocks.NewMockClient() + When(vcsClient.GetModifiedFiles(Any[logging.SimpleLogging](), Any[models.Repo](), + Any[models.PullRequest]())).ThenReturn([]string{"environments/prod/main.tf"}, nil) + + logger := logging.NewNoopLogger(t) + scope := metricstest.NewLoggingScope(t, logger, "atlantis") + + globalCfgArgs := valid.GlobalCfgArgs{AllowAllRepoSettings: true} + globalCfg := valid.NewGlobalCfgFromArgs(globalCfgArgs) + + terraformClient := tfclientmocks.NewMockClient() + userConfig := defaultUserConfig + + builder := events.NewProjectCommandBuilder( + false, + &config.ParserValidator{}, + &events.DefaultProjectFinder{}, + vcsClient, + workingDir, + events.NewDefaultWorkingDirLocker(), + globalCfg, + &events.DefaultPendingPlanFinder{}, + &events.CommentParser{ExecutableName: "atlantis"}, + userConfig.SkipCloneNoChanges, + userConfig.EnableRegExpCmd, + userConfig.EnableAutoMerge, + userConfig.EnableParallelPlan, + userConfig.EnableParallelApply, + userConfig.AutoDetectModuleFiles, + userConfig.AutoplanFileList, + userConfig.RestrictFileList, + userConfig.SilenceNoProjects, + userConfig.IncludeGitUntrackedFiles, + userConfig.AutoDiscoverMode, + scope, + terraformClient, + ) + + cmdCtx := &command.Context{Log: logger, Scope: scope} + + // Targeted plan -d to ignored path with explicit config should succeed + planCtxs, err := builder.BuildPlanCommands(cmdCtx, &events.CommentCommand{ + Name: command.Plan, + RepoRelDir: "environments/prod", + Workspace: "default", + }) + Ok(t, err) + Equals(t, 1, len(planCtxs)) + Equals(t, "prod-project", planCtxs[0].ProjectName) + + // Targeted apply -d to ignored path with explicit config should succeed + applyCtxs, err := builder.BuildApplyCommands(cmdCtx, &events.CommentCommand{ + Name: command.Apply, + RepoRelDir: "environments/prod", + Workspace: "default", + }) + Ok(t, err) + Equals(t, 1, len(applyCtxs)) + Equals(t, "prod-project", applyCtxs[0].ProjectName) +} + // Test building a plan and apply command for one project // with the RestrictFileList func TestDefaultProjectCommandBuilder_BuildSinglePlanApplyCommand_WithRestrictFileList(t *testing.T) { @@ -692,6 +1753,8 @@ func TestDefaultProjectCommandBuilder_BuildSinglePlanApplyCommand_WithRestrictFi ModifiedFiles []string Cmd events.CommentCommand ExpErr string + ExpNoProjects bool + ExpSkipFileList bool }{ { Description: "planning a file outside of the changed files", @@ -728,6 +1791,32 @@ func TestDefaultProjectCommandBuilder_BuildSinglePlanApplyCommand_WithRestrictFi }, ModifiedFiles: []string{"directory-1/main.tf"}, }, + { + Description: "planning an ignored targeted dir outside of the changed files", + Cmd: events.CommentCommand{ + Name: command.Plan, + RepoRelDir: "ignored", + Workspace: "default", + }, + AtlantisYAML: ` +version: 3 +autodiscover: + mode: enabled + ignore_paths: + - ignored/** +`, + DirectoryStructure: map[string]any{ + "ignored": map[string]any{ + "main.tf": nil, + }, + "directory-2": map[string]any{ + "main.tf": nil, + }, + }, + ModifiedFiles: []string{"directory-2/main.tf"}, + ExpNoProjects: true, + ExpSkipFileList: true, + }, { Description: "planning a project outside of the requested changed files", Cmd: events.CommentCommand{ @@ -842,6 +1931,14 @@ projects: Scope: scope, }, &cmd) + if c.ExpNoProjects { + Assert(t, errors.Is(err, events.ErrIgnoredTargetedDir), "expected ignored targeted dir error, got %v", err) + Equals(t, 0, len(actCtxs)) + if c.ExpSkipFileList { + vcsClient.VerifyWasCalled(Never()).GetModifiedFiles(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest]()) + } + return + } if c.ExpErr != "" { ErrEquals(t, c.ExpErr, err) return diff --git a/server/events/state_command_runner.go b/server/events/state_command_runner.go index aaad6000c8..318203bc92 100644 --- a/server/events/state_command_runner.go +++ b/server/events/state_command_runner.go @@ -37,13 +37,23 @@ func (v *StateCommandRunner) Run(ctx *command.Context, cmd *CommentCommand) { Failure: fmt.Sprintf("unknown state subcommand %s", cmd.SubName), } } + if ctx.CommandSkipped { + return + } v.pullUpdater.updatePull(ctx, cmd, result) } func (v *StateCommandRunner) runRm(ctx *command.Context, cmd *CommentCommand) command.Result { projectCmds, err := v.prjCmdBuilder.BuildStateRmCommands(ctx, cmd) + if MarkCommandSkippedIfIgnoredTargetedDir(ctx, cmd.CommandName(), err) { + return command.Result{} + } if err != nil { ctx.Log.Warn("Error %s", err) } return runProjectCmds(projectCmds, v.prjCmdRunner.StateRm) } + +func (v *StateCommandRunner) ShouldSkipPreWorkflowHooks(ctx *command.Context, cmd *CommentCommand) bool { + return MarkCommandSkippedIfIgnoredTarget(ctx, cmd.CommandName(), cmd, v.prjCmdBuilder) +} diff --git a/server/events/version_command_runner.go b/server/events/version_command_runner.go index db9c4b32f6..e951fe7b47 100644 --- a/server/events/version_command_runner.go +++ b/server/events/version_command_runner.go @@ -37,6 +37,9 @@ func (v *VersionCommandRunner) Run(ctx *command.Context, cmd *CommentCommand) { var err error var projectCmds []command.ProjectContext projectCmds, err = v.prjCmdBuilder.BuildVersionCommands(ctx, cmd) + if MarkCommandSkippedIfIgnoredTargetedDir(ctx, cmd.CommandName(), err) { + return + } if err != nil { ctx.Log.Warn("Error %s", err) } @@ -58,6 +61,10 @@ func (v *VersionCommandRunner) Run(ctx *command.Context, cmd *CommentCommand) { v.pullUpdater.updatePull(ctx, cmd, result) } +func (v *VersionCommandRunner) ShouldSkipPreWorkflowHooks(ctx *command.Context, cmd *CommentCommand) bool { + return MarkCommandSkippedIfIgnoredTarget(ctx, cmd.CommandName(), cmd, v.prjCmdBuilder) +} + func (v *VersionCommandRunner) isParallelEnabled(cmds []command.ProjectContext) bool { return len(cmds) > 0 && cmds[0].ParallelPolicyCheckEnabled } diff --git a/server/events/working_dir.go b/server/events/working_dir.go index dd1ff13912..be84133a74 100644 --- a/server/events/working_dir.go +++ b/server/events/working_dir.go @@ -109,6 +109,10 @@ type FileWorkspace struct { CheckForUpstreamChanges bool } +func (w *FileWorkspace) CheckoutMergeEnabled() bool { + return w.CheckoutMerge +} + // Clone git clones headRepo, checks out the branch and then returns the absolute // path to the root of the cloned repo. // If the repo already exists and is at