feat: --allow-commands restricts available atlantis commands (#2877)

* feat: --allow-command configuration restricts available atlantis commands

* add version and approve_policies into allow commands defaults

* allow-commands accept all keyword which allows all commands

* remove redundant nest

* more detail abount allow-commands all keyword

* Update server/events/comment_parser.go
This commit is contained in:
Ken Kaizu
2022-12-28 13:52:39 +09:00
committed by GitHub
parent 27b9897517
commit f09a9d4c01
13 changed files with 512 additions and 78 deletions

View File

@@ -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.

View File

@@ -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 setuse --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) {

View File

@@ -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`
<Badge text="Deprecated" type="warn"/>
```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`

View File

@@ -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

View File

@@ -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,

View File

@@ -0,0 +1,5 @@
```
Error: unknown command "import".
Run 'atlantis --help' for usage.
Available commands(--allow-commands): plan, apply
```

View File

@@ -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)
}

View File

@@ -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")
})
}

View File

@@ -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 }} <command> [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:

View File

@@ -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 <command> [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 <command> [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 <command> [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)
})
}
}

View File

@@ -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{},

View File

@@ -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 {

View File

@@ -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