Krikri::PlaybookParser
Parser for Ansible YAML playbooks
Constants
Legacy action-directive keywords (action:/local_action: and
their FQCN spellings) - parsed as free-form module directives, not
module names (see parse_task). Deliberately NOT in SPECIAL_KEYS:
they have to be captured as the task's module key to be rewritten.
List of available (implemented) plugins - using FQCN. Almost all of these are ansible.builtin.* (bundled with ansible-core); two exceptions verified against a ansible-core install (not assumed): authorized_key lives in the separate ansible.posix collection, and archive/unarchive live in community.general - neither ships with ansible-core itself. A Set, not an Array: this is membership-tested once per task in parse_task and again per task in validate, and a linear scan of 44 entries is the wrong shape for a lookup table even where the cost is unmeasurable.
ansible-core's own builtin-runtime tombstones for the three pre-collection F5 module names - the exact console [ERROR] text ansible-playbook 2.19.11 prints for them (live-verified for the bare, ansible.builtin.- and ansible.legacy.-qualified spellings; every spelling's message names the ansible.builtin FQCN, since real resolves the bare name into ansible.builtin's routing before the tombstone fires).
community.docker's own removal text for docker_compose v1 (End-of-Life since July 2022; removed from community.docker in v4.0.0, docker_compose_v2 is the replacement) - ansible-playbook's exact hard-stop wording, verified live against ansible-core 2.19.11. Shared verbatim by all three tombstone spellings (bare, community.general.- and community.docker.- qualified): Ansible echoes the RESOLVED module name, never the as-written spelling, for this one.
docker_container's list-typed options (command, entrypoint,
volumes, ports/published_ports, and the list form of the
dict-typed env / labels) travel JSON-encoded rather than on
the generic comma-joined wire, because a comma inside one element
is meaningful there - see #parse_module_params.
Loop keywords consumed by their own dedicated parser branches above (plus loop: itself) - a with_-prefixed key on this list never reaches the generic lookup-loop fallback below.
Real include_vars action's own argument classification Observed behavior: VALID_DIR_ARGUMENTS, VALID_FILE_ARGUMENTS, VALID_ALL. Anything else fails the task at RUN time ("<key> is not a valid option in include_vars") - see parse_include_vars_task / execute_include_vars.
The subset of those keywords whose YAML value IS the loop's item list
(loop:, with_items:, with_list:, with_dict:, with_indexed_items:), and
for which a single-element ARRAY holding one bare {{ ... }} is that
list with the template standing in for its whole content - the
resolver decides at runtime whether that turns into the list itself
(loop:, with_list:) or into the list's own elements (with_items:).
with_nested:/with_together: are deliberately absent: their value is a
list of independent factors/columns, not an item list, and their own
parse branches already defer a {{ }} entry to the executor's
cartesian/zip resolvers. Reading their one-element array as
"the whole source is secretly a list-producing template" instead ran
them through the generic loop-template resolver, which took the
wrong cartesian product for with_nested: and matched no resolver at
all for with_together: - the task then ran ONCE with item unbound
("'item' is undefined"), where Ansible zips/iterates the
resolved list.
Loop source keywords that support a plain literal (array or hash) at parse time, in the same priority order used when picking a loop source in parse_task. Checked here for a scalar "{{ ... }}" template value once none of them matched literally.
The MODULE_ALIASES spellings aliased onto community.mysql's mysql_db/mysql_user - the small set parse_task's controller- collection refusal additionally covers when CollectionIndex says the controller lacks ansible.mariadb (real refuses all four at playbook load there, issue #499): the alias alone would make resolved_module_name non-nil and exempt them from a refusal real still performs.
Ansible module aliases - a second FQCN (or bare name) that
resolves to the exact same module, not merely a similarly-named
one. systemd_service was added in ansible-core 2.12 as the
"correct" name (systemd was ambiguous with systemd_service/
systemd_socket... at the time only one of each ever shipped);
systemd is still kept as a working alias, and real-world roles
use both spellings interchangeably (konstruktoid/ansible-role-
hardening's own tasks write ansible.builtin.systemd_service 19
times across 14 files, never the bare ansible.builtin.systemd
this codebase's plugin is actually named after). Checked before
the AVAILABLE_PLUGINS/MODULE_SEARCH_COLLECTIONS lookups below, so
both spellings resolve to the one real plugin binary.
The collections a bare (non-FQCN) module name resolves against, in
Ansible's own default search order - getent: (no ansible. builtin. prefix) is extremely common in real-world playbooks/roles
(dev-sec's own molecule test fixtures use it, unlike the role's own
tasks, which are always fully qualified) and previously only ever
matched AVAILABLE_PLUGINS verbatim, so any bare name failed outright
("Plugin not available: getent") even though the qualified form
works fine. None of AVAILABLE_PLUGINS' short names collide across
collections, so the search order only matters for documentation
purposes here, not correctness.
Returns each COMPLETE top-level {% %} block construct (the opening
tag through its matching {% end* %}, body included) in s. Used by
the executor's _jinja_block_args handling (see parse_module_params's
marker comment): Ansible's observed finalization type for such an args
string tracks whether any {% %} block contributed output, which only
a whole-construct render can answer - a {% if %} tag rendered on its
own is a syntax error, and whitespace inside a block splits it across
split_shell_like tokens. Only PAIRED constructs are collected: a
self-contained tag ({% set x = 1 %}) can never contribute output,
and rendering it standalone would only re-run its side effects. A
construct nested inside another is consumed by the outer one (Jinja
skips an inner block when the enclosing condition is false, so
rendering the inner standalone would wrongly report output).
The same whitelist spelled out for the legacy k=v merge deprecation: parse_kv's check_raw=True branch tests the token's key text against this list BEFORE stripping (see warn_legacy_kv_args_deprecation).
Modules whose bare-string task arg is a raw command line, not free-form key=value params - see the yaml.as_s? branch of #parse_module_params. Bare "command"/"shell" is included defensively alongside the resolved FQCN forms, in case this is ever reached before module_name resolution.
Collection-level plugin deprecations that fire at task-load time,
keyed by module FQCN: the collection's own meta/runtime.yml
plugin_routing deprecation entries - a redirect's warning_text or
a moved-content notice - plus the collection that carries the
deprecation and the version it will be removed in, live-verified
against ansible-core 2.19.11's console output and the installed
collections' meta/runtime.yml data. Real resolves a task's module
at task-load time and record_deprecation warns there - once per
distinct message per run (the Display dedups), with the one-time
"Deprecation warnings can be disabled" hint before the first - and
every task result the module actually produced carries the
deprecations entry (TaskExecutor's DeferredWarningContext). The
bare (non-FQCN) spellings resolve through MODULE_SEARCH_COLLECTIONS
and warn with the RESOLVED FQCN (amazon.aws's bare spellings
excepted - that collection is not in MODULE_SEARCH_COLLECTIONS, so
its bare names qualify through redirect_deprecation_lookup's own
explicit prefix).
Only deprecations observable in krikri's own runs belong here, whatever the module's support state: the community.mysql entries redirect to their ansible.mysql.* homes (removal_version 6.0.0, warning_text "Use ansible.mysql.<module> instead." - installed community.mysql 5.0.2), and that includes mysql_replication and mysql_role even though neither is a krikri-supported module: real prints their load-time [DEPRECATION WARNING] alongside the supported modules' ones (the hoplacloud.wordpress round, which diverged on the missing mysql_replication line alone - the warning is observable even though the module never executes; the same reasoning keeps proxmox_kvm here). community.general.proxmox_kvm is not krikri-supported either - only its parse-time stderr deprecation is observable (the inoxio.proxmox_vms role round): community.general moved all proxmox content to community.proxmox and its meta/runtime.yml marks proxmox_kvm deprecated with removal_version 15.0.0, warning_text naming the new home.
Bare module names ansible-core can no longer resolve in ANY collection (removed from ansible-core years ago and from the collections that absorbed them), so every ansible-playbook install hard-stops on them. Deliberately minimal: an entry here hard-stops the whole run at parse time, so a name belongs here only when it is unresolvable on EVERY real controller - never a module that a current collection still ships. Widening = adding entries here. Value is Ansible's own hard-stop error text for that name: nil means the generic couldn't-resolve wording (what ansible-core prints when nothing anywhere resolves the name), while some removed names have Ansible print its own specific removal message instead - verified live against ansible-core 2.19.11, including which names get which wording.
include_vars: - a controller-side pseudo-module ("_include_vars").
It reads a YAML file from the controller and merges it into the
variable context, so there is no plugin binary and nothing runs on
the target. Accepts the bare-string form (include_vars: x.yml) and
the dict form with file:/name:.
The task-attribute subset EVERY task-parsing branch shares - used
to be an identical ~10-line block copy-pasted into five separate
parsers (parse_task, parse_include_vars_task, parse_block_task,
parse_include_tasks, parse_include_role), and each branch's copy
accumulated its own silent-failure bugs over the rounds: a fix
landed in one branch's block had to be re-discovered in the others
(several of those re-discoveries are still visible as "same gap
<branch> had" comments on the branches' own module-specific
parsing below). One shared implementation now; each caller adds
only its own module-specific parsing on top. tags: accepts a bare
string as well as a list (Ansible's own parser does; only
include_vars's copy of this block used to honor that).
ansible-core's reserved variable names (the Ansible module's
get_reserved_names, live-verified from 2.19.11's own list - the
with_ PREFIX entry excluded: a vars: entry named with_items
does not warn, every exact name does). A play/task vars: entry
with one of these names emits "[WARNING]: Found variable using
reserved name 'X'." with an Origin block naming the var key's own
position - live-verified byte-for-byte vs 2.19.11 (play vars and
task vars both warn, at definition/compile time). vars itself is
NOT in the list: ansible's warn_if_reserved does
varnames.discard('vars') (it is added internally, so safe to
ignore), so a vars: key never warns there - a top-level vars:
in a role's vars/main.yml must stay silent too.
parse_kv's own whitelist: a free-form command:/shell: token whose key is one of these becomes a module option instead of command text (ansible.parsing.splitter.parse_kv with check_raw=True - the exact set mod_args.py's parsing hands to the module).
Task-level special (non-module) keywords parse_task must skip when hunting for the module key, plus the same names fully qualified (directive() accepts either spelling) and the ansible.legacy.* spellings of the structural directives. A Set constant built once at load: this used to be rebuilt as a ~110-element array with two map copies per parsed task, then linear-scanned per key.
meta: - a pseudo-module ("_meta"), like block:/include_tasks:, that acts on the executor's own state rather than running a plugin on a target.
clear_facts/flush_handlers/end_host/end_play/
clear_host_errors/noop/refresh_inventory are supported, as of
0.9.789 also end_batch/end_role/reset_connection (see
TaskExecutor#execute_meta).
flush_handlers added in round 18 - found via robertdebock's own
roles, several of which (mysql, selinux, zabbix_repository,
zabbix_server, core_dependencies) use ansible.builtin.meta: flush_handlers deliberately mid-role (e.g. flushing a "Update
cache" handler BEFORE a later task that needs the freshly-added
repo's package list) - skipping the task entirely, the previous
behavior, isn't just a display-order cosmetic gap here: it caused a
genuine functional divergence from ansible-playbook (a
package install failing "Unable to locate package" because the apt
cache update handler ran at the very end of the play instead of
mid-role). end_host/end_play/clear_host_errors/noop/
refresh_inventory added after that - see TaskExecutor#execute_
meta for the exact semantics (each verified against
ansible-playbook, including the non-obvious ones: end_play affects
every currently-active host even if only ONE host's own when:
actually reaches it; clear_host_errors does NOT resume execution
in the current play, only exempts the host from the next one;
refresh_inventory does NOT add hosts to the CURRENT play's own
host loop either, only to a LATER play's - Ansible's own
documented caveat). end_batch behaves exactly like end_play
here - its one distinguishing behavior, ending only the current
serial: batch, is meaningless while this engine doesn't model
serial batching (one batch per play). end_role skips every
remaining task of the CALLING role for the host that executes it
(Ansible consumes them silently - no banners, no recap
counters). reset_connection drops the host's persistent
connection state (daemons + ssh ControlMaster sockets).
ansible-core 2.19's TaskInclude/HandlerTaskInclude parser
validates the task dict against a fixed allowlist (TaskInclude's
own VALID_INCLUDE_KEYWORDS frozenset) and raises
'X' is not a valid attribute for a TaskInclude for any key not
on the list. become/become_user/become_method/become_flags/
become_exe are the ones that bite in practice - the Task class
has them as fields (so a normal task accepts them) but the
include-only classes deliberately do not, since the include
statement itself is a control-flow directive and per-task
privilege escalation doesn't compose with that. The
andrewrothstein.java-oracle role's alpine-glibc-shim dependency
had become: yes / become_user: root on an include_tasks: line
and crystal accepted it (then failed downstream with a
different error, rc=2 vs Ansible's rc=4). Same fix for
RoleInclude (the parser class behind include_role:), whose
_validate_attributes rejects any key not in its own fattributes
- become/ is not there either, same error class
'X' is not a valid attribute for a IncludeRole.
import_tasks:/import_role: are NOT validated this way (ansible's own ImportPlaybook/ImportRole inherit the full Task
fattributes and accept become:/become_user:), so this set is
consulted only by parse_include_tasks and parse_include_role
below. Starts from Ansible's TaskInclude.VALID_INCLUDE_KEYWORDS
verbatim, then extends
with the keys crystal's existing parse_include_tasks and the
broader task parser already read off a task_hash so the FQCN
ansible.builtin.include_tasks: form (parse_task's directive()
helper keys on the bare name, but the FQCN form lands in the
same task_hash) and the legacy with_first_found: form
(githubixx.ansible_role_wireguard's "Include tasks depending
on OS" pattern, see playbook_parser_spec.cr:1614) keep
working. Ansible would reject some of these (notably
with_first_found and the block-level attrs) with the same
error; that's a separate gap from this round-194 fix, not
one any role in ROLES_TESTED.md currently depends on
behaving the ansible way. notify WAS on this allowlist too,
until juju4.ansible_role_mattermost's own include_tasks: selinux.yml carrying a notify: key on the include line itself
(RHEL-family round 60113) hit exactly this predicted gap live -
ansible-core's actual VALID_INCLUDE_KEYWORDS (verified via
python3 -c "import ansible.playbook.task_include as ti; print(sorted(ti.TaskInclude.VALID_INCLUDE_KEYWORDS))",
ansible-core 2.19.4) does not include it - a task's OWN notify:
is always valid (handled entirely separately, by the regular Task
parser, not this one); notifying anything from the include
directive line itself is not Ansible's syntax at all.
Class methods
Fills each task's missing arguments from the module_defaults in scope. Runs as a post-parse pass over the whole task tree so play, block and task scope are handled in one place, with the nearest scope winning and the task's OWN arguments always winning over any default.
ansible-core 2.19's loader refuses at LOAD time (rc=4, on STDERR,
with the same Origin block shape as every other YAML error) any
file where an unquoted template parses as a collection used AS a
mapping key - owner: {{ x }} is a flow mapping whose KEY is a
flow mapping. Every other YAML error here comes from libyaml; this
one doesn't (libyaml accepts the file), so the position comes from
the source map's collection-key detection instead of a
ParseException. Callers run it wherever YAML text is about to be
parsed (playbook, role files, static imports), the way Ansible's
single DataLoader path covers all of them.
The same when:-list, kept as its own per-item condition strings for
the STRICT evaluation path (see Task#when_condition_list for why
the joined string can't serve there). nil unless when: is a list
with more than one non-empty item - single-item lists and scalars
evaluate identically through either representation.
Helper: Safely convert any YAML value to string This handles cases where YAML values might be booleans, integers, etc. when:/changed_when:/failed_when: may each be given as a list, which Ansible ANDs together - it is the idiomatic way to write a multi-clause condition and is used throughout widely-deployed roles (dev-sec's os_hardening alone has 79 of them).
This used to fall through safe_yaml_to_string's else branch to
YAML::Any#to_s, producing a Crystal array literal -
["a_var", "'x' in pkgs"] - as the condition string. That was
never evaluable: at best it was truthy by accident (a non-empty
string), and at worst it hung the run outright, because an element
containing " and " inside its quotes made ConditionalEvaluator
split on nothing and recurse on the identical string forever.
Each element is parenthesized before joining so an element that is
itself a compound condition (a or b) cannot bind loosely against
its neighbours - (a or b) and (c), not a or b and c.
Not private: RoleLoader also needs this for a roles: entry's/meta
dependency's own when: (a role-level when: is Ansible's own
RoleRequirement field too, same list-or-scalar shape as a task's).
Strips " key=value" tokens (key one of command:/shell:'s own
recognized special params - SPECIAL_COMMAND_PARAM_RE, any position
in the string, matching Ansible's parse_kv which examines EVERY
whitespace-delimited token, not just the trailing ones) off
raw, returning the remaining command text and the extracted
params. The command body itself, including any "=" it legitimately
contains (VAR=1 somecommand), is never re-tokenized or
rewritten.
Tokenizes via #split_shell_like (the same brace-depth-aware
scanner #parse_inline_kv_params already uses) rather than a
single backtracking regex over the whole string - two independent
regex-based attempts here each had a real bug, in OPPOSITE
directions, because a bare \{\{.*?\}\} alternative can't be
trusted to stop at the boundary of a single template block:
- Under-matching: a value with exactly one
{{ }}block and no further "}}" anywhere later in the string couldn't complete the pattern's trailing\s*\zat all, so extraction silently never happened (geerlingguy.solr'screates={{ solr_install_path }}/bin/solr). - Over-matching: with a SECOND "{{ }}" block later in the
string, the lazy
.*?could backtrack straight through an entire separatekey=valueparam - including the space between them and that param's own braces - to reach that later "}}", silently absorbing it into the wrong param's value. geerlingguy.svn's own "Create a test repository." task,svnadmin create testrepo chdir={{ svn_repository_home }} creates={{ svn_repository_home }}/testrepo/README.txt, hit this:chdir's value swallowed the entire trailingcreates={{ ... }}/testrepo/README.txttext as part of itself, sochdir=failed outright ("No such file or directory") on the resulting, never-a-real-path string.
A brace-depth-tracking tokenizer (rather than backtracking regex
matching) can't make either mistake: it splits on whitespace
outside any {{ }}/{% %} span, so each key=value token's
boundary is exactly right regardless of how many template blocks
appear anywhere else in the string.
Public (not private): the task executor ALSO needs this, at
RUNTIME - a key=value whose key only appears after templating
(the value side of a {% if %} branch, found live via
kamaln7.swapfile) is not a token at parse time at all, so the
executor's post-render pass over the RENDERED command line has to
keep running too - it is the same function, applied to the text
the module will really be handed.
Ansible flattens nested tag lists (tags: [[a, b], c] -> a, b, c),
so a tag value may be an arbitrarily nested array; mapping the
top-level entries with .as_s raised on the nested form and the
whole task was skipped ("Cast from Array(YAML::Any) to String
failed"). Public so RoleLoader's role-tag parse shares it.
Whether a module's load-time deprecation warning carries an Origin
block: only the two builtin-core deprecations do (issues #917/#918 -
their console warning prints once per task occurrence, positioned at
the module key for the bare and FQCN spellings and position-less
<unknown> for ansible.legacy.); every collection-redirect entry
above is Origin-less and dedups per run. Matched on the bare trailing
segment because the table is keyed by bare and FQCN spellings alike.
A parse-time task error with source context, in ansible-core's
_error_utils.SourceContext layout: "[ERROR]: <msg>", "Origin:
<abs path>:<line>:<col>", a blank line, then the two preceding
source lines + the target line (right-aligned line-number labels,
tabs echoed as spaces) and a caret under the column. Best-effort:
without a source position only the [ERROR] line is rendered.
Same render as origin_error_render, but pointed at an EXPLICIT
source position instead of a task-prefix lookup - for errors whose
Origin is a specific sibling KEY inside the task (the static:
attribute refusal points at the static: line itself, not the task).
Public (not private) because RoleLoader's meta/main.yml dependency
not-found raise builds its Origin block from the declaring role's
meta file, outside this class (issue #12).
Parse module parameters into a hash
Parses an ansible ad-hoc command's -a string into module params.
Two paths, matching Ansible's own ad-hoc arg handling: a
string that looks like a JSON object (starts with { after
stripping whitespace) and actually parses as one is used as the
module params directly, with nested types kept; everything else
uses the exact same rules as a playbook's own bare-string task
arg (the yaml.as_s? branch of #parse_module_params below - see
that branch's own comment for the full rationale): command:/
shell:/script:/raw: get the whole string as a command line plus
any trailing key=value specials (creates=/removes=/chdir=/
executable=) stripped off the end; every other module gets
Ansible's free-form key=value key2="quoted value" inline syntax.
(verified live against ansible-core 2.19.11: a -a string that
starts with { but is not valid JSON, e.g. {bad json, is NOT
specially errored - Ansible's ModuleArgsParser falls through
to the ordinary k=v split, so the string lands in _raw_params
and whatever the module does with raw params (debug rejects it,
command tries to execute it) is the only "error" there is; the
fallback below reproduces exactly that). A parsed-but-not-object
value ([1,2], "str") falls through the same way -
Ansible 2.19.11 also treated those as free-form raw params.
The JSON-object path is why genuinely dict/list-shaped module
args (expect's responses, command's argv, xml's
namespaces) have any ad-hoc-CLI path at all: the k=v encoding
cannot express a dict value, and before this path the whole -a
string was silently ignored (module ran on its own defaults).
Parses Ansible's free-form inline key=value key2="quoted value" key3='{{ a_template }}' task-arg syntax into individual
params. Tokenizes on whitespace outside single/double quotes
(so a quoted value may itself contain spaces - msg="hello world", or a {{ }} expression with its own internal spaces),
splits each token on its first = (a value may legitimately
contain further = characters, e.g. base64 padding - only the
first one is the key/value separator), and strips one layer of
matching quotes from the value. A token with no = at all (a
malformed fragment, or the whole string is actually a bare
free-form value with no key=value pairs anywhere) is skipped, not
raised on - callers already fall back to _raw_params for that
case.
Public entry point for the executor's runtime re-parse of a
templated action:/local_action: free-form string (see
Task#templated_action / TaskExecutor#resolve_templated_action):
the module name is only known after substitution, so the rest of
the string can't be shaped into params at parse time.
Returns the key=value params plus, as a second tuple element, the leftover free-form text (tokens with no "=" - Ansible's parse_kv raw_params list, joined back with single spaces) or nil when every token was a key=value pair.
Parse a single play
Parses a module_defaults: mapping into {module name => {arg =>
value}}. Keys are normalized to the bare module name, because
Ansible matches a short key against an FQCN task and vice versa -
verified against ansible-core 2.19.4: a debug: key supplies
defaults to an ansible.builtin.debug: task, and an
ansible.builtin.debug: key to a debug: task.
An action-group key (group/aws) is expanded to its member
modules via ActionGroups, which reads the installed collections'
meta/runtime.yml exactly as Ansible does.
Parse playbook from string
Parse a list of task-shaped YAML nodes, skipping (with a warning) any individual entry that fails to parse rather than failing the whole list. Shared by play.tasks, play.handlers, block/rescue/always, and (via RoleLoader) a role's tasks/main.yml and handlers/main.yml - public for that last one. file_dir is the directory of whichever YAML file tasks_yaml came from - used to resolve import_tasks:/ include_tasks: paths relative to that file (not the top-level playbook), and passed down unchanged for block/rescue/always since those stay within the same file.
role_path/playbook_dir are the two library/ search roots
PythonModuleRunner uses at execution time for a role-private module
(task.role_path and the executor's own playbook dir). Threaded
through here so the unconditional unimplemented-module hard-stop
can run the SAME lookup at parse time and stay graceful for exactly
the tasks the runner would later execute - nil means "not
knowable at this call site", never a false "source exists".
Buffered like the top-level loads: runtime include_tasks/handlers
loads go through here too, and a load Ansible refuses (a removed
bare include: aborts before its task vars are ever evaluated)
must not emit the reserved-name warnings it already scanned.
Companion to #resolve_become's family: parses a timeout: keyword
value - an integer literal (seconds) or a raw {{ }} expression
(resolved per host at execution time by the executor). Returns
{int, expr}: exactly one non-nil when the keyword is present,
{nil, nil} when absent. A non-numeric literal keeps {nil, nil}
(no limit) - Ansible's own post_validate rejects those, but the
executor's 0/absent no-limit path is the gentler degradation.
Public so RoleLoader's roles:-entry parser can share it verbatim.
Raises UnresolvedModuleError for the tombstoned-removed hard-stop shape - with Ansible's own exact wording for that name (the generic couldn't-resolve text, or the collection's own specific removal message where Ansible prints one instead) - returns normally for every other name. as_written is the module/action name exactly as the task wrote it - Ansible's message echoes the source spelling, not any resolved form (except a tombstone with its own fixed message, like docker_compose's).
resolved is the name #resolve_module_name landed on, when it resolved at all: the tombstone is consulted for the RESOLVED FQCN too, not just the as-written one - real's loader resolves a task's name first and then checks the resolved FQCN against the collection's meta/runtime.yml plugin_routing tombstones; a bare spelling that lands on a tombstoned FQCN is therefore refused without every bare spelling needing its own table entry (which would wrongly refuse spellings real resolves through redirects that are NOT tombstoned, like the builtin-runtime ones).
A name krikri cannot resolve natively (resolved nil) also gets the
builtin-runtime redirect hop: a bare spelling whose
ansible_builtin_runtime.yml redirect lands on a tombstoned
collection FQCN refuses with THAT FQCN's own removal message when
the target collection is installed on this controller (bare
docker_service: -> community.general.docker_service ->
community.general 2.0.0's tombstone: rc=1 and the
community.general.docker_service message, live-verified vs
2.19.11 for the bare, ansible.builtin.- and ansible.legacy.-
qualified spellings; issue #8). Without the collection real's
redirect chain dies at the collection import and the name falls
back to the generic couldn't-resolve refusal - which the bare
spellings' nil entries in the table still provide.
source_file/source_map/prefix build the RemovedModuleError render
([ERROR] line + task Origin) where the raise site has a source
position.
The FQCN that one of the deprecations above echoes on the console,
nil for every name that is not one of them. A caller compares it
against the as-written spelling to tell whether real kept the YAML
tag on the name: an as-written FQCN keeps it (a positioned Origin
block), a bare or ansible.legacy spelling is rebuilt by joining the
collection onto the name and prints the position-less
Origin: <unknown> form instead (issue #973).
The deprecations result entry real attaches to every executed
task result for a deprecated module (live-verified via register:
r.deprecations == [{"msg": "community.mysql.mysql_info has been
deprecated. Use ansible.mysql.mysql_info instead.",
"collection_name": "community.mysql", "version": "6.0.0",
"deprecator": {"resolved_name": "community.mysql", "type": null}}]
- note the msg carries NO removal tail; only the console line does). nil for every name that is not one of the deprecations.
The exact console [DEPRECATION WARNING] text ansible-core 2.19.11 prints for one of the deprecations above (live-verified: the loader's warning_text joined onto the "has been deprecated." stem, followed by Display's removal-version tail naming the collection and version). nil for every name that is not one of the deprecations.
import_tasks: is resolved at PARSE time - the imported file's tasks
are spliced directly into the caller's task list (returning
Array(Task) rather than a single wrapping Task, unlike block:).
Per ansible-doc: "Most keywords, including loops and conditionals,
only apply to the imported tasks, not to this statement itself" - so
the import's own when:/tags: are applied to EACH imported task
individually. loop: is not supported on import_tasks (use
include_tasks instead) and is simply ignored here. Returns nil when
the YAML node isn't an import_tasks: entry at all.
Resolves an include_tasks:/import_tasks: file path, trying the
direct interpretation first (relative to file_dir, the
including file's own directory) and falling back to stripping a
leading tasks/ from file_rel and retrying against the same
directory if that doesn't exist. Ansible's own include-path
search considers multiple roots (including the role root itself,
not just the including file's directory), so a role convention
like include_tasks: tasks/foo.yml written inside a file that's
already directly in <role>/tasks/ resolves there correctly - our
single-root resolution doubled it into <role>/tasks/tasks/foo.yml
instead. Found via linux-system-roles' journald role, whose
tasks/main.yml does exactly this (include_tasks: tasks/set_vars. yml) - a common enough convention (explicit tasks/ prefix even
from within the tasks dir) that this isn't specific to one role.
Resolves a task's module key (as written) to the AVAILABLE_PLUGINS entry it refers to - itself unchanged if already fully qualified (or a pseudo-module like "_block"), otherwise the first MODULE_SEARCH_COLLECTIONS prefix that matches. nil if nothing matches at all (a genuinely unimplemented/unknown module).
Strips standalone line-continuation backslashes from a free-form
command:/shell: task string, exactly the way Ansible's own
controller does at task-parse time: the free-form string goes
through parse_kv → split_args BEFORE
any Jinja templating, and split_args silently DROPS every
whitespace-delimited token that is exactly \ (outside quotes),
also suppressing the newline it would otherwise rejoin at the end
of such a line. This is why the common role idiom of a folded YAML
scalar with trailing backslash continuations works at all under
Ansible:
shell: >
set -o errexit;
set -o pipefail;
cfssl gencert ...
PyYAML folds that to "...errexit; \ set -o pipefail..." - backslash,
folded space and all - which bash itself REJECTS: the \ starts a
command word " set" (escaped leading space) that is no builtin, so
bash dies with exit 127 and "line 1: set: command not found" (the
doubled space in the message is the word's own leading space).
Ansible never hands bash that string - split_args has already
removed every standalone backslash, so bash receives
"set -o errexit; set -o pipefail; cfssl gencert ..." and runs it.
Found live via githubixx.kubernetes_ca's "Generate the etcd
certificate authority (CA) and private key" task (round900207):
this engine passed the folded text through verbatim and the task
died with exactly that bash 127, while a minimal local repro
(identical folded shape, executable: /bin/bash) succeeds under
ansible-playbook with the backslash-free command above.
The transformation here is a matches split_args' per-line
token walk (dropped lone-\ tokens, the quote state that suppresses
the drop inside quotes, the {{ }}/{% %}/{# #} depth tracking, the
empty-token run that preserves internal space runs, and the
newline-restoration suppression for lines containing a lone \),
followed by join_args' reassembly - for any string without a
standalone \ token that round-trip is byte-for-byte lossless
(verified against real split_args on the round900207 repro shape,
including its preserved double space and trailing newline), so the
early includes?('\\') bail-out below keeps every existing
command:/shell: string untouched. A backslash glued to other
characters (\;, path\to) or inside quotes is NOT a standalone
token and survives, same as real split_args. Only the free-form
string form gets this treatment - a cmd:/_raw_params dict key
or post-render variable content never goes through Ansible's
split_args, so callers apply this to parse-time raw text only.
Ansible validates register:'s value as a variable-name
identifier at task-load time and refuses the whole run for anything
else - see InvalidRegisterError's own comment. The value is checked
RAW: Ansible never templates a register: value, so
register: '{{ var }}' is rejected whatever var resolves to
(webbylab.sources, round 900914).
ansible-core 2.19 (constructor.py construct_mapping) prints, at LOAD time and before any PLAY output, one warning per duplicate mapping-key occurrence, with the Origin at the DUPLICATE (second) key's own position and the help_text line after the excerpt: [WARNING]: Found duplicate mapping key 'bookworm'. Origin: /abs/path/pb.yml:6:7 <up to two preceding source lines + the duplicate key's line> ^ column 7 Using last defined value only. The warning is cosmetic - the last value still wins. Public because role_loader warns the same way for the vars/task files it loads through the same YamlSourceMap scan. io exists for tests. ansible-core's load_list_of_tasks warns (plain, uncolored) when a statically imported file parses to nothing, naming the resolved path.
Same warning for one play vars_files file, keyed off a parse of the file itself (the caller only has a candidate path). Tolerant like the runtime vars_files loader: a missing or unparseable file stays silent - ansible-playbook does not abort for a vars_files entry it cannot use either.