Krikri
PythonPattern - a scan of a user-supplied pattern through Python's own re._parser tokenizer semantics, shared by the file-editing plugins (replace, lineinfile). It rejects first what PCRE2 would happily compile but Ansible's Python rejects, rewrites the two escape forms Python accepts and PCRE2 does not (\uXXXX, \Uhhhhhhhh), and carries the positions python_pcre_error needs to translate a PCRE2 compile error into the re.error wording Ansible's user actually sees. Lived in the replace plugin until lineinfile needed the same machinery: real lineinfile.py compiles regexp: (and a non-literal insertafter:/insertbefore: anchor) with a bare re.compile no except clause catches, so a bad pattern crashes the module instead of silently matching nothing.
Constants
The ansible-core release krikri's output is verified against, byte for
byte (see README "Compatibility target"). Also the value of the
ansible_version magic variable.
ansible_version - a 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 ansible-core version
(not this project's own "0.9.x" version number) deliberately: this
engine's whole design goal is behavioral parity with 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.11 matches the exact ansible-core release this project's own
benchmark rounds compare against (see AGENTS.md/ROLES_TESTED.md).
Key ORDER matters, not just the values: roles that render the bare
dict into a debug string ("ansible_version => {{ ansible_version }}",
buluma.os_updates' SmokeTests task) stringify it in Python dict
insertion order, and ansible-core builds it as
{string, full, major, minor, revision} - live-verified against
ansible-core 2.21.6. Crystal's Hash iterates in insertion order and
the Jinja str() path preserves it, so the literal must be written in
ansible's own order; a sorted-looking order makes the rendered string
diverge from ansible byte for byte.
Prefix marking a string as the decimal text of an integer too wide for Int64 (RSA moduli, EC coordinates, big certificate serial numbers). JSON::Any's raw union tops out at Int64, so such a value cannot exist as a number in the engine's result world: a module that returns a Python int beyond Int64 reaches the controller as bare digits on the JSON wire, the bigint-aware parse (Krikri::BigJson) stores it as this marked string, and every renderer that prints a value to a user (ResultDisplay's dumps, PyDump, the filters, string interpolation) prints it as bare unquoted digits again - the exact text Ansible's Python ints produce. The engine side (krikri-jinja) turns the marked string into a real BigIntValue for templating/tests/comparisons; its canonical constant is KrikriJinja::BIGINT_SENTINEL_PREFIX, duplicated here because the plugin binaries (which also emit wide integers, and mark them with this prefix before BasePlugin's wire rewrite unquotes them) link neither that shard nor the jinja engine. The private-use control character makes a false positive on real string data effectively impossible.
Prefix marking a param value whose bytes are NOT valid UTF-8 - binary
module-arg data such as a slurp+b64decode'd corosync authkey passed as
copy: content: (bloxberg.corosync_pacemaker, issue #835). The plugin
wire is JSON, and a Crystal JSON round trip mangles invalid-UTF-8
strings (observed: 32 random authkey bytes became 52 bytes sprinkled
with U+FFFD sequences), so real ansible's "content equals what the
file already holds -> ok" idempotency check failed here and every run
rewrote the file as changed. The executor base64-encodes any param
string that fails valid_encoding? under this prefix before the
config JSON is built (base64 output is pure ASCII, so every later JSON
encode/parse round trip - including the remote transport's own - is
lossless), and BasePlugin demotes it back to the exact original bytes
when the plugin process parses its config. Like the other prefixes,
the leading private-use control character makes a false positive on
real user data effectively impossible.
Baked in at compile time via the same --release flag build.sh
passes through to crystal build. A timing-sensitive round run
against a debug binary is ~1.8x slower wall-clock than release on
identical work (measured on dev-sec os_hardening) - this lets
krikri-role-tester warn when a round launches on one, instead of
silently publishing inflated per-role timings.
assert: its own condition keys sit between failed and msg.
ansible-core 2.19.11's REGISTERED key order for a FAILED
action-level result, live-verified per action with {{ r | to_json }}
under ignore_errors: true (Ansible's registered dict keeps the module
action's own insertion order - these orders genuinely differ per
action, so each builder picks its own). The order travels with the
result as the private _ansible_key_order marker key (stripped at
register, like every other _ansible_* key) and is applied by
TaskExecutor#register_result, which is also where the missing
exception: "(traceback unavailable)" key is backfilled.
unarchive, set_fact and the task-arg finalization failure: exception leads, right after failed.
include_vars: the action's own keys lead, and the wrapped msg comes last (Ansible keeps it after changed/exception).
copy/template (a missing controller-side src) and add_host's own AnsibleActionFail: exception sits between msg and changed.
the task timeout: deadline failure: TaskTimeoutError's
ContributesToTaskResult payload (the timedout dict) leads the merged
registered dict, ahead of failed/exception/msg/changed (live-verified
against 2.19.11 via {{ r | to_json }} - Atlantic round 3100000's
KEYORDER probe).
Ansible's INTERPRETER_PYTHON_FALLBACK default (ansible-core 2.19.11's
config/base.yml), in its own order. Ansible's discovery runs
command -v <name> over this list on the target and hands the module
the FIRST hit, so the sys.executable basic.py reports is that
interpreter's - python3.13 before /usr/bin/python3, which on a
Debian box resolves to /usr/bin/python3.13 rather than the
/usr/bin/python3 symlink a bare python3 probe would find.
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.
Bare Jinja2/Python boolean/null literal spellings, in both
capitalizations Jinja2 accepts (the vendored Crinja lexer's own
SPECIAL_CONSTANTS maps the camelcase forms too). The strict-undefined
probes below match these against REGEX_BARE_VAR_REF as if they were
variable NAMES - {{ true }} and, one shape over, {{ true | bool }}
both failed the task with "'true' is undefined" where
ansible-playbook renders the literal (christiangda.amazon_
cloudwatch_agent's cwa_need_credentials: "{{ true | bool if cwa_agent_mode == 'onPremise' else cwa_use_credentials }}" behind a
bare when:, round 952063). A filter chain or a when: can't be
strict about a name that is a literal, never a lookup. omit keeps
its own separate carve-outs (its "drop this parameter" semantics
differ from a plain value literal).
ansible-core 2.19 words a dict-subscript miss differently by key:
any key with text in it gets the templar's dict-typed rewording
("object of type 'dict' has no attribute 'nope'"), but an
EMPTY-string key never reaches that rewording - it falls through
to Python's getattr on the lazy templated-dict wrapper, which
reports the wrapper's full dotted class path instead
("'ansible._internal._templating._lazy_containers.
_AnsibleLazyTemplateDict object' has no attribute ''"; both
live-verified vs 2.19.11 on {{ groups[k] }} with k: '' vs
k: 'nope').
Play/role/set_fact list variables are wrapped in ansible's lazy template list, and 2.19.11 reports an out-of-range INDEX on such a bare-identifier var with Jinja's own "no element" wording (its "object of type ... has no attribute" hint is only built for truthy names, so index 0 - the one shape live-verified on the issue #582 repro - falls through to it). Literal lists and other indexes keep the plain list wording.
The head of ansible-core's mandatory filter-plugin failure text, as the finalization chain words it (see #mandatory_message for the full text).
Prefix marking a set_fact param value as the JSON encoding of the
expression's NATIVELY-TYPED result, not substituted display text.
The executor's param wire is strings-only, so a whole-single-span
{{ expr }} set_fact value (the only shape ansible-core 2.19
native-typing keeps unstringified) would otherwise arrive at the
set_fact plugin as bare text and get re-coerced by string shape -
which is pre-2.19 ANSIBLE_JINJA2_NATIVE=off literal_eval behavior,
not 2.19's "the expression's own type is the value's type" rule: a
Jinja string expression stays a str even when it looks like a number
(pluggero.openssh round 981024: "{{ '8.9' }}" became the float 8.9,
so openssh_installed_version != openssh_pkg_mgr_version compared
float-to-str and was always true, forcing a package reinstall every
run). The control character makes a false positive on a literal
(non-templated) set_fact value - which still takes the legacy
string-shape coercion below - effectively impossible.
The MEMBER counterpart: the parser's comma-joined list branch marks a
non-string scalar list member with THIS prefix instead, so a
single-element list whose only member is a non-string (parents: [7])
stays distinguishable from the whole-value scalar literal (parents: 7) that Ansible crashes on DIFFERENTLY ("object is not iterable"
vs "object has no attribute 'replace'"). The demotion sites treat both
prefixes identically - decode the JSON payload, emit the exact
stringify_value text - so no plugin that never asks changes behavior;
only the executor-side hooks mirroring Ansible's member-iterating action
plugins (group_by/add_host) tell them apart.
Prefix marking a module-arg value as a NON-STRING YAML scalar literal -
an int, float or bool the playbook wrote unquoted (copy: dest: 89,
fetch: src: true, debug: msg: 1.5-style). Task#params is a
strings-only wire (Hash(String, String)), so stringify_value erases the
YAML type at parse time - and with it every behavior that hinges on the
value's Python type rather than its text: ansible-core 2.19 passes
the literal AS ITS NATIVE TYPE into the action plugin, so a non-string
dest/src crashes Ansible's copy with "'_AnsibleTaggedInt' object has no
attribute 'startswith'" (bools print as plain 'bool' - they are not
tagged), fails fetch's action with "Invalid type supplied for dest
option, it must be a string", renders through template as Python
str(True) = "True" (not YAML's "true"), and makes falsy scalars (false,
0, 0.0) count as "not provided" in copy's truthiness checks
(live-verified against 2.19.11 for the full dest/src x int/bool/float
matrix). The parser prefixes such a literal with this control character
plus the JSON encoding of the parsed YAML value; BasePlugin strips it
back to the exact same plain string every plugin saw before (so no
plugin that never asks changes behavior) and records the native value
for the plugins that mirror Ansible's type-checking - query it with
BasePlugin#non_string_param. Like NATIVE_TYPED_PREFIX, the leading
private-use control character makes a false positive on real user data
effectively impossible, and templated values (any {{/{%/{#)
are never marked - their type belongs to the executor's whole-span
evaluation, not to the literal.
The null/None counterpart to OMIT_SENTINEL (variable_substitutor.cr).
The param wire between the executor and a plugin binary is
strings-only, so a module call whose param natively resolved to
Python None (enablerepo: "{{ item.enablerepo | default('') }}" with
a null item field - round 900905 officel.httpd) would collapse to the
same "" as a real empty string, which Ansible's argument specs
treat completely differently (an explicit None fails every type: list param; an empty string coerces to an empty list just fine -
live-verified against ansible-core 2.19.11). The executor marks such
params with this sentinel, BasePlugin demotes it back to "" (plus a
null bookkeeping entry) so every plugin that never asks about it
stays behavior-identical, and only plugins that mirror a real
module's argspec consult BasePlugin#explicit_null_param?.
Sentinel a rendered param value is compared against to detect
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).
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.
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. krikri-jinja and krikri-xml are krikri's own libraries, not forks, so they are annotated with their repo and pin but not labeled "fork".
The one grammar dict_attribute_miss_name can walk: a root identifier
followed by .attr / bracket-free [...] steps - the same permit the
probe hands to its operand pieces (see strict_undefined_message).
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 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 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.
ansible-core 2.19 tags every scalar flowing through templating with a
datatag wrapper class, and a FAILED bracket subscript on one names that
wrapper's full dotted class path in its falsy-key/empty-key wordings -
not the plain type name the truthy-key attribute wording uses
(live-verified vs 2.19.11 on "C"[none], "C"[''], 5[0], 1.5[0]).
bool and None are never tagged, so they keep their plain Python names.
Marker a chain-undefined templated leaf rides inside the template action plugin's variable scope ("'bind_addr' is undefined" wrapped in private-use control characters, see JinjaRenderer#rerender_string_value). Both characters are the same private-use code points the param-wire sentinels (param_sentinels.cr) already rely on to be unrepresentable in real playbook content, so the scope conversion in TemplateActionPlugin decodes the marker without ever mistaking authored text for one.
Sentinel a strict +/- operand resolves to when Ansible 2.19
would have produced an UndefinedMarker rather than a value: a
method call on a None-valued variable (nil_var.rstrip(';') - the
NoneType itself coming from a {% for %} over an empty list,
inofix.yapkg issue #531) never runs, it short-circuits to
undefined, so the enclosing operator fails with
can only concatenate list (not "UndefinedMarker") to list instead
of naming NoneType. Same role as OMIT_SENTINEL: a unique marker the
combine step must fail on rather than silently string-concatenate.
The only filters 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 Ansible's interactive
debugger strategy and is deliberately NOT accepted here - see
KNOWN_MISSING.md's note on debugger:.
Class methods
Ordering comparison of two signed decimal integer strings of arbitrary width (the payloads of BIGINT_SENTINEL_PREFIX-marked values), for the comparison paths that must treat a marked value as a real number. Sign first, then magnitude by length (a longer magnitude is the larger number), then lexicographic on equal lengths - exact, no Float64 rounding anywhere.
The decimal digits a BIGINT_SENTINEL_PREFIX-marked string carries (sign included), or nil when the text is not a marked value. A prefix occurrence with a non-integer payload (only possible via hostile string data, never via the parse boundary) is not a bigint.
The bigint-sentinel check over a JSON::Any node: the decimal digits when the node is a marked string, nil otherwise.
Builds the marked string for a decimal integer too wide for Int64.
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 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 Ansible
answers leniently, never reaches the check), structurally evaluates
the remaining base via Crinja, and returns 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).
The innermost strict-failure message of the templated value owned by
the name a strict probe blamed, or nil when the name's value renders
fine (the outer message was a genuine miss) or the name is not a
plain {{ }} template at all.
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 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 Ansible
fails ("dict2items requires a dictionary, got ...AnsibleUndefined").
Only the FIRST filter is consulted, which is what 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 filter chain) pipes its source through the named filter FIRST - shared shape logic with #undefined_tolerant_first_filter?.
Plugin-side decode: restores the exact original bytes behind an encoded value; anything not carrying the prefix is returned as-is.
The target's Python interpreter, the way Ansible's interpreter
discovery picks it: the first INTERPRETER_FALLBACK entry command -v
resolves on the target. nil when the target has none.
This runs on the TARGET (each plugin binary is uploaded there), so both the probe and the hostname it reports are the target's own - the same two values Ansible's import failure reports.
Executor-side encode: passes valid-UTF-8 text through untouched (the common case - no behavior change), base64-wraps everything else.
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 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.
The engine's own attribute-miss wording ("'dict object' has no attribute 'k'") rewritten into ansible-core's, including the empty-string-key exception dict_attribute_miss_message covers.
Ansible's boolean(value, strict=False) (the Ansible module convert_bool.py) answering TRUE - the exact predicate the assemble action plugin applies to remote_src to pick between its module branch (truthy: the assemble module itself runs and re-converts remote_src with its own strict type: bool, which can then only ever succeed) and its controller-side branch (everything else: falsy spellings, invalid spellings like 'timjjr', explicit None, native 2/0.5 - boolean() returns False for all of them under strict=False, so the action assembles locally and delegates the placement to the copy module, whose spec - with remote_src STRIPPED - is what validates the rest). Only BOOLEANS_TRUE counts: the spellings y/yes/on/1/true/t (lowercased, whitespace-stripped) and the native true/1/1.0.
mandatory's failure text: Ansible's filter plugin uses its msg argument when given ("custom message here"), else "Mandatory variable 'X' not defined." quoting the UNDEFINED variable's name (live-verified).
Tags result with the real key order its registered FAILED form must carry; see FAILED_KEY_ORDER_DEFAULT.
nil when <import_stmt> imports cleanly under the target's Python - the module carries on, exactly as Ansible's does. Otherwise Ansible's exact failure text.
detail is the ImportError's own text for the modules that pass
their caught exception along (module.fail_json(msg= missing_required_lib("pexpect"), exception=PEXPECT_IMP_ERR)), which
real shows in the [ERROR] block only - never in the result's msg -
so it is returned SEPARATELY, for the caller to pass as the
[ERROR]-only detail rather than appended to the message.
Ansible's missing_required_lib(<library>) message, verbatim, for a target that cannot import <library>.
The shape of the parity problem this covers: several Ansible modules import a Python library at MODULE level, so a target without it fails the task with this message before the module validates a single one of its own arguments. krikri implements those modules natively (its own pty layer for expect, its own XML parser for xml) and therefore does NOT need the library - which is why it used to carry on past the point where Ansible stops, reporting its own downstream failure (or, worse, success) instead. The dependency still decides the outcome, so it is reproduced here: same message, same ordering, same point in the flow. Same precedent as the python3-debian gate in deb822_repository, the gssapi gate in nsupdate/url_preflight and the boto3 gate in the aws modules.
Callers whose library has a reason line (gssapi, ...) pass it as
reason; it sits between the first sentence and the generic advice.
The members of a parser comma-joined list param value, in wire order: a member the parser marked with NON_STRING_MEMBER_PREFIX (see that prefix's comment) as its decoded native scalar, every other member as its own plain text. nil when the value carries no member marker at all - a plain string, or a whole-value scalar literal (which rides NON_STRING_PARAM_PREFIX instead and stays #non_string_scalar's business). The plugin-side counterpart of the demotion every param-parse site performs: a plugin mirroring a real module's per-MEMBER type checking can still see the native types here, since the demoted @params text has lost the markers.
Same decode for a NON_STRING_MEMBER_PREFIX-prefixed list member (or a single-member list whose whole wire value carries the member prefix).
The plain string form a marked literal demotes to on the plugin wire.
Bools render as Python str() ("True"/"False") - what Ansible's argspec
finalization puts on the wire for a str-typed option carrying a native
bool literal (2.21 live-verified: cmd: false executes the literally-
named "False", rc 2 ENOENT) - matching #python_str_scalar, whose
template's-dest note records the same behavior live-verified against
2.19.11. Int/float spellings are identical either way. A marked nil
(YAML key: with no value) demotes to "" like stringify_value's own
Nil case - the explicit-null argspec flow handles real Nones.
Decodes a NON_STRING_PARAM_PREFIX-prefixed param value back into the native YAML scalar (JSON::Any wrapping Int64/Float64/Bool), or nil when the value is not a marked literal. Shared by BasePlugin's param parse (plugin binaries) and the executor/ArgspecValidator side (main executable) - the one file both can require.
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.
Whether expr must NOT be probed because a second evaluation would run side-effecting lookups twice (the same convention bracket_index_failure_message applies to its own probe).
Python truthiness of a JSON value (None/False/0/0.0/""/[]/{} are
falsy) - ansible's display callbacks gate the => (item=...) suffix
and non-string miss wording on the label/key OBJECT, not its text.
The Python type name CPython's str.join puts in its own "sequence item
N: expected str instance, X found" TypeError - the message Ansible's
debconf module dies with when a non-string value: literal reaches its
' '.join([pkg, question, vtype, value]) (debconf.py:179, live-verified
vs 2.19.11). These values are PLAIN Python objects by then - the same
value: literal that would be reported as an _AnsibleTaggedInt in an
"'X' object has no attribute" crash names plain int here - so this is
the builtin name, unlike #python_value_type_name.
Python truthiness of a task-param value, native-type aware: a marked non-string literal carries its own truthiness (false/0/0.0 are falsy exactly like in Python), a nil/absent param is falsy, and a plain string is falsy only when empty - matching Python's bool("") without changing what any existing plugin saw (they got the same "" before).
The Python type name ansible-core 2.19 reports for a non-string scalar literal in an "'X' object has no attribute ..." crash: YAML ints/floats arrive natively tagged (_AnsibleTaggedInt/_AnsibleTaggedFloat), bools are plain Python bools (live-verified vs 2.19.11).
Python str() of a marked non-string scalar - the coercion Ansible's action
plugins effectively put dest/src through when they use a non-string
literal as text (template's dest: true writes a file named "True",
not "true" - live-verified vs 2.19.11; int/float spellings are
identical to the demoted text).
Whether operand is a quoted STRING LITERAL written directly in the
expression ('10', "10") rather than a variable reference. Jinja
builds a plain Python str for a literal, so ansible's TypeError names
it 'str'; only a value READ from a yaml document carries the loader's
tag and reports '_AnsibleTaggedStr' (live-verified vs 2.21.6:
when: "'10' >= 1" says 'str' while v: "10" from vars says
'_AnsibleTaggedStr').
The FULL Python class path Ansible reports for a non-string value in its AddHost result field validation ("AddHost.host_name must be <class 'str'> instead of <class '<type>'>"): datatag-wrapped YAML scalars AND containers are fully qualified, plain Python bool/NoneType are not (live-verified vs 2.21.6 for int, float, bool, dict and list).
The Python type name ansible-core 2.19 reports for ANY non-string
value in an "'X' object has no attribute ..." crash - scalars (see
#python_scalar_type_name) plus the container/None shapes a YAML list
member or name: value can carry (live-verified vs 2.19.11: dict and
list members report _AnsibleTaggedDict/_AnsibleTaggedList, a nil one
plain 'NoneType').
PyYAML resolves an unquoted date-like YAML scalar the way YAML 1.1's
core schema says (X-GitHub-Api-Version: 2026-03-10 arrives as a real
Python datetime.date, round 5460281), and every place Python then turns
one into text - a templated render, an HTTP header value, a JSON dump -
produces str(date)/str(datetime): "2026-03-10" for a date-only scalar,
"%Y-%m-%d %H:%M:%S" (microseconds only when nonzero, "+HH:MM" only when
tz-aware) for a full timestamp. Crystal's YAML parser resolves the same
scalars to a Time, whose own to_json/to_s render ISO-Z forms
("2026-03-10T00:00:00Z") Python never produces - the exact divergence
that made krikri's GitHub API request fail HTTP 400 where real
ansible-playbook succeeded.
A date-only scalar and a naive midnight datetime both arrive here as a
zero-offset UTC-midnight Time and are indistinguishable after the parse,
so the date-only form is chosen for that shape (the overwhelmingly
common YAML usage; a literal 2026-03-10 00:00:00 is vanishingly rare
and only its seconds text diverges).
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). 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.
One param value's restoration: a whole-value marked literal goes back to the JSON value it encoded (an int/float/bool keeps its type, which is the whole point - a JSON number no longer looks like a string to as_i64?/as_bool?), and a comma-joined list's marked members go back to their own plain text. Anything unmarked is returned untouched.
The plugin-side view of BasePlugin#initialize's param parse, for the
callers that read the RAW config JSON rather than the demoted
@params hash - the FactsGatherer behind setup:/facts: does
exactly that, and used to see a gather_timeout: 75 written
unquoted in the playbook as the literal STRING
"nonstring:75" (the parser's marker rides the strings-only
wire), so a type-specific read failed the module with "argument
'gather_timeout' is of type str and we were unable to convert to
int" where Ansible - which converts the native int - happily
gathers. Restores each marked literal to the JSON value it stood
for, exactly as the demotion sites above do, so a reader sees the
same numbers/lists/strings the playbook actually wrote.
A module result's "failed" flag read the way Ansible's Python truthiness reads it: the wire protocol normally carries a JSON bool, but 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.
Runs a short-lived controller-side helper process, feeding stdin and capturing stdout/stderr, with the same spawn classification as LocalExecutor's #704 rule: only a failure BEFORE the process exists raises Krikri::SpawnError (the simulated-failure hook included); a File::Error - the executable vanished or is bad - returns nil; and anything raised AFTER the process ran keeps the caller's pre-#705 verdict, nil (a wrapper that ran is not a spawn failure). stdin is written in a fiber concurrently with draining stdout/stderr, so a chatty child cannot deadlock a payload larger than the pipe buffer against a full pipe.
"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 Ansible --version.
Test-only spawn-failure hook for the controller-side Python runners
(issue #705): KRIKRI_TEST_FORCE_PYTHON_SPAWN_FAILURE names a counter
file; while that file exists with a positive integer, each python3
spawn attempt consumes one count and fails with a simulated
environment-level error - the same failure class LocalExecutor's
KRIKRI_TEST_FORCE_SPAWN_FAILURE hook raises - letting a spec force
exactly N transient spawn failures before the real interpreter runs.
Read from process ENV like LocalExecutor's hook: the runners spawn
in-process with no env: parameter to carry it, so specs using it
declare exclusive!.
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) - 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.
Whether expr references any variable whose stored value is itself
template text - a cheap syntactic pre-filter for the strict probe's
second trigger. The engine's miss signal does not fire when a
demoted Undefined flows through ~/arithmetic (the resolver hands
the engine its own Undefined without the context's miss counter), so
a chained failing value (badvar: "{{ undefined_deep }}" consumed by
"{{ 'a' ~ badvar ~ 'b' }}") must be probed even on a clean signal:
the probe's own strict evaluation is what raises, and a tolerated
shape (badvar | default('x'), live-verified vs 2.19.11) probes
clean and costs one extra evaluation.
The full strict-undefined error message for a failed lookup: a dict-subscript miss on a resolvable chain gets Ansible's attribute-error wording, everything else the classic "'x' is undefined".
The strict-undefined PROBE for missing-attribute access on NATIVE
dict/list/scalar values (ansible-core 2.19 StrictUndefined semantics,
live-verified against 2.19.11): re-evaluates expr ONCE on the
krikri-jinja engine with the strict-undefined environment and returns
Ansible's error message when a defined-but-missing attribute/key read
was actually CONSUMED (comparison operand, interpolation, filter
input/argument, arithmetic operand, boolean coercion, ternary
condition, any is <test> other than defined/undefined) - nil when
the expression only tolerated the miss the way real Ansible does
(is defined/is undefined, | default(...), .get(...), a
short-circuited and/or arm, default(omit) flowing into a task arg).
The caller gates this on a cheap miss SIGNAL (KrikriJinja.miss_count delta across the lenient evaluation - a signal means some lookup produced an undefined value somewhere in the span), so spans that resolve cleanly never pay the second evaluation, and the lenient pipeline's output is untouched whenever the probe declines to raise. A signal is only a hint: the probe's own strict evaluation is the sole authority, with real Jinja's short-circuit and tolerate shapes, so a false-positive signal costs one wasted evaluation and nothing else.
Deliberately declines to raise for shapes whose strict handling is owned elsewhere with (possibly different) real-Ansible wording: an out-of-range list index ("has no element N" / "... no attribute 5" - bracket_index_failure_message's machinery) - those keep the existing behavior rather than risk a wording regression. Also returns nil for every non-undefined engine failure (unknown construct, syntax gap): the probe must never turn an evaluator capability gap into a task failure - the lenient pipeline stays the fallback for those.
Demotes every non-string marker in ONE param value: a whole-value marked literal (either prefix at the very start, its JSON payload consuming the entire rest) back to its plain scalar text, and - since the parser's comma-joined list branch marks non-string MEMBERS in place - marked members inside a comma-joined list back to their own plain text. The comma-join invariant (a scalar JSON payload never contains a comma) keeps the split exact, and a marker occurrence whose payload does not parse as a complete JSON document can only sit in a member position ("\u{E000}nonmember:7,g1"), so the member path takes over.
Executor-side demotion: every param value the parser marked as a non-string YAML literal is stripped back to its plain string form, so the spec checks (and every other executor-side consumer) see exactly the text they always did. Returns the same hash object when nothing is marked (the common case - no copying).
The interpreter's own sys.executable, which is what Ansible's missing_required_lib names (never the bare command name). Ansible's module runs UNDER that interpreter, so the two are the same path; asking the interpreter is just how Ansible's own message gets built.
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). 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 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
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.
Unwraps a marked leaf (its real-Ansible message), nil for any text not marked. Shared with TemplateActionPlugin's engine-scope conversion.
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
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.
Rewrites every BIGINT_SENTINEL_PREFIX-marked string in serialized JSON text to its bare digits: the wire form a module's Python int takes. Used by BasePlugin#run_and_capture (plugin result -> wire) and by the serializers that have no per-node hook (Crystal's own JSON::Any#to_pretty_json, whose quoted-sentinel bytes this post- processes back to unquoted digits).
Shared --version shape for every krikri binary (playbook, ad-hoc CLI, lint): tool name/version, tagline, then the exact Crystal and shard versions compiled in.
Nested types
- Krikri::ActionGroups
- Krikri::ActionPlugin
- Krikri::ActionPluginManager
- Krikri::ActionResult
- Krikri::AddHostActionPlugin
- Krikri::AptDebInstall
- Krikri::AptLocale
- Krikri::AptLockRetry
- Krikri::AptRepositoryCacheRetry
- Krikri::ArgspecValidator
- Krikri::AssertActionPlugin
- Krikri::AsyncJobs
- Krikri::AwsSecretLookup
- Krikri::BasePlugin
- Krikri::BatchScript
- Krikri::BecomeMessages
- Krikri::BecomeMethodError
- Krikri::BigJson
- Krikri::BlockInheritedWhen
- Krikri::BlockNameKeywordError
- Krikri::BoolParamError
- Krikri::CliOptions
- Krikri::CollectionIndex
- Krikri::ConditionalEvaluator
- Krikri::ConflictingActionStatementsError
- Krikri::ConnectionSettings
- Krikri::ControllerPython
- Krikri::ControllingTty
- Krikri::CryptoTimeSpec
- Krikri::CustomStats
- Krikri::DebugActionPlugin
- Krikri::DeprecationHint
- Krikri::DifferentialFuzz
- Krikri::Difflib
- Krikri::EndRoleOutsideRoleError
- Krikri::ErrorBlock
- Krikri::ExtraVarsParser
- Krikri::FactCache
- Krikri::FactsGatherer
- Krikri::FailActionPlugin
- Krikri::FileVarOrigin
- Krikri::FiletreeLookup
- Krikri::FilterPluginError
- Krikri::FirstFoundLookupError
- Krikri::HandlerNotFoundError
- Krikri::HandlerRunner
- Krikri::Host
- Krikri::HostGroup
- Krikri::IncludeDirectiveError
- Krikri::InvalidModeError
- Krikri::InvalidRegisterError
- Krikri::InvalidStrategyError
- Krikri::InvalidTaskAttributeError
- Krikri::Inventory
- Krikri::InventoryParser
- Krikri::InventoryPatterns
- Krikri::InventoryPlugins
- Krikri::InvocationInjection
- Krikri::IpAddrCore
- Krikri::JMESPath
- Krikri::JinjaDateTime
- Krikri::JinjaHostContext
- Krikri::KeywordBooleanError
- Krikri::KeywordIntError
- Krikri::KrikriJinjaFilters
- Krikri::KrikriJinjaLookups
- Krikri::Lint
- Krikri::LocalExecutor
- Krikri::LocalPluginDaemon
- Krikri::LookupPlugins
- Krikri::LoopResolver
- Krikri::MetaActionTypeError
- Krikri::NeedleLookup
- Krikri::Oneline
- Krikri::OutputBanner
- Krikri::OutputRouting
- Krikri::OwnerLookupFailure
- Krikri::Passwords
- Krikri::PauseActionPlugin
- Krikri::PipeLookupError
- Krikri::Play
- Krikri::Playbook
- Krikri::PlaybookNotListError
- Krikri::PlaybookParser
- Krikri::PluginArchUnsupportedError
- Krikri::PluginDaemon
- Krikri::PluginHelpers
- Krikri::PluginManager
- Krikri::PluginResult
- Krikri::PlusMinusOperandError
- Krikri::ProfileTasks
- Krikri::PyDump
- Krikri::PyRandom
- Krikri::PySet
- Krikri::PythonActionRunner
- Krikri::PythonCodecs
- Krikri::PythonFilterRunner
- Krikri::PythonLiteral
- Krikri::PythonLookupRunner
- Krikri::PythonModuleRunner
- Krikri::PythonPattern
- Krikri::PythonTestRunner
- Krikri::RemovedActionError
- Krikri::RemovedModuleError
- Krikri::RenderExpressionError
- Krikri::ResultDisplay
- Krikri::ResultShape
- Krikri::RoleBoolKeywords
- Krikri::RoleDependencyCycleError
- Krikri::RoleLoader
- Krikri::RoleNotFoundError
- Krikri::RoleTasksFromFileError
- Krikri::RunOptions
- Krikri::RuntimeVarOrigin
- Krikri::SSHManager
- Krikri::SdkImportGateError
- Krikri::SerialBatches
- Krikri::SetFactActionPlugin
- Krikri::ShardYmlPin
- Krikri::Shell
- Krikri::SpawnError
- Krikri::StartAtFilter
- Krikri::StaticImportMissingFileError
- Krikri::StaticImportRoleUndefinedError
- Krikri::StaticImportUndefinedError
- Krikri::SynchronizeActionPlugin
- Krikri::SynchronizeRsync
- Krikri::SystemdCliFlags
- Krikri::SystemdEnabledState
- Krikri::SystemdUnitFound
- Krikri::TagFilter
- Krikri::Task
- Krikri::TaskBatcher
- Krikri::TaskDebugger
- Krikri::TaskExecutor
- Krikri::TaskLister
- Krikri::TemplateActionPlugin
- Krikri::TemplateDoTagScanner
- Krikri::TemplateSearchPathLoader
- Krikri::TemplatedOriginSpan
- Krikri::TestPluginError
- Krikri::TextVarOrigin
- Krikri::TimingProfile
- Krikri::UndefinedVariableError
- Krikri::UnresolvedModuleError
- Krikri::UnsafeValues
- Krikri::ValueOriginChain
- Krikri::VarOrigin
- Krikri::VarSubstitutor
- Krikri::VariableSubstitutor
- Krikri::VarsPrompt
- Krikri::Vault
- Krikri::VaultCli
- Krikri::VaultFileError
- Krikri::X509CertInfo
- Krikri::YamlSourceMap
- Krikri::YamlSyntaxError