diff --git a/cmd/server.go b/cmd/server.go index 3fc9ff1aaf..d41cbde4e9 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -46,6 +46,7 @@ const ( GitlabWebHookSecret = "gitlab-webhook-secret" LogLevelFlag = "log-level" PortFlag = "port" + RepoSubdirFlag = "repo-subdir" RepoWhitelistFlag = "repo-whitelist" RequireApprovalFlag = "require-approval" SSLCertFileFlag = "ssl-cert-file" @@ -127,6 +128,11 @@ var stringFlags = []stringFlag{ "The format is {hostname}/{owner}/{repo}, ex. github.com/runatlantis/atlantis. '*' matches any characters until the next comma and can be used for example to whitelist " + "all repos: '*' (not recommended), an entire hostname: 'internalgithub.com/*' or an organization: 'github.com/runatlantis/*'.", }, + { + name: RepoSubdirFlag, + description: "Optional subdirectory inside the repository. to filter the modifications only from this particular subdirectory. It supports regular expression syntax.", + defaultValue: "", + }, { name: SSLCertFileFlag, description: "File containing x509 Certificate used for serving HTTPS. If the cert is signed by a CA, the file should be the concatenation of the server's certificate, any intermediates, and the CA's certificate.", diff --git a/cmd/server_test.go b/cmd/server_test.go index 4a41f72060..7cb13cfd4a 100644 --- a/cmd/server_test.go +++ b/cmd/server_test.go @@ -372,6 +372,7 @@ func TestExecute_Flags(t *testing.T) { cmd.GitlabWebHookSecret: "gitlab-secret", cmd.LogLevelFlag: "debug", cmd.PortFlag: 8181, + cmd.RepoSubdirFlag: "/dir", cmd.RepoWhitelistFlag: "github.com/runatlantis/atlantis", cmd.RequireApprovalFlag: true, cmd.SSLCertFileFlag: "cert-file", @@ -393,6 +394,7 @@ func TestExecute_Flags(t *testing.T) { Equals(t, "gitlab-secret", passedConfig.GitlabWebHookSecret) Equals(t, "debug", passedConfig.LogLevel) Equals(t, 8181, passedConfig.Port) + Equals(t, "/dir", passedConfig.RepoSubdir) Equals(t, "github.com/runatlantis/atlantis", passedConfig.RepoWhitelist) Equals(t, true, passedConfig.RequireApproval) Equals(t, "cert-file", passedConfig.SSLCertFile) @@ -531,6 +533,7 @@ gitlab-user: "gitlab-user" gitlab-webhook-secret: "gitlab-secret" log-level: "debug" port: 8181 +repo-subdir: 8181 repo-whitelist: "github.com/runatlantis/atlantis" require-approval: true ssl-cert-file: cert-file @@ -552,6 +555,7 @@ ssl-key-file: key-file cmd.GitlabWebHookSecret: "override-gitlab-webhook-secret", cmd.LogLevelFlag: "info", cmd.PortFlag: 8282, + cmd.RepoSubdirFlag: "/override-dir", cmd.RepoWhitelistFlag: "override,override", cmd.RequireApprovalFlag: false, cmd.SSLCertFileFlag: "override-cert-file", @@ -572,6 +576,7 @@ ssl-key-file: key-file Equals(t, "override-gitlab-webhook-secret", passedConfig.GitlabWebHookSecret) Equals(t, "info", passedConfig.LogLevel) Equals(t, 8282, passedConfig.Port) + Equals(t, "/override-dir", passedConfig.RepoSubdir) Equals(t, "override,override", passedConfig.RepoWhitelist) Equals(t, false, passedConfig.RequireApproval) Equals(t, "override-cert-file", passedConfig.SSLCertFile) @@ -595,6 +600,7 @@ func TestExecute_FlagEnvVarOverride(t *testing.T) { "GITLAB_WEBHOOK_SECRET": "gitlab-webhook-secret", "LOG_LEVEL": "debug", "PORT": "8181", + "REPO_SUBDIR": "/dir", "REPO_WHITELIST": "*", "REQUIRE_APPROVAL": "true", "SSL_CERT_FILE": "cert-file", @@ -617,6 +623,7 @@ func TestExecute_FlagEnvVarOverride(t *testing.T) { cmd.GitlabWebHookSecret: "override-gitlab-webhook-secret", cmd.LogLevelFlag: "info", cmd.PortFlag: 8282, + cmd.RepoSubdirFlag: "/override-dir", cmd.RepoWhitelistFlag: "override,override", cmd.RequireApprovalFlag: false, cmd.SSLCertFileFlag: "override-cert-file", @@ -638,6 +645,7 @@ func TestExecute_FlagEnvVarOverride(t *testing.T) { Equals(t, "override-gitlab-webhook-secret", passedConfig.GitlabWebHookSecret) Equals(t, "info", passedConfig.LogLevel) Equals(t, 8282, passedConfig.Port) + Equals(t, "/override-dir", passedConfig.RepoSubdir) Equals(t, "override,override", passedConfig.RepoWhitelist) Equals(t, false, passedConfig.RequireApproval) Equals(t, "override-cert-file", passedConfig.SSLCertFile) diff --git a/server/events/plan_executor.go b/server/events/plan_executor.go index 478de10a22..5366353957 100644 --- a/server/events/plan_executor.go +++ b/server/events/plan_executor.go @@ -45,6 +45,7 @@ type PlanExecutor struct { Terraform terraform.Client Locker locking.Locker LockURL func(id string) (url string) + RepoSubdir string Run run.Runner Workspace AtlantisWorkspace ProjectPreExecute ProjectPreExecutor @@ -79,7 +80,7 @@ func (p *PlanExecutor) Execute(ctx *CommandContext) CommandResponse { return CommandResponse{Error: errors.Wrap(err, "getting modified files")} } ctx.Log.Info("found %d files modified in this pull request", len(modifiedFiles)) - projects = p.ProjectFinder.DetermineProjects(ctx.Log, modifiedFiles, ctx.BaseRepo.FullName, cloneDir) + projects = p.ProjectFinder.DetermineProjects(ctx.Log, modifiedFiles, p.RepoSubdir, ctx.BaseRepo.FullName, cloneDir) if len(projects) == 0 { return CommandResponse{Failure: "No Terraform files were modified."} } diff --git a/server/events/project_finder.go b/server/events/project_finder.go index e14c3b7e72..e34e00bb67 100644 --- a/server/events/project_finder.go +++ b/server/events/project_finder.go @@ -17,6 +17,7 @@ import ( "os" "path" "path/filepath" + "regexp" "strings" "github.com/runatlantis/atlantis/server/events/models" @@ -29,7 +30,7 @@ import ( type ProjectFinder interface { // DetermineProjects returns the list of projects that were modified based on // the modifiedFiles. The list will be de-duplicated. - DetermineProjects(log *logging.SimpleLogger, modifiedFiles []string, repoFullName string, repoDir string) []models.Project + DetermineProjects(log *logging.SimpleLogger, modifiedFiles []string, restrictSubDir string, repoFullName string, repoDir string) []models.Project } // DefaultProjectFinder implements ProjectFinder. @@ -39,10 +40,10 @@ var excludeList = []string{"terraform.tfstate", "terraform.tfstate.backup"} // DetermineProjects returns the list of projects that were modified based on // the modifiedFiles. The list will be de-duplicated. -func (p *DefaultProjectFinder) DetermineProjects(log *logging.SimpleLogger, modifiedFiles []string, repoFullName string, repoDir string) []models.Project { +func (p *DefaultProjectFinder) DetermineProjects(log *logging.SimpleLogger, modifiedFiles []string, restrictSubDir string, repoFullName string, repoDir string) []models.Project { var projects []models.Project - modifiedTerraformFiles := p.filterToTerraform(modifiedFiles) + modifiedTerraformFiles := p.filterToTerraform(modifiedFiles, restrictSubDir) if len(modifiedTerraformFiles) == 0 { return projects } @@ -65,11 +66,13 @@ func (p *DefaultProjectFinder) DetermineProjects(log *logging.SimpleLogger, modi return projects } -func (p *DefaultProjectFinder) filterToTerraform(files []string) []string { +func (p *DefaultProjectFinder) filterToTerraform(files []string, restrictSubDir string) []string { var filtered []string for _, fileName := range files { - if !p.isInExcludeList(fileName) && strings.Contains(fileName, ".tf") { - filtered = append(filtered, fileName) + if matched, err := regexp.MatchString(restrictSubDir, fileName); matched && err == nil { + if !p.isInExcludeList(fileName) && strings.Contains(fileName, ".tf") { + filtered = append(filtered, fileName) + } } } return filtered diff --git a/server/events/project_finder_test.go b/server/events/project_finder_test.go index dd9af7792f..97a638f3a0 100644 --- a/server/events/project_finder_test.go +++ b/server/events/project_finder_test.go @@ -81,89 +81,110 @@ func TestDetermineProjects(t *testing.T) { files []string expProjectPaths []string repoDir string + restricPath string }{ { "If no files were modified then should return an empty list", nil, nil, "", + "", }, { "Should ignore non .tf files and return an empty list", []string{"non-tf"}, nil, "", + "", }, { "Should plan in the parent directory from modules if that dir has a main.tf", []string{"project1/modules/main.tf"}, []string{"project1"}, nestedModules1, + "", }, { "Should plan in the parent directory from modules if that dir has a main.tf", []string{"modules/main.tf"}, []string{"."}, nestedModules2, + "", }, { "Should plan in the parent directory from modules when module is in a subdir if that dir has a main.tf", []string{"modules/subdir/main.tf"}, []string{"."}, nestedModules2, + "", }, { "Should not plan in the parent directory from modules if that dir does not have a main.tf", []string{"modules/main.tf"}, []string{}, topLevelModules, + "", }, { "Should not plan in the parent directory from modules if that dir does not have a main.tf", []string{"modules/main.tf", "project1/main.tf"}, []string{"project1"}, topLevelModules, + "", }, { "Should ignore tfstate files and return an empty list", []string{"terraform.tfstate", "terraform.tfstate.backup", "parent/terraform.tfstate", "parent/terraform.tfstate.backup"}, nil, "", + "", }, { "Should ignore tfstate files and return an empty list", []string{"terraform.tfstate", "terraform.tfstate.backup", "parent/terraform.tfstate", "parent/terraform.tfstate.backup"}, nil, "", + "", }, { "Should return '.' when changed file is at root", []string{"a.tf"}, []string{"."}, "", + "", }, { "Should return directory when changed file is in a dir", []string{"parent/a.tf"}, []string{"parent"}, "", + "", }, { "Should return parent dir when changed file is in an env/ dir", []string{"env/a.tfvars"}, []string{"."}, "", + "", }, { "Should de-duplicate when multiple files changed in the same dir", []string{"root.tf", "env/env.tfvars", "parent/parent.tf", "parent/parent2.tf", "parent/child/child.tf", "parent/child/env/env.tfvars"}, []string{".", "parent", "parent/child"}, "", + "", + }, + { + "Should de-duplicate when multiple files changed in the same dir", + []string{"outscope/main.tf", "filter/child/main.tf", "filter/child1/main.tf", "filter/child2/main.tf", "filter/childout/main.tf"}, + []string{"filter/child1", "filter/child2"}, + "", + "filter/(child1)|(child2)", }, } for _, c := range cases { t.Log(c.description) - projects := m.DetermineProjects(noopLogger, c.files, modifiedRepo, c.repoDir) + projects := m.DetermineProjects(noopLogger, c.files, c.restricPath, modifiedRepo, c.repoDir) // Extract the paths from the projects. We use a slice here instead of a // map so we can test whether there are duplicates returned. diff --git a/server/server.go b/server/server.go index ee0ffdf119..48b8bee09c 100644 --- a/server/server.go +++ b/server/server.go @@ -82,6 +82,7 @@ type UserConfig struct { GitlabWebHookSecret string `mapstructure:"gitlab-webhook-secret"` LogLevel string `mapstructure:"log-level"` Port int `mapstructure:"port"` + RepoSubdir string `mapstructure:"repo-subdir"` RepoWhitelist string `mapstructure:"repo-whitelist"` // RequireApproval is whether to require pull request approval before // allowing terraform apply's to be run. @@ -204,6 +205,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { Terraform: terraformClient, Run: run, Workspace: workspace, + RepoSubdir: userConfig.RepoSubdir, ProjectPreExecute: projectPreExecute, Locker: lockingClient, ProjectFinder: &events.DefaultProjectFinder{},