diff --git a/cmd/server.go b/cmd/server.go index e3367b999..a77f06708 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -28,6 +28,7 @@ import ( "github.com/runatlantis/atlantis/server" "github.com/runatlantis/atlantis/server/core/config/valid" + "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/vcs/bitbucketcloud" "github.com/runatlantis/atlantis/server/logging" ) @@ -43,6 +44,7 @@ const ( ADTokenFlag = "azuredevops-token" // nolint: gosec ADUserFlag = "azuredevops-user" ADHostnameFlag = "azuredevops-hostname" + AllowCommandsFlag = "allow-commands" AllowForkPRsFlag = "allow-fork-prs" AllowRepoConfigFlag = "allow-repo-config" AtlantisURLFlag = "atlantis-url" @@ -135,6 +137,7 @@ const ( DefaultADBasicPassword = "" DefaultADHostname = "dev.azure.com" DefaultAutoplanFileList = "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl" + DefaultAllowCommands = "version,plan,apply,unlock,approve_policies" DefaultCheckoutStrategy = "branch" DefaultBitbucketBaseURL = bitbucketcloud.BaseURL DefaultDataDir = "~/.atlantis" @@ -183,6 +186,10 @@ var stringFlags = map[string]stringFlag{ description: "Azure DevOps hostname to support cloud and self hosted instances.", defaultValue: "dev.azure.com", }, + AllowCommandsFlag: { + description: "Comma separated list of acceptable atlantis commands.", + defaultValue: DefaultAllowCommands, + }, AtlantisURLFlag: { description: "URL that Atlantis can be reached at. Defaults to http://$(hostname):$port where $port is from --" + PortFlag + ". Supports a base path ex. https://example.com/basepath.", }, @@ -751,6 +758,9 @@ func (s *ServerCmd) setDefaults(c *server.UserConfig) { if c.AutoplanFileList == "" { c.AutoplanFileList = DefaultAutoplanFileList } + if c.AllowCommands == "" { + c.AllowCommands = DefaultAllowCommands + } if c.CheckoutStrategy == "" { c.CheckoutStrategy = DefaultCheckoutStrategy } @@ -904,6 +914,10 @@ func (s *ServerCmd) validate(userConfig server.UserConfig) error { return errors.Wrapf(patternErr, "invalid pattern in --%s, %s", AutoplanFileListFlag, userConfig.AutoplanFileList) } + if _, err := userConfig.ToAllowCommandNames(); err != nil { + return errors.Wrapf(err, "invalid --%s", AllowCommandsFlag) + } + return nil } @@ -1015,6 +1029,16 @@ func (s *ServerCmd) deprecationWarnings(userConfig *server.UserConfig) error { deprecatedFlags = append(deprecatedFlags, RequireMergeableFlag) commandReqs = append(commandReqs, valid.MergeableCommandReq) } + if userConfig.DisableApply { + deprecatedFlags = append(deprecatedFlags, DisableApplyFlag) + var filtered []string + for _, allowCommand := range strings.Split(userConfig.AllowCommands, ",") { + if allowCommand != command.Apply.String() { + filtered = append(filtered, allowCommand) + } + } + userConfig.AllowCommands = strings.Join(filtered, ",") + } // Build up strings with what the recommended yaml and json config should // be instead of using the deprecated flags. diff --git a/cmd/server_test.go b/cmd/server_test.go index 4ea944ced..572242b7e 100644 --- a/cmd/server_test.go +++ b/cmd/server_test.go @@ -57,6 +57,7 @@ var testFlags = map[string]interface{}{ ADWebhookPasswordFlag: "ad-wh-pass", ADWebhookUserFlag: "ad-wh-user", AtlantisURLFlag: "url", + AllowCommandsFlag: "version,plan,unlock,import,approve_policies", // apply is disabled by DisableApply AllowForkPRsFlag: true, AllowRepoConfigFlag: true, AutomergeFlag: true, @@ -553,6 +554,36 @@ func TestExecute_ValidateVCSConfig(t *testing.T) { } } +func TestExecute_ValidateAllowCommands(t *testing.T) { + cases := []struct { + name string + allowCommandsFlag string + expErr string + }{ + { + name: "invalid allow commands", + allowCommandsFlag: "noallow", + expErr: "invalid --allow-commands: unknown command name: noallow", + }, + { + name: "success with empty allow commands", + allowCommandsFlag: "", + expErr: "", + }, + } + for _, testCase := range cases { + c := setupWithDefaults(map[string]interface{}{ + AllowCommandsFlag: testCase.allowCommandsFlag, + }, t) + err := c.Execute() + if testCase.expErr != "" { + ErrEquals(t, testCase.expErr, err) + } else { + Ok(t, err) + } + } +} + func TestExecute_ExpandHomeInDataDir(t *testing.T) { t.Log("If ~ is used as a data-dir path, should expand to absolute home path") c := setup(map[string]interface{}{ @@ -752,6 +783,16 @@ func TestExecute_BothSilenceAllowAndWhitelistErrors(t *testing.T) { ErrEquals(t, "both --silence-allowlist-errors and --silence-whitelist-errors cannot be set–use --silence-allowlist-errors", err) } +func TestExecute_DisableApplyDeprecation(t *testing.T) { + c := setupWithDefaults(map[string]interface{}{ + DisableApplyFlag: true, + AllowCommandsFlag: "plan,apply,unlock", + }, t) + err := c.Execute() + Ok(t, err) + Equals(t, "plan,unlock", passedConfig.AllowCommands) +} + // Test that we set the corresponding allow list values on the userConfig // struct if the deprecated whitelist flags are used. func TestExecute_RepoWhitelistDeprecation(t *testing.T) { diff --git a/runatlantis.io/docs/server-configuration.md b/runatlantis.io/docs/server-configuration.md index ba67914dc..33d2b020c 100644 --- a/runatlantis.io/docs/server-configuration.md +++ b/runatlantis.io/docs/server-configuration.md @@ -47,6 +47,19 @@ Values are chosen in this order: ## Flags +### `--allow-commands` + ```bash + atlantis server --allow-commands=version,plan,apply,unlock,approve_policies + # or + ATLANTIS_ALLOW_COMMANDS='version,plan,apply,unlock,approve_policies' + ``` + List of allowed commands to be run on the Atlantis server, Defaults to `version,plan,apply,unlock,approve_policies` + + Notes: + * Accepts a comma separated list, ex. `command1,command2`. + * `version`, `plan`, `apply`, `unlock`, `approve_policies`, `import` and `all` are available. + * `all` is a special keyword that allows all commands. If pass `all` then all other commands will be ignored. + ### `--allow-draft-prs` ```bash atlantis server --allow-draft-prs @@ -318,11 +331,14 @@ and set `--autoplan-modules` to `false`. if not in `PATH`. See [Terraform Versions](terraform-versions.html) for more details. ### `--disable-apply` + ```bash atlantis server --disable-apply # or ATLANTIS_DISABLE_APPLY=true ``` + Deprecated for `--allow-commands`. + Disable all `atlantis apply` commands, regardless of which flags are passed with it. ### `--disable-apply-all` diff --git a/runatlantis.io/docs/using-atlantis.md b/runatlantis.io/docs/using-atlantis.md index ca43da7c9..82d610d05 100644 --- a/runatlantis.io/docs/using-atlantis.md +++ b/runatlantis.io/docs/using-atlantis.md @@ -133,6 +133,8 @@ atlantis import [options] ADDRESS ID -- [terraform import flags] Runs `terraform import` that matches the directory/project/workspace. This command discards the terraform plan result. After an import and before an apply, another `atlantis plan` must be run again. +To allow the `import` command requires [--allow-commands](/docs/server-configuration.html#allow-commands) configuration. + ### Examples ```bash # Runs import diff --git a/server/controllers/events/events_controller_e2e_test.go b/server/controllers/events/events_controller_e2e_test.go index 5922c745d..0cd8dfa45 100644 --- a/server/controllers/events/events_controller_e2e_test.go +++ b/server/controllers/events/events_controller_e2e_test.go @@ -94,6 +94,8 @@ func TestGitHubWorkflow(t *testing.T) { DisableApply bool // ApplyLock creates an apply lock that temporarily disables apply command ApplyLock bool + // AllowCommands flag what kind of atlantis commands are available. + AllowCommands []command.Name // ExpAutomerge is true if we expect Atlantis to automerge. ExpAutomerge bool // ExpAutoplan is true if we expect Atlantis to autoplan. @@ -108,6 +110,10 @@ func TestGitHubWorkflow(t *testing.T) { // Atlantis writes to the pull request in order. A reply from a parallel operation // will be matched using a substring check. ExpReplies [][]string + // ExpAllowResponseCommentBack allow http response content with "Commenting back on pull request" + ExpAllowResponseCommentBack bool + // ExpParseFailedCount represents how many times test sends invalid commands + ExpParseFailedCount int }{ { Description: "simple", @@ -193,6 +199,19 @@ func TestGitHubWorkflow(t *testing.T) { {"exp-output-merge-workspaces.txt"}, }, }, + { + Description: "simple with allow commands", + RepoDir: "simple", + AllowCommands: []command.Name{command.Plan, command.Apply}, + Comments: []string{ + "atlantis import ADDRESS ID", + }, + ExpReplies: [][]string{ + {"exp-output-allow-command-unknown-import.txt"}, + }, + ExpAllowResponseCommentBack: true, + ExpParseFailedCount: 1, + }, { Description: "simple with atlantis.yaml", RepoDir: "simple-yaml", @@ -472,7 +491,8 @@ func TestGitHubWorkflow(t *testing.T) { userConfig = server.UserConfig{} userConfig.DisableApply = c.DisableApply - ctrl, vcsClient, githubGetter, atlantisWorkspace := setupE2E(t, c.RepoDir, c.RepoConfigFile) + opt := setupOption{repoConfigFile: c.RepoConfigFile, allowCommands: c.AllowCommands} + ctrl, vcsClient, githubGetter, atlantisWorkspace := setupE2E(t, c.RepoDir, opt) // Set the repo to be cloned through the testing backdoor. repoDir, headSHA := initializeRepo(t, c.RepoDir) atlantisWorkspace.TestingOverrideHeadCloneURL = fmt.Sprintf("file://%s", repoDir) @@ -497,7 +517,11 @@ func TestGitHubWorkflow(t *testing.T) { commentReq := GitHubCommentEvent(t, comment) w = httptest.NewRecorder() ctrl.Post(w, commentReq) - ResponseContains(t, w, 200, "Processing...") + if c.ExpAllowResponseCommentBack { + ResponseContains(t, w, 200, "Commenting back on pull request") + } else { + ResponseContains(t, w, 200, "Processing...") + } } // Send the "pull closed" event which would be triggered by the @@ -507,17 +531,17 @@ func TestGitHubWorkflow(t *testing.T) { ctrl.Post(w, pullClosedReq) ResponseContains(t, w, 200, "Pull request cleaned successfully") + expNumHooks := len(c.Comments) + 1 - c.ExpParseFailedCount // Let's verify the pre-workflow hook was called for each comment including the pull request opened event - mockPreWorkflowHookRunner.VerifyWasCalled(Times(len(c.Comments)+1)).Run(runtimematchers.AnyModelsWorkflowHookCommandContext(), EqString("some dummy command"), AnyString()) - + mockPreWorkflowHookRunner.VerifyWasCalled(Times(expNumHooks)).Run(runtimematchers.AnyModelsWorkflowHookCommandContext(), EqString("some dummy command"), AnyString()) // Let's verify the post-workflow hook was called for each comment including the pull request opened event - mockPostWorkflowHookRunner.VerifyWasCalled(Times(len(c.Comments)+1)).Run(runtimematchers.AnyModelsWorkflowHookCommandContext(), EqString("some post dummy command"), AnyString()) + mockPostWorkflowHookRunner.VerifyWasCalled(Times(expNumHooks)).Run(runtimematchers.AnyModelsWorkflowHookCommandContext(), EqString("some post dummy command"), AnyString()) // Now we're ready to verify Atlantis made all the comments back (or // replies) that we expect. We expect each plan to have 1 comment, // and apply have 1 for each comment plus one for the locks deleted at the // end. - expNumReplies := len(c.Comments) + 1 + expNumReplies := len(c.Comments) + 1 - c.ExpParseFailedCount if c.ExpAutoplan { expNumReplies++ @@ -543,7 +567,7 @@ func TestGitHubWorkflow(t *testing.T) { } } -func TestSimlpleWorkflow_terraformLockFile(t *testing.T) { +func TestSimpleWorkflow_terraformLockFile(t *testing.T) { if testing.Short() { t.SkipNow() @@ -621,7 +645,7 @@ func TestSimlpleWorkflow_terraformLockFile(t *testing.T) { userConfig = server.UserConfig{} userConfig.DisableApply = true - ctrl, vcsClient, githubGetter, atlantisWorkspace := setupE2E(t, c.RepoDir, "") + ctrl, vcsClient, githubGetter, atlantisWorkspace := setupE2E(t, c.RepoDir, setupOption{}) // Set the repo to be cloned through the testing backdoor. repoDir, headSHA := initializeRepo(t, c.RepoDir) @@ -864,7 +888,7 @@ func TestGitHubWorkflowWithPolicyCheck(t *testing.T) { userConfig.EnablePolicyChecksFlag = true userConfig.QuietPolicyChecks = c.ExpQuietPolicyChecks - ctrl, vcsClient, githubGetter, atlantisWorkspace := setupE2E(t, c.RepoDir, "") + ctrl, vcsClient, githubGetter, atlantisWorkspace := setupE2E(t, c.RepoDir, setupOption{}) // Set the repo to be cloned through the testing backdoor. repoDir, headSHA := initializeRepo(t, c.RepoDir) @@ -941,7 +965,12 @@ func TestGitHubWorkflowWithPolicyCheck(t *testing.T) { } } -func setupE2E(t *testing.T, repoDir, repoConfigFile string) (events_controllers.VCSEventsController, *vcsmocks.MockClient, *mocks.MockGithubPullGetter, *events.FileWorkspace) { +type setupOption struct { + repoConfigFile string + allowCommands []command.Name +} + +func setupE2E(t *testing.T, repoDir string, opt setupOption) (events_controllers.VCSEventsController, *vcsmocks.MockClient, *mocks.MockGithubPullGetter, *events.FileWorkspace) { allowForkPRs := false dataDir, binDir, cacheDir := mkSubDirs(t) @@ -961,10 +990,15 @@ func setupE2E(t *testing.T, repoDir, repoConfigFile string) (events_controllers. GitlabUser: "gitlab-user", GitlabToken: "gitlab-token", } + allowCommands := command.AllCommentCommands + if opt.allowCommands != nil { + allowCommands = opt.allowCommands + } commentParser := &events.CommentParser{ GithubUser: "github-user", GitlabUser: "gitlab-user", ExecutableName: "atlantis", + AllowCommands: allowCommands, } terraformClient, err := terraform.NewClient(logger, binDir, cacheDir, "", "", "", "default-tf-version", "https://releases.hashicorp.com", &NoopTFDownloader{}, true, false, projectCmdOutputHandler) Ok(t, err) @@ -989,7 +1023,7 @@ func setupE2E(t *testing.T, repoDir, repoConfigFile string) (events_controllers. parser := &config.ParserValidator{} globalCfgArgs := valid.GlobalCfgArgs{ - RepoConfigFile: repoConfigFile, + RepoConfigFile: opt.repoConfigFile, AllowRepoCfg: true, MergeableReq: false, ApprovedReq: false, diff --git a/server/controllers/events/testfixtures/test-repos/simple/exp-output-allow-command-unknown-import.txt b/server/controllers/events/testfixtures/test-repos/simple/exp-output-allow-command-unknown-import.txt new file mode 100644 index 000000000..9feb8168f --- /dev/null +++ b/server/controllers/events/testfixtures/test-repos/simple/exp-output-allow-command-unknown-import.txt @@ -0,0 +1,5 @@ +``` +Error: unknown command "import". +Run 'atlantis --help' for usage. +Available commands(--allow-commands): plan, apply +``` \ No newline at end of file diff --git a/server/events/command/name.go b/server/events/command/name.go index fcdcc5cd4..fd61864a4 100644 --- a/server/events/command/name.go +++ b/server/events/command/name.go @@ -1,6 +1,7 @@ package command import ( + "fmt" "strings" "golang.org/x/text/cases" @@ -30,6 +31,16 @@ const ( // Adding more? Don't forget to update String() below ) +// AllCommentCommands are list of commands that can be run from a comment. +var AllCommentCommands = []Name{ + Version, + Plan, + Apply, + Unlock, + ApprovePolicies, + Import, +} + // TitleString returns the string representation in title form. // ie. policy_check becomes Policy Check func (c Name) TitleString() string { @@ -66,3 +77,24 @@ func (c Name) DefaultUsage() string { return c.String() } } + +// ParseCommandName parses raw name into a command name. +func ParseCommandName(name string) (Name, error) { + switch name { + case "apply": + return Apply, nil + case "plan": + return Plan, nil + case "unlock": + return Unlock, nil + case "policy_check": + return PolicyCheck, nil + case "approve_policies": + return ApprovePolicies, nil + case "version": + return Version, nil + case "import": + return Import, nil + } + return -1, fmt.Errorf("unknown command name: %s", name) +} diff --git a/server/events/command/name_test.go b/server/events/command/name_test.go index 220810e39..56307e017 100644 --- a/server/events/command/name_test.go +++ b/server/events/command/name_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/runatlantis/atlantis/server/events/command" + "github.com/stretchr/testify/assert" ) func TestName_TitleString(t *testing.T) { @@ -66,3 +67,30 @@ func TestName_DefaultUsage(t *testing.T) { }) } } + +func TestParseCommandName(t *testing.T) { + tests := []struct { + exp command.Name + name string + }{ + {command.Apply, "apply"}, + {command.Plan, "plan"}, + {command.Unlock, "unlock"}, + {command.PolicyCheck, "policy_check"}, + {command.ApprovePolicies, "approve_policies"}, + {command.Version, "version"}, + {command.Import, "import"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := command.ParseCommandName(tt.name) + assert.NoError(t, err) + assert.Equal(t, tt.exp, got) + }) + } + + t.Run("unknown command", func(t *testing.T) { + _, err := command.ParseCommandName("unknown") + assert.ErrorContains(t, err, "unknown command name: unknown") + }) +} diff --git a/server/events/comment_parser.go b/server/events/comment_parser.go index 49396fb5b..3c64cc93f 100644 --- a/server/events/comment_parser.go +++ b/server/events/comment_parser.go @@ -74,8 +74,30 @@ type CommentParser struct { GitlabUser string BitbucketUser string AzureDevopsUser string - ApplyDisabled bool ExecutableName string + AllowCommands []command.Name +} + +// NewCommentParser returns a CommentParser +func NewCommentParser(githubUser, gitlabUser, bitbucketUser, azureDevopsUser, executableName string, allowCommands []command.Name) *CommentParser { + var commentAllowCommands []command.Name + for _, acceptableCommand := range command.AllCommentCommands { + for _, allowCommand := range allowCommands { + if acceptableCommand == allowCommand { + commentAllowCommands = append(commentAllowCommands, allowCommand) + break // for distinct + } + } + } + + return &CommentParser{ + GithubUser: githubUser, + GitlabUser: gitlabUser, + BitbucketUser: bitbucketUser, + AzureDevopsUser: azureDevopsUser, + ExecutableName: executableName, + AllowCommands: commentAllowCommands, + } } // CommentParseResult describes the result of parsing a comment as a command. @@ -160,18 +182,22 @@ func (e *CommentParser) Parse(rawComment string, vcsHost models.VCSHostType) Com // If they've just typed the name of the executable then give them the help // output. if len(args) == 1 { - return CommentParseResult{CommentResponse: e.HelpComment(e.ApplyDisabled)} + return CommentParseResult{CommentResponse: e.HelpComment()} } cmd := args[1] // Help output. if e.stringInSlice(cmd, []string{"help", "-h", "--help"}) { - return CommentParseResult{CommentResponse: e.HelpComment(e.ApplyDisabled)} + return CommentParseResult{CommentResponse: e.HelpComment()} } - // Need to have a plan, apply, approve_policy or unlock at this point. - if !e.stringInSlice(cmd, []string{command.Plan.String(), command.Apply.String(), command.Unlock.String(), command.ApprovePolicies.String(), command.Version.String(), command.Import.String()}) { - return CommentParseResult{CommentResponse: fmt.Sprintf("```\nError: unknown command %q.\nRun '%s --help' for usage.\n```", cmd, e.ExecutableName)} + // Need to have allow commands at this point. + if !e.isAllowedCommand(cmd) { + var allowCommandList []string + for _, allowCommand := range e.AllowCommands { + allowCommandList = append(allowCommandList, allowCommand.String()) + } + return CommentParseResult{CommentResponse: fmt.Sprintf("```\nError: unknown command %q.\nRun '%s --help' for usage.\nAvailable commands(--allow-commands): %s\n```", cmd, e.ExecutableName, strings.Join(allowCommandList, ", "))} } var workspace string @@ -374,24 +400,42 @@ func (e *CommentParser) stringInSlice(a string, list []string) bool { return false } +func (e *CommentParser) isAllowedCommand(cmd string) bool { + for _, allowed := range e.AllowCommands { + if allowed.String() == cmd { + return true + } + } + return false +} + func (e *CommentParser) errMarkdown(errMsg string, cmd string, flagSet *pflag.FlagSet) string { return fmt.Sprintf("```\nError: %s.\nUsage of %s:\n%s```", errMsg, cmd, flagSet.FlagUsagesWrapped(usagesCols)) } -func (e *CommentParser) HelpComment(applyDisabled bool) string { +func (e *CommentParser) HelpComment() string { buf := &bytes.Buffer{} var tmpl = template.Must(template.New("").Parse(helpCommentTemplate)) if err := tmpl.Execute(buf, struct { - ApplyDisabled bool - ExecutableName string + ExecutableName string + AllowVersion bool + AllowPlan bool + AllowApply bool + AllowUnlock bool + AllowApprovePolicies bool + AllowImport bool }{ - ApplyDisabled: applyDisabled, - ExecutableName: e.ExecutableName, + ExecutableName: e.ExecutableName, + AllowVersion: e.isAllowedCommand(command.Version.String()), + AllowPlan: e.isAllowedCommand(command.Plan.String()), + AllowApply: e.isAllowedCommand(command.Apply.String()), + AllowUnlock: e.isAllowedCommand(command.Unlock.String()), + AllowApprovePolicies: e.isAllowedCommand(command.ApprovePolicies.String()), + AllowImport: e.isAllowedCommand(command.Import.String()), }); err != nil { return fmt.Sprintf("Failed to render template, this is a bug: %v", err) } return buf.String() - } var helpCommentTemplate = "```cmake\n" + @@ -402,9 +446,14 @@ Usage: {{ .ExecutableName }} [options] -- [terraform options] Examples: + # show atlantis help + {{ .ExecutableName }} help +{{- if .AllowPlan }} + # run plan in the root directory passing the -target flag to terraform {{ .ExecutableName }} plan -d . -- -target=resource - {{- if not .ApplyDisabled }} +{{- end }} +{{- if .AllowApply }} # apply all unapplied plans from this pull request {{ .ExecutableName }} apply @@ -414,19 +463,29 @@ Examples: {{- end }} Commands: +{{- if .AllowPlan }} plan Runs 'terraform plan' for the changes in this pull request. To plan a specific project, use the -d, -w and -p flags. -{{- if not .ApplyDisabled }} +{{- end }} +{{- if .AllowApply }} apply Runs 'terraform apply' on all unapplied plans from this pull request. To only apply a specific plan, use the -d, -w and -p flags. {{- end }} +{{- if .AllowUnlock }} unlock Removes all atlantis locks and discards all plans for this PR. To unlock a specific plan you can use the Atlantis UI. +{{- end }} +{{- if .AllowApprovePolicies }} approve_policies Approves all current policy checking failures for the PR. +{{- end }} +{{- if .AllowVersion }} version Print the output of 'terraform version' +{{- end }} +{{- if .AllowImport }} import Runs 'terraform import' for the changes in this pull request. To plan a specific project, use the -d, -w and -p flags. +{{- end }} help View help. Flags: diff --git a/server/events/comment_parser_test.go b/server/events/comment_parser_test.go index 05742fc7b..c297d4a03 100644 --- a/server/events/comment_parser_test.go +++ b/server/events/comment_parser_test.go @@ -22,12 +22,61 @@ import ( "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" . "github.com/runatlantis/atlantis/testing" + "github.com/stretchr/testify/assert" ) var commentParser = events.CommentParser{ GithubUser: "github-user", GitlabUser: "gitlab-user", ExecutableName: "atlantis", + AllowCommands: []command.Name{ + command.Plan, + command.Apply, + command.Unlock, + command.ApprovePolicies, + command.Import, + }, +} + +func TestNewCommentParser(t *testing.T) { + type args struct { + githubUser string + gitlabUser string + bitbucketUser string + azureDevopsUser string + executableName string + allowCommands []command.Name + } + tests := []struct { + name string + args args + want *events.CommentParser + }{ + { + name: "duplicate allow commands filtered", + args: args{ + allowCommands: []command.Name{command.Plan, command.Plan, command.Plan}, + }, + want: &events.CommentParser{ + AllowCommands: []command.Name{command.Plan}, + }, + }, + { + name: "comment un-available commands filtered", + args: args{ + // PolicyCheck and Autoplan cannot be used on comment command, so filtered + allowCommands: []command.Name{command.Plan, command.Apply, command.Unlock, command.PolicyCheck, command.ApprovePolicies, command.Autoplan, command.Version, command.Import}, + }, + want: &events.CommentParser{ + AllowCommands: []command.Name{command.Version, command.Plan, command.Apply, command.Unlock, command.ApprovePolicies, command.Import}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equalf(t, tt.want, events.NewCommentParser(tt.args.githubUser, tt.args.gitlabUser, tt.args.bitbucketUser, tt.args.azureDevopsUser, tt.args.executableName, tt.args.allowCommands), "NewCommentParser(%v, %v, %v, %v, %v, %v)", tt.args.githubUser, tt.args.gitlabUser, tt.args.bitbucketUser, tt.args.azureDevopsUser, tt.args.executableName, tt.args.allowCommands) + }) + } } func TestParse_Ignored(t *testing.T) { @@ -70,6 +119,10 @@ func TestParse_ExecutableName(t *testing.T) { } func TestParse_HelpResponse(t *testing.T) { + allowCommandsCases := [][]command.Name{ + command.AllCommentCommands, + {}, // empty case + } helpComments := []string{ "run", "atlantis", @@ -80,27 +133,18 @@ func TestParse_HelpResponse(t *testing.T) { "atlantis help something else", "atlantis help plan", } - for _, c := range helpComments { - r := commentParser.Parse(c, models.Github) - Equals(t, commentParser.HelpComment(false), r.CommentResponse) - } -} - -func TestParse_HelpResponseWithApplyDisabled(t *testing.T) { - helpComments := []string{ - "run", - "atlantis", - "@github-user", - "atlantis help", - "atlantis --help", - "atlantis -h", - "atlantis help something else", - "atlantis help plan", - } - for _, c := range helpComments { - commentParser.ApplyDisabled = true - r := commentParser.Parse(c, models.Github) - Equals(t, commentParser.HelpComment(true), r.CommentResponse) + for _, allowCommandCase := range allowCommandsCases { + for _, c := range helpComments { + t.Run(fmt.Sprintf("%s with allow commands %v", c, allowCommandCase), func(t *testing.T) { + commentParser := events.CommentParser{ + GithubUser: "github-user", + ExecutableName: "atlantis", + AllowCommands: allowCommandCase, + } + r := commentParser.Parse(c, models.Github) + Equals(t, commentParser.HelpComment(), r.CommentResponse) + }) + } } } @@ -239,11 +283,24 @@ func TestParse_InvalidCommand(t *testing.T) { "atlantis Plan", "atlantis appely apply", } + cp := events.NewCommentParser( + "github-user", + "gitlab-user", + "bitbucket-user", + "azure-devops-user", + "atlantis", + []command.Name{ + command.Version, + command.Unlock, + command.Apply, + command.Plan, + command.Apply, // duplicate command is filtered + }, + ) for _, c := range comments { - r := commentParser.Parse(c, models.Github) - exp := fmt.Sprintf("```\nError: unknown command %q.\nRun 'atlantis --help' for usage.\n```", strings.Fields(c)[1]) - Assert(t, r.CommentResponse == exp, - "For comment %q expected CommentResponse==%q but got %q", c, exp, r.CommentResponse) + r := cp.Parse(c, models.Github) + exp := fmt.Sprintf("```\nError: unknown command %q.\nRun 'atlantis --help' for usage.\nAvailable commands(--allow-commands): version, plan, apply, unlock\n```", strings.Fields(c)[1]) + Equals(t, exp, r.CommentResponse) } } @@ -779,11 +836,13 @@ func TestBuildPlanApplyVersionComment(t *testing.T) { func TestCommentParser_HelpComment(t *testing.T) { cases := []struct { - applyDisabled bool + name string + allowCommands []command.Name expectResult string }{ { - applyDisabled: false, + name: "all commands allowed", + allowCommands: command.AllCommentCommands, expectResult: "```cmake\n" + `atlantis Terraform Pull Request Automation @@ -792,6 +851,9 @@ Usage: atlantis [options] -- [terraform options] Examples: + # show atlantis help + atlantis help + # run plan in the root directory passing the -target flag to terraform atlantis plan -d . -- -target=resource @@ -822,7 +884,8 @@ Use "atlantis [command] --help" for more information about a command.` + "\n```", }, { - applyDisabled: true, + name: "all commands disallowed", + allowCommands: []command.Name{}, expectResult: "```cmake\n" + `atlantis Terraform Pull Request Automation @@ -831,19 +894,46 @@ Usage: atlantis [options] -- [terraform options] Examples: - # run plan in the root directory passing the -target flag to terraform - atlantis plan -d . -- -target=resource + # show atlantis help + atlantis help Commands: - plan Runs 'terraform plan' for the changes in this pull request. - To plan a specific project, use the -d, -w and -p flags. + help View help. + +Flags: + -h, --help help for atlantis + +Use "atlantis [command] --help" for more information about a command.` + + "\n```", + }, + { + name: "partial commands allowed", + allowCommands: []command.Name{ + command.Apply, + command.Unlock, + }, + expectResult: "```cmake\n" + + `atlantis +Terraform Pull Request Automation + +Usage: + atlantis [options] -- [terraform options] + +Examples: + # show atlantis help + atlantis help + + # apply all unapplied plans from this pull request + atlantis apply + + # apply the plan for the root directory and staging workspace + atlantis apply -d . -w staging + +Commands: + apply Runs 'terraform apply' on all unapplied plans from this pull request. + To only apply a specific plan, use the -d, -w and -p flags. unlock Removes all atlantis locks and discards all plans for this PR. To unlock a specific plan you can use the Atlantis UI. - approve_policies - Approves all current policy checking failures for the PR. - version Print the output of 'terraform version' - import Runs 'terraform import' for the changes in this pull request. - To plan a specific project, use the -d, -w and -p flags. help View help. Flags: @@ -855,8 +945,12 @@ Use "atlantis [command] --help" for more information about a command.` + } for _, c := range cases { - t.Run(fmt.Sprintf("ApplyDisabled: %v", c.applyDisabled), func(t *testing.T) { - Equals(t, commentParser.HelpComment(c.applyDisabled), c.expectResult) + t.Run(c.name, func(t *testing.T) { + commentParser := events.CommentParser{ + ExecutableName: "atlantis", + AllowCommands: c.allowCommands, + } + Equals(t, commentParser.HelpComment(), c.expectResult) }) } } @@ -898,7 +992,7 @@ func TestParse_VCSUsername(t *testing.T) { for _, c := range cases { t.Run(c.vcs.String(), func(t *testing.T) { r := cp.Parse(fmt.Sprintf("@%s %s", c.user, "help"), c.vcs) - Equals(t, commentParser.HelpComment(false), r.CommentResponse) + Equals(t, cp.HelpComment(), r.CommentResponse) }) } } diff --git a/server/server.go b/server/server.go index 5b31b29ad..5907c5afb 100644 --- a/server/server.go +++ b/server/server.go @@ -35,6 +35,7 @@ import ( "github.com/mitchellh/go-homedir" "github.com/uber-go/tally" "github.com/uber-go/tally/prometheus" + "github.com/urfave/negroni/v3" cfg "github.com/runatlantis/atlantis/server/core/config" "github.com/runatlantis/atlantis/server/core/config/valid" @@ -47,8 +48,6 @@ import ( assetfs "github.com/elazarl/go-bindata-assetfs" "github.com/gorilla/mux" "github.com/pkg/errors" - "github.com/urfave/negroni/v3" - "github.com/runatlantis/atlantis/server/controllers" events_controllers "github.com/runatlantis/atlantis/server/controllers/events" "github.com/runatlantis/atlantis/server/controllers/templates" @@ -176,6 +175,18 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { policyChecksEnabled = true } + allowCommands, err := userConfig.ToAllowCommandNames() + if err != nil { + return nil, err + } + disableApply := true + for _, allowCommand := range allowCommands { + if allowCommand == command.Apply { + disableApply = false + break + } + } + validator := &cfg.ParserValidator{} globalCfg := valid.NewGlobalCfgFromArgs( @@ -405,7 +416,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { gitlabClient.SupportsCommonMark(), userConfig.DisableApplyAll, userConfig.DisableMarkdownFolding, - userConfig.DisableApply, + disableApply, userConfig.DisableRepoLocking, userConfig.EnableDiffMarkdownFormat, userConfig.MarkdownTemplateOverridesDir, @@ -438,7 +449,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { lockingClient = locking.NewClient(backend) } - applyLockingClient = locking.NewApplyClient(backend, userConfig.DisableApply) + applyLockingClient = locking.NewApplyClient(backend, disableApply) workingDirLocker := events.NewDefaultWorkingDirLocker() var workingDir events.WorkingDir = &events.FileWorkspace{ @@ -496,14 +507,14 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { AzureDevopsUser: userConfig.AzureDevopsUser, AzureDevopsToken: userConfig.AzureDevopsToken, } - commentParser := &events.CommentParser{ - GithubUser: userConfig.GithubUser, - GitlabUser: userConfig.GitlabUser, - BitbucketUser: userConfig.BitbucketUser, - AzureDevopsUser: userConfig.AzureDevopsUser, - ApplyDisabled: userConfig.DisableApply, - ExecutableName: userConfig.ExecutableName, - } + commentParser := events.NewCommentParser( + userConfig.GithubUser, + userConfig.GitlabUser, + userConfig.BitbucketUser, + userConfig.AzureDevopsUser, + userConfig.ExecutableName, + allowCommands, + ) defaultTfVersion := terraformClient.DefaultVersion() pendingPlanFinder := &events.DefaultPendingPlanFinder{} runStepRunner := &runtime.RunStepRunner{ @@ -820,7 +831,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { CommentParser: commentParser, Logger: logger, Scope: statsScope, - ApplyDisabled: userConfig.DisableApply, + ApplyDisabled: disableApply, GithubWebhookSecret: []byte(userConfig.GithubWebhookSecret), GithubRequestValidator: &events_controllers.DefaultGithubRequestValidator{}, GitlabRequestParserValidator: &events_controllers.DefaultGitlabRequestParserValidator{}, diff --git a/server/user_config.go b/server/user_config.go index 4203ca613..40da82501 100644 --- a/server/user_config.go +++ b/server/user_config.go @@ -1,6 +1,9 @@ package server import ( + "strings" + + "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/logging" ) @@ -10,6 +13,7 @@ import ( type UserConfig struct { AllowForkPRs bool `mapstructure:"allow-fork-prs"` AllowRepoConfig bool `mapstructure:"allow-repo-config"` + AllowCommands string `mapstructure:"allow-commands"` AtlantisURL string `mapstructure:"atlantis-url"` Automerge bool `mapstructure:"automerge"` AutoplanFileList string `mapstructure:"autoplan-file-list"` @@ -115,6 +119,30 @@ type UserConfig struct { WebsocketCheckOrigin bool `mapstructure:"websocket-check-origin"` } +// ToAllowCommandNames parse AllowCommands into a slice of CommandName +func (u UserConfig) ToAllowCommandNames() ([]command.Name, error) { + var allowCommands []command.Name + var hasAll bool + for _, input := range strings.Split(u.AllowCommands, ",") { + if input == "" { + continue + } + if input == "all" { + hasAll = true + continue + } + cmd, err := command.ParseCommandName(input) + if err != nil { + return nil, err + } + allowCommands = append(allowCommands, cmd) + } + if hasAll { + return command.AllCommentCommands, nil + } + return allowCommands, nil +} + // ToLogLevel returns the LogLevel object corresponding to the user-passed // log level. func (u UserConfig) ToLogLevel() logging.LogLevel { diff --git a/server/user_config_test.go b/server/user_config_test.go index ceeafeeda..32f7fdda7 100644 --- a/server/user_config_test.go +++ b/server/user_config_test.go @@ -4,10 +4,70 @@ import ( "testing" "github.com/runatlantis/atlantis/server" + "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" + "github.com/stretchr/testify/assert" ) +func TestUserConfig_ToAllowCommandNames(t *testing.T) { + tests := []struct { + name string + allowCommands string + want []command.Name + wantErr string + }{ + { + name: "full commands can be parsed by comma", + allowCommands: "apply,plan,unlock,policy_check,approve_policies,version,import", + want: []command.Name{ + command.Apply, command.Plan, command.Unlock, command.PolicyCheck, command.ApprovePolicies, command.Version, command.Import, + }, + }, + { + name: "all", + allowCommands: "all", + want: []command.Name{ + command.Version, command.Plan, command.Apply, command.Unlock, command.ApprovePolicies, command.Import, + }, + }, + { + name: "all with others returns same with all result", + allowCommands: "all,plan", + want: []command.Name{ + command.Version, command.Plan, command.Apply, command.Unlock, command.ApprovePolicies, command.Import, + }, + }, + { + name: "empty", + allowCommands: "", + want: nil, + }, + { + name: "invalid command", + allowCommands: "plan,all,invalid", + wantErr: "unknown command name: invalid", + }, + { + name: "invalid command", + allowCommands: "invalid,plan,all", + wantErr: "unknown command name: invalid", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + u := server.UserConfig{ + AllowCommands: tt.allowCommands, + } + got, err := u.ToAllowCommandNames() + if err != nil { + assert.ErrorContains(t, err, tt.wantErr, "ToAllowCommandNames()") + } + assert.Equalf(t, tt.want, got, "ToAllowCommandNames()") + }) + } +} + func TestUserConfig_ToLogLevel(t *testing.T) { cases := []struct { userLvl string