diff --git a/.changes/v1.15/BUG FIXES-20260115-103000.yaml b/.changes/v1.15/BUG FIXES-20260115-103000.yaml new file mode 100644 index 000000000000..61fec4c20f2e --- /dev/null +++ b/.changes/v1.15/BUG FIXES-20260115-103000.yaml @@ -0,0 +1,5 @@ +kind: BUG FIXES +body: 'stacks: component instances should report no-op plan/apply. This solves a UI inconsistency with convergence destroy plans ' +time: 2026-01-15T10:30:00.72402+01:00 +custom: + Issue: "38049" diff --git a/internal/stacks/stackruntime/apply_destroy_test.go b/internal/stacks/stackruntime/apply_destroy_test.go index 260f6cede7f2..16fbb6595d10 100644 --- a/internal/stacks/stackruntime/apply_destroy_test.go +++ b/internal/stacks/stackruntime/apply_destroy_test.go @@ -1718,6 +1718,18 @@ func TestApplyDestroy(t *testing.T) { InstanceAddrs: []stackaddrs.AbsComponentInstance{mustAbsComponentInstance("component.self")}, }, }, + PendingComponentInstancePlan: collections.NewSet[stackaddrs.AbsComponentInstance]( + mustAbsComponentInstance("component.self"), + ), + BeginComponentInstancePlan: collections.NewSet[stackaddrs.AbsComponentInstance]( + mustAbsComponentInstance("component.self"), + ), + EndComponentInstancePlan: collections.NewSet[stackaddrs.AbsComponentInstance]( + mustAbsComponentInstance("component.self"), + ), + ReportComponentInstancePlanned: []*hooks.ComponentInstanceChange{{ + Addr: mustAbsComponentInstance("component.self"), + }}, }, wantAppliedHooks: &ExpectedHooks{ ComponentExpanded: []*hooks.ComponentInstances{ @@ -1726,6 +1738,18 @@ func TestApplyDestroy(t *testing.T) { InstanceAddrs: []stackaddrs.AbsComponentInstance{mustAbsComponentInstance("component.self")}, }, }, + PendingComponentInstanceApply: collections.NewSet[stackaddrs.AbsComponentInstance]( + mustAbsComponentInstance("component.self"), + ), + BeginComponentInstanceApply: collections.NewSet[stackaddrs.AbsComponentInstance]( + mustAbsComponentInstance("component.self"), + ), + EndComponentInstanceApply: collections.NewSet[stackaddrs.AbsComponentInstance]( + mustAbsComponentInstance("component.self"), + ), + ReportComponentInstanceApplied: []*hooks.ComponentInstanceChange{{ + Addr: mustAbsComponentInstance("component.self"), + }}, }, }, }, diff --git a/internal/stacks/stackruntime/internal/stackeval/component_instance.go b/internal/stacks/stackruntime/internal/stackeval/component_instance.go index 2c330e96352d..144a7b3cafe3 100644 --- a/internal/stacks/stackruntime/internal/stackeval/component_instance.go +++ b/internal/stacks/stackruntime/internal/stackeval/component_instance.go @@ -22,6 +22,7 @@ import ( "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/stacks/stackaddrs" "github.com/hashicorp/terraform/internal/stacks/stackplan" + "github.com/hashicorp/terraform/internal/stacks/stackruntime/hooks" "github.com/hashicorp/terraform/internal/stacks/stackstate" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/terraform" @@ -192,6 +193,7 @@ func (c *ComponentInstance) CheckModuleTreePlan(ctx context.Context) (*plans.Pla ctx, c.tracingName()+" modules", &c.moduleTreePlan, func(ctx context.Context) (*plans.Plan, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics + h := hooksFromContext(ctx) if c.mode == plans.DestroyMode { @@ -203,7 +205,13 @@ func (c *ComponentInstance) CheckModuleTreePlan(ctx context.Context) (*plans.Pla // and never applied, or that it was previously destroyed // via an earlier destroy operation. // - // Return a dummy plan: + // Return a dummy plan and send dummy events: + hookSingle(ctx, h.PendingComponentInstancePlan, c.Addr()) + seq, ctx := hookBegin(ctx, h.BeginComponentInstancePlan, h.ContextAttach, c.Addr()) + hookMore(ctx, seq, h.ReportComponentInstancePlanned, &hooks.ComponentInstanceChange{ + Addr: c.Addr(), + }) + hookMore(ctx, seq, h.EndComponentInstancePlan, c.Addr()) return &plans.Plan{ UIMode: plans.DestroyMode, Complete: true, @@ -220,7 +228,6 @@ func (c *ComponentInstance) CheckModuleTreePlan(ctx context.Context) (*plans.Pla // outputs from this component can read from the refresh result // without causing a cycle. - h := hooksFromContext(ctx) hookSingle(ctx, h.PendingComponentInstancePlan, c.Addr()) seq, planCtx := hookBegin(ctx, h.BeginComponentInstancePlan, h.ContextAttach, c.Addr()) @@ -336,7 +343,6 @@ func (c *ComponentInstance) CheckModuleTreePlan(ctx context.Context) (*plans.Pla } } - h := hooksFromContext(ctx) hookSingle(ctx, h.PendingComponentInstancePlan, c.Addr()) seq, ctx := hookBegin(ctx, h.BeginComponentInstancePlan, h.ContextAttach, c.Addr()) plan, moreDiags := PlanComponentInstance(ctx, c.main, c.PlanPrevState(), opts, []terraform.Hook{ @@ -382,6 +388,15 @@ func (c *ComponentInstance) ApplyModuleTreePlan(ctx context.Context, plan *plans // If we're destroying and there's nothing to destroy, then we can // consider this a no-op. + // We still need to report through the hooks that this component instance has been handled. + h := hooksFromContext(ctx) + hookSingle(ctx, hooksFromContext(ctx).PendingComponentInstanceApply, c.Addr()) + seq, ctx := hookBegin(ctx, h.BeginComponentInstanceApply, h.ContextAttach, c.Addr()) + hookMore(ctx, seq, h.ReportComponentInstanceApplied, &hooks.ComponentInstanceChange{ + Addr: c.Addr(), + }) + hookMore(ctx, seq, h.EndComponentInstanceApply, c.Addr()) + return &ComponentInstanceApplyResult{ FinalState: plan.PriorState, // after refresh AffectedResourceInstanceObjects: resourceInstanceObjectsAffectedByStackPlan(stackPlan),