Objective
By the end of this lab you will have taken a role that “works fine” in one
environment, demonstrated that it cannot be reused in a second without
editing the role itself, and refactored it so that three environments
differ only in their group_vars. You will be able to state the rule that
decides where a value goes, and to justify it from the precedence
behaviour rather than from convention.
Architecture
One role, three environments, one inventory per environment. Nothing
connects; the role’s “work” is a debug task that prints the values it
resolved, which is all this lab needs to observe.
roles/webapp/
├── defaults/main.yml <- the public interface (overridable by everything)
├── vars/main.yml <- internals (overridable by almost nothing)
└── tasks/main.yml
inventories/
├── dev/ group_vars/webapp.yml
├── staging/ group_vars/webapp.yml
└── prod/ group_vars/webapp.yml
Requirements
- A controller with
ansible-core2.21.x. Behaviour verified on 2.21.3. - No managed nodes, no SSH, no privilege escalation. The role’s tasks are
debugandassertonly. - The precedence ladder from the variable-precedence lab, or the willingness to rebuild the relevant part of it here.
Scenario
Your team has a webapp role that has worked for a year against
production. A staging environment is being built, and staging needs a
different listen port, a different worker count and a different log level.
The engineer building staging has opened a pull request that adds
when: inventory_hostname in groups['staging'] conditionals to
roles/webapp/tasks/main.yml.
That pull request is the problem this lab solves.
Tasks
Task 1: Build the role as it currently exists
WORKDIR="$HOME/ansible-role-interface-lab"
mkdir -p "$WORKDIR"/roles/webapp/{defaults,vars,tasks}
mkdir -p "$WORKDIR"/inventories/{dev,staging,prod}/group_vars
cd "$WORKDIR"
roles/webapp/vars/main.yml — everything is here, which is the defect:
# roles/webapp/vars/main.yml (the "before" state)
webapp_port: 8080
webapp_workers: 4
webapp_log_level: warn
webapp_package_name: webapp
webapp_service_name: webapp
webapp_config_path: /etc/webapp/webapp.conf
webapp_user: webapp
roles/webapp/defaults/main.yml — empty, which is the other half of the
defect:
# roles/webapp/defaults/main.yml (the "before" state)
---
roles/webapp/tasks/main.yml:
- name: Report the configuration this role resolved
ansible.builtin.debug:
msg: >-
{{ inventory_hostname }}:
port={{ webapp_port }}
workers={{ webapp_workers }}
log_level={{ webapp_log_level }}
config={{ webapp_config_path }}
An inventory per environment, all pointing at the controller:
# inventories/prod/hosts.yml
webapp:
hosts:
prod-web01:
vars:
ansible_connection: local
Create the same shape under inventories/staging/ and inventories/dev/,
changing the hostname to staging-web01 and dev-web01.
And site.yml:
- name: Configure the web application
hosts: webapp
gather_facts: false
roles:
- webapp
Task 2: Prove the role cannot be layered
Staging needs port 9090. Try to set it the way any operator would:
cd "$HOME/ansible-role-interface-lab"
cat > inventories/staging/group_vars/webapp.yml <<'YAML'
webapp_port: 9090
webapp_workers: 2
webapp_log_level: debug
YAML
ansible-playbook -i inventories/staging/hosts.yml site.yml
$ ansible-playbook -i inventories/staging/hosts.yml site.ymlTASK [webapp : Report the configuration this role resolved] *********************
ok: [staging-web01] => {
"msg": "staging-web01: port=8080 workers=4 log_level=warn config=/etc/webapp/webapp.conf"
}The group_vars file was read. The values were loaded. The role ignored
all three, because roles/webapp/vars/main.yml sits above group_vars in
the precedence order and wrote last.
Confirm the values really are in the inventory layer, so you know the file is not simply being missed:
ansible-inventory -i inventories/staging/hosts.yml \
--playbook-dir . --host staging-web01
That reports webapp_port: 9090. The variable exists; the role overrides
it.
Task 3: Separate the interface from the internals
Go through the seven values and ask one question of each: would a legitimate consumer of this role ever need a different value?
| Value | Consumer changes it? | Destination |
|---|---|---|
webapp_port | yes, per environment | defaults/ |
webapp_workers | yes, sized to the host | defaults/ |
webapp_log_level | yes, per environment | defaults/ |
webapp_package_name | rarely, but on a distro variant, yes | defaults/ |
webapp_service_name | no — it is what the packaged unit is called | vars/ |
webapp_config_path | no — the package decides where it reads from | vars/ |
webapp_user | no — the package creates it | vars/ |
Rewrite the two files accordingly.
# roles/webapp/defaults/main.yml (the public interface)
---
# TCP port the application listens on.
webapp_port: 8080
# Worker processes. Size to the host; 4 suits a 2-vCPU node.
webapp_workers: 4
# One of: debug, info, warn, error.
webapp_log_level: warn
# Override on distributions that name the package differently.
webapp_package_name: webapp
# roles/webapp/vars/main.yml (internals — deliberately hard to override)
---
# These follow the packaged layout. Changing them does not reconfigure
# the application; it makes this role manage the wrong files.
webapp_service_name: webapp
webapp_config_path: /etc/webapp/webapp.conf
webapp_user: webapp
Note that the comments in defaults/main.yml are not decoration. That file
is the role’s documentation — it is the first thing a consumer reads, and
the only place where “what may I change” is answered.
Re-run staging:
$ ansible-playbook -i inventories/staging/hosts.yml site.ymlTASK [webapp : Report the configuration this role resolved] *********************
ok: [staging-web01] => {
"msg": "staging-web01: port=9090 workers=2 log_level=debug config=/etc/webapp/webapp.conf"
}Three values changed. webapp_config_path did not, because it is an
internal and staging has no business changing it.
Task 4: Layer all three environments
cd "$HOME/ansible-role-interface-lab"
cat > inventories/dev/group_vars/webapp.yml <<'YAML'
webapp_port: 8080
webapp_workers: 1
webapp_log_level: debug
YAML
cat > inventories/prod/group_vars/webapp.yml <<'YAML'
webapp_port: 8080
webapp_workers: 16
webapp_log_level: warn
YAML
for env in dev staging prod; do
echo "--- $env"
ansible-playbook -i "inventories/$env/hosts.yml" site.yml | grep 'msg'
done
Three configurations, one role, zero role edits. The role does not know that environments exist, which is exactly the property that makes it safe to change.
Task 5: Prove the internals are still protected
The point of leaving three values in vars/ is that they resist casual
override. Confirm that:
cd "$HOME/ansible-role-interface-lab"
# Try to change an internal from group_vars
echo 'webapp_config_path: /opt/webapp/custom.conf' \
>> inventories/dev/group_vars/webapp.yml
ansible-playbook -i inventories/dev/hosts.yml site.yml | grep 'msg'
The path is unchanged. vars/ won, which is the intent: someone who
genuinely needs a different config path has to make a deliberate change to
the role, not add a line to an inventory file during an incident.
Remove that line before continuing:
sed -i '/webapp_config_path/d' inventories/dev/group_vars/webapp.yml
Task 6: Write the rule
In interface-rule.md, write the rule in one sentence, plus the four
values you classified as interface and the three as internals, with one
clause each saying why.
A serviceable form of the rule: a value goes in defaults/ when a
legitimate consumer of the role could need a different one, and in vars/
when a different value would mean the role is managing the wrong thing.
Validation
- Before the refactor,
ansible-playbook -i inventories/staging/hosts.yml site.ymlreportsport=8080despitegroup_varssaying 9090. ansible-inventory --playbook-dir . --host staging-web01reportswebapp_port: 9090at the same time — proving the variable exists and the role overrode it.- After the refactor, the same playbook reports
port=9090 workers=2 log_level=debug. - Running dev, staging and prod in turn produces three distinct lines of
output with no change to any file under
roles/. - Adding
webapp_config_pathto agroup_varsfile does not change the reported config path. git diff --statonroles/between the start and end of Task 4 shows changes only todefaults/main.ymlandvars/main.yml— nevertasks/main.yml.
Expected Outcome
ansible-role-interface-lab/
├── interface-rule.md
├── inventories/
│ ├── dev/ {hosts.yml, group_vars/webapp.yml}
│ ├── prod/ {hosts.yml, group_vars/webapp.yml}
│ └── staging/ {hosts.yml, group_vars/webapp.yml}
├── roles/webapp/
│ ├── defaults/main.yml <- 4 documented, overridable values
│ ├── tasks/main.yml <- unchanged throughout
│ └── vars/main.yml <- 3 internals
└── site.yml
roles/webapp/tasks/main.yml is byte-identical to the version you started
with. Every environmental difference lives in inventory. The role has a
documented interface and a private implementation, and you can say which
is which and why.
Troubleshooting
The refactor did not change anything. Check that you actually removed
the four values from vars/main.yml rather than only adding them to
defaults/. A value present in both files resolves from vars/, so the
role behaves exactly as before and the change looks like it did nothing.
group_vars/webapp.yml is not being read. The filename must match the
group name. The group here is webapp, defined by the inventory’s
top-level key. Confirm with ansible-inventory --graph.
Values apply to dev but not to prod. Each environment has its own
group_vars directory under its own inventory. Running with the wrong
-i reads the wrong one, and the failure is silent because the role has
defaults for everything.
A required value has no default and the role fails mid-run. Correct
diagnosis, wrong layer. Add meta/argument_specs.yml so the failure
happens before task one. That is the subject of the argument-specs lab.
ansible-inventory --host disagrees with the play. Pass
--playbook-dir .; without it, playbook-adjacent group_vars is not in
scope and the command reports a different value than the play will use.
Cleanup
This lab created files in one directory. Nothing outside it was written, no host was contacted, and no service state was touched.
Step 1. Confirm nothing leaked into a shared role path:
cd "$HOME/ansible-role-interface-lab"
ansible-config dump --only-changed | grep -i -E 'roles|config_file'
ls -d ~/.ansible/roles/webapp 2>/dev/null && echo 'WARNING: a webapp role exists in the user roles path'
If that warning appears, it is not from this lab — this lab wrote only under the working directory — but it may have been shadowing your role throughout. Investigate before deleting anything.
Step 2. Keep the deliverables:
mkdir -p "$HOME/ansible-lab-deliverables/role-interface"
cp -a roles/webapp/defaults/main.yml \
roles/webapp/vars/main.yml \
interface-rule.md \
"$HOME/ansible-lab-deliverables/role-interface/"
Step 3. Remove the working directory, by absolute path:
rm -rf "$HOME/ansible-role-interface-lab"
What You Learned
- A role whose values live in
vars/cannot be layered. You watched agroup_varsfile be read, loaded and then ignored, and confirmed withansible-inventorythat the variable really was there. - The refactor is a file move, not a rewrite.
tasks/main.ymlwas never touched. The pull request that added environment conditionals to the tasks would have been strictly more work and permanently more risk. defaults/main.ymlis the role’s documentation. Four values with a comment each tell a consumer everything they may change; the absence of the other three tells them what they may not.vars/protects internals on purpose. A config path that agroup_varsline can silently redirect is not an internal, it is a trap.- State production’s values in production’s inventory even when they match the default, so a role-default change cannot move production without anyone deciding to.