Planning and Applying: The Resource Instance Change Lifecycle
Prerequisites
- ›Article 1: Architecture and Codebase Navigation
- ›Article 2: Graph Engine and DAG Walk
- ›Understanding of Terraform's plan/apply two-phase workflow as a user
Planning and Applying: The Resource Instance Change Lifecycle
In Article 1, we traced the path from main() to Context.Plan(). In Article 2, we dissected the graph engine that makes parallel execution possible. Now we follow a single resource instance through the complete lifecycle: from the moment Context.Plan() is called, through graph construction and walking, into the provider's PlanResourceChange call, and finally through Context.Apply() where the change materializes in real infrastructure.
This article covers the central abstractions that make the two-phase workflow possible: the Context orchestrator, the EvalContext interface that graph nodes use for all external interactions, the ContextGraphWalker that bridges these two worlds, and the Hook system that enables real-time progress reporting.
terraform.Context: The Orchestrator
The Context struct at internal/terraform/context.go#L90-L109 is the center of Terraform's universe:
type Context struct {
meta *ContextMeta
plugins *contextPlugins
hooks []Hook
sh *stopHook
uiInput UIInput
graphOpts *ContextGraphOpts
l sync.Mutex
parallelSem Semaphore
providerInputConfig map[string]map[string]cty.Value
runCond *sync.Cond
runContext context.Context
runContextCancel context.CancelFunc
}
classDiagram
class Context {
-plugins *contextPlugins
-hooks []Hook
-parallelSem Semaphore
+Plan(config, state, opts) (*Plan, Diagnostics)
+Apply(plan, config, opts) (*State, Diagnostics)
+Validate(config) Diagnostics
+Stop()
}
class ContextOpts {
+Hooks []Hook
+Parallelism int
+Providers map[Provider]Factory
+Provisioners map[string]Factory
+PreloadedProviderSchemas map
}
class contextPlugins {
-providerFactories map
-provisionerFactories map
-preloadedProviderSchemas map
}
ContextOpts --> Context : NewContext()
Context --> contextPlugins
NewContext() at lines 120-166 performs minimal setup: it copies hooks, adds the internal stopHook, validates parallelism (defaulting to 10), and wraps provider/provisioner factories into contextPlugins. The actual heavy lifting is deferred to the operation methods.
The parallelSem semaphore is worth noting. When par == 0, it defaults to 10 — a deliberate choice to limit both CPU pressure and provider API rate-limiting risk. This semaphore is acquired inside the graph walk callback, gating how many vertices execute concurrently regardless of how many goroutines the Walker creates.
The Plan Phase: PlanAndEval()
When you call Context.Plan() at internal/terraform/context_plan.go#L180-L183, it delegates to PlanAndEval():
func (c *Context) Plan(config *configs.Config, prevRunState *states.State, opts *PlanOpts) (*plans.Plan, tfdiags.Diagnostics) {
plan, _, diags := c.PlanAndEval(config, prevRunState, opts)
return plan, diags
}
PlanAndEval() at lines 194-250+ is the real entry point. It starts by acquiring the run lock (acquireRun("plan")), ensuring only one operation runs at a time, then:
- Validates configuration dependencies and state dependencies
- Checks external provider configurations
- Validates the plan mode (Normal, Destroy, RefreshOnly)
- Processes
movedandremovedblocks for refactoring - Builds and walks the plan graph
- Collects the resulting
Changes
The PlanOpts struct at lines 32-163 controls the plan's behavior:
sequenceDiagram
participant Caller
participant Ctx as Context
participant PGB as PlanGraphBuilder
participant Walker as ContextGraphWalker
participant Node as NodePlannableResourceInstance
Caller->>Ctx: PlanAndEval(config, state, opts)
Ctx->>Ctx: acquireRun("plan")
Ctx->>Ctx: checkConfigDependencies
Ctx->>Ctx: process moved/removed blocks
Ctx->>PGB: Build(rootModule)
PGB-->>Ctx: *Graph
Ctx->>Walker: graph.Walk(walker)
Walker->>Node: Execute(evalCtx, walkPlan)
Node->>Node: managedResourceExecute()
Node-->>Walker: changes recorded
Walker-->>Ctx: walk complete
Ctx-->>Caller: *plans.Plan, Diagnostics
Tip: Terraform supports a deferred-actions loop. If
DeferralAllowedis set inPlanOpts, the plan phase may run multiple rounds, deferring resources whosecountorfor_eachcan't be evaluated yet. This is the mechanism behind the "deferred changes" feature.
EvalContext and ContextGraphWalker
When a graph node's Execute() method runs, it receives an EvalContext — the interface that provides access to everything outside the node itself. Defined at internal/terraform/eval_context.go#L36-L99, its key methods include:
type EvalContext interface {
StopCtx() context.Context
Path() addrs.ModuleInstance
Hook(func(Hook) (HookAction, error)) error
InitProvider(addr, configs) (providers.Interface, error)
Provider(addrs.AbsProviderConfig) providers.Interface
ProviderSchema(addrs.AbsProviderConfig) (ProviderSchema, error)
ConfigureProvider(addrs.AbsProviderConfig, cty.Value) Diagnostics
// ... state access, changes, evaluation, etc.
}
The concrete implementation is BuiltinEvalContext, but graph nodes never see it directly — they program against the interface. This enables testing with mock eval contexts.
The ContextGraphWalker at internal/terraform/graph_walk_context.go#L34-L78 bridges the graph walk with the Context:
type ContextGraphWalker struct {
NullGraphWalker
Context *Context
State *states.SyncState
RefreshState *states.SyncState
PrevRunState *states.SyncState
Changes *plans.ChangesSync
Checks *checks.State
NamedValues *namedvals.State
InstanceExpander *instances.Expander
Deferrals *deferring.Deferred
Operation walkOperation
// ...
}
classDiagram
class EvalContext {
<<interface>>
+Path() ModuleInstance
+Hook(cb) error
+Provider(addr) Interface
+State() *SyncState
+Changes() *ChangesSync
}
class ContextGraphWalker {
+Context *Context
+State *SyncState
+Changes *ChangesSync
+Operation walkOperation
+EvalContext() EvalContext
+enterScope(scope) EvalContext
}
class BuiltinEvalContext {
-walker *ContextGraphWalker
-scope evalContextScope
}
EvalContext <|.. BuiltinEvalContext
ContextGraphWalker --> BuiltinEvalContext : creates
ContextGraphWalker --> Context : references
Notice that State is wrapped in SyncState and Changes in ChangesSync — thread-safe wrappers that allow concurrent graph nodes to safely read and write shared data. As we discussed in Article 2, the Walker spawns goroutines for every vertex, so this concurrency safety is essential.
Node Execution: Planning a Resource Instance
When the Walker reaches a resource vertex, it calls Execute() on NodePlannableResourceInstance at internal/terraform/node_resource_plan_instance.go#L73-L89:
func (n *NodePlannableResourceInstance) Execute(ctx EvalContext, op walkOperation) tfdiags.Diagnostics {
addr := n.ResourceInstanceAddr()
switch addr.Resource.Resource.Mode {
case addrs.ManagedResourceMode:
return n.managedResourceExecute(ctx)
case addrs.DataResourceMode:
return n.dataResourceExecute(ctx)
case addrs.EphemeralResourceMode:
return n.ephemeralResourceExecute(ctx)
case addrs.ListResourceMode:
return n.listResourceExecute(ctx)
default:
panic(fmt.Errorf("unsupported resource mode %s", n.Config.Mode))
}
}
The dispatch by resource mode is clean and extensible. For a managed resource (managedResourceExecute), the flow is:
- Read prior state — fetch the resource's current state from
SyncState - Refresh (unless skipped) — call
provider.ReadResource()to get the latest real-world state - Propose new state — evaluate the resource's configuration to produce the desired state
- Plan the change — call
provider.PlanResourceChange()with prior state, proposed state, and config - Record the change — write a
ResourceInstanceChangetoChangesSync
sequenceDiagram
participant Node as NodePlannableResourceInstance
participant ECtx as EvalContext
participant Provider as providers.Interface
participant State as SyncState
participant Changes as ChangesSync
Node->>State: Read prior state
State-->>Node: priorState
Node->>Provider: ReadResource(priorState)
Provider-->>Node: refreshedState
Node->>Node: Evaluate config → proposedNewState
Node->>Provider: PlanResourceChange(prior, proposed, config)
Provider-->>Node: plannedNewState + requiresReplace
Node->>Changes: AppendResourceInstanceChange(change)
The PlanResourceChange response from the provider includes the planned new state — which may contain unknown values for computed attributes — and optionally a list of attributes that require replacement rather than in-place update.
The Apply Phase: ApplyAndEval()
Context.Apply() at internal/terraform/context_apply.go#L81-L84 delegates to ApplyAndEval() (lines 95-150+):
func (c *Context) Apply(plan *plans.Plan, config *configs.Config, opts *ApplyOpts) (*states.State, tfdiags.Diagnostics) {
state, _, diags := c.ApplyAndEval(plan, config, opts)
return state, diags
}
The apply phase differs from planning in several key ways:
- It takes a
*plans.Planas input rather than producing one - It validates that the plan is applyable (no errors, changes are consistent)
- It builds an
ApplyGraphBuilderfrom the plan's change set NodeApplyableResourceInstancecallsprovider.ApplyResourceChange()instead ofPlanResourceChange()- State is updated during the walk via
SyncState, not just at the end
The graph walk uses the same Context.walk() infrastructure at internal/terraform/context_walk.go#L86-L99:
func (c *Context) walk(graph *Graph, operation walkOperation, opts *graphWalkOpts) (*ContextGraphWalker, tfdiags.Diagnostics) {
walker := c.graphWalker(graph, operation, opts)
watchStop, watchWait := c.watchStop(walker)
diags := graph.Walk(walker)
close(watchStop)
<-watchWait
// ...
}
The watchStop goroutine monitors the StopContext and calls provider.Stop() on all active providers when a cancellation signal arrives. This is how Ctrl-C gracefully interrupts a running apply.
The Hook System: Observing Execution
The Hook interface at internal/terraform/hook.go#L56-L120+ provides ~20 observer callbacks:
type Hook interface {
PreApply(id HookResourceIdentity, dk addrs.DeposedKey, action plans.Action, priorState, plannedNewState cty.Value) (HookAction, error)
PostApply(id HookResourceIdentity, dk addrs.DeposedKey, newState cty.Value, err error) (HookAction, error)
PreDiff(id HookResourceIdentity, dk addrs.DeposedKey, priorState, proposedNewState cty.Value, err error) (HookAction, error)
PostDiff(id HookResourceIdentity, dk addrs.DeposedKey, action plans.Action, priorState, plannedNewState cty.Value, err error) (HookAction, error)
PreRefresh(...)
PostRefresh(...)
PreProvisionInstance(...)
PostProvisionInstance(...)
ProvisionOutput(...)
// ... and more
}
classDiagram
class Hook {
<<interface>>
+PreApply() HookAction
+PostApply() HookAction
+PreDiff() HookAction
+PostDiff() HookAction
+PreRefresh() HookAction
+PostRefresh() HookAction
+PreProvisionInstance() HookAction
+ProvisionOutput()
}
class NilHook {
+PreApply() HookAction
+PostApply() HookAction
}
class UiHook {
+PreApply() HookAction
+PostApply() HookAction
}
class JSONHook {
+PreApply() HookAction
+PostApply() HookAction
}
Hook <|.. NilHook
NilHook <|-- UiHook
NilHook <|-- JSONHook
The NilHook base struct implements all methods as no-ops, returning HookActionContinue. Concrete hooks embed NilHook and override only the methods they care about. This is the classic "null object" pattern applied to Go interfaces.
Hooks power both the human-readable CLI output (spinners, timing, resource counts) and the JSON streaming output used by CI/CD integrations. Node execution calls hooks through EvalContext.Hook(), which iterates over all registered hooks and stops if any returns HookActionHalt.
Tip: The
HookActionreturn value means hooks can cancel operations. If a hook returnsHookActionHalt, the current node's operation stops immediately. This is used by thestopHookto implementContext.Stop()— when a stop is requested, the hook starts returningHookActionHaltfor every subsequent callback.
Changes and State: The Accumulation Pattern
During a plan walk, changes accumulate in a plans.Changes struct (internal/plans/changes.go#L22-L41):
type Changes struct {
Resources []*ResourceInstanceChange
Queries []*QueryInstance
ActionInvocations ActionInvocationInstances
Outputs []*OutputChange
}
During an apply walk, state updates happen in-place through SyncState. Each NodeApplyableResourceInstance writes the result of ApplyResourceChange() directly to the state, meaning the state is continuously updated as the walk progresses. If the apply fails partway through, the state reflects exactly which resources were successfully modified — this partial state is critical for recovery.
What's Ahead
We've now seen how resources flow through the plan and apply phases. But we've been treating the provider as a black box — a thing that accepts PlanResourceChange() calls and returns planned states. In Article 4, we'll open that box and explore the provider plugin system: how providers are discovered, launched as separate OS processes, connected over gRPC, and how the three-layer abstraction (providers.Interface → GRPCProvider → protobuf) translates between Terraform's type system and the wire format.