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