diff --git a/server/events/apply_command_runner.go b/server/events/apply_command_runner.go
index aa5a4a652b..2ccc75bf0f 100644
--- a/server/events/apply_command_runner.go
+++ b/server/events/apply_command_runner.go
@@ -2,6 +2,7 @@ package events
import (
"github.com/runatlantis/atlantis/server/events/db"
+ "github.com/runatlantis/atlantis/server/events/locking"
"github.com/runatlantis/atlantis/server/events/models"
"github.com/runatlantis/atlantis/server/events/vcs"
)
@@ -9,7 +10,7 @@ import (
func NewApplyCommandRunner(
vcsClient vcs.Client,
disableApplyAll bool,
- disableApply bool,
+ applyCommandLocker locking.ApplyLockChecker,
commitStatusUpdater CommitStatusUpdater,
prjCommandBuilder ProjectApplyCommandBuilder,
prjCmdRunner ProjectApplyCommandRunner,
@@ -22,7 +23,7 @@ func NewApplyCommandRunner(
return &ApplyCommandRunner{
vcsClient: vcsClient,
DisableApplyAll: disableApplyAll,
- DisableApply: disableApply,
+ locker: applyCommandLocker,
commitStatusUpdater: commitStatusUpdater,
prjCmdBuilder: prjCommandBuilder,
prjCmdRunner: prjCmdRunner,
@@ -36,8 +37,8 @@ func NewApplyCommandRunner(
type ApplyCommandRunner struct {
DisableApplyAll bool
- DisableApply bool
DB *db.BoltDB
+ locker locking.ApplyLockChecker
vcsClient vcs.Client
commitStatusUpdater CommitStatusUpdater
prjCmdBuilder ProjectApplyCommandBuilder
@@ -53,7 +54,15 @@ func (a *ApplyCommandRunner) Run(ctx *CommandContext, cmd *CommentCommand) {
baseRepo := ctx.Pull.BaseRepo
pull := ctx.Pull
- if a.DisableApply {
+ locked, err := a.IsLocked()
+ // CheckApplyLock falls back to DisableApply flag if fetching the lock
+ // raises an erro r
+ // We will log failure as warning
+ if err != nil {
+ ctx.Log.Warn("checking global apply lock: %s", err)
+ }
+
+ if locked {
ctx.Log.Info("ignoring apply command since apply disabled globally")
if err := a.vcsClient.CreateComment(baseRepo, pull.Num, applyDisabledComment, models.ApplyCommand.String()); err != nil {
ctx.Log.Err("unable to comment on pull request: %s", err)
@@ -135,6 +144,12 @@ func (a *ApplyCommandRunner) Run(ctx *CommandContext, cmd *CommentCommand) {
}
}
+func (a *ApplyCommandRunner) IsLocked() (bool, error) {
+ lock, err := a.locker.CheckApplyLock()
+
+ return lock.Locked, err
+}
+
func (a *ApplyCommandRunner) isParallelEnabled(projectCmds []models.ProjectCommandContext) bool {
return len(projectCmds) > 0 && projectCmds[0].ParallelApplyEnabled
}
diff --git a/server/events/apply_command_runner_test.go b/server/events/apply_command_runner_test.go
new file mode 100644
index 0000000000..9a17e76589
--- /dev/null
+++ b/server/events/apply_command_runner_test.go
@@ -0,0 +1,69 @@
+package events_test
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/google/go-github/v31/github"
+ . "github.com/petergtz/pegomock"
+ "github.com/runatlantis/atlantis/server/events"
+ "github.com/runatlantis/atlantis/server/events/locking"
+ "github.com/runatlantis/atlantis/server/events/models"
+ "github.com/runatlantis/atlantis/server/events/models/fixtures"
+)
+
+func TestApplyCommandRunner_IsLocked(t *testing.T) {
+ RegisterMockTestingT(t)
+
+ cases := []struct {
+ Description string
+ ApplyLocked bool
+ ApplyLockError error
+ ExpComment string
+ }{
+ {
+ Description: "When global apply lock is present IsDisabled returns true",
+ ApplyLocked: true,
+ ApplyLockError: nil,
+ ExpComment: "**Error:** Running `atlantis apply` is disabled.",
+ },
+ {
+ Description: "When no global apply lock is present and DisableApply flag is false IsDisabled returns false",
+ ApplyLocked: false,
+ ApplyLockError: nil,
+ ExpComment: "Ran Apply for 0 projects:\n\n\n\n",
+ },
+ {
+ Description: "If ApplyLockChecker returns an error IsDisabled return value of DisableApply flag",
+ ApplyLockError: errors.New("error"),
+ ApplyLocked: false,
+ ExpComment: "Ran Apply for 0 projects:\n\n\n\n",
+ },
+ }
+
+ for _, c := range cases {
+ t.Run(c.Description, func(t *testing.T) {
+ vcsClient := setup(t)
+
+ pull := &github.PullRequest{
+ State: github.String("open"),
+ }
+ modelPull := models.PullRequest{BaseRepo: fixtures.GithubRepo, State: models.OpenPullState, Num: fixtures.Pull.Num}
+ When(githubGetter.GetPullRequest(fixtures.GithubRepo, fixtures.Pull.Num)).ThenReturn(pull, nil)
+ When(eventParsing.ParseGithubPull(pull)).ThenReturn(modelPull, modelPull.BaseRepo, fixtures.GithubRepo, nil)
+
+ ctx := &events.CommandContext{
+ User: fixtures.User,
+ Log: noopLogger,
+ Pull: modelPull,
+ HeadRepo: fixtures.GithubRepo,
+ Trigger: events.Comment,
+ }
+
+ When(applyLockChecker.CheckApplyLock()).ThenReturn(locking.ApplyCommandLock{Locked: c.ApplyLocked}, c.ApplyLockError)
+ applyCommandRunner.Run(ctx, &events.CommentCommand{Name: models.ApplyCommand})
+
+ vcsClient.VerifyWasCalledOnce().CreateComment(fixtures.GithubRepo, modelPull.Num, c.ExpComment, "apply")
+ })
+ }
+}
diff --git a/server/events/command_runner_test.go b/server/events/command_runner_test.go
index 6ca31c1563..048ca25115 100644
--- a/server/events/command_runner_test.go
+++ b/server/events/command_runner_test.go
@@ -26,6 +26,7 @@ import (
"github.com/google/go-github/v31/github"
. "github.com/petergtz/pegomock"
"github.com/runatlantis/atlantis/server/events"
+ lockingmocks "github.com/runatlantis/atlantis/server/events/locking/mocks"
"github.com/runatlantis/atlantis/server/events/mocks"
eventmocks "github.com/runatlantis/atlantis/server/events/mocks"
"github.com/runatlantis/atlantis/server/events/mocks/matchers"
@@ -59,6 +60,7 @@ var autoMerger *events.AutoMerger
var policyCheckCommandRunner *events.PolicyCheckCommandRunner
var approvePoliciesCommandRunner *events.ApprovePoliciesCommandRunner
var planCommandRunner *events.PlanCommandRunner
+var applyLockChecker *lockingmocks.MockApplyLockChecker
var applyCommandRunner *events.ApplyCommandRunner
var unlockCommandRunner *events.UnlockCommandRunner
var preWorkflowHooksCommandRunner events.PreWorkflowHooksCommandRunner
@@ -85,6 +87,7 @@ func setup(t *testing.T) *vcsmocks.MockClient {
drainer = &events.Drainer{}
deleteLockCommand = eventmocks.NewMockDeleteLockCommand()
+ applyLockChecker = lockingmocks.NewMockApplyLockChecker()
When(logger.GetLevel()).ThenReturn(logging.Info)
When(logger.NewLogger("runatlantis/atlantis#1", true, logging.Info)).
ThenReturn(pullLogger)
@@ -131,7 +134,7 @@ func setup(t *testing.T) *vcsmocks.MockClient {
applyCommandRunner = events.NewApplyCommandRunner(
vcsClient,
false,
- false,
+ applyLockChecker,
commitUpdater,
projectCommandBuilder,
projectCommandRunner,
@@ -292,23 +295,6 @@ func TestRunCommentCommand_DisableApplyAllDisabled(t *testing.T) {
vcsClient.VerifyWasCalledOnce().CreateComment(fixtures.GithubRepo, modelPull.Num, "**Error:** Running `atlantis apply` without flags is disabled. You must specify which project to apply via the `-d
`, `-w ` or `-p ` flags.", "apply")
}
-func TestRunCommentCommand_ApplyDisabled(t *testing.T) {
- t.Log("if \"atlantis apply\" is run and this is disabled globally atlantis should" +
- " comment saying that this is not allowed")
- vcsClient := setup(t)
- applyCommandRunner.DisableApply = true
- defer func() { applyCommandRunner.DisableApply = false }()
- pull := &github.PullRequest{
- State: github.String("open"),
- }
- modelPull := models.PullRequest{BaseRepo: fixtures.GithubRepo, State: models.OpenPullState, Num: fixtures.Pull.Num}
- When(githubGetter.GetPullRequest(fixtures.GithubRepo, fixtures.Pull.Num)).ThenReturn(pull, nil)
- When(eventParsing.ParseGithubPull(pull)).ThenReturn(modelPull, modelPull.BaseRepo, fixtures.GithubRepo, nil)
-
- ch.RunCommentCommand(fixtures.GithubRepo, nil, nil, fixtures.User, modelPull.Num, &events.CommentCommand{Name: models.ApplyCommand})
- vcsClient.VerifyWasCalledOnce().CreateComment(fixtures.GithubRepo, modelPull.Num, "**Error:** Running `atlantis apply` is disabled.", "apply")
-}
-
func TestRunCommentCommand_DisableDisableAutoplan(t *testing.T) {
t.Log("if \"DisableAutoplan is true\" are disabled and we are silencing return and do not comment with error")
setup(t)
diff --git a/server/events/db/boltdb.go b/server/events/db/boltdb.go
index 898d99d359..9a95c789c9 100644
--- a/server/events/db/boltdb.go
+++ b/server/events/db/boltdb.go
@@ -17,15 +17,17 @@ import (
// BoltDB is a database using BoltDB
type BoltDB struct {
- db *bolt.DB
- locksBucketName []byte
- pullsBucketName []byte
+ db *bolt.DB
+ locksBucketName []byte
+ pullsBucketName []byte
+ globalLocksBucketName []byte
}
const (
- locksBucketName = "runLocks"
- pullsBucketName = "pulls"
- pullKeySeparator = "::"
+ locksBucketName = "runLocks"
+ pullsBucketName = "pulls"
+ globalLocksBucketName = "globalLocks"
+ pullKeySeparator = "::"
)
// New returns a valid locker. We need to be able to write to dataDir
@@ -50,18 +52,31 @@ func New(dataDir string) (*BoltDB, error) {
if _, err = tx.CreateBucketIfNotExists([]byte(pullsBucketName)); err != nil {
return errors.Wrapf(err, "creating bucket %q", pullsBucketName)
}
+ if _, err = tx.CreateBucketIfNotExists([]byte(globalLocksBucketName)); err != nil {
+ return errors.Wrapf(err, "creating bucket %q", globalLocksBucketName)
+ }
return nil
})
if err != nil {
return nil, errors.Wrap(err, "starting BoltDB")
}
// todo: close BoltDB when server is sigtermed
- return &BoltDB{db: db, locksBucketName: []byte(locksBucketName), pullsBucketName: []byte(pullsBucketName)}, nil
+ return &BoltDB{
+ db: db,
+ locksBucketName: []byte(locksBucketName),
+ pullsBucketName: []byte(pullsBucketName),
+ globalLocksBucketName: []byte(globalLocksBucketName),
+ }, nil
}
// NewWithDB is used for testing.
-func NewWithDB(db *bolt.DB, bucket string) (*BoltDB, error) {
- return &BoltDB{db: db, locksBucketName: []byte(bucket), pullsBucketName: []byte(pullsBucketName)}, nil
+func NewWithDB(db *bolt.DB, bucket string, globalBucket string) (*BoltDB, error) {
+ return &BoltDB{
+ db: db,
+ locksBucketName: []byte(bucket),
+ pullsBucketName: []byte(pullsBucketName),
+ globalLocksBucketName: []byte(globalBucket),
+ }, nil
}
// TryLock attempts to create a new lock. If the lock is
@@ -155,6 +170,87 @@ func (b *BoltDB) List() ([]models.ProjectLock, error) {
return locks, nil
}
+// LockCommand attempts to create a new lock for a CommandName.
+// If the lock doesn't exists, it will create a lock and return a pointer to it.
+// If the lock already exists, it will return an "lock already exists" error
+func (b *BoltDB) LockCommand(cmdName models.CommandName, lockTime time.Time) (*models.CommandLock, error) {
+ lock := models.CommandLock{
+ CommandName: cmdName,
+ LockMetadata: models.LockMetadata{
+ UnixTime: lockTime.Unix(),
+ },
+ }
+
+ newLockSerialized, _ := json.Marshal(lock)
+ transactionErr := b.db.Update(func(tx *bolt.Tx) error {
+ bucket := tx.Bucket(b.globalLocksBucketName)
+
+ currLockSerialized := bucket.Get([]byte(b.commandLockKey(cmdName)))
+ if currLockSerialized != nil {
+ return errors.New("lock already exists")
+ }
+
+ // This will only error on readonly buckets, it's okay to ignore.
+ bucket.Put([]byte(b.commandLockKey(cmdName)), newLockSerialized) // nolint: errcheck
+ return nil
+ })
+
+ if transactionErr != nil {
+ return nil, errors.Wrap(transactionErr, "db transaction failed")
+ }
+
+ return &lock, nil
+}
+
+// UnlockCommand removes CommandName lock if present.
+// If there are no lock it returns an error.
+func (b *BoltDB) UnlockCommand(cmdName models.CommandName) error {
+ transactionErr := b.db.Update(func(tx *bolt.Tx) error {
+ bucket := tx.Bucket(b.globalLocksBucketName)
+
+ if l := bucket.Get([]byte(b.commandLockKey(cmdName))); l == nil {
+ return errors.New("no lock exists")
+ }
+
+ return bucket.Delete([]byte(b.commandLockKey(cmdName)))
+ })
+
+ if transactionErr != nil {
+ return errors.Wrap(transactionErr, "db transaction failed")
+ }
+
+ return nil
+}
+
+// CheckCommandLock checks if CommandName lock was set.
+// If the lock exists return the pointer to the lock object, otherwise return nil
+func (b *BoltDB) CheckCommandLock(cmdName models.CommandName) (*models.CommandLock, error) {
+ cmdLock := models.CommandLock{}
+
+ found := false
+
+ err := b.db.View(func(tx *bolt.Tx) error {
+ bucket := tx.Bucket(b.globalLocksBucketName)
+
+ serializedLock := bucket.Get([]byte(b.commandLockKey(cmdName)))
+
+ if serializedLock != nil {
+ if err := json.Unmarshal(serializedLock, &cmdLock); err != nil {
+ return errors.Wrap(err, "failed to deserialize UserConfig")
+ }
+ found = true
+ }
+
+ return nil
+ })
+
+ if found {
+ return &cmdLock, err
+ }
+
+ return nil, err
+}
+
// UnlockByPull deletes all locks associated with that pull request and returns them.
func (b *BoltDB) UnlockByPull(repoFullName string, pullNum int) ([]models.ProjectLock, error) {
var locks []models.ProjectLock
@@ -355,6 +451,10 @@ func (b *BoltDB) pullKey(pull models.PullRequest) ([]byte, error) {
nil
}
+func (b *BoltDB) commandLockKey(cmdName models.CommandName) string {
+ return fmt.Sprintf("%s/lock", cmdName)
+}
+
func (b *BoltDB) lockKey(p models.Project, workspace string) string {
return fmt.Sprintf("%s/%s/%s", p.RepoFullName, p.Path, workspace)
}
diff --git a/server/events/db/boltdb_test.go b/server/events/db/boltdb_test.go
index 7129bb3ee6..d333eb5280 100644
--- a/server/events/db/boltdb_test.go
+++ b/server/events/db/boltdb_test.go
@@ -28,6 +28,7 @@ import (
)
var lockBucket = "bucket"
+var configBucket = "configBucket"
var project = models.NewProject("owner/repo", "parent/child")
var workspace = "default"
var pullNum = 1
@@ -43,6 +44,82 @@ var lock = models.ProjectLock{
Time: time.Now(),
}
+func TestLockCommandNotSet(t *testing.T) {
+ t.Log("retrieving apply lock when there are none should return empty LockCommand")
+ db, b := newTestDB()
+ defer cleanupDB(db)
+ exists, err := b.CheckCommandLock(models.ApplyCommand)
+ Ok(t, err)
+ Assert(t, exists == nil, "exp nil")
+}
+
+func TestLockCommandEnabled(t *testing.T) {
+ t.Log("setting the apply lock")
+ db, b := newTestDB()
+ defer cleanupDB(db)
+ timeNow := time.Now()
+ _, err := b.LockCommand(models.ApplyCommand, timeNow)
+ Ok(t, err)
+
+ config, err := b.CheckCommandLock(models.ApplyCommand)
+ Ok(t, err)
+ Equals(t, true, config.IsLocked())
+}
+
+func TestLockCommandFail(t *testing.T) {
+ t.Log("setting the apply lock")
+ db, b := newTestDB()
+ defer cleanupDB(db)
+ timeNow := time.Now()
+ _, err := b.LockCommand(models.ApplyCommand, timeNow)
+ Ok(t, err)
+
+ _, err = b.LockCommand(models.ApplyCommand, timeNow)
+ ErrEquals(t, "db transaction failed: lock already exists", err)
+}
+
+func TestUnlockCommandDisabled(t *testing.T) {
+ t.Log("unsetting the apply lock")
+ db, b := newTestDB()
+ defer cleanupDB(db)
+ timeNow := time.Now()
+ _, err := b.LockCommand(models.ApplyCommand, timeNow)
+ Ok(t, err)
+
+ config, err := b.CheckCommandLock(models.ApplyCommand)
+ Ok(t, err)
+ Equals(t, true, config.IsLocked())
+
+ err = b.UnlockCommand(models.ApplyCommand)
+ Ok(t, err)
+
+ config, err = b.CheckCommandLock(models.ApplyCommand)
+ Ok(t, err)
+ Assert(t, config == nil, "exp nil object")
+}
+
+func TestUnlockCommandFail(t *testing.T) {
+ t.Log("setting the apply lock")
+ db, b := newTestDB()
+ defer cleanupDB(db)
+ err := b.UnlockCommand(models.ApplyCommand)
+ ErrEquals(t, "db transaction failed: no lock exists", err)
+}
+
+func TestMixedLocksPresent(t *testing.T) {
+ db, b := newTestDB()
+ defer cleanupDB(db)
+ timeNow := time.Now()
+ _, err := b.LockCommand(models.ApplyCommand, timeNow)
+ Ok(t, err)
+
+ _, _, err = b.TryLock(lock)
+ Ok(t, err)
+ ls, err := b.List()
+ Ok(t, err)
+ Equals(t, 1, len(ls))
+}
+
func TestListNoLocks(t *testing.T) {
t.Log("listing locks when there are none should return an empty list")
db, b := newTestDB()
@@ -353,7 +430,7 @@ func TestGetLock(t *testing.T) {
Equals(t, lock.User, l.User)
}
-// Test we can create a status and then get it.
+// Test we can create a status and then getCommandLock it.
func TestPullStatus_UpdateGet(t *testing.T) {
b, cleanup := newTestDB2(t)
defer cleanup()
@@ -404,7 +481,7 @@ func TestPullStatus_UpdateGet(t *testing.T) {
}, status.Projects)
}
-// Test we can create a status, delete it, and then we shouldn't be able to get
+// Test we can create a status, delete it, and then we shouldn't be able to getCommandLock
// it.
func TestPullStatus_UpdateDeleteGet(t *testing.T) {
b, cleanup := newTestDB2(t)
@@ -450,7 +527,7 @@ func TestPullStatus_UpdateDeleteGet(t *testing.T) {
}
// Test we can create a status, update a specific project's status within that
-// pull status, and when we get all the project statuses, that specific project
+// pull status, and when we getCommandLock all the project statuses, that specific project
// should be updated.
func TestPullStatus_UpdateProject(t *testing.T) {
b, cleanup := newTestDB2(t)
@@ -661,7 +738,7 @@ func TestPullStatus_UpdateMerge(t *testing.T) {
getStatus, err := b.GetPullStatus(pull)
Ok(t, err)
- // Test both the pull state returned from the update call *and* the get
+ // Test both the pull state returned from the update call *and* the getCommandLock
// call.
for _, s := range []models.PullStatus{updateStatus, *getStatus} {
Equals(t, pull, s.Pull)
@@ -710,11 +787,14 @@ func newTestDB() (*bolt.DB, *db.BoltDB) {
if _, err := tx.CreateBucketIfNotExists([]byte(lockBucket)); err != nil {
return errors.Wrap(err, "failed to create bucket")
}
+ if _, err := tx.CreateBucketIfNotExists([]byte(configBucket)); err != nil {
+ return errors.Wrap(err, "failed to create bucket")
+ }
return nil
}); err != nil {
panic(errors.Wrap(err, "could not create bucket"))
}
- b, _ := db.NewWithDB(boltDB, lockBucket)
+ b, _ := db.NewWithDB(boltDB, lockBucket, configBucket)
return boltDB, b
}
diff --git a/server/events/locking/apply_locking.go b/server/events/locking/apply_locking.go
new file mode 100644
index 0000000000..bb13104433
--- /dev/null
+++ b/server/events/locking/apply_locking.go
@@ -0,0 +1,113 @@
+package locking
+
+import (
+ "errors"
+ "time"
+
+ "github.com/runatlantis/atlantis/server/events/models"
+)
+
+//go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_apply_lock_checker.go ApplyLockChecker
+
+// ApplyLockChecker is an implementation of the global apply lock retrieval.
+// It returns an object that contains information about apply locks status.
+type ApplyLockChecker interface {
+ CheckApplyLock() (ApplyCommandLock, error)
+}
+
+//go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_apply_locker.go ApplyLocker
+
+// ApplyLocker interface that manages locks for apply command runner
+type ApplyLocker interface {
+ // LockApply creates a lock for ApplyCommand if lock already exists it will
+ // return existing lock without any changes
+ LockApply() (ApplyCommandLock, error)
+ // UnlockApply deletes apply lock created by LockApply if present, otherwise
+ // it is a no-op
+ UnlockApply() error
+ ApplyLockChecker
+}
+
+// ApplyCommandLock contains information about apply command lock status.
+type ApplyCommandLock struct {
+ // Locked is true is when apply commands are locked
+ // Either by using DisableApply flag or creating a global ApplyCommandLock
+ // DisableApply lock take precedence when set
+ Locked bool
+ Time time.Time
+ Failure string
+}
+
+type ApplyClient struct {
+ backend Backend
+ disableApplyFlag bool
+}
+
+func NewApplyClient(backend Backend, disableApplyFlag bool) ApplyLocker {
+ return &ApplyClient{
+ backend: backend,
+ disableApplyFlag: disableApplyFlag,
+ }
+}
+
+// LockApply acquires global apply lock.
+// DisableApplyFlag takes presedence to any existing locks, if it is set to true
+// this function returns an error
+func (c *ApplyClient) LockApply() (ApplyCommandLock, error) {
+ response := ApplyCommandLock{}
+
+ if c.disableApplyFlag {
+ return response, errors.New("DisableApplyFlag is set; Apply commands are locked globally until flag is unset")
+ }
+
+ applyCmdLock, err := c.backend.LockCommand(models.ApplyCommand, time.Now())
+ if err != nil {
+ return response, err
+ }
+
+ if applyCmdLock != nil {
+ response.Locked = true
+ response.Time = applyCmdLock.LockTime()
+ }
+ return response, nil
+}
+
+// UnlockApply releases a global apply lock.
+// DisableApplyFlag takes presedence to any existing locks, if it is set to true
+// this function returns an error
+func (c *ApplyClient) UnlockApply() error {
+ if c.disableApplyFlag {
+ return errors.New("apply commands are disabled until DisableApply flag is unset")
+ }
+
+ err := c.backend.UnlockCommand(models.ApplyCommand)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// CheckApplyLock retrieves an apply command lock if present.
+// If DisableApplyFlag is set it will always return a lock.
+func (c *ApplyClient) CheckApplyLock() (ApplyCommandLock, error) {
+ response := ApplyCommandLock{}
+
+ if c.disableApplyFlag {
+ return ApplyCommandLock{
+ Locked: true,
+ }, nil
+ }
+
+ applyCmdLock, err := c.backend.CheckCommandLock(models.ApplyCommand)
+ if err != nil {
+ return response, err
+ }
+
+ if applyCmdLock != nil {
+ response.Locked = true
+ response.Time = applyCmdLock.LockTime()
+ }
+
+ return response, nil
+}
diff --git a/server/events/locking/locking.go b/server/events/locking/locking.go
index 7e9ee78b45..17f6b8aadf 100644
--- a/server/events/locking/locking.go
+++ b/server/events/locking/locking.go
@@ -32,6 +32,10 @@ type Backend interface {
List() ([]models.ProjectLock, error)
GetLock(project models.Project, workspace string) (*models.ProjectLock, error)
UnlockByPull(repoFullName string, pullNum int) ([]models.ProjectLock, error)
+
+ LockCommand(cmdName models.CommandName, lockTime time.Time) (*models.CommandLock, error)
+ UnlockCommand(cmdName models.CommandName) error
+ CheckCommandLock(cmdName models.CommandName) (*models.CommandLock, error)
}
// TryLockResponse results from an attempted lock.
diff --git a/server/events/locking/locking_test.go b/server/events/locking/locking_test.go
index fe2ba6229a..37b7f4ec92 100644
--- a/server/events/locking/locking_test.go
+++ b/server/events/locking/locking_test.go
@@ -181,3 +181,107 @@ func TestGetLock_NoOpLocker(t *testing.T) {
var expected *models.ProjectLock = nil
Equals(t, expected, lock)
}
+
+func TestApplyLocker(t *testing.T) {
+ RegisterMockTestingT(t)
+ applyLock := &models.CommandLock{
+ CommandName: models.ApplyCommand,
+ LockMetadata: models.LockMetadata{
+ UnixTime: time.Now().Unix(),
+ },
+ }
+
+ t.Run("LockApply", func(t *testing.T) {
+ t.Run("backend errors", func(t *testing.T) {
+ backend := mocks.NewMockBackend()
+
+ When(backend.LockCommand(matchers.AnyModelsCommandName(), matchers.AnyTimeTime())).ThenReturn(nil, errExpected)
+ l := locking.NewApplyClient(backend, false)
+ lock, err := l.LockApply()
+ Equals(t, errExpected, err)
+ Assert(t, !lock.Locked, "exp false")
+ })
+
+ t.Run("can't lock if userConfig.DisableApply is set", func(t *testing.T) {
+ backend := mocks.NewMockBackend()
+
+ l := locking.NewApplyClient(backend, true)
+ _, err := l.LockApply()
+ ErrEquals(t, "DisableApplyFlag is set; Apply commands are locked globally until flag is unset", err)
+
+ backend.VerifyWasCalled(Never()).LockCommand(matchers.AnyModelsCommandName(), matchers.AnyTimeTime())
+ })
+
+ t.Run("succeeds", func(t *testing.T) {
+ backend := mocks.NewMockBackend()
+
+ When(backend.LockCommand(matchers.AnyModelsCommandName(), matchers.AnyTimeTime())).ThenReturn(applyLock, nil)
+ l := locking.NewApplyClient(backend, false)
+ lock, _ := l.LockApply()
+ Assert(t, lock.Locked, "exp lock present")
+ })
+ })
+
+ t.Run("UnlockApply", func(t *testing.T) {
+ t.Run("backend fails", func(t *testing.T) {
+ backend := mocks.NewMockBackend()
+
+ When(backend.UnlockCommand(matchers.AnyModelsCommandName())).ThenReturn(errExpected)
+ l := locking.NewApplyClient(backend, false)
+ err := l.UnlockApply()
+ Equals(t, errExpected, err)
+ })
+
+ t.Run("can't unlock if userConfig.DisableApply is set", func(t *testing.T) {
+ backend := mocks.NewMockBackend()
+
+ l := locking.NewApplyClient(backend, true)
+ err := l.UnlockApply()
+ ErrEquals(t, "apply commands are disabled until DisableApply flag is unset", err)
+
+ backend.VerifyWasCalled(Never()).UnlockCommand(matchers.AnyModelsCommandName())
+ })
+
+ t.Run("succeeds", func(t *testing.T) {
+ backend := mocks.NewMockBackend()
+
+ When(backend.UnlockCommand(matchers.AnyModelsCommandName())).ThenReturn(nil)
+ l := locking.NewApplyClient(backend, false)
+ err := l.UnlockApply()
+ Equals(t, nil, err)
+ })
+
+ })
+
+ t.Run("CheckApplyLock", func(t *testing.T) {
+ t.Run("fails", func(t *testing.T) {
+ backend := mocks.NewMockBackend()
+
+ When(backend.CheckCommandLock(matchers.AnyModelsCommandName())).ThenReturn(nil, errExpected)
+ l := locking.NewApplyClient(backend, false)
+ lock, err := l.CheckApplyLock()
+ Equals(t, errExpected, err)
+ Equals(t, lock.Locked, false)
+ })
+
+ t.Run("when DisableApply flag is set always return a lock", func(t *testing.T) {
+ backend := mocks.NewMockBackend()
+
+ l := locking.NewApplyClient(backend, true)
+ lock, err := l.CheckApplyLock()
+ Ok(t, err)
+ Equals(t, lock.Locked, true)
+ backend.VerifyWasCalled(Never()).CheckCommandLock(matchers.AnyModelsCommandName())
+ })
+
+ t.Run("UnlockCommand succeeds", func(t *testing.T) {
+ backend := mocks.NewMockBackend()
+
+ When(backend.CheckCommandLock(matchers.AnyModelsCommandName())).ThenReturn(applyLock, nil)
+ l := locking.NewApplyClient(backend, false)
+ lock, err := l.CheckApplyLock()
+ Equals(t, nil, err)
+ Assert(t, lock.Locked, "exp lock present")
+ })
+ })
+}
diff --git a/server/events/locking/mocks/matchers/locking_applycommandlockresponse.go b/server/events/locking/mocks/matchers/locking_applycommandlockresponse.go
new file mode 100644
index 0000000000..735879321f
--- /dev/null
+++ b/server/events/locking/mocks/matchers/locking_applycommandlockresponse.go
@@ -0,0 +1,20 @@
+// Code generated by pegomock. DO NOT EDIT.
+package matchers
+
+import (
+ "reflect"
+ "github.com/petergtz/pegomock"
+ locking "github.com/runatlantis/atlantis/server/events/locking"
+)
+
+func AnyLockingApplyCommandLockResponse() locking.ApplyCommandLock {
+ pegomock.RegisterMatcher(pegomock.NewAnyMatcher(reflect.TypeOf((*(locking.ApplyCommandLock))(nil)).Elem()))
+ var nullValue locking.ApplyCommandLock
+ return nullValue
+}
+
+func EqLockingApplyCommandLockResponse(value locking.ApplyCommandLock) locking.ApplyCommandLock {
+ pegomock.RegisterMatcher(&pegomock.EqMatcher{Value: value})
+ var nullValue locking.ApplyCommandLock
+ return nullValue
+}
diff --git a/server/events/locking/mocks/matchers/models_commandlock.go b/server/events/locking/mocks/matchers/models_commandlock.go
new file mode 100644
index 0000000000..87669404be
--- /dev/null
+++ b/server/events/locking/mocks/matchers/models_commandlock.go
@@ -0,0 +1,20 @@
+// Code generated by pegomock. DO NOT EDIT.
+package matchers
+
+import (
+ "reflect"
+ "github.com/petergtz/pegomock"
+ models "github.com/runatlantis/atlantis/server/events/models"
+)
+
+func AnyModelsCommandLock() models.CommandLock {
+ pegomock.RegisterMatcher(pegomock.NewAnyMatcher(reflect.TypeOf((*(models.CommandLock))(nil)).Elem()))
+ var nullValue models.CommandLock
+ return nullValue
+}
+
+func EqModelsCommandLock(value models.CommandLock) models.CommandLock {
+ pegomock.RegisterMatcher(&pegomock.EqMatcher{Value: value})
+ var nullValue models.CommandLock
+ return nullValue
+}
diff --git a/server/events/locking/mocks/matchers/models_commandname.go b/server/events/locking/mocks/matchers/models_commandname.go
new file mode 100644
index 0000000000..6eba95f8ee
--- /dev/null
+++ b/server/events/locking/mocks/matchers/models_commandname.go
@@ -0,0 +1,20 @@
+// Code generated by pegomock. DO NOT EDIT.
+package matchers
+
+import (
+ "reflect"
+ "github.com/petergtz/pegomock"
+ models "github.com/runatlantis/atlantis/server/events/models"
+)
+
+func AnyModelsCommandName() models.CommandName {
+ pegomock.RegisterMatcher(pegomock.NewAnyMatcher(reflect.TypeOf((*(models.CommandName))(nil)).Elem()))
+ var nullValue models.CommandName
+ return nullValue
+}
+
+func EqModelsCommandName(value models.CommandName) models.CommandName {
+ pegomock.RegisterMatcher(&pegomock.EqMatcher{Value: value})
+ var nullValue models.CommandName
+ return nullValue
+}
diff --git a/server/events/locking/mocks/matchers/ptr_to_models_commandlock.go b/server/events/locking/mocks/matchers/ptr_to_models_commandlock.go
new file mode 100644
index 0000000000..bac0e1a4d9
--- /dev/null
+++ b/server/events/locking/mocks/matchers/ptr_to_models_commandlock.go
@@ -0,0 +1,20 @@
+// Code generated by pegomock. DO NOT EDIT.
+package matchers
+
+import (
+ "reflect"
+ "github.com/petergtz/pegomock"
+ models "github.com/runatlantis/atlantis/server/events/models"
+)
+
+func AnyPtrToModelsCommandLock() *models.CommandLock {
+ pegomock.RegisterMatcher(pegomock.NewAnyMatcher(reflect.TypeOf((*(*models.CommandLock))(nil)).Elem()))
+ var nullValue *models.CommandLock
+ return nullValue
+}
+
+func EqPtrToModelsCommandLock(value *models.CommandLock) *models.CommandLock {
+ pegomock.RegisterMatcher(&pegomock.EqMatcher{Value: value})
+ var nullValue *models.CommandLock
+ return nullValue
+}
diff --git a/server/events/locking/mocks/matchers/time_time.go b/server/events/locking/mocks/matchers/time_time.go
new file mode 100644
index 0000000000..b08cb5e177
--- /dev/null
+++ b/server/events/locking/mocks/matchers/time_time.go
@@ -0,0 +1,20 @@
+// Code generated by pegomock. DO NOT EDIT.
+package matchers
+
+import (
+ "reflect"
+ "github.com/petergtz/pegomock"
+ time "time"
+)
+
+func AnyTimeTime() time.Time {
+ pegomock.RegisterMatcher(pegomock.NewAnyMatcher(reflect.TypeOf((*(time.Time))(nil)).Elem()))
+ var nullValue time.Time
+ return nullValue
+}
+
+func EqTimeTime(value time.Time) time.Time {
+ pegomock.RegisterMatcher(&pegomock.EqMatcher{Value: value})
+ var nullValue time.Time
+ return nullValue
+}
diff --git a/server/events/locking/mocks/mock_apply_lock_checker.go b/server/events/locking/mocks/mock_apply_lock_checker.go
new file mode 100644
index 0000000000..15b5294d38
--- /dev/null
+++ b/server/events/locking/mocks/mock_apply_lock_checker.go
@@ -0,0 +1,99 @@
+// Code generated by pegomock. DO NOT EDIT.
+// Source: github.com/runatlantis/atlantis/server/events/locking (interfaces: ApplyLockChecker)
+
+package mocks
+
+import (
+ pegomock "github.com/petergtz/pegomock"
+ locking "github.com/runatlantis/atlantis/server/events/locking"
+ "reflect"
+ "time"
+)
+
+type MockApplyLockChecker struct {
+ fail func(message string, callerSkip ...int)
+}
+
+func NewMockApplyLockChecker(options ...pegomock.Option) *MockApplyLockChecker {
+ mock := &MockApplyLockChecker{}
+ for _, option := range options {
+ option.Apply(mock)
+ }
+ return mock
+}
+
+func (mock *MockApplyLockChecker) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh }
+func (mock *MockApplyLockChecker) FailHandler() pegomock.FailHandler { return mock.fail }
+
+func (mock *MockApplyLockChecker) CheckApplyLock() (locking.ApplyCommandLock, error) {
+ if mock == nil {
+ panic("mock must not be nil. Use myMock := NewMockApplyLockChecker().")
+ }
+ params := []pegomock.Param{}
+ result := pegomock.GetGenericMockFrom(mock).Invoke("CheckApplyLock", params, []reflect.Type{reflect.TypeOf((*locking.ApplyCommandLock)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()})
+ var ret0 locking.ApplyCommandLock
+ var ret1 error
+ if len(result) != 0 {
+ if result[0] != nil {
+ ret0 = result[0].(locking.ApplyCommandLock)
+ }
+ if result[1] != nil {
+ ret1 = result[1].(error)
+ }
+ }
+ return ret0, ret1
+}
+
+func (mock *MockApplyLockChecker) VerifyWasCalledOnce() *VerifierMockApplyLockChecker {
+ return &VerifierMockApplyLockChecker{
+ mock: mock,
+ invocationCountMatcher: pegomock.Times(1),
+ }
+}
+
+func (mock *MockApplyLockChecker) VerifyWasCalled(invocationCountMatcher pegomock.Matcher) *VerifierMockApplyLockChecker {
+ return &VerifierMockApplyLockChecker{
+ mock: mock,
+ invocationCountMatcher: invocationCountMatcher,
+ }
+}
+
+func (mock *MockApplyLockChecker) VerifyWasCalledInOrder(invocationCountMatcher pegomock.Matcher, inOrderContext *pegomock.InOrderContext) *VerifierMockApplyLockChecker {
+ return &VerifierMockApplyLockChecker{
+ mock: mock,
+ invocationCountMatcher: invocationCountMatcher,
+ inOrderContext: inOrderContext,
+ }
+}
+
+func (mock *MockApplyLockChecker) VerifyWasCalledEventually(invocationCountMatcher pegomock.Matcher, timeout time.Duration) *VerifierMockApplyLockChecker {
+ return &VerifierMockApplyLockChecker{
+ mock: mock,
+ invocationCountMatcher: invocationCountMatcher,
+ timeout: timeout,
+ }
+}
+
+type VerifierMockApplyLockChecker struct {
+ mock *MockApplyLockChecker
+ invocationCountMatcher pegomock.Matcher
+ inOrderContext *pegomock.InOrderContext
+ timeout time.Duration
+}
+
+func (verifier *VerifierMockApplyLockChecker) CheckApplyLock() *MockApplyLockChecker_CheckApplyLock_OngoingVerification {
+ params := []pegomock.Param{}
+ methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "CheckApplyLock", params, verifier.timeout)
+ return &MockApplyLockChecker_CheckApplyLock_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations}
+}
+
+type MockApplyLockChecker_CheckApplyLock_OngoingVerification struct {
+ mock *MockApplyLockChecker
+ methodInvocations []pegomock.MethodInvocation
+}
+
+func (c *MockApplyLockChecker_CheckApplyLock_OngoingVerification) GetCapturedArguments() {
+}
+
+func (c *MockApplyLockChecker_CheckApplyLock_OngoingVerification) GetAllCapturedArguments() {
+}
diff --git a/server/events/locking/mocks/mock_apply_locker.go b/server/events/locking/mocks/mock_apply_locker.go
new file mode 100644
index 0000000000..3275a975d1
--- /dev/null
+++ b/server/events/locking/mocks/mock_apply_locker.go
@@ -0,0 +1,167 @@
+// Code generated by pegomock. DO NOT EDIT.
+// Source: github.com/runatlantis/atlantis/server/events/locking (interfaces: ApplyLocker)
+
+package mocks
+
+import (
+ pegomock "github.com/petergtz/pegomock"
+ locking "github.com/runatlantis/atlantis/server/events/locking"
+ "reflect"
+ "time"
+)
+
+type MockApplyLocker struct {
+ fail func(message string, callerSkip ...int)
+}
+
+func NewMockApplyLocker(options ...pegomock.Option) *MockApplyLocker {
+ mock := &MockApplyLocker{}
+ for _, option := range options {
+ option.Apply(mock)
+ }
+ return mock
+}
+
+func (mock *MockApplyLocker) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh }
+func (mock *MockApplyLocker) FailHandler() pegomock.FailHandler { return mock.fail }
+
+func (mock *MockApplyLocker) LockApply() (locking.ApplyCommandLock, error) {
+ if mock == nil {
+ panic("mock must not be nil. Use myMock := NewMockApplyLocker().")
+ }
+ params := []pegomock.Param{}
+ result := pegomock.GetGenericMockFrom(mock).Invoke("LockApply", params, []reflect.Type{reflect.TypeOf((*locking.ApplyCommandLock)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()})
+ var ret0 locking.ApplyCommandLock
+ var ret1 error
+ if len(result) != 0 {
+ if result[0] != nil {
+ ret0 = result[0].(locking.ApplyCommandLock)
+ }
+ if result[1] != nil {
+ ret1 = result[1].(error)
+ }
+ }
+ return ret0, ret1
+}
+
+func (mock *MockApplyLocker) UnlockApply() error {
+ if mock == nil {
+ panic("mock must not be nil. Use myMock := NewMockApplyLocker().")
+ }
+ params := []pegomock.Param{}
+ result := pegomock.GetGenericMockFrom(mock).Invoke("UnlockApply", params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()})
+ var ret0 error
+ if len(result) != 0 {
+ if result[0] != nil {
+ ret0 = result[0].(error)
+ }
+ }
+ return ret0
+}
+
+func (mock *MockApplyLocker) CheckApplyLock() (locking.ApplyCommandLock, error) {
+ if mock == nil {
+ panic("mock must not be nil. Use myMock := NewMockApplyLocker().")
+ }
+ params := []pegomock.Param{}
+ result := pegomock.GetGenericMockFrom(mock).Invoke("CheckApplyLock", params, []reflect.Type{reflect.TypeOf((*locking.ApplyCommandLock)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()})
+ var ret0 locking.ApplyCommandLock
+ var ret1 error
+ if len(result) != 0 {
+ if result[0] != nil {
+ ret0 = result[0].(locking.ApplyCommandLock)
+ }
+ if result[1] != nil {
+ ret1 = result[1].(error)
+ }
+ }
+ return ret0, ret1
+}
+
+func (mock *MockApplyLocker) VerifyWasCalledOnce() *VerifierMockApplyLocker {
+ return &VerifierMockApplyLocker{
+ mock: mock,
+ invocationCountMatcher: pegomock.Times(1),
+ }
+}
+
+func (mock *MockApplyLocker) VerifyWasCalled(invocationCountMatcher pegomock.Matcher) *VerifierMockApplyLocker {
+ return &VerifierMockApplyLocker{
+ mock: mock,
+ invocationCountMatcher: invocationCountMatcher,
+ }
+}
+
+func (mock *MockApplyLocker) VerifyWasCalledInOrder(invocationCountMatcher pegomock.Matcher, inOrderContext *pegomock.InOrderContext) *VerifierMockApplyLocker {
+ return &VerifierMockApplyLocker{
+ mock: mock,
+ invocationCountMatcher: invocationCountMatcher,
+ inOrderContext: inOrderContext,
+ }
+}
+
+func (mock *MockApplyLocker) VerifyWasCalledEventually(invocationCountMatcher pegomock.Matcher, timeout time.Duration) *VerifierMockApplyLocker {
+ return &VerifierMockApplyLocker{
+ mock: mock,
+ invocationCountMatcher: invocationCountMatcher,
+ timeout: timeout,
+ }
+}
+
+type VerifierMockApplyLocker struct {
+ mock *MockApplyLocker
+ invocationCountMatcher pegomock.Matcher
+ inOrderContext *pegomock.InOrderContext
+ timeout time.Duration
+}
+
+func (verifier *VerifierMockApplyLocker) LockApply() *MockApplyLocker_LockApply_OngoingVerification {
+ params := []pegomock.Param{}
+ methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "LockApply", params, verifier.timeout)
+ return &MockApplyLocker_LockApply_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations}
+}
+
+type MockApplyLocker_LockApply_OngoingVerification struct {
+ mock *MockApplyLocker
+ methodInvocations []pegomock.MethodInvocation
+}
+
+func (c *MockApplyLocker_LockApply_OngoingVerification) GetCapturedArguments() {
+}
+
+func (c *MockApplyLocker_LockApply_OngoingVerification) GetAllCapturedArguments() {
+}
+
+func (verifier *VerifierMockApplyLocker) UnlockApply() *MockApplyLocker_UnlockApply_OngoingVerification {
+ params := []pegomock.Param{}
+ methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "UnlockApply", params, verifier.timeout)
+ return &MockApplyLocker_UnlockApply_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations}
+}
+
+type MockApplyLocker_UnlockApply_OngoingVerification struct {
+ mock *MockApplyLocker
+ methodInvocations []pegomock.MethodInvocation
+}
+
+func (c *MockApplyLocker_UnlockApply_OngoingVerification) GetCapturedArguments() {
+}
+
+func (c *MockApplyLocker_UnlockApply_OngoingVerification) GetAllCapturedArguments() {
+}
+
+func (verifier *VerifierMockApplyLocker) CheckApplyLock() *MockApplyLocker_CheckApplyLock_OngoingVerification {
+ params := []pegomock.Param{}
+ methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "CheckApplyLock", params, verifier.timeout)
+ return &MockApplyLocker_CheckApplyLock_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations}
+}
+
+type MockApplyLocker_CheckApplyLock_OngoingVerification struct {
+ mock *MockApplyLocker
+ methodInvocations []pegomock.MethodInvocation
+}
+
+func (c *MockApplyLocker_CheckApplyLock_OngoingVerification) GetCapturedArguments() {
+}
+
+func (c *MockApplyLocker_CheckApplyLock_OngoingVerification) GetAllCapturedArguments() {
+}
diff --git a/server/events/locking/mocks/mock_backend.go b/server/events/locking/mocks/mock_backend.go
index 073dee2034..3bad8fc614 100644
--- a/server/events/locking/mocks/mock_backend.go
+++ b/server/events/locking/mocks/mock_backend.go
@@ -124,6 +124,59 @@ func (mock *MockBackend) UnlockByPull(repoFullName string, pullNum int) ([]model
return ret0, ret1
}
+func (mock *MockBackend) LockCommand(cmdName models.CommandName, lockTime time.Time) (*models.CommandLock, error) {
+ if mock == nil {
+ panic("mock must not be nil. Use myMock := NewMockBackend().")
+ }
+ params := []pegomock.Param{cmdName, lockTime}
+ result := pegomock.GetGenericMockFrom(mock).Invoke("LockCommand", params, []reflect.Type{reflect.TypeOf((**models.CommandLock)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()})
+ var ret0 *models.CommandLock
+ var ret1 error
+ if len(result) != 0 {
+ if result[0] != nil {
+ ret0 = result[0].(*models.CommandLock)
+ }
+ if result[1] != nil {
+ ret1 = result[1].(error)
+ }
+ }
+ return ret0, ret1
+}
+
+func (mock *MockBackend) UnlockCommand(cmdName models.CommandName) error {
+ if mock == nil {
+ panic("mock must not be nil. Use myMock := NewMockBackend().")
+ }
+ params := []pegomock.Param{cmdName}
+ result := pegomock.GetGenericMockFrom(mock).Invoke("UnlockCommand", params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()})
+ var ret0 error
+ if len(result) != 0 {
+ if result[0] != nil {
+ ret0 = result[0].(error)
+ }
+ }
+ return ret0
+}
+
+func (mock *MockBackend) CheckCommandLock(cmdName models.CommandName) (*models.CommandLock, error) {
+ if mock == nil {
+ panic("mock must not be nil. Use myMock := NewMockBackend().")
+ }
+ params := []pegomock.Param{cmdName}
+ result := pegomock.GetGenericMockFrom(mock).Invoke("CheckCommandLock", params, []reflect.Type{reflect.TypeOf((**models.CommandLock)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()})
+ var ret0 *models.CommandLock
+ var ret1 error
+ if len(result) != 0 {
+ if result[0] != nil {
+ ret0 = result[0].(*models.CommandLock)
+ }
+ if result[1] != nil {
+ ret1 = result[1].(error)
+ }
+ }
+ return ret0, ret1
+}
+
func (mock *MockBackend) VerifyWasCalledOnce() *VerifierMockBackend {
return &VerifierMockBackend{
mock: mock,
@@ -297,3 +350,88 @@ func (c *MockBackend_UnlockByPull_OngoingVerification) GetAllCapturedArguments()
}
return
}
+
+func (verifier *VerifierMockBackend) LockCommand(cmdName models.CommandName, lockTime time.Time) *MockBackend_LockCommand_OngoingVerification {
+ params := []pegomock.Param{cmdName, lockTime}
+ methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "LockCommand", params, verifier.timeout)
+ return &MockBackend_LockCommand_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations}
+}
+
+type MockBackend_LockCommand_OngoingVerification struct {
+ mock *MockBackend
+ methodInvocations []pegomock.MethodInvocation
+}
+
+func (c *MockBackend_LockCommand_OngoingVerification) GetCapturedArguments() (models.CommandName, time.Time) {
+ cmdName, lockTime := c.GetAllCapturedArguments()
+ return cmdName[len(cmdName)-1], lockTime[len(lockTime)-1]
+}
+
+func (c *MockBackend_LockCommand_OngoingVerification) GetAllCapturedArguments() (_param0 []models.CommandName, _param1 []time.Time) {
+ params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations)
+ if len(params) > 0 {
+ _param0 = make([]models.CommandName, len(c.methodInvocations))
+ for u, param := range params[0] {
+ _param0[u] = param.(models.CommandName)
+ }
+ _param1 = make([]time.Time, len(c.methodInvocations))
+ for u, param := range params[1] {
+ _param1[u] = param.(time.Time)
+ }
+ }
+ return
+}
+
+func (verifier *VerifierMockBackend) UnlockCommand(cmdName models.CommandName) *MockBackend_UnlockCommand_OngoingVerification {
+ params := []pegomock.Param{cmdName}
+ methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "UnlockCommand", params, verifier.timeout)
+ return &MockBackend_UnlockCommand_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations}
+}
+
+type MockBackend_UnlockCommand_OngoingVerification struct {
+ mock *MockBackend
+ methodInvocations []pegomock.MethodInvocation
+}
+
+func (c *MockBackend_UnlockCommand_OngoingVerification) GetCapturedArguments() models.CommandName {
+ cmdName := c.GetAllCapturedArguments()
+ return cmdName[len(cmdName)-1]
+}
+
+func (c *MockBackend_UnlockCommand_OngoingVerification) GetAllCapturedArguments() (_param0 []models.CommandName) {
+ params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations)
+ if len(params) > 0 {
+ _param0 = make([]models.CommandName, len(c.methodInvocations))
+ for u, param := range params[0] {
+ _param0[u] = param.(models.CommandName)
+ }
+ }
+ return
+}
+
+func (verifier *VerifierMockBackend) CheckCommandLock(cmdName models.CommandName) *MockBackend_CheckCommandLock_OngoingVerification {
+ params := []pegomock.Param{cmdName}
+ methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "CheckCommandLock", params, verifier.timeout)
+ return &MockBackend_CheckCommandLock_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations}
+}
+
+type MockBackend_CheckCommandLock_OngoingVerification struct {
+ mock *MockBackend
+ methodInvocations []pegomock.MethodInvocation
+}
+
+func (c *MockBackend_CheckCommandLock_OngoingVerification) GetCapturedArguments() models.CommandName {
+ cmdName := c.GetAllCapturedArguments()
+ return cmdName[len(cmdName)-1]
+}
+
+func (c *MockBackend_CheckCommandLock_OngoingVerification) GetAllCapturedArguments() (_param0 []models.CommandName) {
+ params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations)
+ if len(params) > 0 {
+ _param0 = make([]models.CommandName, len(c.methodInvocations))
+ for u, param := range params[0] {
+ _param0[u] = param.(models.CommandName)
+ }
+ }
+ return
+}
diff --git a/server/events/mocks/mock_apply_command_locker.go b/server/events/mocks/mock_apply_command_locker.go
new file mode 100644
index 0000000000..37fc004d75
--- /dev/null
+++ b/server/events/mocks/mock_apply_command_locker.go
@@ -0,0 +1,105 @@
+// Code generated by pegomock. DO NOT EDIT.
+// Source: github.com/runatlantis/atlantis/server/events (interfaces: ApplyCommandLocker)
+
+package mocks
+
+import (
+ pegomock "github.com/petergtz/pegomock"
+ events "github.com/runatlantis/atlantis/server/events"
+ "reflect"
+ "time"
+)
+
+type MockApplyCommandLocker struct {
+ fail func(message string, callerSkip ...int)
+}
+
+func NewMockApplyCommandLocker(options ...pegomock.Option) *MockApplyCommandLocker {
+ mock := &MockApplyCommandLocker{}
+ for _, option := range options {
+ option.Apply(mock)
+ }
+ return mock
+}
+
+func (mock *MockApplyCommandLocker) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh }
+func (mock *MockApplyCommandLocker) FailHandler() pegomock.FailHandler { return mock.fail }
+
+func (mock *MockApplyCommandLocker) IsDisabled(ctx *events.CommandContext) bool {
+ if mock == nil {
+ panic("mock must not be nil. Use myMock := NewMockApplyCommandLocker().")
+ }
+ params := []pegomock.Param{ctx}
+ result := pegomock.GetGenericMockFrom(mock).Invoke("IsDisabled", params, []reflect.Type{reflect.TypeOf((*bool)(nil)).Elem()})
+ var ret0 bool
+ if len(result) != 0 {
+ if result[0] != nil {
+ ret0 = result[0].(bool)
+ }
+ }
+ return ret0
+}
+
+func (mock *MockApplyCommandLocker) VerifyWasCalledOnce() *VerifierMockApplyCommandLocker {
+ return &VerifierMockApplyCommandLocker{
+ mock: mock,
+ invocationCountMatcher: pegomock.Times(1),
+ }
+}
+
+func (mock *MockApplyCommandLocker) VerifyWasCalled(invocationCountMatcher pegomock.Matcher) *VerifierMockApplyCommandLocker {
+ return &VerifierMockApplyCommandLocker{
+ mock: mock,
+ invocationCountMatcher: invocationCountMatcher,
+ }
+}
+
+func (mock *MockApplyCommandLocker) VerifyWasCalledInOrder(invocationCountMatcher pegomock.Matcher, inOrderContext *pegomock.InOrderContext) *VerifierMockApplyCommandLocker {
+ return &VerifierMockApplyCommandLocker{
+ mock: mock,
+ invocationCountMatcher: invocationCountMatcher,
+ inOrderContext: inOrderContext,
+ }
+}
+
+func (mock *MockApplyCommandLocker) VerifyWasCalledEventually(invocationCountMatcher pegomock.Matcher, timeout time.Duration) *VerifierMockApplyCommandLocker {
+ return &VerifierMockApplyCommandLocker{
+ mock: mock,
+ invocationCountMatcher: invocationCountMatcher,
+ timeout: timeout,
+ }
+}
+
+type VerifierMockApplyCommandLocker struct {
+ mock *MockApplyCommandLocker
+ invocationCountMatcher pegomock.Matcher
+ inOrderContext *pegomock.InOrderContext
+ timeout time.Duration
+}
+
+func (verifier *VerifierMockApplyCommandLocker) IsDisabled(ctx *events.CommandContext) *MockApplyCommandLocker_IsDisabled_OngoingVerification {
+ params := []pegomock.Param{ctx}
+ methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "IsDisabled", params, verifier.timeout)
+ return &MockApplyCommandLocker_IsDisabled_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations}
+}
+
+type MockApplyCommandLocker_IsDisabled_OngoingVerification struct {
+ mock *MockApplyCommandLocker
+ methodInvocations []pegomock.MethodInvocation
+}
+
+func (c *MockApplyCommandLocker_IsDisabled_OngoingVerification) GetCapturedArguments() *events.CommandContext {
+ ctx := c.GetAllCapturedArguments()
+ return ctx[len(ctx)-1]
+}
+
+func (c *MockApplyCommandLocker_IsDisabled_OngoingVerification) GetAllCapturedArguments() (_param0 []*events.CommandContext) {
+ params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations)
+ if len(params) > 0 {
+ _param0 = make([]*events.CommandContext, len(c.methodInvocations))
+ for u, param := range params[0] {
+ _param0[u] = param.(*events.CommandContext)
+ }
+ }
+ return
+}
diff --git a/server/events/models/models.go b/server/events/models/models.go
index 39052ef3de..6ce3271256 100644
--- a/server/events/models/models.go
+++ b/server/events/models/models.go
@@ -206,6 +206,27 @@ type User struct {
Username string
}
+// LockMetadata contains additional data provided to the lock
+type LockMetadata struct {
+ UnixTime int64
+}
+
+// CommandLock represents a global lock for an atlantis command (plan, apply, policy_check).
+// It is used to prevent commands from being executed
+type CommandLock struct {
+ // Time is the time at which the lock was first created.
+ LockMetadata LockMetadata
+ CommandName CommandName
+}
+
+func (l *CommandLock) LockTime() time.Time {
+ return time.Unix(l.LockMetadata.UnixTime, 0)
+}
+
+func (l *CommandLock) IsLocked() bool {
+ return !l.LockTime().IsZero()
+}
+
// ProjectLock represents a lock on a project.
type ProjectLock struct {
// Project is the project that is being locked.
diff --git a/server/events_controller_e2e_test.go b/server/events_controller_e2e_test.go
index 823cf5945a..181ec7ca70 100644
--- a/server/events_controller_e2e_test.go
+++ b/server/events_controller_e2e_test.go
@@ -37,6 +37,9 @@ import (
. "github.com/runatlantis/atlantis/testing"
)
+var applyLocker locking.ApplyLocker
+var userConfig server.UserConfig
+
type NoopTFDownloader struct{}
var mockPreWorkflowHookRunner *runtimemocks.MockPreWorkflowHookRunner
@@ -73,6 +76,10 @@ func TestGitHubWorkflow(t *testing.T) {
ModifiedFiles []string
// Comments are what our mock user writes to the pull request.
Comments []string
+ // DisableApply flag used by userConfig object when initializing atlantis server.
+ DisableApply bool
+ // ApplyLock creates an apply lock that temporarily disables apply command
+ ApplyLock bool
// ExpAutomerge is true if we expect Atlantis to automerge.
ExpAutomerge bool
// ExpAutoplan is true if we expect Atlantis to autoplan.
@@ -336,12 +343,48 @@ func TestGitHubWorkflow(t *testing.T) {
{"exp-output-merge.txt"},
},
},
+ {
+ Description: "global apply lock disables apply commands",
+ RepoDir: "simple-yaml",
+ ModifiedFiles: []string{"main.tf"},
+ DisableApply: false,
+ ApplyLock: true,
+ ExpAutoplan: true,
+ Comments: []string{
+ "atlantis apply",
+ },
+ ExpReplies: [][]string{
+ {"exp-output-autoplan.txt"},
+ {"exp-output-apply-locked.txt"},
+ {"exp-output-merge.txt"},
+ },
+ },
+ {
+ Description: "disable apply flag always takes presedence",
+ RepoDir: "simple-yaml",
+ ModifiedFiles: []string{"main.tf"},
+ DisableApply: true,
+ ApplyLock: false,
+ ExpAutoplan: true,
+ Comments: []string{
+ "atlantis apply",
+ },
+ ExpReplies: [][]string{
+ {"exp-output-autoplan.txt"},
+ {"exp-output-apply-locked.txt"},
+ {"exp-output-merge.txt"},
+ },
+ },
}
for _, c := range cases {
t.Run(c.Description, func(t *testing.T) {
RegisterMockTestingT(t)
- ctrl, vcsClient, githubGetter, atlantisWorkspace := setupE2E(t, c.RepoDir, false)
+ // reset userConfig
+ userConfig = server.UserConfig{}
+ userConfig.DisableApply = c.DisableApply
+
+ ctrl, vcsClient, githubGetter, atlantisWorkspace := setupE2E(t, c.RepoDir)
// Set the repo to be cloned through the testing backdoor.
repoDir, headSHA, cleanup := initializeRepo(t, c.RepoDir)
defer cleanup()
@@ -357,6 +400,11 @@ func TestGitHubWorkflow(t *testing.T) {
ctrl.Post(w, pullOpenedReq)
responseContains(t, w, 200, "Processing...")
+ // Create global apply lock if required
+ if c.ApplyLock {
+ _, _ = applyLocker.LockApply()
+ }
+
// Now send any other comments.
for _, comment := range c.Comments {
commentReq := GitHubCommentEvent(t, comment)
@@ -489,7 +537,11 @@ func TestGitHubWorkflowWithPolicyCheck(t *testing.T) {
t.Run(c.Description, func(t *testing.T) {
RegisterMockTestingT(t)
- ctrl, vcsClient, githubGetter, atlantisWorkspace := setupE2E(t, c.RepoDir, true)
+ // reset userConfig
+ userConfig = server.UserConfig{}
+ userConfig.EnablePolicyChecksFlag = true
+
+ ctrl, vcsClient, githubGetter, atlantisWorkspace := setupE2E(t, c.RepoDir)
// Set the repo to be cloned through the testing backdoor.
repoDir, headSHA, cleanup := initializeRepo(t, c.RepoDir)
defer cleanup()
@@ -559,14 +611,14 @@ func TestGitHubWorkflowWithPolicyCheck(t *testing.T) {
}
}
-func setupE2E(t *testing.T, repoDir string, policyChecksEnabled bool) (server.EventsController, *vcsmocks.MockClient, *mocks.MockGithubPullGetter, *events.FileWorkspace) {
+func setupE2E(t *testing.T, repoDir string) (server.EventsController, *vcsmocks.MockClient, *mocks.MockGithubPullGetter, *events.FileWorkspace) {
allowForkPRs := false
dataDir, binDir, cacheDir, cleanup := mkSubDirs(t)
defer cleanup()
//env vars
- if policyChecksEnabled {
+ if userConfig.EnablePolicyChecksFlag {
// need this to be set or we'll fail the policy check step
os.Setenv(policy.DefaultConftestVersionEnvKey, "0.21.0")
}
@@ -594,6 +646,7 @@ func setupE2E(t *testing.T, repoDir string, policyChecksEnabled bool) (server.Ev
boltdb, err := db.New(dataDir)
Ok(t, err)
lockingClient := locking.NewClient(boltdb)
+ applyLocker = locking.NewApplyClient(boltdb, userConfig.DisableApply)
projectLocker := &events.DefaultProjectLocker{
Locker: lockingClient,
VCSClient: e2eVCSClient,
@@ -630,7 +683,7 @@ func setupE2E(t *testing.T, repoDir string, policyChecksEnabled bool) (server.Ev
PreWorkflowHookRunner: mockPreWorkflowHookRunner,
}
projectCommandBuilder := events.NewProjectCommandBuilder(
- policyChecksEnabled,
+ userConfig.EnablePolicyChecksFlag,
parser,
&events.DefaultProjectFinder{},
e2eVCSClient,
@@ -729,7 +782,7 @@ func setupE2E(t *testing.T, repoDir string, policyChecksEnabled bool) (server.Ev
applyCommandRunner := events.NewApplyCommandRunner(
e2eVCSClient,
false,
- false,
+ applyLocker,
e2eStatusUpdater,
projectCommandBuilder,
projectCommandRunner,
diff --git a/server/locks_controller.go b/server/locks_controller.go
index 60151b16d9..66313dce04 100644
--- a/server/locks_controller.go
+++ b/server/locks_controller.go
@@ -20,6 +20,7 @@ type LocksController struct {
AtlantisVersion string
AtlantisURL *url.URL
Locker locking.Locker
+ ApplyLocker locking.ApplyLocker
Logger *logging.SimpleLogger
VCSClient vcs.Client
LockDetailTemplate TemplateWriter
@@ -29,6 +30,30 @@ type LocksController struct {
DeleteLockCommand events.DeleteLockCommand
}
+// LockApply handles creating a global apply lock.
+// If Lock already exists it will be a no-op
+func (l *LocksController) LockApply(w http.ResponseWriter, r *http.Request) {
+ lock, err := l.ApplyLocker.LockApply()
+ if err != nil {
+ l.respond(w, logging.Error, http.StatusInternalServerError, "creating apply lock failed with: %s", err)
+ return
+ }
+
+ l.respond(w, logging.Info, http.StatusOK, "Apply Lock is acquired on %s", lock.Time.Format("2006-01-02 15:04:05"))
+}
+
+// UnlockApply handles releasing a global apply lock.
+// If Lock doesn't exists it will be a no-op
+func (l *LocksController) UnlockApply(w http.ResponseWriter, r *http.Request) {
+ err := l.ApplyLocker.UnlockApply()
+ if err != nil {
+ l.respond(w, logging.Error, http.StatusInternalServerError, "deleting apply lock failed with: %s", err)
+ return
+ }
+
+ l.respond(w, logging.Info, http.StatusOK, "Deleted apply lock")
+}
+
// GetLock is the GET /locks/{id} route. It renders the lock detail view.
func (l *LocksController) GetLock(w http.ResponseWriter, r *http.Request) {
id, ok := mux.Vars(r)["id"]
diff --git a/server/locks_controller_test.go b/server/locks_controller_test.go
index 42487afbf4..bdd0d5ae6b 100644
--- a/server/locks_controller_test.go
+++ b/server/locks_controller_test.go
@@ -3,13 +3,16 @@ package server_test
import (
"bytes"
"errors"
+ "fmt"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"testing"
+ "time"
"github.com/runatlantis/atlantis/server/events/db"
+ "github.com/runatlantis/atlantis/server/events/locking"
"github.com/gorilla/mux"
. "github.com/petergtz/pegomock"
@@ -30,6 +33,84 @@ func AnyRepo() models.Repo {
return models.Repo{}
}
+func TestCreateApplyLock(t *testing.T) {
+ t.Run("Creates apply lock", func(t *testing.T) {
+ req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil))
+ w := httptest.NewRecorder()
+
+ layout := "2006-01-02T15:04:05.000Z"
+ strLockTime := "2020-09-01T00:45:26.371Z"
+ expLockTime := "2020-09-01 00:45:26"
+ lockTime, _ := time.Parse(layout, strLockTime)
+
+ l := mocks.NewMockApplyLocker()
+ When(l.LockApply()).ThenReturn(locking.ApplyCommandLock{
+ Locked: true,
+ Time: lockTime,
+ }, nil)
+
+ lc := server.LocksController{
+ Logger: logging.NewNoopLogger(),
+ ApplyLocker: l,
+ }
+ lc.LockApply(w, req)
+
+ responseContains(t, w, http.StatusOK, fmt.Sprintf("Apply Lock is acquired on %s", expLockTime))
+ })
+
+ t.Run("Apply lock creation fails", func(t *testing.T) {
+ req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil))
+ w := httptest.NewRecorder()
+
+ l := mocks.NewMockApplyLocker()
+ When(l.LockApply()).ThenReturn(locking.ApplyCommandLock{
+ Locked: false,
+ }, errors.New("failed to acquire lock"))
+
+ lc := server.LocksController{
+ Logger: logging.NewNoopLogger(),
+ ApplyLocker: l,
+ }
+ lc.LockApply(w, req)
+
+ responseContains(t, w, http.StatusInternalServerError, fmt.Sprintf("creating apply lock failed with: failed to acquire lock"))
+ })
+}
+
+func TestUnlockApply(t *testing.T) {
+ t.Run("Apply lock deleted successfully", func(t *testing.T) {
+ req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil))
+ w := httptest.NewRecorder()
+
+ l := mocks.NewMockApplyLocker()
+ When(l.UnlockApply()).ThenReturn(nil)
+
+ lc := server.LocksController{
+ Logger: logging.NewNoopLogger(),
+ ApplyLocker: l,
+ }
+ lc.UnlockApply(w, req)
+
+ responseContains(t, w, http.StatusOK, fmt.Sprintf("Deleted apply lock"))
+ })
+
+ t.Run("Apply lock deletion failed", func(t *testing.T) {
+ req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil))
+ w := httptest.NewRecorder()
+
+ l := mocks.NewMockApplyLocker()
+ When(l.UnlockApply()).ThenReturn(errors.New("failed to delete lock"))
+
+ lc := server.LocksController{
+ Logger: logging.NewNoopLogger(),
+ ApplyLocker: l,
+ }
+ lc.UnlockApply(w, req)
+
+ responseContains(t, w, http.StatusInternalServerError, fmt.Sprintf("deleting apply lock failed with: failed to delete lock"))
+ })
+}
+
func TestGetLockRoute_NoLockID(t *testing.T) {
t.Log("If there is no lock ID in the request then we should get a 400")
req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil))
diff --git a/server/server.go b/server/server.go
index a0eb0e694c..0d2ae59a7f 100644
--- a/server/server.go
+++ b/server/server.go
@@ -84,6 +84,7 @@ type Server struct {
CommandRunner *events.DefaultCommandRunner
Logger *logging.SimpleLogger
Locker locking.Locker
+ ApplyLocker locking.ApplyLocker
EventsController *EventsController
GithubAppController *GithubAppController
LocksController *LocksController
@@ -294,10 +295,12 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) {
return nil, err
}
var lockingClient locking.Locker
+ var applyLockingClient locking.ApplyLocker
if userConfig.DisableRepoLocking {
lockingClient = locking.NewNoOpLocker()
} else {
lockingClient = locking.NewClient(boltdb)
+ applyLockingClient = locking.NewApplyClient(boltdb, userConfig.DisableApply)
}
workingDirLocker := events.NewDefaultWorkingDirLocker()
@@ -501,7 +504,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) {
applyCommandRunner := events.NewApplyCommandRunner(
vcsClient,
userConfig.DisableApplyAll,
- userConfig.DisableApply,
+ applyLockingClient,
commitStatusUpdater,
projectCommandBuilder,
projectCommandRunner,
@@ -556,6 +559,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) {
AtlantisVersion: config.AtlantisVersion,
AtlantisURL: parsedURL,
Locker: lockingClient,
+ ApplyLocker: applyLockingClient,
Logger: logger,
VCSClient: vcsClient,
LockDetailTemplate: lockTemplate,
@@ -601,6 +605,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) {
CommandRunner: commandRunner,
Logger: logger,
Locker: lockingClient,
+ ApplyLocker: applyLockingClient,
EventsController: eventsController,
GithubAppController: githubAppController,
LocksController: locksController,
@@ -624,6 +629,8 @@ func (s *Server) Start() error {
s.Router.HandleFunc("/events", s.EventsController.Post).Methods("POST")
s.Router.HandleFunc("/github-app/exchange-code", s.GithubAppController.ExchangeCode).Methods("GET")
s.Router.HandleFunc("/github-app/setup", s.GithubAppController.New).Methods("GET")
+ s.Router.HandleFunc("/apply/lock", s.LocksController.LockApply).Methods("POST").Queries()
+ s.Router.HandleFunc("/apply/unlock", s.LocksController.UnlockApply).Methods("DELETE").Queries()
s.Router.HandleFunc("/locks", s.LocksController.DeleteLock).Methods("DELETE").Queries("id", "{id:.*}")
s.Router.HandleFunc("/lock", s.LocksController.GetLock).Methods("GET").
Queries(LockViewRouteIDQueryParam, fmt.Sprintf("{%s}", LockViewRouteIDQueryParam)).Name(LockViewRouteName)
@@ -710,11 +717,25 @@ func (s *Server) Index(w http.ResponseWriter, _ *http.Request) {
})
}
+ applyCmdLock, err := s.ApplyLocker.CheckApplyLock()
+ s.Logger.Info("Apply Lock: %v", applyCmdLock)
+ if err != nil {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ fmt.Fprintf(w, "Could not retrieve global apply lock: %s", err)
+ return
+ }
+
+ applyLockData := ApplyLockData{
+ Time: applyCmdLock.Time,
+ Locked: applyCmdLock.Locked,
+ TimeFormatted: applyCmdLock.Time.Format("02-01-2006 15:04:05"),
+ }
//Sort by date - newest to oldest.
sort.SliceStable(lockResults, func(i, j int) bool { return lockResults[i].Time.After(lockResults[j].Time) })
err = s.IndexTemplate.Execute(w, IndexData{
Locks: lockResults,
+ ApplyLock: applyLockData,
AtlantisVersion: s.AtlantisVersion,
CleanedBasePath: s.AtlantisURL.Path,
})
diff --git a/server/server_test.go b/server/server_test.go
index 7be7947af2..27a34ef231 100644
--- a/server/server_test.go
+++ b/server/server_test.go
@@ -76,6 +76,7 @@ func TestIndex_Success(t *testing.T) {
t.Log("Index should render the index template successfully.")
RegisterMockTestingT(t)
l := mocks.NewMockLocker()
+ al := mocks.NewMockApplyLocker()
// These are the locks that we expect to be rendered.
now := time.Now()
locks := map[string]models.ProjectLock{
@@ -100,6 +101,7 @@ func TestIndex_Success(t *testing.T) {
Ok(t, err)
s := server.Server{
Locker: l,
+ ApplyLocker: al,
IndexTemplate: it,
Router: r,
AtlantisVersion: atlantisVersion,
@@ -109,6 +111,11 @@ func TestIndex_Success(t *testing.T) {
w := httptest.NewRecorder()
s.Index(w, req)
it.VerifyWasCalledOnce().Execute(w, server.IndexData{
+ ApplyLock: server.ApplyLockData{
+ Locked: false,
+ Time: time.Time{},
+ TimeFormatted: "01-01-0001 00:00:00",
+ },
Locks: []server.LockIndexData{
{
LockPath: "/lock?id=lkysow%252Fatlantis-example%252F.%252Fdefault",
diff --git a/server/testfixtures/test-repos/simple-yaml/exp-output-apply-locked.txt b/server/testfixtures/test-repos/simple-yaml/exp-output-apply-locked.txt
new file mode 100644
index 0000000000..9c9f52f43a
--- /dev/null
+++ b/server/testfixtures/test-repos/simple-yaml/exp-output-apply-locked.txt
@@ -0,0 +1 @@
+**Error:** Running `atlantis apply` is disabled.
diff --git a/server/web_templates.go b/server/web_templates.go
index e7ba1a852d..5aefa76dcc 100644
--- a/server/web_templates.go
+++ b/server/web_templates.go
@@ -40,9 +40,17 @@ type LockIndexData struct {
TimeFormatted string
}
+// ApplyLockData holds the fields to display in the index view
+type ApplyLockData struct {
+ Locked bool
+ Time time.Time
+ TimeFormatted string
+}
+
// IndexData holds the data for rendering the index page
type IndexData struct {
Locks []LockIndexData
+ ApplyLock ApplyLockData
AtlantisVersion string
// CleanedBasePath is the path Atlantis is accessible at externally. If
// not using a path-based proxy, this will be an empty string. Never ends
@@ -80,11 +88,23 @@ var indexTemplate = template.Must(template.New("index.html.tmpl").Parse(`
atlantis
Plan discarded and unlocked!
-