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" +}