Chef Infra Client executes a run in two phases, compile and converge. What happens in each, and why does plain Ruby written inside a recipe so often run earlier than its author expected?
answer
- two passes, not one
- resources are queued, not run
- everything else is Ruby, right now
- the node has not changed yet
- lazy for a value, ruby_block for logic
basics
~20 sCompile evaluates every recipe as Ruby and builds an ordered resource collection; converge then walks that collection and lets each provider act. Bare Ruby in a recipe runs during compile — before any resource has taken effect — so it sees the node's pre-run state.
solid answer
~50 sA Chef run has two distinct passes. In the **compile phase** Chef Infra Client evaluates the run list top to bottom as Ruby: `include_recipe` pulls in more code, attribute files are read, and every resource block encountered is *added to the resource collection* rather than executed. In the **converge phase** Chef walks that collection in order and runs each resource's provider, which is where anything actually changes on the node. The trap is that a recipe is ordinary Ruby, so anything that is not a resource block — `File.exist?`, a `shell_out`, reading a file's contents, a conditional around those — executes during compile, when none of the resources have run yet. So a check for a file that an earlier `template` resource will create sees it missing, and a variable read from a package the recipe installs is empty. The fixes are `lazy` for a property value evaluated at converge time, and the `ruby_block` resource to move whole chunks of Ruby into the converge phase.
code
ruby · 15 linesexecute 'generate-secret' do
command '/usr/local/bin/gen-secret > /etc/app/secret'
creates '/etc/app/secret'
end
ruby_block 'read-secret-into-run-state' do
block do
node.run_state['app_secret'] = ::File.read('/etc/app/secret').strip
end
end
template '/etc/app/app.conf' do
source 'app.conf.erb'
variables(lazy { { secret: node.run_state['app_secret'] } })
endgo deeper
Know that a Chef run first reads all the recipes and collects resources, then applies them in a second pass. Do not expect a line of Ruby in a recipe to see changes the same recipe is about to make.
Explain compile versus converge precisely: resource blocks are queued into the resource collection, everything else is evaluated immediately. Name lazy and ruby_block as the two ways to move work into converge.
Diagnose from symptoms — a recipe that behaves differently on the first run than on later runs, or a template rendered with an empty value, is usually compile-time evaluation. Show how you would prove it with logging and then restructure rather than patch.
Set the convention that recipes declare and do not compute: heavy Ruby belongs in cookbook libraries and custom resources with tested interfaces, so junior contributors never have to learn this trap the hard way in production.
## Two passes, not one Chef Infra Client does not stream a recipe line by line into changes on the machine. Each run has a clear shape: 1. **Ohai** collects facts about the node — platform, network, memory, filesystems — into automatic attributes. 2. **Compile phase.** Chef expands the run list, loads each cookbook's libraries, attribute files and resources, then evaluates the recipes as Ruby. Every resource block it meets is instantiated and appended to the **resource collection**. Nothing is applied. 3. **Converge phase.** Chef walks the resource collection in order. For each resource it resolves the provider, loads current state, and acts only if the node diverges from the declaration. 4. Handlers and reporting run at the end. The important consequence: by the time *anything* has changed on the node, the entire body of every recipe has already been read and evaluated. ## Why the surprise happens A recipe file is Ruby, and this is deliberately powerful — you can loop, branch, and call library methods to build resources dynamically. But it means only the resource blocks are deferred. Everything else is plain Ruby that executes immediately, in compile. ```ruby # Compile phase: this line runs BEFORE the template below is written if ::File.exist?('/etc/app/app.conf') Chef::Log.info('config already present') end template '/etc/app/app.conf' do # queued, not written yet source 'app.conf.erb' end ``` On a fresh node the `File.exist?` check is false even on the run that creates the file, and it is still false on the *next* run at the moment it executes — no, on the next run the file exists, which is worse: the recipe now behaves differently between the first and second run for reasons the author cannot see in the source order. Classic variants of the same mistake: - Reading a password out of a file that an earlier resource in the same recipe generates. - Calling `shell_out('myapp --version')` on a binary the recipe installs. - Interpolating `node['app']['port']` into a property when that attribute is set by a `ruby_block` later in the run. ## `lazy` — defer one property When only a single property value needs the converge-time answer, wrap it in `lazy`. The block is evaluated when the provider reads the property, not when the resource is declared. ```ruby template '/etc/app/generated.conf' do source 'generated.conf.erb' variables(lazy { { secret: ::File.read('/etc/app/secret') } }) end ``` ## `ruby_block` — defer a whole chunk When you need real logic to run in the middle of the converge, put it in a `ruby_block` resource. Because it is a resource, it takes its place in the collection and runs at that point in the order — and it can be notified, and it can notify. ```ruby execute 'generate-secret' do command '/usr/local/bin/gen-secret > /etc/app/secret' creates '/etc/app/secret' end ruby_block 'read-secret-into-node' do block do node.run_state['app_secret'] = ::File.read('/etc/app/secret').strip end end template '/etc/app/app.conf' do source 'app.conf.erb' variables(lazy { { secret: node.run_state['app_secret'] } }) end ``` Note `node.run_state` rather than a normal attribute: run_state is a scratch space for the current run and is not saved back to the Chef Infra Server, which is what you want for a secret. ## Compile-time execution, on purpose Occasionally you genuinely need a resource to act during compile — the canonical case is installing a gem that a later library or resource must `require`. Chef's `chef_gem` resource is designed for exactly that and runs against the client's own Ruby. The general escape hatch is calling `.run_action(:something)` on a resource to force it immediately, but treat it as a smell: it breaks the mental model that order in the collection is order of execution, and it makes the recipe hard to reason about for everyone after you. ## Why interviewers ask this The two-phase model is the single biggest difference between reading a Chef recipe as a script and reading it as a declaration. Candidates who have only skimmed cookbooks describe a run as "it goes through the recipe and does each thing" — which is right about the ordering and wrong about the timing, and that gap is what produces the bug reports. Being able to say "that check runs at compile time, so wrap it in a ruby_block" is the fastest possible demonstration that you have actually debugged a Chef run. ## How you see it The run output lists the resource collection as it converges, with each resource marked up to date or updated. Running `chef-client --why-run` (also spelled `-W`) performs a compile and then reports what converge *would* change without changing it — useful, though limited, because resources whose behaviour depends on earlier resources having run cannot be predicted accurately. Chef's own logs distinguish the phases, and `Chef::Log.info` calls placed in bare recipe code versus inside a `ruby_block` are a quick way to prove the ordering to yourself.
- When would you reach for `lazy` rather than a `ruby_block`?Use `lazy` when a single resource property needs a value that is only correct at converge time — a file's contents, an attribute another resource sets. The block is evaluated when the provider reads that property. Reach for `ruby_block` when you need statements rather than a value: mutating run_state, calling out to something, or logic that must sit at a specific point in the resource collection and be able to notify other resources.
- Why is calling `.run_action` on a resource inside a recipe usually a bad idea?It forces the resource to act during compile, out of band with the collection. The run's reported order no longer matches its real execution order, notifications and guards behave unexpectedly, and anyone reading the recipe later has to spot the one line that breaks the model. The legitimate cases are narrow — installing a gem the client itself needs, which `chef_gem` already handles.
- How does the two-phase model interact with attributes set inside a recipe?Attribute files load during compile, so attributes set there are visible to every recipe evaluated afterwards. An attribute assigned in bare recipe code is also set at compile time, but one assigned inside a `ruby_block` is not set until that point in the converge — so any resource declared earlier that reads it directly will have captured the old value. That is precisely the case for `lazy` or `node.run_state`.
saying these in an interview costs you the question
- Describes a Chef run as executing each recipe line in order on the node
- Expects a File.exist? check in a recipe to see files the same recipe creates
- Thinks resource blocks execute where they appear in the source
- Believes wrapping a resource in an if statement defers the check to converge
- Uses run_action to force ordering instead of fixing the resource order