From b5e2a730de932a6da07cdfd09c72900f96927fa3 Mon Sep 17 00:00:00 2001 From: Luke Kysow Date: Fri, 15 Jun 2018 14:01:09 +0100 Subject: [PATCH] WIP Add operators and get compiled. --- server/events/apply_executor.go | 92 -------- server/events/command_handler.go | 13 +- server/events/event_parser.go | 2 +- server/events/execution_planner.go | 215 ------------------ server/events/models/models.go | 24 ++ server/events/plan_executor.go | 109 --------- server/events/project_locker.go | 19 +- server/events/project_operator.go | 190 ++++++++++++++++ ...cutor_test.go => project_operator_test.go} | 2 +- server/events/pull_request_operator.go | 193 ++++++++++++++++ ..._test.go => pull_request_operator_test.go} | 4 +- server/events/runtime/apply_step.go | 24 -- server/events/runtime/apply_step_operator.go | 30 +++ ...ep_test.go => apply_step_operator_test.go} | 0 server/events/runtime/approval_operator.go | 18 ++ server/events/runtime/init_step.go | 20 -- server/events/runtime/init_step_operator.go | 29 +++ ...tep_test.go => init_step_operator_test.go} | 22 +- .../{plan_step.go => plan_step_operater.go} | 52 +++-- ...tep_test.go => plan_step_operater_test.go} | 0 server/events/runtime/repoconfig.go | 93 -------- server/events/runtime/run_step.go | 35 --- server/events/runtime/run_step_operator.go | 33 +++ ...step_test.go => run_step_operator_test.go} | 0 server/events/runtime/runtime.go | 18 ++ server/events/terraform/terraform_client.go | 13 +- server/events/yaml/raw/project.go | 13 +- server/events/yaml/valid/valid.go | 31 ++- server/server.go | 68 +++--- 29 files changed, 673 insertions(+), 689 deletions(-) delete mode 100644 server/events/apply_executor.go delete mode 100644 server/events/execution_planner.go delete mode 100644 server/events/plan_executor.go create mode 100644 server/events/project_operator.go rename server/events/{plan_executor_test.go => project_operator_test.go} (99%) create mode 100644 server/events/pull_request_operator.go rename server/events/{execution_planner_test.go => pull_request_operator_test.go} (98%) delete mode 100644 server/events/runtime/apply_step.go create mode 100644 server/events/runtime/apply_step_operator.go rename server/events/runtime/{apply_step_test.go => apply_step_operator_test.go} (100%) create mode 100644 server/events/runtime/approval_operator.go delete mode 100644 server/events/runtime/init_step.go create mode 100644 server/events/runtime/init_step_operator.go rename server/events/runtime/{init_step_test.go => init_step_operator_test.go} (75%) rename server/events/runtime/{plan_step.go => plan_step_operater.go} (54%) rename server/events/runtime/{plan_step_test.go => plan_step_operater_test.go} (100%) delete mode 100644 server/events/runtime/repoconfig.go delete mode 100644 server/events/runtime/run_step.go create mode 100644 server/events/runtime/run_step_operator.go rename server/events/runtime/{run_step_test.go => run_step_operator_test.go} (100%) diff --git a/server/events/apply_executor.go b/server/events/apply_executor.go deleted file mode 100644 index 260a7fffa..000000000 --- a/server/events/apply_executor.go +++ /dev/null @@ -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}}} -} diff --git a/server/events/command_handler.go b/server/events/command_handler.go index 54c0ffa47..e327c13ee 100644 --- a/server/events/command_handler.go +++ b/server/events/command_handler.go @@ -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") } diff --git a/server/events/event_parser.go b/server/events/event_parser.go index 742a765a9..bfe08a6c6 100644 --- a/server/events/event_parser.go +++ b/server/events/event_parser.go @@ -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 diff --git a/server/events/execution_planner.go b/server/events/execution_planner.go deleted file mode 100644 index 4b13ce219..000000000 --- a/server/events/execution_planner.go +++ /dev/null @@ -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 found––continuing 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 set––continuing 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, - }, - } -} diff --git a/server/events/models/models.go b/server/events/models/models.go index 1d8d72453..30faf4ea2 100644 --- a/server/events/models/models.go +++ b/server/events/models/models.go @@ -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 "" } + +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 +} diff --git a/server/events/plan_executor.go b/server/events/plan_executor.go deleted file mode 100644 index 4dd221422..000000000 --- a/server/events/plan_executor.go +++ /dev/null @@ -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} -} diff --git a/server/events/project_locker.go b/server/events/project_locker.go index 9b7060585..e6983d5c0 100644 --- a/server/events/project_locker.go +++ b/server/events/project_locker.go @@ -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 { diff --git a/server/events/project_operator.go b/server/events/project_operator.go new file mode 100644 index 000000000..37dfcb5f0 --- /dev/null +++ b/server/events/project_operator.go @@ -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", + }, + }, + } +} diff --git a/server/events/plan_executor_test.go b/server/events/project_operator_test.go similarity index 99% rename from server/events/plan_executor_test.go rename to server/events/project_operator_test.go index 371aa9ec5..2659c2d50 100644 --- a/server/events/plan_executor_test.go +++ b/server/events/project_operator_test.go @@ -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")). diff --git a/server/events/pull_request_operator.go b/server/events/pull_request_operator.go new file mode 100644 index 000000000..61218e902 --- /dev/null +++ b/server/events/pull_request_operator.go @@ -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 +} diff --git a/server/events/execution_planner_test.go b/server/events/pull_request_operator_test.go similarity index 98% rename from server/events/execution_planner_test.go rename to server/events/pull_request_operator_test.go index 7b6fc7ac9..4bbdfd14b 100644 --- a/server/events/execution_planner_test.go +++ b/server/events/pull_request_operator_test.go @@ -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, } diff --git a/server/events/runtime/apply_step.go b/server/events/runtime/apply_step.go deleted file mode 100644 index 359141481..000000000 --- a/server/events/runtime/apply_step.go +++ /dev/null @@ -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 %q–did 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) -} diff --git a/server/events/runtime/apply_step_operator.go b/server/events/runtime/apply_step_operator.go new file mode 100644 index 000000000..819bcbf20 --- /dev/null +++ b/server/events/runtime/apply_step_operator.go @@ -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 %q–did 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) +} diff --git a/server/events/runtime/apply_step_test.go b/server/events/runtime/apply_step_operator_test.go similarity index 100% rename from server/events/runtime/apply_step_test.go rename to server/events/runtime/apply_step_operator_test.go diff --git a/server/events/runtime/approval_operator.go b/server/events/runtime/approval_operator.go new file mode 100644 index 000000000..63731f342 --- /dev/null +++ b/server/events/runtime/approval_operator.go @@ -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 +} diff --git a/server/events/runtime/init_step.go b/server/events/runtime/init_step.go deleted file mode 100644 index 1bf5b8734..000000000 --- a/server/events/runtime/init_step.go +++ /dev/null @@ -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 - } -} diff --git a/server/events/runtime/init_step_operator.go b/server/events/runtime/init_step_operator.go new file mode 100644 index 000000000..307c91bcf --- /dev/null +++ b/server/events/runtime/init_step_operator.go @@ -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 + } +} diff --git a/server/events/runtime/init_step_test.go b/server/events/runtime/init_step_operator_test.go similarity index 75% rename from server/events/runtime/init_step_test.go rename to server/events/runtime/init_step_operator_test.go index 911bcb233..829639497 100644 --- a/server/events/runtime/init_step_test.go +++ b/server/events/runtime/init_step_operator_test.go @@ -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) diff --git a/server/events/runtime/plan_step.go b/server/events/runtime/plan_step_operater.go similarity index 54% rename from server/events/runtime/plan_step.go rename to server/events/runtime/plan_step_operater.go index b8d6b3ace..a7fd6793d 100644 --- a/server/events/runtime/plan_step.go +++ b/server/events/runtime/plan_step_operater.go @@ -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 diff --git a/server/events/runtime/plan_step_test.go b/server/events/runtime/plan_step_operater_test.go similarity index 100% rename from server/events/runtime/plan_step_test.go rename to server/events/runtime/plan_step_operater_test.go diff --git a/server/events/runtime/repoconfig.go b/server/events/runtime/repoconfig.go deleted file mode 100644 index 0880920fb..000000000 --- a/server/events/runtime/repoconfig.go +++ /dev/null @@ -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 -} diff --git a/server/events/runtime/run_step.go b/server/events/runtime/run_step.go deleted file mode 100644 index ba6dbe385..000000000 --- a/server/events/runtime/run_step.go +++ /dev/null @@ -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 -} diff --git a/server/events/runtime/run_step_operator.go b/server/events/runtime/run_step_operator.go new file mode 100644 index 000000000..6554527be --- /dev/null +++ b/server/events/runtime/run_step_operator.go @@ -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 +} diff --git a/server/events/runtime/run_step_test.go b/server/events/runtime/run_step_operator_test.go similarity index 100% rename from server/events/runtime/run_step_test.go rename to server/events/runtime/run_step_operator_test.go diff --git a/server/events/runtime/runtime.go b/server/events/runtime/runtime.go index ea70550a6..8e599ff65 100644 --- a/server/events/runtime/runtime.go +++ b/server/events/runtime/runtime.go @@ -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 +} diff --git a/server/events/terraform/terraform_client.go b/server/events/terraform/terraform_client.go index a3508be72..55ed5d687 100644 --- a/server/events/terraform/terraform_client.go +++ b/server/events/terraform/terraform_client.go @@ -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()...) diff --git a/server/events/yaml/raw/project.go b/server/events/yaml/raw/project.go index be9ce1980..3273cb38a 100644 --- a/server/events/yaml/raw/project.go +++ b/server/events/yaml/raw/project.go @@ -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 { diff --git a/server/events/yaml/valid/valid.go b/server/events/yaml/valid/valid.go index 75d79a3d9..0a7ef2e97 100644 --- a/server/events/yaml/valid/valid.go +++ b/server/events/yaml/valid/valid.go @@ -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 } diff --git a/server/server.go b/server/server.go index 368553a0e..fe6e3f7b5 100644 --- a/server/server.go +++ b/server/server.go @@ -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,