actions: support conditions in trigger blocks

pull/37545/head
Daniel Schmidt 6 months ago
parent 5124967f5a
commit 1f0dd6b80a

@ -1706,6 +1706,147 @@ resource "test_object" "a" {
},
},
},
"conditions": {
module: map[string]string{
"main.tf": `
action "act_unlinked" "hello" {
count = 3
config {
attr = "value-${count.index}"
}
}
resource "test_object" "foo" {
name = "foo"
}
resource "test_object" "resource" {
name = "resource"
lifecycle {
action_trigger {
events = [before_create]
condition = test_object.foo.name == "bar"
actions = [action.act_unlinked.hello[0]]
}
action_trigger {
events = [before_create]
condition = test_object.foo.name == "foo"
actions = [action.act_unlinked.hello[1], action.act_unlinked.hello[2]]
}
}
}
`,
},
expectInvokeActionCalled: true,
expectInvokeActionCalls: []providers.InvokeActionRequest{{
ActionType: "act_unlinked",
PlannedActionData: cty.ObjectVal(map[string]cty.Value{
"attr": cty.StringVal("value-1"),
}),
}, {
ActionType: "act_unlinked",
PlannedActionData: cty.ObjectVal(map[string]cty.Value{
"attr": cty.StringVal("value-2"),
}),
}},
},
"simple condition evaluation - true": {
module: map[string]string{
"main.tf": `
action "act_unlinked" "hello" {}
resource "test_object" "a" {
name = "foo"
lifecycle {
action_trigger {
events = [after_create]
condition = "foo" == "foo"
actions = [action.act_unlinked.hello]
}
}
}
`,
},
expectInvokeActionCalled: true,
},
"simple condition evaluation - false": {
module: map[string]string{
"main.tf": `
action "act_unlinked" "hello" {}
resource "test_object" "a" {
name = "foo"
lifecycle {
action_trigger {
events = [after_create]
condition = "foo" == "bar"
actions = [action.act_unlinked.hello]
}
}
}
`,
},
expectInvokeActionCalled: false,
},
"using count.index in after_create condition": {
module: map[string]string{
"main.tf": `
action "act_unlinked" "hello" {}
resource "test_object" "a" {
count = 3
name = "item-${count.index}"
lifecycle {
action_trigger {
events = [after_create]
condition = count.index == 1
actions = [action.act_unlinked.hello]
}
}
}
`,
},
expectInvokeActionCalled: true,
},
"using each.key in after_create condition": {
module: map[string]string{
"main.tf": `
action "act_unlinked" "hello" {}
resource "test_object" "a" {
for_each = toset(["foo", "bar"])
name = each.key
lifecycle {
action_trigger {
events = [after_create]
condition = each.key == "foo"
actions = [action.act_unlinked.hello]
}
}
}
`,
},
expectInvokeActionCalled: true,
},
"using each.value in after_create condition": {
module: map[string]string{
"main.tf": `
action "act_unlinked" "hello" {}
resource "test_object" "a" {
for_each = {"foo" = "value1", "bar" = "value2"}
name = each.value
lifecycle {
action_trigger {
events = [after_create]
condition = each.value == "value1"
actions = [action.act_unlinked.hello]
}
}
}
`,
},
expectInvokeActionCalled: true,
},
} {
t.Run(name, func(t *testing.T) {
if tc.toBeImplemented {

@ -2328,6 +2328,460 @@ resource "test_object" "a" {
}
},
},
"boolean condition": {
module: map[string]string{
"main.tf": `
action "test_unlinked" "hello" {}
action "test_unlinked" "world" {}
action "test_unlinked" "bye" {}
resource "test_object" "foo" {
name = "foo"
}
resource "test_object" "a" {
lifecycle {
action_trigger {
events = [before_create]
condition = test_object.foo.name == "foo"
actions = [action.test_unlinked.hello, action.test_unlinked.world]
}
action_trigger {
events = [after_create]
condition = test_object.foo.name == "bye"
actions = [action.test_unlinked.bye]
}
}
}
`,
},
expectPlanActionCalled: true,
assertPlan: func(t *testing.T, p *plans.Plan) {
if len(p.Changes.ActionInvocations) != 2 {
t.Fatalf("expected 2 actions in plan, got %d", len(p.Changes.ActionInvocations))
}
invokedActionAddrs := []string{}
for _, action := range p.Changes.ActionInvocations {
invokedActionAddrs = append(invokedActionAddrs, action.Addr.String())
}
slices.Sort(invokedActionAddrs)
expectedActions := []string{
"action.test_unlinked.hello",
"action.test_unlinked.world",
}
if !cmp.Equal(expectedActions, invokedActionAddrs) {
t.Fatalf("expected actions: %v, got %v", expectedActions, invokedActionAddrs)
}
},
},
"unknown condition": {
module: map[string]string{
"main.tf": `
variable "cond" {
type = string
}
action "test_unlinked" "hello" {}
resource "test_object" "a" {
lifecycle {
action_trigger {
events = [before_create]
condition = var.cond == "foo"
actions = [action.test_unlinked.hello]
}
}
}
`,
},
expectPlanActionCalled: false,
planOpts: &PlanOpts{
Mode: plans.NormalMode,
SetVariables: InputValues{
"cond": &InputValue{
Value: cty.UnknownVal(cty.String),
SourceType: ValueFromCaller,
},
},
},
expectPlanDiagnostics: func(m *configs.Config) tfdiags.Diagnostics {
return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Condition must be known",
Detail: "The condition expression resulted in an unknown value, but it must be a known boolean value.",
Subject: &hcl.Range{
Filename: filepath.Join(m.Module.SourceDir, "main.tf"),
Start: hcl.Pos{Line: 10, Column: 19, Byte: 186},
End: hcl.Pos{Line: 10, Column: 36, Byte: 203},
},
})
},
},
"non-boolean condition": {
module: map[string]string{
"main.tf": `
action "test_unlinked" "hello" {}
resource "test_object" "foo" {
name = "foo"
}
resource "test_object" "a" {
lifecycle {
action_trigger {
events = [before_create]
condition = test_object.foo.name
actions = [action.test_unlinked.hello]
}
}
}
`,
},
expectPlanActionCalled: false,
expectPlanDiagnostics: func(m *configs.Config) tfdiags.Diagnostics {
return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Incorrect value type",
Detail: "Invalid expression value: a bool is required.",
Subject: &hcl.Range{
Filename: filepath.Join(m.Module.SourceDir, "main.tf"),
Start: hcl.Pos{Line: 10, Column: 19, Byte: 196},
End: hcl.Pos{Line: 10, Column: 39, Byte: 216},
},
})
},
},
"using self in before_* condition": {
module: map[string]string{
"main.tf": `
action "test_unlinked" "hello" {}
action "test_unlinked" "world" {}
resource "test_object" "a" {
name = "foo"
lifecycle {
action_trigger {
events = [before_create]
condition = self.name == "foo"
actions = [action.test_unlinked.hello]
}
action_trigger {
events = [after_update]
condition = self.name == "bar"
actions = [action.test_unlinked.world]
}
}
}
`,
},
expectPlanActionCalled: false,
expectPlanDiagnostics: func(m *configs.Config) tfdiags.Diagnostics {
// We only expect one diagnostic, as the other condition is valid
return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Self reference not allowed",
Detail: `The condition expression cannot reference "self".`,
Subject: &hcl.Range{
Filename: filepath.Join(m.Module.SourceDir, "main.tf"),
Start: hcl.Pos{Line: 11, Column: 19, Byte: 199},
End: hcl.Pos{Line: 11, Column: 37, Byte: 217},
},
})
},
},
"using self in after_* condition": {
module: map[string]string{
"main.tf": `
action "test_unlinked" "hello" {}
action "test_unlinked" "world" {}
resource "test_object" "a" {
name = "foo"
lifecycle {
action_trigger {
events = [after_create]
condition = self.name == "foo"
actions = [action.test_unlinked.hello]
}
action_trigger {
events = [after_update]
condition = self.name == "bar"
actions = [action.test_unlinked.world]
}
}
}
`,
},
expectPlanActionCalled: false,
expectPlanDiagnostics: func(m *configs.Config) tfdiags.Diagnostics {
// We only expect one diagnostic, as the other condition is valid
return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Self reference not allowed",
Detail: `The condition expression cannot reference "self".`,
Subject: &hcl.Range{
Filename: filepath.Join(m.Module.SourceDir, "main.tf"),
Start: hcl.Pos{Line: 11, Column: 19, Byte: 198},
End: hcl.Pos{Line: 11, Column: 37, Byte: 216},
},
})
},
},
"using each in before_* condition": {
module: map[string]string{
"main.tf": `
action "test_unlinked" "hello" {}
action "test_unlinked" "world" {}
resource "test_object" "a" {
for_each = toset(["foo", "bar"])
name = each.key
lifecycle {
action_trigger {
events = [before_create]
condition = each.key == "foo"
actions = [action.test_unlinked.hello]
}
}
}
`,
},
expectPlanActionCalled: false,
expectPlanDiagnostics: func(m *configs.Config) tfdiags.Diagnostics {
return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Each reference not allowed",
Detail: `The condition expression cannot reference "each" if the action is run before the resource is applied.`,
Subject: &hcl.Range{
Filename: filepath.Join(m.Module.SourceDir, "main.tf"),
Start: hcl.Pos{Line: 12, Column: 19, Byte: 237},
End: hcl.Pos{Line: 12, Column: 36, Byte: 254},
},
}).Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Each reference not allowed",
Detail: `The condition expression cannot reference "each" if the action is run before the resource is applied.`,
Subject: &hcl.Range{
Filename: filepath.Join(m.Module.SourceDir, "main.tf"),
Start: hcl.Pos{Line: 12, Column: 19, Byte: 237},
End: hcl.Pos{Line: 12, Column: 36, Byte: 254},
},
})
},
},
"using each in after_* condition": {
module: map[string]string{
"main.tf": `
action "test_unlinked" "hello" {}
action "test_unlinked" "world" {}
resource "test_object" "a" {
for_each = toset(["foo", "bar"])
name = each.key
lifecycle {
action_trigger {
events = [after_create]
condition = each.key == "foo"
actions = [action.test_unlinked.hello]
}
action_trigger {
events = [after_update]
condition = each.key == "bar"
actions = [action.test_unlinked.world]
}
}
}
`,
},
expectPlanActionCalled: true,
assertPlan: func(t *testing.T, p *plans.Plan) {
if len(p.Changes.ActionInvocations) != 1 {
t.Errorf("Expected 1 action invocations, got %d", len(p.Changes.ActionInvocations))
}
if p.Changes.ActionInvocations[0].Addr.String() != "action.test_unlinked.hello" {
t.Errorf("Expected action 'action.test_unlinked.hello', got %s", p.Changes.ActionInvocations[0].Addr.String())
}
},
},
"using count.index in before_* condition": {
module: map[string]string{
"main.tf": `
action "test_unlinked" "hello" {}
action "test_unlinked" "world" {}
resource "test_object" "a" {
count = 3
name = "item-${count.index}"
lifecycle {
action_trigger {
events = [before_create]
condition = count.index == 1
actions = [action.test_unlinked.hello]
}
action_trigger {
events = [before_update]
condition = count.index == 2
actions = [action.test_unlinked.world]
}
}
}
`,
},
expectPlanActionCalled: false,
expectPlanDiagnostics: func(m *configs.Config) tfdiags.Diagnostics {
return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Count reference not allowed",
Detail: `The condition expression cannot reference "count" if the action is run before the resource is applied.`,
Subject: &hcl.Range{
Filename: filepath.Join(m.Module.SourceDir, "main.tf"),
Start: hcl.Pos{Line: 11, Column: 19, Byte: 226},
End: hcl.Pos{Line: 11, Column: 35, Byte: 242},
},
}).Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Count reference not allowed",
Detail: `The condition expression cannot reference "count" if the action is run before the resource is applied.`,
Subject: &hcl.Range{
Filename: filepath.Join(m.Module.SourceDir, "main.tf"),
Start: hcl.Pos{Line: 11, Column: 19, Byte: 226},
End: hcl.Pos{Line: 11, Column: 35, Byte: 242},
},
}).Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Count reference not allowed",
Detail: `The condition expression cannot reference "count" if the action is run before the resource is applied.`,
Subject: &hcl.Range{
Filename: filepath.Join(m.Module.SourceDir, "main.tf"),
Start: hcl.Pos{Line: 11, Column: 19, Byte: 226},
End: hcl.Pos{Line: 11, Column: 35, Byte: 242},
},
})
},
},
"using count.index in after_* condition": {
module: map[string]string{
"main.tf": `
action "test_unlinked" "hello" {}
action "test_unlinked" "world" {}
resource "test_object" "a" {
count = 3
name = "item-${count.index}"
lifecycle {
action_trigger {
events = [after_create]
condition = count.index == 1
actions = [action.test_unlinked.hello]
}
action_trigger {
events = [after_update]
condition = count.index == 2
actions = [action.test_unlinked.world]
}
}
}
`,
},
expectPlanActionCalled: true,
assertPlan: func(t *testing.T, p *plans.Plan) {
if len(p.Changes.ActionInvocations) != 1 {
t.Errorf("Expected 1 action invocation, got %d", len(p.Changes.ActionInvocations))
}
if p.Changes.ActionInvocations[0].Addr.String() != "action.test_unlinked.hello" {
t.Errorf("Expected action invocation %q, got %q", "action.test_unlinked.hello", p.Changes.ActionInvocations[0].Addr.String())
}
},
},
"using each.value in before_* condition": {
module: map[string]string{
"main.tf": `
action "test_unlinked" "hello" {}
action "test_unlinked" "world" {}
resource "test_object" "a" {
for_each = {"foo" = "value1", "bar" = "value2"}
name = each.value
lifecycle {
action_trigger {
events = [before_create]
condition = each.value == "value1"
actions = [action.test_unlinked.hello]
}
action_trigger {
events = [before_update]
condition = each.value == "value2"
actions = [action.test_unlinked.world]
}
}
}
`,
},
expectPlanActionCalled: false,
expectPlanDiagnostics: func(m *configs.Config) tfdiags.Diagnostics {
return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Each reference not allowed",
Detail: `The condition expression cannot reference "each" if the action is run before the resource is applied.`,
Subject: &hcl.Range{
Filename: filepath.Join(m.Module.SourceDir, "main.tf"),
Start: hcl.Pos{Line: 11, Column: 19, Byte: 253},
End: hcl.Pos{Line: 11, Column: 41, Byte: 275},
},
}).Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Each reference not allowed",
Detail: `The condition expression cannot reference "each" if the action is run before the resource is applied.`,
Subject: &hcl.Range{
Filename: filepath.Join(m.Module.SourceDir, "main.tf"),
Start: hcl.Pos{Line: 11, Column: 19, Byte: 253},
End: hcl.Pos{Line: 11, Column: 41, Byte: 275},
},
})
},
},
"using each.value in after_* condition": {
module: map[string]string{
"main.tf": `
action "test_unlinked" "hello" {}
action "test_unlinked" "world" {}
resource "test_object" "a" {
for_each = {"foo" = "value1", "bar" = "value2"}
name = each.value
lifecycle {
action_trigger {
events = [after_create]
condition = each.value == "value1"
actions = [action.test_unlinked.hello]
}
action_trigger {
events = [after_update]
condition = each.value == "value2"
actions = [action.test_unlinked.world]
}
}
}
`,
},
expectPlanActionCalled: true,
assertPlan: func(t *testing.T, p *plans.Plan) {
if len(p.Changes.ActionInvocations) != 1 {
t.Errorf("Expected 1 action invocations, got %d", len(p.Changes.ActionInvocations))
}
if p.Changes.ActionInvocations[0].Addr.String() != "action.test_unlinked.hello" {
t.Errorf("Expected action 'action.test_unlinked.hello', got %s", p.Changes.ActionInvocations[0].Addr.String())
}
},
},
} {
t.Run(name, func(t *testing.T) {
if tc.toBeImplemented {

@ -10,6 +10,7 @@ import (
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/lang/ephemeral"
"github.com/hashicorp/terraform/internal/lang/langrefs"
"github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/plans/objchange"
"github.com/hashicorp/terraform/internal/providers"
@ -20,6 +21,7 @@ type nodeActionTriggerApply struct {
ActionInvocation *plans.ActionInvocationInstanceSrc
resolvedProvider addrs.AbsProviderConfig
ActionTriggerRange *hcl.Range
ConditionExpr hcl.Expression
}
var (
@ -35,7 +37,26 @@ func (n *nodeActionTriggerApply) Execute(ctx EvalContext, wo walkOperation) tfdi
var diags tfdiags.Diagnostics
actionInvocation := n.ActionInvocation
// TODO: Handle verifying the condition here, if we have any.
if n.ConditionExpr != nil {
condition, conditionDiags := evaluateCondition(ctx, n.ConditionExpr)
diags = diags.Append(conditionDiags)
if diags.HasErrors() {
return diags
}
if !condition.IsWhollyKnown() {
return diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Condition expression is not known",
Detail: "During apply the condition expression must be known, and must evaluate to a boolean value",
Subject: n.ConditionExpr.Range().Ptr(),
})
}
// If the condition evaluates to false, skip the action
if condition.False() {
return diags
}
}
ai := ctx.Changes().GetActionInvocation(actionInvocation.Addr, actionInvocation.ActionTrigger)
if ai == nil {
diags = diags.Append(&hcl.Diagnostic{
@ -164,6 +185,14 @@ func (n *nodeActionTriggerApply) References() []*addrs.Reference {
Subject: n.ActionInvocation.Addr.Action,
})
conditionRefs, refDiags := langrefs.ReferencesInExpr(addrs.ParseRef, n.ConditionExpr)
if refDiags.HasErrors() {
panic(fmt.Sprintf("error parsing references in expression: %v", refDiags))
}
if conditionRefs != nil {
refs = append(refs, conditionRefs...)
}
return refs
}
@ -172,6 +201,11 @@ func (n *nodeActionTriggerApply) ModulePath() addrs.Module {
return n.ActionInvocation.Addr.Module.Module()
}
// GraphNodeModuleInstance
func (n *nodeActionTriggerApply) Path() addrs.ModuleInstance {
return n.ActionInvocation.Addr.Module
}
func (n *nodeActionTriggerApply) AddSubjectToDiagnostics(input tfdiags.Diagnostics) tfdiags.Diagnostics {
var diags tfdiags.Diagnostics
if len(input) > 0 {

@ -15,6 +15,7 @@ import (
"github.com/hashicorp/terraform/internal/plans/deferring"
"github.com/hashicorp/terraform/internal/providers"
"github.com/hashicorp/terraform/internal/tfdiags"
"github.com/zclconf/go-cty/cty"
)
type nodeActionTriggerPlanInstance struct {
@ -32,6 +33,7 @@ type lifecycleActionTriggerInstance struct {
actionTriggerBlockIndex int
actionListIndex int
invokingSubject *hcl.Range
conditionExpr hcl.Expression
}
func (at *lifecycleActionTriggerInstance) Name() string {
@ -111,6 +113,21 @@ func (n *nodeActionTriggerPlanInstance) Execute(ctx EvalContext, operation walkO
if triggeringEvent == nil {
panic("triggeringEvent cannot be nil")
}
// Evaluate the condition expression if it exists (otherwise it's true)
if n.lifecycleActionTrigger != nil && n.lifecycleActionTrigger.conditionExpr != nil {
condition, conditionDiags := evaluateCondition(ctx, n.lifecycleActionTrigger.conditionExpr)
diags = diags.Append(conditionDiags)
if conditionDiags.HasErrors() {
return conditionDiags
}
// The condition is false so we skip the action
if condition.False() {
return diags
}
}
// We need to set the triggering event on the action invocation
ai.ActionTrigger = n.lifecycleActionTrigger.ActionTrigger(*triggeringEvent)
@ -183,3 +200,28 @@ func (n *nodeActionTriggerPlanInstance) Path() addrs.ModuleInstance {
// to the same module. So we can simply return the module path of the action.
return n.actionAddress.Module
}
func evaluateCondition(ctx EvalContext, conditionExpr hcl.Expression) (cty.Value, tfdiags.Diagnostics) {
// TODO: Support self in conditions
val, diags := ctx.EvaluateExpr(conditionExpr, cty.Bool, nil)
if diags.HasErrors() {
return cty.False, diags
}
// TODO: Support unknown condition values
if !val.IsWhollyKnown() {
panic("condition is not wholly known")
}
// If the condition is neither true nor false, it's an error
if !(val.True() || val.False()) {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid condition",
Detail: "The condition must be either true or false",
Subject: conditionExpr.Range().Ptr(),
})
return cty.False, diags
}
return val, nil
}

@ -24,13 +24,13 @@ type nodeActionTriggerPlanExpand struct {
}
type lifecycleActionTrigger struct {
resourceAddress addrs.ConfigResource
events []configs.ActionTriggerEvent
//condition hcl.Expression
resourceAddress addrs.ConfigResource
events []configs.ActionTriggerEvent
actionTriggerBlockIndex int
actionListIndex int
invokingSubject *hcl.Range
actionExpr hcl.Expression
conditionExpr hcl.Expression
}
func (at *lifecycleActionTrigger) Name() string {
@ -103,6 +103,7 @@ func (n *nodeActionTriggerPlanExpand) DynamicExpand(ctx EvalContext) (*Graph, tf
actionTriggerBlockIndex: n.lifecycleActionTrigger.actionTriggerBlockIndex,
actionListIndex: n.lifecycleActionTrigger.actionListIndex,
invokingSubject: n.lifecycleActionTrigger.invokingSubject,
conditionExpr: n.lifecycleActionTrigger.conditionExpr,
},
}

@ -57,6 +57,7 @@ func (t *ActionDiffTransformer) Transform(g *Graph) error {
act := triggerBlock.Actions[at.ActionsListIndex]
node.ActionTriggerRange = &act.Range
node.ConditionExpr = triggerBlock.Condition
}
g.Add(node)

@ -146,6 +146,7 @@ func (t *ActionPlanTransformer) transformSingle(g *Graph, config *configs.Config
actionTriggerBlockIndex: i,
actionListIndex: j,
invokingSubject: action.Expr.Range().Ptr(),
conditionExpr: at.Condition,
},
}

Loading…
Cancel
Save