From 786e57c1d6ff610404b728c32c04d8dd0ff0cc0c Mon Sep 17 00:00:00 2001 From: Koen van Zuijlen <8818390+kvanzuijlen@users.noreply.github.com> Date: Tue, 10 Oct 2023 17:02:26 +0200 Subject: [PATCH] feat: Added disable-unlock-label config option (#3799) * Added disable-unlock-label config option * Fixed tests * Wrote tests + fixed mistakes * Added docs * added defaults to docs --------- Co-authored-by: PePe Amengual --- cmd/server.go | 7 ++- cmd/server_test.go | 1 + runatlantis.io/docs/server-configuration.md | 8 +++ .../events/events_controller_e2e_test.go | 5 +- server/events/command_runner.go | 6 +- server/events/command_runner_test.go | 62 +++++++++++++++++++ server/events/unlock_command_runner.go | 41 +++++++++--- server/events/vcs/mocks/mock_client.go | 7 +++ server/server.go | 1 + server/user_config.go | 3 +- 10 files changed, 127 insertions(+), 14 deletions(-) diff --git a/cmd/server.go b/cmd/server.go index 5aeecbb909..d9e37a7c62 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -75,6 +75,7 @@ const ( DisableAutoplanLabelFlag = "disable-autoplan-label" DisableMarkdownFoldingFlag = "disable-markdown-folding" DisableRepoLockingFlag = "disable-repo-locking" + DisableUnlockLabelFlag = "disable-unlock-label" DiscardApprovalOnPlanFlag = "discard-approval-on-plan" EmojiReaction = "emoji-reaction" EnablePolicyChecksFlag = "enable-policy-checks" @@ -135,7 +136,7 @@ const ( RestrictFileList = "restrict-file-list" TFDownloadFlag = "tf-download" TFDownloadURLFlag = "tf-download-url" - UseTFPluginCache = "use-tf-plugin-cache" + UseTFPluginCache = "use-tf-plugin-cache" VarFileAllowlistFlag = "var-file-allowlist" VCSStatusName = "vcs-status-name" TFEHostnameFlag = "tfe-hostname" @@ -262,6 +263,10 @@ var stringFlags = map[string]stringFlag{ description: "Pull request label to disable atlantis auto planning feature only if present.", defaultValue: "", }, + DisableUnlockLabelFlag: { + description: "Pull request label to disable atlantis unlock feature only if present.", + defaultValue: "", + }, EmojiReaction: { description: "Emoji Reaction to use to react to comments", defaultValue: DefaultEmojiReaction, diff --git a/cmd/server_test.go b/cmd/server_test.go index b5bfba2574..3ed524df77 100644 --- a/cmd/server_test.go +++ b/cmd/server_test.go @@ -116,6 +116,7 @@ var testFlags = map[string]interface{}{ WriteGitCredsFlag: true, DisableAutoplanFlag: true, DisableAutoplanLabelFlag: "no-auto-plan", + DisableUnlockLabelFlag: "do-not-unlock", EnablePolicyChecksFlag: false, EnableRegExpCmdFlag: false, EnableDiffMarkdownFormat: false, diff --git a/runatlantis.io/docs/server-configuration.md b/runatlantis.io/docs/server-configuration.md index e486fbb99b..f87ec83144 100644 --- a/runatlantis.io/docs/server-configuration.md +++ b/runatlantis.io/docs/server-configuration.md @@ -381,6 +381,14 @@ and set `--autoplan-modules` to `false`. ``` Stops atlantis from locking projects and or workspaces when running terraform. +### `--disable-unlock-label` + ```bash + atlantis server --disable-unlock-label do-not-unlock + # or + ATLANTIS_DISABLE_UNLOCK_LABEL="do-not-unlock" + ``` + Stops atlantis from unlocking a pull request with this label. Defaults to "" (feature disabled). + ### `--emoji-reaction` ```bash atlantis server --emoji-reaction thumbsup diff --git a/server/controllers/events/events_controller_e2e_test.go b/server/controllers/events/events_controller_e2e_test.go index 14b1f02c0d..93c63df3bd 100644 --- a/server/controllers/events/events_controller_e2e_test.go +++ b/server/controllers/events/events_controller_e2e_test.go @@ -1275,6 +1275,8 @@ func setupE2E(t *testing.T, repoDir string, opt setupOption) (events_controllers parallelPoolSize := 1 silenceNoProjects := false + disableUnlockLabel := "do-not-unlock" + statusUpdater := runtimemocks.NewMockStatusUpdater() commitStatusUpdater := mocks.NewMockCommitStatusUpdater() asyncTfExec := runtimemocks.NewMockAsyncTFExec() @@ -1460,6 +1462,7 @@ func setupE2E(t *testing.T, repoDir string, opt setupOption) (events_controllers mocks.NewMockDeleteLockCommand(), e2eVCSClient, silenceNoProjects, + disableUnlockLabel, ) versionCommandRunner := events.NewVersionCommandRunner( @@ -1739,7 +1742,7 @@ func ensureRunningConftest(t *testing.T) { _, err := exec.LookPath(conftestCommand) if err != nil { t.Logf(`%s must be installed to run this test -- on local, please install contest command or run 'make docker/test-all' +- on local, please install conftest command or run 'make docker/test-all' - on CI, please check testing-env docker image contains conftest command. see testing/Dockerfile `, conftestCommand) t.FailNow() diff --git a/server/events/command_runner.go b/server/events/command_runner.go index 24d697392b..f339ad9129 100644 --- a/server/events/command_runner.go +++ b/server/events/command_runner.go @@ -98,9 +98,9 @@ type DefaultCommandRunner struct { AzureDevopsPullGetter AzureDevopsPullGetter GitlabMergeRequestGetter GitlabMergeRequestGetter // User config option: Disables autoplan when a pull request is opened or updated. - DisableAutoplan bool - DisableAutoplanLabel string - EventParser EventParsing + DisableAutoplan bool + DisableAutoplanLabel string + EventParser EventParsing // User config option: Fail and do not run the Atlantis command request if any of the pre workflow hooks error FailOnPreWorkflowHookError bool Logger logging.SimpleLogging diff --git a/server/events/command_runner_test.go b/server/events/command_runner_test.go index 59b6a6b21d..8f1a3a77c5 100644 --- a/server/events/command_runner_test.go +++ b/server/events/command_runner_test.go @@ -77,6 +77,7 @@ type TestConfig struct { StatusName string discardApprovalOnPlan bool backend locking.Backend + DisableUnlockLabel string } func setup(t *testing.T, options ...func(testConfig *TestConfig)) *vcsmocks.MockClient { @@ -93,6 +94,7 @@ func setup(t *testing.T, options ...func(testConfig *TestConfig)) *vcsmocks.Mock StatusName: "atlantis-test", discardApprovalOnPlan: false, backend: defaultBoltDB, + DisableUnlockLabel: "do-not-unlock", } for _, op := range options { @@ -195,6 +197,7 @@ func setup(t *testing.T, options ...func(testConfig *TestConfig)) *vcsmocks.Mock deleteLockCommand, vcsClient, testConfig.SilenceNoProjects, + testConfig.DisableUnlockLabel, ) versionCommandRunner := events.NewVersionCommandRunner( @@ -670,6 +673,65 @@ func TestRunUnlockCommandFail_VCSComment(t *testing.T) { vcsClient.VerifyWasCalledOnce().CreateComment(testdata.GithubRepo, testdata.Pull.Num, "Failed to delete PR locks", "unlock") } +func TestRunUnlockCommandFail_DisableUnlockLabel(t *testing.T) { + t.Log("if PR has label equal to disable-unlock-label unlock should fail") + + doNotUnlock := "do-not-unlock" + + vcsClient := setup(t) + pull := &github.PullRequest{ + State: github.String("open"), + } + modelPull := models.PullRequest{BaseRepo: testdata.GithubRepo, State: models.OpenPullState, Num: testdata.Pull.Num} + When(githubGetter.GetPullRequest(testdata.GithubRepo, testdata.Pull.Num)).ThenReturn(pull, nil) + When(eventParsing.ParseGithubPull(pull)).ThenReturn(modelPull, modelPull.BaseRepo, testdata.GithubRepo, nil) + When(deleteLockCommand.DeleteLocksByPull(testdata.GithubRepo.FullName, testdata.Pull.Num)).ThenReturn(0, errors.New("err")) + When(ch.VCSClient.GetPullLabels(testdata.GithubRepo, modelPull)).ThenReturn([]string{doNotUnlock, "need-help"}, nil) + + ch.RunCommentCommand(testdata.GithubRepo, &testdata.GithubRepo, nil, testdata.User, testdata.Pull.Num, &events.CommentCommand{Name: command.Unlock}) + + vcsClient.VerifyWasCalledOnce().CreateComment(testdata.GithubRepo, testdata.Pull.Num, "Not allowed to unlock PR with "+doNotUnlock+" label", "unlock") +} + +func TestRunUnlockCommandFail_GetLabelsFail(t *testing.T) { + t.Log("if GetPullLabels fails do not unlock PR") + + vcsClient := setup(t) + pull := &github.PullRequest{ + State: github.String("open"), + } + modelPull := models.PullRequest{BaseRepo: testdata.GithubRepo, State: models.OpenPullState, Num: testdata.Pull.Num} + When(githubGetter.GetPullRequest(testdata.GithubRepo, testdata.Pull.Num)).ThenReturn(pull, nil) + When(eventParsing.ParseGithubPull(pull)).ThenReturn(modelPull, modelPull.BaseRepo, testdata.GithubRepo, nil) + When(deleteLockCommand.DeleteLocksByPull(testdata.GithubRepo.FullName, testdata.Pull.Num)).ThenReturn(0, errors.New("err")) + When(ch.VCSClient.GetPullLabels(testdata.GithubRepo, modelPull)).ThenReturn(nil, errors.New("err")) + + ch.RunCommentCommand(testdata.GithubRepo, &testdata.GithubRepo, nil, testdata.User, testdata.Pull.Num, &events.CommentCommand{Name: command.Unlock}) + + vcsClient.VerifyWasCalledOnce().CreateComment(testdata.GithubRepo, testdata.Pull.Num, "Failed to retrieve PR labels... Not unlocking", "unlock") +} + +func TestRunUnlockCommandDoesntRetrieveLabelsIfDisableUnlockLabelNotSet(t *testing.T) { + t.Log("if disable-unlock-label is not set do not call GetPullLabels") + + doNotUnlock := "do-not-unlock" + + vcsClient := setup(t) + pull := &github.PullRequest{ + State: github.String("open"), + } + modelPull := models.PullRequest{BaseRepo: testdata.GithubRepo, State: models.OpenPullState, Num: testdata.Pull.Num} + When(githubGetter.GetPullRequest(testdata.GithubRepo, testdata.Pull.Num)).ThenReturn(pull, nil) + When(eventParsing.ParseGithubPull(pull)).ThenReturn(modelPull, modelPull.BaseRepo, testdata.GithubRepo, nil) + When(deleteLockCommand.DeleteLocksByPull(testdata.GithubRepo.FullName, testdata.Pull.Num)).ThenReturn(0, errors.New("err")) + When(ch.VCSClient.GetPullLabels(testdata.GithubRepo, modelPull)).ThenReturn([]string{doNotUnlock, "need-help"}, nil) + unlockCommandRunner.DisableUnlockLabel = "" + + ch.RunCommentCommand(testdata.GithubRepo, &testdata.GithubRepo, nil, testdata.User, testdata.Pull.Num, &events.CommentCommand{Name: command.Unlock}) + + vcsClient.VerifyWasNotCalled().GetPullLabels(testdata.GithubRepo, modelPull) +} + func TestRunAutoplanCommand_DeletePlans(t *testing.T) { setup(t) tmp := t.TempDir() diff --git a/server/events/unlock_command_runner.go b/server/events/unlock_command_runner.go index 012da284ee..648f829b9b 100644 --- a/server/events/unlock_command_runner.go +++ b/server/events/unlock_command_runner.go @@ -3,17 +3,20 @@ package events import ( "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/vcs" + "slices" ) func NewUnlockCommandRunner( deleteLockCommand DeleteLockCommand, vcsClient vcs.Client, SilenceNoProjects bool, + DisableUnlockLabel string, ) *UnlockCommandRunner { return &UnlockCommandRunner{ - deleteLockCommand: deleteLockCommand, - vcsClient: vcsClient, - SilenceNoProjects: SilenceNoProjects, + deleteLockCommand: deleteLockCommand, + vcsClient: vcsClient, + SilenceNoProjects: SilenceNoProjects, + DisableUnlockLabel: DisableUnlockLabel, } } @@ -22,7 +25,8 @@ type UnlockCommandRunner struct { deleteLockCommand DeleteLockCommand // SilenceNoProjects is whether Atlantis should respond to PRs if no projects // are found - SilenceNoProjects bool + SilenceNoProjects bool + DisableUnlockLabel string } func (u *UnlockCommandRunner) Run( @@ -31,13 +35,34 @@ func (u *UnlockCommandRunner) Run( ) { baseRepo := ctx.Pull.BaseRepo pullNum := ctx.Pull.Num + disableUnlockLabel := u.DisableUnlockLabel ctx.Log.Info("Unlocking all locks") vcsMessage := "All Atlantis locks for this PR have been unlocked and plans discarded" - numLocks, err := u.deleteLockCommand.DeleteLocksByPull(baseRepo.FullName, pullNum) - if err != nil { - vcsMessage = "Failed to delete PR locks" - ctx.Log.Err("failed to delete locks by pull %s", err.Error()) + + var hasLabel bool + var err error + if disableUnlockLabel != "" { + var labels []string + labels, err = u.vcsClient.GetPullLabels(baseRepo, ctx.Pull) + if err != nil { + vcsMessage = "Failed to retrieve PR labels... Not unlocking" + ctx.Log.Err("Failed to retrieve PR labels for pull %s", err.Error()) + } + hasLabel = slices.Contains(labels, disableUnlockLabel) + if hasLabel { + vcsMessage = "Not allowed to unlock PR with " + disableUnlockLabel + " label" + ctx.Log.Info("Not allowed to unlock PR with %v label", disableUnlockLabel) + } + } + + var numLocks int + if err == nil && !hasLabel { + numLocks, err = u.deleteLockCommand.DeleteLocksByPull(baseRepo.FullName, pullNum) + if err != nil { + vcsMessage = "Failed to delete PR locks" + ctx.Log.Err("failed to delete locks by pull %s", err.Error()) + } } // if there are no locks to delete, no errors, and SilenceNoProjects is enabled, don't comment diff --git a/server/events/vcs/mocks/mock_client.go b/server/events/vcs/mocks/mock_client.go index 7583e22fac..4150e5ffeb 100644 --- a/server/events/vcs/mocks/mock_client.go +++ b/server/events/vcs/mocks/mock_client.go @@ -286,6 +286,13 @@ func (mock *MockClient) UpdateStatus(repo models.Repo, pull models.PullRequest, return ret0 } +func (mock *MockClient) VerifyWasNotCalled() *VerifierMockClient { + return &VerifierMockClient{ + mock: mock, + invocationCountMatcher: pegomock.Times(0), + } +} + func (mock *MockClient) VerifyWasCalledOnce() *VerifierMockClient { return &VerifierMockClient{ mock: mock, diff --git a/server/server.go b/server/server.go index 1fbbce19f3..6f80cb420f 100644 --- a/server/server.go +++ b/server/server.go @@ -746,6 +746,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { deleteLockCommand, vcsClient, userConfig.SilenceNoProjects, + userConfig.DisableUnlockLabel, ) versionCommandRunner := events.NewVersionCommandRunner( diff --git a/server/user_config.go b/server/user_config.go index 81fb7bef7a..49e3ed6fba 100644 --- a/server/user_config.go +++ b/server/user_config.go @@ -37,6 +37,7 @@ type UserConfig struct { DisableAutoplanLabel string `mapstructure:"disable-autoplan-label"` DisableMarkdownFolding bool `mapstructure:"disable-markdown-folding"` DisableRepoLocking bool `mapstructure:"disable-repo-locking"` + DisableUnlockLabel string `mapstructure:"disable-unlock-label"` DiscardApprovalOnPlanFlag bool `mapstructure:"discard-approval-on-plan"` EmojiReaction string `mapstructure:"emoji-reaction"` EnablePolicyChecksFlag bool `mapstructure:"enable-policy-checks"` @@ -126,7 +127,7 @@ type UserConfig struct { WebPassword string `mapstructure:"web-password"` WriteGitCreds bool `mapstructure:"write-git-creds"` WebsocketCheckOrigin bool `mapstructure:"websocket-check-origin"` - UseTFPluginCache bool `mapstructure:"use-tf-plugin-cache"` + UseTFPluginCache bool `mapstructure:"use-tf-plugin-cache"` } // ToAllowCommandNames parse AllowCommands into a slice of CommandName