Add documentation for server, bootstrap and server/events packages

This commit is contained in:
Luke Kysow
2017-11-18 23:23:08 -08:00
parent c8200bb169
commit cc924899e5
43 changed files with 585 additions and 414 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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"

4
cmd/cmd.go Normal file
View File

@@ -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

View File

@@ -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)

View File

@@ -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, "@")
}

View File

@@ -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,

View File

@@ -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{

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -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:

View File

@@ -1,5 +1,6 @@
package events
// CommandResponse is the result of running a Command.
type CommandResponse struct {
Error error
Failure string

View File

@@ -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 != "" {

View File

@@ -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

View File

@@ -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}

View File

@@ -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
}

View File

@@ -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.

View File

@@ -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 <command> [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<details><summary>Log</summary>\n <p>\n\n```\n{{.Log}}```\n</p></details>{{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 <command> [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<details><summary>Log</summary>\n <p>\n\n```\n{{.Log}}```\n</p></details>{{end}}\n"

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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]))

View File

@@ -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")

View File

@@ -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,

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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 {

View File

@@ -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))
}

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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")

View File

@@ -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"

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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(`
</html>
`))
// LockDetailData holds the fields needed to display the lock detail view.
type LockDetailData struct {
UnlockURL string
LockKeyEncoded string