mirror of
https://git.vectorsigma.ru/public/atlantis.git
synced 2026-07-28 21:28:20 +00:00
Local locking with BoltDB or remote with DynamoDB
This commit is contained in:
19
README.md
19
README.md
@@ -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"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
113
locking/boltdb_lock_manager.go
Normal file
113
locking/boltdb_lock_manager.go
Normal 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)
|
||||
}
|
||||
260
locking/boltdb_lock_manager_test.go
Normal file
260
locking/boltdb_lock_manager_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
152
locking/dynamodb_lock_manager.go
Normal file
152
locking/dynamodb_lock_manager.go
Normal 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)
|
||||
}
|
||||
28
locking/dynamodb_lock_manager_test.go
Normal file
28
locking/dynamodb_lock_manager_test.go
Normal 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
40
locking/lock_manager.go
Normal 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
154
main.go
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
228
main_test.go
228
main_test.go
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"github.com/hootsuite/atlantis/logging"
|
||||
)
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
140
server.go
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
5
static/js/jquery-1.11.3.min.js
vendored
5
static/js/jquery-1.11.3.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -6,7 +6,6 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
|
||||
"github.com/hootsuite/atlantis/logging"
|
||||
)
|
||||
|
||||
|
||||
38
testing_util/testing_util.go
Normal file
38
testing_util/testing_util.go
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user