diff --git a/cmd/server.go b/cmd/server.go index 8550e985c6..0220e2247f 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -58,6 +58,7 @@ const ( DisableMarkdownFoldingFlag = "disable-markdown-folding" DisableRepoLockingFlag = "disable-repo-locking" EnablePolicyChecksFlag = "enable-policy-checks" + EnableRegExpCmdFlag = "enable-regexp-cmd" GHHostnameFlag = "gh-hostname" GHTokenFlag = "gh-token" GHUserFlag = "gh-user" @@ -300,6 +301,10 @@ var boolFlags = map[string]boolFlag{ description: "Enable atlantis to run user defined policy checks. This is explicitly disabled for TFE/TFC backends since plan files are inaccessible.", defaultValue: false, }, + EnableRegExpCmdFlag: { + description: "Enable Atlantis to use regular expressions on plan/apply commands when \"-p\" flag is passed with it.", + defaultValue: false, + }, AllowDraftPRs: { description: "Enable autoplan for Github Draft Pull Requests", defaultValue: false, diff --git a/cmd/server_test.go b/cmd/server_test.go index 27c846eb29..c98c5c0bd5 100644 --- a/cmd/server_test.go +++ b/cmd/server_test.go @@ -101,6 +101,7 @@ var testFlags = map[string]interface{}{ WriteGitCredsFlag: true, DisableAutoplanFlag: true, EnablePolicyChecksFlag: false, + EnableRegExpCmdFlag: false, } func TestExecute_Defaults(t *testing.T) { diff --git a/runatlantis.io/docs/server-configuration.md b/runatlantis.io/docs/server-configuration.md index e24a08dff4..8cf09da191 100644 --- a/runatlantis.io/docs/server-configuration.md +++ b/runatlantis.io/docs/server-configuration.md @@ -233,6 +233,17 @@ Values are chosen in this order: ``` Enables atlantis to run server side policies on the result of a terraform plan. Policies are defined in [server side repo config](https://www.runatlantis.io/docs/server-side-repo-config.html#reference). +* ### `--enable-regexp-cmd` + ```bash + atlantis server --enable-regexp-cmd + ``` + Enable Atlantis to use regular expressions on plan/apply commands when \"-p\" flag is passed with it. + + ::: warning SECURITY WARNING + It's not supposed to be used with `--disable-apply-all`. + The command `atlantis apply -p .*` will bypass the restriction and run apply on every projects + ::: + * ### `--gh-hostname` ```bash atlantis server --gh-hostname="my.github.enterprise.com" diff --git a/server/events/project_command_builder.go b/server/events/project_command_builder.go index 54ff76c962..a26f2e3b6f 100644 --- a/server/events/project_command_builder.go +++ b/server/events/project_command_builder.go @@ -38,6 +38,7 @@ func NewProjectCommandBuilder( pendingPlanFinder *DefaultPendingPlanFinder, commentBuilder CommentBuilder, skipCloneNoChanges bool, + EnableRegExpCmd bool, ) *DefaultProjectCommandBuilder { projectCommandBuilder := &DefaultProjectCommandBuilder{ ParserValidator: parserValidator, @@ -48,6 +49,7 @@ func NewProjectCommandBuilder( GlobalCfg: globalCfg, PendingPlanFinder: pendingPlanFinder, SkipCloneNoChanges: skipCloneNoChanges, + EnableRegExpCmd: EnableRegExpCmd, ProjectCommandContextBuilder: NewProjectCommandContextBulder( policyChecksSupported, commentBuilder, @@ -101,6 +103,7 @@ type DefaultProjectCommandBuilder struct { PendingPlanFinder *DefaultPendingPlanFinder ProjectCommandContextBuilder ProjectCommandContextBuilder SkipCloneNoChanges bool + EnableRegExpCmd bool } // See ProjectCommandBuilder.BuildAutoplanCommands. @@ -303,7 +306,7 @@ func (p *DefaultProjectCommandBuilder) buildProjectPlanCommand(ctx *CommandConte // getCfg returns the atlantis.yaml config (if it exists) for this project. If // there is no config, then projectCfg and repoCfg will be nil. -func (p *DefaultProjectCommandBuilder) getCfg(ctx *CommandContext, projectName string, dir string, workspace string, repoDir string) (projectCfg *valid.Project, repoCfg *valid.RepoCfg, err error) { +func (p *DefaultProjectCommandBuilder) getCfg(ctx *CommandContext, projectName string, dir string, workspace string, repoDir string) (projectsCfg []valid.Project, repoCfg *valid.RepoCfg, err error) { hasConfigFile, err := p.ParserValidator.HasRepoCfg(repoDir) if err != nil { err = errors.Wrapf(err, "looking for %s file in %q", yaml.AtlantisYAMLFilename, repoDir) @@ -327,8 +330,14 @@ func (p *DefaultProjectCommandBuilder) getCfg(ctx *CommandContext, projectName s // If they've specified a project by name we look it up. Otherwise we // use the dir and workspace. if projectName != "" { - projectCfg = repoCfg.FindProjectByName(projectName) - if projectCfg == nil { + if p.EnableRegExpCmd { + projectsCfg = repoCfg.FindProjectsByName(projectName) + } else { + if p := repoCfg.FindProjectByName(projectName); p != nil { + projectsCfg = append(projectsCfg, *p) + } + } + if len(projectsCfg) == 0 { err = fmt.Errorf("no project with name %q is defined in %s", projectName, yaml.AtlantisYAMLFilename) return } @@ -343,7 +352,7 @@ func (p *DefaultProjectCommandBuilder) getCfg(ctx *CommandContext, projectName s err = fmt.Errorf("must specify project name: more than one project defined in %s matched dir: %q workspace: %q", yaml.AtlantisYAMLFilename, dir, workspace) return } - projectCfg = &projCfgs[0] + projectsCfg = projCfgs return } @@ -418,7 +427,7 @@ func (p *DefaultProjectCommandBuilder) buildProjectApplyCommand(ctx *CommandCont ) } -// buildProjectCommandCtx builds a context for a single project identified +// buildProjectCommandCtx builds a context for a single or several projects identified // by the parameters. func (p *DefaultProjectCommandBuilder) buildProjectCommandCtx(ctx *CommandContext, cmd models.CommandName, @@ -429,47 +438,66 @@ func (p *DefaultProjectCommandBuilder) buildProjectCommandCtx(ctx *CommandContex workspace string, verbose bool) ([]models.ProjectCommandContext, error) { - projCfgPtr, repoCfgPtr, err := p.getCfg(ctx, projectName, repoRelDir, workspace, repoDir) + matchingProjects, repoCfgPtr, err := p.getCfg(ctx, projectName, repoRelDir, workspace, repoDir) if err != nil { return []models.ProjectCommandContext{}, err } - + var projCtxs []models.ProjectCommandContext var projCfg valid.MergedProjectCfg - if projCfgPtr != nil { + automerge := DefaultAutomergeEnabled + parallelApply := DefaultParallelApplyEnabled + parallelPlan := DefaultParallelPlanEnabled + if repoCfgPtr != nil { + automerge = repoCfgPtr.Automerge + parallelApply = repoCfgPtr.ParallelApply + parallelPlan = repoCfgPtr.ParallelPlan + } + + if len(matchingProjects) > 0 { // Override any dir/workspace defined on the comment with what was // defined in config. This shouldn't matter since we don't allow comments // with both project name and dir/workspace. repoRelDir = projCfg.RepoRelDir workspace = projCfg.Workspace - projCfg = p.GlobalCfg.MergeProjectCfg(ctx.Log, ctx.Pull.BaseRepo.ID(), *projCfgPtr, *repoCfgPtr) + for _, mp := range matchingProjects { + ctx.Log.Debug("Merging config for project at dir: %q workspace: %q", mp.Dir, mp.Workspace) + projCfg = p.GlobalCfg.MergeProjectCfg(ctx.Log, ctx.Pull.BaseRepo.ID(), mp, *repoCfgPtr) + + projCtxs = append(projCtxs, + p.ProjectCommandContextBuilder.BuildProjectContext( + ctx, + cmd, + projCfg, + commentFlags, + repoDir, + automerge, + parallelApply, + parallelPlan, + verbose, + )...) + } } else { projCfg = p.GlobalCfg.DefaultProjCfg(ctx.Log, ctx.Pull.BaseRepo.ID(), repoRelDir, workspace) + projCtxs = append(projCtxs, + p.ProjectCommandContextBuilder.BuildProjectContext( + ctx, + cmd, + projCfg, + commentFlags, + repoDir, + automerge, + parallelApply, + parallelPlan, + verbose, + )...) } if err := p.validateWorkspaceAllowed(repoCfgPtr, repoRelDir, workspace); err != nil { return []models.ProjectCommandContext{}, err } - automerge := DefaultAutomergeEnabled - parallelApply := DefaultParallelApplyEnabled - parallelPlan := DefaultParallelPlanEnabled - if repoCfgPtr != nil { - automerge = repoCfgPtr.Automerge - parallelApply = repoCfgPtr.ParallelApply - parallelPlan = repoCfgPtr.ParallelPlan - } + return projCtxs, nil - return p.ProjectCommandContextBuilder.BuildProjectContext( - ctx, - cmd, - projCfg, - commentFlags, - repoDir, - automerge, - parallelApply, - parallelPlan, - verbose, - ), nil } // validateWorkspaceAllowed returns an error if repoCfg defines projects in diff --git a/server/events/project_command_builder_internal_test.go b/server/events/project_command_builder_internal_test.go index f137f35591..3bb07fcc41 100644 --- a/server/events/project_command_builder_internal_test.go +++ b/server/events/project_command_builder_internal_test.go @@ -588,6 +588,7 @@ projects: &DefaultPendingPlanFinder{}, &CommentParser{}, false, + false, ) // We run a test for each type of command. @@ -640,6 +641,191 @@ projects: } } +func TestBuildProjectCmdCtx_WithRegExpCmdEnabled(t *testing.T) { + emptyPolicySets := valid.PolicySets{ + Version: nil, + PolicySets: []valid.PolicySet{}, + } + baseRepo := models.Repo{ + FullName: "owner/repo", + VCSHost: models.VCSHost{ + Hostname: "github.com", + }, + } + pull := models.PullRequest{ + BaseRepo: baseRepo, + } + cases := map[string]struct { + globalCfg string + repoCfg string + expErr string + expCtx models.ProjectCommandContext + expPlanSteps []string + expApplySteps []string + }{ + + // Test that if we've set global defaults, that they are used but the + // allowed project config values also come through. + "global defaults with repo cfg": { + globalCfg: ` +repos: +- id: /.*/ + workflow: default +workflows: + default: + plan: + steps: + - init + - plan + apply: + steps: + - apply`, + repoCfg: ` +version: 3 +automerge: true +projects: +- name: myproject_1 + dir: project1 + workspace: myworkspace + autoplan: + enabled: true + when_modified: [../modules/**/*.tf] + terraform_version: v10.0 +- name: myproject_2 + dir: project2 + workspace: myworkspace + autoplan: + enabled: true + when_modified: [../modules/**/*.tf] + terraform_version: v10.0 +- name: myproject_3 + dir: project3 + workspace: myworkspace + autoplan: + enabled: true + when_modified: [../modules/**/*.tf] + terraform_version: v10.0 + `, + expCtx: models.ProjectCommandContext{ + ApplyCmd: "atlantis apply -p myproject_1", + BaseRepo: baseRepo, + EscapedCommentArgs: []string{`\f\l\a\g`}, + AutomergeEnabled: true, + AutoplanEnabled: true, + HeadRepo: models.Repo{}, + Log: nil, + PullMergeable: true, + Pull: pull, + ProjectName: "myproject_1", + ApplyRequirements: []string{}, + RepoConfigVersion: 3, + RePlanCmd: "atlantis plan -p myproject_1 -- flag", + RepoRelDir: "project1", + TerraformVersion: mustVersion("10.0"), + User: models.User{}, + Verbose: true, + Workspace: "myworkspace", + PolicySets: emptyPolicySets, + }, + expPlanSteps: []string{"init", "plan"}, + expApplySteps: []string{"apply"}, + }, + } + + for name, c := range cases { + t.Run(name, func(t *testing.T) { + tmp, cleanup := DirStructure(t, map[string]interface{}{ + "project1": map[string]interface{}{ + "main.tf": nil, + }, + "modules": map[string]interface{}{ + "module": map[string]interface{}{ + "main.tf": nil, + }, + }, + }) + defer cleanup() + + workingDir := NewMockWorkingDir() + When(workingDir.Clone(matchers.AnyPtrToLoggingSimpleLogger(), matchers.AnyModelsRepo(), matchers.AnyModelsPullRequest(), AnyString())).ThenReturn(tmp, false, nil) + vcsClient := vcsmocks.NewMockClient() + When(vcsClient.GetModifiedFiles(matchers.AnyModelsRepo(), matchers.AnyModelsPullRequest())).ThenReturn([]string{"modules/module/main.tf"}, nil) + + // Write and parse the global config file. + globalCfgPath := filepath.Join(tmp, "global.yaml") + Ok(t, ioutil.WriteFile(globalCfgPath, []byte(c.globalCfg), 0600)) + parser := &yaml.ParserValidator{} + globalCfg, err := parser.ParseGlobalCfg(globalCfgPath, valid.NewGlobalCfg(false, false, false)) + Ok(t, err) + + if c.repoCfg != "" { + Ok(t, ioutil.WriteFile(filepath.Join(tmp, "atlantis.yaml"), []byte(c.repoCfg), 0600)) + } + + builder := NewProjectCommandBuilder( + false, + parser, + &DefaultProjectFinder{}, + vcsClient, + workingDir, + NewDefaultWorkingDirLocker(), + globalCfg, + &DefaultPendingPlanFinder{}, + &CommentParser{}, + false, + true, + ) + + // We run a test for each type of command, again specific projects + for _, cmd := range []models.CommandName{models.PlanCommand, models.ApplyCommand} { + t.Run(cmd.String(), func(t *testing.T) { + ctxs, err := builder.buildProjectCommandCtx(&CommandContext{ + Pull: models.PullRequest{ + BaseRepo: baseRepo, + }, + PullMergeable: true, + }, cmd, "myproject_[1-2]", []string{"flag"}, tmp, "project1", "myworkspace", true) + + if c.expErr != "" { + ErrEquals(t, c.expErr, err) + return + } + ctx := ctxs[0] + + Ok(t, err) + + Equals(t, 2, len(ctxs)) + // Construct expected steps. + var stepNames []string + switch cmd { + case models.PlanCommand: + stepNames = c.expPlanSteps + case models.ApplyCommand: + stepNames = c.expApplySteps + } + var expSteps []valid.Step + for _, stepName := range stepNames { + expSteps = append(expSteps, valid.Step{ + StepName: stepName, + }) + } + + c.expCtx.CommandName = cmd + // Init fields we couldn't in our cases map. + c.expCtx.Steps = expSteps + ctx.PolicySets = emptyPolicySets + Equals(t, c.expCtx, ctx) + // Equals() doesn't compare TF version properly so have to + // use .String(). + if c.expCtx.TerraformVersion != nil { + Equals(t, c.expCtx.TerraformVersion.String(), ctx.TerraformVersion.String()) + } + }) + } + }) + } +} + func TestBuildProjectCmdCtx_WithPolicCheckEnabled(t *testing.T) { emptyPolicySets := valid.PolicySets{ Version: nil, @@ -790,6 +976,7 @@ workflows: &DefaultPendingPlanFinder{}, &CommentParser{}, false, + false, ) cmd := models.PolicyCheckCommand diff --git a/server/events/project_command_builder_test.go b/server/events/project_command_builder_test.go index cd191a06fe..781e3a253e 100644 --- a/server/events/project_command_builder_test.go +++ b/server/events/project_command_builder_test.go @@ -145,6 +145,7 @@ projects: &events.DefaultPendingPlanFinder{}, &events.CommentParser{}, false, + false, ) ctxs, err := builder.BuildAutoplanCommands(&events.CommandContext{ @@ -370,6 +371,7 @@ projects: &events.DefaultPendingPlanFinder{}, &events.CommentParser{}, false, + true, ) var actCtxs []models.ProjectCommandContext @@ -506,6 +508,7 @@ projects: &events.DefaultPendingPlanFinder{}, &events.CommentParser{}, false, + false, ) ctxs, err := builder.BuildPlanCommands( @@ -580,6 +583,7 @@ func TestDefaultProjectCommandBuilder_BuildMultiApply(t *testing.T) { &events.DefaultPendingPlanFinder{}, &events.CommentParser{}, false, + false, ) ctxs, err := builder.BuildApplyCommands( @@ -649,6 +653,7 @@ projects: &events.DefaultPendingPlanFinder{}, &events.CommentParser{}, false, + false, ) ctx := &events.CommandContext{ @@ -713,6 +718,7 @@ func TestDefaultProjectCommandBuilder_EscapeArgs(t *testing.T) { &events.DefaultPendingPlanFinder{}, &events.CommentParser{}, false, + false, ) var actCtxs []models.ProjectCommandContext @@ -879,6 +885,7 @@ projects: &events.DefaultPendingPlanFinder{}, &events.CommentParser{}, false, + false, ) actCtxs, err := builder.BuildPlanCommands( @@ -929,6 +936,7 @@ projects: &events.DefaultPendingPlanFinder{}, &events.CommentParser{}, true, + false, ) var actCtxs []models.ProjectCommandContext @@ -969,6 +977,7 @@ func TestDefaultProjectCommandBuilder_WithPolicyCheckEnabled_BuildAutoplanComman &events.DefaultPendingPlanFinder{}, &events.CommentParser{}, false, + false, ) ctxs, err := builder.BuildAutoplanCommands(&events.CommandContext{ diff --git a/server/events/yaml/valid/repo_cfg.go b/server/events/yaml/valid/repo_cfg.go index 78234cd1e0..6af60141f7 100644 --- a/server/events/yaml/valid/repo_cfg.go +++ b/server/events/yaml/valid/repo_cfg.go @@ -4,6 +4,7 @@ package valid import ( "fmt" + "regexp" "strings" version "github.com/hashicorp/go-version" @@ -52,6 +53,20 @@ func (r RepoCfg) FindProjectByName(name string) *Project { return nil } +// FindProjectsByName returns all projects that match with name. +func (r RepoCfg) FindProjectsByName(name string) []Project { + var ps []Project + sanitizedName := "^" + name + "$" + for _, p := range r.Projects { + if p.Name != nil { + if match, _ := regexp.MatchString(sanitizedName, *p.Name); match { + ps = append(ps, p) + } + } + } + return ps +} + // validateWorkspaceAllowed returns an error if repoCfg defines projects in // repoRelDir but none of them use workspace. We want this to be an error // because if users have gone to the trouble of defining projects in repoRelDir diff --git a/server/events_controller_e2e_test.go b/server/events_controller_e2e_test.go index 861e5d272f..823cf5945a 100644 --- a/server/events_controller_e2e_test.go +++ b/server/events_controller_e2e_test.go @@ -640,6 +640,7 @@ func setupE2E(t *testing.T, repoDir string, policyChecksEnabled bool) (server.Ev &events.DefaultPendingPlanFinder{}, commentParser, false, + false, ) showStepRunner, err := runtime.NewShowStepRunner(terraformClient, defaultTFVersion) diff --git a/server/server.go b/server/server.go index dfcabfec4f..a0eb0e694c 100644 --- a/server/server.go +++ b/server/server.go @@ -412,6 +412,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { pendingPlanFinder, commentParser, userConfig.SkipCloneNoChanges, + userConfig.EnableRegExpCmd, ) showStepRunner, err := runtime.NewShowStepRunner(terraformClient, defaultTfVersion) diff --git a/server/user_config.go b/server/user_config.go index 81a3eeee86..c632c85fc4 100644 --- a/server/user_config.go +++ b/server/user_config.go @@ -28,6 +28,7 @@ type UserConfig struct { DisableMarkdownFolding bool `mapstructure:"disable-markdown-folding"` DisableRepoLocking bool `mapstructure:"disable-repo-locking"` EnablePolicyChecksFlag bool `mapstructure:"enable-policy-checks"` + EnableRegExpCmd bool `mapstructure:"enable-regexp-cmd"` GithubHostname string `mapstructure:"gh-hostname"` GithubToken string `mapstructure:"gh-token"` GithubUser string `mapstructure:"gh-user"`