diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go index 5cc962cab..fc94789fb 100644 --- a/bootstrap/bootstrap.go +++ b/bootstrap/bootstrap.go @@ -1,10 +1,13 @@ -// Package bootstrap is used to make getting started with atlantis easier +// Package bootstrap is used by the bootstrap command as a quick-start of +// Atlantis. package bootstrap import ( "context" "fmt" "os" + "os/exec" + "os/signal" "runtime" "strings" "syscall" @@ -14,14 +17,10 @@ import ( "github.com/google/go-github/github" "github.com/mitchellh/colorstring" "github.com/pkg/errors" - - "os/exec" - "os/signal" ) var terraformExampleRepoOwner = "hootsuite" var terraformExampleRepo = "atlantis-example" - var bootstrapDescription = `[white]Welcome to Atlantis bootstrap! This mode walks you through setting up and using Atlantis. We will @@ -38,7 +37,7 @@ var pullRequestBody = "In this pull request we will learn how to use atlantis. T "* Now lets apply that plan. Type `atlantis apply` in the comments. This will run a `terraform apply`.\n" + "\nThank you for trying out atlantis. For more info on running atlantis in production see https://github.com/hootsuite/atlantis" -// Start begins the bootstrap process +// Start begins the bootstrap process. // nolint: errcheck func Start() error { s := spinner.New(spinner.CharSets[14], 100*time.Millisecond) @@ -58,19 +57,16 @@ Follow these instructions to create a token (we don't store any tokens): - add "repo" scope - copy the access token `) - // read github token, check for error later + // Read github token, check for error later. colorstring.Print("[white][bold]GitHub access token (will be hidden): ") githubToken, _ = readPassword() - - // create github client tp := github.BasicAuthTransport{ Username: strings.TrimSpace(githubUsername), Password: strings.TrimSpace(githubToken), } - githubClient := &Client{client: github.NewClient(tp.Client()), ctx: context.Background()} - // fork terraform example repo + // Fork terraform example repo. colorstring.Printf("\n[white]=> forking repo ") s.Start() if err := githubClient.CreateFork(terraformExampleRepoOwner, terraformExampleRepo); err != nil { @@ -82,10 +78,9 @@ Follow these instructions to create a token (we don't store any tokens): s.Stop() colorstring.Println("\n[green]=> fork completed!") - // detect terraform + // Detect terraform and install it if not installed. _, err := exec.LookPath("terraform") if err != nil { - // download terraform colorstring.Println("[yellow]=> terraform not found in $PATH.") colorstring.Printf("[white]=> downloading terraform ") s.Start() @@ -107,7 +102,7 @@ Follow these instructions to create a token (we don't store any tokens): colorstring.Println("[green]=> terraform found in $PATH!") } - // download ngrok + // Download ngrok. colorstring.Printf("[white]=> downloading ngrok ") s.Start() ngrokURL := fmt.Sprintf("%s/ngrok-stable-%s-%s.zip", ngrokDownloadURL, runtime.GOOS, runtime.GOARCH) @@ -117,7 +112,7 @@ Follow these instructions to create a token (we don't store any tokens): s.Stop() colorstring.Println("\n[green]=> downloaded ngrok successfully!") - // create ngrok tunnel + // Create ngrok tunnel. colorstring.Printf("[white]=> creating secure tunnel ") s.Start() ngrokCmd, err := executeCmd("/tmp/ngrok", []string{"http", "4141"}) @@ -129,10 +124,10 @@ Follow these instructions to create a token (we don't store any tokens): go func() { ngrokErrChan <- ngrokCmd.Wait() }() - // if this function returns ngrok tunnel should be stopped + // When this function returns, ngrok tunnel should be stopped. defer ngrokCmd.Process.Kill() - // wait for the tunnel to be up + // Wait for the tunnel to be up. time.Sleep(2 * time.Second) s.Stop() colorstring.Println("\n[green]=> started tunnel!") @@ -142,7 +137,7 @@ Follow these instructions to create a token (we don't store any tokens): } s.Stop() - // start atlantis server + // Start atlantis server. colorstring.Printf("[white]=> starting atlantis server ") s.Start() atlantisCmd, err := executeCmd(os.Args[0], []string{"server", "--gh-user", githubUsername, "--gh-token", githubToken, "--data-dir", "/tmp/atlantis/data", "--atlantis-url", tunnelURL}) @@ -154,12 +149,12 @@ Follow these instructions to create a token (we don't store any tokens): go func() { atlantisErrChan <- atlantisCmd.Wait() }() - // if this function returns atlantis server should be stopped + // When this function returns atlantis server should be stopped. defer atlantisCmd.Process.Kill() colorstring.Printf("\n[green]=> atlantis server is now securely exposed at [bold][underline]%s", tunnelURL) fmt.Println("") - // create atlantis webhook + // Create atlantis webhook. colorstring.Printf("[white]=> creating atlantis webhook ") s.Start() err = githubClient.CreateWebhook(githubUsername, terraformExampleRepo, fmt.Sprintf("%s/events", tunnelURL)) @@ -169,7 +164,7 @@ Follow these instructions to create a token (we don't store any tokens): s.Stop() colorstring.Println("\n[green]=> atlantis webhook created!") - // create a new pr in the example repo + // Create a new pr in the example repo. colorstring.Printf("[white]=> creating a new pull request ") s.Start() pullRequestURL, err := githubClient.CreatePullRequest(githubUsername, terraformExampleRepo, "example", "master") @@ -179,7 +174,7 @@ Follow these instructions to create a token (we don't store any tokens): s.Stop() colorstring.Println("\n[green]=> pull request created!") - // open new pull request in the browser + // Open new pull request in the browser. colorstring.Printf("[white]=> opening pull request ") s.Start() time.Sleep(2 * time.Second) @@ -189,12 +184,13 @@ Follow these instructions to create a token (we don't store any tokens): } s.Stop() - // wait for ngrok and atlantis server process to finish + // Wait for ngrok and atlantis server process to finish. colorstring.Printf("\n[_green_][light_green]atlantis is running ") s.Start() colorstring.Println("[green] [press Ctrl-c to exit]") - // wait for sigterm or siginit signal + // Wait for SIGINT or SIGTERM signals meaning the user has Ctrl-C'd the + // bootstrap process and want's to stop. signalChan := make(chan os.Signal, 1) signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM) <-signalChan diff --git a/bootstrap/github.go b/bootstrap/github.go index a318509bb..f9a652bb1 100644 --- a/bootstrap/github.go +++ b/bootstrap/github.go @@ -11,25 +11,25 @@ import ( var githubUsername string var githubToken string -// Client used for github interactions +// Client used for GitHub interactions. type Client struct { client *github.Client ctx context.Context } -// CreateFork forks a github repo into user that is authenticated +// CreateFork forks a GitHub repo into the user's account that is authenticated. func (g *Client) CreateFork(owner string, repoName string) error { - // forks usually take up to 5 minutes to complete according to github _, _, err := g.client.Repositories.CreateFork(g.ctx, owner, repoName, nil) - // github client returns an error even though the fork was successful - // in order to figure out the exact error we will need to do the string evaluation below + // The GitHub client returns an error even though the fork was successful. + // In order to figure out the exact error we will need to check the message. if err != nil && !strings.Contains(err.Error(), "job scheduled on GitHub side; try again later") { return err } return nil } -// CheckForkSuccess waits for github fork to complete +// CheckForkSuccess waits for github fork to complete. +// Forks can take up to 5 minutes to complete according to GitHub. func (g *Client) CheckForkSuccess(ownerName string, forkRepoName string) bool { for i := 0; i < 5; i++ { if err := g.CreateFork(ownerName, forkRepoName); err == nil { @@ -40,9 +40,8 @@ func (g *Client) CheckForkSuccess(ownerName string, forkRepoName string) bool { return false } -// CreateWebhook creates a github webhook +// CreateWebhook creates a GitHub webhook to send requests to our local ngrok. func (g *Client) CreateWebhook(ownerName string, repoName string, hookURL string) error { - // create atlantis hook atlantisHook := &github.Hook{ Name: github.String("web"), Events: []string{"issue_comment", "pull_request", "pull_request_review", "push"}, @@ -56,11 +55,12 @@ func (g *Client) CreateWebhook(ownerName string, repoName string, hookURL string return err } -// CreatePullRequest creates a github pull request with custom title and description. -// It first checks if there's already a pull request open for this branch +// CreatePullRequest creates a GitHub pull request with custom title and +// description. If there's already a pull request open for this branch it will +// return successfully. func (g *Client) CreatePullRequest(ownerName string, repoName string, head string, base string) (string, error) { - // first check if the pull request already exists + // First check if the pull request already exists. pulls, _, err := g.client.PullRequests.List(g.ctx, ownerName, repoName, nil) if err != nil { return "", err @@ -71,14 +71,13 @@ func (g *Client) CreatePullRequest(ownerName string, repoName string, head strin } } - // if not, create it + // If not, create it. newPullRequest := &github.NewPullRequest{ Title: github.String("Welcome to Atlantis!"), Head: github.String(head), Body: github.String(pullRequestBody), Base: github.String(base), } - pull, _, err := g.client.PullRequests.Create(g.ctx, ownerName, repoName, newPullRequest) if err != nil { return "", err diff --git a/bootstrap/utils.go b/bootstrap/utils.go index 649070ac6..25b7d40c8 100644 --- a/bootstrap/utils.go +++ b/bootstrap/utils.go @@ -15,7 +15,7 @@ import ( ) var hashicorpReleasesURL = "https://releases.hashicorp.com" -var terraformVersion = "0.9.11" +var terraformVersion = "0.10.8" var ngrokDownloadURL = "https://bin.equinox.io/c/4VmDzA7iaHb" var ngrokAPIURL = "http://localhost:4040" diff --git a/cmd/cmd.go b/cmd/cmd.go new file mode 100644 index 000000000..d1b5b18c7 --- /dev/null +++ b/cmd/cmd.go @@ -0,0 +1,4 @@ +// Package cmd provides all CLI commands. +// NOTE: These are different from the commands that get run via pull request +// comments. +package cmd diff --git a/cmd/root.go b/cmd/root.go index 6daab7e27..90fbc0373 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,5 +1,3 @@ -// Package cmd holds all our cli commands. -// These are different from the commands that get run via pull request comments. package cmd import ( @@ -8,11 +6,13 @@ import ( "github.com/spf13/cobra" ) +// RootCmd is the base command onto which all other commands are added. var RootCmd = &cobra.Command{ Use: "atlantis", Short: "Manage your Terraform workflow from GitHub", } +// Execute starts RootCmd. func Execute() { if err := RootCmd.Execute(); err != nil { os.Exit(1) diff --git a/cmd/server.go b/cmd/server.go index 5f41c4d68..4c5f516a6 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -3,7 +3,6 @@ package cmd import ( "fmt" "os" - "strings" "github.com/hootsuite/atlantis/server" @@ -224,16 +223,16 @@ func (s *ServerCmd) run() error { if err := s.Viper.Unmarshal(&config); err != nil { return err } - if err := validate(config); err != nil { + if err := s.validate(config); err != nil { return err } - if err := setAtlantisURL(&config); err != nil { + if err := s.setAtlantisURL(&config); err != nil { return err } - if err := setDataDir(&config); err != nil { + if err := s.setDataDir(&config); err != nil { return err } - trimAtSymbolFromUsers(&config) + s.trimAtSymbolFromUsers(&config) // Config looks good. Start the server. server, err := s.ServerCreator.NewServer(config) @@ -243,7 +242,8 @@ func (s *ServerCmd) run() error { return server.Start() } -func validate(config server.Config) error { +// nolint: gocyclo +func (s *ServerCmd) validate(config server.Config) error { logLevel := config.LogLevel if logLevel != "debug" && logLevel != "info" && logLevel != "warn" && logLevel != "error" { return errors.New("invalid log level: not one of debug, info, warn, error") @@ -270,7 +270,7 @@ func validate(config server.Config) error { } // setAtlantisURL sets the externally accessible URL for atlantis. -func setAtlantisURL(config *server.Config) error { +func (s *ServerCmd) setAtlantisURL(config *server.Config) error { if config.AtlantisURL == "" { hostname, err := os.Hostname() if err != nil { @@ -284,7 +284,7 @@ func setAtlantisURL(config *server.Config) error { // setDataDir checks if ~ was used in data-dir and converts it to the actual // home directory. If we don't do this, we'll create a directory called "~" // instead of actually using home. -func setDataDir(config *server.Config) error { +func (s *ServerCmd) setDataDir(config *server.Config) error { if strings.HasPrefix(config.DataDir, "~/") { expanded, err := homedir.Expand(config.DataDir) if err != nil { @@ -296,7 +296,7 @@ func setDataDir(config *server.Config) error { } // trimAtSymbolFromUsers trims @ from the front of the github and gitlab usernames -func trimAtSymbolFromUsers(config *server.Config) { +func (s *ServerCmd) trimAtSymbolFromUsers(config *server.Config) { config.GithubUser = strings.TrimPrefix(config.GithubUser, "@") config.GitlabUser = strings.TrimPrefix(config.GitlabUser, "@") } diff --git a/main.go b/main.go index ed08c8cbd..e321c5257 100644 --- a/main.go +++ b/main.go @@ -1,3 +1,4 @@ +// Package main is the entrypoint for the CLI. package main import ( @@ -9,6 +10,8 @@ func main() { v := viper.New() v.Set("version", "0.2.0") + // We're creating commands manually here rather than using init() functions + // (as recommended by cobra) because it makes testing easier. server := &cmd.ServerCmd{ ServerCreator: &cmd.DefaultServerCreator{}, Viper: v, diff --git a/server/events/apply_executor.go b/server/events/apply_executor.go index e89fec1ca..694ff6281 100644 --- a/server/events/apply_executor.go +++ b/server/events/apply_executor.go @@ -3,9 +3,6 @@ package events import ( "fmt" "os" - - "github.com/pkg/errors" - "path/filepath" "github.com/hootsuite/atlantis/server/events/models" @@ -13,18 +10,21 @@ import ( "github.com/hootsuite/atlantis/server/events/terraform" "github.com/hootsuite/atlantis/server/events/vcs" "github.com/hootsuite/atlantis/server/events/webhooks" + "github.com/pkg/errors" ) +// ApplyExecutor handles executing terraform apply. type ApplyExecutor struct { VCSClient vcs.ClientProxy Terraform *terraform.Client RequireApproval bool Run *run.Run Workspace Workspace - ProjectPreExecute *ProjectPreExecute + ProjectPreExecute *DefaultProjectPreExecutor Webhooks webhooks.Sender } +// Execute executes apply for the ctx. func (a *ApplyExecutor) Execute(ctx *CommandContext) CommandResponse { if a.RequireApproval { approved, err := a.VCSClient.PullIsApproved(ctx.BaseRepo, ctx.Pull, ctx.VCSHost) @@ -43,13 +43,14 @@ func (a *ApplyExecutor) Execute(ctx *CommandContext) CommandResponse { } ctx.Log.Info("found workspace in %q", repoDir) - // plans are stored at project roots by their environment names. We just need to find them + // Plans are stored at project roots by their environment names. We just + // need to find them. var plans []models.Plan err = filepath.Walk(repoDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } - // if the plan is for the right env, + // Check if the plan is for the right env, if !info.IsDir() && info.Name() == ctx.Command.Environment+".tfplan" { rel, _ := filepath.Rel(repoDir, filepath.Dir(path)) plans = append(plans, models.Plan{ diff --git a/server/events/command_context.go b/server/events/command_context.go index 39dc0c4b2..e43421efa 100644 --- a/server/events/command_context.go +++ b/server/events/command_context.go @@ -6,12 +6,21 @@ import ( "github.com/hootsuite/atlantis/server/logging" ) +// CommandContext represents the context of a command that came from a comment +// on a pull request. type CommandContext struct { + // BaseRepo is the repository that the pull request will be merged into. BaseRepo models.Repo + // HeadRepo is the repository that is getting merged into the BaseRepo. + // If the pull request branch is from the same repository then HeadRepo will + // be the same as BaseRepo. + // See https://help.github.com/articles/about-pull-request-merges/. HeadRepo models.Repo Pull models.PullRequest - User models.User - Command *Command - Log *logging.SimpleLogger - VCSHost vcs.Host + // User is the user that triggered this command. + User models.User + Command *Command + Log *logging.SimpleLogger + // VCSHost is the host that the command came from. + VCSHost vcs.Host } diff --git a/server/events/command_handler.go b/server/events/command_handler.go index 0dd7189f3..429553833 100644 --- a/server/events/command_handler.go +++ b/server/events/command_handler.go @@ -14,19 +14,27 @@ import ( //go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_command_runner.go CommandRunner +// CommandRunner is the first step after a command request has been parsed. type CommandRunner interface { + // ExecuteCommand is the first step after a command request has been parsed. + // It handles gathering additional information needed to execute the command + // and then calling the appropriate services to finish executing the command. ExecuteCommand(baseRepo models.Repo, headRepo models.Repo, user models.User, pullNum int, cmd *Command, vcsHost vcs.Host) } //go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_github_pull_getter.go GithubPullGetter +// GithubPullGetter makes API calls to get pull requests. type GithubPullGetter interface { + // GetPullRequest gets the pull request with id pullNum for the repo. GetPullRequest(repo models.Repo, pullNum int) (*github.PullRequest, error) } //go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_gitlab_merge_request_getter.go GitlabMergeRequestGetter +// GitlabMergeRequestGetter makes API calls to get merge requests. type GitlabMergeRequestGetter interface { + // GetMergeRequest gets the pull request with the id pullNum for the repo. GetMergeRequest(repoFullName string, pullNum int) (*gitlab.MergeRequest, error) } @@ -41,12 +49,12 @@ type CommandHandler struct { GitlabMergeRequestGetter GitlabMergeRequestGetter CommitStatusUpdater CommitStatusUpdater EventParser EventParsing - EnvLocker EnvLocker + WorkspaceLocker WorkspaceLocker MarkdownRenderer *MarkdownRenderer Logger logging.SimpleLogging } -// ExecuteCommand executes the command +// ExecuteCommand executes the command. func (c *CommandHandler) ExecuteCommand(baseRepo models.Repo, headRepo models.Repo, user models.User, pullNum int, cmd *Command, vcsHost vcs.Host) { var err error var pull models.PullRequest @@ -105,9 +113,11 @@ func (c *CommandHandler) buildLogger(repoFullName string, pullNum int) *logging. return logging.NewSimpleLogger(src, c.Logger.Underlying(), true, c.Logger.GetLevel()) } +// SetLockURL sets a function that's used to return the URL for a lock. func (c *CommandHandler) SetLockURL(f func(id string) (url string)) { c.LockURLGenerator.SetLockURL(f) } + func (c *CommandHandler) run(ctx *CommandContext) { log := c.buildLogger(ctx.BaseRepo.FullName, ctx.Pull.Num) ctx.Log = log @@ -120,7 +130,7 @@ func (c *CommandHandler) run(ctx *CommandContext) { } c.CommitStatusUpdater.Update(ctx.BaseRepo, ctx.Pull, vcs.Pending, ctx.Command, ctx.VCSHost) // nolint: errcheck - if !c.EnvLocker.TryLock(ctx.BaseRepo.FullName, ctx.Command.Environment, ctx.Pull.Num) { + if !c.WorkspaceLocker.TryLock(ctx.BaseRepo.FullName, ctx.Command.Environment, ctx.Pull.Num) { errMsg := fmt.Sprintf( "The %s environment is currently locked by another"+ " command that is running for this pull request."+ @@ -130,7 +140,7 @@ func (c *CommandHandler) run(ctx *CommandContext) { c.updatePull(ctx, CommandResponse{Failure: errMsg}) return } - defer c.EnvLocker.Unlock(ctx.BaseRepo.FullName, ctx.Command.Environment, ctx.Pull.Num) + defer c.WorkspaceLocker.Unlock(ctx.BaseRepo.FullName, ctx.Command.Environment, ctx.Pull.Num) var cr CommandResponse switch ctx.Command.Name { @@ -160,7 +170,7 @@ func (c *CommandHandler) updatePull(ctx *CommandContext, res CommandResponse) { c.VCSClient.CreateComment(ctx.BaseRepo, ctx.Pull, comment, ctx.VCSHost) // nolint: errcheck } -// logPanics logs and creates a comment on the pull request for panics +// logPanics logs and creates a comment on the pull request for panics. func (c *CommandHandler) logPanics(ctx *CommandContext) { if err := recover(); err != nil { stack := recovery.Stack(3) diff --git a/server/events/command_handler_test.go b/server/events/command_handler_test.go index 56565b233..aa797bb3b 100644 --- a/server/events/command_handler_test.go +++ b/server/events/command_handler_test.go @@ -28,7 +28,7 @@ var vcsClient *vcsmocks.MockClientProxy var ghStatus *mocks.MockCommitStatusUpdater var githubGetter *mocks.MockGithubPullGetter var gitlabGetter *mocks.MockGitlabMergeRequestGetter -var envLocker *mocks.MockEnvLocker +var workspaceLocker *mocks.MockWorkspaceLocker var ch events.CommandHandler var logBytes *bytes.Buffer @@ -39,7 +39,7 @@ func setup(t *testing.T) { planner = mocks.NewMockExecutor() eventParsing = mocks.NewMockEventParsing() ghStatus = mocks.NewMockCommitStatusUpdater() - envLocker = mocks.NewMockEnvLocker() + workspaceLocker = mocks.NewMockWorkspaceLocker() vcsClient = vcsmocks.NewMockClientProxy() githubGetter = mocks.NewMockGithubPullGetter() gitlabGetter = mocks.NewMockGitlabMergeRequestGetter() @@ -53,7 +53,7 @@ func setup(t *testing.T) { VCSClient: vcsClient, CommitStatusUpdater: ghStatus, EventParser: eventParsing, - EnvLocker: envLocker, + WorkspaceLocker: workspaceLocker, MarkdownRenderer: &events.MarkdownRenderer{}, GithubPullGetter: githubGetter, GitlabMergeRequestGetter: gitlabGetter, @@ -141,7 +141,7 @@ func TestExecuteCommand_EnvLocked(t *testing.T) { When(githubGetter.GetPullRequest(fixtures.Repo, fixtures.Pull.Num)).ThenReturn(pull, nil) When(eventParsing.ParseGithubPull(pull)).ThenReturn(fixtures.Pull, fixtures.Repo, nil) - When(envLocker.TryLock(fixtures.Repo.FullName, cmd.Environment, fixtures.Pull.Num)).ThenReturn(false) + When(workspaceLocker.TryLock(fixtures.Repo.FullName, cmd.Environment, fixtures.Pull.Num)).ThenReturn(false) ch.ExecuteCommand(fixtures.Repo, fixtures.Repo, fixtures.User, fixtures.Pull.Num, &cmd, vcs.Github) msg := "The env environment is currently locked by another" + @@ -168,7 +168,7 @@ func TestExecuteCommand_FullRun(t *testing.T) { } When(githubGetter.GetPullRequest(fixtures.Repo, fixtures.Pull.Num)).ThenReturn(pull, nil) When(eventParsing.ParseGithubPull(pull)).ThenReturn(fixtures.Pull, fixtures.Repo, nil) - When(envLocker.TryLock(fixtures.Repo.FullName, cmd.Environment, fixtures.Pull.Num)).ThenReturn(true) + When(workspaceLocker.TryLock(fixtures.Repo.FullName, cmd.Environment, fixtures.Pull.Num)).ThenReturn(true) switch c { case events.Help: When(helper.Execute(matchers.AnyPtrToEventsCommandContext())).ThenReturn(cmdResponse) @@ -184,6 +184,6 @@ func TestExecuteCommand_FullRun(t *testing.T) { _, response := ghStatus.VerifyWasCalledOnce().UpdateProjectResult(matchers.AnyPtrToEventsCommandContext(), matchers.AnyEventsCommandResponse()).GetCapturedArguments() Equals(t, cmdResponse, response) vcsClient.VerifyWasCalledOnce().CreateComment(matchers.AnyModelsRepo(), matchers.AnyModelsPullRequest(), AnyString(), matchers.AnyVcsHost()) - envLocker.VerifyWasCalledOnce().Unlock(fixtures.Repo.FullName, cmd.Environment, fixtures.Pull.Num) + workspaceLocker.VerifyWasCalledOnce().Unlock(fixtures.Repo.FullName, cmd.Environment, fixtures.Pull.Num) } } diff --git a/server/events/command_name.go b/server/events/command_name.go index ffa1e389b..63d75a6ce 100644 --- a/server/events/command_name.go +++ b/server/events/command_name.go @@ -1,5 +1,6 @@ package events +// CommandName is the type of command. type CommandName int const ( @@ -9,6 +10,7 @@ const ( // Adding more? Don't forget to update String() below ) +// String returns the string representation of c. func (c CommandName) String() string { switch c { case Apply: diff --git a/server/events/command_response.go b/server/events/command_response.go index 2f108725e..a37bc73fe 100644 --- a/server/events/command_response.go +++ b/server/events/command_response.go @@ -1,5 +1,6 @@ package events +// CommandResponse is the result of running a Command. type CommandResponse struct { Error error Failure string diff --git a/server/events/commit_status_updater.go b/server/events/commit_status_updater.go index 7fc8a8ce1..8c6bbe85f 100644 --- a/server/events/commit_status_updater.go +++ b/server/events/commit_status_updater.go @@ -10,20 +10,28 @@ import ( //go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_commit_status_updater.go CommitStatusUpdater +// CommitStatusUpdater updates the status of a commit with the VCS host. We set +// the status to signify whether the plan/apply succeeds. type CommitStatusUpdater interface { + // Update updates the status of the head commit of pull. Update(repo models.Repo, pull models.PullRequest, status vcs.CommitStatus, cmd *Command, host vcs.Host) error + // UpdateProjectResult updates the status of the head commit given the + // state of response. UpdateProjectResult(ctx *CommandContext, res CommandResponse) error } +// DefaultCommitStatusUpdater implements CommitStatusUpdater. type DefaultCommitStatusUpdater struct { Client vcs.ClientProxy } +// Update updates the commit status. func (d *DefaultCommitStatusUpdater) Update(repo models.Repo, pull models.PullRequest, status vcs.CommitStatus, cmd *Command, host vcs.Host) error { description := fmt.Sprintf("%s %s", strings.Title(cmd.Name.String()), strings.Title(status.String())) return d.Client.UpdateStatus(repo, pull, status, description, host) } +// UpdateProjectResult updates the commit status based on the status of res. func (d *DefaultCommitStatusUpdater) UpdateProjectResult(ctx *CommandContext, res CommandResponse) error { var status vcs.CommitStatus if res.Error != nil || res.Failure != "" { diff --git a/server/events/event_parser.go b/server/events/event_parser.go index 3e2e7dcfa..9cf20f3d2 100644 --- a/server/events/event_parser.go +++ b/server/events/event_parser.go @@ -41,6 +41,7 @@ type EventParser struct { // DetermineCommand parses the comment as an atlantis command. If it succeeds, // it returns the command. Otherwise it returns error. +// nolint: gocyclo func (e *EventParser) DetermineCommand(comment string, vcsHost vcs.Host) (*Command, error) { // valid commands contain: // the initial "executable" name, 'run' or 'atlantis' or '@GithubUser' where GithubUser is the api user atlantis is running as diff --git a/server/events/event_parser_test.go b/server/events/event_parser_test.go index 29c8efd8f..b489f7298 100644 --- a/server/events/event_parser_test.go +++ b/server/events/event_parser_test.go @@ -61,6 +61,7 @@ func TestDetermineCommandHelp(t *testing.T) { } } +// nolint: gocyclo func TestDetermineCommandPermutations(t *testing.T) { execNames := []string{"run", "atlantis", "@github-user", "@gitlab-user"} commandNames := []events.CommandName{events.Plan, events.Apply} diff --git a/server/events/executor.go b/server/events/executor.go index d8afd6620..8c88a4ccb 100644 --- a/server/events/executor.go +++ b/server/events/executor.go @@ -2,6 +2,8 @@ package events //go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_executor.go Executor +// Executor is the generic interface implemented by each command type: +// help, plan, and apply. type Executor interface { Execute(ctx *CommandContext) CommandResponse } diff --git a/server/events/help_executor.go b/server/events/help_executor.go index bb3d5ba89..74cbd2e4e 100644 --- a/server/events/help_executor.go +++ b/server/events/help_executor.go @@ -1,7 +1,9 @@ package events +// HelpExecutor executes the help command. type HelpExecutor struct{} +// Execute executes the help command. func (h *HelpExecutor) Execute(ctx *CommandContext) CommandResponse { // We don't actually need to do anything since the comment renderer // will see that it is a help command and render the help comment. diff --git a/server/events/markdown_renderer.go b/server/events/markdown_renderer.go index 2e854cdab..ab3ae792d 100644 --- a/server/events/markdown_renderer.go +++ b/server/events/markdown_renderer.go @@ -7,87 +7,35 @@ import ( "text/template" ) -var helpTmpl = template.Must(template.New("").Parse("```cmake\n" + - `atlantis - Terraform collaboration tool that enables you to collaborate on infrastructure -safely and securely. - -Usage: atlantis [environment] [--verbose] - -Commands: -plan Runs 'terraform plan' on the files changed in the pull request -apply Runs 'terraform apply' using the plans generated by 'atlantis plan' -help Get help - -Examples: - -# Generates a plan for staging environment -atlantis plan staging - -# Generates a plan for a standalone terraform project -atlantis plan - -# Applies a plan for staging environment -atlantis apply staging - -# Applies a plan for a standalone terraform project -atlantis apply -`)) -var singleProjectTmpl = template.Must(template.New("").Parse("{{ range $result := .Results }}{{$result}}{{end}}\n" + logTmpl)) -var multiProjectTmpl = template.Must(template.New("").Parse( - "Ran {{.Command}} in {{ len .Results }} directories:\n" + - "{{ range $path, $result := .Results }}" + - " * `{{$path}}`\n" + - "{{end}}\n" + - "{{ range $path, $result := .Results }}" + - "## {{$path}}/\n" + - "{{$result}}\n" + - "---\n{{end}}" + - logTmpl)) -var planSuccessTmpl = template.Must(template.New("").Parse( - "```diff\n" + - "{{.TerraformOutput}}\n" + - "```\n\n" + - "* To **discard** this plan click [here]({{.LockURL}}).")) -var applySuccessTmpl = template.Must(template.New("").Parse( - "```diff\n" + - "{{.Output}}\n" + - "```")) -var errTmplText = "**{{.Command}} Error**\n" + - "```\n" + - "{{.Error}}\n" + - "```\n" -var errTmpl = template.Must(template.New("").Parse(errTmplText)) -var errWithLogTmpl = template.Must(template.New("").Parse(errTmplText + logTmpl)) -var failureTmplText = "**{{.Command}} Failed**: {{.Failure}}\n" -var failureTmpl = template.Must(template.New("").Parse(failureTmplText)) -var failureWithLogTmpl = template.Must(template.New("").Parse(failureTmplText + logTmpl)) -var logTmpl = "{{if .Verbose}}\n
Log\n

\n\n```\n{{.Log}}```\n

{{end}}\n" - -// MarkdownRenderer renders responses as markdown +// MarkdownRenderer renders responses as markdown. type MarkdownRenderer struct{} +// CommonData is data that all responses have. type CommonData struct { Command string Verbose bool Log string } +// ErrData is data about an error response. type ErrData struct { Error string CommonData } +// FailureData is data about a failure response. type FailureData struct { Failure string CommonData } +// ResultData is data about a successful response. type ResultData struct { Results map[string]string CommonData } -// Render formats the data into a string that can be commented back to GitHub. +// Render formats the data into a markdown string. // nolint: interfacer func (g *MarkdownRenderer) Render(res CommandResponse, cmdName CommandName, log string, verbose bool) string { if cmdName == Help { @@ -148,3 +96,59 @@ func (g *MarkdownRenderer) renderTemplate(tmpl *template.Template, data interfac } return buf.String() } + +var helpTmpl = template.Must(template.New("").Parse("```cmake\n" + + `atlantis - Terraform collaboration tool that enables you to collaborate on infrastructure +safely and securely. + +Usage: atlantis [environment] [--verbose] + +Commands: +plan Runs 'terraform plan' on the files changed in the pull request +apply Runs 'terraform apply' using the plans generated by 'atlantis plan' +help Get help + +Examples: + +# Generates a plan for staging environment +atlantis plan staging + +# Generates a plan for a standalone terraform project +atlantis plan + +# Applies a plan for staging environment +atlantis apply staging + +# Applies a plan for a standalone terraform project +atlantis apply +`)) +var singleProjectTmpl = template.Must(template.New("").Parse("{{ range $result := .Results }}{{$result}}{{end}}\n" + logTmpl)) +var multiProjectTmpl = template.Must(template.New("").Parse( + "Ran {{.Command}} in {{ len .Results }} directories:\n" + + "{{ range $path, $result := .Results }}" + + " * `{{$path}}`\n" + + "{{end}}\n" + + "{{ range $path, $result := .Results }}" + + "## {{$path}}/\n" + + "{{$result}}\n" + + "---\n{{end}}" + + logTmpl)) +var planSuccessTmpl = template.Must(template.New("").Parse( + "```diff\n" + + "{{.TerraformOutput}}\n" + + "```\n\n" + + "* To **discard** this plan click [here]({{.LockURL}}).")) +var applySuccessTmpl = template.Must(template.New("").Parse( + "```diff\n" + + "{{.Output}}\n" + + "```")) +var errTmplText = "**{{.Command}} Error**\n" + + "```\n" + + "{{.Error}}\n" + + "```\n" +var errTmpl = template.Must(template.New("").Parse(errTmplText)) +var errWithLogTmpl = template.Must(template.New("").Parse(errTmplText + logTmpl)) +var failureTmplText = "**{{.Command}} Failed**: {{.Failure}}\n" +var failureTmpl = template.Must(template.New("").Parse(failureTmplText)) +var failureWithLogTmpl = template.Must(template.New("").Parse(failureTmplText + logTmpl)) +var logTmpl = "{{if .Verbose}}\n
Log\n

\n\n```\n{{.Log}}```\n

{{end}}\n" diff --git a/server/events/mocks/mock_modified_project_finder.go b/server/events/mocks/mock_modified_project_finder.go deleted file mode 100644 index cc199c9b6..000000000 --- a/server/events/mocks/mock_modified_project_finder.go +++ /dev/null @@ -1,85 +0,0 @@ -// Automatically generated by pegomock. DO NOT EDIT! -// Source: github.com/hootsuite/atlantis/server/events (interfaces: ModifiedProjectFinder) - -package mocks - -import ( - "reflect" - - models "github.com/hootsuite/atlantis/server/events/models" - logging "github.com/hootsuite/atlantis/server/logging" - pegomock "github.com/petergtz/pegomock" -) - -type MockModifiedProjectFinder struct { - fail func(message string, callerSkip ...int) -} - -func NewMockModifiedProjectFinder() *MockModifiedProjectFinder { - return &MockModifiedProjectFinder{fail: pegomock.GlobalFailHandler} -} - -func (mock *MockModifiedProjectFinder) FindModified(log *logging.SimpleLogger, modifiedFiles []string, repoFullName string) []models.Project { - params := []pegomock.Param{log, modifiedFiles, repoFullName} - result := pegomock.GetGenericMockFrom(mock).Invoke("FindModified", params, []reflect.Type{reflect.TypeOf((*[]models.Project)(nil)).Elem()}) - var ret0 []models.Project - if len(result) != 0 { - if result[0] != nil { - ret0 = result[0].([]models.Project) - } - } - return ret0 -} - -func (mock *MockModifiedProjectFinder) VerifyWasCalledOnce() *VerifierModifiedProjectFinder { - return &VerifierModifiedProjectFinder{mock, pegomock.Times(1), nil} -} - -func (mock *MockModifiedProjectFinder) VerifyWasCalled(invocationCountMatcher pegomock.Matcher) *VerifierModifiedProjectFinder { - return &VerifierModifiedProjectFinder{mock, invocationCountMatcher, nil} -} - -func (mock *MockModifiedProjectFinder) VerifyWasCalledInOrder(invocationCountMatcher pegomock.Matcher, inOrderContext *pegomock.InOrderContext) *VerifierModifiedProjectFinder { - return &VerifierModifiedProjectFinder{mock, invocationCountMatcher, inOrderContext} -} - -type VerifierModifiedProjectFinder struct { - mock *MockModifiedProjectFinder - invocationCountMatcher pegomock.Matcher - inOrderContext *pegomock.InOrderContext -} - -func (verifier *VerifierModifiedProjectFinder) FindModified(log *logging.SimpleLogger, modifiedFiles []string, repoFullName string) *ModifiedProjectFinder_FindModified_OngoingVerification { - params := []pegomock.Param{log, modifiedFiles, repoFullName} - methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "FindModified", params) - return &ModifiedProjectFinder_FindModified_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} -} - -type ModifiedProjectFinder_FindModified_OngoingVerification struct { - mock *MockModifiedProjectFinder - methodInvocations []pegomock.MethodInvocation -} - -func (c *ModifiedProjectFinder_FindModified_OngoingVerification) GetCapturedArguments() (*logging.SimpleLogger, []string, string) { - log, modifiedFiles, repoFullName := c.GetAllCapturedArguments() - return log[len(log)-1], modifiedFiles[len(modifiedFiles)-1], repoFullName[len(repoFullName)-1] -} - -func (c *ModifiedProjectFinder_FindModified_OngoingVerification) GetAllCapturedArguments() (_param0 []*logging.SimpleLogger, _param1 [][]string, _param2 []string) { - params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) - if len(params) > 0 { - _param0 = make([]*logging.SimpleLogger, len(params[0])) - for u, param := range params[0] { - _param0[u] = param.(*logging.SimpleLogger) - } - _param1 = make([][]string, len(params[1])) - for u, param := range params[1] { - _param1[u] = param.([]string) - } - _param2 = make([]string, len(params[2])) - for u, param := range params[2] { - _param2[u] = param.(string) - } - } - return -} diff --git a/server/events/mocks/mock_project_finder.go b/server/events/mocks/mock_project_finder.go new file mode 100644 index 000000000..98689778a --- /dev/null +++ b/server/events/mocks/mock_project_finder.go @@ -0,0 +1,85 @@ +// Automatically generated by pegomock. DO NOT EDIT! +// Source: github.com/hootsuite/atlantis/server/events (interfaces: ProjectFinder) + +package mocks + +import ( + "reflect" + + models "github.com/hootsuite/atlantis/server/events/models" + logging "github.com/hootsuite/atlantis/server/logging" + pegomock "github.com/petergtz/pegomock" +) + +type MockProjectFinder struct { + fail func(message string, callerSkip ...int) +} + +func NewMockProjectFinder() *MockProjectFinder { + return &MockProjectFinder{fail: pegomock.GlobalFailHandler} +} + +func (mock *MockProjectFinder) FindModified(log *logging.SimpleLogger, modifiedFiles []string, repoFullName string) []models.Project { + params := []pegomock.Param{log, modifiedFiles, repoFullName} + result := pegomock.GetGenericMockFrom(mock).Invoke("FindModified", params, []reflect.Type{reflect.TypeOf((*[]models.Project)(nil)).Elem()}) + var ret0 []models.Project + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].([]models.Project) + } + } + return ret0 +} + +func (mock *MockProjectFinder) VerifyWasCalledOnce() *VerifierProjectFinder { + return &VerifierProjectFinder{mock, pegomock.Times(1), nil} +} + +func (mock *MockProjectFinder) VerifyWasCalled(invocationCountMatcher pegomock.Matcher) *VerifierProjectFinder { + return &VerifierProjectFinder{mock, invocationCountMatcher, nil} +} + +func (mock *MockProjectFinder) VerifyWasCalledInOrder(invocationCountMatcher pegomock.Matcher, inOrderContext *pegomock.InOrderContext) *VerifierProjectFinder { + return &VerifierProjectFinder{mock, invocationCountMatcher, inOrderContext} +} + +type VerifierProjectFinder struct { + mock *MockProjectFinder + invocationCountMatcher pegomock.Matcher + inOrderContext *pegomock.InOrderContext +} + +func (verifier *VerifierProjectFinder) FindModified(log *logging.SimpleLogger, modifiedFiles []string, repoFullName string) *ProjectFinder_FindModified_OngoingVerification { + params := []pegomock.Param{log, modifiedFiles, repoFullName} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "FindModified", params) + return &ProjectFinder_FindModified_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type ProjectFinder_FindModified_OngoingVerification struct { + mock *MockProjectFinder + methodInvocations []pegomock.MethodInvocation +} + +func (c *ProjectFinder_FindModified_OngoingVerification) GetCapturedArguments() (*logging.SimpleLogger, []string, string) { + log, modifiedFiles, repoFullName := c.GetAllCapturedArguments() + return log[len(log)-1], modifiedFiles[len(modifiedFiles)-1], repoFullName[len(repoFullName)-1] +} + +func (c *ProjectFinder_FindModified_OngoingVerification) GetAllCapturedArguments() (_param0 []*logging.SimpleLogger, _param1 [][]string, _param2 []string) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]*logging.SimpleLogger, len(params[0])) + for u, param := range params[0] { + _param0[u] = param.(*logging.SimpleLogger) + } + _param1 = make([][]string, len(params[1])) + for u, param := range params[1] { + _param1[u] = param.([]string) + } + _param2 = make([]string, len(params[2])) + for u, param := range params[2] { + _param2[u] = param.(string) + } + } + return +} diff --git a/server/events/mocks/mock_env_locker.go b/server/events/mocks/mock_workspace_locker.go similarity index 53% rename from server/events/mocks/mock_env_locker.go rename to server/events/mocks/mock_workspace_locker.go index e3b7929ac..c2800f35c 100644 --- a/server/events/mocks/mock_env_locker.go +++ b/server/events/mocks/mock_workspace_locker.go @@ -1,5 +1,5 @@ // Automatically generated by pegomock. DO NOT EDIT! -// Source: github.com/hootsuite/atlantis/server/events (interfaces: EnvLocker) +// Source: github.com/hootsuite/atlantis/server/events (interfaces: WorkspaceLocker) package mocks @@ -9,15 +9,15 @@ import ( pegomock "github.com/petergtz/pegomock" ) -type MockEnvLocker struct { +type MockWorkspaceLocker struct { fail func(message string, callerSkip ...int) } -func NewMockEnvLocker() *MockEnvLocker { - return &MockEnvLocker{fail: pegomock.GlobalFailHandler} +func NewMockWorkspaceLocker() *MockWorkspaceLocker { + return &MockWorkspaceLocker{fail: pegomock.GlobalFailHandler} } -func (mock *MockEnvLocker) TryLock(repoFullName string, env string, pullNum int) bool { +func (mock *MockWorkspaceLocker) TryLock(repoFullName string, env string, pullNum int) bool { params := []pegomock.Param{repoFullName, env, pullNum} result := pegomock.GetGenericMockFrom(mock).Invoke("TryLock", params, []reflect.Type{reflect.TypeOf((*bool)(nil)).Elem()}) var ret0 bool @@ -29,46 +29,46 @@ func (mock *MockEnvLocker) TryLock(repoFullName string, env string, pullNum int) return ret0 } -func (mock *MockEnvLocker) Unlock(repoFullName string, env string, pullNum int) { +func (mock *MockWorkspaceLocker) Unlock(repoFullName string, env string, pullNum int) { params := []pegomock.Param{repoFullName, env, pullNum} pegomock.GetGenericMockFrom(mock).Invoke("Unlock", params, []reflect.Type{}) } -func (mock *MockEnvLocker) VerifyWasCalledOnce() *VerifierEnvLocker { - return &VerifierEnvLocker{mock, pegomock.Times(1), nil} +func (mock *MockWorkspaceLocker) VerifyWasCalledOnce() *VerifierWorkspaceLocker { + return &VerifierWorkspaceLocker{mock, pegomock.Times(1), nil} } -func (mock *MockEnvLocker) VerifyWasCalled(invocationCountMatcher pegomock.Matcher) *VerifierEnvLocker { - return &VerifierEnvLocker{mock, invocationCountMatcher, nil} +func (mock *MockWorkspaceLocker) VerifyWasCalled(invocationCountMatcher pegomock.Matcher) *VerifierWorkspaceLocker { + return &VerifierWorkspaceLocker{mock, invocationCountMatcher, nil} } -func (mock *MockEnvLocker) VerifyWasCalledInOrder(invocationCountMatcher pegomock.Matcher, inOrderContext *pegomock.InOrderContext) *VerifierEnvLocker { - return &VerifierEnvLocker{mock, invocationCountMatcher, inOrderContext} +func (mock *MockWorkspaceLocker) VerifyWasCalledInOrder(invocationCountMatcher pegomock.Matcher, inOrderContext *pegomock.InOrderContext) *VerifierWorkspaceLocker { + return &VerifierWorkspaceLocker{mock, invocationCountMatcher, inOrderContext} } -type VerifierEnvLocker struct { - mock *MockEnvLocker +type VerifierWorkspaceLocker struct { + mock *MockWorkspaceLocker invocationCountMatcher pegomock.Matcher inOrderContext *pegomock.InOrderContext } -func (verifier *VerifierEnvLocker) TryLock(repoFullName string, env string, pullNum int) *EnvLocker_TryLock_OngoingVerification { +func (verifier *VerifierWorkspaceLocker) TryLock(repoFullName string, env string, pullNum int) *WorkspaceLocker_TryLock_OngoingVerification { params := []pegomock.Param{repoFullName, env, pullNum} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "TryLock", params) - return &EnvLocker_TryLock_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} + return &WorkspaceLocker_TryLock_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } -type EnvLocker_TryLock_OngoingVerification struct { - mock *MockEnvLocker +type WorkspaceLocker_TryLock_OngoingVerification struct { + mock *MockWorkspaceLocker methodInvocations []pegomock.MethodInvocation } -func (c *EnvLocker_TryLock_OngoingVerification) GetCapturedArguments() (string, string, int) { +func (c *WorkspaceLocker_TryLock_OngoingVerification) GetCapturedArguments() (string, string, int) { repoFullName, env, pullNum := c.GetAllCapturedArguments() return repoFullName[len(repoFullName)-1], env[len(env)-1], pullNum[len(pullNum)-1] } -func (c *EnvLocker_TryLock_OngoingVerification) GetAllCapturedArguments() (_param0 []string, _param1 []string, _param2 []int) { +func (c *WorkspaceLocker_TryLock_OngoingVerification) GetAllCapturedArguments() (_param0 []string, _param1 []string, _param2 []int) { params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(params) > 0 { _param0 = make([]string, len(params[0])) @@ -87,23 +87,23 @@ func (c *EnvLocker_TryLock_OngoingVerification) GetAllCapturedArguments() (_para return } -func (verifier *VerifierEnvLocker) Unlock(repoFullName string, env string, pullNum int) *EnvLocker_Unlock_OngoingVerification { +func (verifier *VerifierWorkspaceLocker) Unlock(repoFullName string, env string, pullNum int) *WorkspaceLocker_Unlock_OngoingVerification { params := []pegomock.Param{repoFullName, env, pullNum} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Unlock", params) - return &EnvLocker_Unlock_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} + return &WorkspaceLocker_Unlock_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } -type EnvLocker_Unlock_OngoingVerification struct { - mock *MockEnvLocker +type WorkspaceLocker_Unlock_OngoingVerification struct { + mock *MockWorkspaceLocker methodInvocations []pegomock.MethodInvocation } -func (c *EnvLocker_Unlock_OngoingVerification) GetCapturedArguments() (string, string, int) { +func (c *WorkspaceLocker_Unlock_OngoingVerification) GetCapturedArguments() (string, string, int) { repoFullName, env, pullNum := c.GetAllCapturedArguments() return repoFullName[len(repoFullName)-1], env[len(env)-1], pullNum[len(pullNum)-1] } -func (c *EnvLocker_Unlock_OngoingVerification) GetAllCapturedArguments() (_param0 []string, _param1 []string, _param2 []int) { +func (c *WorkspaceLocker_Unlock_OngoingVerification) GetAllCapturedArguments() (_param0 []string, _param1 []string, _param2 []int) { params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(params) > 0 { _param0 = make([]string, len(params[0])) diff --git a/server/events/plan_executor.go b/server/events/plan_executor.go index c139a4daf..b097c765c 100644 --- a/server/events/plan_executor.go +++ b/server/events/plan_executor.go @@ -15,9 +15,10 @@ import ( //go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_lock_url_generator.go LockURLGenerator +// LockURLGenerator consumes lock URLs. type LockURLGenerator interface { // SetLockURL takes a function that given a lock id, will return a url - // to view that lock + // to view that lock. SetLockURL(func(id string) (url string)) } @@ -34,20 +35,24 @@ type PlanExecutor struct { Run run.Runner Workspace Workspace ProjectPreExecute ProjectPreExecutor - ProjectFinder ModifiedProjectFinder + ProjectFinder ProjectFinder } +// PlanSuccess is the result of a successful plan. type PlanSuccess struct { TerraformOutput string LockURL string } +// SetLockURL takes a function that given a lock id, will return a url +// to view that lock. func (p *PlanExecutor) SetLockURL(f func(id string) (url string)) { p.LockURL = f } +// Execute executes terraform plan for the ctx. func (p *PlanExecutor) Execute(ctx *CommandContext) CommandResponse { - // figure out what projects have been modified so we know where to run plan + // Figure out what projects have been modified so we know where to run plan. modifiedFiles, err := p.VCSClient.GetModifiedFiles(ctx.BaseRepo, ctx.Pull, ctx.VCSHost) if err != nil { return CommandResponse{Error: errors.Wrap(err, "getting modified files")} @@ -63,7 +68,7 @@ func (p *PlanExecutor) Execute(ctx *CommandContext) CommandResponse { return CommandResponse{Error: err} } - results := []ProjectResult{} + var results []ProjectResult for _, project := range projects { ctx.Log.Info("running plan for project at path %q", project.Path) result := p.plan(ctx, cloneDir, project) @@ -73,8 +78,6 @@ func (p *PlanExecutor) Execute(ctx *CommandContext) CommandResponse { return CommandResponse{ProjectResults: results} } -// plan runs the steps necessary to run `terraform plan`. If there is an error, the error message will be encapsulated in error -// and the GeneratePlanResponse struct will also contain the full log including the error func (p *PlanExecutor) plan(ctx *CommandContext, repoDir string, project models.Project) ProjectResult { preExecute := p.ProjectPreExecute.Execute(ctx, repoDir, project) if preExecute.ProjectResult != (ProjectResult{}) { @@ -84,20 +87,20 @@ func (p *PlanExecutor) plan(ctx *CommandContext, repoDir string, project models. terraformVersion := preExecute.TerraformVersion tfEnv := ctx.Command.Environment - // Run terraform plan + // Run terraform plan. planFile := filepath.Join(repoDir, project.Path, fmt.Sprintf("%s.tfplan", tfEnv)) userVar := fmt.Sprintf("%s=%s", atlantisUserTFVar, ctx.User.Username) planExtraArgs := config.GetExtraArguments(ctx.Command.Name.String()) tfPlanCmd := append(append([]string{"plan", "-refresh", "-no-color", "-out", planFile, "-var", userVar}, planExtraArgs...), ctx.Command.Flags...) - // check if env/{environment}.tfvars exist + // Check if env/{environment}.tfvars exist. tfEnvFileName := filepath.Join("env", tfEnv+".tfvars") if _, err := os.Stat(filepath.Join(repoDir, project.Path, tfEnvFileName)); err == nil { tfPlanCmd = append(tfPlanCmd, "-var-file", tfEnvFileName) } output, err := p.Terraform.RunCommandWithVersion(ctx.Log, filepath.Join(repoDir, project.Path), tfPlanCmd, terraformVersion, tfEnv) if err != nil { - // plan failed so unlock the state + // Plan failed so unlock the state. if _, unlockErr := p.Locker.Unlock(preExecute.LockResponse.LockKey); unlockErr != nil { ctx.Log.Err("error unlocking state after plan error: %v", unlockErr) } @@ -105,7 +108,7 @@ func (p *PlanExecutor) plan(ctx *CommandContext, repoDir string, project models. } ctx.Log.Info("plan succeeded") - // if there are post plan commands then run them + // If there are post plan commands then run them. if len(config.PostPlan) > 0 { absolutePath := filepath.Join(repoDir, project.Path) _, err := p.Run.Execute(ctx.Log, config.PostPlan, absolutePath, tfEnv, terraformVersion, "post_plan") diff --git a/server/events/plan_executor_test.go b/server/events/plan_executor_test.go index 65c30b4d9..a50dc42ba 100644 --- a/server/events/plan_executor_test.go +++ b/server/events/plan_executor_test.go @@ -93,7 +93,7 @@ func TestExecute_Success(t *testing.T) { } func TestExecute_PreExecuteResult(t *testing.T) { - t.Log("If ProjectPreExecute.Execute returns a ProjectResult we should return it") + t.Log("If DefaultProjectPreExecutor.Execute returns a ProjectResult we should return it") p, _, _ := setupPlanExecutorTest(t) When(p.VCSClient.GetModifiedFiles(matchers.AnyModelsRepo(), matchers.AnyModelsPullRequest(), matchers.AnyVcsHost())).ThenReturn([]string{"file.tf"}, nil) When(p.Workspace.Clone(planCtx.Log, planCtx.BaseRepo, planCtx.HeadRepo, planCtx.Pull, "env")). @@ -182,7 +182,7 @@ func setupPlanExecutorTest(t *testing.T) (*events.PlanExecutor, *tmocks.MockRunn run := rmocks.NewMockRunner() p := events.PlanExecutor{ VCSClient: vcsProxy, - ProjectFinder: &events.ProjectFinder{}, + ProjectFinder: &events.DefaultProjectFinder{}, Workspace: w, ProjectPreExecute: ppe, Terraform: runner, diff --git a/server/events/project_config.go b/server/events/project_config.go index 723944bb8..0a1c10499 100644 --- a/server/events/project_config.go +++ b/server/events/project_config.go @@ -10,6 +10,7 @@ import ( "gopkg.in/yaml.v2" ) +// ProjectConfigFile is the filename of Atlantis project config. const ProjectConfigFile = "atlantis.yaml" //go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_project_config_reader.go ProjectConfigReader @@ -19,7 +20,8 @@ type ProjectConfigReader interface { // Exists returns true if a project config file exists at projectPath. Exists(projectPath string) bool // Read attempts to read the project config file for the project at projectPath. - // NOTE: projectPath is not the path to the actual config file. + // NOTE: projectPath is not the path to the actual config file, just to the + // project root. // Returns the parsed ProjectConfig or error if unable to read. Read(projectPath string) (ProjectConfig, error) } diff --git a/server/events/project_finder.go b/server/events/project_finder.go index 24b8c7d70..9da72c25c 100644 --- a/server/events/project_finder.go +++ b/server/events/project_finder.go @@ -8,22 +8,23 @@ import ( "github.com/hootsuite/atlantis/server/logging" ) -//go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_modified_project_finder.go ModifiedProjectFinder +//go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_project_finder.go ProjectFinder -type ModifiedProjectFinder interface { +// ProjectFinder determines what are the terraform project(s) within a repo. +type ProjectFinder interface { // FindModified returns the list of projects that were modified based on // the modifiedFiles. The list will be de-duplicated. FindModified(log *logging.SimpleLogger, modifiedFiles []string, repoFullName string) []models.Project } -// ProjectFinder identifies projects in a repo. -type ProjectFinder struct{} +// DefaultProjectFinder implements ProjectFinder. +type DefaultProjectFinder struct{} var excludeList = []string{"terraform.tfstate", "terraform.tfstate.backup", "_modules", "modules"} // FindModified returns the list of projects that were modified based on // the modifiedFiles. The list will be de-duplicated. -func (p *ProjectFinder) FindModified(log *logging.SimpleLogger, modifiedFiles []string, repoFullName string) []models.Project { +func (p *DefaultProjectFinder) FindModified(log *logging.SimpleLogger, modifiedFiles []string, repoFullName string) []models.Project { var projects []models.Project modifiedTerraformFiles := p.filterToTerraform(modifiedFiles) @@ -46,7 +47,7 @@ func (p *ProjectFinder) FindModified(log *logging.SimpleLogger, modifiedFiles [] return projects } -func (p *ProjectFinder) filterToTerraform(files []string) []string { +func (p *DefaultProjectFinder) filterToTerraform(files []string) []string { var filtered []string for _, fileName := range files { if !p.isInExcludeList(fileName) && strings.Contains(fileName, ".tf") { @@ -56,7 +57,7 @@ func (p *ProjectFinder) filterToTerraform(files []string) []string { return filtered } -func (p *ProjectFinder) isInExcludeList(fileName string) bool { +func (p *DefaultProjectFinder) isInExcludeList(fileName string) bool { for _, s := range excludeList { if strings.Contains(fileName, s) { return true @@ -67,17 +68,17 @@ func (p *ProjectFinder) isInExcludeList(fileName string) bool { // getProjectPath returns the path to the project relative to the repo root // if the project is at the root returns "." -func (p *ProjectFinder) getProjectPath(modifiedFilePath string) string { +func (p *DefaultProjectFinder) getProjectPath(modifiedFilePath string) string { dir := path.Dir(modifiedFilePath) if path.Base(dir) == "env" { - // if the modified file was inside an env/ directory, we treat this specially and - // run plan one level up + // If the modified file was inside an env/ directory, we treat this + // specially and run plan one level up. return path.Dir(dir) } return dir } -func (p *ProjectFinder) unique(strs []string) []string { +func (p *DefaultProjectFinder) unique(strs []string) []string { hash := make(map[string]bool) var unique []string for _, s := range strs { diff --git a/server/events/project_finder_test.go b/server/events/project_finder_test.go index 835fbbc53..282be0fef 100644 --- a/server/events/project_finder_test.go +++ b/server/events/project_finder_test.go @@ -10,7 +10,7 @@ import ( var noopLogger = logging.NewNoopLogger() var modifiedRepo = "owner/repo" -var m = events.ProjectFinder{} +var m = events.DefaultProjectFinder{} func TestGetModified_NoFiles(t *testing.T) { cases := []struct { diff --git a/server/events/project_pre_execute.go b/server/events/project_pre_execute.go index 3b263e51c..e5b48e3f7 100644 --- a/server/events/project_pre_execute.go +++ b/server/events/project_pre_execute.go @@ -15,17 +15,22 @@ import ( //go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_project_pre_executor.go ProjectPreExecutor +// ProjectPreExecutor executes before the plan and apply executors. It handles +// the setup tasks that are common to both plan and apply. type ProjectPreExecutor interface { + // Execute executes the pre plan/apply tasks. Execute(ctx *CommandContext, repoDir string, project models.Project) PreExecuteResult } -type ProjectPreExecute struct { +// DefaultProjectPreExecutor implements ProjectPreExecutor. +type DefaultProjectPreExecutor struct { Locker locking.Locker ConfigReader ProjectConfigReader Terraform terraform.Runner Run run.Runner } +// PreExecuteResult is the result of running the pre execute. type PreExecuteResult struct { ProjectResult ProjectResult ProjectConfig ProjectConfig @@ -33,7 +38,8 @@ type PreExecuteResult struct { LockResponse locking.TryLockResponse } -func (p *ProjectPreExecute) Execute(ctx *CommandContext, repoDir string, project models.Project) PreExecuteResult { +// Execute executes the pre plan/apply tasks. +func (p *DefaultProjectPreExecutor) Execute(ctx *CommandContext, repoDir string, project models.Project) PreExecuteResult { tfEnv := ctx.Command.Environment lockAttempt, err := p.Locker.TryLock(project, tfEnv, ctx.Pull, ctx.User) if err != nil { @@ -46,7 +52,7 @@ func (p *ProjectPreExecute) Execute(ctx *CommandContext, repoDir string, project } ctx.Log.Info("acquired lock with id %q", lockAttempt.LockKey) - // check if config file is found, if not we continue the run + // Check if config file is found, if not we continue the run. var config ProjectConfig absolutePath := filepath.Join(repoDir, project.Path) if p.ConfigReader.Exists(absolutePath) { @@ -57,7 +63,7 @@ func (p *ProjectPreExecute) Execute(ctx *CommandContext, repoDir string, project ctx.Log.Info("parsed atlantis config file in %q", absolutePath) } - // check if terraform version is >= 0.9.0 + // Check if terraform version is >= 0.9.0. terraformVersion := p.Terraform.Version() if config.TerraformVersion != nil { terraformVersion = config.TerraformVersion diff --git a/server/events/project_pre_execute_test.go b/server/events/project_pre_execute_test.go index f625011b5..e3c5f4791 100644 --- a/server/events/project_pre_execute_test.go +++ b/server/events/project_pre_execute_test.go @@ -254,13 +254,13 @@ func TestExecute_SuccessPreApply(t *testing.T) { r.VerifyWasCalledOnce().Execute(cpCtx.Log, []string{"command"}, "", "", tfVersion, "pre_apply") } -func setupPreExecuteTest(t *testing.T) (*events.ProjectPreExecute, *lmocks.MockLocker, *tmocks.MockRunner, *rmocks.MockRunner) { +func setupPreExecuteTest(t *testing.T) (*events.DefaultProjectPreExecutor, *lmocks.MockLocker, *tmocks.MockRunner, *rmocks.MockRunner) { RegisterMockTestingT(t) l := lmocks.NewMockLocker() cr := mocks.NewMockProjectConfigReader() tm := tmocks.NewMockRunner() r := rmocks.NewMockRunner() - return &events.ProjectPreExecute{ + return &events.DefaultProjectPreExecutor{ Locker: l, ConfigReader: cr, Terraform: tm, diff --git a/server/events/project_result.go b/server/events/project_result.go index 52a79666a..ca5a51d08 100644 --- a/server/events/project_result.go +++ b/server/events/project_result.go @@ -2,6 +2,7 @@ package events import "github.com/hootsuite/atlantis/server/events/vcs" +// ProjectResult is the result of executing a plan/apply for a project. type ProjectResult struct { Path string Error error @@ -10,6 +11,7 @@ type ProjectResult struct { ApplySuccess string } +// Status returns the vcs commit status of this project result. func (p ProjectResult) Status() vcs.CommitStatus { if p.Error != nil { return vcs.Failed diff --git a/server/events/pull_closed_executor.go b/server/events/pull_closed_executor.go index 726d35e2f..c3a2d6315 100644 --- a/server/events/pull_closed_executor.go +++ b/server/events/pull_closed_executor.go @@ -3,11 +3,10 @@ package events import ( "bytes" "fmt" + "sort" "strings" "text/template" - "sort" - "github.com/hootsuite/atlantis/server/events/locking" "github.com/hootsuite/atlantis/server/events/models" "github.com/hootsuite/atlantis/server/events/vcs" @@ -16,10 +15,15 @@ import ( //go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_pull_cleaner.go PullCleaner +// PullCleaner cleans up pull requests after they're closed/merged. type PullCleaner interface { + // CleanUpPull deletes the workspaces used by the pull request on disk + // and deletes any locks associated with this pull request for all envs. CleanUpPull(repo models.Repo, pull models.PullRequest, host vcs.Host) error } +// PullClosedExecutor executes the tasks required to clean up a closed pull +// request. type PullClosedExecutor struct { Locker locking.Locker VCSClient vcs.ClientProxy @@ -36,21 +40,21 @@ var pullClosedTemplate = template.Must(template.New("").Parse( "{{ range . }}\n" + "- path: `{{ .Path }}` {{ .Envs }}{{ end }}")) +// CleanUpPull cleans up after a closed pull request. func (p *PullClosedExecutor) CleanUpPull(repo models.Repo, pull models.PullRequest, host vcs.Host) error { - // delete the workspace if err := p.Workspace.Delete(repo, pull); err != nil { return errors.Wrap(err, "cleaning workspace") } - // finally, delete locks. We do this last because when someone + // Finally, delete locks. We do this last because when someone // unlocks a project, right now we don't actually delete the plan - // so we might have plans laying around but no locks + // so we might have plans laying around but no locks. locks, err := p.Locker.UnlockByPull(repo.FullName, pull.Num) if err != nil { return errors.Wrap(err, "cleaning up locks") } - // if there are no locks then there's no need to comment + // If there are no locks then there's no need to comment. if len(locks) == 0 { return nil } @@ -63,9 +67,10 @@ func (p *PullClosedExecutor) CleanUpPull(repo models.Repo, pull models.PullReque return p.VCSClient.CreateComment(repo, pull, buf.String(), host) } -// buildTemplateData formats the lock data into a slice that can easily be templated -// for the VCS comment. We organize all the environments by their respective project paths -// so the comment can look like: path: {path}, environments: {all-envs} +// buildTemplateData formats the lock data into a slice that can easily be +// templated for the VCS comment. We organize all the environments by their +// respective project paths so the comment can look like: +// path: {path}, environments: {all-envs} func (p *PullClosedExecutor) buildTemplateData(locks []models.ProjectLock) []templatedProject { envsByPath := make(map[string][]string) for _, l := range locks { diff --git a/server/events/workspace.go b/server/events/workspace.go index 2b84c845c..482f5fbcf 100644 --- a/server/events/workspace.go +++ b/server/events/workspace.go @@ -2,11 +2,10 @@ package events import ( "os" + "os/exec" "path/filepath" "strconv" - "os/exec" - "github.com/hootsuite/atlantis/server/events/models" "github.com/hootsuite/atlantis/server/logging" "github.com/pkg/errors" @@ -16,14 +15,18 @@ const workspacePrefix = "repos" //go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_workspace.go Workspace +// Workspace handles the workspace on disk for running commands. type Workspace interface { - // Clone git clones headRepo, checks out the branch and then returns the absolute - // path to the root of the cloned repo. + // Clone git clones headRepo, checks out the branch and then returns the + // absolute path to the root of the cloned repo. Clone(log *logging.SimpleLogger, baseRepo models.Repo, headRepo models.Repo, p models.PullRequest, env string) (string, error) + // GetWorkspace returns the path to the workspace for this repo and pull. GetWorkspace(r models.Repo, p models.PullRequest, env string) (string, error) + // Delete deletes the workspace for this repo and pull. Delete(r models.Repo, p models.PullRequest) error } +// FileWorkspace implements Workspace with the file system. type FileWorkspace struct { DataDir string } @@ -38,13 +41,14 @@ func (w *FileWorkspace) Clone( env string) (string, error) { cloneDir := w.cloneDir(baseRepo, p, env) - // this is safe to do because we lock runs on repo/pull/env so no one else is using this workspace + // This is safe to do because we lock runs on repo/pull/env so no one else + // is using this workspace. log.Info("cleaning clone directory %q", cloneDir) if err := os.RemoveAll(cloneDir); err != nil { return "", errors.Wrap(err, "deleting old workspace") } - // create the directory and parents if necessary + // Create the directory and parents if necessary. log.Info("creating dir %q", cloneDir) if err := os.MkdirAll(cloneDir, 0755); err != nil { return "", errors.Wrap(err, "creating new workspace") @@ -56,7 +60,7 @@ func (w *FileWorkspace) Clone( return "", errors.Wrapf(err, "cloning %s: %s", headRepo.SanitizedCloneURL, string(output)) } - // check out the branch for this PR + // Check out the branch for this PR. log.Info("checking out branch %q", p.Branch) checkoutCmd := exec.Command("git", "checkout", p.Branch) checkoutCmd.Dir = cloneDir @@ -66,6 +70,7 @@ func (w *FileWorkspace) Clone( return cloneDir, nil } +// GetWorkspace returns the path to the workspace for this repo and pull. func (w *FileWorkspace) GetWorkspace(r models.Repo, p models.PullRequest, env string) (string, error) { repoDir := w.cloneDir(r, p, env) if _, err := os.Stat(repoDir); err != nil { @@ -74,7 +79,7 @@ func (w *FileWorkspace) GetWorkspace(r models.Repo, p models.PullRequest, env st return repoDir, nil } -// Delete deletes the workspace for this repo and pull +// Delete deletes the workspace for this repo and pull. func (w *FileWorkspace) Delete(r models.Repo, p models.PullRequest) error { return os.RemoveAll(w.repoPullDir(r, p)) } diff --git a/server/events/workspace_locker.go b/server/events/workspace_locker.go new file mode 100644 index 000000000..13e918903 --- /dev/null +++ b/server/events/workspace_locker.go @@ -0,0 +1,59 @@ +package events + +import ( + "fmt" + "sync" +) + +//go:generate pegomock generate --use-experimental-model-gen --package mocks -o mocks/mock_workspace_locker.go WorkspaceLocker + +// WorkspaceLocker is used to prevent multiple commands from executing at the same +// time for a single repo, pull, and environment. We need to prevent this from +// happening because a specific repo/pull/env has a single workspace on disk +// and we haven't written Atlantis (yet) to handle concurrent execution within +// this workspace. +type WorkspaceLocker interface { + // TryLock tries to acquire a lock for this repo, env and pull. + TryLock(repoFullName string, env string, pullNum int) bool + // Unlock deletes the lock for this repo, env and pull. If there was no + // lock it will do nothing. + Unlock(repoFullName, env string, pullNum int) +} + +// DefaultWorkspaceLocker implements WorkspaceLocker. +type DefaultWorkspaceLocker struct { + mutex sync.Mutex + locks map[string]interface{} +} + +// NewDefaultWorkspaceLocker is a constructor. +func NewDefaultWorkspaceLocker() *DefaultWorkspaceLocker { + return &DefaultWorkspaceLocker{ + locks: make(map[string]interface{}), + } +} + +// TryLock returns true if a lock is acquired for this repo, pull and env and +// false otherwise. +func (d *DefaultWorkspaceLocker) TryLock(repoFullName string, env string, pullNum int) bool { + d.mutex.Lock() + defer d.mutex.Unlock() + + key := d.key(repoFullName, env, pullNum) + if _, ok := d.locks[key]; !ok { + d.locks[key] = true + return true + } + return false +} + +// Unlock unlocks the repo, pull and env. +func (d *DefaultWorkspaceLocker) Unlock(repoFullName, env string, pullNum int) { + d.mutex.Lock() + defer d.mutex.Unlock() + delete(d.locks, d.key(repoFullName, env, pullNum)) +} + +func (d *DefaultWorkspaceLocker) key(repo string, env string, pull int) string { + return fmt.Sprintf("%s/%s/%d", repo, env, pull) +} diff --git a/server/events/env_lock_test.go b/server/events/workspace_locker_test.go similarity index 88% rename from server/events/env_lock_test.go rename to server/events/workspace_locker_test.go index 19ada456c..08ea8cb69 100644 --- a/server/events/env_lock_test.go +++ b/server/events/workspace_locker_test.go @@ -11,7 +11,7 @@ var repo = "repo/owner" var env = "default" func TestTryLock(t *testing.T) { - locker := events.NewEnvLock() + locker := events.NewDefaultWorkspaceLocker() t.Log("the first lock should succeed") Equals(t, true, locker.TryLock(repo, env, 1)) @@ -21,7 +21,7 @@ func TestTryLock(t *testing.T) { } func TestTryLockDifferentEnv(t *testing.T) { - locker := events.NewEnvLock() + locker := events.NewDefaultWorkspaceLocker() t.Log("a lock for the same repo and pull but different env should succeed") Equals(t, true, locker.TryLock(repo, env, 1)) @@ -33,7 +33,7 @@ func TestTryLockDifferentEnv(t *testing.T) { } func TestTryLockDifferentRepo(t *testing.T) { - locker := events.NewEnvLock() + locker := events.NewDefaultWorkspaceLocker() t.Log("a lock for a different repo but the same env and pull should succeed") Equals(t, true, locker.TryLock(repo, env, 1)) @@ -46,7 +46,7 @@ func TestTryLockDifferentRepo(t *testing.T) { } func TestTryLockDifferent1(t *testing.T) { - locker := events.NewEnvLock() + locker := events.NewDefaultWorkspaceLocker() t.Log("a lock for a different pull but the same repo and env should succeed") Equals(t, true, locker.TryLock(repo, env, 1)) @@ -59,7 +59,7 @@ func TestTryLockDifferent1(t *testing.T) { } func TestUnlock(t *testing.T) { - locker := events.NewEnvLock() + locker := events.NewDefaultWorkspaceLocker() t.Log("unlocking should work") Equals(t, true, locker.TryLock(repo, env, 1)) @@ -68,7 +68,7 @@ func TestUnlock(t *testing.T) { } func TestUnlockDifferentEnvs(t *testing.T) { - locker := events.NewEnvLock() + locker := events.NewDefaultWorkspaceLocker() t.Log("unlocking should work for different envs") Equals(t, true, locker.TryLock(repo, env, 1)) Equals(t, true, locker.TryLock(repo, "new-env", 1)) @@ -79,7 +79,7 @@ func TestUnlockDifferentEnvs(t *testing.T) { } func TestUnlockDifferentRepos(t *testing.T) { - locker := events.NewEnvLock() + locker := events.NewDefaultWorkspaceLocker() t.Log("unlocking should work for different repos") Equals(t, true, locker.TryLock(repo, env, 1)) newRepo := "owner/newrepo" @@ -91,7 +91,7 @@ func TestUnlockDifferentRepos(t *testing.T) { } func TestUnlockDifferentPulls(t *testing.T) { - locker := events.NewEnvLock() + locker := events.NewDefaultWorkspaceLocker() t.Log("unlocking should work for different 1s") Equals(t, true, locker.TryLock(repo, env, 1)) new1 := 2 diff --git a/server/events_controller.go b/server/events_controller.go index 12996a7a2..00f94e23e 100644 --- a/server/events_controller.go +++ b/server/events_controller.go @@ -15,6 +15,8 @@ import ( const githubHeader = "X-Github-Event" const gitlabHeader = "X-Gitlab-Event" +// EventsController handles all webhook requests which signify 'events' in the +// VCS host, ex. GitHub. It's split out from Server to make testing easier. type EventsController struct { CommandRunner events.CommandRunner PullCleaner events.PullCleaner @@ -35,6 +37,7 @@ type EventsController struct { SupportedVCSHosts []vcs.Host } +// Post handles POST webhook requests. func (e *EventsController) Post(w http.ResponseWriter, r *http.Request) { if r.Header.Get(githubHeader) != "" { if !e.supportsHost(vcs.Github) { @@ -54,33 +57,6 @@ func (e *EventsController) Post(w http.ResponseWriter, r *http.Request) { e.respond(w, logging.Debug, http.StatusBadRequest, "Ignoring request") } -// supportsHost returns true if h is in e.SupportedVCSHosts and false otherwise -func (e *EventsController) supportsHost(h vcs.Host) bool { - for _, supported := range e.SupportedVCSHosts { - if h == supported { - return true - } - } - return false -} - -func (e *EventsController) handleGitlabPost(w http.ResponseWriter, r *http.Request) { - event, err := e.GitlabRequestParser.Validate(r, e.GitlabWebHookSecret) - if err != nil { - e.respond(w, logging.Warn, http.StatusBadRequest, err.Error()) - return - } - switch event := event.(type) { - case gitlab.MergeCommentEvent: - e.HandleGitlabCommentEvent(w, event) - case gitlab.MergeEvent: - e.HandleGitlabMergeRequestEvent(w, event) - default: - e.respond(w, logging.Debug, http.StatusOK, "Ignoring unsupported event") - } - -} - func (e *EventsController) handleGithubPost(w http.ResponseWriter, r *http.Request) { // Validate the request against the optional webhook secret. payload, err := e.GithubRequestValidator.Validate(r, e.GithubWebHookSecret) @@ -101,6 +77,8 @@ func (e *EventsController) handleGithubPost(w http.ResponseWriter, r *http.Reque } } +// HandleGithubCommentEvent handles comment events from GitHub where Atlantis +// commands can come from. It's exported to make testing easier. func (e *EventsController) HandleGithubCommentEvent(w http.ResponseWriter, event *github.IssueCommentEvent, githubReqID string) { if event.GetAction() != "created" { e.respond(w, logging.Debug, http.StatusOK, "Ignoring comment event since action was not created %s", githubReqID) @@ -126,37 +104,9 @@ func (e *EventsController) HandleGithubCommentEvent(w http.ResponseWriter, event go e.CommandRunner.ExecuteCommand(baseRepo, models.Repo{}, user, pullNum, command, vcs.Github) } -func (e *EventsController) HandleGitlabCommentEvent(w http.ResponseWriter, event gitlab.MergeCommentEvent) { - baseRepo, headRepo, user := e.Parser.ParseGitlabMergeCommentEvent(event) - command, err := e.Parser.DetermineCommand(event.ObjectAttributes.Note, vcs.Gitlab) - if err != nil { - e.respond(w, logging.Debug, http.StatusOK, "Ignoring: %s", err) - return - } - - // Respond with success and then actually execute the command asynchronously. - // We use a goroutine so that this function returns and the connection is - // closed. - fmt.Fprintln(w, "Processing...") - go e.CommandRunner.ExecuteCommand(baseRepo, headRepo, user, event.MergeRequest.IID, command, vcs.Gitlab) -} - -// HandleGitlabMergeRequestEvent will delete any locks associated with the merge request -func (e *EventsController) HandleGitlabMergeRequestEvent(w http.ResponseWriter, event gitlab.MergeEvent) { - pull, repo := e.Parser.ParseGitlabMergeEvent(event) - if pull.State != models.Closed { - e.respond(w, logging.Debug, http.StatusOK, "Ignoring opened merge request event") - return - } - if err := e.PullCleaner.CleanUpPull(repo, pull, vcs.Gitlab); err != nil { - e.respond(w, logging.Error, http.StatusInternalServerError, "Error cleaning pull request: %s", err) - return - } - e.Logger.Info("deleted locks and workspace for repo %s, pull %d", repo.FullName, pull.Num) - fmt.Fprintln(w, "Merge request cleaned successfully") -} - -// HandleGithubPullRequestEvent will delete any locks associated with the pull request +// HandleGithubPullRequestEvent will delete any locks associated with the pull +// request if the event is a pull request closed event. It's exported to make +// testing easier. func (e *EventsController) HandleGithubPullRequestEvent(w http.ResponseWriter, pullEvent *github.PullRequestEvent, githubReqID string) { if pullEvent.GetAction() != "closed" { e.respond(w, logging.Debug, http.StatusOK, "Ignoring pull request event since action was not closed %s", githubReqID) @@ -181,6 +131,67 @@ func (e *EventsController) HandleGithubPullRequestEvent(w http.ResponseWriter, p fmt.Fprintln(w, "Pull request cleaned successfully") } +func (e *EventsController) handleGitlabPost(w http.ResponseWriter, r *http.Request) { + event, err := e.GitlabRequestParser.Validate(r, e.GitlabWebHookSecret) + if err != nil { + e.respond(w, logging.Warn, http.StatusBadRequest, err.Error()) + return + } + switch event := event.(type) { + case gitlab.MergeCommentEvent: + e.HandleGitlabCommentEvent(w, event) + case gitlab.MergeEvent: + e.HandleGitlabMergeRequestEvent(w, event) + default: + e.respond(w, logging.Debug, http.StatusOK, "Ignoring unsupported event") + } + +} + +// HandleGitlabCommentEvent handles comment events from GitLab where Atlantis +// commands can come from. It's exported to make testing easier. +func (e *EventsController) HandleGitlabCommentEvent(w http.ResponseWriter, event gitlab.MergeCommentEvent) { + baseRepo, headRepo, user := e.Parser.ParseGitlabMergeCommentEvent(event) + command, err := e.Parser.DetermineCommand(event.ObjectAttributes.Note, vcs.Gitlab) + if err != nil { + e.respond(w, logging.Debug, http.StatusOK, "Ignoring: %s", err) + return + } + + // Respond with success and then actually execute the command asynchronously. + // We use a goroutine so that this function returns and the connection is + // closed. + fmt.Fprintln(w, "Processing...") + go e.CommandRunner.ExecuteCommand(baseRepo, headRepo, user, event.MergeRequest.IID, command, vcs.Gitlab) +} + +// HandleGitlabMergeRequestEvent will delete any locks associated with the pull +// request if the event is a merge request closed event. It's exported to make +// testing easier. +func (e *EventsController) HandleGitlabMergeRequestEvent(w http.ResponseWriter, event gitlab.MergeEvent) { + pull, repo := e.Parser.ParseGitlabMergeEvent(event) + if pull.State != models.Closed { + e.respond(w, logging.Debug, http.StatusOK, "Ignoring opened merge request event") + return + } + if err := e.PullCleaner.CleanUpPull(repo, pull, vcs.Gitlab); err != nil { + e.respond(w, logging.Error, http.StatusInternalServerError, "Error cleaning pull request: %s", err) + return + } + e.Logger.Info("deleted locks and workspace for repo %s, pull %d", repo.FullName, pull.Num) + fmt.Fprintln(w, "Merge request cleaned successfully") +} + +// supportsHost returns true if h is in e.SupportedVCSHosts and false otherwise. +func (e *EventsController) supportsHost(h vcs.Host) bool { + for _, supported := range e.SupportedVCSHosts { + if h == supported { + return true + } + } + return false +} + func (e *EventsController) respond(w http.ResponseWriter, lvl logging.LogLevel, code int, format string, args ...interface{}) { response := fmt.Sprintf(format, args...) e.Logger.Log(lvl, response) diff --git a/server/github_request_validator.go b/server/github_request_validator.go index 95957e300..8a679de0d 100644 --- a/server/github_request_validator.go +++ b/server/github_request_validator.go @@ -9,9 +9,10 @@ import ( "github.com/google/go-github/github" ) -//go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_gh_request_validation.go GithubRequestValidator +//go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_github_request_validator.go GithubRequestValidator -// GithubRequestValidator validates GitHub requests. +// GithubRequestValidator handles checking if GitHub requests are signed +// properly by the secret. type GithubRequestValidator interface { // Validate returns the JSON payload of the request. // If secret is not empty, it checks that the request was signed @@ -20,7 +21,8 @@ type GithubRequestValidator interface { Validate(r *http.Request, secret []byte) ([]byte, error) } -// DefaultGithubRequestValidator validates GitHub requests. +// DefaultGithubRequestValidator handles checking if GitHub requests are signed +// properly by the secret. type DefaultGithubRequestValidator struct{} // Validate returns the JSON payload of the request. @@ -51,7 +53,7 @@ func (d *DefaultGithubRequestValidator) validateWithoutSecret(r *http.Request) ( } return payload, nil case "application/x-www-form-urlencoded": - // GitHub stores the json payload as a form value + // GitHub stores the json payload as a form value. payloadForm := r.FormValue("payload") if payloadForm == "" { return nil, errors.New("webhook request did not contain expected 'payload' form value") diff --git a/server/gitlab_request_parser.go b/server/gitlab_request_parser.go index 1a4b27c4d..9c2839eeb 100644 --- a/server/gitlab_request_parser.go +++ b/server/gitlab_request_parser.go @@ -18,7 +18,7 @@ type GitlabRequestParser interface { // Validate validates that the request has a token header matching secret. // If the secret does not match it returns an error. // If secret is empty it does not check the token header. - // It then parses the request as a gitlab object depending on the header + // It then parses the request as a GitLab object depending on the header // provided by GitLab identifying the webhook type. If the webhook type // is not recognized it will return nil but will not return an error. // Usage: @@ -37,13 +37,11 @@ type GitlabRequestParser interface { Validate(r *http.Request, secret []byte) (interface{}, error) } -// DefaultGitlabRequestParser parses GitLab requests. +// DefaultGitlabRequestParser parses and validates GitLab requests. type DefaultGitlabRequestParser struct{} // Validate returns the JSON payload of the request. -// If secret is not empty, it checks that the request was signed -// by secret and returns an error if it was not. -// If secret is empty, it does not check if the request was signed. +// See GitlabRequestParser.Validate() func (d *DefaultGitlabRequestParser) Validate(r *http.Request, secret []byte) (interface{}, error) { const mergeEventHeader = "Merge Request Hook" const noteEventHeader = "Note Hook" diff --git a/server/logging/simple_logger.go b/server/logging/simple_logger.go index f0e9f25dd..262397721 100644 --- a/server/logging/simple_logger.go +++ b/server/logging/simple_logger.go @@ -12,6 +12,8 @@ import ( //go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_simple_logging.go SimpleLogging +// SimpleLogging is the interface that our SimpleLogger implements. +// It's really only used for mocking when we need to test what's being logged. type SimpleLogging interface { Debug(format string, a ...interface{}) Info(format string, a ...interface{}) @@ -51,20 +53,20 @@ const ( ) // NewSimpleLogger creates a new logger. -// - source is added as a prefix to each log entry. It's useful if you want to trace a log entry back to a -// context, for example a pull request id. -// - logger is the underlying logger. If nil will create a logger from stdlib. -// - keepHistory set to true will store all log entries written using this logger. -// - level will set the level at which logs >= than that level will be written. -// If keepHistory is set to true, we'll store logs at all levels, regardless of what level -// is set to. +// source is added as a prefix to each log entry. It's useful if you want to +// trace a log entry back to a specific context, for example a pull request id. +// logger is the underlying logger. If nil will create a logger from stdlib. +// keepHistory set to true will store all log entries written using this logger. +// level will set the level at which logs >= than that level will be written. +// If keepHistory is set to true, we'll store logs at all levels, regardless of +// what level is set to. func NewSimpleLogger(source string, logger *log.Logger, keepHistory bool, level LogLevel) *SimpleLogger { if logger == nil { flags := log.LstdFlags if level == Debug { // If we're using debug logging, we also have the logger print the // filename the log comes from with log.Lshortfile. - flags = log.LstdFlags | log.Lshortfile + flags = flags | log.Lshortfile } logger = log.New(os.Stderr, "", flags) } @@ -89,9 +91,8 @@ func NewNoopLogger() *SimpleLogger { } } -// ToLogLevel converts a log level string to a valid -// LogLevel object. If the string doesn't match a level, -// it will return Info. +// ToLogLevel converts a log level string to a valid LogLevel object. +// If the string doesn't match a level, it will return Info. func ToLogLevel(levelStr string) LogLevel { switch levelStr { case "debug": @@ -106,44 +107,51 @@ func ToLogLevel(levelStr string) LogLevel { return Info } +// Debug logs at debug level. func (l *SimpleLogger) Debug(format string, a ...interface{}) { l.Log(Debug, format, a...) } +// Info logs at info level. func (l *SimpleLogger) Info(format string, a ...interface{}) { l.Log(Info, format, a...) } +// Warn logs at warn level. func (l *SimpleLogger) Warn(format string, a ...interface{}) { l.Log(Warn, format, a...) } +// Err logs at error level. func (l *SimpleLogger) Err(format string, a ...interface{}) { l.Log(Error, format, a...) } +// Log writes the log at level. func (l *SimpleLogger) Log(level LogLevel, format string, a ...interface{}) { levelStr := l.levelToString(level) msg := l.capitalizeFirstLetter(fmt.Sprintf(format, a...)) - // only log this message if configured to log at this level + // Only log this message if configured to log at this level. if l.Level <= level { - // Calling .Output instead of Printf so we can change the calldepth param - // to 3. The default is 2 which would identify the log as coming from - // this file and line every time instead of our caller's. + // Calling .Output instead of Printf so we can change the calldepth + // param to 3. The default is 2 which would identify the log as coming + // from this file and line every time instead of our caller's. l.Logger.Output(3, fmt.Sprintf("[%s] %s: %s\n", levelStr, l.Source, msg)) // nolint: errcheck } - // keep history at all log levels + // Keep history at all log levels. if l.KeepHistory { l.saveToHistory(levelStr, msg) } } +// Underlying returns the underlying logger. func (l *SimpleLogger) Underlying() *log.Logger { return l.Logger } +// GetLevel returns the current log level of the logger. func (l *SimpleLogger) GetLevel() LogLevel { return l.Level } diff --git a/server/middleware.go b/server/middleware.go index 4cc6ef7c4..6b51c7c4b 100644 --- a/server/middleware.go +++ b/server/middleware.go @@ -2,22 +2,24 @@ package server import ( "net/http" - "strings" "github.com/hootsuite/atlantis/server/logging" "github.com/urfave/negroni" ) +// NewRequestLogger creates a RequestLogger. func NewRequestLogger(logger *logging.SimpleLogger) *RequestLogger { return &RequestLogger{logger} } -// RequestLogger logs requests and their response codes +// RequestLogger logs requests and their response codes. type RequestLogger struct { logger *logging.SimpleLogger } +// ServeHTTP implements the middleware function. It logs a request at INFO +// level unless it's a request to /static/*. func (l *RequestLogger) ServeHTTP(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc) { next(rw, r) res := rw.(negroni.ResponseWriter) diff --git a/server/mocks/mock_gh_request_validation.go b/server/mocks/mock_github_request_validator.go similarity index 100% rename from server/mocks/mock_gh_request_validation.go rename to server/mocks/mock_github_request_validator.go diff --git a/server/recovery/recovery.go b/server/recovery/recovery.go index fcd0d7c50..4d9332b06 100644 --- a/server/recovery/recovery.go +++ b/server/recovery/recovery.go @@ -1,4 +1,4 @@ -// Package recovery is aken from +// Package recovery is taken from // https://github.com/gin-gonic/gin/blob/master/recovery.go // License of source below: // Copyright 2014 Manu Martinez-Almeida. All rights reserved. @@ -20,7 +20,7 @@ var ( slash = []byte("/") ) -// Stack returns a nicely formatted stack frame, skipping skip frames +// Stack returns a nicely formatted stack frame, skipping skip frames. func Stack(skip int) []byte { buf := new(bytes.Buffer) // the returned data // As we loop, we open files and read them. These variables record the currently diff --git a/server/server.go b/server/server.go index de2a54b17..887d7cb70 100644 --- a/server/server.go +++ b/server/server.go @@ -1,8 +1,9 @@ -// Package server is the main package for Atlantis. It handles the web server -// and executing commands that come in via pull request comments. +// Package server handles the web server and executing commands that come in +// via webhooks. package server import ( + "flag" "fmt" "log" "net/http" @@ -10,8 +11,6 @@ import ( "os" "strings" - "flag" - "github.com/elazarl/go-bindata-assetfs" "github.com/gorilla/mux" "github.com/hootsuite/atlantis/server/events" @@ -31,8 +30,7 @@ import ( const LockRouteName = "lock-detail" -// Server runs the Atlantis web server. It's used for webhook requests and the -// Atlantis UI. +// Server runs the Atlantis web server. type Server struct { Router *mux.Router Port int @@ -49,31 +47,43 @@ type Server struct { // The mapstructure tags correspond to flags in cmd/server.go and are used when // the config is parsed from a YAML file. type Config struct { - AtlantisURL string `mapstructure:"atlantis-url"` - DataDir string `mapstructure:"data-dir"` - GithubHostname string `mapstructure:"gh-hostname"` - GithubToken string `mapstructure:"gh-token"` - GithubUser string `mapstructure:"gh-user"` - GithubWebHookSecret string `mapstructure:"gh-webhook-secret"` - GitlabHostname string `mapstructure:"gitlab-hostname"` - GitlabToken string `mapstructure:"gitlab-token"` - GitlabUser string `mapstructure:"gitlab-user"` - GitlabWebHookSecret string `mapstructure:"gitlab-webhook-secret"` - LogLevel string `mapstructure:"log-level"` - Port int `mapstructure:"port"` - RequireApproval bool `mapstructure:"require-approval"` - SlackToken string `mapstructure:"slack-token"` - Webhooks []WebhookConfig `mapstructure:"webhooks"` + AtlantisURL string `mapstructure:"atlantis-url"` + DataDir string `mapstructure:"data-dir"` + GithubHostname string `mapstructure:"gh-hostname"` + GithubToken string `mapstructure:"gh-token"` + GithubUser string `mapstructure:"gh-user"` + GithubWebHookSecret string `mapstructure:"gh-webhook-secret"` + GitlabHostname string `mapstructure:"gitlab-hostname"` + GitlabToken string `mapstructure:"gitlab-token"` + GitlabUser string `mapstructure:"gitlab-user"` + GitlabWebHookSecret string `mapstructure:"gitlab-webhook-secret"` + LogLevel string `mapstructure:"log-level"` + Port int `mapstructure:"port"` + // RequireApproval is whether to require pull request approval before + // allowing terraform apply's to be run. + RequireApproval bool `mapstructure:"require-approval"` + SlackToken string `mapstructure:"slack-token"` + Webhooks []WebhookConfig `mapstructure:"webhooks"` } +// WebhookConfig is nested within Config. It's used to configure webhooks. type WebhookConfig struct { - Event string `mapstructure:"event"` + // Event is the type of event we should send this webhook for, ex. apply. + Event string `mapstructure:"event"` + // WorkspaceRegex is a regex that is used to match against the workspace + // that is being modified for this event. If the regex matches, we'll + // send the webhook, ex. "production.*". WorkspaceRegex string `mapstructure:"workspace-regex"` - Kind string `mapstructure:"kind"` - // Slack specific + // Kind is the type of webhook we should send, ex. slack. + Kind string `mapstructure:"kind"` + // Channel is the channel to send this webhook to. It only applies to + // slack webhooks. Should be without '#'. Channel string `mapstructure:"channel"` } +// NewServer returns a new server. If there are issues starting the server or +// its dependencies an error will be returned. This is like the main() function +// for the server CLI command because it injects all the dependencies. func NewServer(config Config) (*Server, error) { var supportedVCSHosts []vcs.Host var githubClient *vcs.GithubClient @@ -123,11 +133,11 @@ func NewServer(config Config) (*Server, error) { lockingClient := locking.NewClient(boltdb) run := &run.Run{} configReader := &events.ProjectConfigManager{} - concurrentRunLocker := events.NewEnvLock() + concurrentRunLocker := events.NewDefaultWorkspaceLocker() workspace := &events.FileWorkspace{ DataDir: config.DataDir, } - projectPreExecute := &events.ProjectPreExecute{ + projectPreExecute := &events.DefaultProjectPreExecutor{ Locker: lockingClient, Run: run, ConfigReader: configReader, @@ -149,7 +159,7 @@ func NewServer(config Config) (*Server, error) { Workspace: workspace, ProjectPreExecute: projectPreExecute, Locker: lockingClient, - ProjectFinder: &events.ProjectFinder{}, + ProjectFinder: &events.DefaultProjectFinder{}, } helpExecutor := &events.HelpExecutor{} pullClosedExecutor := &events.PullClosedExecutor{ @@ -174,7 +184,7 @@ func NewServer(config Config) (*Server, error) { GithubPullGetter: githubClient, GitlabMergeRequestGetter: gitlabClient, CommitStatusUpdater: commitStatusUpdater, - EnvLocker: concurrentRunLocker, + WorkspaceLocker: concurrentRunLocker, MarkdownRenderer: markdownRenderer, Logger: logger, } @@ -203,6 +213,7 @@ func NewServer(config Config) (*Server, error) { }, nil } +// Start creates the routes and starts serving traffic. func (s *Server) Start() error { s.Router.HandleFunc("/", s.Index).Methods("GET").MatcherFunc(func(r *http.Request, rm *mux.RouteMatch) bool { return r.URL.Path == "/" || r.URL.Path == "/index.html" @@ -229,6 +240,7 @@ func (s *Server) Start() error { return cli.NewExitError(http.ListenAndServe(fmt.Sprintf(":%d", s.Port), n), 1) } +// Index is the / route. func (s *Server) Index(w http.ResponseWriter, _ *http.Request) { locks, err := s.Locker.List() if err != nil { @@ -250,6 +262,7 @@ func (s *Server) Index(w http.ResponseWriter, _ *http.Request) { s.IndexTemplate.Execute(w, results) // nolint: errcheck } +// GetLockRoute is the GET /locks/{id} route. It renders the lock detail view. func (s *Server) GetLockRoute(w http.ResponseWriter, r *http.Request) { id, ok := mux.Vars(r)["id"] if !ok { @@ -263,7 +276,6 @@ func (s *Server) GetLockRoute(w http.ResponseWriter, r *http.Request) { // GetLock handles a lock detail page view. getLockRoute is expected to // be called before. This function was extracted to make it testable. func (s *Server) GetLock(w http.ResponseWriter, _ *http.Request, id string) { - // get details for lock id idUnencoded, err := url.QueryUnescape(id) if err != nil { w.WriteHeader(http.StatusBadRequest) @@ -271,7 +283,6 @@ func (s *Server) GetLock(w http.ResponseWriter, _ *http.Request, id string) { return } - // for the given lock key get lock data lock, err := s.Locker.GetLock(idUnencoded) if err != nil { w.WriteHeader(http.StatusInternalServerError) @@ -284,7 +295,7 @@ func (s *Server) GetLock(w http.ResponseWriter, _ *http.Request, id string) { return } - // extract the repo owner and repo name + // Extract the repo owner and repo name. repo := strings.Split(lock.Project.RepoFullName, "/") l := LockDetailData{ @@ -300,6 +311,7 @@ func (s *Server) GetLock(w http.ResponseWriter, _ *http.Request, id string) { s.LockDetailTemplate.Execute(w, l) // nolint: errcheck } +// DeleteLockRoute handles deleting the lock at id. func (s *Server) DeleteLockRoute(w http.ResponseWriter, r *http.Request) { id, ok := mux.Vars(r)["id"] if !ok || id == "" { @@ -309,6 +321,8 @@ func (s *Server) DeleteLockRoute(w http.ResponseWriter, r *http.Request) { s.DeleteLock(w, r, id) } +// DeleteLock deletes the lock. DeleteLockRoute should be called first. +// This method is split out to make this route testable. func (s *Server) DeleteLock(w http.ResponseWriter, _ *http.Request, id string) { idUnencoded, err := url.PathUnescape(id) if err != nil { @@ -332,6 +346,9 @@ func (s *Server) DeleteLock(w http.ResponseWriter, _ *http.Request, id string) { func (s *Server) postEvents(w http.ResponseWriter, r *http.Request) { s.EventsController.Post(w, r) } + +// respond is a helper function to respond and log the response. lvl is the log +// level to log at, code is the HTTP response code. func (s *Server) respond(w http.ResponseWriter, lvl logging.LogLevel, code int, format string, args ...interface{}) { response := fmt.Sprintf(format, args...) s.Logger.Log(lvl, response) diff --git a/server/web_templates.go b/server/web_templates.go index bbafa7e6f..460f33e2c 100644 --- a/server/web_templates.go +++ b/server/web_templates.go @@ -7,14 +7,20 @@ import ( ) //go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_template_writer.go TemplateWriter + +// TemplateWriter is an interface over html/template that's used to enable +// mocking. type TemplateWriter interface { + // Execute applies a parsed template to the specified data object, + // writing the output to wr. Execute(wr io.Writer, data interface{}) error } +// LockIndexData holds the fields needed to display the index view for locks. type LockIndexData struct { LockURL string RepoFullName string - PullNum int `` + PullNum int Time time.Time } @@ -75,6 +81,7 @@ var indexTemplate = template.Must(template.New("index.html.tmpl").Parse(` `)) +// LockDetailData holds the fields needed to display the lock detail view. type LockDetailData struct { UnlockURL string LockKeyEncoded string