Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add Targeted Execution to the Pre/Post Workflow Hooks #3708

Merged
merged 9 commits into from
Oct 6, 2023
21 changes: 21 additions & 0 deletions runatlantis.io/docs/post-workflow-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,27 @@ back to the PR as a comment.
Post workflow hooks can only be specified in the Server-Side Repo Config under
the `repos` key.

## Atlantis Command Targetting

By default, the workflow hook will run when any command is processed by Atlantis.
This can be modified by specifying the `commands` key in the workflow hook containing a comma delimited list
of Atlantis commands that the hook should be run for. Detail of the Atlantis commands
can be found in [Using Atlantis](using-atlantis.md).

### Example

```yaml
repos:
- id: /.*/
post_workflow_hooks:
- run: ./plan-hook.sh
description: Plan Hook
commands: plan
- run: ./plan-apply-hook.sh
description: Plan & Apply Hook
commands: plan, apply
```

## Use Cases

### Cost estimation reporting
Expand Down
21 changes: 21 additions & 0 deletions runatlantis.io/docs/pre-workflow-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,27 @@ behavior can be changed by setting the [fail-on-pre-workflow-hook-error](server-
flag in the Atlantis server configuration.
:::

## Atlantis Command Targetting

By default, the workflow hook will run when any command is processed by Atlantis.
This can be modified by specifying the `commands` key in the workflow hook containing a comma delimited list
of Atlantis commands that the hook should be run for. Detail of the Atlantis commands
can be found in [Using Atlantis](using-atlantis.md).

### Example

```yaml
repos:
- id: /.*/
pre_workflow_hooks:
- run: ./plan-hook.sh
description: Plan Hook
commands: plan
- run: ./plan-apply-hook.sh
description: Plan & Apply Hook
commands: plan, apply
```

## Use Cases

### Dynamic Repo Config Generation
Expand Down
1 change: 1 addition & 0 deletions server/core/config/raw/workflow_step.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ func (s WorkflowHook) ToValid() *valid.WorkflowHook {
StepDescription: s.StringVal["description"],
Shell: s.StringVal["shell"],
ShellArgs: s.StringVal["shellArgs"],
Commands: s.StringVal["commands"],
}
}

Expand Down
1 change: 1 addition & 0 deletions server/core/config/valid/global_cfg.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ type WorkflowHook struct {
StepDescription string
Shell string
ShellArgs string
Commands string
}

// DefaultApplyStage is the Atlantis default apply stage.
Expand Down
10 changes: 10 additions & 0 deletions server/events/post_workflow_hooks_command_runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package events

import (
"fmt"
"strings"

"github.com/google/uuid"
"github.com/runatlantis/atlantis/server/core/config/valid"
Expand Down Expand Up @@ -108,6 +109,15 @@ func (w *DefaultPostWorkflowHooksCommandRunner) runHooks(
hookDescription = fmt.Sprintf("Post workflow hook #%d", i)
}

ctx.Log.Debug("Processing post workflow hook '%s', Command '%s', Target commands [%s]",
hookDescription, ctx.CommandName, hook.Commands)
if hook.Commands != "" && !strings.Contains(hook.Commands, ctx.CommandName) {
ctx.Log.Debug("Skipping post workflow hook '%s' as command '%s' is not in Commands [%s]",
hookDescription, ctx.CommandName, hook.Commands)
continue
}

ctx.Log.Debug("Running post workflow hook: '%s'", hookDescription)
ctx.HookID = uuid.NewString()
shell := hook.Shell
if shell == "" {
Expand Down
139 changes: 128 additions & 11 deletions server/events/post_workflow_hooks_command_runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,18 @@ func TestRunPostHooks_Clone(t *testing.T) {
ShellArgs: "-ce",
}

testHookWithPlanCommand := valid.WorkflowHook{
StepName: "test4",
RunCommand: "echo test4",
Commands: "plan",
}

testHookWithPlanApplyCommands := valid.WorkflowHook{
StepName: "test5",
RunCommand: "echo test5",
Commands: "plan, apply",
}

repoDir := "path/to/repo"
result := "some result"
runtimeDesc := ""
Expand All @@ -99,10 +111,14 @@ func TestRunPostHooks_Clone(t *testing.T) {
CommandName: "plan",
}

cmd := &events.CommentCommand{
planCmd := &events.CommentCommand{
Name: command.Plan,
}

applyCmd := &events.CommentCommand{
Name: command.Apply,
}

t.Run("success hooks in cfg", func(t *testing.T) {
postWorkflowHooksSetup(t)

Expand All @@ -129,7 +145,7 @@ func TestRunPostHooks_Clone(t *testing.T) {
When(whPostWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](), Eq(testHook.RunCommand), Any[string](),
Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, nil)

err := postWh.RunPostHooks(ctx, cmd)
err := postWh.RunPostHooks(ctx, planCmd)

Ok(t, err)
whPostWorkflowHookRunner.VerifyWasCalledOnce().Run(Any[models.WorkflowHookCommandContext](),
Expand Down Expand Up @@ -157,7 +173,7 @@ func TestRunPostHooks_Clone(t *testing.T) {

postWh.GlobalCfg = globalCfg

err := postWh.RunPostHooks(ctx, cmd)
err := postWh.RunPostHooks(ctx, planCmd)

Ok(t, err)

Expand All @@ -184,7 +200,7 @@ func TestRunPostHooks_Clone(t *testing.T) {

When(postWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace, events.DefaultRepoRelDir)).ThenReturn(func() {}, errors.New("some error"))

err := postWh.RunPostHooks(ctx, cmd)
err := postWh.RunPostHooks(ctx, planCmd)

Assert(t, err != nil, "error not nil")
postWhWorkingDir.VerifyWasCalled(Never()).Clone(testdata.GithubRepo, newPull, events.DefaultWorkspace)
Expand Down Expand Up @@ -216,7 +232,7 @@ func TestRunPostHooks_Clone(t *testing.T) {
When(postWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace, events.DefaultRepoRelDir)).ThenReturn(unlockFn, nil)
When(postWhWorkingDir.Clone(testdata.GithubRepo, newPull, events.DefaultWorkspace)).ThenReturn(repoDir, false, errors.New("some error"))

err := postWh.RunPostHooks(ctx, cmd)
err := postWh.RunPostHooks(ctx, planCmd)

Assert(t, err != nil, "error not nil")

Expand Down Expand Up @@ -251,7 +267,7 @@ func TestRunPostHooks_Clone(t *testing.T) {
When(whPostWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](), Eq(testHook.RunCommand),
Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, errors.New("some error"))

err := postWh.RunPostHooks(ctx, cmd)
err := postWh.RunPostHooks(ctx, planCmd)

Assert(t, err != nil, "error not nil")
Assert(t, *unlockCalled == true, "unlock function called")
Expand All @@ -276,7 +292,7 @@ func TestRunPostHooks_Clone(t *testing.T) {
},
}

cmd := &events.CommentCommand{
planCmd := &events.CommentCommand{
Name: command.Plan,
Flags: []string{"comment", "args"},
}
Expand All @@ -291,7 +307,7 @@ func TestRunPostHooks_Clone(t *testing.T) {
When(whPostWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](), Eq(testHook.RunCommand),
Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, nil)

err := postWh.RunPostHooks(ctx, cmd)
err := postWh.RunPostHooks(ctx, planCmd)

Ok(t, err)
whPostWorkflowHookRunner.VerifyWasCalledOnce().Run(Any[models.WorkflowHookCommandContext](),
Expand Down Expand Up @@ -325,7 +341,7 @@ func TestRunPostHooks_Clone(t *testing.T) {
When(whPostWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](), Eq(testHookWithShell.RunCommand),
Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, nil)

err := postWh.RunPostHooks(ctx, cmd)
err := postWh.RunPostHooks(ctx, planCmd)

Ok(t, err)
whPostWorkflowHookRunner.VerifyWasCalledOnce().Run(Any[models.WorkflowHookCommandContext](),
Expand Down Expand Up @@ -359,7 +375,7 @@ func TestRunPostHooks_Clone(t *testing.T) {
When(whPostWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](), Eq(testHook.RunCommand),
Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, nil)

err := postWh.RunPostHooks(ctx, cmd)
err := postWh.RunPostHooks(ctx, planCmd)

Ok(t, err)
whPostWorkflowHookRunner.VerifyWasCalledOnce().Run(Any[models.WorkflowHookCommandContext](),
Expand Down Expand Up @@ -393,12 +409,113 @@ func TestRunPostHooks_Clone(t *testing.T) {
When(whPostWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](),
Eq(testHookWithShellandShellArgs.RunCommand), Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, nil)

err := postWh.RunPostHooks(ctx, cmd)
err := postWh.RunPostHooks(ctx, planCmd)

Ok(t, err)
whPostWorkflowHookRunner.VerifyWasCalledOnce().Run(Any[models.WorkflowHookCommandContext](),
Eq(testHookWithShellandShellArgs.RunCommand), Eq(testHookWithShellandShellArgs.Shell), Eq(testHookWithShellandShellArgs.ShellArgs), Eq(repoDir))
Assert(t, *unlockCalled == true, "unlock function called")
})

t.Run("Commands 'plan' set on webhook and plan command", func(t *testing.T) {
preWorkflowHooksSetup(t)

var unlockCalled = newBool(false)
unlockFn := func() {
unlockCalled = newBool(true)
}

globalCfg := valid.GlobalCfg{
Repos: []valid.Repo{
{
ID: testdata.GithubRepo.ID(),
PreWorkflowHooks: []*valid.WorkflowHook{
&testHookWithPlanCommand,
},
},
},
}

preWh.GlobalCfg = globalCfg

When(preWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace, events.DefaultRepoRelDir)).ThenReturn(unlockFn, nil)
When(preWhWorkingDir.Clone(testdata.GithubRepo, newPull, events.DefaultWorkspace)).ThenReturn(repoDir, false, nil)
When(whPreWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](),
Eq(testHookWithPlanCommand.RunCommand), Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, nil)

err := preWh.RunPreHooks(ctx, planCmd)

Ok(t, err)
whPreWorkflowHookRunner.VerifyWasCalledOnce().Run(Any[models.WorkflowHookCommandContext](),
Eq(testHookWithPlanCommand.RunCommand), Any[string](), Any[string](), Eq(repoDir))
Assert(t, *unlockCalled == true, "unlock function called")
})

t.Run("Commands 'plan' set on webhook and non-plan command", func(t *testing.T) {
preWorkflowHooksSetup(t)

var unlockCalled = newBool(false)
unlockFn := func() {
unlockCalled = newBool(true)
}

globalCfg := valid.GlobalCfg{
Repos: []valid.Repo{
{
ID: testdata.GithubRepo.ID(),
PreWorkflowHooks: []*valid.WorkflowHook{
&testHookWithPlanCommand,
},
},
},
}

preWh.GlobalCfg = globalCfg

When(preWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace, events.DefaultRepoRelDir)).ThenReturn(unlockFn, nil)
When(preWhWorkingDir.Clone(testdata.GithubRepo, newPull, events.DefaultWorkspace)).ThenReturn(repoDir, false, nil)
When(whPreWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](),
Eq(testHookWithPlanCommand.RunCommand), Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, nil)

err := preWh.RunPreHooks(ctx, applyCmd)

Ok(t, err)
whPreWorkflowHookRunner.VerifyWasCalled(Never()).Run(Any[models.WorkflowHookCommandContext](),
Eq(testHookWithPlanCommand.RunCommand), Any[string](), Any[string](), Eq(repoDir))
Assert(t, *unlockCalled == true, "unlock function called")
})

t.Run("Commands 'plan, apply' set on webhook and plan command", func(t *testing.T) {
preWorkflowHooksSetup(t)

var unlockCalled = newBool(false)
unlockFn := func() {
unlockCalled = newBool(true)
}

globalCfg := valid.GlobalCfg{
Repos: []valid.Repo{
{
ID: testdata.GithubRepo.ID(),
PreWorkflowHooks: []*valid.WorkflowHook{
&testHookWithPlanApplyCommands,
},
},
},
}

preWh.GlobalCfg = globalCfg

When(preWhWorkingDirLocker.TryLock(testdata.GithubRepo.FullName, newPull.Num, events.DefaultWorkspace, events.DefaultRepoRelDir)).ThenReturn(unlockFn, nil)
When(preWhWorkingDir.Clone(testdata.GithubRepo, newPull, events.DefaultWorkspace)).ThenReturn(repoDir, false, nil)
When(whPreWorkflowHookRunner.Run(Any[models.WorkflowHookCommandContext](),
Eq(testHookWithPlanApplyCommands.RunCommand), Any[string](), Any[string](), Eq(repoDir))).ThenReturn(result, runtimeDesc, nil)

err := preWh.RunPreHooks(ctx, planCmd)

Ok(t, err)
whPreWorkflowHookRunner.VerifyWasCalledOnce().Run(Any[models.WorkflowHookCommandContext](),
Eq(testHookWithPlanApplyCommands.RunCommand), Any[string](), Any[string](), Eq(repoDir))
Assert(t, *unlockCalled == true, "unlock function called")
})
}
10 changes: 10 additions & 0 deletions server/events/pre_workflow_hooks_command_runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package events

import (
"fmt"
"strings"

"github.com/google/uuid"
"github.com/runatlantis/atlantis/server/core/config/valid"
Expand Down Expand Up @@ -105,6 +106,15 @@ func (w *DefaultPreWorkflowHooksCommandRunner) runHooks(
hookDescription = fmt.Sprintf("Pre workflow hook #%d", i)
}

ctx.Log.Debug("Processing pre workflow hook '%s', Command '%s', Target commands [%s]",
hookDescription, ctx.CommandName, hook.Commands)
if hook.Commands != "" && !strings.Contains(hook.Commands, ctx.CommandName) {
ctx.Log.Debug("Skipping pre workflow hook '%s' as command '%s' is not in Commands [%s]",
hookDescription, ctx.CommandName, hook.Commands)
continue
}

ctx.Log.Debug("Running pre workflow hook: '%s'", hookDescription)
ctx.HookID = uuid.NewString()
shell := hook.Shell
if shell == "" {
Expand Down
Loading