Skip to content

Commit

Permalink
feat: plan queue functionality
Browse files Browse the repository at this point in the history
  • Loading branch information
ghaiszaher committed Aug 12, 2023
1 parent a12823e commit 30eae01
Show file tree
Hide file tree
Showing 32 changed files with 1,546 additions and 311 deletions.
6 changes: 6 additions & 0 deletions cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ const (
StatsNamespace = "stats-namespace"
AllowDraftPRs = "allow-draft-prs"
PortFlag = "port"
QueueEnabled = "queue-enabled"
RedisDB = "redis-db"
RedisHost = "redis-host"
RedisPassword = "redis-password"
Expand Down Expand Up @@ -166,6 +167,7 @@ const (
DefaultRedisDB = 0
DefaultRedisPort = 6379
DefaultRedisTLSEnabled = false
DefaultQueueEnabled = false
DefaultRedisInsecureSkipVerify = false
DefaultTFDownloadURL = "https://releases.hashicorp.com"
DefaultTFDownload = true
Expand Down Expand Up @@ -482,6 +484,10 @@ var boolFlags = map[string]boolFlag{
description: "Exclude policy check comments from pull requests unless there's an actual error from conftest. This also excludes warnings.",
defaultValue: false,
},
QueueEnabled: {
description: "Enable lock queue.",
defaultValue: DefaultQueueEnabled,
},
RedisTLSEnabled: {
description: "Enable TLS on the connection to Redis with a min TLS version of 1.2",
defaultValue: DefaultRedisTLSEnabled,
Expand Down
8 changes: 8 additions & 0 deletions runatlantis.io/docs/server-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,14 @@ This is useful when you have many projects and want to keep the pull request cle
```
Exclude policy check comments from pull requests unless there's an actual error from conftest. This also excludes warnings. Defaults to `false`.

### `--queue-enabled`
```bash
atlantis server --queue-enabled
# or
ATLANTIS_QUEUE_ENABLED=true
```
Enable lock queue. Defaults to `false`.

### `--redis-host`
```bash
atlantis server --redis-host="localhost"
Expand Down
4 changes: 2 additions & 2 deletions server/controllers/api_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ func (a *APIController) Plan(w http.ResponseWriter, r *http.Request) {
a.apiReportError(w, http.StatusInternalServerError, err)
return
}
defer a.Locker.UnlockByPull(ctx.HeadRepo.FullName, 0) // nolint: errcheck
defer a.Locker.UnlockByPull(ctx.HeadRepo.FullName, 0, true) // nolint: errcheck
if result.HasErrors() {
code = http.StatusInternalServerError
}
Expand Down Expand Up @@ -121,7 +121,7 @@ func (a *APIController) Apply(w http.ResponseWriter, r *http.Request) {
a.apiReportError(w, http.StatusInternalServerError, err)
return
}
defer a.Locker.UnlockByPull(ctx.HeadRepo.FullName, 0) // nolint: errcheck
defer a.Locker.UnlockByPull(ctx.HeadRepo.FullName, 0, true) // nolint: errcheck

// We can now prepare and run the apply step
result, err := a.apiApply(request, ctx)
Expand Down
2 changes: 1 addition & 1 deletion server/controllers/events/events_controller_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1224,7 +1224,7 @@ func setupE2E(t *testing.T, repoDir string, opt setupOption) (events_controllers
}
terraformClient, err := terraform.NewClient(logger, binDir, cacheDir, "", "", "", "default-tf-version", "https://releases.hashicorp.com", &NoopTFDownloader{}, true, false, projectCmdOutputHandler)
Ok(t, err)
boltdb, err := db.New(dataDir)
boltdb, err := db.New(dataDir, false)
Ok(t, err)
backend := boltdb
lockingClient := locking.NewClient(boltdb)
Expand Down
51 changes: 50 additions & 1 deletion server/controllers/locks_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"net/http"
"net/url"
"strings"

"github.com/runatlantis/atlantis/server/controllers/templates"

Expand Down Expand Up @@ -77,6 +78,10 @@ func (l *LocksController) GetLock(w http.ResponseWriter, r *http.Request) {
return
}

// get queues locks for this lock details page
var queue models.ProjectLockQueue
queue, _ = l.Locker.GetQueueByLock(lock.Project, lock.Workspace)
lockDetailQueue := GetQueueItemIndexData(queue)
owner, repo := models.SplitRepoFullName(lock.Project.RepoFullName)
viewData := templates.LockDetailData{
LockKeyEncoded: id,
Expand All @@ -88,6 +93,7 @@ func (l *LocksController) GetLock(w http.ResponseWriter, r *http.Request) {
CleanedBasePath: l.AtlantisURL.Path,
RepoOwner: owner,
RepoName: repo,
Queue: lockDetailQueue,
}

err = l.LockDetailTemplate.Execute(w, viewData)
Expand All @@ -111,7 +117,7 @@ func (l *LocksController) DeleteLock(w http.ResponseWriter, r *http.Request) {
return
}

lock, err := l.DeleteLockCommand.DeleteLock(idUnencoded)
lock, dequeuedLock, err := l.DeleteLockCommand.DeleteLock(idUnencoded)
if err != nil {
l.respond(w, logging.Error, http.StatusInternalServerError, "deleting lock failed with: %s", err)
return
Expand All @@ -136,6 +142,10 @@ func (l *LocksController) DeleteLock(w http.ResponseWriter, r *http.Request) {
if err = l.VCSClient.CreateComment(lock.Pull.BaseRepo, lock.Pull.Num, comment, ""); err != nil {
l.Logger.Warn("failed commenting on pull request: %s", err)
}
if dequeuedLock != nil {
l.Logger.Warn("dequeued lock: %s", dequeuedLock)
l.commentOnDequeuedPullRequests(*dequeuedLock)
}
} else {
l.Logger.Debug("skipping commenting on pull request and deleting workspace because BaseRepo field is empty")
}
Expand All @@ -150,3 +160,42 @@ func (l *LocksController) respond(w http.ResponseWriter, lvl logging.LogLevel, r
w.WriteHeader(responseCode)
fmt.Fprintln(w, response)
}

func (l *LocksController) commentOnDequeuedPullRequests(dequeuedLock models.ProjectLock) {
planVcsMessage := buildCommentOnDequeuedPullRequest([]models.ProjectLock{dequeuedLock})
if commentErr := l.VCSClient.CreateComment(dequeuedLock.Pull.BaseRepo, dequeuedLock.Pull.Num, planVcsMessage, ""); commentErr != nil {
l.Logger.Err("unable to comment on PR %d: %s", dequeuedLock.Pull.Num, commentErr)
}
}

func buildCommentOnDequeuedPullRequest(projectLocks []models.ProjectLock) string {
var releasedLocksMessages []string
for _, lock := range projectLocks {
releasedLocksMessages = append(releasedLocksMessages, fmt.Sprintf("* dir: `%s` workspace: `%s`", lock.Project.Path, lock.Workspace))
}

// stick to the first User for now, if needed, create a list of unique users and mention them all
lockCreatorMention := "@" + projectLocks[0].User.Username
releasedLocksMessage := strings.Join(releasedLocksMessages, "\n")

return fmt.Sprintf("%s\nThe following locks have been aquired by this PR and can now be planned:\n%s",
lockCreatorMention, releasedLocksMessage)
}

func GetQueueItemIndexData(q models.ProjectLockQueue) []templates.QueueItemIndexData {
var queueIndexDataList []templates.QueueItemIndexData
for _, projectLock := range q {
queueIndexDataList = append(queueIndexDataList, templates.QueueItemIndexData{
LockPath: "Not yet acquired",
RepoFullName: projectLock.Project.RepoFullName,
PullNum: projectLock.Pull.Num,
Path: projectLock.Project.Path,
Workspace: projectLock.Workspace,
Time: projectLock.Time,
TimeFormatted: projectLock.Time.Format("02-01-2006 15:04:05"),
PullURL: projectLock.Pull.URL,
Author: projectLock.Pull.Author,
})
}
return queueIndexDataList
}
67 changes: 58 additions & 9 deletions server/controllers/locks_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"testing"
"time"

Expand Down Expand Up @@ -222,7 +223,7 @@ func TestDeleteLock_LockerErr(t *testing.T) {
t.Log("If there is an error retrieving the lock, a 500 is returned")
RegisterMockTestingT(t)
dlc := mocks2.NewMockDeleteLockCommand()
When(dlc.DeleteLock("id")).ThenReturn(nil, errors.New("err"))
When(dlc.DeleteLock("id")).ThenReturn(nil, nil, errors.New("err"))
lc := controllers.LocksController{
DeleteLockCommand: dlc,
Logger: logging.NewNoopLogger(t),
Expand All @@ -238,7 +239,7 @@ func TestDeleteLock_None(t *testing.T) {
t.Log("If there is no lock at that ID we get a 404")
RegisterMockTestingT(t)
dlc := mocks2.NewMockDeleteLockCommand()
When(dlc.DeleteLock("id")).ThenReturn(nil, nil)
When(dlc.DeleteLock("id")).ThenReturn(nil, nil, nil)
lc := controllers.LocksController{
DeleteLockCommand: dlc,
Logger: logging.NewNoopLogger(t),
Expand All @@ -255,7 +256,7 @@ func TestDeleteLock_OldFormat(t *testing.T) {
RegisterMockTestingT(t)
cp := vcsmocks.NewMockClient()
dlc := mocks2.NewMockDeleteLockCommand()
When(dlc.DeleteLock("id")).ThenReturn(&models.ProjectLock{}, nil)
When(dlc.DeleteLock("id")).ThenReturn(&models.ProjectLock{}, nil, nil)
lc := controllers.LocksController{
DeleteLockCommand: dlc,
Logger: logging.NewNoopLogger(t),
Expand Down Expand Up @@ -291,10 +292,10 @@ func TestDeleteLock_UpdateProjectStatus(t *testing.T) {
Path: projectPath,
RepoFullName: repoName,
},
}, nil)
}, nil, nil)
var backend locking.Backend
tmp := t.TempDir()
backend, err := db.New(tmp)
backend, err := db.New(tmp, false)
Ok(t, err)
// Seed the DB with a successful plan for that project (that is later discarded).
_, err = backend.UpdatePullWithResults(pull, []command.ProjectResult{
Expand Down Expand Up @@ -342,13 +343,13 @@ func TestDeleteLock_CommentFailed(t *testing.T) {
Pull: models.PullRequest{
BaseRepo: models.Repo{FullName: "owner/repo"},
},
}, nil)
}, nil, nil)
cp := vcsmocks.NewMockClient()
workingDir := mocks2.NewMockWorkingDir()
workingDirLocker := events.NewDefaultWorkingDirLocker()
var backend locking.Backend
tmp := t.TempDir()
backend, err := db.New(tmp)
backend, err := db.New(tmp, false)
Ok(t, err)
When(cp.CreateComment(Any[models.Repo](), Any[int](), Any[string](), Any[string]())).ThenReturn(errors.New("err"))
lc := controllers.LocksController{
Expand All @@ -375,7 +376,7 @@ func TestDeleteLock_CommentSuccess(t *testing.T) {
workingDirLocker := events.NewDefaultWorkingDirLocker()
var backend locking.Backend
tmp := t.TempDir()
backend, err := db.New(tmp)
backend, err := db.New(tmp, false)
Ok(t, err)
pull := models.PullRequest{
BaseRepo: models.Repo{FullName: "owner/repo"},
Expand All @@ -387,7 +388,7 @@ func TestDeleteLock_CommentSuccess(t *testing.T) {
Path: "path",
RepoFullName: "owner/repo",
},
}, nil)
}, nil, nil)
lc := controllers.LocksController{
DeleteLockCommand: dlc,
Logger: logging.NewNoopLogger(t),
Expand All @@ -405,3 +406,51 @@ func TestDeleteLock_CommentSuccess(t *testing.T) {
"**Warning**: The plan for dir: `path` workspace: `workspace` was **discarded** via the Atlantis UI.\n\n"+
"To `apply` this plan you must run `plan` again.", "")
}

func TestQueueItemIndexData(t *testing.T) {
layout := "2006-01-02T15:04:05.000Z"
strLockTime := "2020-09-01T00:45:26.371Z"
lockTime, _ := time.Parse(layout, strLockTime)
tests := []struct {
name string
queue models.ProjectLockQueue
want []templates.QueueItemIndexData
}{
{
name: "empty list",
queue: models.ProjectLockQueue{},
want: nil,
},
{
name: "list with one item",
queue: models.ProjectLockQueue{
{
Project: models.Project{RepoFullName: "org/repo", Path: "path"},
Workspace: "workspace",
Time: lockTime,
Pull: models.PullRequest{Num: 15, Author: "pull-author", URL: "org/repo/pull/15"},
},
},
want: []templates.QueueItemIndexData{
{
LockPath: "Not yet acquired",
RepoFullName: "org/repo",
PullNum: 15,
Path: "path",
Workspace: "workspace",
Time: lockTime,
TimeFormatted: "01-09-2020 00:45:26",
PullURL: "org/repo/pull/15",
Author: "pull-author",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := controllers.GetQueueItemIndexData(tt.queue); !reflect.DeepEqual(got, tt.want) {
t.Errorf("GetQueueItemIndexData() = %v, want %v", got, tt.want)
}
})
}
}
30 changes: 30 additions & 0 deletions server/controllers/templates/web_templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,19 @@ type LockIndexData struct {
LockedBy string
Time time.Time
TimeFormatted string
Queue []QueueItemIndexData
}

type QueueItemIndexData struct {
LockPath string
RepoFullName string
PullNum int
Path string
Workspace string
Time time.Time
TimeFormatted string
PullURL string
Author string
}

// ApplyLockData holds the fields to display in the index view
Expand Down Expand Up @@ -123,6 +136,7 @@ var IndexTemplate = template.Must(template.New("index.html.tmpl").Parse(`
<span>Locked By</span>
<span>Date/Time</span>
<span>Status</span>
<span>Queue</span>
</div>
{{ range .Locks }}
<div class="lock-row">
Expand All @@ -144,6 +158,9 @@ var IndexTemplate = template.Must(template.New("index.html.tmpl").Parse(`
<a class="lock-link" tabindex="-1" href="{{ $basePath }}{{.LockPath}}">
<span><code>Locked</code></span>
</a>
<a class="lock-link" tabindex="-1" href="{{ $basePath }}{{.LockPath}}">
{{ len .Queue }}
</a>
</div>
{{ end }}
</div>
Expand Down Expand Up @@ -275,6 +292,7 @@ type LockDetailData struct {
// not using a path-based proxy, this will be an empty string. Never ends
// in a '/' (hence "cleaned").
CleanedBasePath string
Queue []QueueItemIndexData
}

var LockTemplate = template.Must(template.New("lock.html.tmpl").Parse(`
Expand Down Expand Up @@ -308,6 +326,18 @@ var LockTemplate = template.Must(template.New("lock.html.tmpl").Parse(`
<div><strong>Pull Request Link:</strong></div><div><a href="{{.PullRequestLink}}" target="_blank">{{.PullRequestLink}}</a></div>
<div><strong>Locked By:</strong></div><div>{{.LockedBy}}</div>
<div><strong>Workspace:</strong></div><div>{{.Workspace}}</div>
{{ if .Queue }}
<div><strong>Queue:</strong></div>
<div>
{{ range .Queue }}
<div class="lock-detail-grid">
<div><strong>Pull Request Link:</strong></div><div><a href="{{.PullURL}}">{{.PullURL}}</a></div>
<div><strong>Author</strong></div><div>{{.Author}}</div>
<div><strong>Time:</strong></div><div>{{.TimeFormatted}}</div>
</div>
{{ end }}
</div>
{{ end }}
</div>
<br>
<a class="button button-primary" id="discardPlanUnlock">Discard Plan & Unlock</a>
Expand Down
Loading

0 comments on commit 30eae01

Please sign in to comment.