Skip to main content
RunBook Academy

AnsibleXXII · Roles and ReuseRoles and reuse

Refactoring a monolith into roles

Advanced⏱ ~22 minansible-playbookgit

What you'll learn

  • Sequence a monolith-to-roles migration so every step is separately reviewable
  • Prove a refactor changed structure and not behaviour, with evidence
  • Preserve the --tags and --limit contracts operators already rely on
  • Choose what to extract first from the change history rather than from taste

Prerequisites

Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11

Not yet marked complete on this device.

Almost nobody starts with roles. Estates start with a playbook, the playbook grows, and one day it is 900 lines with four plays and a tasks: list that nobody reads end to end. The refactor to roles is a well-understood exercise that goes wrong for a reason that has nothing to do with roles:

A working playbook has an interface, and the interface is not the file. Operators depend on --tags deploy selecting a specific set of work. A cron job runs it with --limit web. A runbook says “run it with -e skip_migrations=true if the database team is mid-window”. None of that is written down, and all of it can break during a refactor that leaves the resulting host state identical.

So the migration has two jobs: move the tasks, and preserve the contracts.

Establish the baseline before you touch anything

Three artefacts, captured from the current playbook on the current branch. None of them contacts a host except the last, which contacts them read-only.

Read-only / Safecapture the task list, the tag surface and the host selection
mkdir -p /tmp/refactor-baseline
ansible-playbook -i inventory/production.ini site.yml --list-tasks > /tmp/refactor-baseline/tasks.txt
ansible-playbook -i inventory/production.ini site.yml --list-tags  > /tmp/refactor-baseline/tags.txt
ansible-playbook -i inventory/production.ini site.yml --list-hosts > /tmp/refactor-baseline/hosts.txt

The task list is the one that does the work. Every refactor step is then judged by one question: does the task list still match?

Read-only / Safethe diff that should be empty
$ ansible-playbook -i inventory/production.ini site.yml --list-tasks | diff /tmp/refactor-baseline/tasks.txt -
3c3
<       install the application package	TAGS: [deploy]
---
>       webapp : install the application package	TAGS: [deploy]

That is the expected diff for an extraction: task names gain a role prefix and nothing else moves. Any other line — a task that disappeared, a tag that changed, an order that shifted — is a behaviour change you did not intend, visible before you ran anything.

What to extract first

Not the biggest section, and not the one you dislike most. Extract in this order:

1. The part with the fewest inbound references. Something at the end of the play that nothing else depends on. Monitoring agent installation is the classic first extraction: it reads a couple of variables, writes a config, starts a service, and nothing later in the playbook cares.

2. The part that changes most often. Ask the repository rather than guessing:

Read-only / Safewhich sections of the playbook actually churn
git log --format='%h %ad %s' --date=short -20 -- site.yml

High-churn work benefits most from becoming a role, because a role can be tested, versioned and reviewed independently.

3. Anything you already copy-pasted into a second playbook. Duplication is the strongest evidence that a boundary exists; the second copy has already told you where it is.

Leave for last: the part that everything else depends on, usually the baseline or common setup. It is the hardest to extract cleanly and the easiest to break, and by the time you get to it you will have a much better picture of what it actually provides.

The extraction, step by step

For one section, in one pull request:

Move the tasks verbatim. Cut them from site.yml into roles/monitoring/tasks/main.yml. Do not reword names, do not reorder, do not “tidy while I am in here”. The diff must stay reviewable, and every edit you make in this step is an edit whose effect you cannot separate from the move.

Add the role to the play in the same position. If the tasks ran between two others, the role goes between the same two:

- name: build the web tier
  hosts: web
  tasks:
    - name: install the application package
      ansible.builtin.package:
        name: webapp
        state: present

    - name: monitoring
      ansible.builtin.import_role:
        name: monitoring

    - name: start the application
      ansible.builtin.systemd_service:
        name: webapp
        state: started

Use import_role, not include_role. Lesson 5 explains why: the static form keeps --list-tasks complete, which is the only thing making this refactor verifiable at all.

Move the variables into defaults/main.yml and namespace them. This is the step that changes behaviour if you are careless. A variable that was set in the play’s vars: was at precedence entry 12; in defaults/main.yml it is at entry 2. Anything in the inventory that previously lost to it now wins.

Move the handlers, and prefix them. Handlers move to roles/monitoring/handlers/main.yml, where lesson 7 applies: the name is now in a shared namespace with every other role’s. Prefix it during the move, and update the notify: in the same commit.

Diff the task list. Then diff it against the other inventories.

Preserving the operator contracts

Three things break silently. Check each one explicitly.

--tags

Tags that were on individual tasks come along with the tasks. Tags that were on a block wrapping the section need to be reapplied — put them on the import_role statement, where lesson 5 established they propagate to every task inside.

Read-only / Safeprove the tag still selects the same work
ansible-playbook -i inventory/production.ini site.yml --list-tasks --tags monitoring \
> /tmp/refactor-baseline/tasks-monitoring-after.txt
diff /tmp/refactor-baseline/tasks-monitoring.txt /tmp/refactor-baseline/tasks-monitoring-after.txt

Do this for every tag in the baseline tags.txt. It is tedious once and never again.

--limit

--limit operates on the inventory and is unaffected by roles — unless the refactor changed a hosts: line, which it should not have. Confirm with --list-hosts against the same baseline.

-e overrides in runbooks and cron

These are the ones with no artefact to diff, because they live in a wiki page and a crontab. Grep what you can:

Read-only / Safefind the invocations that exist outside the repository
grep -rn 'ansible-playbook' /etc/cron.d/ .gitlab-ci.yml .github/workflows/ 2>/dev/null

Any variable passed with -e must still be a name something reads. If the refactor renamed it — and namespacing renames it — the override becomes inert and the cron job silently reverts to the default. That is the same failure as Part XIII lesson 5’s inert group_vars line, arriving through a different door.

Verifying against real hosts

The task-list diff proves the plan is unchanged. To prove the result is unchanged, run both versions in check mode with diff enabled against a representative host:

Read-only / Safedry run with content diffs, before and after
ansible-playbook -i inventory/staging.ini site.yml --check --diff --limit web-01.example.com

On a host already converged, both versions should report zero changes. On a host that is not, both should propose the same changes. A difference between them is the refactor altering behaviour.

This is evidence rather than proof, because check mode skips modules that do not support it and cannot predict the effects of command or shell tasks. Part XXV covers where check mode tells the truth and where it does not.

Knowledge check

Knowledge check · 4 questions

  1. Q1. What is the single most useful piece of evidence that a monolith-to-roles extraction changed structure and not behaviour?

  2. Q2. Moving a value from a play vars: block into a role defaults/main.yml is behaviour-neutral, since both are just places variables live.

  3. Q3. Which operator-facing contracts can a roles refactor break while leaving the resulting host state identical? Select all that apply.

  4. Q4. Which section of a long playbook is the right one to extract first?

Passing score: 75%. Answers are checked in this browser.