Skip to main content
RunBook Academy

← All labs in Ansible

Lab · intermediate · ~75 min

Lab: Layer a real role — turn internals into an interface

C · SimulationB · Nested virtualisation

Objectives

  • Identify every value in a role that a consumer cannot override, and prove it
  • Move consumer-facing values from vars/ to defaults/ without changing behaviour
  • Layer the same role across three environments using group_vars alone
  • State the rule that decides whether a new value belongs in defaults/ or vars/

Prerequisites

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-core 2.21.x. Behaviour verified on 2.21.3.
  • No managed nodes, no SSH, no privilege escalation. The role’s tasks are debug and assert only.
  • 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
Read-only / Safecontroller
$ ansible-playbook -i inventories/staging/hosts.yml site.yml
TASK [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?

ValueConsumer changes it?Destination
webapp_portyes, per environmentdefaults/
webapp_workersyes, sized to the hostdefaults/
webapp_log_levelyes, per environmentdefaults/
webapp_package_namerarely, but on a distro variant, yesdefaults/
webapp_service_nameno — it is what the packaged unit is calledvars/
webapp_config_pathno — the package decides where it reads fromvars/
webapp_userno — the package creates itvars/

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:

Read-only / Safecontroller
$ ansible-playbook -i inventories/staging/hosts.yml site.yml
TASK [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.yml reports port=8080 despite group_vars saying 9090.
  • ansible-inventory --playbook-dir . --host staging-web01 reports webapp_port: 9090 at 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_path to a group_vars file does not change the reported config path.
  • git diff --stat on roles/ between the start and end of Task 4 shows changes only to defaults/main.yml and vars/main.yml — never tasks/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 a group_vars file be read, loaded and then ignored, and confirmed with ansible-inventory that the variable really was there.
  • The refactor is a file move, not a rewrite. tasks/main.yml was never touched. The pull request that added environment conditionals to the tasks would have been strictly more work and permanently more risk.
  • defaults/main.yml is 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 a group_vars line 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.

Deliverables

  • · A refactored role whose defaults/main.yml documents its full public interface
  • · Three group_vars files producing three different configurations from one role
  • · A written rule, in one sentence, for deciding defaults/ versus vars/

Verification status

Last reviewed
2026-08-11
Executed end to end
not yet run on hardware

The commands and configuration here have been reviewed against the verified software versions, but nobody has run this lab start to finish on a system meeting its prerequisites. Treat the Expected Outcome as the intended result rather than an observed one, and keep the Cleanup section to hand.