From 9819e5ff416d56f35e740771fe1af8470a16d3a3 Mon Sep 17 00:00:00 2001 From: John Julien Date: Fri, 15 Feb 2019 15:04:51 -0600 Subject: [PATCH] Added new --repo-config option and deprecated --allow-repo-config This enables atlantis.yaml in all repos, but by default restricts certain sensitive keys from being used. The keys apply_requirements, workflow, and workflows can only be specified in an atlantis.yaml file if explicitly allowed by a server side repo config. The repo config file provides the ability to specify a default set of workflows, and default values for apply_requirements and workflow to use use on a per repo basis. It also supports applying to a collection of repos by using regex to match a repo name. If more than one repo name matches, the values from last repo matched are used. This deprecates the --allow-repo-config option --- cmd/server.go | 21 + cmd/server_test.go | 10 + runatlantis.io/.vuepress/config.js | 1 + runatlantis.io/docs/apply-requirements.md | 74 ++- .../docs/atlantis-yaml-reference.md | 53 +- runatlantis.io/docs/customizing-atlantis.md | 5 +- runatlantis.io/docs/repos-yaml-reference.md | 126 ++++ server/events/event_parser_test.go | 22 + server/events/models/models.go | 10 + server/events/models/models_test.go | 3 + server/events/project_command_builder.go | 67 +- server/events/project_command_builder_test.go | 280 ++++++--- server/events/yaml/parser_validator.go | 123 +++- server/events/yaml/parser_validator_test.go | 594 ++++++++++++++---- server/events/yaml/raw/repo_config.go | 75 +++ server/events_controller_test.go | 1 + server/server.go | 19 +- server/server_test.go | 31 + server/user_config.go | 1 + 19 files changed, 1267 insertions(+), 249 deletions(-) create mode 100644 runatlantis.io/docs/repos-yaml-reference.md create mode 100644 server/events/yaml/raw/repo_config.go diff --git a/cmd/server.go b/cmd/server.go index fe74365b50..39ee0229df 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -57,6 +57,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" @@ -167,6 +168,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. " + @@ -205,6 +210,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, @@ -239,16 +245,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 @@ -324,6 +333,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 } @@ -334,12 +346,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 } @@ -431,6 +449,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 c9396bf373..ef949b356f 100644 --- a/cmd/server_test.go +++ b/cmd/server_test.go @@ -900,6 +900,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 cc0a703bb8..815d158f90 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 f575cf69ef..1118a80309 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 @@ -86,18 +115,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 125a0d0c55..28925b0e2c 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. Requires there to be a binary in the Atlantis `PATH` with the name `terraform{VERSION}`, ex. `terraform0.11.0` | -| 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. Requires there to be a binary in the Atlantis `PATH` with the name `terraform{VERSION}`, ex. `terraform0.11.0` | +| 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:
| + +### 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 a0d3a040a1..d27b0ff45b 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" @@ -193,6 +194,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 83d4089e13..e767b2b86f 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.