mirror of
https://git.vectorsigma.ru/public/atlantis.git
synced 2026-07-28 20:28:22 +00:00
Refactoring
* set version in one place * rename ExecutionContext to CommandContext * delete unneeded PullRequestContext since it just duplicated what is in CommandContext * split fields inside CommandContext into Repo, PullRequest, and User models * clean up unused fields * remove base_executor stuff which was trying to do inheritance in go and didn't get us anywhere * clean up unused code and assets * created a locking.Client that creates the Keys that are used on the front-end. This allows the backends to store the locks any way they like as long as they support the interface
This commit is contained in:
@@ -36,7 +36,7 @@ var stringFlags = []stringFlag{
|
|||||||
{
|
{
|
||||||
name: ghHostnameFlag,
|
name: ghHostnameFlag,
|
||||||
description: "Hostname of Github installation.",
|
description: "Hostname of Github installation.",
|
||||||
value: "api.github.com",
|
value: "api.github.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: logLevelFlag,
|
name: logLevelFlag,
|
||||||
|
|||||||
@@ -3,15 +3,14 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
const version = "0.1.0"
|
|
||||||
|
|
||||||
var versionCmd = &cobra.Command{
|
var versionCmd = &cobra.Command{
|
||||||
Use: "version",
|
Use: "version",
|
||||||
Short: "Print the current atlantis version",
|
Short: "Print the current atlantis version",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
fmt.Printf("atlantis %s\n", version)
|
fmt.Printf("atlantis %s\n", viper.Get("version"))
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
package boltdb
|
package boltdb
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/boltdb/bolt"
|
|
||||||
"fmt"
|
|
||||||
"encoding/json"
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"github.com/boltdb/bolt"
|
||||||
|
"github.com/hootsuite/atlantis/models"
|
||||||
|
"github.com/pkg/errors"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
|
||||||
"path"
|
"path"
|
||||||
"github.com/hootsuite/atlantis/locking"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Backend struct {
|
type Backend struct {
|
||||||
db *bolt.DB
|
db *bolt.DB
|
||||||
bucket []byte
|
bucket []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,14 +21,14 @@ const bucketName = "runLocks"
|
|||||||
|
|
||||||
func New(dataDir string) (*Backend, error) {
|
func New(dataDir string) (*Backend, error) {
|
||||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||||
return nil, errors.Wrap(err,"creating data dir")
|
return nil, errors.Wrap(err, "creating data dir")
|
||||||
}
|
}
|
||||||
db, err := bolt.Open(path.Join(dataDir, "atlantis.db"), 0600, &bolt.Options{Timeout: 1 * time.Second})
|
db, err := bolt.Open(path.Join(dataDir, "atlantis.db"), 0600, &bolt.Options{Timeout: 1 * time.Second})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err.Error() == "timeout" {
|
if err.Error() == "timeout" {
|
||||||
return nil, errors.New("starting BoltDB: timeout (a possible cause is another Atlantis instance already running)")
|
return nil, errors.New("starting BoltDB: timeout (a possible cause is another Atlantis instance already running)")
|
||||||
}
|
}
|
||||||
return nil, errors.Wrap(err,"starting BoltDB")
|
return nil, errors.Wrap(err, "starting BoltDB")
|
||||||
}
|
}
|
||||||
err = db.Update(func(tx *bolt.Tx) error {
|
err = db.Update(func(tx *bolt.Tx) error {
|
||||||
if _, err := tx.CreateBucketIfNotExists([]byte(bucketName)); err != nil {
|
if _, err := tx.CreateBucketIfNotExists([]byte(bucketName)); err != nil {
|
||||||
@@ -37,7 +37,7 @@ func New(dataDir string) (*Backend, error) {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err,"starting BoltDB")
|
return nil, errors.Wrap(err, "starting BoltDB")
|
||||||
}
|
}
|
||||||
// todo: close BoltDB when server is sigtermed
|
// todo: close BoltDB when server is sigtermed
|
||||||
return &Backend{db, []byte(bucketName)}, nil
|
return &Backend{db, []byte(bucketName)}, nil
|
||||||
@@ -48,104 +48,111 @@ func NewWithDB(db *bolt.DB, bucket string) (*Backend, error) {
|
|||||||
return &Backend{db, []byte(bucket)}, nil
|
return &Backend{db, []byte(bucket)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b Backend) TryLock(run locking.Run) (locking.TryLockResponse, error) {
|
func (b *Backend) TryLock(project models.Project, env string, pullNum int) (bool, int, error) {
|
||||||
var response locking.TryLockResponse
|
// return variables
|
||||||
newRunSerialized, _ := json.Marshal(run)
|
var lockAcquired bool
|
||||||
lockID := run.StateKey()
|
var lockingPullNum int
|
||||||
|
key := b.key(project, env)
|
||||||
|
newLock := models.ProjectLock{
|
||||||
|
PullNum: pullNum,
|
||||||
|
Project: project,
|
||||||
|
Time: time.Now(),
|
||||||
|
Env: env,
|
||||||
|
}
|
||||||
|
newLockSerialized, _ := json.Marshal(newLock)
|
||||||
transactionErr := b.db.Update(func(tx *bolt.Tx) error {
|
transactionErr := b.db.Update(func(tx *bolt.Tx) error {
|
||||||
bucket := tx.Bucket(b.bucket)
|
bucket := tx.Bucket(b.bucket)
|
||||||
|
|
||||||
// if there is no run at that key then we're free to create the lock
|
// if there is no run at that key then we're free to create the lock
|
||||||
lockingRunSerialized := bucket.Get([]byte(lockID))
|
currLockSerialized := bucket.Get([]byte(key))
|
||||||
if lockingRunSerialized == nil {
|
if currLockSerialized == nil {
|
||||||
bucket.Put([]byte(lockID), newRunSerialized) // not a readonly bucketName so okay to ignore error
|
bucket.Put([]byte(key), newLockSerialized) // not a readonly bucketName so okay to ignore error
|
||||||
response = locking.TryLockResponse{
|
lockAcquired = true
|
||||||
LockAcquired: true,
|
lockingPullNum = pullNum
|
||||||
LockingRun: run,
|
|
||||||
LockID: lockID,
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// otherwise the lock fails, return to caller the run that's holding the lock
|
// otherwise the lock fails, return to caller the run that's holding the lock
|
||||||
var lockingRun locking.Run
|
var currLock models.ProjectLock
|
||||||
if err := b.deserialize(lockingRunSerialized, &lockingRun); err != nil {
|
if err := json.Unmarshal(currLockSerialized, &currLock); err != nil {
|
||||||
return errors.Wrap(err, "failed to deserialize run")
|
return errors.Wrap(err, "failed to deserialize current lock")
|
||||||
}
|
|
||||||
response = locking.TryLockResponse{
|
|
||||||
LockAcquired: false,
|
|
||||||
LockingRun: lockingRun,
|
|
||||||
LockID: lockID,
|
|
||||||
}
|
}
|
||||||
|
lockAcquired = false
|
||||||
|
lockingPullNum = currLock.PullNum
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
if transactionErr != nil {
|
if transactionErr != nil {
|
||||||
return response, errors.Wrap(transactionErr, "DB transaction failed")
|
return false, lockingPullNum, errors.Wrap(transactionErr, "DB transaction failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
return response, nil
|
return lockAcquired, lockingPullNum, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b Backend) Unlock(lockID string) error {
|
func (b Backend) Unlock(p models.Project, env string) error {
|
||||||
|
key := b.key(p, env)
|
||||||
err := b.db.Update(func(tx *bolt.Tx) error {
|
err := b.db.Update(func(tx *bolt.Tx) error {
|
||||||
locks := tx.Bucket(b.bucket)
|
locks := tx.Bucket(b.bucket)
|
||||||
return locks.Delete([]byte(lockID))
|
return locks.Delete([]byte(key))
|
||||||
})
|
})
|
||||||
return errors.Wrap(err, "DB transaction failed")
|
return errors.Wrap(err, "DB transaction failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b Backend) ListLocks() (map[string]locking.Run, error) {
|
func (b Backend) List() ([]models.ProjectLock, error) {
|
||||||
m := make(map[string]locking.Run)
|
var locks []models.ProjectLock
|
||||||
bytes := make(map[string][]byte)
|
var locksBytes [][]byte
|
||||||
|
|
||||||
err := b.db.View(func(tx *bolt.Tx) error {
|
err := b.db.View(func(tx *bolt.Tx) error {
|
||||||
bucket := tx.Bucket(b.bucket)
|
bucket := tx.Bucket(b.bucket)
|
||||||
c := bucket.Cursor()
|
c := bucket.Cursor()
|
||||||
for k, v := c.First(); k != nil; k, v = c.Next() {
|
for k, v := c.First(); k != nil; k, v = c.Next() {
|
||||||
bytes[string(k)] = v
|
locksBytes = append(locksBytes, v)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return m, errors.Wrap(err, "DB transaction failed")
|
return locks, errors.Wrap(err, "DB transaction failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
// deserialize bytes into the proper objects
|
// deserialize bytes into the proper objects
|
||||||
for k, v := range bytes {
|
for k, v := range locksBytes {
|
||||||
var run locking.Run
|
var lock models.ProjectLock
|
||||||
if err := b.deserialize(v, &run); err != nil {
|
if err := json.Unmarshal(v, &lock); err != nil {
|
||||||
return m, errors.Wrap(err, fmt.Sprintf("failed to deserialize run at key %q", string(k)))
|
return locks, errors.Wrap(err, fmt.Sprintf("failed to deserialize lock at key %q", string(k)))
|
||||||
}
|
}
|
||||||
m[k] = run
|
locks = append(locks, lock)
|
||||||
}
|
}
|
||||||
|
|
||||||
return m, nil
|
return locks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b Backend) FindLocksForPull(repoFullName string, pullNum int) ([]string, error) {
|
func (b Backend) UnlockByPull(repoFullName string, pullNum int) error {
|
||||||
var ids []string
|
// get the locks that match that pull request
|
||||||
|
var locks []models.ProjectLock
|
||||||
err := b.db.View(func(tx *bolt.Tx) error {
|
err := b.db.View(func(tx *bolt.Tx) error {
|
||||||
c := tx.Bucket(b.bucket).Cursor()
|
c := tx.Bucket(b.bucket).Cursor()
|
||||||
|
|
||||||
// the key for each lock is repoFullName/path/env so we can scan through all entries
|
// we can use the repoFullName as a prefix search since that's the first part of the key
|
||||||
// and get the locks for that repo. Then we can check if the lock is for the right pull
|
for k, v := c.Seek([]byte(repoFullName)); k != nil && bytes.HasPrefix(k, []byte(repoFullName)); k, v = c.Next() {
|
||||||
prefix := []byte(repoFullName + "/")
|
var lock models.ProjectLock
|
||||||
for k, v := c.Seek(prefix); k != nil && bytes.HasPrefix(k, prefix); k, v = c.Next() {
|
if err := json.Unmarshal(v, &lock); err != nil {
|
||||||
var run locking.Run
|
return errors.Wrapf(err, "failed to deserialize lock at key %q", string(k))
|
||||||
if err := b.deserialize(v, &run); err != nil {
|
|
||||||
return errors.Wrapf(err, "failed to deserialize run at key %q", string(k))
|
|
||||||
}
|
}
|
||||||
if run.PullNum == pullNum {
|
if lock.PullNum == pullNum {
|
||||||
ids = append(ids, string(k))
|
locks = append(locks, lock)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
return ids, err
|
|
||||||
|
// delete the locks
|
||||||
|
for _, lock := range locks {
|
||||||
|
if err = b.Unlock(lock.Project, lock.Env); err != nil {
|
||||||
|
return errors.Wrapf(err, "unlocking repo %s, path %s, env %s", lock.Project.RepoFullName, lock.Project.Path, lock.Env)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b Backend) deserialize(bs []byte, run *locking.Run) error {
|
func (b Backend) key(p models.Project, env string) string {
|
||||||
return json.Unmarshal(bs, run)
|
return fmt.Sprintf("%s/%s/%s", p.RepoFullName, p.Path, env)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,28 @@
|
|||||||
package boltdb_test
|
package boltdb_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/hootsuite/atlantis/locking"
|
|
||||||
. "github.com/hootsuite/atlantis/testing_util"
|
. "github.com/hootsuite/atlantis/testing_util"
|
||||||
|
|
||||||
"github.com/boltdb/bolt"
|
"github.com/boltdb/bolt"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"io/ioutil"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
"io/ioutil"
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
|
|
||||||
"time"
|
|
||||||
"github.com/hootsuite/atlantis/locking/boltdb"
|
"github.com/hootsuite/atlantis/locking/boltdb"
|
||||||
|
"github.com/hootsuite/atlantis/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
var lockBucket = "bucket"
|
var lockBucket = "bucket"
|
||||||
var run = locking.Run{
|
var project = models.NewProject("owner/repo", "parent/child")
|
||||||
RepoFullName: "owner/repo",
|
var env = "default"
|
||||||
Path: "parent/child",
|
var pullNum = 1
|
||||||
Env: "default",
|
|
||||||
PullNum: 1,
|
|
||||||
User: "user",
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListNoLocks(t *testing.T) {
|
func TestListNoLocks(t *testing.T) {
|
||||||
t.Log("listing locks when there are none should return an empty list")
|
t.Log("listing locks when there are none should return an empty list")
|
||||||
db, b := newTestDB()
|
db, b := newTestDB()
|
||||||
defer cleanupDB(db)
|
defer cleanupDB(db)
|
||||||
ls, err := b.ListLocks()
|
ls, err := b.List()
|
||||||
Ok(t, err)
|
Ok(t, err)
|
||||||
Equals(t, 0, len(ls))
|
Equals(t, 0, len(ls))
|
||||||
}
|
}
|
||||||
@@ -37,12 +31,11 @@ func TestListOneLock(t *testing.T) {
|
|||||||
t.Log("listing locks when there is one should return it")
|
t.Log("listing locks when there is one should return it")
|
||||||
db, b := newTestDB()
|
db, b := newTestDB()
|
||||||
defer cleanupDB(db)
|
defer cleanupDB(db)
|
||||||
_, err := b.TryLock(run)
|
_, _, err := b.TryLock(project, env, pullNum)
|
||||||
Ok(t, err)
|
Ok(t, err)
|
||||||
ls, err := b.ListLocks()
|
ls, err := b.List()
|
||||||
Ok(t, err)
|
Ok(t, err)
|
||||||
Equals(t, 1, len(ls))
|
Equals(t, 1, len(ls))
|
||||||
Equals(t, run, ls[run.StateKey()])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListMultipleLocks(t *testing.T) {
|
func TestListMultipleLocks(t *testing.T) {
|
||||||
@@ -51,42 +44,40 @@ func TestListMultipleLocks(t *testing.T) {
|
|||||||
defer cleanupDB(db)
|
defer cleanupDB(db)
|
||||||
|
|
||||||
// add multiple locks
|
// add multiple locks
|
||||||
_, err := b.TryLock(run)
|
repos := []string{
|
||||||
Ok(t, err)
|
"owner/repo1",
|
||||||
|
"owner/repo2",
|
||||||
|
"owner/repo3",
|
||||||
|
"owner/repo4",
|
||||||
|
}
|
||||||
|
|
||||||
run2 := run
|
for _, r := range repos {
|
||||||
run2.RepoFullName = "different"
|
_, _, err := b.TryLock(models.NewProject(r, "path"), env, pullNum)
|
||||||
_, err = b.TryLock(run2)
|
Ok(t, err)
|
||||||
Ok(t, err)
|
}
|
||||||
|
ls, err := b.List()
|
||||||
run3 := run
|
|
||||||
run3.RepoFullName = "different again"
|
|
||||||
_, err = b.TryLock(run3)
|
|
||||||
Ok(t, err)
|
|
||||||
|
|
||||||
run4 := run
|
|
||||||
run4.RepoFullName = "different again again"
|
|
||||||
_, err = b.TryLock(run4)
|
|
||||||
Ok(t, err)
|
|
||||||
|
|
||||||
ls, err := b.ListLocks()
|
|
||||||
Ok(t, err)
|
Ok(t, err)
|
||||||
Equals(t, 4, len(ls))
|
Equals(t, 4, len(ls))
|
||||||
Equals(t, run, ls[run.StateKey()])
|
for _, r := range repos {
|
||||||
Equals(t, run2, ls[run2.StateKey()])
|
found := false
|
||||||
Equals(t, run3, ls[run3.StateKey()])
|
for _, l := range ls {
|
||||||
Equals(t, run4, ls[run4.StateKey()])
|
if l.Project.RepoFullName == r {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Assert(t, found == true, "expected %s in %v", r, ls)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListAddRemove(t *testing.T) {
|
func TestListAddRemove(t *testing.T) {
|
||||||
t.Log("listing after adding and removing should return none")
|
t.Log("listing after adding and removing should return none")
|
||||||
db, b := newTestDB()
|
db, b := newTestDB()
|
||||||
defer cleanupDB(db)
|
defer cleanupDB(db)
|
||||||
_, err := b.TryLock(run)
|
_, _, err := b.TryLock(project, env, pullNum)
|
||||||
Ok(t, err)
|
Ok(t, err)
|
||||||
b.Unlock(run.StateKey())
|
b.Unlock(project, env)
|
||||||
|
|
||||||
ls, err := b.ListLocks()
|
ls, err := b.List()
|
||||||
Ok(t, err)
|
Ok(t, err)
|
||||||
Equals(t, 0, len(ls))
|
Equals(t, 0, len(ls))
|
||||||
}
|
}
|
||||||
@@ -95,62 +86,50 @@ func TestLockingNoLocks(t *testing.T) {
|
|||||||
t.Log("with no locks yet, lock should succeed")
|
t.Log("with no locks yet, lock should succeed")
|
||||||
db, b := newTestDB()
|
db, b := newTestDB()
|
||||||
defer cleanupDB(db)
|
defer cleanupDB(db)
|
||||||
r, err := b.TryLock(run)
|
acquired, lockingPullNum, err := b.TryLock(project, env, pullNum)
|
||||||
Ok(t, err)
|
Ok(t, err)
|
||||||
Equals(t, true, r.LockAcquired)
|
Equals(t, true, acquired)
|
||||||
Equals(t, run, r.LockingRun)
|
Equals(t, pullNum, lockingPullNum)
|
||||||
Equals(t, run.StateKey(), r.LockID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLockingExistingLock(t *testing.T) {
|
func TestLockingExistingLock(t *testing.T) {
|
||||||
t.Log("if there is an existing lock, lock should...")
|
t.Log("if there is an existing lock, lock should...")
|
||||||
db, b := newTestDB()
|
db, b := newTestDB()
|
||||||
defer cleanupDB(db)
|
defer cleanupDB(db)
|
||||||
_, err := b.TryLock(run)
|
_, _, err := b.TryLock(project, env, pullNum)
|
||||||
Ok(t, err)
|
Ok(t, err)
|
||||||
|
|
||||||
t.Log("...succeed if the new run has a different path")
|
t.Log("...succeed if the new project has a different path")
|
||||||
{
|
{
|
||||||
new := run
|
acquired, lockingPull, err := b.TryLock(models.NewProject(project.RepoFullName, "different/path"), env, pullNum)
|
||||||
new.Path = "different/path"
|
|
||||||
r, err := b.TryLock(new)
|
|
||||||
Ok(t, err)
|
Ok(t, err)
|
||||||
Equals(t, true, r.LockAcquired)
|
Equals(t, true, acquired)
|
||||||
Equals(t, new, r.LockingRun)
|
Equals(t, pullNum, lockingPull)
|
||||||
Equals(t, new.StateKey(), r.LockID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Log("...succeed if the new run has a different environment")
|
t.Log("...succeed if the new project has a different environment")
|
||||||
{
|
{
|
||||||
new := run
|
acquired, lockingPull, err := b.TryLock(project, "different-env", pullNum)
|
||||||
new.Env = "different-env"
|
|
||||||
r, err := b.TryLock(new)
|
|
||||||
Ok(t, err)
|
Ok(t, err)
|
||||||
Equals(t, true, r.LockAcquired)
|
Equals(t, true, acquired)
|
||||||
Equals(t, new, r.LockingRun)
|
Equals(t, pullNum, lockingPull)
|
||||||
Equals(t, new.StateKey(), r.LockID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Log("...succeed if the new run has a different repoName")
|
t.Log("...succeed if the new project has a different repoName")
|
||||||
{
|
{
|
||||||
new := run
|
acquired, lockingPull, err := b.TryLock(models.NewProject("different/repo", project.Path), env, pullNum)
|
||||||
new.RepoFullName = "new/repo"
|
|
||||||
r, err := b.TryLock(new)
|
|
||||||
Ok(t, err)
|
Ok(t, err)
|
||||||
Equals(t, true, r.LockAcquired)
|
Equals(t, true, acquired)
|
||||||
Equals(t, new, r.LockingRun)
|
Equals(t, pullNum, lockingPull)
|
||||||
Equals(t, new.StateKey(), r.LockID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Log("...not succeed if the new run only has a different pullNum")
|
t.Log("...not succeed if the new project only has a different pullNum")
|
||||||
{
|
{
|
||||||
new := run
|
newPull := pullNum + 1
|
||||||
new.PullNum = run.PullNum + 1
|
acquired, lockingPull, err := b.TryLock(project, env, newPull)
|
||||||
r, err := b.TryLock(new)
|
|
||||||
Ok(t, err)
|
Ok(t, err)
|
||||||
Equals(t, false, r.LockAcquired)
|
Equals(t, false, acquired)
|
||||||
Equals(t, run, r.LockingRun)
|
Equals(t, lockingPull, pullNum)
|
||||||
Equals(t, run.StateKey(), r.LockID)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +138,7 @@ func TestUnlockingNoLocks(t *testing.T) {
|
|||||||
db, b := newTestDB()
|
db, b := newTestDB()
|
||||||
defer cleanupDB(db)
|
defer cleanupDB(db)
|
||||||
|
|
||||||
Ok(t, b.Unlock("any-lock-id"))
|
Ok(t, b.Unlock(project, env))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUnlocking(t *testing.T) {
|
func TestUnlocking(t *testing.T) {
|
||||||
@@ -167,20 +146,20 @@ func TestUnlocking(t *testing.T) {
|
|||||||
db, b := newTestDB()
|
db, b := newTestDB()
|
||||||
defer cleanupDB(db)
|
defer cleanupDB(db)
|
||||||
|
|
||||||
b.TryLock(run)
|
b.TryLock(project, env, pullNum)
|
||||||
Ok(t, b.Unlock(run.StateKey()))
|
Ok(t, b.Unlock(project, env))
|
||||||
|
|
||||||
// should be no locks listed
|
// should be no locks listed
|
||||||
ls, err := b.ListLocks()
|
ls, err := b.List()
|
||||||
Ok(t, err)
|
Ok(t, err)
|
||||||
Equals(t, 0, len(ls))
|
Equals(t, 0, len(ls))
|
||||||
|
|
||||||
// should be able to re-lock that repo with a new pull num
|
// should be able to re-lock that repo with a new pull num
|
||||||
new := run
|
newPull := pullNum + 1
|
||||||
new.PullNum = run.PullNum + 1
|
acquired, lockingPull, err := b.TryLock(project, env, newPull)
|
||||||
r, err := b.TryLock(new)
|
|
||||||
Ok(t, err)
|
Ok(t, err)
|
||||||
Equals(t, true, r.LockAcquired)
|
Equals(t, true, acquired)
|
||||||
|
Equals(t, newPull, lockingPull)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUnlockingMultiple(t *testing.T) {
|
func TestUnlockingMultiple(t *testing.T) {
|
||||||
@@ -188,107 +167,114 @@ func TestUnlockingMultiple(t *testing.T) {
|
|||||||
db, b := newTestDB()
|
db, b := newTestDB()
|
||||||
defer cleanupDB(db)
|
defer cleanupDB(db)
|
||||||
|
|
||||||
b.TryLock(run)
|
b.TryLock(project, env, pullNum)
|
||||||
|
|
||||||
new := run
|
new := project
|
||||||
new.RepoFullName = "new/repo"
|
new.RepoFullName = "new/repo"
|
||||||
b.TryLock(new)
|
b.TryLock(project, env, pullNum)
|
||||||
|
|
||||||
new2 := run
|
new2 := project
|
||||||
new2.Path = "new/path"
|
new2.Path = "new/path"
|
||||||
b.TryLock(new2)
|
b.TryLock(project, env, pullNum)
|
||||||
|
|
||||||
new3 := run
|
new3Env := "new-env"
|
||||||
new3.Env = "new/env"
|
b.TryLock(project, new3Env, pullNum)
|
||||||
b.TryLock(new3)
|
|
||||||
|
|
||||||
// now try and unlock them
|
// now try and unlock them
|
||||||
Ok(t, b.Unlock(new3.StateKey()))
|
Ok(t, b.Unlock(project, new3Env))
|
||||||
Ok(t, b.Unlock(new2.StateKey()))
|
Ok(t, b.Unlock(new2, env))
|
||||||
Ok(t, b.Unlock(new.StateKey()))
|
Ok(t, b.Unlock(new, env))
|
||||||
Ok(t, b.Unlock(run.StateKey()))
|
Ok(t, b.Unlock(project, env))
|
||||||
|
|
||||||
// should be none left
|
// should be none left
|
||||||
ls, err := b.ListLocks()
|
ls, err := b.List()
|
||||||
Ok(t, err)
|
Ok(t, err)
|
||||||
Equals(t, 0, len(ls))
|
Equals(t, 0, len(ls))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFindLocksNone(t *testing.T) {
|
func TestUnlockByPullNone(t *testing.T) {
|
||||||
t.Log("find should return no locks when there are none")
|
t.Log("UnlockByPull should be successful when there are no locks")
|
||||||
db, b := newTestDB()
|
db, b := newTestDB()
|
||||||
defer cleanupDB(db)
|
defer cleanupDB(db)
|
||||||
|
|
||||||
ls, err := b.FindLocksForPull("any/repo", 1)
|
err := b.UnlockByPull("any/repo", 1)
|
||||||
Ok(t, err)
|
Ok(t, err)
|
||||||
Equals(t, 0, len(ls))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFindLocksOne(t *testing.T) {
|
func TestUnlockByPullOne(t *testing.T) {
|
||||||
t.Log("with one lock find should...")
|
t.Log("with one lock, UnlockByPull should...")
|
||||||
db, b := newTestDB()
|
db, b := newTestDB()
|
||||||
defer cleanupDB(db)
|
defer cleanupDB(db)
|
||||||
_, err := b.TryLock(run)
|
_, _, err := b.TryLock(project, env, pullNum)
|
||||||
Ok(t, err)
|
Ok(t, err)
|
||||||
|
|
||||||
t.Log("...return no locks from the same repo but different pull num")
|
t.Log("...delete nothing when its the same repo but a different pull")
|
||||||
{
|
{
|
||||||
ls, err := b.FindLocksForPull(run.RepoFullName, run.PullNum + 1)
|
err := b.UnlockByPull(project.RepoFullName, pullNum+1)
|
||||||
Ok(t, err)
|
Ok(t, err)
|
||||||
Equals(t, 0, len(ls))
|
ls, err := b.List()
|
||||||
}
|
|
||||||
t.Log("...return no locks from a different repo but the same pull num")
|
|
||||||
{
|
|
||||||
ls, err := b.FindLocksForPull(run.RepoFullName + "dif", run.PullNum)
|
|
||||||
Ok(t, err)
|
|
||||||
Equals(t, 0, len(ls))
|
|
||||||
}
|
|
||||||
t.Log("...return the one lock when called with that repo and pull num")
|
|
||||||
{
|
|
||||||
ls, err := b.FindLocksForPull(run.RepoFullName, run.PullNum)
|
|
||||||
Ok(t, err)
|
Ok(t, err)
|
||||||
Equals(t, 1, len(ls))
|
Equals(t, 1, len(ls))
|
||||||
Equals(t, run.StateKey(), ls[0])
|
}
|
||||||
|
t.Log("...delete nothing when its the same pull but a different repo")
|
||||||
|
{
|
||||||
|
err := b.UnlockByPull("different/repo", pullNum)
|
||||||
|
Ok(t, err)
|
||||||
|
ls, err := b.List()
|
||||||
|
Ok(t, err)
|
||||||
|
Equals(t, 1, len(ls))
|
||||||
|
}
|
||||||
|
t.Log("...delete the lock when its the same repo and pull")
|
||||||
|
{
|
||||||
|
err := b.UnlockByPull(project.RepoFullName, pullNum)
|
||||||
|
Ok(t, err)
|
||||||
|
ls, err := b.List()
|
||||||
|
Ok(t, err)
|
||||||
|
Equals(t, 0, len(ls))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFindLocksAfterUnlock(t *testing.T) {
|
func TestUnlockByPullAfterUnlock(t *testing.T) {
|
||||||
t.Log("after locking and unlocking find should return no locks")
|
t.Log("after locking and unlocking, UnlockByPull should be successful")
|
||||||
db, b := newTestDB()
|
db, b := newTestDB()
|
||||||
defer cleanupDB(db)
|
defer cleanupDB(db)
|
||||||
_, err := b.TryLock(run)
|
_, _, err := b.TryLock(project, env, pullNum)
|
||||||
Ok(t, err)
|
Ok(t, err)
|
||||||
Ok(t, b.Unlock(run.StateKey()))
|
Ok(t, b.Unlock(project, env))
|
||||||
|
|
||||||
ls, err := b.FindLocksForPull(run.RepoFullName, run.PullNum)
|
err = b.UnlockByPull(project.RepoFullName, pullNum)
|
||||||
|
Ok(t, err)
|
||||||
|
ls, err := b.List()
|
||||||
Ok(t, err)
|
Ok(t, err)
|
||||||
Equals(t, 0, len(ls))
|
Equals(t, 0, len(ls))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFindMultipleMatching(t *testing.T) {
|
func TestUnlockByPullMatching(t *testing.T) {
|
||||||
t.Log("find should return all matching lock ids")
|
t.Log("UnlockByPull should delete all locks in that repo and pull num")
|
||||||
db, b := newTestDB()
|
db, b := newTestDB()
|
||||||
defer cleanupDB(db)
|
defer cleanupDB(db)
|
||||||
_, err := b.TryLock(run)
|
_, _, err := b.TryLock(project, env, pullNum)
|
||||||
Ok(t, err)
|
Ok(t, err)
|
||||||
|
|
||||||
// add additional locks with the same repo and pull num but different paths/envs
|
// add additional locks with the same repo and pull num but different paths/envs
|
||||||
new := run
|
new := project
|
||||||
new.Path = "dif/path"
|
new.Path = "dif/path"
|
||||||
_, err = b.TryLock(new)
|
_, _, err = b.TryLock(new, env, pullNum)
|
||||||
Ok(t, err)
|
Ok(t, err)
|
||||||
new2 := run
|
_, _, err = b.TryLock(project, "new-env", pullNum)
|
||||||
new2.Env = "new-env"
|
|
||||||
_, err = b.TryLock(new2)
|
|
||||||
Ok(t, err)
|
Ok(t, err)
|
||||||
|
|
||||||
// should get all of them back
|
// there should now be 3
|
||||||
ls, err := b.FindLocksForPull(run.RepoFullName, run.PullNum)
|
ls, err := b.List()
|
||||||
Ok(t, err)
|
Ok(t, err)
|
||||||
Equals(t, 3, len(ls))
|
Equals(t, 3, len(ls))
|
||||||
ContainsStr(t, run.StateKey(), ls)
|
|
||||||
ContainsStr(t, new.StateKey(), ls)
|
// should all be unlocked
|
||||||
ContainsStr(t, new2.StateKey(), ls)
|
err = b.UnlockByPull(project.RepoFullName, pullNum)
|
||||||
|
Ok(t, err)
|
||||||
|
ls, err = b.List()
|
||||||
|
Ok(t, err)
|
||||||
|
Equals(t, 0, len(ls))
|
||||||
}
|
}
|
||||||
|
|
||||||
// newTestDB returns a TestDB using a temporary path.
|
// newTestDB returns a TestDB using a temporary path.
|
||||||
@@ -322,4 +308,3 @@ func cleanupDB(db *bolt.DB) {
|
|||||||
os.Remove(db.Path())
|
os.Remove(db.Path())
|
||||||
db.Close()
|
db.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
package dynamodb
|
package dynamodb
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/aws/aws-sdk-go/service/dynamodb"
|
|
||||||
"github.com/aws/aws-sdk-go/aws/client"
|
|
||||||
"github.com/aws/aws-sdk-go/aws"
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"encoding/hex"
|
"github.com/aws/aws-sdk-go/aws"
|
||||||
"github.com/aws/aws-sdk-go/service/dynamodb/dynamodbiface"
|
"github.com/aws/aws-sdk-go/aws/client"
|
||||||
"time"
|
"github.com/aws/aws-sdk-go/service/dynamodb"
|
||||||
"github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute"
|
"github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute"
|
||||||
|
"github.com/aws/aws-sdk-go/service/dynamodb/dynamodbiface"
|
||||||
|
"github.com/hootsuite/atlantis/models"
|
||||||
|
"github.com/pkg/errors"
|
||||||
"strconv"
|
"strconv"
|
||||||
"github.com/hootsuite/atlantis/locking"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Backend struct {
|
type Backend struct {
|
||||||
@@ -20,104 +18,114 @@ type Backend struct {
|
|||||||
LockTable string
|
LockTable string
|
||||||
}
|
}
|
||||||
|
|
||||||
type dynamoRun struct {
|
// dynamoLock duplicates the fields of models.ProjectLock and adds LocksKey
|
||||||
LockID string
|
// so everything is a top-level field for serialization and then querying
|
||||||
|
// in DynamodB and also so any changes to models.ProjectLock won't affect
|
||||||
|
// how we're storing our data or will at least cause a compile error
|
||||||
|
type dynamoLock struct {
|
||||||
|
LockKey string
|
||||||
RepoFullName string
|
RepoFullName string
|
||||||
Path string
|
Path string
|
||||||
Env string
|
|
||||||
PullNum int
|
PullNum int
|
||||||
User string
|
Env string
|
||||||
Timestamp time.Time
|
Time time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(lockTable string, p client.ConfigProvider) *Backend {
|
func New(lockTable string, p client.ConfigProvider) Backend {
|
||||||
return &Backend{
|
return Backend{
|
||||||
DB: dynamodb.New(p),
|
DB: dynamodb.New(p),
|
||||||
LockTable: lockTable,
|
LockTable: lockTable,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Backend) TryLock(run locking.Run) (locking.TryLockResponse, error) {
|
func (b Backend) key(project models.Project, env string) string {
|
||||||
var r locking.TryLockResponse
|
return fmt.Sprintf("%s/%s/%s", project.RepoFullName, project.Path, env)
|
||||||
newRunSerialized, err := d.toDynamoItem(run)
|
}
|
||||||
|
|
||||||
|
func (b Backend) TryLock(project models.Project, env string, pullNum int) (bool, int, error) {
|
||||||
|
key := b.key(project, env)
|
||||||
|
newDynamoLock := dynamoLock{
|
||||||
|
LockKey: key,
|
||||||
|
RepoFullName: project.RepoFullName,
|
||||||
|
Path: project.Path,
|
||||||
|
PullNum: pullNum,
|
||||||
|
Env: env,
|
||||||
|
Time: time.Now(),
|
||||||
|
}
|
||||||
|
newLockSerialized, err := dynamodbattribute.MarshalMap(newDynamoLock)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r, errors.Wrap(err, "serializing")
|
return false, 0, errors.Wrap(err, "serializing")
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if there is an existing lock
|
// check if there is an existing lock
|
||||||
getItemParams := &dynamodb.GetItemInput{
|
getItemParams := &dynamodb.GetItemInput{
|
||||||
Key: map[string]*dynamodb.AttributeValue{
|
Key: map[string]*dynamodb.AttributeValue{
|
||||||
"LockID": {
|
"LockKey": {
|
||||||
S: aws.String(run.StateKey()),
|
S: aws.String(key),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
TableName: aws.String(d.LockTable),
|
TableName: aws.String(b.LockTable),
|
||||||
ConsistentRead: aws.Bool(true),
|
ConsistentRead: aws.Bool(true),
|
||||||
}
|
}
|
||||||
item, err := d.DB.GetItem(getItemParams)
|
item, err := b.DB.GetItem(getItemParams)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r, errors.Wrap(err, "checking if lock exists")
|
return false, 0, errors.Wrap(err, "checking if lock exists")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// if there is already a lock then we can't acquire a lock. Return the existing lock
|
// if there is already a lock then we can't acquire a lock. Return the existing lock
|
||||||
|
var currLock dynamoLock
|
||||||
if len(item.Item) != 0 {
|
if len(item.Item) != 0 {
|
||||||
var dynamoRun dynamoRun
|
if err := dynamodbattribute.UnmarshalMap(item.Item, &currLock); err != nil {
|
||||||
if err := dynamodbattribute.UnmarshalMap(item.Item, &dynamoRun); err != nil {
|
return false, 0, errors.Wrap(err, "found an existing lock at that key but it could not be deserialized. We suggest manually deleting this key from DynamoDB")
|
||||||
return r, errors.Wrap(err,"found an existing lock at that id but it could not be deserialized. We suggest manually deleting this key from DynamoDB")
|
|
||||||
}
|
}
|
||||||
lockingRun := d.fromDynamoItem(dynamoRun)
|
return false, currLock.PullNum, nil
|
||||||
return locking.TryLockResponse{
|
|
||||||
LockAcquired: false,
|
|
||||||
LockingRun: lockingRun,
|
|
||||||
LockID: run.StateKey(),
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// else we should be able to lock
|
// else we should be able to lock
|
||||||
putItem := &dynamodb.PutItemInput{
|
putItem := &dynamodb.PutItemInput{
|
||||||
Item: newRunSerialized,
|
Item: newLockSerialized,
|
||||||
TableName: aws.String(d.LockTable),
|
TableName: aws.String(b.LockTable),
|
||||||
// this will ensure that we don't insert the new item in a race situation
|
// this will ensure that we don't insert the new item in a race situation
|
||||||
// where someone has written this key just after our read
|
// where someone has written this key just after our read
|
||||||
ConditionExpression: aws.String("attribute_not_exists(LockID)"),
|
ConditionExpression: aws.String("attribute_not_exists(LockKey)"),
|
||||||
}
|
}
|
||||||
if _, err := d.DB.PutItem(putItem); err != nil {
|
if _, err := b.DB.PutItem(putItem); err != nil {
|
||||||
return r, errors.Wrap(err, "writing lock")
|
return false, 0, errors.Wrap(err, "writing lock")
|
||||||
}
|
}
|
||||||
return locking.TryLockResponse{
|
return true, pullNum, nil
|
||||||
LockAcquired: true,
|
|
||||||
LockingRun: run,
|
|
||||||
LockID: run.StateKey(),
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Backend) Unlock(lockID string) error {
|
func (b Backend) Unlock(project models.Project, env string) error {
|
||||||
|
key := b.key(project, env)
|
||||||
params := &dynamodb.DeleteItemInput{
|
params := &dynamodb.DeleteItemInput{
|
||||||
Key: map[string]*dynamodb.AttributeValue{
|
Key: map[string]*dynamodb.AttributeValue{
|
||||||
"LockID": {S: aws.String(lockID)},
|
"LockKey": {S: aws.String(key)},
|
||||||
},
|
},
|
||||||
TableName: aws.String(d.LockTable),
|
TableName: aws.String(b.LockTable),
|
||||||
}
|
}
|
||||||
_, err := d.DB.DeleteItem(params)
|
_, err := b.DB.DeleteItem(params)
|
||||||
return errors.Wrap(err, "deleting lock")
|
return errors.Wrap(err, "deleting lock")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Backend) ListLocks() (map[string]locking.Run, error) {
|
func (b Backend) List() ([]models.ProjectLock, error) {
|
||||||
params := &dynamodb.ScanInput{
|
var locks []models.ProjectLock
|
||||||
TableName: aws.String(d.LockTable),
|
|
||||||
}
|
|
||||||
|
|
||||||
m := make(map[string]locking.Run)
|
|
||||||
var err, internalErr error
|
var err, internalErr error
|
||||||
err = d.DB.ScanPages(params, func(out *dynamodb.ScanOutput, lastPage bool) bool {
|
params := &dynamodb.ScanInput{
|
||||||
var runs []dynamoRun
|
TableName: aws.String(b.LockTable),
|
||||||
if err := dynamodbattribute.UnmarshalListOfMaps(out.Items, &runs); err != nil {
|
}
|
||||||
internalErr = errors.Wrap(err,"deserializing locks")
|
err = b.DB.ScanPages(params, func(out *dynamodb.ScanOutput, lastPage bool) bool {
|
||||||
|
var dynamoLocks []dynamoLock
|
||||||
|
if err := dynamodbattribute.UnmarshalListOfMaps(out.Items, &dynamoLocks); err != nil {
|
||||||
|
internalErr = errors.Wrap(err, "deserializing locks")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for _, run := range runs {
|
for _, lock := range dynamoLocks {
|
||||||
m[run.LockID] = d.fromDynamoItem(run)
|
locks = append(locks, models.ProjectLock{
|
||||||
|
PullNum: lock.PullNum,
|
||||||
|
Project: models.NewProject(lock.RepoFullName, lock.Path),
|
||||||
|
Env: lock.Env,
|
||||||
|
Time: lock.Time,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return lastPage
|
return lastPage
|
||||||
})
|
})
|
||||||
@@ -125,10 +133,10 @@ func (d *Backend) ListLocks() (map[string]locking.Run, error) {
|
|||||||
if err == nil && internalErr != nil {
|
if err == nil && internalErr != nil {
|
||||||
err = internalErr
|
err = internalErr
|
||||||
}
|
}
|
||||||
return m, errors.Wrap(err, "scanning dynamodb")
|
return locks, errors.Wrap(err, "scanning dynamodb")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Backend) FindLocksForPull(repoFullName string, pullNum int) ([]string, error) {
|
func (b Backend) UnlockByPull(repoFullName string, pullNum int) error {
|
||||||
params := &dynamodb.ScanInput{
|
params := &dynamodb.ScanInput{
|
||||||
ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{
|
ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{
|
||||||
":pullNum": {
|
":pullNum": {
|
||||||
@@ -139,77 +147,31 @@ func (d *Backend) FindLocksForPull(repoFullName string, pullNum int) ([]string,
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
FilterExpression: aws.String("RepoFullName = :repoFullName and PullNum = :pullNum"),
|
FilterExpression: aws.String("RepoFullName = :repoFullName and PullNum = :pullNum"),
|
||||||
TableName: aws.String(d.LockTable),
|
TableName: aws.String(b.LockTable),
|
||||||
}
|
}
|
||||||
|
|
||||||
var ids []string
|
// scan DynamoDB for locks that match the pull request
|
||||||
|
var locks []dynamoLock
|
||||||
var err, internalErr error
|
var err, internalErr error
|
||||||
err = d.DB.ScanPages(params, func(out *dynamodb.ScanOutput, lastPage bool) bool {
|
err = b.DB.ScanPages(params, func(out *dynamodb.ScanOutput, lastPage bool) bool {
|
||||||
var runs []dynamoRun
|
if err := dynamodbattribute.UnmarshalListOfMaps(out.Items, &locks); err != nil {
|
||||||
if err := dynamodbattribute.UnmarshalListOfMaps(out.Items, &runs); err != nil {
|
internalErr = errors.Wrap(err, "deserializing locks")
|
||||||
internalErr = errors.Wrap(err,"deserializing locks")
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for _, run := range runs {
|
|
||||||
ids = append(ids, run.LockID)
|
|
||||||
}
|
|
||||||
return lastPage
|
return lastPage
|
||||||
})
|
})
|
||||||
|
if err == nil {
|
||||||
if err == nil && internalErr != nil {
|
|
||||||
err = internalErr
|
err = internalErr
|
||||||
}
|
}
|
||||||
return ids, errors.Wrap(err,"scanning dynamodb")
|
if err != nil {
|
||||||
}
|
return errors.Wrap(err, "scanning dynamodb")
|
||||||
|
|
||||||
func (d *Backend) deserializeItem(item map[string]*dynamodb.AttributeValue) (string, locking.Run, error) {
|
|
||||||
var lockID string
|
|
||||||
var run locking.Run
|
|
||||||
|
|
||||||
lockIDItem, ok := item["LockID"]
|
|
||||||
if !ok || lockIDItem == nil {
|
|
||||||
return lockID, run, fmt.Errorf("lock did not have expected key 'LockID'")
|
|
||||||
}
|
|
||||||
lockID = string(hex.EncodeToString(lockIDItem.B))
|
|
||||||
runItem, ok := item["Run"]
|
|
||||||
if !ok || runItem == nil {
|
|
||||||
return lockID, run, fmt.Errorf("lock did not have expected key 'Run'")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := d.deserialize(runItem.B, &run); err != nil {
|
// now we can unlock all of them
|
||||||
return lockID, run, fmt.Errorf("deserializing run at key %q: %s", lockID, err)
|
for _, lock := range locks {
|
||||||
}
|
if err := b.Unlock(models.NewProject(lock.RepoFullName, lock.Path), lock.Env); err != nil {
|
||||||
return lockID, run, nil
|
return errors.Wrapf(err, "unlocking repo %s, path %s, env %s", lock.RepoFullName, lock.Path, lock.Env)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Backend) deserialize(bs []byte, run *locking.Run) error {
|
|
||||||
return json.Unmarshal(bs, run)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Backend) serialize(run locking.Run) ([]byte, error) {
|
|
||||||
return json.Marshal(run)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Backend) toDynamoItem(run locking.Run) (map[string]*dynamodb.AttributeValue, error) {
|
|
||||||
item := dynamoRun{
|
|
||||||
LockID: run.StateKey(),
|
|
||||||
PullNum: run.PullNum,
|
|
||||||
RepoFullName: run.RepoFullName,
|
|
||||||
Env: run.Env,
|
|
||||||
Path: run.Path,
|
|
||||||
Timestamp: run.Timestamp,
|
|
||||||
User: run.User,
|
|
||||||
}
|
|
||||||
return dynamodbattribute.MarshalMap(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Backend) fromDynamoItem(dynamoRun dynamoRun) locking.Run {
|
|
||||||
return locking.Run{
|
|
||||||
User: dynamoRun.User,
|
|
||||||
Timestamp: dynamoRun.Timestamp,
|
|
||||||
Path: dynamoRun.Path,
|
|
||||||
Env: dynamoRun.Env,
|
|
||||||
RepoFullName: dynamoRun.RepoFullName,
|
|
||||||
PullNum: dynamoRun.PullNum,
|
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,70 @@
|
|||||||
package locking
|
package locking
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/hootsuite/atlantis/models"
|
||||||
|
"regexp"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Run struct {
|
type Backend interface {
|
||||||
RepoFullName string
|
TryLock(project models.Project, env string, pullNum int) (bool, int, error)
|
||||||
Path string
|
Unlock(project models.Project, env string) error
|
||||||
Env string
|
List() ([]models.ProjectLock, error)
|
||||||
PullNum int
|
UnlockByPull(repoFullName string, pullNum int) error
|
||||||
User string
|
|
||||||
Timestamp time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// StateKey returns the unique key to identify the set of infrastructure being modified by this run.
|
|
||||||
// Returns `{fullRepoName}/{tfProjectPath}/{environment}`.
|
|
||||||
// Used in locking to determine what part of the infrastructure is locked.
|
|
||||||
func (r Run) StateKey() string {
|
|
||||||
return fmt.Sprintf("%s/%s/%s", r.RepoFullName, r.Path, r.Env)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type TryLockResponse struct {
|
type TryLockResponse struct {
|
||||||
LockAcquired bool
|
LockAcquired bool
|
||||||
LockingRun Run // what is currently holding the lock
|
LockingPullNum int
|
||||||
LockID string
|
LockKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Backend interface {
|
type Client struct {
|
||||||
TryLock(run Run) (TryLockResponse, error)
|
backend Backend
|
||||||
Unlock(lockID string) error
|
}
|
||||||
ListLocks() (map[string]Run, error)
|
|
||||||
FindLocksForPull(repoFullName string, pullNum int) ([]string, error)
|
func NewClient(backend Backend) *Client {
|
||||||
|
return &Client{
|
||||||
|
backend: backend,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyRegex matches and captures {repoFullName}/{path}/{env} where path can have multiple /'s in it
|
||||||
|
var keyRegex = regexp.MustCompile(`^(.*?\/.*?)\/(.*)\/(.*)$`)
|
||||||
|
|
||||||
|
func (c *Client) TryLock(p models.Project, env string, pullNum int) (TryLockResponse, error) {
|
||||||
|
lockAcquired, lockingPullNum, err := c.backend.TryLock(p, env, pullNum)
|
||||||
|
if err != nil {
|
||||||
|
return TryLockResponse{}, err
|
||||||
|
}
|
||||||
|
return TryLockResponse{lockAcquired, lockingPullNum, c.key(p, env)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Unlock(key string) error {
|
||||||
|
matches := keyRegex.FindStringSubmatch(key)
|
||||||
|
if len(matches) != 4 {
|
||||||
|
return errors.New("invalid key format")
|
||||||
|
}
|
||||||
|
return c.backend.Unlock(models.Project{matches[1], matches[2]}, matches[3])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) List() (map[string]models.ProjectLock, error) {
|
||||||
|
m := make(map[string]models.ProjectLock)
|
||||||
|
locks, err := c.backend.List()
|
||||||
|
if err != nil {
|
||||||
|
return m, err
|
||||||
|
}
|
||||||
|
for _, lock := range locks {
|
||||||
|
m[c.key(lock.Project, lock.Env)] = lock
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) UnlockByPull(repoFullName string, pullNum int) error {
|
||||||
|
return c.backend.UnlockByPull(repoFullName, pullNum)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) key(p models.Project, env string) string {
|
||||||
|
return fmt.Sprintf("%s/%s/%s", p.RepoFullName, p.Path, env)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ const (
|
|||||||
|
|
||||||
func NewSimpleLogger(source string, log *log.Logger, keepHistory bool, level LogLevel) *SimpleLogger {
|
func NewSimpleLogger(source string, log *log.Logger, keepHistory bool, level LogLevel) *SimpleLogger {
|
||||||
return &SimpleLogger{
|
return &SimpleLogger{
|
||||||
Source: source,
|
Source: source,
|
||||||
Log: log,
|
Log: log,
|
||||||
Level: level,
|
Level: level,
|
||||||
KeepHistory: keepHistory,
|
KeepHistory: keepHistory,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
|
||||||
"github.com/urfave/negroni"
|
|
||||||
"github.com/hootsuite/atlantis/logging"
|
"github.com/hootsuite/atlantis/logging"
|
||||||
|
"github.com/urfave/negroni"
|
||||||
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewNon200Logger(logger *logging.SimpleLogger) *FailedRequestLogger {
|
func NewNon200Logger(logger *logging.SimpleLogger) *FailedRequestLogger {
|
||||||
@@ -11,7 +11,7 @@ func NewNon200Logger(logger *logging.SimpleLogger) *FailedRequestLogger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FailedRequestLogger logs the request when a response code >= 400 is sent
|
// FailedRequestLogger logs the request when a response code >= 400 is sent
|
||||||
type FailedRequestLogger struct{
|
type FailedRequestLogger struct {
|
||||||
logger *logging.SimpleLogger
|
logger *logging.SimpleLogger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
54
models/models.go
Normal file
54
models/models.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
paths "path"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Repo struct {
|
||||||
|
FullName string
|
||||||
|
Owner string
|
||||||
|
Name string
|
||||||
|
SSHURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PullRequest struct {
|
||||||
|
Num int
|
||||||
|
HeadCommit string
|
||||||
|
BaseCommit string
|
||||||
|
Link string
|
||||||
|
Branch string
|
||||||
|
Author string
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
Username string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectLock struct {
|
||||||
|
Project Project
|
||||||
|
PullNum int
|
||||||
|
Env string
|
||||||
|
Time time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project represents a Terraform project.
|
||||||
|
// Since there may be multiple Terraform projects in a single repo we also include Path
|
||||||
|
type Project struct {
|
||||||
|
RepoFullName string
|
||||||
|
// Path to project root in the repo.
|
||||||
|
// If "." then project is at root.
|
||||||
|
// Never ends in "/".
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProject(repoFullName string, path string) Project {
|
||||||
|
path = paths.Clean(path)
|
||||||
|
if path == "/" {
|
||||||
|
path = "."
|
||||||
|
}
|
||||||
|
return Project{
|
||||||
|
RepoFullName: repoFullName,
|
||||||
|
Path: path,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,13 +14,20 @@ import (
|
|||||||
"github.com/aws/aws-sdk-go/service/s3"
|
"github.com/aws/aws-sdk-go/service/s3"
|
||||||
"github.com/aws/aws-sdk-go/service/s3/s3manager"
|
"github.com/aws/aws-sdk-go/service/s3/s3manager"
|
||||||
"github.com/hootsuite/atlantis/locking"
|
"github.com/hootsuite/atlantis/locking"
|
||||||
"time"
|
"github.com/hootsuite/atlantis/models"
|
||||||
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ApplyExecutor struct {
|
type ApplyExecutor struct {
|
||||||
BaseExecutor
|
github *GithubClient
|
||||||
requireApproval bool
|
awsConfig *AWSConfig
|
||||||
atlantisGithubUser string
|
scratchDir string
|
||||||
|
s3Bucket string
|
||||||
|
sshKey string
|
||||||
|
terraform *TerraformClient
|
||||||
|
githubCommentRenderer *GithubCommentRenderer
|
||||||
|
lockingClient *locking.Client
|
||||||
|
requireApproval bool
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Result Types **/
|
/** Result Types **/
|
||||||
@@ -54,82 +61,85 @@ func (n NoPlansFailure) Template() *CompiledTemplate {
|
|||||||
return NoPlansFailureTmpl
|
return NoPlansFailureTmpl
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ApplyExecutor) execute(ctx *ExecutionContext, pullCtx *PullRequestContext) ExecutionResult {
|
func (a *ApplyExecutor) execute(ctx *CommandContext, github *GithubClient) {
|
||||||
res := a.setupAndApply(ctx, pullCtx)
|
res := a.setupAndApply(ctx)
|
||||||
res.Command = Apply
|
res.Command = Apply
|
||||||
return res
|
comment := a.githubCommentRenderer.render(res, ctx.Log.History.String(), ctx.Command.verbose)
|
||||||
|
github.CreateComment(ctx, comment)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ApplyExecutor) setupAndApply(ctx *ExecutionContext, pullCtx *PullRequestContext) ExecutionResult {
|
func (a *ApplyExecutor) setupAndApply(ctx *CommandContext) ExecutionResult {
|
||||||
a.github.UpdateStatus(pullCtx, PendingStatus, "Applying...")
|
a.github.UpdateStatus(ctx.Repo, ctx.Pull, PendingStatus, "Applying...")
|
||||||
|
|
||||||
if a.requireApproval {
|
if a.requireApproval {
|
||||||
ok, err := a.github.PullIsApproved(pullCtx)
|
ok, err := a.github.PullIsApproved(ctx.Repo, ctx.Pull)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
msg := fmt.Sprintf("failed to determine if pull request was approved: %v", err)
|
msg := fmt.Sprintf("failed to determine if pull request was approved: %v", err)
|
||||||
ctx.log.Err(msg)
|
ctx.Log.Err(msg)
|
||||||
a.github.UpdateStatus(pullCtx, ErrorStatus, "Apply Error")
|
a.github.UpdateStatus(ctx.Repo, ctx.Pull, ErrorStatus, "Apply Error")
|
||||||
return ExecutionResult{SetupError: GeneralError{errors.New(msg)}}
|
return ExecutionResult{SetupError: GeneralError{errors.New(msg)}}
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
ctx.log.Info("pull request was not approved")
|
ctx.Log.Info("pull request was not approved")
|
||||||
a.github.UpdateStatus(pullCtx, FailureStatus, "Apply Failed")
|
a.github.UpdateStatus(ctx.Repo, ctx.Pull, FailureStatus, "Apply Failed")
|
||||||
return ExecutionResult{SetupFailure: PullNotApprovedFailure{}}
|
return ExecutionResult{SetupFailure: PullNotApprovedFailure{}}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
planPaths, err := a.downloadPlans(ctx.repoFullName, ctx.pullNum, ctx.command.environment, a.scratchDir, a.awsConfig, a.s3Bucket)
|
planPaths, err := a.downloadPlans(ctx.Repo.FullName, ctx.Pull.Num, ctx.Command.environment, a.scratchDir, a.awsConfig, a.s3Bucket)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errMsg := fmt.Sprintf("failed to download plans: %v", err)
|
errMsg := fmt.Sprintf("failed to download plans: %v", err)
|
||||||
ctx.log.Err(errMsg)
|
ctx.Log.Err(errMsg)
|
||||||
a.github.UpdateStatus(pullCtx, ErrorStatus, "Apply Error")
|
a.github.UpdateStatus(ctx.Repo, ctx.Pull, ErrorStatus, "Apply Error")
|
||||||
return ExecutionResult{SetupError: GeneralError{errors.New(errMsg)}}
|
return ExecutionResult{SetupError: GeneralError{errors.New(errMsg)}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If there are no plans found for the pull request
|
// If there are no plans found for the pull request
|
||||||
if len(planPaths) == 0 {
|
if len(planPaths) == 0 {
|
||||||
failure := "found 0 plans for this pull request"
|
failure := "found 0 plans for this pull request"
|
||||||
ctx.log.Warn(failure)
|
ctx.Log.Warn(failure)
|
||||||
a.github.UpdateStatus(pullCtx, FailureStatus, "Apply Failure")
|
a.github.UpdateStatus(ctx.Repo, ctx.Pull, FailureStatus, "Apply Failure")
|
||||||
return ExecutionResult{SetupFailure: NoPlansFailure{}}
|
return ExecutionResult{SetupFailure: NoPlansFailure{}}
|
||||||
}
|
}
|
||||||
|
|
||||||
//runLog = append(runLog, fmt.Sprintf("-> Downloaded plans: %v", planPaths))
|
//runLog = append(runLog, fmt.Sprintf("-> Downloaded plans: %v", planPaths))
|
||||||
applyOutputs := []PathResult{}
|
applyOutputs := []PathResult{}
|
||||||
for _, planPath := range planPaths {
|
for _, planPath := range planPaths {
|
||||||
output := a.apply(ctx, pullCtx, planPath)
|
output := a.apply(ctx, planPath)
|
||||||
output.Path = planPath
|
output.Path = planPath
|
||||||
applyOutputs = append(applyOutputs, output)
|
applyOutputs = append(applyOutputs, output)
|
||||||
}
|
}
|
||||||
a.updateGithubStatus(pullCtx, applyOutputs)
|
a.updateGithubStatus(ctx, applyOutputs)
|
||||||
return ExecutionResult{PathResults: applyOutputs}
|
return ExecutionResult{PathResults: applyOutputs}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ApplyExecutor) apply(ctx *ExecutionContext, pullCtx *PullRequestContext, planPath string) PathResult {
|
func (a *ApplyExecutor) apply(ctx *CommandContext, planPath string) PathResult {
|
||||||
//runLog = append(runLog, fmt.Sprintf("-> Running apply %s", planPath))
|
|
||||||
planName := path.Base(planPath)
|
planName := path.Base(planPath)
|
||||||
planSubDir := a.determinePlanSubDir(planName, ctx.pullNum)
|
planSubDir := a.determinePlanSubDir(planName, ctx.Pull.Num)
|
||||||
planDir := filepath.Join(a.scratchDir, ctx.repoFullName, fmt.Sprintf("%v", ctx.pullNum), planSubDir)
|
// todo: don't assume repo is cloned here
|
||||||
|
repoDir := filepath.Join(a.scratchDir, ctx.Repo.FullName, strconv.Itoa(ctx.Pull.Num))
|
||||||
|
planDir := filepath.Join(repoDir, planSubDir)
|
||||||
|
project := models.NewProject(ctx.Repo.FullName, planSubDir)
|
||||||
execPath := NewExecutionPath(planDir, planSubDir)
|
execPath := NewExecutionPath(planDir, planSubDir)
|
||||||
var config Config
|
var config Config
|
||||||
var remoteStatePath string
|
var remoteStatePath string
|
||||||
// check if config file is found, if not we continue the run
|
// check if config file is found, if not we continue the run
|
||||||
if config.Exists(execPath.Absolute) {
|
if config.Exists(execPath.Absolute) {
|
||||||
ctx.log.Info("Config file found in %s", execPath.Absolute)
|
ctx.Log.Info("Config file found in %s", execPath.Absolute)
|
||||||
err := config.Read(execPath.Absolute)
|
err := config.Read(execPath.Absolute)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
msg := fmt.Sprintf("Error reading config file: %v", err)
|
msg := fmt.Sprintf("Error reading config file: %v", err)
|
||||||
ctx.log.Err(msg)
|
ctx.Log.Err(msg)
|
||||||
return PathResult{
|
return PathResult{
|
||||||
Status: "error",
|
Status: "error",
|
||||||
Result: GeneralError{errors.New(msg)},
|
Result: GeneralError{errors.New(msg)},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// need to use the remote state path and backend to do remote configure
|
// need to use the remote state path and backend to do remote configure
|
||||||
err = PreRun(&config, ctx.log, execPath.Absolute, ctx.command)
|
err = PreRun(&config, ctx.Log, execPath.Absolute, ctx.Command)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
msg := fmt.Sprintf("pre run failed: %v", err)
|
msg := fmt.Sprintf("pre run failed: %v", err)
|
||||||
ctx.log.Err(msg)
|
ctx.Log.Err(msg)
|
||||||
return PathResult{
|
return PathResult{
|
||||||
Status: "error",
|
Status: "error",
|
||||||
Result: GeneralError{errors.New(msg)},
|
Result: GeneralError{errors.New(msg)},
|
||||||
@@ -146,10 +156,10 @@ func (a *ApplyExecutor) apply(ctx *ExecutionContext, pullCtx *PullRequestContext
|
|||||||
// NOTE: THIS CODE IS TO SUPPORT TERRAFORM PROJECTS THAT AREN'T USING ATLANTIS CONFIG FILE.
|
// NOTE: THIS CODE IS TO SUPPORT TERRAFORM PROJECTS THAT AREN'T USING ATLANTIS CONFIG FILE.
|
||||||
if config.StashPath == "" {
|
if config.StashPath == "" {
|
||||||
// configure remote state
|
// configure remote state
|
||||||
statePath, err := a.terraform.ConfigureRemoteState(ctx.log, execPath, ctx.command.environment, a.sshKey)
|
statePath, err := a.terraform.ConfigureRemoteState(ctx.Log, repoDir, project, ctx.Command.environment, a.sshKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
msg := fmt.Sprintf("failed to set up remote state: %v", err)
|
msg := fmt.Sprintf("failed to set up remote state: %v", err)
|
||||||
ctx.log.Err(msg)
|
ctx.Log.Err(msg)
|
||||||
return PathResult{
|
return PathResult{
|
||||||
Status: "error",
|
Status: "error",
|
||||||
Result: GeneralError{errors.New(msg)},
|
Result: GeneralError{errors.New(msg)},
|
||||||
@@ -158,72 +168,62 @@ func (a *ApplyExecutor) apply(ctx *ExecutionContext, pullCtx *PullRequestContext
|
|||||||
remoteStatePath = statePath
|
remoteStatePath = statePath
|
||||||
} else {
|
} else {
|
||||||
// use state path from config file
|
// use state path from config file
|
||||||
remoteStatePath = generateStatePath(config.StashPath, ctx.command.environment)
|
remoteStatePath = generateStatePath(config.StashPath, ctx.Command.environment)
|
||||||
}
|
}
|
||||||
|
|
||||||
if remoteStatePath != "" {
|
if remoteStatePath != "" {
|
||||||
tfEnv := ctx.command.environment
|
tfEnv := ctx.Command.environment
|
||||||
if tfEnv == "" {
|
if tfEnv == "" {
|
||||||
tfEnv = "default"
|
tfEnv = "default"
|
||||||
}
|
}
|
||||||
run := locking.Run{
|
|
||||||
RepoFullName: pullCtx.repoFullName,
|
|
||||||
Path: execPath.Relative,
|
|
||||||
Env: tfEnv,
|
|
||||||
PullNum: pullCtx.number,
|
|
||||||
User: pullCtx.terraformApplier,
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
lockAttempt, err := a.lockingBackend.TryLock(run)
|
lockAttempt, err := a.lockingClient.TryLock(project, tfEnv, ctx.Pull.Num)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return PathResult{
|
return PathResult{
|
||||||
Status: "error",
|
Status: "error",
|
||||||
Result: GeneralError{fmt.Errorf("failed to acquire lock: %s", err)},
|
Result: GeneralError{fmt.Errorf("failed to acquire lock: %s", err)},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if lockAttempt.LockAcquired != true && lockAttempt.LockingRun.PullNum != pullCtx.number {
|
if lockAttempt.LockAcquired != true && lockAttempt.LockingPullNum != ctx.Pull.Num {
|
||||||
return PathResult{
|
return PathResult{
|
||||||
Status: "error",
|
Status: "error",
|
||||||
Result: GeneralError{fmt.Errorf("failed to acquire lock: lock held by pull request #%d", lockAttempt.LockingRun.PullNum)},
|
Result: GeneralError{fmt.Errorf("failed to acquire lock: lock held by pull request #%d", lockAttempt.LockingPullNum)},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// need to get auth data from assumed role
|
// need to get auth data from assumed role
|
||||||
// todo: de-duplicate calls to assumeRole
|
// todo: de-duplicate calls to assumeRole
|
||||||
//runLog = append(runLog, "-> Assuming role prior to running apply")
|
a.awsConfig.AWSSessionName = ctx.User.Username
|
||||||
a.awsConfig.AWSSessionName = ctx.pullCreator
|
|
||||||
awsSession, err := a.awsConfig.CreateAWSSession()
|
awsSession, err := a.awsConfig.CreateAWSSession()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.log.Err(err.Error())
|
ctx.Log.Err(err.Error())
|
||||||
return PathResult{
|
return PathResult{
|
||||||
Status: "error",
|
Status: "error",
|
||||||
Result: GeneralError{err},
|
Result: GeneralError{err},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
//runLog = append(runLog, "-> Assumed AWS role successfully")
|
|
||||||
|
|
||||||
credVals, err := awsSession.Config.Credentials.Get()
|
credVals, err := awsSession.Config.Credentials.Get()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
msg := fmt.Sprintf("failed to get assumed role credentials: %v", err)
|
msg := fmt.Sprintf("failed to get assumed role credentials: %v", err)
|
||||||
ctx.log.Err(msg)
|
ctx.Log.Err(msg)
|
||||||
return PathResult{
|
return PathResult{
|
||||||
Status: "error",
|
Status: "error",
|
||||||
Result: GeneralError{errors.New(msg)},
|
Result: GeneralError{errors.New(msg)},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.log.Info("running apply from %q", execPath.Relative)
|
ctx.Log.Info("running apply from %q", execPath.Relative)
|
||||||
|
|
||||||
terraformApplyCmdArgs, output, err := a.terraform.RunTerraformCommand(execPath, []string{"apply", "-no-color", planPath}, []string{
|
terraformApplyCmdArgs, output, err := a.terraform.RunTerraformCommand(execPath.Absolute, []string{"apply", "-no-color", planPath}, []string{
|
||||||
fmt.Sprintf("AWS_ACCESS_KEY_ID=%s", credVals.AccessKeyID),
|
fmt.Sprintf("AWS_ACCESS_KEY_ID=%s", credVals.AccessKeyID),
|
||||||
fmt.Sprintf("AWS_SECRET_ACCESS_KEY=%s", credVals.SecretAccessKey),
|
fmt.Sprintf("AWS_SECRET_ACCESS_KEY=%s", credVals.SecretAccessKey),
|
||||||
fmt.Sprintf("AWS_SESSION_TOKEN=%s", credVals.SessionToken),
|
fmt.Sprintf("AWS_SESSION_TOKEN=%s", credVals.SessionToken),
|
||||||
})
|
})
|
||||||
//runLog = append(runLog, "```\n Apply output:\n", fmt.Sprintf("```bash\n%s\n", string(out[:])))
|
//runLog = append(runLog, "```\n Apply output:\n", fmt.Sprintf("```bash\n%s\n", string(out[:])))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.log.Err("failed to apply: %v %s", err, output)
|
ctx.Log.Err("failed to apply: %v %s", err, output)
|
||||||
return PathResult{
|
return PathResult{
|
||||||
Status: "failure",
|
Status: "failure",
|
||||||
Result: ApplyFailure{Command: strings.Join(terraformApplyCmdArgs, " "), Output: output, ErrorMessage: err.Error()},
|
Result: ApplyFailure{Command: strings.Join(terraformApplyCmdArgs, " "), Output: output, ErrorMessage: err.Error()},
|
||||||
@@ -292,3 +292,27 @@ func (a *ApplyExecutor) determinePlanSubDir(planName string, pullNum int) string
|
|||||||
dirsStr := match[1] // in form dir_subdir_subsubdir
|
dirsStr := match[1] // in form dir_subdir_subsubdir
|
||||||
return filepath.Clean(strings.Replace(dirsStr, "_", "/", -1))
|
return filepath.Clean(strings.Replace(dirsStr, "_", "/", -1))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *ApplyExecutor) updateGithubStatus(ctx *CommandContext, pathResults []PathResult) {
|
||||||
|
// the status will be the worst result
|
||||||
|
worstResult := a.worstResult(pathResults)
|
||||||
|
if worstResult == "success" {
|
||||||
|
a.github.UpdateStatus(ctx.Repo, ctx.Pull, SuccessStatus, "Apply Succeeded")
|
||||||
|
} else if worstResult == "failure" {
|
||||||
|
a.github.UpdateStatus(ctx.Repo, ctx.Pull, FailureStatus, "Apply Failed")
|
||||||
|
} else {
|
||||||
|
a.github.UpdateStatus(ctx.Repo, ctx.Pull, ErrorStatus, "Apply Error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ApplyExecutor) worstResult(results []PathResult) string {
|
||||||
|
var worst string = "success"
|
||||||
|
for _, result := range results {
|
||||||
|
if result.Status == "error" {
|
||||||
|
return result.Status
|
||||||
|
} else if result.Status == "failure" {
|
||||||
|
worst = result.Status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return worst
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
. "github.com/hootsuite/atlantis/testing_util"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
. "github.com/hootsuite/atlantis/testing_util"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var tempConfigFile = "/tmp/" + AtlantisConfigFile
|
var tempConfigFile = "/tmp/" + AtlantisConfigFile
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"path/filepath"
|
|
||||||
"github.com/hootsuite/atlantis/locking"
|
|
||||||
)
|
|
||||||
|
|
||||||
type BaseExecutor struct {
|
|
||||||
github *GithubClient
|
|
||||||
awsConfig *AWSConfig
|
|
||||||
scratchDir string
|
|
||||||
s3Bucket string
|
|
||||||
sshKey string
|
|
||||||
ghComments *GithubCommentRenderer
|
|
||||||
terraform *TerraformClient
|
|
||||||
githubCommentRenderer *GithubCommentRenderer
|
|
||||||
lockingBackend locking.Backend
|
|
||||||
}
|
|
||||||
|
|
||||||
type PullRequestContext struct {
|
|
||||||
repoFullName string
|
|
||||||
head string
|
|
||||||
base string
|
|
||||||
number int
|
|
||||||
pullRequestLink string
|
|
||||||
terraformApplier string
|
|
||||||
terraformApplierEmail string
|
|
||||||
}
|
|
||||||
|
|
||||||
type ExecutionResult struct {
|
|
||||||
SetupError Templater
|
|
||||||
SetupFailure Templater
|
|
||||||
PathResults []PathResult
|
|
||||||
Command CommandType
|
|
||||||
}
|
|
||||||
|
|
||||||
type PathResult struct {
|
|
||||||
Path string
|
|
||||||
Status string // todo: this should be an enum for success/error/failure
|
|
||||||
Result Templater
|
|
||||||
}
|
|
||||||
|
|
||||||
type ExecutionPath struct {
|
|
||||||
// Absolute is the full path on the OS where we will execute.
|
|
||||||
// Will never end with a '/'.
|
|
||||||
Absolute string
|
|
||||||
// Relative is the path relative to the repo root.
|
|
||||||
// Will never end with a '/'.
|
|
||||||
Relative string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewExecutionPath(absolutePath string, relativePath string) ExecutionPath {
|
|
||||||
return ExecutionPath{filepath.Clean(absolutePath), filepath.Clean(relativePath)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *BaseExecutor) updateGithubStatus(pullCtx *PullRequestContext, pathResults []PathResult) {
|
|
||||||
// the status will be the worst result
|
|
||||||
worstResult := b.worstResult(pathResults)
|
|
||||||
if worstResult == "success" {
|
|
||||||
b.github.UpdateStatus(pullCtx, SuccessStatus, "Plan Succeeded")
|
|
||||||
} else if worstResult == "failure" {
|
|
||||||
b.github.UpdateStatus(pullCtx, FailureStatus, "Plan Failed")
|
|
||||||
} else {
|
|
||||||
b.github.UpdateStatus(pullCtx, ErrorStatus, "Plan Error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *BaseExecutor) worstResult(results []PathResult) string {
|
|
||||||
var worst string = "success"
|
|
||||||
for _, result := range results {
|
|
||||||
if result.Status == "error" {
|
|
||||||
return result.Status
|
|
||||||
} else if result.Status == "failure" {
|
|
||||||
worst = result.Status
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return worst
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *BaseExecutor) Exec(f func(*ExecutionContext, *PullRequestContext) ExecutionResult, ctx *ExecutionContext, github *GithubClient) {
|
|
||||||
pullCtx := b.githubContext(ctx)
|
|
||||||
result := f(ctx, pullCtx)
|
|
||||||
comment := b.githubCommentRenderer.render(result, ctx.log.History.String(), ctx.command.verbose)
|
|
||||||
github.CreateComment(pullCtx, comment)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *BaseExecutor) githubContext(ctx *ExecutionContext) *PullRequestContext {
|
|
||||||
return &PullRequestContext{
|
|
||||||
repoFullName: ctx.repoFullName,
|
|
||||||
head: ctx.head,
|
|
||||||
base: ctx.base,
|
|
||||||
number: ctx.pullNum,
|
|
||||||
pullRequestLink: ctx.pullLink,
|
|
||||||
terraformApplier: ctx.requesterUsername,
|
|
||||||
terraformApplierEmail: ctx.requesterEmail,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type Templater interface {
|
|
||||||
Template() *CompiledTemplate
|
|
||||||
}
|
|
||||||
|
|
||||||
type GeneralError struct {
|
|
||||||
Error error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g GeneralError) Template() *CompiledTemplate {
|
|
||||||
return GeneralErrorTmpl
|
|
||||||
}
|
|
||||||
@@ -12,10 +12,10 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/elazarl/go-bindata-assetfs"
|
|
||||||
"bytes"
|
"bytes"
|
||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/elazarl/go-bindata-assetfs"
|
||||||
"io"
|
"io"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"os"
|
"os"
|
||||||
@@ -267,12 +267,12 @@ func AssetNames() []string {
|
|||||||
|
|
||||||
// _bindata is a table, holding each asset generator, mapped to its name.
|
// _bindata is a table, holding each asset generator, mapped to its name.
|
||||||
var _bindata = map[string]func() (*asset, error){
|
var _bindata = map[string]func() (*asset, error){
|
||||||
"static/atlantis-icon.png": staticAtlantisIconPng,
|
"static/atlantis-icon.png": staticAtlantisIconPng,
|
||||||
"static/atlantis-icon_512.png": staticAtlantisIcon_512Png,
|
"static/atlantis-icon_512.png": staticAtlantisIcon_512Png,
|
||||||
"static/css/custom.css": staticCssCustomCss,
|
"static/css/custom.css": staticCssCustomCss,
|
||||||
"static/css/normalize.css": staticCssNormalizeCss,
|
"static/css/normalize.css": staticCssNormalizeCss,
|
||||||
"static/css/skeleton.css": staticCssSkeletonCss,
|
"static/css/skeleton.css": staticCssSkeletonCss,
|
||||||
"static/images/atlantis-icon.png": staticImagesAtlantisIconPng,
|
"static/images/atlantis-icon.png": staticImagesAtlantisIconPng,
|
||||||
"static/images/atlantis-icon_512.png": staticImagesAtlantisIcon_512Png,
|
"static/images/atlantis-icon_512.png": staticImagesAtlantisIcon_512Png,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,18 +315,19 @@ type bintree struct {
|
|||||||
Func func() (*asset, error)
|
Func func() (*asset, error)
|
||||||
Children map[string]*bintree
|
Children map[string]*bintree
|
||||||
}
|
}
|
||||||
|
|
||||||
var _bintree = &bintree{nil, map[string]*bintree{
|
var _bintree = &bintree{nil, map[string]*bintree{
|
||||||
"static": &bintree{nil, map[string]*bintree{
|
"static": {nil, map[string]*bintree{
|
||||||
"atlantis-icon.png": &bintree{staticAtlantisIconPng, map[string]*bintree{}},
|
"atlantis-icon.png": {staticAtlantisIconPng, map[string]*bintree{}},
|
||||||
"atlantis-icon_512.png": &bintree{staticAtlantisIcon_512Png, map[string]*bintree{}},
|
"atlantis-icon_512.png": {staticAtlantisIcon_512Png, map[string]*bintree{}},
|
||||||
"css": &bintree{nil, map[string]*bintree{
|
"css": {nil, map[string]*bintree{
|
||||||
"custom.css": &bintree{staticCssCustomCss, map[string]*bintree{}},
|
"custom.css": {staticCssCustomCss, map[string]*bintree{}},
|
||||||
"normalize.css": &bintree{staticCssNormalizeCss, map[string]*bintree{}},
|
"normalize.css": {staticCssNormalizeCss, map[string]*bintree{}},
|
||||||
"skeleton.css": &bintree{staticCssSkeletonCss, map[string]*bintree{}},
|
"skeleton.css": {staticCssSkeletonCss, map[string]*bintree{}},
|
||||||
}},
|
}},
|
||||||
"images": &bintree{nil, map[string]*bintree{
|
"images": {nil, map[string]*bintree{
|
||||||
"atlantis-icon.png": &bintree{staticImagesAtlantisIconPng, map[string]*bintree{}},
|
"atlantis-icon.png": {staticImagesAtlantisIconPng, map[string]*bintree{}},
|
||||||
"atlantis-icon_512.png": &bintree{staticImagesAtlantisIcon_512Png, map[string]*bintree{}},
|
"atlantis-icon_512.png": {staticImagesAtlantisIcon_512Png, map[string]*bintree{}},
|
||||||
}},
|
}},
|
||||||
}},
|
}},
|
||||||
}}
|
}}
|
||||||
@@ -378,7 +379,6 @@ func _filePath(dir, name string) string {
|
|||||||
return filepath.Join(append([]string{dir}, strings.Split(cannonicalName, "/")...)...)
|
return filepath.Join(append([]string{dir}, strings.Split(cannonicalName, "/")...)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func assetFS() *assetfs.AssetFS {
|
func assetFS() *assetfs.AssetFS {
|
||||||
for k := range _bintree.Children {
|
for k := range _bintree.Children {
|
||||||
return &assetfs.AssetFS{Asset: Asset, AssetDir: AssetDir, AssetInfo: AssetInfo, Prefix: k}
|
return &assetfs.AssetFS{Asset: Asset, AssetDir: AssetDir, AssetInfo: AssetInfo, Prefix: k}
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
type Executor interface {
|
|
||||||
execute(ctx *ExecutionContext, pullCtx *PullRequestContext) ExecutionResult
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/google/go-github/github"
|
"github.com/google/go-github/github"
|
||||||
"context"
|
"github.com/hootsuite/atlantis/models"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type GithubClient struct {
|
type GithubClient struct {
|
||||||
@@ -16,21 +16,21 @@ const (
|
|||||||
statusContext = "Atlantis"
|
statusContext = "Atlantis"
|
||||||
PendingStatus = "pending"
|
PendingStatus = "pending"
|
||||||
SuccessStatus = "success"
|
SuccessStatus = "success"
|
||||||
ErrorStatus = "error"
|
ErrorStatus = "error"
|
||||||
FailureStatus = "failure"
|
FailureStatus = "failure"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (g *GithubClient) UpdateStatus(ctx *PullRequestContext, status string, description string) {
|
func (g *GithubClient) UpdateStatus(repo models.Repo, pull models.PullRequest, status string, description string) {
|
||||||
repoStatus := github.RepoStatus{State: github.String(status), Description: github.String(description), Context: github.String(statusContext)}
|
repoStatus := github.RepoStatus{State: github.String(status), Description: github.String(description), Context: github.String(statusContext)}
|
||||||
owner, repo := g.repoFullNameToOwnerAndRepo(ctx.repoFullName)
|
g.client.Repositories.CreateStatus(g.ctx, repo.Owner, repo.Name, pull.HeadCommit, &repoStatus)
|
||||||
g.client.Repositories.CreateStatus(g.ctx, owner, repo, ctx.head, &repoStatus)
|
|
||||||
// todo: deal with error updating status
|
// todo: deal with error updating status
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *GithubClient) GetModifiedFiles(ctx *PullRequestContext) ([]string, error) {
|
// GetModifiedFiles returns the names of files that were modified in the pull request.
|
||||||
var files = []string{}
|
// The names include the path to the file from the repo root, ex. parent/child/file.txt
|
||||||
owner, repo := g.repoFullNameToOwnerAndRepo(ctx.repoFullName)
|
func (g *GithubClient) GetModifiedFiles(repo models.Repo, pull models.PullRequest) ([]string, error) {
|
||||||
comparison, _, err := g.client.Repositories.CompareCommits(g.ctx, owner, repo, ctx.base, ctx.head)
|
var files []string
|
||||||
|
comparison, _, err := g.client.Repositories.CompareCommits(g.ctx, repo.Owner, repo.Name, pull.BaseCommit, pull.HeadCommit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return files, err
|
return files, err
|
||||||
}
|
}
|
||||||
@@ -40,40 +40,15 @@ func (g *GithubClient) GetModifiedFiles(ctx *PullRequestContext) ([]string, erro
|
|||||||
return files, nil
|
return files, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *GithubClient) CreateComment(ctx *PullRequestContext, comment string) error {
|
func (g *GithubClient) CreateComment(ctx *CommandContext, comment string) error {
|
||||||
owner, repo := g.repoFullNameToOwnerAndRepo(ctx.repoFullName)
|
_, _, err := g.client.Issues.CreateComment(g.ctx, ctx.Repo.Owner, ctx.Repo.Name, ctx.Pull.Num, &github.IssueComment{Body: &comment})
|
||||||
_, _, err := g.client.Issues.CreateComment(g.ctx, owner, repo, ctx.number, &github.IssueComment{Body: &comment})
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// CommentExists searches through comments on a pull request and returns true if one matches matcher
|
func (g *GithubClient) PullIsApproved(repo models.Repo, pull models.PullRequest) (bool, error) {
|
||||||
func (g *GithubClient) CommentExists(ctx *PullRequestContext, matcher func(*github.IssueComment) bool) (bool, error) {
|
|
||||||
opt := &github.IssueListCommentsOptions{}
|
|
||||||
// need to loop since there may be multiple pages of comments
|
|
||||||
for {
|
|
||||||
owner, repo := g.repoFullNameToOwnerAndRepo(ctx.repoFullName)
|
|
||||||
comments, resp, err := g.client.Issues.ListComments(g.ctx, owner, repo, ctx.number, opt)
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("failed to retrieve comments: %v", err)
|
|
||||||
}
|
|
||||||
for _, comment := range comments {
|
|
||||||
if matcher(comment) {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if resp.NextPage == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
opt.ListOptions.Page = resp.NextPage
|
|
||||||
}
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *GithubClient) PullIsApproved(ctx *PullRequestContext) (bool, error) {
|
|
||||||
// todo: move back to using g.client.PullRequests.ListReviews when we update our GitHub enterprise version
|
// todo: move back to using g.client.PullRequests.ListReviews when we update our GitHub enterprise version
|
||||||
// to where we don't need to include the custom accept header
|
// to where we don't need to include the custom accept header
|
||||||
owner, repo := g.repoFullNameToOwnerAndRepo(ctx.repoFullName)
|
u := fmt.Sprintf("repos/%v/%v/pulls/%d/reviews", repo.Owner, repo.Name, pull.Num)
|
||||||
u := fmt.Sprintf("repos/%v/%v/pulls/%d/reviews", owner, repo, ctx.number)
|
|
||||||
req, err := g.client.NewRequest("GET", u, nil)
|
req, err := g.client.NewRequest("GET", u, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
@@ -93,17 +68,6 @@ func (g *GithubClient) PullIsApproved(ctx *PullRequestContext) (bool, error) {
|
|||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *GithubClient) GetPullRequest(repoFullName string, number int) (*github.PullRequest, *github.Response, error) {
|
func (g *GithubClient) GetPullRequest(repo models.Repo, num int) (*github.PullRequest, *github.Response, error) {
|
||||||
owner, repo := g.repoFullNameToOwnerAndRepo(repoFullName)
|
return g.client.PullRequests.Get(g.ctx, repo.Owner, repo.Name, num)
|
||||||
return g.client.PullRequests.Get(g.ctx, owner, repo, number)
|
|
||||||
}
|
|
||||||
|
|
||||||
// repoFullNameToOwnerAndRepo splits up a repository full name which contains the organization and repo name separated by /
|
|
||||||
// into its two parts: organization and repo name. ex baxterthehacker/public-repo => (baxterthehacker, public-repo)
|
|
||||||
func (g *GithubClient) repoFullNameToOwnerAndRepo(fullName string) (string, string) {
|
|
||||||
split := strings.SplitN(fullName, "/", 2)
|
|
||||||
if len(split) != 2 {
|
|
||||||
return fmt.Sprintf("repo name %s could not be split into organization and name", fullName), ""
|
|
||||||
}
|
|
||||||
return split[0], split[1]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,10 @@ package server
|
|||||||
|
|
||||||
import "github.com/spf13/viper"
|
import "github.com/spf13/viper"
|
||||||
|
|
||||||
type HelpExecutor struct {
|
type HelpExecutor struct{}
|
||||||
BaseExecutor
|
|
||||||
}
|
|
||||||
|
|
||||||
var helpComment = "```cmake\n" +
|
var helpComment = "```cmake\n" +
|
||||||
`atlantis - Terraform collaboration tool that enables you to collaborate on infrastructure
|
`atlantis - Terraform collaboration tool that enables you to collaborate on infrastructure
|
||||||
safely and securely. (v` + viper.GetString("version") + `)
|
safely and securely. (v` + viper.GetString("version") + `)
|
||||||
|
|
||||||
Usage: atlantis <command> [environment] [--verbose]
|
Usage: atlantis <command> [environment] [--verbose]
|
||||||
@@ -32,18 +30,8 @@ atlantis apply staging
|
|||||||
atlantis apply
|
atlantis apply
|
||||||
`
|
`
|
||||||
|
|
||||||
func (h *HelpExecutor) execute(ctx *ExecutionContext, github *GithubClient) {
|
func (h *HelpExecutor) execute(ctx *CommandContext, github *GithubClient) {
|
||||||
pullCtx := &PullRequestContext{
|
ctx.Log.Info("generating help comment....")
|
||||||
repoFullName: ctx.repoFullName,
|
github.CreateComment(ctx, helpComment)
|
||||||
head: ctx.head,
|
|
||||||
base: ctx.base,
|
|
||||||
number: ctx.pullNum,
|
|
||||||
pullRequestLink: ctx.pullLink,
|
|
||||||
terraformApplier: ctx.requesterUsername,
|
|
||||||
terraformApplierEmail: ctx.requesterEmail,
|
|
||||||
}
|
|
||||||
github.CreateComment(pullCtx, helpComment)
|
|
||||||
|
|
||||||
ctx.log.Info("generating help comment....")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,19 +3,27 @@ package server
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/hootsuite/atlantis/locking"
|
||||||
|
"github.com/hootsuite/atlantis/logging"
|
||||||
|
"github.com/hootsuite/atlantis/models"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"github.com/hootsuite/atlantis/locking"
|
|
||||||
"time"
|
|
||||||
"github.com/hootsuite/atlantis/logging"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// PlanExecutor handles everything related to running the Terraform plan including integration with S3, Terraform, and Github
|
// PlanExecutor handles everything related to running the Terraform plan including integration with S3, Terraform, and Github
|
||||||
type PlanExecutor struct {
|
type PlanExecutor struct {
|
||||||
BaseExecutor
|
github *GithubClient
|
||||||
|
awsConfig *AWSConfig
|
||||||
|
scratchDir string
|
||||||
|
s3Bucket string
|
||||||
|
sshKey string
|
||||||
|
terraform *TerraformClient
|
||||||
|
githubCommentRenderer *GithubCommentRenderer
|
||||||
|
lockingClient *locking.Client
|
||||||
// DeleteLockURL is a function that given a lock id will return a url for deleting the lock
|
// DeleteLockURL is a function that given a lock id will return a url for deleting the lock
|
||||||
DeleteLockURL func(id string) (url string)
|
DeleteLockURL func(id string) (url string)
|
||||||
}
|
}
|
||||||
@@ -23,7 +31,7 @@ type PlanExecutor struct {
|
|||||||
/** Result Types **/
|
/** Result Types **/
|
||||||
type PlanSuccess struct {
|
type PlanSuccess struct {
|
||||||
TerraformOutput string
|
TerraformOutput string
|
||||||
LockURL string
|
LockURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p PlanSuccess) Template() *CompiledTemplate {
|
func (p PlanSuccess) Template() *CompiledTemplate {
|
||||||
@@ -61,37 +69,38 @@ func (e EnvironmentFailure) Template() *CompiledTemplate {
|
|||||||
return EnvironmentErrorTmpl
|
return EnvironmentErrorTmpl
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PlanExecutor) execute(ctx *ExecutionContext, pullCtx *PullRequestContext) ExecutionResult {
|
func (p *PlanExecutor) execute(ctx *CommandContext, github *GithubClient) {
|
||||||
res := p.setupAndPlan(ctx, pullCtx)
|
res := p.setupAndPlan(ctx)
|
||||||
res.Command = Plan
|
res.Command = Plan
|
||||||
return res
|
comment := p.githubCommentRenderer.render(res, ctx.Log.History.String(), ctx.Command.verbose)
|
||||||
|
github.CreateComment(ctx, comment)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PlanExecutor) setupAndPlan(ctx *ExecutionContext, pullCtx *PullRequestContext) ExecutionResult {
|
func (p *PlanExecutor) setupAndPlan(ctx *CommandContext) ExecutionResult {
|
||||||
p.github.UpdateStatus(pullCtx, "pending", "Planning...")
|
p.github.UpdateStatus(ctx.Repo, ctx.Pull, "pending", "Planning...")
|
||||||
|
|
||||||
// todo: lock when cloning or somehow separate workspaces
|
// todo: lock when cloning or somehow separate workspaces
|
||||||
// clean the directory where we're going to clone
|
// clean the directory where we're going to clone
|
||||||
cloneDir := fmt.Sprintf("%s/%s/%d", p.scratchDir, ctx.repoFullName, ctx.pullNum)
|
cloneDir := fmt.Sprintf("%s/%s/%d", p.scratchDir, ctx.Repo.FullName, ctx.Pull.Num)
|
||||||
ctx.log.Info("cleaning clone directory %q", cloneDir)
|
ctx.Log.Info("cleaning clone directory %q", cloneDir)
|
||||||
if err := os.RemoveAll(cloneDir); err != nil {
|
if err := os.RemoveAll(cloneDir); err != nil {
|
||||||
ctx.log.Warn("failed to clean dir %q before cloning, attempting to continue: %v", cloneDir, err)
|
ctx.Log.Warn("failed to clean dir %q before cloning, attempting to continue: %v", cloneDir, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// create the directory and parents if necessary
|
// create the directory and parents if necessary
|
||||||
ctx.log.Info("creating dir %q", cloneDir)
|
ctx.Log.Info("creating dir %q", cloneDir)
|
||||||
if err := os.MkdirAll(cloneDir, 0755); err != nil {
|
if err := os.MkdirAll(cloneDir, 0755); err != nil {
|
||||||
ctx.log.Warn("failed to create dir %q prior to cloning, attempting to continue: %v", cloneDir, err)
|
ctx.Log.Warn("failed to create dir %q prior to cloning, attempting to continue: %v", cloneDir, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if ssh key is set and create git ssh wrapper
|
// Check if ssh key is set and create git ssh wrapper
|
||||||
cloneCmd := exec.Command("git", "clone", ctx.repoSSHUrl, cloneDir)
|
cloneCmd := exec.Command("git", "clone", ctx.Repo.SSHURL, cloneDir)
|
||||||
if p.sshKey != "" {
|
if p.sshKey != "" {
|
||||||
err := GenerateSSHWrapper()
|
err := GenerateSSHWrapper()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errMsg := fmt.Sprintf("failed to create git ssh wrapper: %v", err)
|
errMsg := fmt.Sprintf("failed to create git ssh wrapper: %v", err)
|
||||||
ctx.log.Err(errMsg)
|
ctx.Log.Err(errMsg)
|
||||||
p.github.UpdateStatus(pullCtx, ErrorStatus, "Plan Error")
|
p.github.UpdateStatus(ctx.Repo, ctx.Pull, ErrorStatus, "Plan Error")
|
||||||
return ExecutionResult{SetupError: GeneralError{errors.New(errMsg)}}
|
return ExecutionResult{SetupError: GeneralError{errors.New(errMsg)}}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,53 +111,53 @@ func (p *PlanExecutor) setupAndPlan(ctx *ExecutionContext, pullCtx *PullRequestC
|
|||||||
}
|
}
|
||||||
|
|
||||||
// git clone the repo
|
// git clone the repo
|
||||||
ctx.log.Info("git cloning %q into %q", ctx.repoSSHUrl, cloneDir)
|
ctx.Log.Info("git cloning %q into %q", ctx.Repo.SSHURL, cloneDir)
|
||||||
if output, err := cloneCmd.CombinedOutput(); err != nil {
|
if output, err := cloneCmd.CombinedOutput(); err != nil {
|
||||||
errMsg := fmt.Sprintf("failed to clone repository %q: %v: %s", ctx.repoSSHUrl, err, string(output))
|
errMsg := fmt.Sprintf("failed to clone repository %q: %v: %s", ctx.Repo.SSHURL, err, string(output))
|
||||||
ctx.log.Err(errMsg)
|
ctx.Log.Err(errMsg)
|
||||||
p.github.UpdateStatus(pullCtx, ErrorStatus, "Plan Error")
|
p.github.UpdateStatus(ctx.Repo, ctx.Pull, ErrorStatus, "Plan Error")
|
||||||
return ExecutionResult{SetupError: GeneralError{errors.New(errMsg)}}
|
return ExecutionResult{SetupError: GeneralError{errors.New(errMsg)}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// check out the branch for this PR
|
// check out the branch for this PR
|
||||||
ctx.log.Info("checking out branch %q", ctx.branch)
|
ctx.Log.Info("checking out branch %q", ctx.Pull.Branch)
|
||||||
checkoutCmd := exec.Command("git", "checkout", ctx.branch)
|
checkoutCmd := exec.Command("git", "checkout", ctx.Pull.Branch)
|
||||||
checkoutCmd.Dir = cloneDir
|
checkoutCmd.Dir = cloneDir
|
||||||
if err := checkoutCmd.Run(); err != nil {
|
if err := checkoutCmd.Run(); err != nil {
|
||||||
errMsg := fmt.Sprintf("failed to git checkout branch %q: %v", ctx.branch, err)
|
errMsg := fmt.Sprintf("failed to git checkout branch %q: %v", ctx.Pull.Branch, err)
|
||||||
ctx.log.Err(errMsg)
|
ctx.Log.Err(errMsg)
|
||||||
p.github.UpdateStatus(pullCtx, ErrorStatus, "Plan Error")
|
p.github.UpdateStatus(ctx.Repo, ctx.Pull, ErrorStatus, "Plan Error")
|
||||||
return ExecutionResult{SetupError: GeneralError{errors.New(errMsg)}}
|
return ExecutionResult{SetupError: GeneralError{errors.New(errMsg)}}
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.log.Info("listing modified files from pull request")
|
ctx.Log.Info("listing modified files from pull request")
|
||||||
modifiedFiles, err := p.github.GetModifiedFiles(pullCtx)
|
modifiedFiles, err := p.github.GetModifiedFiles(ctx.Repo, ctx.Pull)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errMsg := fmt.Sprintf("failed to retrieve list of modified files from GitHub: %v", err)
|
errMsg := fmt.Sprintf("failed to retrieve list of modified files from GitHub: %v", err)
|
||||||
ctx.log.Err(errMsg)
|
ctx.Log.Err(errMsg)
|
||||||
p.github.UpdateStatus(pullCtx, ErrorStatus, "Plan Error")
|
p.github.UpdateStatus(ctx.Repo, ctx.Pull, ErrorStatus, "Plan Error")
|
||||||
return ExecutionResult{SetupError: GeneralError{errors.New(errMsg)}}
|
return ExecutionResult{SetupError: GeneralError{errors.New(errMsg)}}
|
||||||
}
|
}
|
||||||
modifiedTerraformFiles := p.filterToTerraform(modifiedFiles)
|
modifiedTerraformFiles := p.filterToTerraform(modifiedFiles)
|
||||||
if len(modifiedTerraformFiles) == 0 {
|
if len(modifiedTerraformFiles) == 0 {
|
||||||
ctx.log.Info("no modified terraform files found, exiting")
|
ctx.Log.Info("no modified terraform files found, exiting")
|
||||||
p.github.UpdateStatus(pullCtx, FailureStatus, "Plan Failed")
|
p.github.UpdateStatus(ctx.Repo, ctx.Pull, FailureStatus, "Plan Failed")
|
||||||
return ExecutionResult{SetupError: GeneralError{errors.New("Plan Failed: no modified terraform files found")}}
|
return ExecutionResult{SetupError: GeneralError{errors.New("Plan Failed: no modified terraform files found")}}
|
||||||
}
|
}
|
||||||
ctx.log.Debug("Found %d modified terraform files: %v", len(modifiedTerraformFiles), modifiedTerraformFiles)
|
ctx.Log.Debug("Found %d modified terraform files: %v", len(modifiedTerraformFiles), modifiedTerraformFiles)
|
||||||
|
|
||||||
execPaths := p.DetermineExecPaths(p.trimSuffix(cloneDir, "/"), modifiedTerraformFiles)
|
projects := p.ModifiedProjects(ctx.Repo.FullName, modifiedTerraformFiles)
|
||||||
if len(execPaths) == 0 {
|
if len(projects) == 0 {
|
||||||
ctx.log.Info("no exec paths found, exiting")
|
ctx.Log.Info("no Terraform projects were modified")
|
||||||
p.github.UpdateStatus(pullCtx, FailureStatus, "Plan Failed")
|
p.github.UpdateStatus(ctx.Repo, ctx.Pull, FailureStatus, "Plan Failed")
|
||||||
return ExecutionResult{SetupError: GeneralError{errors.New("Plan Failed: there were no paths to run plan in")}}
|
return ExecutionResult{SetupError: GeneralError{errors.New("Plan Failed: we determined that no terraform projects were modified")}}
|
||||||
}
|
}
|
||||||
|
|
||||||
planFilesPrefix := fmt.Sprintf("%s_%d", strings.Replace(ctx.repoFullName, "/", "_", -1), ctx.pullNum)
|
planFilesPrefix := fmt.Sprintf("%s_%d", strings.Replace(ctx.Repo.FullName, "/", "_", -1), ctx.Pull.Num)
|
||||||
if err := p.CleanWorkspace(ctx.log, planFilesPrefix, p.scratchDir, execPaths); err != nil {
|
if err := p.CleanWorkspace(ctx.Log, planFilesPrefix, p.scratchDir, cloneDir, projects); err != nil {
|
||||||
errMsg := fmt.Sprintf("failed to clean workspace, aborting: %v", err)
|
errMsg := fmt.Sprintf("failed to clean workspace, aborting: %v", err)
|
||||||
ctx.log.Err(errMsg)
|
ctx.Log.Err(errMsg)
|
||||||
p.github.UpdateStatus(pullCtx, ErrorStatus, "Plan Error")
|
p.github.UpdateStatus(ctx.Repo, ctx.Pull, ErrorStatus, "Plan Error")
|
||||||
return ExecutionResult{SetupError: GeneralError{errors.New(errMsg)}}
|
return ExecutionResult{SetupError: GeneralError{errors.New(errMsg)}}
|
||||||
}
|
}
|
||||||
s3Client := NewS3Client(p.awsConfig, p.s3Bucket, "plans")
|
s3Client := NewS3Client(p.awsConfig, p.s3Bucket, "plans")
|
||||||
@@ -156,25 +165,26 @@ func (p *PlanExecutor) setupAndPlan(ctx *ExecutionContext, pullCtx *PullRequestC
|
|||||||
var config Config
|
var config Config
|
||||||
// run `terraform plan` in each plan path and collect the results
|
// run `terraform plan` in each plan path and collect the results
|
||||||
planOutputs := []PathResult{}
|
planOutputs := []PathResult{}
|
||||||
for _, path := range execPaths {
|
for _, project := range projects {
|
||||||
// todo: not sure it makes sense to be generating the output filename and plan name here
|
// todo: not sure it makes sense to be generating the output filename and plan name here
|
||||||
tfPlanFilename := p.GenerateOutputFilename(cloneDir, path, ctx.command.environment)
|
tfPlanFilename := p.GenerateOutputFilename(project, ctx.Command.environment)
|
||||||
tfPlanName := fmt.Sprintf("%s_%d%s", strings.Replace(ctx.repoFullName, "/", "_", -1), ctx.pullNum, tfPlanFilename)
|
tfPlanName := fmt.Sprintf("%s_%d%s", strings.Replace(ctx.Repo.FullName, "/", "_", -1), ctx.Pull.Num, tfPlanFilename)
|
||||||
s3Key := fmt.Sprintf("%s/%s", ctx.repoFullName, tfPlanName)
|
s3Key := fmt.Sprintf("%s/%s", ctx.Repo.FullName, tfPlanName)
|
||||||
// check if config file is found, if not we continue the run
|
// check if config file is found, if not we continue the run
|
||||||
if config.Exists(path.Absolute) {
|
absolutePath := filepath.Join(cloneDir, project.Path)
|
||||||
ctx.log.Info("Config file found in %s", path.Absolute)
|
if config.Exists(absolutePath) {
|
||||||
err := config.Read(path.Absolute)
|
ctx.Log.Info("Config file found in %s", absolutePath)
|
||||||
|
err := config.Read(absolutePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errMsg := fmt.Sprintf("Error reading config file: %v", err)
|
errMsg := fmt.Sprintf("Error reading config file: %v", err)
|
||||||
ctx.log.Err(errMsg)
|
ctx.Log.Err(errMsg)
|
||||||
return ExecutionResult{SetupError: GeneralError{errors.New(errMsg)}}
|
return ExecutionResult{SetupError: GeneralError{errors.New(errMsg)}}
|
||||||
}
|
}
|
||||||
// need to use the remote state path and backend to do remote configure
|
// need to use the remote state path and backend to do remote configure
|
||||||
err = PreRun(&config, ctx.log, path.Absolute, ctx.command)
|
err = PreRun(&config, ctx.Log, absolutePath, ctx.Command)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errMsg := fmt.Sprintf("pre run failed: %v", err)
|
errMsg := fmt.Sprintf("pre run failed: %v", err)
|
||||||
ctx.log.Err(errMsg)
|
ctx.Log.Err(errMsg)
|
||||||
return ExecutionResult{SetupError: GeneralError{errors.New(errMsg)}}
|
return ExecutionResult{SetupError: GeneralError{errors.New(errMsg)}}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,41 +195,31 @@ func (p *PlanExecutor) setupAndPlan(ctx *ExecutionContext, pullCtx *PullRequestC
|
|||||||
p.terraform.tfExecutableName = "terraform"
|
p.terraform.tfExecutableName = "terraform"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
generatePlanResponse := p.plan(ctx.log, pullCtx, cloneDir, p.scratchDir, tfPlanName, s3Client, path, ctx.command.environment, s3Key, p.sshKey, ctx.pullCreator, config.StashPath)
|
generatePlanResponse := p.plan(ctx, cloneDir, p.scratchDir, tfPlanName, s3Client, project, s3Key, p.sshKey, config.StashPath)
|
||||||
generatePlanResponse.Path = path.Relative
|
generatePlanResponse.Path = project.Path
|
||||||
planOutputs = append(planOutputs, generatePlanResponse)
|
planOutputs = append(planOutputs, generatePlanResponse)
|
||||||
}
|
}
|
||||||
p.updateGithubStatus(pullCtx, planOutputs)
|
p.updateGithubStatus(ctx, planOutputs)
|
||||||
return ExecutionResult{PathResults: planOutputs}
|
return ExecutionResult{PathResults: planOutputs}
|
||||||
}
|
}
|
||||||
|
|
||||||
// plan runs the steps necessary to run `terraform plan`. If there is an error, the error message will be encapsulated in error
|
// 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
|
// and the GeneratePlanResponse struct will also contain the full log including the error
|
||||||
func (p *PlanExecutor) plan(log *logging.SimpleLogger,
|
func (p *PlanExecutor) plan(
|
||||||
pullCtx *PullRequestContext,
|
ctx *CommandContext,
|
||||||
repoDir string,
|
repoDir string,
|
||||||
planOutDir string,
|
planOutDir string,
|
||||||
tfPlanName string,
|
tfPlanName string,
|
||||||
s3Client S3Client,
|
s3Client S3Client,
|
||||||
path ExecutionPath,
|
project models.Project,
|
||||||
tfEnvName string,
|
|
||||||
s3Key string,
|
s3Key string,
|
||||||
sshKey string,
|
sshKey string,
|
||||||
pullRequestCreator string,
|
|
||||||
stashPath string) PathResult {
|
stashPath string) PathResult {
|
||||||
log.Info("generating plan for path %q", path)
|
ctx.Log.Info("generating plan for path %q", project.Path)
|
||||||
run := locking.Run{
|
|
||||||
RepoFullName: pullCtx.repoFullName,
|
|
||||||
Path: path.Relative,
|
|
||||||
Env: tfEnvName,
|
|
||||||
PullNum: pullCtx.number,
|
|
||||||
User: pullCtx.terraformApplier,
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: THIS CODE IS TO SUPPORT TERRAFORM PROJECTS THAT AREN'T USING ATLANTIS CONFIG FILE.
|
// NOTE: THIS CODE IS TO SUPPORT TERRAFORM PROJECTS THAT AREN'T USING ATLANTIS CONFIG FILE.
|
||||||
if stashPath == "" {
|
if stashPath == "" {
|
||||||
_, err := p.terraform.ConfigureRemoteState(log, path, tfEnvName, sshKey)
|
_, err := p.terraform.ConfigureRemoteState(ctx.Log, repoDir, project, ctx.Command.environment, sshKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return PathResult{
|
return PathResult{
|
||||||
Status: "error",
|
Status: "error",
|
||||||
@@ -227,34 +227,39 @@ func (p *PlanExecutor) plan(log *logging.SimpleLogger,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lockAttempt, err := p.lockingBackend.TryLock(run)
|
// todo: setting environment to default should be done elsewhere
|
||||||
|
tfEnv := ctx.Command.environment
|
||||||
|
if tfEnv == "" {
|
||||||
|
tfEnv = "default"
|
||||||
|
}
|
||||||
|
lockAttempt, err := p.lockingClient.TryLock(project, ctx.Command.environment, ctx.Pull.Num)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return PathResult{
|
return PathResult{
|
||||||
Status:" failure",
|
Status: " failure",
|
||||||
Result: GeneralError{fmt.Errorf("failed to lock state: %v", err)},
|
Result: GeneralError{fmt.Errorf("failed to lock state: %v", err)},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// the run is locked unless the locking run is the same pull id as this run
|
// the run is locked unless the locking run is the same pull id as this run
|
||||||
if lockAttempt.LockAcquired == false && lockAttempt.LockingRun.PullNum != pullCtx.number {
|
if lockAttempt.LockAcquired == false && lockAttempt.LockingPullNum != ctx.Pull.Num {
|
||||||
return PathResult{
|
return PathResult{
|
||||||
Status: "failure",
|
Status: "failure",
|
||||||
Result: RunLockedFailure{lockAttempt.LockingRun.PullNum},
|
Result: RunLockedFailure{lockAttempt.LockingPullNum},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run terraform plan
|
// Run terraform plan
|
||||||
log.Info("running terraform plan in directory %q", path.Relative)
|
ctx.Log.Info("running terraform plan in directory %q", project.Path)
|
||||||
tfPlanCmd := []string{"plan", "-refresh", "-no-color"}
|
tfPlanCmd := []string{"plan", "-refresh", "-no-color"}
|
||||||
// Generate terraform plan filename
|
// Generate terraform plan filename
|
||||||
tfPlanOutputPath := filepath.Join(planOutDir, tfPlanName)
|
tfPlanOutputPath := filepath.Join(planOutDir, tfPlanName)
|
||||||
// Generate terraform plan arguments
|
// Generate terraform plan arguments
|
||||||
if tfEnvName != "" {
|
if ctx.Command.environment != "" {
|
||||||
tfEnvFileName := filepath.Join("env", tfEnvName+".tfvars")
|
tfEnvFileName := filepath.Join("env", ctx.Command.environment+".tfvars")
|
||||||
if _, err := os.Stat(filepath.Join(path.Absolute, tfEnvFileName)); err == nil {
|
if _, err := os.Stat(filepath.Join(repoDir, project.Path, tfEnvFileName)); err == nil {
|
||||||
tfPlanCmd = append(tfPlanCmd, "-var-file", tfEnvFileName, "-out", tfPlanOutputPath)
|
tfPlanCmd = append(tfPlanCmd, "-var-file", tfEnvFileName, "-out", tfPlanOutputPath)
|
||||||
} else {
|
} else {
|
||||||
log.Err("environment file %q not found", tfEnvFileName)
|
ctx.Log.Err("environment file %q not found", tfEnvFileName)
|
||||||
return PathResult{
|
return PathResult{
|
||||||
Status: "failure",
|
Status: "failure",
|
||||||
Result: EnvironmentFileNotFoundFailure{tfEnvFileName},
|
Result: EnvironmentFileNotFoundFailure{tfEnvFileName},
|
||||||
@@ -265,10 +270,10 @@ func (p *PlanExecutor) plan(log *logging.SimpleLogger,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// set pull request creator as the session name
|
// set pull request creator as the session name
|
||||||
p.awsConfig.AWSSessionName = pullRequestCreator
|
p.awsConfig.AWSSessionName = ctx.Pull.Author
|
||||||
awsSession, err := p.awsConfig.CreateAWSSession()
|
awsSession, err := p.awsConfig.CreateAWSSession()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Err(err.Error())
|
ctx.Log.Err(err.Error())
|
||||||
return PathResult{
|
return PathResult{
|
||||||
Status: "error",
|
Status: "error",
|
||||||
Result: GeneralError{err},
|
Result: GeneralError{err},
|
||||||
@@ -278,14 +283,14 @@ func (p *PlanExecutor) plan(log *logging.SimpleLogger,
|
|||||||
credVals, err := awsSession.Config.Credentials.Get()
|
credVals, err := awsSession.Config.Credentials.Get()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err = fmt.Errorf("failed to get assumed role credentials: %v", err)
|
err = fmt.Errorf("failed to get assumed role credentials: %v", err)
|
||||||
log.Err(err.Error())
|
ctx.Log.Err(err.Error())
|
||||||
return PathResult{
|
return PathResult{
|
||||||
Status: "error",
|
Status: "error",
|
||||||
Result: GeneralError{err},
|
Result: GeneralError{err},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
terraformPlanCmdArgs, output, err := p.terraform.RunTerraformCommand(path, tfPlanCmd, []string{
|
terraformPlanCmdArgs, output, err := p.terraform.RunTerraformCommand(filepath.Join(repoDir, project.Path), tfPlanCmd, []string{
|
||||||
fmt.Sprintf("AWS_ACCESS_KEY_ID=%s", credVals.AccessKeyID),
|
fmt.Sprintf("AWS_ACCESS_KEY_ID=%s", credVals.AccessKeyID),
|
||||||
fmt.Sprintf("AWS_SECRET_ACCESS_KEY=%s", credVals.SecretAccessKey),
|
fmt.Sprintf("AWS_SECRET_ACCESS_KEY=%s", credVals.SecretAccessKey),
|
||||||
fmt.Sprintf("AWS_SESSION_TOKEN=%s", credVals.SessionToken),
|
fmt.Sprintf("AWS_SESSION_TOKEN=%s", credVals.SessionToken),
|
||||||
@@ -299,10 +304,10 @@ func (p *PlanExecutor) plan(log *logging.SimpleLogger,
|
|||||||
Command: strings.Join(terraformPlanCmdArgs, " "),
|
Command: strings.Join(terraformPlanCmdArgs, " "),
|
||||||
Output: output,
|
Output: output,
|
||||||
}
|
}
|
||||||
log.Err("error running terraform plan: %v", output)
|
ctx.Log.Err("error running terraform plan: %v", output)
|
||||||
log.Info("unlocking state since plan failed")
|
ctx.Log.Info("unlocking state since plan failed")
|
||||||
if err := p.lockingBackend.Unlock(lockAttempt.LockID); err != nil {
|
if err := p.lockingClient.Unlock(lockAttempt.LockKey); err != nil {
|
||||||
log.Err("error unlocking state: %v", err)
|
ctx.Log.Err("error unlocking state: %v", err)
|
||||||
}
|
}
|
||||||
return PathResult{
|
return PathResult{
|
||||||
Status: "failure",
|
Status: "failure",
|
||||||
@@ -310,12 +315,12 @@ func (p *PlanExecutor) plan(log *logging.SimpleLogger,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Upload plan to S3
|
// Upload plan to S3
|
||||||
log.Info("uploading plan to S3 with key %q", s3Key)
|
ctx.Log.Info("uploading plan to S3 with key %q", s3Key)
|
||||||
if err := UploadPlanFile(s3Client, s3Key, tfPlanOutputPath); err != nil {
|
if err := UploadPlanFile(s3Client, s3Key, tfPlanOutputPath); err != nil {
|
||||||
err = fmt.Errorf("failed to upload to S3: %v", err)
|
err = fmt.Errorf("failed to upload to S3: %v", err)
|
||||||
log.Err(err.Error())
|
ctx.Log.Err(err.Error())
|
||||||
if err := p.lockingBackend.Unlock(lockAttempt.LockID); err != nil {
|
if err := p.lockingClient.Unlock(lockAttempt.LockKey); err != nil {
|
||||||
log.Err("error unlocking state: %v", err)
|
ctx.Log.Err("error unlocking state: %v", err)
|
||||||
}
|
}
|
||||||
return PathResult{
|
return PathResult{
|
||||||
Status: "error",
|
Status: "error",
|
||||||
@@ -324,22 +329,22 @@ func (p *PlanExecutor) plan(log *logging.SimpleLogger,
|
|||||||
}
|
}
|
||||||
// Delete local plan file
|
// Delete local plan file
|
||||||
planFilePath := fmt.Sprintf("%s/%s", planOutDir, tfPlanName)
|
planFilePath := fmt.Sprintf("%s/%s", planOutDir, tfPlanName)
|
||||||
log.Info("deleting local plan file %q", planFilePath)
|
ctx.Log.Info("deleting local plan file %q", planFilePath)
|
||||||
if err := os.Remove(planFilePath); err != nil {
|
if err := os.Remove(planFilePath); err != nil {
|
||||||
log.Err("failed to delete local plan file %q", planFilePath, err)
|
ctx.Log.Err("failed to delete local plan file %q", planFilePath, err)
|
||||||
// todo: return an error
|
// todo: return an error
|
||||||
}
|
}
|
||||||
return PathResult{
|
return PathResult{
|
||||||
Status: "success",
|
Status: "success",
|
||||||
Result: PlanSuccess{
|
Result: PlanSuccess{
|
||||||
TerraformOutput: output,
|
TerraformOutput: output,
|
||||||
LockURL: p.DeleteLockURL(lockAttempt.LockID),
|
LockURL: p.DeleteLockURL(lockAttempt.LockKey),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PlanExecutor) filterToTerraform(files []string) []string {
|
func (p *PlanExecutor) filterToTerraform(files []string) []string {
|
||||||
var out = []string{}
|
var out []string
|
||||||
for _, fileName := range files {
|
for _, fileName := range files {
|
||||||
if !p.isInExcludeList(fileName) && strings.Contains(fileName, ".tf") {
|
if !p.isInExcludeList(fileName) && strings.Contains(fileName, ".tf") {
|
||||||
out = append(out, fileName)
|
out = append(out, fileName)
|
||||||
@@ -359,46 +364,40 @@ func (p *PlanExecutor) trimSuffix(s, suffix string) string {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PlanExecutor) removeDuplicates(paths []ExecutionPath) []ExecutionPath {
|
// ModifiedProjects returns the list of Terraform projects that have been changed due to the
|
||||||
deDuped := []ExecutionPath{}
|
// modified files
|
||||||
seen := map[ExecutionPath]bool{}
|
func (p *PlanExecutor) ModifiedProjects(repoFullName string, modifiedFiles []string) []models.Project {
|
||||||
for _, path := range paths {
|
var projects []models.Project
|
||||||
if _, ok := seen[path]; !ok {
|
seenPaths := make(map[string]bool)
|
||||||
deDuped = append(deDuped, path)
|
for _, modifiedFile := range modifiedFiles {
|
||||||
seen[path] = true
|
path := p.getProjectPath(modifiedFile)
|
||||||
|
if _, ok := seenPaths[path]; !ok {
|
||||||
|
projects = append(projects, models.NewProject(repoFullName, path))
|
||||||
|
seenPaths[path] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return deDuped
|
return projects
|
||||||
}
|
}
|
||||||
|
|
||||||
// DetermineExecPaths returns the list of directories in which we'll need to run `terraform plan`
|
// getProjectPath returns the path to the project relative to the repo root
|
||||||
func (p *PlanExecutor) DetermineExecPaths(repoPath string, modifiedFiles []string) []ExecutionPath {
|
// if the project is at the root returns "."
|
||||||
var paths []ExecutionPath
|
func (p *PlanExecutor) getProjectPath(modifiedFilePath string) string {
|
||||||
for _, modifiedFile := range modifiedFiles {
|
dir := path.Dir(modifiedFilePath)
|
||||||
relative := p.getRelativePlanPath(modifiedFile)
|
if path.Base(dir) == "env" {
|
||||||
absolute := filepath.Join(repoPath, relative) + "/"
|
|
||||||
paths = append(paths, NewExecutionPath(absolute, relative))
|
|
||||||
}
|
|
||||||
return p.removeDuplicates(paths)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *PlanExecutor) getRelativePlanPath(modifiedFilePath string) string {
|
|
||||||
parentDir := filepath.Dir(modifiedFilePath)
|
|
||||||
if filepath.Base(parentDir) == "env" {
|
|
||||||
// if the modified file was inside an env/ directory, we treat this specially and
|
// if the modified file was inside an env/ directory, we treat this specially and
|
||||||
// run plan one level up
|
// run plan one level up
|
||||||
return filepath.Dir(parentDir)
|
return path.Dir(dir)
|
||||||
}
|
}
|
||||||
return parentDir
|
return dir
|
||||||
}
|
}
|
||||||
|
|
||||||
// CleanWorkspace deletes all .terraform/ folders from the plan paths and cleans up any plans in the output directory
|
// CleanWorkspace deletes all .terraform/ folders from the project folders and cleans up any plans in the output directory
|
||||||
func (p *PlanExecutor) CleanWorkspace(log *logging.SimpleLogger, deleteFilesPrefix string, planOutDir string, execPaths []ExecutionPath) error {
|
func (p *PlanExecutor) CleanWorkspace(log *logging.SimpleLogger, deleteFilesPrefix string, planOutDir string, repoDir string, projects []models.Project) error {
|
||||||
log.Info("cleaning workspace directory %q and plan paths %v", planOutDir, execPaths)
|
log.Info("cleaning workspace directory %q", planOutDir)
|
||||||
|
|
||||||
// delete .terraform directories
|
// delete .terraform directories
|
||||||
for _, path := range execPaths {
|
for _, project := range projects {
|
||||||
os.RemoveAll(filepath.Join(path.Absolute, ".terraform"))
|
os.RemoveAll(filepath.Join(repoDir, project.Path, ".terraform"))
|
||||||
}
|
}
|
||||||
// delete old plan files
|
// delete old plan files
|
||||||
files, err := ioutil.ReadDir(planOutDir)
|
files, err := ioutil.ReadDir(planOutDir)
|
||||||
@@ -423,14 +422,14 @@ func (p *PlanExecutor) DeleteLocalPlanFile(path string) error {
|
|||||||
|
|
||||||
// GenerateOutputFilename determines the name of the plan that will be stored in s3
|
// GenerateOutputFilename determines the name of the plan that will be stored in s3
|
||||||
// if we're executing inside a sub directory, there will be a leading underscore
|
// if we're executing inside a sub directory, there will be a leading underscore
|
||||||
func (p *PlanExecutor) GenerateOutputFilename(repoDir string, execPath ExecutionPath, tfEnvName string) string {
|
func (p *PlanExecutor) GenerateOutputFilename(project models.Project, tfEnvName string) string {
|
||||||
prefix := ""
|
prefix := ""
|
||||||
if execPath.Relative != "." {
|
if project.Path != "." {
|
||||||
// If not executing at repo root, need to encode the sub dir in the name of the output file.
|
// If not executing at repo root, need to encode the sub dir in the name of the output file.
|
||||||
// We do this by substituting / for _
|
// We do this by substituting / for _
|
||||||
// We also add an _ because this gets appended to a larger path
|
// We also add an _ because this gets appended to a larger path
|
||||||
// todo: refactor the path handling so it's all in one place
|
// todo: refactor the path handling so it's all in one place
|
||||||
prefix = "_" + strings.Replace(execPath.Relative, "/", "_", -1)
|
prefix = "_" + strings.Replace(project.Path, "/", "_", -1)
|
||||||
}
|
}
|
||||||
suffix := ""
|
suffix := ""
|
||||||
if tfEnvName != "" {
|
if tfEnvName != "" {
|
||||||
@@ -443,3 +442,27 @@ func (p *PlanExecutor) GenerateOutputFilename(repoDir string, execPath Execution
|
|||||||
func generateStatePath(path string, tfEnvName string) string {
|
func generateStatePath(path string, tfEnvName string) string {
|
||||||
return strings.Replace(path, "$ENVIRONMENT", tfEnvName, -1)
|
return strings.Replace(path, "$ENVIRONMENT", tfEnvName, -1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *PlanExecutor) updateGithubStatus(ctx *CommandContext, pathResults []PathResult) {
|
||||||
|
// the status will be the worst result
|
||||||
|
worstResult := p.worstResult(pathResults)
|
||||||
|
if worstResult == "success" {
|
||||||
|
p.github.UpdateStatus(ctx.Repo, ctx.Pull, SuccessStatus, "Plan Succeeded")
|
||||||
|
} else if worstResult == "failure" {
|
||||||
|
p.github.UpdateStatus(ctx.Repo, ctx.Pull, FailureStatus, "Plan Failed")
|
||||||
|
} else {
|
||||||
|
p.github.UpdateStatus(ctx.Repo, ctx.Pull, ErrorStatus, "Plan Error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PlanExecutor) worstResult(results []PathResult) string {
|
||||||
|
var worst string = "success"
|
||||||
|
for _, result := range results {
|
||||||
|
if result.Status == "error" {
|
||||||
|
return result.Status
|
||||||
|
} else if result.Status == "failure" {
|
||||||
|
worst = result.Status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return worst
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,40 +1,41 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"reflect"
|
"github.com/hootsuite/atlantis/models"
|
||||||
|
. "github.com/hootsuite/atlantis/testing_util"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDetermineExecPaths(t *testing.T) {
|
var p PlanExecutor
|
||||||
runTest(t, "should handle empty plan paths", "/repoPath", []string{}, []ExecutionPath{})
|
|
||||||
runTest(t, "should extract path and append to repoPath", "/repoPath", []string{"a/b/c.ext"}, []ExecutionPath{{"/repoPath/a/b", "a/b"}})
|
func TestModifiedProjects(t *testing.T) {
|
||||||
runTest(t, "should return repoPath if at root", "/repoPath", []string{"a.ext"}, []ExecutionPath{{"/repoPath", "."}})
|
runTest(t, "should handle no files modified", []string{}, []string{})
|
||||||
runTest(t, "should handle repoPath with trailing slash", "/repoPath/", []string{"a.ext"}, []ExecutionPath{{"/repoPath", "."}})
|
runTest(t, "should handle files at root", []string{"root.tf"}, []string{"."})
|
||||||
runTest(t, "should set plan dir one level up from env/ directories", "/repoPath/", []string{"env/a.ext"}, []ExecutionPath{{"/repoPath", "."}})
|
runTest(t, "should de-duplicate files at root", []string{"root1.tf", "root2.tf"}, []string{"."})
|
||||||
runTest(t, "should set plan dir one level up from env/ directories and deduplicate plans.", "/repoPath/", []string{"env/a.ext", "b.ext"}, []ExecutionPath{{"/repoPath", "."}})
|
runTest(t, "should handle sub directories", []string{"sub/dir/file.tf"}, []string{"sub/dir"})
|
||||||
runTest(t, "should de-depluciate", "/repoPath/", []string{"a/b/c.ext", "a/b/d.ext"}, []ExecutionPath{{"/repoPath/a/b", "a/b"}})
|
runTest(t, "should de-duplicate files in sub directories", []string{"sub/dir/file.tf", "sub/dir/file2.tf"}, []string{"sub/dir"})
|
||||||
|
runTest(t, "should handle nested sub directories", []string{"root.tf", "sub/child.tf", "sub/sub/child.tf"}, []string{".", "sub", "sub/sub"})
|
||||||
|
runTest(t, "should use parent of env/ dirs", []string{"env/env.tf"}, []string{"."})
|
||||||
|
runTest(t, "should use parent of env/ dirs in sub dirs", []string{"sub/env/env.tf"}, []string{"sub"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func runTest(t *testing.T, testDescrip string, repoPath string, filesChanged []string, expected []ExecutionPath) {
|
func runTest(t *testing.T, testDescrip string, filesChanged []string, expectedPaths []string) {
|
||||||
p := PlanExecutor{}
|
projects := p.ModifiedProjects("owner/repo", filesChanged)
|
||||||
plans := p.DetermineExecPaths(repoPath, filesChanged)
|
for i, p := range projects {
|
||||||
if !reflect.DeepEqual(expected, plans) {
|
t.Log(testDescrip)
|
||||||
t.Errorf("%s: expected %v, got %v", testDescrip, expected, plans)
|
Equals(t, expectedPaths[i], p.Path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateOutputFilename(t *testing.T) {
|
func TestGenerateOutputFilename(t *testing.T) {
|
||||||
runTestGetOutputFilename(t, "should handle empty plan path", "/repoPath", NewExecutionPath("", ""), "env", ".tfplan.env")
|
runTestGetOutputFilename(t, "should handle root", ".", "env", ".tfplan.env")
|
||||||
runTestGetOutputFilename(t, "should handle empty environment", "/repoPath", NewExecutionPath("", ""), "", ".tfplan")
|
runTestGetOutputFilename(t, "should handle empty environment", ".", "", ".tfplan")
|
||||||
runTestGetOutputFilename(t, "should prepend underscore on relative paths", "/repoPath", NewExecutionPath("", "a/b"), "", "_a_b.tfplan")
|
runTestGetOutputFilename(t, "should prepend underscore on relative paths", "a/b", "", "_a_b.tfplan")
|
||||||
runTestGetOutputFilename(t, "should work with relative path and environment", "/repoPath", NewExecutionPath("", "a/b"), "env", "_a_b.tfplan.env")
|
runTestGetOutputFilename(t, "should prepend underscore on relative paths and env", "a/b", "env", "_a_b.tfplan.env")
|
||||||
runTestGetOutputFilename(t, "should exec path at root", "/a/b", NewExecutionPath("", "."), "env", ".tfplan.env")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func runTestGetOutputFilename(t *testing.T, testDescrip string, repoPath string, tfPlanPath ExecutionPath, tfEnvName string, expected string) {
|
func runTestGetOutputFilename(t *testing.T, testDescrip string, path string, env string, expected string) {
|
||||||
p := PlanExecutor{}
|
t.Log(testDescrip)
|
||||||
outputFileName := p.GenerateOutputFilename(repoPath, tfPlanPath, tfEnvName)
|
outputFileName := p.GenerateOutputFilename(models.NewProject("owner/repo", path), env)
|
||||||
if !reflect.DeepEqual(expected, outputFileName) {
|
Equals(t, expected, outputFileName)
|
||||||
t.Errorf("%s: expected %v, got %v", testDescrip, expected, outputFileName)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,17 +3,18 @@ package server
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/hootsuite/atlantis/logging"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"github.com/hootsuite/atlantis/logging"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const InlineShebang = "/bin/sh -e"
|
const InlineShebang = "/bin/sh -e"
|
||||||
|
|
||||||
|
// todo: make OO
|
||||||
// PreRun is a function that will determine whether
|
// PreRun is a function that will determine whether
|
||||||
func PreRun(c *Config, log *logging.SimpleLogger, execPath string, command *Command) error {
|
func PreRun(c *Config, log *logging.SimpleLogger, path string, command *Command) error {
|
||||||
log.Info("Staring pre run in %s", execPath)
|
log.Info("Staring pre run in %s", path)
|
||||||
var execScript string
|
var execScript string
|
||||||
|
|
||||||
if command.commandType == Plan {
|
if command.commandType == Plan {
|
||||||
@@ -44,7 +45,7 @@ func PreRun(c *Config, log *logging.SimpleLogger, execPath string, command *Comm
|
|||||||
if c.TerraformVersion != "" {
|
if c.TerraformVersion != "" {
|
||||||
os.Setenv("ATLANTIS_TERRAFORM_VERSION", c.TerraformVersion)
|
os.Setenv("ATLANTIS_TERRAFORM_VERSION", c.TerraformVersion)
|
||||||
}
|
}
|
||||||
os.Setenv("WORKSPACE", execPath)
|
os.Setenv("WORKSPACE", path)
|
||||||
output, err := execute(execScript)
|
output, err := execute(execScript)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/hootsuite/atlantis/logging"
|
||||||
|
. "github.com/hootsuite/atlantis/testing_util"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
. "github.com/hootsuite/atlantis/testing_util"
|
|
||||||
"github.com/hootsuite/atlantis/logging"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var level logging.LogLevel = logging.Info
|
var level logging.LogLevel = logging.Info
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/google/go-github/github"
|
"github.com/google/go-github/github"
|
||||||
|
"github.com/hootsuite/atlantis/models"
|
||||||
"regexp"
|
"regexp"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ type Command struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *RequestParser) determineCommand(comment *github.IssueCommentEvent) (*Command, error) {
|
func (r *RequestParser) determineCommand(comment *github.IssueCommentEvent) (*Command, error) {
|
||||||
|
// for legacy, also support "run" instead of atlantis
|
||||||
atlantisCommentRegex := `^(?:run|atlantis) (plan|apply|help)([[:blank:]])?([a-zA-Z0-9_-]+)?\s*(--verbose)?$`
|
atlantisCommentRegex := `^(?:run|atlantis) (plan|apply|help)([[:blank:]])?([a-zA-Z0-9_-]+)?\s*(--verbose)?$`
|
||||||
runPlanMatcher := regexp.MustCompile(atlantisCommentRegex)
|
runPlanMatcher := regexp.MustCompile(atlantisCommentRegex)
|
||||||
|
|
||||||
@@ -37,7 +39,7 @@ func (r *RequestParser) determineCommand(comment *github.IssueCommentEvent) (*Co
|
|||||||
return nil, errors.New("key 'comment.comment.body' is null")
|
return nil, errors.New("key 'comment.comment.body' is null")
|
||||||
}
|
}
|
||||||
|
|
||||||
// extract the command and environment. ex. for "run plan staging", the command is "plan", and the environment is "staging"
|
// extract the command and environment. ex. for "atlantis plan staging", the command is "plan", and the environment is "staging"
|
||||||
match := runPlanMatcher.FindStringSubmatch(*commentBody)
|
match := runPlanMatcher.FindStringSubmatch(*commentBody)
|
||||||
if len(match) < 5 {
|
if len(match) < 5 {
|
||||||
var truncated = *commentBody
|
var truncated = *commentBody
|
||||||
@@ -65,13 +67,19 @@ func (r *RequestParser) determineCommand(comment *github.IssueCommentEvent) (*Co
|
|||||||
return command, nil
|
return command, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *RequestParser) extractCommentData(comment *github.IssueCommentEvent, params *ExecutionContext) error {
|
func (r *RequestParser) extractCommentData(comment *github.IssueCommentEvent, ctx *CommandContext) error {
|
||||||
missingField := "<nil>"
|
|
||||||
|
|
||||||
repoFullName := comment.Repo.FullName
|
repoFullName := comment.Repo.FullName
|
||||||
if repoFullName == nil {
|
if repoFullName == nil {
|
||||||
return errors.New("key 'comment.repo.full_name' is null")
|
return errors.New("key 'comment.repo.full_name' is null")
|
||||||
}
|
}
|
||||||
|
repoOwner := comment.Repo.Owner.Login
|
||||||
|
if repoOwner == nil {
|
||||||
|
return errors.New("key 'comment.repo.owner.login' is null")
|
||||||
|
}
|
||||||
|
repoName := comment.Repo.Name
|
||||||
|
if repoName == nil {
|
||||||
|
return errors.New("key 'comment.repo.name' is null")
|
||||||
|
}
|
||||||
pullNum := comment.Issue.Number
|
pullNum := comment.Issue.Number
|
||||||
if pullNum == nil {
|
if pullNum == nil {
|
||||||
return errors.New("key 'comment.issue.number' is null")
|
return errors.New("key 'comment.issue.number' is null")
|
||||||
@@ -84,10 +92,6 @@ func (r *RequestParser) extractCommentData(comment *github.IssueCommentEvent, pa
|
|||||||
if commentorUsername == nil {
|
if commentorUsername == nil {
|
||||||
return errors.New("key 'comment.comment.user.login' is null")
|
return errors.New("key 'comment.comment.user.login' is null")
|
||||||
}
|
}
|
||||||
commentorEmail := comment.Comment.User.Email
|
|
||||||
if commentorEmail == nil {
|
|
||||||
commentorEmail = &missingField
|
|
||||||
}
|
|
||||||
repoSSHURL := comment.Repo.SSHURL
|
repoSSHURL := comment.Repo.SSHURL
|
||||||
if repoSSHURL == nil {
|
if repoSSHURL == nil {
|
||||||
return errors.New("key 'comment.repo.sshurl' is null")
|
return errors.New("key 'comment.repo.sshurl' is null")
|
||||||
@@ -96,17 +100,23 @@ func (r *RequestParser) extractCommentData(comment *github.IssueCommentEvent, pa
|
|||||||
if htmlURL == nil {
|
if htmlURL == nil {
|
||||||
return errors.New("key 'comment.issue.htmlUrl' is null")
|
return errors.New("key 'comment.issue.htmlUrl' is null")
|
||||||
}
|
}
|
||||||
params.requesterUsername = *commentorUsername
|
ctx.Repo = models.Repo{
|
||||||
params.requesterEmail = *commentorEmail
|
FullName: *repoFullName,
|
||||||
params.repoFullName = *repoFullName
|
Owner: *repoOwner,
|
||||||
params.pullNum = *pullNum
|
Name: *repoName,
|
||||||
params.pullCreator = *pullCreator
|
SSHURL: *repoSSHURL,
|
||||||
params.repoSSHUrl = *repoSSHURL
|
}
|
||||||
params.htmlUrl = *htmlURL
|
ctx.User = models.User{
|
||||||
|
Username: *commentorUsername,
|
||||||
|
}
|
||||||
|
ctx.Pull = models.PullRequest{
|
||||||
|
Num: *pullNum,
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *RequestParser) extractPullData(pull *github.PullRequest, params *ExecutionContext) error {
|
func (r *RequestParser) extractPullData(pull *github.PullRequest, params *CommandContext) error {
|
||||||
commit := pull.Head.SHA
|
commit := pull.Head.SHA
|
||||||
if commit == nil {
|
if commit == nil {
|
||||||
return errors.New("key 'pull.head.sha' is null")
|
return errors.New("key 'pull.head.sha' is null")
|
||||||
@@ -123,9 +133,21 @@ func (r *RequestParser) extractPullData(pull *github.PullRequest, params *Execut
|
|||||||
if branch == nil {
|
if branch == nil {
|
||||||
return errors.New("key 'pull.head.ref' is null")
|
return errors.New("key 'pull.head.ref' is null")
|
||||||
}
|
}
|
||||||
params.branch = *branch
|
authorUsername := pull.User.Login
|
||||||
params.head = *commit
|
if authorUsername == nil {
|
||||||
params.base = *base
|
return errors.New("key 'pull.user.login' is null")
|
||||||
params.pullLink = *pullLink
|
}
|
||||||
|
num := pull.Number
|
||||||
|
if num == nil {
|
||||||
|
return errors.New("key 'pull.num' is null")
|
||||||
|
}
|
||||||
|
params.Pull = models.PullRequest{
|
||||||
|
BaseCommit: *base,
|
||||||
|
Author: *authorUsername,
|
||||||
|
Branch: *branch,
|
||||||
|
HeadCommit: *commit,
|
||||||
|
Link: *pullLink,
|
||||||
|
Num: *num,
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ func NewS3Client(awsConfig *AWSConfig, bucketName string, prefix string) S3Clien
|
|||||||
return _s3.GetS3Info()
|
return _s3.GetS3Info()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// todo: make OO
|
||||||
func UploadPlanFile(s S3Client, key string, outputFilePath string) error {
|
func UploadPlanFile(s S3Client, key string, outputFilePath string) error {
|
||||||
file, err := os.Open(outputFilePath)
|
file, err := os.Open(outputFilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -75,7 +76,7 @@ func UploadPlanFile(s S3Client, key string, outputFilePath string) error {
|
|||||||
ContentLength: aws.Int64(size),
|
ContentLength: aws.Int64(size),
|
||||||
ContentType: aws.String(fileType),
|
ContentType: aws.String(fileType),
|
||||||
Metadata: map[string]*string{
|
Metadata: map[string]*string{
|
||||||
"Key": aws.String("MetadataValue"), //required
|
"Key": aws.String("MetadataValue"), //required //todo: I don't think this is required
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
210
server/server.go
210
server/server.go
@@ -2,27 +2,30 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"encoding/json"
|
|
||||||
|
|
||||||
"github.com/google/go-github/github"
|
|
||||||
"github.com/urfave/cli"
|
|
||||||
"github.com/hootsuite/atlantis/recovery"
|
|
||||||
"github.com/hootsuite/atlantis/locking"
|
|
||||||
"github.com/urfave/negroni"
|
|
||||||
"github.com/hootsuite/atlantis/middleware"
|
|
||||||
"github.com/hootsuite/atlantis/logging"
|
|
||||||
"github.com/elazarl/go-bindata-assetfs"
|
"github.com/elazarl/go-bindata-assetfs"
|
||||||
|
"github.com/google/go-github/github"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/pkg/errors"
|
"github.com/hootsuite/atlantis/locking"
|
||||||
"io/ioutil"
|
|
||||||
"github.com/hootsuite/atlantis/locking/dynamodb"
|
|
||||||
"github.com/hootsuite/atlantis/locking/boltdb"
|
"github.com/hootsuite/atlantis/locking/boltdb"
|
||||||
|
"github.com/hootsuite/atlantis/locking/dynamodb"
|
||||||
|
"github.com/hootsuite/atlantis/logging"
|
||||||
|
"github.com/hootsuite/atlantis/middleware"
|
||||||
|
"github.com/hootsuite/atlantis/models"
|
||||||
|
"github.com/hootsuite/atlantis/recovery"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/urfave/cli"
|
||||||
|
"github.com/urfave/negroni"
|
||||||
|
"io/ioutil"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -31,7 +34,7 @@ const (
|
|||||||
LockingDynamoDBBackend = "dynamodb"
|
LockingDynamoDBBackend = "dynamodb"
|
||||||
)
|
)
|
||||||
|
|
||||||
// WebhookServer listens for Github webhooks and runs the necessary Atlantis command
|
// Server listens for Github webhooks and runs the necessary Atlantis command
|
||||||
type Server struct {
|
type Server struct {
|
||||||
router *mux.Router
|
router *mux.Router
|
||||||
port int
|
port int
|
||||||
@@ -46,16 +49,16 @@ type Server struct {
|
|||||||
logger *logging.SimpleLogger
|
logger *logging.SimpleLogger
|
||||||
githubComments *GithubCommentRenderer
|
githubComments *GithubCommentRenderer
|
||||||
requestParser *RequestParser
|
requestParser *RequestParser
|
||||||
lockingBackend locking.Backend
|
lockingClient *locking.Client
|
||||||
atlantisURL string
|
atlantisURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// the mapstructure tags correspond to flags in cmd/server.go
|
// the mapstructure tags correspond to flags in cmd/server.go
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
GitHubHostname string `mapstructure:"gh-hostname"`
|
GitHubHostname string `mapstructure:"gh-hostname"`
|
||||||
GitHubUser string `mapstructure:"gh-user"`
|
GitHubUser string `mapstructure:"gh-user"`
|
||||||
GitHubPassword string `mapstructure:"gh-password"`
|
GitHubPassword string `mapstructure:"gh-password"`
|
||||||
SSHKey string `mapstructure:"ssh-key"`
|
SSHKey string `mapstructure:"ssh-key"`
|
||||||
AssumeRole string `mapstructure:"aws-assume-role-arn"`
|
AssumeRole string `mapstructure:"aws-assume-role-arn"`
|
||||||
Port int `mapstructure:"port"`
|
Port int `mapstructure:"port"`
|
||||||
ScratchDir string `mapstructure:"scratch-dir"`
|
ScratchDir string `mapstructure:"scratch-dir"`
|
||||||
@@ -69,22 +72,51 @@ type ServerConfig struct {
|
|||||||
LockingDynamoDBTable string `mapstructure:"locking-dynamodb-table"`
|
LockingDynamoDBTable string `mapstructure:"locking-dynamodb-table"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExecutionContext struct {
|
// todo: rename to Command
|
||||||
repoFullName string
|
type CommandContext struct {
|
||||||
pullNum int
|
Repo models.Repo
|
||||||
requesterUsername string
|
Pull models.PullRequest
|
||||||
requesterEmail string
|
User models.User
|
||||||
comment string
|
Command *Command
|
||||||
repoSSHUrl string
|
Log *logging.SimpleLogger
|
||||||
head string
|
}
|
||||||
// commit base sha
|
|
||||||
base string
|
type ExecutionResult struct {
|
||||||
pullLink string
|
SetupError Templater
|
||||||
branch string
|
SetupFailure Templater
|
||||||
htmlUrl string
|
PathResults []PathResult
|
||||||
pullCreator string
|
Command CommandType
|
||||||
command *Command
|
}
|
||||||
log *logging.SimpleLogger
|
|
||||||
|
type PathResult struct {
|
||||||
|
Path string
|
||||||
|
Status string // todo: this should be an enum for success/error/failure
|
||||||
|
Result Templater
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExecutionPath struct {
|
||||||
|
// Absolute is the full path on the OS where we will execute.
|
||||||
|
// Will never end with a '/'.
|
||||||
|
Absolute string
|
||||||
|
// Relative is the path relative to the repo root.
|
||||||
|
// Will never end with a '/'.
|
||||||
|
Relative string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewExecutionPath(absolutePath string, relativePath string) ExecutionPath {
|
||||||
|
return ExecutionPath{filepath.Clean(absolutePath), filepath.Clean(relativePath)}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Templater interface {
|
||||||
|
Template() *CompiledTemplate
|
||||||
|
}
|
||||||
|
|
||||||
|
type GeneralError struct {
|
||||||
|
Error error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g GeneralError) Template() *CompiledTemplate {
|
||||||
|
return GeneralErrorTmpl
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(config ServerConfig) (*Server, error) {
|
func NewServer(config ServerConfig) (*Server, error) {
|
||||||
@@ -108,34 +140,42 @@ func NewServer(config ServerConfig) (*Server, error) {
|
|||||||
AWSRegion: config.AWSRegion,
|
AWSRegion: config.AWSRegion,
|
||||||
AWSRoleArn: config.AssumeRole,
|
AWSRoleArn: config.AssumeRole,
|
||||||
}
|
}
|
||||||
var lockingBackend locking.Backend
|
var lockingClient *locking.Client
|
||||||
if config.LockingBackend == LockingDynamoDBBackend {
|
if config.LockingBackend == LockingDynamoDBBackend {
|
||||||
session, err := awsConfig.CreateAWSSession()
|
session, err := awsConfig.CreateAWSSession()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "creating aws session for DynamoDB")
|
return nil, errors.Wrap(err, "creating aws session for DynamoDB")
|
||||||
}
|
}
|
||||||
lockingBackend = dynamodb.New(config.LockingDynamoDBTable, session)
|
lockingClient = locking.NewClient(dynamodb.New(config.LockingDynamoDBTable, session))
|
||||||
} else {
|
} else {
|
||||||
var err error
|
backend, err := boltdb.New(config.DataDir)
|
||||||
lockingBackend, err = boltdb.New(config.DataDir)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
lockingClient = locking.NewClient(backend)
|
||||||
}
|
}
|
||||||
baseExecutor := BaseExecutor{
|
applyExecutor := &ApplyExecutor{
|
||||||
github: githubClient,
|
github: githubClient,
|
||||||
awsConfig: awsConfig,
|
awsConfig: awsConfig,
|
||||||
scratchDir: config.ScratchDir,
|
scratchDir: config.ScratchDir,
|
||||||
s3Bucket: config.S3Bucket,
|
s3Bucket: config.S3Bucket,
|
||||||
sshKey: config.SSHKey,
|
sshKey: config.SSHKey,
|
||||||
ghComments: githubComments,
|
|
||||||
terraform: terraformClient,
|
terraform: terraformClient,
|
||||||
githubCommentRenderer: githubComments,
|
githubCommentRenderer: githubComments,
|
||||||
lockingBackend: lockingBackend,
|
lockingClient: lockingClient,
|
||||||
|
requireApproval: config.RequireApproval,
|
||||||
}
|
}
|
||||||
applyExecutor := &ApplyExecutor{BaseExecutor: baseExecutor, requireApproval: config.RequireApproval, atlantisGithubUser: config.GitHubUser}
|
planExecutor := &PlanExecutor{
|
||||||
planExecutor := &PlanExecutor{BaseExecutor: baseExecutor}
|
github: githubClient,
|
||||||
helpExecutor := &HelpExecutor{BaseExecutor: baseExecutor}
|
awsConfig: awsConfig,
|
||||||
|
scratchDir: config.ScratchDir,
|
||||||
|
s3Bucket: config.S3Bucket,
|
||||||
|
sshKey: config.SSHKey,
|
||||||
|
terraform: terraformClient,
|
||||||
|
githubCommentRenderer: githubComments,
|
||||||
|
lockingClient: lockingClient,
|
||||||
|
}
|
||||||
|
helpExecutor := &HelpExecutor{}
|
||||||
logger := logging.NewSimpleLogger("server", log.New(os.Stderr, "", log.LstdFlags), false, logging.ToLogLevel(config.LogLevel))
|
logger := logging.NewSimpleLogger("server", log.New(os.Stderr, "", log.LstdFlags), false, logging.ToLogLevel(config.LogLevel))
|
||||||
router := mux.NewRouter()
|
router := mux.NewRouter()
|
||||||
return &Server{
|
return &Server{
|
||||||
@@ -152,7 +192,7 @@ func NewServer(config ServerConfig) (*Server, error) {
|
|||||||
logger: logger,
|
logger: logger,
|
||||||
githubComments: githubComments,
|
githubComments: githubComments,
|
||||||
requestParser: &RequestParser{},
|
requestParser: &RequestParser{},
|
||||||
lockingBackend: lockingBackend,
|
lockingClient: lockingClient,
|
||||||
atlantisURL: config.AtlantisURL,
|
atlantisURL: config.AtlantisURL,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -172,7 +212,7 @@ func (s *Server) Start() error {
|
|||||||
|
|
||||||
// function that planExecutor can use to construct delete lock urls
|
// function that planExecutor can use to construct delete lock urls
|
||||||
// injecting this here because this is the earliest routes are created
|
// injecting this here because this is the earliest routes are created
|
||||||
s.planExecutor.DeleteLockURL = func (lockID string) string {
|
s.planExecutor.DeleteLockURL = func(lockID string) string {
|
||||||
// ignoring error since guaranteed to succeed if "id" is specified
|
// ignoring error since guaranteed to succeed if "id" is specified
|
||||||
u, _ := deleteLockRoute.URL("id", url.QueryEscape(lockID))
|
u, _ := deleteLockRoute.URL("id", url.QueryEscape(lockID))
|
||||||
return s.atlantisURL + u.RequestURI()
|
return s.atlantisURL + u.RequestURI()
|
||||||
@@ -189,23 +229,27 @@ func (s *Server) Start() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) index(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) index(w http.ResponseWriter, r *http.Request) {
|
||||||
locks, err := s.lockingBackend.ListLocks()
|
locks, err := s.lockingClient.List()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
fmt.Fprintf(w, "Could not retrieve locks: %s", err)
|
fmt.Fprintf(w, "Could not retrieve locks: %s", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
type runLock struct {
|
type lock struct {
|
||||||
locking.Run
|
UnlockURL string
|
||||||
UnlockURL string
|
RepoFullName string
|
||||||
|
PullNum int
|
||||||
|
Time time.Time
|
||||||
}
|
}
|
||||||
var results []runLock
|
var results []lock
|
||||||
for id, v := range locks {
|
for id, v := range locks {
|
||||||
u, _ := s.router.Get(deleteLockRoute).URL("id", url.QueryEscape(id))
|
u, _ := s.router.Get(deleteLockRoute).URL("id", url.QueryEscape(id))
|
||||||
results = append(results, runLock{
|
results = append(results, lock{
|
||||||
v,
|
UnlockURL: u.String(),
|
||||||
u.String(),
|
RepoFullName: v.Project.RepoFullName,
|
||||||
|
PullNum: v.PullNum,
|
||||||
|
Time: v.Time,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
indexTemplate.Execute(w, results)
|
indexTemplate.Execute(w, results)
|
||||||
@@ -222,7 +266,7 @@ func (s *Server) deleteLock(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
fmt.Fprint(w, "invalid lock id")
|
fmt.Fprint(w, "invalid lock id")
|
||||||
}
|
}
|
||||||
if err := s.lockingBackend.Unlock(idUnencoded); err != nil {
|
if err := s.lockingClient.Unlock(idUnencoded); err != nil {
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
fmt.Fprintf(w, "Failed to unlock: %s", err)
|
fmt.Fprintf(w, "Failed to unlock: %s", err)
|
||||||
return
|
return
|
||||||
@@ -238,7 +282,7 @@ func (s *Server) postHooks(w http.ResponseWriter, r *http.Request) {
|
|||||||
bytes, err := ioutil.ReadAll(r.Body)
|
bytes, err := ioutil.ReadAll(r.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
fmt.Fprintf(w,"could not read body: %s\n", err)
|
fmt.Fprintf(w, "could not read body: %s\n", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,42 +305,27 @@ func (s *Server) postHooks(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (s *Server) handlePullClosedEvent(w http.ResponseWriter, pullEvent github.PullRequestEvent, githubReqID string) {
|
func (s *Server) handlePullClosedEvent(w http.ResponseWriter, pullEvent github.PullRequestEvent, githubReqID string) {
|
||||||
repo := *pullEvent.Repo.FullName
|
repo := *pullEvent.Repo.FullName
|
||||||
pullNum := *pullEvent.PullRequest.Number
|
pullNum := *pullEvent.PullRequest.Number
|
||||||
locks, err := s.lockingBackend.FindLocksForPull(repo, pullNum)
|
s.logger.Debug("Unlocking locks for repo %s and pull %d %s", repo, pullNum, githubReqID)
|
||||||
|
err := s.lockingClient.UnlockByPull(repo, pullNum)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Err("finding locks for repo %s pull %d: %s", repo, pullNum, err)
|
s.logger.Err("unlocking locks for repo %s pull %d: %v", repo, pullNum, err)
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
fmt.Fprintf(w, "Error finding locks: %s\n", err)
|
fmt.Fprintf(w, "Error unlocking locks: %v\n", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
fmt.Fprintln(w, "Locks unlocked")
|
||||||
s.logger.Debug("Unlocking locks %v %s", locks, githubReqID)
|
|
||||||
var errors []error
|
|
||||||
for _, l := range locks {
|
|
||||||
if err := s.lockingBackend.Unlock(l); err != nil {
|
|
||||||
errors = append(errors, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(errors) != 0 {
|
|
||||||
s.logger.Err("unlocking locks for repo %s pull %d: %v", repo, pullNum, errors)
|
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
|
||||||
fmt.Fprintf(w, "Error unlocking locks: %v\n", errors)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintln(w,"Locks unlocked")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleCommentCreatedEvent(w http.ResponseWriter, comment github.IssueCommentEvent, githubReqID string) {
|
func (s *Server) handleCommentCreatedEvent(w http.ResponseWriter, comment github.IssueCommentEvent, githubReqID string) {
|
||||||
// determine if the comment matches a plan or apply command
|
// determine if the comment matches a plan or apply command
|
||||||
ctx := &ExecutionContext{}
|
ctx := &CommandContext{}
|
||||||
command, err := s.requestParser.determineCommand(&comment)
|
command, err := s.requestParser.determineCommand(&comment)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Debug("Ignoring request: %v %s", err, githubReqID)
|
s.logger.Debug("Ignoring request: %v %s", err, githubReqID)
|
||||||
fmt.Fprintln(w, "Ignoring")
|
fmt.Fprintln(w, "Ignoring")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx.command = command
|
ctx.Command = command
|
||||||
|
|
||||||
if err = s.requestParser.extractCommentData(&comment, ctx); err != nil {
|
if err = s.requestParser.extractCommentData(&comment, ctx); err != nil {
|
||||||
s.logger.Err("Failed parsing event: %v %s", err, githubReqID)
|
s.logger.Err("Failed parsing event: %v %s", err, githubReqID)
|
||||||
@@ -308,32 +337,32 @@ func (s *Server) handleCommentCreatedEvent(w http.ResponseWriter, comment github
|
|||||||
go s.executeCommand(ctx)
|
go s.executeCommand(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) executeCommand(ctx *ExecutionContext) {
|
func (s *Server) executeCommand(ctx *CommandContext) {
|
||||||
src := fmt.Sprintf("%s/pull/%d", ctx.repoFullName, ctx.pullNum)
|
src := fmt.Sprintf("%s/pull/%d", ctx.Repo.FullName, ctx.Pull.Num)
|
||||||
// it's safe to reuse the underlying logger s.logger.Log
|
// it's safe to reuse the underlying logger s.logger.Log
|
||||||
ctx.log = logging.NewSimpleLogger(src, s.logger.Log, true, s.logger.Level)
|
ctx.Log = logging.NewSimpleLogger(src, s.logger.Log, true, s.logger.Level)
|
||||||
defer s.recover(ctx)
|
defer s.recover(ctx)
|
||||||
|
|
||||||
// we've got data from the comment, now we need to get data from the actual PR
|
// we've got data from the comment, now we need to get data from the actual PR
|
||||||
pull, _, err := s.githubClient.GetPullRequest(ctx.repoFullName, ctx.pullNum)
|
pull, _, err := s.githubClient.GetPullRequest(ctx.Repo, ctx.Pull.Num)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.log.Err("pull request data api call failed: %v", err)
|
ctx.Log.Err("pull request data api call failed: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.requestParser.extractPullData(pull, ctx); err != nil {
|
if err := s.requestParser.extractPullData(pull, ctx); err != nil {
|
||||||
ctx.log.Err("failed to extract required fields from comment data: %v", err)
|
ctx.Log.Err("failed to extract required fields from comment data: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
switch ctx.command.commandType {
|
switch ctx.Command.commandType {
|
||||||
case Plan:
|
case Plan:
|
||||||
s.planExecutor.Exec(s.planExecutor.execute, ctx, s.githubClient)
|
s.planExecutor.execute(ctx, s.githubClient)
|
||||||
case Apply:
|
case Apply:
|
||||||
s.applyExecutor.Exec(s.applyExecutor.execute, ctx, s.githubClient)
|
s.applyExecutor.execute(ctx, s.githubClient)
|
||||||
case Help:
|
case Help:
|
||||||
s.helpExecutor.execute(ctx, s.githubClient)
|
s.helpExecutor.execute(ctx, s.githubClient)
|
||||||
default:
|
default:
|
||||||
ctx.log.Err("failed to determine desired command, neither plan nor apply")
|
ctx.Log.Err("failed to determine desired command, neither plan nor apply")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,11 +375,10 @@ func (s *Server) isPullClosedEvent(event github.PullRequestEvent) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// recover logs and creates a comment on the pull request for panics
|
// recover logs and creates a comment on the pull request for panics
|
||||||
func (s *Server) recover(ctx *ExecutionContext) {
|
func (s *Server) recover(ctx *CommandContext) {
|
||||||
if err := recover(); err != nil {
|
if err := recover(); err != nil {
|
||||||
ghCtx := s.planExecutor.githubContext(ctx) // this won't have every field but it has the ones needed by CreateComment
|
|
||||||
stack := recovery.Stack(3)
|
stack := recovery.Stack(3)
|
||||||
s.githubClient.CreateComment(ghCtx, fmt.Sprintf("**Error: goroutine panic. This is a bug.**\n```\n%s\n%s```", err, stack))
|
s.githubClient.CreateComment(ctx, fmt.Sprintf("**Error: goroutine panic. This is a bug.**\n```\n%s\n%s```", err, stack))
|
||||||
ctx.log.Err("PANIC: %s\n%s", err, stack)
|
ctx.Log.Err("PANIC: %s\n%s", err, stack)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,52 +2,55 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/hootsuite/atlantis/logging"
|
||||||
|
"github.com/hootsuite/atlantis/models"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"github.com/hootsuite/atlantis/logging"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type TerraformClient struct {
|
type TerraformClient struct {
|
||||||
tfExecutableName string
|
tfExecutableName string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TerraformClient) ConfigureRemoteState(log *logging.SimpleLogger, execPath ExecutionPath, tfEnvName string, sshKey string) (string, error) {
|
func (t *TerraformClient) ConfigureRemoteState(log *logging.SimpleLogger, repoDir string, project models.Project, env string, sshKey string) (string, error) {
|
||||||
log.Info("setting up remote state in directory %q", execPath.Absolute)
|
absolutePath := filepath.Join(repoDir, project.Path)
|
||||||
|
log.Info("setting up remote state in directory %q", absolutePath)
|
||||||
var remoteSetupCmdArgs []string
|
var remoteSetupCmdArgs []string
|
||||||
|
|
||||||
// Check if setup file exists
|
// Check if setup file exists
|
||||||
setupFileInfo, err := os.Stat(filepath.Join(execPath.Absolute, "setup.sh"))
|
setupFileInfo, err := os.Stat(filepath.Join(absolutePath, "setup.sh"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("setup.sh file doesn't exist in terraform plan path %q: %v", execPath.Relative, err)
|
return "", fmt.Errorf("setup.sh file doesn't exist in terraform plan path %q: %v", project.Path, err)
|
||||||
}
|
}
|
||||||
// Check if setup file is executed
|
// Check if setup file is executed
|
||||||
if setupFileInfo.Mode() != os.FileMode(0755) {
|
if setupFileInfo.Mode() != os.FileMode(0755) {
|
||||||
return "", fmt.Errorf("setup file isn't executable, required permissions are 0755")
|
return "", fmt.Errorf("setup file isn't executable, required permissions are 0755")
|
||||||
}
|
}
|
||||||
// Check if environment is specified
|
// Check if environment is specified
|
||||||
if tfEnvName == "" {
|
if env == "" {
|
||||||
// Check if env/ folder exist and environment isn't specified
|
// Check if env/ folder exist and environment isn't specified
|
||||||
if _, err := os.Stat(filepath.Join(execPath.Absolute, "env")); err == nil {
|
// todo: make env a constant (environmentDirName)
|
||||||
|
if _, err := os.Stat(filepath.Join(absolutePath, "env")); err == nil {
|
||||||
log.Info("environment directory exists but no environment was supplied")
|
log.Info("environment directory exists but no environment was supplied")
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Check if the environment file exists
|
// Check if the environment file exists
|
||||||
envFile := tfEnvName + ".tfvars"
|
envFile := env + ".tfvars"
|
||||||
envPath := filepath.Join(execPath.Absolute, "env")
|
envPath := filepath.Join(absolutePath, "env")
|
||||||
if _, err := os.Stat(filepath.Join(envPath, envFile)); err != nil {
|
if _, err := os.Stat(filepath.Join(envPath, envFile)); err != nil {
|
||||||
return "", fmt.Errorf("environment file %q not found in %q", envFile, envPath)
|
return "", fmt.Errorf("environment file %q not found in %q", envFile, envPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set environment file parameter for ./setup.sh
|
// Set environment file parameter for ./setup.sh
|
||||||
if tfEnvName != "" {
|
if env != "" {
|
||||||
remoteSetupCmdArgs = append(remoteSetupCmdArgs, "-e", tfEnvName)
|
remoteSetupCmdArgs = append(remoteSetupCmdArgs, "-e", env)
|
||||||
}
|
}
|
||||||
remoteSetupCmd := exec.Command("./setup.sh", remoteSetupCmdArgs...)
|
remoteSetupCmd := exec.Command("./setup.sh", remoteSetupCmdArgs...)
|
||||||
remoteSetupCmd.Dir = execPath.Absolute
|
remoteSetupCmd.Dir = absolutePath
|
||||||
// Check if ssh key is set
|
// Check if ssh key is set
|
||||||
if sshKey != "" {
|
if sshKey != "" {
|
||||||
// Fixing a bug when git isn't found in path when environment variables are set for command
|
// Fixing a bug when git isn't found in path when environment variables are set for command
|
||||||
@@ -71,9 +74,9 @@ func (t *TerraformClient) ConfigureRemoteState(log *logging.SimpleLogger, execPa
|
|||||||
return remoteStatePath, nil
|
return remoteStatePath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TerraformClient) RunTerraformCommand(path ExecutionPath, tfPlanCmd []string, tfEnvVars []string) ([]string, string, error) {
|
func (t *TerraformClient) RunTerraformCommand(path string, tfPlanCmd []string, tfEnvVars []string) ([]string, string, error) {
|
||||||
terraformCmd := exec.Command(t.tfExecutableName, tfPlanCmd...)
|
terraformCmd := exec.Command(t.tfExecutableName, tfPlanCmd...)
|
||||||
terraformCmd.Dir = path.Absolute
|
terraformCmd.Dir = path
|
||||||
terraformCmd.Env = tfEnvVars
|
terraformCmd.Env = tfEnvVars
|
||||||
out, err := terraformCmd.CombinedOutput()
|
out, err := terraformCmd.CombinedOutput()
|
||||||
output := string(out)
|
output := string(out)
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
// taken from https://github.com/benbjohnson/testing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"path/filepath"
|
|
||||||
"reflect"
|
|
||||||
"runtime"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// assert fails the test if the condition is false.
|
|
||||||
func assert(tb testing.TB, condition bool, msg string, v ...interface{}) {
|
|
||||||
if !condition {
|
|
||||||
_, file, line, _ := runtime.Caller(1)
|
|
||||||
fmt.Printf("\033[31m%s:%d: "+msg+"\033[39m\n\n", append([]interface{}{filepath.Base(file), line}, v...)...)
|
|
||||||
tb.FailNow()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ok fails the test if an err is not nil.
|
|
||||||
func ok(tb testing.TB, err error) {
|
|
||||||
if err != nil {
|
|
||||||
_, file, line, _ := runtime.Caller(1)
|
|
||||||
fmt.Printf("\033[31m%s:%d: unexpected error: %s\033[39m\n\n", filepath.Base(file), line, err.Error())
|
|
||||||
tb.FailNow()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// equals fails the test if exp is not equal to act.
|
|
||||||
func equals(tb testing.TB, exp, act interface{}) {
|
|
||||||
if !reflect.DeepEqual(exp, act) {
|
|
||||||
_, file, line, _ := runtime.Caller(1)
|
|
||||||
fmt.Printf("\033[31m%s:%d:\n\n\texp: %#v\n\n\tgot: %#v\033[39m\n\n", filepath.Base(file), line, exp, act)
|
|
||||||
tb.FailNow()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
// ContainsStr returns true if slice has a value equal to search and false otherwise
|
|
||||||
func ContainsStr(slice []string, search string) bool {
|
|
||||||
for _, v := range slice {
|
|
||||||
if v == search {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -16,12 +16,12 @@ var indexTemplate = template.Must(template.New("index.html.tmpl").Parse(`
|
|||||||
<link rel="stylesheet" href="/static/css/normalize.css">
|
<link rel="stylesheet" href="/static/css/normalize.css">
|
||||||
<link rel="stylesheet" href="/static/css/skeleton.css">
|
<link rel="stylesheet" href="/static/css/skeleton.css">
|
||||||
<link rel="stylesheet" href="/static/css/custom.css">
|
<link rel="stylesheet" href="/static/css/custom.css">
|
||||||
<link rel="icon" type="image/png" href="/static/atlantis-icon.png">
|
<link rel="icon" type="image/png" href="/static/images/atlantis-icon.png">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<section class="header">
|
<section class="header">
|
||||||
<a title="atlantis" href="/"><img src="/static/atlantis-icon.png"/></a>
|
<a title="atlantis" href="/"><img src="/static/images/atlantis-icon.png"/></a>
|
||||||
<p style="font-family: monospace, monospace; font-size: 1.1em; text-align: center;">atlantis</p>
|
<p style="font-family: monospace, monospace; font-size: 1.1em; text-align: center;">atlantis</p>
|
||||||
</section>
|
</section>
|
||||||
<nav class="navbar">
|
<nav class="navbar">
|
||||||
@@ -38,7 +38,7 @@ var indexTemplate = template.Must(template.New("index.html.tmpl").Parse(`
|
|||||||
<div class="list-title">{{.RepoFullName}} - <span class="heading-font-size">#{{.PullNum}}</span></div>
|
<div class="list-title">{{.RepoFullName}} - <span class="heading-font-size">#{{.PullNum}}</span></div>
|
||||||
<div class="list-unlock"><button class="unlock"><a class="unlock-link" href="{{.UnlockURL}}">Unlock</a></button></div>
|
<div class="list-unlock"><button class="unlock"><a class="unlock-link" href="{{.UnlockURL}}">Unlock</a></button></div>
|
||||||
<div class="list-status"><code>Locked</code></div>
|
<div class="list-status"><code>Locked</code></div>
|
||||||
<div class="list-timestamp"><span class="heading-font-size">{{.Timestamp}}</span></div>
|
<div class="list-timestamp"><span class="heading-font-size">{{.Time}}</span></div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ else }}
|
{{ else }}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
@@ -38,13 +38,13 @@ func Equals(tb testing.TB, exp, act interface{}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Contains fails the test if the slice doesn't contain the expected element
|
// Contains fails the test if the slice doesn't contain the expected element
|
||||||
func ContainsStr(tb testing.TB, exp string, act []string) {
|
func Contains(tb testing.TB, exp interface{}, slice []interface{}) {
|
||||||
for _, v := range act {
|
for _, v := range slice {
|
||||||
if v == exp {
|
if reflect.DeepEqual(v, exp) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_, file, line, _ := runtime.Caller(1)
|
_, file, line, _ := runtime.Caller(1)
|
||||||
fmt.Printf("\033[31m%s:%d:\n\n\texp: %#v\n\n\tin: %#v\033[39m\n\n", filepath.Base(file), line, exp, act)
|
fmt.Printf("\033[31m%s:%d:\n\n\texp: %#v\n\n\twas not in: %#v\033[39m\n\n", filepath.Base(file), line, exp, slice)
|
||||||
tb.FailNow()
|
tb.FailNow()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user