WIP Add operators and get compiled.

This commit is contained in:
Luke Kysow
2018-06-15 14:01:09 +01:00
parent d70e57e87b
commit b5e2a730de
29 changed files with 673 additions and 689 deletions

View File

@@ -1,92 +0,0 @@
// Copyright 2017 HootSuite Media Inc.
//
// Licensed under the Apache License, Version 2.0 (the License);
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an AS IS BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Modified hereafter by contributors to runatlantis/atlantis.
//
package events
import (
"github.com/runatlantis/atlantis/server/events/models"
"github.com/runatlantis/atlantis/server/events/run"
"github.com/runatlantis/atlantis/server/events/terraform"
"github.com/runatlantis/atlantis/server/events/vcs"
"github.com/runatlantis/atlantis/server/events/webhooks"
)
// ApplyExecutor handles executing terraform apply.
type ApplyExecutor struct {
VCSClient vcs.ClientProxy
Terraform *terraform.DefaultClient
RequireApproval bool
Run *run.Run
AtlantisWorkspace AtlantisWorkspace
ProjectLocker *DefaultProjectLocker
Webhooks webhooks.Sender
ExecutionPlanner *ExecutionPlanner
}
// Execute executes apply for the ctx.
func (a *ApplyExecutor) Execute(ctx *CommandContext) CommandResponse {
//if a.RequireApproval {
// approved, err := a.VCSClient.PullIsApproved(ctx.BaseRepo, ctx.Pull)
// if err != nil {
// return CommandResponse{Error: errors.Wrap(err, "checking if pull request was approved")}
// }
// if !approved {
// return CommandResponse{Failure: "Pull request must be approved before running apply."}
// }
// ctx.Log.Info("confirmed pull request was approved")
//}
repoDir, err := a.AtlantisWorkspace.GetWorkspace(ctx.BaseRepo, ctx.Pull, ctx.Command.Workspace)
if err != nil {
return CommandResponse{Failure: "No workspace found. Did you run plan?"}
}
ctx.Log.Info("found workspace in %q", repoDir)
stage, err := a.ExecutionPlanner.BuildApplyStage(ctx.Log, repoDir, ctx.Command.Workspace, ctx.Command.Dir, ctx.Command.Flags, ctx.User.Username)
if err != nil {
return CommandResponse{Error: err}
}
// check if we have the lock
tryLockResponse, err := a.ProjectLocker.TryLock(ctx, models.NewProject(ctx.BaseRepo.FullName, ctx.Command.Dir))
if err != nil {
return CommandResponse{ProjectResults: []ProjectResult{{Error: err}}}
}
if !tryLockResponse.LockAcquired {
return CommandResponse{ProjectResults: []ProjectResult{{Failure: tryLockResponse.LockFailureReason}}}
}
// Check apply requirements.
for _, req := range stage.ApplyRequirements {
isMet, reason := req.IsMet()
if !isMet {
return CommandResponse{Failure: reason}
}
}
out, err := stage.Run()
// Send webhooks even if there's an error.
a.Webhooks.Send(ctx.Log, webhooks.ApplyResult{ // nolint: errcheck
Workspace: ctx.Command.Workspace,
User: ctx.User,
Repo: ctx.BaseRepo,
Pull: ctx.Pull,
Success: err == nil,
})
if err != nil {
return CommandResponse{Error: err}
}
return CommandResponse{ProjectResults: []ProjectResult{{ApplySuccess: out}}}
}

View File

@@ -53,8 +53,6 @@ type GitlabMergeRequestGetter interface {
// CommandHandler is the first step when processing a comment command.
type CommandHandler struct {
PlanExecutor Executor
ApplyExecutor Executor
VCSClient vcs.ClientProxy
GithubPullGetter GithubPullGetter
GitlabMergeRequestGetter GitlabMergeRequestGetter
@@ -68,7 +66,8 @@ type CommandHandler struct {
// AllowForkPRsFlag is the name of the flag that controls fork PR's. We use
// this in our error message back to the user on a forked PR so they know
// how to enable this functionality.
AllowForkPRsFlag string
AllowForkPRsFlag string
PullRequestOperator PullRequestOperator
}
// ExecuteCommand executes the command.
@@ -169,9 +168,13 @@ func (c *CommandHandler) run(ctx *CommandContext) {
var cr CommandResponse
switch ctx.Command.Name {
case Plan:
cr = c.PlanExecutor.Execute(ctx)
if ctx.Command.Autoplan {
cr = c.PullRequestOperator.Autoplan(ctx)
} else {
cr = c.PullRequestOperator.PlanViaComment(ctx)
}
case Apply:
cr = c.ApplyExecutor.Execute(ctx)
cr = c.PullRequestOperator.ApplyViaComment(ctx)
default:
ctx.Log.Err("failed to determine desired command, neither plan nor apply")
}

View File

@@ -36,7 +36,7 @@ type Command struct {
// Dir is the path relative to the repo root to run the command in.
// Will never be an empty string and will never end in "/".
Dir string
// Flags are the extra arguments appended to comment,
// CommentArgs are the extra arguments appended to comment,
// ex. atlantis plan -- -target=resource
Flags []string
Name CommandName

View File

@@ -1,215 +0,0 @@
package events
import (
"fmt"
"os"
"path/filepath"
"github.com/hashicorp/go-version"
"github.com/runatlantis/atlantis/server/events/models"
"github.com/runatlantis/atlantis/server/events/runtime"
"github.com/runatlantis/atlantis/server/events/yaml"
"github.com/runatlantis/atlantis/server/events/yaml/valid"
"github.com/runatlantis/atlantis/server/logging"
)
const PlanStageName = "plan"
const ApplyStageName = "apply"
const AtlantisYAMLFilename = "atlantis.yaml"
type ExecutionPlanner struct {
TerraformExecutor TerraformExec
DefaultTFVersion *version.Version
ParserValidator *yaml.ParserValidator
ProjectFinder ProjectFinder
}
type TerraformExec interface {
RunCommandWithVersion(log *logging.SimpleLogger, path string, args []string, v *version.Version, workspace string) (string, error)
}
func (s *ExecutionPlanner) BuildPlanStage(log *logging.SimpleLogger, repoDir string, workspace string, relProjectPath string, extraCommentArgs []string, username string) (runtime.PlanStage, error) {
defaults := s.defaultPlanSteps(log, repoDir, workspace, relProjectPath, extraCommentArgs, username)
steps, err := s.buildStage(PlanStageName, log, repoDir, workspace, relProjectPath, extraCommentArgs, username, defaults)
if err != nil {
return runtime.PlanStage{}, err
}
return runtime.PlanStage{
Steps: steps,
Workspace: workspace,
ProjectPath: relProjectPath,
}, nil
}
func (s *ExecutionPlanner) BuildAutoplanStages(log *logging.SimpleLogger, repoFullName string, repoDir string, username string, modifiedFiles []string) ([]runtime.PlanStage, error) {
// If there is an atlantis.yaml
// -> Get modified files from pull request.
// -> For each project, if autoplan == true && files match
// ->-> Build plan stage for that project.
// Else
// -> Get modified files
// -> For each modified project use default plan stage.
config, err := s.ParserValidator.ReadConfig(repoDir)
if err != nil && !os.IsNotExist(err) {
return nil, err
}
// If there is no config file, then we try to plan for each project that
// was modified in the pull request.
if os.IsNotExist(err) {
projects := s.ProjectFinder.DetermineProjects(log, modifiedFiles, repoFullName, repoDir)
var stages []runtime.PlanStage
for _, p := range projects {
// NOTE: we use the default workspace because we don't know about
// other workspaces. If users want to plan for other workspaces they
// need to use a config file.
steps := s.defaultPlanSteps(log, repoDir, models.DefaultWorkspace, p.Path, nil, username)
stages = append(stages, runtime.PlanStage{
Steps: steps,
Workspace: models.DefaultWorkspace,
ProjectPath: p.Path,
})
}
return stages, nil
}
// Else we run plan according to the config file.
var stages []runtime.PlanStage
for _, p := range config.Projects {
if s.shouldAutoplan(p.Autoplan, modifiedFiles) {
// todo
stages = append(stages)
}
}
return stages, nil
}
func (s *ExecutionPlanner) shouldAutoplan(autoplan valid.Autoplan, modifiedFiles []string) bool {
return true
}
func (s *ExecutionPlanner) getSteps() {
}
func (s *ExecutionPlanner) BuildApplyStage(log *logging.SimpleLogger, repoDir string, workspace string, relProjectPath string, extraCommentArgs []string, username string) (*runtime.ApplyStage, error) {
defaults := s.defaultApplySteps(log, repoDir, workspace, relProjectPath, extraCommentArgs, username)
steps, err := s.buildStage(ApplyStageName, log, repoDir, workspace, relProjectPath, extraCommentArgs, username, defaults)
if err != nil {
return nil, err
}
return &runtime.ApplyStage{
Steps: steps,
}, nil
}
func (s *ExecutionPlanner) buildStage(stageName string, log *logging.SimpleLogger, repoDir string, workspace string, relProjectPath string, extraCommentArgs []string, username string, defaults []runtime.Step) ([]runtime.Step, error) {
config, err := s.ParserValidator.ReadConfig(repoDir)
// If there's no config file, use defaults.
if os.IsNotExist(err) {
log.Info("no %s file foundcontinuing with defaults", AtlantisYAMLFilename)
return defaults, nil
}
if err != nil {
return nil, err
}
// Get this project's configuration.
for _, p := range config.Projects {
if p.Dir == relProjectPath && p.Workspace == workspace {
workflowNamePtr := p.Workflow
// If they didn't specify a workflow, use the default.
if workflowNamePtr == nil {
log.Info("no %s workflow setcontinuing with defaults", AtlantisYAMLFilename)
return defaults, nil
}
// If they did specify a workflow, find it.
workflowName := *workflowNamePtr
workflow, exists := config.Workflows[workflowName]
if !exists {
return nil, fmt.Errorf("no workflow with key %q defined", workflowName)
}
// We have a workflow defined, so now we need to build it.
meta := s.buildMeta(log, repoDir, workspace, relProjectPath, extraCommentArgs, username)
var steps []runtime.Step
var stepsConfig []valid.Step
if stageName == PlanStageName {
stepsConfig = workflow.Plan.Steps
} else {
stepsConfig = workflow.Apply.Steps
}
for _, stepConfig := range stepsConfig {
var step runtime.Step
switch stepConfig.StepName {
case "init":
step = &runtime.InitStep{
Meta: meta,
ExtraArgs: stepConfig.ExtraArgs,
}
case "plan":
step = &runtime.PlanStep{
Meta: meta,
ExtraArgs: stepConfig.ExtraArgs,
}
case "apply":
step = &runtime.ApplyStep{
Meta: meta,
ExtraArgs: stepConfig.ExtraArgs,
}
case "run":
step = &runtime.RunStep{
Meta: meta,
Commands: stepConfig.RunCommand,
}
}
steps = append(steps, step)
}
return steps, nil
}
}
// They haven't defined this project, use the default workflow.
log.Info("no project with dir %q and workspace %q defined; continuing with defaults", relProjectPath, workspace)
return defaults, nil
}
func (s *ExecutionPlanner) buildMeta(log *logging.SimpleLogger, repoDir string, workspace string, relProjectPath string, extraCommentArgs []string, username string) runtime.StepMeta {
return runtime.StepMeta{
Log: log,
Workspace: workspace,
AbsolutePath: filepath.Join(repoDir, relProjectPath),
DirRelativeToRepoRoot: relProjectPath,
// If there's no config then we should use the default tf version.
TerraformVersion: s.DefaultTFVersion,
TerraformExecutor: s.TerraformExecutor,
ExtraCommentArgs: extraCommentArgs,
Username: username,
}
}
func (s *ExecutionPlanner) defaultPlanSteps(log *logging.SimpleLogger, repoDir string, workspace string, relProjectPath string, extraCommentArgs []string, username string) []runtime.Step {
meta := s.buildMeta(log, repoDir, workspace, relProjectPath, extraCommentArgs, username)
return []runtime.Step{
&runtime.InitStep{
ExtraArgs: nil,
Meta: meta,
},
&runtime.PlanStep{
ExtraArgs: nil,
Meta: meta,
},
}
}
func (s *ExecutionPlanner) defaultApplySteps(log *logging.SimpleLogger, repoDir string, workspace string, relProjectPath string, extraCommentArgs []string, username string) []runtime.Step {
meta := s.buildMeta(log, repoDir, workspace, relProjectPath, extraCommentArgs, username)
return []runtime.Step{
&runtime.ApplyStep{
ExtraArgs: nil,
Meta: meta,
},
}
}

View File

@@ -24,6 +24,8 @@ import (
"time"
"github.com/pkg/errors"
"github.com/runatlantis/atlantis/server/events/yaml/valid"
"github.com/runatlantis/atlantis/server/logging"
)
// DefaultWorkspace is the default Terraform workspace for both Atlantis and
@@ -210,3 +212,25 @@ func (h VCSHostType) String() string {
}
return "<missing String() implementation>"
}
type ProjectCommandContext struct {
// BaseRepo is the repository that the pull request will be merged into.
BaseRepo Repo
// HeadRepo is the repository that is getting merged into the BaseRepo.
// If the pull request branch is from the same repository then HeadRepo will
// be the same as BaseRepo.
// See https://help.github.com/articles/about-pull-request-merges/.
HeadRepo Repo
Pull PullRequest
// User is the user that triggered this command.
User User
Log *logging.SimpleLogger
RepoRelPath string
ProjectConfig *valid.Project
GlobalConfig *valid.Spec
// CommentArgs are the extra arguments appended to comment,
// ex. atlantis plan -- -target=resource
CommentArgs []string
Workspace string
}

View File

@@ -1,109 +0,0 @@
// Copyright 2017 HootSuite Media Inc.
//
// Licensed under the Apache License, Version 2.0 (the License);
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an AS IS BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Modified hereafter by contributors to runatlantis/atlantis.
//
package events
import (
"fmt"
"github.com/pkg/errors"
"github.com/runatlantis/atlantis/server/events/locking"
"github.com/runatlantis/atlantis/server/events/run"
"github.com/runatlantis/atlantis/server/events/runtime"
"github.com/runatlantis/atlantis/server/events/terraform"
"github.com/runatlantis/atlantis/server/events/vcs"
)
//go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_lock_url_generator.go LockURLGenerator
type LockURLGenerator interface {
GenerateLockURL(lockID string) string
}
// PlanExecutor handles everything related to running terraform plan.
type PlanExecutor struct {
VCSClient vcs.ClientProxy
Terraform terraform.Client
Locker locking.Locker
Run run.Runner
Workspace AtlantisWorkspace
ProjectFinder ProjectFinder
ProjectLocker ProjectLocker
ExecutionPlanner *ExecutionPlanner
LockURLGenerator LockURLGenerator
}
// PlanSuccess is the result of a successful plan.
type PlanSuccess struct {
TerraformOutput string
LockURL string
}
// Execute executes terraform plan for the ctx.
func (p *PlanExecutor) Execute(ctx *CommandContext) CommandResponse {
cloneDir, err := p.Workspace.Clone(ctx.Log, ctx.BaseRepo, ctx.HeadRepo, ctx.Pull, ctx.Command.Workspace)
if err != nil {
return CommandResponse{Error: err}
}
var stages []runtime.PlanStage
if ctx.Command.Autoplan {
modifiedFiles, err := p.VCSClient.GetModifiedFiles(ctx.BaseRepo, ctx.Pull)
if err != nil {
return CommandResponse{Error: errors.Wrap(err, "getting modified files")}
}
stages, err = p.ExecutionPlanner.BuildAutoplanStages(ctx.Log, ctx.BaseRepo.FullName, cloneDir, ctx.User.Username, modifiedFiles)
if err != nil {
return CommandResponse{Error: err}
}
} else {
stage, err := p.ExecutionPlanner.BuildPlanStage(ctx.Log, cloneDir, ctx.Command.Workspace, ctx.Command.Dir, ctx.Command.Flags, ctx.User.Username)
if err != nil {
return CommandResponse{Error: err}
}
stages = append(stages, stage)
}
var projectResults []ProjectResult
for _, stage := range stages {
projectResult := ProjectResult{
Path: stage.ProjectPath,
Workspace: stage.Workspace,
}
// todo: this should be moved into the plan stage
//tryLockResponse, err := p.ProjectLocker.TryLock(ctx, models.NewProject(ctx.BaseRepo.FullName, ctx.Command.Dir))
//if err != nil {
// return CommandResponse{ProjectResults: []ProjectResult{{Error: err}}}
//}
//if !tryLockResponse.LockAcquired {
// return CommandResponse{ProjectResults: []ProjectResult{{Failure: tryLockResponse.LockFailureReason}}}
//}
// todo: endtodo
out, err := stage.Run()
if err != nil {
//if unlockErr := tryLockResponse.UnlockFn(); unlockErr != nil {
// ctx.Log.Err("error unlocking state after plan error: %s", unlockErr)
//}
projectResult.Error = fmt.Errorf("%s\n%s", err.Error(), out)
} else {
projectResult.PlanSuccess = &PlanSuccess{
TerraformOutput: out,
//LockURL: p.LockURLGenerator.GenerateLockURL(tryLockResponse.LockKey),
}
}
projectResults = append(projectResults, projectResult)
}
return CommandResponse{ProjectResults: projectResults}
}

View File

@@ -18,8 +18,7 @@ import (
"github.com/runatlantis/atlantis/server/events/locking"
"github.com/runatlantis/atlantis/server/events/models"
"github.com/runatlantis/atlantis/server/events/run"
"github.com/runatlantis/atlantis/server/events/terraform"
"github.com/runatlantis/atlantis/server/logging"
)
//go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_project_lock.go ProjectLocker
@@ -33,15 +32,12 @@ type ProjectLocker interface {
// The third return value is a function that can be called to unlock the
// lock. It will only be set if the lock was acquired. Any errors will set
// error.
TryLock(ctx *CommandContext, project models.Project) (*TryLockResponse, error)
TryLock(log *logging.SimpleLogger, pull models.PullRequest, user models.User, workspace string, project models.Project) (*TryLockResponse, error)
}
// DefaultProjectLocker implements ProjectLocker.
type DefaultProjectLocker struct {
Locker locking.Locker
ConfigReader ProjectConfigReader
Terraform terraform.Client
Run run.Runner
Locker locking.Locker
}
// TryLockResponse is the result of trying to lock a project.
@@ -60,13 +56,12 @@ type TryLockResponse struct {
}
// TryLock implements ProjectLocker.TryLock.
func (p *DefaultProjectLocker) TryLock(ctx *CommandContext, project models.Project) (*TryLockResponse, error) {
workspace := ctx.Command.Workspace
lockAttempt, err := p.Locker.TryLock(project, workspace, ctx.Pull, ctx.User)
func (p *DefaultProjectLocker) TryLock(log *logging.SimpleLogger, pull models.PullRequest, user models.User, workspace string, project models.Project) (*TryLockResponse, error) {
lockAttempt, err := p.Locker.TryLock(project, workspace, pull, user)
if err != nil {
return nil, err
}
if !lockAttempt.LockAcquired && lockAttempt.CurrLock.Pull.Num != ctx.Pull.Num {
if !lockAttempt.LockAcquired && lockAttempt.CurrLock.Pull.Num != pull.Num {
failureMsg := fmt.Sprintf(
"This project is currently locked by #%d. The locking plan must be applied or discarded before future plans can execute.",
lockAttempt.CurrLock.Pull.Num)
@@ -75,7 +70,7 @@ func (p *DefaultProjectLocker) TryLock(ctx *CommandContext, project models.Proje
LockFailureReason: failureMsg,
}, nil
}
ctx.Log.Info("acquired lock with id %q", lockAttempt.LockKey)
log.Info("acquired lock with id %q", lockAttempt.LockKey)
return &TryLockResponse{
LockAcquired: true,
UnlockFn: func() error {

View File

@@ -0,0 +1,190 @@
// Copyright 2017 HootSuite Media Inc.
//
// Licensed under the Apache License, Version 2.0 (the License);
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an AS IS BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Modified hereafter by contributors to runatlantis/atlantis.
//
package events
import (
"path/filepath"
"strings"
"github.com/pkg/errors"
"github.com/runatlantis/atlantis/server/events/models"
"github.com/runatlantis/atlantis/server/events/runtime"
"github.com/runatlantis/atlantis/server/events/webhooks"
"github.com/runatlantis/atlantis/server/events/yaml/valid"
)
//go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_lock_url_generator.go LockURLGenerator
type LockURLGenerator interface {
GenerateLockURL(lockID string) string
}
// PlanSuccess is the result of a successful plan.
type PlanSuccess struct {
TerraformOutput string
LockURL string
}
type ProjectOperator struct {
Locker ProjectLocker
LockURLGenerator LockURLGenerator
InitStepOperator runtime.InitStepOperator
PlanStepOperator runtime.PlanStepOperator
ApplyStepOperator runtime.ApplyStepOperator
RunStepOperator runtime.RunStepOperator
ApprovalOperator runtime.ApprovalOperator
Workspace AtlantisWorkspace
Webhooks *webhooks.MultiWebhookSender
}
func (p *ProjectOperator) Plan(ctx models.ProjectCommandContext, projAbsPathPtr *string) ProjectResult {
// Acquire Atlantis lock for this repo/dir/workspace.
lockAttempt, err := p.Locker.TryLock(ctx.Log, ctx.Pull, ctx.User, ctx.Workspace, models.NewProject(ctx.BaseRepo.FullName, ctx.RepoRelPath))
if err != nil {
return ProjectResult{Error: errors.Wrap(err, "acquiring lock")}
}
if !lockAttempt.LockAcquired {
return ProjectResult{Failure: lockAttempt.LockFailureReason}
}
// Ensure project has been cloned.
var projAbsPath string
if projAbsPathPtr == nil {
repoDir, err := p.Workspace.Clone(ctx.Log, ctx.BaseRepo, ctx.HeadRepo, ctx.Pull, ctx.Workspace)
if err != nil {
if unlockErr := lockAttempt.UnlockFn(); unlockErr != nil {
ctx.Log.Err("error unlocking state after plan error: %v", unlockErr)
}
return ProjectResult{Error: err}
}
projAbsPath = filepath.Join(repoDir, ctx.RepoRelPath)
} else {
projAbsPath = *projAbsPathPtr
}
// Use default stage unless another workflow is defined in config
stage := p.defaultPlanStage()
if ctx.ProjectConfig != nil && ctx.ProjectConfig.Workflow != nil {
configuredStage := ctx.GlobalConfig.GetPlanStage(*ctx.ProjectConfig.Workflow)
if configuredStage != nil {
stage = *configuredStage
}
}
outputs, err := p.runSteps(stage.Steps, ctx, projAbsPath)
if err != nil {
if unlockErr := lockAttempt.UnlockFn(); unlockErr != nil {
ctx.Log.Err("error unlocking state after plan error: %v", unlockErr)
}
// todo: include output from other steps.
return ProjectResult{Error: err}
}
return ProjectResult{
PlanSuccess: &PlanSuccess{
LockURL: p.LockURLGenerator.GenerateLockURL(lockAttempt.LockKey),
TerraformOutput: strings.Join(outputs, "\n"),
},
}
}
func (p *ProjectOperator) runSteps(steps []valid.Step, ctx models.ProjectCommandContext, absPath string) ([]string, error) {
var outputs []string
for _, step := range steps {
var out string
var err error
switch step.StepName {
case "init":
out, err = p.InitStepOperator.Run(ctx, step.ExtraArgs, absPath)
case "plan":
out, err = p.PlanStepOperator.Run(ctx, step.ExtraArgs, absPath)
case "apply":
out, err = p.ApplyStepOperator.Run(ctx, step.ExtraArgs, absPath)
case "run":
out, err = p.RunStepOperator.Run(ctx, step.RunCommand, absPath)
}
if err != nil {
// todo: include output from other steps.
return nil, err
}
if out != "" {
outputs = append(outputs, out)
}
}
return outputs, nil
}
func (p *ProjectOperator) Apply(ctx models.ProjectCommandContext, absPath string) ProjectResult {
if ctx.ProjectConfig != nil {
for _, req := range ctx.ProjectConfig.ApplyRequirements {
switch req {
case "approved":
approved, err := p.ApprovalOperator.IsApproved(ctx.BaseRepo, ctx.Pull)
if err != nil {
return ProjectResult{Error: errors.Wrap(err, "checking if pull request was approved")}
}
if !approved {
return ProjectResult{Failure: "Pull request must be approved before running apply."}
}
}
}
}
// Use default stage unless another workflow is defined in config
stage := p.defaultApplyStage()
if ctx.ProjectConfig != nil && ctx.ProjectConfig.Workflow != nil {
configuredStage := ctx.GlobalConfig.GetApplyStage(*ctx.ProjectConfig.Workflow)
if configuredStage != nil {
stage = *configuredStage
}
}
outputs, err := p.runSteps(stage.Steps, ctx, absPath)
p.Webhooks.Send(ctx.Log, webhooks.ApplyResult{ // nolint: errcheck
Workspace: ctx.Workspace,
User: ctx.User,
Repo: ctx.BaseRepo,
Pull: ctx.Pull,
Success: err == nil,
})
if err != nil {
// todo: include output from other steps.
return ProjectResult{Error: err}
}
return ProjectResult{
ApplySuccess: strings.Join(outputs, "\n"),
}
}
func (p ProjectOperator) defaultPlanStage() valid.Stage {
return valid.Stage{
Steps: []valid.Step{
{
StepName: "init",
},
{
StepName: "plan",
},
},
}
}
func (p ProjectOperator) defaultApplyStage() valid.Stage {
return valid.Stage{
Steps: []valid.Step{
{
StepName: "apply",
},
},
}
}

View File

@@ -116,7 +116,7 @@ package events_test
// p, runner, _ := setupPlanExecutorTest(t)
// ctx := deepcopy.Copy(planCtx).(events.CommandContext)
// ctx.Log = logging.NewNoopLogger()
// ctx.Command.Flags = []string{"\"-target=resource\"", "\"-var\"", "\"a=b\"", "\";\"", "\"echo\"", "\"hi\""}
// ctx.Command.CommentArgs = []string{"\"-target=resource\"", "\"-var\"", "\"a=b\"", "\";\"", "\"echo\"", "\"hi\""}
//
// When(p.VCSClient.GetModifiedFiles(matchers.AnyModelsRepo(), matchers.AnyModelsPullRequest())).ThenReturn([]string{"file.tf"}, nil)
// When(p.Workspace.Clone(ctx.Log, ctx.BaseRepo, ctx.HeadRepo, ctx.Pull, "workspace")).

View File

@@ -0,0 +1,193 @@
package events
import (
"os"
"path/filepath"
"github.com/hashicorp/go-version"
"github.com/runatlantis/atlantis/server/events/models"
"github.com/runatlantis/atlantis/server/events/vcs"
"github.com/runatlantis/atlantis/server/events/yaml"
"github.com/runatlantis/atlantis/server/events/yaml/valid"
"github.com/runatlantis/atlantis/server/logging"
)
type PullRequestOperator struct {
TerraformExecutor TerraformExec
DefaultTFVersion *version.Version
ParserValidator *yaml.ParserValidator
ProjectFinder ProjectFinder
VCSClient vcs.ClientProxy
Workspace AtlantisWorkspace
ProjectOperator ProjectOperator
}
type TerraformExec interface {
RunCommandWithVersion(log *logging.SimpleLogger, path string, args []string, v *version.Version, workspace string) (string, error)
}
func (p *PullRequestOperator) Autoplan(ctx *CommandContext) CommandResponse {
// check out repo to parse atlantis.yaml
// this will check out the repo to a * dir
repoDir, err := p.Workspace.Clone(ctx.Log, ctx.BaseRepo, ctx.HeadRepo, ctx.Pull, ctx.Command.Workspace)
if err != nil {
return CommandResponse{Error: err}
}
// Parse config file if it exists.
config, err := p.ParserValidator.ReadConfig(repoDir)
if err != nil && !os.IsNotExist(err) {
return CommandResponse{Error: err}
}
noAtlantisYAML := os.IsNotExist(err)
// We'll need the list of modified files.
modifiedFiles, err := p.VCSClient.GetModifiedFiles(ctx.BaseRepo, ctx.Pull)
if err != nil {
return CommandResponse{Error: err}
}
// Prepare the project contexts so the ProjectOperator can execute.
var projCtxs []models.ProjectCommandContext
// If there is no config file, then we try to plan for each project that
// was modified in the pull request.
if noAtlantisYAML {
modifiedProjects := p.ProjectFinder.DetermineProjects(ctx.Log, modifiedFiles, ctx.BaseRepo.FullName, repoDir)
for _, mp := range modifiedProjects {
projCtxs = append(projCtxs, models.ProjectCommandContext{
BaseRepo: ctx.BaseRepo,
HeadRepo: ctx.HeadRepo,
Pull: ctx.Pull,
User: ctx.User,
Log: ctx.Log,
RepoRelPath: mp.Path,
ProjectConfig: nil,
GlobalConfig: nil,
CommentArgs: nil,
Workspace: DefaultWorkspace,
})
}
} else {
// Otherwise, we use the projects that match the WhenModified fields
// in the config file.
matchingProjects := p.matchingProjects(modifiedFiles, config)
for _, mp := range matchingProjects {
projCtxs = append(projCtxs, models.ProjectCommandContext{
BaseRepo: ctx.BaseRepo,
HeadRepo: ctx.HeadRepo,
Pull: ctx.Pull,
User: ctx.User,
Log: ctx.Log,
CommentArgs: nil,
Workspace: mp.Workspace,
RepoRelPath: mp.Dir,
ProjectConfig: &mp,
GlobalConfig: &config,
})
}
}
// Execute the operations.
var results []ProjectResult
for _, pCtx := range projCtxs {
res := p.ProjectOperator.Plan(pCtx, nil)
res.Path = pCtx.RepoRelPath
res.Workspace = pCtx.Workspace
results = append(results, res)
}
return CommandResponse{ProjectResults: results}
}
func (p *PullRequestOperator) PlanViaComment(ctx *CommandContext) CommandResponse {
repoDir, err := p.Workspace.Clone(ctx.Log, ctx.BaseRepo, ctx.HeadRepo, ctx.Pull, ctx.Command.Workspace)
if err != nil {
return CommandResponse{Error: err}
}
var projCfg *valid.Project
var globalCfg *valid.Spec
// Parse config file if it exists.
config, err := p.ParserValidator.ReadConfig(repoDir)
if err != nil && !os.IsNotExist(err) {
return CommandResponse{Error: err}
}
if !os.IsNotExist(err) {
projCfg = config.FindProject(ctx.Command.Dir, ctx.Command.Workspace)
globalCfg = &config
}
projCtx := models.ProjectCommandContext{
BaseRepo: ctx.BaseRepo,
HeadRepo: ctx.HeadRepo,
Pull: ctx.Pull,
User: ctx.User,
Log: ctx.Log,
CommentArgs: ctx.Command.Flags,
Workspace: ctx.Command.Workspace,
RepoRelPath: ctx.Command.Dir,
ProjectConfig: projCfg,
GlobalConfig: globalCfg,
}
projAbsPath := filepath.Join(repoDir, ctx.Command.Dir)
res := p.ProjectOperator.Plan(projCtx, &projAbsPath)
res.Workspace = projCtx.Workspace
res.Path = projCtx.RepoRelPath
return CommandResponse{
ProjectResults: []ProjectResult{
res,
},
}
}
func (p *PullRequestOperator) ApplyViaComment(ctx *CommandContext) CommandResponse {
repoDir, err := p.Workspace.GetWorkspace(ctx.BaseRepo, ctx.Pull, ctx.Command.Workspace)
if err != nil {
return CommandResponse{Failure: "No workspace found. Did you run plan?"}
}
// todo: can deduplicate this between PlanViaComment
var projCfg *valid.Project
var globalCfg *valid.Spec
// Parse config file if it exists.
config, err := p.ParserValidator.ReadConfig(repoDir)
if err != nil && !os.IsNotExist(err) {
return CommandResponse{Error: err}
}
if !os.IsNotExist(err) {
projCfg = config.FindProject(ctx.Command.Dir, ctx.Command.Workspace)
globalCfg = &config
}
projCtx := models.ProjectCommandContext{
BaseRepo: ctx.BaseRepo,
HeadRepo: ctx.HeadRepo,
Pull: ctx.Pull,
User: ctx.User,
Log: ctx.Log,
CommentArgs: ctx.Command.Flags,
Workspace: ctx.Command.Workspace,
RepoRelPath: ctx.Command.Dir,
ProjectConfig: projCfg,
GlobalConfig: globalCfg,
}
res := p.ProjectOperator.Apply(projCtx, filepath.Join(repoDir, ctx.Command.Dir))
res.Workspace = projCtx.Workspace
res.Path = projCtx.RepoRelPath
return CommandResponse{
ProjectResults: []ProjectResult{
res,
},
}
}
// matchingProjects returns the list of projects whose WhenModified fields match
// any of the modifiedFiles.
func (p *PullRequestOperator) matchingProjects(modifiedFiles []string, config valid.Spec) []valid.Project {
//todo
// match the modified files against the config
// remember the modified_files paths are relative to the project paths
return nil
}

View File

@@ -16,7 +16,7 @@ import (
func TestBuildStage_NoConfigFile(t *testing.T) {
var defaultTFVersion *version.Version
var terraformExecutor runtime.TerraformExec
e := events.ExecutionPlanner{
e := events.PullRequestOperator{
DefaultTFVersion: defaultTFVersion,
TerraformExecutor: terraformExecutor,
}
@@ -71,7 +71,7 @@ func TestBuildStage_NoConfigFile(t *testing.T) {
func TestBuildStage(t *testing.T) {
var defaultTFVersion *version.Version
var terraformExecutor runtime.TerraformExec
e := events.ExecutionPlanner{
e := events.PullRequestOperator{
DefaultTFVersion: defaultTFVersion,
TerraformExecutor: terraformExecutor,
}

View File

@@ -1,24 +0,0 @@
package runtime
import (
"fmt"
"os"
"path/filepath"
)
// ApplyStep runs `terraform apply`.
type ApplyStep struct {
ExtraArgs []string
Meta StepMeta
}
func (a *ApplyStep) Run() (string, error) {
planPath := filepath.Join(a.Meta.AbsolutePath, a.Meta.Workspace+".tfplan")
stat, err := os.Stat(planPath)
if err != nil || stat.IsDir() {
return "", fmt.Errorf("no plan found at path %q and workspace %qdid you run plan?", a.Meta.DirRelativeToRepoRoot, a.Meta.Workspace)
}
tfApplyCmd := append(append(append([]string{"apply", "-no-color"}, a.ExtraArgs...), a.Meta.ExtraCommentArgs...), planPath)
return a.Meta.TerraformExecutor.RunCommandWithVersion(a.Meta.Log, a.Meta.AbsolutePath, tfApplyCmd, a.Meta.TerraformVersion, a.Meta.Workspace)
}

View File

@@ -0,0 +1,30 @@
package runtime
import (
"fmt"
"os"
"path/filepath"
"github.com/hashicorp/go-version"
"github.com/runatlantis/atlantis/server/events/models"
)
// ApplyStepOperator runs `terraform apply`.
type ApplyStepOperator struct {
TerraformExecutor TerraformExec
}
func (a *ApplyStepOperator) Run(ctx models.ProjectCommandContext, extraArgs []string, path string) (string, error) {
planPath := filepath.Join(path, ctx.Workspace+".tfplan")
stat, err := os.Stat(planPath)
if err != nil || stat.IsDir() {
return "", fmt.Errorf("no plan found at path %q and workspace %qdid you run plan?", ctx.RepoRelPath, ctx.Workspace)
}
tfApplyCmd := append(append(append([]string{"apply", "-no-color"}, extraArgs...), ctx.CommentArgs...), planPath)
var tfVersion *version.Version
if ctx.ProjectConfig != nil && ctx.ProjectConfig.TerraformVersion != nil {
tfVersion = ctx.ProjectConfig.TerraformVersion
}
return a.TerraformExecutor.RunCommandWithVersion(ctx.Log, path, tfApplyCmd, tfVersion, ctx.Workspace)
}

View File

@@ -0,0 +1,18 @@
package runtime
import (
"github.com/runatlantis/atlantis/server/events/models"
"github.com/runatlantis/atlantis/server/events/vcs"
)
type ApprovalOperator struct {
VCSClient vcs.ClientProxy
}
func (a *ApprovalOperator) IsApproved(baseRepo models.Repo, pull models.PullRequest) (bool, error) {
approved, err := a.VCSClient.PullIsApproved(baseRepo, pull)
if err != nil {
return false, err
}
return approved, nil
}

View File

@@ -1,20 +0,0 @@
package runtime
// InitStep runs `terraform init`.
type InitStep struct {
ExtraArgs []string
Meta StepMeta
}
func (i *InitStep) Run() (string, error) {
// If we're running < 0.9 we have to use `terraform get` instead of `init`.
if MustConstraint("< 0.9.0").Check(i.Meta.TerraformVersion) {
i.Meta.Log.Info("running terraform version %s so will use `get` instead of `init`", i.Meta.TerraformVersion)
terraformGetCmd := append([]string{"get", "-no-color"}, i.ExtraArgs...)
_, err := i.Meta.TerraformExecutor.RunCommandWithVersion(i.Meta.Log, i.Meta.AbsolutePath, terraformGetCmd, i.Meta.TerraformVersion, i.Meta.Workspace)
return "", err
} else {
_, err := i.Meta.TerraformExecutor.RunCommandWithVersion(i.Meta.Log, i.Meta.AbsolutePath, append([]string{"init", "-no-color"}, i.ExtraArgs...), i.Meta.TerraformVersion, i.Meta.Workspace)
return "", err
}
}

View File

@@ -0,0 +1,29 @@
package runtime
import (
"github.com/hashicorp/go-version"
"github.com/runatlantis/atlantis/server/events/models"
)
// InitStep runs `terraform init`.
type InitStepOperator struct {
TerraformExecutor TerraformExec
DefaultTFVersion *version.Version
}
func (i *InitStepOperator) Run(ctx models.ProjectCommandContext, extraArgs []string, path string) (string, error) {
tfVersion := i.DefaultTFVersion
if ctx.ProjectConfig != nil && ctx.ProjectConfig.TerraformVersion != nil {
tfVersion = ctx.ProjectConfig.TerraformVersion
}
// If we're running < 0.9 we have to use `terraform get` instead of `init`.
if MustConstraint("< 0.9.0").Check(tfVersion) {
ctx.Log.Info("running terraform version %s so will use `get` instead of `init`", tfVersion)
terraformGetCmd := append([]string{"get", "-no-color"}, extraArgs...)
_, err := i.TerraformExecutor.RunCommandWithVersion(ctx.Log, path, terraformGetCmd, tfVersion, ctx.Workspace)
return "", err
} else {
_, err := i.TerraformExecutor.RunCommandWithVersion(ctx.Log, path, append([]string{"init", "-no-color"}, extraArgs...), tfVersion, ctx.Workspace)
return "", err
}
}

View File

@@ -43,23 +43,17 @@ func TestRun_UsesGetOrInitForRightVersion(t *testing.T) {
tfVersion, _ := version.NewVersion(c.version)
logger := logging.NewNoopLogger()
s := runtime.InitStep{
Meta: runtime.StepMeta{
Log: logger,
Workspace: "workspace",
AbsolutePath: "/path",
DirRelativeToRepoRoot: ".",
TerraformVersion: tfVersion,
TerraformExecutor: terraform,
ExtraCommentArgs: []string{"comment", "args"},
Username: "username",
},
ExtraArgs: []string{"extra", "args"},
iso := runtime.InitStepOperator{
TerraformExecutor: terraform,
}
When(terraform.RunCommandWithVersion(matchers.AnyPtrToLoggingSimpleLogger(), AnyString(), AnyStringSlice(), matchers2.AnyPtrToGoVersionVersion(), AnyString())).
ThenReturn("output", nil)
output, err := s.Run()
output, err := iso.Run(runtime.ProjectCommandContext{
Log: logger,
Workspace: "workspace",
AbsPath: "/path",
RepoRelPath: ".",
}, []string{"extra", "args"})
Ok(t, err)
// Shouldn't return output since we don't print init output to PR.
Equals(t, "", output)

View File

@@ -5,6 +5,9 @@ import (
"os"
"path/filepath"
"strings"
"github.com/hashicorp/go-version"
"github.com/runatlantis/atlantis/server/events/models"
)
// atlantisUserTFVar is the name of the variable we execute terraform
@@ -12,45 +15,48 @@ import (
const atlantisUserTFVar = "atlantis_user"
const defaultWorkspace = "default"
// PlanStep runs `terraform plan`.
type PlanStep struct {
ExtraArgs []string
Meta StepMeta
type PlanStepOperator struct {
TerraformExecutor TerraformExec
DefaultTFVersion *version.Version
}
func (p *PlanStep) Run() (string, error) {
func (p *PlanStepOperator) Run(ctx models.ProjectCommandContext, extraArgs []string, path string) (string, error) {
tfVersion := p.DefaultTFVersion
if ctx.ProjectConfig != nil && ctx.ProjectConfig.TerraformVersion != nil {
tfVersion = ctx.ProjectConfig.TerraformVersion
}
// We only need to switch workspaces in version 0.9.*. In older versions,
// there is no such thing as a workspace so we don't need to do anything.
// In newer versions, the TF_WORKSPACE env var is respected and will handle
// using the right workspace and even creating it if it doesn't exist.
// This variable is set inside the Terraform executor.
if err := p.switchWorkspace(); err != nil {
if err := p.switchWorkspace(ctx, path, tfVersion); err != nil {
return "", err
}
planFile := filepath.Join(p.Meta.AbsolutePath, fmt.Sprintf("%s.tfplan", p.Meta.Workspace))
userVar := fmt.Sprintf("%s=%s", atlantisUserTFVar, p.Meta.Username)
tfPlanCmd := append(append([]string{"plan", "-refresh", "-no-color", "-out", planFile, "-var", userVar}, p.ExtraArgs...), p.Meta.ExtraCommentArgs...)
planFile := filepath.Join(path, fmt.Sprintf("%s.tfplan", ctx.Workspace))
userVar := fmt.Sprintf("%s=%s", atlantisUserTFVar, ctx.User.Username)
tfPlanCmd := append(append([]string{"plan", "-refresh", "-no-color", "-out", planFile, "-var", userVar}, extraArgs...), ctx.CommentArgs...)
// Check if env/{workspace}.tfvars exist and include it. This is a use-case
// from Hootsuite where Atlantis was first created so we're keeping this as
// an homage and a favor so they don't need to refactor all their repos.
// It's also a nice way to structure your repos to reduce duplication.
optionalEnvFile := filepath.Join(p.Meta.AbsolutePath, "env", p.Meta.Workspace+".tfvars")
optionalEnvFile := filepath.Join(path, "env", ctx.Workspace+".tfvars")
if _, err := os.Stat(optionalEnvFile); err == nil {
tfPlanCmd = append(tfPlanCmd, "-var-file", optionalEnvFile)
}
return p.Meta.TerraformExecutor.RunCommandWithVersion(p.Meta.Log, filepath.Join(p.Meta.AbsolutePath), tfPlanCmd, p.Meta.TerraformVersion, p.Meta.Workspace)
return p.TerraformExecutor.RunCommandWithVersion(ctx.Log, filepath.Join(path), tfPlanCmd, tfVersion, ctx.Workspace)
}
// switchWorkspace changes the terraform workspace if necessary and will create
// it if it doesn't exist. It handles differences between versions.
func (p *PlanStep) switchWorkspace() error {
func (p *PlanStepOperator) switchWorkspace(ctx models.ProjectCommandContext, path string, tfVersion *version.Version) error {
// In versions less than 0.9 there is no support for workspaces.
noWorkspaceSupport := MustConstraint("<0.9").Check(p.Meta.TerraformVersion)
if noWorkspaceSupport && p.Meta.Workspace != defaultWorkspace {
return fmt.Errorf("terraform version %s does not support workspaces", p.Meta.TerraformVersion)
noWorkspaceSupport := MustConstraint("<0.9").Check(tfVersion)
// If the user tried to set a specific workspace in the comment but their
// version of TF doesn't support workspaces then error out.
if noWorkspaceSupport && ctx.Workspace != defaultWorkspace {
return fmt.Errorf("terraform version %s does not support workspaces", tfVersion)
}
if noWorkspaceSupport {
return nil
@@ -58,7 +64,7 @@ func (p *PlanStep) switchWorkspace() error {
// In version 0.9.* the workspace command was called env.
workspaceCmd := "workspace"
runningZeroPointNine := MustConstraint(">=0.9,<0.10").Check(p.Meta.TerraformVersion)
runningZeroPointNine := MustConstraint(">=0.9,<0.10").Check(tfVersion)
if runningZeroPointNine {
workspaceCmd = "env"
}
@@ -67,12 +73,12 @@ func (p *PlanStep) switchWorkspace() error {
// already in the right workspace then no need to switch. This will save us
// about ten seconds. This command is only available in > 0.10.
if !runningZeroPointNine {
workspaceShowOutput, err := p.Meta.TerraformExecutor.RunCommandWithVersion(p.Meta.Log, p.Meta.AbsolutePath, []string{workspaceCmd, "show"}, p.Meta.TerraformVersion, p.Meta.Workspace)
workspaceShowOutput, err := p.TerraformExecutor.RunCommandWithVersion(ctx.Log, path, []string{workspaceCmd, "show"}, tfVersion, ctx.Workspace)
if err != nil {
return err
}
// If `show` says we're already on this workspace then we're done.
if strings.TrimSpace(workspaceShowOutput) == p.Meta.Workspace {
if strings.TrimSpace(workspaceShowOutput) == ctx.Workspace {
return nil
}
}
@@ -82,11 +88,11 @@ func (p *PlanStep) switchWorkspace() error {
// we can create it if it doesn't. To do this we can either select and catch
// the error or use list and then look for the workspace. Both commands take
// the same amount of time so that's why we're running select here.
_, err := p.Meta.TerraformExecutor.RunCommandWithVersion(p.Meta.Log, p.Meta.AbsolutePath, []string{workspaceCmd, "select", "-no-color", p.Meta.Workspace}, p.Meta.TerraformVersion, p.Meta.Workspace)
_, err := p.TerraformExecutor.RunCommandWithVersion(ctx.Log, path, []string{workspaceCmd, "select", "-no-color", ctx.Workspace}, tfVersion, ctx.Workspace)
if err != nil {
// If terraform workspace select fails we run terraform workspace
// new to create a new workspace automatically.
_, err = p.Meta.TerraformExecutor.RunCommandWithVersion(p.Meta.Log, p.Meta.AbsolutePath, []string{workspaceCmd, "new", "-no-color", p.Meta.Workspace}, p.Meta.TerraformVersion, p.Meta.Workspace)
_, err = p.TerraformExecutor.RunCommandWithVersion(ctx.Log, path, []string{workspaceCmd, "new", "-no-color", ctx.Workspace}, tfVersion, ctx.Workspace)
return err
}
return nil

View File

@@ -1,93 +0,0 @@
package runtime
import (
"github.com/hashicorp/go-version"
"github.com/runatlantis/atlantis/server/logging"
)
type TerraformExec interface {
RunCommandWithVersion(log *logging.SimpleLogger, path string, args []string, v *version.Version, workspace string) (string, error)
}
type ApplyRequirement interface {
// IsMet returns true if the requirement is met and false if not.
// If it returns false, it also returns a string describing why not.
IsMet() (bool, string)
}
type PlanStage struct {
Steps []Step
Workspace string
ProjectPath string
}
type ApplyStage struct {
Steps []Step
ApplyRequirements []ApplyRequirement
}
func (p PlanStage) Run() (string, error) {
var outputs string
for _, step := range p.Steps {
out, err := step.Run()
if err != nil {
return outputs, err
}
if out != "" {
// Outputs are separated by newlines.
outputs += "\n" + out
}
}
return outputs, nil
}
func (a ApplyStage) Run() (string, error) {
var outputs string
for _, step := range a.Steps {
out, err := step.Run()
if err != nil {
return outputs, err
}
if out != "" {
// Outputs are separated by newlines.
outputs += "\n" + out
}
}
return outputs, nil
}
type Step interface {
// Run runs the step. It returns any output that needs to be commented back
// onto the pull request and error.
Run() (string, error)
}
// StepMeta is the data that is available to all steps.
type StepMeta struct {
Log *logging.SimpleLogger
Workspace string
// AbsolutePath is the path to this project on disk. It's not necessarily
// the repository root since a project can be in a subdir of the root.
AbsolutePath string
// DirRelativeToRepoRoot is the directory for this project relative
// to the repo root.
DirRelativeToRepoRoot string
TerraformVersion *version.Version
TerraformExecutor TerraformExec
// ExtraCommentArgs are the arguments that may have been appended to the comment.
// For example 'atlantis plan -- -target=resource'. They are already quoted
// further up the call tree to mitigate security issues.
ExtraCommentArgs []string
// VCS username of who caused this step to be executed. For example the
// commenter, or who pushed a new commit.
Username string
}
// MustConstraint returns a constraint. It panics on error.
func MustConstraint(constraint string) version.Constraints {
c, err := version.NewConstraint(constraint)
if err != nil {
panic(err)
}
return c
}

View File

@@ -1,35 +0,0 @@
package runtime
import (
"fmt"
"os/exec"
"strings"
"github.com/pkg/errors"
)
// RunStep runs custom commands.
type RunStep struct {
Commands []string
Meta StepMeta
}
func (r *RunStep) Run() (string, error) {
if len(r.Commands) < 1 {
return "", errors.New("no commands for run step")
}
path := r.Meta.AbsolutePath
cmd := exec.Command("sh", "-c", strings.Join(r.Commands, " ")) // #nosec
cmd.Dir = path
out, err := cmd.CombinedOutput()
commandStr := strings.Join(r.Commands, " ")
if err != nil {
err = fmt.Errorf("%s: running %q in %q: \n%s", err, commandStr, path, out)
r.Meta.Log.Debug("error: %s", err)
return string(out), err
}
r.Meta.Log.Info("successfully ran %q in %q", commandStr, path)
return string(out), nil
}

View File

@@ -0,0 +1,33 @@
package runtime
import (
"fmt"
"os/exec"
"strings"
"github.com/pkg/errors"
"github.com/runatlantis/atlantis/server/events/models"
)
// RunStepOperator runs custom commands.
type RunStepOperator struct {
}
func (r *RunStepOperator) Run(ctx models.ProjectCommandContext, command []string, path string) (string, error) {
if len(command) < 1 {
return "", errors.New("no commands for run step")
}
cmd := exec.Command("sh", "-c", strings.Join(command, " ")) // #nosec
cmd.Dir = path
out, err := cmd.CombinedOutput()
commandStr := strings.Join(command, " ")
if err != nil {
err = fmt.Errorf("%s: running %q in %q: \n%s", err, commandStr, path, out)
ctx.Log.Debug("error: %s", err)
return string(out), err
}
ctx.Log.Info("successfully ran %q in %q", commandStr, path)
return string(out), nil
}

View File

@@ -2,3 +2,21 @@
// based on configuration and defaults. The handlers can then execute this
// graph.
package runtime
import (
"github.com/hashicorp/go-version"
"github.com/runatlantis/atlantis/server/logging"
)
type TerraformExec interface {
RunCommandWithVersion(log *logging.SimpleLogger, path string, args []string, v *version.Version, workspace string) (string, error)
}
// MustConstraint returns a constraint. It panics on error.
func MustConstraint(constraint string) version.Constraints {
c, err := version.NewConstraint(constraint)
if err != nil {
panic(err)
}
return c
}

View File

@@ -87,14 +87,17 @@ func (c *DefaultClient) Version() *version.Version {
}
// RunCommandWithVersion executes the provided version of terraform with
// the provided args in path. v is the version of terraform executable to use
// and workspace is the workspace specified by the user commenting
// "atlantis plan/apply {workspace}" which is set to "default" by default.
// the provided args in path. v is the version of terraform executable to use.
// If v is nil, will use the default version.
// Workspace is the terraform workspace to run in. We won't switch workspaces
// but will set the TERRAFORM_WORKSPACE environment variable.
func (c *DefaultClient) RunCommandWithVersion(log *logging.SimpleLogger, path string, args []string, v *version.Version, workspace string) (string, error) {
tfExecutable := "terraform"
tfVersionStr := c.defaultVersion.String()
// if version is the same as the default, don't need to prepend the version name to the executable
if !v.Equal(c.defaultVersion) {
if v != nil && !v.Equal(c.defaultVersion) {
tfExecutable = fmt.Sprintf("%s%s", tfExecutable, v.String())
tfVersionStr = v.String()
}
// set environment variables
@@ -115,7 +118,7 @@ func (c *DefaultClient) RunCommandWithVersion(log *logging.SimpleLogger, path st
// because it's probably safer for users to rely on it. Terraform might
// change the way TF_WORKSPACE works in the future.
fmt.Sprintf("WORKSPACE=%s", workspace),
fmt.Sprintf("ATLANTIS_TERRAFORM_VERSION=%s", v.String()),
fmt.Sprintf("ATLANTIS_TERRAFORM_VERSION=%s", tfVersionStr),
fmt.Sprintf("DIR=%s", path),
}
envVars = append(envVars, os.Environ()...)

View File

@@ -6,6 +6,7 @@ import (
"strings"
"github.com/go-ozzo/ozzo-validation"
"github.com/hashicorp/go-version"
"github.com/runatlantis/atlantis/server/events/yaml/valid"
)
@@ -39,9 +40,17 @@ func (p Project) Validate() error {
}
return nil
}
validTFVersion := func(value interface{}) error {
// Safe to dereference because this is only called if the pointer is
// not nil.
versionStr := *value.(*string)
_, err := version.NewVersion(versionStr)
return err
}
return validation.ValidateStruct(&p,
validation.Field(&p.Dir, validation.Required, validation.By(hasDotDot)),
validation.Field(&p.ApplyRequirements, validation.By(validApplyReq)),
validation.Field(&p.TerraformVersion, validation.By(validTFVersion)),
)
}
@@ -56,7 +65,9 @@ func (p Project) ToValid() valid.Project {
}
v.Workflow = p.Workflow
v.TerraformVersion = p.TerraformVersion
if p.TerraformVersion != nil {
v.TerraformVersion, _ = version.NewVersion(*p.TerraformVersion)
}
if p.Autoplan == nil {
v.Autoplan = DefaultAutoPlan()
} else {

View File

@@ -1,5 +1,7 @@
package valid
import "github.com/hashicorp/go-version"
// Spec is the atlantis yaml spec after it's been parsed and validated.
// The raw.Spec is transformed into the ValidSpec which is then used by the
// rest of Atlantis.
@@ -11,11 +13,38 @@ type Spec struct {
Workflows map[string]Workflow
}
func (s Spec) GetPlanStage(workflowName string) *Stage {
for name, flow := range s.Workflows {
if name == workflowName {
return flow.Plan
}
}
return nil
}
func (s Spec) GetApplyStage(workflowName string) *Stage {
for name, flow := range s.Workflows {
if name == workflowName {
return flow.Apply
}
}
return nil
}
func (s Spec) FindProject(dir string, workspace string) *Project {
for _, p := range s.Projects {
if p.Dir == dir && p.Workspace == workspace {
return &p
}
}
return nil
}
type Project struct {
Dir string
Workspace string
Workflow *string
TerraformVersion *string
TerraformVersion *version.Version
Autoplan Autoplan
ApplyRequirements []string
}

View File

@@ -36,7 +36,7 @@ import (
"github.com/runatlantis/atlantis/server/events/locking"
"github.com/runatlantis/atlantis/server/events/locking/boltdb"
"github.com/runatlantis/atlantis/server/events/models"
"github.com/runatlantis/atlantis/server/events/run"
"github.com/runatlantis/atlantis/server/events/runtime"
"github.com/runatlantis/atlantis/server/events/terraform"
"github.com/runatlantis/atlantis/server/events/vcs"
"github.com/runatlantis/atlantis/server/events/webhooks"
@@ -188,23 +188,12 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) {
return nil, err
}
lockingClient := locking.NewClient(boltdb)
run := &run.Run{}
configReader := &events.ProjectConfigManager{}
workspaceLocker := events.NewDefaultAtlantisWorkspaceLocker()
workspace := &events.FileWorkspace{
DataDir: userConfig.DataDir,
}
projectLocker := &events.DefaultProjectLocker{
Locker: lockingClient,
Run: run,
ConfigReader: configReader,
Terraform: terraformClient,
}
executionPlanner := &events.ExecutionPlanner{
ParserValidator: &yaml.ParserValidator{},
DefaultTFVersion: terraformClient.Version(),
TerraformExecutor: terraformClient,
ProjectFinder: &events.DefaultProjectFinder{},
Locker: lockingClient,
}
underlyingRouter := mux.NewRouter()
router := &Router{
@@ -213,27 +202,6 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) {
LockViewRouteName: LockViewRouteName,
Underlying: underlyingRouter,
}
applyExecutor := &events.ApplyExecutor{
VCSClient: vcsClient,
Terraform: terraformClient,
RequireApproval: userConfig.RequireApproval,
Run: run,
AtlantisWorkspace: workspace,
ProjectLocker: projectLocker,
ExecutionPlanner: executionPlanner,
Webhooks: webhooksManager,
}
planExecutor := &events.PlanExecutor{
VCSClient: vcsClient,
Terraform: terraformClient,
Run: run,
Workspace: workspace,
ProjectLocker: projectLocker,
Locker: lockingClient,
ProjectFinder: &events.DefaultProjectFinder{},
ExecutionPlanner: executionPlanner,
LockURLGenerator: router,
}
pullClosedExecutor := &events.PullClosedExecutor{
VCSClient: vcsClient,
Locker: lockingClient,
@@ -252,9 +220,8 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) {
GitlabUser: userConfig.GitlabUser,
GitlabToken: userConfig.GitlabToken,
}
defaultTfVersion := terraformClient.Version()
commandHandler := &events.CommandHandler{
ApplyExecutor: applyExecutor,
PlanExecutor: planExecutor,
EventParser: eventParser,
VCSClient: vcsClient,
GithubPullGetter: githubClient,
@@ -265,6 +232,35 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) {
Logger: logger,
AllowForkPRs: userConfig.AllowForkPRs,
AllowForkPRsFlag: config.AllowForkPRsFlag,
PullRequestOperator: events.PullRequestOperator{
TerraformExecutor: terraformClient,
DefaultTFVersion: defaultTfVersion,
ParserValidator: &yaml.ParserValidator{},
ProjectFinder: &events.DefaultProjectFinder{},
VCSClient: vcsClient,
Workspace: workspace,
ProjectOperator: events.ProjectOperator{
Locker: projectLocker,
LockURLGenerator: router,
InitStepOperator: runtime.InitStepOperator{
TerraformExecutor: terraformClient,
DefaultTFVersion: defaultTfVersion,
},
PlanStepOperator: runtime.PlanStepOperator{
TerraformExecutor: terraformClient,
DefaultTFVersion: defaultTfVersion,
},
ApplyStepOperator: runtime.ApplyStepOperator{
TerraformExecutor: terraformClient,
},
RunStepOperator: runtime.RunStepOperator{},
ApprovalOperator: runtime.ApprovalOperator{
VCSClient: vcsClient,
},
Workspace: workspace,
Webhooks: webhooksManager,
},
},
}
repoWhitelist := &events.RepoWhitelist{
Whitelist: userConfig.RepoWhitelist,