Skip to content

Commit

Permalink
feat: Add Targeted Execution to the Pre/Post Workflow Hooks (#3708)
Browse files Browse the repository at this point in the history
* Add Workflow hook target filter

* Fix post workflow hook example

* Update WokrflowHook in global_cfg

* Fix linting

---------

Co-authored-by: PePe Amengual <[email protected]>
  • Loading branch information
X-Guardian and jamengual authored Oct 6, 2023
1 parent 271b51e commit 64f7d2e
Show file tree
Hide file tree
Showing 8 changed files with 321 additions and 22 deletions.
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

0 comments on commit 64f7d2e

Please sign in to comment.