Implement branch matching in repo-level config (#2522)

This commit is contained in:
Anton Bronnikov
2022-11-09 07:08:20 +01:00
committed by GitHub
parent c9bc748ba2
commit 4cacaeb0c7
9 changed files with 181 additions and 18 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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