diff --git a/cmd/server.go b/cmd/server.go
index b59f8be428..79b44e537a 100644
--- a/cmd/server.go
+++ b/cmd/server.go
@@ -58,6 +58,7 @@ const (
GitlabWebhookSecretFlag = "gitlab-webhook-secret" // nolint: gosec
LogLevelFlag = "log-level"
PortFlag = "port"
+ RepoConfigFlag = "repo-config"
RepoWhitelistFlag = "repo-whitelist"
RequireApprovalFlag = "require-approval"
RequireMergeableFlag = "require-mergeable"
@@ -168,6 +169,10 @@ var stringFlags = []stringFlag{
description: "Log level. Either debug, info, warn, or error.",
defaultValue: DefaultLogLevel,
},
+ {
+ name: RepoConfigFlag,
+ description: "Path to a repo config file, used to configure how atlantis.yaml will behave on repos. Repos can be specified as an exact string or using regular expressions",
+ },
{
name: RepoWhitelistFlag,
description: "Comma separated list of repositories that Atlantis will operate on. " +
@@ -211,6 +216,7 @@ var boolFlags = []boolFlag{
" Should only be enabled in a trusted environment since it enables a pull request to run arbitrary commands" +
" on the Atlantis server.",
defaultValue: false,
+ deprecated: fmt.Sprintf("use --%s to allow sensitive keys in atlantis.yaml", RepoConfigFlag),
},
{
name: AutomergeFlag,
@@ -245,16 +251,19 @@ type stringFlag struct {
name string
description string
defaultValue string
+ deprecated string
}
type intFlag struct {
name string
description string
defaultValue int
+ deprecated string
}
type boolFlag struct {
name string
description string
defaultValue bool
+ deprecated string
}
// ServerCmd is an abstraction that helps us test. It allows
@@ -330,6 +339,9 @@ func (s *ServerCmd) Init() *cobra.Command {
usage = fmt.Sprintf("%s (default \"%s\")", usage, f.defaultValue)
}
c.Flags().String(f.name, "", usage+"\n")
+ if f.deprecated != "" {
+ c.Flags().MarkDeprecated(f.name, f.deprecated) // nolint: errcheck
+ }
s.Viper.BindPFlag(f.name, c.Flags().Lookup(f.name)) // nolint: errcheck
}
@@ -340,12 +352,18 @@ func (s *ServerCmd) Init() *cobra.Command {
usage = fmt.Sprintf("%s (default %d)", usage, f.defaultValue)
}
c.Flags().Int(f.name, 0, usage+"\n")
+ if f.deprecated != "" {
+ c.Flags().MarkDeprecated(f.name, f.deprecated) // nolint: errcheck
+ }
s.Viper.BindPFlag(f.name, c.Flags().Lookup(f.name)) // nolint: errcheck
}
// Set bool flags.
for _, f := range boolFlags {
c.Flags().Bool(f.name, f.defaultValue, f.description+"\n")
+ if f.deprecated != "" {
+ c.Flags().MarkDeprecated(f.name, f.deprecated) // nolint: errcheck
+ }
s.Viper.BindPFlag(f.name, c.Flags().Lookup(f.name)) // nolint: errcheck
}
@@ -438,6 +456,9 @@ func (s *ServerCmd) validate(userConfig server.UserConfig) error {
if (userConfig.SSLKeyFile == "") != (userConfig.SSLCertFile == "") {
return fmt.Errorf("--%s and --%s are both required for ssl", SSLKeyFileFlag, SSLCertFileFlag)
}
+ if userConfig.AllowRepoConfig && userConfig.RepoConfig != "" {
+ return fmt.Errorf("You cannot use both --%s and --%s together. --%s is deprecated and will be removed in a later version, you should use --%s instead", AllowRepoConfigFlag, RepoConfigFlag, AllowRepoConfigFlag, RepoConfigFlag)
+ }
// The following combinations are valid.
// 1. github user and token set
diff --git a/cmd/server_test.go b/cmd/server_test.go
index fb78e641c8..87004ea259 100644
--- a/cmd/server_test.go
+++ b/cmd/server_test.go
@@ -914,6 +914,16 @@ func TestExecute_BitbucketServerBaseURLPort(t *testing.T) {
Equals(t, "http://mydomain.com:7990", passedConfig.BitbucketBaseURL)
}
+// Cannot use both --allow-repo-config and --repo-config
+func TestExecute_AllowRepoConfigWithAllowRestrictedRepoConfig(t *testing.T) {
+ c := setup(map[string]interface{}{
+ cmd.AllowRepoConfigFlag: true,
+ cmd.RepoConfigFlag: "somefile",
+ })
+ err := c.Execute()
+ ErrEquals(t, "You cannot use both --allow-repo-config and --repo-config together. --allow-repo-config is deprecated and will be removed in a later version, you should use --repo-config instead", err)
+}
+
func setup(flags map[string]interface{}) *cobra.Command {
vipr := viper.New()
for k, v := range flags {
diff --git a/runatlantis.io/.vuepress/config.js b/runatlantis.io/.vuepress/config.js
index 377f198840..6edcca06fe 100644
--- a/runatlantis.io/.vuepress/config.js
+++ b/runatlantis.io/.vuepress/config.js
@@ -69,6 +69,7 @@ module.exports = {
collapsable: true,
children: [
['customizing-atlantis', 'Overview'],
+ 'repos-yaml-reference',
'atlantis-yaml-reference',
'upgrading-atlantis-yaml-to-version-2',
'apply-requirements',
diff --git a/runatlantis.io/docs/apply-requirements.md b/runatlantis.io/docs/apply-requirements.md
index 7bf6b4f1f3..08c0d9956d 100644
--- a/runatlantis.io/docs/apply-requirements.md
+++ b/runatlantis.io/docs/apply-requirements.md
@@ -20,7 +20,21 @@ by at least one person other than the author.
#### Usage
You can set the `approved` requirement by:
1. Passing the `--require-approval` flag to `atlantis server` or
-1. Creating an `atlantis.yaml` file with the `apply_requirements` key:
+1. Creating a `repos.yaml` file with the `apply_requirements` key:
+ ```yaml
+ repos:
+ - id: /.*/
+ apply_requirements: [approved]
+ ```
+1. Or by allowing an `atlantis.yaml` file to specify the `apply_requirements` key in your `repos.yaml` config:
+ #### repos.yaml
+ ```yaml
+ repos:
+ - id: /.*/
+ allowed_overrides: [apply_requirements]
+ ```
+
+ #### atlantis.yaml
```yaml
version: 2
projects:
@@ -47,14 +61,29 @@ The `mergeable` requirement will prevent applies unless a pull request is able t
#### Usage
You can set the `mergeable` requirement by:
1. Passing the `--require-mergeable` flag to `atlantis server` or
-1. Creating an `atlantis.yaml` file with the `apply_requirements` key:
+1. Creating a `repos.yaml` file with the `apply_requirements` key:
+ ```yaml
+ repos:
+ - id: /.*/
+ apply_requirements: [mergeable]
+ ```
+
+1. Or by allowing an `atlantis.yaml` file to specify the `apply_requirements` key in your `repos.yaml` config:
+ #### repos.yaml
+ ```yaml
+ repos:
+ - id: /.*/
+ allowed_overrides: [apply_requirements]
+ ```
+
+ #### atlantis.yaml
```yaml
version: 2
projects:
- dir: .
apply_requirements: [mergeable]
- ```
-
+ ```
+
#### Meaning
Each VCS provider has a different concept of "mergeability":
#### GitHub
@@ -92,18 +121,47 @@ If you need a specific check, please
[open an issue](https://github.com/runatlantis/atlantis/issues/new).
## Setting Apply Requirements
-As mentioned above, you can set apply requirements via flags or `atlantis.yaml`.
+As mentioned above, you can set apply requirements via flags, in `repos.yaml`, or in `atlantis.yaml` if `repos.yaml`
+allows the override.
### Flags Override
-Flags **override** any `atlantis.yaml` settings so they are equivalent to always
+Flags **override** any `repos.yaml` or `atlantis.yaml` settings so they are equivalent to always
having that apply requirement set.
### Project-Specific Settings
If you only want some projects/repos to have apply requirements, then you must
1. Not set the `--require-approval` or `--require-mergeable` flags, since those
- will override any `atlantis.yaml` settings
-1. Specify which projects have which requirements via an `atlantis.yaml` file.
+ will override any `repos.yaml` or `atlantis.yaml` settings
+1. Specifying which repos have which requirements via the `repos.yaml` file.
+ ```yaml
+ repos:
+ - id: /.*/
+ apply_requirements: [approved]
+ # Regex that defaults all repos to requiring approval
+ - id: /github.com/runatlantis/.*/
+ # Regex to match any repo under the atlantis namespace, and not require approval
+ # except for repos that might match later in the chain
+ apply_requirements: []
+ - id: github.com/runatlantis/atlantis
+ apply_requirements: [approved]
+ # Exact string match of the github.com/runatlantis/atlantis repo
+ # that sets apply_requirements to approved
+ ```
+
+1. Specify which projects have which requirements via an `atlantis.yaml` file, and allowing
+ `apply_requirements` to be set in in `atlantis.yaml` by the server side `repos.yaml`
+ config.
+
For example if I have two directories, `staging` and `production`, I might use:
+ #### repos.yaml
+ ```yaml
+ repos:
+ - id: /.*/
+ allowed_overrides: [apply_requirements]
+ # Allow any repo to specify apply_requirements in atlantis.yaml
+ ```
+
+ #### atlatis.yaml
```yaml
version: 2
projects:
diff --git a/runatlantis.io/docs/atlantis-yaml-reference.md b/runatlantis.io/docs/atlantis-yaml-reference.md
index f188c8e0eb..100e7e9921 100644
--- a/runatlantis.io/docs/atlantis-yaml-reference.md
+++ b/runatlantis.io/docs/atlantis-yaml-reference.md
@@ -10,8 +10,12 @@ See [www.runatlantis.io/guide/atlantis-yaml-use-cases.html](../guide/atlantis-ya
:::
## Enabling atlantis.yaml
-The atlantis server must be running with `--allow-repo-config` to allow Atlantis
-to use `atlantis.yaml` files.
+By default all repos are allowed to have an `atlantis.yaml` file, but not all of the keys are enabled by default due to
+the sensitive nature of some keys.
+
+Restricted keys can be set in the server side `repos.yaml` file, and you can enable `atlantis.yaml` to override restricted
+keys by setting `allowed_overrides` in the `repos.yaml`. See the [repos.yaml reference](repos-yaml-reference.html) for
+more information.
## Example Using All Keys
```yaml
@@ -58,11 +62,10 @@ likely hold your highest privilege credentials.
The risk is increased because Atlantis uses the `atlantis.yaml` file from the
pull request so anyone that can submit a pull request can submit a malicious file.
-As such, **`atlantis.yaml` files should only be enabled in a trusted environment**.
-
-::: danger
-It should be noted that `atlantis apply` itself could be exploited if run on a malicious terraform file. See [Security](security.html#exploits).
-:::
+By default, the keys that are sensitive in nature are restricted from being used in the `atlantis.yaml` file.
+Restricted keys can be set in the server side `repos.yaml` file, and you can enable `atlantis.yaml` to override restricted
+keys by setting `allowed_overrides` in the `repos.yaml`. See the [repos.yaml reference](repos-yaml-reference.html) for
+more information.
## Reference
### Top-Level Keys
@@ -72,12 +75,12 @@ automerge:
projects:
workflows:
```
-| Key | Type | Default | Required | Description |
-| --------- | ---------------------------------------------------------------- | ------- | -------- | ----------------------------------------------------------- |
-| version | int | none | yes | This key is required and must be set to `2` |
-| automerge | bool | false | no | Automatically merge pull request when all plans are applied |
-| projects | array[[Project](atlantis-yaml-reference.html#project)] | [] | no | Lists the projects in this repo |
-| workflows | map[string -> [Workflow](atlantis-yaml-reference.html#workflow)] | {} | no | Custom workflows |
+| Key | Type | Default | Required | Description |
+| ----------------------------- | ---------------------------------------------------------------- | ------- | -------- | ----------------------------------------------------------- |
+| version | int | none | yes | This key is required and must be set to `2` |
+| automerge | bool | false | no | Automatically merge pull request when all plans are applied |
+| projects | array[[Project](atlantis-yaml-reference.html#project)] | [] | no | Lists the projects in this repo |
+| workflows
*(restricted)* | map[string -> [Workflow](atlantis-yaml-reference.html#workflow)] | {} | no | Custom workflows |
### Project
```yaml
@@ -90,15 +93,15 @@ apply_requirements: ["approved"]
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. Use `.` for the root. For example if the project was under `./project1` then use `project1` |
-| 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. |
-| autoplan | [Autoplan](atlantis-yaml-reference.html#autoplan) | none | no | A custom autoplan configuration. If not specified, will use the default algorithm. See [Autoplanning](autoplanning.html). |
-| terraform_version | string | none | no | A specific Terraform version to use when running commands for this project. Must be [Semver compatible](https://semver.org/), ex. `v0.11.0`, `0.12.0-beta1`. |
-| apply_requirements | array[string] | [] | no | Requirements that must be satisfied before `atlantis apply` can be run. Currently the only supported requirements are `approved` and `mergeable`. See [Apply Requirements](apply-requirements.html) for more details. |
-| workflow | string | none | no | A custom workflow. If not specified, Atlantis will use its default workflow. |
+| 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. Use `.` for the root. For example if the project was under `./project1` then use `project1` |
+| 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. |
+| autoplan | [Autoplan](atlantis-yaml-reference.html#autoplan) | none | no | A custom autoplan configuration. If not specified, will use the default algorithm. See [Autoplanning](autoplanning.html). |
+| terraform_version | string | none | no | A specific Terraform version to use when running commands for this project. Must be [Semver compatible](https://semver.org/), ex. `v0.11.0`, `0.12.0-beta1`. |
+| apply_requirements
*(restricted)* | array[string] | [] | no | Requirements that must be satisfied before `atlantis apply` can be run. Currently the only supported requirements are `approved` and `mergeable`. See [Apply Requirements](apply-requirements.html) for more details. |
+| workflow
*(restricted)* | string | none | no | A custom workflow. If not specified, Atlantis will use its default workflow. |
::: tip
A project represents a Terraform state. Typically, there is one state per directory and workspace however it's possible to
@@ -112,7 +115,7 @@ enabled: true
when_modified: ["*.tf"]
```
| Key | Type | Default | Required | Description |
-| ------------- | ------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| ------------- | ------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| enabled | boolean | true | no | Whether autoplanning is enabled for this project. |
| when_modified | array[string] | no | no | Uses [.dockerignore](https://docs.docker.com/engine/reference/builder/#dockerignore-file) syntax. If any modified file in the pull request matches, this project will be planned. If not specified, Atlantis will use its own algorithm. See [Autoplanning](autoplanning.html). Paths are relative to the project's dir. |
@@ -122,8 +125,8 @@ plan:
apply:
```
-| Key | Type | Default | Required | Description |
-| ----- | ------------------------------------------- | --------------------- | -------- | ------------------------------ |
+| Key | Type | Default | Required | Description |
+| ----- | ------------------------------------------- | --------------------- | -------- | ------------------------------ |
| plan | [Stage](atlantis-yaml-reference.html#stage) | `steps: [init, plan]` | no | How to plan for this project. |
| apply | [Stage](atlantis-yaml-reference.html#stage) | `steps: [apply]` | no | How to apply for this project. |
diff --git a/runatlantis.io/docs/customizing-atlantis.md b/runatlantis.io/docs/customizing-atlantis.md
index d7f6cf0cb7..6f0093ba32 100644
--- a/runatlantis.io/docs/customizing-atlantis.md
+++ b/runatlantis.io/docs/customizing-atlantis.md
@@ -1,7 +1,8 @@
# Customizing Atlantis
-How Atlantis exactly operates for each repo can be customized via an
-`atlantis.yaml` file placed at the root of each repo.
+How Atlantis exactly operates for each repo can be customized by using a `repos.yaml` file when starting the Atlantis
+server and via an `atlantis.yaml` file placed at the root of each repo.
* Read about the possible [use cases](/guide/atlantis-yaml-use-cases.html)
* Check out the [atlantis.yaml reference](atlantis-yaml-reference.html)
+* Check out the [repos.yaml reference](repos-yaml-reference.html)
diff --git a/runatlantis.io/docs/repos-yaml-reference.md b/runatlantis.io/docs/repos-yaml-reference.md
new file mode 100644
index 0000000000..dca1355c0d
--- /dev/null
+++ b/runatlantis.io/docs/repos-yaml-reference.md
@@ -0,0 +1,126 @@
+# repos.yaml Reference
+[[toc]]
+
+::: tip Do I need a repos.yaml file?
+A `repos.yaml` file is only required if you wish to customize some aspect of Atlantis.
+You can provide even more customizations by combining a server side `repos.yaml` file with an
+`atlantis.yaml` file in a repository. See the [atlantis.yaml reference](atlantis-yaml-reference.html) for
+more information on additional customizations.
+:::
+
+## Enabling repos.yaml
+The atlantis server must be running with the `--allow-restricted-repo-config` option to allow Atlantis to use
+`repos.yaml` and `atlantis.yaml` files. The location of the `repos.yaml` file on the Atlantis server must be provided
+to the `--repos-config` option.
+
+## Overview
+The `repos.yaml` file lets you provide customized settings to be applied globally to repositories that match a
+regular expression or an exact string. An `atlantis.yaml` file allows you to provide more fine grained configuration
+about how atlantis should act on a specific repository.
+
+Some of the settings in `repos.yaml` are sensitive in nature, and would allow users to run arbitrary code on the
+Atlantis server if they were allowed in the `atlantis.yaml` file. These settings are restricted to the `repos.yaml`
+file by default, but the `repos.yaml` file can allow them to be set in an `atlantis.yaml` file.
+
+The `repos.yaml` file allows a list of repos to be defined, using either a regex or exact string to match a repository
+path. If a repository matches multiple entries, the settings from the last entry in the list take precedence.
+
+## Example Using All Keys
+```yaml
+repos:
+- id: /.*/
+ apply_requirements: [approved, mergeable]
+ workflow: repoworkflow
+ allowed_overrides: [apply_requirements, workflow]
+ allow_custom_workflows: true
+
+workflows:
+ repoworkflow:
+ plan:
+ steps:
+ - run: my-custom-command arg1 arg2
+ - init
+ - plan:
+ extra_args: ["-lock", "false"]
+ - run: my-custom-command arg1 arg2
+ apply:
+ steps:
+ - run: echo hi
+ - apply
+```
+
+## Reference
+
+
+### Top-Level Keys
+| Key | Type | Default | Required | Description |
+| --------- | --------------------------------------------- | ------- | -------- | --------------------------------- |
+| repos | array[[Repo](repos-yaml-reference.html#repo)] |[] | no | List of repos to apply settings to|
+| workflows | map[string -> [Workflow](repos-yaml-reference.html#workflow)]
+
+### Repo
+| Key | Type | Default | Required | Description |
+| ------------------ | -------- | ------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| id | string | none | yes | Value can be a regular expersion when specified as /<regex>/ or an exact string match |
+| workflow | string | none | no | A custom workflow. If not specified, Atlantis will use its default workflow |
+| apply_requirements | []string | none | no | Requirements that must be satisfied before `atlantis apply` can be run. Currently the only supported requirements are `approved` and `mergeable`. See [Apply Requirements](apply-requirements.html) for more details. |
+| allowed_overrides | []string | none | no | A list of restricted keys to allow the `atlantis.yaml` file to override settings from the `repos.yaml` file.
Restricted Keys Include:
- apply_requirements
- workflow
|
+
+### Workflow
+```yaml
+plan:
+apply:
+```
+
+| Key | Type | Default | Required | Description |
+| ----- | ------------------------------------------- | --------------------- | -------- | ------------------------------ |
+| plan | [Stage](atlantis-yaml-reference.html#stage) | `steps: [init, plan]` | no | How to plan for this project. |
+| apply | [Stage](atlantis-yaml-reference.html#stage) | `steps: [apply]` | no | How to apply for this project. |
+
+### Stage
+```yaml
+steps:
+- run: custom-command
+- init
+- plan:
+ extra_args: [-lock=false]
+```
+
+| Key | Type | Default | Required | Description |
+| ----- | ------------------------------------------------ | ------- | -------- | --------------------------------------------------------------------------------------------- |
+| steps | array[[Step](atlantis-yaml-reference.html#step)] | `[]` | no | List of steps for this stage. If the steps key is empty, no steps will be run for this stage. |
+
+### Step
+#### Built-In Commands: init, plan, apply
+Steps can be a single string for a built-in command.
+```yaml
+- init
+- plan
+- apply
+```
+| Key | Type | Default | Required | Description |
+| --------------- | ------ | ------- | -------- | ------------------------------------------------------------------------------------------------------ |
+| init/plan/apply | string | none | no | Use a built-in command without additional configuration. Only `init`, `plan` and `apply` are supported |
+
+#### Built-In Command With Extra Args
+A map from string to `extra_args` for a built-in command with extra arguments.
+```yaml
+- init:
+ extra_args: [arg1, arg2]
+- plan:
+ extra_args: [arg1, arg2]
+- apply:
+ extra_args: [arg1, arg2]
+```
+| Key | Type | Default | Required | Description |
+| --------------- | ---------------------------------- | ------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
+| init/plan/apply | map[`extra_args` -> array[string]] | none | no | Use a built-in command and append `extra_args`. Only `init`, `plan` and `apply` are supported as keys and only `extra_args` is supported as a value |
+#### Custom `run` Command
+Or a custom command
+```yaml
+- run: custom-command
+```
+| Key | Type | Default | Required | Description |
+| --- | ------ | ------- | -------- | -------------------- |
+| run | string | none | no | Run a custom command |
+
diff --git a/server/events/event_parser_test.go b/server/events/event_parser_test.go
index ff64a6962e..1741674769 100644
--- a/server/events/event_parser_test.go
+++ b/server/events/event_parser_test.go
@@ -46,6 +46,7 @@ func TestParseGithubRepo(t *testing.T) {
Equals(t, models.Repo{
Owner: "owner",
FullName: "owner/repo",
+ FullNameWithHost: "github.com/owner/repo",
CloneURL: "https://github-user:github-token@github.com/owner/repo.git",
SanitizedCloneURL: Repo.GetCloneURL(),
Name: "repo",
@@ -95,6 +96,7 @@ func TestParseGithubIssueCommentEvent(t *testing.T) {
Equals(t, models.Repo{
Owner: *comment.Repo.Owner.Login,
FullName: *comment.Repo.FullName,
+ FullNameWithHost: "github.com/owner/repo",
CloneURL: "https://github-user:github-token@github.com/owner/repo.git",
SanitizedCloneURL: *comment.Repo.CloneURL,
Name: "repo",
@@ -133,6 +135,7 @@ func TestParseGithubPullEvent(t *testing.T) {
expBaseRepo := models.Repo{
Owner: "owner",
FullName: "owner/repo",
+ FullNameWithHost: "github.com/owner/repo",
CloneURL: "https://github-user:github-token@github.com/owner/repo.git",
SanitizedCloneURL: Repo.GetCloneURL(),
Name: "repo",
@@ -251,6 +254,7 @@ func TestParseGithubPull(t *testing.T) {
expBaseRepo := models.Repo{
Owner: "owner",
FullName: "owner/repo",
+ FullNameWithHost: "github.com/owner/repo",
CloneURL: "https://github-user:github-token@github.com/owner/repo.git",
SanitizedCloneURL: Repo.GetCloneURL(),
Name: "repo",
@@ -286,6 +290,7 @@ func TestParseGitlabMergeEvent(t *testing.T) {
expBaseRepo := models.Repo{
FullName: "lkysow/atlantis-example",
+ FullNameWithHost: "gitlab.com/lkysow/atlantis-example",
Name: "atlantis-example",
SanitizedCloneURL: "https://gitlab.com/lkysow/atlantis-example.git",
Owner: "lkysow",
@@ -311,6 +316,7 @@ func TestParseGitlabMergeEvent(t *testing.T) {
Equals(t, expBaseRepo, actBaseRepo)
Equals(t, models.Repo{
FullName: "sourceorg/atlantis-example",
+ FullNameWithHost: "gitlab.com/sourceorg/atlantis-example",
Name: "atlantis-example",
SanitizedCloneURL: "https://gitlab.com/sourceorg/atlantis-example.git",
Owner: "sourceorg",
@@ -343,6 +349,7 @@ func TestParseGitlabMergeEvent_Subgroup(t *testing.T) {
expBaseRepo := models.Repo{
FullName: "lkysow-test/subgroup/sub-subgroup/atlantis-example",
+ FullNameWithHost: "gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example",
Name: "atlantis-example",
SanitizedCloneURL: "https://gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example.git",
Owner: "lkysow-test/subgroup/sub-subgroup",
@@ -368,6 +375,7 @@ func TestParseGitlabMergeEvent_Subgroup(t *testing.T) {
Equals(t, expBaseRepo, actBaseRepo)
Equals(t, models.Repo{
FullName: "lkysow-test/subgroup/sub-subgroup/atlantis-example",
+ FullNameWithHost: "gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example",
Name: "atlantis-example",
SanitizedCloneURL: "https://gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example.git",
Owner: "lkysow-test/subgroup/sub-subgroup",
@@ -439,6 +447,7 @@ func TestParseGitlabMergeRequest(t *testing.T) {
Ok(t, err)
repo := models.Repo{
FullName: "gitlabhq/gitlab-test",
+ FullNameWithHost: "example.com/gitlabhq/gitlab-test",
Name: "gitlab-test",
SanitizedCloneURL: "https://example.com/gitlabhq/gitlab-test.git",
Owner: "gitlabhq",
@@ -478,6 +487,7 @@ func TestParseGitlabMergeRequest_Subgroup(t *testing.T) {
repo := models.Repo{
FullName: "lkysow-test/subgroup/sub-subgroup/atlantis-example",
+ FullNameWithHost: "gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example",
Name: "atlantis-example",
SanitizedCloneURL: "https://gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example.git",
Owner: "lkysow-test/subgroup/sub-subgroup",
@@ -512,6 +522,7 @@ func TestParseGitlabMergeCommentEvent(t *testing.T) {
Ok(t, err)
Equals(t, models.Repo{
FullName: "gitlabhq/gitlab-test",
+ FullNameWithHost: "example.com/gitlabhq/gitlab-test",
Name: "gitlab-test",
SanitizedCloneURL: "https://example.com/gitlabhq/gitlab-test.git",
Owner: "gitlabhq",
@@ -523,6 +534,7 @@ func TestParseGitlabMergeCommentEvent(t *testing.T) {
}, baseRepo)
Equals(t, models.Repo{
FullName: "gitlab-org/gitlab-test",
+ FullNameWithHost: "example.com/gitlab-org/gitlab-test",
Name: "gitlab-test",
SanitizedCloneURL: "https://example.com/gitlab-org/gitlab-test.git",
Owner: "gitlab-org",
@@ -550,6 +562,7 @@ func TestParseGitlabMergeCommentEvent_Subgroup(t *testing.T) {
Equals(t, models.Repo{
FullName: "lkysow-test/subgroup/sub-subgroup/atlantis-example",
+ FullNameWithHost: "gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example",
Name: "atlantis-example",
SanitizedCloneURL: "https://gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example.git",
Owner: "lkysow-test/subgroup/sub-subgroup",
@@ -561,6 +574,7 @@ func TestParseGitlabMergeCommentEvent_Subgroup(t *testing.T) {
}, baseRepo)
Equals(t, models.Repo{
FullName: "lkysow-test/subgroup/sub-subgroup/atlantis-example",
+ FullNameWithHost: "gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example",
Name: "atlantis-example",
SanitizedCloneURL: "https://gitlab.com/lkysow-test/subgroup/sub-subgroup/atlantis-example.git",
Owner: "lkysow-test/subgroup/sub-subgroup",
@@ -704,6 +718,7 @@ func TestParseBitbucketCloudCommentEvent_ValidEvent(t *testing.T) {
Ok(t, err)
expBaseRepo := models.Repo{
FullName: "lkysow/atlantis-example",
+ FullNameWithHost: "bitbucket.org/lkysow/atlantis-example",
Owner: "lkysow",
Name: "atlantis-example",
CloneURL: "https://bitbucket-user:bitbucket-token@bitbucket.org/lkysow/atlantis-example.git",
@@ -726,6 +741,7 @@ func TestParseBitbucketCloudCommentEvent_ValidEvent(t *testing.T) {
}, pull)
Equals(t, models.Repo{
FullName: "lkysow-fork/atlantis-example",
+ FullNameWithHost: "bitbucket.org/lkysow-fork/atlantis-example",
Owner: "lkysow-fork",
Name: "atlantis-example",
CloneURL: "https://bitbucket-user:bitbucket-token@bitbucket.org/lkysow-fork/atlantis-example.git",
@@ -790,6 +806,7 @@ func TestParseBitbucketCloudPullEvent_ValidEvent(t *testing.T) {
Ok(t, err)
expBaseRepo := models.Repo{
FullName: "lkysow/atlantis-example",
+ FullNameWithHost: "bitbucket.org/lkysow/atlantis-example",
Owner: "lkysow",
Name: "atlantis-example",
CloneURL: "https://bitbucket-user:bitbucket-token@bitbucket.org/lkysow/atlantis-example.git",
@@ -812,6 +829,7 @@ func TestParseBitbucketCloudPullEvent_ValidEvent(t *testing.T) {
}, pull)
Equals(t, models.Repo{
FullName: "lkysow-fork/atlantis-example",
+ FullNameWithHost: "bitbucket.org/lkysow-fork/atlantis-example",
Owner: "lkysow-fork",
Name: "atlantis-example",
CloneURL: "https://bitbucket-user:bitbucket-token@bitbucket.org/lkysow-fork/atlantis-example.git",
@@ -891,6 +909,7 @@ func TestParseBitbucketServerCommentEvent_ValidEvent(t *testing.T) {
Ok(t, err)
expBaseRepo := models.Repo{
FullName: "atlantis/atlantis-example",
+ FullNameWithHost: "mycorp.com:7490/atlantis/atlantis-example",
Owner: "atlantis",
Name: "atlantis-example",
CloneURL: "http://bitbucket-user:bitbucket-token@mycorp.com:7490/scm/at/atlantis-example.git",
@@ -913,6 +932,7 @@ func TestParseBitbucketServerCommentEvent_ValidEvent(t *testing.T) {
}, pull)
Equals(t, models.Repo{
FullName: "atlantis-fork/atlantis-example",
+ FullNameWithHost: "mycorp.com:7490/atlantis-fork/atlantis-example",
Owner: "atlantis-fork",
Name: "atlantis-example",
CloneURL: "http://bitbucket-user:bitbucket-token@mycorp.com:7490/scm/fk/atlantis-example.git",
@@ -973,6 +993,7 @@ func TestParseBitbucketServerPullEvent_ValidEvent(t *testing.T) {
Ok(t, err)
expBaseRepo := models.Repo{
FullName: "atlantis/atlantis-example",
+ FullNameWithHost: "mycorp.com:7490/atlantis/atlantis-example",
Owner: "atlantis",
Name: "atlantis-example",
CloneURL: "http://bitbucket-user:bitbucket-token@mycorp.com:7490/scm/at/atlantis-example.git",
@@ -995,6 +1016,7 @@ func TestParseBitbucketServerPullEvent_ValidEvent(t *testing.T) {
}, pull)
Equals(t, models.Repo{
FullName: "atlantis-fork/atlantis-example",
+ FullNameWithHost: "mycorp.com:7490/atlantis-fork/atlantis-example",
Owner: "atlantis-fork",
Name: "atlantis-example",
CloneURL: "http://bitbucket-user:bitbucket-token@mycorp.com:7490/scm/fk/atlantis-example.git",
diff --git a/server/events/models/models.go b/server/events/models/models.go
index 9f2bd4c240..901624f706 100644
--- a/server/events/models/models.go
+++ b/server/events/models/models.go
@@ -35,6 +35,9 @@ type Repo struct {
// FullName is the owner and repo name separated
// by a "/", ex. "runatlantis/atlantis", "gitlab/subgroup/atlantis", "Bitbucket Server/atlantis".
FullName string
+ // FullNameWithHost
+ // This is the full name of the repo including the hostname. ex github.com/runatlantis/atlantis
+ FullNameWithHost string
// Owner is just the repo owner, ex. "runatlantis" or "gitlab/subgroup".
// This may contain /'s in the case of GitLab subgroups.
// This may contain spaces in the case of Bitbucket Server.
@@ -73,6 +76,12 @@ func NewRepo(vcsHostType VCSHostType, repoFullName string, cloneURL string, vcsU
return Repo{}, errors.Wrap(err, "invalid clone url")
}
+ repoHostname := cloneURLParsed.Hostname()
+ if cloneURLParsed.Port() != "" {
+ repoHostname = fmt.Sprintf("%s:%s", repoHostname, cloneURLParsed.Port())
+ }
+ repoFullNameWithHost := fmt.Sprintf("%s/%s", repoHostname, repoFullName)
+
// Ensure the Clone URL is for the same repo to avoid something malicious.
// We skip this check for Bitbucket Server because its format is different
// and because the caller in that case actually constructs the clone url
@@ -111,6 +120,7 @@ func NewRepo(vcsHostType VCSHostType, repoFullName string, cloneURL string, vcsU
return Repo{
FullName: repoFullName,
+ FullNameWithHost: repoFullNameWithHost,
Owner: owner,
Name: repo,
CloneURL: authedCloneURL,
diff --git a/server/events/models/models_test.go b/server/events/models/models_test.go
index fe6857cb5f..4636cceaf3 100644
--- a/server/events/models/models_test.go
+++ b/server/events/models/models_test.go
@@ -49,6 +49,7 @@ func TestNewRepo_CloneURLBitbucketServer(t *testing.T) {
Ok(t, err)
Equals(t, models.Repo{
FullName: "owner/repo",
+ FullNameWithHost: "mycorp.com:7990/owner/repo",
Owner: "owner",
Name: "repo",
CloneURL: "http://u:p@mycorp.com:7990/scm/at/atlantis-example.git",
@@ -119,6 +120,7 @@ func TestNewRepo_HTTPAuth(t *testing.T) {
SanitizedCloneURL: "http://github.com/owner/repo.git",
CloneURL: "http://u:p@github.com/owner/repo.git",
FullName: "owner/repo",
+ FullNameWithHost: "github.com/owner/repo",
Owner: "owner",
Name: "repo",
}, repo)
@@ -136,6 +138,7 @@ func TestNewRepo_HTTPSAuth(t *testing.T) {
SanitizedCloneURL: "https://github.com/owner/repo.git",
CloneURL: "https://u:p@github.com/owner/repo.git",
FullName: "owner/repo",
+ FullNameWithHost: "github.com/owner/repo",
Owner: "owner",
Name: "repo",
}, repo)
diff --git a/server/events/project_command_builder.go b/server/events/project_command_builder.go
index 6f22938537..809bdb4d81 100644
--- a/server/events/project_command_builder.go
+++ b/server/events/project_command_builder.go
@@ -2,6 +2,8 @@ package events
import (
"fmt"
+ "github.com/runatlantis/atlantis/server/events/yaml/raw"
+ "github.com/runatlantis/atlantis/server/events/yaml/valid"
"strings"
"github.com/hashicorp/go-version"
@@ -9,7 +11,6 @@ import (
"github.com/runatlantis/atlantis/server/events/models"
"github.com/runatlantis/atlantis/server/events/vcs"
"github.com/runatlantis/atlantis/server/events/yaml"
- "github.com/runatlantis/atlantis/server/events/yaml/valid"
"github.com/runatlantis/atlantis/server/logging"
)
@@ -50,6 +51,7 @@ type DefaultProjectCommandBuilder struct {
WorkingDirLocker WorkingDirLocker
AllowRepoConfig bool
AllowRepoConfigFlag string
+ RepoConfig raw.RepoConfig
PendingPlanFinder *DefaultPendingPlanFinder
CommentBuilder CommentBuilder
}
@@ -102,10 +104,7 @@ func (p *DefaultProjectCommandBuilder) buildPlanAllCommands(ctx *CommandContext,
return nil, errors.Wrapf(err, "looking for %s file in %q", yaml.AtlantisYAMLFilename, repoDir)
}
if hasConfigFile {
- if !p.AllowRepoConfig {
- return nil, fmt.Errorf("%s files not allowed because Atlantis is not running with --%s", yaml.AtlantisYAMLFilename, p.AllowRepoConfigFlag)
- }
- config, err = p.ParserValidator.ReadConfig(repoDir)
+ config, err = p.ParserValidator.ReadConfig(repoDir, p.RepoConfig, ctx.BaseRepo.FullNameWithHost, p.AllowRepoConfig)
if err != nil {
return nil, err
}
@@ -130,6 +129,24 @@ func (p *DefaultProjectCommandBuilder) buildPlanAllCommands(ctx *CommandContext,
modifiedProjects := p.ProjectFinder.DetermineProjects(ctx.Log, modifiedFiles, ctx.BaseRepo.FullName, repoDir)
ctx.Log.Info("automatically determined that there were %d projects modified in this pull request: %s", len(modifiedProjects), modifiedProjects)
for _, mp := range modifiedProjects {
+ var globalConfig valid.Config
+ var projectConfig *valid.Project
+
+ // If there is a server side repo config that matches, then the project should be planned using
+ // a config that honors those values. Creating a single project config with no settings other than
+ // dir and merging with the server side repo yaml achieves this
+ version := 2
+ config := raw.Config{
+ Version: &version,
+ Projects: []raw.Project{{Dir: &mp.Path}},
+ }
+ config, err = p.ParserValidator.ValidateOverridesAndMergeConfig(config, p.RepoConfig, ctx.BaseRepo.FullNameWithHost, p.AllowRepoConfig)
+ if err != nil {
+ return nil, err
+ }
+ globalConfig = config.ToValid()
+ projectConfig = &globalConfig.Projects[0]
+
projCtxs = append(projCtxs, models.ProjectCommandContext{
BaseRepo: ctx.BaseRepo,
HeadRepo: ctx.HeadRepo,
@@ -137,8 +154,8 @@ func (p *DefaultProjectCommandBuilder) buildPlanAllCommands(ctx *CommandContext,
User: ctx.User,
Log: ctx.Log,
RepoRelDir: mp.Path,
- ProjectConfig: nil,
- GlobalConfig: nil,
+ ProjectConfig: projectConfig,
+ GlobalConfig: &globalConfig,
CommentArgs: commentFlags,
Workspace: DefaultWorkspace,
Verbose: verbose,
@@ -293,7 +310,7 @@ func (p *DefaultProjectCommandBuilder) buildProjectApplyCommand(ctx *CommandCont
}
func (p *DefaultProjectCommandBuilder) buildProjectCommandCtx(ctx *CommandContext, projectName string, commentFlags []string, repoDir string, repoRelDir string, workspace string) (models.ProjectCommandContext, error) {
- projCfg, globalCfg, err := p.getCfg(projectName, repoRelDir, workspace, repoDir)
+ projCfg, globalCfg, err := p.getCfg(ctx, projectName, repoRelDir, workspace, repoDir)
if err != nil {
return models.ProjectCommandContext{}, err
}
@@ -327,26 +344,38 @@ func (p *DefaultProjectCommandBuilder) buildProjectCommandCtx(ctx *CommandContex
}, nil
}
-func (p *DefaultProjectCommandBuilder) getCfg(projectName string, dir string, workspace string, repoDir string) (projectCfg *valid.Project, globalCfg *valid.Config, err error) {
+// This function is used to get the project config file when apply is being run
+func (p *DefaultProjectCommandBuilder) getCfg(ctx *CommandContext, projectName string, dir string, workspace string, repoDir string) (projectCfg *valid.Project, globalCfg *valid.Config, err error) {
hasConfigFile, err := p.ParserValidator.HasConfigFile(repoDir)
if err != nil {
err = errors.Wrapf(err, "looking for %s file in %q", yaml.AtlantisYAMLFilename, repoDir)
return
}
- if !hasConfigFile {
- if projectName != "" {
- err = fmt.Errorf("cannot specify a project name unless an %s file exists to configure projects", yaml.AtlantisYAMLFilename)
- return
- }
+ if !hasConfigFile && projectName != "" {
+ err = fmt.Errorf("cannot specify a project name unless an %s file exists to configure projects", yaml.AtlantisYAMLFilename)
return
}
- if !p.AllowRepoConfig {
- err = fmt.Errorf("%s files not allowed because Atlantis is not running with --%s", yaml.AtlantisYAMLFilename, p.AllowRepoConfigFlag)
- return
+ var globalCfgStruct valid.Config
+ // If we have a config file, read it and let any repo restricted config be merged and validated
+ if hasConfigFile {
+ globalCfgStruct, err = p.ParserValidator.ReadConfig(repoDir, p.RepoConfig, ctx.BaseRepo.FullNameWithHost, p.AllowRepoConfig)
+ } else {
+ // If no atlantis.yaml file exists, we generate a skeleton config and merge all of the server side repo config
+ // settings into. If no server side repo config was provided, a default one was generated at server start
+ version := 2
+ rawConfig := raw.Config{
+ Version: &version,
+ Projects: []raw.Project{
+ {
+ Dir: &dir,
+ Workspace: &workspace,
+ },
+ },
+ }
+ rawConfig, err = p.ParserValidator.ValidateOverridesAndMergeConfig(rawConfig, p.RepoConfig, ctx.BaseRepo.FullNameWithHost, p.AllowRepoConfig)
+ globalCfgStruct = rawConfig.ToValid()
}
-
- globalCfgStruct, err := p.ParserValidator.ReadConfig(repoDir)
if err != nil {
return
}
diff --git a/server/events/project_command_builder_test.go b/server/events/project_command_builder_test.go
index f32fc9e4b9..8753b9ca7c 100644
--- a/server/events/project_command_builder_test.go
+++ b/server/events/project_command_builder_test.go
@@ -1,6 +1,7 @@
package events_test
import (
+ "github.com/runatlantis/atlantis/server/events/yaml/raw"
"io/ioutil"
"path/filepath"
"testing"
@@ -35,9 +36,13 @@ func TestDefaultProjectCommandBuilder_BuildAutoplanCommands(t *testing.T) {
AtlantisYAML: "",
exp: []exp{
{
- projectConfig: nil,
- dir: ".",
- workspace: "default",
+ projectConfig: &valid.Project{
+ Dir: ".",
+ Workspace: "default",
+ Autoplan: getDefaultAutoPlan(),
+ },
+ dir: ".",
+ workspace: "default",
},
},
},
@@ -229,6 +234,175 @@ projects:
}
}
+func TestDefaultProjectCommandBuilder_RepoRestrictionsBuildPlanCommands(t *testing.T) {
+
+ workflow := "repoworkflow"
+ repoConfig := raw.RepoConfig{
+ Repos: []raw.Repo{{
+ ID: "/.*/",
+ Workflow: &workflow,
+ }},
+ }
+
+ expWorkspace := "default"
+ expDir := "."
+ expProjectCfg := &valid.Project{
+ Dir: ".",
+ Workflow: repoConfig.Repos[0].Workflow,
+ Workspace: expWorkspace,
+ Autoplan: valid.Autoplan{
+ Enabled: true,
+ WhenModified: []string{"**/*.tf*"},
+ },
+ }
+
+ t.Run("run plan with server side repo config and no atlantis.yaml", func(t *testing.T) {
+ RegisterMockTestingT(t)
+ tmpDir, cleanup := TempDir(t)
+ defer cleanup()
+
+ baseRepo := models.Repo{}
+ headRepo := models.Repo{}
+ pull := models.PullRequest{}
+ logger := logging.NewNoopLogger()
+ workingDir := mocks.NewMockWorkingDir()
+ When(workingDir.Clone(logger, baseRepo, headRepo, pull, "default")).ThenReturn(tmpDir, nil)
+
+ vcsClient := vcsmocks.NewMockClient()
+ When(vcsClient.GetModifiedFiles(baseRepo, pull)).ThenReturn([]string{"main.tf"}, nil)
+
+ builder := &events.DefaultProjectCommandBuilder{
+ WorkingDirLocker: events.NewDefaultWorkingDirLocker(),
+ WorkingDir: workingDir,
+ ParserValidator: &yaml.ParserValidator{},
+ VCSClient: vcsClient,
+ ProjectFinder: &events.DefaultProjectFinder{},
+ AllowRepoConfig: false,
+ RepoConfig: repoConfig,
+ PendingPlanFinder: &events.DefaultPendingPlanFinder{},
+ AllowRepoConfigFlag: "allow-repo-config",
+ CommentBuilder: &events.CommentParser{},
+ }
+
+ ctxs, err := builder.BuildAutoplanCommands(&events.CommandContext{
+ BaseRepo: baseRepo,
+ HeadRepo: headRepo,
+ Pull: pull,
+ User: models.User{},
+ Log: logger,
+ })
+ Ok(t, err)
+
+ for _, actCtx := range ctxs {
+ Equals(t, baseRepo, actCtx.BaseRepo)
+ Equals(t, baseRepo, actCtx.HeadRepo)
+ Equals(t, pull, actCtx.Pull)
+ Equals(t, models.User{}, actCtx.User)
+ Equals(t, logger, actCtx.Log)
+ Equals(t, 0, len(actCtx.CommentArgs))
+
+ Equals(t, expProjectCfg, actCtx.ProjectConfig)
+ Equals(t, expDir, actCtx.RepoRelDir)
+ Equals(t, expWorkspace, actCtx.Workspace)
+ }
+ })
+
+}
+
+func TestDefaultProjectCommandBuilder_BuildSingleApplyCommandRepoRestrictions(t *testing.T) {
+ workflow := "repoworkflow"
+ repoConfig := raw.RepoConfig{
+ Repos: []raw.Repo{{
+ ID: "/.*/",
+ Workflow: &workflow,
+ }},
+ }
+
+ expWorkspace := "default"
+ expDir := "."
+ expProjectCfg := &valid.Project{
+ Dir: ".",
+ Workflow: repoConfig.Repos[0].Workflow,
+ Workspace: expWorkspace,
+ Autoplan: valid.Autoplan{
+ Enabled: true,
+ WhenModified: []string{"**/*.tf*"},
+ },
+ }
+
+ expCommentArgs := "commentarg"
+ cmd := events.CommentCommand{
+ RepoRelDir: ".",
+ Flags: []string{expCommentArgs},
+ Name: models.PlanCommand,
+ }
+
+ for _, cmdName := range []models.CommandName{models.PlanCommand, models.ApplyCommand} {
+ t.Run("run apply with server side repo config and no atlantis.yaml", func(t *testing.T) {
+ RegisterMockTestingT(t)
+ tmpDir, cleanup := TempDir(t)
+ defer cleanup()
+
+ baseRepo := models.Repo{}
+ headRepo := models.Repo{}
+ pull := models.PullRequest{}
+ logger := logging.NewNoopLogger()
+ workingDir := mocks.NewMockWorkingDir()
+ if cmdName == models.PlanCommand {
+ When(workingDir.Clone(logger, baseRepo, headRepo, pull, expWorkspace)).ThenReturn(tmpDir, nil)
+ } else {
+ When(workingDir.GetWorkingDir(baseRepo, pull, expWorkspace)).ThenReturn(tmpDir, nil)
+ }
+
+ vcsClient := vcsmocks.NewMockClient()
+ When(vcsClient.GetModifiedFiles(baseRepo, pull)).ThenReturn([]string{"main.tf"}, nil)
+
+ builder := &events.DefaultProjectCommandBuilder{
+ WorkingDirLocker: events.NewDefaultWorkingDirLocker(),
+ WorkingDir: workingDir,
+ ParserValidator: &yaml.ParserValidator{},
+ VCSClient: vcsClient,
+ ProjectFinder: &events.DefaultProjectFinder{},
+ AllowRepoConfig: false,
+ RepoConfig: repoConfig,
+ AllowRepoConfigFlag: "allow-repo-config",
+ CommentBuilder: &events.CommentParser{},
+ }
+
+ cmdCtx := &events.CommandContext{
+ BaseRepo: baseRepo,
+ HeadRepo: headRepo,
+ Pull: pull,
+ User: models.User{},
+ Log: logger,
+ }
+ var actCtxs []models.ProjectCommandContext
+
+ var err error
+ if cmdName == models.PlanCommand {
+ actCtxs, err = builder.BuildPlanCommands(cmdCtx, &cmd)
+ } else {
+ actCtxs, err = builder.BuildApplyCommands(cmdCtx, &cmd)
+ }
+ Ok(t, err)
+
+ Equals(t, 1, len(actCtxs))
+ actCtx := actCtxs[0]
+ Equals(t, baseRepo, actCtx.BaseRepo)
+ Equals(t, baseRepo, actCtx.HeadRepo)
+ Equals(t, pull, actCtx.Pull)
+ Equals(t, models.User{}, actCtx.User)
+ Equals(t, logger, actCtx.Log)
+
+ Equals(t, expProjectCfg, actCtx.ProjectConfig)
+ Equals(t, expDir, actCtx.RepoRelDir)
+ Equals(t, expWorkspace, actCtx.Workspace)
+ Equals(t, []string{expCommentArgs}, actCtx.CommentArgs)
+ })
+ }
+
+}
+
// Test building a plan and apply command for one project.
func TestDefaultProjectCommandBuilder_BuildSinglePlanApplyCommand(t *testing.T) {
cases := []struct {
@@ -249,11 +423,15 @@ func TestDefaultProjectCommandBuilder_BuildSinglePlanApplyCommand(t *testing.T)
Name: models.PlanCommand,
Workspace: "myworkspace",
},
- AtlantisYAML: "",
- ExpProjectConfig: nil,
- ExpCommentArgs: []string{"commentarg"},
- ExpWorkspace: "myworkspace",
- ExpDir: ".",
+ AtlantisYAML: "",
+ ExpProjectConfig: &valid.Project{
+ Dir: ".",
+ Workspace: "myworkspace",
+ Autoplan: getDefaultAutoPlan(),
+ },
+ ExpCommentArgs: []string{"commentarg"},
+ ExpWorkspace: "myworkspace",
+ ExpDir: ".",
},
{
Description: "no atlantis.yaml with project flag",
@@ -571,11 +749,21 @@ func TestDefaultProjectCommandBuilder_BuildMultiPlanNoAtlantisYAML(t *testing.T)
Equals(t, 2, len(ctxs))
Equals(t, "project1", ctxs[0].RepoRelDir)
Equals(t, "default", ctxs[0].Workspace)
- var nilProjectConfig *valid.Project
- Equals(t, nilProjectConfig, ctxs[0].ProjectConfig)
+ project1Config := valid.Project{
+ Dir: "project1",
+ Workspace: events.DefaultWorkspace,
+ Autoplan: getDefaultAutoPlan(),
+ }
+
+ project2Config := valid.Project{
+ Dir: "project2",
+ Workspace: events.DefaultWorkspace,
+ Autoplan: getDefaultAutoPlan(),
+ }
+ Equals(t, project1Config, *ctxs[0].ProjectConfig)
Equals(t, "project2", ctxs[1].RepoRelDir)
Equals(t, "default", ctxs[1].Workspace)
- Equals(t, nilProjectConfig, ctxs[1].ProjectConfig)
+ Equals(t, project2Config, *ctxs[1].ProjectConfig)
}
// Test building plan command for multiple projects when the comment
@@ -839,69 +1027,6 @@ func TestDefaultProjectCommandBuilder_BuildMultiApply(t *testing.T) {
Equals(t, "workspace2", ctxs[3].Workspace)
}
-// Test that if repo config is disabled we error out if there's an atlantis.yaml
-// file.
-func TestDefaultProjectCommandBuilder_RepoConfigDisabled(t *testing.T) {
- RegisterMockTestingT(t)
- workingDir := mocks.NewMockWorkingDir()
-
- tmpDir, cleanup := DirStructure(t, map[string]interface{}{
- "pulldir": map[string]interface{}{
- "workspace": map[string]interface{}{},
- },
- })
- defer cleanup()
- repoDir := filepath.Join(tmpDir, "pulldir/workspace")
- err := ioutil.WriteFile(filepath.Join(repoDir, yaml.AtlantisYAMLFilename), nil, 0600)
- Ok(t, err)
-
- When(workingDir.Clone(
- matchers.AnyPtrToLoggingSimpleLogger(),
- matchers.AnyModelsRepo(),
- matchers.AnyModelsRepo(),
- matchers.AnyModelsPullRequest(),
- AnyString())).ThenReturn(repoDir, nil)
- When(workingDir.GetWorkingDir(
- matchers.AnyModelsRepo(),
- matchers.AnyModelsPullRequest(),
- AnyString())).ThenReturn(repoDir, nil)
-
- builder := &events.DefaultProjectCommandBuilder{
- WorkingDirLocker: events.NewDefaultWorkingDirLocker(),
- WorkingDir: workingDir,
- ParserValidator: &yaml.ParserValidator{},
- VCSClient: nil,
- ProjectFinder: &events.DefaultProjectFinder{},
- AllowRepoConfig: false,
- AllowRepoConfigFlag: "allow-repo-config",
- CommentBuilder: &events.CommentParser{},
- }
-
- ctx := &events.CommandContext{
- BaseRepo: models.Repo{},
- HeadRepo: models.Repo{},
- Pull: models.PullRequest{},
- User: models.User{},
- Log: logging.NewNoopLogger(),
- }
- _, err = builder.BuildAutoplanCommands(ctx)
- ErrEquals(t, "atlantis.yaml files not allowed because Atlantis is not running with --allow-repo-config", err)
-
- commentCmd := &events.CommentCommand{
- RepoRelDir: "",
- Flags: nil,
- Name: 0,
- Verbose: false,
- Workspace: "workspace",
- ProjectName: "",
- }
- _, err = builder.BuildPlanCommands(ctx, commentCmd)
- ErrEquals(t, "atlantis.yaml files not allowed because Atlantis is not running with --allow-repo-config", err)
-
- _, err = builder.BuildApplyCommands(ctx, commentCmd)
- ErrEquals(t, "atlantis.yaml files not allowed because Atlantis is not running with --allow-repo-config", err)
-}
-
// Test that if a directory has a list of workspaces configured then we don't
// allow plans for other workspace names.
func TestDefaultProjectCommandBuilder_WrongWorkspaceName(t *testing.T) {
@@ -967,3 +1092,10 @@ projects:
}
func String(v string) *string { return &v }
+
+func getDefaultAutoPlan() valid.Autoplan {
+ return valid.Autoplan{
+ WhenModified: []string{"**/*.tf*"},
+ Enabled: true,
+ }
+}
diff --git a/server/events/yaml/parser_validator.go b/server/events/yaml/parser_validator.go
index 14917753af..b13eda728a 100644
--- a/server/events/yaml/parser_validator.go
+++ b/server/events/yaml/parser_validator.go
@@ -22,7 +22,7 @@ type ParserValidator struct{}
// If there was no config file, then this can be detected by checking the type
// of error: os.IsNotExist(error) but it's instead preferred to check with
// HasConfigFile.
-func (p *ParserValidator) ReadConfig(repoDir string) (valid.Config, error) {
+func (p *ParserValidator) ReadConfig(repoDir string, repoConfig raw.RepoConfig, repoName string, allowAllRepoConfig bool) (valid.Config, error) {
configFile := p.configFilePath(repoDir)
configData, err := ioutil.ReadFile(configFile) // nolint: gosec
@@ -38,13 +38,33 @@ func (p *ParserValidator) ReadConfig(repoDir string) (valid.Config, error) {
}
// If the config file exists, parse it.
- config, err := p.parseAndValidate(configData)
+ config, err := p.parseAndValidate(configData, repoConfig, repoName, allowAllRepoConfig)
if err != nil {
return valid.Config{}, errors.Wrapf(err, "parsing %s", AtlantisYAMLFilename)
}
return config, err
}
+func (p *ParserValidator) ReadServerConfig(configFile string) (raw.RepoConfig, error) {
+ configData, err := ioutil.ReadFile(configFile) // nolint: gosec
+
+ // NOTE: the error we return here must also be os.IsNotExist since that's
+ // what our callers use to detect a missing config file.
+ if err != nil && os.IsNotExist(err) {
+ return raw.RepoConfig{}, err
+ }
+
+ // If it exists but we couldn't read it return an error.
+ if err != nil {
+ return raw.RepoConfig{}, errors.Wrapf(err, "unable to read %s file", configFile)
+ }
+ config, err := p.parseAndValidateServerConfig(configData)
+ if err != nil {
+ return raw.RepoConfig{}, errors.Wrapf(err, "parsing %s", configFile)
+ }
+ return config, err
+}
+
func (p *ParserValidator) HasConfigFile(repoDir string) (bool, error) {
_, err := os.Stat(p.configFilePath(repoDir))
if os.IsNotExist(err) {
@@ -60,7 +80,25 @@ func (p *ParserValidator) configFilePath(repoDir string) string {
return filepath.Join(repoDir, AtlantisYAMLFilename)
}
-func (p *ParserValidator) parseAndValidate(configData []byte) (valid.Config, error) {
+func (p *ParserValidator) parseAndValidateServerConfig(configData []byte) (raw.RepoConfig, error) {
+ var config raw.RepoConfig
+ if err := yaml.UnmarshalStrict(configData, &config); err != nil {
+ return raw.RepoConfig{}, err
+ }
+
+ validation.ErrorTag = "yaml"
+
+ if err := config.Validate(); err != nil {
+ return raw.RepoConfig{}, err
+ }
+
+ if err := p.validateRepoWorkflows(config); err != nil {
+ return raw.RepoConfig{}, err
+ }
+ return config, nil
+}
+
+func (p *ParserValidator) parseAndValidate(configData []byte, repoConfig raw.RepoConfig, repoName string, allowAllRepoConfig bool) (valid.Config, error) {
var rawConfig raw.Config
if err := yaml.UnmarshalStrict(configData, &rawConfig); err != nil {
return valid.Config{}, err
@@ -69,6 +107,12 @@ func (p *ParserValidator) parseAndValidate(configData []byte) (valid.Config, err
// Set ErrorTag to yaml so it uses the YAML field names in error messages.
validation.ErrorTag = "yaml"
+ var err error
+ rawConfig, err = p.ValidateOverridesAndMergeConfig(rawConfig, repoConfig, repoName, allowAllRepoConfig)
+ if err != nil {
+ return valid.Config{}, err
+ }
+
if err := rawConfig.Validate(); err != nil {
return valid.Config{}, err
}
@@ -86,6 +130,57 @@ func (p *ParserValidator) parseAndValidate(configData []byte) (valid.Config, err
return validConfig, nil
}
+func (p *ParserValidator) getOverrideErrorMessage(key string) error {
+ return fmt.Errorf("%q cannot be specified in %q by default. To enable this, add %q to %q in the server side repo config", key, AtlantisYAMLFilename, key, raw.AllowedOverridesKey)
+}
+
+// Checks any sensitive fields present in atlantis.yaml against the list of allowed overrides and merge the configuration
+// from the server side repo config with project settings found in atlantis.yaml
+func (p *ParserValidator) ValidateOverridesAndMergeConfig(config raw.Config, repoConfig raw.RepoConfig, repoName string, allowAllRepoConfig bool) (raw.Config, error) {
+ var finalProjects []raw.Project
+
+ // Start with a repo regex that will match everything, but sets no allowed_overrides. This will
+ // provide a default behavior of "deny all overrides" if no server side defined repos are matched
+ lastMatchingRepo := raw.Repo{ID: "/.*/"}
+
+ // Find the last repo to match. If multiple are found, the last matched repo's settings will be used
+ for _, repo := range repoConfig.Repos {
+ matches, err := repo.Matches(repoName)
+ if err != nil {
+ return config, err
+ } else if matches {
+ lastMatchingRepo = repo
+ }
+ }
+
+ for _, project := range config.Projects {
+ // If atlantis.yaml has apply requirements, only honor them if this key is allowed in a server side
+ // --repo-config or if --allow-repo-config is specified.
+ if len(project.ApplyRequirements) > 0 && !(allowAllRepoConfig || lastMatchingRepo.IsOverrideAllowed(raw.ApplyRequirementsKey)) {
+ return config, p.getOverrideErrorMessage(raw.ApplyRequirementsKey)
+ }
+
+ // Do not allow projects to specify a workflow unless it is explicitly allowed
+ if project.Workflow != nil && !(allowAllRepoConfig || lastMatchingRepo.IsOverrideAllowed(raw.WorkflowKey)) {
+ return config, p.getOverrideErrorMessage(raw.WorkflowKey)
+ } else if project.Workflow == nil && lastMatchingRepo.Workflow != nil {
+ project.Workflow = lastMatchingRepo.Workflow
+ }
+
+ finalProjects = append(finalProjects, project)
+ }
+ config.Projects = finalProjects
+
+ if len(config.Workflows) > 0 && !(allowAllRepoConfig || lastMatchingRepo.AllowCustomWorkflows) {
+ return config, fmt.Errorf("%q cannot be specified in %q by default. To enable this, set %q to true in the server side repo config", raw.CustomWorkflowsKey, AtlantisYAMLFilename, raw.CustomWorkflowsKey)
+ } else if len(config.Workflows) == 0 {
+ if len(repoConfig.Workflows) > 0 {
+ config.Workflows = repoConfig.Workflows
+ }
+ }
+ return config, nil
+}
+
func (p *ParserValidator) validateProjectNames(config valid.Config) error {
// First, validate that all names are unique.
seen := make(map[string]bool)
@@ -132,6 +227,28 @@ func (p *ParserValidator) validateWorkflows(config raw.Config) error {
return nil
}
+func (p *ParserValidator) validateRepoWorkflows(config raw.RepoConfig) error {
+ for _, repo := range config.Repos {
+ if err := p.validateRepoWorkflowExists(repo, config.Workflows); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (p *ParserValidator) validateRepoWorkflowExists(repo raw.Repo, workflows map[string]raw.Workflow) error {
+ if repo.Workflow == nil {
+ return nil
+ }
+ workflow := *repo.Workflow
+ for w := range workflows {
+ if w == workflow {
+ return nil
+ }
+ }
+ return fmt.Errorf("workflow %q is not defined", workflow)
+}
+
func (p *ParserValidator) validateWorkflowExists(project raw.Project, workflows map[string]raw.Workflow) error {
if project.Workflow == nil {
return nil
diff --git a/server/events/yaml/parser_validator_test.go b/server/events/yaml/parser_validator_test.go
index 28e7b56dee..4117f01e91 100644
--- a/server/events/yaml/parser_validator_test.go
+++ b/server/events/yaml/parser_validator_test.go
@@ -1,6 +1,8 @@
package yaml_test
import (
+ "fmt"
+ "github.com/runatlantis/atlantis/server/events/yaml/raw"
"io/ioutil"
"os"
"path/filepath"
@@ -14,7 +16,7 @@ import (
func TestReadConfig_DirDoesNotExist(t *testing.T) {
r := yaml.ParserValidator{}
- _, err := r.ReadConfig("/not/exist")
+ _, err := r.ReadConfig("/not/exist", raw.RepoConfig{}, "", false)
Assert(t, os.IsNotExist(err), "exp nil ptr")
exists, err := r.HasConfigFile("/not/exist")
@@ -27,7 +29,7 @@ func TestReadConfig_FileDoesNotExist(t *testing.T) {
defer cleanup()
r := yaml.ParserValidator{}
- _, err := r.ReadConfig(tmpDir)
+ _, err := r.ReadConfig(tmpDir, raw.RepoConfig{}, "", false)
Assert(t, os.IsNotExist(err), "exp nil ptr")
exists, err := r.HasConfigFile(tmpDir)
@@ -42,7 +44,7 @@ func TestReadConfig_BadPermissions(t *testing.T) {
Ok(t, err)
r := yaml.ParserValidator{}
- _, err = r.ReadConfig(tmpDir)
+ _, err = r.ReadConfig(tmpDir, raw.RepoConfig{}, "", false)
ErrContains(t, "unable to read atlantis.yaml file: ", err)
}
@@ -74,14 +76,13 @@ func TestReadConfig_UnmarshalErrors(t *testing.T) {
err := ioutil.WriteFile(filepath.Join(tmpDir, "atlantis.yaml"), []byte(c.input), 0600)
Ok(t, err)
r := yaml.ParserValidator{}
- _, err = r.ReadConfig(tmpDir)
+ _, err = r.ReadConfig(tmpDir, raw.RepoConfig{}, "", false)
ErrEquals(t, c.expErr, err)
})
}
}
-func TestReadConfig(t *testing.T) {
- tfVersion, _ := version.NewVersion("v0.11.0")
+func TestReadConfig_CommonKeys(t *testing.T) {
cases := []struct {
description string
input string
@@ -160,6 +161,143 @@ projects:
Workflows: map[string]valid.Workflow{},
},
},
+ {
+ description: "project dir with ..",
+ input: `
+version: 2
+projects:
+- dir: ..`,
+ expErr: "projects: (0: (dir: cannot contain '..'.).).",
+ },
+
+ // Project must have dir set.
+ {
+ description: "project with no config",
+ input: `
+version: 2
+projects:
+-`,
+ expErr: "projects: (0: (dir: cannot be blank.).).",
+ },
+ {
+ description: "project with no config at index 1",
+ input: `
+version: 2
+projects:
+- dir: "."
+-`,
+ expErr: "projects: (1: (dir: cannot be blank.).).",
+ },
+ {
+ description: "project with unknown key",
+ input: `
+version: 2
+projects:
+- unknown: value`,
+ expErr: "yaml: unmarshal errors:\n line 4: field unknown not found in struct raw.Project",
+ },
+ {
+ description: "two projects with same dir/workspace without names",
+ input: `
+version: 2
+projects:
+- dir: .
+ workspace: workspace
+- dir: .
+ workspace: workspace`,
+ expErr: "there are two or more projects with dir: \".\" workspace: \"workspace\" that are not all named; they must have a 'name' key so they can be targeted for apply's separately",
+ },
+ {
+ description: "two projects with same dir/workspace only one with name",
+ input: `
+version: 2
+projects:
+- name: myname
+ dir: .
+ workspace: workspace
+- dir: .
+ workspace: workspace`,
+ expErr: "there are two or more projects with dir: \".\" workspace: \"workspace\" that are not all named; they must have a 'name' key so they can be targeted for apply's separately",
+ },
+ {
+ description: "two projects with same dir/workspace both with same name",
+ input: `
+version: 2
+projects:
+- name: myname
+ dir: .
+ workspace: workspace
+- name: myname
+ dir: .
+ workspace: workspace`,
+ expErr: "found two or more projects with name \"myname\"; project names must be unique",
+ },
+ {
+ description: "two projects with same dir/workspace with different names",
+ input: `
+version: 2
+projects:
+- name: myname
+ dir: .
+ workspace: workspace
+- name: myname2
+ dir: .
+ workspace: workspace`,
+ exp: valid.Config{
+ Version: 2,
+ Projects: []valid.Project{
+ {
+ Name: String("myname"),
+ Dir: ".",
+ Workspace: "workspace",
+ Autoplan: valid.Autoplan{
+ WhenModified: []string{"**/*.tf*"},
+ Enabled: true,
+ },
+ },
+ {
+ Name: String("myname2"),
+ Dir: ".",
+ Workspace: "workspace",
+ Autoplan: valid.Autoplan{
+ WhenModified: []string{"**/*.tf*"},
+ Enabled: true,
+ },
+ },
+ },
+ Workflows: map[string]valid.Workflow{},
+ },
+ },
+ }
+
+ tmpDir, cleanup := TempDir(t)
+ defer cleanup()
+
+ for _, c := range cases {
+ t.Run(c.description, func(t *testing.T) {
+ err := ioutil.WriteFile(filepath.Join(tmpDir, "atlantis.yaml"), []byte(c.input), 0600)
+ Ok(t, err)
+
+ r := yaml.ParserValidator{}
+ act, err := r.ReadConfig(tmpDir, raw.RepoConfig{}, "", false)
+ if c.expErr != "" {
+ ErrEquals(t, "parsing atlantis.yaml: "+c.expErr, err)
+ return
+ }
+ Ok(t, err)
+ Equals(t, c.exp, act)
+ })
+ }
+}
+
+func TestReadConfig_AllowRepoConfig(t *testing.T) {
+ tfVersion, _ := version.NewVersion("v0.11.0")
+ cases := []struct {
+ description string
+ input string
+ expErr string
+ exp valid.Config
+ }{
{
description: "project fields set except autoplan",
input: `
@@ -294,41 +432,6 @@ workflows:
},
},
},
- {
- description: "project dir with ..",
- input: `
-version: 2
-projects:
-- dir: ..`,
- expErr: "projects: (0: (dir: cannot contain '..'.).).",
- },
-
- // Project must have dir set.
- {
- description: "project with no config",
- input: `
-version: 2
-projects:
--`,
- expErr: "projects: (0: (dir: cannot be blank.).).",
- },
- {
- description: "project with no config at index 1",
- input: `
-version: 2
-projects:
-- dir: "."
--`,
- expErr: "projects: (1: (dir: cannot be blank.).).",
- },
- {
- description: "project with unknown key",
- input: `
-version: 2
-projects:
-- unknown: value`,
- expErr: "yaml: unmarshal errors:\n line 4: field unknown not found in struct raw.Project",
- },
{
description: "referencing workflow that doesn't exist",
input: `
@@ -338,78 +441,6 @@ projects:
workflow: undefined`,
expErr: "workflow \"undefined\" is not defined",
},
- {
- description: "two projects with same dir/workspace without names",
- input: `
-version: 2
-projects:
-- dir: .
- workspace: workspace
-- dir: .
- workspace: workspace`,
- expErr: "there are two or more projects with dir: \".\" workspace: \"workspace\" that are not all named; they must have a 'name' key so they can be targeted for apply's separately",
- },
- {
- description: "two projects with same dir/workspace only one with name",
- input: `
-version: 2
-projects:
-- name: myname
- dir: .
- workspace: workspace
-- dir: .
- workspace: workspace`,
- expErr: "there are two or more projects with dir: \".\" workspace: \"workspace\" that are not all named; they must have a 'name' key so they can be targeted for apply's separately",
- },
- {
- description: "two projects with same dir/workspace both with same name",
- input: `
-version: 2
-projects:
-- name: myname
- dir: .
- workspace: workspace
-- name: myname
- dir: .
- workspace: workspace`,
- expErr: "found two or more projects with name \"myname\"; project names must be unique",
- },
- {
- description: "two projects with same dir/workspace with different names",
- input: `
-version: 2
-projects:
-- name: myname
- dir: .
- workspace: workspace
-- name: myname2
- dir: .
- workspace: workspace`,
- exp: valid.Config{
- Version: 2,
- Projects: []valid.Project{
- {
- Name: String("myname"),
- Dir: ".",
- Workspace: "workspace",
- Autoplan: valid.Autoplan{
- WhenModified: []string{"**/*.tf*"},
- Enabled: true,
- },
- },
- {
- Name: String("myname2"),
- Dir: ".",
- Workspace: "workspace",
- Autoplan: valid.Autoplan{
- WhenModified: []string{"**/*.tf*"},
- Enabled: true,
- },
- },
- },
- Workflows: map[string]valid.Workflow{},
- },
- },
}
tmpDir, cleanup := TempDir(t)
@@ -421,7 +452,7 @@ projects:
Ok(t, err)
r := yaml.ParserValidator{}
- act, err := r.ReadConfig(tmpDir)
+ act, err := r.ReadConfig(tmpDir, raw.RepoConfig{}, "", true)
if c.expErr != "" {
ErrEquals(t, "parsing atlantis.yaml: "+c.expErr, err)
return
@@ -430,9 +461,9 @@ projects:
Equals(t, c.exp, act)
})
}
-}
-func TestReadConfig_Successes(t *testing.T) {
+}
+func TestReadConfig_Successes_AllowRepoConfig(t *testing.T) {
basicProjects := []valid.Project{
{
Autoplan: valid.Autoplan{
@@ -685,13 +716,342 @@ workflows:
Ok(t, err)
r := yaml.ParserValidator{}
- act, err := r.ReadConfig(tmpDir)
+ act, err := r.ReadConfig(tmpDir, raw.RepoConfig{}, "", true)
Ok(t, err)
Equals(t, c.expOutput, act)
})
}
}
+func TestReadConfig_ServerSideRepoConfig(t *testing.T) {
+ cases := []struct {
+ description string
+ atlantisYaml string
+ repoYaml string
+ repoName string
+ expErr string
+ exp valid.Config
+ }{
+ {
+ description: "atlantis config with workflow denied by repo config",
+ repoName: "anything",
+ atlantisYaml: `
+version: 2
+projects:
+- dir: .
+ workflow: projworkflow
+`,
+ repoYaml: `
+repos:
+- id: /.*/
+`,
+ expErr: `"workflow" cannot be specified in "atlantis.yaml" by default. To enable this, add "workflow" to "allowed_overrides" in the server side repo config`,
+ },
+ {
+ description: "atlantis config with custom workflows denied by repo config",
+ repoName: "anything",
+ atlantisYaml: `
+version: 2
+projects:
+- dir: .
+ workflow: projworkflow
+workflows:
+ projworkflow: ~
+`,
+ repoYaml: `
+repos:
+- id: /.*/
+ allowed_overrides: ["workflow"]
+`,
+ expErr: `"workflows" cannot be specified in "atlantis.yaml" by default. To enable this, set "workflows" to true in the server side repo config`,
+ },
+ {
+ description: "atlantis config with workflow override allowed by repo config",
+ repoName: "thisproject",
+ atlantisYaml: `
+version: 2
+projects:
+- dir: .
+ workflow: workflow2
+`,
+ repoYaml: `
+repos:
+- id: /.*/
+ workflow: workflow1
+ allowed_overrides: ["workflow"]
+workflows:
+ workflow1: ~
+ workflow2: ~
+`,
+ exp: valid.Config{
+ Version: 2,
+ Projects: []valid.Project{
+ {
+ Dir: ".",
+ Workspace: "default",
+ Workflow: String("workflow2"),
+ Autoplan: valid.Autoplan{
+ WhenModified: []string{"**/*.tf*"},
+ Enabled: true,
+ },
+ },
+ },
+ Workflows: map[string]valid.Workflow{
+ "workflow1": {},
+ "workflow2": {},
+ },
+ },
+ },
+ {
+ description: "atlantis config with no workflow, using workflow from repo config",
+ repoName: "thisproject",
+ atlantisYaml: `
+version: 2
+projects:
+- dir: .
+`,
+ repoYaml: `
+repos:
+- id: /.*/
+ workflow: workflow1
+ allowed_overrides: ["workflow"]
+workflows:
+ workflow1: ~
+ workflow2: ~
+`,
+ exp: valid.Config{
+ Version: 2,
+ Projects: []valid.Project{
+ {
+ Dir: ".",
+ Workspace: "default",
+ Workflow: String("workflow1"),
+ Autoplan: valid.Autoplan{
+ WhenModified: []string{"**/*.tf*"},
+ Enabled: true,
+ },
+ },
+ },
+ Workflows: map[string]valid.Workflow{
+ "workflow1": {},
+ "workflow2": {},
+ },
+ },
+ },
+ {
+ description: "atlantis config with apply_requirements denied by repo config",
+ repoName: "anything",
+ atlantisYaml: `
+version: 2
+projects:
+- dir: .
+ apply_requirements: ["approved"]
+`,
+ repoYaml: `
+repos:
+- id: /.*/
+`,
+ expErr: `"apply_requirements" cannot be specified in "atlantis.yaml" by default. To enable this, add "apply_requirements" to "allowed_overrides" in the server side repo config`,
+ },
+ {
+ description: "last matching repo should be used",
+ repoName: "thisproject",
+ atlantisYaml: `
+version: 2
+projects:
+- dir: .
+`,
+ repoYaml: `
+repos:
+- id: /.*/
+ workflow: workflow1
+- id: "thisproject"
+ workflow: workflow2
+workflows:
+ workflow1: ~
+ workflow2: ~
+`,
+ exp: valid.Config{
+ Version: 2,
+ Projects: []valid.Project{
+ {
+ Dir: ".",
+ Workspace: "default",
+ Workflow: String("workflow2"),
+ Autoplan: valid.Autoplan{
+ WhenModified: []string{"**/*.tf*"},
+ Enabled: true,
+ },
+ },
+ },
+ Workflows: map[string]valid.Workflow{
+ "workflow1": {},
+ "workflow2": {},
+ },
+ },
+ },
+ {
+ description: "atlantis config uses a workflow that doesn't exist in atlantis.yaml or repo config",
+ repoName: "anything",
+ atlantisYaml: `
+version: 2
+projects:
+- dir: .
+ workflow: notexist
+`,
+ repoYaml: `
+repos:
+- id: /.*/
+ allowed_overrides: ["workflow"]
+`,
+ expErr: `workflow "notexist" is not defined`,
+ },
+ {
+ description: "repo config contains invalid regex",
+ repoName: "anything",
+ atlantisYaml: `
+version: 2
+projects:
+- dir: .
+`,
+ repoYaml: `
+repos:
+- id: /inva\lid.regex/
+`,
+ expErr: "regex compile of repo.ID `/inva\\lid.regex/`: error parsing regexp: invalid escape sequence: `\\l`",
+ },
+ }
+
+ tmpDir, cleanup := TempDir(t)
+ defer cleanup()
+
+ for _, c := range cases {
+ t.Run(c.description, func(t *testing.T) {
+ err := ioutil.WriteFile(filepath.Join(tmpDir, "atlantis.yaml"), []byte(c.atlantisYaml), 0600)
+ Ok(t, err)
+
+ err = ioutil.WriteFile(filepath.Join(tmpDir, "repo.yaml"), []byte(c.repoYaml), 0600)
+ Ok(t, err)
+
+ r := yaml.ParserValidator{}
+ repoConfig, err := r.ReadServerConfig(filepath.Join(tmpDir, "repo.yaml"))
+ Ok(t, err)
+ act, err := r.ReadConfig(tmpDir, repoConfig, c.repoName, false)
+ if c.expErr != "" {
+ ErrEquals(t, "parsing atlantis.yaml: "+c.expErr, err)
+ return
+ }
+ Equals(t, c.exp, act)
+ })
+ }
+
+}
+
+func TestReadServerConfig_DirDoesNotExist(t *testing.T) {
+ r := yaml.ParserValidator{}
+ _, err := r.ReadServerConfig("/not/exist")
+ Assert(t, os.IsNotExist(err), "exp nil ptr")
+}
+
+func TestReadServerConfig_FileDoesNotExist(t *testing.T) {
+ tmpDir, cleanup := TempDir(t)
+ defer cleanup()
+
+ r := yaml.ParserValidator{}
+ _, err := r.ReadServerConfig(tmpDir + "repos.yaml")
+ Assert(t, os.IsNotExist(err), "exp nil ptr")
+}
+
+func TestReadServerConfig_BadPermissions(t *testing.T) {
+ tmpDir, cleanup := TempDir(t)
+ defer cleanup()
+ repoYamlFile := filepath.Join(tmpDir, "repos.yaml")
+ err := ioutil.WriteFile(repoYamlFile, nil, 0000)
+ Ok(t, err)
+
+ r := yaml.ParserValidator{}
+ _, err = r.ReadServerConfig(repoYamlFile)
+ ErrContains(t, "unable to read "+repoYamlFile, err)
+}
+
+func TestServerReadConfig_UnmarshalErrors(t *testing.T) {
+ // We only have a few cases here because we assume the YAML library to be
+ // well tested. See https://github.com/go-yaml/yaml/blob/v2/decode_test.go#L810.
+ cases := []struct {
+ description string
+ input string
+ expErr string
+ }{
+ {
+ "random characters",
+ "slkjds",
+ "yaml: unmarshal errors:\n line 1: cannot unmarshal !!str `slkjds` into raw.RepoConfig",
+ },
+ {
+ "just a colon",
+ ":",
+ "yaml: did not find expected key",
+ },
+ }
+
+ tmpDir, cleanup := TempDir(t)
+ defer cleanup()
+
+ repoYamlFile := filepath.Join(tmpDir, "repos.yaml")
+ for _, c := range cases {
+ t.Run(c.description, func(t *testing.T) {
+ err := ioutil.WriteFile(repoYamlFile, []byte(c.input), 0600)
+ Ok(t, err)
+ r := yaml.ParserValidator{}
+ _, err = r.ReadServerConfig(repoYamlFile)
+ c.expErr = fmt.Sprintf("parsing %s: %s", repoYamlFile, c.expErr)
+ ErrEquals(t, c.expErr, err)
+ })
+ }
+}
+
+func TestReadServerConfigValidation(t *testing.T) {
+ cases := []struct {
+ description string
+ input string
+ expErr string
+ }{
+ {
+ "workflow doesn't exist",
+ `
+repos:
+- id: /.*/
+ workflow: notexist
+`,
+ `workflow "notexist" is not defined`,
+ },
+ {
+ description: "invalid override",
+ input: `
+repos:
+- id: /.*/
+ allowed_overrides: ["notvalid"]
+`,
+ expErr: "repos: (0: (allowed_overrides: value must be one of [apply_requirements workflow].).).",
+ },
+ }
+
+ tmpDir, cleanup := TempDir(t)
+ defer cleanup()
+
+ repoYamlFile := filepath.Join(tmpDir, "repos.yaml")
+ for _, c := range cases {
+ t.Run(c.description, func(t *testing.T) {
+ err := ioutil.WriteFile(repoYamlFile, []byte(c.input), 0600)
+ Ok(t, err)
+ r := yaml.ParserValidator{}
+ _, err = r.ReadServerConfig(repoYamlFile)
+ c.expErr = fmt.Sprintf("parsing %s: %s", repoYamlFile, c.expErr)
+ ErrEquals(t, c.expErr, err)
+ })
+ }
+}
+
// String is a helper routine that allocates a new string value
// to store v and returns a pointer to it.
func String(v string) *string { return &v }
diff --git a/server/events/yaml/raw/repo_config.go b/server/events/yaml/raw/repo_config.go
new file mode 100644
index 0000000000..f87e1ae811
--- /dev/null
+++ b/server/events/yaml/raw/repo_config.go
@@ -0,0 +1,75 @@
+package raw
+
+import (
+ "fmt"
+ "github.com/go-ozzo/ozzo-validation"
+ "regexp"
+ "strings"
+)
+
+const ApplyRequirementsKey string = "apply_requirements"
+const WorkflowKey string = "workflow"
+const CustomWorkflowsKey string = "workflows"
+const AllowedOverridesKey string = "allowed_overrides"
+
+type RepoConfig struct {
+ Repos []Repo `yaml:"repos"`
+ Workflows map[string]Workflow `yaml:"workflows"`
+}
+
+type Repo struct {
+ ID string `yaml:"id"`
+ ApplyRequirements []string `yaml:"apply_requirements"`
+ Workflow *string `yaml:"workflow,omitempty"`
+ AllowedOverrides []string `yaml:"allowed_overrides"`
+ AllowCustomWorkflows bool `yaml:"allow_custom_workflows"`
+}
+
+func (r RepoConfig) Validate() error {
+ return validation.ValidateStruct(&r,
+ validation.Field(&r.Repos),
+ validation.Field(&r.Workflows))
+}
+
+func (r Repo) Validate() error {
+ return validation.ValidateStruct(&r,
+ validation.Field(&r.ID, validation.Required),
+ validation.Field(&r.AllowedOverrides, validation.By(checkOverrideValues)),
+ )
+}
+
+func checkOverrideValues(values interface{}) error {
+ if allValues, _ := values.([]string); allValues != nil {
+ validOverrides := []string{"apply_requirements", "workflow"}
+ for _, value := range allValues {
+ for _, validStr := range validOverrides {
+ if value == validStr {
+ return nil
+ }
+ }
+ }
+ return fmt.Errorf("value must be one of %v", validOverrides)
+ }
+ return nil
+}
+
+func (r *Repo) IsOverrideAllowed(override string) bool {
+ for _, allowed := range r.AllowedOverrides {
+ if allowed == override {
+ return true
+ }
+ }
+ return false
+}
+
+func (r *Repo) Matches(repoName string) (bool, error) {
+ if strings.Index(r.ID, "/") == 0 && strings.LastIndex(r.ID, "/") == len(r.ID)-1 {
+ matchString := strings.Trim(r.ID, "/")
+ compiled, err := regexp.Compile(matchString)
+ if err != nil {
+ return false, fmt.Errorf("regex compile of repo.ID `%s`: %s", r.ID, err)
+ }
+ return compiled.MatchString(repoName), nil
+ }
+ return repoName == r.ID, nil
+}
diff --git a/server/events_controller_test.go b/server/events_controller_test.go
index 07950db452..30a7c800ab 100644
--- a/server/events_controller_test.go
+++ b/server/events_controller_test.go
@@ -578,6 +578,7 @@ func TestPost_BBServerPullClosed(t *testing.T) {
expRepo := models.Repo{
FullName: "project/repository",
+ FullNameWithHost: "bbserver.com/project/repository",
Owner: "project",
Name: "repository",
CloneURL: "https://bb-user:bb-token@bbserver.com/scm/proj/repository.git",
diff --git a/server/server.go b/server/server.go
index 686673e104..63284ddd1f 100644
--- a/server/server.go
+++ b/server/server.go
@@ -21,6 +21,7 @@ import (
"flag"
"fmt"
"github.com/runatlantis/atlantis/server/events/db"
+ "github.com/runatlantis/atlantis/server/events/yaml/raw"
"log"
"net/http"
"net/url"
@@ -194,6 +195,21 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) {
return nil, errors.Wrapf(err,
"parsing --%s flag %q", config.AtlantisURLFlag, userConfig.AtlantisURL)
}
+ validator := &yaml.ParserValidator{}
+
+ // This is a default config that will allow safe keys to be used in atlantis.yaml by default
+ // but restrict all sensitive keys. This is used if the server is started without --repo-config.
+ repoConfig := raw.RepoConfig{
+ Repos: []raw.Repo{{ID: "/.*/"}},
+ }
+
+ if userConfig.RepoConfig != "" {
+ repoConfig, err = validator.ReadServerConfig(userConfig.RepoConfig)
+ if err != nil {
+ return nil, err
+ }
+ }
+
underlyingRouter := mux.NewRouter()
router := &Router{
AtlantisURL: parsedURL,
@@ -235,12 +251,13 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) {
AllowForkPRs: userConfig.AllowForkPRs,
AllowForkPRsFlag: config.AllowForkPRsFlag,
ProjectCommandBuilder: &events.DefaultProjectCommandBuilder{
- ParserValidator: &yaml.ParserValidator{},
+ ParserValidator: validator,
ProjectFinder: &events.DefaultProjectFinder{},
VCSClient: vcsClient,
WorkingDir: workingDir,
WorkingDirLocker: workingDirLocker,
AllowRepoConfig: userConfig.AllowRepoConfig,
+ RepoConfig: repoConfig,
AllowRepoConfigFlag: config.AllowRepoConfigFlag,
PendingPlanFinder: pendingPlanFinder,
CommentBuilder: commentParser,
diff --git a/server/server_test.go b/server/server_test.go
index 951d1f6e1e..6558cb4894 100644
--- a/server/server_test.go
+++ b/server/server_test.go
@@ -16,10 +16,13 @@ package server_test
import (
"bytes"
"errors"
+ "github.com/runatlantis/atlantis/server/events"
+ "github.com/runatlantis/atlantis/server/events/yaml/raw"
"io/ioutil"
"net/http"
"net/http/httptest"
"net/url"
+ "path/filepath"
"strings"
"testing"
"time"
@@ -44,6 +47,34 @@ func TestNewServer(t *testing.T) {
Ok(t, err)
}
+func TestRepoConfig(t *testing.T) {
+ tmpDir, err := ioutil.TempDir("", "")
+ Ok(t, err)
+
+ repoYaml := `
+repos:
+- id: "https://github.com/runatlantis/atlantis"
+`
+ expConfig := raw.RepoConfig{
+ Repos: []raw.Repo{
+ {
+ ID: "https://github.com/runatlantis/atlantis",
+ },
+ },
+ }
+ repoFileLocation := filepath.Join(tmpDir, "repos.yaml")
+ err = ioutil.WriteFile(repoFileLocation, []byte(repoYaml), 0600)
+ Ok(t, err)
+
+ s, err := server.NewServer(server.UserConfig{
+ DataDir: tmpDir,
+ RepoConfig: repoFileLocation,
+ AtlantisURL: "http://example.com",
+ }, server.Config{})
+ Ok(t, err)
+ Equals(t, s.CommandRunner.ProjectCommandBuilder.(*events.DefaultProjectCommandBuilder).RepoConfig, expConfig)
+}
+
func TestNewServer_InvalidAtlantisURL(t *testing.T) {
tmpDir, err := ioutil.TempDir("", "")
Ok(t, err)
diff --git a/server/user_config.go b/server/user_config.go
index b188751746..b3e609dbe1 100644
--- a/server/user_config.go
+++ b/server/user_config.go
@@ -26,6 +26,7 @@ type UserConfig struct {
GitlabWebhookSecret string `mapstructure:"gitlab-webhook-secret"`
LogLevel string `mapstructure:"log-level"`
Port int `mapstructure:"port"`
+ RepoConfig string `mapstructure:"repo-config"`
RepoWhitelist string `mapstructure:"repo-whitelist"`
// RequireApproval is whether to require pull request approval before
// allowing terraform apply's to be run.