module

Krikri

Runner for role-private custom modules - a role's own library/*.py, outside the plugin set this engine ships as native binaries. Real Ansible executes these as ordinary Python on the target; the previous scope cut skipped them with a parse-time "uses unimplemented plugin" warning (exit 4 via reachable_unavailable_modules since 0.9.558), which diverged on every role leaning on its own library/ - seen repeatedly benchmarking linux-system-roles (sr_fingerprint, timesync_provider, kernel_settings_get_config, blivet).

The runner delegates to the TARGET's own python3, the same way real Ansible does - no Python is embedded or reimplemented here. The module source is uploaded and executed through the same plugin-binary transport everything else uses (see plugins/py_module.cr), so local and SSH connections both work without any new plumbing.

Deliberately scoped to role-private library/ directories and the playbook-adjacent library/ (real Ansible's two most common search roots); third-party COLLECTION modules (bodsch., community.) are still the unchanged scope cut - those live inside installed collections on the comparison side, not in the playbook tree this runner can see.

Constants

ANSIBLE_VERSION_MAGIC_VAR = JSON.parse("{\n \"full\": \"2.19.4\", \"major\": 2, \"minor\": 19, \"revision\": 4, \"string\": \"2.19.4\"\n }")

ansible_version - a real Ansible magic var ({full, major, minor, revision, string}) giving the CONTROLLER's ansible-core version, used by real roles for feature-detection (ansible_version.string is version_compare(min_version, '>=')). Entirely unimplemented before - any reference to it (even the common ansible_version.string is version_compare(...) idiom, a BARE dotted lookup) resolved to this engine's own "undefined" sentinel and either silently mis-evaluated the comparison or (since 0.9.517's strict module-arg templating) hard failed the task outright. Found live re-benchmarking xanmanning.k3s (round 163 regression check) - its own pre_checks.yml gates on exactly this pattern before doing anything else, so the WHOLE role failed at task 1 on every rerun. Reports a real ansible-core version (not this project's own "0.9.x" version number) deliberately: this engine's whole design goal is behavioral parity with real Ansible, and every version-gated role feature in the wild was written expecting a 2.x-shaped comparison target, not a sub-1.0 one - reporting crystal's own version here would make EVERY such min-version check fail unconditionally, a worse outcome than picking one fixed real version. 2.19.4 matches the exact ansible-core release this project's own benchmark rounds compare against (see CLAUDE.md/ROLES_TESTED.md).

NON_VAR_ROOT_NAMES = Set {"true", "false", "none", "omit", "lookup", "query", "q", "url", "range", "dict", "list", "tuple", "namespace", "now"}

Jinja2/Ansible GLOBAL names (functions/constants, not variables) - a root identifier from this set never means "look up this var", the same carve-out shape as SCAN_STRICT_BLOCK_TAG_BUILTIN_FILTERS. Everything here is callable or a constant in plain Jinja2/Ansible, so an expression rooted at one of these names (e.g. lookup('env', ...)) must not be reported as "'<root>' is undefined" even when no variable of that name exists. Ansible's own magic namespaces (hostvars/groups/ vars) are deliberately NOT listed - those ARE real lookups in every vars context a strict finalization runs against, so a genuinely missing one SHOULD raise, like any other undefined root.

OMIT_SENTINEL = "__crystal_ansible_omit__"

Sentinel a rendered param value is compared against to detect real Ansible's omit magic variable ({{ item.proto | default(omit) }} - konstruktoid-hardening's "Allow outgoing specified ports" task uses exactly this to drop proto: for loop items that don't specify one). Real Ansible's omit causes the parameter itself to be dropped from the module call entirely, not set to some placeholder value - can't be represented as a plain rendered string, so FilterEngine's default resolves a bare omit argument to this unique marker instead, and #substitute_task_params (the one place that assembles a task's final param hash) strips any key whose fully-substituted value equals it.

REGEX_BARE_VAR_REF = /\A[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*|\[(?:-?\d+|'[^']*'|"[^"]*")\])*\z/

Conservative "pure variable reference" shape - letters/digits/ underscore, .field and [0]/['key'] access only. No spaces, pipes, parens, quotes outside of a bracket index, or keywords - those all indicate a real expression, not a plain lookup, and stay on the lenient path.

RUNTIME_DEPENDENCY_FORK_NOTES = begin notes = {} of String => String (parse_shard_yml_dependency_pins(SHARD_YML_TEXT, "dependencies")).each do |name, pin| if (github = pin[:github]) && (github.starts_with?("weirdbricks/")) else next end suffix = "" if tag = pin[:tag] suffix = ", tag #{tag}" elsif branch = pin[:branch] suffix = ", branch #{branch}" end notes[name] = " (#{github} fork#{suffix})" end notes end

Fork annotation per runtime dependency, keyed by shard name. Several of the shards krikri ships are its own patched forks of upstream libraries (identified by the weirdbricks GitHub owner in shard.yml, which carries real behavioral changes, not just version pins); a deployed binary should say which repo it was actually built from.

RUNTIME_DEPENDENCY_VERSIONS = begin dev_names = parse_shard_yml_section_names(SHARD_YML_TEXT, "development_dependencies") (parse_shard_lock_versions(SHARD_LOCK_TEXT)) .reject do |name, _| dev_names.includes?(name) end .map do |name, version| {name, semantic_shard_version(version)} end .sort_by! do |entry| entry[0] end end

Runtime dependency list (dev-only shards like ameba filtered out by category, not by name), sorted for a stable, diffable listing.

SCAN_STRICT_BLOCK_TAG_BUILTIN_FILTERS = Set {"abs", "attr", "batch", "capitalize", "center", "count", "d", "default", "dictsort", "e", "escape", "escapejs", "filesizeformat", "first", "float", "forceescape", "format", "groupby", "indent", "int", "items", "join", "last", "length", "list", "lower", "map", "max", "min", "pprint", "random", "reject", "rejectattr", "replace", "reverse", "round", "safe", "select", "selectattr", "slice", "sort", "string", "striptags", "sum", "title", "tojson", "trim", "truncate", "unique", "upper", "urlencode", "urlize", "wordcount", "wordwrap", "xmlattr", "as_json", "as_yaml", "b64decode", "b64encode", "sha1", "sha256", "md5", "flatten", "combine", "items2dict", "dict2items", "to_datetime", "from_json", "from_yaml", "to_yaml", "to_nice_yaml", "to_nice_json", "from_csv", "regex_search", "regex_findall", "regex_replace", "product", "log", "permutations", "combinations", "extract", "type_debug", "shuffle", "comment", "password_hash", "b32decode", "b32encode", "human_readable", "human_to_bytes", "to_bytes", "subelements", "start_with", "end_with", "match", "search", "ipaddr", "ipwrap", "bool", "checksum", "shorthash", "hash", "mandatory", "match_regex", "search_regex", "ternary"}

Jinja2/Ansible built-in filter names - a filter invocation never means "look up this identifier as a var". The same allowlist shape as UNDEFINED_TOLERANT_FILTERS but more complete (the tolerant set is only the subset of filters that pass undefined through; this one is the set of filter NAMES the strict block-tag scan must not treat as a var). Includes the ansible.builtin.X collection-prefixed forms used in real playbooks (ansible.builtin.default, etc.) - stripped of the prefix by the bare-ref regex's own \. step, but only if the form is a filter invocation; bare ansible.builtin.foo is still a var lookup, so the whole allowlist is needed either way.

SCAN_STRICT_BLOCK_TAG_KEYWORDS = Set {"if", "elif", "else", "endif", "for", "endfor", "set", "endset", "include", "extends", "block", "endblock", "macro", "endmacro", "filter", "endfilter", "call", "endcall", "in", "is", "not", "and", "or", "true", "false", "none", "recursive", "loop", "self", "super", "caller", "args", "kwargs", "varargs", "import", "from", "as", "with", "without", "scoped", "endscoped", "autoescape", "endautoescape", "raw", "endraw", "do", "enddo", "case", "when", "endcase", "default", "applymacro", "endapplymacro", "defined", "undefined", "divisibleby", "even", "odd", "mapping", "sequence", "number", "string", "boolean", "integer", "float", "iterable", "callable", "sameas", "lower", "upper", "eq", "ne", "lt", "le", "gt", "ge", "failed", "changed", "succeeded", "success", "skipped", "reachable", "omit"}

Jinja keywords and tests - identifiers the {% %} block-tag strict scan must NEVER flag, even when they appear in a {% if %} condition. Includes the basic block keywords (if/else/endif/etc.), the boolean operators (and/or/ not/in/is), the constant literals (true/false/none), the Jinja2 test names that follow is (is defined, is mapping, is failed, is iterable, etc.), and Ansible's task-result tests (is changed/is failed/is success/etc.). Tuned against the round-194 andrewrothstein.openjdk openjdk_app==<literal> shape; is defined/is failed are the common two that need not flag. omit is Ansible's own magic bareword sentinel ("drop this parameter entirely"), never a variable anyone sets - a strict scan flagging it (Stouts.openvpn's own {{ ansible_lsb.codename | default(omit) }} candidate, where the scan of the task's raw vars: params recursed into default(omit)'s ARGUMENT and raised "'omit' is undefined" there) fails a task real Ansible runs.

SCAN_STRICT_BLOCK_TAG_REF = /\b([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*|\[(?:-?\d+|'[^']*'|"[^"]*")\])*)\b/

Same identifier-class regex as REGEX_BARE_VAR_REF but anchored for substring matches inside {% %} block-tag conditions. The full anchor isn't right for the block-tag use - we need to find every bare reference in the condition, not just ones that span the whole string.

UNDEFINED_TOLERANT_FILTERS = Set {"default", "d", "type_debug"}

The only filters real Ansible lets a genuinely UNDEFINED value reach without failing the task. Everything else in Jinja2/Ansible raises on AnsibleUndefined - differentialed against the local ansible-core 2.19.4 with msg: "{{ nope | <filter> }}" over 24 filters (dict2items, items2dict, list, first, join, length, string, bool, int, ternary, flatten, map, select, unique, sort, lower, trim, to_json, combine, count, min, mandatory all FAIL; only these three succeed), so a tolerant ALLOWLIST is the accurate model here, not a denylist of the handful of filters a benchmark round happened to hit.

VALID_STRATEGIES = ["linear", "free", "host_pinned"]

Represents a play (collection of tasks for specific hosts) The strategies this engine implements. host_pinned is accepted and behaves as free: its only difference is worker affinity, which this engine has no equivalent of. debug is real Ansible's interactive debugger strategy and is deliberately NOT accepted here - see KNOWN_MISSING.md's note on debugger:.

VERSION = "0.9.1154"

Class methods

bracket_index_failure_message(expr : String, vars : Hash(String, JSON::Any)) : String | Nil

Round 812045 (pluggero.bibata_cursor): a bracket index applied to a FILTER-CHAIN or parenthesized base ((x.stdout | regex_search('...', '\\1', multiline=True))[0]) is invisible to dict_attribute_miss_name above (its shape regex only accepts plain bare-var chains), so a base that resolves to Python None (regex_search with no match at all) or an index past the end of a real-but-too-short list silently rendered the "undefined" sentinel and the play ran green where real Ansible hard-fails the task. Live-verified against ansible-core 2.19.11 - both at task-arg finalization and in when: evaluation, which are DISTINCT Python error shapes, deliberately not collapsed into one message: None/JSON-null base -> "None has no element 0" list index past end -> "object of type 'list' has no attribute 5" Walks the TRAILING top-level integer-index brackets off expr (so a | default(...) guard after the index, which real Ansible answers leniently, never reaches the check), structurally evaluates the remaining base via Crinja, and returns real Ansible's own message for the first failing index. nil for every shape this can't pin down (undefined base - the generic "'x' is undefined" probe already owns that; non-integer index; dict-key miss, lenient by long-standing convention here; or a base containing a side-effecting lookup(...)/ query(...) call that must not run a second time).

Source
dict_attribute_miss_name(expr : String, vars : Hash(String, JSON::Any)) : String | Nil

For a chained lookup expression (d['missing'], d.missing, groups[rke2_servers_group_name], pkg[ver]["update"]) that rendered to the "undefined" sentinel: if some step of the chain subscripts a RESOLVABLE dict with a key it doesn't have, real Ansible's error names the dict and the key - "object of type 'dict' has no attribute 'missing'"

  • not "'<whole expr>' is undefined" (both live-verified against ansible-core 2.19.4, for bracket access, dot access, and a dynamic-key bracket like rke2's groups[rke2_servers_group_name] where the key itself is a defined variable resolving to "masters"). Returns the missing attribute name when that's the shape, nil for every other undefined shape (root variable missing, array index out of range - message not live-verified, keep the generic text - key expression itself undefined, non-dict intermediate, nested brackets this simple walker doesn't parse).
Source
expression_resolves_to_undefined?(expr : String, vars : Hash(String, JSON::Any)) : Bool

A chained-subscript/dot expression that ultimately resolves to nothing (the inner-most lookup misses, the entire expression renders to the literal text "undefined") - pkg_upgrade_update_ cmds[ansible_distribution_major_version]["update"] on Rocky 9.6, where ansible_distribution_major_version is "9" but the role's vars/RedHat.yml only has keys "7" and "8", is the canonical case found in round-194's andrewrothstein.pkg-upgrade. Used by raise_if_strict_undefined's chained-subscript branch (which has to decide whether the inner expression ultimately renders to "undefined" without itself recursing into substitute_impl). The detection is ExpressionEvaluator's undefined-typed #evaluate_or_undefined, not a string comparison against its own rendered output: the older rendered == "undefined" check could not tell a genuine miss from a REAL value that happens to be the text "undefined" (printf 'undefined' + register: s2, then {{ s2.stdout_lines.0 }} - juju4.pocketid round 60151 - failed the task where real Ansible renders the string; the bracket form, decided structurally, was never affected). Doesn't apply to the bare-ref or filter-chain shapes the OTHER raise_if_strict_undefined branches already cover.

Source
parse_json_or_python_literal(rendered : String) : JSON::Any

Parses rendered (text an evaluator's own .evaluate/.evaluate_output already rendered, from a whole-value {{ }} template being re-rendered to recover its real type - see every rerender_if_ templated-shaped helper across this codebase) back into a real JSON::Any, tolerating Python's OWN literal spellings that plain JSON.parse rejects outright (True/False/None - capitalized, not JSON's lowercase true/false/null; a Python dict/list repr with single-quoted strings, {'a': 1} not {"a": 1}). Every one of those independent copies previously just did (JSON.parse(rendered) rescue nil) || JSON::Any.new(rendered) - for a real Python bool/ None this ALWAYS falls to the rescue branch (JSON.parse("False") raises, "Unexpected char 'F'"), wrapping the STRING "False" instead of recovering the real boolean. Found via sscheib.openwrt_ bootstrap's own vars/main.yml: _bts_install_full_python: "{{ bts_install_full_python | default(_def_bts_install_full_python) }}" (a real Python bool default, false) rendered to the STRING "False" here instead of a real bool, so the role's own _bts_install_full_ python is boolean assert always failed regardless of the real (correct) underlying value.

Source
parse_shard_lock_versions(content : String) : Hash(String, String)

Parses a shard.lock's shards: section into {name => version}. Line-based rather than YAML-lib based so it stays a pure, fixture- testable function; the lock file's own emitted shape (2-space shard names, 4-space keys) is stable across shards versions.

Source
parse_shard_yml_dependency_pins(content : String, section : String) : Hash(String, ShardYmlPin)
Source
parse_shard_yml_section_names(content : String, section : String) : Array(String)

Extracts the shard names listed under a top-level section header (e.g. "dependencies:" / "development_dependencies:") of a shard.yml.

Source
raise_if_chain_source_value_undefined(expr : String, vars : Hash(String, JSON::Any)) : Nil

The nested-undefined companion to undefined_filter_chain_source: expr is a filter chain whose head IS a defined bare variable, but whose stored VALUE is itself unrendered Jinja that bottoms out at a name set nowhere (php_fpm_site_errorlog: "/home/{{ system_user }}/ logs/x.log" with system_user never defined - inmotionhosting.php_fpm, round 82024). Real Ansible's recursive re-templating renders the head's own value strictly BEFORE the first filter ever applies, so a when: using the chain fails with the innermost missing name; this engine's lenient re-render baked the "undefined" sentinel into the string and the conditional silently answered falsy instead. The same tolerant-first- filter rule as undefined_filter_chain_source applies, and it applies to the nested case too - live-verified against ansible-core 2.19.4: site_errorlog | default('x') | length > 0 runs, site_errorlog | length > 0 fails. Raises (rather than returning a name to raise on) because the correct message names the innermost undefined reference, which only the strict render itself knows.

Source
result_failed_flag(result : JSON::Any) : Bool

A module result's "failed" flag read the way real Ansible's Python truthiness reads it: the wire protocol normally carries a JSON bool, but real ansible-core's TaskExecutor puts INTEGER 0 in the async fire-and-forget launch result ("failed: 0" - confirmed via the podman-diff async_status cases), and a hard as_bool cast crashes the executor on it. 0 is falsy, 1 truthy, matching Python.

Source
semantic_shard_version(version : String) : String

"0.9.0+git.commit.<sha>" -> "0.9.0" - the semantic version a user comparing "what version of X am I running" actually wants, matching how pip reports jinja2/pyyaml in real ansible --version.

Source
stat_atime_f(stat : LibC::Stat) : Float64

Float-seconds variants matching Python's own os.stat_result st_atime/st_mtime/st_ctime, which are tv_sec + tv_nsec / 1e9 computed as float64 (CPython combines the two halves the same way) - real Ansible's stat and find results carry that float straight through (e.g. "atime": 1789308974.764945), so truncating to whole seconds broke sub-second timestamp comparisons against real-Ansible output. The *_sec Int64 variants above stay for Time.unix() call sites (file module touch-time change detection) that genuinely want whole seconds.

Source
stat_atime_sec(stat : LibC::Stat) : Int64

LibC::Stat's timestamp fields are named differently per libc: glibc (Linux) uses st_atim/st_mtim/st_ctim, while Darwin's BSD-derived libc uses st_atimespec/st_mtimespec/st_ctimespec for the same Timespec struct. Only matters for compiling a macOS controller binary - the plugin binaries this stats normally run on the (Linux) target host, but ansible_connection=local and the controller's own bookkeeping exercise this on whatever host krikri-playbook itself runs on.

Source
stat_ctime_f(stat : LibC::Stat) : Float64
Source
stat_ctime_sec(stat : LibC::Stat) : Int64
Source
stat_mtime_f(stat : LibC::Stat) : Float64
Source
stat_mtime_sec(stat : LibC::Stat) : Int64
Source
strict_undefined_message(expr : String, vars : Hash(String, JSON::Any)) : String

The full strict-undefined error message for a failed lookup: a dict-subscript miss on a resolvable chain gets real Ansible's attribute-error wording, everything else the classic "'x' is undefined".

Source
undefined_access_chain_source(expr : String, vars : Hash(String, JSON::Any)) : String | Nil

The ATTRIBUTE/SUBSCRIPT/CALL companion to undefined_filter_chain_source: returns the ROOT variable name when expr starts with a plain identifier that is genuinely absent from vars and immediately accesses something off it - root.split(':'), root.attr, root['key'], root(...), optionally piped onward (root.split(':') | map(...) | list). Real Ansible's strict finalization raises the moment the undefined root is ACCESSED (Jinja2's StrictUndefined raises on attribute/subscript/call), BEFORE any later tolerant filter in the chain could see it - so unlike the bare-ref chain case, no first-filter tolerance carve-out applies here: x.split(':') | default([]) fails in real Ansible too.

Why this exists (round 0.9.879, wcm_io_devops.conga_host_facts' very first task): _host_pattern_variants: "{{ conga_host_facts_pattern .split(':') | map('regex_replace', ...) | list }}" with the variable never defined. undefined_filter_chain_source rejected the source (conga_host_facts_pattern.split(':')) because it contains parens, and the chained-subscript branch rejects ( and |, so the expression silently rendered to [] and the task succeeded where real Ansible fatally fails ("'conga_host_facts_pattern' is undefined").

Deliberately narrow, same spirit as the other strict probes: the root must be a plain identifier directly followed by ./[/( (so binary-operator and is defined/if shapes never match), it must be absent from vars by a straight lookup (no evaluation), and known Jinja globals are excluded - none of this evaluator's documented expression-syntax gaps can turn into a spurious task failure here.

Source
undefined_filter_chain_source(expr : String, vars : Hash(String, JSON::Any)) : String | Nil

Returns the offending variable name when expr is a filter chain whose SOURCE is a genuinely undefined bare variable reference and whose FIRST filter is not one of UNDEFINED_TOLERANT_FILTERS - i.e. exactly the shape real Ansible hard-fails - and nil otherwise.

Why this exists (round185, buluma.environment's loop: "{{ environment_list | dict2items }}", with no default anywhere in the role): the strict-undefined machinery only ever looked at BARE {{ var }} references, so the moment an undefined value flowed through any filter it stopped being strict - and FilterEngine's own as_hash/as_array helpers independently coerced the missing value to {}/[] before anything upstream could notice. The task then produced zero loop items and silently no-op'd where real Ansible fails ("dict2items requires a dictionary, got ...AnsibleUndefined").

Only the FIRST filter is consulted, which is what real Ansible does too: x | default([]) | dict2items is fine (default consumes the undefined - the legitimate, extremely common idiom), while x | dict2items | default([]) still fails, because dict2items has already raised by the time default is reached.

Deliberately narrow in the same spirit as REGEX_BARE_VAR_REF: the source has to be a plain variable reference that is genuinely absent from vars (a straight lookup, no evaluation), so none of this evaluator's documented expression-syntax gaps can turn into a spurious task failure here.

Source
undefined_tolerant_first_filter?(expr : String) : Bool

Whether expr (a full {{ }} span's content, possibly a filter chain) pipes its source through an undefined-TOLERANT filter first (x | default(y), x | d(y), x | type_debug) - the shape real Ansible's own strict templating never fails, because the tolerant filter consumes the undefined before anything can choke on it. Shared by the strict-undefined checks that probe a chain's ROOT rather than the whole chain (raise_if_strict_undefined's undefined-access branch), so both agree on the tolerant-first-filter rule undefined_filter_chain_source already implements.

Source
version_info
Source

Nested types