Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changes/v1.15/ENHANCEMENTS-20251128-112428.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
kind: ENHANCEMENTS
body: 'actions: `action_trigger` block now supports `on_failure` attribute, which allows specifying terraform behavior on action failure.'
time: 2025-11-28T11:24:28.415647+01:00
custom:
Issue: "37945"
33 changes: 33 additions & 0 deletions internal/configs/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ type ActionTrigger struct {
Condition hcl.Expression
Events []ActionTriggerEvent
Actions []ActionRef // References to actions
OnFailure ActionTriggerOnFailure

DeclRange hcl.Range
}
Expand All @@ -55,6 +56,17 @@ type ActionTriggerEvent int

//go:generate go tool golang.org/x/tools/cmd/stringer -type ActionTriggerEvent

// ActionTriggerOnFailure is an enum capturing the types of behaviors available
// to action trigger on_failure attribute.
type ActionTriggerOnFailure int

const (
ActionTriggerOnFailureFail ActionTriggerOnFailure = iota
ActionTriggerOnFailureContinue
)

//go:generate go tool golang.org/x/tools/cmd/stringer -type ActionTriggerOnFailure

const (
Unknown ActionTriggerEvent = iota
BeforeCreate
Expand All @@ -78,6 +90,7 @@ func decodeActionTriggerBlock(block *hcl.Block) (*ActionTrigger, hcl.Diagnostics
Events: []ActionTriggerEvent{},
Actions: []ActionRef{},
Condition: nil,
OnFailure: ActionTriggerOnFailureFail,
}

content, bodyDiags := block.Body.Content(actionTriggerSchema)
Expand Down Expand Up @@ -137,6 +150,22 @@ func decodeActionTriggerBlock(block *hcl.Block) (*ActionTrigger, hcl.Diagnostics
a.Actions = actionRefs
}

if attr, exists := content.Attributes["on_failure"]; exists {
switch hcl.ExprAsKeyword(attr.Expr) {
case "continue":
a.OnFailure = ActionTriggerOnFailureContinue
case "fail":
a.OnFailure = ActionTriggerOnFailureFail
default:
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid \"on_failure\" keyword",
Detail: "The \"on_failure\" argument requires one of the following keywords: continue or fail.",
Subject: attr.Expr.Range().Ptr(),
})
}
}

if len(a.Actions) == 0 {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Expand Down Expand Up @@ -266,6 +295,10 @@ var actionTriggerSchema = &hcl.BodySchema{
Name: "actions",
Required: true,
},
{
Name: "on_failure",
Required: false,
},
},
}

Expand Down
43 changes: 43 additions & 0 deletions internal/configs/action_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,3 +223,46 @@ func TestDecodeActionTriggerBlock(t *testing.T) {
})
}
}

func TestDecodeActionTriggerBlock_onFailure(t *testing.T) {
fooActionExpr := hcltest.MockExprTraversalSrc("action.action_type.foo")

testData := map[string]struct {
valid bool
diagMsg string
}{
"continue": {true, ""},
"fail": {true, ""},
"foo": {false, "MockExprLiteral:0,0-0: Invalid " +
"\"on_failure\" keyword; The \"on_failure\" argument requires " +
"one of the following keywords: continue or fail."},
}
for keyword, td := range testData {
t.Run("", func(t *testing.T) {
givenActionTriggerBlock := &hcl.Block{
Type: "action_trigger",
Body: hcltest.MockBody(&hcl.BodyContent{
Attributes: hcltest.MockAttrs(map[string]hcl.Expression{
"events": hcltest.MockExprList([]hcl.Expression{
hcltest.MockExprTraversalSrc("before_create"),
}),
"actions": hcltest.MockExprList([]hcl.Expression{
fooActionExpr,
}),
"on_failure": hcltest.MockExprTraversalSrc(keyword),
}),
}),
}

_, diags := decodeActionTriggerBlock(givenActionTriggerBlock)

if diags.HasErrors() && td.valid {
t.Fatalf("keyword %s should be valid but has returned"+
" diags: %v", keyword, diags)
} else if !diags.HasErrors() && !td.valid {
t.Fatalf("keyword %s should have been invalid but was"+
"valid.", keyword)
}
})
}
}
25 changes: 25 additions & 0 deletions internal/configs/actiontriggeronfailure_string.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion internal/configs/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (

"github.com/davecgh/go-spew/spew"

version "github.com/hashicorp/go-version"
"github.com/hashicorp/go-version"
"github.com/hashicorp/hcl/v2"
"github.com/spf13/afero"
)
Expand Down
13 changes: 13 additions & 0 deletions internal/plans/action_invocation.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ type ActionTrigger interface {

TriggerEvent() configs.ActionTriggerEvent

TriggerOnFailure() configs.ActionTriggerOnFailure

String() string

Equals(to ActionTrigger) bool
Expand All @@ -55,6 +57,8 @@ type LifecycleActionTrigger struct {
// Information about the trigger
// The event that triggered this action invocation.
ActionTriggerEvent configs.ActionTriggerEvent
// Hint to Terraform how to handle action failure
ActionTriggerOnFailure configs.ActionTriggerOnFailure
// The index of the action_trigger block that triggered this invocation.
ActionTriggerBlockIndex int
// The index of the action in the events list of the action_trigger block
Expand All @@ -65,6 +69,10 @@ func (t *LifecycleActionTrigger) TriggerEvent() configs.ActionTriggerEvent {
return t.ActionTriggerEvent
}

func (t *LifecycleActionTrigger) TriggerOnFailure() configs.ActionTriggerOnFailure {
return t.ActionTriggerOnFailure
}

func (t *LifecycleActionTrigger) actionTriggerSigil() {}

func (t *LifecycleActionTrigger) String() string {
Expand Down Expand Up @@ -110,6 +118,11 @@ func (t *InvokeActionTrigger) TriggerEvent() configs.ActionTriggerEvent {
return configs.Invoke
}

func (t *InvokeActionTrigger) TriggerOnFailure() configs.ActionTriggerOnFailure {
// We always fail on direct invocation
return configs.ActionTriggerOnFailureFail
}

func (t *InvokeActionTrigger) Equals(other ActionTrigger) bool {
_, ok := other.(*InvokeActionTrigger)
if !ok {
Expand Down
69 changes: 69 additions & 0 deletions internal/terraform/context_apply_action_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2554,6 +2554,74 @@ lifecycle {
},
},
},

"trigger on_failure set to 'fail' fails the resource": {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the test cases could be more extensive:

  • we should assert that the resource change has been made (so the right RPC call on the provider has been made)
  • we should check that on_failure = continue continues with the other actions in the actions list and in other action_trigger blocks.
  • we should make sure the behavior with multiple action triggers where the on_failure behavior is mixed is consistent with the expectations

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey thanks for this pointers.

They should have been address in this commit: 2a46bea.

Just for the clarification:

we should assert that the resource change has been made (so the right RPC call on the provider has been made)

Do you mean we should assert action invocations or we do something else to verify changes?

module: map[string]string{
"main.tf": `
action "action_example" "dummy_action" {}
resource "test_object" "dummy_resource" {
lifecycle {
action_trigger {
events = [before_create]
actions = [action.action_example.dummy_action]
on_failure = fail
}
}
}
`,
},
expectInvokeActionCalled: true,
callingInvokeReturnsDiagnostics: func(providers.InvokeActionRequest) tfdiags.Diagnostics {
return tfdiags.Diagnostics{
tfdiags.Sourceless(
tfdiags.Error,
"Test failure",
"action failing for test",
),
}
},
expectDiagnostics: func(m *configs.Config) tfdiags.Diagnostics {
return tfdiags.Diagnostics{}.Append(
&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Error when invoking action",
Detail: "Test failure: action failing for test",
Subject: &hcl.Range{
Filename: filepath.Join(m.Module.SourceDir, "main.tf"),
Start: hcl.Pos{Line: 7, Column: 18, Byte: 168},
End: hcl.Pos{Line: 7, Column: 52, Byte: 202},
},
},
)
},
},

"trigger on_failure set to 'continue' doesn't cause failure": {
module: map[string]string{
"main.tf": `
action "action_example" "dummy_action" {}
resource "test_object" "dummy_resource" {
lifecycle {
action_trigger {
events = [before_create]
actions = [action.action_example.dummy_action]
on_failure = continue
}
}
}
`,
},
expectInvokeActionCalled: true,
callingInvokeReturnsDiagnostics: func(providers.InvokeActionRequest) tfdiags.Diagnostics {
return tfdiags.Diagnostics{
tfdiags.Sourceless(
tfdiags.Error,
"Test failure",
"action failing for test",
),
}
},
},
} {
t.Run(name, func(t *testing.T) {
if tc.toBeImplemented {
Expand Down Expand Up @@ -2804,6 +2872,7 @@ func newActionHookCapture() actionHookCapture {
mu: &sync.Mutex{},
}
}

func (a *actionHookCapture) StartAction(identity HookActionIdentity) (HookAction, error) {
a.mu.Lock()
defer a.mu.Unlock()
Expand Down
1 change: 1 addition & 0 deletions internal/terraform/node_action_trigger_abstract.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ type nodeAbstractActionTriggerExpand struct {
type lifecycleActionTrigger struct {
resourceAddress addrs.ConfigResource
events []configs.ActionTriggerEvent
onFailure configs.ActionTriggerOnFailure
actionTriggerBlockIndex int
actionListIndex int
invokingSubject *hcl.Range
Expand Down
33 changes: 22 additions & 11 deletions internal/terraform/node_action_trigger_instance_apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package terraform

import (
"fmt"
"log"

"github.com/hashicorp/hcl/v2"

Expand Down Expand Up @@ -159,7 +160,7 @@ func (n *nodeActionTriggerApplyInstance) Execute(ctx EvalContext, wo walkOperati
diags = diags.Append(ctx.Hook(func(h Hook) (HookAction, error) {
return h.CompleteAction(hookIdentity, respDiags.Err())
}))
return diags
return diagsIfNeeded(ai, diags)
}

if resp.Events != nil { // should only occur in misconfigured tests
Expand All @@ -169,21 +170,12 @@ func (n *nodeActionTriggerApplyInstance) Execute(ctx EvalContext, wo walkOperati
diags = diags.Append(ctx.Hook(func(h Hook) (HookAction, error) {
return h.ProgressAction(hookIdentity, ev.Message)
}))
if diags.HasErrors() {
return diags
}
case providers.InvokeActionEvent_Completed:
// Enhance the diagnostics
diags = diags.Append(n.AddSubjectToDiagnostics(ev.Diagnostics))
diags = diags.Append(ctx.Hook(func(h Hook) (HookAction, error) {
return h.CompleteAction(hookIdentity, ev.Diagnostics.Err())
}))
if ev.Diagnostics.HasErrors() {
return diags
}
if diags.HasErrors() {
return diags
}
default:
panic(fmt.Sprintf("unexpected action event type %T", ev))
}
Expand All @@ -197,7 +189,26 @@ func (n *nodeActionTriggerApplyInstance) Execute(ctx EvalContext, wo walkOperati
})
}

return diags
return diagsIfNeeded(ai, diags)
}

func diagsIfNeeded(
aii *plans.ActionInvocationInstance,
currentDiags tfdiags.Diagnostics,
) tfdiags.Diagnostics {
switch aii.ActionTrigger.TriggerOnFailure() {
case configs.ActionTriggerOnFailureContinue:
if currentDiags.HasErrors() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of printing the errors we want to continue with we probably want to wrap them in warning level diagnostics so that consumers that work directly with the returned diagnostics (e.g. the stacks runtime) can appropriately handle these diagnostics as well.

Also we should only do this for errors coming from the provider complete event, if a hook sends a diagnostic it is unrelated to the on_failure behavior and we should still pass them through.

log.Printf("[WARN] Errors while running action %s, but"+
" continuing as request in configuration.", aii.Addr)
}
return nil
default:
// Nothing to do for now - here to make it exhaustive and to denote the
// place to put potential new `on failure` cases.
}

return currentDiags
}

func (n *nodeActionTriggerApplyInstance) ProvidedBy() (addr addrs.ProviderConfig, exact bool) {
Expand Down
2 changes: 2 additions & 0 deletions internal/terraform/node_action_trigger_instance_plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ type nodeActionTriggerPlanInstance struct {
type lifecycleActionTriggerInstance struct {
resourceAddress addrs.AbsResourceInstance
events []configs.ActionTriggerEvent
onFailure configs.ActionTriggerOnFailure
actionTriggerBlockIndex int
actionListIndex int
invokingSubject *hcl.Range
Expand All @@ -52,6 +53,7 @@ func (at *lifecycleActionTriggerInstance) ActionTrigger(triggeringEvent configs.
ActionTriggerBlockIndex: at.actionTriggerBlockIndex,
ActionsListIndex: at.actionListIndex,
ActionTriggerEvent: triggeringEvent,
ActionTriggerOnFailure: at.onFailure,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is now part of plans.LifecycleActionTrigger, so it needs to also be handled in the JSON representation:

case *plans.LifecycleActionTrigger:

Also I think we need to handle this in plans.ActionInvocationInstanceSrc and in the serialization to and from the planfile:

ret.ActionTrigger = &plans.LifecycleActionTrigger{

}
}

Expand Down
2 changes: 2 additions & 0 deletions internal/terraform/node_action_trigger_partialexp.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type NodeActionTriggerPartialExpanded struct {
type lifecycleActionTriggerPartialExpanded struct {
resourceAddress addrs.PartialExpandedResource
events []configs.ActionTriggerEvent
onFailure configs.ActionTriggerOnFailure
actionTriggerBlockIndex int
actionListIndex int
invokingSubject *hcl.Range
Expand Down Expand Up @@ -121,6 +122,7 @@ func (n *NodeActionTriggerPartialExpanded) Execute(ctx EvalContext, op walkOpera
ActionTrigger: &plans.LifecycleActionTrigger{
TriggeringResourceAddr: n.lifecycleActionTrigger.resourceAddress.UnknownResourceInstance(),
ActionTriggerEvent: triggeringEvent,
ActionTriggerOnFailure: n.lifecycleActionTrigger.onFailure,
ActionTriggerBlockIndex: n.lifecycleActionTrigger.actionTriggerBlockIndex,
ActionsListIndex: n.lifecycleActionTrigger.actionListIndex,
},
Expand Down
1 change: 1 addition & 0 deletions internal/terraform/node_action_trigger_plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ func (n *nodeActionTriggerPlanExpand) DynamicExpand(ctx EvalContext) (*Graph, tf
lifecycleActionTrigger: &lifecycleActionTriggerInstance{
resourceAddress: absResourceInstanceAddr,
events: n.lifecycleActionTrigger.events,
onFailure: n.lifecycleActionTrigger.onFailure,
actionTriggerBlockIndex: n.lifecycleActionTrigger.actionTriggerBlockIndex,
actionListIndex: n.lifecycleActionTrigger.actionListIndex,
invokingSubject: n.lifecycleActionTrigger.invokingSubject,
Expand Down
Loading