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 - 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).
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.
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.
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.
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 list (dev-only shards like ameba filtered out by category, not by name), sorted for a stable, diffable listing.
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.
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.
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.
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.
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:.
Class methods
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).
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).
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.
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.
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.
Extracts the shard names listed under a top-level section header (e.g. "dependencies:" / "development_dependencies:") of a shard.yml.
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.
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.
"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.
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.
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.
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".
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.
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.
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.
Nested types
- Krikri::ActionGroups
- Krikri::ActionPlugin
- Krikri::ActionPluginManager
- Krikri::ActionResult
- Krikri::AddHostActionPlugin
- Krikri::AptLockRetry
- Krikri::AptRepositoryCacheRetry
- Krikri::AssertActionPlugin
- Krikri::AsyncJobs
- Krikri::BasePlugin
- Krikri::BatchScript
- Krikri::CliOptions
- Krikri::ConditionalEvaluator
- Krikri::ConflictingActionStatementsError
- Krikri::ControllingTty
- Krikri::CustomStats
- Krikri::DebugActionPlugin
- Krikri::EndRoleOutsideRoleError
- Krikri::ExtraVarsParser
- Krikri::FactCache
- Krikri::FactsGatherer
- Krikri::FailActionPlugin
- Krikri::FiletreeLookup
- Krikri::FirstFoundLookupError
- Krikri::HandlerNotFoundError
- Krikri::HandlerRunner
- Krikri::Host
- Krikri::HostGroup
- Krikri::HostVarsVarsDict
- Krikri::InvalidStrategyError
- Krikri::Inventory
- Krikri::InventoryParser
- Krikri::InventoryPlugins
- Krikri::IpAddrCore
- Krikri::JMESPath
- Krikri::JinjaFilters
- Krikri::LocalExecutor
- Krikri::LoopResolver
- Krikri::OutputRouting
- Krikri::PauseActionPlugin
- Krikri::PipeLookupError
- Krikri::Play
- Krikri::Playbook
- Krikri::PlaybookParser
- Krikri::PluginDaemon
- Krikri::PluginHelpers
- Krikri::PluginManager
- Krikri::PluginResult
- Krikri::PyRandom
- Krikri::PythonFilterRunner
- Krikri::PythonLookupRunner
- Krikri::PythonModuleRunner
- Krikri::RemovedActionError
- Krikri::ResultDisplay
- Krikri::RoleLoader
- Krikri::RoleNotFoundError
- Krikri::SSHManager
- Krikri::SerialBatches
- Krikri::SetFactActionPlugin
- Krikri::ShardYmlPin
- Krikri::Shell
- Krikri::StartAtFilter
- Krikri::StaticImportRoleUndefinedError
- Krikri::StaticImportUndefinedError
- Krikri::StrictTemplating
- Krikri::SynchronizeActionPlugin
- Krikri::SynchronizeRsync
- Krikri::SystemdCliFlags
- Krikri::SystemdEnabledState
- Krikri::TagFilter
- Krikri::Task
- Krikri::TaskBatcher
- Krikri::TaskDebugger
- Krikri::TaskExecutor
- Krikri::TaskLister
- Krikri::TemplateActionPlugin
- Krikri::TimingProfile
- Krikri::UndefinedVariableError
- Krikri::UnresolvedModuleError
- Krikri::VarSubstitutor
- Krikri::VariableSubstitutor
- Krikri::VarsPrompt
- Krikri::Vault
- Krikri::VaultCli
- Krikri::X509CertInfo
- Krikri::YamlSyntaxError