AnsibleXXII · Roles and ReuseRoles and reuse
Roles that refuse bad input
What you'll learn
- Write a meta/argument_specs.yml that fails a role before it changes anything
- Declare entry points that match the role tasks_from files
- Predict what a spec default does and does not do to a variable
- Explain why a validated int option can still arrive as a string
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
A role’s interface, up to this point, has been a convention: keys in
defaults/main.yml and a README that describes them. Nothing checks that a
caller supplied what the role needs, and nothing checks that what they
supplied makes sense. A missing required value surfaces as an
undefined-variable error somewhere in the middle of the role, after three
tasks have already changed the host.
meta/argument_specs.yml replaces the convention with something the run
enforces. It is a declaration of the role’s inputs, and Ansible inserts a
validation task at the start of the role that fails before any of the
role’s own tasks execute.
The file
# roles/webapp/meta/argument_specs.yml
argument_specs:
main:
short_description: Configure and run the storefront application
description:
- Installs the application package, writes its configuration and
manages the systemd unit.
options:
webapp_listen_port:
type: int
required: true
description: TCP port the application binds.
webapp_listen_address:
type: str
default: 127.0.0.1
description: Address to bind. Loopback unless a proxy fronts it.
webapp_mode:
type: str
default: balanced
choices:
- balanced
- strict
description: Operating profile.
webapp_workers:
type: int
default: 4
description: Worker processes.
webapp_vhosts:
type: list
elements: dict
default: []
description: Virtual hosts to configure.
options:
name:
type: str
required: true
document_root:
type: path
required: true
main is an entry point, and it corresponds to tasks/main.yml. A role
with tasks/verify.yml invoked via tasks_from: verify can declare a
verify: entry point with its own options, validated independently. Options
nest: elements: dict plus a nested options: block validates the shape of
every item in a list.
What it does at run time
The validation task appears in the run output with a generated name that
includes the entry point and its short_description:
$ ansible-playbook -i inventory.ini site.ymlTASK [webapp : Validating arguments against arg spec 'main' - Configure and run the storefront application] ***
ok: [web-01.example.com]
TASK [webapp : report effective configuration] *********************************
ok: [web-01.example.com] => {
"msg": "port=8080 mode=balanced"
}And when the call is wrong, it fails before anything is touched — reporting every problem at once rather than the first one:
$ ansible-playbook -i inventory.ini site-bad.ymlTASK [webapp : Validating arguments against arg spec 'main' - Configure and run the storefront application] ***
fatal: [web-01.example.com]: FAILED! => {
"argument_errors": [
"missing required arguments: webapp_listen_port",
"value of webapp_mode must be one of: balanced, strict, got: aggressive"
],
"changed": false,
"msg": "Validation of arguments failed:\nmissing required arguments: webapp_listen_port\nvalue of webapp_mode must be one of: balanced, strict, got: aggressive",
"validate_args_context": {
"argument_spec_name": "main",
"name": "webapp",
"path": "/srv/automation/roles/webapp",
"type": "role"
}
}
PLAY RECAP *********************************************************************
web-01.example.com : ok=0 changed=0 unreachable=0 failed=1 skipped=0 rescued=0 ignored=0Reporting all errors together is the property that makes this worth the file. A caller fixing a role invocation gets the whole list in one run rather than discovering the next problem after each fix.
The two behaviours that catch people
A spec default does not define the variable
This is the one that produces a confusing failure, and it is worth stating
flatly: default: in argument_specs.yml is used for validation and does
not set the variable for the role to use. You still need the key in
defaults/main.yml.
Verified by execution. A role whose spec declares webapp_mode with
default: balanced, and whose defaults/main.yml does not declare it,
passes validation and then fails on the undefined variable:
$ ansible-playbook -i inventory.ini site.ymlTASK [webapp : Validating arguments against arg spec 'main' - Configure and run the storefront application] ***
ok: [web-01.example.com]
TASK [webapp : report effective configuration] *********************************
fatal: [web-01.example.com]: FAILED! => {"msg": "Task failed: Finalization of task args for 'ansible.builtin.debug' failed: Error while resolving value for 'msg': 'webapp_mode' is undefined"}
PLAY RECAP *********************************************************************
web-01.example.com : ok=1 changed=0 unreachable=0 failed=1 skipped=0 rescued=0 ignored=0Adding webapp_mode: balanced to defaults/main.yml fixes it. The spec and
the defaults file are two declarations of the same interface and they must
agree — which is a real maintenance cost and the strongest argument against
adding a spec to a role that does not need one.
Type validation accepts, it does not convert
The second surprise: an option declared type: int accepts a string that
looks like an integer, and the role then receives the original string.
$ ansible-playbook -i inventory.ini site.ymlTASK [webapp : Validating arguments against arg spec 'main' - Configure and run the storefront application] ***
ok: [web-01.example.com]
TASK [webapp : report effective configuration] *********************************
ok: [web-01.example.com] => {
"msg": "port=8080 type=str mode=balanced"
}So validation tells you the value could be an int. It does not hand your role an int. Any arithmetic or numeric comparison inside the role must still convert:
- name: refuse a privileged port without the capability
ansible.builtin.assert:
that: webapp_listen_port | int >= 1024
fail_msg: >-
webapp_listen_port is {{ webapp_listen_port }}; binding below 1024
requires CAP_NET_BIND_SERVICE on the unit, which this role does not set.
Without the | int, a string port compared against an integer raises a
templating error rather than a useful message — and it raises it only for
the callers who happened to quote the value, which is the worst kind of
intermittent.
Validating outside a role
ansible.builtin.validate_argument_spec runs the same validation machinery
from an ordinary task, which is useful for a playbook that takes inputs
without being a role:
- name: validate the inputs this playbook requires
ansible.builtin.validate_argument_spec:
argument_spec:
target_environment:
type: str
required: true
choices: [staging, production]
batch_size:
type: int
default: 5
The module has an action plugin and full check-mode support, so it fires
under --check as well as a real run. That matters: a dry run that skipped
input validation would report a clean plan for a run that cannot start.
Knowledge check
Knowledge check · 4 questions
Q1. A role declares webapp_mode with default: balanced in meta/argument_specs.yml, and does not mention it in defaults/main.yml. What happens on a run where the caller does not set it?
Q2. An option declared type: int in an argument spec is converted to an integer before the role body runs, so arithmetic inside the role is safe without an int filter.
Q3. Which statements about the generated validation task are correct? Select all that apply.
Q4. You are adding an argument spec to a role that forty playbooks already call. What sequence introduces it without risking an outage?
Passing score: 75%. Answers are checked in this browser.