Skip to main content
RunBook Academy

AnsibleXXII · Roles and ReuseRoles and reuse

Roles that refuse bad input

Intermediate⏱ ~20 minansible-playbook

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

Not yet marked complete on this device.

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:

Read-only / Safea valid call
$ ansible-playbook -i inventory.ini site.yml
TASK [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:

Read-only / Safean invalid call, with two independent errors
$ ansible-playbook -i inventory.ini site-bad.yml
TASK [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=0

Reporting 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:

Read-only / Safevalidation passes, the role then fails on the same variable
$ ansible-playbook -i inventory.ini site.yml
TASK [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=0

Adding 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.

Read-only / Safean int option arriving as a str
$ ansible-playbook -i inventory.ini site.yml
TASK [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

  1. 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?

  2. 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.

  3. Q3. Which statements about the generated validation task are correct? Select all that apply.

  4. 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.