Local locking with BoltDB or remote with DynamoDB

This commit is contained in:
Luke Kysow
2017-05-31 00:10:49 -07:00
committed by GitHub
parent 88fc0d0881
commit 4e20cc15a9
27 changed files with 1158 additions and 512 deletions

View File

@@ -1,2 +1,21 @@
# atlantis
A [terraform](https://www.terraform.io/) collaboration tool that enables you to collaborate on infrastructure safely and securely.
## Locking
When a **Run** is triggered, the set of infrastructure that is being modified is locked against any modification or planning until the **Run** has been
completed by an `apply` and the pull request has been merged
```
{
"data_dir": "/var/lib/atlantis",
"locking": {
"backend": "file"
}
}
{
"locking": {
"backend": "dynamodb"
}
}
```

View File

@@ -13,7 +13,8 @@ import (
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/aws/aws-sdk-go/service/s3/s3manager"
"github.com/google/go-github/github"
"github.com/hootsuite/atlantis/locking"
"time"
)
type ApplyExecutor struct {
@@ -60,7 +61,6 @@ func (a *ApplyExecutor) execute(ctx *ExecutionContext, prCtx *PullRequestContext
}
func (a *ApplyExecutor) setupAndApply(ctx *ExecutionContext, prCtx *PullRequestContext) ExecutionResult {
stashCtx := a.stashContext(ctx)
a.github.UpdateStatus(prCtx, PendingStatus, "Applying...")
if a.requireApproval {
@@ -78,8 +78,6 @@ func (a *ApplyExecutor) setupAndApply(ctx *ExecutionContext, prCtx *PullRequestC
}
}
//runLog = append(runLog, "-> Confirmed pull request was plus one'd")
planPaths, err := a.downloadPlans(ctx.repoOwner, ctx.repoName, ctx.pullNum, ctx.command.environment, a.scratchDir, a.awsConfig, a.s3Bucket)
if err != nil {
errMsg := fmt.Sprintf("failed to download plans: %v", err)
@@ -99,7 +97,7 @@ func (a *ApplyExecutor) setupAndApply(ctx *ExecutionContext, prCtx *PullRequestC
//runLog = append(runLog, fmt.Sprintf("-> Downloaded plans: %v", planPaths))
applyOutputs := []PathResult{}
for _, planPath := range planPaths {
output := a.apply(ctx, stashCtx, planPath)
output := a.apply(ctx, prCtx, planPath)
output.Path = planPath
applyOutputs = append(applyOutputs, output)
}
@@ -107,7 +105,7 @@ func (a *ApplyExecutor) setupAndApply(ctx *ExecutionContext, prCtx *PullRequestC
return ExecutionResult{PathResults: applyOutputs}
}
func (a *ApplyExecutor) apply(ctx *ExecutionContext, stashCtx *StashPullRequestContext, planPath string) PathResult {
func (a *ApplyExecutor) apply(ctx *ExecutionContext, prCtx *PullRequestContext, planPath string) PathResult {
//runLog = append(runLog, fmt.Sprintf("-> Running apply %s", planPath))
planName := path.Base(planPath)
planSubDir := a.determinePlanSubDir(planName, ctx.pullNum)
@@ -164,18 +162,33 @@ func (a *ApplyExecutor) apply(ctx *ExecutionContext, stashCtx *StashPullRequestC
}
if remoteStatePath != "" {
//runLog = append(runLog, "-> Remote state configured")
// now lock the state prior to applying
stashLockResponse := a.stash.LockState(ctx.log, stashCtx, remoteStatePath)
if !stashLockResponse.Success {
msg := fmt.Sprintf("failed to lock state: %s", stashLockResponse.Message)
ctx.log.Err(msg)
tfEnv := ctx.command.environment
if tfEnv == "" {
tfEnv = "default"
}
run := locking.Run{
RepoOwner: prCtx.owner,
RepoName: prCtx.repoName,
Path: execPath.Relative,
Env: tfEnv,
PullID: prCtx.number,
User: prCtx.terraformApplier,
Timestamp: time.Now(),
}
lockAttempt, err := a.lockManager.TryLock(run)
if err != nil {
return PathResult{
Status: "error",
Result: GeneralError{errors.New(msg)},
Result: GeneralError{fmt.Errorf("failed to acquire lock: %s", err)},
}
}
if lockAttempt.LockAcquired != true && lockAttempt.LockingRun.PullID != prCtx.number {
return PathResult{
Status: "error",
Result: GeneralError{fmt.Errorf("failed to acquire lock: lock held by pull request #%d", lockAttempt.LockingRun.PullID)},
}
}
//runLog = append(runLog, "-> Stash lock aquired")
}
// need to get auth data from assumed role
@@ -226,23 +239,6 @@ func (a *ApplyExecutor) apply(ctx *ExecutionContext, stashCtx *StashPullRequestC
}
}
func (a *ApplyExecutor) validatePlusOne(prSubmitter string) func(*github.IssueComment) bool {
return func(c *github.IssueComment) bool {
if c == nil || c.Body == nil {
return false
}
body := *c.Body
if !(strings.Contains(body, ":+1:") || strings.Contains(body, "+1") || strings.ContainsRune(body, '\U0001f44d')) {
return false
}
if c.User == nil || c.User.Login == nil {
return false
}
// the plus-oner can't be the user that submitted the PR or ourselves (otherwise our comment telling the user they're missing a +1 would count as approval)
return *c.User.Login != prSubmitter && *c.User.Login != a.atlantisGithubUser
}
}
func (a *ApplyExecutor) downloadPlans(repoOwner string, repoName string, pullNum int, env string, outputDir string, awsConfig *AWSConfig, s3Bucket string) (planPaths []string, err error) {
awsSession, err := awsConfig.CreateAWSSession()
if err != nil {

View File

@@ -4,33 +4,34 @@ import (
"io/ioutil"
"os"
"testing"
. "github.com/hootsuite/atlantis/testing_util"
)
var tempConfigFile = "/tmp/" + AtlantisConfigFile
func TestConfigFileExists_invalid_path(t *testing.T) {
var c Config
equals(t, c.Exists("/invalid/path"), false)
Equals(t, c.Exists("/invalid/path"), false)
}
func TestConfigFileExists_valid_path(t *testing.T) {
var c Config
var str = `
---
---
terraform_version: "0.0.1"
pre_apply:
commands:
pre_apply:
commands:
- "echo"
- "date"
pre_plan:
commands:
pre_plan:
commands:
- "echo"
- "date"
stash_path: "file/path"
`
writeAtlantisConfigFile([]byte(str))
defer os.Remove(tempConfigFile)
equals(t, c.Exists("/tmp"), true)
Equals(t, c.Exists("/tmp"), true)
}
func TestConfigFileRead_invalid_config(t *testing.T) {
@@ -39,20 +40,20 @@ func TestConfigFileRead_invalid_config(t *testing.T) {
writeAtlantisConfigFile(str)
defer os.Remove(tempConfigFile)
err := c.Read("/tmp")
assert(t, err != nil, "expect an error")
Assert(t, err != nil, "expect an error")
}
func TestConfigFileRead_valid_config(t *testing.T) {
var c Config
var str = `
---
---
terraform_version: "0.0.1"
pre_apply:
commands:
pre_apply:
commands:
- "echo"
- "date"
pre_plan:
commands:
pre_plan:
commands:
- "echo"
- "date"
stash_path: "file/path"
@@ -60,7 +61,7 @@ stash_path: "file/path"
writeAtlantisConfigFile([]byte(str))
defer os.Remove(tempConfigFile)
err := c.Read("/tmp")
assert(t, err == nil, "should be valid yaml")
Assert(t, err == nil, "should be valid json")
}
func writeAtlantisConfigFile(s []byte) error {

View File

@@ -1,6 +1,9 @@
package main
import "path/filepath"
import (
"path/filepath"
"github.com/hootsuite/atlantis/locking"
)
type BaseExecutor struct {
github *GithubClient
@@ -8,10 +11,10 @@ type BaseExecutor struct {
scratchDir string
s3Bucket string
sshKey string
stash *StashPRClient
ghComments *GithubCommentRenderer
terraform *TerraformClient
githubCommentRenderer *GithubCommentRenderer
lockManager locking.LockManager
}
type PullRequestContext struct {
@@ -75,17 +78,6 @@ func (b *BaseExecutor) worstResult(results []PathResult) string {
return worst
}
func (b *BaseExecutor) stashContext(ctx *ExecutionContext) *StashPullRequestContext {
return &StashPullRequestContext{
owner: ctx.repoOwner,
repoName: ctx.repoName,
number: ctx.pullNum,
pullRequestLink: ctx.pullLink,
terraformApplier: ctx.requesterUsername,
terraformApplierEmail: ctx.requesterEmail,
}
}
func (b *BaseExecutor) Exec(f func(*ExecutionContext, *PullRequestContext) ExecutionResult, ctx *ExecutionContext, github *GithubClient) {
prCtx := b.githubContext(ctx)
result := f(ctx, prCtx)

File diff suppressed because one or more lines are too long

View File

@@ -15,20 +15,20 @@ results:
- path-execution results
naming:
- `Atlantis` has (currently) two `commands`: `plan`, `apply`. There could be more like `help`
- `Atlantis` has (currently) three `commands`: `plan`, `apply`, and `help`
- `Terraform` has `commands` like `plan`, `apply`, `get`, `remote`
- When a user comments with an `Atlantis` `command`, they trigger an `execution`
- We determine the `Atlantis` `command` (ex. `plan` or `apply`)
- We also determine the `context` of the `execution`, or `executionContext`. This consists of data like the `repo owner`, the `pull request number`, the `commentor username`, etc.
- From the `command` type, we know which `executor` to use. Either the `PlanExecutor`, or the `ApplyExecutor`
- We then tell the `executor` to `execute` the `command`
- There may be multiple `execution paths` involved in an `execution` because we may have to run `terraform plan/apply` in multiple `paths`.
- There may be multiple `terraform projects` involved in an `execution` because we may have to run `terraform plan/apply` in multiple `paths`.
- The `executor` returns to us the `executionResult`
- each `result` has one of three `statuses`: `successful`, `failed`, `errored`
- `successful` is when... it was successful
- `failed` is when it didn't work but because of a user-solvable problem, like their terraform was wrong, or that environment doesn't exist
- `errored` is when there was an internal Atlantis error that couldn't be fixed by the user, like a request to github timed out
- `result` has sub-types to represent different results. For example there is a `PlanResult` that has the output of `terraform plan`, and the url to `stash` to discard the plan
- `result` has sub-types to represent different results. For example there is a `PlanResult` that has the output of `terraform plan`, and the url to discard the plan and lock
- We then need to add a `comment` back to the `pull request`, so we `render` the `executionResult` using a `renderer`. In this case, we need the `GithubCommentRenderer`
- We can then add the `comment` to the `pull request` by using the `githubClient`
@@ -36,7 +36,6 @@ components:
clients for external services:
- github (created)
- s3 (created)
- stash (created)
clients for on-host "services":
- git
- aws cli
@@ -58,3 +57,13 @@ logging guidelines:
- if something is an error, ex. we couldn't clean up the workspace, use the words "failed to" in the log
- never use colons "`:`" in a log since that's used to separate error descriptions and causes
- if you need to have a break in your comment, either use `-` or `,` ex. `failed to clean directory, continuing regardless` or `POST /404 - Response code 404`
Glossary
* **Run**: Encompasses the two steps (plan and apply) for modifying infrastructure in a specific environment
* **Run Lock**: When a run has started but is not yet completed, the infrastructure and environment that's being modified is "locked" against
other runs being started for the same set of infrastructure and environment. We determine what infrastructure is being modified by combining the
repository name, the directory in the repository at which the terraform commands need to be run, and the environment that's being modified
* **Run Path**: The path relative to the repository's root at which terraform commands need to be executed for this Run
* **Run Key**: The unique id for the set of infrastructure that is being modified in a Run. It is a combination of the repository name, run path, and environment
* **Run Id**: The id for this specific Run

View File

@@ -47,10 +47,10 @@ var (
text: "```diff\n" +
"{{.TerraformOutput}}\n" +
"```\n\n" +
"* To **discard** this plan click [here]({{.DiscardPlanLink}}).",
"* To **discard** this plan click [here]({{.LockURL}}).",
}
RunLockedFailureTmpl *CompiledTemplate = &CompiledTemplate{
text: "This plan is currently locked by {{.LockingPullLink}}\n" +
text: "This plan is currently locked by #{{.LockingPullID}}\n" +
"The locking plan must be applied or discarded before future plans can execute.",
}
TerraformFailureTmpl *CompiledTemplate = &CompiledTemplate{

View File

@@ -0,0 +1,113 @@
package locking
import (
"github.com/boltdb/bolt"
"fmt"
"encoding/json"
"github.com/pkg/errors"
"encoding/hex"
)
type BoltDBLockManager struct {
db *bolt.DB
locksBucket []byte
}
func NewBoltDBLockManager(db *bolt.DB, locksBucket string) *BoltDBLockManager {
return &BoltDBLockManager{db, []byte(locksBucket)}
}
func (b BoltDBLockManager) TryLock(run Run) (TryLockResponse, error) {
var response TryLockResponse
newRunSerialized, err := b.serialize(run)
if err != nil {
return response, errors.Wrap(err, "failed to serialize run")
}
lockId := run.StateKey()
transactionErr := b.db.Update(func(tx *bolt.Tx) error {
locksBucket := tx.Bucket(b.locksBucket)
// if there is no run at that key then we're free to create the lock
lockingRunSerialized := locksBucket.Get(lockId)
if lockingRunSerialized == nil {
locksBucket.Put(lockId, newRunSerialized) // not a readonly bucket so okay to ignore error
response = TryLockResponse{
LockAcquired: true,
LockingRun: run,
LockID: b.lockIDToString(lockId),
}
return nil
}
// otherwise the lock fails, return to caller the run that's holding the lock
var lockingRun Run
if err := b.deserialize(lockingRunSerialized, &lockingRun); err != nil {
return errors.Wrap(err, "failed to deserialize run")
}
response = TryLockResponse{
LockAcquired: false,
LockingRun: lockingRun,
LockID: b.lockIDToString(lockId),
}
return nil
})
if transactionErr != nil {
return response, errors.Wrap(transactionErr, "DB transaction failed")
}
return response, nil
}
func (b BoltDBLockManager) Unlock(lockID string) error {
idAsHex, err := hex.DecodeString(lockID)
if err != nil {
return errors.Wrap(err, "id was not in correct format")
}
err = b.db.Update(func(tx *bolt.Tx) error {
locks := tx.Bucket(b.locksBucket)
return locks.Delete(idAsHex)
})
return errors.Wrap(err, "DB transaction failed")
}
func (b BoltDBLockManager) ListLocks() (map[string]Run, error) {
m := make(map[string]Run)
bytes := make(map[string][]byte)
err := b.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket(b.locksBucket)
c := bucket.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
bytes[b.lockIDToString(k)] = v
}
return nil
})
if err != nil {
return m, errors.Wrap(err, "DB transaction failed")
}
// deserialize bytes into the proper objects
for k, v := range bytes {
var run 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)))
}
m[k] = run
}
return m, nil
}
func (b BoltDBLockManager) lockIDToString(key []byte) string {
return string(hex.EncodeToString(key))
}
func (b BoltDBLockManager) deserialize(bs []byte, run *Run) error {
return json.Unmarshal(bs, run)
}
func (b BoltDBLockManager) serialize(run Run) ([]byte, error) {
return json.Marshal(run)
}

View File

@@ -0,0 +1,260 @@
package locking_test
import (
"testing"
"github.com/hootsuite/atlantis/locking"
"time"
. "github.com/hootsuite/atlantis/testing_util"
"os"
"github.com/boltdb/bolt"
"io/ioutil"
"github.com/pkg/errors"
)
const LockBucket = "locks"
var run = locking.Run{
RepoOwner: "repoOwner",
RepoName: "repo",
Path: "parent/child",
Env: "default",
PullID: 1,
User: "user",
Timestamp: time.Now(),
}
func keyWithNewField(fieldName string, value string) locking.Run {
copy := run
switch fieldName {
case "RepoOwner":
copy.RepoOwner = value
return copy
case "RepoName":
copy.RepoName = value
return copy
case "Path":
copy.Path = value
return copy
case "Env":
copy.Env = value
return copy
default:
return copy
}
}
// NewTestDB returns a TestDB using a temporary path.
func NewTestDB() *bolt.DB {
// Retrieve a temporary path.
f, err := ioutil.TempFile("", "")
if err != nil {
panic(errors.Wrap(err, "failed to create temp file"))
}
path := f.Name()
f.Close()
// Open the database.
db, err := bolt.Open(path, 0600, nil)
if err != nil {
panic(errors.Wrap(err, "could not start bolt DB"))
}
if err := db.Update(func(tx *bolt.Tx) error {
if _, err := tx.CreateBucketIfNotExists([]byte(LockBucket)); err != nil {
return errors.Wrap(err, "failed to create bucket")
}
return nil
}); err != nil {
panic(errors.Wrap(err, "could not create bucket"))
}
return db
}
func cleanupDB(db *bolt.DB) {
os.Remove(db.Path())
db.Close()
}
func TestListWhenNoLocks(t *testing.T) {
db := NewTestDB()
defer cleanupDB(db)
b := locking.NewBoltDBLockManager(db, LockBucket)
m, err := b.ListLocks()
Ok(t, err)
Equals(t, 0, len(m))
}
func TestListLocks(t *testing.T) {
db := NewTestDB()
defer cleanupDB(db)
b := locking.NewBoltDBLockManager(db, LockBucket)
_, err := b.TryLock(run)
Ok(t, err)
locks, err := b.ListLocks()
Assert(t, len(locks) == 1, "should have 1 lock")
for _, v := range locks {
Equals(t, run, v)
}
}
type LockCommand struct {
run locking.Run
expected locking.TryLockResponse
}
type UnlockCommand struct {
runKey string
expected error
}
var regularLock = LockCommand{
run,
locking.TryLockResponse{
LockAcquired: true,
LockingRun: run,
LockID: "f13c9d87dc7156638cc075f0164d628f1338d23b17296dd391d922ea6fe6e9f8",
},
}
var tableTestData = []struct {
sequence []interface{}
description string
}{
{
description: "regular lock should succeed",
sequence: []interface{}{regularLock},
},
{
description: "unlock when there are no locks should succeed",
sequence: []interface{}{
UnlockCommand{
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
nil,
},
},
},
{
description: "lock and unlock should succeed",
sequence: []interface{}{
regularLock,
UnlockCommand{
"f13c9d87dc7156638cc075f0164d628f1338d23b17296dd391d922ea6fe6e9f8",
nil,
},
},
},
{
description: "lock for same run path should fail",
sequence: []interface{}{
regularLock,
LockCommand{
run,
locking.TryLockResponse{
LockAcquired: false,
LockingRun: regularLock.run,
LockID: regularLock.expected.LockID,
},
},
},
},
{
description: "lock when existing lock is for different repo owner should succeed",
sequence: []interface{}{
regularLock,
LockCommand{
keyWithNewField("RepoOwner", "different"),
locking.TryLockResponse{
LockAcquired: true,
LockingRun: keyWithNewField("RepoOwner", "different"),
},
},
},
},
{
description: "lock when existing lock is for different repo should succeed",
sequence: []interface{}{
regularLock,
LockCommand{
keyWithNewField("RepoName", "different"),
locking.TryLockResponse{
LockAcquired: true,
LockingRun: keyWithNewField("RepoName", "different"),
},
},
},
},
{
description: "lock when existing lock is for different path should succeed",
sequence: []interface{}{
regularLock,
LockCommand{
keyWithNewField("Path", "different"),
locking.TryLockResponse{
LockAcquired: true,
LockingRun: keyWithNewField("Path", "different"),
},
},
},
},
{
description: "lock when existing lock is for different env should succeed",
sequence: []interface{}{
regularLock,
LockCommand{
keyWithNewField("Env", "different"),
locking.TryLockResponse{
LockAcquired: true,
LockingRun: keyWithNewField("Env", "different"),
},
},
},
},
{
description: "lock, unlock, lock should succeed",
sequence: []interface{}{
regularLock,
UnlockCommand{
"f13c9d87dc7156638cc075f0164d628f1338d23b17296dd391d922ea6fe6e9f8",
nil,
},
LockCommand{
run,
locking.TryLockResponse{
LockAcquired: true,
LockingRun: run,
LockID: regularLock.expected.LockID,
},
},
},
},
}
func TestAllCases(t *testing.T) {
for _, testCase := range tableTestData {
t.Logf("testing: %q", testCase.description)
db := NewTestDB()
b := locking.NewBoltDBLockManager(db, LockBucket)
for i, cmd := range testCase.sequence {
t.Logf("index: %d", i)
switch cmd.(type) {
case LockCommand:
lockCmd := cmd.(LockCommand)
r, err := b.TryLock(lockCmd.run)
Ok(t, err)
Equals(t, lockCmd.expected.LockAcquired, r.LockAcquired)
Equals(t, lockCmd.expected.LockingRun, r.LockingRun)
if lockCmd.expected.LockID != "" {
Equals(t, lockCmd.expected.LockID, r.LockID)
}
case UnlockCommand:
unlockCmd := cmd.(UnlockCommand)
err := b.Unlock(unlockCmd.runKey)
Equals(t, unlockCmd.expected, err)
}
}
cleanupDB(db)
}
}

View File

@@ -0,0 +1,152 @@
package locking
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"
)
type DynamoDBLockManager struct {
DB dynamodbiface.DynamoDBAPI
LockTable string
}
func NewDynamoDBLockManager(lockTable string, p client.ConfigProvider) *DynamoDBLockManager {
return &DynamoDBLockManager{
DB: dynamodb.New(p),
LockTable: lockTable,
}
}
func (d *DynamoDBLockManager) TryLock(run Run) (TryLockResponse, error) {
var r TryLockResponse
newRunSerialized, err := d.serialize(run)
if err != nil {
return r, errors.Wrap(err, "serializing run data")
}
// check if there is an existing lock
getItemParams := &dynamodb.GetItemInput{
Key: map[string]*dynamodb.AttributeValue{
"LockID": {
B: run.StateKey(),
},
},
TableName: aws.String(d.LockTable),
ConsistentRead: aws.Bool(true),
}
item, err := d.DB.GetItem(getItemParams)
if err != nil {
return r, errors.Wrap(err, "checking if lock exists")
}
// if there is already a lock then we can't acquire a lock. Return the existing lock
if len(item.Item) != 0 {
runAttr, ok := item.Item["Run"]
if !ok || runAttr == nil || len(runAttr.B) == 0 {
return r, fmt.Errorf("found an existing lock at that id but it did not contain expected 'Run' key. We suggest manually deleting this key from DynamoDB")
}
var lockingRun Run
if err := d.deserialize(runAttr.B, &lockingRun); err != nil {
return r, errors.Wrap(err, "deserializing existing lock")
}
return TryLockResponse{
LockAcquired: false,
LockingRun: lockingRun,
LockID: string(hex.EncodeToString(run.StateKey())),
}, nil
}
// else we should be able to lock
putItem := &dynamodb.PutItemInput{
Item: map[string]*dynamodb.AttributeValue{
"LockID": {B: run.StateKey()},
"Run": {B: newRunSerialized},
},
TableName: aws.String(d.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)"),
}
if _, err := d.DB.PutItem(putItem); err != nil {
return r, errors.Wrap(err, "writing lock")
}
return TryLockResponse{
LockAcquired: true,
LockingRun: run,
LockID: string(hex.EncodeToString(run.StateKey())),
}, nil
}
func (d *DynamoDBLockManager) Unlock(lockID string) error {
idAsBytes, err := hex.DecodeString(lockID)
if err != nil {
return errors.Wrap(err, "id was not in correct format")
}
params := &dynamodb.DeleteItemInput{
Key: map[string]*dynamodb.AttributeValue{
"LockID": {B: idAsBytes},
},
TableName: aws.String(d.LockTable),
}
_, err = d.DB.DeleteItem(params)
return errors.Wrap(err, "deleting lock")
}
func (d *DynamoDBLockManager) ListLocks() (map[string]Run, error) {
m := make(map[string]Run)
params := &dynamodb.ScanInput{
ProjectionExpression: aws.String("LockID,Run"),
TableName: aws.String(d.LockTable),
}
// loop to get all locks since if datasize is over 1MB the client will page.
// we're setting a counter here just in case something goes horribly wrong and we loop forever
var i int
var startKey map[string]*dynamodb.AttributeValue
for ; i < 1000; i++ {
params.SetExclusiveStartKey(startKey)
scanOut, err := d.DB.Scan(params)
if err != nil {
return m, errors.Wrap(err, "reading dynamodb")
}
for _, item := range scanOut.Items {
lockIDItem, ok := item["LockID"]
if !ok || lockIDItem == nil {
return m, fmt.Errorf("lock did not have expected key 'LockID'")
}
lockID := string(hex.EncodeToString(lockIDItem.B))
runItem, ok := item["Run"]
if !ok || runItem == nil {
return m, fmt.Errorf("lock did not have expected key 'Run'")
}
var run Run
if err := d.deserialize(runItem.B, &run); err != nil {
return m, fmt.Errorf("deserializing run at key %q: %s", lockID, err)
}
m[lockID] = run
}
startKey = scanOut.LastEvaluatedKey
// if there are no more pages then we're done
if len(startKey) == 0 {
return m, nil
}
}
return m, errors.New("maxed out at 1000 scan iterations on the DynamoDB table. Something must be wrong")
}
func (d *DynamoDBLockManager) deserialize(bs []byte, run *Run) error {
return json.Unmarshal(bs, run)
}
func (d *DynamoDBLockManager) serialize(run Run) ([]byte, error) {
return json.Marshal(run)
}

View File

@@ -0,0 +1,28 @@
package locking_test
import (
"github.com/aws/aws-sdk-go/service/dynamodb/dynamodbiface"
"github.com/hootsuite/atlantis/locking"
"testing"
. "github.com/hootsuite/atlantis/testing_util"
"github.com/aws/aws-sdk-go/service/dynamodb"
)
type mockDynamoDB struct {
dynamodbiface.DynamoDBAPI
}
func (m mockDynamoDB) Scan(s *dynamodb.ScanInput) (*dynamodb.ScanOutput, error) {
return &dynamodb.ScanOutput{
Items: []map[string]*dynamodb.AttributeValue{},
}, nil
}
func TestListLocksEmpty(t *testing.T) {
lock := &locking.DynamoDBLockManager{
&mockDynamoDB{},
"lockTable",
}
m, err := lock.ListLocks()
Ok(t, err)
Equals(t, 0, len(m))
}

40
locking/lock_manager.go Normal file
View File

@@ -0,0 +1,40 @@
package locking
import (
"time"
"fmt"
"crypto/sha256"
)
type Run struct {
RepoOwner string
RepoName string
Path string
Env string
PullID int
User string
Timestamp time.Time
}
// StateKey returns the unique key to identify the set of infrastructure being modified by this run.
// Used in locking to determine what part of the infrastructure is locked.
func (r Run) StateKey() []byte {
// we combine the repository, path into the repo where terraform is being run and the environment
// to make up the state key and then hash it with sha256
key := fmt.Sprintf("%s/%s/%s/%s", r.RepoOwner, r.RepoName, r.Path, r.Env)
h := sha256.New()
h.Write([]byte(key))
return h.Sum(nil)
}
type TryLockResponse struct {
LockAcquired bool
LockingRun Run // what is currently holding the lock
LockID string
}
type LockManager interface {
TryLock(run Run) (TryLockResponse, error)
Unlock(lockID string) error
ListLocks() (map[string]Run, error)
}

154
main.go
View File

@@ -2,35 +2,50 @@ package main
import (
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"github.com/urfave/cli"
"github.com/boltdb/bolt"
"time"
"github.com/pkg/errors"
"path"
)
const (
// flags
ghUsernameFlag = "gh-username"
ghPasswordFlag = "gh-password"
ghHostnameFlag = "gh-hostname"
sshKeyFlag = "ssh-key"
portFlag = "port"
awsAssumeRoleFlag = "aws-assume-role-arn"
scratchDirFlag = "scratch-dir"
awsRegionFlag = "aws-region"
s3BucketFlag = "s3-bucket"
logLevelFlag = "log-level"
version = "2.0.0"
configFileFlag = "config-file"
requireApprovalFlag = "require-approval"
ghPasswordFlag = "gh-password"
ghHostnameFlag = "gh-hostname"
sshKeyFlag = "ssh-key"
portFlag = "port"
awsAssumeRoleFlag = "aws-assume-role-arn"
scratchDirFlag = "scratch-dir"
awsRegionFlag = "aws-region"
s3BucketFlag = "s3-bucket"
logLevelFlag = "log-level"
configFileFlag = "config-file"
requireApprovalFlag = "require-approval"
atlantisURLFlag = "atlantis-url"
dataDirFlag = "data-dir"
lockingBackendFlag = "locking-backend"
lockingTableFlag = "locking-table"
defaultGHHostname = "github.com"
defaultPort = 4141
defaultScratchDir = "/tmp/atlantis"
defaultRegion = "us-east-1"
defaultS3Bucket = "atlantis"
defaultLogLevel = "info"
// defaults
boltDBRunLocksBucket = "runLocks"
version = "2.0.0"
defaultGHHostname = "github.com"
defaultPort = 4141
defaultScratchDir = "/tmp/atlantis"
defaultRegion = "us-east-1"
defaultS3Bucket = "atlantis"
defaultLogLevel = "info"
defaultDataDir = "/var/lib/atlantis"
fileLockingBackend = "file"
dynamoDBLockingBackend = "dynamodb"
defaultLockingBackend = fileLockingBackend
defaultLockingTable = "atlantis-locks"
)
type AtlantisConfig struct {
@@ -44,7 +59,11 @@ type AtlantisConfig struct {
AWSRegion *string `json:"aws-region"`
S3Bucket *string `json:"s3-bucket"`
LogLevel *string `json:"log-level"`
AtlantisURL *string `json:"atlantis-url"`
RequireApproval bool `json:"require-approval"`
DataDir *string `json:"data-dir"`
LockingBackend *string `json:"locking-backend"`
LockingTable *string `json:"locking-table"`
}
func main() {
@@ -54,13 +73,39 @@ func main() {
app.Run(os.Args)
}
// mainAction is the only "action" for this cli program. It starts the web server
// mainAction is the only "action" for this cli program. It starts the web server and initializes the bolt db
func mainAction(c *cli.Context) error {
conf, err := validateConfig(c)
hostname, err := os.Hostname()
if err != nil {
return cli.NewExitError(fmt.Errorf("Failed to determine hostname: %v", err), 1)
}
conf, err := validateConfig(c, hostname)
if err != nil {
return cli.NewExitError(err.Error(), 1)
}
return NewServer(conf).Start()
var db *bolt.DB
// if file is the locking backend then initialize the bolt DB
if conf.lockingBackend == fileLockingBackend {
if err := os.MkdirAll(conf.dataDir, 0755); err != nil {
return cli.NewExitError(fmt.Errorf("Failed to create data dir: %v", err), 1)
}
db, err = bolt.Open(path.Join(conf.dataDir, "atlantis.db"), 0600, &bolt.Options{Timeout: 1 * time.Second})
if err != nil {
if err.Error() == "timeout" {
return cli.NewExitError(errors.New("Failed to start Bolt DB due to a timeout. A likely cause is Atlantis already running"), 1)
}
return cli.NewExitError(fmt.Errorf("Failed to start Bolt DB: %v", err), 1)
}
if err = initDB(db); err != nil {
return cli.NewExitError(fmt.Errorf("Failed to initialize Bolt DB: %v", err), 1)
}
defer db.Close()
}
server, err := NewServer(conf, db)
if err != nil {
return cli.NewExitError(fmt.Errorf("Failed to start server: %s", err), 1)
}
return server.Start()
}
// configureCli sets up the flags, name of the cli, etc.
@@ -112,6 +157,10 @@ func configureCli(app *cli.App) {
Value: defaultS3Bucket,
Usage: "The S3 bucket name to store atlantis data (terraform plans, terraform state, etc.)",
},
cli.StringFlag{
Name: atlantisURLFlag,
Usage: "The url that Atlantis can be reached at. Defaults to http, hostname, and configured port",
},
cli.BoolFlag{
Name: requireApprovalFlag,
Usage: "Require pull requests to be \"Approved\" before allowing the apply command to be run",
@@ -125,6 +174,21 @@ func configureCli(app *cli.App) {
Name: configFileFlag,
Usage: "Load configuration from `file`",
},
cli.StringFlag{
Name: dataDirFlag,
Value: defaultDataDir,
Usage: "Directory to store Atlantis data",
},
cli.StringFlag{
Name: lockingBackendFlag,
Value: defaultLockingBackend,
Usage: "Which backend to use for locking: file (default) or dynamodb",
},
cli.StringFlag{
Name: lockingTableFlag,
Value: defaultLockingTable,
Usage: "Name of the DynamoDB table used for locking. Only required if locking-backend is set to dynamodb. Defaults to 'atlantis-locks'",
},
// todo: allow option of where to log
// todo: network interface to bind to? by default localhost
}
@@ -139,7 +203,7 @@ version: {{.Version}}{{end}}
`
}
func validateConfig(c *cli.Context) (*ServerConfig, error) {
func validateConfig(c *cli.Context, hostname string) (*ServerConfig, error) {
// set defaults for non-required flags if not specified
port := defaultPort
ghHostname := defaultGHHostname
@@ -152,6 +216,10 @@ func validateConfig(c *cli.Context) (*ServerConfig, error) {
sshKey := ""
assumeRole := ""
requireApproval := false
atlantisURL := ""
dataDir := defaultDataDir
lockingBackend := defaultLockingBackend
lockingTable := defaultLockingTable
// check if config file flag is set
if c.IsSet(configFileFlag) {
@@ -197,6 +265,18 @@ func validateConfig(c *cli.Context) (*ServerConfig, error) {
if config.AssumeRole != nil {
assumeRole = *config.AssumeRole
}
if config.AtlantisURL != nil {
atlantisURL = *config.AtlantisURL
}
if config.DataDir != nil {
dataDir = *config.DataDir
}
if config.LockingBackend != nil {
lockingBackend = *config.LockingBackend
}
if config.LockingBackend != nil {
lockingTable = *config.LockingTable
}
requireApproval = config.RequireApproval
}
@@ -241,10 +321,23 @@ func validateConfig(c *cli.Context) (*ServerConfig, error) {
if c.IsSet(requireApprovalFlag) {
requireApproval = c.Bool(requireApprovalFlag)
}
if c.IsSet(atlantisURLFlag) {
atlantisURL = c.String(atlantisURLFlag)
} else if atlantisURL == "" {
atlantisURL = fmt.Sprintf("http://%s:%d", hostname, port)
}
if logLevel != "debug" && logLevel != "info" && logLevel != "warn" && logLevel != "error" {
return nil, errors.New("Invalid log level")
}
if c.IsSet(dataDirFlag) {
dataDir = c.String(dataDirFlag)
}
if c.IsSet(lockingBackendFlag) {
lockingBackend = c.String(lockingBackendFlag)
}
if c.IsSet(lockingTableFlag) {
lockingTable = c.String(lockingTableFlag)
}
return &ServerConfig{
githubUsername: ghUsername,
@@ -257,6 +350,19 @@ func validateConfig(c *cli.Context) (*ServerConfig, error) {
awsRegion: awsRegion,
s3Bucket: s3Bucket,
logLevel: logLevel,
atlantisURL: atlantisURL,
requireApproval: requireApproval,
dataDir: dataDir,
lockingBackend: lockingBackend,
lockingTable: lockingTable,
}, nil
}
func initDB(db *bolt.DB) error {
return db.Update(func(tx *bolt.Tx) error {
if _, err := tx.CreateBucketIfNotExists([]byte(boltDBRunLocksBucket)); err != nil {
return errors.Wrap(err, "failed to create bucket")
}
return nil
})
}

View File

@@ -9,8 +9,10 @@ import (
"os"
"strings"
"testing"
. "github.com/hootsuite/atlantis/testing_util"
)
var hostname = "hostname"
var baseConfig *AtlantisConfig = &AtlantisConfig{
GithubHostname: stringPtr("test-gh-hostname"),
GithubUsername: stringPtr("test-gh-username"),
@@ -22,15 +24,17 @@ var baseConfig *AtlantisConfig = &AtlantisConfig{
AWSRegion: stringPtr("test-aws-region"),
S3Bucket: stringPtr("test-s3-bucket"),
LogLevel: stringPtr("info"),
AtlantisURL: stringPtr("http://test-atlantis-url"),
RequireApproval: false,
DataDir: stringPtr("/datadir"),
}
func TestValidateConfig_missing_config_file_should_error(t *testing.T) {
ctx := buildTestContext()
ok(t, ctx.Set(configFileFlag, "non-existent-file"))
_, err := validateConfig(ctx)
assert(t, err != nil, "expected an error")
assert(t, strings.Contains(err.Error(), "couldn't read config file"), "error did not contain expected message, was: %q", err.Error())
Ok(t, ctx.Set(configFileFlag, "non-existent-file"))
_, err := validateConfig(ctx, hostname)
Assert(t, err != nil, "expected an error")
Assert(t, strings.Contains(err.Error(), "couldn't read config file"), "error did not contain expected message, was: %q", err.Error())
}
func TestValidateConfig_non_json_config_file_should_error(t *testing.T) {
@@ -38,28 +42,30 @@ func TestValidateConfig_non_json_config_file_should_error(t *testing.T) {
defer os.Remove(configFile)
ctx := buildTestContext()
ok(t, ctx.Set(configFileFlag, configFile))
_, err := validateConfig(ctx)
assert(t, err != nil, "expected an error")
assert(t, strings.Contains(err.Error(), "failed to parse config file"), "error did not contain expected message, was: %q", err.Error())
Ok(t, ctx.Set(configFileFlag, configFile))
_, err := validateConfig(ctx, hostname)
Assert(t, err != nil, "expected an error")
Assert(t, strings.Contains(err.Error(), "failed to parse config file"), "error did not contain expected message, was: %q", err.Error())
}
func TestValidateConfig_should_use_defaults(t *testing.T) {
ctx := buildTestContext()
ok(t, ctx.Set(ghUsernameFlag, "gh-username"))
ok(t, ctx.Set(ghPasswordFlag, "gh-password"))
conf, err := validateConfig(ctx)
ok(t, err)
Ok(t, ctx.Set(ghUsernameFlag, "gh-username"))
Ok(t, ctx.Set(ghPasswordFlag, "gh-password"))
conf, err := validateConfig(ctx, hostname)
Ok(t, err)
equals(t, defaultGHHostname, conf.githubHostname)
equals(t, defaultPort, conf.port)
equals(t, defaultScratchDir, conf.scratchDir)
equals(t, defaultRegion, conf.awsRegion)
equals(t, defaultS3Bucket, conf.s3Bucket)
equals(t, defaultLogLevel, conf.logLevel)
equals(t, false, conf.requireApproval)
equals(t, "", conf.sshKey)
equals(t, "", conf.awsAssumeRole)
Equals(t, defaultGHHostname, conf.githubHostname)
Equals(t, defaultPort, conf.port)
Equals(t, defaultScratchDir, conf.scratchDir)
Equals(t, defaultRegion, conf.awsRegion)
Equals(t, defaultS3Bucket, conf.s3Bucket)
Equals(t, defaultLogLevel, conf.logLevel)
Equals(t, false, conf.requireApproval)
Equals(t, "", conf.sshKey)
Equals(t, "", conf.awsAssumeRole)
Equals(t, fmt.Sprintf("http://hostname:%d", defaultPort), conf.atlantisURL)
Equals(t, defaultDataDir, conf.dataDir)
}
func TestValidateConfig_config_file_should_work(t *testing.T) {
@@ -67,49 +73,55 @@ func TestValidateConfig_config_file_should_work(t *testing.T) {
defer os.Remove(configFile)
ctx := buildTestContext()
ok(t, ctx.Set(configFileFlag, configFile))
conf, err := validateConfig(ctx)
ok(t, err)
equals(t, "test-gh-hostname", conf.githubHostname)
equals(t, "test-gh-username", conf.githubUsername)
equals(t, "test-gh-password", conf.githubPassword)
equals(t, "test-ssh-key", conf.sshKey)
equals(t, "test-assume-role", conf.awsAssumeRole)
equals(t, 9999, conf.port)
equals(t, "test-scratch-dir", conf.scratchDir)
equals(t, "test-aws-region", conf.awsRegion)
equals(t, "test-s3-bucket", conf.s3Bucket)
equals(t, false, conf.requireApproval)
equals(t, "info", conf.logLevel)
Ok(t, ctx.Set(configFileFlag, configFile))
conf, err := validateConfig(ctx, hostname)
Ok(t, err)
Equals(t, "test-gh-hostname", conf.githubHostname)
Equals(t, "test-gh-username", conf.githubUsername)
Equals(t, "test-gh-password", conf.githubPassword)
Equals(t, "test-ssh-key", conf.sshKey)
Equals(t, "test-assume-role", conf.awsAssumeRole)
Equals(t, 9999, conf.port)
Equals(t, "test-scratch-dir", conf.scratchDir)
Equals(t, "test-aws-region", conf.awsRegion)
Equals(t, "test-s3-bucket", conf.s3Bucket)
Equals(t, "info", conf.logLevel)
Equals(t, "http://test-atlantis-url", conf.atlantisURL)
Equals(t, false, conf.requireApproval)
Equals(t, "/datadir", conf.dataDir)
}
func TestValidateConfig_flags_should_work(t *testing.T) {
ctx := buildTestContext()
ok(t, ctx.Set(ghHostnameFlag, "gh-hostname"))
ok(t, ctx.Set(ghUsernameFlag, "gh-username"))
ok(t, ctx.Set(ghPasswordFlag, "gh-password"))
ok(t, ctx.Set(sshKeyFlag, "ssh-key"))
ok(t, ctx.Set(awsAssumeRoleFlag, "assume-role"))
ok(t, ctx.Set(portFlag, "8888"))
ok(t, ctx.Set(scratchDirFlag, "scratch-dir"))
ok(t, ctx.Set(awsRegionFlag, "aws-region"))
ok(t, ctx.Set(s3BucketFlag, "s3-bucket"))
ok(t, ctx.Set(logLevelFlag, "debug"))
ok(t, ctx.Set(requireApprovalFlag, "true"))
Ok(t, ctx.Set(ghHostnameFlag, "gh-hostname"))
Ok(t, ctx.Set(ghUsernameFlag, "gh-username"))
Ok(t, ctx.Set(ghPasswordFlag, "gh-password"))
Ok(t, ctx.Set(sshKeyFlag, "ssh-key"))
Ok(t, ctx.Set(awsAssumeRoleFlag, "assume-role"))
Ok(t, ctx.Set(portFlag, "8888"))
Ok(t, ctx.Set(scratchDirFlag, "scratch-dir"))
Ok(t, ctx.Set(awsRegionFlag, "aws-region"))
Ok(t, ctx.Set(s3BucketFlag, "s3-bucket"))
Ok(t, ctx.Set(logLevelFlag, "debug"))
Ok(t, ctx.Set(atlantisURLFlag, "http://new-atlantis-url"))
Ok(t, ctx.Set(requireApprovalFlag, "true"))
Ok(t, ctx.Set(dataDirFlag, "/new/dir"))
conf, err := validateConfig(ctx)
ok(t, err)
equals(t, "gh-hostname", conf.githubHostname)
equals(t, "gh-username", conf.githubUsername)
equals(t, "gh-password", conf.githubPassword)
equals(t, "ssh-key", conf.sshKey)
equals(t, "assume-role", conf.awsAssumeRole)
equals(t, 8888, conf.port)
equals(t, "scratch-dir", conf.scratchDir)
equals(t, "aws-region", conf.awsRegion)
equals(t, "s3-bucket", conf.s3Bucket)
equals(t, true, conf.requireApproval)
equals(t, "debug", conf.logLevel)
conf, err := validateConfig(ctx, hostname)
Ok(t, err)
Equals(t, "gh-hostname", conf.githubHostname)
Equals(t, "gh-username", conf.githubUsername)
Equals(t, "gh-password", conf.githubPassword)
Equals(t, "ssh-key", conf.sshKey)
Equals(t, "assume-role", conf.awsAssumeRole)
Equals(t, 8888, conf.port)
Equals(t, "scratch-dir", conf.scratchDir)
Equals(t, "aws-region", conf.awsRegion)
Equals(t, "s3-bucket", conf.s3Bucket)
Equals(t, "debug", conf.logLevel)
Equals(t, "http://new-atlantis-url", conf.atlantisURL)
Equals(t, true, conf.requireApproval)
Equals(t, "/new/dir", conf.dataDir)
}
func TestValidateConfig_flags_should_override_config_file(t *testing.T) {
@@ -117,34 +129,38 @@ func TestValidateConfig_flags_should_override_config_file(t *testing.T) {
defer os.Remove(configFile)
ctx := buildTestContext()
ok(t, ctx.Set(configFileFlag, configFile))
Ok(t, ctx.Set(configFileFlag, configFile))
// override all flags
ok(t, ctx.Set(ghHostnameFlag, "overridden-gh-hostname"))
ok(t, ctx.Set(ghUsernameFlag, "overridden-gh-username"))
ok(t, ctx.Set(ghPasswordFlag, "overridden-gh-password"))
ok(t, ctx.Set(sshKeyFlag, "overridden-ssh-key"))
ok(t, ctx.Set(awsAssumeRoleFlag, "overridden-assume-role"))
ok(t, ctx.Set(portFlag, "8888"))
ok(t, ctx.Set(scratchDirFlag, "overridden-scratch-dir"))
ok(t, ctx.Set(awsRegionFlag, "overridden-aws-region"))
ok(t, ctx.Set(s3BucketFlag, "overridden-s3-bucket"))
ok(t, ctx.Set(requireApprovalFlag, "true"))
ok(t, ctx.Set(logLevelFlag, "debug"))
Ok(t, ctx.Set(ghHostnameFlag, "overridden-gh-hostname"))
Ok(t, ctx.Set(ghUsernameFlag, "overridden-gh-username"))
Ok(t, ctx.Set(ghPasswordFlag, "overridden-gh-password"))
Ok(t, ctx.Set(sshKeyFlag, "overridden-ssh-key"))
Ok(t, ctx.Set(awsAssumeRoleFlag, "overridden-assume-role"))
Ok(t, ctx.Set(portFlag, "8888"))
Ok(t, ctx.Set(scratchDirFlag, "overridden-scratch-dir"))
Ok(t, ctx.Set(awsRegionFlag, "overridden-aws-region"))
Ok(t, ctx.Set(s3BucketFlag, "overridden-s3-bucket"))
Ok(t, ctx.Set(logLevelFlag, "debug"))
Ok(t, ctx.Set(atlantisURLFlag, "overridden-url"))
Ok(t, ctx.Set(requireApprovalFlag, "true"))
Ok(t, ctx.Set(dataDirFlag, "/overridden/dir"))
conf, err := validateConfig(ctx)
ok(t, err)
equals(t, "overridden-gh-hostname", conf.githubHostname)
equals(t, "overridden-gh-username", conf.githubUsername)
equals(t, "overridden-gh-password", conf.githubPassword)
equals(t, "overridden-ssh-key", conf.sshKey)
equals(t, "overridden-assume-role", conf.awsAssumeRole)
equals(t, 8888, conf.port)
equals(t, "overridden-scratch-dir", conf.scratchDir)
equals(t, "overridden-aws-region", conf.awsRegion)
equals(t, "overridden-s3-bucket", conf.s3Bucket)
equals(t, true, conf.requireApproval)
equals(t, "debug", conf.logLevel)
conf, err := validateConfig(ctx, hostname)
Ok(t, err)
Equals(t, "overridden-gh-hostname", conf.githubHostname)
Equals(t, "overridden-gh-username", conf.githubUsername)
Equals(t, "overridden-gh-password", conf.githubPassword)
Equals(t, "overridden-ssh-key", conf.sshKey)
Equals(t, "overridden-assume-role", conf.awsAssumeRole)
Equals(t, 8888, conf.port)
Equals(t, "overridden-scratch-dir", conf.scratchDir)
Equals(t, "overridden-aws-region", conf.awsRegion)
Equals(t, "overridden-s3-bucket", conf.s3Bucket)
Equals(t, "debug", conf.logLevel)
Equals(t, "overridden-url", conf.atlantisURL)
Equals(t, true, conf.requireApproval)
Equals(t, "/overridden/dir", conf.dataDir)
}
func TestValidateConfig_missing_required_flags_should_error(t *testing.T) {
@@ -153,12 +169,12 @@ func TestValidateConfig_missing_required_flags_should_error(t *testing.T) {
for _, flag := range []string{ghUsernameFlag, ghPasswordFlag} {
ctx := buildTestContext()
ok(t, ctx.Set(configFileFlag, configFile))
ok(t, ctx.Set(flag, ""))
_, err := validateConfig(ctx)
assert(t, err != nil, "expected an error")
Ok(t, ctx.Set(configFileFlag, configFile))
Ok(t, ctx.Set(flag, ""))
_, err := validateConfig(ctx, hostname)
Assert(t, err != nil, "expected an error")
expected := fmt.Sprintf("Error: must specify the --%s flag", flag)
assert(t, strings.Contains(err.Error(), expected), "error did not contain expected message, was: %q, expected: %q", err.Error(), expected)
Assert(t, strings.Contains(err.Error(), expected), "error did not contain expected message, was: %q, expected: %q", err.Error(), expected)
}
}
@@ -166,12 +182,12 @@ func TestValidateConfig_invalid_log_level_should_error(t *testing.T) {
configFile := writeJSONConfigFile(t, baseConfig)
defer os.Remove(configFile)
ctx := buildTestContext()
ok(t, ctx.Set(configFileFlag, configFile))
ok(t, ctx.Set(logLevelFlag, "invalid-level"))
_, err := validateConfig(ctx)
assert(t, err != nil, "expected an error")
Ok(t, ctx.Set(configFileFlag, configFile))
Ok(t, ctx.Set(logLevelFlag, "invalid-level"))
_, err := validateConfig(ctx, hostname)
Assert(t, err != nil, "expected an error")
expected := fmt.Sprintf("Invalid log level")
assert(t, strings.Contains(err.Error(), expected), "error did not contain expected message, was: %q, expected: %q", err.Error(), expected)
Assert(t, strings.Contains(err.Error(), expected), "error did not contain expected message, was: %q, expected: %q", err.Error(), expected)
}
func TestValidateConfig_valid_log_levels_should_validate(t *testing.T) {
@@ -180,11 +196,11 @@ func TestValidateConfig_valid_log_levels_should_validate(t *testing.T) {
for _, level := range []string{"debug", "info", "warn", "error"} {
ctx := buildTestContext()
ok(t, ctx.Set(configFileFlag, configFile))
ok(t, ctx.Set(logLevelFlag, level))
conf, err := validateConfig(ctx)
assert(t, err == nil, "Did not expect error for valid log level %q: %v", level, err)
equals(t, level, conf.logLevel)
Ok(t, ctx.Set(configFileFlag, configFile))
Ok(t, ctx.Set(logLevelFlag, level))
conf, err := validateConfig(ctx, hostname)
Assert(t, err == nil, "Did not expect error for valid log level %q: %v", level, err)
Equals(t, level, conf.logLevel)
}
}
@@ -194,24 +210,24 @@ func TestValidateConfig_uppercase_log_levels_should_validate(t *testing.T) {
for _, level := range []string{"DEBUG", "INFO", "WARN", "ERROR"} {
ctx := buildTestContext()
ok(t, ctx.Set(configFileFlag, configFile))
ok(t, ctx.Set(logLevelFlag, level))
conf, err := validateConfig(ctx)
assert(t, err == nil, "Did not expect error for valid log level %q: %v", level, err)
equals(t, strings.ToLower(level), conf.logLevel)
Ok(t, ctx.Set(configFileFlag, configFile))
Ok(t, ctx.Set(logLevelFlag, level))
conf, err := validateConfig(ctx, hostname)
Assert(t, err == nil, "Did not expect error for valid log level %q: %v", level, err)
Equals(t, strings.ToLower(level), conf.logLevel)
}
}
func writeJSONConfigFile(t *testing.T, config *AtlantisConfig) string {
bytes, err := json.Marshal(config)
ok(t, err)
Ok(t, err)
return writeConfigFile(t, string(bytes))
}
func writeConfigFile(t *testing.T, contents string) string {
path := "/tmp/atlantis-test-config-file"
invalidJSON := []byte(contents)
ok(t, ioutil.WriteFile(path, invalidJSON, 0644))
Ok(t, ioutil.WriteFile(path, invalidJSON, 0644))
return path
}

View File

@@ -2,9 +2,8 @@ package middleware
import (
"net/http"
"github.com/hootsuite/atlantis/logging"
"github.com/urfave/negroni"
"github.com/hootsuite/atlantis/logging"
)
func NewNon200Logger(logger *logging.SimpleLogger) *FailedRequestLogger {
@@ -12,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
}

View File

@@ -1,7 +1,6 @@
package main
import (
"bytes"
"errors"
"fmt"
"io/ioutil"
@@ -9,19 +8,21 @@ import (
"os/exec"
"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 Stash, S3, Terraform, and Github
// PlanExecutor handles everything related to running the Terraform plan including integration with S3, Terraform, and Github
type PlanExecutor struct {
BaseExecutor
atlantisURL string
}
/** Result Types **/
type PlanSuccess struct {
TerraformOutput string
DiscardPlanLink string
LockURL string
}
func (p PlanSuccess) Template() *CompiledTemplate {
@@ -29,7 +30,7 @@ func (p PlanSuccess) Template() *CompiledTemplate {
}
type RunLockedFailure struct {
LockingPullLink string
LockingPullID int
}
func (r RunLockedFailure) Template() *CompiledTemplate {
@@ -66,7 +67,6 @@ func (p *PlanExecutor) execute(ctx *ExecutionContext, prCtx *PullRequestContext)
}
func (p *PlanExecutor) setupAndPlan(ctx *ExecutionContext, prCtx *PullRequestContext) ExecutionResult {
stashCtx := p.stashContext(ctx)
p.github.UpdateStatus(prCtx, "pending", "Planning...")
// todo: lock when cloning or somehow separate workspaces
@@ -184,7 +184,7 @@ func (p *PlanExecutor) setupAndPlan(ctx *ExecutionContext, prCtx *PullRequestCon
p.terraform.tfExecutableName = "terraform"
}
}
generatePlanResponse := p.plan(ctx.log, p.stash, stashCtx, cloneDir, p.scratchDir, tfPlanName, s3Client, path, ctx.command.environment, s3Key, p.sshKey, ctx.pullCreator, config.StashPath)
generatePlanResponse := p.plan(ctx.log, prCtx, cloneDir, p.scratchDir, tfPlanName, s3Client, path, ctx.command.environment, s3Key, p.sshKey, ctx.pullCreator, config.StashPath)
generatePlanResponse.Path = path.Relative
planOutputs = append(planOutputs, generatePlanResponse)
}
@@ -195,8 +195,7 @@ func (p *PlanExecutor) setupAndPlan(ctx *ExecutionContext, prCtx *PullRequestCon
// 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,
stash *StashPRClient,
stashCtx *StashPullRequestContext,
prCtx *PullRequestContext,
repoDir string,
planOutDir string,
tfPlanName string,
@@ -208,40 +207,40 @@ func (p *PlanExecutor) plan(log *logging.SimpleLogger,
pullRequestCreator string,
stashPath string) PathResult {
log.Info("generating plan for path %q", path)
var discardUrl string
var remoteStatePath string
run := locking.Run{
RepoOwner: prCtx.owner,
RepoName: prCtx.repoName,
Path: path.Relative,
Env: tfEnvName,
PullID: prCtx.number,
User: prCtx.terraformApplier,
Timestamp: time.Now(),
}
// NOTE: THIS CODE IS TO SUPPORT TERRAFORM PROJECTS THAT AREN'T USING ATLANTIS CONFIG FILE.
if stashPath == "" {
remoteStatePath, err := p.terraform.ConfigureRemoteState(log, path, tfEnvName, sshKey)
_, err := p.terraform.ConfigureRemoteState(log, path, tfEnvName, sshKey)
if err != nil {
return PathResult{
Status: "error",
Result: GeneralError{fmt.Errorf("failed to configure remote state: %s", err)},
}
}
stashLockResponse := stash.LockState(log, stashCtx, remoteStatePath)
if !stashLockResponse.Success {
return PathResult{
Status: "failure",
Result: RunLockedFailure{stashLockResponse.PullRequestLink},
}
}
lockAttempt, err := p.lockManager.TryLock(run)
if err != nil {
return PathResult{
Status:" failure",
Result: GeneralError{fmt.Errorf("failed to lock state: %v", err)},
}
}
discardUrl = stashLockResponse.DiscardUrl
} else {
// use state path from config file
remoteStatePath = generateStatePath(stashPath, tfEnvName)
stashLockResponse := stash.LockState(log, stashCtx, remoteStatePath)
if !stashLockResponse.Success {
return PathResult{
Status: "failure",
Result: RunLockedFailure{stashLockResponse.PullRequestLink},
}
// the run is locked unless the locking run is the same pull id as this run
if lockAttempt.LockAcquired == false && lockAttempt.LockingRun.PullID != prCtx.number {
return PathResult{
Status: "failure",
Result: RunLockedFailure{lockAttempt.LockingRun.PullID},
}
discardUrl = stashLockResponse.DiscardUrl
}
// Run terraform plan
@@ -301,9 +300,10 @@ func (p *PlanExecutor) plan(log *logging.SimpleLogger,
Output: output,
}
log.Err("error running terraform plan: %v", output)
log.Info("unlocking stash state since plan failed")
stash.UnlockState(log, stashCtx, remoteStatePath)
// todo: log if error locking state in stash
log.Info("unlocking state since plan failed")
if err := p.lockManager.Unlock(lockAttempt.LockID); err != nil {
log.Err("error unlocking state: %v", err)
}
return PathResult{
Status: "failure",
Result: err,
@@ -314,8 +314,9 @@ func (p *PlanExecutor) plan(log *logging.SimpleLogger,
if err := UploadPlanFile(s3Client, s3Key, tfPlanOutputPath); err != nil {
err = fmt.Errorf("failed to upload to S3: %v", err)
log.Err(err.Error())
stash.UnlockState(log, stashCtx, remoteStatePath)
// todo: log if error locking state in stash
if err := p.lockManager.Unlock(lockAttempt.LockID); err != nil {
log.Err("error unlocking state: %v", err)
}
return PathResult{
Status: "error",
Result: GeneralError{err},
@@ -332,7 +333,7 @@ func (p *PlanExecutor) plan(log *logging.SimpleLogger,
Status: "success",
Result: PlanSuccess{
TerraformOutput: output,
DiscardPlanLink: stashUrl + discardUrl, // todo: stashUrl shouldn't be here, wrong level of abstraction
LockURL: fmt.Sprintf("%s%s/%s?method=DELETE", p.atlantisURL, lockPath, lockAttempt.LockID),
},
}
}
@@ -358,15 +359,6 @@ func (p *PlanExecutor) trimSuffix(s, suffix string) string {
return s
}
func concatStr(s []string) string {
var buffer bytes.Buffer
for _, str := range s {
buffer.WriteString(str)
}
return buffer.String()
}
func (p *PlanExecutor) removeDuplicates(paths []ExecutionPath) []ExecutionPath {
deDuped := []ExecutionPath{}
seen := map[ExecutionPath]bool{}

View File

@@ -6,7 +6,6 @@ import (
"io/ioutil"
"os"
"os/exec"
"github.com/hootsuite/atlantis/logging"
)

View File

@@ -4,11 +4,11 @@ import (
"log"
"os"
"testing"
. "github.com/hootsuite/atlantis/testing_util"
"github.com/hootsuite/atlantis/logging"
)
var level = logging.Info
var level logging.LogLevel = logging.Info
var logger = &logging.SimpleLogger{
Source: "server",
Log: log.New(os.Stderr, "", log.LstdFlags),
@@ -17,30 +17,30 @@ var logger = &logging.SimpleLogger{
func TestPreRunCreateScript_empty(t *testing.T) {
scriptName, err := createScript(nil)
assert(t, scriptName == "", "there should not be a script name")
assert(t, err == nil, "there should not be an error")
Assert(t, scriptName == "", "there should not be a script name")
Assert(t, err == nil, "there should not be an error")
}
func TestPreRunCreateScript_valid(t *testing.T) {
cmds := []string{"echo", "date"}
scriptName, err := createScript(cmds)
assert(t, scriptName != "", "there should be a script name")
assert(t, err == nil, "there should not be an error")
Assert(t, scriptName != "", "there should be a script name")
Assert(t, err == nil, "there should not be an error")
}
func TestPreRunExecuteScript_invalid(t *testing.T) {
cmds := []string{"invalid", "command"}
scriptName, _ := createScript(cmds)
_, err := execute(scriptName)
assert(t, err != nil, "there should be an error")
Assert(t, err != nil, "there should be an error")
}
func TestPreRunExecuteScript_valid(t *testing.T) {
cmds := []string{"echo", "date"}
scriptName, _ := createScript(cmds)
output, err := execute(scriptName)
assert(t, err == nil, "there should not be an error")
assert(t, output != "", "there should be output")
Assert(t, err == nil, "there should not be an error")
Assert(t, output != "", "there should be output")
}
func TestPreRun_valid(t *testing.T) {
@@ -52,7 +52,7 @@ func TestPreRun_valid(t *testing.T) {
config.PreApply = preApply
config.StashPath = "/some/path"
err := PreRun(&config, logger, "/some/path", &Command{environment: "staging", commandType: Plan})
assert(t, err == nil, "should not error")
Assert(t, err == nil, "should not error")
}
@@ -63,6 +63,5 @@ func TestPreRun_partial_valid(t *testing.T) {
config.PrePlan = prePlan
config.StashPath = "/some/path"
err := PreRun(&config, logger, "/some/path", &Command{environment: "staging", commandType: Plan})
assert(t, err == nil, "should not error")
Assert(t, err == nil, "should not error")
}

View File

@@ -66,7 +66,7 @@ func (r *RequestParser) determineCommand(comment *github.IssueCommentEvent) (*Co
}
func (r *RequestParser) extractCommentData(comment *github.IssueCommentEvent, params *ExecutionContext) error {
missingField := "<nil>" // todo move this to shared
missingField := "<nil>"
owner := comment.Repo.Owner.Login
if owner == nil {

140
server.go
View File

@@ -2,21 +2,29 @@ package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"os"
"strings"
"encoding/json"
"github.com/elazarl/go-bindata-assetfs"
"github.com/google/go-github/github"
"github.com/hootsuite/atlantis/logging"
"github.com/hootsuite/atlantis/middleware"
"github.com/hootsuite/atlantis/recovery"
"github.com/urfave/cli"
"github.com/hootsuite/atlantis/recovery"
"github.com/hootsuite/atlantis/locking"
"github.com/boltdb/bolt"
"github.com/urfave/negroni"
"github.com/hootsuite/atlantis/middleware"
"github.com/hootsuite/atlantis/logging"
"github.com/elazarl/go-bindata-assetfs"
"github.com/gorilla/mux"
"github.com/pkg/errors"
)
const (
lockPath = "/locks"
)
// WebhookServer listens for Github webhooks and runs the necessary Atlantis command
@@ -33,20 +41,25 @@ type Server struct {
logger *logging.SimpleLogger
githubComments *GithubCommentRenderer
requestParser *RequestParser
lockManager locking.LockManager
}
type ServerConfig struct {
githubUsername string
githubPassword string
githubHostname string
sshKey string
awsAssumeRole string
port int
scratchDir string
awsRegion string
s3Bucket string
logLevel string
requireApproval bool
githubUsername string
githubPassword string
githubHostname string
sshKey string
awsAssumeRole string
port int
scratchDir string
awsRegion string
s3Bucket string
logLevel string
atlantisURL string
requireApproval bool
dataDir string
lockingBackend string
lockingTable string
}
type ExecutionContext struct {
@@ -59,16 +72,16 @@ type ExecutionContext struct {
repoSSHUrl string
head string
// commit base sha
base string
pullLink string
branch string
htmlUrl string
pullCreator string
command *Command
base string
pullLink string
branch string
htmlUrl string
pullCreator string
command *Command
log *logging.SimpleLogger
}
func NewServer(config *ServerConfig) *Server {
func NewServer(config *ServerConfig, db *bolt.DB) (*Server, error) {
tp := github.BasicAuthTransport{
Username: strings.TrimSpace(config.githubUsername),
Password: strings.TrimSpace(config.githubPassword),
@@ -77,7 +90,6 @@ func NewServer(config *ServerConfig) *Server {
githubClientCtx := context.Background()
githubBaseClient.BaseURL, _ = url.Parse(fmt.Sprintf("https://%s/api/v3/", config.githubHostname))
githubClient := &GithubClient{client: githubBaseClient, ctx: githubClientCtx}
stashClient := &StashClient{}
terraformClient := &TerraformClient{
tfExecutableName: "terraform",
}
@@ -86,19 +98,29 @@ func NewServer(config *ServerConfig) *Server {
AWSRegion: config.awsRegion,
AWSRoleArn: config.awsAssumeRole,
}
var lockManager locking.LockManager
if config.lockingBackend == dynamoDBLockingBackend {
session, err := awsConfig.CreateAWSSession()
if err != nil {
return nil, errors.Wrap(err, "creating aws session for DynamoDB")
}
lockManager = locking.NewDynamoDBLockManager(config.lockingTable, session)
} else {
lockManager = locking.NewBoltDBLockManager(db, boltDBRunLocksBucket)
}
baseExecutor := BaseExecutor{
github: githubClient,
awsConfig: awsConfig,
scratchDir: config.scratchDir,
s3Bucket: config.s3Bucket,
sshKey: config.sshKey,
stash: &StashPRClient{client: stashClient},
ghComments: githubComments,
terraform: terraformClient,
githubCommentRenderer: githubComments,
lockManager: lockManager,
}
applyExecutor := &ApplyExecutor{BaseExecutor: baseExecutor, requireApproval: config.requireApproval, atlantisGithubUser: config.githubUsername}
planExecutor := &PlanExecutor{BaseExecutor: baseExecutor}
planExecutor := &PlanExecutor{BaseExecutor: baseExecutor, atlantisURL: config.atlantisURL}
helpExecutor := &HelpExecutor{BaseExecutor: baseExecutor}
logger := logging.NewSimpleLogger("server", log.New(os.Stderr, "", log.LstdFlags), false, logging.ToLogLevel(config.logLevel))
return &Server{
@@ -114,14 +136,23 @@ func NewServer(config *ServerConfig) *Server {
logger: logger,
githubComments: githubComments,
requestParser: &RequestParser{},
}
lockManager: lockManager,
}, nil
}
func (s *Server) Start() error {
router := http.NewServeMux()
router.HandleFunc("/", s.index)
router.Handle("/static/", http.FileServer(&assetfs.AssetFS{Asset: Asset, AssetDir: AssetDir, AssetInfo: AssetInfo}))
router.HandleFunc("/hooks", s.postHooks)
router := mux.NewRouter()
router.HandleFunc("/", s.index).Methods("GET").MatcherFunc(func(r *http.Request, rm *mux.RouteMatch) bool {
return r.URL.Path == "/" || r.URL.Path == "/index.html"
})
router.PathPrefix("/static/").Handler(http.FileServer(&assetfs.AssetFS{Asset: Asset, AssetDir: AssetDir, AssetInfo: AssetInfo}))
router.HandleFunc("/hooks", s.postHooks).Methods("POST")
router.HandleFunc("/locks/{id}", s.deleteLock).Methods("DELETE")
// todo: remove this route when there is a detail view
// right now we need this route because from the pull request comment in GitHub only a GET request can be made
// in the future, the pull discard link will link to the detail view which will have a Delete button which will
// make an real DELETE call but we don't have a detail view right now
router.HandleFunc("/locks/{id}", s.deleteLock).Queries("method", "DELETE").Methods("GET")
n := negroni.New(&negroni.Recovery{
Logger: log.New(os.Stdout, "", log.LstdFlags),
PrintStack: false,
@@ -134,33 +165,42 @@ func (s *Server) Start() error {
}
func (s *Server) index(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" && r.URL.Path != "/index.html" {
http.NotFound(w, r)
locks, err := s.lockManager.ListLocks()
if err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
fmt.Fprintf(w, "Could not retrieve locks: %s", err)
return
}
// this is just a placeholder to test the ui
type result struct {
RepoOwner string
RepoName string
PullRequestId int
Timestamp string
}
res := result{}
res.RepoOwner = "anubhavmishra"
res.RepoName = "atlantis-test"
res.PullRequestId = 10
res.Timestamp = "2017-05-08T11:18:43.91646206-07:00"
var results []result
results = append(results, res)
type runLock struct {
locking.Run
ID string
}
var results []runLock
for id, v := range locks {
results = append(results, runLock{
v,
id,
})
}
indexTemplate.Execute(w, results)
}
func (s *Server) postHooks(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.NotFound(w, r)
func (s *Server) deleteLock(w http.ResponseWriter, r *http.Request) {
id, ok := mux.Vars(r)["id"]
if !ok {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, "no lock id in request")
}
if err := s.lockManager.Unlock(id); err != nil {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, "Failed to unlock: %s", err)
return
}
fmt.Fprint(w, "Unlocked successfully")
}
func (s *Server) postHooks(w http.ResponseWriter, r *http.Request) {
githubReqID := "X-Github-Delivery=" + r.Header.Get("X-Github-Delivery")
// try to parse webhook data as a comment event

View File

@@ -1,72 +0,0 @@
package main
import (
"bytes"
"encoding/json"
"io/ioutil"
"net/http"
"github.com/hootsuite/atlantis/logging"
)
const stashUrl = "http://stash.hootops.com"
type StashClient struct{}
type StashLockResponse struct {
Message string `json:"message"`
PullRequestLink string `json:"pull_request_link"`
DiscardUrl string `json:"discard_url"`
StatusCode int64 `json:"status_code"`
Success bool `json:"success"`
}
type StashUnlockResponse struct {
Message string `json:"message"`
Success bool `json:"success"`
}
// todo: refactor so this returns error
func (s *StashClient) LockState(log *logging.SimpleLogger, path string, state []byte) StashLockResponse {
req, err := http.NewRequest("POST", stashUrl+"/lock/"+path, bytes.NewBuffer(state))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
log.Err("failed making lock request to Stash: %v", err)
}
defer resp.Body.Close()
body, _ := ioutil.ReadAll(resp.Body)
log.Debug("stash response body: %s", string(body))
var jsonData StashLockResponse
err = json.Unmarshal(body, &jsonData)
if err != nil {
log.Err("failed parsing lock stash response: %v", err)
}
return jsonData
}
func (s *StashClient) UnlockState(log *logging.SimpleLogger, path string, state []byte) StashUnlockResponse {
req, err := http.NewRequest("POST", stashUrl+"/unlock/"+path, bytes.NewBuffer(state))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
log.Err("failed making unlock request to Stash: %v", err)
}
defer resp.Body.Close()
body, _ := ioutil.ReadAll(resp.Body)
log.Info("stash response body: %s", string(body))
var jsonData StashUnlockResponse
err = json.Unmarshal(body, &jsonData)
if err != nil {
log.Err("failed parsing unlock stash response: %v", err)
}
return jsonData
}

View File

@@ -1,45 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"github.com/hootsuite/atlantis/logging"
)
type StashPullRequestContext struct {
owner string
repoName string
number int
pullRequestLink string
terraformApplier string
terraformApplierEmail string
}
type StashPRClient struct {
client *StashClient
}
func (s *StashPRClient) LockState(log *logging.SimpleLogger, ctx *StashPullRequestContext, path string) StashLockResponse {
state := map[string]string{
"pull_request_id": fmt.Sprintf("%d", ctx.number),
"pull_request_link": ctx.pullRequestLink,
"owner_name": ctx.owner,
"repo_name": ctx.repoName,
"terraform_applier": ctx.terraformApplier,
"terraform_applier_email": ctx.terraformApplierEmail,
}
stateJson, _ := json.Marshal(state) // todo: swallowing error here since don't want to construct StashLockResponse, but I think that StashLockResponse should not encapsulate the idea of an error
return s.client.LockState(log, path, stateJson)
}
func (s *StashPRClient) UnlockState(log *logging.SimpleLogger, ctx *StashPullRequestContext, path string) StashUnlockResponse {
state := map[string]string{
"pull_request_link": ctx.pullRequestLink,
"terraform_applier": ctx.terraformApplier,
"repo_name": ctx.repoName,
"terraform_applier_email": ctx.terraformApplierEmail,
}
stateJson, _ := json.Marshal(state) // todo: swallowing error here since don't want to construct StashLockResponse, but I think that StashLockResponse should not encapsulate the idea of an error
return s.client.UnlockState(log, path, stateJson)
}

View File

@@ -142,7 +142,7 @@ tbody {
-webkit-filter: drop-shadow(0 0 6px rgba(0,0,0,.1));
-moz-filter: drop-shadow(0 0 6px rgba(0,0,0,.1));
filter: drop-shadow(0 0 6px rgba(0,0,0,.1)); }
.popover-item:first-child .popover-link:after,
.popover-item:first-child .popover-link:after,
.popover-item:first-child .popover-link:before {
bottom: 100%;
left: 50%;
@@ -232,4 +232,21 @@ tbody {
background-color: #E74C3C;
border: #E74C3C;
color: #FFFFFF;
}
}
.unlock{
height: 16px;
margin-top: 12px;
line-height: 18px;
padding: 0 10px;
font-family: monospace;
}
.list-unlock{
float: right;
}
.lock-row{
cursor: default;
}
.unlock-link{
color: #555;
text-decoration: none;
}

File diff suppressed because one or more lines are too long

View File

@@ -6,7 +6,6 @@ import (
"os/exec"
"path/filepath"
"regexp"
"github.com/hootsuite/atlantis/logging"
)

View File

@@ -0,0 +1,38 @@
package testing_util
// 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()
}
}

View File

@@ -2,9 +2,6 @@ package main
import (
"html/template"
"net/http"
"strings"
"github.com/elazarl/go-bindata-assetfs"
)
var indexTemplate = template.Must(template.New("index.html.tmpl").Parse(`
@@ -22,52 +19,33 @@ var indexTemplate = template.Must(template.New("index.html.tmpl").Parse(`
<link rel="icon" type="image/png" href="/static/atlantis-icon.png">
</head>
<body>
<div class="container">
<section class="header">
<a title="atlantis" href="/"><img src="/static/atlantis-icon.png" /></a>
<div class="container">
<section class="header">
<a title="atlantis" href="/"><img src="/static/atlantis-icon.png"/></a>
<p style="font-family: monospace, monospace; font-size: 1.1em; text-align: center;">atlantis</p>
</section>
<nav class="navbar">
<div class="container">
</div>
</nav>
<div class="navbar-spacer"></div>
<br>
<section>
</section>
<nav class="navbar">
<div class="container">
</div>
</nav>
<div class="navbar-spacer"></div>
<br>
<section>
<p style="font-family: monospace, monospace; font-size: 1.0em; text-align: center;"><strong>Environments</strong></p>
{{ if . }}
{{ range . }}
<a href="/lock/view/{{.RepoOwner}}/{{.RepoName}}/{{.PullRequestId}}"><div class="twelve columns button content"><div class="list-title">{{.RepoOwner}}/{{.RepoName}} - <span class="heading-font-size">#{{.PullRequestId}}</span></div> <div class="list-status"><code>Locked</code></div> <div class="list-timestamp"><span class="heading-font-size">{{.Timestamp}}</span></div></div></a>
{{ end }}
{{ else }}
<p class="placeholder">No environments found.</p>
{{ end }}
</section>
</div>
{{ if . }}
{{ range . }}
<div class="twelve columns button content lock-row">
<div class="list-title">{{.RepoOwner}}/{{.RepoName}} - <span class="heading-font-size">#{{.PullID}}</span></div>
<div class="list-unlock"><button class="unlock"><a class="unlock-link" href="/locks/{{.ID}}?method=DELETE">Unlock</a></button></div>
<div class="list-status"><code>Locked</code></div>
<div class="list-timestamp"><span class="heading-font-size">{{.Timestamp}}</span></div>
</div>
{{ end }}
{{ else }}
<p class="placeholder">No environments found.</p>
{{ end }}
</section>
</div>
</body>
</html>
`))
type binaryFileSystem struct {
fs http.FileSystem
}
func NewBinaryFileSystem(root string) *binaryFileSystem {
return &binaryFileSystem{
&assetfs.AssetFS{Asset: Asset, AssetDir: AssetDir, AssetInfo: AssetInfo, Prefix: root},
}
}
func (b *binaryFileSystem) Open(name string) (http.File, error) {
return b.fs.Open(name)
}
func (b *binaryFileSystem) Exists(prefix string, filepath string) bool {
if p := strings.TrimPrefix(filepath, prefix); len(p) < len(filepath) {
if _, err := b.fs.Open(p); err != nil {
return false
}
return true
}
return false
}