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 real 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.
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.
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).
Real 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
real 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.
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.
Bare module names real ansible-core can no longer resolve in ANY collection (removed from ansible-core years ago and from the collections that absorbed them), so every real ansible-playbook install hard-stops on them with "couldn't resolve module/action" (verified live against ansible-core 2.19.4, including the amazon.aws-qualified spelling - amazon.aws's own runtime.yml tombstoned it too). 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.
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 real 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 real
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 - real 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
(real Ansible consumes them silently - no banners, no recap
counters). reset_connection drops the host's persistent
connection state (daemons + ssh ControlMaster sockets).
Real 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 real 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 (real
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 real ansible's TaskInclude.VALID_INCLUDE_KEYWORDS
verbatim (lib/ansible/playbook/task_include.py), 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. Real 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 -
real 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 real 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.
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 real 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 real Ansible's own
RoleRequirement field too, same list-or-scalar shape as a task's).
Repeatedly strips a trailing " key=value" token (key one of
command:/shell:'s own recognized special params) off the end of
raw, returning the remaining command text and the extracted
params. Only ever touches the trailing end - 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 - real Ansible parses a command:/shell:'s trailing
key=value specials from the module args AFTER templating, not
before. This parse-time pass alone misses the shape where the
whole command is a {% if %}...{% endif %} block (found live via
kamaln7.swapfile): the raw text's last token is then the literal
{% endif %} tag, so a creates=... sitting inside one of the
branches never gets stripped here - but it IS last in the RENDERED
text, where the executor's post-render pass correctly catches it.
Parse module parameters into a hash
Parses an ansible ad-hoc command's -a string into module params.
Two paths, matching real 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 real
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 - real 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 - real
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 real 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 "=" - real 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 real
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 real 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".
Raises UnresolvedModuleError for the tombstoned-removed hard-stop shape (real Ansible's own exact wording - real Ansible also hard-stops there, for its own genuine reason), returns normally for every other name. as_written is the module/action name exactly as the task wrote it - real Ansible's message echoes the source spelling, not any resolved form.
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. Real 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.