From e43f05e9342ea6066fb16331271bbc1c8f1af7e7 Mon Sep 17 00:00:00 2001 From: Luke Kysow Date: Sat, 8 Jul 2017 19:54:14 -0700 Subject: [PATCH] Allow atlantis to be called by the name of the api user (#72) --- cmd/server.go | 12 ++++- server/apply_executor.go | 16 +++---- server/command_handler.go | 16 +++---- server/event_parser.go | 58 +++++++++++++++++------ server/event_parser_test.go | 93 +++++++++++++++++++++++++++++++++++++ server/github_status.go | 2 +- server/plan_executor.go | 12 ++--- server/server.go | 4 +- server/workspace.go | 2 +- 9 files changed, 173 insertions(+), 42 deletions(-) create mode 100644 server/event_parser_test.go diff --git a/cmd/server.go b/cmd/server.go index 6fef428f0..2c98ea6ce 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -4,6 +4,8 @@ import ( "fmt" "os" + "strings" + "github.com/hootsuite/atlantis/server" "github.com/pkg/errors" "github.com/spf13/cobra" @@ -46,7 +48,7 @@ var stringFlags = []stringFlag{ }, { name: configFlag, - description: "Config file.", + description: "Path to config file.", }, { name: dataDirFlag, @@ -122,7 +124,7 @@ var serverCmd = &cobra.Command{ Long: `Start the atlantis server Flags can also be set in a yaml config file (see --` + configFlag + `). -Config values are overridden by environment variables which in turn are overridden by flags.`, +Config file values are overridden by environment variables which in turn are overridden by flags.`, SilenceUsage: true, PreRunE: func(cmd *cobra.Command, args []string) error { @@ -147,6 +149,7 @@ Config values are overridden by environment variables which in turn are overridd if err := setAtlantisURL(&config); err != nil { return err } + sanitizeGithubUser(&config) // config looks good, start the server server, err := server.NewServer(config) @@ -209,3 +212,8 @@ func setAtlantisURL(config *server.ServerConfig) error { } return nil } + +// sanitizeGithubUser trims @ from the front of the username if it exists +func sanitizeGithubUser(config *server.ServerConfig) { + config.GithubUser = strings.TrimPrefix(config.GithubUser, "@") +} diff --git a/server/apply_executor.go b/server/apply_executor.go index d60db34b7..8a3e012c5 100644 --- a/server/apply_executor.go +++ b/server/apply_executor.go @@ -35,16 +35,16 @@ func (a *ApplyExecutor) execute(ctx *CommandContext) { a.githubStatus.Update(ctx.BaseRepo, ctx.Pull, Pending, ApplyStep) res := a.setupAndApply(ctx) res.Command = Apply - comment := a.githubCommentRenderer.render(res, ctx.Log.History.String(), ctx.Command.verbose) + comment := a.githubCommentRenderer.render(res, ctx.Log.History.String(), ctx.Command.Verbose) a.github.CreateComment(ctx.BaseRepo, ctx.Pull, comment) } func (a *ApplyExecutor) setupAndApply(ctx *CommandContext) CommandResponse { - if a.concurrentRunLocker.TryLock(ctx.BaseRepo.FullName, ctx.Command.environment, ctx.Pull.Num) != true { + if a.concurrentRunLocker.TryLock(ctx.BaseRepo.FullName, ctx.Command.Environment, ctx.Pull.Num) != true { return a.failureResponse(ctx, - fmt.Sprintf("The %s environment is currently locked by another command that is running for this pull request. Wait until command is complete and try again.", ctx.Command.environment)) + fmt.Sprintf("The %s environment is currently locked by another command that is running for this pull request. Wait until command is complete and try again.", ctx.Command.Environment)) } - defer a.concurrentRunLocker.Unlock(ctx.BaseRepo.FullName, ctx.Command.environment, ctx.Pull.Num) + defer a.concurrentRunLocker.Unlock(ctx.BaseRepo.FullName, ctx.Command.Environment, ctx.Pull.Num) if a.requireApproval { approved, err := a.github.PullIsApproved(ctx.BaseRepo, ctx.Pull) @@ -68,7 +68,7 @@ func (a *ApplyExecutor) setupAndApply(ctx *CommandContext) CommandResponse { return err } // if the plan is for the right env, - if !info.IsDir() && info.Name() == ctx.Command.environment+".tfplan" { + if !info.IsDir() && info.Name() == ctx.Command.Environment+".tfplan" { rel, _ := filepath.Rel(repoDir, filepath.Dir(path)) plans = append(plans, models.Plan{ Project: models.NewProject(ctx.BaseRepo.FullName, rel), @@ -92,7 +92,7 @@ func (a *ApplyExecutor) setupAndApply(ctx *CommandContext) CommandResponse { } func (a *ApplyExecutor) apply(ctx *CommandContext, repoDir string, plan models.Plan) ProjectResult { - tfEnv := ctx.Command.environment + tfEnv := ctx.Command.Environment lockAttempt, err := a.lockingClient.TryLock(plan.Project, tfEnv, ctx.Pull, ctx.User) if err != nil { return ProjectResult{Error: errors.Wrap(err, "acquiring lock")} @@ -115,7 +115,7 @@ func (a *ApplyExecutor) apply(ctx *CommandContext, repoDir string, plan models.P } // add terraform arguments from project config - terraformApplyExtraArgs = config.GetExtraArguments(ctx.Command.commandType.String()) + terraformApplyExtraArgs = config.GetExtraArguments(ctx.Command.Name.String()) } // check if terraform version is >= 0.9.0 @@ -135,7 +135,7 @@ func (a *ApplyExecutor) apply(ctx *CommandContext, repoDir string, plan models.P // if there are pre plan commands then run them if len(config.PreApply.Commands) > 0 { - preRunOutput, err := a.preRun.Start(config.PreApply.Commands, projectAbsolutePath, ctx.Command.environment, config.TerraformVersion) + preRunOutput, err := a.preRun.Start(config.PreApply.Commands, projectAbsolutePath, ctx.Command.Environment, config.TerraformVersion) if err != nil { return ProjectResult{Error: err} } diff --git a/server/command_handler.go b/server/command_handler.go index 9ac2c3756..c2ac3a3f1 100644 --- a/server/command_handler.go +++ b/server/command_handler.go @@ -21,7 +21,7 @@ type CommandResponse struct { Error error Failure string ProjectResults []ProjectResult - Command CommandType + Command CommandName } type ProjectResult struct { @@ -42,16 +42,16 @@ func (p ProjectResult) Status() Status { return Success } -type CommandType int +type CommandName int const ( - Apply CommandType = iota + Apply CommandName = iota Plan Help // Adding more? Don't forget to update String() below ) -func (c CommandType) String() string { +func (c CommandName) String() string { switch c { case Apply: return "apply" @@ -64,9 +64,9 @@ func (c CommandType) String() string { } type Command struct { - verbose bool - environment string - commandType CommandType + Verbose bool + Environment string + Name CommandName } func (c *CommandHandler) ExecuteCommand(ctx *CommandContext) { @@ -95,7 +95,7 @@ func (c *CommandHandler) ExecuteCommand(ctx *CommandContext) { return } - switch ctx.Command.commandType { + switch ctx.Command.Name { case Plan: c.planExecutor.execute(ctx) case Apply: diff --git a/server/event_parser.go b/server/event_parser.go index 89b0281fc..40662d513 100644 --- a/server/event_parser.go +++ b/server/event_parser.go @@ -9,11 +9,27 @@ import ( "github.com/hootsuite/atlantis/models" ) -type EventParser struct{} +type EventParser struct { + GithubUser string +} func (e *EventParser) DetermineCommand(comment *github.IssueCommentEvent) (*Command, error) { - // for legacy, also support "run" instead of atlantis - atlantisCommentRegex := `^(?:run|atlantis) (plan|apply|help)([[:blank:]])?([a-zA-Z0-9_-]+)?\s*(--verbose)?$` + // regex matches: + // 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 an optional environment and an optional --verbose flag + // + // examples: + // atlantis help + // run plan + // @GithubUser plan staging + // atlantis plan staging --verbose + // + // capture groups: + // 1: the command, i.e. plan/apply/help + // 2: the environment OR the --verbose flag (if they didn't specify and environment) + // 3: the --verbose flag (if they specified an environment) + atlantisCommentRegex := `^(?:run|atlantis|@` + e.GithubUser + `)[[:blank:]]+(plan|apply|help)(?:[[:blank:]]+([a-zA-Z0-9_-]+))?[[:blank:]]*(--verbose)?$` runPlanMatcher := regexp.MustCompile(atlantisCommentRegex) commentBody := comment.Comment.GetBody() @@ -23,7 +39,7 @@ func (e *EventParser) DetermineCommand(comment *github.IssueCommentEvent) (*Comm // extract the command and environment. ex. for "atlantis plan staging", the command is "plan", and the environment is "staging" match := runPlanMatcher.FindStringSubmatch(commentBody) - if len(match) < 5 { + if len(match) < 4 { var truncated = commentBody if len(truncated) > 30 { truncated = truncated[0:30] + "..." @@ -31,27 +47,39 @@ func (e *EventParser) DetermineCommand(comment *github.IssueCommentEvent) (*Comm return nil, errors.New("not an Atlantis command") } + // depending on the comment, the command/env/verbose may be in different matching groups + // if there is no env (ex. just atlantis plan --verbose) then, verbose would be in the 2nd group + // if there is an env, then verbose would be in the 3rd + command := match[1] + env := match[2] + verboseFlag := match[3] + if verboseFlag == "" && env == "--verbose" { + verboseFlag = env + env = "" + } + + // now we're ready to actually look at the values verbose := false - if match[4] == "--verbose" { + if verboseFlag == "--verbose" { verbose = true } - // defaulting to terraform's default environment - env := "default" - if match[3] != "" { - env = match[3] + // if env not specified, use terraform's default + if env == "" { + env = "default" } - command := &Command{verbose: verbose, environment: env} - switch match[1] { + + c := &Command{Verbose: verbose, Environment: env} + switch command { case "plan": - command.commandType = Plan + c.Name = Plan case "apply": - command.commandType = Apply + c.Name = Apply case "help": - command.commandType = Help + c.Name = Help default: return nil, fmt.Errorf("something went wrong with our regex, the command we parsed %q was not apply or plan", match[1]) } - return command, nil + return c, nil } func (e *EventParser) ExtractCommentData(comment *github.IssueCommentEvent, ctx *CommandContext) error { diff --git a/server/event_parser_test.go b/server/event_parser_test.go new file mode 100644 index 000000000..1e4da6e32 --- /dev/null +++ b/server/event_parser_test.go @@ -0,0 +1,93 @@ +package server_test + +import ( + "fmt" + "testing" + + "github.com/google/go-github/github" + "github.com/hootsuite/atlantis/server" + . "github.com/hootsuite/atlantis/testing_util" +) + +func TestDetermineCommandInvalid(t *testing.T) { + t.Log("given a comment that does not match the regex should return an error") + e := server.EventParser{"user"} + comments := []string{ + // just the executable, no command + "run", + "atlantis", + "@user", + // invalid command + "run slkjd", + "atlantis slkjd", + "@user slkjd", + "atlantis plans", + // whitespace + " atlantis plan", + // misc + "related comment mentioning atlantis", + } + for _, c := range comments { + _, e := e.DetermineCommand(buildComment(c)) + Assert(t, e != nil, "expected error for comment: "+c) + } +} + +func TestDetermineCommandHelp(t *testing.T) { + t.Log("given a help comment, should match") + e := server.EventParser{"user"} + comments := []string{ + "run help", + "atlantis help", + "@user help", + "atlantis help --verbose", + } + for _, c := range comments { + command, e := e.DetermineCommand(buildComment(c)) + Ok(t, e) + Equals(t, server.Help, command.Name) + } +} + +func TestDetermineCommandPermutations(t *testing.T) { + e := server.EventParser{"user"} + + execNames := []string{"run", "atlantis", "@user"} + commandNames := []server.CommandName{server.Plan, server.Apply} + envs := []string{"", "default", "env", "env-dash", "env_underscore", "camelEnv"} + verboses := []bool{true, false} + + // test all permutations + for _, exec := range execNames { + for _, name := range commandNames { + for _, env := range envs { + for _, v := range verboses { + vFlag := "" + if v == true { + vFlag = "--verbose" + } + + comment := fmt.Sprintf("%s %s %s %s", exec, name.String(), env, vFlag) + t.Log("testing comment: " + comment) + c, err := e.DetermineCommand(buildComment(comment)) + Ok(t, err) + Equals(t, name, c.Name) + if env == "" { + Equals(t, "default", c.Environment) + } else { + Equals(t, env, c.Environment) + } + Equals(t, v, c.Verbose) + } + } + } + } +} + +func buildComment(c string) *github.IssueCommentEvent { + return &github.IssueCommentEvent{ + Comment: &github.IssueComment{ + Body: github.String(c), + }, + } +} diff --git a/server/github_status.go b/server/github_status.go index 6d5367fef..2d44c1170 100644 --- a/server/github_status.go +++ b/server/github_status.go @@ -50,7 +50,7 @@ func (g *GithubStatus) UpdatePathResult(ctx *CommandContext, pathResults []Proje statuses = append(statuses, p.Status()) } worst := g.worstStatus(statuses) - return g.Update(ctx.BaseRepo, ctx.Pull, worst, ctx.Command.commandType.String()) + return g.Update(ctx.BaseRepo, ctx.Pull, worst, ctx.Command.Name.String()) } func (g *GithubStatus) worstStatus(ss []Status) Status { diff --git a/server/plan_executor.go b/server/plan_executor.go index 557ef29f8..3232fe37d 100644 --- a/server/plan_executor.go +++ b/server/plan_executor.go @@ -44,16 +44,16 @@ func (p *PlanExecutor) execute(ctx *CommandContext) { p.githubStatus.Update(ctx.BaseRepo, ctx.Pull, Pending, PlanStep) res := p.setupAndPlan(ctx) res.Command = Plan - comment := p.githubCommentRenderer.render(res, ctx.Log.History.String(), ctx.Command.verbose) + comment := p.githubCommentRenderer.render(res, ctx.Log.History.String(), ctx.Command.Verbose) p.github.CreateComment(ctx.BaseRepo, ctx.Pull, comment) } func (p *PlanExecutor) setupAndPlan(ctx *CommandContext) CommandResponse { - if p.concurrentRunLocker.TryLock(ctx.BaseRepo.FullName, ctx.Command.environment, ctx.Pull.Num) != true { + if p.concurrentRunLocker.TryLock(ctx.BaseRepo.FullName, ctx.Command.Environment, ctx.Pull.Num) != true { return p.failureResponse(ctx, - fmt.Sprintf("The %s environment is currently locked by another command that is running for this pull request. Wait until command is complete and try again.", ctx.Command.environment)) + fmt.Sprintf("The %s environment is currently locked by another command that is running for this pull request. Wait until command is complete and try again.", ctx.Command.Environment)) } - defer p.concurrentRunLocker.Unlock(ctx.BaseRepo.FullName, ctx.Command.environment, ctx.Pull.Num) + defer p.concurrentRunLocker.Unlock(ctx.BaseRepo.FullName, ctx.Command.Environment, ctx.Pull.Num) // figure out what projects have been modified so we know where to run plan ctx.Log.Info("listing modified files from pull request") @@ -88,7 +88,7 @@ func (p *PlanExecutor) setupAndPlan(ctx *CommandContext) CommandResponse { func (p *PlanExecutor) plan(ctx *CommandContext, repoDir string, project models.Project) ProjectResult { ctx.Log.Info("generating plan at %q", project.Path) - tfEnv := ctx.Command.environment + tfEnv := ctx.Command.Environment lockAttempt, err := p.lockingClient.TryLock(project, tfEnv, ctx.Pull, ctx.User) if err != nil { return ProjectResult{Error: errors.Wrap(err, "acquiring lock")} @@ -110,7 +110,7 @@ func (p *PlanExecutor) plan(ctx *CommandContext, repoDir string, project models. } // add terraform arguments from project config - planExtraArgs = config.GetExtraArguments(ctx.Command.commandType.String()) + planExtraArgs = config.GetExtraArguments(ctx.Command.Name.String()) } // check if terraform version is >= 0.9.0 diff --git a/server/server.go b/server/server.go index dda2ee4c4..6f5148a37 100644 --- a/server/server.go +++ b/server/server.go @@ -153,7 +153,9 @@ func NewServer(config ServerConfig) (*Server, error) { workspace: workspace, } logger := logging.NewSimpleLogger("server", log.New(os.Stderr, "", log.LstdFlags), false, logging.ToLogLevel(config.LogLevel)) - eventParser := &EventParser{} + eventParser := &EventParser{ + GithubUser: config.GithubUser, + } commandHandler := &CommandHandler{ applyExecutor: applyExecutor, planExecutor: planExecutor, diff --git a/server/workspace.go b/server/workspace.go index 975c4b24d..5023a4449 100644 --- a/server/workspace.go +++ b/server/workspace.go @@ -66,5 +66,5 @@ func (w *Workspace) repoPullDir(repo models.Repo, pull models.PullRequest) strin } func (w *Workspace) cloneDir(ctx *CommandContext) string { - return filepath.Join(w.repoPullDir(ctx.BaseRepo, ctx.Pull), ctx.Command.environment) + return filepath.Join(w.repoPullDir(ctx.BaseRepo, ctx.Pull), ctx.Command.Environment) }