Skip to main content
RunBook Academy

← All labs in Ansible

Lab · intermediate · ~90 min

Lab: Split a monolith into roles and prove the result is identical

C · SimulationB · Nested virtualisation

Objectives

  • Find the role boundaries in an existing monolithic playbook using its handlers and its variables
  • Extract two roles without changing any task body
  • Prove behavioural equivalence with --list-tasks and a check-mode diff, not by reading
  • Recognise the three things that quietly change when a play becomes a role

Prerequisites

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-core 2.21.x. Verified on 2.21.3.
  • No managed nodes required. Every task in the supplied monolith is either debug, assert or a command guarded by check_mode: false, so the whole lab runs against ansible_connection: local without changing anything.
  • diff and 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
Read-only / Safecontroller
$ ansible-playbook -i inventory.yml site.yml --list-tasks | head -12
playbook: 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
Read-only / Safecontroller
$ 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:

  1. Variable layer. Play vars became role defaults. Lower precedence, now overridable from inventory.
  2. 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.
  3. 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 add tags: baseline to the role entry gets different behaviour than they would have got adding it to eight individual tasks.

Validation

  • before-tasks.txt was created before any role directory existed.

  • After normalising the rolename : prefix, diff between the before and after task lists prints TASK GRAPH IDENTICAL.

  • diff -u before-summary.txt after-summary.txt shows only role-name prefixes; the RUNNING HANDLER lines and their order match exactly.

  • No file under roles/*/tasks/ differs from the corresponding block of the original site.yml other 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.md names 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-tasks is 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_role would have defeated the whole verification approach, because dynamic includes do not appear in --list-tasks.

Deliverables

  • · Two roles extracted from one playbook, with tasks/main.yml bodies unchanged
  • · Before and after --list-tasks captures, and the diff between them
  • · A note listing what changed that was not a task: handler names, variable scope, tag inheritance

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.