Skip to content

Commit ee48039

Browse files
authored
Merge pull request #14 from runatlantis/comment-syntax
Add -w workspace and -d directory flags to plan/apply comments
2 parents cf4fd37 + 3ec956e commit ee48039

10 files changed

+426
-165
lines changed

README.md

+41-13
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,13 @@ Read about [Why We Built Atlantis](https://www.atlantis.run/blog/atlantis-releas
4545
- Optionally, require a **review and approval** prior to running `apply`
4646

4747
➜ Also
48-
- No more **copy-pasted code across workspaces/environments**. Atlantis supports using an `env/{env}.tfvars` file per workspace/environment so you can write your base configuration once
4948
- Support **multiple versions of Terraform** with a simple project config file
5049

5150
## Atlantis Works With
5251
* GitHub (public, private or enterprise) and GitLab (public, private or enterprise)
5352
* Any Terraform version (see [Terraform Versions](#terraform-version))
5453
* Can be run with a [single binary](https://github.com/runatlantis/atlantis/releases) or with our [Docker image](https://hub.docker.com/r/runatlantis/atlantis/)
54+
* Any repository structure
5555

5656
## Getting Started
5757
Download from [https://github.com/runatlantis/atlantis/releases](https://github.com/runatlantis/atlantis/releases)
@@ -71,15 +71,42 @@ If you're ready to permanently set up Atlantis see [Production-Ready Deployment]
7171
## Pull/Merge Request Commands
7272
Atlantis currently supports three commands that can be run via pull request comments (or merge request comments on GitLab):
7373

74+
![Help Command](./docs/pr-comment-help.png)
7475
#### `atlantis help`
7576
View help
7677

77-
#### `atlantis plan [workspace]`
78-
Runs `terraform plan` for the changes in this pull request. If `[workspace]` is specified, will switch to that workspace, before running `plan`. Any additional arguments passed to `atlantis plan` will be passed on to `terraform plan`. For example if you'd like to run `terraform plan -target={target}` then you can comment `atlantis plan -target={target}`.
78+
---
79+
![Plan Command](./docs/pr-comment-plan.png)
80+
#### `atlantis plan [options] -- [terraform plan flags]`
81+
Runs `terraform plan` for the changes in this pull request.
82+
83+
Options:
84+
* `-d directory` Which directory to run plan in relative to root of repo. Use '.' for root. If not specified, will attempt to run plan for all Terraform projects we think were modified in this changeset.
85+
* -w workspace` Switch to this [Terraform workspace](https://www.terraform.io/docs/state/workspaces.html) before planning. Defaults to 'default'. If not using Terraform workspaces you can ignore this.
86+
* `--verbose` Append Atlantis log to comment.
87+
88+
Additional Terraform flags:
89+
90+
If you need to run `terraform plan` with additional arguments, like `-target=resource` or `-var 'foo-bar'`
91+
you can append them to the end of the comment after `--`, ex.
92+
```
93+
atlantis plan -d dir -- -var 'foo=bar'
94+
```
95+
If you always need to append a certain flag, see [Project-Specific Customization](#project-specific-customization).
96+
97+
---
98+
![Apply Command](./docs/pr-comment-apply.png)
99+
#### `atlantis apply [options] -- [terraform apply flags]`
100+
Runs `terraform plan` for the changes in this pull request.
101+
102+
Options:
103+
* `-d directory` Apply the plan for this directory, relative to root of repo. Use '.' for root. If not specified, will run apply against all plans created for this workspace.
104+
* -w workspace` Apply the plan for this [Terraform workspace](https://www.terraform.io/docs/state/workspaces.html). Defaults to 'default'. If not using Terraform workspaces you can ignore this.
105+
* `--verbose` Append Atlantis log to comment.
106+
107+
Additional Terraform flags:
79108

80-
#### `atlantis apply [workspace]`
81-
Runs `terraform apply` for the plan generated by `atlantis plan`. If `[workspace]` is specified, will switch to that workspace.
82-
Any additional arguments passed to `atlantis apply` will be passed on to `terraform apply`.
109+
Same as with `atlantis plan`.
83110

84111
## Project Structure
85112
Atlantis supports several Terraform project structures:
@@ -131,23 +158,24 @@ or
131158
│   └── staging.tfvars
132159
└── main.tf
133160
```
134-
With the above project structure you can de-duplicate your Terraform code between workspaces/environments without requiring extensive use of modules. At Hootsuite we've found this project format to be very successful and use it in all of our 100+ Terraform repositories.
161+
With the above project structure you can de-duplicate your Terraform code between workspaces/environments without requiring extensive use of modules. At Hootsuite we found this project format to be very successful and use it in all of our 100+ Terraform repositories.
135162

136163
## Workspaces/Environments
137164
Terraform introduced [Workspaces](https://www.terraform.io/docs/state/workspaces.html) in 0.9. They allow for
138165
> a single directory of Terraform configuration to be used to manage multiple distinct sets of infrastructure resources
139166
140-
If you're using a Terraform version >= 0.9.0, Atlantis supports workspaces through an additional argument to the `atlantis plan` and `atlantis apply` commands.
167+
If you're using a Terraform version >= 0.9.0, Atlantis supports workspaces through the `-w` flag.
141168
For example,
142169
```
143-
atlantis plan staging
170+
atlantis plan -w staging
144171
```
145172

146173
If a workspace is specified, Atlantis will use `terraform workspace select {workspace}` prior to running `terraform plan` or `terraform apply`.
147174

148175
If you're using the `env/{env}.tfvars` [project structure](#project-structure) we will also append `-tfvars=env/{env}.tfvars` to `plan` and `apply`.
149176

150-
If no workspace is specified, terraform will use the `default` workspace by default.
177+
If no workspace is specified, we'll use the `default` workspace by default.
178+
This replicates Terraform's default behaviour which also uses the `default` workspace.
151179

152180
## Terraform Versions
153181
By default, Atlantis will use the `terraform` executable that is in its path. To use a specific version of Terraform just install that version on the server that Atlantis is running on.
@@ -209,13 +237,13 @@ extra_arguments:
209237
```
210238
211239
When running the `pre_plan`, `post_plan`, `pre_apply`, and `post_apply` commands the following environment variables are available
212-
- `WORKSPACE`: if a workspace argument is supplied to `atlantis plan` or `atlantis apply`, ex `atlantis plan staging`, this will
240+
- `WORKSPACE`: if a workspace argument is supplied to `atlantis plan` or `atlantis apply`, ex `atlantis plan -w staging`, this will
213241
be the value of that argument. Else it will be `default`
214242
- `ATLANTIS_TERRAFORM_VERSION`: local version of `terraform` or the version from `terraform_version` if specified, ex. `0.10.0`
215243
- `DIR`: absolute path to the root of the project on disk
216244

217245
## Locking
218-
When `plan` is run, the [project](#project) and [workspace](#workspaceenvironment) are **Locked** until an `apply` succeeds **and** the pull request/merge request is merged.
246+
When `plan` is run, the [project](#project) and [workspace](#workspaceenvironment) (**but not the whole repo**) are **Locked** until an `apply` succeeds **and** the pull request/merge request is merged.
219247
This protects against concurrent modifications to the same set of infrastructure and prevents
220248
users from seeing a `plan` that will be invalid if another pull request is merged.
221249

@@ -463,7 +491,7 @@ A Terraform workspace. See [terraform docs](https://www.terraform.io/docs/state/
463491
## FAQ
464492
**Q: Does Atlantis affect Terraform [remote state](https://www.terraform.io/docs/state/remote.html)?**
465493

466-
A: No. Atlantis does not interfere with Terraform remote state in anyway. Under the hood, Atlantis is simply executing `terraform plan` and `terraform apply`.
494+
A: No. Atlantis does not interfere with Terraform remote state in any way. Under the hood, Atlantis is simply executing `terraform plan` and `terraform apply`.
467495

468496
**Q: How does Atlantis locking interact with Terraform [locking](https://www.terraform.io/docs/state/locking.html)?**
469497

docs/pr-comment-apply.png

19 KB
Loading

docs/pr-comment-help.png

19.3 KB
Loading

docs/pr-comment-plan.png

18.9 KB
Loading

server/events/apply_executor.go

+30-13
Original file line numberDiff line numberDiff line change
@@ -46,22 +46,39 @@ func (a *ApplyExecutor) Execute(ctx *CommandContext) CommandResponse {
4646
// Plans are stored at project roots by their workspace names. We just
4747
// need to find them.
4848
var plans []models.Plan
49-
err = filepath.Walk(repoDir, func(path string, info os.FileInfo, err error) error {
49+
// If they didn't specify a directory, we apply all plans we can find for
50+
// this workspace.
51+
if ctx.Command.Dir == "" {
52+
err = filepath.Walk(repoDir, func(path string, info os.FileInfo, err error) error {
53+
if err != nil {
54+
return err
55+
}
56+
// Check if the plan is for the right workspace,
57+
if !info.IsDir() && info.Name() == ctx.Command.Workspace+".tfplan" {
58+
rel, _ := filepath.Rel(repoDir, filepath.Dir(path))
59+
plans = append(plans, models.Plan{
60+
Project: models.NewProject(ctx.BaseRepo.FullName, rel),
61+
LocalPath: path,
62+
})
63+
}
64+
return nil
65+
})
5066
if err != nil {
51-
return err
67+
return CommandResponse{Error: errors.Wrap(err, "finding plans")}
5268
}
53-
// Check if the plan is for the right workspace,
54-
if !info.IsDir() && info.Name() == ctx.Command.Workspace+".tfplan" {
55-
rel, _ := filepath.Rel(repoDir, filepath.Dir(path))
56-
plans = append(plans, models.Plan{
57-
Project: models.NewProject(ctx.BaseRepo.FullName, rel),
58-
LocalPath: path,
59-
})
69+
} else {
70+
// If they did specify a dir, we apply just the plan in that directory
71+
// for this workspace.
72+
path := filepath.Join(repoDir, ctx.Command.Dir, ctx.Command.Workspace+".tfplan")
73+
stat, err := os.Stat(path)
74+
if err != nil || stat.IsDir() {
75+
return CommandResponse{Error: errors.Wrapf(err, "finding plan for dir %q and workspace %q", ctx.Command.Dir, ctx.Command.Workspace)}
6076
}
61-
return nil
62-
})
63-
if err != nil {
64-
return CommandResponse{Error: errors.Wrap(err, "finding plans")}
77+
rel, _ := filepath.Rel(repoDir, filepath.Dir(path))
78+
plans = append(plans, models.Plan{
79+
Project: models.NewProject(ctx.BaseRepo.FullName, filepath.Dir(rel)),
80+
LocalPath: path,
81+
})
6582
}
6683
if len(plans) == 0 {
6784
return CommandResponse{Failure: "No plans found for that workspace."}

server/events/event_parser.go

+66-41
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ package events
33
import (
44
"errors"
55
"fmt"
6+
"path/filepath"
67
"strings"
78

89
"github.com/google/go-github/github"
910
"github.com/lkysow/go-gitlab"
1011
"github.com/runatlantis/atlantis/server/events/models"
1112
"github.com/runatlantis/atlantis/server/events/vcs"
13+
"github.com/spf13/pflag"
1214
)
1315

1416
const gitlabPullOpened = "opened"
@@ -20,6 +22,10 @@ type Command struct {
2022
Workspace string
2123
Verbose bool
2224
Flags []string
25+
// Dir is the path relative to the repo root to run the command in.
26+
// If empty string then it wasn't specified. "." is the root of the repo.
27+
// Dir will never end in "/".
28+
Dir string
2329
}
2430

2531
type EventParsing interface {
@@ -60,52 +66,82 @@ func (e *EventParser) DetermineCommand(comment string, vcsHost vcs.Host) (*Comma
6066
return nil, err
6167
}
6268

63-
workspace := "default"
64-
verbose := false
65-
var flags []string
66-
6769
vcsUser := e.GithubUser
6870
if vcsHost == vcs.Gitlab {
6971
vcsUser = e.GitlabUser
7072
}
7173
if !e.stringInSlice(args[0], []string{"run", "atlantis", "@" + vcsUser}) {
7274
return nil, err
7375
}
74-
if !e.stringInSlice(args[1], []string{"plan", "apply", "help"}) {
76+
if !e.stringInSlice(args[1], []string{"plan", "apply", "help", "-help", "--help"}) {
7577
return nil, err
7678
}
77-
if args[1] == "help" {
79+
80+
command := args[1]
81+
if command == "help" || command == "-help" || command == "--help" {
7882
return &Command{Name: Help}, nil
7983
}
80-
command := args[1]
8184

82-
if len(args) > 2 {
83-
flags = args[2:]
84-
85-
// if the third arg doesn't start with '-' then we assume it's a
86-
// workspace, not a flag
87-
if !strings.HasPrefix(args[2], "-") {
88-
workspace = args[2]
89-
flags = args[3:]
85+
var workspace string
86+
var dir string
87+
var verbose bool
88+
var extraArgs []string
89+
var flagSet *pflag.FlagSet
90+
var name CommandName
91+
92+
// Set up the flag parsing depending on the command.
93+
const defaultWorkspace = "default"
94+
if command == "plan" {
95+
name = Plan
96+
flagSet = pflag.NewFlagSet("plan", pflag.ContinueOnError)
97+
flagSet.StringVarP(&workspace, "workspace", "w", defaultWorkspace, fmt.Sprintf("Switch to this Terraform workspace before planning. Defaults to '%s'", defaultWorkspace))
98+
flagSet.StringVarP(&dir, "dir", "d", "", "Which directory to run plan in relative to root of repo. Use '.' for root. If not specified, will attempt to run plan for all Terraform projects we think were modified in this changeset.")
99+
flagSet.BoolVarP(&verbose, "verbose", "", false, "Append Atlantis log to comment.")
100+
} else if command == "apply" {
101+
name = Apply
102+
flagSet = pflag.NewFlagSet("apply", pflag.ContinueOnError)
103+
flagSet.StringVarP(&workspace, "workspace", "w", defaultWorkspace, fmt.Sprintf("Apply the plan for this Terraform workspace. Defaults to '%s'", defaultWorkspace))
104+
flagSet.StringVarP(&dir, "dir", "d", "", "Apply the plan for this directory, relative to root of repo. Use '.' for root. If not specified, will run apply against all plans created for this workspace.")
105+
flagSet.BoolVarP(&verbose, "verbose", "", false, "Append Atlantis log to comment.")
106+
} else {
107+
return nil, fmt.Errorf("unknown command %q – this is a bug", command)
108+
}
109+
110+
// Now parse the flags.
111+
if err := flagSet.Parse(args[2:]); err != nil {
112+
return nil, err
113+
}
114+
// We only use the extra args after the --. For example given a comment:
115+
// "atlantis plan -bad-option -- -target=hi"
116+
// we only append "-target=hi" to the eventual command.
117+
// todo: keep track of the args we're discarding and include that with
118+
// comment as a warning.
119+
if flagSet.ArgsLenAtDash() != -1 {
120+
extraArgs = flagSet.Args()[flagSet.ArgsLenAtDash():]
121+
}
122+
123+
// If dir is specified, must ensure it's a valid path.
124+
if dir != "" {
125+
validatedDir := filepath.Clean(dir)
126+
// Join with . so the path is relative. This helps us if they use '/',
127+
// and is safe to do if their path is relative since it's a no-op.
128+
validatedDir = filepath.Join(".", validatedDir)
129+
// Need to clean again to resolve relative validatedDirs.
130+
validatedDir = filepath.Clean(validatedDir)
131+
// Detect relative dirs since they're not allowed.
132+
if strings.HasPrefix(validatedDir, "..") {
133+
return nil, fmt.Errorf("relative path %q not allowed", dir)
90134
}
91135

92-
// check for --verbose specially and then remove any additional
93-
// occurrences
94-
if e.stringInSlice("--verbose", flags) {
95-
verbose = true
96-
flags = e.removeOccurrences("--verbose", flags)
97-
}
136+
dir = validatedDir
98137
}
99-
100-
c := &Command{Verbose: verbose, Workspace: workspace, Flags: flags}
101-
switch command {
102-
case "plan":
103-
c.Name = Plan
104-
case "apply":
105-
c.Name = Apply
106-
default:
107-
return nil, fmt.Errorf("something went wrong parsing the command, the command we parsed %q was not apply or plan", command)
138+
// Because we use the workspace name as a file, need to make sure it's
139+
// not doing something weird like being a relative dir.
140+
if strings.Contains(workspace, "..") {
141+
return nil, errors.New("workspace can't contain '..'")
108142
}
143+
144+
c := &Command{Name: name, Verbose: verbose, Workspace: workspace, Dir: dir, Flags: extraArgs}
109145
return c, nil
110146
}
111147

@@ -308,14 +344,3 @@ func (e *EventParser) stringInSlice(a string, list []string) bool {
308344
}
309345
return false
310346
}
311-
312-
// nolint: unparam
313-
func (e *EventParser) removeOccurrences(a string, list []string) []string {
314-
var out []string
315-
for _, b := range list {
316-
if b != a {
317-
out = append(out, b)
318-
}
319-
}
320-
return out
321-
}

0 commit comments

Comments
 (0)