Files
atlantis/apply_executor.go
2017-05-31 00:10:49 -07:00

296 lines
9.1 KiB
Go

package main
import (
"errors"
"fmt"
"os"
"path"
"regexp"
"strings"
"path/filepath"
"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/hootsuite/atlantis/locking"
"time"
)
type ApplyExecutor struct {
BaseExecutor
requireApproval bool
atlantisGithubUser string
}
/** Result Types **/
type ApplyFailure struct {
Command string
Output string
ErrorMessage string
}
func (a ApplyFailure) Template() *CompiledTemplate {
return ApplyFailureTmpl
}
type ApplySuccess struct {
Output string
}
func (a ApplySuccess) Template() *CompiledTemplate {
return ApplySuccessTmpl
}
type PullNotApprovedFailure struct{}
func (p PullNotApprovedFailure) Template() *CompiledTemplate {
return PullNotApprovedFailureTmpl
}
type NoPlansFailure struct{}
func (n NoPlansFailure) Template() *CompiledTemplate {
return NoPlansFailureTmpl
}
func (a *ApplyExecutor) execute(ctx *ExecutionContext, prCtx *PullRequestContext) ExecutionResult {
res := a.setupAndApply(ctx, prCtx)
res.Command = Apply
return res
}
func (a *ApplyExecutor) setupAndApply(ctx *ExecutionContext, prCtx *PullRequestContext) ExecutionResult {
a.github.UpdateStatus(prCtx, PendingStatus, "Applying...")
if a.requireApproval {
ok, err := a.github.PullIsApproved(prCtx)
if err != nil {
msg := fmt.Sprintf("failed to determine if pull request was approved: %v", err)
ctx.log.Err(msg)
a.github.UpdateStatus(prCtx, ErrorStatus, "Apply Error")
return ExecutionResult{SetupError: GeneralError{errors.New(msg)}}
}
if !ok {
ctx.log.Info("pull request was not approved")
a.github.UpdateStatus(prCtx, FailureStatus, "Apply Failed")
return ExecutionResult{SetupFailure: PullNotApprovedFailure{}}
}
}
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)
ctx.log.Err(errMsg)
a.github.UpdateStatus(prCtx, ErrorStatus, "Apply Error")
return ExecutionResult{SetupError: GeneralError{errors.New(errMsg)}}
}
// If there are no plans found for the pull request
if len(planPaths) == 0 {
failure := "found 0 plans for this pull request"
ctx.log.Warn(failure)
a.github.UpdateStatus(prCtx, FailureStatus, "Apply Failure")
return ExecutionResult{SetupFailure: NoPlansFailure{}}
}
//runLog = append(runLog, fmt.Sprintf("-> Downloaded plans: %v", planPaths))
applyOutputs := []PathResult{}
for _, planPath := range planPaths {
output := a.apply(ctx, prCtx, planPath)
output.Path = planPath
applyOutputs = append(applyOutputs, output)
}
a.updateGithubStatus(prCtx, applyOutputs)
return ExecutionResult{PathResults: applyOutputs}
}
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)
planDir := filepath.Join(a.scratchDir, ctx.repoOwner, ctx.repoName, fmt.Sprintf("%v", ctx.pullNum), planSubDir)
execPath := NewExecutionPath(planDir, planSubDir)
var config Config
var remoteStatePath string
// check if config file is found, if not we continue the run
if config.Exists(execPath.Absolute) {
ctx.log.Info("Config file found in %s", execPath.Absolute)
err := config.Read(execPath.Absolute)
if err != nil {
msg := fmt.Sprintf("Error reading config file: %v", err)
ctx.log.Err(msg)
return PathResult{
Status: "error",
Result: GeneralError{errors.New(msg)},
}
}
// need to use the remote state path and backend to do remote configure
err = PreRun(&config, ctx.log, execPath.Absolute, ctx.command)
if err != nil {
msg := fmt.Sprintf("pre run failed: %v", err)
ctx.log.Err(msg)
return PathResult{
Status: "error",
Result: GeneralError{errors.New(msg)},
}
}
// check if terraform version is specified in config
if config.TerraformVersion != "" {
a.terraform.tfExecutableName = "terraform" + config.TerraformVersion
} else {
a.terraform.tfExecutableName = "terraform"
}
}
// NOTE: THIS CODE IS TO SUPPORT TERRAFORM PROJECTS THAT AREN'T USING ATLANTIS CONFIG FILE.
if config.StashPath == "" {
// configure remote state
statePath, err := a.terraform.ConfigureRemoteState(ctx.log, execPath, ctx.command.environment, a.sshKey)
if err != nil {
msg := fmt.Sprintf("failed to set up remote state: %v", err)
ctx.log.Err(msg)
return PathResult{
Status: "error",
Result: GeneralError{errors.New(msg)},
}
}
remoteStatePath = statePath
} else {
// use state path from config file
remoteStatePath = generateStatePath(config.StashPath, ctx.command.environment)
}
if remoteStatePath != "" {
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{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)},
}
}
}
// need to get auth data from assumed role
// todo: de-duplicate calls to assumeRole
//runLog = append(runLog, "-> Assuming role prior to running apply")
a.awsConfig.AWSSessionName = ctx.pullCreator
awsSession, err := a.awsConfig.CreateAWSSession()
if err != nil {
ctx.log.Err(err.Error())
return PathResult{
Status: "error",
Result: GeneralError{err},
}
}
//runLog = append(runLog, "-> Assumed AWS role successfully")
credVals, err := awsSession.Config.Credentials.Get()
if err != nil {
msg := fmt.Sprintf("failed to get assumed role credentials: %v", err)
ctx.log.Err(msg)
return PathResult{
Status: "error",
Result: GeneralError{errors.New(msg)},
}
}
ctx.log.Info("running apply from %q", execPath.Relative)
terraformApplyCmdArgs, output, err := a.terraform.RunTerraformCommand(execPath, []string{"apply", "-no-color", planPath}, []string{
fmt.Sprintf("AWS_ACCESS_KEY_ID=%s", credVals.AccessKeyID),
fmt.Sprintf("AWS_SECRET_ACCESS_KEY=%s", credVals.SecretAccessKey),
fmt.Sprintf("AWS_SESSION_TOKEN=%s", credVals.SessionToken),
})
//runLog = append(runLog, "```\n Apply output:\n", fmt.Sprintf("```bash\n%s\n", string(out[:])))
if err != nil {
ctx.log.Err("failed to apply: %v %s", err, output)
return PathResult{
Status: "failure",
Result: ApplyFailure{Command: strings.Join(terraformApplyCmdArgs, " "), Output: output, ErrorMessage: err.Error()},
}
}
// clean up, delete local plan file
os.Remove(execPath.Absolute) // swallow errors, okay if we failed to delete
return PathResult{
Status: "success",
Result: ApplySuccess{output},
}
}
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 {
return nil, fmt.Errorf("failed to assume role: %v", err)
}
// now use the assumed role to download all the plans
s3Client := s3.New(awsSession)
// this will be plans/owner/repo/owner_repo_1, may be more than one if there are subdirs or multiple envs
plansPath := fmt.Sprintf("plans/%s/%s/%s_%s_%d", repoOwner, repoName, repoOwner, repoName, pullNum)
list, err := s3Client.ListObjects(&s3.ListObjectsInput{Bucket: aws.String(s3Bucket), Prefix: &plansPath})
if err != nil {
return nil, fmt.Errorf("failed to list plans in path: %v", err)
}
for _, obj := range list.Contents {
planName := path.Base(*obj.Key)
// filter to plans for the right env, plan names have the format owner_repo_pullNum_optional_sub_dirs.tfvars.env
if !strings.HasSuffix(planName, env) {
continue
}
// will be something like /tmp/owner_repo_pullNum_optional_sub_dirs.tfvars.env
outputPath := fmt.Sprintf("%s/%s", outputDir, planName)
file, err := os.Create(outputPath)
if err != nil {
return nil, fmt.Errorf("failed to create file to write plan to: %v", err)
}
defer file.Close()
downloader := s3manager.NewDownloader(awsSession)
_, err = downloader.Download(file,
&s3.GetObjectInput{
Bucket: aws.String(s3Bucket),
Key: obj.Key,
})
if err != nil {
return nil, fmt.Errorf("failed to download plan from s3: %v", err)
}
planPaths = append(planPaths, outputPath)
}
return planPaths, nil
}
func (a *ApplyExecutor) determinePlanSubDir(planName string, pullNum int) string {
planDirRegex := fmt.Sprintf(`.*_%d_(.*?)\.`, pullNum)
regex := regexp.MustCompile(planDirRegex) // we assume this will compile
match := regex.FindStringSubmatch(planName)
if len(match) <= 1 {
return "."
}
dirsStr := match[1] // in form dir_subdir_subsubdir
return filepath.Clean(strings.Replace(dirsStr, "_", "/", -1))
}