Krikri::PluginManager
Plugin Manager - Handles plugin execution locally or remotely For remote hosts, uploads plugin binary and executes it there
Constants
fetch: pulls a file FROM the target TO the controller - the reverse direction of every other plugin, which read/write whichever filesystem they end up running on. The normal local/remote dispatch below uploads a plugin binary and executes it directly ON a remote target (forcing ansible_connection=local into its config so its own internal filesystem calls correctly mean "the target's filesystem"); that's exactly backwards for fetch, which needs to run on the controller and pull via BasePlugin#remote_download (SSHManager, using the original, non-overridden host/vars) instead. wait_for_connection: real Ansible's own module retries the actual CONNECTION ATTEMPT from the controller until it succeeds or timeout: is exceeded - it exists specifically for the "just rebooted the target" idiom, where the connection is DOWN when the task starts. Dispatched the normal remote way (upload the plugin binary, then SSH-exec it ON the target), this whole module can never do its actual job: by the time its own process is running at all, the very connection it's supposed to be waiting for has already succeeded (needed just to get the binary there) - GROG. reboot's own "Reboot host" -> "Wait for host" sequence hit exactly this, failing with a generic "Plugin execution failed on remote" the instant the reboot actually took the SSH connection down. Controller-only (like fetch above) so it can retry the connection itself via BasePlugin#remote_exec/SSHManager, the same mechanism real Ansible's own connection plugin retry uses.
Empty, and deliberately kept rather than deleted: it is the one place a module can be pulled back off the daemon path if one ever needs to be.
facts (gather_facts) used to be the sole entry, because it was
the one real remote module missing from the fat plugin binary's
dispatch table - a daemon request for it would only have hit the
generated dispatcher's "unknown plugin" fallback. That exclusion
cost a fresh ssh fork and remote process spawn for the one task
that runs on every host in every play, so
Perf item 2 put facts INTO the fat
binary (build.sh's FAT_EXTRA_MODULES) and removed it from here.
debug/assert/fail/set_fact/pause need no entry here
at all: ActionPluginManager.skips_module_dispatch? already keeps
every one of them from ever reaching #execute_remote_plugin in the
first place (verified directly - they're controller-side action
plugins, no target-side module dispatch ever happens for them,
remote or local).
become: used to be excluded here unconditionally - the original
landing's documented scope cut. Perf
item 1 closed it: nearly every real Galaxy role runs become: true, so excluding it meant the single biggest measured
optimization in the project was switched off for the
overwhelming majority of tasks in the overwhelming majority of
real playbooks. A daemon is still one resident process running as
one fixed user - that part was never the problem - so a become:
task simply gets its OWN daemon, spawned through the same sudo -n -u <become_user> -- wrapper #remote_plugin_target already
builds for the one-shot path, keyed on become_user in
SSHManager's own daemon table.
Perf item 6a - the safe half of "an agent that outlives the run".
A warm run's bootstrap is two round trips before any real work:
one exec_script listing the remote .md5 files to decide what
needs uploading, and one fact gather. Measured with item 0's
profile across ten real roles, that bootstrap is 4.9% of a
15-second run but 59-74% of a sub-second one - it dominates
exactly the small roles that items 1-3 cannot help, because they
have too few tasks to batch.
This removes the first of those two. The remote binaries already persist between runs (the staging dirs are under /var/tmp), so the listing round trip is pure re-verification of something that was true when we last looked. Recording the verified md5 set on the CONTROLLER lets a later run skip it.
Deliberately NOT the systemd unit the plan describes: that would install a persistent service on every managed host, which is a real operational and security imposition that no amount of speed justifies doing by default. Fact caching, the other half of the plan's item 6, is not here either - see #facts_cacheable? for the measurement showing it cannot be made airtight.
Safety rests entirely on the recovery path: if the remote binary
turns out to be missing after all (a /var/tmp sweep, a rebuilt
host, a tmp.mount remount), #recover_missing_plugin! invalidates
this state and re-uploads. Without that this would be a
correctness bug, not an optimization - see its own comment.
Plugins that actually read the "vars" field of their config JSON -
everything else only ever reads the 3 connection keys BasePlugin
itself pulls out (ansible_connection/ansible_host/
ansible_ssh_private_key_file), confirmed by
grep -l '@vars\[' plugins/*.cr -> debug.cr and assert.cr only
(debug: msg: "{{ var }}" needs live lookup against the full vars
context; assert: that: conditions evaluate against it the same
way when: does). Everyone else gets a pruned config instead of
the full vars_context - see build_plugin_config's use of this.
Unreachable on the normal execution path, and kept only as the
explicit statement of the rule: debug/assert are the only
modules that read the vars context inside the plugin process, and
both are controller-side action plugins
(ActionPluginManager::CONTROLLER_ONLY_MODULES), so neither ever
reaches a module dispatch. Removing this would make
build_plugin_config's pruning look unconditional and invite
someone to "simplify" it into always sending the full context.
Class methods
Pre-upload all plugins needed for a playbook to all remote hosts This is called once before task execution begins Much more efficient than uploading plugins one at a time during execution
ANSIBLE_BECOME_ALLOW_SAME_USER - real Ansible's own config knob,
default false, forcing the escalation even when it is a no-op.
Whether a become: task actually needs an escalation command.
Real Ansible does NOT wrap a command in sudo just because become: true was given - _low_level_execute_command gates it on
C.BECOME_ALLOW_SAME_USER or (buser != ruser or not any((ruser, buser))), so escalating to the user you already are is skipped
entirely. Since become_user defaults to root and most inventories
connect as root, that means the overwhelmingly common become: true task runs with NO sudo at all under real Ansible.
This engine wrapped every such task in sudo -n -u root --, which
works only if sudo happens to be installed: on a minimal image
without it (a container, a hardened or slimmed cloud image) EVERY
become: true task failed with "sudo: command not found" where
real Ansible succeeded. Found while reproducing an unrelated recap
delta on a plain debian:trixie container, and confirmed with a
two-task minimal repro against real ansible-core 2.19.
Whether plugin_name must run on the controller regardless of the
target host. Exposed so callers building a config String up front
(see the String entry point below) can make the same local/remote
decision this class makes internally, and therefore know whether
the payload needs ansible_connection=local injected.
Resolves a plugin's remote path (remote_plugin_dir/<simple name>)
and, if become, wraps it in sudo -n -u <become_user> --.
Shared by the normal one-task-at-a-time remote path above and by
TaskExecutor's batch script generation (batching, on by default),
so both ways of reaching a remote plugin resolve the exact same
target string. become_user is expected to
already have passed valid_become_user? - this only formats, it
doesn't validate (the SSH path interpolates the result directly
into a shell command, unlike the local/args-array path, so
validation happens once, at the call site, before this is ever
invoked with untrusted input).
Uploads plugin_name to host if this run hasn't already put it
there. Pre-upload (batch_upload_plugins_for_playbook) covers
everything statically reachable from the playbook, but it cannot
see inside a runtime include_tasks:/include_role: - those name
a file that may itself be templated, so their contents are unknown
until they actually run. A module used only inside one therefore
reached the target with no binary present and failed with a bare
"staging_dir/set_fact: No such file or directory",
which is both confusing and, for a role like dev-sec's
os_hardening, fatal on the first included task.
Calling this before every remote execution makes pre-upload a pure optimization rather than a correctness requirement: it is a hash lookup when the plugin is already there (the overwhelmingly common case, since pre-upload got it), and costs the upload round trips only the first time an unforeseen module is actually needed.
String-config entry point - the one the hot paths use.
config must already be the exact JSON the plugin will receive on
stdin, including ansible_connection=local inside "vars" when
remote_execution? is true (TaskExecutor builds it that way, the
same way #prepare_batch_step already did for the batch path).
become/become_user must already be resolved and validated by
the caller via valid_become_user?.
The point of taking a String: the whole variable context used to make three full passes on this path - serialize in build_plugin_config, parse in execute_task_once, then dup and re-serialize here - purely so one key could be injected after the fact. The batch path never did that; now neither does this one.
JSON::Any entry point: resolves become:/become_user: back out of the
config itself and injects ansible_connection for the remote case.
Retained for __async_run, which reads an already-serialized config
back from a job file and has no separate become context to pass.
Execute plugin remotely (uploads if needed, then runs).
config is already the exact payload to send - see the String
entry point above for who is responsible for injecting
ansible_connection=local into it.
Transport failures never propagate as process-killing exceptions. The lazy-upload path below (and its missing-binary retry) shells out to scp/rsync, which raises when the host cannot be reached - previously that exception escaped uncaught and killed the whole run with a stack trace. A host that goes unreachable MID-play (network drops between tasks) now yields a per-task UNREACHABLE result - the same shape the pre-run batch-upload pass produces - exactly like real Ansible, which never ends a run for one bad host. Non-transport exceptions (a missing local binary, a staging-dir safety refusal, anything else) still propagate: those are engine bugs, not unreachability, and must stay loud.
Written once at the end of a run (krikri-playbook.cr), not on every mutation - this is a cache, and losing the last run's entry only costs one round trip next time.
Get the actual hostname to connect to (checks ansible_host variable). Public - TaskExecutor's batch path needs this to know which host to run the batch script against, same as the non-batched remote path above.
Same interpretation execute_remote_plugin already applies to a
single task's raw SSH result - exit code wins first (a nonzero exit
means the plugin crashed or couldn't run at all, so its stdout, if
any, isn't trustworthy JSON), otherwise the plugin's own stdout is
parsed as its authoritative result. Exposed so TaskExecutor's batch
path (item 3) interprets each step's captured result exactly the
same way, instead of re-deriving this logic.
Drops everything known about a host, so the next check verifies the slow way. Called from the missing-binary recovery path.
Check if connection is local. Public - TaskExecutor's batch path (item 3) needs the same local/remote decision execute_plugin already makes internally, before deciding whether batching even applies to a given host.
Does this failure look like "the plugin binary is not on the target"? Deliberately narrow: a real module failing normally returns parseable JSON, so this only fires on the shell's own "command not found"/"No such file" shapes. Spec seams. The real methods stay private because nothing outside this class should be making these decisions; these exist so the two properties the safety argument rests on can be pinned.
Host-aware variant for TaskExecutor's batch path: the staging dir is per-connecting-user now, so the check needs the same user the batch was uploaded/executed with.
Controller-side result normalization - real Ansible's own
task_executor._execute_internal pass, applied to every module
result before register:/when:/display ever see it: a module wire
result only carries failed when the module called fail_json (a
successful exit_json never emits the key), so the controller
backfills it - failed: true when a nonzero rc says so, false
otherwise - and backfills changed: false when the module
omitted it. (Live-verified against 2.19.4: a registered command
result carries "failed": false even though command.py's
exit_json never passes it; the ad-hoc JSON dump still omits it
because the CALLBACK pipeline strips failed/skipped from the
display copy - ResultDisplay.adhoc_result_json mirrors that
strip.) Applied where module wire results are parsed: the local
spawn, the remote one-shot/batch interpretation, and the daemon
response (via execute_remote_plugin's transport wrap).
Batch-path counterpart: one invalidation, one upload covering every module the group needs.
Whether a plugin invocation actually goes over SSH: everything that isn't controller-only and isn't a local connection.
Where plugin binaries live on the remote host. Deliberately
/var/tmp, not /tmp: several real hardening roles (konstruktoid-
hardening's "Start tmp.mount" task among them) mount a fresh, empty
systemd tmpfs over /tmp mid-play as part of hardening it - which
silently wipes out every plugin binary already uploaded there,
breaking every subsequent task and handler with "command not
found" even though ensure_uploaded's cache still (correctly, as
far as it knows) believes they're present. Those same roles
generally redirect their own $TMPDIR/$TMP to /var/tmp for
exactly this reason (konstruktoid-hardening's tmp.mount task does),
which is survivable across a tmpfs remount the way /tmp isn't.
The directory is PER-CONNECTING-USER (.krikri-playbook-<user>-<hash>),
not one shared world-traversable path: /var/tmp is sticky-world, so
without the per-user component any local user could pre-create the
(entirely predictable) directory before krikri's first run and plant
binaries later executed via sudo -n - the CVE-2014-3498 class that
pushed real Ansible to per-user temp dirs. The bootstrap in
#upload_plugins_to_host additionally VERIFIES ownership (and
symlink-freedom) of every component before use and fails the run
loudly on a mismatch, and the directories themselves are mode 0711:
traversable (a become_user: exec needs to reach the binary through
them) but never listable or writable by anyone else.
Strips the FQCN collection prefix a module name may carry
(ansible.builtin.debug -> debug) down to the bare name used to
look up both the plugin binary and the upload/full-vars predicates
below. Shared rather than inlined per call site (was duplicated
once already, in collect_required_plugins, before this extraction).
Same become_user allow-list resolve_become already enforces,
exposed for TaskExecutor's batch path (item 3), which resolves
become/become_user per task itself (from Task#become/#become_user,
already substituted the same way the non-batched path does) rather
than going through the config-JSON-embedded fields resolve_become
reads. One shared regex, two call sites, no risk of the two
diverging.