diff --git a/Makefile b/Makefile index b273b4e63..74003d975 100644 --- a/Makefile +++ b/Makefile @@ -25,10 +25,10 @@ deps-test: go get -t test: ## Run tests, coverage reports, and clean (coverage taints the compiled code) - go test -v . + go test -v ./... test-coverage: - go test -v -coverprofile=c.out + go test -v ./... -coverprofile=c.out go tool cover -html=c.out -o coverage.html dist: ## Package up everything in static/ using go-bindata-assetfs so it can be served by a single binary diff --git a/README.md b/README.md index 8f531afd9..9af9088bb 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ # atlantis +[![CircleCI](https://circleci.com/gh/hootsuite/atlantis.svg?style=svg&circle-token=6a0c78c9b1fd77486c72a5e22512c7c9175f2aaf)](https://circleci.com/gh/hootsuite/atlantis) + A [terraform](https://www.terraform.io/) collaboration tool that enables you to collaborate on infrastructure safely and securely. ## Locking diff --git a/circle.yml b/circle.yml new file mode 100644 index 000000000..dbe1dfea0 --- /dev/null +++ b/circle.yml @@ -0,0 +1,32 @@ +machine: + # Environment variables for build + environment: + TERRAFORM_VERSION: 0.8.8 + +dependencies: + override: + # Install dependencies + - make deps + +test: + pre: + # Test dependencies + - make deps-test + + override: + # Run tests + - ./scripts/build.sh + + # Run e2e tests + post: + - ./scripts/e2e-deps.sh + # Start atlantis server + - ./atlantis server --gh-user="$GITHUB_USERNAME" --gh-password="$GITHUB_PASSWORD" --data-dir="/tmp" --require-approval=false --s3-bucket="$ATLANTIS_S3_BUCKET_NAME" --log-level="debug" &> /tmp/atlantis-server.log: + background: true + - sleep 2 + - ./ngrok http 4141: + background: true + - sleep 2 + # Set ATLANTIS_URL environment variable to be used by atlantis e2e test to create the webhook + - echo 'export ATLANTIS_URL=$(curl -s 'http://localhost:4040/api/tunnels' | jq -r '.tunnels[1].public_url') ' >> ~/.circlerc + - ./scripts/e2e.sh diff --git a/e2e/.gitconfig b/e2e/.gitconfig new file mode 100644 index 000000000..320325ead --- /dev/null +++ b/e2e/.gitconfig @@ -0,0 +1,3 @@ +[user] + name = Anubhav Mishra + email = anubhav.mishra@hootsuite.com diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 000000000..b38a38e4a --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1 @@ +atlantis-tests diff --git a/e2e/Makefile b/e2e/Makefile new file mode 100644 index 000000000..e9ab78f0b --- /dev/null +++ b/e2e/Makefile @@ -0,0 +1,25 @@ +WORKSPACE := $(shell pwd) + +.PHONY: test + +.DEFAULT_GOAL := help +help: ## List targets & descriptions + @cat Makefile* | grep -E '^[a-zA-Z_-]+:.*?## .*$$' | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' + +debug: ## Output internal make variables + @echo WORKSPACE = $(WORKSPACE) + +deps: ## Get go dependencies + go get . + +build: ## Build the main Go service + go build -v -o atlantis-tests . + +deps-test: ## Run tests for go dependencies + go get -t + +test: ## Run tests, coverage reports, and clean (coverage taints the compiled code) + go test -v . + +run: ## Run e2e tests + ./atlantis-tests diff --git a/e2e/e2e.go b/e2e/e2e.go new file mode 100644 index 000000000..4cb019c38 --- /dev/null +++ b/e2e/e2e.go @@ -0,0 +1,189 @@ +package main + +import ( + "errors" + "fmt" + "io/ioutil" + "log" + "os" + "os/exec" + "time" + + "github.com/google/go-github/github" +) + +type E2ETester struct { + githubClient *GithubClient + repoUrl string + ownerName string + repoName string + hookID int + cloneDirRoot string + projectType Project +} + +type E2EResult struct { + projectType string + githubPullRequestURL string + testResult string +} + +var testFileData = ` +resource "null_resource" "hello" { +} +` + +func (t *E2ETester) Start() (*E2EResult, error) { + cloneDir := fmt.Sprintf("%s/%s-test", t.cloneDirRoot, t.projectType.Name) + branchName := fmt.Sprintf("%s-%s", t.projectType.Name, time.Now().Format("20060102150405")) + testFileName := fmt.Sprintf("%s.tf", t.projectType.Name) + e2eResult := &E2EResult{} + e2eResult.projectType = t.projectType.Name + + // create the directory and parents if necessary + log.Printf("creating dir %q", cloneDir) + if err := os.MkdirAll(cloneDir, 0755); err != nil { + return e2eResult, errors.New(fmt.Sprintf("failed to create dir %q prior to cloning, attempting to continue: %v", cloneDir, err)) + } + + cloneCmd := exec.Command("git", "clone", t.repoUrl, cloneDir) + // git clone the repo + log.Printf("git cloning into %q", cloneDir) + if output, err := cloneCmd.CombinedOutput(); err != nil { + return e2eResult, errors.New(fmt.Sprintf("failed to clone repository: %v: %s", err, string(output))) + } + + // checkout a new branch for the project + log.Printf("checking out branch %q", branchName) + checkoutCmd := exec.Command("git", "checkout", "-b", branchName) + checkoutCmd.Dir = cloneDir + if err := checkoutCmd.Run(); err != nil { + return e2eResult, errors.New(fmt.Sprintf("failed to git checkout branch %q: %v", branchName, err)) + } + + // write a file for running the tests + randomData := []byte(testFileData) + filePath := fmt.Sprintf("%s/%s/%s", cloneDir, t.projectType.Name, testFileName) + log.Printf("creating file to commit %q", filePath) + err := ioutil.WriteFile(filePath, randomData, 0644) + if err != nil { + return e2eResult, errors.New(fmt.Sprintf("couldn't write file %s: %v", filePath, err)) + } + + // add the file + log.Printf("git add file %q", filePath) + addCmd := exec.Command("git", "add", filePath) + addCmd.Dir = cloneDir + if err := addCmd.Run(); err != nil { + return e2eResult, errors.New(fmt.Sprintf("failed to git add file %q: %v", filePath, err)) + } + + // commit the file + log.Printf("git commit file %q", filePath) + commitCmd := exec.Command("git", "commit", "-am", "test commit") + commitCmd.Dir = cloneDir + if output, err := commitCmd.CombinedOutput(); err != nil { + return e2eResult, errors.New(fmt.Sprintf("failed to run git commit in %q: %v", cloneDir, err, string(output))) + } + + // push the branch to remote + log.Printf("git push branch %q", branchName) + pushCmd := exec.Command("git", "push", "origin", branchName) + pushCmd.Dir = cloneDir + if err := pushCmd.Run(); err != nil { + return e2eResult, errors.New(fmt.Sprintf("failed to git push branch %q: %v", branchName, err)) + } + + // create a new pr + title := fmt.Sprintf("This is a test pull request for atlantis e2e test for %s project type", t.projectType.Name) + head := fmt.Sprintf("%s:%s", t.githubClient.username, branchName) + body := "" + base := "master" + newPullRequest := &github.NewPullRequest{Title: &title, Head: &head, Body: &body, Base: &base} + + pull, _, err := t.githubClient.client.PullRequests.Create(t.githubClient.ctx, t.ownerName, t.repoName, newPullRequest) + if err != nil { + return e2eResult, errors.New(fmt.Sprintf("error while creating new pull request: %v", err)) + } + + // set pull request url + e2eResult.githubPullRequestURL = pull.GetHTMLURL() + + log.Printf("created pull request %s", pull.GetHTMLURL()) + + // defer closing pull request and delete remote branch + defer cleanUp(t, pull.GetNumber(), branchName) + + // create run plan comment + log.Printf("creating plan comment: %q", t.projectType.PlanCommand) + _, _, err = t.githubClient.client.Issues.CreateComment(t.githubClient.ctx, t.ownerName, t.repoName, pull.GetNumber(), &github.IssueComment{Body: github.String(t.projectType.PlanCommand)}) + if err != nil { + return e2eResult, errors.New(fmt.Sprintf("error creating 'run plan' comment on github")) + } + + // wait for atlantis to respond to webhook + time.Sleep(2 * time.Second) + + state := "not started" + // waiting for atlantis run and finish + for checkStatus(state) { + if state == "" { + log.Println("atlantis run hasn't started") + } + time.Sleep(2 * time.Second) + state, _ = getAtlantisStatus(t, branchName) + log.Printf("atlantis run is in %s state", state) + } + + log.Printf("atlantis run finished with %s status", state) + e2eResult.testResult = state + // check if atlantis run was a success + if state != "\"success\"" { + return e2eResult, errors.New(fmt.Sprintf("atlantis run project type %q failed with %s status", t.projectType.Name, state)) + } + + return e2eResult, nil +} + +func getAtlantisStatus(t *E2ETester, branchName string) (string, error) { + // check repo status + combinedStatus, _, err := t.githubClient.client.Repositories.GetCombinedStatus(t.githubClient.ctx, t.ownerName, t.repoName, branchName, nil) + if err != nil { + return "", err + } + + for _, status := range combinedStatus.Statuses { + if github.Stringify(status.Context) == "\"Atlantis\"" { + return github.Stringify(status.State), nil + } + } + + return "", nil +} + +func checkStatus(state string) bool { + for _, s := range []string{"\"success\"", "\"error\"", "\"failure\""} { + if state == s { + return false + } + } + return true +} + +func cleanUp(t *E2ETester, pullRequestNumber int, branchName string) error { + // clean up + pullClosed, _, err := t.githubClient.client.PullRequests.Edit(t.githubClient.ctx, t.ownerName, t.repoName, pullRequestNumber, &github.PullRequest{State: github.String("closed")}) + if err != nil { + return errors.New(fmt.Sprintf("error while closing new pull request: %v", err)) + } + log.Printf("closed pull request %d", pullClosed.GetNumber()) + + deleteBranchName := fmt.Sprintf("%s/%s", "heads", branchName) + _, err = t.githubClient.client.Git.DeleteRef(t.githubClient.ctx, t.ownerName, t.repoName, deleteBranchName) + if err != nil { + return errors.New(fmt.Sprintf("error while deleting branch %s: %v", deleteBranchName, err)) + } + log.Printf("deleted branch %s", deleteBranchName) + + return nil +} diff --git a/e2e/github.go b/e2e/github.go new file mode 100644 index 000000000..67e87788f --- /dev/null +++ b/e2e/github.go @@ -0,0 +1,13 @@ +package main + +import ( + "context" + + "github.com/google/go-github/github" +) + +type GithubClient struct { + client *github.Client + ctx context.Context + username string +} diff --git a/e2e/main.go b/e2e/main.go new file mode 100644 index 000000000..f1200032c --- /dev/null +++ b/e2e/main.go @@ -0,0 +1,161 @@ +package main + +import ( + "context" + "log" + "os" + "strings" + + "fmt" + + "github.com/google/go-github/github" + "github.com/hashicorp/go-multierror" +) + +var defaultAtlantisURL = "http://localhost:4141/hooks" +var projectTypes = []Project{ + Project{"standalone", "run plan", "run apply"}, + Project{"standalone-with-env", "run plan staging", "run apply staging"}, +} + +type Project struct { + Name string + PlanCommand string + ApplyCommand string +} + +func main() { + + githubUsername := os.Getenv("GITHUB_USERNAME") + if githubUsername == "" { + log.Fatalf("GITHUB_USERNAME cannot be empty") + } + githubPassword := os.Getenv("GITHUB_PASSWORD") + if githubPassword == "" { + log.Fatalf("GITHUB_PASSWORD cannot be empty") + } + atlantisURL := os.Getenv("ATLANTIS_URL") + if atlantisURL == "" { + atlantisURL = defaultAtlantisURL + } + // add /hooks to the url + atlantisURL = fmt.Sprintf("%s/hooks", atlantisURL) + ownerName := os.Getenv("GITHUB_REPO_OWNER_NAME") + if ownerName == "" { + ownerName = "anubhavmishra" + } + repoName := os.Getenv("GITHUB_REPO_NAME") + if repoName == "" { + repoName = "atlantis-tests" + } + // using https to clone the repo + repoUrl := fmt.Sprintf("https://%s:%s@github.com/%s/%s.git", githubUsername, githubPassword, ownerName, repoName) + cloneDirRoot := os.Getenv("CLONE_DIR") + if cloneDirRoot == "" { + cloneDirRoot = "/tmp/atlantis-tests" + } + + // clean workspace + log.Printf("cleaning workspace %s", cloneDirRoot) + err := cleanDir(cloneDirRoot) + if err != nil { + log.Fatalf("failed to clean dir %q before cloning, attempting to continue: %v", cloneDirRoot, err) + } + + // create github client + tp := github.BasicAuthTransport{ + Username: strings.TrimSpace(githubUsername), + Password: strings.TrimSpace(githubPassword), + } + ghClient := github.NewClient(tp.Client()) + + githubClient := &GithubClient{client: ghClient, ctx: context.Background(), username: githubUsername} + + // we create atlantis hook once for the repo, since the atlantis server can handle multiple requests + log.Printf("creating atlantis webhook with %s url", atlantisURL) + hookID, err := createAtlantisWebhook(githubClient, ownerName, repoName, atlantisURL) + if err != nil { + log.Fatalf("error creating atlantis webhook: %v", err) + } + + // create e2e test + e2e := E2ETester{ + githubClient: githubClient, + repoUrl: repoUrl, + ownerName: ownerName, + repoName: repoName, + hookID: hookID, + cloneDirRoot: cloneDirRoot, + } + + // start e2e tests + results, err := startTests(e2e) + log.Printf("Test Results\n---------------------------\n") + for _, result := range results { + fmt.Printf("Project Type: %s \n", result.projectType) + fmt.Printf("Pull Request Link: %s \n", result.githubPullRequestURL) + fmt.Printf("Atlantis Run Status: %s \n", result.testResult) + fmt.Println("---------------------------") + } + if err != nil { + log.Fatalf(fmt.Sprintf("%s", err)) + } + +} + +func createAtlantisWebhook(g *GithubClient, ownerName string, repoName string, hookURL string) (int, error) { + // create atlantis hook + atlantisHook := &github.Hook{ + Name: github.String("web"), + Events: []string{"issue_comment", "pull_request", "push"}, + Config: map[string]interface{}{ + "url": hookURL, + "content_type": "json", + }, + Active: github.Bool(true), + } + + // moved to github.go + hook, _, err := g.client.Repositories.CreateHook(g.ctx, ownerName, repoName, atlantisHook) + if err != nil { + return 0, err + } + log.Println(hook.GetURL()) + + return hook.GetID(), nil +} + +func deleteAtlantisHook(g *GithubClient, ownerName string, repoName string, hookID int) error { + _, err := g.client.Repositories.DeleteHook(g.ctx, ownerName, repoName, hookID) + if err != nil { + return err + } + log.Printf("deleted webhook id %d", hookID) + + return nil +} + +func cleanDir(path string) error { + if err := os.RemoveAll(path); err != nil { + return err + } + return nil +} + +func startTests(e2e E2ETester) ([]*E2EResult, error) { + var testResults []*E2EResult + var testErrors *multierror.Error + // delete webhook when we are done running tests + defer deleteAtlantisHook(e2e.githubClient, e2e.ownerName, e2e.repoName, e2e.hookID) + + for _, projectType := range projectTypes { + log.Printf("starting e2e test for project type %q", projectType.Name) + e2e.projectType = projectType + // start e2e test + result, err := e2e.Start() + testResults = append(testResults, result) + testErrors = multierror.Append(testErrors, err) + } + + return testResults, testErrors.ErrorOrNil() +} diff --git a/e2e/secrets-envs b/e2e/secrets-envs new file mode 100644 index 000000000..c58583e83 Binary files /dev/null and b/e2e/secrets-envs differ diff --git a/e2e/standalone-with-env/atlantis.yaml b/e2e/standalone-with-env/atlantis.yaml new file mode 100644 index 000000000..1df19fe5d --- /dev/null +++ b/e2e/standalone-with-env/atlantis.yaml @@ -0,0 +1,2 @@ +--- +stash_path: "/hello/path" \ No newline at end of file diff --git a/e2e/standalone-with-env/env/staging.tfvars b/e2e/standalone-with-env/env/staging.tfvars new file mode 100644 index 000000000..e69de29bb diff --git a/e2e/standalone-with-env/main.tf b/e2e/standalone-with-env/main.tf new file mode 100644 index 000000000..d59a27308 --- /dev/null +++ b/e2e/standalone-with-env/main.tf @@ -0,0 +1,3 @@ +provider "aws" { + region = "us-east-1" +} diff --git a/e2e/standalone/atlantis.yaml b/e2e/standalone/atlantis.yaml new file mode 100644 index 000000000..1df19fe5d --- /dev/null +++ b/e2e/standalone/atlantis.yaml @@ -0,0 +1,2 @@ +--- +stash_path: "/hello/path" \ No newline at end of file diff --git a/e2e/standalone/main.tf b/e2e/standalone/main.tf new file mode 100644 index 000000000..e22b634ef --- /dev/null +++ b/e2e/standalone/main.tf @@ -0,0 +1,3 @@ +provider "aws" { + region = "us-east-1" +} \ No newline at end of file diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 000000000..20d8d452e --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +run_unit_test() { + echo "Running unit tests: 'make test'" + make test +} + +# Run unit tests +run_unit_test + +# Build packages to make sure they can be compiled +echo "Running 'make build'" +make build-service diff --git a/scripts/e2e-deps.sh b/scripts/e2e-deps.sh new file mode 100755 index 000000000..989eadd86 --- /dev/null +++ b/scripts/e2e-deps.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +echo "Preparing to run e2e tests" +mv atlantis e2e/ + +# cd into e2e folder +cd e2e/ +# Encrypting secrets for atlantis runtime: https://github.com/circleci/encrypted-files +openssl aes-256-cbc -d -in secrets-envs -k $KEY >> ~/.circlerc +# Download terraform +curl -LOk https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_amd64.zip +unzip terraform_${TERRAFORM_VERSION}_linux_amd64.zip -d /home/ubuntu/bin +# Download ngrok to create a tunnel to expose atlantis server +wget https://bin.equinox.io/c/4VmDzA7iaHb/ngrok-stable-linux-amd64.zip +unzip ngrok-stable-linux-amd64.zip +chmod +x ngrok +wget https://stedolan.github.io/jq/download/linux64/jq +chmod +x jq +# Copy github config file - replace with circleci user later +cp .gitconfig ~/.gitconfig \ No newline at end of file diff --git a/scripts/e2e.sh b/scripts/e2e.sh new file mode 100755 index 000000000..3653834c4 --- /dev/null +++ b/scripts/e2e.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash + +set -euo pipefail +IFS=$'\n\t' + +# Download dependencies +echo "Running 'make deps'" +make deps + +# Test dependencies +echo "Running 'make deps-test'" +make deps-test + +# Run tests +echo "Running 'make test'" +make test + +# Build packages to make sure they can be compiled +echo "Running 'make build'" +make build + +# Run e2e tests +echo "Running e2e test: 'make run'" +make run diff --git a/server/server.go b/server/server.go index dddad0869..bbde32e7e 100644 --- a/server/server.go +++ b/server/server.go @@ -10,6 +10,11 @@ import ( "os" "strings" +<<<<<<< d425de95fa1f3ee312a8b569290932163bc93bf1 +======= + "io/ioutil" + +>>>>>>> Adding ci for atlantis "github.com/elazarl/go-bindata-assetfs" "github.com/google/go-github/github" "github.com/gorilla/mux" @@ -18,14 +23,20 @@ import ( "github.com/hootsuite/atlantis/locking/dynamodb" "github.com/hootsuite/atlantis/logging" "github.com/hootsuite/atlantis/middleware" +<<<<<<< d425de95fa1f3ee312a8b569290932163bc93bf1 "github.com/hootsuite/atlantis/models" +======= +>>>>>>> Adding ci for atlantis "github.com/hootsuite/atlantis/recovery" "github.com/pkg/errors" "github.com/urfave/cli" "github.com/urfave/negroni" +<<<<<<< d425de95fa1f3ee312a8b569290932163bc93bf1 "io/ioutil" "path/filepath" "time" +======= +>>>>>>> Adding ci for atlantis ) const ( @@ -72,6 +83,7 @@ type ServerConfig struct { LockingDynamoDBTable string `mapstructure:"locking-dynamodb-table"` } +<<<<<<< d425de95fa1f3ee312a8b569290932163bc93bf1 // todo: rename to Command type CommandContext struct { Repo models.Repo @@ -117,6 +129,24 @@ type GeneralError struct { func (g GeneralError) Template() *CompiledTemplate { return GeneralErrorTmpl +======= +type ExecutionContext struct { + repoFullName string + pullNum int + requesterUsername string + requesterEmail string + comment string + repoSSHUrl string + head string + // commit base sha + base string + pullLink string + branch string + htmlUrl string + pullCreator string + command *Command + log *logging.SimpleLogger +>>>>>>> Adding ci for atlantis } func NewServer(config ServerConfig) (*Server, error) { @@ -313,6 +343,10 @@ func (s *Server) handlePullClosedEvent(w http.ResponseWriter, pullEvent github.P fmt.Fprintf(w, "Error unlocking locks: %v\n", err) return } +<<<<<<< d425de95fa1f3ee312a8b569290932163bc93bf1 +======= + +>>>>>>> Adding ci for atlantis fmt.Fprintln(w, "Locks unlocked") }