Ansible roles and collections: reusable configuration with controlled inputs
Design Ansible roles and collections with explicit inputs, handlers and pinned dependencies. Understand include versus import behavior and test repeatable configuration changes.
TL;DR: A role packages a reusable configuration responsibility. A collection distributes roles, modules and plugins under a namespace. Keep the role's input contract explicit and verify both the first configuration run and the unchanged second run.
Choose a responsibility small enough to reason about
A role might configure a report worker's directories and configuration file. It should not quietly provision the network, create unrelated users and restart every application on the host. The caller needs to understand which resources the role owns and what changes can interrupt service.
The conventional role layout separates tasks, handlers, templates, defaults and other resources. Ansible loads these according to role conventions; the roles documentation explains the structure. Defaults are suitable for overridable inputs. High-precedence role variables are a poor place for values callers are expected to customize routinely.
Give inputs a prefix such as report_worker_port instead of a generic port. Define supported types and ranges, using role argument validation where the installed Ansible version supports it. A string that looks like an integer may travel through inventory and templates differently from an actual integer. Catch an invalid port before rendering a broken service configuration.
Collections establish distribution identity
A fully qualified collection name identifies a plugin through namespace, collection and plugin name, such as ansible.builtin.copy. It reduces ambiguity when two collections expose modules with the same short name. A role remains a role when packaged inside a collection; the collection is the distribution boundary.
Pin reviewed collection versions in the automation's dependency specification and record the Ansible runtime alongside them. A version constraint that permits a range still permits different resolutions. Preserve the resolved environment used to test a release, particularly when a collection's module behavior changes independently of the playbook repository. See the collection installation guide.
| Packaging decision | Helps with | Review concern |
|---|---|---|
| Role defaults | Documented caller customization | Unexpected variable override |
| Role handlers | Responding to changed configuration | Shared names and restart scope |
| Collection version | Repeatable dependency selection | Transitive dependencies and runtime compatibility |
| Explicit FQCN | Module identity | Correct plugin arguments for that version |
A local configuration exercise
Create a disposable project with a role directory named roles/report_worker. Inside it, save this task as tasks/main.yml. The exercise writes a marker file; it does not configure or restart a real service.
- name: Write worker settings in the exercise directory
ansible.builtin.copy:
dest: "{{ report_worker_output }}"
content: "worker_port={{ report_worker_port }}\n"
mode: '0600'
Use a playbook with a caller-supplied temporary path:
- name: Exercise a role without a remote host
hosts: localhost
connection: local
gather_facts: false
vars:
report_worker_port: 8091
roles:
- role: report_worker
Invoke it against localhost with report_worker_output set to a new file in that temporary directory. The first run should create the file; an identical second run should report no changes for this task. Changing the port to 8092 should update the file once. This tests the declared input and the module's convergence behavior without claiming that every task in a larger role is idempotent.
Inside the same role directory, save this input schema as meta/argument_specs.yml. It rejects an invalid port type before the role writes its output. The role entry point is main; the declared names must match the values the tasks actually consume.
argument_specs:
main:
short_description: Render a local worker settings file
options:
report_worker_output:
type: path
required: true
report_worker_port:
type: int
required: true
Run a negative case with report_worker_port=not-a-port. The role should fail argument validation and leave the previous output unchanged. Type checking alone does not establish that an integer is a valid TCP port; add the allowed range to the role's validation when developing the production contract. Test a missing required value as another caller error.
A published collection can contain several roles under roles/, plugin implementations under plugins/ and collection metadata in galaxy.yml. The consuming project installs that distribution and selects its roles through qualified names. Keep the public input contract stable across compatible releases. If a new role version changes a default from a local file to a remote destination, treat that as a behavior change requiring review even if the YAML keys are unchanged.
Import and include affect when tasks are expanded
Static imports are expanded during playbook processing; dynamic includes are evaluated during execution. Loops, conditions and task-list visibility consequently behave differently. Variable exposure has also evolved across Ansible versions and configuration defaults. Avoid teaching a universal visibility rule from a single old example. The reuse guide explains the distinction; verify it against your installed release.
Self-check: the role works in isolation but selects the wrong port inside a larger playbook. Before adding another override, inspect variable precedence and the generic names the role consumes. A prefixed input contract and a small caller test make ownership explicit. Pinning the collection alone cannot resolve two unrelated roles competing for the same variable name.