From 4ed8668a8ff60818ca2ee703e0f4e37ac5178d25 Mon Sep 17 00:00:00 2001 From: Liam Cervante Date: Thu, 28 Aug 2025 14:05:05 +0200 Subject: [PATCH] actions: make action address targetable (#37499) * actions: make action address targetable * add missing functions * copyright headers --- internal/addrs/action.go | 43 +++-- internal/addrs/parse_target.go | 4 + internal/addrs/parse_target_action.go | 162 ++++++++++++++++++ internal/addrs/parse_target_action_test.go | 187 +++++++++++++++++++++ internal/addrs/targetable.go | 2 + 5 files changed, 388 insertions(+), 10 deletions(-) create mode 100644 internal/addrs/parse_target_action.go create mode 100644 internal/addrs/parse_target_action_test.go diff --git a/internal/addrs/action.go b/internal/addrs/action.go index 536aeefc2e..040d013290 100644 --- a/internal/addrs/action.go +++ b/internal/addrs/action.go @@ -140,6 +140,7 @@ func (a ActionInstance) Absolute(module ModuleInstance) AbsActionInstance { // AbsAction is an absolute address for an action under a given module path. type AbsAction struct { + targetable Module ModuleInstance Action Action } @@ -172,6 +173,23 @@ func (a AbsAction) Config() ConfigAction { } } +// TargetContains implements Targetable +func (a AbsAction) TargetContains(other Targetable) bool { + switch to := other.(type) { + case AbsAction: + return a.Equal(to) + case AbsActionInstance: + return a.Equal(to.ContainingAction()) + default: + return false + } +} + +// AddrType implements Targetable +func (a AbsAction) AddrType() TargetableAddrType { + return ActionAddrType +} + func (a AbsAction) String() string { if len(a.Module) == 0 { return a.Action.String() @@ -179,11 +197,6 @@ func (a AbsAction) String() string { return fmt.Sprintf("%s.%s", a.Module.String(), a.Action.String()) } -// AffectedAbsAction returns the AbsAction. -func (a AbsAction) AffectedAbsAction() AbsAction { - return a -} - func (a AbsAction) Equal(o AbsAction) bool { return a.Module.Equal(o.Module) && a.Action.Equal(o.Action) } @@ -211,6 +224,7 @@ func (a AbsAction) UniqueKey() UniqueKey { // AbsActionInstance is an absolute address for an action instance under a // given module path. type AbsActionInstance struct { + targetable Module ModuleInstance Action ActionInstance } @@ -255,14 +269,23 @@ func (a AbsActionInstance) String() string { return fmt.Sprintf("%s.%s", a.Module.String(), a.Action.String()) } -// AffectedAbsAction returns the AbsAction for the instance. -func (a AbsActionInstance) AffectedAbsAction() AbsAction { - return AbsAction{ - Module: a.Module, - Action: a.Action.Action, +// TargetContains implements Targetable +func (a AbsActionInstance) TargetContains(other Targetable) bool { + switch to := other.(type) { + case AbsAction: + return to.Equal(a.ContainingAction()) && a.Action.Key == NoKey + case AbsActionInstance: + return to.Equal(a) + default: + return false } } +// AddrType implements Targetable +func (a AbsActionInstance) AddrType() TargetableAddrType { + return ActionInstanceAddrType +} + func (a AbsActionInstance) Equal(o AbsActionInstance) bool { return a.Module.Equal(o.Module) && a.Action.Equal(o.Action) } diff --git a/internal/addrs/parse_target.go b/internal/addrs/parse_target.go index bc3120b2ae..452f62956d 100644 --- a/internal/addrs/parse_target.go +++ b/internal/addrs/parse_target.go @@ -562,6 +562,10 @@ func (t *Target) ModuleAddr() ModuleInstance { return addr.Module case AbsResource: return addr.Module + case AbsAction: + return addr.Module + case AbsActionInstance: + return addr.Module default: // The above cases should be exhaustive for all // implementations of Targetable. diff --git a/internal/addrs/parse_target_action.go b/internal/addrs/parse_target_action.go new file mode 100644 index 0000000000..c7a12768ff --- /dev/null +++ b/internal/addrs/parse_target_action.go @@ -0,0 +1,162 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package addrs + +import ( + "fmt" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + + "github.com/hashicorp/terraform/internal/tfdiags" +) + +// ParseTargetAction attempts to interpret the given traversal as a targetable +// action address. The given traversal must be absolute, or this function will +// panic. +// +// If no error diagnostics are returned, the returned target includes the +// address that was extracted and the source range it was extracted from. +// +// If error diagnostics are returned then the Target value is invalid and +// must not be used. +// +// This function matches the behaviour of ParseTarget, except we are ensuring +// the caller is explicit about what kind of target they want to get. We prevent +// callers accidentally including action targets where they shouldn't be +// accessible by keeping these methods separate. +func ParseTargetAction(traversal hcl.Traversal) (*Target, tfdiags.Diagnostics) { + path, remain, diags := parseModuleInstancePrefix(traversal, false) + if diags.HasErrors() { + return nil, diags + } + + if len(remain) == 0 { + return nil, diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid address", + Detail: "Action addresses must contain an action reference after the module reference.", + Subject: traversal.SourceRange().Ptr(), + }) + } + + target, moreDiags := parseActionInstanceUnderModule(path, remain, tfdiags.SourceRangeFromHCL(traversal.SourceRange())) + return target, diags.Append(moreDiags) +} + +// ParseTargetActionStr is a helper wrapper around ParseTargetAction that takes +// a string and parses it into HCL before interpreting it. +// +// All the same cautions apply to this as with the equivalent ParseTargetStr. +func ParseTargetActionStr(str string) (*Target, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + + traversal, parseDiags := hclsyntax.ParseTraversalAbs([]byte(str), "", hcl.Pos{Line: 1, Column: 1}) + diags = diags.Append(parseDiags) + if parseDiags.HasErrors() { + return nil, diags + } + + target, targetDiags := ParseTargetAction(traversal) + diags = diags.Append(targetDiags) + return target, diags +} + +func parseActionInstanceUnderModule(moduleAddr ModuleInstance, remain hcl.Traversal, srcRng tfdiags.SourceRange) (*Target, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + + if remain.RootName() != "action" { + return nil, diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid address", + Detail: "Action specification must start with `action`.", + Subject: remain.SourceRange().Ptr(), + }) + } + + remain = remain[1:] + + if len(remain) < 2 { + return nil, diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid address", + Detail: "Action specification must include an action type and name.", + Subject: remain.SourceRange().Ptr(), + }) + } + + var typeName, name string + switch tt := remain[0].(type) { + case hcl.TraverseRoot: + typeName = tt.Name + case hcl.TraverseAttr: + typeName = tt.Name + default: + return nil, diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid address", + Detail: "Action type is required.", + Subject: remain[0].SourceRange().Ptr(), + }) + } + + switch tt := remain[1].(type) { + case hcl.TraverseAttr: + name = tt.Name + default: + return nil, diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid address", + Detail: "An action name is required.", + Subject: remain[1].SourceRange().Ptr(), + }) + } + + remain = remain[2:] + switch len(remain) { + case 0: + return &Target{ + Subject: moduleAddr.Action(typeName, name), + SourceRange: srcRng, + }, diags + case 1: + switch tt := remain[0].(type) { + case hcl.TraverseIndex: + key, err := ParseInstanceKey(tt.Key) + if err != nil { + return nil, diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid address", + Detail: fmt.Sprintf("Invalid action instance key: %s.", err), + Subject: remain[0].SourceRange().Ptr(), + }) + } + return &Target{ + Subject: moduleAddr.ActionInstance(typeName, name, key), + SourceRange: srcRng, + }, diags + case hcl.TraverseSplat: + return nil, diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid address", + Detail: "Action instance key must be given in square brackets.", + Subject: remain[0].SourceRange().Ptr(), + }) + default: + return nil, diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid address", + Detail: "Action instance key must be given in square brackets.", + Subject: remain[0].SourceRange().Ptr(), + }) + } + default: + return nil, diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid address", + Detail: "Unexpected extra operators after address.", + Subject: remain[1].SourceRange().Ptr(), + }) + } +} diff --git a/internal/addrs/parse_target_action_test.go b/internal/addrs/parse_target_action_test.go new file mode 100644 index 0000000000..39b1374fcd --- /dev/null +++ b/internal/addrs/parse_target_action_test.go @@ -0,0 +1,187 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package addrs + +import ( + "testing" + + "github.com/go-test/deep" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + + "github.com/hashicorp/terraform/internal/tfdiags" +) + +func TestParseTargetAction(t *testing.T) { + tcs := []struct { + Input string + Want *Target + WantErr string + }{ + { + Input: "action.action_type.action_name", + Want: &Target{ + Subject: AbsAction{ + Action: Action{ + Type: "action_type", + Name: "action_name", + }, + }, + SourceRange: tfdiags.SourceRange{ + Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + End: tfdiags.SourcePos{Line: 1, Column: 31, Byte: 30}, + }, + }, + }, + { + Input: "action.action_type.action_name[0]", + Want: &Target{ + Subject: AbsActionInstance{ + Action: ActionInstance{ + Action: Action{ + Type: "action_type", + Name: "action_name", + }, + Key: IntKey(0), + }, + }, + SourceRange: tfdiags.SourceRange{ + Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + End: tfdiags.SourcePos{Line: 1, Column: 34, Byte: 33}, + }, + }, + }, + { + Input: "module.module_name.action.action_type.action_name", + Want: &Target{ + Subject: AbsAction{ + Module: ModuleInstance{ + { + Name: "module_name", + }, + }, + Action: Action{ + Type: "action_type", + Name: "action_name", + }, + }, + SourceRange: tfdiags.SourceRange{ + Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + End: tfdiags.SourcePos{Line: 1, Column: 50, Byte: 49}, + }, + }, + }, + { + Input: "module.module_name.action.action_type.action_name[0]", + Want: &Target{ + Subject: AbsActionInstance{ + Module: ModuleInstance{ + { + Name: "module_name", + }, + }, + Action: ActionInstance{ + Action: Action{ + Type: "action_type", + Name: "action_name", + }, + Key: IntKey(0), + }, + }, + SourceRange: tfdiags.SourceRange{ + Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + End: tfdiags.SourcePos{Line: 1, Column: 53, Byte: 52}, + }, + }, + }, + { + Input: "module.module_name[0].action.action_type.action_name", + Want: &Target{ + Subject: AbsAction{ + Module: ModuleInstance{ + { + Name: "module_name", + InstanceKey: IntKey(0), + }, + }, + Action: Action{ + Type: "action_type", + Name: "action_name", + }, + }, + SourceRange: tfdiags.SourceRange{ + Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + End: tfdiags.SourcePos{Line: 1, Column: 53, Byte: 52}, + }, + }, + }, + { + Input: "module.module_name[0].action.action_type.action_name[0]", + Want: &Target{ + Subject: AbsActionInstance{ + Module: ModuleInstance{ + { + Name: "module_name", + InstanceKey: IntKey(0), + }, + }, + Action: ActionInstance{ + Action: Action{ + Type: "action_type", + Name: "action_name", + }, + Key: IntKey(0), + }, + }, + SourceRange: tfdiags.SourceRange{ + Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0}, + End: tfdiags.SourcePos{Line: 1, Column: 56, Byte: 55}, + }, + }, + }, + { + Input: "module.module_name", + WantErr: "Action addresses must contain an action reference after the module reference.", + }, + { + Input: "module.module_name.resource_type.resource_name", + WantErr: "Action specification must start with `action`.", + }, + } + for _, test := range tcs { + t.Run(test.Input, func(t *testing.T) { + traversal, travDiags := hclsyntax.ParseTraversalAbs([]byte(test.Input), "", hcl.Pos{Line: 1, Column: 1}) + if travDiags.HasErrors() { + t.Fatal(travDiags.Error()) + } + + got, diags := ParseTargetAction(traversal) + + switch len(diags) { + case 0: + if test.WantErr != "" { + t.Fatalf("succeeded; want error: %s", test.WantErr) + } + case 1: + if test.WantErr == "" { + t.Fatalf("unexpected diagnostics: %s", diags.Err()) + } + if got, want := diags[0].Description().Detail, test.WantErr; got != want { + t.Fatalf("wrong error\ngot: %s\nwant: %s", got, want) + } + default: + t.Fatalf("too many diagnostics: %s", diags.Err()) + } + + if diags.HasErrors() { + return + } + + for _, problem := range deep.Equal(got, test.Want) { + t.Error(problem) + } + }) + } + +} diff --git a/internal/addrs/targetable.go b/internal/addrs/targetable.go index d179d8129e..ac19c46736 100644 --- a/internal/addrs/targetable.go +++ b/internal/addrs/targetable.go @@ -42,4 +42,6 @@ const ( AbsResourceAddrType ModuleAddrType ModuleInstanceAddrType + ActionAddrType + ActionInstanceAddrType )