class

Krikri::PluginManager

Inherits Reference < Object

Plugin Manager - Handles plugin execution locally or remotely For remote hosts, uploads plugin binary and executes it there

Constants

CONTROLLER_ONLY_PLUGINS = {"fetch", "ansible.builtin.fetch", "wait_for_connection", "ansible.builtin.wait_for_connection"}

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.

DAEMON_INELIGIBLE_PLUGINS = Set(String).new

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.

HOST_STATE_TTL = 24.hours

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.

NEEDS_FULL_VARS = Set {"debug", "assert"}

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

batch_upload_plugins_for_playbook(playbook : Playbook, inventory : Inventory, forks : Int32 = 5) : Array(String)

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

Source
become_allow_same_user?

ANSIBLE_BECOME_ALLOW_SAME_USER - real Ansible's own config knob, default false, forcing the escalation even when it is a no-op.

Source
become_needed?(become : Bool, become_user : String | Nil, remote_user : String | Nil) : Bool

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.

Source
clear_cache

Clear uploaded plugins cache (for testing)

Source
controller_only?(plugin_name : String) : Bool

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.

Source
daemon_eligible?(plugin_name : String, become : Bool) : Bool
Source
daemon_enabled=(value : Bool)
Source
daemon_enabled?
Source
ensure_uploaded(host : Host, plugin_name : String, vars : Hash(String, JSON::Any)) : Nil

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.

Source
execute_plugin(plugin_name : String, config : String, host : Host, vars : Hash(String, JSON::Any), become : Bool, become_user : String | Nil) : JSON::Any

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.

Source
execute_plugin(plugin_name : String, config : JSON::Any, host : Host, vars : Hash(String, JSON::Any)) : JSON::Any

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.

Source
execute_remote_plugin(plugin_name : String, config : String, host : Host, vars : Hash(String, JSON::Any), become : Bool, become_user : String | Nil) : JSON::Any

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.

Source
flush_host_state

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.

Source
get_connection_host(host : Host, vars : Hash(String, JSON::Any)) : String

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.

Source
host_state_cache_enabled=(value : Bool)
Source
host_state_path
Source
host_state_satisfies_for_spec?(host_key : String, names : Array(String)) : Bool
Source
interpret_remote_result(exit_code : Int32, stdout : String, stderr : String) : JSON::Any

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.

Source
invalidate_host_state(host_key : String) : Nil

Drops everything known about a host, so the next check verifies the slow way. Called from the missing-binary recovery path.

Source
local_connection?(host : Host, vars : Hash(String, JSON::Any)) : Bool

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.

Source
local_username
Source
missing_remote_binary_for_spec?(result : JSON::Any) : Bool

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.

Source
missing_remote_binary_on_host?(result : JSON::Any, target : String, remote_user : String | Nil) : Bool

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.

Source
needs_full_vars?(module_name : String) : Bool
Source
normalize_module_result(result : JSON::Any) : JSON::Any

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).

Source
recover_missing_plugins!(host : Host, plugin_names : Array(String), vars : Hash(String, JSON::Any)) : Nil

Batch-path counterpart: one invalidation, one upload covering every module the group needs.

Source
remote_execution?(plugin_name : String, host : Host, vars : Hash(String, JSON::Any)) : Bool

Whether a plugin invocation actually goes over SSH: everything that isn't controller-only and isn't a local connection.

Source
remote_plugin_dir(remote_user : String | Nil) : String

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.

Source
remote_plugin_target(plugin_name : String, become : Bool, become_user : String | Nil, remote_user : String | Nil = nil) : String
Source
simple_plugin_name(module_name : String) : String

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).

Source
valid_become_user?(user : String) : Bool

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.

Source
verbose=(value : Bool)

Set verbose mode

Source