ansible
Write, review, and run Ansible: the ansible-core line a project pins, what the 2.19 templating engine refuses, which task results are claims rather than measurements, where a variable's value comes fr
SKILL.mdplugins/infrastructure/skills/ansible/SKILL.md
GitHubThree biases decide most calls:
- The ansible-core line the project pins decides what may be written, not the binary on the machine. The 2.19 templating rewrite is the boundary: content that runs on 2.18 can fail, warn, or render literally on 2.19 and later with no YAML change.
okandchangedare claims made by the module, not measurements. Acommandtask claims a change on every run, an async launch always claims one, and a--checkrun skips whatever it cannot simulate. Idempotence is proven by a second run reportingchanged=0, and by nothing else.- Where a value comes from is not guessable. Inventory layout,
group_varsadjacency,ansible.cfgdiscovery, and collection versions each change what a play does. Read them before the first edit.
Core Version
Read the pin before writing: requires_ansible in meta/runtime.yml, the ansible-core line in a requirements or
execution-environment file, and the CI matrix. ansible --version reports one machine. The ansible community package
pulls one ansible-core line: 11 → 2.18, 12 → 2.19, 13 → 2.20, 14 → 2.21.
Support windows, which decide whether a pin is defensible:
- 2.18 — end of life May 2026. No fixes of any kind. Control Python 3.11 to 3.13, target 3.8 to 3.13.
- 2.19 — security fixes only from 2026-05-18, end of life November 2026. Control 3.11 to 3.13, target 3.8 to 3.13.
- 2.20 — critical fixes only from 2026-05-18, security only from 2026-11-02, end of life May 2027. Control 3.12 to 3.14, target 3.9 to 3.14.
- 2.21 — GA 2026-05-18, critical only from November 2026, end of life November 2027. Control 3.12 to 3.14, target 3.9 to 3.14.
Floor per feature — the changes the rules below depend on:
- 2.18 —
loop_control.break_when;meta: end_role;timedoutandvaulted_filetests;rawover SSH to Windows runs unwrapped, withoutpowershell.exe. - 2.19 — trust-tagged templating, boolean-only conditionals, no multi-pass rendering, lazy evaluation, native types
only;
ALLOW_BROKEN_CONDITIONALS;DISPLAY_TRACEBACK;SSH_ASKPASSreplacessshpass; abecometimeout is unreachable;omitdropped from loop items;boolfilter narrowed; pluralcollections_pathsremoved;paramikoandauto_legacydeprecated. - 2.20 — control Python 3.12+, target 3.9+;
transport = smartremoved; Galaxy v2 API removed;vaultidfilter parameter removed;include_varsrejects a non-listextensions;INJECT_FACTS_AS_VARSdeprecated, default flips in 2.24. - 2.21 — register projections and the
_taskobject;ansible-galaxyrefuses a collection whoserequires_ansibleexcludes the running core;paramikoandauto_legacyremoved;invocationdropped from results; failure inference fromrcdeprecated, warnings from 2.22;to_yamldecrypts vaulted values again.
Read [${CLAUDE_SKILL_DIR}/references/versions/core-2.NN.md] — core-2.18.md through core-2.21.md — when writing
against a feature near the floor, when raising the floor, and before touching content that must run on 2.18 and 2.19
both. Each carries what its line added, changed, removed, and deprecated with the removal target, and the traps.
Templating
The 2.19 engine holds on every maintained line. Write for it; do not write for 2.18 unless the project pins 2.18.
- Never put
{{ }}insidewhen,failed_when,changed_when,until,break_when,assert.that, or aregisterprojection. They are expressions already. Delimiters there are the errorTemplate delimiters are not supported in expressions. - A conditional must evaluate to a boolean.
when: some_stringfails withConditional result was 'x' of type 'str' ... Conditionals must have a boolean result.Writesome_string | length > 0,is truthy,is defined, or| bool. A YAML list underwhenisand; writeorin one string. - A string that arrives with
{{ }}in it is data. Module results, facts, command output, and file contents are never rendered, and no setting restores rendering. A custom plugin that returns template text must tag it withtrust_as_template. - Types survive.
Noneis not""; a list through a string filter becomes a string;range()needs| listbefore it is stored;"127" == 127is false. Convert withint,string,bool,list. default()binds to the immediate value only. A container with an undefined leaf is defined; putdefaultat the leaf.default(x, true)also replacesfalse,0,"", and empty containers.default(omit)belongs on the module argument. A loop item value that resolves toomitis dropped from the item, soitem.keyis undefined, not a placeholder.set_factevaluates on assignment; avarsentry is re-evaluated on every access. Useset_factto freeze a value,varsto derive one.{% if %}text{% endif %}in a value yieldsNonewhen the branch is not taken. Add an explicit{% else %}{{ "" }}{% endif %}.set_factdoes not turn"yes"or"true"strings into booleans. Use YAML booleans.
Read [${CLAUDE_SKILL_DIR}/references/templating.md] when writing a filter chain, a boolean conversion, a combine
merge, a serialization, or when an error names a type — it carries the expression contexts, the type rules, undefined
handling, omit, dictionary and list filters, and the trust model.
YAML
- Quote a value that starts with
{{. Unquoted,{opens a flow mapping and the error is a YAML syntax error. - Quote file modes:
mode: "0644". Unquoted0644is 420. Symbolicu=rw,g=r,o=ralso works. - Write
trueandfalse.yes,no,on,offare booleans to the parser;version: yesis not a string. - Quote an expression that contains
:insideassert.thatorwhen; otherwise it parses as a mapping and fails withConditional expressions must be strings. !unsafemarks a value as data so it is never rendered even from a trusted file.{% raw %}keeps delimiters literal inside a template that is otherwise rendered.- INI
[group:vars]values are strings, whilekey=valueon a host line is a Python literal. Apply| boolor| intwhen consuming, or use YAML inventory.
Idempotence
commandandshellreportchangedon every run. Give themcreates,removes, orchanged_when;changed_when: falseon a read. Prefer the module that owns the state (apt,service,file,lineinfile).commandruns no shell. No|,>,;,&, globbing;$VARis expanded by Python and left alone when unset. Useargvfor arguments with spaces andquotefor interpolated values.- A
shellpipeline hides the first failure. Start it withset -o pipefailand setexecutable: /bin/bash. - An async task always reports
changed. Putchanged_when,creates, and friends on theasync_statustask. lineinfileregexpmust match the line after the change too, or every run rewrites it.blockinfileneeds a distinctmarkerper logical block, or one block replaces another.- A loop is
changedif any item changed, and every notified handler fires once for the whole task. Per-item truth is inresults[]. state: latestand an unpinnedgitcheckout give a different result on a different day. Pin the version.- Check mode skips a module that cannot simulate, and
commandwithoutcreatesorremovesis one of them.check_mode: falseforces a task to run under--check;ansible_check_modeis the variable to branch on.--diffreveals file contents;diff: falseon a secret.
Failure
ignore_errorsswallows only afailedresult. An undefined variable, a syntax error, a missing interpreter, or an unreachable host is not covered;ignore_unreachablehandles the last.- A
failed_whenlist isand.failed_when: rc != 0 or 'ERROR' in stdoutin one string fails on either. - A
becometimeout is unreachable, not failed, soignore_errorsdoes not cover it. - A
rescuethat succeeds reverts the failure for play control: the host continues, and neithermax_fail_percentagenorany_errors_fatalcounts it, while the recap counts it underrescued. An invalid task definition or an unreachable host never entersrescue.ansible_failed_taskandansible_failed_resultare set inside it. max_fail_percentageis perserialbatch and must be exceeded, not met: withserial: 4, aborting on two failures needs49.any_errors_fatalfinishes the failing task on the current batch, then stops every host.- A module that returns
rc: 1and nofailedis failed by inference on 2.21 and earlier, and the inference is deprecated from 2.21. Setfailedor callfail_jsonin every module. _task.resultreplacesregisterinsidefailed_when,changed_when, anduntilon 2.21; in a loop it needsdefault({})on the first iteration.
Handlers
- A handler does not run on a host where a later task failed unless
force_handlersis set. The configuration is changed and the service is not restarted.meta: flush_handlersruns the handlers notified so far, and works insiderescue. - Handlers run in definition order, once per play section (
pre_tasks,rolesandtasks,post_tasks), no matter how many tasks notified them or in what order. - Two handlers with one name shadow: the last loaded wins, in the order
roles:,handlers:,import_role,include_role. Notify a role's handler asrole_name : handler_name; uselistentopics to decouple. - Never template a handler's name. Names are templated early; a name with an undefined variable, a syntax error, or
a value resolving to
omitis skipped on 2.19 and later and fails the play on 2.18, and in no case runs. Put the variable in the handler's arguments.listentopics cannot be templated at all. - A handler inside an
include_tasksfile cannot be notified; a handler that is animport_tasksis rewritten into its tasks. A handler cannotinclude_roleorimport_role, and handlers ignore tags.
Delegation and Execution
- Under
delegate_to, everything exceptinventory_hostnameis the delegate's:ansible_host, the interpreter, connection, become, and shell options, eveninventory_hostname_short. Reach the original host withhostvars[inventory_hostname]. Facts gathered by a delegated task land on the original host unlessdelegate_facts: true. - Delegation does not serialize. Five forks writing one file on one delegate overwrite each other. Use
throttle: 1, orrun_once: truewith a loop overansible_play_hosts_all. run_onceis once perserialbatch, evaluated with the first host's variables. Truly once iswhen: inventory_hostname == ansible_play_hosts_all[0].throttlecan only lower parallelism belowforks(default 5) and the batch size.serialtakes a number, a percentage, or a list of either; the last batch holds the remainder and a batch is never smaller than one.order: inventoryis the compiled selection order, not the file order. Usesortedwhen the order matters.connection: localruns modules under/usr/bin/pythonunlessansible_python_interpreteris"{{ ansible_playbook_python }}".delegate_to: localhosthas no such trap.add_hostandgroup_bychange inventory for later plays, subject to--limit; the current play's host list is already fixed.- Third-party strategy plugins are deprecated.
linear,free,host_pinned, anddebugare the set.
Variables
Lowest to highest, the rungs that decide most disputes: role defaults/, inventory group vars, inventory host vars,
facts and cached set_fact, play vars, role vars/, block and task vars, include_vars, registered results and
set_fact, role parameters, extra vars.
defaults/is for what a caller overrides;vars/is for what the role forces. A value invars/beats inventory, facts, and play vars; block and taskvars,include_vars,set_fact, role parameters, and-ebeat it.roles:andimport_rolepublish a role's variables to the whole play at parse time, visible to earlier tasks;include_rolepublishes nothing unlesspublic: true, and then only to later tasks.include_rolealso runs the role every time it is called, whereroles:entries with equal parameters run once per play.- A registered variable exists on every host, including where the task failed or was skipped. Test
is skippedoris failed, notis defined. set_factwithcacheable: truemakes two copies, a host variable atset_factprecedence for this run and a fact seven rungs lower that the cache persists.meta: clear_factsremoves only the fact.- Groups at one level merge alphabetically;
ansible_group_priorityreorders them and is honored only in an inventory source, never ingroup_vars/. group_vars/next to the playbook beatsgroup_vars/next to the inventory, andansible-inventorywithout--playbook-dirnever reads the playbook-adjacent copy, so it can print a value a play does not use.-e key=valueis a string;-e '{"key": 1}'and-e @file.ymlkeep types.- Ansible loads the first
ansible.cfgfound —ANSIBLE_CONFIG,./ansible.cfg,~/.ansible.cfg,/etc/ansible/ansible.cfg— and refuses one in a world-writable current directory. - Write
ansible_facts['os_family'], notansible_os_family. The injected form warns on 2.20 and later and is off by default from 2.24. - Facts are a snapshot from the gathering task, once per play by default; a cached fact is from a previous run
within
CACHE_PLUGIN_TIMEOUT(86400 s).gather_subsetand--limitdecide which hosts have which facts.
Read [${CLAUDE_SKILL_DIR}/references/precedence.md] when two definitions of one name disagree, when a value differs
between hosts or commands, or when designing a group_vars layout — it carries the full 22-rung ladder, the inventory
merge order, directory adjacency, INI and extra-vars typing, scope, and role publication.
Roles and Collections
- Always the FQCN:
ansible.builtin.copy.ansible.legacy.copyonly where a locallibrary/override must win. Short names resolve at run time and failansible-lint --profile production. - The
collections:keyword is not inherited by roles, and never applies to lookups, filters, or tests. A role sets its own list inmeta/main.yml, and a role inside a collection searches its own collection first. meta/argument_specs.ymlvalidates coercively and passes the original value through:1satisfiestype: strand the role receives the integer. The validation task carries thealwaystag and runs dependencies' specs first.- A role listed twice with equal parameters runs once per play. Different role parameters (not
vars:), tags, orwhenmake it distinct;allow_duplicates: truein the role's ownmeta/main.ymlforces it. Deduplication runs before variable evaluation. dependenciesrun before the role and are deduplicated the same way;vars:inside a dependency entry is play scoped and reaches roles that ran earlier. Galaxy import requires dependencies innamespace.roleform, never a URL.requirements.ymltakesroles:andcollections:sections in one file. Roles take a singleversionwith no ranges; collections take>=,==,!=(treated as unpinned), and*.ansible-galaxy install -rinstalls both sections;role installandcollection installeach install their own.- A collection's
requires_ansibleis enforced at install from 2.21: a mismatch is skipped, not installed and failed later.COLLECTIONS_ON_ANSIBLE_VERSION_MISMATCHgoverns both the install and the load-time warning. plugin_routinginmeta/runtime.ymlredirects, deprecates, and tombstones names. Read it before declaring a plugin missing.- A Galaxy server must speak the v3 API from 2.20.
collections_pathis singular from 2.19. - Roles inside collections cannot embed plugins in
library/orfilter_plugins/; standalone roles can, and the plugins stay visible to roles that run after them.
Connection and Become
- Pipelining is off by default and is the largest cheap speedup. It needs
requirettyabsent from sudoers and does not apply to file-transfer modules (copy,template,fetch). ssh_argsreplaces the default-C -o ControlMaster=auto -o ControlPersist=60s. Add options throughssh_common_argsorssh_extra_args; a custom-o ControlPathdisables the managed control path.- SSH passwords use
SSH_ASKPASSfrom 2.19;sshpassis opt-in throughansible_ssh_password_mechanism. A key with a passphrase needs an agent; Ansible never proxies the passphrase prompt. become_userdoes not implybecome.-Ksupplies one become password for every host; per-host passwords go in vaultedansible_become_password.- Privilege escalation cannot be limited to specific commands. Modules run from a temporary file with a changing name, so a sudoers command allowlist breaks every module.
- An unprivileged connection user becoming another unprivileged user needs POSIX ACLs (
setfacl) on the target, a shared group named inansible_common_remote_group, or pipelining. Without one of them Ansible errors rather than fall back toworld_readable_temp. - Interpreter discovery is
auto. Pinningansible_python_interpretersilences the warning and freezes the path. ansible_hostmust never depend oninventory_hostname; useinventory_hostname_shortso delegation works.becomedoes not open a systemd session:XDG_RUNTIME_DIRis empty andsystemctl --userfails;become_method: machinectlopens one.paramikois gone from core in 2.21.ansible_connection: paramikofails to load; network platforms useansible.netcommonconnections.
Vault
- Vault protects data at rest only. Decrypted values reach modules, diffs, callbacks, and errors. Put
no_log: trueon the task that handles the secret; it does not protect a laterdebugof the same variable. ansible-vault encrypt_stringproduces an inline!vaultscalar;encryptencrypts a whole file. Thevault_prefix convention (db_password: "{{ vault_db_password }}") keeps variable names greppable at the cost of two variables in precedence.- A vault-id label is a hint. Every supplied password is tried, matching label first;
DEFAULT_VAULT_ID_MATCHrestricts each password to its label. Two files can carry the same label and different passwords;rekeyfixes it. - A password source with the executable bit set is run, not read. A client script must accept
--vault-id, print the password, and be named*-clientor*-client.EXT. A non-executable file is read as the password. ANSIBLE_VAULT_PASSWORD_FILEorvault_password_fileinansible.cfgis a silent default source that changes which secret a run uses from machine to machine; ansible-lint honors both.- An encrypted file passed as
srctocopy,template,unarchive,script, orassemblelands decrypted on the target. to_yamlon a vaulted value writes!vaultciphertext on 2.19 and 2.20, and the decrypted value on 2.21 (vault_behaviorselects).vault_encryptedtests a variable,vaulted_filea path.
Verification
- ansible-lint from the project root, under the ansible-core line the project pins. With no
--profileevery rule applies;--profile productionis the bar for content that ships. The rules that fire most on generated code:no-changed-when,risky-shell-pipe,fqcn,name[casing],name[template],var-naming[no-role-prefix],yaml[octal-values],yaml[truthy],no-free-form,partial-become.# noqa: rule-idat the end of the line suppresses one rule;skip_listhides it from output. --syntax-checkparses without connecting;--check --diffon one host simulates what modules can simulate and skips the rest.- Run the play twice; the second recap is the idempotence result. Molecule's
idempotenceaction is exactly that comparison and nothing deeper:changed_when: falsepasses it, a barecommandfails it. A clean second recap proves the play converged, not that the target runs the result: a run against a real target is a change, and the effect check in the devops skill'sreferences/effect.mdis what closes it. molecule testruns a configurabletest_sequence; a scenario that droppedidempotenceorverifypasses all the same. The built-in driver provisions nothing; ansible-native scenarios create instances increate.ymland write their addresses into an inventory source. Drivers come frommolecule-plugins.ansible-inventory --list -i <source> --playbook-dir .shows the merged variables a play will see;ansible-config dump --only-changedshows whichansible.cfgwon;ansible-doc -t <type> <fqcn>is the argument spec.- ansible-lint 26.8.0 and Molecule 26.8.0 support the two newest ansible-core lines at their release. Testing 2.18 content needs a pinned older toolchain in its own environment.
Read [${CLAUDE_SKILL_DIR}/references/lint.md] when a rule fires that the list above does not name, when configuring
.ansible-lint, or when a lint result disagrees with a run — it carries the profiles, every rule that fires on
generated code, suppression, and the coupling to the ansible-core line. Read
[${CLAUDE_SKILL_DIR}/references/molecule.md] when writing or repairing a scenario — it carries the ansible-native and
legacy layouts, the sequences, the idempotence criterion, dependency paths, and the MOLECULE_ variables.
Boundary
The automation controller and its job templates and surveys (AWX and the Automation Platform), automation mesh,
Automation Hub certification, the platform's Event-Driven Ansible service, and Lightspeed are out of scope; a play never
configures them. ansible-rulebook, ansible-builder, ansible-navigator, and the Ansible development tools MCP
server are open source and usable without the platform.
Application
When writing Ansible, apply these rules silently — do not narrate a rule while following it. Where the existing content contradicts one, follow the content and flag the divergence once. State the ansible-core line an edit assumes when it is not 2.19-compatible.
When reviewing Ansible, cite the violation and give the corrected line inline. Do not lecture.
Integration
The devops skill governs the change: what it must carry before apply, how it is applied, and the evidence that closes it. This skill governs how Ansible behaves and how Ansible is written, and wins on any question of what a play, role, or inventory does. The coding skill governs the shape of the work; the containers skill owns the images an execution environment is built from. All are active at once.