mirror of
https://git.vectorsigma.ru/public/atlantis.git
synced 2026-07-28 22:18:30 +00:00
296 lines
9.1 KiB
Go
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))
|
|
}
|