Krikri::PythonModuleRunner
Constants
ansible-core's ansible/module_utils/ansible_release.py is the
running release's own metadata, and modules read __version__ out
of it (a module built on amazon.aws' module_utils, which import it,
failed with "No module named 'ansible.module_utils.ansible_release'"
under the shim bundle - issue #843 - while ansible-playbook, whose
AnsiballZ payload bundles the file, ran it). The version is the
ansible-core release this engine reproduces (ANSIBLE_TARGET_VERSION,
the same value the ansible_version magic variable reports) rather
than a second literal, so it tracks a compatibility-target bump.
The ansible/module_utils bundle a new-style module's
from ansible.module_utils.basic import AnsibleModule import needs.
Ansible never relies on ansible-core being installed on the
target - the AnsiballZ wrapper bundles module_utils INTO the module
payload it ships - so every new-style role-private module runs on
any target with a python3. This engine runs the raw module script
instead, so on a target with no ansible-core installed the import
died with ModuleNotFoundError and the module printed no result JSON
("MODULE FAILURE") - hard-FAILING the task where Ansible ran
it successfully (found via newrelic.newrelic-infra's own
"Setup agent config *NIX" task: the role ships its own
library/merge_yaml.py, which took the py_module path and failed on
every fresh target while ansible-playbook succeeded). The shim
covers what corpus role-private modules actually use - params
parsing/validation against argument_spec (with type coercion,
defaults, aliases, required), check_mode, exit_json/fail_json,
warn/run_command, log, get_bin_path - plus the
ansible/module_utils/_text helpers modules import directly; not
the whole real basic.py surface; anything beyond that fails
exactly as before this shim existed.
Ansible's own new-style detection (module_common.py
_find_module_utils): a module importing ansible.module_utils - or
one carrying the legacy INCLUDE marker, which the modularizer
rewrites into from ansible.module_utils.basic import * - gets
its args as a JSON dict (via the ANSIBLE_MODULE_ARGS env var its
basic.py reads when no argv is given); everything else is
old-style key=value argv.
NEW_STYLE_PYTHON_MODULE_RE in the same file ALSO counts a
collection module_utils import (from|import ansible_collections.<ns>.<coll>.plugins.module_utils...) as
new-style - such a module (a custom library/ module built on
amazon.aws' AnsibleAWSModule, which wraps AnsibleModule) gets its
args on stdin JSON like any module_utils importer, because ansible
bundles the collection's module_utils into the AnsiballZ payload.
Missing this form classified the module old-style, and the shim
AnsibleModule (argv is never read) then saw no args at all:
"missing required arguments: tags" for a dict arg the task passed
(issue #955).
ansible.module_utils.parsing.convert_bool - the BOOLEANS_TRUE/ BOOLEANS_FALSE tables and boolean() converter, which custom modules import directly when they parse a loose boolean themselves (the same tables this engine's own argspec validator emulates). Surveyed beside issue #779 as the other small pure module_utils file a role-private module most often imports that the shim bundle lacked; adopted from ansible-core's own file minus the _no_six deprecation hook (a py2-compat remnant no py3 module path exercises).
In modern ansible-core the real text-conversion implementation
moved from the Ansible module to
the Ansible module - _text remains
only as a deprecated re-export shim - and newer roles import the
new path directly. bodsch.users' own library/multi_users.py does
exactly that (from ansible.module_utils.common.text.converters import to_native, round 813275) and died with
ModuleNotFoundError: No module named 'ansible.module_utils.common'
on a target without ansible-core, while Ansible - which ships
both paths - succeeded on the same task. So this file ships
alongside _text.py, self-contained rather than importing from it,
since role code may import either path (or both) and Ansible
keeps both importable. Same to_bytes/to_text/to_native surface and
the same surrogateescape mapping of the Ansible error-handler
spellings as the _text shim above.
The dict-conversion helpers ansible-core ships at ansible/module_utils/common/dict_transformations - the camel/snake dict converters most amazon.aws-style modules run their facts through. A custom module importing it at top level (issue #779) died with ModuleNotFoundError: No module named 'ansible.module_utils.common.dict_transformations' on a target where the shim bundle stood in for ansible-core, while Ansible - which bundles every module_utils import into the AnsiballZ payload - ran the module fine. Adopted from ansible-core's own file (same GPL-3.0 family) so the conversion semantics - the ARNs-pluralization case in _camel_to_snake, the reversible flag, recursive_diff's TypeError shape - match exactly rather than approximately.
ansible-core vendors the python-distro project inside module_utils
(ansible/module_utils/distro/) and ships it in every module
payload, so role-private modules detect the target distribution
without python3-distro being installed on it. This engine runs the
raw module script instead, so on a target without ansible-core the
from ansible.module_utils import distro import died with
"cannot import name 'distro' from 'ansible.module_utils'" and the
module printed no result JSON - hard-FAILING the task where
ansible ran it successfully (found via bodsch.php: both of its own
library/php_version.py and library/php_modules.py import distro and
gate their package-manager search on the returned distribution id,
so the role died at its very first custom-module task). The shim
covers python-distro's documented surface over the same files
upstream python-distro reads - /etc/os-release, then
/usr/lib/os-release, then /etc/lsb-release - including the
linux_distribution(full_distribution_name=...) compatibility
tuple both bodsch modules call - the shim always wins over the
target's own ansible-core now (issue #915), so this surface is
what every target sees.
The legacy module-boilerplate marker: a module leaving
#<<INCLUDE_ANSIBLE_MODULE_COMMON>> in its source expects the
controller to splice the module_utils bundle in at that spot.
Real ansible-core's modularizer (executor/module_common.py
_find_module_utils) replaces the marker with
from ansible.module_utils.basic import * and then classifies
the module as NEW-style - so kunik.deploy-metadata's
library/deployment_facts (which calls AnsibleModule(argument_spec
= dict()) without ever importing it) ran fine under
ansible-playbook while this engine - shipping the raw source -
died with "name 'AnsibleModule' is not defined" (issue #543).
Finds a role-private module source for module_name, or nil.
Search roots mirror Ansible's two most-used locations: the
current role's own library/ and the playbook-adjacent
library/. First match wins (Ansible's own nearest-first
order).
Takes the role's ROOT directory directly (task.role_path, always
set - see role_loader.cr's task.role_path = role_dir), not
role_files_dir (only set when the role actually ships a files/
subdirectory - existing_dir returns nil otherwise). A role with
no files/ dir at all (linux-system-roles.storage/.logging/
.timesync, none of them ship one) could never resolve its own
library/*.py modules through the old files/-derived path, so
sr_fingerprint/blivet/timesync_provider fell straight back
to "unavailable modules" - the exact scope cut 0.9.819 was
supposed to have already closed for role-private modules. Found
re-testing linux-system-roles.storage/logging/timesync.
Extensions Ansible's legacy module finder never matches a
module name against (ansible-core's MODULE_IGNORE_EXTS). A role
shipping a DOCUMENTATION stub next to its script -
linux-system-roles.timesync's library/timesync_provider.yml beside
timesync_provider.sh - must resolve to the script, never the stub.
ansible.module_utils.six - ansible-core's own vendored copy of the
six compatibility library, bundled into every AnsiballZ module
payload. Custom modules and collections import it directly
(from ansible.module_utils.six import string_types, and amazon.aws'
own module_utils do the same, issue #903), so it resolves under
ansible-playbook on a target that has no ansible-core installed at
all. This bundle is written both for an ansible-core-less target and
unconditionally whenever a role ships its own module_utils tree - and
in that second case it SHADOWS an installed ansible-core (the module
file's own directory is sys.path[0]), which made the import fail
exactly where ansible-playbook succeeded. six ships as a PACKAGE
(six/__init__.py), the same shape ansible-core uses, because
modules also import its six.moves submodules
(from ansible.module_utils.six.moves import ...,
...six.moves.urllib.parse). Python-3 targets are all this shim has
to cover - the py2 branches of six 1.16 are dead code there - but
the public surface (type aliases, iterator/view helpers, moves,
with_metaclass/add_metaclass, ensure_*, reraise/raise_from) is
complete so a module behaves exactly as it does against the real one.
The ansible/module_utils/_text helpers a new-style module can
import DIRECTLY alongside basic (nbde_server_tang via
linux-system-roles.nbde_server does
from ansible.module_utils._text import to_native) - without
this file the import dies with ModuleNotFoundError at module top
level, before AnsibleModule is ever constructed. Only the
to_bytes/to_text/to_native surface modules actually import; the
error-handler spellings real _text.py maps (surrogate_or_strict
et al) become surrogateescape on py3 like the real code.
ansible.module_utils.urls - role-private modules fetch URLs with
open_url (kazoo-ansible.common's own library/publicip.py does
from ansible.module_utils.urls import * and then open_url, issue
#826: on a target without ansible-core the import died with
"No module named 'ansible.module_utils.urls'", hard-failing the
task where ansible - whose AnsiballZ payload bundles the real
urls.py - ran the same module past the import, into its own
bytes-JSON failure). Only the open_url surface such modules call
is covered, over python's own urllib: the real call signature and
defaults (timeout=10, validate_certs=True), a .read() that returns
bytes like the real response, and 'urllib2' redirect semantics
that are urllib's own. Client certificates, cookies and
unix-socket transports stay uncovered - no shim-covered module
uses them, and a wrong guess would mask real behavior.
ansible.module_utils.common.warnings - the global warning and deprecation accumulators real basic.py merges into the module result (kwargs['warnings'] = get_warnings()). Modules importing warn or deprecate directly (issue #908: a custom module importing warn, the same import amazon.aws' AnsibleAWSModule tree uses) died with ModuleNotFoundError under the shim bundle, which lacked the file, while ansible-playbook's AnsiballZ payload ran it. The shim carries the same warn/deprecate/get_warning_messages/get_deprecation_messages/ get_warnings/get_deprecations/error_as_warning surface and tracks messages as plain strings so the shim's basic.py pulls them into result['warnings'] the way real does.
Class methods
Directories to put on a custom module's PYTHONPATH so an
ansible_collections.* import resolves the way real ansible's
module payload provides it. Real ansible never needs the
collections importable on the target: its AnsiballZ wrapper
bundles every ansible.module_utils.* AND
ansible_collections.<ns>.<coll>.plugins.module_utils.* import INTO
the zipapp it ships (ansible-core remaps collection module_utils
into the payload), so a module doing from ansible_collections.amazon.aws.plugins.module_utils.retries import AWSRetry runs on a python that cannot import the
collection at all. This runner executes the raw script with the
target's python instead, so when that python is not the one the
collections were installed into (Homebrew's isolated Cellar
interpreter, issue #692; an ansible-galaxy user install under
~/.ansible/collections, which no python has on sys.path) the
import died with ModuleNotFoundError where ansible-playbook ran
the module fine. The roots are the same ones CollectionIndex
already enumerates for module-name resolution (ANSIBLE_COLLECTIONS_PATH,
the default install locations, the ansible-playbook shebang's
virtualenv site-packages). A root may name either a collections
ROOT (a directory CONTAINING an ansible_collections/ tree) or a
path pointing directly AT an ansible_collections dir - Python
needs the PARENT of ansible_collections on sys.path either way.
Roots whose tree is absent are dropped (CollectionIndex keeps the
two default locations even when they don't exist); roots is a
test seam so a spec can pin the mapping without touching the
process-wide collection env.
Writes the shim bundle above into work_dir as a ansible/module_utils package tree. The module script itself sits in work_dir too, and Python puts the script's own directory first on sys.path - so the shim shadows any installed ansible-core exactly when it's written, and the import resolves to it instead of dying with ModuleNotFoundError. Written for EVERY new-style python module run (see py_module.cr): shadowing the target's own ansible-core unconditionally is what real's AnsiballZ payload does (it bundles the controller's module_utils), and it keeps the invocation echo independent of which ansible-core version the target happens to carry (issue #915).
Instance methods
The old-style key=value argv line (one entry per param).
The module's argument dict: the substituted task params (already
stringified by the parser) re-typed as JSON where they parse -
the parser JSON-encodes list/dict-valued params verbatim, so
"['a','b']" becomes a real array for the module, the way
Ansible passes typed args. Plus Ansible's own reserved
_ansible_* keys a new-style module's AnsibleModule reads.
A role (or the playbook tree) shipping its OWN custom module_utils
packages - e.g. linux-system-roles.storage's the Ansible module
beside its library/blivet.py, which does from ansible.module_utils.storage_lsr.argument_validator import validate_parameters. Ansible's AnsiballZ wrapper bundles the
role's own module_utils tree into the zipapp alongside the module
source so the import resolves; this runner previously uploaded only
the ONE module source file, so the import died with a plain Python
ModuleNotFoundError. The convention mirrors find_source's own two
search roots: <role_root>/module_utils/ (sibling of the role's
library/) and the playbook-adjacent module_utils/, nearest-first
- a relative path found under an earlier root shadows the same
relative path under a later one. Collections are NOT part of this
surface: a collection module importing its own utils uses the
ansible_collections.<ns>.<coll>.plugins.module_utils.*namespace (notansible.module_utils.*), and third-party collection modules remain the unchanged scope cut above.
Returns RELATIVE path (under module_utils/) -> absolute source path, empty when neither root exists (the common case - zero overhead, the caller sends no extra payload at all). Only regular files travel; .pyc/.pyo caches are skipped the way MODULE_IGNORE_EXTS skips junk in find_source.
Real basic.py's fail_json(exception=...) wraps the exception in an ErrorSummary event chain whose dumped shape krikri keeps dropping (issue #557: a plain-string traceback is hidden the same way), yet real ansible composes the "[ERROR] <msg>: <cause>" header from the chain's cause messages (issue #384: run_command's missing-binary Popen OSError). Extracts those cause messages, deepest chain included; nil when the result carries no ErrorSummary object.
Between the role's own library/ and the playbook's sit the library/ dirs of every role loaded so far: ansible adds a role's library/ to the module path at Role.load and keeps it for the rest of the run, so a role pulled in through include_role: still sees its ancestors' custom modules.
Ansible refuses to ship a module payload whose first line is
not a #! interpreter line: ActionBase._execute_module's
if not module_shebang and module_style != 'binary' guard raises
"module (name) is missing interpreter line" as a controller-side
AnsibleError (failed task, nothing executed on the target).
New-style modules are exempt in practice - the AnsiballZ wrapper
embeds its own shebang. This engine runs the raw script with the
target's python3 instead of honoring the file's own line, so
without this guard a shebangless old-style module - which
ansible-playbook FAILS - silently succeeded (found by the
py_module podman-diff edge cases: only the new-style fixture
survived the real side without a shebang).
The modularizer step itself: swap the legacy marker for the import the marker stood for, exactly as _find_module_utils does before anything else looks at the source. A module without the marker comes back untouched.
Parses the module's stdout into its result JSON: Ansible modules print a JSON object (pretty or single-line), possibly preceded by other output (warnings, prints) that Ansible also strips. Walks backwards from the end for the first offset where a JSON object parse succeeds.
Short module name for the FQCN spellings a task can write
(sr_fingerprint, linux_system_roles.sr_fingerprint, ...).
A parser comma-joined list param carrying NON_STRING_MEMBER_PREFIX-
marked members (see param_sentinels.cr) reconstructs to a real JSON
array here: real Ansible hands a module a YAML list arg as a list
whose members keep their types (live-verified vs 2.19.11 -
r: ["{{ x }}", 1] with an int x reaches a role-local module as
[5, 1], r_type "list"), where this engine used to ship the
comma-joined wire TEXT with the sentinel still inside it
(issue #696: the module saw the string "5,\u{E000}nonmember:1").
Marked members decode to their native YAML scalar; unmarked ones
re-infer from text exactly the way typed_value re-infers a whole
value - the engine's established py-module convention. nil when the
value carries no member marker at all: a plain string arg (a list
of only-string members is indistinguishable from one on the
strings-only wire and stays a string, as before) or a whole-value
JSON container, both unchanged.
Recovers the message of a module's uncaught exception from the raw
run's stderr. Real Ansible wraps every module run in an Exception
handler (AnsiballZ's loader) and reports the exception as a clean
module failure - but only Exception: SystemExit,
KeyboardInterrupt and GeneratorExit pass through unhandled there,
so they return nil here too. "" means the exception had no
message (bare raise SomeError(), which Ansible spells
"Module failed."), nil means no parseable traceback at all. An
explicit cause chain (raise X from Y) joins each direct cause's
message with ": ", outer first, deepest last - the way Ansible's
msg renders the chain (issue #523); implicit context ("During
handling of the above exception, another exception occurred")
contributes nothing, keeping only the final traceback's message.