diff --git a/runatlantis.io/docs/repo-level-atlantis-yaml.md b/runatlantis.io/docs/repo-level-atlantis-yaml.md index 991cc06ee7..d3dcf7ed0b 100644 --- a/runatlantis.io/docs/repo-level-atlantis-yaml.md +++ b/runatlantis.io/docs/repo-level-atlantis-yaml.md @@ -51,6 +51,7 @@ parallel_plan: true parallel_apply: true projects: - name: my-project-name + branch: /main/ dir: . workspace: default terraform_version: v0.11.0 @@ -214,7 +215,7 @@ projects: ``` With this config above, Atlantis runs planning/applying for project2 first, then for project1. Several projects can have same `execution_order_group`. Any order in one group isn't guaranteed. -`parallel_plan` and `parallel_apply` respect these order groups, so parallel planning/applying works +`parallel_plan` and `parallel_apply` respect these order groups, so parallel planning/applying works in each group one by one. ### Custom Backend Config @@ -242,6 +243,7 @@ allowed_regexp_prefixes: ### Project ```yaml name: myname +branch: /mybranch/ dir: mydir workspace: myworkspace execution_order_group: 0 @@ -255,7 +257,7 @@ workflow: myworkflow | Key | Type | Default | Required | Description | |----------------------------------------|-----------------------|-------------|----------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | name | string | none | maybe | Required if there is more than one project with the same `dir` and `workspace`. This project name can be used with the `-p` flag. | -| dir | string | none | **yes** | The directory of this project relative to the repo root. For example if the project was under `./project1` then use `project1`. Use `.` to indicate the repo root. | +| branch | string | none | no | Regex matching projects by the base branch of pull request (the branch the pull request is getting merged into). Only projects that match the PR's branch will be considered. By default, all branches are matched. || dir | string | none | **yes** | The directory of this project relative to the repo root. For example if the project was under `./project1` then use `project1`. Use `.` to indicate the repo root. | | workspace | string | `"default"` | no | The [Terraform workspace](https://www.terraform.io/docs/state/workspaces.html) for this project. Atlantis will switch to this workplace when planning/applying and will create it if it doesn't exist. | | execution_order_group | int | `0` | no | Index of execution order group. Projects will be sort by this field before planning/applying. | | delete_source_branch_on_merge | bool | `false` | no | Automatically deletes the source branch on merge. | diff --git a/server/core/config/parser_validator.go b/server/core/config/parser_validator.go index b5989baa95..4e23c1d4f8 100644 --- a/server/core/config/parser_validator.go +++ b/server/core/config/parser_validator.go @@ -43,7 +43,7 @@ func (p *ParserValidator) HasRepoCfg(absRepoDir string) (bool, error) { // ParseRepoCfg returns the parsed and validated atlantis.yaml config for the // repo at absRepoDir. // If there was no config file, it will return an os.IsNotExist(error). -func (p *ParserValidator) ParseRepoCfg(absRepoDir string, globalCfg valid.GlobalCfg, repoID string) (valid.RepoCfg, error) { +func (p *ParserValidator) ParseRepoCfg(absRepoDir string, globalCfg valid.GlobalCfg, repoID string, branch string) (valid.RepoCfg, error) { configFile := p.repoCfgPath(absRepoDir, AtlantisYAMLFilename) configData, err := os.ReadFile(configFile) // nolint: gosec @@ -55,10 +55,10 @@ func (p *ParserValidator) ParseRepoCfg(absRepoDir string, globalCfg valid.Global // able to detect if it's a NotExist err. return valid.RepoCfg{}, err } - return p.ParseRepoCfgData(configData, globalCfg, repoID) + return p.ParseRepoCfgData(configData, globalCfg, repoID, branch) } -func (p *ParserValidator) ParseRepoCfgData(repoCfgData []byte, globalCfg valid.GlobalCfg, repoID string) (valid.RepoCfg, error) { +func (p *ParserValidator) ParseRepoCfgData(repoCfgData []byte, globalCfg valid.GlobalCfg, repoID string, branch string) (valid.RepoCfg, error) { var rawConfig raw.RepoCfg if err := yaml.UnmarshalStrict(repoCfgData, &rawConfig); err != nil { return valid.RepoCfg{}, err @@ -72,6 +72,21 @@ func (p *ParserValidator) ParseRepoCfgData(repoCfgData []byte, globalCfg valid.G validConfig := rawConfig.ToValid() + // Filter the repo config's projects based on pull request's branch. Only + // keep projects that either: + // + // - Have no branch regex defined at all (i.e. match all branches), or + // - Those that have branch regex matching the PR's base branch. + // + i := 0 + for _, p := range validConfig.Projects { + if branch == "" || p.BranchRegex == nil || p.BranchRegex.Match([]byte(branch)) { + validConfig.Projects[i] = p + i++ + } + } + validConfig.Projects = validConfig.Projects[:i] + // We do the project name validation after we get the valid config because // we need the defaults of dir and workspace to be populated. if err := p.validateProjectNames(validConfig); err != nil { diff --git a/server/core/config/parser_validator_test.go b/server/core/config/parser_validator_test.go index 9fa0912fb0..25422c431c 100644 --- a/server/core/config/parser_validator_test.go +++ b/server/core/config/parser_validator_test.go @@ -52,7 +52,7 @@ func TestHasRepoCfg_InvalidFileExtension(t *testing.T) { func TestParseRepoCfg_DirDoesNotExist(t *testing.T) { r := config.ParserValidator{} - _, err := r.ParseRepoCfg("/not/exist", globalCfg, "") + _, err := r.ParseRepoCfg("/not/exist", globalCfg, "", "") Assert(t, os.IsNotExist(err), "exp not exist err") } @@ -60,7 +60,7 @@ func TestParseRepoCfg_FileDoesNotExist(t *testing.T) { tmpDir, cleanup := TempDir(t) defer cleanup() r := config.ParserValidator{} - _, err := r.ParseRepoCfg(tmpDir, globalCfg, "") + _, err := r.ParseRepoCfg(tmpDir, globalCfg, "", "") Assert(t, os.IsNotExist(err), "exp not exist err") } @@ -71,7 +71,7 @@ func TestParseRepoCfg_BadPermissions(t *testing.T) { Ok(t, err) r := config.ParserValidator{} - _, err = r.ParseRepoCfg(tmpDir, globalCfg, "") + _, err = r.ParseRepoCfg(tmpDir, globalCfg, "", "") ErrContains(t, "unable to read atlantis.yaml file: ", err) } @@ -105,7 +105,7 @@ func TestParseCfgs_InvalidYAML(t *testing.T) { err := os.WriteFile(confPath, []byte(c.input), 0600) Ok(t, err) r := config.ParserValidator{} - _, err = r.ParseRepoCfg(tmpDir, globalCfg, "") + _, err = r.ParseRepoCfg(tmpDir, globalCfg, "", "") ErrContains(t, c.expErr, err) globalCfgArgs := valid.GlobalCfgArgs{ AllowRepoCfg: false, @@ -1071,7 +1071,7 @@ workflows: Ok(t, err) r := config.ParserValidator{} - act, err := r.ParseRepoCfg(tmpDir, globalCfg, "") + act, err := r.ParseRepoCfg(tmpDir, globalCfg, "", "") if c.expErr != "" { ErrEquals(t, c.expErr, err) return @@ -1106,7 +1106,7 @@ workflows: UnDivergedReq: false, } - _, err = r.ParseRepoCfg(tmpDir, valid.NewGlobalCfgFromArgs(globalCfgArgs), "repo_id") + _, err = r.ParseRepoCfg(tmpDir, valid.NewGlobalCfgFromArgs(globalCfgArgs), "repo_id", "branch") ErrEquals(t, "repo config not allowed to set 'workflow' key: server-side config needs 'allowed_overrides: [workflow]'", err) } @@ -1756,7 +1756,7 @@ func TestParseRepoCfg_V2ShellParsing(t *testing.T) { ApprovedReq: false, UnDivergedReq: false, } - v2Cfg, err := p.ParseRepoCfg(v2Dir, valid.NewGlobalCfgFromArgs(globalCfgArgs), "") + v2Cfg, err := p.ParseRepoCfg(v2Dir, valid.NewGlobalCfgFromArgs(globalCfgArgs), "", "") if c.expV2Err != "" { ErrEquals(t, c.expV2Err, err) } else { @@ -1770,7 +1770,7 @@ func TestParseRepoCfg_V2ShellParsing(t *testing.T) { ApprovedReq: false, UnDivergedReq: false, } - v3Cfg, err := p.ParseRepoCfg(v3Dir, valid.NewGlobalCfgFromArgs(globalCfgArgs), "") + v3Cfg, err := p.ParseRepoCfg(v3Dir, valid.NewGlobalCfgFromArgs(globalCfgArgs), "", "") Ok(t, err) Equals(t, c.in, v3Cfg.Workflows["custom"].Plan.Steps[0].RunCommand) Equals(t, c.in, v3Cfg.Workflows["custom"].Apply.Steps[0].RunCommand) diff --git a/server/core/config/raw/global_cfg.go b/server/core/config/raw/global_cfg.go index 260b10c028..980596daf1 100644 --- a/server/core/config/raw/global_cfg.go +++ b/server/core/config/raw/global_cfg.go @@ -159,10 +159,14 @@ func (r Repo) Validate() error { branchValid := func(value interface{}) error { branch := value.(string) - if !r.HasRegexBranch() { + if branch == "" { return nil } - _, err := regexp.Compile(branch[1 : len(branch)-1]) + if !strings.HasPrefix(branch, "/") || !strings.HasSuffix(branch, "/") { + return errors.New("regex must begin and end with a slash '/'") + } + withoutSlashes := branch[1 : len(branch)-1] + _, err := regexp.Compile(withoutSlashes) return errors.Wrapf(err, "parsing: %s", branch) } diff --git a/server/core/config/raw/project.go b/server/core/config/raw/project.go index 13a0a1d4ce..cf2422f046 100644 --- a/server/core/config/raw/project.go +++ b/server/core/config/raw/project.go @@ -4,6 +4,7 @@ import ( "fmt" "net/url" "path/filepath" + "regexp" "strings" validation "github.com/go-ozzo/ozzo-validation" @@ -21,6 +22,7 @@ const ( type Project struct { Name *string `yaml:"name,omitempty"` + Branch *string `yaml:"branch,omitempty"` Dir *string `yaml:"dir,omitempty"` Workspace *string `yaml:"workspace,omitempty"` Workflow *string `yaml:"workflow,omitempty"` @@ -52,11 +54,27 @@ func (p Project) Validate() error { } return nil } + + branchValid := func(value interface{}) error { + strPtr := value.(*string) + if strPtr == nil { + return nil + } + branch := *strPtr + if !strings.HasPrefix(branch, "/") || !strings.HasSuffix(branch, "/") { + return errors.New("regex must begin and end with a slash '/'") + } + withoutSlashes := branch[1 : len(branch)-1] + _, err := regexp.Compile(withoutSlashes) + return errors.Wrapf(err, "parsing: %s", branch) + } + return validation.ValidateStruct(&p, validation.Field(&p.Dir, validation.Required, validation.By(hasDotDot)), validation.Field(&p.ApplyRequirements, validation.By(validApplyReq)), validation.Field(&p.TerraformVersion, validation.By(VersionValidator)), validation.Field(&p.Name, validation.By(validName)), + validation.Field(&p.Branch, validation.By(branchValid)), ) } @@ -68,6 +86,13 @@ func (p Project) ToValid() valid.Project { cleanedDir := filepath.Clean("./" + *p.Dir) v.Dir = cleanedDir + if p.Branch != nil { + branch := *p.Branch + withoutSlashes := branch[1 : len(branch)-1] + // Safe to use MustCompile because we test it in Validate(). + v.BranchRegex = regexp.MustCompile(withoutSlashes) + } + if p.Workspace == nil || *p.Workspace == "" { v.Workspace = DefaultWorkspace } else { diff --git a/server/core/config/raw/project_test.go b/server/core/config/raw/project_test.go index 7b63d3b505..1698a35678 100644 --- a/server/core/config/raw/project_test.go +++ b/server/core/config/raw/project_test.go @@ -28,12 +28,14 @@ func TestProject_UnmarshalYAML(t *testing.T) { Autoplan: nil, ApplyRequirements: nil, Name: nil, + Branch: nil, }, }, { description: "all fields set including mergeable apply requirement", input: ` name: myname +branch: mybranch dir: mydir workspace: workspace workflow: workflow @@ -46,6 +48,7 @@ apply_requirements: execution_order_group: 10`, exp: raw.Project{ Name: String("myname"), + Branch: String("mybranch"), Dir: String("mydir"), Workspace: String("workspace"), Workflow: String("workflow"), @@ -97,6 +100,22 @@ func TestProject_Validate(t *testing.T) { }, expErr: "dir: cannot contain '..'.", }, + { + description: "not a regexp for branch", + input: raw.Project{ + Branch: String("text"), + Dir: String("."), + }, + expErr: "branch: regex must begin and end with a slash '/'.", + }, + { + description: "invalid regexp for branch", + input: raw.Project{ + Branch: String("/(text/"), + Dir: String("."), + }, + expErr: "branch: parsing: /(text/: error parsing regexp: missing closing ): `(text`.", + }, { description: "apply reqs with unsupported", input: raw.Project{ @@ -261,6 +280,7 @@ func TestProject_ToValid(t *testing.T) { }, exp: valid.Project{ Dir: ".", + BranchRegex: nil, Workspace: "default", WorkflowName: nil, TerraformVersion: nil, diff --git a/server/core/config/valid/repo_cfg.go b/server/core/config/valid/repo_cfg.go index eab85c1fb3..f645fa9fbd 100644 --- a/server/core/config/valid/repo_cfg.go +++ b/server/core/config/valid/repo_cfg.go @@ -119,6 +119,7 @@ func (r RepoCfg) ValidateWorkspaceAllowed(repoRelDir string, workspace string) e type Project struct { Dir string + BranchRegex *regexp.Regexp Workspace string Name *string WorkflowName *string diff --git a/server/events/project_command_builder.go b/server/events/project_command_builder.go index ed5c576bbe..a8af5669e7 100644 --- a/server/events/project_command_builder.go +++ b/server/events/project_command_builder.go @@ -225,7 +225,7 @@ func (p *DefaultProjectCommandBuilder) buildPlanAllCommands(ctx *command.Context } if hasRepoCfg { - repoCfg, err := p.ParserValidator.ParseRepoCfgData(repoCfgData, p.GlobalCfg, ctx.Pull.BaseRepo.ID()) + repoCfg, err := p.ParserValidator.ParseRepoCfgData(repoCfgData, p.GlobalCfg, ctx.Pull.BaseRepo.ID(), ctx.Pull.BaseBranch) if err != nil { return nil, errors.Wrapf(err, "parsing %s", config.AtlantisYAMLFilename) } @@ -272,7 +272,7 @@ func (p *DefaultProjectCommandBuilder) buildPlanAllCommands(ctx *command.Context if hasRepoCfg { // If there's a repo cfg then we'll use it to figure out which projects // should be planed. - repoCfg, err := p.ParserValidator.ParseRepoCfg(repoDir, p.GlobalCfg, ctx.Pull.BaseRepo.ID()) + repoCfg, err := p.ParserValidator.ParseRepoCfg(repoDir, p.GlobalCfg, ctx.Pull.BaseRepo.ID(), ctx.Pull.BaseBranch) if err != nil { return nil, errors.Wrapf(err, "parsing %s", config.AtlantisYAMLFilename) } @@ -401,7 +401,7 @@ func (p *DefaultProjectCommandBuilder) getCfg(ctx *command.Context, projectName } var repoConfig valid.RepoCfg - repoConfig, err = p.ParserValidator.ParseRepoCfg(repoDir, p.GlobalCfg, ctx.Pull.BaseRepo.ID()) + repoConfig, err = p.ParserValidator.ParseRepoCfg(repoDir, p.GlobalCfg, ctx.Pull.BaseRepo.ID(), ctx.Pull.BaseBranch) if err != nil { return } diff --git a/server/events/repo_branch_test.go b/server/events/repo_branch_test.go new file mode 100644 index 0000000000..a0b9540de9 --- /dev/null +++ b/server/events/repo_branch_test.go @@ -0,0 +1,96 @@ +package events + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/runatlantis/atlantis/server/core/config" + "github.com/runatlantis/atlantis/server/core/config/valid" + "github.com/stretchr/testify/require" +) + +func TestRepoBranch(t *testing.T) { + globalYAML := `repos: + - id: github.com/foo/bar + branch: /release/.*/ + apply_requirements: [approved, mergeable] + allowed_overrides: [workflow] + allowed_workflows: [development, production] + allow_custom_workflows: true +workflows: + development: + plan: + steps: + - run: 'echo "Executing test workflow: terraform plan in ..."' + - init: + extra_args: ["-upgrade"] + - plan + apply: + steps: + - run: 'echo "Executing test workflow: terraform apply in ..."' + - apply + production: + plan: + steps: + - run: 'echo "Executing production workflow: terraform plan in ..."' + - init: + extra_args: ["-upgrade"] + - plan + apply: + steps: + - run: 'echo "Executing production workflow: terraform apply in ..."' + - apply +` + + repoYAML := `version: 3 +projects: + - name: development + branch: /main/ + dir: terraform/development + workflow: development + autoplan: + when_modified: + - "**/*" + - name: production + branch: /production/ + dir: terraform/production + workflow: production + autoplan: + when_modified: + - "**/*" +` + + tmp, err := ioutil.TempDir("", "") + require.NoError(t, err) + defer func() { + os.RemoveAll(tmp) + }() + + globalYAMLPath := filepath.Join(tmp, "config.yaml") + err = ioutil.WriteFile(globalYAMLPath, []byte(globalYAML), 0600) + require.NoError(t, err) + + globalCfgArgs := valid.GlobalCfgArgs{ + AllowRepoCfg: false, + MergeableReq: false, + ApprovedReq: false, + UnDivergedReq: false, + } + + parser := &config.ParserValidator{} + global, err := parser.ParseGlobalCfg(globalYAMLPath, valid.NewGlobalCfgFromArgs(globalCfgArgs)) + require.NoError(t, err) + + repoYAMLPath := filepath.Join(tmp, "atlantis.yaml") + err = ioutil.WriteFile(repoYAMLPath, []byte(repoYAML), 0600) + require.NoError(t, err) + + repo, err := parser.ParseRepoCfg(tmp, global, "github.com/foo/bar", "main") + require.NoError(t, err) + + require.Equal(t, 1, len(repo.Projects)) + + t.Logf("Projects: %+v", repo.Projects) +}