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-core2.21.x. Every message quoted below was captured from 2.21.3; older versions word them differently. - No managed nodes.
ansible_connection: localand adebugtask. - 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:
$ ansible-playbook -i inventory.yml site.ymlTASK [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:
$ ansible-playbook -i inventory.yml site.ymlTASK [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:
$ 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:
$ 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 }})
$ ansible-playbook -i inventory.yml site.ymlTASK [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:
$ rm roles/webapp/defaults/main.yml && ansible-playbook -i inventory.yml site.ymlTASK [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 spec | assert in tasks | |
|---|---|---|
| Runs before task 1 | yes, always | only if it is task 1, and only if nobody adds a task above it |
| Reports every error at once | yes — argument_errors is a list | no, first failed condition only |
| Coerces types | yes | no |
| Applies defaults | yes | no, defaults/main.yml does |
Visible in ansible-doc | yes | no |
| Documents the interface | yes, with descriptions | no |
| Costs a task in the output | one, named clearly | one, 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_versionproducesmissing required arguments: webapp_versionand the role’s own tasks do not appear in the output at all. webapp_env: prodproducesvalue of webapp_env must be one of: staging, production, got: prod.webapp_port: eighty-eightyproducesunable to convert to int.webapp_port: "8080"succeeds, and the role reportsport 8080 (str)— validation coerced a copy, not the variable.- With
roles/webapp/defaults/main.ymldeleted, validation still passes and the role’s first task fails with'webapp_port' is undefined, proving the spec’sdefault:does not define the variable. ansible-doc -t role -r ./roles webappprints the options with their types, choices, defaults and descriptions.rejections.mdrecords 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 oftasks/main.yml, which is the property anassertcannot 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"passedtype: intand the role still saw astr. A spec is a gate, not a transformation. default:in the spec does not define anything. Withdefaults/main.ymlremoved, validation passed and the first real task failed on an undefined variable. The two files must be kept in step by hand.argument_errorsis a list, so a caller who got three options wrong learns about all three in one run.ansible-doc -t role -rwants 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 rolewithout opening the source. - An option missing from the spec has no contract. Validation covers what it declares and is silent about everything else.