-
Notifications
You must be signed in to change notification settings - Fork 1.1k
/
comment_parser.go
435 lines (383 loc) · 17.4 KB
/
comment_parser.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
// Copyright 2017 HootSuite Media Inc.
//
// Licensed under the Apache License, Version 2.0 (the License);
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an AS IS BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Modified hereafter by contributors to runatlantis/atlantis.
package events
import (
"bytes"
"fmt"
"io"
"net/url"
"path/filepath"
"regexp"
"strings"
"text/template"
"github.com/google/shlex"
"github.com/runatlantis/atlantis/server/events/command"
"github.com/runatlantis/atlantis/server/events/models"
"github.com/spf13/pflag"
)
const (
workspaceFlagLong = "workspace"
workspaceFlagShort = "w"
dirFlagLong = "dir"
dirFlagShort = "d"
projectFlagLong = "project"
projectFlagShort = "p"
autoMergeDisabledFlagLong = "auto-merge-disabled"
autoMergeDisabledFlagShort = ""
verboseFlagLong = "verbose"
verboseFlagShort = ""
)
// multiLineRegex is used to ignore multi-line comments since those aren't valid
// Atlantis commands. If the second line just has newlines then we let it pass
// through because when you double click on a comment in GitHub and then you
// paste it again, GitHub adds two newlines and so we wanted to allow copying
// and pasting GitHub comments.
var multiLineRegex = regexp.MustCompile(`.*\r?\n[^\r\n]+`)
//go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_comment_parsing.go CommentParsing
// CommentParsing handles parsing pull request comments.
type CommentParsing interface {
// Parse attempts to parse a pull request comment to see if it's an Atlantis
// command.
Parse(comment string, vcsHost models.VCSHostType) CommentParseResult
}
//go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_comment_building.go CommentBuilder
// CommentBuilder builds comment commands that can be used on pull requests.
type CommentBuilder interface {
// BuildPlanComment builds a plan comment for the specified args.
BuildPlanComment(repoRelDir string, workspace string, project string, commentArgs []string) string
// BuildApplyComment builds an apply comment for the specified args.
BuildApplyComment(repoRelDir string, workspace string, project string, autoMergeDisabled bool) string
// BuildVersionComment builds a version comment for the specified args.
BuildVersionComment(repoRelDir string, workspace string, project string) string
}
// CommentParser implements CommentParsing
type CommentParser struct {
GithubUser string
GitlabUser string
BitbucketUser string
AzureDevopsUser string
ApplyDisabled bool
ExecutableName string
}
// CommentParseResult describes the result of parsing a comment as a command.
type CommentParseResult struct {
// Command is the successfully parsed command. Will be nil if
// CommentResponse or Ignore is set.
Command *CommentCommand
// CommentResponse is set when we should respond immediately to the command
// for example for atlantis help.
CommentResponse string
// Ignore is set to true when we should just ignore this comment.
Ignore bool
}
// Parse parses the comment as an Atlantis command.
//
// Valid commands contain:
// - The initial "executable" name, 'run' or 'atlantis' or '@GithubUser'
// where GithubUser is the API user Atlantis is running as.
// - Then a command: 'plan', 'apply', 'unlock', 'version, 'approve_policies',
// or 'help'.
// - Then optional flags, then an optional separator '--' followed by optional
// extra flags to be appended to the terraform plan/apply command.
//
// Examples:
// - atlantis help
// - run apply
// - @GithubUser plan -w staging
// - atlantis plan -w staging -d dir --verbose
// - atlantis plan --verbose -- -key=value -key2 value2
// - atlantis unlock
// - atlantis version
// - atlantis approve_policies
func (e *CommentParser) Parse(rawComment string, vcsHost models.VCSHostType) CommentParseResult {
comment := strings.TrimSpace(rawComment)
if multiLineRegex.MatchString(comment) {
return CommentParseResult{Ignore: true}
}
// We first use strings.Fields to parse and do an initial evaluation.
// Later we use a proper shell parser and re-parse.
args := strings.Fields(comment)
if len(args) < 1 {
return CommentParseResult{Ignore: true}
}
// Helpfully warn the user if they're using "terraform" instead of "atlantis"
if args[0] == "terraform" {
return CommentParseResult{CommentResponse: DidYouMeanAtlantisComment}
}
// Atlantis can be invoked using the name of the VCS host user we're
// running under. Need to be able to match against that user.
var vcsUser string
switch vcsHost {
case models.Github:
vcsUser = e.GithubUser
case models.Gitlab:
vcsUser = e.GitlabUser
case models.BitbucketCloud, models.BitbucketServer:
vcsUser = e.BitbucketUser
case models.AzureDevops:
vcsUser = e.AzureDevopsUser
}
executableNames := []string{"run", e.ExecutableName, "@" + vcsUser}
if !e.stringInSlice(args[0], executableNames) {
return CommentParseResult{Ignore: true}
}
// Now that we know Atlantis is being invoked, re-parse using a shell-style
// parser.
args, err := shlex.Split(comment)
if err != nil {
return CommentParseResult{CommentResponse: fmt.Sprintf("```\nError parsing command: %s\n```", err)}
}
if len(args) < 1 {
return CommentParseResult{Ignore: true}
}
// If they've just typed the name of the executable then give them the help
// output.
if len(args) == 1 {
return CommentParseResult{CommentResponse: e.HelpComment(e.ApplyDisabled)}
}
cmd := args[1]
// Help output.
if e.stringInSlice(cmd, []string{"help", "-h", "--help"}) {
return CommentParseResult{CommentResponse: e.HelpComment(e.ApplyDisabled)}
}
// Need to have a plan, apply, approve_policy or unlock at this point.
if !e.stringInSlice(cmd, []string{command.Plan.String(), command.Apply.String(), command.Unlock.String(), command.ApprovePolicies.String(), command.Version.String()}) {
return CommentParseResult{CommentResponse: fmt.Sprintf("```\nError: unknown command %q.\nRun 'atlantis --help' for usage.\n```", cmd)}
}
var workspace string
var dir string
var project string
var verbose, autoMergeDisabled bool
var flagSet *pflag.FlagSet
var name command.Name
// Set up the flag parsing depending on the command.
switch cmd {
case command.Plan.String():
name = command.Plan
flagSet = pflag.NewFlagSet(command.Plan.String(), pflag.ContinueOnError)
flagSet.SetOutput(io.Discard)
flagSet.StringVarP(&workspace, workspaceFlagLong, workspaceFlagShort, "", "Switch to this Terraform workspace before planning.")
flagSet.StringVarP(&dir, dirFlagLong, dirFlagShort, "", "Which directory to run plan in relative to root of repo, ex. 'child/dir'.")
flagSet.StringVarP(&project, projectFlagLong, projectFlagShort, "", "Which project to run plan for. Refers to the name of the project configured in a repo config file. Cannot be used at same time as workspace or dir flags.")
flagSet.BoolVarP(&verbose, verboseFlagLong, verboseFlagShort, false, "Append Atlantis log to comment.")
case command.Apply.String():
name = command.Apply
flagSet = pflag.NewFlagSet(command.Apply.String(), pflag.ContinueOnError)
flagSet.SetOutput(io.Discard)
flagSet.StringVarP(&workspace, workspaceFlagLong, workspaceFlagShort, "", "Apply the plan for this Terraform workspace.")
flagSet.StringVarP(&dir, dirFlagLong, dirFlagShort, "", "Apply the plan for this directory, relative to root of repo, ex. 'child/dir'.")
flagSet.StringVarP(&project, projectFlagLong, projectFlagShort, "", "Apply the plan for this project. Refers to the name of the project configured in a repo config file. Cannot be used at same time as workspace or dir flags.")
flagSet.BoolVarP(&autoMergeDisabled, autoMergeDisabledFlagLong, autoMergeDisabledFlagShort, false, "Disable automerge after apply.")
flagSet.BoolVarP(&verbose, verboseFlagLong, verboseFlagShort, false, "Append Atlantis log to comment.")
case command.ApprovePolicies.String():
name = command.ApprovePolicies
flagSet = pflag.NewFlagSet(command.ApprovePolicies.String(), pflag.ContinueOnError)
flagSet.SetOutput(io.Discard)
flagSet.BoolVarP(&verbose, verboseFlagLong, verboseFlagShort, false, "Append Atlantis log to comment.")
case command.Unlock.String():
name = command.Unlock
flagSet = pflag.NewFlagSet(command.Unlock.String(), pflag.ContinueOnError)
flagSet.SetOutput(io.Discard)
case command.Version.String():
name = command.Version
flagSet = pflag.NewFlagSet(command.Version.String(), pflag.ContinueOnError)
flagSet.StringVarP(&workspace, workspaceFlagLong, workspaceFlagShort, "", "Switch to this Terraform workspace before running version.")
flagSet.StringVarP(&dir, dirFlagLong, dirFlagShort, "", "Which directory to run version in relative to root of repo, ex. 'child/dir'.")
flagSet.StringVarP(&project, projectFlagLong, projectFlagShort, "", "Print the version for this project. Refers to the name of the project configured in a repo config file.")
flagSet.BoolVarP(&verbose, verboseFlagLong, verboseFlagShort, false, "Append Atlantis log to comment.")
default:
return CommentParseResult{CommentResponse: fmt.Sprintf("Error: unknown command %q – this is a bug", cmd)}
}
// Now parse the flags.
// It's safe to use [2:] because we know there's at least 2 elements in args.
err = flagSet.Parse(args[2:])
if err == pflag.ErrHelp {
return CommentParseResult{CommentResponse: fmt.Sprintf("```\nUsage of %s:\n%s\n```", cmd, flagSet.FlagUsagesWrapped(usagesCols))}
}
if err != nil {
if cmd == command.Unlock.String() {
return CommentParseResult{CommentResponse: UnlockUsage}
}
return CommentParseResult{CommentResponse: e.errMarkdown(err.Error(), cmd, flagSet)}
}
var unusedArgs []string
if flagSet.ArgsLenAtDash() == -1 {
unusedArgs = flagSet.Args()
} else {
unusedArgs = flagSet.Args()[0:flagSet.ArgsLenAtDash()]
}
if len(unusedArgs) > 0 {
return CommentParseResult{CommentResponse: e.errMarkdown(fmt.Sprintf("unknown argument(s) – %s", strings.Join(unusedArgs, " ")), cmd, flagSet)}
}
var extraArgs []string
if flagSet.ArgsLenAtDash() != -1 {
extraArgs = flagSet.Args()[flagSet.ArgsLenAtDash():]
}
dir, err = e.validateDir(dir)
if err != nil {
return CommentParseResult{CommentResponse: e.errMarkdown(err.Error(), cmd, flagSet)}
}
// Use the same validation that Terraform uses: https://git.io/vxGhU. Plus
// we also don't allow '..'. We don't want the workspace to contain a path
// since we create files based on the name.
if workspace != url.PathEscape(workspace) || strings.Contains(workspace, "..") {
return CommentParseResult{CommentResponse: e.errMarkdown(fmt.Sprintf("invalid workspace: %q", workspace), cmd, flagSet)}
}
// If project is specified, dir or workspace should not be set. Since we
// dir/workspace have defaults we can't detect if the user set the flag
// to the default or didn't set the flag so there is an edge case here we
// don't detect, ex. atlantis plan -p project -d . -w default won't cause
// an error.
if project != "" && (workspace != "" || dir != "") {
err := fmt.Sprintf("cannot use -%s/--%s at same time as -%s/--%s or -%s/--%s", projectFlagShort, projectFlagLong, dirFlagShort, dirFlagLong, workspaceFlagShort, workspaceFlagLong)
return CommentParseResult{CommentResponse: e.errMarkdown(err, cmd, flagSet)}
}
return CommentParseResult{
Command: NewCommentCommand(dir, extraArgs, name, verbose, autoMergeDisabled, workspace, project),
}
}
// BuildPlanComment builds a plan comment for the specified args.
func (e *CommentParser) BuildPlanComment(repoRelDir string, workspace string, project string, commentArgs []string) string {
flags := e.buildFlags(repoRelDir, workspace, project, false)
commentFlags := ""
if len(commentArgs) > 0 {
var flagsWithoutQuotes []string
for _, f := range commentArgs {
f = strings.TrimPrefix(f, "\"")
f = strings.TrimSuffix(f, "\"")
flagsWithoutQuotes = append(flagsWithoutQuotes, f)
}
commentFlags = fmt.Sprintf(" -- %s", strings.Join(flagsWithoutQuotes, " "))
}
return fmt.Sprintf("%s %s%s%s", e.ExecutableName, command.Plan.String(), flags, commentFlags)
}
// BuildApplyComment builds an apply comment for the specified args.
func (e *CommentParser) BuildApplyComment(repoRelDir string, workspace string, project string, autoMergeDisabled bool) string {
flags := e.buildFlags(repoRelDir, workspace, project, autoMergeDisabled)
return fmt.Sprintf("%s %s%s", e.ExecutableName, command.Apply.String(), flags)
}
// BuildVersionComment builds a version comment for the specified args.
func (e *CommentParser) BuildVersionComment(repoRelDir string, workspace string, project string) string {
flags := e.buildFlags(repoRelDir, workspace, project, false)
return fmt.Sprintf("%s %s%s", e.ExecutableName, command.Version.String(), flags)
}
func (e *CommentParser) buildFlags(repoRelDir string, workspace string, project string, autoMergeDisabled bool) string {
// Add quotes if dir has spaces.
if strings.Contains(repoRelDir, " ") {
repoRelDir = fmt.Sprintf("%q", repoRelDir)
}
var flags string
switch {
// If project is specified we can just use its name.
case project != "":
flags = fmt.Sprintf(" -%s %s", projectFlagShort, project)
case repoRelDir == DefaultRepoRelDir && workspace == DefaultWorkspace:
// If it's the root and default workspace then we just need to specify one
// of the flags and the other will get defaulted.
flags = fmt.Sprintf(" -%s %s", dirFlagShort, DefaultRepoRelDir)
case repoRelDir == DefaultRepoRelDir:
// If dir is the default then we just need to specify workspace.
flags = fmt.Sprintf(" -%s %s", workspaceFlagShort, workspace)
case workspace == DefaultWorkspace:
// If workspace is the default then we just need to specify the dir.
flags = fmt.Sprintf(" -%s %s", dirFlagShort, repoRelDir)
default:
// Otherwise we have to specify both flags.
flags = fmt.Sprintf(" -%s %s -%s %s", dirFlagShort, repoRelDir, workspaceFlagShort, workspace)
}
if autoMergeDisabled {
flags = fmt.Sprintf("%s --%s", flags, autoMergeDisabledFlagLong)
}
return flags
}
func (e *CommentParser) validateDir(dir string) (string, error) {
if dir == "" {
return dir, nil
}
validatedDir := filepath.Clean(dir)
// Join with . so the path is relative. This helps us if they use '/',
// and is safe to do if their path is relative since it's a no-op.
validatedDir = filepath.Join(".", validatedDir)
// Need to clean again to resolve relative validatedDirs.
validatedDir = filepath.Clean(validatedDir)
// Detect relative dirs since they're not allowed.
if strings.HasPrefix(validatedDir, "..") {
return "", fmt.Errorf("using a relative path %q with -%s/--%s is not allowed", dir, dirFlagShort, dirFlagLong)
}
return validatedDir, nil
}
func (e *CommentParser) stringInSlice(a string, list []string) bool {
for _, b := range list {
if b == a {
return true
}
}
return false
}
func (e *CommentParser) errMarkdown(errMsg string, cmd string, flagSet *pflag.FlagSet) string {
return fmt.Sprintf("```\nError: %s.\nUsage of %s:\n%s```", errMsg, cmd, flagSet.FlagUsagesWrapped(usagesCols))
}
func (e *CommentParser) HelpComment(applyDisabled bool) string {
buf := &bytes.Buffer{}
var tmpl = template.Must(template.New("").Parse(helpCommentTemplate))
if err := tmpl.Execute(buf, struct {
ApplyDisabled bool
}{
ApplyDisabled: applyDisabled,
}); err != nil {
return fmt.Sprintf("Failed to render template, this is a bug: %v", err)
}
return buf.String()
}
var helpCommentTemplate = "```cmake\n" +
`atlantis
Terraform Pull Request Automation
Usage:
atlantis <command> [options] -- [terraform options]
Examples:
# run plan in the root directory passing the -target flag to terraform
atlantis plan -d . -- -target=resource
{{- if not .ApplyDisabled }}
# apply all unapplied plans from this pull request
atlantis apply
# apply the plan for the root directory and staging workspace
atlantis apply -d . -w staging
{{- end }}
Commands:
plan Runs 'terraform plan' for the changes in this pull request.
To plan a specific project, use the -d, -w and -p flags.
{{- if not .ApplyDisabled }}
apply Runs 'terraform apply' on all unapplied plans from this pull request.
To only apply a specific plan, use the -d, -w and -p flags.
{{- end }}
unlock Removes all atlantis locks and discards all plans for this PR.
To unlock a specific plan you can use the Atlantis UI.
approve_policies
Approves all current policy checking failures for the PR.
version Print the output of 'terraform version'
help View help.
Flags:
-h, --help help for atlantis
Use "atlantis [command] --help" for more information about a command.` +
"\n```"
// DidYouMeanAtlantisComment is the comment we add to the pull request when
// someone runs a command with terraform instead of atlantis.
var DidYouMeanAtlantisComment = "Did you mean to use `atlantis` instead of `terraform`?"
// UnlockUsage is the comment we add to the pull request when someone runs
// `atlantis unlock` with flags.
var UnlockUsage = "`Usage of unlock:`\n\n ```cmake\n" +
`atlantis unlock
Unlocks the entire PR and discards all plans in this PR.
Arguments or flags are not supported at the moment.
If you need to unlock a specific project please use the atlantis UI.` +
"\n```"