Objective
By the end of this lab you will have determined the relative precedence of
six variable layers by experiment — removing one layer at a time and
observing what surfaces — rather than by consulting a table you will
misremember under pressure. You will also have found the case where
ansible-inventory --host, the obvious diagnostic, reports a value the
playbook will not use.
Architecture
One inventory, one host, one variable defined six times.
repo/
├── inventories/prod/
│ ├── hosts.yml
│ ├── group_vars/web.yml app_port: 8000 (inventory group_vars)
│ └── host_vars/node1.yml app_port: 8002 (inventory host_vars)
├── group_vars/web.yml app_port: 8001 (playbook group_vars)
├── host_vars/node1.yml app_port: 8003 (playbook host_vars)
├── roles/app/
│ ├── defaults/main.yml app_port: 8004 (role defaults)
│ └── vars/main.yml app_port: 8005 (role vars)
└── site.yml
The port numbers encode nothing — they are labels, chosen so that the value
in the output tells you which file it came from without a second lookup.
ansible_connection: local on the group means the lab needs no managed
nodes.
Requirements
- A controller with
ansible-core2.21.x. All values below were observed on 2.21.3. - No SSH, no credentials, no managed nodes. The play runs
debugonly. - A place to write predictions before running commands. As with the targeting lab, running first and rationalising afterwards teaches nothing.
Scenario
Production is serving on the wrong port. The team’s group_vars/web.yml
clearly says app_port: 8001, three people have read it, and the service
is not on 8001. Somebody suggests the file is not being loaded. Somebody
else suggests a caching problem. Your job is to find where the value
actually comes from, and to build the two-command procedure that answers
this question in future without a discussion.
Tasks
Task 1: Build the six-layer repository
WORKDIR="$HOME/ansible-precedence-lab"
mkdir -p "$WORKDIR"/{inventories/prod/{group_vars,host_vars},group_vars,host_vars,roles/app/{defaults,vars,tasks}}
cd "$WORKDIR"
# Confirm nothing is redirecting inventory or roles_path
ansible-config dump --only-changed
Write inventories/prod/hosts.yml:
web:
hosts:
node1:
vars:
ansible_connection: local
Now seed all six layers. Each value is deliberately distinct:
cd "$HOME/ansible-precedence-lab"
echo 'app_port: 8000' > inventories/prod/group_vars/web.yml
echo 'app_port: 8001' > group_vars/web.yml
echo 'app_port: 8002' > inventories/prod/host_vars/node1.yml
echo 'app_port: 8003' > host_vars/node1.yml
echo 'app_port: 8004' > roles/app/defaults/main.yml
echo 'app_port: 8005' > roles/app/vars/main.yml
Write roles/app/tasks/main.yml:
- name: Report the value the role sees
ansible.builtin.debug:
msg: "role sees {{ app_port }}"
And two playbooks. site.yml uses no role, so it isolates the four
inventory and playbook layers:
# site.yml
- name: Layers without a role
hosts: web
gather_facts: false
tasks:
- name: Report
ansible.builtin.debug:
msg: "no role: {{ app_port }}"
# site-role.yml
- name: Layers including the role
hosts: web
gather_facts: false
roles:
- app
Task 2: Predict, then peel
Write down your prediction for each row before running anything. Then run
site.yml, delete the layer that won, and run again.
| Step | Layers still present | Predicted | Actual |
|---|---|---|---|
| 1 | all four non-role layers | ||
| 2 | after deleting host_vars/node1.yml | ||
| 3 | after deleting inventories/prod/host_vars/node1.yml | ||
| 4 | after deleting group_vars/web.yml |
Run step 1:
$ ansible-playbook -i inventories/prod/hosts.yml site.ymlTASK [Report] ******************************************************************
ok: [node1] => {
"msg": "no role: 8003"
}8003 is host_vars/node1.yml — the playbook-adjacent host_vars, not
the inventory-adjacent one. Peel it away:
rm host_vars/node1.yml
ansible-playbook -i inventories/prod/hosts.yml site.yml | grep msg
Then the next, and the next:
rm inventories/prod/host_vars/node1.yml
ansible-playbook -i inventories/prod/hosts.yml site.yml | grep msg
rm group_vars/web.yml
ansible-playbook -i inventories/prod/hosts.yml site.yml | grep msg
The observed sequence is 8003, 8002, 8001, 8000, which orders the
four layers, highest first:
- playbook-adjacent
host_vars/ - inventory-adjacent
host_vars/ - playbook-adjacent
group_vars/ - inventory-adjacent
group_vars/
Restore the three files you deleted before continuing:
echo 'app_port: 8001' > group_vars/web.yml
echo 'app_port: 8002' > inventories/prod/host_vars/node1.yml
echo 'app_port: 8003' > host_vars/node1.yml
Task 3: Add the role and watch the answer invert
Run the role-based playbook:
$ ansible-playbook -i inventories/prod/hosts.yml site-role.ymlTASK [app : Report the value the role sees] *************************************
ok: [node1] => {
"msg": "role sees 8005"
}8005 is roles/app/vars/main.yml. Every one of the four layers you just
ranked has been overridden by a file inside the role.
Confirm the two directions of the same fact:
# Role defaults lose to inventory: delete vars/main.yml and re-run
mv roles/app/vars/main.yml /tmp/role-vars-main.yml
ansible-playbook -i inventories/prod/hosts.yml site-role.yml | grep msg
# Put it back
mv /tmp/role-vars-main.yml roles/app/vars/main.yml
With vars/main.yml gone the role reports 8003 — the playbook host_vars
— because defaults/main.yml at 8004 is the lowest layer of all and
loses to everything.
Task 4: The diagnostic that lies
Now the part that costs people an afternoon. ansible-inventory --host is
the obvious way to ask “what value does this host have”. Run it from inside
the repository:
$ ansible-inventory -i inventories/prod/hosts.yml --host node1{
"ansible_connection": "local",
"app_port": 8003
}Now run exactly the same command from one directory up:
$ cd .. && ansible-inventory -i ansible-precedence-lab/inventories/prod/hosts.yml --host node1{
"ansible_connection": "local",
"app_port": 8002
}The fix is to say which playbook directory you mean:
REPO="$HOME/ansible-precedence-lab"
ansible-inventory -i "$REPO/inventories/prod/hosts.yml" \
--playbook-dir "$REPO" --host node1
That reports 8003 from anywhere.
Task 5: Find the value in a repository you did not write
The point of the exercise. Have a colleague — or a shell one-liner — hide a seventh definition somewhere, then find it without reading every file:
cd "$HOME/ansible-precedence-lab"
# Every definition of the variable, with its file and line
grep -rn --include='*.yml' --include='*.yaml' 'app_port' . | sort
$ grep -rn --include='*.yml' 'app_port' . | sort./group_vars/web.yml:1:app_port: 8001
./host_vars/node1.yml:1:app_port: 8003
./inventories/prod/group_vars/web.yml:1:app_port: 8000
./inventories/prod/host_vars/node1.yml:1:app_port: 8002
./roles/app/defaults/main.yml:1:app_port: 8004
./roles/app/vars/main.yml:1:app_port: 8005
./roles/app/tasks/main.yml:3: msg: "role sees {{ app_port }}"Six definitions and one use. Rank them with the ladder you built in Tasks 2 and 3, and you have the answer without running anything.
Task 6: Write your ladder
In ladder.md, write the precedence order you observed, highest first,
with the value you saw at each step as evidence. Add one sentence per layer
saying what it is for. A layer whose purpose you cannot state in one
sentence is a layer your repository should probably not be using.
Validation
ansible-playbook -i inventories/prod/hosts.yml site.ymlreports 8003 with all layers present, and 8002, 8001, 8000 as you delete each winner.ansible-playbook -i inventories/prod/hosts.yml site-role.ymlreports 8005.- The same command with
-e app_port=9999reports 9999. - With
roles/app/vars/main.ymlmoved aside,site-role.ymlreports 8003, not 8004 — role defaults are the lowest layer, not a middle one. ansible-inventory -i inventories/prod/hosts.yml --host node1reports 8003 from the repo root and 8002 from the parent directory.- The same command with
--playbook-dir "$HOME/ansible-precedence-lab"reports 8003 regardless of where it is run from. ladder.mdorders six layers with an observed value beside each.
Expected Outcome
ansible-precedence-lab/
├── group_vars/web.yml
├── host_vars/node1.yml
├── inventories/prod/
│ ├── group_vars/web.yml
│ ├── host_vars/node1.yml
│ └── hosts.yml
├── ladder.md
├── roles/app/
│ ├── defaults/main.yml
│ ├── tasks/main.yml
│ └── vars/main.yml
├── site-role.yml
└── site.yml
You can order six precedence layers from memory because you watched each
one surface. You know that ansible-inventory --host needs
--playbook-dir to tell the truth about a repository, and you know that a
variable in roles/*/vars/ cannot be overridden by anything an operator
would try first.
Troubleshooting
Every run reports the same value no matter what you delete. An
--extra-vars is set somewhere — check your shell history and any wrapper
script. Extra vars beat all six layers and produce exactly this symptom.
group_vars/web.yml has no effect at all. The filename must match the
group name exactly. web.yml applies to the group web; webs.yml
applies to a group called webs, which does not exist, and Ansible does
not warn about a group_vars file that matches nothing.
A group_vars directory instead of a file behaves differently.
group_vars/web/ containing several files is valid and all of them load,
merged in filename order. That is a useful pattern and a confusing one to
debug — grep -rn finds them, a glance at the directory listing may not.
Role defaults appear to win. Then the layer you expected to beat them
is not being loaded at all. Confirm with ansible-inventory --list --playbook-dir, and check for a typo in the group name.
ansible-inventory --host shows a variable the play does not have.
The reverse of Task 4: you ran it from a directory containing an unrelated
host_vars/. Always pass --playbook-dir explicitly when diagnosing.
Cleanup
Everything this lab created lives inside one directory and one temporary file. No system configuration was changed, no host was contacted, and no privilege was escalated.
Step 1. Confirm nothing escaped the working directory:
cd "$HOME/ansible-precedence-lab"
ansible --version | grep 'config file'
ls -la /tmp/role-vars-main.yml 2>/dev/null || echo 'no leftover role vars file'
If /tmp/role-vars-main.yml still exists, Task 3 was interrupted before
the mv back. Restore it before deleting anything:
[ -f /tmp/role-vars-main.yml ] && \
mv /tmp/role-vars-main.yml "$HOME/ansible-precedence-lab/roles/app/vars/main.yml"
Step 2. Keep the ladder — it is the deliverable and you will refer to it:
mkdir -p "$HOME/ansible-lab-deliverables"
cp -a "$HOME/ansible-precedence-lab/ladder.md" \
"$HOME/ansible-lab-deliverables/precedence-ladder.md"
Step 3. Remove the working directory by absolute path:
rm -rf "$HOME/ansible-precedence-lab"
What You Learned
- You derived the ladder by peeling layers, and it came out playbook host_vars, inventory host_vars, playbook group_vars, inventory group_vars — with role vars above all four and role defaults below all four.
roles/*/vars/main.ymlis unreachable from inventory. Only a role parameter,set_factor--extra-varsoverrides it, which is why everything a consumer might tune belongs indefaults/.ansible-inventory --hostanswers differently depending on your working directory. You reproduced 8003-versus-8002 from the same command, and fixed it with--playbook-dir.- A
grep -rnfor the variable name is a complete search of the file layers, and an incomplete search overall: extra vars, environment andset_factdo not appear in it. - Nothing warns you. Six definitions, one winner, zero diagnostics. The only defence is a repository convention about where variables are allowed to live, and the ladder you just wrote is the argument for it.