Objective
By the end of this lab you will have split a single 60-task play into two
roles and demonstrated that the resulting run is equivalent — same tasks,
same order, same conditionals, same handler notifications — using
--list-tasks and a check-mode comparison rather than by reading both
versions and feeling confident. You will also have found the three things
that change during such a refactor even when no task body is edited.
Architecture
One playbook becomes one playbook plus two roles. The inventory and the managed nodes are irrelevant to the exercise; what matters is the task graph.
before: after:
site.yml site.yml
└─ play: hosts: webapp └─ play: hosts: webapp
├─ tasks (baseline x8) └─ roles:
├─ tasks (webapp x9) ├─ baseline
└─ handlers x3 └─ webapp
roles/baseline/{tasks,handlers,defaults}
roles/webapp/{tasks,handlers,defaults}
Requirements
- A controller with
ansible-core2.21.x. Verified on 2.21.3. - No managed nodes required. Every task in the supplied monolith is either
debug,assertor acommandguarded bycheck_mode: false, so the whole lab runs againstansible_connection: localwithout changing anything. diffand a text editor.
Scenario
You have inherited site.yml: one play, seventeen tasks, three handlers,
no roles. It works. Nobody wants to touch it, which is why it has grown a
when: ansible_facts['os_family'] == 'Debian' on nine of the seventeen
tasks and a comment at the top that says “DO NOT REORDER”.
The team wants to reuse the first eight tasks — the baseline hardening — in three other playbooks. Copy-paste is on the table. Your job is to extract them into a role instead, and to be able to prove that the extraction changed nothing.
Tasks
Task 1: Build the monolith
WORKDIR="$HOME/ansible-monolith-lab"
mkdir -p "$WORKDIR"
cd "$WORKDIR"
inventory.yml:
webapp:
hosts:
node1:
node2:
vars:
ansible_connection: local
site.yml — the monolith. It is deliberately shaped like real inherited
code: a mixture of concerns, a shared handler, and variables defined at
play level that both halves consume.
- name: Configure the application estate
hosts: webapp
gather_facts: false
vars:
ntp_servers:
- 192.0.2.101
- 192.0.2.102
baseline_motd_owner: platform-team
webapp_port: 8080
webapp_workers: 4
webapp_release: '2.4.1'
handlers:
- name: Restart chrony
ansible.builtin.debug:
msg: "HANDLER restart chrony on {{ inventory_hostname }}"
- name: Restart webapp
ansible.builtin.debug:
msg: "HANDLER restart webapp on {{ inventory_hostname }}"
- name: Reload the audit rules
ansible.builtin.debug:
msg: "HANDLER reload auditd on {{ inventory_hostname }}"
tasks:
# ---- baseline: tasks 1-8 ----
- name: baseline | Assert the NTP server list is non-empty
ansible.builtin.assert:
that: ntp_servers | length > 0
fail_msg: "ntp_servers must not be empty"
- name: baseline | Render the chrony configuration
ansible.builtin.debug:
msg: "would write chrony.conf with {{ ntp_servers | join(', ') }}"
changed_when: true
notify: Restart chrony
- name: baseline | Render the message of the day
ansible.builtin.debug:
msg: "would write /etc/motd owned by {{ baseline_motd_owner }}"
changed_when: false
- name: baseline | Install the audit rules
ansible.builtin.debug:
msg: "would write /etc/audit/rules.d/50-baseline.rules"
changed_when: true
notify: Reload the audit rules
- name: baseline | Set kernel parameters
ansible.builtin.debug:
msg: "would apply sysctl settings"
changed_when: false
- name: baseline | Ensure the admin group exists
ansible.builtin.debug:
msg: "would ensure group platform-admins"
changed_when: false
- name: baseline | Record the baseline version
ansible.builtin.debug:
msg: "baseline applied by {{ baseline_motd_owner }}"
changed_when: false
- name: baseline | Verify chrony would be running
ansible.builtin.assert:
that: ntp_servers is defined
success_msg: "baseline complete"
# ---- webapp: tasks 9-17 ----
- name: webapp | Assert the release is pinned
ansible.builtin.assert:
that:
- webapp_release is defined
- webapp_release is match('^[0-9]+\.[0-9]+\.[0-9]+$')
fail_msg: "webapp_release must be a three-part version"
- name: webapp | Create the application user
ansible.builtin.debug:
msg: "would ensure user webapp"
changed_when: false
- name: webapp | Fetch release {{ webapp_release }}
ansible.builtin.debug:
msg: "would fetch webapp-{{ webapp_release }}.tar.gz"
changed_when: true
- name: webapp | Unpack the release
ansible.builtin.debug:
msg: "would unpack to /opt/webapp/{{ webapp_release }}"
changed_when: true
- name: webapp | Render the application configuration
ansible.builtin.debug:
msg: "would write config: port={{ webapp_port }} workers={{ webapp_workers }}"
changed_when: true
notify: Restart webapp
- name: webapp | Point the current symlink at the release
ansible.builtin.debug:
msg: "would link /opt/webapp/current -> {{ webapp_release }}"
changed_when: true
notify: Restart webapp
- name: webapp | Install the systemd unit
ansible.builtin.debug:
msg: "would write webapp.service"
changed_when: false
notify: Restart webapp
- name: webapp | Open the application port
ansible.builtin.debug:
msg: "would allow tcp/{{ webapp_port }}"
changed_when: false
- name: webapp | Verify the release was recorded
ansible.builtin.assert:
that: webapp_release is defined
success_msg: "webapp {{ webapp_release }} deployed"
Task 2: Capture the baseline, before touching anything
This is the step people skip and then regret. --list-tasks prints the
resolved task graph without running it:
cd "$HOME/ansible-monolith-lab"
ansible-playbook -i inventory.yml site.yml --list-tasks > before-tasks.txt
cat before-tasks.txt
$ ansible-playbook -i inventory.yml site.yml --list-tasks | head -12playbook: site.yml
play #1 (webapp): Configure the application estate TAGS: []
tasks:
baseline | Assert the NTP server list is non-empty TAGS: []
baseline | Render the chrony configuration TAGS: []
baseline | Render the message of the day TAGS: []
baseline | Install the audit rules TAGS: []
baseline | Set kernel parameters TAGS: []
baseline | Ensure the admin group exists TAGS: []
baseline | Record the baseline version TAGS: []
baseline | Verify chrony would be running TAGS: []Also capture the run itself. Check mode plus --diff gives you the change
decisions without making any:
ansible-playbook -i inventory.yml site.yml --check --diff > before-run.txt 2>&1
grep -E '^(TASK|RUNNING HANDLER|ok:|changed:|skipping:)' before-run.txt > before-summary.txt
tail -5 before-run.txt
Task 3: Find the boundary
The seam is not arbitrary. Three signals tell you where it is:
Task name prefixes. Somebody has already done the work: baseline |
and webapp |. Inherited playbooks very often carry the intended role
boundary in their naming convention.
Handler ownership. Restart chrony and Reload the audit rules are
notified only by baseline tasks. Restart webapp is notified only by
webapp tasks. A handler notified from both halves would mean the boundary
is in the wrong place — and finding that out now is much cheaper than
finding it out after the split.
cd "$HOME/ansible-monolith-lab"
# Which tasks notify which handler
grep -n -B8 'notify:' site.yml | grep -E 'name:|notify:'
Variable ownership. ntp_servers and baseline_motd_owner are used
only by baseline tasks; webapp_port, webapp_workers and
webapp_release only by webapp tasks. That clean split is what makes the
extraction possible without a shared variable file.
for v in ntp_servers baseline_motd_owner webapp_port webapp_workers webapp_release; do
echo "--- $v"
grep -n "$v" site.yml | grep -v '^\s*#'
done
Task 4: Extract the roles
cd "$HOME/ansible-monolith-lab"
mkdir -p roles/baseline/{tasks,handlers,defaults}
mkdir -p roles/webapp/{tasks,handlers,defaults}
Move the task bodies verbatim. roles/baseline/tasks/main.yml gets tasks
one to eight, dedented by two spaces so they start at column zero, with
no other change — same names, same changed_when, same notify.
roles/baseline/handlers/main.yml:
- name: Restart chrony
ansible.builtin.debug:
msg: "HANDLER restart chrony on {{ inventory_hostname }}"
- name: Reload the audit rules
ansible.builtin.debug:
msg: "HANDLER reload auditd on {{ inventory_hostname }}"
roles/baseline/defaults/main.yml:
---
ntp_servers:
- 192.0.2.101
- 192.0.2.102
baseline_motd_owner: platform-team
Do the same for webapp, taking tasks nine to seventeen, the
Restart webapp handler, and the three webapp_* variables.
The new site.yml becomes:
- name: Configure the application estate
hosts: webapp
gather_facts: false
roles:
- baseline
- webapp
Task 5: Prove equivalence
cd "$HOME/ansible-monolith-lab"
ansible-playbook -i inventory.yml site.yml --list-tasks > after-tasks.txt
diff -u before-tasks.txt after-tasks.txt
$ diff -u before-tasks.txt after-tasks.txt--- before-tasks.txt
+++ after-tasks.txt
@@ -3,7 +3,7 @@
play #1 (webapp): Configure the application estate TAGS: []
tasks:
- baseline | Assert the NTP server list is non-empty TAGS: []
+ baseline : baseline | Assert the NTP server list is non-empty TAGS: []
...Every line differs by exactly the rolename : prefix that Ansible adds to
a task belonging to a role. Nothing has been reordered, added or dropped.
Confirm that mechanically rather than by eye:
# Strip the role prefix, then the two files must be identical
sed -E 's/^( +)[a-z_]+ : /\1/' after-tasks.txt > after-normalised.txt
diff -u before-tasks.txt after-normalised.txt && echo 'TASK GRAPH IDENTICAL'
Then compare the runs:
ansible-playbook -i inventory.yml site.yml --check --diff > after-run.txt 2>&1
grep -E '^(TASK|RUNNING HANDLER|ok:|changed:|skipping:)' after-run.txt > after-summary.txt
diff -u before-summary.txt after-summary.txt
The remaining differences should be role-name prefixes only. The handler sections — which handlers fired, on which hosts, in which order — must be byte-identical.
Task 6: Record the three things that changed anyway
In refactor-notes.md, record what the diff did not show:
- Variable layer. Play vars became role defaults. Lower precedence, now overridable from inventory.
- Handler scope. Handlers are still play-global — a role’s handler can be notified by a task in another role, and a duplicate handler name across two roles silently collapses to one. Check for collisions after any split.
- Tag inheritance. Tags applied to a role entry in
roles:propagate to every task in that role. The monolith had no tags, so nothing changed here, but the next person to addtags: baselineto the role entry gets different behaviour than they would have got adding it to eight individual tasks.
Validation
-
before-tasks.txtwas created before any role directory existed. -
After normalising the
rolename :prefix,diffbetween the before and after task lists printsTASK GRAPH IDENTICAL. -
diff -u before-summary.txt after-summary.txtshows only role-name prefixes; theRUNNING HANDLERlines and their order match exactly. -
No file under
roles/*/tasks/differs from the corresponding block of the originalsite.ymlother than by indentation. Verify with:grep -c 'name:' roles/baseline/tasks/main.yml # 8 grep -c 'name:' roles/webapp/tasks/main.yml # 9 -
The repository-wide grep for the five moved variable names finds no definition outside
roles/. -
refactor-notes.mdnames all three non-task changes.
Expected Outcome
ansible-monolith-lab/
├── after-run.txt, after-summary.txt, after-tasks.txt
├── before-run.txt, before-summary.txt, before-tasks.txt
├── inventory.yml
├── refactor-notes.md
├── roles/
│ ├── baseline/{defaults,handlers,tasks}/main.yml
│ └── webapp/{defaults,handlers,tasks}/main.yml
└── site.yml <- now six lines
site.yml is six lines. Two roles are reusable in the three other
playbooks that wanted them. You have a captured, mechanical proof that the
task graph and the handler behaviour are unchanged, and a written note of
the three things that changed which no diff would have shown you.
Troubleshooting
ERROR! the role 'baseline' was not found. Roles are looked up
relative to the playbook directory in roles/, and in roles_path.
Confirm with ansible-config dump --only-changed | grep -i roles and check
you are running ansible-playbook from the directory containing roles/.
A handler stops firing after the split. The notifying task and the handler ended up in different roles and the handler name was changed during the move. Handler names are matched as strings; there is no compile-time check. Grep both roles for the exact name.
Both roles define a handler with the same name. Ansible keeps one. Which
one depends on load order, and there is no warning. Prefix handler names
with the role name, or use listen: topics, which are designed for exactly
this.
The task list diff shows reordering. You moved a task while extracting.
Recover from before-tasks.txt, which is the only record of the intended
order — this is why Task 2 comes first.
--check fails on a task that worked in the monolith. Something in the
extraction changed the variable a when: depends on, most likely because a
play var moved to role defaults and something else in scope also defines
it. Re-run the repository-wide grep from Task 4.
Cleanup
Everything happened inside the working directory. Nothing was installed, no
host was contacted, and every task ran in check mode or as debug.
Step 1. Confirm no role escaped into a shared path:
cd "$HOME/ansible-monolith-lab"
ansible-config dump --only-changed | grep -i -E 'roles_path|config file'
ls -d ~/.ansible/roles/{baseline,webapp} 2>/dev/null \
&& echo 'a role of this name exists in the user roles path - investigate before deleting'
Step 2. Keep the roles and the proof. Both are worth having:
mkdir -p "$HOME/ansible-lab-deliverables/monolith-split"
cp -a roles before-tasks.txt after-tasks.txt refactor-notes.md \
"$HOME/ansible-lab-deliverables/monolith-split/"
Step 3. Remove the working directory by absolute path:
rm -rf "$HOME/ansible-monolith-lab"
What You Learned
- Capture the task graph before you edit.
--list-tasksis the only cheap, mechanical baseline available, and it is worthless if taken afterwards. - The boundary is already in the code. Name prefixes, handler ownership and variable ownership all pointed at the same seam. A handler notified from both halves would have told you the seam was wrong.
- Task bodies moved verbatim. The proof is a normalised diff printing
TASK GRAPH IDENTICAL, not a careful read of two files. - Play vars becoming role defaults is a real precedence change, and the diff cannot see it. A repository-wide grep for the moved names is part of the refactor, not an optional extra.
- Handlers are play-global even inside roles. Two roles with a
same-named handler silently collapse to one, and
listen:is the composable alternative. include_rolewould have defeated the whole verification approach, because dynamic includes do not appear in--list-tasks.