From d425de95fa1f3ee312a8b569290932163bc93bf1 Mon Sep 17 00:00:00 2001 From: Luke Kysow Date: Sat, 17 Jun 2017 14:14:21 -0700 Subject: [PATCH] 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 --- cmd/server.go | 2 +- cmd/version.go | 5 +- locking/boltdb/boltdb.go | 129 +++++++------ locking/boltdb/boltdb_test.go | 255 ++++++++++++------------- locking/dynamodb/dynamodb.go | 210 +++++++++------------ locking/locking.go | 81 +++++--- logging/simple_logger.go | 6 +- middleware/middleware.go | 6 +- models/models.go | 54 ++++++ server/apply_executor.go | 128 +++++++------ server/atlantis_config_file_test.go | 2 +- server/base_executor.go | 109 ----------- server/bindata_assetfs.go | 36 ++-- server/executor.go | 5 - server/github_client.go | 68 ++----- server/help_executor.go | 22 +-- server/plan_executor.go | 279 +++++++++++++++------------- server/plan_executor_test.go | 51 ++--- server/pre_run.go | 9 +- server/pre_run_test.go | 4 +- server/request_parser.go | 62 +++++-- server/s3_client.go | 3 +- server/server.go | 210 ++++++++++++--------- server/terraform_client.go | 31 ++-- server/testing_util.go | 38 ---- server/util.go | 11 -- server/web_templates.go | 6 +- static/atlantis-icon.png | Bin 4339 -> 0 bytes static/atlantis-icon_512.png | Bin 13413 -> 0 bytes testing_util/testing_util.go | 8 +- 30 files changed, 882 insertions(+), 948 deletions(-) create mode 100644 models/models.go delete mode 100644 server/base_executor.go delete mode 100644 server/executor.go delete mode 100644 server/testing_util.go delete mode 100644 server/util.go delete mode 100644 static/atlantis-icon.png delete mode 100644 static/atlantis-icon_512.png diff --git a/cmd/server.go b/cmd/server.go index 1ba59b322..034283f47 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -36,7 +36,7 @@ var stringFlags = []stringFlag{ { name: ghHostnameFlag, description: "Hostname of Github installation.", - value: "api.github.com", + value: "api.github.com", }, { name: logLevelFlag, diff --git a/cmd/version.go b/cmd/version.go index 505484c2e..d23669cf7 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -3,15 +3,14 @@ package cmd import ( "fmt" "github.com/spf13/cobra" + "github.com/spf13/viper" ) -const version = "0.1.0" - var versionCmd = &cobra.Command{ Use: "version", Short: "Print the current atlantis version", Run: func(cmd *cobra.Command, args []string) { - fmt.Printf("atlantis %s\n", version) + fmt.Printf("atlantis %s\n", viper.Get("version")) }, } diff --git a/locking/boltdb/boltdb.go b/locking/boltdb/boltdb.go index abf36af2b..766b60aeb 100644 --- a/locking/boltdb/boltdb.go +++ b/locking/boltdb/boltdb.go @@ -1,19 +1,19 @@ package boltdb import ( - "github.com/boltdb/bolt" - "fmt" - "encoding/json" - "github.com/pkg/errors" "bytes" + "encoding/json" + "fmt" + "github.com/boltdb/bolt" + "github.com/hootsuite/atlantis/models" + "github.com/pkg/errors" "os" - "time" "path" - "github.com/hootsuite/atlantis/locking" + "time" ) type Backend struct { - db *bolt.DB + db *bolt.DB bucket []byte } @@ -21,14 +21,14 @@ const bucketName = "runLocks" func New(dataDir string) (*Backend, error) { 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}) if err != nil { if err.Error() == "timeout" { 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 { if _, err := tx.CreateBucketIfNotExists([]byte(bucketName)); err != nil { @@ -37,7 +37,7 @@ func New(dataDir string) (*Backend, error) { return 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 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 } -func (b Backend) TryLock(run locking.Run) (locking.TryLockResponse, error) { - var response locking.TryLockResponse - newRunSerialized, _ := json.Marshal(run) - lockID := run.StateKey() +func (b *Backend) TryLock(project models.Project, env string, pullNum int) (bool, int, error) { + // return variables + var lockAcquired bool + 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 { bucket := tx.Bucket(b.bucket) // if there is no run at that key then we're free to create the lock - lockingRunSerialized := bucket.Get([]byte(lockID)) - if lockingRunSerialized == nil { - bucket.Put([]byte(lockID), newRunSerialized) // not a readonly bucketName so okay to ignore error - response = locking.TryLockResponse{ - LockAcquired: true, - LockingRun: run, - LockID: lockID, - } + currLockSerialized := bucket.Get([]byte(key)) + if currLockSerialized == nil { + bucket.Put([]byte(key), newLockSerialized) // not a readonly bucketName so okay to ignore error + lockAcquired = true + lockingPullNum = pullNum return nil } // otherwise the lock fails, return to caller the run that's holding the lock - var lockingRun locking.Run - if err := b.deserialize(lockingRunSerialized, &lockingRun); err != nil { - return errors.Wrap(err, "failed to deserialize run") - } - response = locking.TryLockResponse{ - LockAcquired: false, - LockingRun: lockingRun, - LockID: lockID, + var currLock models.ProjectLock + if err := json.Unmarshal(currLockSerialized, &currLock); err != nil { + return errors.Wrap(err, "failed to deserialize current lock") } + lockAcquired = false + lockingPullNum = currLock.PullNum return 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 { locks := tx.Bucket(b.bucket) - return locks.Delete([]byte(lockID)) + return locks.Delete([]byte(key)) }) return errors.Wrap(err, "DB transaction failed") } -func (b Backend) ListLocks() (map[string]locking.Run, error) { - m := make(map[string]locking.Run) - bytes := make(map[string][]byte) - +func (b Backend) List() ([]models.ProjectLock, error) { + var locks []models.ProjectLock + var locksBytes [][]byte err := b.db.View(func(tx *bolt.Tx) error { bucket := tx.Bucket(b.bucket) c := bucket.Cursor() for k, v := c.First(); k != nil; k, v = c.Next() { - bytes[string(k)] = v + locksBytes = append(locksBytes, v) } return 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 - for k, v := range bytes { - var run locking.Run - if err := b.deserialize(v, &run); err != nil { - return m, errors.Wrap(err, fmt.Sprintf("failed to deserialize run at key %q", string(k))) + for k, v := range locksBytes { + var lock models.ProjectLock + if err := json.Unmarshal(v, &lock); err != nil { + 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) { - var ids []string +func (b Backend) UnlockByPull(repoFullName string, pullNum int) error { + // get the locks that match that pull request + var locks []models.ProjectLock err := b.db.View(func(tx *bolt.Tx) error { c := tx.Bucket(b.bucket).Cursor() - // the key for each lock is repoFullName/path/env so we can scan through all entries - // and get the locks for that repo. Then we can check if the lock is for the right pull - prefix := []byte(repoFullName + "/") - for k, v := c.Seek(prefix); k != nil && bytes.HasPrefix(k, prefix); k, v = c.Next() { - var run locking.Run - if err := b.deserialize(v, &run); err != nil { - return errors.Wrapf(err, "failed to deserialize run at key %q", string(k)) + // we can use the repoFullName as a prefix search since that's the first part of the key + for k, v := c.Seek([]byte(repoFullName)); k != nil && bytes.HasPrefix(k, []byte(repoFullName)); k, v = c.Next() { + var lock models.ProjectLock + if err := json.Unmarshal(v, &lock); err != nil { + return errors.Wrapf(err, "failed to deserialize lock at key %q", string(k)) } - if run.PullNum == pullNum { - ids = append(ids, string(k)) + if lock.PullNum == pullNum { + locks = append(locks, lock) } } - 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 { - return json.Unmarshal(bs, run) +func (b Backend) key(p models.Project, env string) string { + return fmt.Sprintf("%s/%s/%s", p.RepoFullName, p.Path, env) } diff --git a/locking/boltdb/boltdb_test.go b/locking/boltdb/boltdb_test.go index c40f0faef..1efce5955 100644 --- a/locking/boltdb/boltdb_test.go +++ b/locking/boltdb/boltdb_test.go @@ -1,34 +1,28 @@ package boltdb_test import ( - "github.com/hootsuite/atlantis/locking" . "github.com/hootsuite/atlantis/testing_util" "github.com/boltdb/bolt" + "github.com/pkg/errors" + "io/ioutil" "os" "testing" - "io/ioutil" - "github.com/pkg/errors" - "time" "github.com/hootsuite/atlantis/locking/boltdb" + "github.com/hootsuite/atlantis/models" ) var lockBucket = "bucket" -var run = locking.Run{ - RepoFullName: "owner/repo", - Path: "parent/child", - Env: "default", - PullNum: 1, - User: "user", - Timestamp: time.Now(), -} +var project = models.NewProject("owner/repo", "parent/child") +var env = "default" +var pullNum = 1 func TestListNoLocks(t *testing.T) { t.Log("listing locks when there are none should return an empty list") db, b := newTestDB() defer cleanupDB(db) - ls, err := b.ListLocks() + ls, err := b.List() Ok(t, err) 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") db, b := newTestDB() defer cleanupDB(db) - _, err := b.TryLock(run) + _, _, err := b.TryLock(project, env, pullNum) Ok(t, err) - ls, err := b.ListLocks() + ls, err := b.List() Ok(t, err) Equals(t, 1, len(ls)) - Equals(t, run, ls[run.StateKey()]) } func TestListMultipleLocks(t *testing.T) { @@ -51,42 +44,40 @@ func TestListMultipleLocks(t *testing.T) { defer cleanupDB(db) // add multiple locks - _, err := b.TryLock(run) - Ok(t, err) + repos := []string{ + "owner/repo1", + "owner/repo2", + "owner/repo3", + "owner/repo4", + } - run2 := run - run2.RepoFullName = "different" - _, err = b.TryLock(run2) - Ok(t, err) - - 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() + for _, r := range repos { + _, _, err := b.TryLock(models.NewProject(r, "path"), env, pullNum) + Ok(t, err) + } + ls, err := b.List() Ok(t, err) Equals(t, 4, len(ls)) - Equals(t, run, ls[run.StateKey()]) - Equals(t, run2, ls[run2.StateKey()]) - Equals(t, run3, ls[run3.StateKey()]) - Equals(t, run4, ls[run4.StateKey()]) + for _, r := range repos { + found := false + for _, l := range ls { + if l.Project.RepoFullName == r { + found = true + } + } + Assert(t, found == true, "expected %s in %v", r, ls) + } } func TestListAddRemove(t *testing.T) { t.Log("listing after adding and removing should return none") db, b := newTestDB() defer cleanupDB(db) - _, err := b.TryLock(run) + _, _, err := b.TryLock(project, env, pullNum) Ok(t, err) - b.Unlock(run.StateKey()) + b.Unlock(project, env) - ls, err := b.ListLocks() + ls, err := b.List() Ok(t, err) Equals(t, 0, len(ls)) } @@ -95,62 +86,50 @@ func TestLockingNoLocks(t *testing.T) { t.Log("with no locks yet, lock should succeed") db, b := newTestDB() defer cleanupDB(db) - r, err := b.TryLock(run) + acquired, lockingPullNum, err := b.TryLock(project, env, pullNum) Ok(t, err) - Equals(t, true, r.LockAcquired) - Equals(t, run, r.LockingRun) - Equals(t, run.StateKey(), r.LockID) + Equals(t, true, acquired) + Equals(t, pullNum, lockingPullNum) } func TestLockingExistingLock(t *testing.T) { t.Log("if there is an existing lock, lock should...") db, b := newTestDB() defer cleanupDB(db) - _, err := b.TryLock(run) + _, _, err := b.TryLock(project, env, pullNum) 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 - new.Path = "different/path" - r, err := b.TryLock(new) + acquired, lockingPull, err := b.TryLock(models.NewProject(project.RepoFullName, "different/path"), env, pullNum) Ok(t, err) - Equals(t, true, r.LockAcquired) - Equals(t, new, r.LockingRun) - Equals(t, new.StateKey(), r.LockID) + Equals(t, true, acquired) + Equals(t, pullNum, lockingPull) } - t.Log("...succeed if the new run has a different environment") + t.Log("...succeed if the new project has a different environment") { - new := run - new.Env = "different-env" - r, err := b.TryLock(new) + acquired, lockingPull, err := b.TryLock(project, "different-env", pullNum) Ok(t, err) - Equals(t, true, r.LockAcquired) - Equals(t, new, r.LockingRun) - Equals(t, new.StateKey(), r.LockID) + Equals(t, true, acquired) + Equals(t, pullNum, lockingPull) } - t.Log("...succeed if the new run has a different repoName") + t.Log("...succeed if the new project has a different repoName") { - new := run - new.RepoFullName = "new/repo" - r, err := b.TryLock(new) + acquired, lockingPull, err := b.TryLock(models.NewProject("different/repo", project.Path), env, pullNum) Ok(t, err) - Equals(t, true, r.LockAcquired) - Equals(t, new, r.LockingRun) - Equals(t, new.StateKey(), r.LockID) + Equals(t, true, acquired) + Equals(t, pullNum, lockingPull) } - 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 - new.PullNum = run.PullNum + 1 - r, err := b.TryLock(new) + newPull := pullNum + 1 + acquired, lockingPull, err := b.TryLock(project, env, newPull) Ok(t, err) - Equals(t, false, r.LockAcquired) - Equals(t, run, r.LockingRun) - Equals(t, run.StateKey(), r.LockID) + Equals(t, false, acquired) + Equals(t, lockingPull, pullNum) } } @@ -159,7 +138,7 @@ func TestUnlockingNoLocks(t *testing.T) { db, b := newTestDB() defer cleanupDB(db) - Ok(t, b.Unlock("any-lock-id")) + Ok(t, b.Unlock(project, env)) } func TestUnlocking(t *testing.T) { @@ -167,20 +146,20 @@ func TestUnlocking(t *testing.T) { db, b := newTestDB() defer cleanupDB(db) - b.TryLock(run) - Ok(t, b.Unlock(run.StateKey())) + b.TryLock(project, env, pullNum) + Ok(t, b.Unlock(project, env)) // should be no locks listed - ls, err := b.ListLocks() + ls, err := b.List() Ok(t, err) Equals(t, 0, len(ls)) // should be able to re-lock that repo with a new pull num - new := run - new.PullNum = run.PullNum + 1 - r, err := b.TryLock(new) + newPull := pullNum + 1 + acquired, lockingPull, err := b.TryLock(project, env, newPull) Ok(t, err) - Equals(t, true, r.LockAcquired) + Equals(t, true, acquired) + Equals(t, newPull, lockingPull) } func TestUnlockingMultiple(t *testing.T) { @@ -188,107 +167,114 @@ func TestUnlockingMultiple(t *testing.T) { db, b := newTestDB() defer cleanupDB(db) - b.TryLock(run) + b.TryLock(project, env, pullNum) - new := run + new := project new.RepoFullName = "new/repo" - b.TryLock(new) + b.TryLock(project, env, pullNum) - new2 := run + new2 := project new2.Path = "new/path" - b.TryLock(new2) + b.TryLock(project, env, pullNum) - new3 := run - new3.Env = "new/env" - b.TryLock(new3) + new3Env := "new-env" + b.TryLock(project, new3Env, pullNum) // now try and unlock them - Ok(t, b.Unlock(new3.StateKey())) - Ok(t, b.Unlock(new2.StateKey())) - Ok(t, b.Unlock(new.StateKey())) - Ok(t, b.Unlock(run.StateKey())) + Ok(t, b.Unlock(project, new3Env)) + Ok(t, b.Unlock(new2, env)) + Ok(t, b.Unlock(new, env)) + Ok(t, b.Unlock(project, env)) // should be none left - ls, err := b.ListLocks() + ls, err := b.List() Ok(t, err) Equals(t, 0, len(ls)) } -func TestFindLocksNone(t *testing.T) { - t.Log("find should return no locks when there are none") +func TestUnlockByPullNone(t *testing.T) { + t.Log("UnlockByPull should be successful when there are no locks") db, b := newTestDB() defer cleanupDB(db) - ls, err := b.FindLocksForPull("any/repo", 1) + err := b.UnlockByPull("any/repo", 1) Ok(t, err) - Equals(t, 0, len(ls)) } -func TestFindLocksOne(t *testing.T) { - t.Log("with one lock find should...") +func TestUnlockByPullOne(t *testing.T) { + t.Log("with one lock, UnlockByPull should...") db, b := newTestDB() defer cleanupDB(db) - _, err := b.TryLock(run) + _, _, err := b.TryLock(project, env, pullNum) 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) - Equals(t, 0, len(ls)) - } - 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) + ls, err := b.List() Ok(t, err) 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) { - t.Log("after locking and unlocking find should return no locks") +func TestUnlockByPullAfterUnlock(t *testing.T) { + t.Log("after locking and unlocking, UnlockByPull should be successful") db, b := newTestDB() defer cleanupDB(db) - _, err := b.TryLock(run) + _, _, err := b.TryLock(project, env, pullNum) 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) Equals(t, 0, len(ls)) } -func TestFindMultipleMatching(t *testing.T) { - t.Log("find should return all matching lock ids") +func TestUnlockByPullMatching(t *testing.T) { + t.Log("UnlockByPull should delete all locks in that repo and pull num") db, b := newTestDB() defer cleanupDB(db) - _, err := b.TryLock(run) + _, _, err := b.TryLock(project, env, pullNum) Ok(t, err) // add additional locks with the same repo and pull num but different paths/envs - new := run + new := project new.Path = "dif/path" - _, err = b.TryLock(new) + _, _, err = b.TryLock(new, env, pullNum) Ok(t, err) - new2 := run - new2.Env = "new-env" - _, err = b.TryLock(new2) + _, _, err = b.TryLock(project, "new-env", pullNum) Ok(t, err) - // should get all of them back - ls, err := b.FindLocksForPull(run.RepoFullName, run.PullNum) + // there should now be 3 + ls, err := b.List() Ok(t, err) Equals(t, 3, len(ls)) - ContainsStr(t, run.StateKey(), ls) - ContainsStr(t, new.StateKey(), ls) - ContainsStr(t, new2.StateKey(), ls) + + // should all be unlocked + 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. @@ -322,4 +308,3 @@ func cleanupDB(db *bolt.DB) { os.Remove(db.Path()) db.Close() } - diff --git a/locking/dynamodb/dynamodb.go b/locking/dynamodb/dynamodb.go index 9206ec212..a35ef3033 100644 --- a/locking/dynamodb/dynamodb.go +++ b/locking/dynamodb/dynamodb.go @@ -1,18 +1,16 @@ package dynamodb 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" - "encoding/hex" - "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbiface" - "time" + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/client" + "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/dynamodbiface" + "github.com/hootsuite/atlantis/models" + "github.com/pkg/errors" "strconv" - "github.com/hootsuite/atlantis/locking" + "time" ) type Backend struct { @@ -20,104 +18,114 @@ type Backend struct { LockTable string } -type dynamoRun struct { - LockID string +// dynamoLock duplicates the fields of models.ProjectLock and adds LocksKey +// 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 Path string - Env string PullNum int - User string - Timestamp time.Time + Env string + Time time.Time } -func New(lockTable string, p client.ConfigProvider) *Backend { - return &Backend{ +func New(lockTable string, p client.ConfigProvider) Backend { + return Backend{ DB: dynamodb.New(p), LockTable: lockTable, } } -func (d *Backend) TryLock(run locking.Run) (locking.TryLockResponse, error) { - var r locking.TryLockResponse - newRunSerialized, err := d.toDynamoItem(run) +func (b Backend) key(project models.Project, env string) string { + return fmt.Sprintf("%s/%s/%s", project.RepoFullName, project.Path, env) +} + +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 { - return r, errors.Wrap(err, "serializing") + return false, 0, errors.Wrap(err, "serializing") } // check if there is an existing lock getItemParams := &dynamodb.GetItemInput{ Key: map[string]*dynamodb.AttributeValue{ - "LockID": { - S: aws.String(run.StateKey()), + "LockKey": { + S: aws.String(key), }, }, - TableName: aws.String(d.LockTable), + TableName: aws.String(b.LockTable), ConsistentRead: aws.Bool(true), } - item, err := d.DB.GetItem(getItemParams) + item, err := b.DB.GetItem(getItemParams) 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 + var currLock dynamoLock if len(item.Item) != 0 { - var dynamoRun dynamoRun - if err := dynamodbattribute.UnmarshalMap(item.Item, &dynamoRun); err != nil { - 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") + if err := dynamodbattribute.UnmarshalMap(item.Item, &currLock); 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") } - lockingRun := d.fromDynamoItem(dynamoRun) - return locking.TryLockResponse{ - LockAcquired: false, - LockingRun: lockingRun, - LockID: run.StateKey(), - }, nil + return false, currLock.PullNum, nil } // else we should be able to lock putItem := &dynamodb.PutItemInput{ - Item: newRunSerialized, - TableName: aws.String(d.LockTable), + Item: newLockSerialized, + TableName: aws.String(b.LockTable), // 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 - ConditionExpression: aws.String("attribute_not_exists(LockID)"), + ConditionExpression: aws.String("attribute_not_exists(LockKey)"), } - if _, err := d.DB.PutItem(putItem); err != nil { - return r, errors.Wrap(err, "writing lock") + if _, err := b.DB.PutItem(putItem); err != nil { + return false, 0, errors.Wrap(err, "writing lock") } - return locking.TryLockResponse{ - LockAcquired: true, - LockingRun: run, - LockID: run.StateKey(), - }, nil + return true, pullNum, 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{ 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") } -func (d *Backend) ListLocks() (map[string]locking.Run, error) { - params := &dynamodb.ScanInput{ - TableName: aws.String(d.LockTable), - } - - m := make(map[string]locking.Run) +func (b Backend) List() ([]models.ProjectLock, error) { + var locks []models.ProjectLock var err, internalErr error - err = d.DB.ScanPages(params, func(out *dynamodb.ScanOutput, lastPage bool) bool { - var runs []dynamoRun - if err := dynamodbattribute.UnmarshalListOfMaps(out.Items, &runs); err != nil { - internalErr = errors.Wrap(err,"deserializing locks") + params := &dynamodb.ScanInput{ + TableName: aws.String(b.LockTable), + } + 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 } - for _, run := range runs { - m[run.LockID] = d.fromDynamoItem(run) + for _, lock := range dynamoLocks { + locks = append(locks, models.ProjectLock{ + PullNum: lock.PullNum, + Project: models.NewProject(lock.RepoFullName, lock.Path), + Env: lock.Env, + Time: lock.Time, + }) } return lastPage }) @@ -125,10 +133,10 @@ func (d *Backend) ListLocks() (map[string]locking.Run, error) { if err == nil && internalErr != nil { 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{ ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ ":pullNum": { @@ -139,77 +147,31 @@ func (d *Backend) FindLocksForPull(repoFullName string, pullNum int) ([]string, }, }, 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 - err = d.DB.ScanPages(params, func(out *dynamodb.ScanOutput, lastPage bool) bool { - var runs []dynamoRun - 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 { + if err := dynamodbattribute.UnmarshalListOfMaps(out.Items, &locks); err != nil { + internalErr = errors.Wrap(err, "deserializing locks") return false } - for _, run := range runs { - ids = append(ids, run.LockID) - } return lastPage }) - - if err == nil && internalErr != nil { + if err == nil { err = internalErr } - return ids, 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 != nil { + return errors.Wrap(err, "scanning dynamodb") } - if err := d.deserialize(runItem.B, &run); err != nil { - return lockID, run, fmt.Errorf("deserializing run at key %q: %s", lockID, err) - } - return lockID, run, nil -} - -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, + // now we can unlock all of them + for _, lock := range locks { + if err := b.Unlock(models.NewProject(lock.RepoFullName, lock.Path), lock.Env); err != nil { + return errors.Wrapf(err, "unlocking repo %s, path %s, env %s", lock.RepoFullName, lock.Path, lock.Env) + } } + return nil } diff --git a/locking/locking.go b/locking/locking.go index 1b297e9c2..43152d663 100644 --- a/locking/locking.go +++ b/locking/locking.go @@ -1,35 +1,70 @@ package locking import ( - "time" + "errors" "fmt" + "github.com/hootsuite/atlantis/models" + "regexp" ) -type Run struct { - RepoFullName string - Path string - Env string - PullNum int - 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 Backend interface { + TryLock(project models.Project, env string, pullNum int) (bool, int, error) + Unlock(project models.Project, env string) error + List() ([]models.ProjectLock, error) + UnlockByPull(repoFullName string, pullNum int) error } type TryLockResponse struct { - LockAcquired bool - LockingRun Run // what is currently holding the lock - LockID string + LockAcquired bool + LockingPullNum int + LockKey string } -type Backend interface { - TryLock(run Run) (TryLockResponse, error) - Unlock(lockID string) error - ListLocks() (map[string]Run, error) - FindLocksForPull(repoFullName string, pullNum int) ([]string, error) +type Client struct { + backend Backend +} + +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) } diff --git a/logging/simple_logger.go b/logging/simple_logger.go index ef22e19b4..1a9fd14a1 100644 --- a/logging/simple_logger.go +++ b/logging/simple_logger.go @@ -26,9 +26,9 @@ const ( func NewSimpleLogger(source string, log *log.Logger, keepHistory bool, level LogLevel) *SimpleLogger { return &SimpleLogger{ - Source: source, - Log: log, - Level: level, + Source: source, + Log: log, + Level: level, KeepHistory: keepHistory, } } diff --git a/middleware/middleware.go b/middleware/middleware.go index fb474f043..b3873b3d9 100644 --- a/middleware/middleware.go +++ b/middleware/middleware.go @@ -1,9 +1,9 @@ package middleware import ( - "net/http" - "github.com/urfave/negroni" "github.com/hootsuite/atlantis/logging" + "github.com/urfave/negroni" + "net/http" ) 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 -type FailedRequestLogger struct{ +type FailedRequestLogger struct { logger *logging.SimpleLogger } diff --git a/models/models.go b/models/models.go new file mode 100644 index 000000000..5f0cefb0b --- /dev/null +++ b/models/models.go @@ -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, + } +} diff --git a/server/apply_executor.go b/server/apply_executor.go index 992c803f2..24a8a5fb4 100644 --- a/server/apply_executor.go +++ b/server/apply_executor.go @@ -14,13 +14,20 @@ import ( "github.com/aws/aws-sdk-go/service/s3" "github.com/aws/aws-sdk-go/service/s3/s3manager" "github.com/hootsuite/atlantis/locking" - "time" + "github.com/hootsuite/atlantis/models" + "strconv" ) type ApplyExecutor struct { - BaseExecutor - requireApproval bool - atlantisGithubUser string + github *GithubClient + awsConfig *AWSConfig + scratchDir string + s3Bucket string + sshKey string + terraform *TerraformClient + githubCommentRenderer *GithubCommentRenderer + lockingClient *locking.Client + requireApproval bool } /** Result Types **/ @@ -54,82 +61,85 @@ func (n NoPlansFailure) Template() *CompiledTemplate { return NoPlansFailureTmpl } -func (a *ApplyExecutor) execute(ctx *ExecutionContext, pullCtx *PullRequestContext) ExecutionResult { - res := a.setupAndApply(ctx, pullCtx) +func (a *ApplyExecutor) execute(ctx *CommandContext, github *GithubClient) { + res := a.setupAndApply(ctx) 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 { - a.github.UpdateStatus(pullCtx, PendingStatus, "Applying...") +func (a *ApplyExecutor) setupAndApply(ctx *CommandContext) ExecutionResult { + a.github.UpdateStatus(ctx.Repo, ctx.Pull, PendingStatus, "Applying...") if a.requireApproval { - ok, err := a.github.PullIsApproved(pullCtx) + ok, err := a.github.PullIsApproved(ctx.Repo, ctx.Pull) if err != nil { msg := fmt.Sprintf("failed to determine if pull request was approved: %v", err) - ctx.log.Err(msg) - a.github.UpdateStatus(pullCtx, ErrorStatus, "Apply Error") + ctx.Log.Err(msg) + a.github.UpdateStatus(ctx.Repo, ctx.Pull, ErrorStatus, "Apply Error") return ExecutionResult{SetupError: GeneralError{errors.New(msg)}} } if !ok { - ctx.log.Info("pull request was not approved") - a.github.UpdateStatus(pullCtx, FailureStatus, "Apply Failed") + ctx.Log.Info("pull request was not approved") + a.github.UpdateStatus(ctx.Repo, ctx.Pull, FailureStatus, "Apply Failed") 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 { errMsg := fmt.Sprintf("failed to download plans: %v", err) - ctx.log.Err(errMsg) - a.github.UpdateStatus(pullCtx, ErrorStatus, "Apply Error") + ctx.Log.Err(errMsg) + a.github.UpdateStatus(ctx.Repo, ctx.Pull, ErrorStatus, "Apply Error") return ExecutionResult{SetupError: GeneralError{errors.New(errMsg)}} } // If there are no plans found for the pull request if len(planPaths) == 0 { failure := "found 0 plans for this pull request" - ctx.log.Warn(failure) - a.github.UpdateStatus(pullCtx, FailureStatus, "Apply Failure") + ctx.Log.Warn(failure) + a.github.UpdateStatus(ctx.Repo, ctx.Pull, FailureStatus, "Apply Failure") return ExecutionResult{SetupFailure: NoPlansFailure{}} } //runLog = append(runLog, fmt.Sprintf("-> Downloaded plans: %v", planPaths)) applyOutputs := []PathResult{} for _, planPath := range planPaths { - output := a.apply(ctx, pullCtx, planPath) + output := a.apply(ctx, planPath) output.Path = planPath applyOutputs = append(applyOutputs, output) } - a.updateGithubStatus(pullCtx, applyOutputs) + a.updateGithubStatus(ctx, applyOutputs) return ExecutionResult{PathResults: applyOutputs} } -func (a *ApplyExecutor) apply(ctx *ExecutionContext, pullCtx *PullRequestContext, planPath string) PathResult { - //runLog = append(runLog, fmt.Sprintf("-> Running apply %s", planPath)) +func (a *ApplyExecutor) apply(ctx *CommandContext, planPath string) PathResult { planName := path.Base(planPath) - planSubDir := a.determinePlanSubDir(planName, ctx.pullNum) - planDir := filepath.Join(a.scratchDir, ctx.repoFullName, fmt.Sprintf("%v", ctx.pullNum), planSubDir) + planSubDir := a.determinePlanSubDir(planName, ctx.Pull.Num) + // 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) var config Config var remoteStatePath string // check if config file is found, if not we continue the run 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) if err != nil { msg := fmt.Sprintf("Error reading config file: %v", err) - ctx.log.Err(msg) + ctx.Log.Err(msg) return PathResult{ Status: "error", Result: GeneralError{errors.New(msg)}, } } // 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 { msg := fmt.Sprintf("pre run failed: %v", err) - ctx.log.Err(msg) + ctx.Log.Err(msg) return PathResult{ Status: "error", 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. if config.StashPath == "" { // 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 { msg := fmt.Sprintf("failed to set up remote state: %v", err) - ctx.log.Err(msg) + ctx.Log.Err(msg) return PathResult{ Status: "error", Result: GeneralError{errors.New(msg)}, @@ -158,72 +168,62 @@ func (a *ApplyExecutor) apply(ctx *ExecutionContext, pullCtx *PullRequestContext remoteStatePath = statePath } else { // use state path from config file - remoteStatePath = generateStatePath(config.StashPath, ctx.command.environment) + remoteStatePath = generateStatePath(config.StashPath, ctx.Command.environment) } if remoteStatePath != "" { - tfEnv := ctx.command.environment + tfEnv := ctx.Command.environment if tfEnv == "" { 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 { return PathResult{ Status: "error", 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{ 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 // todo: de-duplicate calls to assumeRole - //runLog = append(runLog, "-> Assuming role prior to running apply") - a.awsConfig.AWSSessionName = ctx.pullCreator + a.awsConfig.AWSSessionName = ctx.User.Username awsSession, err := a.awsConfig.CreateAWSSession() if err != nil { - ctx.log.Err(err.Error()) + ctx.Log.Err(err.Error()) return PathResult{ Status: "error", Result: GeneralError{err}, } } - //runLog = append(runLog, "-> Assumed AWS role successfully") credVals, err := awsSession.Config.Credentials.Get() if err != nil { msg := fmt.Sprintf("failed to get assumed role credentials: %v", err) - ctx.log.Err(msg) + ctx.Log.Err(msg) return PathResult{ Status: "error", 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_SECRET_ACCESS_KEY=%s", credVals.SecretAccessKey), fmt.Sprintf("AWS_SESSION_TOKEN=%s", credVals.SessionToken), }) //runLog = append(runLog, "```\n Apply output:\n", fmt.Sprintf("```bash\n%s\n", string(out[:]))) 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{ Status: "failure", 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 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 +} diff --git a/server/atlantis_config_file_test.go b/server/atlantis_config_file_test.go index 741a021f9..efc98e58b 100644 --- a/server/atlantis_config_file_test.go +++ b/server/atlantis_config_file_test.go @@ -1,10 +1,10 @@ package server import ( + . "github.com/hootsuite/atlantis/testing_util" "io/ioutil" "os" "testing" - . "github.com/hootsuite/atlantis/testing_util" ) var tempConfigFile = "/tmp/" + AtlantisConfigFile diff --git a/server/base_executor.go b/server/base_executor.go deleted file mode 100644 index 3ce3232bf..000000000 --- a/server/base_executor.go +++ /dev/null @@ -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 -} diff --git a/server/bindata_assetfs.go b/server/bindata_assetfs.go index 8120d6452..64ff1f59a 100644 --- a/server/bindata_assetfs.go +++ b/server/bindata_assetfs.go @@ -12,10 +12,10 @@ package server import ( - "github.com/elazarl/go-bindata-assetfs" "bytes" "compress/gzip" "fmt" + "github.com/elazarl/go-bindata-assetfs" "io" "io/ioutil" "os" @@ -267,12 +267,12 @@ func AssetNames() []string { // _bindata is a table, holding each asset generator, mapped to its name. var _bindata = map[string]func() (*asset, error){ - "static/atlantis-icon.png": staticAtlantisIconPng, - "static/atlantis-icon_512.png": staticAtlantisIcon_512Png, - "static/css/custom.css": staticCssCustomCss, - "static/css/normalize.css": staticCssNormalizeCss, - "static/css/skeleton.css": staticCssSkeletonCss, - "static/images/atlantis-icon.png": staticImagesAtlantisIconPng, + "static/atlantis-icon.png": staticAtlantisIconPng, + "static/atlantis-icon_512.png": staticAtlantisIcon_512Png, + "static/css/custom.css": staticCssCustomCss, + "static/css/normalize.css": staticCssNormalizeCss, + "static/css/skeleton.css": staticCssSkeletonCss, + "static/images/atlantis-icon.png": staticImagesAtlantisIconPng, "static/images/atlantis-icon_512.png": staticImagesAtlantisIcon_512Png, } @@ -315,18 +315,19 @@ type bintree struct { Func func() (*asset, error) Children map[string]*bintree } + var _bintree = &bintree{nil, map[string]*bintree{ - "static": &bintree{nil, map[string]*bintree{ - "atlantis-icon.png": &bintree{staticAtlantisIconPng, map[string]*bintree{}}, - "atlantis-icon_512.png": &bintree{staticAtlantisIcon_512Png, map[string]*bintree{}}, - "css": &bintree{nil, map[string]*bintree{ - "custom.css": &bintree{staticCssCustomCss, map[string]*bintree{}}, - "normalize.css": &bintree{staticCssNormalizeCss, map[string]*bintree{}}, - "skeleton.css": &bintree{staticCssSkeletonCss, map[string]*bintree{}}, + "static": {nil, map[string]*bintree{ + "atlantis-icon.png": {staticAtlantisIconPng, map[string]*bintree{}}, + "atlantis-icon_512.png": {staticAtlantisIcon_512Png, map[string]*bintree{}}, + "css": {nil, map[string]*bintree{ + "custom.css": {staticCssCustomCss, map[string]*bintree{}}, + "normalize.css": {staticCssNormalizeCss, map[string]*bintree{}}, + "skeleton.css": {staticCssSkeletonCss, map[string]*bintree{}}, }}, - "images": &bintree{nil, map[string]*bintree{ - "atlantis-icon.png": &bintree{staticImagesAtlantisIconPng, map[string]*bintree{}}, - "atlantis-icon_512.png": &bintree{staticImagesAtlantisIcon_512Png, map[string]*bintree{}}, + "images": {nil, map[string]*bintree{ + "atlantis-icon.png": {staticImagesAtlantisIconPng, 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, "/")...)...) } - func assetFS() *assetfs.AssetFS { for k := range _bintree.Children { return &assetfs.AssetFS{Asset: Asset, AssetDir: AssetDir, AssetInfo: AssetInfo, Prefix: k} diff --git a/server/executor.go b/server/executor.go deleted file mode 100644 index 18f77f7c5..000000000 --- a/server/executor.go +++ /dev/null @@ -1,5 +0,0 @@ -package server - -type Executor interface { - execute(ctx *ExecutionContext, pullCtx *PullRequestContext) ExecutionResult -} diff --git a/server/github_client.go b/server/github_client.go index 3be567247..49c583f37 100644 --- a/server/github_client.go +++ b/server/github_client.go @@ -1,10 +1,10 @@ package server import ( + "context" "fmt" "github.com/google/go-github/github" - "context" - "strings" + "github.com/hootsuite/atlantis/models" ) type GithubClient struct { @@ -16,21 +16,21 @@ const ( statusContext = "Atlantis" PendingStatus = "pending" SuccessStatus = "success" - ErrorStatus = "error" + ErrorStatus = "error" 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)} - owner, repo := g.repoFullNameToOwnerAndRepo(ctx.repoFullName) - g.client.Repositories.CreateStatus(g.ctx, owner, repo, ctx.head, &repoStatus) + g.client.Repositories.CreateStatus(g.ctx, repo.Owner, repo.Name, pull.HeadCommit, &repoStatus) // todo: deal with error updating status } -func (g *GithubClient) GetModifiedFiles(ctx *PullRequestContext) ([]string, error) { - var files = []string{} - owner, repo := g.repoFullNameToOwnerAndRepo(ctx.repoFullName) - comparison, _, err := g.client.Repositories.CompareCommits(g.ctx, owner, repo, ctx.base, ctx.head) +// GetModifiedFiles returns the names of files that were modified in the pull request. +// The names include the path to the file from the repo root, ex. parent/child/file.txt +func (g *GithubClient) GetModifiedFiles(repo models.Repo, pull models.PullRequest) ([]string, error) { + var files []string + comparison, _, err := g.client.Repositories.CompareCommits(g.ctx, repo.Owner, repo.Name, pull.BaseCommit, pull.HeadCommit) if err != nil { return files, err } @@ -40,40 +40,15 @@ func (g *GithubClient) GetModifiedFiles(ctx *PullRequestContext) ([]string, erro return files, nil } -func (g *GithubClient) CreateComment(ctx *PullRequestContext, comment string) error { - owner, repo := g.repoFullNameToOwnerAndRepo(ctx.repoFullName) - _, _, err := g.client.Issues.CreateComment(g.ctx, owner, repo, ctx.number, &github.IssueComment{Body: &comment}) +func (g *GithubClient) CreateComment(ctx *CommandContext, comment string) error { + _, _, err := g.client.Issues.CreateComment(g.ctx, ctx.Repo.Owner, ctx.Repo.Name, ctx.Pull.Num, &github.IssueComment{Body: &comment}) return err } -// CommentExists searches through comments on a pull request and returns true if one matches matcher -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) { +func (g *GithubClient) PullIsApproved(repo models.Repo, pull models.PullRequest) (bool, error) { // 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 - owner, repo := g.repoFullNameToOwnerAndRepo(ctx.repoFullName) - u := fmt.Sprintf("repos/%v/%v/pulls/%d/reviews", owner, repo, ctx.number) + u := fmt.Sprintf("repos/%v/%v/pulls/%d/reviews", repo.Owner, repo.Name, pull.Num) req, err := g.client.NewRequest("GET", u, nil) if err != nil { return false, err @@ -93,17 +68,6 @@ func (g *GithubClient) PullIsApproved(ctx *PullRequestContext) (bool, error) { return false, nil } -func (g *GithubClient) GetPullRequest(repoFullName string, number int) (*github.PullRequest, *github.Response, error) { - owner, repo := g.repoFullNameToOwnerAndRepo(repoFullName) - 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] +func (g *GithubClient) GetPullRequest(repo models.Repo, num int) (*github.PullRequest, *github.Response, error) { + return g.client.PullRequests.Get(g.ctx, repo.Owner, repo.Name, num) } diff --git a/server/help_executor.go b/server/help_executor.go index a94d17163..b801795de 100644 --- a/server/help_executor.go +++ b/server/help_executor.go @@ -2,12 +2,10 @@ package server import "github.com/spf13/viper" -type HelpExecutor struct { - BaseExecutor -} +type HelpExecutor struct{} 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") + `) Usage: atlantis [environment] [--verbose] @@ -32,18 +30,8 @@ atlantis apply staging atlantis apply ` -func (h *HelpExecutor) execute(ctx *ExecutionContext, github *GithubClient) { - pullCtx := &PullRequestContext{ - repoFullName: ctx.repoFullName, - 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....") +func (h *HelpExecutor) execute(ctx *CommandContext, github *GithubClient) { + ctx.Log.Info("generating help comment....") + github.CreateComment(ctx, helpComment) return } diff --git a/server/plan_executor.go b/server/plan_executor.go index 10ed1263e..ba85c32dd 100644 --- a/server/plan_executor.go +++ b/server/plan_executor.go @@ -3,19 +3,27 @@ package server import ( "errors" "fmt" + "github.com/hootsuite/atlantis/locking" + "github.com/hootsuite/atlantis/logging" + "github.com/hootsuite/atlantis/models" "io/ioutil" "os" "os/exec" + "path" "path/filepath" "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 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 func(id string) (url string) } @@ -23,7 +31,7 @@ type PlanExecutor struct { /** Result Types **/ type PlanSuccess struct { TerraformOutput string - LockURL string + LockURL string } func (p PlanSuccess) Template() *CompiledTemplate { @@ -61,37 +69,38 @@ func (e EnvironmentFailure) Template() *CompiledTemplate { return EnvironmentErrorTmpl } -func (p *PlanExecutor) execute(ctx *ExecutionContext, pullCtx *PullRequestContext) ExecutionResult { - res := p.setupAndPlan(ctx, pullCtx) +func (p *PlanExecutor) execute(ctx *CommandContext, github *GithubClient) { + res := p.setupAndPlan(ctx) 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 { - p.github.UpdateStatus(pullCtx, "pending", "Planning...") +func (p *PlanExecutor) setupAndPlan(ctx *CommandContext) ExecutionResult { + p.github.UpdateStatus(ctx.Repo, ctx.Pull, "pending", "Planning...") // todo: lock when cloning or somehow separate workspaces // clean the directory where we're going to clone - cloneDir := fmt.Sprintf("%s/%s/%d", p.scratchDir, ctx.repoFullName, ctx.pullNum) - ctx.log.Info("cleaning clone directory %q", cloneDir) + cloneDir := fmt.Sprintf("%s/%s/%d", p.scratchDir, ctx.Repo.FullName, ctx.Pull.Num) + ctx.Log.Info("cleaning clone directory %q", cloneDir) 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 - ctx.log.Info("creating dir %q", cloneDir) + ctx.Log.Info("creating dir %q", cloneDir) 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 - cloneCmd := exec.Command("git", "clone", ctx.repoSSHUrl, cloneDir) + cloneCmd := exec.Command("git", "clone", ctx.Repo.SSHURL, cloneDir) if p.sshKey != "" { err := GenerateSSHWrapper() if err != nil { errMsg := fmt.Sprintf("failed to create git ssh wrapper: %v", err) - ctx.log.Err(errMsg) - p.github.UpdateStatus(pullCtx, ErrorStatus, "Plan Error") + ctx.Log.Err(errMsg) + p.github.UpdateStatus(ctx.Repo, ctx.Pull, ErrorStatus, "Plan Error") return ExecutionResult{SetupError: GeneralError{errors.New(errMsg)}} } @@ -102,53 +111,53 @@ func (p *PlanExecutor) setupAndPlan(ctx *ExecutionContext, pullCtx *PullRequestC } // 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 { - errMsg := fmt.Sprintf("failed to clone repository %q: %v: %s", ctx.repoSSHUrl, err, string(output)) - ctx.log.Err(errMsg) - p.github.UpdateStatus(pullCtx, ErrorStatus, "Plan Error") + errMsg := fmt.Sprintf("failed to clone repository %q: %v: %s", ctx.Repo.SSHURL, err, string(output)) + ctx.Log.Err(errMsg) + p.github.UpdateStatus(ctx.Repo, ctx.Pull, ErrorStatus, "Plan Error") return ExecutionResult{SetupError: GeneralError{errors.New(errMsg)}} } // check out the branch for this PR - ctx.log.Info("checking out branch %q", ctx.branch) - checkoutCmd := exec.Command("git", "checkout", ctx.branch) + ctx.Log.Info("checking out branch %q", ctx.Pull.Branch) + checkoutCmd := exec.Command("git", "checkout", ctx.Pull.Branch) checkoutCmd.Dir = cloneDir if err := checkoutCmd.Run(); err != nil { - errMsg := fmt.Sprintf("failed to git checkout branch %q: %v", ctx.branch, err) - ctx.log.Err(errMsg) - p.github.UpdateStatus(pullCtx, ErrorStatus, "Plan Error") + errMsg := fmt.Sprintf("failed to git checkout branch %q: %v", ctx.Pull.Branch, err) + ctx.Log.Err(errMsg) + p.github.UpdateStatus(ctx.Repo, ctx.Pull, ErrorStatus, "Plan Error") return ExecutionResult{SetupError: GeneralError{errors.New(errMsg)}} } - ctx.log.Info("listing modified files from pull request") - modifiedFiles, err := p.github.GetModifiedFiles(pullCtx) + ctx.Log.Info("listing modified files from pull request") + modifiedFiles, err := p.github.GetModifiedFiles(ctx.Repo, ctx.Pull) if err != nil { errMsg := fmt.Sprintf("failed to retrieve list of modified files from GitHub: %v", err) - ctx.log.Err(errMsg) - p.github.UpdateStatus(pullCtx, ErrorStatus, "Plan Error") + ctx.Log.Err(errMsg) + p.github.UpdateStatus(ctx.Repo, ctx.Pull, ErrorStatus, "Plan Error") return ExecutionResult{SetupError: GeneralError{errors.New(errMsg)}} } modifiedTerraformFiles := p.filterToTerraform(modifiedFiles) if len(modifiedTerraformFiles) == 0 { - ctx.log.Info("no modified terraform files found, exiting") - p.github.UpdateStatus(pullCtx, FailureStatus, "Plan Failed") + ctx.Log.Info("no modified terraform files found, exiting") + p.github.UpdateStatus(ctx.Repo, ctx.Pull, FailureStatus, "Plan Failed") 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) - if len(execPaths) == 0 { - ctx.log.Info("no exec paths found, exiting") - p.github.UpdateStatus(pullCtx, FailureStatus, "Plan Failed") - return ExecutionResult{SetupError: GeneralError{errors.New("Plan Failed: there were no paths to run plan in")}} + projects := p.ModifiedProjects(ctx.Repo.FullName, modifiedTerraformFiles) + if len(projects) == 0 { + ctx.Log.Info("no Terraform projects were modified") + p.github.UpdateStatus(ctx.Repo, ctx.Pull, FailureStatus, "Plan Failed") + 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) - if err := p.CleanWorkspace(ctx.log, planFilesPrefix, p.scratchDir, execPaths); err != nil { + planFilesPrefix := fmt.Sprintf("%s_%d", strings.Replace(ctx.Repo.FullName, "/", "_", -1), ctx.Pull.Num) + if err := p.CleanWorkspace(ctx.Log, planFilesPrefix, p.scratchDir, cloneDir, projects); err != nil { errMsg := fmt.Sprintf("failed to clean workspace, aborting: %v", err) - ctx.log.Err(errMsg) - p.github.UpdateStatus(pullCtx, ErrorStatus, "Plan Error") + ctx.Log.Err(errMsg) + p.github.UpdateStatus(ctx.Repo, ctx.Pull, ErrorStatus, "Plan Error") return ExecutionResult{SetupError: GeneralError{errors.New(errMsg)}} } s3Client := NewS3Client(p.awsConfig, p.s3Bucket, "plans") @@ -156,25 +165,26 @@ func (p *PlanExecutor) setupAndPlan(ctx *ExecutionContext, pullCtx *PullRequestC var config Config // run `terraform plan` in each plan path and collect the results 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 - tfPlanFilename := p.GenerateOutputFilename(cloneDir, path, ctx.command.environment) - tfPlanName := fmt.Sprintf("%s_%d%s", strings.Replace(ctx.repoFullName, "/", "_", -1), ctx.pullNum, tfPlanFilename) - s3Key := fmt.Sprintf("%s/%s", ctx.repoFullName, tfPlanName) + tfPlanFilename := p.GenerateOutputFilename(project, ctx.Command.environment) + tfPlanName := fmt.Sprintf("%s_%d%s", strings.Replace(ctx.Repo.FullName, "/", "_", -1), ctx.Pull.Num, tfPlanFilename) + s3Key := fmt.Sprintf("%s/%s", ctx.Repo.FullName, tfPlanName) // check if config file is found, if not we continue the run - if config.Exists(path.Absolute) { - ctx.log.Info("Config file found in %s", path.Absolute) - err := config.Read(path.Absolute) + absolutePath := filepath.Join(cloneDir, project.Path) + if config.Exists(absolutePath) { + ctx.Log.Info("Config file found in %s", absolutePath) + err := config.Read(absolutePath) if err != nil { errMsg := fmt.Sprintf("Error reading config file: %v", err) - ctx.log.Err(errMsg) + ctx.Log.Err(errMsg) return ExecutionResult{SetupError: GeneralError{errors.New(errMsg)}} } // 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 { errMsg := fmt.Sprintf("pre run failed: %v", err) - ctx.log.Err(errMsg) + ctx.Log.Err(errMsg) return ExecutionResult{SetupError: GeneralError{errors.New(errMsg)}} } @@ -185,41 +195,31 @@ func (p *PlanExecutor) setupAndPlan(ctx *ExecutionContext, pullCtx *PullRequestC 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.Path = path.Relative + generatePlanResponse := p.plan(ctx, cloneDir, p.scratchDir, tfPlanName, s3Client, project, s3Key, p.sshKey, config.StashPath) + generatePlanResponse.Path = project.Path planOutputs = append(planOutputs, generatePlanResponse) } - p.updateGithubStatus(pullCtx, planOutputs) + p.updateGithubStatus(ctx, 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 // and the GeneratePlanResponse struct will also contain the full log including the error -func (p *PlanExecutor) plan(log *logging.SimpleLogger, - pullCtx *PullRequestContext, +func (p *PlanExecutor) plan( + ctx *CommandContext, repoDir string, planOutDir string, tfPlanName string, s3Client S3Client, - path ExecutionPath, - tfEnvName string, + project models.Project, s3Key string, sshKey string, - pullRequestCreator string, stashPath string) PathResult { - log.Info("generating plan for path %q", path) - run := locking.Run{ - RepoFullName: pullCtx.repoFullName, - Path: path.Relative, - Env: tfEnvName, - PullNum: pullCtx.number, - User: pullCtx.terraformApplier, - Timestamp: time.Now(), - } + ctx.Log.Info("generating plan for path %q", project.Path) // NOTE: THIS CODE IS TO SUPPORT TERRAFORM PROJECTS THAT AREN'T USING ATLANTIS CONFIG FILE. 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 { return PathResult{ 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 { return PathResult{ - Status:" failure", + Status: " failure", 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 - if lockAttempt.LockAcquired == false && lockAttempt.LockingRun.PullNum != pullCtx.number { + if lockAttempt.LockAcquired == false && lockAttempt.LockingPullNum != ctx.Pull.Num { return PathResult{ Status: "failure", - Result: RunLockedFailure{lockAttempt.LockingRun.PullNum}, + Result: RunLockedFailure{lockAttempt.LockingPullNum}, } } // 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"} // Generate terraform plan filename tfPlanOutputPath := filepath.Join(planOutDir, tfPlanName) // Generate terraform plan arguments - if tfEnvName != "" { - tfEnvFileName := filepath.Join("env", tfEnvName+".tfvars") - if _, err := os.Stat(filepath.Join(path.Absolute, tfEnvFileName)); err == nil { + if ctx.Command.environment != "" { + tfEnvFileName := filepath.Join("env", ctx.Command.environment+".tfvars") + if _, err := os.Stat(filepath.Join(repoDir, project.Path, tfEnvFileName)); err == nil { tfPlanCmd = append(tfPlanCmd, "-var-file", tfEnvFileName, "-out", tfPlanOutputPath) } else { - log.Err("environment file %q not found", tfEnvFileName) + ctx.Log.Err("environment file %q not found", tfEnvFileName) return PathResult{ Status: "failure", Result: EnvironmentFileNotFoundFailure{tfEnvFileName}, @@ -265,10 +270,10 @@ func (p *PlanExecutor) plan(log *logging.SimpleLogger, } // set pull request creator as the session name - p.awsConfig.AWSSessionName = pullRequestCreator + p.awsConfig.AWSSessionName = ctx.Pull.Author awsSession, err := p.awsConfig.CreateAWSSession() if err != nil { - log.Err(err.Error()) + ctx.Log.Err(err.Error()) return PathResult{ Status: "error", Result: GeneralError{err}, @@ -278,14 +283,14 @@ func (p *PlanExecutor) plan(log *logging.SimpleLogger, credVals, err := awsSession.Config.Credentials.Get() if err != nil { err = fmt.Errorf("failed to get assumed role credentials: %v", err) - log.Err(err.Error()) + ctx.Log.Err(err.Error()) return PathResult{ Status: "error", 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_SECRET_ACCESS_KEY=%s", credVals.SecretAccessKey), fmt.Sprintf("AWS_SESSION_TOKEN=%s", credVals.SessionToken), @@ -299,10 +304,10 @@ func (p *PlanExecutor) plan(log *logging.SimpleLogger, Command: strings.Join(terraformPlanCmdArgs, " "), Output: output, } - log.Err("error running terraform plan: %v", output) - log.Info("unlocking state since plan failed") - if err := p.lockingBackend.Unlock(lockAttempt.LockID); err != nil { - log.Err("error unlocking state: %v", err) + ctx.Log.Err("error running terraform plan: %v", output) + ctx.Log.Info("unlocking state since plan failed") + if err := p.lockingClient.Unlock(lockAttempt.LockKey); err != nil { + ctx.Log.Err("error unlocking state: %v", err) } return PathResult{ Status: "failure", @@ -310,12 +315,12 @@ func (p *PlanExecutor) plan(log *logging.SimpleLogger, } } // 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 { err = fmt.Errorf("failed to upload to S3: %v", err) - log.Err(err.Error()) - if err := p.lockingBackend.Unlock(lockAttempt.LockID); err != nil { - log.Err("error unlocking state: %v", err) + ctx.Log.Err(err.Error()) + if err := p.lockingClient.Unlock(lockAttempt.LockKey); err != nil { + ctx.Log.Err("error unlocking state: %v", err) } return PathResult{ Status: "error", @@ -324,22 +329,22 @@ func (p *PlanExecutor) plan(log *logging.SimpleLogger, } // Delete local plan file 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 { - 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 } return PathResult{ Status: "success", Result: PlanSuccess{ TerraformOutput: output, - LockURL: p.DeleteLockURL(lockAttempt.LockID), + LockURL: p.DeleteLockURL(lockAttempt.LockKey), }, } } func (p *PlanExecutor) filterToTerraform(files []string) []string { - var out = []string{} + var out []string for _, fileName := range files { if !p.isInExcludeList(fileName) && strings.Contains(fileName, ".tf") { out = append(out, fileName) @@ -359,46 +364,40 @@ func (p *PlanExecutor) trimSuffix(s, suffix string) string { return s } -func (p *PlanExecutor) removeDuplicates(paths []ExecutionPath) []ExecutionPath { - deDuped := []ExecutionPath{} - seen := map[ExecutionPath]bool{} - for _, path := range paths { - if _, ok := seen[path]; !ok { - deDuped = append(deDuped, path) - seen[path] = true +// ModifiedProjects returns the list of Terraform projects that have been changed due to the +// modified files +func (p *PlanExecutor) ModifiedProjects(repoFullName string, modifiedFiles []string) []models.Project { + var projects []models.Project + seenPaths := make(map[string]bool) + for _, modifiedFile := range modifiedFiles { + 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` -func (p *PlanExecutor) DetermineExecPaths(repoPath string, modifiedFiles []string) []ExecutionPath { - var paths []ExecutionPath - for _, modifiedFile := range modifiedFiles { - relative := p.getRelativePlanPath(modifiedFile) - 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" { +// getProjectPath returns the path to the project relative to the repo root +// if the project is at the root returns "." +func (p *PlanExecutor) getProjectPath(modifiedFilePath string) string { + dir := path.Dir(modifiedFilePath) + if path.Base(dir) == "env" { // if the modified file was inside an env/ directory, we treat this specially and // run plan one level up - 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 -func (p *PlanExecutor) CleanWorkspace(log *logging.SimpleLogger, deleteFilesPrefix string, planOutDir string, execPaths []ExecutionPath) error { - log.Info("cleaning workspace directory %q and plan paths %v", planOutDir, execPaths) +// 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, repoDir string, projects []models.Project) error { + log.Info("cleaning workspace directory %q", planOutDir) // delete .terraform directories - for _, path := range execPaths { - os.RemoveAll(filepath.Join(path.Absolute, ".terraform")) + for _, project := range projects { + os.RemoveAll(filepath.Join(repoDir, project.Path, ".terraform")) } // delete old plan files 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 // 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 := "" - 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. // We do this by substituting / for _ // We also add an _ because this gets appended to a larger path // 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 := "" if tfEnvName != "" { @@ -443,3 +442,27 @@ func (p *PlanExecutor) GenerateOutputFilename(repoDir string, execPath Execution func generateStatePath(path string, tfEnvName string) string { 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 +} diff --git a/server/plan_executor_test.go b/server/plan_executor_test.go index e0836bcb9..47548c17b 100644 --- a/server/plan_executor_test.go +++ b/server/plan_executor_test.go @@ -1,40 +1,41 @@ package server import ( - "reflect" + "github.com/hootsuite/atlantis/models" + . "github.com/hootsuite/atlantis/testing_util" "testing" ) -func TestDetermineExecPaths(t *testing.T) { - 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"}}) - runTest(t, "should return repoPath if at root", "/repoPath", []string{"a.ext"}, []ExecutionPath{{"/repoPath", "."}}) - runTest(t, "should handle repoPath with trailing slash", "/repoPath/", []string{"a.ext"}, []ExecutionPath{{"/repoPath", "."}}) - runTest(t, "should set plan dir one level up from env/ directories", "/repoPath/", []string{"env/a.ext"}, []ExecutionPath{{"/repoPath", "."}}) - 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 de-depluciate", "/repoPath/", []string{"a/b/c.ext", "a/b/d.ext"}, []ExecutionPath{{"/repoPath/a/b", "a/b"}}) +var p PlanExecutor + +func TestModifiedProjects(t *testing.T) { + runTest(t, "should handle no files modified", []string{}, []string{}) + runTest(t, "should handle files at root", []string{"root.tf"}, []string{"."}) + runTest(t, "should de-duplicate files at root", []string{"root1.tf", "root2.tf"}, []string{"."}) + runTest(t, "should handle sub directories", []string{"sub/dir/file.tf"}, []string{"sub/dir"}) + 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) { - p := PlanExecutor{} - plans := p.DetermineExecPaths(repoPath, filesChanged) - if !reflect.DeepEqual(expected, plans) { - t.Errorf("%s: expected %v, got %v", testDescrip, expected, plans) +func runTest(t *testing.T, testDescrip string, filesChanged []string, expectedPaths []string) { + projects := p.ModifiedProjects("owner/repo", filesChanged) + for i, p := range projects { + t.Log(testDescrip) + Equals(t, expectedPaths[i], p.Path) } } func TestGenerateOutputFilename(t *testing.T) { - runTestGetOutputFilename(t, "should handle empty plan path", "/repoPath", NewExecutionPath("", ""), "env", ".tfplan.env") - runTestGetOutputFilename(t, "should handle empty environment", "/repoPath", NewExecutionPath("", ""), "", ".tfplan") - runTestGetOutputFilename(t, "should prepend underscore on relative paths", "/repoPath", NewExecutionPath("", "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 exec path at root", "/a/b", NewExecutionPath("", "."), "env", ".tfplan.env") + runTestGetOutputFilename(t, "should handle root", ".", "env", ".tfplan.env") + runTestGetOutputFilename(t, "should handle empty environment", ".", "", ".tfplan") + runTestGetOutputFilename(t, "should prepend underscore on relative paths", "a/b", "", "_a_b.tfplan") + runTestGetOutputFilename(t, "should prepend underscore on relative paths and env", "a/b", "env", "_a_b.tfplan.env") } -func runTestGetOutputFilename(t *testing.T, testDescrip string, repoPath string, tfPlanPath ExecutionPath, tfEnvName string, expected string) { - p := PlanExecutor{} - outputFileName := p.GenerateOutputFilename(repoPath, tfPlanPath, tfEnvName) - if !reflect.DeepEqual(expected, outputFileName) { - t.Errorf("%s: expected %v, got %v", testDescrip, expected, outputFileName) - } +func runTestGetOutputFilename(t *testing.T, testDescrip string, path string, env string, expected string) { + t.Log(testDescrip) + outputFileName := p.GenerateOutputFilename(models.NewProject("owner/repo", path), env) + Equals(t, expected, outputFileName) } diff --git a/server/pre_run.go b/server/pre_run.go index 8543e395d..e8a52efdd 100644 --- a/server/pre_run.go +++ b/server/pre_run.go @@ -3,17 +3,18 @@ package server import ( "bufio" "fmt" + "github.com/hootsuite/atlantis/logging" "io/ioutil" "os" "os/exec" - "github.com/hootsuite/atlantis/logging" ) const InlineShebang = "/bin/sh -e" +// todo: make OO // PreRun is a function that will determine whether -func PreRun(c *Config, log *logging.SimpleLogger, execPath string, command *Command) error { - log.Info("Staring pre run in %s", execPath) +func PreRun(c *Config, log *logging.SimpleLogger, path string, command *Command) error { + log.Info("Staring pre run in %s", path) var execScript string if command.commandType == Plan { @@ -44,7 +45,7 @@ func PreRun(c *Config, log *logging.SimpleLogger, execPath string, command *Comm if c.TerraformVersion != "" { os.Setenv("ATLANTIS_TERRAFORM_VERSION", c.TerraformVersion) } - os.Setenv("WORKSPACE", execPath) + os.Setenv("WORKSPACE", path) output, err := execute(execScript) if err != nil { return err diff --git a/server/pre_run_test.go b/server/pre_run_test.go index c545e3cab..3eb6313fb 100644 --- a/server/pre_run_test.go +++ b/server/pre_run_test.go @@ -1,11 +1,11 @@ package server import ( + "github.com/hootsuite/atlantis/logging" + . "github.com/hootsuite/atlantis/testing_util" "log" "os" "testing" - . "github.com/hootsuite/atlantis/testing_util" - "github.com/hootsuite/atlantis/logging" ) var level logging.LogLevel = logging.Info diff --git a/server/request_parser.go b/server/request_parser.go index c32f1df89..905cc9159 100644 --- a/server/request_parser.go +++ b/server/request_parser.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "github.com/google/go-github/github" + "github.com/hootsuite/atlantis/models" "regexp" ) @@ -24,6 +25,7 @@ type Command struct { } 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)?$` 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") } - // 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) if len(match) < 5 { var truncated = *commentBody @@ -65,13 +67,19 @@ func (r *RequestParser) determineCommand(comment *github.IssueCommentEvent) (*Co return command, nil } -func (r *RequestParser) extractCommentData(comment *github.IssueCommentEvent, params *ExecutionContext) error { - missingField := "" - +func (r *RequestParser) extractCommentData(comment *github.IssueCommentEvent, ctx *CommandContext) error { repoFullName := comment.Repo.FullName if repoFullName == nil { 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 if pullNum == nil { return errors.New("key 'comment.issue.number' is null") @@ -84,10 +92,6 @@ func (r *RequestParser) extractCommentData(comment *github.IssueCommentEvent, pa if commentorUsername == nil { return errors.New("key 'comment.comment.user.login' is null") } - commentorEmail := comment.Comment.User.Email - if commentorEmail == nil { - commentorEmail = &missingField - } repoSSHURL := comment.Repo.SSHURL if repoSSHURL == nil { return errors.New("key 'comment.repo.sshurl' is null") @@ -96,17 +100,23 @@ func (r *RequestParser) extractCommentData(comment *github.IssueCommentEvent, pa if htmlURL == nil { return errors.New("key 'comment.issue.htmlUrl' is null") } - params.requesterUsername = *commentorUsername - params.requesterEmail = *commentorEmail - params.repoFullName = *repoFullName - params.pullNum = *pullNum - params.pullCreator = *pullCreator - params.repoSSHUrl = *repoSSHURL - params.htmlUrl = *htmlURL + ctx.Repo = models.Repo{ + FullName: *repoFullName, + Owner: *repoOwner, + Name: *repoName, + SSHURL: *repoSSHURL, + } + ctx.User = models.User{ + Username: *commentorUsername, + } + ctx.Pull = models.PullRequest{ + Num: *pullNum, + } + 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 if commit == nil { 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 { return errors.New("key 'pull.head.ref' is null") } - params.branch = *branch - params.head = *commit - params.base = *base - params.pullLink = *pullLink + authorUsername := pull.User.Login + if authorUsername == nil { + return errors.New("key 'pull.user.login' is null") + } + 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 } diff --git a/server/s3_client.go b/server/s3_client.go index 4edb04627..a1222cc5b 100644 --- a/server/s3_client.go +++ b/server/s3_client.go @@ -48,6 +48,7 @@ func NewS3Client(awsConfig *AWSConfig, bucketName string, prefix string) S3Clien return _s3.GetS3Info() } +// todo: make OO func UploadPlanFile(s S3Client, key string, outputFilePath string) error { file, err := os.Open(outputFilePath) if err != nil { @@ -75,7 +76,7 @@ func UploadPlanFile(s S3Client, key string, outputFilePath string) error { ContentLength: aws.Int64(size), ContentType: aws.String(fileType), Metadata: map[string]*string{ - "Key": aws.String("MetadataValue"), //required + "Key": aws.String("MetadataValue"), //required //todo: I don't think this is required }, } diff --git a/server/server.go b/server/server.go index 0636dfccd..dddad0869 100644 --- a/server/server.go +++ b/server/server.go @@ -2,27 +2,30 @@ package server import ( "context" + "encoding/json" "fmt" "log" "net/http" "net/url" "os" "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/google/go-github/github" "github.com/gorilla/mux" - "github.com/pkg/errors" - "io/ioutil" - "github.com/hootsuite/atlantis/locking/dynamodb" + "github.com/hootsuite/atlantis/locking" "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 ( @@ -31,7 +34,7 @@ const ( 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 { router *mux.Router port int @@ -46,16 +49,16 @@ type Server struct { logger *logging.SimpleLogger githubComments *GithubCommentRenderer requestParser *RequestParser - lockingBackend locking.Backend + lockingClient *locking.Client atlantisURL string } // the mapstructure tags correspond to flags in cmd/server.go type ServerConfig struct { - GitHubHostname string `mapstructure:"gh-hostname"` - GitHubUser string `mapstructure:"gh-user"` - GitHubPassword string `mapstructure:"gh-password"` - SSHKey string `mapstructure:"ssh-key"` + GitHubHostname string `mapstructure:"gh-hostname"` + GitHubUser string `mapstructure:"gh-user"` + GitHubPassword string `mapstructure:"gh-password"` + SSHKey string `mapstructure:"ssh-key"` AssumeRole string `mapstructure:"aws-assume-role-arn"` Port int `mapstructure:"port"` ScratchDir string `mapstructure:"scratch-dir"` @@ -69,22 +72,51 @@ type ServerConfig struct { LockingDynamoDBTable string `mapstructure:"locking-dynamodb-table"` } -type ExecutionContext struct { - repoFullName string - pullNum int - requesterUsername string - requesterEmail string - comment string - repoSSHUrl string - head string - // commit base sha - base string - pullLink string - branch string - htmlUrl string - pullCreator string - command *Command - log *logging.SimpleLogger +// todo: rename to Command +type CommandContext struct { + Repo models.Repo + Pull models.PullRequest + User models.User + Command *Command + Log *logging.SimpleLogger +} + +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)} +} + +type Templater interface { + Template() *CompiledTemplate +} + +type GeneralError struct { + Error error +} + +func (g GeneralError) Template() *CompiledTemplate { + return GeneralErrorTmpl } func NewServer(config ServerConfig) (*Server, error) { @@ -108,34 +140,42 @@ func NewServer(config ServerConfig) (*Server, error) { AWSRegion: config.AWSRegion, AWSRoleArn: config.AssumeRole, } - var lockingBackend locking.Backend + var lockingClient *locking.Client if config.LockingBackend == LockingDynamoDBBackend { session, err := awsConfig.CreateAWSSession() if err != nil { 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 { - var err error - lockingBackend, err = boltdb.New(config.DataDir) + backend, err := boltdb.New(config.DataDir) if err != nil { return nil, err } + lockingClient = locking.NewClient(backend) } - baseExecutor := BaseExecutor{ + applyExecutor := &ApplyExecutor{ github: githubClient, awsConfig: awsConfig, scratchDir: config.ScratchDir, s3Bucket: config.S3Bucket, sshKey: config.SSHKey, - ghComments: githubComments, terraform: terraformClient, githubCommentRenderer: githubComments, - lockingBackend: lockingBackend, + lockingClient: lockingClient, + requireApproval: config.RequireApproval, } - applyExecutor := &ApplyExecutor{BaseExecutor: baseExecutor, requireApproval: config.RequireApproval, atlantisGithubUser: config.GitHubUser} - planExecutor := &PlanExecutor{BaseExecutor: baseExecutor} - helpExecutor := &HelpExecutor{BaseExecutor: baseExecutor} + planExecutor := &PlanExecutor{ + github: githubClient, + 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)) router := mux.NewRouter() return &Server{ @@ -152,7 +192,7 @@ func NewServer(config ServerConfig) (*Server, error) { logger: logger, githubComments: githubComments, requestParser: &RequestParser{}, - lockingBackend: lockingBackend, + lockingClient: lockingClient, atlantisURL: config.AtlantisURL, }, nil } @@ -172,7 +212,7 @@ func (s *Server) Start() error { // function that planExecutor can use to construct delete lock urls // 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 u, _ := deleteLockRoute.URL("id", url.QueryEscape(lockID)) return s.atlantisURL + u.RequestURI() @@ -189,23 +229,27 @@ func (s *Server) Start() error { } func (s *Server) index(w http.ResponseWriter, r *http.Request) { - locks, err := s.lockingBackend.ListLocks() + locks, err := s.lockingClient.List() if err != nil { w.WriteHeader(http.StatusServiceUnavailable) fmt.Fprintf(w, "Could not retrieve locks: %s", err) return } - type runLock struct { - locking.Run - UnlockURL string + type lock struct { + UnlockURL string + RepoFullName string + PullNum int + Time time.Time } - var results []runLock + var results []lock for id, v := range locks { u, _ := s.router.Get(deleteLockRoute).URL("id", url.QueryEscape(id)) - results = append(results, runLock{ - v, - u.String(), + results = append(results, lock{ + UnlockURL: u.String(), + RepoFullName: v.Project.RepoFullName, + PullNum: v.PullNum, + Time: v.Time, }) } indexTemplate.Execute(w, results) @@ -222,7 +266,7 @@ func (s *Server) deleteLock(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) 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) fmt.Fprintf(w, "Failed to unlock: %s", err) return @@ -238,7 +282,7 @@ func (s *Server) postHooks(w http.ResponseWriter, r *http.Request) { bytes, err := ioutil.ReadAll(r.Body) if err != nil { 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 } @@ -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) { repo := *pullEvent.Repo.FullName 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 { - 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) - fmt.Fprintf(w, "Error finding locks: %s\n", err) + fmt.Fprintf(w, "Error unlocking locks: %v\n", err) return } - - 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") + fmt.Fprintln(w, "Locks unlocked") } func (s *Server) handleCommentCreatedEvent(w http.ResponseWriter, comment github.IssueCommentEvent, githubReqID string) { // determine if the comment matches a plan or apply command - ctx := &ExecutionContext{} + ctx := &CommandContext{} command, err := s.requestParser.determineCommand(&comment) if err != nil { s.logger.Debug("Ignoring request: %v %s", err, githubReqID) fmt.Fprintln(w, "Ignoring") return } - ctx.command = command + ctx.Command = command if err = s.requestParser.extractCommentData(&comment, ctx); err != nil { 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) } -func (s *Server) executeCommand(ctx *ExecutionContext) { - src := fmt.Sprintf("%s/pull/%d", ctx.repoFullName, ctx.pullNum) +func (s *Server) executeCommand(ctx *CommandContext) { + src := fmt.Sprintf("%s/pull/%d", ctx.Repo.FullName, ctx.Pull.Num) // 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) // 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 { - ctx.log.Err("pull request data api call failed: %v", err) + ctx.Log.Err("pull request data api call failed: %v", err) return } 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 } - switch ctx.command.commandType { + switch ctx.Command.commandType { case Plan: - s.planExecutor.Exec(s.planExecutor.execute, ctx, s.githubClient) + s.planExecutor.execute(ctx, s.githubClient) case Apply: - s.applyExecutor.Exec(s.applyExecutor.execute, ctx, s.githubClient) + s.applyExecutor.execute(ctx, s.githubClient) case Help: s.helpExecutor.execute(ctx, s.githubClient) 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 -func (s *Server) recover(ctx *ExecutionContext) { +func (s *Server) recover(ctx *CommandContext) { 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) - s.githubClient.CreateComment(ghCtx, 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) + 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) } } diff --git a/server/terraform_client.go b/server/terraform_client.go index 6fb54c994..ee01d1b35 100644 --- a/server/terraform_client.go +++ b/server/terraform_client.go @@ -2,52 +2,55 @@ package server import ( "fmt" + "github.com/hootsuite/atlantis/logging" + "github.com/hootsuite/atlantis/models" "os" "os/exec" "path/filepath" "regexp" - "github.com/hootsuite/atlantis/logging" ) type TerraformClient struct { tfExecutableName string } -func (t *TerraformClient) ConfigureRemoteState(log *logging.SimpleLogger, execPath ExecutionPath, tfEnvName string, sshKey string) (string, error) { - log.Info("setting up remote state in directory %q", execPath.Absolute) +func (t *TerraformClient) ConfigureRemoteState(log *logging.SimpleLogger, repoDir string, project models.Project, env string, sshKey string) (string, error) { + absolutePath := filepath.Join(repoDir, project.Path) + log.Info("setting up remote state in directory %q", absolutePath) var remoteSetupCmdArgs []string // 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 { - 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 if setupFileInfo.Mode() != os.FileMode(0755) { return "", fmt.Errorf("setup file isn't executable, required permissions are 0755") } // Check if environment is specified - if tfEnvName == "" { + if env == "" { // 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") return "", nil } } else { // Check if the environment file exists - envFile := tfEnvName + ".tfvars" - envPath := filepath.Join(execPath.Absolute, "env") + envFile := env + ".tfvars" + envPath := filepath.Join(absolutePath, "env") if _, err := os.Stat(filepath.Join(envPath, envFile)); err != nil { return "", fmt.Errorf("environment file %q not found in %q", envFile, envPath) } } // Set environment file parameter for ./setup.sh - if tfEnvName != "" { - remoteSetupCmdArgs = append(remoteSetupCmdArgs, "-e", tfEnvName) + if env != "" { + remoteSetupCmdArgs = append(remoteSetupCmdArgs, "-e", env) } remoteSetupCmd := exec.Command("./setup.sh", remoteSetupCmdArgs...) - remoteSetupCmd.Dir = execPath.Absolute + remoteSetupCmd.Dir = absolutePath // Check if ssh key is set if sshKey != "" { // 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 } -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.Dir = path.Absolute + terraformCmd.Dir = path terraformCmd.Env = tfEnvVars out, err := terraformCmd.CombinedOutput() output := string(out) diff --git a/server/testing_util.go b/server/testing_util.go deleted file mode 100644 index 40ee98355..000000000 --- a/server/testing_util.go +++ /dev/null @@ -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() - } -} diff --git a/server/util.go b/server/util.go deleted file mode 100644 index 2e6388af1..000000000 --- a/server/util.go +++ /dev/null @@ -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 -} diff --git a/server/web_templates.go b/server/web_templates.go index c60369dd4..a41e32ed0 100644 --- a/server/web_templates.go +++ b/server/web_templates.go @@ -16,12 +16,12 @@ var indexTemplate = template.Must(template.New("index.html.tmpl").Parse(` - +
- +

atlantis

{{ end }} {{ else }} diff --git a/static/atlantis-icon.png b/static/atlantis-icon.png deleted file mode 100644 index f98f15a09e6efbc144b4a89cf43a0d992ce2cd9a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4339 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 diff --git a/static/atlantis-icon_512.png b/static/atlantis-icon_512.png deleted file mode 100644 index 0b57dfd904468047d4bab51f9f61e234db8989c9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13413 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 F`yay4ZG8X$ diff --git a/testing_util/testing_util.go b/testing_util/testing_util.go index 38e9d619f..660c6d7b1 100644 --- a/testing_util/testing_util.go +++ b/testing_util/testing_util.go @@ -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 -func ContainsStr(tb testing.TB, exp string, act []string) { - for _, v := range act { - if v == exp { +func Contains(tb testing.TB, exp interface{}, slice []interface{}) { + for _, v := range slice { + if reflect.DeepEqual(v, exp) { return } } _, 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() }