Files
atlantis/server/events/comment_parser.go
2018-08-30 16:47:17 -06:00

345 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 (
"fmt"
"io/ioutil"
"net/url"
"path/filepath"
"regexp"
"strings"
"github.com/runatlantis/atlantis/server/events/models"
"github.com/runatlantis/atlantis/server/events/yaml"
"github.com/spf13/pflag"
)
const (
workspaceFlagLong = "workspace"
workspaceFlagShort = "w"
dirFlagLong = "dir"
dirFlagShort = "d"
projectFlagLong = "project"
projectFlagShort = "p"
verboseFlagLong = "verbose"
verboseFlagShort = ""
atlantisExecutable = "atlantis"
)
// 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
}
// 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) string
}
// CommentParser implements CommentParsing
type CommentParser struct {
GithubUser string
GithubToken string
GitlabUser string
GitlabToken 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, either 'plan', 'apply', 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 plan
// - @GithubUser plan -w staging
// - atlantis plan -w staging -d dir --verbose
// - atlantis plan --verbose -- -key=value -key2 value2
//
func (e *CommentParser) Parse(comment string, vcsHost models.VCSHostType) CommentParseResult {
if multiLineRegex.MatchString(comment) {
return CommentParseResult{Ignore: true}
}
// strings.Fields strips out newlines but that's okay since we've removed
// multiline strings above.
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.
vcsUser := e.GithubUser
if vcsHost == models.Gitlab {
vcsUser = e.GitlabUser
}
executableNames := []string{"run", atlantisExecutable, "@" + vcsUser}
// If the comment doesn't start with the name of our 'executable' then
// ignore it.
if !e.stringInSlice(args[0], executableNames) {
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: HelpComment}
}
command := args[1]
// Help output.
if e.stringInSlice(command, []string{"help", "-h", "--help"}) {
return CommentParseResult{CommentResponse: HelpComment}
}
// Need to have a plan or apply at this point.
if !e.stringInSlice(command, []string{PlanCommand.String(), ApplyCommand.String()}) {
return CommentParseResult{CommentResponse: fmt.Sprintf("```\nError: unknown command %q.\nRun 'atlantis --help' for usage.\n```", command)}
}
var workspace string
var dir string
var project string
var verbose bool
var extraArgs []string
var flagSet *pflag.FlagSet
var name CommandName
// Set up the flag parsing depending on the command.
switch command {
case PlanCommand.String():
name = PlanCommand
flagSet = pflag.NewFlagSet(PlanCommand.String(), pflag.ContinueOnError)
flagSet.SetOutput(ioutil.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, "", fmt.Sprintf("Which project to run plan for. Refers to the name of the project configured in %s. Cannot be used at same time as workspace or dir flags.", yaml.AtlantisYAMLFilename))
flagSet.BoolVarP(&verbose, verboseFlagLong, verboseFlagShort, false, "Append Atlantis log to comment.")
case ApplyCommand.String():
name = ApplyCommand
flagSet = pflag.NewFlagSet(ApplyCommand.String(), pflag.ContinueOnError)
flagSet.SetOutput(ioutil.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, "", fmt.Sprintf("Apply the plan for this project. Refers to the name of the project configured in %s. Cannot be used at same time as workspace or dir flags.", yaml.AtlantisYAMLFilename))
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", command)}
}
// 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```", command, flagSet.FlagUsagesWrapped(usagesCols))}
}
if err != nil {
return CommentParseResult{CommentResponse: e.errMarkdown(err.Error(), command, 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, " ")), command, flagSet)}
}
if flagSet.ArgsLenAtDash() != -1 {
extraArgsUnsafe := flagSet.Args()[flagSet.ArgsLenAtDash():]
// Quote all extra args so there isn't a security issue when we append
// them to the terraform commands, ex. "; cat /etc/passwd"
for _, arg := range extraArgsUnsafe {
quotesEscaped := strings.Replace(arg, `"`, `\"`, -1)
extraArgs = append(extraArgs, fmt.Sprintf(`"%s"`, quotesEscaped))
}
}
dir, err = e.validateDir(dir)
if err != nil {
return CommentParseResult{CommentResponse: e.errMarkdown(err.Error(), command, 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), command, 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, command, flagSet)}
}
return CommentParseResult{
Command: NewCommentCommand(dir, extraArgs, name, verbose, 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)
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", atlantisExecutable, PlanCommand.String(), flags, commentFlags)
}
// BuildApplyComment builds an apply comment for the specified args.
func (e *CommentParser) BuildApplyComment(repoRelDir string, workspace string, project string) string {
flags := e.buildFlags(repoRelDir, workspace, project)
return fmt.Sprintf("%s %s%s", atlantisExecutable, ApplyCommand.String(), flags)
}
func (e *CommentParser) buildFlags(repoRelDir string, workspace string, project string) string {
switch {
// If project is specified we can just use its name.
case project != "":
return fmt.Sprintf(" -%s %s", projectFlagShort, project)
// If it's the root and default workspace then we just need to specify one
// of the flags and the other will get defaulted.
case repoRelDir == DefaultRepoRelDir && workspace == DefaultWorkspace:
return fmt.Sprintf(" -%s %s", dirFlagShort, DefaultRepoRelDir)
// If dir is the default then we just need to specify workspace.
case repoRelDir == DefaultRepoRelDir:
return fmt.Sprintf(" -%s %s", workspaceFlagShort, workspace)
// If workspace is the default then we just need to specify the dir.
case workspace == DefaultWorkspace:
return fmt.Sprintf(" -%s %s", dirFlagShort, repoRelDir)
// Otherwise we have to specify both flags.
default:
return fmt.Sprintf(" -%s %s -%s %s", dirFlagShort, repoRelDir, workspaceFlagShort, workspace)
}
}
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, command string, flagSet *pflag.FlagSet) string {
return fmt.Sprintf("```\nError: %s.\nUsage of %s:\n%s```", errMsg, command, flagSet.FlagUsagesWrapped(usagesCols))
}
// HelpComment is the comment we add to the pull request when someone runs
// `atlantis help`.
var HelpComment = "```cmake\n" +
`atlantis
Terraform For Teams
Usage:
atlantis <command> [options] -- [terraform options]
Examples:
# run plan in the root directory passing the -target flag to terraform
atlantis plan -d . -- -target=resource
# apply all unapplied plans
atlantis apply
# apply the plan for the root directory and staging workspace
atlantis apply -d . -w staging
Commands:
plan Runs 'terraform plan' for the changes in this pull request.
To plan a specific project, use the -d, -w and -p flags.
apply Runs 'terraform apply' on all unapplied plans.
To only apply a specific plan, use the -d, -w and -p flags.
help View help.
Flags:
-h, --help help for atlantis
Use "atlantis [command] --help" for more information about a command.
`
// 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`?"