From b6e6137a4018fe5f903c722a9ab18d4e72d1fe29 Mon Sep 17 00:00:00 2001 From: Roniece Date: Mon, 22 Jun 2026 10:34:17 -0400 Subject: [PATCH] stacks: add invoke_action_addrs plan option for direct action invocation Adds a new repeated string invoke_action_addrs field (proto field 8) to PlanStackChanges.Request, parsed in the RPC handler (InvalidArgument on a bad address) and threaded through PlanRequest and stackeval.PlanOpts. ComponentInstance.PlanOpts now collects any invoke addresses that target the instance, sets them as ActionTargets, and forces RefreshOnlyMode so only the action is planned while all other resource changes are suppressed. This reuses the existing Core action-targeting engine without touching context_plan.go or any apply-side graph code. Includes a stackruntime plan test (direct-invoke-action fixture) and an rpcapi test covering the InvalidArgument path, plus a changelog entry. --- .../v1.16/ENHANCEMENTS-20260622-102851.yaml | 5 ++ internal/rpcapi/stacks.go | 10 +++ internal/rpcapi/stacks_test.go | 44 ++++++++++ .../rpcapi/terraform1/stacks/stacks.pb.go | 24 +++-- .../rpcapi/terraform1/stacks/stacks.proto | 4 + internal/stacks/stackruntime/helper_test.go | 2 + .../internal/stackeval/component_instance.go | 19 +++- .../internal/stackeval/planning.go | 5 ++ internal/stacks/stackruntime/plan.go | 6 ++ internal/stacks/stackruntime/plan_test.go | 88 +++++++++++++++++++ .../direct-invoke-action.tfcomponent.hcl | 19 ++++ .../direct-invoke-action/module_web/main.tf | 25 ++++++ 12 files changed, 244 insertions(+), 7 deletions(-) create mode 100644 .changes/v1.16/ENHANCEMENTS-20260622-102851.yaml create mode 100644 internal/stacks/stackruntime/testdata/mainbundle/test/direct-invoke-action/direct-invoke-action.tfcomponent.hcl create mode 100644 internal/stacks/stackruntime/testdata/mainbundle/test/direct-invoke-action/module_web/main.tf diff --git a/.changes/v1.16/ENHANCEMENTS-20260622-102851.yaml b/.changes/v1.16/ENHANCEMENTS-20260622-102851.yaml new file mode 100644 index 000000000000..6dccd39d5d11 --- /dev/null +++ b/.changes/v1.16/ENHANCEMENTS-20260622-102851.yaml @@ -0,0 +1,5 @@ +kind: ENHANCEMENTS +body: 'stacks: add `invoke_action_addrs` plan option to directly invoke pre-defined actions, scoping the targeted component instance to a refresh-only plan that triggers only the action' +time: 2026-06-22T10:28:51.000000-04:00 +custom: + Issue: "0" diff --git a/internal/rpcapi/stacks.go b/internal/rpcapi/stacks.go index e39c71d10365..4a554a946f45 100644 --- a/internal/rpcapi/stacks.go +++ b/internal/rpcapi/stacks.go @@ -404,6 +404,15 @@ func (s *stacksServer) PlanStackChanges(req *stacks.PlanStackChanges_Request, ev } } + invokeActionAddrs := make([]stackaddrs.AbsActionInvocationInstance, 0, len(req.InvokeActionAddrs)) + for _, raw := range req.InvokeActionAddrs { + addr, diags := stackaddrs.ParseActionInvocationInstanceStr(raw) + if diags.HasErrors() { + return status.Errorf(codes.InvalidArgument, "invalid invoke action address %q: %s", raw, diags.Err()) + } + invokeActionAddrs = append(invokeActionAddrs, addr) + } + changesCh := make(chan stackplan.PlannedChange, 8) diagsCh := make(chan tfdiags.Diagnostic, 2) rtReq := stackruntime.PlanRequest{ @@ -414,6 +423,7 @@ func (s *stacksServer) PlanStackChanges(req *stacks.PlanStackChanges_Request, ev InputValues: inputValues, ExperimentsAllowed: s.experimentsAllowed, DependencyLocks: *deps, + InvokeActionAddrs: invokeActionAddrs, // planTimestampOverride will be null if not set, so it's fine for // us to just set this all the time. In practice, this will only have diff --git a/internal/rpcapi/stacks_test.go b/internal/rpcapi/stacks_test.go index b3cf9c814da5..326c1d92452e 100644 --- a/internal/rpcapi/stacks_test.go +++ b/internal/rpcapi/stacks_test.go @@ -488,6 +488,50 @@ func TestStacksPlanStackChanges(t *testing.T) { } } +func TestStacksPlanStackChanges_invalidInvokeActionAddr(t *testing.T) { + ctx := context.Background() + + handles := newHandleTable() + stacksServer := newStacksServer(newStopper(), handles, disco.New(), &serviceOpts{}) + + fakeSourceBundle := &sourcebundle.Bundle{} + bundleHnd := handles.NewSourceBundle(fakeSourceBundle) + emptyConfig := &stackconfig.Config{ + Root: &stackconfig.ConfigNode{ + Stack: &stackconfig.Stack{ + SourceAddr: sourceaddrs.MustParseSource("git::https://example.com/foo.git").(sourceaddrs.RemoteSource), + }, + }, + } + configHnd, err := handles.NewStackConfig(emptyConfig, bundleHnd) + if err != nil { + t.Fatal(err) + } + + grpcClient, close := grpcClientForTesting(ctx, t, func(srv *grpc.Server) { + stacks.RegisterStacksServer(srv, stacksServer) + }) + defer close() + + stacksClient := stacks.NewStacksClient(grpcClient) + events, err := stacksClient.PlanStackChanges(ctx, &stacks.PlanStackChanges_Request{ + PlanMode: stacks.PlanMode_NORMAL, + StackConfigHandle: configHnd.ForProtobuf(), + InvokeActionAddrs: []string{"this is not a valid address"}, + }) + if err != nil { + t.Fatalf("unexpected error establishing stream: %s", err) + } + + _, err = events.Recv() + if err == nil { + t.Fatal("expected an error for an invalid invoke action address, but got none") + } + if got, want := status.Code(err), codes.InvalidArgument; got != want { + t.Fatalf("wrong error code: got %s, want %s (err: %s)", got, want, err) + } +} + func TestStackChangeProgressDuringPlanNormal(t *testing.T) { tcs := map[string]struct { source string diff --git a/internal/rpcapi/terraform1/stacks/stacks.pb.go b/internal/rpcapi/terraform1/stacks/stacks.pb.go index dae6720f6231..e2f9f994c8b3 100644 --- a/internal/rpcapi/terraform1/stacks/stacks.pb.go +++ b/internal/rpcapi/terraform1/stacks/stacks.pb.go @@ -3666,9 +3666,13 @@ type PlanStackChanges_Request struct { PreviousState map[string]*anypb.Any `protobuf:"bytes,3,rep,name=previous_state,json=previousState,proto3" json:"previous_state,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` DependencyLocksHandle int64 `protobuf:"varint,4,opt,name=dependency_locks_handle,json=dependencyLocksHandle,proto3" json:"dependency_locks_handle,omitempty"` ProviderCacheHandle int64 `protobuf:"varint,5,opt,name=provider_cache_handle,json=providerCacheHandle,proto3" json:"provider_cache_handle,omitempty"` - InputValues map[string]*DynamicValueWithSource `protobuf:"bytes,6,rep,name=input_values,json=inputValues,proto3" json:"input_values,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` // TODO: Various other planning options - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + InputValues map[string]*DynamicValueWithSource `protobuf:"bytes,6,rep,name=input_values,json=inputValues,proto3" json:"input_values,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // invoke_action_addrs lists full action invocation instance addresses to + // directly invoke during this plan. When set, the matched component + // instance plans in refresh-only mode targeting only the action. + InvokeActionAddrs []string `protobuf:"bytes,8,rep,name=invoke_action_addrs,json=invokeActionAddrs,proto3" json:"invoke_action_addrs,omitempty"` // TODO: Various other planning options + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *PlanStackChanges_Request) Reset() { @@ -3751,6 +3755,13 @@ func (x *PlanStackChanges_Request) GetInputValues() map[string]*DynamicValueWith return nil } +func (x *PlanStackChanges_Request) GetInvokeActionAddrs() []string { + if x != nil { + return x.InvokeActionAddrs + } + return nil +} + type PlanStackChanges_Event struct { state protoimpl.MessageState `protogen:"open.v1"` // Types that are valid to be assigned to Event: @@ -7566,8 +7577,8 @@ const file_stacks_proto_rawDesc = "" + "\aRequest\x12!\n" + "\fstate_handle\x18\x01 \x01(\x03R\vstateHandle\x1a\n" + "\n" + - "\bResponse\"\x9b\a\n" + - "\x10PlanStackChanges\x1a\xa2\x05\n" + + "\bResponse\"\xcb\a\n" + + "\x10PlanStackChanges\x1a\xd2\x05\n" + "\aRequest\x128\n" + "\tplan_mode\x18\x01 \x01(\x0e2\x1b.terraform1.stacks.PlanModeR\bplanMode\x12.\n" + "\x13stack_config_handle\x18\x02 \x01(\x03R\x11stackConfigHandle\x122\n" + @@ -7575,7 +7586,8 @@ const file_stacks_proto_rawDesc = "" + "\x0eprevious_state\x18\x03 \x03(\v2>.terraform1.stacks.PlanStackChanges.Request.PreviousStateEntryB\x02\x18\x01R\rpreviousState\x126\n" + "\x17dependency_locks_handle\x18\x04 \x01(\x03R\x15dependencyLocksHandle\x122\n" + "\x15provider_cache_handle\x18\x05 \x01(\x03R\x13providerCacheHandle\x12_\n" + - "\finput_values\x18\x06 \x03(\v2<.terraform1.stacks.PlanStackChanges.Request.InputValuesEntryR\vinputValues\x1aV\n" + + "\finput_values\x18\x06 \x03(\v2<.terraform1.stacks.PlanStackChanges.Request.InputValuesEntryR\vinputValues\x12.\n" + + "\x13invoke_action_addrs\x18\b \x03(\tR\x11invokeActionAddrs\x1aV\n" + "\x12PreviousStateEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12*\n" + "\x05value\x18\x02 \x01(\v2\x14.google.protobuf.AnyR\x05value:\x028\x01\x1ai\n" + diff --git a/internal/rpcapi/terraform1/stacks/stacks.proto b/internal/rpcapi/terraform1/stacks/stacks.proto index f3bd8f35258f..00ce40f9ac85 100644 --- a/internal/rpcapi/terraform1/stacks/stacks.proto +++ b/internal/rpcapi/terraform1/stacks/stacks.proto @@ -257,6 +257,10 @@ message PlanStackChanges { int64 dependency_locks_handle = 4; int64 provider_cache_handle = 5; map input_values = 6; + // invoke_action_addrs lists full action invocation instance addresses to + // directly invoke during this plan. When set, the matched component + // instance plans in refresh-only mode targeting only the action. + repeated string invoke_action_addrs = 8; // TODO: Various other planning options } message Event { diff --git a/internal/stacks/stackruntime/helper_test.go b/internal/stacks/stackruntime/helper_test.go index 15e91a7f3ae8..75418f7cc61b 100644 --- a/internal/stacks/stackruntime/helper_test.go +++ b/internal/stacks/stackruntime/helper_test.go @@ -60,6 +60,7 @@ type TestCycle struct { planMode plans.Mode planInputs map[string]cty.Value + invokeActionAddrs []stackaddrs.AbsActionInvocationInstance wantPlannedChanges []stackplan.PlannedChange wantPlannedHooks *ExpectedHooks wantPlannedDiags tfdiags.Diagnostics @@ -100,6 +101,7 @@ func (tc TestContext) Plan(t *testing.T, ctx context.Context, state *stackstate. DependencyLocks: tc.dependencyLocks, ForcePlanTimestamp: tc.timestamp, ExperimentsAllowed: true, + InvokeActionAddrs: cycle.invokeActionAddrs, } changesCh := make(chan stackplan.PlannedChange) diff --git a/internal/stacks/stackruntime/internal/stackeval/component_instance.go b/internal/stacks/stackruntime/internal/stackeval/component_instance.go index b1ea7ac257ea..338e3acb59e6 100644 --- a/internal/stacks/stackruntime/internal/stackeval/component_instance.go +++ b/internal/stacks/stackruntime/internal/stackeval/component_instance.go @@ -164,15 +164,32 @@ func (c *ComponentInstance) PlanOpts(ctx context.Context, mode plans.Mode, skipR providerClients := configuredProviderClients(ctx, c.main, known, unknown, PlanPhase) + // If any direct action invocation addresses target this component + // instance, scope the plan to only those actions by setting them as + // ActionTargets and forcing RefreshOnlyMode. This produces "only the + // action, nothing else" for the matched component while leaving + // non-matching component instances untouched. + var actionTargets []addrs.Targetable + for _, target := range c.main.PlanningOpts().InvokeActionAddrs { + if target.Component.String() == c.Addr().String() { + actionTargets = append(actionTargets, target.Item) + } + } + effectiveMode := mode + if len(actionTargets) > 0 { + effectiveMode = plans.RefreshOnlyMode + } + plantimestamp := c.main.PlanTimestamp() return &terraform.PlanOpts{ - Mode: mode, + Mode: effectiveMode, SkipRefresh: skipRefresh, SetVariables: inputValues, ExternalProviders: providerClients, ExternalDependencyDeferred: c.deferred, DeferralAllowed: true, AllowRootEphemeralOutputs: false, // TODO(issues/37822): Enable this. + ActionTargets: actionTargets, // We want the same plantimestamp between all components and the stacks language ForcePlanTimestamp: &plantimestamp, diff --git a/internal/stacks/stackruntime/internal/stackeval/planning.go b/internal/stacks/stackruntime/internal/stackeval/planning.go index 5fddb770e8c7..08a6da40e358 100644 --- a/internal/stacks/stackruntime/internal/stackeval/planning.go +++ b/internal/stacks/stackruntime/internal/stackeval/planning.go @@ -31,6 +31,11 @@ type PlanOpts struct { PlanTimestamp time.Time DependencyLocks depsfile.Locks + + // InvokeActionAddrs lists full action invocation instance addresses to + // directly invoke during this plan. When set, the matched component + // instance plans in refresh-only mode targeting only the action. + InvokeActionAddrs []stackaddrs.AbsActionInvocationInstance } // Plannable is implemented by objects that can participate in planning. diff --git a/internal/stacks/stackruntime/plan.go b/internal/stacks/stackruntime/plan.go index f7064d7e861e..e5fbcea93c4a 100644 --- a/internal/stacks/stackruntime/plan.go +++ b/internal/stacks/stackruntime/plan.go @@ -51,6 +51,7 @@ func Plan(ctx context.Context, req *PlanRequest, resp *PlanResponse) { InputVariableValues: req.InputValues, ProviderFactories: req.ProviderFactories, DependencyLocks: req.DependencyLocks, + InvokeActionAddrs: req.InvokeActionAddrs, PlanTimestamp: planTimestamp, }) @@ -100,6 +101,11 @@ type PlanRequest struct { ProviderFactories map[addrs.Provider]providers.Factory DependencyLocks depsfile.Locks + // InvokeActionAddrs lists full action invocation instance addresses to + // directly invoke during this plan. When set, the matched component + // instance plans in refresh-only mode targeting only the action. + InvokeActionAddrs []stackaddrs.AbsActionInvocationInstance + // ForcePlanTimestamp, if not nil, will force the plantimestamp function // to return the given value instead of whatever real time the plan // operation started. This is for testing purposes only. diff --git a/internal/stacks/stackruntime/plan_test.go b/internal/stacks/stackruntime/plan_test.go index db79e0e5298a..25464a844b84 100644 --- a/internal/stacks/stackruntime/plan_test.go +++ b/internal/stacks/stackruntime/plan_test.go @@ -6499,6 +6499,94 @@ func TestPlanWithActionInvocationHooks(t *testing.T) { testCtx.Plan(t, ctx, stackstate.NewState(), cycle) } +// TestPlanWithDirectActionInvocation verifies that supplying an +// InvokeActionAddrs entry plans the targeted component in refresh-only mode, +// emitting the action invocation while suppressing the unrelated resource +// change. +func TestPlanWithDirectActionInvocation(t *testing.T) { + ctx := context.Background() + cfg := loadMainBundleConfigForTest(t, "direct-invoke-action") + + fakePlanTimestamp, err := time.Parse(time.RFC3339, "1991-08-25T20:57:08Z") + if err != nil { + t.Fatal(err) + } + + webComponentInstance := stackaddrs.AbsComponentInstance{ + Stack: stackaddrs.RootStackInstance, + Item: stackaddrs.ComponentInstance{ + Component: stackaddrs.Component{Name: "web"}, + }, + } + notifyActionInstance := addrs.RootModuleInstance.ActionInstance("testing_action", "notify", addrs.NoKey) + invokeAddr := stackaddrs.AbsActionInvocationInstance{ + Component: webComponentInstance, + Item: notifyActionInstance, + } + + providerFactories := map[addrs.Provider]providers.Factory{ + addrs.NewBuiltInProvider("testing"): func() (providers.Interface, error) { + return stacks_testing_provider.NewProvider(t), nil + }, + } + + changesCh := make(chan stackplan.PlannedChange) + diagsCh := make(chan tfdiags.Diagnostic) + request := PlanRequest{ + PlanMode: plans.NormalMode, + Config: cfg, + PrevState: stackstate.NewState(), + ProviderFactories: providerFactories, + ForcePlanTimestamp: &fakePlanTimestamp, + ExperimentsAllowed: true, + InvokeActionAddrs: []stackaddrs.AbsActionInvocationInstance{invokeAddr}, + } + response := PlanResponse{ + PlannedChanges: changesCh, + Diagnostics: diagsCh, + } + + go Plan(ctx, &request, &response) + gotChanges, diags := collectPlanOutput(changesCh, diagsCh) + reportDiagnosticsForTest(t, diags) + if len(diags) != 0 { + t.FailNow() + } + + // (1) the action invocation is emitted, and it is a *direct* invocation + // (InvokeActionTrigger) rather than a resource lifecycle trigger. + var foundDirectInvocation bool + // (2) the targeted component planned in RefreshOnly, so the unrelated + // testing_resource.main change must be suppressed (no Create change). + var foundResourceCreate bool + for _, change := range gotChanges { + switch c := change.(type) { + case *stackplan.PlannedChangeActionInvocationInstancePlanned: + if c.ActionInvocationAddr.String() == invokeAddr.String() { + if c.Invocation != nil { + if _, ok := c.Invocation.ActionTrigger.(*plans.InvokeActionTrigger); ok { + foundDirectInvocation = true + } + } + } + case *stackplan.PlannedChangeResourceInstancePlanned: + if c.ChangeSrc != nil && c.ChangeSrc.Action == plans.Create { + foundResourceCreate = true + } + } + } + + if !foundDirectInvocation { + t.Errorf("expected a direct action invocation for %s, but none was found", invokeAddr) + for i, change := range gotChanges { + t.Logf(" [%d] %T", i, change) + } + } + if foundResourceCreate { + t.Errorf("expected the unrelated resource change to be suppressed by refresh-only mode, but a Create change was planned") + } +} + func TestPlanWithDeferredActionInvocation(t *testing.T) { ctx := context.Background() cfg := loadMainBundleConfigForTest(t, "deferred-action") diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/direct-invoke-action/direct-invoke-action.tfcomponent.hcl b/internal/stacks/stackruntime/testdata/mainbundle/test/direct-invoke-action/direct-invoke-action.tfcomponent.hcl new file mode 100644 index 000000000000..1636bdd7436d --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/direct-invoke-action/direct-invoke-action.tfcomponent.hcl @@ -0,0 +1,19 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: BUSL-1.1 + +required_providers { + testing = { + source = "terraform.io/builtin/testing" + } +} + +provider "testing" "main" { +} + +component "web" { + source = "./module_web" + + providers = { + testing = provider.testing.main + } +} diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/direct-invoke-action/module_web/main.tf b/internal/stacks/stackruntime/testdata/mainbundle/test/direct-invoke-action/module_web/main.tf new file mode 100644 index 000000000000..dea8b87073d6 --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/direct-invoke-action/module_web/main.tf @@ -0,0 +1,25 @@ + +terraform { + required_providers { + testing = { + source = "terraform.io/builtin/testing" + + configuration_aliases = [testing] + } + } +} + +# A standalone, directly-invocable action. It is not wired to any resource +# lifecycle; it is intended to be invoked directly via invoke_action_addrs. +action "testing_action" "notify" { + config { + message = "directly invoked" + } +} + +# An ordinary resource that would otherwise be created during a normal plan. +# When the action is directly invoked, the plan runs in refresh-only mode for +# this component, so this resource change must be suppressed. +resource "testing_resource" "main" { + value = "example" +}