class

Krikri::PlaybookParser

Inherits Reference < Object

Parser for Ansible YAML playbooks

Constants

ACTION_DIRECTIVE_KEYS = Set {"action", "ansible.builtin.action", "ansible.legacy.action", "local_action", "ansible.builtin.local_action", "ansible.legacy.local_action"}

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.

AVAILABLE_PLUGINS = Set {"ansible.builtin.copy", "ansible.builtin.template", "ansible.builtin.file", "ansible.builtin.lineinfile", "ansible.builtin.replace", "ansible.builtin.service", "ansible.builtin.systemd", "ansible.builtin.hostname", "ansible.builtin.shell", "ansible.builtin.apt", "ansible.builtin.dnf", "ansible.builtin.yum", "ansible.builtin.package", "ansible.builtin.debug", "ansible.builtin.command", "ansible.builtin.setup", "ansible.builtin.facts", "ansible.builtin.package_facts", "ansible.posix.selinux", "ansible.posix.synchronize", "community.general.pam_limits", "community.general.apache2_module", "community.general.capabilities", "community.general.make", "ansible.builtin.user", "ansible.builtin.group", "ansible.builtin.git", "ansible.builtin.pip", "community.general.gem", "ansible.builtin.cron", "ansible.builtin.cronvar", "ansible.posix.acl", "ansible.builtin.acl", "ansible.posix.authorized_key", "ansible.builtin.authorized_key", "ansible.builtin.stat", "ansible.builtin.find", "ansible.builtin.getent", "community.general.archive", "ansible.builtin.unarchive", "ansible.builtin.yum_repository", "ansible.builtin.apt_repository", "ansible.builtin.apt_key", "ansible.builtin.rpm_key", "ansible.posix.seboolean", "community.general.seport", "community.general.sefcontext", "community.general.xml", "ansible.builtin.deb822_repository", "ansible.posix.mount", "ansible.builtin.mount", "ansible.posix.sysctl", "community.general.ufw", "ansible.posix.firewalld", "ansible.builtin.iptables", "ansible.builtin.debconf", "ansible.builtin.async_status", "community.docker.docker_image", "community.docker.docker_network", "community.docker.docker_network_info", "community.docker.docker_container", "community.docker.docker_compose_v2", "community.mysql.mysql_db", "community.mysql.mysql_user", "community.mysql.mysql_info", "community.mysql.mysql_query", "ansible.mysql.mysql_db", "ansible.mysql.mysql_user", "ansible.mysql.mysql_info", "ansible.mysql.mysql_query", "community.postgresql.postgresql_db", "community.postgresql.postgresql_user", "community.postgresql.postgresql_privs", "community.postgresql.postgresql_query", "community.crypto.openssl_dhparam", "community.crypto.openssl_privatekey", "community.crypto.openssl_csr", "community.crypto.x509_certificate", "community.crypto.openssl_pkcs12", "community.crypto.openssh_keypair", "community.crypto.openssl_privatekey_info", "community.crypto.x509_certificate_info", "community.crypto.openssl_publickey_info", "community.crypto.openssl_csr_info", "community.crypto.openssl_publickey", "community.crypto.get_certificate", "community.general.modprobe", "community.general.kernel_blacklist", "community.general.pamd", "community.general.htpasswd", "community.general.ini_file", "community.general.timezone", "ansible.builtin.timezone", "community.general.npm", "community.general.alternatives", "ansible.builtin.alternatives", "community.general.filesystem", "community.general.zfs", "ansible.builtin.lvol", "community.general.lvol", "community.general.parted", "community.general.lvg", "community.general.snap", "community.general.deploy_helper", "community.general.dpkg_divert", "community.general.locale_gen", "community.general.java_cert", "ovirt_auth", "ovirt.ovirt.ovirt_auth", "virt_net", "community.general.maven_artifact", "community.general.nsupdate", "community.general.rhsm_repository", "community.general.rhsm_release", "community.general.homebrew", "community.general.easy_install", "community.mysql.mysql_variables", "community.docker.docker_login", "community.docker.current_container_facts", "containers.podman.podman_image", "amazon.aws.iam_user_info", "iam_user_info", "ansible.builtin.service_facts", "ansible.builtin.slurp", "ansible.builtin.reboot", "ansible.builtin.set_fact", "ansible.builtin.add_host", "ansible.builtin.get_url", "ansible.builtin.blockinfile", "ansible.builtin.uri", "ansible.builtin.assert", "ansible.builtin.fail", "ansible.builtin.wait_for", "ansible.builtin.wait_for_connection", "ansible.builtin.ping", "ansible.builtin.fetch", "ansible.builtin.pause", "ansible.builtin.script", "ansible.builtin.assemble", "ansible.builtin.tempfile", "ansible.builtin.known_hosts", "ansible.builtin.group_by", "ansible.builtin.set_stats", "ansible.builtin.dpkg_selections", "ansible.builtin.subversion", "ansible.builtin.expect", "community.general.git_config", "ansible.builtin.git_config", "community.general.sudoers", "community.general.dnf_versionlock", "community.docker.docker_image_build", "amazon.aws.ec2_metadata_facts", "amazon.aws.ec2_key", "amazon.aws.ec2_security_group", "amazon.aws.ec2_instance", "amazon.aws.ec2_vpc_subnet_info", "amazon.aws.ec2_vpc_net_info", "amazon.aws.ec2_ami_info", "ec2_key", "ec2_security_group", "ec2_instance", "ec2_vpc_subnet_info", "ec2_vpc_net_info", "ec2_ami_info", "community.rabbitmq.rabbitmq_plugin", "community.rabbitmq.rabbitmq_user", "community.general.redhat_subscription", "ansible.builtin.py_module"}

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_TEMPLATE_KEYS = ["loop", "with_items", "with_dict", "with_nested", "with_indexed_items"] of ::String

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.

MODULE_ALIASES = {"systemd_service" => "ansible.builtin.systemd", "ansible.builtin.systemd_service" => "ansible.builtin.systemd", "ansible.legacy.systemd_service" => "ansible.builtin.systemd", "raw" => "ansible.builtin.shell", "ansible.builtin.raw" => "ansible.builtin.shell", "ansible.legacy.raw" => "ansible.builtin.shell", "mariadb_db" => "community.mysql.mysql_db", "mariadb_user" => "community.mysql.mysql_user", "ansible.mariadb.mariadb_db" => "community.mysql.mysql_db", "ansible.mariadb.mariadb_user" => "community.mysql.mysql_user", "openssl_certificate" => "community.crypto.x509_certificate", "ansible.builtin.openssl_certificate" => "community.crypto.x509_certificate", "ansible.legacy.openssl_certificate" => "community.crypto.x509_certificate", "community.crypto.openssl_certificate" => "community.crypto.x509_certificate", "community.general.openssl_certificate" => "community.crypto.x509_certificate", "openssl_certificate_info" => "community.crypto.x509_certificate_info", "ansible.builtin.openssl_certificate_info" => "community.crypto.x509_certificate_info", "ansible.legacy.openssl_certificate_info" => "community.crypto.x509_certificate_info", "community.crypto.openssl_certificate_info" => "community.crypto.x509_certificate_info", "community.general.openssl_certificate_info" => "community.crypto.x509_certificate_info", "community.libvirt.virt_net" => "virt_net"}

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.

MODULE_SEARCH_COLLECTIONS = ["ansible.builtin", "ansible.legacy", "ansible.posix", "community.general", "community.docker", "community.mysql", "community.postgresql", "community.rabbitmq", "community.crypto"]

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.

RAW_COMMAND_MODULES = {"command", "shell", "script", "raw", "ansible.builtin.command", "ansible.builtin.shell", "ansible.legacy.command", "ansible.legacy.shell", "ansible.builtin.script", "ansible.legacy.script", "ansible.builtin.raw", "ansible.legacy.raw"}

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.

REMOVED_MODULE_TOMBSTONES = Set {"ec2_remote_facts", "ansible.builtin.ec2_remote_facts", "ansible.legacy.ec2_remote_facts", "amazon.aws.ec2_remote_facts", "community.general.consul_acl", "consul_acl", "community.general.docker_service", "docker_service"}

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.

SPECIAL_KEYS = begin keys = Set {"name", "when", "register", "ignore_errors", "check_mode", "diff", "become", "become_user", "become_method", "become_flags", "become_pass", "become_exe", "tags", "args", "listen", "with_items", "loop", "with_dict", "with_fileglob", "with_file", "with_first_found", "with_nested", "with_sequence", "with_flattened", "with_community.general.flattened", "with_subelements", "with_indexed_items", "until", "retries", "delay", "with_community.general.filetree", "loop_control", "notify", "changed_when", "failed_when", "delegate_to", "delegate_facts", "run_once", "connection", "async", "poll", "vars", "environment", "no_log", "module_defaults", "ignore_unreachable", "throttle", "remote_user", "debugger", "block", "rescue", "always", "import_tasks", "include_tasks", "include_role", "import_role", "meta", "include_vars"} keys.to_a.each do |k| keys.add("ansible.builtin.#{k}") end keys.add("ansible.legacy.import_tasks") keys.add("ansible.legacy.include_tasks") keys.add("ansible.legacy.include_role") keys.add("ansible.legacy.include_vars") keys.add("ansible.legacy.meta") keys end

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.

SUPPORTED_META_ACTIONS = Set {"clear_facts", "flush_handlers", "end_host", "end_play", "clear_host_errors", "noop", "refresh_inventory", "end_batch", "end_role", "reset_connection"}

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).

TASK_INCLUDE_VALID_KEYWORDS = Set {"action", "args", "collections", "debugger", "ignore_errors", "loop", "loop_control", "loop_with", "name", "no_log", "register", "run_once", "tags", "timeout", "vars", "when", "ansible.builtin.include_tasks", "ansible.builtin.include_role", "ansible.legacy.include_tasks", "ansible.legacy.include_role", "with_first_found", "with_items", "with_dict", "with_nested", "with_sequence", "with_indexed_items", "with_fileglob", "with_file", "with_subelements", "listen", "environment", "changed_when", "failed_when", "until", "retries", "delay", "check_mode", "diff", "delegate_to", "delegate_facts", "connection", "ignore_unreachable", "throttle", "remote_user", "module_defaults"}

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

apply_module_defaults(tasks : Array(Task), inherited : Hash(String, Hash(String, String))) : Nil

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.

Source
condition_to_list(yaml : YAML::Any) : Array(String) | Nil

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.

Source
condition_to_string(yaml : YAML::Any) : String

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).

Source
extract_command_special_params(raw : String) : Tuple(String, Hash(String, String))

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:

  1. Under-matching: a value with exactly one {{ }} block and no further "}}" anywhere later in the string couldn't complete the pattern's trailing \s*\z at all, so extraction silently never happened (geerlingguy.solr's creates={{ solr_install_path }}/bin/solr).
  2. Over-matching: with a SECOND "{{ }}" block later in the string, the lazy .*? could backtrack straight through an entire separate key=value param - 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 trailing creates={{ ... }}/testrepo/README.txt text as part of itself, so chdir= 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.

Source
normalize_module_key(name : String) : String
Source
parse(path : String) : Playbook

Parse playbook from file

Source
parse_adhoc_params(module_name : String, raw_args : String) : Hash(String, String)

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).

Source
parse_free_form_params(s : String, module_name : String) : Hash(String, String)

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.

Source
parse_inline_kv_params_public(s : String) : Tuple(Hash(String, String), String | Nil)

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.

Source
parse_module_defaults(yaml : YAML::Any | Nil) : Hash(String, Hash(String, String))

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.

Source
parse_string(content : String, path : String = "playbook.yml") : Playbook

Parse playbook from string

Source
parse_tasks(tasks_yaml : Array(YAML::Any), play : Play, context : String, file_dir : String, known_vars : Hash(String, JSON::Any) | Nil = nil, role_path : String | Nil = nil, playbook_dir : String | Nil = nil) : Array(Task)

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".

Source
raise_unresolvable_module_error(as_written : String) : Nil

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.

Source
resolve_include_path(file_rel : String, file_dir : String) : String

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.

Source
resolve_module_name(raw : String) : String | Nil
Source
stats(playbook : Playbook) : Hash(String, Int32)

Get statistics about playbook

Source
validate(playbook : Playbook) : Array(String)

Validate playbook structure

Source

Nested types