diff --git a/go.sum b/go.sum index 4c4dcd5b98..62fa09146d 100644 --- a/go.sum +++ b/go.sum @@ -411,6 +411,7 @@ golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAG golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190402181905-9f3314589c9a/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7OVsmBpC8L5BlwK1whH3hm0= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20191122200657-5d9234df094c h1:HjRaKPaiWks0f5tA6ELVF7ZfqSppfPwOEEAvsrKUTO4= golang.org/x/oauth2 v0.0.0-20191122200657-5d9234df094c/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= diff --git a/runatlantis.io/.vuepress/config.js b/runatlantis.io/.vuepress/config.js index cd1f76488d..9730c990cc 100644 --- a/runatlantis.io/.vuepress/config.js +++ b/runatlantis.io/.vuepress/config.js @@ -64,6 +64,7 @@ module.exports = { ['configuring-atlantis', 'Overview'], 'server-configuration', 'server-side-repo-config', + 'pre-workflow-hooks', 'custom-workflows', 'repo-level-atlantis-yaml', 'upgrading-atlantis-yaml', diff --git a/runatlantis.io/docs/pre-workflow-hooks.md b/runatlantis.io/docs/pre-workflow-hooks.md new file mode 100644 index 0000000000..61765aa50e --- /dev/null +++ b/runatlantis.io/docs/pre-workflow-hooks.md @@ -0,0 +1,52 @@ +# Pre Workflow Hooks + +Pre workflow hooks can be defined to run scripts right before default or custom +workflows are executed. + +[[toc]] + +## Usage +Pre workflow hooks can only be specified in the Server-Side Repo Config under +`repos` key. +::: tip Note +`pre-workflow-hooks` do not prevent Atlantis from executing its +workflows(`plan`, `apply`) even if a `run` command exits with an error. +::: + +## Use Cases +### Dynamic Repo Config Generation +If you want generate your `atlantis.yaml` before Atlantis can parse it. You +can add a `run` command to `pre_workflow_hooks`. Your Repo config will be generated +right before Atlantis can parse it. + +```yaml +repos: + - id: /.*/ + pre_workflow_hooks: + - run: ./repo-config-genarator.sh +``` +### 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) + * `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/server-side-repo-config.md b/runatlantis.io/docs/server-side-repo-config.md index 7133cb0a57..76c6c8bd62 100644 --- a/runatlantis.io/docs/server-side-repo-config.md +++ b/runatlantis.io/docs/server-side-repo-config.md @@ -18,7 +18,7 @@ If you don't wish to write a config file to disk, you can use the `--repo-config-json` flag or `ATLANTIS_REPO_CONFIG_JSON` environment variable to specify your config as JSON. See [--repo-config-json](server-configuration.html#repo-config-json) for an example. - + ## Example Server Side Repo ```yaml # repos lists the config for specific repos. @@ -48,6 +48,10 @@ repos: # workflows. If false (default), the repo can only use server-side defined # workflows. allow_custom_workflows: true + + # pre_workflow_hooks defines arbitrary list of scripts to execute before workflow execution. + pre_workflow_hooks: + - run: my-pre-workflow-hook-command arg1 # id can also be an exact match. - id: github.com/myorg/specific-repo @@ -153,6 +157,21 @@ projects: apply_requirements: [] ``` +### Running Scripts Before Atlantis Workflows +If you want to run scripts that would execute before Atlantis can run default or +custom workflows, you can create a `pre-workflow-hooks`: + +```yaml +repos: + - id: /.*/ + pre_workflow_hooks: + - run: my custom command + - run: | + my bash script inline +``` +See [Pre Workflow Hooks](pre-workflow-hooks.html) for more details on writing +pre 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/events/mocks/mock_pre_workflows_hooks_command_runner.go b/server/events/mocks/mock_pre_workflows_hooks_command_runner.go new file mode 100644 index 0000000000..3019cc1a79 --- /dev/null +++ b/server/events/mocks/mock_pre_workflows_hooks_command_runner.go @@ -0,0 +1,112 @@ +// Code generated by pegomock. DO NOT EDIT. +// Source: github.com/runatlantis/atlantis/server/events (interfaces: PreWorkflowHooksCommandRunner) + +package mocks + +import ( + pegomock "github.com/petergtz/pegomock" + models "github.com/runatlantis/atlantis/server/events/models" + "reflect" + "time" +) + +type MockPreWorkflowHooksCommandRunner struct { + fail func(message string, callerSkip ...int) +} + +func NewMockPreWorkflowHooksCommandRunner(options ...pegomock.Option) *MockPreWorkflowHooksCommandRunner { + mock := &MockPreWorkflowHooksCommandRunner{} + for _, option := range options { + option.Apply(mock) + } + return mock +} + +func (mock *MockPreWorkflowHooksCommandRunner) SetFailHandler(fh pegomock.FailHandler) { + mock.fail = fh +} +func (mock *MockPreWorkflowHooksCommandRunner) FailHandler() pegomock.FailHandler { return mock.fail } + +func (mock *MockPreWorkflowHooksCommandRunner) RunPreHooks(baseRepo models.Repo, headRepo models.Repo, pull models.PullRequest, user models.User) { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockPreWorkflowHooksCommandRunner().") + } + params := []pegomock.Param{baseRepo, headRepo, pull, user} + pegomock.GetGenericMockFrom(mock).Invoke("RunPreHooks", params, []reflect.Type{}) +} + +func (mock *MockPreWorkflowHooksCommandRunner) VerifyWasCalledOnce() *VerifierMockPreWorkflowHooksCommandRunner { + return &VerifierMockPreWorkflowHooksCommandRunner{ + mock: mock, + invocationCountMatcher: pegomock.Times(1), + } +} + +func (mock *MockPreWorkflowHooksCommandRunner) VerifyWasCalled(invocationCountMatcher pegomock.Matcher) *VerifierMockPreWorkflowHooksCommandRunner { + return &VerifierMockPreWorkflowHooksCommandRunner{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + } +} + +func (mock *MockPreWorkflowHooksCommandRunner) VerifyWasCalledInOrder(invocationCountMatcher pegomock.Matcher, inOrderContext *pegomock.InOrderContext) *VerifierMockPreWorkflowHooksCommandRunner { + return &VerifierMockPreWorkflowHooksCommandRunner{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + inOrderContext: inOrderContext, + } +} + +func (mock *MockPreWorkflowHooksCommandRunner) VerifyWasCalledEventually(invocationCountMatcher pegomock.Matcher, timeout time.Duration) *VerifierMockPreWorkflowHooksCommandRunner { + return &VerifierMockPreWorkflowHooksCommandRunner{ + mock: mock, + invocationCountMatcher: invocationCountMatcher, + timeout: timeout, + } +} + +type VerifierMockPreWorkflowHooksCommandRunner struct { + mock *MockPreWorkflowHooksCommandRunner + invocationCountMatcher pegomock.Matcher + inOrderContext *pegomock.InOrderContext + timeout time.Duration +} + +func (verifier *VerifierMockPreWorkflowHooksCommandRunner) RunPreHooks(baseRepo models.Repo, headRepo models.Repo, pull models.PullRequest, user models.User) *MockPreWorkflowHooksCommandRunner_RunPreHooks_OngoingVerification { + params := []pegomock.Param{baseRepo, headRepo, pull, user} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "RunPreHooks", params, verifier.timeout) + return &MockPreWorkflowHooksCommandRunner_RunPreHooks_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockPreWorkflowHooksCommandRunner_RunPreHooks_OngoingVerification struct { + mock *MockPreWorkflowHooksCommandRunner + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockPreWorkflowHooksCommandRunner_RunPreHooks_OngoingVerification) GetCapturedArguments() (models.Repo, models.Repo, models.PullRequest, models.User) { + baseRepo, headRepo, pull, user := c.GetAllCapturedArguments() + return baseRepo[len(baseRepo)-1], headRepo[len(headRepo)-1], pull[len(pull)-1], user[len(user)-1] +} + +func (c *MockPreWorkflowHooksCommandRunner_RunPreHooks_OngoingVerification) GetAllCapturedArguments() (_param0 []models.Repo, _param1 []models.Repo, _param2 []models.PullRequest, _param3 []models.User) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]models.Repo, len(c.methodInvocations)) + for u, param := range params[0] { + _param0[u] = param.(models.Repo) + } + _param1 = make([]models.Repo, len(c.methodInvocations)) + for u, param := range params[1] { + _param1[u] = param.(models.Repo) + } + _param2 = make([]models.PullRequest, len(c.methodInvocations)) + for u, param := range params[2] { + _param2[u] = param.(models.PullRequest) + } + _param3 = make([]models.User, len(c.methodInvocations)) + for u, param := range params[3] { + _param3[u] = param.(models.User) + } + } + return +} diff --git a/server/events/models/models.go b/server/events/models/models.go index 533f8097db..f54d086692 100644 --- a/server/events/models/models.go +++ b/server/events/models/models.go @@ -535,3 +535,22 @@ func (c CommandName) String() string { } return "" } + +// PreWorkflowHookCommandContext defines the context for a pre_worklfow_hooks that will +// be executed before workflows. +type PreWorkflowHookCommandContext struct { + // BaseRepo is the repository that the pull request will be merged into. + BaseRepo Repo + // HeadRepo is the repository that is getting merged into the BaseRepo. + // If the pull request branch is from the same repository then HeadRepo will + // be the same as BaseRepo. + HeadRepo Repo + // Log is a logger that's been set up for this context. + Log logging.SimpleLogging + // Pull is the pull request we're responding to. + Pull PullRequest + // User is the user that triggered this command. + User User + // Verbose is true when the user would like verbose output. + Verbose bool +} diff --git a/server/events/pre_workflow_hooks_command_runner.go b/server/events/pre_workflow_hooks_command_runner.go new file mode 100644 index 0000000000..739359b408 --- /dev/null +++ b/server/events/pre_workflow_hooks_command_runner.go @@ -0,0 +1,129 @@ +package events + +import ( + "fmt" + + "github.com/runatlantis/atlantis/server/events/models" + "github.com/runatlantis/atlantis/server/events/runtime" + "github.com/runatlantis/atlantis/server/events/vcs" + "github.com/runatlantis/atlantis/server/events/yaml/valid" + "github.com/runatlantis/atlantis/server/logging" + "github.com/runatlantis/atlantis/server/recovery" +) + +//go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_pre_workflows_hooks_command_runner.go PreWorkflowHooksCommandRunner + +type PreWorkflowHooksCommandRunner interface { + RunPreHooks( + baseRepo models.Repo, + headRepo models.Repo, + pull models.PullRequest, + user models.User, + ) +} + +// DefaultPreWorkflowHooksCommandRunner is the first step when processing a workflow hook commands. +type DefaultPreWorkflowHooksCommandRunner struct { + VCSClient vcs.Client + Logger logging.SimpleLogging + WorkingDirLocker WorkingDirLocker + WorkingDir WorkingDir + GlobalCfg valid.GlobalCfg + Drainer *Drainer + PreWorkflowHookRunner *runtime.PreWorkflowHookRunner +} + +// RunPreHooks runs pre_workflow_hooks when PR is opened or updated. +func (w *DefaultPreWorkflowHooksCommandRunner) RunPreHooks( + baseRepo models.Repo, + headRepo models.Repo, + pull models.PullRequest, + user models.User, +) { + if opStarted := w.Drainer.StartOp(); !opStarted { + if commentErr := w.VCSClient.CreateComment(baseRepo, pull.Num, ShutdownComment, "pre_workflow_hooks"); commentErr != nil { + w.Logger.Log(logging.Error, "unable to comment that Atlantis is shutting down: %s", commentErr) + } + return + } + defer w.Drainer.OpDone() + + log := w.buildLogger(baseRepo.FullName, pull.Num) + defer w.logPanics(baseRepo, pull.Num, log) + + log.Info("running pre hooks") + + unlockFn, err := w.WorkingDirLocker.TryLock(baseRepo.FullName, pull.Num, DefaultWorkspace) + if err != nil { + log.Warn("workspace is locked") + return + } + log.Debug("got workspace lock") + defer unlockFn() + + repoDir, _, err := w.WorkingDir.Clone(log, headRepo, pull, DefaultWorkspace) + if err != nil { + log.Err("unable to run pre workflow hooks: %s", err) + return + } + + preWorkflowHooks := make([]*valid.PreWorkflowHook, 0) + for _, repo := range w.GlobalCfg.Repos { + if repo.IDMatches(baseRepo.ID()) && len(repo.PreWorkflowHooks) > 0 { + preWorkflowHooks = append(preWorkflowHooks, repo.PreWorkflowHooks...) + } + } + + ctx := models.PreWorkflowHookCommandContext{ + BaseRepo: baseRepo, + HeadRepo: headRepo, + Log: log, + Pull: pull, + User: user, + Verbose: false, + } + + err = w.runHooks(ctx, preWorkflowHooks, repoDir) + + if err != nil { + log.Err("pre workflow hook run error results: %s", err) + } +} + +func (w *DefaultPreWorkflowHooksCommandRunner) runHooks( + ctx models.PreWorkflowHookCommandContext, + preWorkflowHooks []*valid.PreWorkflowHook, + repoDir string, +) error { + + for _, hook := range preWorkflowHooks { + _, err := w.PreWorkflowHookRunner.Run(ctx, hook.RunCommand, repoDir) + + if err != nil { + return nil + } + } + + return nil +} + +func (w *DefaultPreWorkflowHooksCommandRunner) buildLogger(repoFullName string, pullNum int) *logging.SimpleLogger { + src := fmt.Sprintf("%s#%d", repoFullName, pullNum) + return w.Logger.NewLogger(src, true, w.Logger.GetLevel()) +} + +// logPanics logs and creates a comment on the pull request for panics. +func (w *DefaultPreWorkflowHooksCommandRunner) logPanics(baseRepo models.Repo, pullNum int, logger logging.SimpleLogging) { + if err := recover(); err != nil { + stack := recovery.Stack(3) + logger.Err("PANIC: %s\n%s", err, stack) + if commentErr := w.VCSClient.CreateComment( + baseRepo, + pullNum, + fmt.Sprintf("**Error: goroutine panic. This is a bug.**\n```\n%s\n%s```", err, stack), + "", + ); commentErr != nil { + logger.Err("unable to comment: %s", commentErr) + } + } +} diff --git a/server/events/pre_workflow_hooks_command_runner_test.go b/server/events/pre_workflow_hooks_command_runner_test.go new file mode 100644 index 0000000000..e8290359c9 --- /dev/null +++ b/server/events/pre_workflow_hooks_command_runner_test.go @@ -0,0 +1,87 @@ +package events_test + +import ( + "fmt" + "strings" + "testing" + + . "github.com/petergtz/pegomock" + "github.com/runatlantis/atlantis/server/events" + "github.com/runatlantis/atlantis/server/events/matchers" + "github.com/runatlantis/atlantis/server/events/mocks" + "github.com/runatlantis/atlantis/server/events/models/fixtures" + "github.com/runatlantis/atlantis/server/events/runtime" + vcsmocks "github.com/runatlantis/atlantis/server/events/vcs/mocks" + "github.com/runatlantis/atlantis/server/logging" + logmocks "github.com/runatlantis/atlantis/server/logging/mocks" + . "github.com/runatlantis/atlantis/testing" +) + +var wh events.DefaultPreWorkflowHooksCommandRunner +var whWorkingDir *mocks.MockWorkingDir +var whWorkingDirLocker *mocks.MockWorkingDirLocker +var whDrainer *events.Drainer + +func preWorkflowHooksSetup(t *testing.T) *vcsmocks.MockClient { + RegisterMockTestingT(t) + vcsClient := vcsmocks.NewMockClient() + logger := logmocks.NewMockSimpleLogging() + whWorkingDir = mocks.NewMockWorkingDir() + whWorkingDirLocker = mocks.NewMockWorkingDirLocker() + whDrainer = &events.Drainer{} + + wh = events.DefaultPreWorkflowHooksCommandRunner{ + VCSClient: vcsClient, + Logger: logger, + WorkingDirLocker: whWorkingDirLocker, + WorkingDir: whWorkingDir, + Drainer: whDrainer, + PreWorkflowHookRunner: &runtime.PreWorkflowHookRunner{}, + } + return vcsClient +} + +func TestPreWorkflowHooksCommand_LogPanics(t *testing.T) { + t.Log("if there is a panic it is commented back on the pull request") + vcsClient := preWorkflowHooksSetup(t) + logger := wh.Logger.NewLogger("log", false, logging.LogLevel(1)) + + When(whWorkingDir.Clone( + logger, + fixtures.GithubRepo, + fixtures.Pull, + events.DefaultWorkspace, + )).ThenPanic("panic test - if you're seeing this in a test failure this isn't the failing test") + + wh.RunPreHooks(fixtures.GithubRepo, fixtures.GithubRepo, fixtures.Pull, fixtures.User) + _, _, comment, _ := vcsClient.VerifyWasCalledOnce().CreateComment(matchers.AnyModelsRepo(), AnyInt(), AnyString(), AnyString()).GetCapturedArguments() + Assert(t, strings.Contains(comment, "Error: goroutine panic"), fmt.Sprintf("comment should be about a goroutine panic but was %q", comment)) +} + +// Test that if one plan fails and we are using automerge, that +// we delete the plans. +func TestRunPreHooks_Clone(t *testing.T) { + preWorkflowHooksSetup(t) + logger := wh.Logger.NewLogger("log", false, logging.LogLevel(1)) + + When(whWorkingDir.Clone(logger, fixtures.GithubRepo, fixtures.Pull, events.DefaultWorkspace)). + ThenReturn("path/to/repo", false, nil) + wh.RunPreHooks(fixtures.GithubRepo, fixtures.GithubRepo, fixtures.Pull, fixtures.User) +} + +func TestRunPreHooks_DrainOngoing(t *testing.T) { + t.Log("if drain is ongoing then a message should be displayed") + vcsClient := preWorkflowHooksSetup(t) + whDrainer.ShutdownBlocking() + wh.RunPreHooks(fixtures.GithubRepo, fixtures.GithubRepo, fixtures.Pull, fixtures.User) + vcsClient.VerifyWasCalledOnce().CreateComment(fixtures.GithubRepo, fixtures.Pull.Num, "Atlantis server is shutting down, please try again later.", "pre_workflow_hooks") +} + +func TestRunPreHooks_DrainNotOngoing(t *testing.T) { + t.Log("if drain is not ongoing then remove ongoing operation must be called even if panic occured") + preWorkflowHooksSetup(t) + When(whWorkingDir.Clone(logger, fixtures.GithubRepo, fixtures.Pull, events.DefaultWorkspace)).ThenPanic("panic test - if you're seeing this in a test failure this isn't the failing test") + wh.RunPreHooks(fixtures.GithubRepo, fixtures.GithubRepo, fixtures.Pull, fixtures.User) + whWorkingDir.VerifyWasCalledOnce().Clone(logger, fixtures.GithubRepo, fixtures.Pull, events.DefaultWorkspace) + Equals(t, 0, whDrainer.GetStatus().InProgressOps) +} diff --git a/server/events/project_command_builder.go b/server/events/project_command_builder.go index 391987fbda..9341ce8acc 100644 --- a/server/events/project_command_builder.go +++ b/server/events/project_command_builder.go @@ -151,6 +151,7 @@ func (p *DefaultProjectCommandBuilder) buildPlanAllCommands(ctx *CommandContext, if err != nil { return nil, err } + // Parse config file if it exists. hasRepoCfg, err := p.ParserValidator.HasRepoCfg(repoDir) if err != nil { diff --git a/server/events/runtime/pre_workflow_hook_runner.go b/server/events/runtime/pre_workflow_hook_runner.go new file mode 100644 index 0000000000..fbe6ee0007 --- /dev/null +++ b/server/events/runtime/pre_workflow_hook_runner.go @@ -0,0 +1,46 @@ +package runtime + +import ( + "fmt" + "os" + "os/exec" + + "github.com/runatlantis/atlantis/server/events/models" +) + +type PreWorkflowHookRunner struct{} + +func (wh *PreWorkflowHookRunner) Run(ctx models.PreWorkflowHookCommandContext, 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_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/events/runtime/pre_workflow_hook_runner_test.go b/server/events/runtime/pre_workflow_hook_runner_test.go new file mode 100644 index 0000000000..19f59b9994 --- /dev/null +++ b/server/events/runtime/pre_workflow_hook_runner_test.go @@ -0,0 +1,110 @@ +package runtime_test + +import ( + "strings" + "testing" + + . "github.com/petergtz/pegomock" + "github.com/runatlantis/atlantis/server/events/mocks/matchers" + "github.com/runatlantis/atlantis/server/events/models" + "github.com/runatlantis/atlantis/server/events/runtime" + "github.com/runatlantis/atlantis/server/events/terraform/mocks" + matchers2 "github.com/runatlantis/atlantis/server/events/terraform/mocks/matchers" + "github.com/runatlantis/atlantis/server/logging" + . "github.com/runatlantis/atlantis/testing" +) + +func TestPreWorkflowHookRunner_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 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 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() + + r := runtime.PreWorkflowHookRunner{} + t.Run(c.Command, func(t *testing.T) { + tmpDir, cleanup := TempDir(t) + defer cleanup() + ctx := models.PreWorkflowHookCommandContext{ + BaseRepo: models.Repo{ + Name: "basename", + Owner: "baseowner", + }, + HeadRepo: models.Repo{ + Name: "headname", + Owner: "headowner", + }, + Pull: models.PullRequest{ + Num: 2, + HeadBranch: "add-feat", + 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/events/yaml/parser_validator_test.go b/server/events/yaml/parser_validator_test.go index 27310d20a5..9339481e32 100644 --- a/server/events/yaml/parser_validator_test.go +++ b/server/events/yaml/parser_validator_test.go @@ -885,6 +885,12 @@ func TestParseGlobalCfg_NotExist(t *testing.T) { func TestParseGlobalCfg(t *testing.T) { defaultCfg := valid.NewGlobalCfg(false, false, false) + preWorkflowHook := &valid.PreWorkflowHook{ + StepName: "run", + RunCommand: "custom workflow command", + } + preWorkflowHooks := []*valid.PreWorkflowHook{preWorkflowHook} + customWorkflow1 := valid.Workflow{ Name: "custom1", Plan: valid.Stage{ @@ -1023,12 +1029,16 @@ workflows: input: ` repos: - id: github.com/owner/repo + apply_requirements: [approved, mergeable] + pre_workflow_hooks: + - run: custom workflow command workflow: custom1 allowed_overrides: [apply_requirements, workflow] allow_custom_workflows: true - id: /.*/ - + pre_workflow_hooks: + - run: custom workflow command workflows: custom1: plan: @@ -1048,12 +1058,14 @@ workflows: { ID: "github.com/owner/repo", ApplyRequirements: []string{"approved", "mergeable"}, + PreWorkflowHooks: preWorkflowHooks, Workflow: &customWorkflow1, AllowedOverrides: []string{"apply_requirements", "workflow"}, AllowCustomWorkflows: Bool(true), }, { - IDRegex: regexp.MustCompile(".*"), + IDRegex: regexp.MustCompile(".*"), + PreWorkflowHooks: preWorkflowHooks, }, }, Workflows: map[string]valid.Workflow{ @@ -1071,7 +1083,8 @@ repos: Repos: []valid.Repo{ defaultCfg.Repos[0], { - IDRegex: regexp.MustCompile("github.com/"), + IDRegex: regexp.MustCompile("github.com/"), + PreWorkflowHooks: []*valid.PreWorkflowHook{}, }, }, Workflows: map[string]valid.Workflow{ @@ -1089,8 +1102,9 @@ repos: Repos: []valid.Repo{ defaultCfg.Repos[0], { - ID: "github.com/owner/repo", - Workflow: defaultCfg.Repos[0].Workflow, + ID: "github.com/owner/repo", + PreWorkflowHooks: []*valid.PreWorkflowHook{}, + Workflow: defaultCfg.Repos[0].Workflow, }, }, Workflows: map[string]valid.Workflow{ @@ -1112,6 +1126,7 @@ workflows: Repos: []valid.Repo{ { IDRegex: regexp.MustCompile(".*"), + PreWorkflowHooks: []*valid.PreWorkflowHook{}, ApplyRequirements: []string{}, Workflow: &valid.Workflow{ Name: "default", @@ -1160,6 +1175,7 @@ workflows: Ok(t, ioutil.WriteFile(path, []byte(c.input), 0600)) act, err := r.ParseGlobalCfg(path, valid.NewGlobalCfg(false, false, false)) + if c.expErr != "" { expErr := strings.Replace(c.expErr, "", path, -1) ErrEquals(t, expErr, err) @@ -1261,6 +1277,7 @@ func TestParserValidator_ParseGlobalCfgJSON(t *testing.T) { { IDRegex: regexp.MustCompile(".*"), ApplyRequirements: []string{"mergeable", "approved"}, + PreWorkflowHooks: []*valid.PreWorkflowHook{}, Workflow: &customWorkflow, AllowedWorkflows: []string{"custom"}, AllowedOverrides: []string{"workflow", "apply_requirements"}, @@ -1269,7 +1286,7 @@ func TestParserValidator_ParseGlobalCfgJSON(t *testing.T) { { ID: "github.com/owner/repo", IDRegex: nil, - AllowedWorkflows: nil, + PreWorkflowHooks: []*valid.PreWorkflowHook{}, ApplyRequirements: nil, AllowedOverrides: nil, AllowCustomWorkflows: nil, diff --git a/server/events/yaml/raw/global_cfg.go b/server/events/yaml/raw/global_cfg.go index dc2fbd9be0..f53cdbcd98 100644 --- a/server/events/yaml/raw/global_cfg.go +++ b/server/events/yaml/raw/global_cfg.go @@ -18,12 +18,13 @@ 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"` - ApplyRequirements []string `yaml:"apply_requirements" json:"apply_requirements"` - 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"` + ID string `yaml:"id" json:"id"` + 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"` } func (g GlobalCfg) Validate() error { @@ -170,10 +171,18 @@ func (r Repo) ToValid(workflows map[string]valid.Workflow) valid.Repo { workflow = &ptr } + preWorkflowHooks := make([]*valid.PreWorkflowHook, 0) + if len(r.PreWorkflowHooks) > 0 { + for _, hook := range r.PreWorkflowHooks { + preWorkflowHooks = append(preWorkflowHooks, hook.ToValid()) + } + } + return valid.Repo{ ID: id, IDRegex: idRegex, ApplyRequirements: r.ApplyRequirements, + PreWorkflowHooks: preWorkflowHooks, Workflow: workflow, AllowedWorkflows: r.AllowedWorkflows, AllowedOverrides: r.AllowedOverrides, diff --git a/server/events/yaml/raw/pre_workflow_step.go b/server/events/yaml/raw/pre_workflow_step.go new file mode 100644 index 0000000000..221d09207f --- /dev/null +++ b/server/events/yaml/raw/pre_workflow_step.go @@ -0,0 +1,115 @@ +package raw + +import ( + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + + validation "github.com/go-ozzo/ozzo-validation" + "github.com/runatlantis/atlantis/server/events/yaml/valid" +) + +// PreWorkflowHook 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 { + StringVal map[string]string +} + +func (s *PreWorkflowHook) UnmarshalYAML(unmarshal func(interface{}) error) error { + return s.unmarshalGeneric(unmarshal) +} + +func (s PreWorkflowHook) MarshalYAML() (interface{}, error) { + return s.marshalGeneric() +} + +func (s *PreWorkflowHook) UnmarshalJSON(data []byte) error { + return s.unmarshalGeneric(func(i interface{}) error { + return json.Unmarshal(data, i) + }) +} + +func (s *PreWorkflowHook) MarshalJSON() ([]byte, error) { + out, err := s.marshalGeneric() + if err != nil { + return nil, err + } + return json.Marshal(out) +} + +func (s PreWorkflowHook) Validate() error { + runStep := func(value interface{}) error { + elem := value.(map[string]string) + var keys []string + for k := range elem { + keys = append(keys, k) + } + // Sort so tests can be deterministic. + sort.Strings(keys) + + if len(keys) > 1 { + return fmt.Errorf("step element can only contain a single key, found %d: %s", + len(keys), strings.Join(keys, ",")) + } + for stepName := range elem { + if stepName != RunStepName { + return fmt.Errorf("%q is not a valid step type", stepName) + } + } + return nil + } + + if len(s.StringVal) > 0 { + return validation.Validate(s.StringVal, validation.By(runStep)) + } + return errors.New("step element is empty") +} + +func (s PreWorkflowHook) ToValid() *valid.PreWorkflowHook { + // This will trigger in case #4 (see PreWorkflowHook 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{ + StepName: RunStepName, + RunCommand: v, + } + } + } + + panic("step was not valid. This is a bug!") +} + +// unmarshalGeneric is used by UnmarshalJSON and UnmarshalYAML to unmarshal +// 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 { + // Try to unmarshal as a custom run step, ex. + // repo_config: + // - run: my command + // We validate if the key is run later. + var runStep map[string]string + err := unmarshal(&runStep) + if err == nil { + s.StringVal = runStep + return nil + } + + return err +} + +func (s PreWorkflowHook) marshalGeneric() (interface{}, error) { + if len(s.StringVal) != 0 { + return s.StringVal, nil + } + + // empty step should be marshalled to null, although this is generally + // unexpected behavior. + return nil, nil +} diff --git a/server/events/yaml/raw/pre_workflow_step_test.go b/server/events/yaml/raw/pre_workflow_step_test.go new file mode 100644 index 0000000000..babbc6a38f --- /dev/null +++ b/server/events/yaml/raw/pre_workflow_step_test.go @@ -0,0 +1,147 @@ +package raw_test + +import ( + "testing" + + "github.com/runatlantis/atlantis/server/events/yaml/raw" + "github.com/runatlantis/atlantis/server/events/yaml/valid" + . "github.com/runatlantis/atlantis/testing" + yaml "gopkg.in/yaml.v2" +) + +func TestPreWorkflowHook_YAMLMarshalling(t *testing.T) { + cases := []struct { + description string + input string + exp raw.PreWorkflowHook + expErr string + }{ + // Run-step style + { + description: "run step", + input: ` +run: my command`, + exp: raw.PreWorkflowHook{ + StringVal: map[string]string{ + "run": "my command", + }, + }, + }, + { + description: "run step multiple top-level keys", + input: ` +run: my command +key: value`, + exp: raw.PreWorkflowHook{ + StringVal: map[string]string{ + "run": "my command", + "key": "value", + }, + }, + }, + + // Errors + { + description: "extra args style no slice strings", + input: ` +key: + value: + another: map`, + expErr: "yaml: unmarshal errors:\n line 3: cannot unmarshal !!map into string", + }, + } + + for _, c := range cases { + t.Run(c.description, func(t *testing.T) { + var got raw.PreWorkflowHook + err := yaml.UnmarshalStrict([]byte(c.input), &got) + if c.expErr != "" { + ErrEquals(t, c.expErr, err) + return + } + Ok(t, err) + Equals(t, c.exp, got) + + _, err = yaml.Marshal(got) + Ok(t, err) + + var got2 raw.PreWorkflowHook + err = yaml.UnmarshalStrict([]byte(c.input), &got2) + Ok(t, err) + Equals(t, got2, got) + }) + } +} + +func TestGlobalConfigStep_Validate(t *testing.T) { + cases := []struct { + description string + input raw.PreWorkflowHook + expErr string + }{ + { + description: "run step", + input: raw.PreWorkflowHook{ + StringVal: map[string]string{ + "run": "my command", + }, + }, + expErr: "", + }, + { + description: "invalid key in string val", + input: raw.PreWorkflowHook{ + StringVal: map[string]string{ + "invalid": "", + }, + }, + expErr: "\"invalid\" is not a valid step type", + }, + { + // For atlantis.yaml v2, this wouldn't parse, but now there should + // be no error. + description: "unparseable shell command", + input: raw.PreWorkflowHook{ + StringVal: map[string]string{ + "run": "my 'c", + }, + }, + }, + } + for _, c := range cases { + t.Run(c.description, func(t *testing.T) { + err := c.input.Validate() + if c.expErr == "" { + Ok(t, err) + return + } + ErrEquals(t, c.expErr, err) + }) + } +} + +func TestPreWorkflowHook_ToValid(t *testing.T) { + cases := []struct { + description string + input raw.PreWorkflowHook + exp *valid.PreWorkflowHook + }{ + { + description: "run step", + input: raw.PreWorkflowHook{ + StringVal: map[string]string{ + "run": "my 'run command'", + }, + }, + exp: &valid.PreWorkflowHook{ + StepName: "run", + RunCommand: "my 'run command'", + }, + }, + } + for _, c := range cases { + t.Run(c.description, func(t *testing.T) { + Equals(t, c.exp, c.input.ToValid()) + }) + } +} diff --git a/server/events/yaml/valid/global_cfg.go b/server/events/yaml/valid/global_cfg.go index 00e8f4ec06..b019556b5e 100644 --- a/server/events/yaml/valid/global_cfg.go +++ b/server/events/yaml/valid/global_cfg.go @@ -12,6 +12,7 @@ import ( const MergeableApplyReq = "mergeable" const ApprovedApplyReq = "approved" const ApplyRequirementsKey = "apply_requirements" +const PreWorkflowHooksKey = "pre_workflow_hooks" const WorkflowKey = "workflow" const AllowedWorkflowsKey = "allowed_workflows" const AllowedOverridesKey = "allowed_overrides" @@ -33,6 +34,7 @@ type Repo struct { // If ID is set then this will be nil. IDRegex *regexp.Regexp ApplyRequirements []string + PreWorkflowHooks []*PreWorkflowHook Workflow *Workflow AllowedWorkflows []string AllowedOverrides []string @@ -51,6 +53,12 @@ type MergedProjectCfg struct { RepoCfgVersion int } +// PreWorkflowHook is a map of custom run commands to run before workflows. +type PreWorkflowHook struct { + StepName string + RunCommand string +} + // DefaultApplyStage is the Atlantis default apply stage. var DefaultApplyStage = Stage{ Steps: []Step{ @@ -89,6 +97,7 @@ func NewGlobalCfg(allowRepoCfg bool, mergeableReq bool, approvedReq bool) Global applyReqs := []string{} allowedOverrides := []string{} allowedWorkflows := []string{} + preWorkflowHooks := make([]*PreWorkflowHook, 0) if mergeableReq { applyReqs = append(applyReqs, MergeableApplyReq) } @@ -107,6 +116,7 @@ func NewGlobalCfg(allowRepoCfg bool, mergeableReq bool, approvedReq bool) Global { IDRegex: regexp.MustCompile(".*"), ApplyRequirements: applyReqs, + PreWorkflowHooks: preWorkflowHooks, Workflow: &defaultWorkflow, AllowedWorkflows: allowedWorkflows, AllowedOverrides: allowedOverrides, @@ -318,7 +328,7 @@ func (g GlobalCfg) getMatchingCfg(log logging.SimpleLogging, repoID string) (app return fmt.Sprintf("setting %s: %s from %s", key, valStr, from) } - for _, key := range []string{ApplyRequirementsKey, WorkflowKey, AllowedOverridesKey, AllowCustomWorkflowsKey} { + for _, key := range []string{ApplyRequirementsKey, WorkflowKey, AllowedOverridesKey, AllowCustomWorkflowsKey, PreWorkflowHooksKey} { for i, repo := range g.Repos { if repo.IDMatches(repoID) { switch key { diff --git a/server/events/yaml/valid/global_cfg_test.go b/server/events/yaml/valid/global_cfg_test.go index e96a0c3d24..61f39bdd65 100644 --- a/server/events/yaml/valid/global_cfg_test.go +++ b/server/events/yaml/valid/global_cfg_test.go @@ -108,6 +108,10 @@ func TestNewGlobalCfg(t *testing.T) { if c.approvedReq { exp.Repos[0].ApplyRequirements = append(exp.Repos[0].ApplyRequirements, "approved") } + if exp.Repos[0].PreWorkflowHooks == nil { + exp.Repos[0].PreWorkflowHooks = []*valid.PreWorkflowHook{} + } + Equals(t, exp, act) // Have to hand-compare regexes because Equals doesn't do it. diff --git a/server/events_controller.go b/server/events_controller.go index de08599083..5dca5efaee 100644 --- a/server/events_controller.go +++ b/server/events_controller.go @@ -45,12 +45,13 @@ const bitbucketServerSignatureHeader = "X-Hub-Signature" // EventsController handles all webhook requests which signify 'events' in the // VCS host, ex. GitHub. type EventsController struct { - CommandRunner events.CommandRunner - PullCleaner events.PullCleaner - Logger *logging.SimpleLogger - Parser events.EventParsing - CommentParser events.CommentParsing - ApplyDisabled bool + PreWorkflowHooksCommandRunner events.PreWorkflowHooksCommandRunner + CommandRunner events.CommandRunner + PullCleaner events.PullCleaner + Logger *logging.SimpleLogger + Parser events.EventParsing + CommentParser events.CommentParsing + ApplyDisabled bool // GithubWebhookSecret is the secret added to this webhook via the GitHub // UI that identifies this call as coming from GitHub. If empty, no // request validation is done. @@ -343,6 +344,9 @@ func (e *EventsController) handlePullRequestEvent(w http.ResponseWriter, baseRep // closed. fmt.Fprintln(w, "Processing...") + e.Logger.Info("running pre workflow hooks if present") + e.PreWorkflowHooksCommandRunner.RunPreHooks(baseRepo, headRepo, pull, user) + e.Logger.Info("executing autoplan") if !e.TestingMode { go e.CommandRunner.RunAutoplanCommand(baseRepo, headRepo, pull, user) diff --git a/server/events_controller_e2e_test.go b/server/events_controller_e2e_test.go index d3f51d9735..2a396cc8d9 100644 --- a/server/events_controller_e2e_test.go +++ b/server/events_controller_e2e_test.go @@ -442,6 +442,15 @@ func setupE2E(t *testing.T, repoDir string) (server.EventsController, *vcsmocks. Ok(t, err) } drainer := &events.Drainer{} + preWorkflowHooksCommandRunner := &events.DefaultPreWorkflowHooksCommandRunner{ + VCSClient: e2eVCSClient, + GlobalCfg: globalCfg, + Logger: logger, + WorkingDirLocker: locker, + WorkingDir: workingDir, + Drainer: drainer, + PreWorkflowHookRunner: &runtime.PreWorkflowHookRunner{}, + } commandRunner := &events.DefaultCommandRunner{ ProjectCommandRunner: &events.DefaultProjectCommandRunner{ Locker: projectLocker, @@ -497,8 +506,9 @@ func setupE2E(t *testing.T, repoDir string) (server.EventsController, *vcsmocks. Ok(t, err) ctrl := server.EventsController{ - TestingMode: true, - CommandRunner: commandRunner, + TestingMode: true, + PreWorkflowHooksCommandRunner: preWorkflowHooksCommandRunner, + CommandRunner: commandRunner, PullCleaner: &events.PullClosedExecutor{ Locker: lockingClient, VCSClient: e2eVCSClient, diff --git a/server/events_controller_test.go b/server/events_controller_test.go index fb50c102d8..ebfa917ad7 100644 --- a/server/events_controller_test.go +++ b/server/events_controller_test.go @@ -45,7 +45,7 @@ var secret = []byte("secret") func TestPost_NotGithubOrGitlab(t *testing.T) { t.Log("when the request is not for gitlab or github a 400 is returned") - e, _, _, _, _, _, _, _ := setup(t) + e, _, _, _, _, _, _, _, _ := setup(t) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) e.Post(w, req) @@ -54,7 +54,7 @@ func TestPost_NotGithubOrGitlab(t *testing.T) { func TestPost_UnsupportedVCSGithub(t *testing.T) { t.Log("when the request is for an unsupported vcs a 400 is returned") - e, _, _, _, _, _, _, _ := setup(t) + e, _, _, _, _, _, _, _, _ := setup(t) e.SupportedVCSHosts = nil req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(githubHeader, "value") @@ -65,7 +65,7 @@ func TestPost_UnsupportedVCSGithub(t *testing.T) { func TestPost_UnsupportedVCSGitlab(t *testing.T) { t.Log("when the request is for an unsupported vcs a 400 is returned") - e, _, _, _, _, _, _, _ := setup(t) + e, _, _, _, _, _, _, _, _ := setup(t) e.SupportedVCSHosts = nil req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(gitlabHeader, "value") @@ -76,7 +76,7 @@ func TestPost_UnsupportedVCSGitlab(t *testing.T) { func TestPost_InvalidGithubSecret(t *testing.T) { t.Log("when the github payload can't be validated a 400 is returned") - e, v, _, _, _, _, _, _ := setup(t) + e, v, _, _, _, _, _, _, _ := setup(t) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(githubHeader, "value") @@ -87,7 +87,7 @@ func TestPost_InvalidGithubSecret(t *testing.T) { func TestPost_InvalidGitlabSecret(t *testing.T) { t.Log("when the gitlab payload can't be validated a 400 is returned") - e, _, gl, _, _, _, _, _ := setup(t) + e, _, gl, _, _, _, _, _, _ := setup(t) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(gitlabHeader, "value") @@ -98,7 +98,7 @@ func TestPost_InvalidGitlabSecret(t *testing.T) { func TestPost_UnsupportedGithubEvent(t *testing.T) { t.Log("when the event type is an unsupported github event we ignore it") - e, v, _, _, _, _, _, _ := setup(t) + e, v, _, _, _, _, _, _, _ := setup(t) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(githubHeader, "value") @@ -109,7 +109,7 @@ func TestPost_UnsupportedGithubEvent(t *testing.T) { func TestPost_UnsupportedGitlabEvent(t *testing.T) { t.Log("when the event type is an unsupported gitlab event we ignore it") - e, _, gl, _, _, _, _, _ := setup(t) + e, _, gl, _, _, _, _, _, _ := setup(t) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(gitlabHeader, "value") @@ -121,7 +121,7 @@ func TestPost_UnsupportedGitlabEvent(t *testing.T) { // Test that if the comment comes from a commit rather than a merge request, // we give an error and ignore it. func TestPost_GitlabCommentOnCommit(t *testing.T) { - e, _, gl, _, _, _, _, _ := setup(t) + e, _, gl, _, _, _, _, _, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) w := httptest.NewRecorder() req.Header.Set(gitlabHeader, "value") @@ -132,7 +132,7 @@ func TestPost_GitlabCommentOnCommit(t *testing.T) { func TestPost_GithubCommentNotCreated(t *testing.T) { t.Log("when the event is a github comment but it's not a created event we ignore it") - e, v, _, _, _, _, _, _ := setup(t) + e, v, _, _, _, _, _, _, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(githubHeader, "issue_comment") // comment action is deleted, not created @@ -145,7 +145,7 @@ func TestPost_GithubCommentNotCreated(t *testing.T) { func TestPost_GithubInvalidComment(t *testing.T) { t.Log("when the event is a github comment without all expected data we return a 400") - e, v, _, p, _, _, _, _ := setup(t) + e, v, _, p, _, _, _, _, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(githubHeader, "issue_comment") event := `{"action": "created"}` @@ -158,7 +158,7 @@ func TestPost_GithubInvalidComment(t *testing.T) { func TestPost_GitlabCommentInvalidCommand(t *testing.T) { t.Log("when the event is a gitlab comment with an invalid command we ignore it") - e, _, gl, _, _, _, _, cp := setup(t) + e, _, gl, _, _, _, _, _, cp := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(gitlabHeader, "value") When(gl.ParseAndValidate(req, secret)).ThenReturn(gitlab.MergeCommentEvent{}, nil) @@ -170,7 +170,7 @@ func TestPost_GitlabCommentInvalidCommand(t *testing.T) { func TestPost_GithubCommentInvalidCommand(t *testing.T) { t.Log("when the event is a github comment with an invalid command we ignore it") - e, v, _, p, _, _, _, cp := setup(t) + e, v, _, p, _, _, _, _, cp := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(githubHeader, "issue_comment") event := `{"action": "created"}` @@ -299,7 +299,7 @@ func TestPost_GithubCommentNotAllowlistedWithSilenceErrors(t *testing.T) { func TestPost_GitlabCommentResponse(t *testing.T) { // When the event is a gitlab comment that warrants a comment response we comment back. - e, _, gl, _, _, _, vcsClient, cp := setup(t) + e, _, gl, _, _, _, _, vcsClient, cp := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(gitlabHeader, "value") When(gl.ParseAndValidate(req, secret)).ThenReturn(gitlab.MergeCommentEvent{}, nil) @@ -312,7 +312,7 @@ func TestPost_GitlabCommentResponse(t *testing.T) { func TestPost_GithubCommentResponse(t *testing.T) { t.Log("when the event is a github comment that warrants a comment response we comment back") - e, v, _, p, _, _, vcsClient, cp := setup(t) + e, v, _, p, _, _, _, vcsClient, cp := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(githubHeader, "issue_comment") event := `{"action": "created"}` @@ -330,7 +330,7 @@ func TestPost_GithubCommentResponse(t *testing.T) { func TestPost_GitlabCommentSuccess(t *testing.T) { t.Log("when the event is a gitlab comment with a valid command we call the command handler") - e, _, gl, _, cr, _, _, _ := setup(t) + e, _, gl, _, cr, _, _, _, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(gitlabHeader, "value") When(gl.ParseAndValidate(req, secret)).ThenReturn(gitlab.MergeCommentEvent{}, nil) @@ -343,7 +343,7 @@ func TestPost_GitlabCommentSuccess(t *testing.T) { func TestPost_GithubCommentSuccess(t *testing.T) { t.Log("when the event is a github comment with a valid command we call the command handler") - e, v, _, p, cr, _, _, cp := setup(t) + e, v, _, p, cr, _, _, _, cp := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(githubHeader, "issue_comment") event := `{"action": "created"}` @@ -362,7 +362,7 @@ func TestPost_GithubCommentSuccess(t *testing.T) { func TestPost_GithubPullRequestInvalid(t *testing.T) { t.Log("when the event is a github pull request with invalid data we return a 400") - e, v, _, p, _, _, _, _ := setup(t) + e, v, _, p, _, _, _, _, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(githubHeader, "pull_request") @@ -376,7 +376,7 @@ func TestPost_GithubPullRequestInvalid(t *testing.T) { func TestPost_GitlabMergeRequestInvalid(t *testing.T) { t.Log("when the event is a gitlab merge request with invalid data we return a 400") - e, _, gl, p, _, _, _, _ := setup(t) + e, _, gl, p, _, _, _, _, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(gitlabHeader, "value") When(gl.ParseAndValidate(req, secret)).ThenReturn(gitlab.MergeEvent{}, nil) @@ -390,7 +390,7 @@ func TestPost_GitlabMergeRequestInvalid(t *testing.T) { func TestPost_GithubPullRequestNotAllowlisted(t *testing.T) { t.Log("when the event is a github pull request to a non-allowlisted repo we return a 400") - e, v, _, _, _, _, _, _ := setup(t) + e, v, _, _, _, _, _, _, _ := setup(t) var err error e.RepoAllowlistChecker, err = events.NewRepoAllowlistChecker("github.com/nevermatch") Ok(t, err) @@ -406,7 +406,7 @@ func TestPost_GithubPullRequestNotAllowlisted(t *testing.T) { func TestPost_GitlabMergeRequestNotAllowlisted(t *testing.T) { t.Log("when the event is a gitlab merge request to a non-allowlisted repo we return a 400") - e, _, gl, p, _, _, _, _ := setup(t) + e, _, gl, p, _, _, _, _, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(gitlabHeader, "value") @@ -425,7 +425,7 @@ func TestPost_GitlabMergeRequestNotAllowlisted(t *testing.T) { func TestPost_GithubPullRequestUnsupportedAction(t *testing.T) { t.Skip("relies too much on mocks, should use real event parser") - e, v, _, _, _, _, _, _ := setup(t) + e, v, _, _, _, _, _, _, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(githubHeader, "pull_request") @@ -440,7 +440,7 @@ func TestPost_GithubPullRequestUnsupportedAction(t *testing.T) { func TestPost_GitlabMergeRequestUnsupportedAction(t *testing.T) { t.Skip("relies too much on mocks, should use real event parser") t.Log("when the event is a gitlab merge request to a non-allowlisted repo we return a 400") - e, _, gl, p, _, _, _, _ := setup(t) + e, _, gl, p, _, _, _, _, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(gitlabHeader, "value") var event gitlab.MergeEvent @@ -537,7 +537,7 @@ func TestPost_GithubPullRequestClosedErrCleaningPull(t *testing.T) { t.Skip("relies too much on mocks, should use real event parser") t.Log("when the event is a closed pull request and we have an error calling CleanUpPull we return a 503") RegisterMockTestingT(t) - e, v, _, p, _, c, _, _ := setup(t) + e, v, _, p, _, _, c, _, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(githubHeader, "pull_request") @@ -555,7 +555,7 @@ func TestPost_GithubPullRequestClosedErrCleaningPull(t *testing.T) { func TestPost_GitlabMergeRequestClosedErrCleaningPull(t *testing.T) { t.Skip("relies too much on mocks, should use real event parser") t.Log("when the event is a closed gitlab merge request and an error occurs calling CleanUpPull we return a 500") - e, _, gl, p, _, c, _, _ := setup(t) + e, _, gl, p, _, _, c, _, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(gitlabHeader, "value") var event gitlab.MergeEvent @@ -573,7 +573,7 @@ func TestPost_GitlabMergeRequestClosedErrCleaningPull(t *testing.T) { func TestPost_GithubClosedPullRequestSuccess(t *testing.T) { t.Skip("relies too much on mocks, should use real event parser") t.Log("when the event is a pull request and everything works we return a 200") - e, v, _, p, _, c, _, _ := setup(t) + e, v, _, p, _, _, c, _, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(githubHeader, "pull_request") @@ -591,7 +591,7 @@ func TestPost_GithubClosedPullRequestSuccess(t *testing.T) { func TestPost_GitlabMergeRequestSuccess(t *testing.T) { t.Skip("relies too much on mocks, should use real event parser") t.Log("when the event is a gitlab merge request and the cleanup works we return a 200") - e, _, gl, p, _, _, _, _ := setup(t) + e, _, gl, p, _, _, _, _, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(gitlabHeader, "value") When(gl.ParseAndValidate(req, secret)).ThenReturn(gitlab.MergeEvent{}, nil) @@ -710,58 +710,65 @@ func TestPost_PullOpenedOrUpdated(t *testing.T) { for _, c := range cases { t.Run(c.Description, func(t *testing.T) { - e, v, gl, p, cr, _, _, _ := setup(t) + e, v, gl, p, cr, wh, _, _, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) + var pullRequest models.PullRequest + var repo models.Repo + switch c.HostType { case models.Gitlab: req.Header.Set(gitlabHeader, "value") var event gitlab.MergeEvent event.ObjectAttributes.Action = c.Action When(gl.ParseAndValidate(req, secret)).ThenReturn(event, nil) - repo := models.Repo{} - pullRequest := models.PullRequest{State: models.ClosedPullState} + repo = models.Repo{} + pullRequest = models.PullRequest{State: models.ClosedPullState} When(p.ParseGitlabMergeRequestEvent(event)).ThenReturn(pullRequest, models.OpenedPullEvent, repo, repo, models.User{}, nil) case models.Github: req.Header.Set(githubHeader, "pull_request") event := fmt.Sprintf(`{"action": "%s"}`, c.Action) When(v.Validate(req, secret)).ThenReturn([]byte(event), nil) - repo := models.Repo{} - pull := models.PullRequest{State: models.ClosedPullState} - When(p.ParseGithubPullEvent(matchers.AnyPtrToGithubPullRequestEvent())).ThenReturn(pull, models.OpenedPullEvent, repo, repo, models.User{}, nil) + repo = models.Repo{} + pullRequest = models.PullRequest{State: models.ClosedPullState} + When(p.ParseGithubPullEvent(matchers.AnyPtrToGithubPullRequestEvent())).ThenReturn(pullRequest, models.OpenedPullEvent, repo, repo, models.User{}, nil) } + w := httptest.NewRecorder() e.Post(w, req) responseContains(t, w, http.StatusOK, "Processing...") + wh.VerifyWasCalledOnce().RunPreHooks(models.Repo{}, models.Repo{}, models.PullRequest{State: models.ClosedPullState}, models.User{}) cr.VerifyWasCalledOnce().RunAutoplanCommand(models.Repo{}, models.Repo{}, models.PullRequest{State: models.ClosedPullState}, models.User{}) }) } } -func setup(t *testing.T) (server.EventsController, *mocks.MockGithubRequestValidator, *mocks.MockGitlabRequestParserValidator, *emocks.MockEventParsing, *emocks.MockCommandRunner, *emocks.MockPullCleaner, *vcsmocks.MockClient, *emocks.MockCommentParsing) { +func setup(t *testing.T) (server.EventsController, *mocks.MockGithubRequestValidator, *mocks.MockGitlabRequestParserValidator, *emocks.MockEventParsing, *emocks.MockCommandRunner, *emocks.MockPreWorkflowHooksCommandRunner, *emocks.MockPullCleaner, *vcsmocks.MockClient, *emocks.MockCommentParsing) { RegisterMockTestingT(t) v := mocks.NewMockGithubRequestValidator() gl := mocks.NewMockGitlabRequestParserValidator() p := emocks.NewMockEventParsing() cp := emocks.NewMockCommentParsing() + wh := emocks.NewMockPreWorkflowHooksCommandRunner() cr := emocks.NewMockCommandRunner() c := emocks.NewMockPullCleaner() vcsmock := vcsmocks.NewMockClient() repoAllowlistChecker, err := events.NewRepoAllowlistChecker("*") Ok(t, err) e := server.EventsController{ - TestingMode: true, - Logger: logging.NewNoopLogger(), - GithubRequestValidator: v, - Parser: p, - CommentParser: cp, - CommandRunner: cr, - PullCleaner: c, - GithubWebhookSecret: secret, - SupportedVCSHosts: []models.VCSHostType{models.Github, models.Gitlab}, - GitlabWebhookSecret: secret, - GitlabRequestParserValidator: gl, - RepoAllowlistChecker: repoAllowlistChecker, - VCSClient: vcsmock, + TestingMode: true, + Logger: logging.NewNoopLogger(), + GithubRequestValidator: v, + Parser: p, + CommentParser: cp, + CommandRunner: cr, + PreWorkflowHooksCommandRunner: wh, + PullCleaner: c, + GithubWebhookSecret: secret, + SupportedVCSHosts: []models.VCSHostType{models.Github, models.Gitlab}, + GitlabWebhookSecret: secret, + GitlabRequestParserValidator: gl, + RepoAllowlistChecker: repoAllowlistChecker, + VCSClient: vcsmock, } - return e, v, gl, p, cr, c, vcsmock, cp + return e, v, gl, p, cr, wh, c, vcsmock, cp } diff --git a/server/server.go b/server/server.go index 5e8ee13c2d..a53fb8a8ae 100644 --- a/server/server.go +++ b/server/server.go @@ -66,22 +66,23 @@ const ( // Server runs the Atlantis web server. type Server struct { - AtlantisVersion string - AtlantisURL *url.URL - Router *mux.Router - Port int - CommandRunner *events.DefaultCommandRunner - Logger *logging.SimpleLogger - Locker locking.Locker - EventsController *EventsController - GithubAppController *GithubAppController - LocksController *LocksController - StatusController *StatusController - IndexTemplate TemplateWriter - LockDetailTemplate TemplateWriter - SSLCertFile string - SSLKeyFile string - Drainer *events.Drainer + AtlantisVersion string + AtlantisURL *url.URL + Router *mux.Router + Port int + PreWorkflowHooksCommandRunner *events.DefaultPreWorkflowHooksCommandRunner + CommandRunner *events.DefaultCommandRunner + Logger *logging.SimpleLogger + Locker locking.Locker + EventsController *EventsController + GithubAppController *GithubAppController + LocksController *LocksController + StatusController *StatusController + IndexTemplate TemplateWriter + LockDetailTemplate TemplateWriter + SSLCertFile string + SSLKeyFile string + Drainer *events.Drainer } // Config holds config for server that isn't passed in by the user. @@ -355,6 +356,15 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { Logger: logger, Drainer: drainer, } + preWorkflowHooksCommandRunner := &events.DefaultPreWorkflowHooksCommandRunner{ + VCSClient: vcsClient, + GlobalCfg: globalCfg, + Logger: logger, + WorkingDirLocker: workingDirLocker, + WorkingDir: workingDir, + Drainer: drainer, + PreWorkflowHookRunner: &runtime.PreWorkflowHookRunner{}, + } commandRunner := &events.DefaultCommandRunner{ VCSClient: vcsClient, GithubPullGetter: githubClient, @@ -436,6 +446,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { DeleteLockCommand: deleteLockCommand, } eventsController := &EventsController{ + PreWorkflowHooksCommandRunner: preWorkflowHooksCommandRunner, CommandRunner: commandRunner, PullCleaner: pullClosedExecutor, Parser: eventParser, @@ -464,22 +475,23 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { } return &Server{ - AtlantisVersion: config.AtlantisVersion, - AtlantisURL: parsedURL, - Router: underlyingRouter, - Port: userConfig.Port, - CommandRunner: commandRunner, - Logger: logger, - Locker: lockingClient, - EventsController: eventsController, - GithubAppController: githubAppController, - LocksController: locksController, - StatusController: statusController, - IndexTemplate: indexTemplate, - LockDetailTemplate: lockTemplate, - SSLKeyFile: userConfig.SSLKeyFile, - SSLCertFile: userConfig.SSLCertFile, - Drainer: drainer, + AtlantisVersion: config.AtlantisVersion, + AtlantisURL: parsedURL, + Router: underlyingRouter, + Port: userConfig.Port, + PreWorkflowHooksCommandRunner: preWorkflowHooksCommandRunner, + CommandRunner: commandRunner, + Logger: logger, + Locker: lockingClient, + EventsController: eventsController, + GithubAppController: githubAppController, + LocksController: locksController, + StatusController: statusController, + IndexTemplate: indexTemplate, + LockDetailTemplate: lockTemplate, + SSLKeyFile: userConfig.SSLKeyFile, + SSLCertFile: userConfig.SSLCertFile, + Drainer: drainer, }, nil } diff --git a/server/testfixtures/test-repos/server-side-cfg/repos.yaml b/server/testfixtures/test-repos/server-side-cfg/repos.yaml index 28e38d9121..5c550ba60e 100644 --- a/server/testfixtures/test-repos/server-side-cfg/repos.yaml +++ b/server/testfixtures/test-repos/server-side-cfg/repos.yaml @@ -1,5 +1,7 @@ repos: - id: /.*/ + pre_workflow_hooks: + - run: echo "hello" workflow: custom allowed_overrides: [workflow] workflows: