Files
atlantis/server/plan_executor.go
2017-06-17 14:42:40 -07:00

469 lines
16 KiB
Go

package server
import (
"errors"
"fmt"
"github.com/hootsuite/atlantis/locking"
"github.com/hootsuite/atlantis/logging"
"github.com/hootsuite/atlantis/models"
"io/ioutil"
"os"
"os/exec"
"path"
"path/filepath"
"strings"
)
// PlanExecutor handles everything related to running the Terraform plan including integration with S3, Terraform, and GitHub
type PlanExecutor struct {
github *GithubClient
awsConfig *AWSConfig
scratchDir string
s3Bucket string
sshKey string
terraform *TerraformClient
githubCommentRenderer *GithubCommentRenderer
lockingClient *locking.Client
// DeleteLockURL is a function that given a lock id will return a url for deleting the lock
DeleteLockURL func(id string) (url string)
}
/** Result Types **/
type PlanSuccess struct {
TerraformOutput string
LockURL string
}
func (p PlanSuccess) Template() *CompiledTemplate {
return PlanSuccessTmpl
}
type RunLockedFailure struct {
LockingPullNum int
}
func (r RunLockedFailure) Template() *CompiledTemplate {
return RunLockedFailureTmpl
}
type EnvironmentFileNotFoundFailure struct {
Filename string
}
func (e EnvironmentFileNotFoundFailure) Template() *CompiledTemplate {
return EnvironmentFileNotFoundFailureTmpl
}
type TerraformFailure struct {
Command string
Output string
}
func (t TerraformFailure) Template() *CompiledTemplate {
return TerraformFailureTmpl
}
type EnvironmentFailure struct{}
func (e EnvironmentFailure) Template() *CompiledTemplate {
return EnvironmentErrorTmpl
}
func (p *PlanExecutor) execute(ctx *CommandContext, github *GithubClient) {
res := p.setupAndPlan(ctx)
res.Command = Plan
comment := p.githubCommentRenderer.render(res, ctx.Log.History.String(), ctx.Command.verbose)
github.CreateComment(ctx, comment)
}
func (p *PlanExecutor) setupAndPlan(ctx *CommandContext) ExecutionResult {
p.github.UpdateStatus(ctx.Repo, ctx.Pull, "pending", "Planning...")
// todo: lock when cloning or somehow separate workspaces
// clean the directory where we're going to clone
cloneDir := fmt.Sprintf("%s/%s/%d", p.scratchDir, ctx.Repo.FullName, ctx.Pull.Num)
ctx.Log.Info("cleaning clone directory %q", cloneDir)
if err := os.RemoveAll(cloneDir); err != nil {
ctx.Log.Warn("failed to clean dir %q before cloning, attempting to continue: %v", cloneDir, err)
}
// create the directory and parents if necessary
ctx.Log.Info("creating dir %q", cloneDir)
if err := os.MkdirAll(cloneDir, 0755); err != nil {
ctx.Log.Warn("failed to create dir %q prior to cloning, attempting to continue: %v", cloneDir, err)
}
// Check if ssh key is set and create git ssh wrapper
cloneCmd := exec.Command("git", "clone", ctx.Repo.SSHURL, cloneDir)
if p.sshKey != "" {
err := GenerateSSHWrapper()
if err != nil {
errMsg := fmt.Sprintf("failed to create git ssh wrapper: %v", err)
ctx.Log.Err(errMsg)
p.github.UpdateStatus(ctx.Repo, ctx.Pull, ErrorStatus, "Plan Error")
return ExecutionResult{SetupError: GeneralError{errors.New(errMsg)}}
}
cloneCmd.Env = []string{
fmt.Sprintf("GIT_SSH=%s", defaultSSHWrapper),
fmt.Sprintf("PKEY=%s", p.sshKey),
}
}
// git clone the repo
ctx.Log.Info("git cloning %q into %q", ctx.Repo.SSHURL, cloneDir)
if output, err := cloneCmd.CombinedOutput(); err != nil {
errMsg := fmt.Sprintf("failed to clone repository %q: %v: %s", ctx.Repo.SSHURL, err, string(output))
ctx.Log.Err(errMsg)
p.github.UpdateStatus(ctx.Repo, ctx.Pull, ErrorStatus, "Plan Error")
return ExecutionResult{SetupError: GeneralError{errors.New(errMsg)}}
}
// check out the branch for this PR
ctx.Log.Info("checking out branch %q", ctx.Pull.Branch)
checkoutCmd := exec.Command("git", "checkout", ctx.Pull.Branch)
checkoutCmd.Dir = cloneDir
if err := checkoutCmd.Run(); err != nil {
errMsg := fmt.Sprintf("failed to git checkout branch %q: %v", ctx.Pull.Branch, err)
ctx.Log.Err(errMsg)
p.github.UpdateStatus(ctx.Repo, ctx.Pull, ErrorStatus, "Plan Error")
return ExecutionResult{SetupError: GeneralError{errors.New(errMsg)}}
}
ctx.Log.Info("listing modified files from pull request")
modifiedFiles, err := p.github.GetModifiedFiles(ctx.Repo, ctx.Pull)
if err != nil {
errMsg := fmt.Sprintf("failed to retrieve list of modified files from GitHub: %v", err)
ctx.Log.Err(errMsg)
p.github.UpdateStatus(ctx.Repo, ctx.Pull, ErrorStatus, "Plan Error")
return ExecutionResult{SetupError: GeneralError{errors.New(errMsg)}}
}
modifiedTerraformFiles := p.filterToTerraform(modifiedFiles)
if len(modifiedTerraformFiles) == 0 {
ctx.Log.Info("no modified terraform files found, exiting")
p.github.UpdateStatus(ctx.Repo, ctx.Pull, FailureStatus, "Plan Failed")
return ExecutionResult{SetupError: GeneralError{errors.New("Plan Failed: no modified terraform files found")}}
}
ctx.Log.Debug("Found %d modified terraform files: %v", len(modifiedTerraformFiles), modifiedTerraformFiles)
projects := p.ModifiedProjects(ctx.Repo.FullName, modifiedTerraformFiles)
if len(projects) == 0 {
ctx.Log.Info("no Terraform projects were modified")
p.github.UpdateStatus(ctx.Repo, ctx.Pull, FailureStatus, "Plan Failed")
return ExecutionResult{SetupError: GeneralError{errors.New("Plan Failed: we determined that no terraform projects were modified")}}
}
planFilesPrefix := fmt.Sprintf("%s_%d", strings.Replace(ctx.Repo.FullName, "/", "_", -1), ctx.Pull.Num)
if err := p.CleanWorkspace(ctx.Log, planFilesPrefix, p.scratchDir, cloneDir, projects); err != nil {
errMsg := fmt.Sprintf("failed to clean workspace, aborting: %v", err)
ctx.Log.Err(errMsg)
p.github.UpdateStatus(ctx.Repo, ctx.Pull, ErrorStatus, "Plan Error")
return ExecutionResult{SetupError: GeneralError{errors.New(errMsg)}}
}
s3Client := NewS3Client(p.awsConfig, p.s3Bucket, "plans")
var config Config
// run `terraform plan` in each plan path and collect the results
planOutputs := []PathResult{}
for _, project := range projects {
// todo: not sure it makes sense to be generating the output filename and plan name here
tfPlanFilename := p.GenerateOutputFilename(project, ctx.Command.environment)
tfPlanName := fmt.Sprintf("%s_%d%s", strings.Replace(ctx.Repo.FullName, "/", "_", -1), ctx.Pull.Num, tfPlanFilename)
s3Key := fmt.Sprintf("%s/%s", ctx.Repo.FullName, tfPlanName)
// check if config file is found, if not we continue the run
absolutePath := filepath.Join(cloneDir, project.Path)
if config.Exists(absolutePath) {
ctx.Log.Info("Config file found in %s", absolutePath)
err := config.Read(absolutePath)
if err != nil {
errMsg := fmt.Sprintf("Error reading config file: %v", err)
ctx.Log.Err(errMsg)
return ExecutionResult{SetupError: GeneralError{errors.New(errMsg)}}
}
// need to use the remote state path and backend to do remote configure
err = PreRun(&config, ctx.Log, absolutePath, ctx.Command)
if err != nil {
errMsg := fmt.Sprintf("pre run failed: %v", err)
ctx.Log.Err(errMsg)
return ExecutionResult{SetupError: GeneralError{errors.New(errMsg)}}
}
// check if terraform version is specified in config
if config.TerraformVersion != "" {
p.terraform.tfExecutableName = "terraform" + config.TerraformVersion
} else {
p.terraform.tfExecutableName = "terraform"
}
}
generatePlanResponse := p.plan(ctx, cloneDir, p.scratchDir, tfPlanName, s3Client, project, s3Key, p.sshKey, config.StashPath)
generatePlanResponse.Path = project.Path
planOutputs = append(planOutputs, generatePlanResponse)
}
p.updateGithubStatus(ctx, planOutputs)
return ExecutionResult{PathResults: planOutputs}
}
// plan runs the steps necessary to run `terraform plan`. If there is an error, the error message will be encapsulated in error
// and the GeneratePlanResponse struct will also contain the full log including the error
func (p *PlanExecutor) plan(
ctx *CommandContext,
repoDir string,
planOutDir string,
tfPlanName string,
s3Client S3Client,
project models.Project,
s3Key string,
sshKey string,
stashPath string) PathResult {
ctx.Log.Info("generating plan for path %q", project.Path)
// NOTE: THIS CODE IS TO SUPPORT TERRAFORM PROJECTS THAT AREN'T USING ATLANTIS CONFIG FILE.
if stashPath == "" {
_, err := p.terraform.ConfigureRemoteState(ctx.Log, repoDir, project, ctx.Command.environment, sshKey)
if err != nil {
return PathResult{
Status: "error",
Result: GeneralError{fmt.Errorf("failed to configure remote state: %s", err)},
}
}
}
// todo: setting environment to default should be done elsewhere
tfEnv := ctx.Command.environment
if tfEnv == "" {
tfEnv = "default"
}
lockAttempt, err := p.lockingClient.TryLock(project, ctx.Command.environment, ctx.Pull.Num)
if err != nil {
return PathResult{
Status: " failure",
Result: GeneralError{fmt.Errorf("failed to lock state: %v", err)},
}
}
// the run is locked unless the locking run is the same pull id as this run
if lockAttempt.LockAcquired == false && lockAttempt.LockingPullNum != ctx.Pull.Num {
return PathResult{
Status: "failure",
Result: RunLockedFailure{lockAttempt.LockingPullNum},
}
}
// Run terraform plan
ctx.Log.Info("running terraform plan in directory %q", project.Path)
tfPlanCmd := []string{"plan", "-refresh", "-no-color"}
// Generate terraform plan filename
tfPlanOutputPath := filepath.Join(planOutDir, tfPlanName)
// Generate terraform plan arguments
if ctx.Command.environment != "" {
tfEnvFileName := filepath.Join("env", ctx.Command.environment+".tfvars")
if _, err := os.Stat(filepath.Join(repoDir, project.Path, tfEnvFileName)); err == nil {
tfPlanCmd = append(tfPlanCmd, "-var-file", tfEnvFileName, "-out", tfPlanOutputPath)
} else {
ctx.Log.Err("environment file %q not found", tfEnvFileName)
return PathResult{
Status: "failure",
Result: EnvironmentFileNotFoundFailure{tfEnvFileName},
}
}
} else {
tfPlanCmd = append(tfPlanCmd, "-out", tfPlanOutputPath)
}
// set pull request creator as the session name
p.awsConfig.AWSSessionName = ctx.Pull.Author
awsSession, err := p.awsConfig.CreateAWSSession()
if err != nil {
ctx.Log.Err(err.Error())
return PathResult{
Status: "error",
Result: GeneralError{err},
}
}
credVals, err := awsSession.Config.Credentials.Get()
if err != nil {
err = fmt.Errorf("failed to get assumed role credentials: %v", err)
ctx.Log.Err(err.Error())
return PathResult{
Status: "error",
Result: GeneralError{err},
}
}
terraformPlanCmdArgs, output, err := p.terraform.RunTerraformCommand(filepath.Join(repoDir, project.Path), tfPlanCmd, []string{
fmt.Sprintf("AWS_ACCESS_KEY_ID=%s", credVals.AccessKeyID),
fmt.Sprintf("AWS_SECRET_ACCESS_KEY=%s", credVals.SecretAccessKey),
fmt.Sprintf("AWS_SESSION_TOKEN=%s", credVals.SessionToken),
})
if err != nil {
if err.Error() != "exit status 1" {
// if it's not an exit 1 then the details about the failure won't be in the output but in the error itself
output = err.Error()
}
err := TerraformFailure{
Command: strings.Join(terraformPlanCmdArgs, " "),
Output: output,
}
ctx.Log.Err("error running terraform plan: %v", output)
ctx.Log.Info("unlocking state since plan failed")
if err := p.lockingClient.Unlock(lockAttempt.LockKey); err != nil {
ctx.Log.Err("error unlocking state: %v", err)
}
return PathResult{
Status: "failure",
Result: err,
}
}
// Upload plan to S3
ctx.Log.Info("uploading plan to S3 with key %q", s3Key)
if err := UploadPlanFile(s3Client, s3Key, tfPlanOutputPath); err != nil {
err = fmt.Errorf("failed to upload to S3: %v", err)
ctx.Log.Err(err.Error())
if err := p.lockingClient.Unlock(lockAttempt.LockKey); err != nil {
ctx.Log.Err("error unlocking state: %v", err)
}
return PathResult{
Status: "error",
Result: GeneralError{err},
}
}
// Delete local plan file
planFilePath := fmt.Sprintf("%s/%s", planOutDir, tfPlanName)
ctx.Log.Info("deleting local plan file %q", planFilePath)
if err := os.Remove(planFilePath); err != nil {
ctx.Log.Err("failed to delete local plan file %q", planFilePath, err)
// todo: return an error
}
return PathResult{
Status: "success",
Result: PlanSuccess{
TerraformOutput: output,
LockURL: p.DeleteLockURL(lockAttempt.LockKey),
},
}
}
func (p *PlanExecutor) filterToTerraform(files []string) []string {
var out []string
for _, fileName := range files {
if !p.isInExcludeList(fileName) && strings.Contains(fileName, ".tf") {
out = append(out, fileName)
}
}
return out
}
func (p *PlanExecutor) isInExcludeList(fileName string) bool {
return strings.Contains(fileName, "terraform.tfstate") || strings.Contains(fileName, "terraform.tfstate.backup") || strings.Contains(fileName, "_modules") || strings.Contains(fileName, "modules")
}
func (p *PlanExecutor) trimSuffix(s, suffix string) string {
if strings.HasSuffix(s, suffix) {
s = s[:len(s)-len(suffix)]
}
return s
}
// ModifiedProjects returns the list of Terraform projects that have been changed due to the
// modified files
func (p *PlanExecutor) ModifiedProjects(repoFullName string, modifiedFiles []string) []models.Project {
var projects []models.Project
seenPaths := make(map[string]bool)
for _, modifiedFile := range modifiedFiles {
path := p.getProjectPath(modifiedFile)
if _, ok := seenPaths[path]; !ok {
projects = append(projects, models.NewProject(repoFullName, path))
seenPaths[path] = true
}
}
return projects
}
// getProjectPath returns the path to the project relative to the repo root
// if the project is at the root returns "."
func (p *PlanExecutor) getProjectPath(modifiedFilePath string) string {
dir := path.Dir(modifiedFilePath)
if path.Base(dir) == "env" {
// if the modified file was inside an env/ directory, we treat this specially and
// run plan one level up
return path.Dir(dir)
}
return dir
}
// CleanWorkspace deletes all .terraform/ folders from the project folders and cleans up any plans in the output directory
func (p *PlanExecutor) CleanWorkspace(log *logging.SimpleLogger, deleteFilesPrefix string, planOutDir string, repoDir string, projects []models.Project) error {
log.Info("cleaning workspace directory %q", planOutDir)
// delete .terraform directories
for _, project := range projects {
os.RemoveAll(filepath.Join(repoDir, project.Path, ".terraform"))
}
// delete old plan files
files, err := ioutil.ReadDir(planOutDir)
if err != nil {
return err
}
for _, file := range files {
if strings.HasPrefix(file.Name(), deleteFilesPrefix) {
log.Info("deleting file %q", file.Name())
fullPath := filepath.Join(planOutDir, file.Name())
if err := os.Remove(fullPath); err != nil {
log.Warn("failed to remove plan file %q", fullPath)
}
}
}
return nil
}
func (p *PlanExecutor) DeleteLocalPlanFile(path string) error {
return os.Remove(path)
}
// GenerateOutputFilename determines the name of the plan that will be stored in s3
// if we're executing inside a sub directory, there will be a leading underscore
func (p *PlanExecutor) GenerateOutputFilename(project models.Project, tfEnvName string) string {
prefix := ""
if project.Path != "." {
// If not executing at repo root, need to encode the sub dir in the name of the output file.
// We do this by substituting / for _
// We also add an _ because this gets appended to a larger path
// todo: refactor the path handling so it's all in one place
prefix = "_" + strings.Replace(project.Path, "/", "_", -1)
}
suffix := ""
if tfEnvName != "" {
suffix = "." + tfEnvName
}
return prefix + ".tfplan" + suffix
}
func generateStatePath(path string, tfEnvName string) string {
return strings.Replace(path, "$ENVIRONMENT", tfEnvName, -1)
}
func (p *PlanExecutor) updateGithubStatus(ctx *CommandContext, pathResults []PathResult) {
// the status will be the worst result
worstResult := p.worstResult(pathResults)
if worstResult == "success" {
p.github.UpdateStatus(ctx.Repo, ctx.Pull, SuccessStatus, "Plan Succeeded")
} else if worstResult == "failure" {
p.github.UpdateStatus(ctx.Repo, ctx.Pull, FailureStatus, "Plan Failed")
} else {
p.github.UpdateStatus(ctx.Repo, ctx.Pull, ErrorStatus, "Plan Error")
}
}
func (p *PlanExecutor) worstResult(results []PathResult) string {
var worst string = "success"
for _, result := range results {
if result.Status == "error" {
return result.Status
} else if result.Status == "failure" {
worst = result.Status
}
}
return worst
}