mirror of
https://git.vectorsigma.ru/public/atlantis.git
synced 2026-07-28 21:38:25 +00:00
Add documentation for server, bootstrap and server/events packages
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
4
cmd/cmd.go
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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, "@")
|
||||
}
|
||||
|
||||
3
main.go
3
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,
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package events
|
||||
|
||||
// CommandResponse is the result of running a Command.
|
||||
type CommandResponse struct {
|
||||
Error error
|
||||
Failure string
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
85
server/events/mocks/mock_project_finder.go
Normal file
85
server/events/mocks/mock_project_finder.go
Normal 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
|
||||
}
|
||||
@@ -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]))
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
59
server/events/workspace_locker.go
Normal file
59
server/events/workspace_locker.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user