From b859f72561b641b360d27024b1c2693b46139bc5 Mon Sep 17 00:00:00 2001 From: Tim McFadden <52185+tim775@users.noreply.github.com> Date: Wed, 12 Jan 2022 19:57:45 -0500 Subject: [PATCH] feat: post workflow hooks (#1990) * Add post-workflow hooks * docs: Add cost estimation as post workflow use case Co-authored-by: Gerald Barker --- runatlantis.io/.vuepress/config.js | 1 + runatlantis.io/docs/post-workflow-hooks.md | 72 ++++++ runatlantis.io/docs/pre-workflow-hooks.md | 6 +- .../docs/server-side-repo-config.md | 19 ++ .../events/events_controller_e2e_test.go | 52 +++-- .../test-repos/server-side-cfg/repos.yaml | 2 + .../models_preworkflowhookcommandcontext.go | 33 --- .../models_workflowhookcommandcontext.go | 33 +++ .../mocks/mock_post_workflows_hook_runner.go | 117 ++++++++++ .../mocks/mock_pre_workflows_hook_runner.go | 12 +- .../mocks/mock_pull_approved_checker.go | 12 +- .../core/runtime/post_workflow_hook_runner.go | 52 +++++ .../runtime/post_workflow_hook_runner_test.go | 111 +++++++++ .../core/runtime/pre_workflow_hook_runner.go | 4 +- .../runtime/pre_workflow_hook_runner_test.go | 2 +- server/events/command_runner.go | 25 +- server/events/command_runner_test.go | 32 +-- ...ock_post_workflows_hooks_command_runner.go | 107 +++++++++ server/events/models/models.go | 4 +- .../post_workflow_hooks_command_runner.go | 94 ++++++++ ...post_workflow_hooks_command_runner_test.go | 218 ++++++++++++++++++ .../pre_workflow_hooks_command_runner.go | 8 +- .../pre_workflow_hooks_command_runner_test.go | 72 +++--- server/events/yaml/parser_validator_test.go | 22 +- server/events/yaml/raw/global_cfg.go | 29 ++- ...{pre_workflow_step.go => workflow_step.go} | 24 +- ...low_step_test.go => workflow_step_test.go} | 30 +-- server/events/yaml/valid/global_cfg.go | 25 +- server/server.go | 132 ++++++----- 29 files changed, 1110 insertions(+), 240 deletions(-) create mode 100644 runatlantis.io/docs/post-workflow-hooks.md delete mode 100644 server/core/runtime/mocks/matchers/models_preworkflowhookcommandcontext.go create mode 100644 server/core/runtime/mocks/matchers/models_workflowhookcommandcontext.go create mode 100644 server/core/runtime/mocks/mock_post_workflows_hook_runner.go create mode 100644 server/core/runtime/post_workflow_hook_runner.go create mode 100644 server/core/runtime/post_workflow_hook_runner_test.go create mode 100644 server/events/mocks/mock_post_workflows_hooks_command_runner.go create mode 100644 server/events/post_workflow_hooks_command_runner.go create mode 100644 server/events/post_workflow_hooks_command_runner_test.go rename server/events/yaml/raw/{pre_workflow_step.go => workflow_step.go} (76%) rename server/events/yaml/raw/{pre_workflow_step_test.go => workflow_step_test.go} (82%) diff --git a/runatlantis.io/.vuepress/config.js b/runatlantis.io/.vuepress/config.js index 8a7eb49bf7..0648fd87f5 100644 --- a/runatlantis.io/.vuepress/config.js +++ b/runatlantis.io/.vuepress/config.js @@ -65,6 +65,7 @@ module.exports = { 'server-configuration', 'server-side-repo-config', 'pre-workflow-hooks', + 'post-workflow-hooks', 'policy-checking', 'custom-workflows', 'repo-level-atlantis-yaml', diff --git a/runatlantis.io/docs/post-workflow-hooks.md b/runatlantis.io/docs/post-workflow-hooks.md new file mode 100644 index 0000000000..66c4972e34 --- /dev/null +++ b/runatlantis.io/docs/post-workflow-hooks.md @@ -0,0 +1,72 @@ +# Post Workflow Hooks + +Post workflow hooks can be defined to run scripts after default or custom +workflows are executed. Post workflow hooks differ from [custom +workflows](custom-workflows.html#custom-run-command) in that they are run +outside of Atlantis commands. Which means they do not surface their output +back to the PR as a comment. + +[[toc]] + +## Usage + +Post workflow hooks can only be specified in the Server-Side Repo Config under +`repos` key. + +## Use Cases + +### Cost estimation reporting + +You can add a post workflow hook to perform custom reporting after all workflows +have finished. + +In this example we use a custom workflow to generate cost estimates for each +workflow, then create a summary report after all workflows have completed. + +```yaml +# repos.yaml +workflows: + myworkflow: + plan: + steps: + - init + - plan + - run: infracost breakdown --path=$PLANFILE --format=json --out-file=/tmp/$BASE_REPO_OWNER-$BASE_REPO_NAME-$PULL_NUM-$WORKSPACE-$REPO_REL_DIR-infracost.json +repos: + - id: /.*/ + workflow: myworkflow + post_workflow_hooks: + - run: infracost output --path=/tmp/$BASE_REPO_OWNER-$BASE_REPO_NAME-$PULL_NUM-*-infracost.json --format=github-comment --out-file=/tmp/infracost-comment.md + # Now report the output as desired, e.g. post to GitHub as a comment. + # ... +``` + +## Reference + +### Custom `run` Command + +This is very similar to [custom workflow run +command](custom-workflows.html#custom-run-command). + +```yaml +- run: custom-command +``` + +| Key | Type | Default | Required | Description | +| --- | ------ | ------- | -------- | -------------------- | +| run | string | none | no | Run a custom command | + +::: tip Notes +* `run` commands are executed with the following environment variables: + * `BASE_REPO_NAME` - Name of the repository that the pull request will be merged into, ex. `atlantis`. + * `BASE_REPO_OWNER` - Owner of the repository that the pull request will be merged into, ex. `runatlantis`. + * `HEAD_REPO_NAME` - Name of the repository that is getting merged into the base repository, ex. `atlantis`. + * `HEAD_REPO_OWNER` - Owner of the repository that is getting merged into the base repository, ex. `acme-corp`. + * `HEAD_BRANCH_NAME` - Name of the head branch of the pull request (the branch that is getting merged into the base) + * `HEAD_COMMIT` - The sha256 that points to the head of the branch that is being pull requested into the base. If the pull request is from Bitbucket Cloud the string will only be 12 characters long because Bitbucket Cloud truncates its commit IDs. + * `BASE_BRANCH_NAME` - Name of the base branch of the pull request (the branch that the pull request is getting merged into) + * `PULL_NUM` - Pull request number or ID, ex. `2`. + * `PULL_AUTHOR` - Username of the pull request author, ex. `acme-user`. + * `DIR` - The absolute path to the root of the cloned repository. + * `USER_NAME` - Username of the VCS user running command, ex. `acme-user`. During an autoplan, the user will be the Atlantis API user, ex. `atlantis`. +::: diff --git a/runatlantis.io/docs/pre-workflow-hooks.md b/runatlantis.io/docs/pre-workflow-hooks.md index e94d927197..a84f91e9c6 100644 --- a/runatlantis.io/docs/pre-workflow-hooks.md +++ b/runatlantis.io/docs/pre-workflow-hooks.md @@ -6,7 +6,7 @@ workflows](custom-workflows.html#custom-run-command) in several ways. 1. Pre workflow hooks do not require for repository configuration to be present. This be utilized to [dynamically generate repo configs](pre-workflow-hooks.html#dynamic-repo-config-generation). -2. Pre workflow hooks are ran outside of Atlantis commands. Which means +2. Pre workflow hooks are run outside of Atlantis commands. Which means they do not surface their output back to the PR as a comment. [[toc]] @@ -31,8 +31,8 @@ repos: pre_workflow_hooks: - run: ./repo-config-generator.sh ``` -### Reference -#### Custom `run` Command +## Reference +### Custom `run` Command This is very similar to [custom workflow run command](custom-workflows.html#custom-run-command). ```yaml diff --git a/runatlantis.io/docs/server-side-repo-config.md b/runatlantis.io/docs/server-side-repo-config.md index 1a75ab0449..c617edf13c 100644 --- a/runatlantis.io/docs/server-side-repo-config.md +++ b/runatlantis.io/docs/server-side-repo-config.md @@ -60,6 +60,10 @@ repos: # pre_workflow_hooks defines arbitrary list of scripts to execute before workflow execution. pre_workflow_hooks: - run: my-pre-workflow-hook-command arg1 + + # post_workflow_hooks defines arbitrary list of scripts to execute after workflow execution. + post_workflow_hooks: + - run: my-post-workflow-hook-command arg1 # id can also be an exact match. - id: github.com/myorg/specific-repo @@ -180,6 +184,21 @@ repos: See [Pre Workflow Hooks](pre-workflow-hooks.html) for more details on writing pre workflow hooks. +### Running Scripts After Atlantis Workflows +If you want to run scripts that would execute after Atlantis runs default or +custom workflows, you can create a `post-workflow-hooks`: + +```yaml +repos: + - id: /.*/ + post_workflow_hooks: + - run: my custom command + - run: | + my bash script inline +``` +See [Post Workflow Hooks](post-workflow-hooks.html) for more details on writing +post workflow hooks. + ### Change The Default Atlantis Workflow If you want to change the default commands that Atlantis runs during `plan` and `apply` phases, you can create a new `workflow`. diff --git a/server/controllers/events/events_controller_e2e_test.go b/server/controllers/events/events_controller_e2e_test.go index 2294526ea9..f81271fa2d 100644 --- a/server/controllers/events/events_controller_e2e_test.go +++ b/server/controllers/events/events_controller_e2e_test.go @@ -48,6 +48,8 @@ type NoopTFDownloader struct{} var mockPreWorkflowHookRunner *runtimemocks.MockPreWorkflowHookRunner +var mockPostWorkflowHookRunner *runtimemocks.MockPostWorkflowHookRunner + func (m *NoopTFDownloader) GetFile(dst, src string, opts ...getter.ClientOption) error { return nil } @@ -425,7 +427,10 @@ func TestGitHubWorkflow(t *testing.T) { ResponseContains(t, w, 200, "Pull request cleaned successfully") // Let's verify the pre-workflow hook was called for each comment including the pull request opened event - mockPreWorkflowHookRunner.VerifyWasCalled(Times(len(c.Comments)+1)).Run(runtimematchers.AnyModelsPreWorkflowHookCommandContext(), EqString("some dummy command"), AnyString()) + mockPreWorkflowHookRunner.VerifyWasCalled(Times(len(c.Comments)+1)).Run(runtimematchers.AnyModelsWorkflowHookCommandContext(), EqString("some dummy command"), AnyString()) + + // Let's verify the post-workflow hook was called for each comment including the pull request opened event + mockPostWorkflowHookRunner.VerifyWasCalled(Times(len(c.Comments)+1)).Run(runtimematchers.AnyModelsWorkflowHookCommandContext(), EqString("some post dummy command"), AnyString()) // Now we're ready to verify Atlantis made all the comments back (or // replies) that we expect. We expect each plan to have 1 comment, @@ -608,7 +613,7 @@ func TestSimlpleWorkflow_terraformLockFile(t *testing.T) { } // Let's verify the pre-workflow hook was called for each comment including the pull request opened event - mockPreWorkflowHookRunner.VerifyWasCalled(Times(2)).Run(runtimematchers.AnyModelsPreWorkflowHookCommandContext(), EqString("some dummy command"), AnyString()) + mockPreWorkflowHookRunner.VerifyWasCalled(Times(2)).Run(runtimematchers.AnyModelsWorkflowHookCommandContext(), EqString("some dummy command"), AnyString()) // Now we're ready to verify Atlantis made all the comments back (or // replies) that we expect. We expect each plan to have 1 comment, @@ -869,12 +874,18 @@ func setupE2E(t *testing.T, repoDir string) (events_controllers.VCSEventsControl AllowRepoCfg: true, MergeableReq: false, ApprovedReq: false, - PreWorkflowHooks: []*valid.PreWorkflowHook{ + PreWorkflowHooks: []*valid.WorkflowHook{ { StepName: "global_hook", RunCommand: "some dummy command", }, }, + PostWorkflowHooks: []*valid.WorkflowHook{ + { + StepName: "global_hook", + RunCommand: "some post dummy command", + }, + }, PolicyCheckEnabled: userConfig.EnablePolicyChecksFlag, } globalCfg := valid.NewGlobalCfgFromArgs(globalCfgArgs) @@ -896,6 +907,16 @@ func setupE2E(t *testing.T, repoDir string) (events_controllers.VCSEventsControl WorkingDir: workingDir, PreWorkflowHookRunner: mockPreWorkflowHookRunner, } + + mockPostWorkflowHookRunner = runtimemocks.NewMockPostWorkflowHookRunner() + postWorkflowHooksCommandRunner := &events.DefaultPostWorkflowHooksCommandRunner{ + VCSClient: e2eVCSClient, + GlobalCfg: globalCfg, + WorkingDirLocker: locker, + WorkingDir: workingDir, + PostWorkflowHookRunner: mockPostWorkflowHookRunner, + } + projectCommandBuilder := events.NewProjectCommandBuilder( userConfig.EnablePolicyChecksFlag, parser, @@ -1052,18 +1073,19 @@ func setupE2E(t *testing.T, repoDir string) (events_controllers.VCSEventsControl } commandRunner := &events.DefaultCommandRunner{ - EventParser: eventParser, - VCSClient: e2eVCSClient, - GithubPullGetter: e2eGithubGetter, - GitlabMergeRequestGetter: e2eGitlabGetter, - Logger: logger, - GlobalCfg: globalCfg, - AllowForkPRs: allowForkPRs, - AllowForkPRsFlag: "allow-fork-prs", - CommentCommandRunnerByCmd: commentCommandRunnerByCmd, - Drainer: drainer, - PreWorkflowHooksCommandRunner: preWorkflowHooksCommandRunner, - PullStatusFetcher: boltdb, + EventParser: eventParser, + VCSClient: e2eVCSClient, + GithubPullGetter: e2eGithubGetter, + GitlabMergeRequestGetter: e2eGitlabGetter, + Logger: logger, + GlobalCfg: globalCfg, + AllowForkPRs: allowForkPRs, + AllowForkPRsFlag: "allow-fork-prs", + CommentCommandRunnerByCmd: commentCommandRunnerByCmd, + Drainer: drainer, + PreWorkflowHooksCommandRunner: preWorkflowHooksCommandRunner, + PostWorkflowHooksCommandRunner: postWorkflowHooksCommandRunner, + PullStatusFetcher: boltdb, } repoAllowlistChecker, err := events.NewRepoAllowlistChecker("*") diff --git a/server/controllers/events/testfixtures/test-repos/server-side-cfg/repos.yaml b/server/controllers/events/testfixtures/test-repos/server-side-cfg/repos.yaml index 5c550ba60e..01ee03dfea 100644 --- a/server/controllers/events/testfixtures/test-repos/server-side-cfg/repos.yaml +++ b/server/controllers/events/testfixtures/test-repos/server-side-cfg/repos.yaml @@ -3,6 +3,8 @@ repos: pre_workflow_hooks: - run: echo "hello" workflow: custom + post_workflow_hooks: + - run: echo "hello" allowed_overrides: [workflow] workflows: custom: diff --git a/server/core/runtime/mocks/matchers/models_preworkflowhookcommandcontext.go b/server/core/runtime/mocks/matchers/models_preworkflowhookcommandcontext.go deleted file mode 100644 index 8a57120c7c..0000000000 --- a/server/core/runtime/mocks/matchers/models_preworkflowhookcommandcontext.go +++ /dev/null @@ -1,33 +0,0 @@ -// Code generated by pegomock. DO NOT EDIT. -package matchers - -import ( - "github.com/petergtz/pegomock" - "reflect" - - models "github.com/runatlantis/atlantis/server/events/models" -) - -func AnyModelsPreWorkflowHookCommandContext() models.PreWorkflowHookCommandContext { - pegomock.RegisterMatcher(pegomock.NewAnyMatcher(reflect.TypeOf((*(models.PreWorkflowHookCommandContext))(nil)).Elem())) - var nullValue models.PreWorkflowHookCommandContext - return nullValue -} - -func EqModelsPreWorkflowHookCommandContext(value models.PreWorkflowHookCommandContext) models.PreWorkflowHookCommandContext { - pegomock.RegisterMatcher(&pegomock.EqMatcher{Value: value}) - var nullValue models.PreWorkflowHookCommandContext - return nullValue -} - -func NotEqModelsPreWorkflowHookCommandContext(value models.PreWorkflowHookCommandContext) models.PreWorkflowHookCommandContext { - pegomock.RegisterMatcher(&pegomock.NotEqMatcher{Value: value}) - var nullValue models.PreWorkflowHookCommandContext - return nullValue -} - -func ModelsPreWorkflowHookCommandContextThat(matcher pegomock.ArgumentMatcher) models.PreWorkflowHookCommandContext { - pegomock.RegisterMatcher(matcher) - var nullValue models.PreWorkflowHookCommandContext - return nullValue -} diff --git a/server/core/runtime/mocks/matchers/models_workflowhookcommandcontext.go b/server/core/runtime/mocks/matchers/models_workflowhookcommandcontext.go new file mode 100644 index 0000000000..18a5a5bae9 --- /dev/null +++ b/server/core/runtime/mocks/matchers/models_workflowhookcommandcontext.go @@ -0,0 +1,33 @@ +// Code generated by pegomock. DO NOT EDIT. +package matchers + +import ( + "github.com/petergtz/pegomock" + "reflect" + + models "github.com/runatlantis/atlantis/server/events/models" +) + +func AnyModelsWorkflowHookCommandContext() models.WorkflowHookCommandContext { + pegomock.RegisterMatcher(pegomock.NewAnyMatcher(reflect.TypeOf((*(models.WorkflowHookCommandContext))(nil)).Elem())) + var nullValue models.WorkflowHookCommandContext + return nullValue +} + +func EqModelsWorkflowHookCommandContext(value models.WorkflowHookCommandContext) models.WorkflowHookCommandContext { + pegomock.RegisterMatcher(&pegomock.EqMatcher{Value: value}) + var nullValue models.WorkflowHookCommandContext + return nullValue +} + +func NotEqModelsWorkflowHookCommandContext(value models.WorkflowHookCommandContext) models.WorkflowHookCommandContext { + pegomock.RegisterMatcher(&pegomock.NotEqMatcher{Value: value}) + var nullValue models.WorkflowHookCommandContext + return nullValue +} + +func ModelsWorkflowHookCommandContextThat(matcher pegomock.ArgumentMatcher) models.WorkflowHookCommandContext { + pegomock.RegisterMatcher(matcher) + var nullValue models.WorkflowHookCommandContext + return nullValue +} diff --git a/server/core/runtime/mocks/mock_post_workflows_hook_runner.go b/server/core/runtime/mocks/mock_post_workflows_hook_runner.go new file mode 100644 index 0000000000..018a093dce --- /dev/null +++ b/server/core/runtime/mocks/mock_post_workflows_hook_runner.go @@ -0,0 +1,117 @@ +// Code generated by pegomock. DO NOT EDIT. +// Source: github.com/runatlantis/atlantis/server/core/runtime (interfaces: PostWorkflowHookRunner) + +package mocks + +import ( + pegomock "github.com/petergtz/pegomock" + models "github.com/runatlantis/atlantis/server/events/models" + "reflect" + "time" +) + +type MockPostWorkflowHookRunner struct { + fail func(message string, callerSkip ...int) +} + +func NewMockPostWorkflowHookRunner(options ...pegomock.Option) *MockPostWorkflowHookRunner { + mock := &MockPostWorkflowHookRunner{} + for _, option := range options { + option.Apply(mock) + } + return mock +} + +func (mock *MockPostWorkflowHookRunner) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } +func (mock *MockPostWorkflowHookRunner) FailHandler() pegomock.FailHandler { return mock.fail } + +func (mock *MockPostWorkflowHookRunner) Run(ctx models.WorkflowHookCommandContext, command string, path string) (string, error) { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockPostWorkflowHookRunner().") + } + params := []pegomock.Param{ctx, command, path} + result := pegomock.GetGenericMockFrom(mock).Invoke("Run", 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 +} + +func (mock *MockPostWorkflowHookRunner) VerifyWasCalledOnce() *VerifierMockPostWorkflowHookRunner { + return &VerifierMockPostWorkflowHookRunner{ + mock: mock, + invocationCountMatcher: pegomock.Times(1), + } +} + +func (mock *MockPostWorkflowHookRunner) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockPostWorkflowHookRunner { + return &VerifierMockPostWorkflowHookRunner{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + } +} + +func (mock *MockPostWorkflowHookRunner) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockPostWorkflowHookRunner { + return &VerifierMockPostWorkflowHookRunner{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + inOrderContext: inOrderContext, + } +} + +func (mock *MockPostWorkflowHookRunner) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockPostWorkflowHookRunner { + return &VerifierMockPostWorkflowHookRunner{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + timeout: timeout, + } +} + +type VerifierMockPostWorkflowHookRunner struct { + mock *MockPostWorkflowHookRunner + invocationCountMatcher pegomock.InvocationCountMatcher + inOrderContext *pegomock.InOrderContext + timeout time.Duration +} + +func (verifier *VerifierMockPostWorkflowHookRunner) Run(ctx models.WorkflowHookCommandContext, command string, path string) *MockPostWorkflowHookRunner_Run_OngoingVerification { + params := []pegomock.Param{ctx, command, path} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Run", params, verifier.timeout) + return &MockPostWorkflowHookRunner_Run_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockPostWorkflowHookRunner_Run_OngoingVerification struct { + mock *MockPostWorkflowHookRunner + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockPostWorkflowHookRunner_Run_OngoingVerification) GetCapturedArguments() (models.WorkflowHookCommandContext, string, string) { + ctx, command, path := c.GetAllCapturedArguments() + return ctx[len(ctx)-1], command[len(command)-1], path[len(path)-1] +} + +func (c *MockPostWorkflowHookRunner_Run_OngoingVerification) GetAllCapturedArguments() (_param0 []models.WorkflowHookCommandContext, _param1 []string, _param2 []string) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]models.WorkflowHookCommandContext, len(c.methodInvocations)) + for u, param := range params[0] { + _param0[u] = param.(models.WorkflowHookCommandContext) + } + _param1 = make([]string, len(c.methodInvocations)) + for u, param := range params[1] { + _param1[u] = param.(string) + } + _param2 = make([]string, len(c.methodInvocations)) + for u, param := range params[2] { + _param2[u] = param.(string) + } + } + return +} diff --git a/server/core/runtime/mocks/mock_pre_workflows_hook_runner.go b/server/core/runtime/mocks/mock_pre_workflows_hook_runner.go index ddc8834912..41a0fd359a 100644 --- a/server/core/runtime/mocks/mock_pre_workflows_hook_runner.go +++ b/server/core/runtime/mocks/mock_pre_workflows_hook_runner.go @@ -25,7 +25,7 @@ func NewMockPreWorkflowHookRunner(options ...pegomock.Option) *MockPreWorkflowHo func (mock *MockPreWorkflowHookRunner) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockPreWorkflowHookRunner) FailHandler() pegomock.FailHandler { return mock.fail } -func (mock *MockPreWorkflowHookRunner) Run(ctx models.PreWorkflowHookCommandContext, command string, path string) (string, error) { +func (mock *MockPreWorkflowHookRunner) Run(ctx models.WorkflowHookCommandContext, command string, path string) (string, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockPreWorkflowHookRunner().") } @@ -81,7 +81,7 @@ type VerifierMockPreWorkflowHookRunner struct { timeout time.Duration } -func (verifier *VerifierMockPreWorkflowHookRunner) Run(ctx models.PreWorkflowHookCommandContext, command string, path string) *MockPreWorkflowHookRunner_Run_OngoingVerification { +func (verifier *VerifierMockPreWorkflowHookRunner) Run(ctx models.WorkflowHookCommandContext, command string, path string) *MockPreWorkflowHookRunner_Run_OngoingVerification { params := []pegomock.Param{ctx, command, path} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Run", params, verifier.timeout) return &MockPreWorkflowHookRunner_Run_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} @@ -92,17 +92,17 @@ type MockPreWorkflowHookRunner_Run_OngoingVerification struct { methodInvocations []pegomock.MethodInvocation } -func (c *MockPreWorkflowHookRunner_Run_OngoingVerification) GetCapturedArguments() (models.PreWorkflowHookCommandContext, string, string) { +func (c *MockPreWorkflowHookRunner_Run_OngoingVerification) GetCapturedArguments() (models.WorkflowHookCommandContext, string, string) { ctx, command, path := c.GetAllCapturedArguments() return ctx[len(ctx)-1], command[len(command)-1], path[len(path)-1] } -func (c *MockPreWorkflowHookRunner_Run_OngoingVerification) GetAllCapturedArguments() (_param0 []models.PreWorkflowHookCommandContext, _param1 []string, _param2 []string) { +func (c *MockPreWorkflowHookRunner_Run_OngoingVerification) GetAllCapturedArguments() (_param0 []models.WorkflowHookCommandContext, _param1 []string, _param2 []string) { params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(params) > 0 { - _param0 = make([]models.PreWorkflowHookCommandContext, len(c.methodInvocations)) + _param0 = make([]models.WorkflowHookCommandContext, len(c.methodInvocations)) for u, param := range params[0] { - _param0[u] = param.(models.PreWorkflowHookCommandContext) + _param0[u] = param.(models.WorkflowHookCommandContext) } _param1 = make([]string, len(c.methodInvocations)) for u, param := range params[1] { diff --git a/server/core/runtime/mocks/mock_pull_approved_checker.go b/server/core/runtime/mocks/mock_pull_approved_checker.go index a71dd3506c..39eeb63317 100644 --- a/server/core/runtime/mocks/mock_pull_approved_checker.go +++ b/server/core/runtime/mocks/mock_pull_approved_checker.go @@ -25,11 +25,11 @@ func NewMockPullApprovedChecker(options ...pegomock.Option) *MockPullApprovedChe func (mock *MockPullApprovedChecker) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh } func (mock *MockPullApprovedChecker) FailHandler() pegomock.FailHandler { return mock.fail } -func (mock *MockPullApprovedChecker) PullIsApproved(_param0 models.Repo, _param1 models.PullRequest) (models.ApprovalStatus, error) { +func (mock *MockPullApprovedChecker) PullIsApproved(baseRepo models.Repo, pull models.PullRequest) (models.ApprovalStatus, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockPullApprovedChecker().") } - params := []pegomock.Param{_param0, _param1} + params := []pegomock.Param{baseRepo, pull} result := pegomock.GetGenericMockFrom(mock).Invoke("PullIsApproved", params, []reflect.Type{reflect.TypeOf((*models.ApprovalStatus)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) var ret0 models.ApprovalStatus var ret1 error @@ -81,8 +81,8 @@ type VerifierMockPullApprovedChecker struct { timeout time.Duration } -func (verifier *VerifierMockPullApprovedChecker) PullIsApproved(_param0 models.Repo, _param1 models.PullRequest) *MockPullApprovedChecker_PullIsApproved_OngoingVerification { - params := []pegomock.Param{_param0, _param1} +func (verifier *VerifierMockPullApprovedChecker) PullIsApproved(baseRepo models.Repo, pull models.PullRequest) *MockPullApprovedChecker_PullIsApproved_OngoingVerification { + params := []pegomock.Param{baseRepo, pull} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "PullIsApproved", params, verifier.timeout) return &MockPullApprovedChecker_PullIsApproved_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } @@ -93,8 +93,8 @@ type MockPullApprovedChecker_PullIsApproved_OngoingVerification struct { } func (c *MockPullApprovedChecker_PullIsApproved_OngoingVerification) GetCapturedArguments() (models.Repo, models.PullRequest) { - _param0, _param1 := c.GetAllCapturedArguments() - return _param0[len(_param0)-1], _param1[len(_param1)-1] + baseRepo, pull := c.GetAllCapturedArguments() + return baseRepo[len(baseRepo)-1], pull[len(pull)-1] } func (c *MockPullApprovedChecker_PullIsApproved_OngoingVerification) GetAllCapturedArguments() (_param0 []models.Repo, _param1 []models.PullRequest) { diff --git a/server/core/runtime/post_workflow_hook_runner.go b/server/core/runtime/post_workflow_hook_runner.go new file mode 100644 index 0000000000..8f2df1d2db --- /dev/null +++ b/server/core/runtime/post_workflow_hook_runner.go @@ -0,0 +1,52 @@ +package runtime + +import ( + "fmt" + "os" + "os/exec" + + "github.com/runatlantis/atlantis/server/events/models" +) + +//go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_post_workflows_hook_runner.go PostWorkflowHookRunner +type PostWorkflowHookRunner interface { + Run(ctx models.WorkflowHookCommandContext, command string, path string) (string, error) +} + +type DefaultPostWorkflowHookRunner struct{} + +func (wh DefaultPostWorkflowHookRunner) Run(ctx models.WorkflowHookCommandContext, command string, path string) (string, error) { + cmd := exec.Command("sh", "-c", command) // #nosec + cmd.Dir = path + + baseEnvVars := os.Environ() + customEnvVars := map[string]string{ + "BASE_BRANCH_NAME": ctx.Pull.BaseBranch, + "BASE_REPO_NAME": ctx.BaseRepo.Name, + "BASE_REPO_OWNER": ctx.BaseRepo.Owner, + "DIR": path, + "HEAD_BRANCH_NAME": ctx.Pull.HeadBranch, + "HEAD_COMMIT": ctx.Pull.HeadCommit, + "HEAD_REPO_NAME": ctx.HeadRepo.Name, + "HEAD_REPO_OWNER": ctx.HeadRepo.Owner, + "PULL_AUTHOR": ctx.Pull.Author, + "PULL_NUM": fmt.Sprintf("%d", ctx.Pull.Num), + "USER_NAME": ctx.User.Username, + } + + finalEnvVars := baseEnvVars + for key, val := range customEnvVars { + finalEnvVars = append(finalEnvVars, fmt.Sprintf("%s=%s", key, val)) + } + + cmd.Env = finalEnvVars + out, err := cmd.CombinedOutput() + + if err != nil { + err = fmt.Errorf("%s: running %q in %q: \n%s", err, command, path, out) + ctx.Log.Debug("error: %s", err) + return "", err + } + ctx.Log.Info("successfully ran %q in %q", command, path) + return string(out), nil +} diff --git a/server/core/runtime/post_workflow_hook_runner_test.go b/server/core/runtime/post_workflow_hook_runner_test.go new file mode 100644 index 0000000000..32a17a7702 --- /dev/null +++ b/server/core/runtime/post_workflow_hook_runner_test.go @@ -0,0 +1,111 @@ +package runtime_test + +import ( + "strings" + "testing" + + . "github.com/petergtz/pegomock" + "github.com/runatlantis/atlantis/server/core/runtime" + "github.com/runatlantis/atlantis/server/core/terraform/mocks" + matchers2 "github.com/runatlantis/atlantis/server/core/terraform/mocks/matchers" + "github.com/runatlantis/atlantis/server/events/mocks/matchers" + "github.com/runatlantis/atlantis/server/events/models" + "github.com/runatlantis/atlantis/server/logging" + . "github.com/runatlantis/atlantis/testing" +) + +func TestPostWorkflowHookRunner_Run(t *testing.T) { + cases := []struct { + Command string + ExpOut string + ExpErr string + }{ + { + Command: "", + ExpOut: "", + }, + { + Command: "echo hi", + ExpOut: "hi\n", + }, + { + Command: `printf \'your main.tf file does not provide default region.\\ncheck\'`, + ExpOut: `'your`, + }, + { + Command: `printf 'your main.tf file does not provide default region.\ncheck'`, + ExpOut: "your main.tf file does not provide default region.\ncheck", + }, + { + Command: "echo 'a", + ExpErr: "exit status 2: running \"echo 'a\" in", + }, + { + Command: "echo hi >> file && cat file", + ExpOut: "hi\n", + }, + { + Command: "lkjlkj", + ExpErr: "exit status 127: running \"lkjlkj\" in", + }, + { + Command: "echo base_repo_name=$BASE_REPO_NAME base_repo_owner=$BASE_REPO_OWNER head_repo_name=$HEAD_REPO_NAME head_repo_owner=$HEAD_REPO_OWNER head_branch_name=$HEAD_BRANCH_NAME head_commit=$HEAD_COMMIT base_branch_name=$BASE_BRANCH_NAME pull_num=$PULL_NUM pull_author=$PULL_AUTHOR", + ExpOut: "base_repo_name=basename base_repo_owner=baseowner head_repo_name=headname head_repo_owner=headowner head_branch_name=add-feat head_commit=12345abcdef base_branch_name=master pull_num=2 pull_author=acme\n", + }, + { + Command: "echo user_name=$USER_NAME", + ExpOut: "user_name=acme-user\n", + }, + } + + for _, c := range cases { + var err error + + Ok(t, err) + + RegisterMockTestingT(t) + terraform := mocks.NewMockClient() + When(terraform.EnsureVersion(matchers.AnyPtrToLoggingSimpleLogger(), matchers2.AnyPtrToGoVersionVersion())). + ThenReturn(nil) + + logger := logging.NewNoopLogger(t) + + r := runtime.DefaultPostWorkflowHookRunner{} + t.Run(c.Command, func(t *testing.T) { + tmpDir, cleanup := TempDir(t) + defer cleanup() + ctx := models.WorkflowHookCommandContext{ + BaseRepo: models.Repo{ + Name: "basename", + Owner: "baseowner", + }, + HeadRepo: models.Repo{ + Name: "headname", + Owner: "headowner", + }, + Pull: models.PullRequest{ + Num: 2, + HeadBranch: "add-feat", + HeadCommit: "12345abcdef", + BaseBranch: "master", + Author: "acme", + }, + User: models.User{ + Username: "acme-user", + }, + Log: logger, + } + out, err := r.Run(ctx, c.Command, tmpDir) + if c.ExpErr != "" { + ErrContains(t, c.ExpErr, err) + return + } + Ok(t, err) + // Replace $DIR in the exp with the actual temp dir. We do this + // here because when constructing the cases we don't yet know the + // temp dir. + expOut := strings.Replace(c.ExpOut, "$DIR", tmpDir, -1) + Equals(t, expOut, out) + }) + } +} diff --git a/server/core/runtime/pre_workflow_hook_runner.go b/server/core/runtime/pre_workflow_hook_runner.go index df0c694463..d3004c84eb 100644 --- a/server/core/runtime/pre_workflow_hook_runner.go +++ b/server/core/runtime/pre_workflow_hook_runner.go @@ -10,12 +10,12 @@ import ( //go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_pre_workflows_hook_runner.go PreWorkflowHookRunner type PreWorkflowHookRunner interface { - Run(ctx models.PreWorkflowHookCommandContext, command string, path string) (string, error) + Run(ctx models.WorkflowHookCommandContext, command string, path string) (string, error) } type DefaultPreWorkflowHookRunner struct{} -func (wh DefaultPreWorkflowHookRunner) Run(ctx models.PreWorkflowHookCommandContext, command string, path string) (string, error) { +func (wh DefaultPreWorkflowHookRunner) Run(ctx models.WorkflowHookCommandContext, command string, path string) (string, error) { cmd := exec.Command("sh", "-c", command) // #nosec cmd.Dir = path diff --git a/server/core/runtime/pre_workflow_hook_runner_test.go b/server/core/runtime/pre_workflow_hook_runner_test.go index 49da87a67d..701cf7397b 100644 --- a/server/core/runtime/pre_workflow_hook_runner_test.go +++ b/server/core/runtime/pre_workflow_hook_runner_test.go @@ -74,7 +74,7 @@ func TestPreWorkflowHookRunner_Run(t *testing.T) { t.Run(c.Command, func(t *testing.T) { tmpDir, cleanup := TempDir(t) defer cleanup() - ctx := models.PreWorkflowHookCommandContext{ + ctx := models.WorkflowHookCommandContext{ BaseRepo: models.Repo{ Name: "basename", Owner: "baseowner", diff --git a/server/events/command_runner.go b/server/events/command_runner.go index 7f4f5f6304..13fd5c4ed2 100644 --- a/server/events/command_runner.go +++ b/server/events/command_runner.go @@ -111,12 +111,13 @@ type DefaultCommandRunner struct { // SilenceForkPRErrorsFlag 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 disable error comment - SilenceForkPRErrorsFlag string - CommentCommandRunnerByCmd map[models.CommandName]CommentCommandRunner - Drainer *Drainer - PreWorkflowHooksCommandRunner PreWorkflowHooksCommandRunner - PullStatusFetcher PullStatusFetcher - TeamAllowlistChecker *TeamAllowlistChecker + SilenceForkPRErrorsFlag string + CommentCommandRunnerByCmd map[models.CommandName]CommentCommandRunner + Drainer *Drainer + PreWorkflowHooksCommandRunner PreWorkflowHooksCommandRunner + PostWorkflowHooksCommandRunner PostWorkflowHooksCommandRunner + PullStatusFetcher PullStatusFetcher + TeamAllowlistChecker *TeamAllowlistChecker } // RunAutoplanCommand runs plan and policy_checks when a pull request is opened or updated. @@ -161,6 +162,12 @@ func (c *DefaultCommandRunner) RunAutoplanCommand(baseRepo models.Repo, headRepo autoPlanRunner := buildCommentCommandRunner(c, models.PlanCommand) autoPlanRunner.Run(ctx, nil) + + err = c.PostWorkflowHooksCommandRunner.RunPostHooks(ctx) + + if err != nil { + ctx.Log.Err("Error running post-workflow hooks %s.", err) + } } // commentUserDoesNotHavePermissions comments on the pull request that the user @@ -250,6 +257,12 @@ func (c *DefaultCommandRunner) RunCommentCommand(baseRepo models.Repo, maybeHead cmdRunner := buildCommentCommandRunner(c, cmd.CommandName()) cmdRunner.Run(ctx, cmd) + + err = c.PostWorkflowHooksCommandRunner.RunPostHooks(ctx) + + if err != nil { + ctx.Log.Err("Error running post-workflow hooks %s.", err) + } } func (c *DefaultCommandRunner) getGithubData(baseRepo models.Repo, pullNum int) (models.PullRequest, models.Repo, error) { diff --git a/server/events/command_runner_test.go b/server/events/command_runner_test.go index 644f06a450..637b9155b4 100644 --- a/server/events/command_runner_test.go +++ b/server/events/command_runner_test.go @@ -64,6 +64,7 @@ var applyLockChecker *lockingmocks.MockApplyLockChecker var applyCommandRunner *events.ApplyCommandRunner var unlockCommandRunner *events.UnlockCommandRunner var preWorkflowHooksCommandRunner events.PreWorkflowHooksCommandRunner +var postWorkflowHooksCommandRunner events.PostWorkflowHooksCommandRunner func setup(t *testing.T) *vcsmocks.MockClient { RegisterMockTestingT(t) @@ -186,22 +187,27 @@ func setup(t *testing.T) *vcsmocks.MockClient { When(preWorkflowHooksCommandRunner.RunPreHooks(matchers.AnyPtrToEventsCommandContext())).ThenReturn(nil) + postWorkflowHooksCommandRunner = mocks.NewMockPostWorkflowHooksCommandRunner() + + When(postWorkflowHooksCommandRunner.RunPostHooks(matchers.AnyPtrToEventsCommandContext())).ThenReturn(nil) + globalCfg := valid.NewGlobalCfgFromArgs(valid.GlobalCfgArgs{}) ch = events.DefaultCommandRunner{ - VCSClient: vcsClient, - CommentCommandRunnerByCmd: commentCommandRunnerByCmd, - EventParser: eventParsing, - GithubPullGetter: githubGetter, - GitlabMergeRequestGetter: gitlabGetter, - AzureDevopsPullGetter: azuredevopsGetter, - Logger: logger, - GlobalCfg: globalCfg, - AllowForkPRs: false, - AllowForkPRsFlag: "allow-fork-prs-flag", - Drainer: drainer, - PreWorkflowHooksCommandRunner: preWorkflowHooksCommandRunner, - PullStatusFetcher: defaultBoltDB, + VCSClient: vcsClient, + CommentCommandRunnerByCmd: commentCommandRunnerByCmd, + EventParser: eventParsing, + GithubPullGetter: githubGetter, + GitlabMergeRequestGetter: gitlabGetter, + AzureDevopsPullGetter: azuredevopsGetter, + Logger: logger, + GlobalCfg: globalCfg, + AllowForkPRs: false, + AllowForkPRsFlag: "allow-fork-prs-flag", + Drainer: drainer, + PreWorkflowHooksCommandRunner: preWorkflowHooksCommandRunner, + PostWorkflowHooksCommandRunner: postWorkflowHooksCommandRunner, + PullStatusFetcher: defaultBoltDB, } return vcsClient } diff --git a/server/events/mocks/mock_post_workflows_hooks_command_runner.go b/server/events/mocks/mock_post_workflows_hooks_command_runner.go new file mode 100644 index 0000000000..fd42ae3ce6 --- /dev/null +++ b/server/events/mocks/mock_post_workflows_hooks_command_runner.go @@ -0,0 +1,107 @@ +// Code generated by pegomock. DO NOT EDIT. +// Source: github.com/runatlantis/atlantis/server/events (interfaces: PostWorkflowHooksCommandRunner) + +package mocks + +import ( + pegomock "github.com/petergtz/pegomock" + events "github.com/runatlantis/atlantis/server/events" + "reflect" + "time" +) + +type MockPostWorkflowHooksCommandRunner struct { + fail func(message string, callerSkip ...int) +} + +func NewMockPostWorkflowHooksCommandRunner(options ...pegomock.Option) *MockPostWorkflowHooksCommandRunner { + mock := &MockPostWorkflowHooksCommandRunner{} + for _, option := range options { + option.Apply(mock) + } + return mock +} + +func (mock *MockPostWorkflowHooksCommandRunner) SetFailHandler(fh pegomock.FailHandler) { + mock.fail = fh +} +func (mock *MockPostWorkflowHooksCommandRunner) FailHandler() pegomock.FailHandler { return mock.fail } + +func (mock *MockPostWorkflowHooksCommandRunner) RunPostHooks(ctx *events.CommandContext) error { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockPostWorkflowHooksCommandRunner().") + } + params := []pegomock.Param{ctx} + result := pegomock.GetGenericMockFrom(mock).Invoke("RunPostHooks", params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}) + var ret0 error + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(error) + } + } + return ret0 +} + +func (mock *MockPostWorkflowHooksCommandRunner) VerifyWasCalledOnce() *VerifierMockPostWorkflowHooksCommandRunner { + return &VerifierMockPostWorkflowHooksCommandRunner{ + mock: mock, + invocationCountMatcher: pegomock.Times(1), + } +} + +func (mock *MockPostWorkflowHooksCommandRunner) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockPostWorkflowHooksCommandRunner { + return &VerifierMockPostWorkflowHooksCommandRunner{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + } +} + +func (mock *MockPostWorkflowHooksCommandRunner) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockPostWorkflowHooksCommandRunner { + return &VerifierMockPostWorkflowHooksCommandRunner{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + inOrderContext: inOrderContext, + } +} + +func (mock *MockPostWorkflowHooksCommandRunner) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockPostWorkflowHooksCommandRunner { + return &VerifierMockPostWorkflowHooksCommandRunner{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + timeout: timeout, + } +} + +type VerifierMockPostWorkflowHooksCommandRunner struct { + mock *MockPostWorkflowHooksCommandRunner + invocationCountMatcher pegomock.InvocationCountMatcher + inOrderContext *pegomock.InOrderContext + timeout time.Duration +} + +func (verifier *VerifierMockPostWorkflowHooksCommandRunner) RunPostHooks(ctx *events.CommandContext) *MockPostWorkflowHooksCommandRunner_RunPostHooks_OngoingVerification { + params := []pegomock.Param{ctx} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "RunPostHooks", params, verifier.timeout) + return &MockPostWorkflowHooksCommandRunner_RunPostHooks_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockPostWorkflowHooksCommandRunner_RunPostHooks_OngoingVerification struct { + mock *MockPostWorkflowHooksCommandRunner + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockPostWorkflowHooksCommandRunner_RunPostHooks_OngoingVerification) GetCapturedArguments() *events.CommandContext { + ctx := c.GetAllCapturedArguments() + return ctx[len(ctx)-1] +} + +func (c *MockPostWorkflowHooksCommandRunner_RunPostHooks_OngoingVerification) GetAllCapturedArguments() (_param0 []*events.CommandContext) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]*events.CommandContext, len(c.methodInvocations)) + for u, param := range params[0] { + _param0[u] = param.(*events.CommandContext) + } + } + return +} diff --git a/server/events/models/models.go b/server/events/models/models.go index b978f6ba02..f59adf255c 100644 --- a/server/events/models/models.go +++ b/server/events/models/models.go @@ -710,9 +710,9 @@ func (c CommandName) String() string { return "" } -// PreWorkflowHookCommandContext defines the context for a pre_worklfow_hooks that will +// WorkflowHookCommandContext defines the context for a pre and post worklfow_hooks that will // be executed before workflows. -type PreWorkflowHookCommandContext struct { +type WorkflowHookCommandContext 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. diff --git a/server/events/post_workflow_hooks_command_runner.go b/server/events/post_workflow_hooks_command_runner.go new file mode 100644 index 0000000000..56666de3e6 --- /dev/null +++ b/server/events/post_workflow_hooks_command_runner.go @@ -0,0 +1,94 @@ +package events + +import ( + "github.com/runatlantis/atlantis/server/core/runtime" + "github.com/runatlantis/atlantis/server/events/models" + "github.com/runatlantis/atlantis/server/events/vcs" + "github.com/runatlantis/atlantis/server/events/yaml/valid" +) + +//go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_post_workflows_hooks_command_runner.go PostWorkflowHooksCommandRunner + +type PostWorkflowHooksCommandRunner interface { + RunPostHooks(ctx *CommandContext) error +} + +// DefaultPostWorkflowHooksCommandRunner is the first step when processing a workflow hook commands. +type DefaultPostWorkflowHooksCommandRunner struct { + VCSClient vcs.Client + WorkingDirLocker WorkingDirLocker + WorkingDir WorkingDir + GlobalCfg valid.GlobalCfg + PostWorkflowHookRunner runtime.PostWorkflowHookRunner +} + +// RunPostHooks runs post_workflow_hooks after a plan/apply has completed +func (w *DefaultPostWorkflowHooksCommandRunner) RunPostHooks( + ctx *CommandContext, +) error { + pull := ctx.Pull + baseRepo := pull.BaseRepo + headRepo := ctx.HeadRepo + user := ctx.User + log := ctx.Log + + postWorkflowHooks := make([]*valid.WorkflowHook, 0) + for _, repo := range w.GlobalCfg.Repos { + if repo.IDMatches(baseRepo.ID()) && repo.BranchMatches(pull.BaseBranch) && len(repo.PostWorkflowHooks) > 0 { + postWorkflowHooks = append(postWorkflowHooks, repo.PostWorkflowHooks...) + } + } + + // short circuit any other calls if there are no post-hooks configured + if len(postWorkflowHooks) == 0 { + return nil + } + + log.Debug("post-hooks configured, running...") + + unlockFn, err := w.WorkingDirLocker.TryLock(baseRepo.FullName, pull.Num, DefaultWorkspace) + if err != nil { + return err + } + log.Debug("got workspace lock") + defer unlockFn() + + repoDir, _, err := w.WorkingDir.Clone(log, headRepo, pull, DefaultWorkspace) + if err != nil { + return err + } + + err = w.runHooks( + models.WorkflowHookCommandContext{ + BaseRepo: baseRepo, + HeadRepo: headRepo, + Log: log, + Pull: pull, + User: user, + Verbose: false, + }, + postWorkflowHooks, repoDir) + + if err != nil { + return err + } + + return nil +} + +func (w *DefaultPostWorkflowHooksCommandRunner) runHooks( + ctx models.WorkflowHookCommandContext, + postWorkflowHooks []*valid.WorkflowHook, + repoDir string, +) error { + + for _, hook := range postWorkflowHooks { + _, err := w.PostWorkflowHookRunner.Run(ctx, hook.RunCommand, repoDir) + + if err != nil { + return err + } + } + + return nil +} diff --git a/server/events/post_workflow_hooks_command_runner_test.go b/server/events/post_workflow_hooks_command_runner_test.go new file mode 100644 index 0000000000..9c615bc2da --- /dev/null +++ b/server/events/post_workflow_hooks_command_runner_test.go @@ -0,0 +1,218 @@ +package events_test + +import ( + "errors" + "testing" + + runtime_mocks "github.com/runatlantis/atlantis/server/core/runtime/mocks" + + . "github.com/petergtz/pegomock" + "github.com/runatlantis/atlantis/server/events" + "github.com/runatlantis/atlantis/server/events/mocks" + "github.com/runatlantis/atlantis/server/events/models" + "github.com/runatlantis/atlantis/server/events/models/fixtures" + vcsmocks "github.com/runatlantis/atlantis/server/events/vcs/mocks" + "github.com/runatlantis/atlantis/server/events/yaml/valid" + "github.com/runatlantis/atlantis/server/logging" + . "github.com/runatlantis/atlantis/testing" +) + +var postWh events.DefaultPostWorkflowHooksCommandRunner +var postWhWorkingDir *mocks.MockWorkingDir +var postWhWorkingDirLocker *mocks.MockWorkingDirLocker +var whPostWorkflowHookRunner *runtime_mocks.MockPostWorkflowHookRunner + +func postWorkflowHooksSetup(t *testing.T) { + RegisterMockTestingT(t) + vcsClient := vcsmocks.NewMockClient() + postWhWorkingDir = mocks.NewMockWorkingDir() + postWhWorkingDirLocker = mocks.NewMockWorkingDirLocker() + whPostWorkflowHookRunner = runtime_mocks.NewMockPostWorkflowHookRunner() + + postWh = events.DefaultPostWorkflowHooksCommandRunner{ + VCSClient: vcsClient, + WorkingDirLocker: postWhWorkingDirLocker, + WorkingDir: postWhWorkingDir, + PostWorkflowHookRunner: whPostWorkflowHookRunner, + } +} + +func TestRunPostHooks_Clone(t *testing.T) { + + log := logging.NewNoopLogger(t) + + var newPull = fixtures.Pull + newPull.BaseRepo = fixtures.GithubRepo + + ctx := &events.CommandContext{ + Pull: newPull, + HeadRepo: fixtures.GithubRepo, + User: fixtures.User, + Log: log, + } + + testHook := valid.WorkflowHook{ + StepName: "test", + RunCommand: "some command", + } + + pCtx := models.WorkflowHookCommandContext{ + BaseRepo: fixtures.GithubRepo, + HeadRepo: fixtures.GithubRepo, + Pull: newPull, + Log: log, + User: fixtures.User, + Verbose: false, + } + + repoDir := "path/to/repo" + result := "some result" + + t.Run("success hooks in cfg", func(t *testing.T) { + postWorkflowHooksSetup(t) + + var unlockCalled *bool = newBool(false) + unlockFn := func() { + unlockCalled = newBool(true) + } + + globalCfg := valid.GlobalCfg{ + Repos: []valid.Repo{ + { + ID: fixtures.GithubRepo.ID(), + PostWorkflowHooks: []*valid.WorkflowHook{ + &testHook, + }, + }, + }, + } + + postWh.GlobalCfg = globalCfg + + When(postWhWorkingDirLocker.TryLock(fixtures.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace)).ThenReturn(unlockFn, nil) + When(postWhWorkingDir.Clone(log, fixtures.GithubRepo, newPull, events.DefaultWorkspace)).ThenReturn(repoDir, false, nil) + When(whPostWorkflowHookRunner.Run(pCtx, testHook.RunCommand, repoDir)).ThenReturn(result, nil) + + err := postWh.RunPostHooks(ctx) + + Ok(t, err) + whPostWorkflowHookRunner.VerifyWasCalledOnce().Run(pCtx, testHook.RunCommand, repoDir) + Assert(t, *unlockCalled == true, "unlock function called") + }) + t.Run("success hooks not in cfg", func(t *testing.T) { + postWorkflowHooksSetup(t) + globalCfg := valid.GlobalCfg{ + Repos: []valid.Repo{ + // one with hooks but mismatched id + { + ID: "id1", + PostWorkflowHooks: []*valid.WorkflowHook{ + &testHook, + }, + }, + // one with the correct id but no hooks + { + ID: fixtures.GithubRepo.ID(), + PostWorkflowHooks: []*valid.WorkflowHook{}, + }, + }, + } + + postWh.GlobalCfg = globalCfg + + err := postWh.RunPostHooks(ctx) + + Ok(t, err) + + whPostWorkflowHookRunner.VerifyWasCalled(Never()).Run(pCtx, testHook.RunCommand, repoDir) + postWhWorkingDirLocker.VerifyWasCalled(Never()).TryLock(fixtures.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace) + postWhWorkingDir.VerifyWasCalled(Never()).Clone(log, fixtures.GithubRepo, newPull, events.DefaultWorkspace) + }) + t.Run("error locking work dir", func(t *testing.T) { + postWorkflowHooksSetup(t) + + globalCfg := valid.GlobalCfg{ + Repos: []valid.Repo{ + { + ID: fixtures.GithubRepo.ID(), + PostWorkflowHooks: []*valid.WorkflowHook{ + &testHook, + }, + }, + }, + } + + postWh.GlobalCfg = globalCfg + + When(postWhWorkingDirLocker.TryLock(fixtures.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace)).ThenReturn(func() {}, errors.New("some error")) + + err := postWh.RunPostHooks(ctx) + + Assert(t, err != nil, "error not nil") + postWhWorkingDir.VerifyWasCalled(Never()).Clone(log, fixtures.GithubRepo, newPull, events.DefaultWorkspace) + whPostWorkflowHookRunner.VerifyWasCalled(Never()).Run(pCtx, testHook.RunCommand, repoDir) + }) + + t.Run("error cloning", func(t *testing.T) { + postWorkflowHooksSetup(t) + + var unlockCalled *bool = newBool(false) + unlockFn := func() { + unlockCalled = newBool(true) + } + + globalCfg := valid.GlobalCfg{ + Repos: []valid.Repo{ + { + ID: fixtures.GithubRepo.ID(), + PostWorkflowHooks: []*valid.WorkflowHook{ + &testHook, + }, + }, + }, + } + + postWh.GlobalCfg = globalCfg + + When(postWhWorkingDirLocker.TryLock(fixtures.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace)).ThenReturn(unlockFn, nil) + When(postWhWorkingDir.Clone(log, fixtures.GithubRepo, newPull, events.DefaultWorkspace)).ThenReturn(repoDir, false, errors.New("some error")) + + err := postWh.RunPostHooks(ctx) + + Assert(t, err != nil, "error not nil") + + whPostWorkflowHookRunner.VerifyWasCalled(Never()).Run(pCtx, testHook.RunCommand, repoDir) + Assert(t, *unlockCalled == true, "unlock function called") + }) + + t.Run("error running post hook", func(t *testing.T) { + postWorkflowHooksSetup(t) + + var unlockCalled *bool = newBool(false) + unlockFn := func() { + unlockCalled = newBool(true) + } + + globalCfg := valid.GlobalCfg{ + Repos: []valid.Repo{ + { + ID: fixtures.GithubRepo.ID(), + PostWorkflowHooks: []*valid.WorkflowHook{ + &testHook, + }, + }, + }, + } + + postWh.GlobalCfg = globalCfg + + When(postWhWorkingDirLocker.TryLock(fixtures.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace)).ThenReturn(unlockFn, nil) + When(postWhWorkingDir.Clone(log, fixtures.GithubRepo, newPull, events.DefaultWorkspace)).ThenReturn(repoDir, false, nil) + When(whPostWorkflowHookRunner.Run(pCtx, testHook.RunCommand, repoDir)).ThenReturn(result, errors.New("some error")) + + err := postWh.RunPostHooks(ctx) + + Assert(t, err != nil, "error not nil") + Assert(t, *unlockCalled == true, "unlock function called") + }) +} diff --git a/server/events/pre_workflow_hooks_command_runner.go b/server/events/pre_workflow_hooks_command_runner.go index 7619fd9a82..44da8a88de 100644 --- a/server/events/pre_workflow_hooks_command_runner.go +++ b/server/events/pre_workflow_hooks_command_runner.go @@ -32,7 +32,7 @@ func (w *DefaultPreWorkflowHooksCommandRunner) RunPreHooks( user := ctx.User log := ctx.Log - preWorkflowHooks := make([]*valid.PreWorkflowHook, 0) + preWorkflowHooks := make([]*valid.WorkflowHook, 0) for _, repo := range w.GlobalCfg.Repos { if repo.IDMatches(baseRepo.ID()) && len(repo.PreWorkflowHooks) > 0 { preWorkflowHooks = append(preWorkflowHooks, repo.PreWorkflowHooks...) @@ -59,7 +59,7 @@ func (w *DefaultPreWorkflowHooksCommandRunner) RunPreHooks( } err = w.runHooks( - models.PreWorkflowHookCommandContext{ + models.WorkflowHookCommandContext{ BaseRepo: baseRepo, HeadRepo: headRepo, Log: log, @@ -77,8 +77,8 @@ func (w *DefaultPreWorkflowHooksCommandRunner) RunPreHooks( } func (w *DefaultPreWorkflowHooksCommandRunner) runHooks( - ctx models.PreWorkflowHookCommandContext, - preWorkflowHooks []*valid.PreWorkflowHook, + ctx models.WorkflowHookCommandContext, + preWorkflowHooks []*valid.WorkflowHook, repoDir string, ) error { diff --git a/server/events/pre_workflow_hooks_command_runner_test.go b/server/events/pre_workflow_hooks_command_runner_test.go index 3476e8446b..f027a03b84 100644 --- a/server/events/pre_workflow_hooks_command_runner_test.go +++ b/server/events/pre_workflow_hooks_command_runner_test.go @@ -16,22 +16,22 @@ import ( . "github.com/runatlantis/atlantis/testing" ) -var wh events.DefaultPreWorkflowHooksCommandRunner -var whWorkingDir *mocks.MockWorkingDir -var whWorkingDirLocker *mocks.MockWorkingDirLocker +var preWh events.DefaultPreWorkflowHooksCommandRunner +var preWhWorkingDir *mocks.MockWorkingDir +var preWhWorkingDirLocker *mocks.MockWorkingDirLocker var whPreWorkflowHookRunner *runtime_mocks.MockPreWorkflowHookRunner func preWorkflowHooksSetup(t *testing.T) { RegisterMockTestingT(t) vcsClient := vcsmocks.NewMockClient() - whWorkingDir = mocks.NewMockWorkingDir() - whWorkingDirLocker = mocks.NewMockWorkingDirLocker() + preWhWorkingDir = mocks.NewMockWorkingDir() + preWhWorkingDirLocker = mocks.NewMockWorkingDirLocker() whPreWorkflowHookRunner = runtime_mocks.NewMockPreWorkflowHookRunner() - wh = events.DefaultPreWorkflowHooksCommandRunner{ + preWh = events.DefaultPreWorkflowHooksCommandRunner{ VCSClient: vcsClient, - WorkingDirLocker: whWorkingDirLocker, - WorkingDir: whWorkingDir, + WorkingDirLocker: preWhWorkingDirLocker, + WorkingDir: preWhWorkingDir, PreWorkflowHookRunner: whPreWorkflowHookRunner, } } @@ -54,12 +54,12 @@ func TestRunPreHooks_Clone(t *testing.T) { Log: log, } - testHook := valid.PreWorkflowHook{ + testHook := valid.WorkflowHook{ StepName: "test", RunCommand: "some command", } - pCtx := models.PreWorkflowHookCommandContext{ + pCtx := models.WorkflowHookCommandContext{ BaseRepo: fixtures.GithubRepo, HeadRepo: fixtures.GithubRepo, Pull: newPull, @@ -83,20 +83,20 @@ func TestRunPreHooks_Clone(t *testing.T) { Repos: []valid.Repo{ { ID: fixtures.GithubRepo.ID(), - PreWorkflowHooks: []*valid.PreWorkflowHook{ + PreWorkflowHooks: []*valid.WorkflowHook{ &testHook, }, }, }, } - wh.GlobalCfg = globalCfg + preWh.GlobalCfg = globalCfg - When(whWorkingDirLocker.TryLock(fixtures.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace)).ThenReturn(unlockFn, nil) - When(whWorkingDir.Clone(log, fixtures.GithubRepo, newPull, events.DefaultWorkspace)).ThenReturn(repoDir, false, nil) + When(preWhWorkingDirLocker.TryLock(fixtures.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace)).ThenReturn(unlockFn, nil) + When(preWhWorkingDir.Clone(log, fixtures.GithubRepo, newPull, events.DefaultWorkspace)).ThenReturn(repoDir, false, nil) When(whPreWorkflowHookRunner.Run(pCtx, testHook.RunCommand, repoDir)).ThenReturn(result, nil) - err := wh.RunPreHooks(ctx) + err := preWh.RunPreHooks(ctx) Ok(t, err) whPreWorkflowHookRunner.VerifyWasCalledOnce().Run(pCtx, testHook.RunCommand, repoDir) @@ -109,27 +109,27 @@ func TestRunPreHooks_Clone(t *testing.T) { // one with hooks but mismatched id { ID: "id1", - PreWorkflowHooks: []*valid.PreWorkflowHook{ + PreWorkflowHooks: []*valid.WorkflowHook{ &testHook, }, }, // one with the correct id but no hooks { ID: fixtures.GithubRepo.ID(), - PreWorkflowHooks: []*valid.PreWorkflowHook{}, + PreWorkflowHooks: []*valid.WorkflowHook{}, }, }, } - wh.GlobalCfg = globalCfg + preWh.GlobalCfg = globalCfg - err := wh.RunPreHooks(ctx) + err := preWh.RunPreHooks(ctx) Ok(t, err) whPreWorkflowHookRunner.VerifyWasCalled(Never()).Run(pCtx, testHook.RunCommand, repoDir) - whWorkingDirLocker.VerifyWasCalled(Never()).TryLock(fixtures.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace) - whWorkingDir.VerifyWasCalled(Never()).Clone(log, fixtures.GithubRepo, newPull, events.DefaultWorkspace) + preWhWorkingDirLocker.VerifyWasCalled(Never()).TryLock(fixtures.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace) + preWhWorkingDir.VerifyWasCalled(Never()).Clone(log, fixtures.GithubRepo, newPull, events.DefaultWorkspace) }) t.Run("error locking work dir", func(t *testing.T) { preWorkflowHooksSetup(t) @@ -138,21 +138,21 @@ func TestRunPreHooks_Clone(t *testing.T) { Repos: []valid.Repo{ { ID: fixtures.GithubRepo.ID(), - PreWorkflowHooks: []*valid.PreWorkflowHook{ + PreWorkflowHooks: []*valid.WorkflowHook{ &testHook, }, }, }, } - wh.GlobalCfg = globalCfg + preWh.GlobalCfg = globalCfg - When(whWorkingDirLocker.TryLock(fixtures.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace)).ThenReturn(func() {}, errors.New("some error")) + When(preWhWorkingDirLocker.TryLock(fixtures.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace)).ThenReturn(func() {}, errors.New("some error")) - err := wh.RunPreHooks(ctx) + err := preWh.RunPreHooks(ctx) Assert(t, err != nil, "error not nil") - whWorkingDir.VerifyWasCalled(Never()).Clone(log, fixtures.GithubRepo, newPull, events.DefaultWorkspace) + preWhWorkingDir.VerifyWasCalled(Never()).Clone(log, fixtures.GithubRepo, newPull, events.DefaultWorkspace) whPreWorkflowHookRunner.VerifyWasCalled(Never()).Run(pCtx, testHook.RunCommand, repoDir) }) @@ -168,19 +168,19 @@ func TestRunPreHooks_Clone(t *testing.T) { Repos: []valid.Repo{ { ID: fixtures.GithubRepo.ID(), - PreWorkflowHooks: []*valid.PreWorkflowHook{ + PreWorkflowHooks: []*valid.WorkflowHook{ &testHook, }, }, }, } - wh.GlobalCfg = globalCfg + preWh.GlobalCfg = globalCfg - When(whWorkingDirLocker.TryLock(fixtures.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace)).ThenReturn(unlockFn, nil) - When(whWorkingDir.Clone(log, fixtures.GithubRepo, newPull, events.DefaultWorkspace)).ThenReturn(repoDir, false, errors.New("some error")) + When(preWhWorkingDirLocker.TryLock(fixtures.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace)).ThenReturn(unlockFn, nil) + When(preWhWorkingDir.Clone(log, fixtures.GithubRepo, newPull, events.DefaultWorkspace)).ThenReturn(repoDir, false, errors.New("some error")) - err := wh.RunPreHooks(ctx) + err := preWh.RunPreHooks(ctx) Assert(t, err != nil, "error not nil") @@ -200,20 +200,20 @@ func TestRunPreHooks_Clone(t *testing.T) { Repos: []valid.Repo{ { ID: fixtures.GithubRepo.ID(), - PreWorkflowHooks: []*valid.PreWorkflowHook{ + PreWorkflowHooks: []*valid.WorkflowHook{ &testHook, }, }, }, } - wh.GlobalCfg = globalCfg + preWh.GlobalCfg = globalCfg - When(whWorkingDirLocker.TryLock(fixtures.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace)).ThenReturn(unlockFn, nil) - When(whWorkingDir.Clone(log, fixtures.GithubRepo, newPull, events.DefaultWorkspace)).ThenReturn(repoDir, false, nil) + When(preWhWorkingDirLocker.TryLock(fixtures.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace)).ThenReturn(unlockFn, nil) + When(preWhWorkingDir.Clone(log, fixtures.GithubRepo, newPull, events.DefaultWorkspace)).ThenReturn(repoDir, false, nil) When(whPreWorkflowHookRunner.Run(pCtx, testHook.RunCommand, repoDir)).ThenReturn(result, errors.New("some error")) - err := wh.RunPreHooks(ctx) + err := preWh.RunPreHooks(ctx) Assert(t, err != nil, "error not nil") Assert(t, *unlockCalled == true, "unlock function called") diff --git a/server/events/yaml/parser_validator_test.go b/server/events/yaml/parser_validator_test.go index a58db826ef..876317a715 100644 --- a/server/events/yaml/parser_validator_test.go +++ b/server/events/yaml/parser_validator_test.go @@ -1131,11 +1131,17 @@ func TestParseGlobalCfg(t *testing.T) { } defaultCfg := valid.NewGlobalCfgFromArgs(globalCfgArgs) - preWorkflowHook := &valid.PreWorkflowHook{ + preWorkflowHook := &valid.WorkflowHook{ StepName: "run", RunCommand: "custom workflow command", } - preWorkflowHooks := []*valid.PreWorkflowHook{preWorkflowHook} + preWorkflowHooks := []*valid.WorkflowHook{preWorkflowHook} + + postWorkflowHook := &valid.WorkflowHook{ + StepName: "run", + RunCommand: "custom workflow command", + } + postWorkflowHooks := []*valid.WorkflowHook{postWorkflowHook} customWorkflow1 := valid.Workflow{ Name: "custom1", @@ -1306,12 +1312,16 @@ repos: pre_workflow_hooks: - run: custom workflow command workflow: custom1 + post_workflow_hooks: + - run: custom workflow command allowed_overrides: [apply_requirements, workflow, delete_source_branch_on_merge] allow_custom_workflows: true - id: /.*/ branch: /(master|main)/ pre_workflow_hooks: - run: custom workflow command + post_workflow_hooks: + - run: custom workflow command workflows: custom1: plan: @@ -1345,13 +1355,15 @@ policies: ApplyRequirements: []string{"approved", "mergeable"}, PreWorkflowHooks: preWorkflowHooks, Workflow: &customWorkflow1, + PostWorkflowHooks: postWorkflowHooks, AllowedOverrides: []string{"apply_requirements", "workflow", "delete_source_branch_on_merge"}, AllowCustomWorkflows: Bool(true), }, { - IDRegex: regexp.MustCompile(".*"), - BranchRegex: regexp.MustCompile("(master|main)"), - PreWorkflowHooks: preWorkflowHooks, + IDRegex: regexp.MustCompile(".*"), + BranchRegex: regexp.MustCompile("(master|main)"), + PreWorkflowHooks: preWorkflowHooks, + PostWorkflowHooks: postWorkflowHooks, }, }, Workflows: map[string]valid.Workflow{ diff --git a/server/events/yaml/raw/global_cfg.go b/server/events/yaml/raw/global_cfg.go index c64112e107..28c9d63ff0 100644 --- a/server/events/yaml/raw/global_cfg.go +++ b/server/events/yaml/raw/global_cfg.go @@ -19,15 +19,16 @@ type GlobalCfg struct { // Repo is the raw schema for repos in the server-side repo config. type Repo struct { - ID string `yaml:"id" json:"id"` - Branch string `yaml:"branch" json:"branch"` - ApplyRequirements []string `yaml:"apply_requirements" json:"apply_requirements"` - PreWorkflowHooks []PreWorkflowHook `yaml:"pre_workflow_hooks" json:"pre_workflow_hooks"` - Workflow *string `yaml:"workflow,omitempty" json:"workflow,omitempty"` - AllowedWorkflows []string `yaml:"allowed_workflows,omitempty" json:"allowed_workflows,omitempty"` - AllowedOverrides []string `yaml:"allowed_overrides" json:"allowed_overrides"` - AllowCustomWorkflows *bool `yaml:"allow_custom_workflows,omitempty" json:"allow_custom_workflows,omitempty"` - DeleteSourceBranchOnMerge *bool `yaml:"delete_source_branch_on_merge,omitempty" json:"delete_source_branch_on_merge,omitempty"` + ID string `yaml:"id" json:"id"` + Branch string `yaml:"branch" json:"branch"` + ApplyRequirements []string `yaml:"apply_requirements" json:"apply_requirements"` + PreWorkflowHooks []WorkflowHook `yaml:"pre_workflow_hooks" json:"pre_workflow_hooks"` + Workflow *string `yaml:"workflow,omitempty" json:"workflow,omitempty"` + PostWorkflowHooks []WorkflowHook `yaml:"post_workflow_hooks" json:"post_workflow_hooks"` + AllowedWorkflows []string `yaml:"allowed_workflows,omitempty" json:"allowed_workflows,omitempty"` + AllowedOverrides []string `yaml:"allowed_overrides" json:"allowed_overrides"` + AllowCustomWorkflows *bool `yaml:"allow_custom_workflows,omitempty" json:"allow_custom_workflows,omitempty"` + DeleteSourceBranchOnMerge *bool `yaml:"delete_source_branch_on_merge,omitempty" json:"delete_source_branch_on_merge,omitempty"` } func (g GlobalCfg) Validate() error { @@ -218,13 +219,20 @@ func (r Repo) ToValid(workflows map[string]valid.Workflow, globalApplyReqs []str workflow = &ptr } - var preWorkflowHooks []*valid.PreWorkflowHook + var preWorkflowHooks []*valid.WorkflowHook if len(r.PreWorkflowHooks) > 0 { for _, hook := range r.PreWorkflowHooks { preWorkflowHooks = append(preWorkflowHooks, hook.ToValid()) } } + var postWorkflowHooks []*valid.WorkflowHook + if len(r.PostWorkflowHooks) > 0 { + for _, hook := range r.PostWorkflowHooks { + postWorkflowHooks = append(postWorkflowHooks, hook.ToValid()) + } + } + var mergedApplyReqs []string mergedApplyReqs = append(mergedApplyReqs, r.ApplyRequirements...) @@ -247,6 +255,7 @@ OUTER: ApplyRequirements: mergedApplyReqs, PreWorkflowHooks: preWorkflowHooks, Workflow: workflow, + PostWorkflowHooks: postWorkflowHooks, AllowedWorkflows: r.AllowedWorkflows, AllowedOverrides: r.AllowedOverrides, AllowCustomWorkflows: r.AllowCustomWorkflows, diff --git a/server/events/yaml/raw/pre_workflow_step.go b/server/events/yaml/raw/workflow_step.go similarity index 76% rename from server/events/yaml/raw/pre_workflow_step.go rename to server/events/yaml/raw/workflow_step.go index 221d09207f..848234a848 100644 --- a/server/events/yaml/raw/pre_workflow_step.go +++ b/server/events/yaml/raw/workflow_step.go @@ -11,29 +11,29 @@ import ( "github.com/runatlantis/atlantis/server/events/yaml/valid" ) -// PreWorkflowHook represents a single action/command to perform. In YAML, +// WorkflowHook represents a single action/command to perform. In YAML, // it can be set as // A map for a custom run commands: // - run: my custom command -type PreWorkflowHook struct { +type WorkflowHook struct { StringVal map[string]string } -func (s *PreWorkflowHook) UnmarshalYAML(unmarshal func(interface{}) error) error { +func (s *WorkflowHook) UnmarshalYAML(unmarshal func(interface{}) error) error { return s.unmarshalGeneric(unmarshal) } -func (s PreWorkflowHook) MarshalYAML() (interface{}, error) { +func (s WorkflowHook) MarshalYAML() (interface{}, error) { return s.marshalGeneric() } -func (s *PreWorkflowHook) UnmarshalJSON(data []byte) error { +func (s *WorkflowHook) UnmarshalJSON(data []byte) error { return s.unmarshalGeneric(func(i interface{}) error { return json.Unmarshal(data, i) }) } -func (s *PreWorkflowHook) MarshalJSON() ([]byte, error) { +func (s *WorkflowHook) MarshalJSON() ([]byte, error) { out, err := s.marshalGeneric() if err != nil { return nil, err @@ -41,7 +41,7 @@ func (s *PreWorkflowHook) MarshalJSON() ([]byte, error) { return json.Marshal(out) } -func (s PreWorkflowHook) Validate() error { +func (s WorkflowHook) Validate() error { runStep := func(value interface{}) error { elem := value.(map[string]string) var keys []string @@ -69,13 +69,13 @@ func (s PreWorkflowHook) Validate() error { return errors.New("step element is empty") } -func (s PreWorkflowHook) ToValid() *valid.PreWorkflowHook { - // This will trigger in case #4 (see PreWorkflowHook docs). +func (s WorkflowHook) ToValid() *valid.WorkflowHook { + // This will trigger in case #4 (see WorkflowHook docs). if len(s.StringVal) > 0 { // After validation we assume there's only one key and it's a valid // step name so we just use the first one. for _, v := range s.StringVal { - return &valid.PreWorkflowHook{ + return &valid.WorkflowHook{ StepName: RunStepName, RunCommand: v, } @@ -89,7 +89,7 @@ func (s PreWorkflowHook) ToValid() *valid.PreWorkflowHook { // a step a custom run step: " - run: my custom command" // It takes a parameter unmarshal that is a function that tries to unmarshal // the current element into a given object. -func (s *PreWorkflowHook) unmarshalGeneric(unmarshal func(interface{}) error) error { +func (s *WorkflowHook) unmarshalGeneric(unmarshal func(interface{}) error) error { // Try to unmarshal as a custom run step, ex. // repo_config: // - run: my command @@ -104,7 +104,7 @@ func (s *PreWorkflowHook) unmarshalGeneric(unmarshal func(interface{}) error) er return err } -func (s PreWorkflowHook) marshalGeneric() (interface{}, error) { +func (s WorkflowHook) marshalGeneric() (interface{}, error) { if len(s.StringVal) != 0 { return s.StringVal, nil } diff --git a/server/events/yaml/raw/pre_workflow_step_test.go b/server/events/yaml/raw/workflow_step_test.go similarity index 82% rename from server/events/yaml/raw/pre_workflow_step_test.go rename to server/events/yaml/raw/workflow_step_test.go index babbc6a38f..9d69ed1df4 100644 --- a/server/events/yaml/raw/pre_workflow_step_test.go +++ b/server/events/yaml/raw/workflow_step_test.go @@ -9,11 +9,11 @@ import ( yaml "gopkg.in/yaml.v2" ) -func TestPreWorkflowHook_YAMLMarshalling(t *testing.T) { +func TestWorkflowHook_YAMLMarshalling(t *testing.T) { cases := []struct { description string input string - exp raw.PreWorkflowHook + exp raw.WorkflowHook expErr string }{ // Run-step style @@ -21,7 +21,7 @@ func TestPreWorkflowHook_YAMLMarshalling(t *testing.T) { description: "run step", input: ` run: my command`, - exp: raw.PreWorkflowHook{ + exp: raw.WorkflowHook{ StringVal: map[string]string{ "run": "my command", }, @@ -32,7 +32,7 @@ run: my command`, input: ` run: my command key: value`, - exp: raw.PreWorkflowHook{ + exp: raw.WorkflowHook{ StringVal: map[string]string{ "run": "my command", "key": "value", @@ -53,7 +53,7 @@ key: for _, c := range cases { t.Run(c.description, func(t *testing.T) { - var got raw.PreWorkflowHook + var got raw.WorkflowHook err := yaml.UnmarshalStrict([]byte(c.input), &got) if c.expErr != "" { ErrEquals(t, c.expErr, err) @@ -65,7 +65,7 @@ key: _, err = yaml.Marshal(got) Ok(t, err) - var got2 raw.PreWorkflowHook + var got2 raw.WorkflowHook err = yaml.UnmarshalStrict([]byte(c.input), &got2) Ok(t, err) Equals(t, got2, got) @@ -76,12 +76,12 @@ key: func TestGlobalConfigStep_Validate(t *testing.T) { cases := []struct { description string - input raw.PreWorkflowHook + input raw.WorkflowHook expErr string }{ { description: "run step", - input: raw.PreWorkflowHook{ + input: raw.WorkflowHook{ StringVal: map[string]string{ "run": "my command", }, @@ -90,7 +90,7 @@ func TestGlobalConfigStep_Validate(t *testing.T) { }, { description: "invalid key in string val", - input: raw.PreWorkflowHook{ + input: raw.WorkflowHook{ StringVal: map[string]string{ "invalid": "", }, @@ -101,7 +101,7 @@ func TestGlobalConfigStep_Validate(t *testing.T) { // For atlantis.yaml v2, this wouldn't parse, but now there should // be no error. description: "unparseable shell command", - input: raw.PreWorkflowHook{ + input: raw.WorkflowHook{ StringVal: map[string]string{ "run": "my 'c", }, @@ -120,20 +120,20 @@ func TestGlobalConfigStep_Validate(t *testing.T) { } } -func TestPreWorkflowHook_ToValid(t *testing.T) { +func TestWorkflowHook_ToValid(t *testing.T) { cases := []struct { description string - input raw.PreWorkflowHook - exp *valid.PreWorkflowHook + input raw.WorkflowHook + exp *valid.WorkflowHook }{ { description: "run step", - input: raw.PreWorkflowHook{ + input: raw.WorkflowHook{ StringVal: map[string]string{ "run": "my 'run command'", }, }, - exp: &valid.PreWorkflowHook{ + exp: &valid.WorkflowHook{ StepName: "run", RunCommand: "my 'run command'", }, diff --git a/server/events/yaml/valid/global_cfg.go b/server/events/yaml/valid/global_cfg.go index 6a630a8ca8..0518fe7fae 100644 --- a/server/events/yaml/valid/global_cfg.go +++ b/server/events/yaml/valid/global_cfg.go @@ -16,6 +16,7 @@ const PoliciesPassedApplyReq = "policies_passed" const ApplyRequirementsKey = "apply_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" @@ -46,8 +47,9 @@ type Repo struct { IDRegex *regexp.Regexp BranchRegex *regexp.Regexp ApplyRequirements []string - PreWorkflowHooks []*PreWorkflowHook + PreWorkflowHooks []*WorkflowHook Workflow *Workflow + PostWorkflowHooks []*WorkflowHook AllowedWorkflows []string AllowedOverrides []string AllowCustomWorkflows *bool @@ -69,8 +71,8 @@ type MergedProjectCfg struct { DeleteSourceBranchOnMerge bool } -// PreWorkflowHook is a map of custom run commands to run before workflows. -type PreWorkflowHook struct { +// WorkflowHook is a map of custom run commands to run before or after workflows. +type WorkflowHook struct { StepName string RunCommand string } @@ -109,13 +111,14 @@ var DefaultPlanStage = Stage{ } // Deprecated: use NewGlobalCfgFromArgs -func NewGlobalCfgWithHooks(allowRepoCfg bool, mergeableReq bool, approvedReq bool, unDivergedReq bool, preWorkflowHooks []*PreWorkflowHook) GlobalCfg { +func NewGlobalCfgWithHooks(allowRepoCfg bool, mergeableReq bool, approvedReq bool, unDivergedReq bool, preWorkflowHooks []*WorkflowHook, postWorkflowHooks []*WorkflowHook) GlobalCfg { return NewGlobalCfgFromArgs(GlobalCfgArgs{ - AllowRepoCfg: allowRepoCfg, - MergeableReq: mergeableReq, - ApprovedReq: approvedReq, - UnDivergedReq: unDivergedReq, - PreWorkflowHooks: preWorkflowHooks, + AllowRepoCfg: allowRepoCfg, + MergeableReq: mergeableReq, + ApprovedReq: approvedReq, + UnDivergedReq: unDivergedReq, + PreWorkflowHooks: preWorkflowHooks, + PostWorkflowHooks: postWorkflowHooks, }) } @@ -140,7 +143,8 @@ type GlobalCfgArgs struct { ApprovedReq bool UnDivergedReq bool PolicyCheckEnabled bool - PreWorkflowHooks []*PreWorkflowHook + PreWorkflowHooks []*WorkflowHook + PostWorkflowHooks []*WorkflowHook } func NewGlobalCfgFromArgs(args GlobalCfgArgs) GlobalCfg { @@ -184,6 +188,7 @@ func NewGlobalCfgFromArgs(args GlobalCfgArgs) GlobalCfg { ApplyRequirements: applyReqs, PreWorkflowHooks: args.PreWorkflowHooks, Workflow: &defaultWorkflow, + PostWorkflowHooks: args.PostWorkflowHooks, AllowedWorkflows: allowedWorkflows, AllowedOverrides: allowedOverrides, AllowCustomWorkflows: &allowCustomWorkflows, diff --git a/server/server.go b/server/server.go index 5c4516c713..49319433c3 100644 --- a/server/server.go +++ b/server/server.go @@ -81,28 +81,29 @@ const ( // Server runs the Atlantis web server. type Server struct { - AtlantisVersion string - AtlantisURL *url.URL - Router *mux.Router - Port int - PreWorkflowHooksCommandRunner *events.DefaultPreWorkflowHooksCommandRunner - CommandRunner *events.DefaultCommandRunner - Logger logging.SimpleLogging - Locker locking.Locker - ApplyLocker locking.ApplyLocker - VCSEventsController *events_controllers.VCSEventsController - GithubAppController *controllers.GithubAppController - LocksController *controllers.LocksController - StatusController *controllers.StatusController - JobsController *controllers.JobsController - IndexTemplate templates.TemplateWriter - LockDetailTemplate templates.TemplateWriter - ProjectJobsTemplate templates.TemplateWriter - ProjectJobsErrorTemplate templates.TemplateWriter - SSLCertFile string - SSLKeyFile string - Drainer *events.Drainer - ProjectCmdOutputHandler handlers.ProjectCommandOutputHandler + AtlantisVersion string + AtlantisURL *url.URL + Router *mux.Router + Port int + PostWorkflowHooksCommandRunner *events.DefaultPostWorkflowHooksCommandRunner + PreWorkflowHooksCommandRunner *events.DefaultPreWorkflowHooksCommandRunner + CommandRunner *events.DefaultCommandRunner + Logger logging.SimpleLogging + Locker locking.Locker + ApplyLocker locking.ApplyLocker + VCSEventsController *events_controllers.VCSEventsController + GithubAppController *controllers.GithubAppController + LocksController *controllers.LocksController + StatusController *controllers.StatusController + JobsController *controllers.JobsController + IndexTemplate templates.TemplateWriter + LockDetailTemplate templates.TemplateWriter + ProjectJobsTemplate templates.TemplateWriter + ProjectJobsErrorTemplate templates.TemplateWriter + SSLCertFile string + SSLKeyFile string + Drainer *events.Drainer + ProjectCmdOutputHandler handlers.ProjectCommandOutputHandler } // Config holds config for server that isn't passed in by the user. @@ -460,6 +461,13 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { WorkingDir: workingDir, PreWorkflowHookRunner: runtime.DefaultPreWorkflowHookRunner{}, } + postWorkflowHooksCommandRunner := &events.DefaultPostWorkflowHooksCommandRunner{ + VCSClient: vcsClient, + GlobalCfg: globalCfg, + WorkingDirLocker: workingDirLocker, + WorkingDir: workingDir, + PostWorkflowHookRunner: runtime.DefaultPostWorkflowHookRunner{}, + } projectCommandBuilder := events.NewProjectCommandBuilder( policyChecksEnabled, validator, @@ -632,23 +640,24 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { } commandRunner := &events.DefaultCommandRunner{ - VCSClient: vcsClient, - GithubPullGetter: githubClient, - GitlabMergeRequestGetter: gitlabClient, - AzureDevopsPullGetter: azuredevopsClient, - CommentCommandRunnerByCmd: commentCommandRunnerByCmd, - EventParser: eventParser, - Logger: logger, - GlobalCfg: globalCfg, - AllowForkPRs: userConfig.AllowForkPRs, - AllowForkPRsFlag: config.AllowForkPRsFlag, - SilenceForkPRErrors: userConfig.SilenceForkPRErrors, - SilenceForkPRErrorsFlag: config.SilenceForkPRErrorsFlag, - DisableAutoplan: userConfig.DisableAutoplan, - Drainer: drainer, - PreWorkflowHooksCommandRunner: preWorkflowHooksCommandRunner, - PullStatusFetcher: boltdb, - TeamAllowlistChecker: githubTeamAllowlistChecker, + VCSClient: vcsClient, + GithubPullGetter: githubClient, + GitlabMergeRequestGetter: gitlabClient, + AzureDevopsPullGetter: azuredevopsClient, + CommentCommandRunnerByCmd: commentCommandRunnerByCmd, + EventParser: eventParser, + Logger: logger, + GlobalCfg: globalCfg, + AllowForkPRs: userConfig.AllowForkPRs, + AllowForkPRsFlag: config.AllowForkPRsFlag, + SilenceForkPRErrors: userConfig.SilenceForkPRErrors, + SilenceForkPRErrorsFlag: config.SilenceForkPRErrorsFlag, + DisableAutoplan: userConfig.DisableAutoplan, + Drainer: drainer, + PreWorkflowHooksCommandRunner: preWorkflowHooksCommandRunner, + PostWorkflowHooksCommandRunner: postWorkflowHooksCommandRunner, + PullStatusFetcher: boltdb, + TeamAllowlistChecker: githubTeamAllowlistChecker, } repoAllowlist, err := events.NewRepoAllowlistChecker(userConfig.RepoAllowlist) if err != nil { @@ -712,28 +721,29 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { GithubOrg: userConfig.GithubOrg, } return &Server{ - AtlantisVersion: config.AtlantisVersion, - AtlantisURL: parsedURL, - Router: underlyingRouter, - Port: userConfig.Port, - PreWorkflowHooksCommandRunner: preWorkflowHooksCommandRunner, - CommandRunner: commandRunner, - Logger: logger, - Locker: lockingClient, - ApplyLocker: applyLockingClient, - VCSEventsController: eventsController, - GithubAppController: githubAppController, - LocksController: locksController, - JobsController: jobsController, - StatusController: statusController, - IndexTemplate: templates.IndexTemplate, - LockDetailTemplate: templates.LockTemplate, - ProjectJobsTemplate: templates.ProjectJobsTemplate, - ProjectJobsErrorTemplate: templates.ProjectJobsErrorTemplate, - SSLKeyFile: userConfig.SSLKeyFile, - SSLCertFile: userConfig.SSLCertFile, - Drainer: drainer, - ProjectCmdOutputHandler: projectCmdOutputHandler, + AtlantisVersion: config.AtlantisVersion, + AtlantisURL: parsedURL, + Router: underlyingRouter, + Port: userConfig.Port, + PostWorkflowHooksCommandRunner: postWorkflowHooksCommandRunner, + PreWorkflowHooksCommandRunner: preWorkflowHooksCommandRunner, + CommandRunner: commandRunner, + Logger: logger, + Locker: lockingClient, + ApplyLocker: applyLockingClient, + VCSEventsController: eventsController, + GithubAppController: githubAppController, + LocksController: locksController, + JobsController: jobsController, + StatusController: statusController, + IndexTemplate: templates.IndexTemplate, + LockDetailTemplate: templates.LockTemplate, + ProjectJobsTemplate: templates.ProjectJobsTemplate, + ProjectJobsErrorTemplate: templates.ProjectJobsErrorTemplate, + SSLKeyFile: userConfig.SSLKeyFile, + SSLCertFile: userConfig.SSLCertFile, + Drainer: drainer, + ProjectCmdOutputHandler: projectCmdOutputHandler, }, nil }