diff --git a/runatlantis.io/docs/depends-on.md b/runatlantis.io/docs/depends-on.md new file mode 100644 index 0000000000..ae0d059f54 --- /dev/null +++ b/runatlantis.io/docs/depends-on.md @@ -0,0 +1,21 @@ +# Depends_on Argument +[[toc]] + +## Description +The depends_on argument allow you to enforce dependencies between projects. Use the depends_on argument to handle cases +where require one project to be applied prior to the other. + +## What Happens if one or more project's dependencies are not applied? +If there's one or more projects in the dependency list is not in an applied status, users will see an error if they try +to run `atlantis apply`. + +### Usage +1. In `atlantis.yaml` file specify the `depends_on` key under the project config: + #### atlantis.yaml + ```yaml + version: 3 + projects: + - dir: . + name: project-2 + depends_on: [project-1] + ``` diff --git a/server/core/config/raw/project.go b/server/core/config/raw/project.go index 12eb6a2b33..f331c64483 100644 --- a/server/core/config/raw/project.go +++ b/server/core/config/raw/project.go @@ -31,6 +31,7 @@ type Project struct { PlanRequirements []string `yaml:"plan_requirements,omitempty"` ApplyRequirements []string `yaml:"apply_requirements,omitempty"` ImportRequirements []string `yaml:"import_requirements,omitempty"` + DependsOn []string `yaml:"depends_on,omitempty"` DeleteSourceBranchOnMerge *bool `yaml:"delete_source_branch_on_merge,omitempty"` RepoLocking *bool `yaml:"repo_locking,omitempty"` ExecutionOrderGroup *int `yaml:"execution_order_group,omitempty"` @@ -73,12 +74,17 @@ func (p Project) Validate() error { return errors.Wrapf(err, "parsing: %s", branch) } + Dependencies := func(value interface{}) error { + return nil + } + return validation.ValidateStruct(&p, validation.Field(&p.Dir, validation.Required, validation.By(hasDotDot)), validation.Field(&p.PlanRequirements, validation.By(validPlanReq)), validation.Field(&p.ApplyRequirements, validation.By(validApplyReq)), validation.Field(&p.ImportRequirements, validation.By(validImportReq)), validation.Field(&p.TerraformVersion, validation.By(VersionValidator)), + validation.Field(&p.DependsOn, validation.By(Dependencies)), validation.Field(&p.Name, validation.By(validName)), validation.Field(&p.Branch, validation.By(branchValid)), ) @@ -122,6 +128,8 @@ func (p Project) ToValid() valid.Project { v.Name = p.Name + v.DependsOn = p.DependsOn + if p.DeleteSourceBranchOnMerge != nil { v.DeleteSourceBranchOnMerge = p.DeleteSourceBranchOnMerge } diff --git a/server/core/config/valid/global_cfg.go b/server/core/config/valid/global_cfg.go index e4a47c39cd..937211c00e 100644 --- a/server/core/config/valid/global_cfg.go +++ b/server/core/config/valid/global_cfg.go @@ -17,10 +17,7 @@ const PoliciesPassedCommandReq = "policies_passed" const PlanRequirementsKey = "plan_requirements" const ApplyRequirementsKey = "apply_requirements" const ImportRequirementsKey = "import_requirements" -const PreWorkflowHooksKey = "pre_workflow_hooks" const WorkflowKey = "workflow" -const PostWorkflowHooksKey = "post_workflow_hooks" -const AllowedWorkflowsKey = "allowed_workflows" const AllowedOverridesKey = "allowed_overrides" const AllowCustomWorkflowsKey = "allow_custom_workflows" const DefaultWorkflowName = "default" @@ -90,6 +87,7 @@ type MergedProjectCfg struct { ImportRequirements []string Workflow Workflow AllowedWorkflows []string + DependsOn []string RepoRelDir string Workspace string Name string @@ -380,6 +378,7 @@ func (g GlobalCfg) MergeProjectCfg(log logging.SimpleLogging, repoID string, pro Workflow: workflow, RepoRelDir: proj.Dir, Workspace: proj.Workspace, + DependsOn: proj.DependsOn, Name: proj.GetName(), AutoplanEnabled: proj.Autoplan.Enabled, TerraformVersion: proj.TerraformVersion, diff --git a/server/core/config/valid/repo_cfg.go b/server/core/config/valid/repo_cfg.go index 7f6f28f344..6929fd42b3 100644 --- a/server/core/config/valid/repo_cfg.go +++ b/server/core/config/valid/repo_cfg.go @@ -131,6 +131,7 @@ type Project struct { PlanRequirements []string ApplyRequirements []string ImportRequirements []string + DependsOn []string DeleteSourceBranchOnMerge *bool RepoLocking *bool ExecutionOrderGroup int diff --git a/server/events/command/project_context.go b/server/events/command/project_context.go index ec6acea34a..5afbe145ac 100644 --- a/server/events/command/project_context.go +++ b/server/events/command/project_context.go @@ -57,6 +57,13 @@ type ProjectContext struct { // If the pull request branch is from the same repository then HeadRepo will // be the same as BaseRepo. HeadRepo models.Repo + // DependsOn are a list of project that this project relies on + // their apply status. These projects must be applied first. + // + // Atlantis uses this information to valid the apply + // orders and to warn the user if they're applying a project that + // depends on other projects. + DependsOn []string // Log is a logger that's been set up for this context. Log logging.SimpleLogging // Scope is the scope for reporting stats setup for this context @@ -67,6 +74,8 @@ type ProjectContext struct { ProjectPlanStatus models.ProjectPlanStatus // ProjectPolicyStatus is the status of policy sets of the current project prior to this command. ProjectPolicyStatus []models.PolicySetStatus + // PullStatus is the current status of a pull request that is in progress. + PullStatus *models.PullStatus // Pull is the pull request we're responding to. Pull models.PullRequest // ProjectName is the name of the project set in atlantis.yaml. If there was diff --git a/server/events/command_requirement_handler.go b/server/events/command_requirement_handler.go index 8af12bec54..20ee61aee1 100644 --- a/server/events/command_requirement_handler.go +++ b/server/events/command_requirement_handler.go @@ -1,13 +1,17 @@ package events import ( + "fmt" + "github.com/runatlantis/atlantis/server/core/config/raw" "github.com/runatlantis/atlantis/server/core/config/valid" "github.com/runatlantis/atlantis/server/events/command" + "github.com/runatlantis/atlantis/server/events/models" ) //go:generate pegomock generate --package mocks -o mocks/mock_command_requirement_handler.go CommandRequirementHandler type CommandRequirementHandler interface { + ValidateProjectDependencies(ctx command.ProjectContext) (string, error) ValidatePlanProject(repoDir string, ctx command.ProjectContext) (string, error) ValidateApplyProject(repoDir string, ctx command.ProjectContext) (string, error) ValidateImportProject(repoDir string, ctx command.ProjectContext) (string, error) @@ -65,6 +69,20 @@ func (a *DefaultCommandRequirementHandler) ValidateApplyProject(repoDir string, return "", nil } +func (a *DefaultCommandRequirementHandler) ValidateProjectDependencies(ctx command.ProjectContext) (failure string, err error) { + for _, dependOnProject := range ctx.DependsOn { + + for _, project := range ctx.PullStatus.Projects { + + if project.ProjectName == dependOnProject && project.Status != models.AppliedPlanStatus { + return fmt.Sprintf("Can't apply your project unless you apply its dependencies: [%s]", project.ProjectName), nil + } + } + } + + return "", nil +} + func (a *DefaultCommandRequirementHandler) ValidateImportProject(repoDir string, ctx command.ProjectContext) (failure string, err error) { for _, req := range ctx.ImportRequirements { switch req { diff --git a/server/events/command_requirement_handler_test.go b/server/events/command_requirement_handler_test.go index 7a9891b07c..86f318825f 100644 --- a/server/events/command_requirement_handler_test.go +++ b/server/events/command_requirement_handler_test.go @@ -207,6 +207,113 @@ func TestAggregateApplyRequirements_ValidateApplyProject(t *testing.T) { } } +func TestRequirements_ValidateProjectDependencies(t *testing.T) { + tests := []struct { + name string + ctx command.ProjectContext + setup func(workingDir *mocks.MockWorkingDir) + wantFailure string + wantErr assert.ErrorAssertionFunc + }{ + { + name: "pass no dependencies", + ctx: command.ProjectContext{}, + wantErr: assert.NoError, + }, + { + name: "pass all dependencies applied", + ctx: command.ProjectContext{ + DependsOn: []string{"project1"}, + PullStatus: &models.PullStatus{ + Projects: []models.ProjectStatus{ + { + ProjectName: "project1", + Status: models.AppliedPlanStatus, + }, + }, + }, + }, + wantErr: assert.NoError, + }, + { + name: "Fail all dependencies are not applied", + ctx: command.ProjectContext{ + DependsOn: []string{"project1", "project2"}, + PullStatus: &models.PullStatus{ + Projects: []models.ProjectStatus{ + { + ProjectName: "project1", + Status: models.PlannedPlanStatus, + }, + { + ProjectName: "project2", + Status: models.ErroredApplyStatus, + }, + }, + }, + }, + wantFailure: "Can't apply your project unless you apply its dependencies: [project1]", + wantErr: assert.NoError, + }, + { + name: "Fail one of dependencies is not applied", + ctx: command.ProjectContext{ + DependsOn: []string{"project1", "project2"}, + PullStatus: &models.PullStatus{ + Projects: []models.ProjectStatus{ + { + ProjectName: "project1", + Status: models.AppliedPlanStatus, + }, + { + ProjectName: "project2", + Status: models.ErroredApplyStatus, + }, + }, + }, + }, + wantFailure: "Can't apply your project unless you apply its dependencies: [project2]", + wantErr: assert.NoError, + }, + { + name: "Fail one of dependencies is not applied", + ctx: command.ProjectContext{ + DependsOn: []string{"project1", "project2", "project3"}, + PullStatus: &models.PullStatus{ + Projects: []models.ProjectStatus{ + { + ProjectName: "project1", + Status: models.AppliedPlanStatus, + }, + { + ProjectName: "project2", + Status: models.ErroredApplyStatus, + }, + { + ProjectName: "project3", + Status: models.PlannedPlanStatus, + }, + }, + }, + }, + wantFailure: "Can't apply your project unless you apply its dependencies: [project2]", + wantErr: assert.NoError, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + RegisterMockTestingT(t) + workingDir := mocks.NewMockWorkingDir() + a := &events.DefaultCommandRequirementHandler{WorkingDir: workingDir} + gotFailure, err := a.ValidateProjectDependencies(tt.ctx) + if !tt.wantErr(t, err, fmt.Sprintf("ValidateProjectDependencies(%v)", tt.ctx)) { + return + } + assert.Equalf(t, tt.wantFailure, gotFailure, "ValidateProjectDependencies(%v)", tt.ctx) + }) + } +} + func TestAggregateApplyRequirements_ValidateImportProject(t *testing.T) { repoDir := "repoDir" fullRequirements := []string{ diff --git a/server/events/mocks/mock_command_requirement_handler.go b/server/events/mocks/mock_command_requirement_handler.go index 8b6dd3c775..aeee17ae83 100644 --- a/server/events/mocks/mock_command_requirement_handler.go +++ b/server/events/mocks/mock_command_requirement_handler.go @@ -125,6 +125,25 @@ func (verifier *VerifierMockCommandRequirementHandler) ValidateApplyProject(repo return &MockCommandRequirementHandler_ValidateApplyProject_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } +func (mock *MockCommandRequirementHandler) ValidateProjectDependencies(_param0 command.ProjectContext) (string, error) { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockCommandRequirementHandler().") + } + params := []pegomock.Param{_param0} + result := pegomock.GetGenericMockFrom(mock).Invoke("ValidateProjectDependencies", params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) + var ret0 string + var ret1 error + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(string) + } + if result[1] != nil { + ret1 = result[1].(error) + } + } + return ret0, ret1 +} + type MockCommandRequirementHandler_ValidateApplyProject_OngoingVerification struct { mock *MockCommandRequirementHandler methodInvocations []pegomock.MethodInvocation diff --git a/server/events/project_command_context_builder.go b/server/events/project_command_context_builder.go index 2170bfb8e2..317e8516a1 100644 --- a/server/events/project_command_context_builder.go +++ b/server/events/project_command_context_builder.go @@ -144,6 +144,7 @@ func (cb *DefaultProjectCommandContextBuilder) BuildProjectContext( abortOnExcecutionOrderFail, ctx.Scope, ctx.PullRequestStatus, + ctx.PullStatus, ) projectCmds = append(projectCmds, projectCmdContext) @@ -215,6 +216,7 @@ func (cb *PolicyCheckProjectCommandContextBuilder) BuildProjectContext( abortOnExcecutionOrderFail, ctx.Scope, ctx.PullRequestStatus, + ctx.PullStatus, )) } @@ -238,7 +240,8 @@ func newProjectCommandContext(ctx *command.Context, verbose bool, abortOnExcecutionOrderFail bool, scope tally.Scope, - pullStatus models.PullReqStatus, + pullReqStatus models.PullReqStatus, + pullStatus *models.PullStatus, ) command.ProjectContext { var projectPlanStatus models.ProjectPlanStatus @@ -274,6 +277,7 @@ func newProjectCommandContext(ctx *command.Context, ParallelApplyEnabled: parallelApplyEnabled, ParallelPlanEnabled: parallelPlanEnabled, ParallelPolicyCheckEnabled: parallelPlanEnabled, + DependsOn: projCfg.DependsOn, AutoplanEnabled: projCfg.AutoplanEnabled, Steps: steps, HeadRepo: ctx.HeadRepo, @@ -296,7 +300,8 @@ func newProjectCommandContext(ctx *command.Context, PolicySets: policySets, PolicySetTarget: ctx.PolicySet, ClearPolicyApproval: ctx.ClearPolicyApproval, - PullReqStatus: pullStatus, + PullReqStatus: pullReqStatus, + PullStatus: pullStatus, JobID: uuid.New().String(), ExecutionOrderGroup: projCfg.ExecutionOrderGroup, AbortOnExcecutionOrderFail: abortOnExcecutionOrderFail, diff --git a/server/events/project_command_runner.go b/server/events/project_command_runner.go index 6b938b2b95..1e8e2fe315 100644 --- a/server/events/project_command_runner.go +++ b/server/events/project_command_runner.go @@ -598,6 +598,11 @@ func (p *DefaultProjectCommandRunner) doApply(ctx command.ProjectContext) (apply return "", failure, err } + failure, err = p.CommandRequirementHandler.ValidateProjectDependencies(ctx) + if failure != "" || err != nil { + return "", failure, err + } + // Acquire internal lock for the directory we're going to operate in. unlockFn, err := p.WorkingDirLocker.TryLock(ctx.Pull.BaseRepo.FullName, ctx.Pull.Num, ctx.Workspace, ctx.RepoRelDir) if err != nil {