Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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.16/ENHANCEMENTS-20260622-102851.yaml
Original file line number Diff line number Diff line change
@@ -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"
10 changes: 10 additions & 0 deletions internal/rpcapi/stacks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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
Expand Down
44 changes: 44 additions & 0 deletions internal/rpcapi/stacks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 18 additions & 6 deletions internal/rpcapi/terraform1/stacks/stacks.pb.go

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

4 changes: 4 additions & 0 deletions internal/rpcapi/terraform1/stacks/stacks.proto
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,10 @@ message PlanStackChanges {
int64 dependency_locks_handle = 4;
int64 provider_cache_handle = 5;
map<string, DynamicValueWithSource> 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 {
Expand Down
2 changes: 2 additions & 0 deletions internal/stacks/stackruntime/helper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions internal/stacks/stackruntime/internal/stackeval/planning.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions internal/stacks/stackruntime/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand Down Expand Up @@ -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.
Expand Down
88 changes: 88 additions & 0 deletions internal/stacks/stackruntime/plan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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"
}