Skip to main content
RunBook Academy

← All labs in Ansible

Lab · intermediate · ~60 min

Lab: A role that refuses bad input before it touches anything

C · SimulationB · Nested virtualisation

Objectives

  • Write an argument spec covering required options, types, choices and defaults
  • Prove each class of rejection independently and read the error message it produces
  • Demonstrate that validation happens before the role first task, not during it
  • Explain the two things an argument spec does not do: it coerces a copy, and its defaults do not define variables

Prerequisites

Objective

By the end of this lab your role will refuse to start when its caller supplies a missing required option, a value outside the permitted set, or a value that cannot be coerced to the declared type — and you will have seen the exact error message for each. You will also have found the two things the spec does not do, both of which look like they should work.

Architecture

A single role with four declared options and a task that reports what it resolved. The validation task Ansible inserts is the subject of the lab.

roles/webapp/
├── meta/argument_specs.yml   <- the contract
├── defaults/main.yml          <- the defaults the contract describes
└── tasks/main.yml             <- runs only if the contract is satisfied

Requirements

  • A controller with ansible-core 2.21.x. Every message quoted below was captured from 2.21.3; older versions word them differently.
  • No managed nodes. ansible_connection: local and a debug task.
  • No privilege escalation and nothing destructive: a rejected role never reaches its first real task, which is the whole point.

Scenario

The webapp role deploys a release to a fleet. Last month someone called it with webapp_env: prod instead of production. The role has no validation, so it ran: it created the directory tree, fetched the artefact, wrote a config file naming an environment that does not exist, and failed on task fourteen when it tried to register with a service discovery endpoint that had no prod entry.

The hosts were left half-configured. Your job is to make that failure happen at task zero instead of task fourteen.

Tasks

Task 1: Build the role without validation

WORKDIR="$HOME/ansible-argspec-lab"
mkdir -p "$WORKDIR"/roles/webapp/{meta,defaults,tasks}
cd "$WORKDIR"

inventory.yml:

webapp:
  hosts:
    node1:
  vars:
    ansible_connection: local

roles/webapp/defaults/main.yml:

---
# TCP port the service listens on.
webapp_port: 8080

roles/webapp/tasks/main.yml:

- name: Report the resolved configuration
  ansible.builtin.debug:
    msg: >-
      deploying {{ webapp_version }} to {{ webapp_env }}
      on port {{ webapp_port }}

site.yml:

- name: Deploy the web application
  hosts: webapp
  gather_facts: false
  roles:
    - role: webapp
      webapp_version: '1.4.2'
      webapp_env: staging

Run it and confirm it works:

ansible-playbook -i inventory.yml site.yml

Now break it the way the incident did — change webapp_env to prod and run again. It succeeds. The role has no opinion about what webapp_env may contain.

Task 2: Write the contract

roles/webapp/meta/argument_specs.yml. The main key names the entry point; a role with tasks/other.yml would declare an other key beside it.

argument_specs:
  main:
    short_description: Deploy the web application
    description:
      - Deploys a pinned release of the web application and writes its
        configuration.
      - Refuses to start unless the release and the target environment are
        both supplied and valid.
    options:
      webapp_version:
        type: str
        required: true
        description: Release tag to deploy, in MAJOR.MINOR.PATCH form.

      webapp_env:
        type: str
        required: true
        choices:
          - staging
          - production
        description: Target environment. Selects the config template and
          the service-discovery namespace.

      webapp_port:
        type: int
        required: false
        default: 8080
        description: TCP port the service listens on.

      webapp_workers:
        type: int
        required: false
        default: 4
        description: Worker processes. Size to the host.

Run the working case first, so you can see the validation task appear:

Read-only / Safecontroller
$ ansible-playbook -i inventory.yml site.yml
TASK [webapp : Validating arguments against arg spec 'main' - Deploy the web application] ***
ok: [node1]

TASK [webapp : Report the resolved configuration] *******************************
ok: [node1] => {
  "msg": "deploying 1.4.2 to staging on port 8080"
}

Validating arguments against arg spec 'main' is a real task, inserted ahead of everything in tasks/main.yml. That position is the whole feature.

Task 3: Prove each rejection independently

A validation you have not watched refuse is a validation you are assuming. Run all three failures.

Missing required option. Remove webapp_version from site.yml:

Read-only / Safecontroller
$ ansible-playbook -i inventory.yml site.yml
TASK [webapp : Validating arguments against arg spec 'main' - Deploy the web application] ***
[ERROR]: Task failed: Action failed: Validation of arguments failed:
missing required arguments: webapp_version
fatal: [node1]: FAILED! => {"argument_errors": ["missing required arguments: webapp_version"], ...}

A value outside the permitted set. Restore webapp_version and set webapp_env: prod:

Read-only / Safecontroller
$ ansible-playbook -i inventory.yml site.yml
[ERROR]: Task failed: Action failed: Validation of arguments failed:
fatal: [node1]: FAILED! => {"argument_errors": ["value of webapp_env must be one of: staging, production, got: prod"], ...}

must be one of: staging, production, got: prod names the allowed values and the one supplied. A caller who has never read the role can fix this from the message alone, which is the difference between a contract and an assertion.

A type that cannot be coerced. Set webapp_port: eighty-eighty:

Read-only / Safecontroller
$ ansible-playbook -i inventory.yml site.yml
[ERROR]: Task failed: Action failed: Validation of arguments failed:
argument 'webapp_port' is of type str and we were unable to convert to int: "'eighty-eighty'" cannot be converted to an int
fatal: [node1]: FAILED! => {"argument_errors": ["argument 'webapp_port' is of type str and we were unable to convert to int: \"'eighty-eighty'\" cannot be converted to an int"], ...}

Record all three in rejections.md, with the exact message.

Task 4: Find the two things the spec does not do

The spec’s error messages make it feel like a type system. It is not, and the two gaps are worth discovering deliberately rather than in production.

Gap one: validation coerces a copy, not the variable. Set webapp_port: "8080" — the correct port, as a quoted string — and have the role report the type it sees:

# roles/webapp/tasks/main.yml
- name: Report the resolved configuration and its type
  ansible.builtin.debug:
    msg: >-
      deploying {{ webapp_version }} to {{ webapp_env }}
      on port {{ webapp_port }} ({{ webapp_port | type_debug }})
Read-only / Safecontroller
$ ansible-playbook -i inventory.yml site.yml
TASK [webapp : Validating arguments against arg spec 'main' - Deploy the web application] ***
ok: [node1]

TASK [webapp : Report the resolved configuration and its type] ******************
ok: [node1] => {
  "msg": "deploying 1.4.2 to staging on port 8080 (str)"
}

(str). The validation task coerced "8080" to an integer, confirmed it satisfied type: int, and discarded the result. The variable the role’s tasks read is still the string the caller passed.

Gap two: default: in the spec does not define the variable. Delete roles/webapp/defaults/main.yml entirely, leaving the spec’s default: 8080 as the only definition, and run again with no webapp_port parameter:

Read-only / Safecontroller
$ rm roles/webapp/defaults/main.yml && ansible-playbook -i inventory.yml site.yml
TASK [webapp : Validating arguments against arg spec 'main' - Deploy the web application] ***
ok: [node1]

TASK [webapp : Report the resolved configuration and its type] ******************
fatal: [node1]: FAILED! => {"msg": "The task includes an option with an undefined variable: 'webapp_port' is undefined"}

Restore roles/webapp/defaults/main.yml before continuing:

cat > roles/webapp/defaults/main.yml <<'YAML'
---
# Must mirror the default: values in meta/argument_specs.yml.
# The spec documents and validates; this file is what actually defines.
webapp_port: 8080
webapp_workers: 4
YAML

Add both gaps to rejections.md, with the observed output.

Task 5: Compare with the assert you would otherwise have written

Without an argument spec the same checks go in tasks/main.yml:

- name: Validate inputs
  ansible.builtin.assert:
    that:
      - webapp_version is defined
      - webapp_env is defined
      - webapp_env in ['staging', 'production']
    fail_msg: "bad inputs"

Write that into a second role, webapp_assert, and note the differences in rejections.md:

argument specassert in tasks
Runs before task 1yes, alwaysonly if it is task 1, and only if nobody adds a task above it
Reports every error at onceyes — argument_errors is a listno, first failed condition only
Coerces typesyesno
Applies defaultsyesno, defaults/main.yml does
Visible in ansible-docyesno
Documents the interfaceyes, with descriptionsno
Costs a task in the outputone, named clearlyone, named by you

Task 6: Confirm the spec is visible to a consumer

An argument spec is documentation as well as validation:

cd "$HOME/ansible-argspec-lab"
ansible-doc -t role -r ./roles webapp

-r takes a roles path, not the project root. Pointing it at . finds nothing and prints nothing, with exit code 0 — another quiet no-op worth knowing about.

A consumer can now read the role’s contract without opening its source — required options, types, choices, defaults and descriptions, from the same file that enforces them.

Validation

  • A valid run shows the task webapp : Validating arguments against arg spec 'main' immediately before the role’s own first task.
  • Removing webapp_version produces missing required arguments: webapp_version and the role’s own tasks do not appear in the output at all.
  • webapp_env: prod produces value of webapp_env must be one of: staging, production, got: prod.
  • webapp_port: eighty-eighty produces unable to convert to int.
  • webapp_port: "8080" succeeds, and the role reports port 8080 (str) — validation coerced a copy, not the variable.
  • With roles/webapp/defaults/main.yml deleted, validation still passes and the role’s first task fails with 'webapp_port' is undefined, proving the spec’s default: does not define the variable.
  • ansible-doc -t role -r ./roles webapp prints the options with their types, choices, defaults and descriptions.
  • rejections.md records three rejections, both gaps, and the comparison table.

Expected Outcome

ansible-argspec-lab/
├── inventory.yml
├── rejections.md
├── roles/
│   ├── webapp/{defaults,meta,tasks}/main.yml
│   └── webapp_assert/{tasks}/main.yml
└── site.yml

The role refuses three classes of bad input before its first task, with messages a caller can act on. You can state which class of error an argument spec does not catch, and why the position of the check matters more than its syntax.

Troubleshooting

The validation task does not appear. The file must be meta/argument_specs.yml — plural specs, singular argument. A misspelling produces no error, because a role with no argument spec is perfectly valid; it simply does not validate.

ERROR! Unexpected Exception mentioning YAML. The top-level key is argument_specs:, then the entry-point name, then options:. A spec written with options: at the top level parses as YAML and fails at load.

Defaults in the spec and in defaults/main.yml disagree. Only defaults/main.yml defines the variable; the spec’s default: is used for validation and documentation and then discarded. If they disagree, the role uses the defaults/main.yml value and ansible-doc publishes the other one. Nothing checks that they match — mirror them by hand and say so in a comment in both files.

A type: list option rejects a single string. Use elements: str alongside type: list to declare the member type. Note that a bare string supplied where a list is declared is coerced to a one-element list, which is convenient and occasionally surprising.

Validation passes but the role fails on an undefined variable. The variable is used by the role but not declared in the spec. The spec validates what it declares and says nothing about the rest — an option missing from the spec is an option with no contract.

Cleanup

Nothing outside the working directory was created; no host was contacted, and every rejected run stopped before its first real task.

Step 1. Confirm no role was written to a shared path:

cd "$HOME/ansible-argspec-lab"
ansible-config dump --only-changed | grep -i -E 'roles_path|config file'

Step 2. Keep the spec and the record of rejections:

mkdir -p "$HOME/ansible-lab-deliverables/argspec"
cp -a roles/webapp/meta/argument_specs.yml rejections.md \
      "$HOME/ansible-lab-deliverables/argspec/"

Step 3. Remove the working directory by absolute path:

rm -rf "$HOME/ansible-argspec-lab"

What You Learned

  • The spec runs before the role’s first task, always. You saw Validating arguments against arg spec 'main' inserted ahead of tasks/main.yml, which is the property an assert cannot guarantee.
  • Three classes of rejection, three actionable messages. Missing required, value not in choices:, and uncoercible type — each naming the option, the constraint and the value supplied.
  • Validation coerces a copy, not the variable. "8080" passed type: int and the role still saw a str. A spec is a gate, not a transformation.
  • default: in the spec does not define anything. With defaults/main.yml removed, validation passed and the first real task failed on an undefined variable. The two files must be kept in step by hand.
  • argument_errors is a list, so a caller who got three options wrong learns about all three in one run.
  • ansible-doc -t role -r wants a roles path. Given a project root it prints nothing and exits 0.
  • The spec is also the role’s published documentation, readable with ansible-doc -t role without opening the source.
  • An option missing from the spec has no contract. Validation covers what it declares and is silent about everything else.

Deliverables

  • · A meta/argument_specs.yml for a four-option role
  • · Three recorded rejections plus the two observed gaps, with the exact messages
  • · A comparison table of an argument spec against the equivalent assert in tasks/main.yml

Verification status

Last reviewed
2026-08-11
Executed end to end
not yet run on hardware

The commands and configuration here have been reviewed against the verified software versions, but nobody has run this lab start to finish on a system meeting its prerequisites. Treat the Expected Outcome as the intended result rather than an observed one, and keep the Cleanup section to hand.