Designing Reusable Roles
A role that is reusable and a role that merely runs are different things, and the gap between them is interface design. The larkspur_web role is reusable because its defaults/ expose every knob a caller needs, its tasks are idempotent so a second run is a no-op, and it does one thing — configure the web tier — instead of also managing the database. This topic turns the scattered Larkspur web tasks into a role you would publish, and names the discipline that makes a role worth reusing rather than just functional.
Everything in this chapter converges here. Anatomy gave the role a shape, defaults gave it an interface, application gave it ways to be called — what remains is the design that lets a second team apply it to a host you never imagined without reading a single one of its tasks.
Interface Design via defaults/
The defaults file is the role's API. Expose larkspur_web_nginx_port, larkspur_web_workers, and larkspur_web_app_path as named, documented, sensibly-defaulted variables, and a caller configures the role without ever opening its tasks. Design this file first — decide what is tunable before you write the implementation behind it — and the role gains a contract. The tasks become the private mechanism that honors the public knobs, the same way a Terraform module's resources sit behind its typed input variables.
Single-Purpose Roles
larkspur_web configures nginx and gunicorn for the app and nothing else. Folding the PostgreSQL setup into it couples two tiers and kills reuse — now no caller can use the web part without dragging in a database they may not want. The database gets its own larkspur_db role, and the two compose in a playbook. One role, one concern: that is what lets larkspur_web apply to a host that talks to a managed database it never provisions, and what lets larkspur_db serve a service that is not the web app at all.
Idempotency as a Requirement, Not a Bonus
Every task uses a state-checking module — ansible.builtin.template, ansible.builtin.package, ansible.builtin.service — not command or shell. Apply the role to an already-configured web2 and it reports zero changes, because nothing needs changing. A role that re-restarts nginx on every run is not reusable; it is a liability in a rolling deploy, because each application drops the host out of rotation for a restart that accomplished nothing. Idempotency is the property that makes a role safe to run repeatedly, in a serial batch, after an incident — not a nicety, a requirement.
argument_specs and the README
meta/argument_specs.yml declares the role's inputs with types and required-ness, so a missing or wrong-typed variable fails fast with a clear message instead of forty tasks into the run with an opaque error. Pass a string where a list was expected and the role rejects it at the start, naming the variable. The README documents the same interface for humans — what each input means, what it defaults to, what it requires. Together they let a caller use the role correctly without reading the tasks, and fail loudly when they do not.
# roles/larkspur_web/meta/argument_specs.yml argument_specs: main: short_description: Configure the Larkspur web tier options: larkspur_web_nginx_port: type: int default: 443 larkspur_web_app_path: type: str required: true
Declare larkspur_web_app_path as a required string and a caller who omits it gets a clear failure before any task runs, naming the variable. Declare larkspur_web_nginx_port as an int and a caller who passes "https" is rejected at the start, not at the task that tries to bind the port.
The larkspur_web Role, Assembled
The pieces come together as the chapter's worked output. defaults/main.yml holds the knobs; tasks/main.yml imports install.yml, configure.yml, and service.yml; templates/ carries the nginx and app-env Jinja2; handlers/ defines reload nginx and restart gunicorn, notified only when config actually changes. The scattered Layer A web tasks are now one named, idempotent, documented unit — ready to apply to any web host, compose with larkspur_db, or publish as the larkspur.web collection.
- hosts: web_servers become: true roles: - role: larkspur_web larkspur_web_nginx_port: 443 larkspur_web_app_path: /opt/larkspur
A dozen scattered tasks reduced to one named role with two tuned inputs — applied to every web host, reporting zero changes on the second run, and configurable for the next app without touching its source. That is the difference interface design makes, and it is the whole point of the chapter.
- Hardcoding the nginx port and worker count in
tasks/main.ymlinstead of exposing them as defaults, so reusing the role for a second app means editing its source — at which point it is not reusable. - Building one mega-role that configures web, database, and load balancer, so no caller can use the web part without dragging in the rest.
- Using
command: systemctl restart nginxinstead of a handler on anotify, so every run restarts nginx whether or not config changed, breaking idempotency and disrupting aserialrolling deploy. - Shipping a role with no
argument_specsand no README, so a caller passing a string where a list was expected fails forty tasks in with an opaque error. - Letting role variables go unprefixed —
port,path— so the role cannot coexist with another role in the same play without a collision.
- Design the
defaults/main.ymlfirst as the role's documented, prefixed, sensibly-defaulted interface, and treat the tasks as implementation behind it. - Keep each role single-purpose — one tier, one concern — so
larkspur_webandlarkspur_dbcompose in a playbook instead of fusing into an un-reusable block. - Make every task idempotent with a state-checking module and
notifyhandlers, so applying the role to an already-correct host reports zero changes and is safe in a rolling deploy. - Declare inputs in
meta/argument_specs.ymland document them in the README, so wrong or missing variables fail fast with a clear message and a caller can use the role without reading it. - Prefix every variable with the role name so the role drops into any play alongside others without colliding on a bare
portorpath.
Knowledge Check
Why is defaults/main.yml the lever of a role's reusability?
- It is the role's documented, overridable interface, so a caller configures the role without editing its tasks
- It carries the highest precedence of any variable source in the role, so its values can never be overridden by a caller by mistake
- It is the single file Ansible loads automatically, which makes the rest of the role's directories optional
- It stores the role's pre-compiled task graph on disk so repeated runs execute faster
Why does folding the PostgreSQL setup into larkspur_web hurt reuse?
- It couples two tiers, so no caller can apply the web configuration without also dragging in the database
- A single role is limited to one module type, so it can never manage both nginx and PostgreSQL together
- Variables from the two tiers would share a namespace and overflow the 22 available precedence levels
- Ansible always runs database tasks before web tasks regardless of their written order, corrupting the sequence
Why does idempotency matter specifically in a serial rolling deploy?
- A non-idempotent role restarts the service on every run, dropping each host out of rotation for a change that accomplished nothing
- Rolling deploys quietly skip any task that reports a change on a previous batch, so non-idempotent tasks effectively never run at all
- Idempotency is the property that makes
serialbatches execute fully in parallel rather than in sequence - Without idempotency the deploy cannot roll back at all, since there is no state file for Ansible to revert
What does meta/argument_specs.yml validate, and when does it fail?
- It declares input types and required-ness, so a missing or wrong-typed variable fails fast at the start with a clear message naming the variable
- It validates the role's computed output against the calling playbook, failing only after every task has completed
- It checks that every single task in the role is idempotent and fails the whole run immediately if any one task reports a change to the target host
- It enforces the role's declared supported operating-system list and aborts the run immediately on any unlisted target platform
You got correct