Skip to content

Commit

Permalink
Implement branch matching in repo-level config (#2522)
Browse files Browse the repository at this point in the history
  • Loading branch information
0x416e746f6e authored Nov 9, 2022
1 parent c9bc748 commit 4cacaeb
Show file tree
Hide file tree
Showing 9 changed files with 181 additions and 18 deletions.
6 changes: 4 additions & 2 deletions runatlantis.io/docs/repo-level-atlantis-yaml.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -242,6 +243,7 @@ allowed_regexp_prefixes:
### Project
```yaml
name: myname
branch: /mybranch/
dir: mydir
workspace: myworkspace
execution_order_group: 0
Expand All @@ -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. |
Expand Down
21 changes: 18 additions & 3 deletions server/core/config/parser_validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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 {
Expand Down
16 changes: 8 additions & 8 deletions server/core/config/parser_validator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,15 @@ 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")
}

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

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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand Down
8 changes: 6 additions & 2 deletions server/core/config/raw/global_cfg.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
25 changes: 25 additions & 0 deletions server/core/config/raw/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"net/url"
"path/filepath"
"regexp"
"strings"

validation "github.com/go-ozzo/ozzo-validation"
Expand All @@ -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"`
Expand Down Expand Up @@ -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)),
)
}

Expand All @@ -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 {
Expand Down
20 changes: 20 additions & 0 deletions server/core/config/raw/project_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"),
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -261,6 +280,7 @@ func TestProject_ToValid(t *testing.T) {
},
exp: valid.Project{
Dir: ".",
BranchRegex: nil,
Workspace: "default",
WorkflowName: nil,
TerraformVersion: nil,
Expand Down
1 change: 1 addition & 0 deletions server/core/config/valid/repo_cfg.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions server/events/project_command_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
}
Expand Down
Loading

0 comments on commit 4cacaeb

Please sign in to comment.