CoursesChefAttributes & precedence

Attributes & precedence

Where node values come from.

Intermediate14 min · lesson 5 of 12

A laminated recipe card in a busy kitchen says the oven runs at 180C. The head chef tapes a note over that line reading 200C. The oven door has a stamped metal plate saying it tops out at 250C, and nobody argues with the oven. Chef Infra works the same way. A cookbook writes down a sensible starting number, a policy pastes something over the top of it, and the machine reports facts that no cookbook is allowed to contradict.

Your recipe asks for node['myapp']['port'] and gets back a single number. It never sees the layers. Chef already merged every source that had an opinion, in a fixed order, and handed you the winner. Attributes are the values attached to a node (one machine that Chef manages). Precedence is the tie-break rule that decides which source wins. Get it wrong and you ship a firewall allow-list wider than the one you typed, or you burn an afternoon editing a cookbook default that something above it has been quietly beating for months.

Six Kinds Of Attribute, Five Of Them Yours

Chef has six attribute types. Five are yours to set: default, force_default, normal, override and force_override. The sixth, automatic, belongs to Ohai (the inventory tool that runs at the start of every Chef Infra Client run and measures the machine it is standing on). Ohai fills in the hostname, the IP addresses (Internet Protocol, the numbers a machine answers on), total memory, platform, mounted filesystems and several hundred other facts. You read those. You do not write them.

default is the everyday baseline, and it is what you should be writing almost all of the time. Chef recomputes it from your cookbook code on every run, so deleting the line deletes the value. force_default sits above every other default source, which is how a cookbook takes the top of the default family back from a policy or a role. normal is the sticky one. Chef writes it into the node object stored on the Chef Infra Server and reads it back on the next run. override is the hammer. force_override is the bigger hammer. automatic beats all of them, always.

cookbooks/myapp/attributes/default.rb
# cookbooks/myapp/attributes/default.rb
default['myapp']['port'] = 8080 # everyday baseline
default['myapp']['dir'] = '/opt/myapp'
default['myapp']['allowed_cidr'] = ['10.0.0.0/8']
force_default['myapp']['log_level'] = 'info' # outranks every other default source
# read-only, supplied by Ohai:
# node['fqdn'] node['ipaddress'] node['platform_version'] node['memory']['total']

Inside a recipe the same five types hang off the node object: node.default, node.force_default, node.normal, node.override, node.force_override. Each one has an _unless variant (node.default_unless, node.normal_unless) that writes only when the key is missing. node.set was removed in Chef 14. If an old cookbook still carries it, the replacement is node.normal, and you want to read the sticky-value warning further down before you make that swap.

cookbooks/myapp/recipes/default.rb
# cookbooks/myapp/recipes/default.rb
node.default['myapp']['workers'] = node['cpu']['total'].to_i * 2 # Ohai fact -> derived default
node.override['myapp']['port'] = 9090 # beats every default source
# nil-safe deep read: returns nil instead of blowing up mid-run on a missing key
ca_path = node.read('myapp', 'tls', 'ca_path') || '/etc/ssl/certs/ca-certificates.crt'
log "myapp will listen on #{node['myapp']['port']} using #{ca_path}"

The Ladder Has Fifteen Rungs

Lowest to highest: default in a cookbook attribute file, default set in a recipe, environment default, role default. Then force_default, attribute file first and recipe second. Then normal, attribute file then recipe. Then the override family in the order attribute file, recipe, role, environment. Then force_override, attribute file then recipe. Automatic sits alone at the top. Fifteen rungs.

The fifteen comes from counting "set in an attribute file" and "set in a recipe" as separate rows. Chef only keeps ten buckets internally, and that particular split is a statement about time rather than rank. Attribute files are evaluated while the run compiles, recipes execute after that, and both write into the same bucket, so the later write wins. That is why a node.default line in a recipe beats a default line in the attribute file of the same cookbook.

One inversion causes more confusion than everything else here. In the default family, a role beats an environment. In the override family, an environment beats a role. Same two sources, opposite winner, decided entirely by which family you set them in. When a value refuses to change, something higher on the ladder is winning, and it is usually an override buried in a policy or an Ohai fact you cannot beat at all.

The four families Chef merges, lowest family first
1. Default family
default (attribute file)
your everyday baseline
default (recipe)
node.default, written later so it wins
env_default, then role_default
role beats environment in this family
force_default
top of the family, still merges arrays
2. Normal
normal (attribute file, then recipe)
node.normal
chef-client -j attrs.json
JSON run attributes land here
saved on the server
stays until you delete it by hand
3. Override family
override (attribute file)
beats every default and normal source
override (recipe)
node.override
role_override, then env_override
environment beats role in this family
force_override
top of the family
4. Automatic
Ohai facts
fqdn, ipaddress, platform, memory
replaced every run
fresh Ohai data overwrites the bucket at run start
unbeatable
no attribute type outranks it
Inside a family, hashes merge and arrays combine. Between families, the higher family replaces arrays and plain values outright.

Roles and environments are legacy. The current way to pin what a node runs is a Policyfile, and a Policyfile carries its own default and override blocks. Chef applies them at the role_default and role_override rungs. So a Policyfile default beats any ordinary cookbook default, and a Policyfile override beats any ordinary cookbook override, with force_default and force_override as the two exceptions that still sit above it inside their own family. Those attributes live in a file you review in Git and lock to a revision id, which is the difference between "somebody edited a role in the web console at 2am" and "somebody opened a merge request".

Policyfile.rb
# Policyfile.rb
name 'web'
default_source :supermarket
run_list 'myapp::default'
cookbook 'myapp', path: 'cookbooks/myapp'
# applied at the role_default rung: beats any ordinary cookbook default,
# but a cookbook force_default still outranks it
default['myapp']['allowed_cidr'] = ['192.168.10.0/24']
# applied at the role_override rung
override['myapp']['log_level'] = 'warn'
terminal
chef install Policyfile.rb
output
Building policy web
Expanded run list: recipe[myapp::default]
Caching Cookbooks...
Using myapp 0.1.0
Lockfile written to /home/eng/policies/web/Policyfile.lock.json
Policy revision id: 3f9a1c07b28d4e6f5a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d9e8f

Watch The Merge Happen

Reading the ladder is one thing. Watching it resolve is better. chef-shell is an interactive Chef session, a workbench version of a converge that hands you a live node object to poke at without changing anything on the machine. On CINC (the open source rebuild of Chef Infra with the trademarks stripped out) the command is cinc-shell, and every merge rule below behaves identically because it is built from the same source.

terminal
chef-shell
output
loading configuration: none (standalone session)
Session type: standalone
Loading......done.
This is the chef-shell.
Chef Infra Client Version: 18.4.12
https://www.chef.io/
https://docs.chef.io/
run `help' for help, `exit' to quit.
Ohai2u eng@build01!
chef (18.4.12)>

Now build the collision by hand. node.attributes.role_default= is the writer a Policyfile uses when it applies its default block, so this is the same code path a real converge takes.

terminal
chef (18.4.12)> node.default['myapp']['port'] = 8080
chef (18.4.12)> node.attributes.role_default = { 'myapp' => { 'port' => 80 } }
chef (18.4.12)> node['myapp']['port']
chef (18.4.12)> node.override['myapp']['port'] = 9090
chef (18.4.12)> node['myapp']['port']
chef (18.4.12)> node.debug_value('myapp', 'port')
output
=> 8080
=> {"myapp"=>{"port"=>80}}
=> 80
=> 9090
=> 9090
=> [["default", 8080], ["env_default", :not_present], ["role_default", 80], ["force_default", :not_present], ["normal", :not_present], ["override", 9090], ["role_override", :not_present], ["env_override", :not_present], ["force_override", :not_present], ["automatic", :not_present]]

debug_value walks all ten buckets and reports what each one holds for that key, printing :not_present for the empty ones. For a plain value like a port number, read left to right and the last bucket with something in it is your answer. For arrays and hashes it is not that simple, which is the whole of the next section. Either way this is the fastest route to the stray override somebody left in a policy two quarters ago, and it works inside a recipe too if you wrap it in a log resource.

Arrays Combine Inside A Family And Replace Between Families

There are two merge rules, not one. Within a family (all four default sources, or all four override sources) Chef deep merges: hashes are combined key by key, and arrays are stuck end to end with duplicates dropped. Treat a family as one shopping list that four people are allowed to add items to. Between families (the whole default family, then normal, then the whole override family, then automatic) Chef merges hashes only. Arrays and plain values from the higher family replace what was underneath, the way handing someone a fresh list replaces the old one instead of being stapled to it.

terminal
chef (18.4.12)> node.default['myapp']['allowed_cidr'] = ['10.0.0.0/8']
chef (18.4.12)> node.attributes.role_default = { 'myapp' => { 'allowed_cidr' => ['192.168.10.0/24'] } }
chef (18.4.12)> node['myapp']['allowed_cidr']
chef (18.4.12)> node.override['myapp']['allowed_cidr'] = ['192.168.10.0/24']
chef (18.4.12)> node['myapp']['allowed_cidr']
output
=> ["10.0.0.0/8"]
=> {"myapp"=>{"allowed_cidr"=>["192.168.10.0/24"]}}
=> ["10.0.0.0/8", "192.168.10.0/24"]
=> ["192.168.10.0/24"]
=> ["192.168.10.0/24"]
Your allow-list can end up wider than the file you edited
A cookbook ships default['myapp']['allowed_cidr'] = ['10.0.0.0/8'], where CIDR (Classless Inter-Domain Routing) is the 10.0.0.0/8 shorthand for a block of addresses. A Policyfile default sets ['192.168.10.0/24']. The node now trusts both, because both live in the default family. Anywhere an extra entry means more access (firewall sources, sudoers entries, TLS cipher lists, authorised SSH keys, trusted certificate authority paths) that quiet append is a privilege expansion no test will fail on. TLS is Transport Layer Security, the encryption behind https; SSH is Secure Shell, the remote login protocol. Set those arrays in the override family, or somewhere nothing lower defines them at all, and read the merged value back before the change reaches production.

So the fix for an array you meant to replace is to move it up a family, not up a rung. force_default still lives inside the default family, so it appends exactly like everything else there. Setting the array with node.override, or from the override block of a Policyfile, is what makes it replace. Confirm with debug_value, because this failure is silent. Nothing errors. The list is only longer than the file you edited.

Sticky Values And Where They Hide

default and override are rebuilt from your code on every run. normal is not. Chef saves it into the node object on the server at the end of each run and loads it back at the start of the next one, like a note written on the machine's own record card in permanent marker. That is genuinely useful for a value a node worked out once and has to keep, such as a generated instance id or a token seeded on first boot. It is a trap for ordinary configuration.

Normal attributes outlive the code that set them
Delete a node.normal line from your recipe and the value stays put, because nothing recomputes it. It sits on the node object outranking the entire default family, force_default included. Attributes handed to chef-client with -j attrs.json arrive at normal precedence as well (JSON is JavaScript Object Notation, a plain-text data format), so one debugging run with a JSON file can leave a permanent value behind on a production host. Clearing one takes an explicit node.rm_normal or a knife node edit. No future converge will ever do it for you.

Here is what that looks like when it bites. The cookbook ships log_level info as a force_default. The node has been logging at debug for four months and nobody can find the line that sets it.

terminal
knife node show web01.example.com -a myapp.log_level
output
web01.example.com:
myapp.log_level: debug
terminal
knife node show web01.example.com -l -F json | jq '.normal.myapp'
output
{
"log_level": "debug",
"token": "seeded-once"
}

The server keeps four buckets per node: default, normal, override and automatic. The client merges each family locally and uploads the result, which is why that query works. knife node edit shows you the name, environment, run-list, policy name, policy group and the normal bucket, and it hides the rest because everything else is regenerated on the next run anyway. To get rid of the value, take it out on purpose.

cookbooks/myapp/recipes/cleanup.rb
# cookbooks/myapp/recipes/cleanup.rb
# add to the run-list once, converge, verify, then delete this recipe
node.rm_normal('myapp', 'log_level') # returns the removed value, or nil if it was absent
# the client saves the node object at the end of the run, which is what makes the removal stick

What A Fleet-Wide Attribute Change Really Is

Attributes are indexed on the Chef Infra Server and searchable, so one query answers a question about the whole estate.

terminal
knife search node 'policy_group:production' -a myapp.port
output
2 items found
web01.example.com:
myapp.port: 9090
web02.example.com:
myapp.port: 9090

Two things follow from that command working. First, it is the fastest inventory you own, and it reports what nodes actually converged to rather than what a repository claims they should be. Second, the same query is available to anything holding a valid client key. Out of the box a node can read other nodes, so one compromised web server can list every attribute on every host in the organisation. Whatever you put in an attribute is stored on the server in clear text and handed straight back by search. No passwords, no API tokens (an API, or Application Programming Interface, is the machine-to-machine door into a service), no private keys. Use an external secret store, or encrypted data bags, and remember that a data bag is encrypted with a shared key sitting on every node allowed to read it, so a compromised node gives those secrets up too. Then trim the node read permissions.

You can also cut down what leaves the machine in the first place. Chef Infra Client 18 takes allow and block lists per attribute family in client.rb. They control what gets saved to the server without changing what recipes can read during the run. Pick one or the other for a given family, not both.

/etc/chef/client.rb
# /etc/chef/client.rb
# keep the local user and group inventory out of the node object on the server
blocked_automatic_attributes [
%w{etc passwd},
%w{etc group},
]
# an allow list is stricter: anything not named here is dropped before upload,
# which will also break any knife search that relies on those keys
# allowed_normal_attributes [%w{myapp version}]

The other direction matters as much. Attributes feed templates, package versions, repository URLs and firewall rules. Whoever can edit a node's normal attributes or push a new policy revision to a policy group decides what every node in that group installs at its next converge. That is remote code execution wearing the costume of a config edit. Review policy pushes the way you review code, keep the push credentials narrow, and keep the Chef Automate event feed (or the Infra Server's own request logs) somewhere you can search, so a surprise attribute arrives with a name and a timestamp attached.

Rules That Keep The Ladder Boring

Write default in attribute files for almost everything. Put per-group differences in the default block of that group's Policyfile. Reach for override only when you cannot change the cookbook, which in practice means a community cookbook you do not own, and leave a comment saying why. force_default and force_override belong in wrapper cookbooks that have to beat the cookbook they wrap, and nowhere else. Use normal for values a node generates and must keep, knowing you now own their cleanup forever.

Then prove the change on a converged node instead of reading the cookbook and hoping. knife node show NODE -a myapp.port gives you the flattened answer a recipe would see, and node.debug_value('myapp', 'port') tells you which bucket produced it. When those two disagree with what you expected, the ladder has already told you which file to open.

Quick check
01A cookbook attribute file sets default['app']['port'] = 8080. The Policyfile for this policy group sets default['app']['port'] = 8000 and override['app']['port'] = 9090. What does node['app']['port'] return during the run?
Incorrect — Cookbook defaults sit on the two lowest rungs of the lowest family, so almost anything else beats them.
Incorrect — That rung does beat the cookbook default, but the same Policyfile's override block sits in a higher family.
Correct — Chef merges the whole default family first, then normal, then the override family, so the override value is the merged answer.
Incorrect — Chef never errors on colliding attributes, it merges them silently, which is exactly why precedence bugs hide so well.
02Your cookbook attribute file sets default['fw']['allowed'] = ['10.0.0.0/8'] and the Policyfile's default block sets ['192.168.10.0/24']. What ends up in node['fw']['allowed']?
Incorrect — Replacement only happens between families, and both of these values live in the default family.
Correct — within the default family Chef deep merges, and deep merging two arrays joins them and drops duplicates.
Incorrect — Evaluation order only breaks ties inside one bucket, it does not decide which array survives across sources.
Incorrect — Length is irrelevant, Chef merges arrays of any size without complaining.
03On a production node, node.debug_value('myapp', 'log_level') returns [["default", :not_present], ["env_default", :not_present], ["role_default", :not_present], ["force_default", "info"], ["normal", "debug"], ["override", :not_present], ...]. The cookbook ships info and the line that set debug was deleted from the recipe months ago. What actually fixes the node?
Incorrect — The default family already resolves to info and still loses to normal, so changing it again changes nothing.
Incorrect — force_default is already set here, and it is still only the top of a family that sits below normal.
Incorrect — No converge clears normal attributes, and local mode reads an entirely different node store.
Correct — normal attributes only disappear when you explicitly delete them from the node object and save it.

Try this

Run chef install Policyfile.rb on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: your allow-list can end up wider than the file you edited. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related