Read OSS

Planning and Applying: The Resource Instance Change Lifecycle

Advanced

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:

  1. Validates configuration dependencies and state dependencies
  2. Checks external provider configurations
  3. Validates the plan mode (Normal, Destroy, RefreshOnly)
  4. Processes moved and removed blocks for refactoring
  5. Builds and walks the plan graph
  6. 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 DeferralAllowed is set in PlanOpts, the plan phase may run multiple rounds, deferring resources whose count or for_each can'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:

  1. Read prior state — fetch the resource's current state from SyncState
  2. Refresh (unless skipped) — call provider.ReadResource() to get the latest real-world state
  3. Propose new state — evaluate the resource's configuration to produce the desired state
  4. Plan the change — call provider.PlanResourceChange() with prior state, proposed state, and config
  5. Record the change — write a ResourceInstanceChange to ChangesSync
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:

  1. It takes a *plans.Plan as input rather than producing one
  2. It validates that the plan is applyable (no errors, changes are consistent)
  3. It builds an ApplyGraphBuilder from the plan's change set
  4. NodeApplyableResourceInstance calls provider.ApplyResourceChange() instead of PlanResourceChange()
  5. 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 HookAction return value means hooks can cancel operations. If a hook returns HookActionHalt, the current node's operation stops immediately. This is used by the stopHook to implement Context.Stop() — when a stop is requested, the hook starts returning HookActionHalt for 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.InterfaceGRPCProvider → protobuf) translates between Terraform's type system and the wire format.