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

BECOME_CHOWN_FAILURE = BecomeMessages::CHOWN_FAILURE
BECOME_METHOD_EMPTY = BecomeMessages::METHOD_EMPTY

become_loader's ValueError for a become METHOD that resolved to None/empty (become is enabled, the var is present, the template rendered to nothing): the task executor books it as this fatal before any module runs (issue #395).

BECOME_PREMATURE_END = BecomeMessages::PREMATURE_END
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: 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 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 connection keys BasePlugin itself pulls out (WIRE_VAR_KEYS below: ansible_connection/ ansible_host/ansible_ssh_private_key_file/ansible_ssh_*_args), 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) - plus the dnf-family wrappers, which get just the single pkg_mgr fact they need through build_plugin_config's own carve-out, not the full context (see RpmPackage's backend resolution). 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.

RESOLVABLE_UNIMPLEMENTED_CONNECTION_TYPES = Set {"psrp", "winrm"}

Connection plugins Ansible RESOLVES (shipped in ansible-core itself, so the loader never returns not-found for them) but this engine has no transport for: psrp/winrm (Windows), etc. They keep the same "unknown connection falls back to SSH" behavior every unimplemented-but-real connection plugin always had here - failing them as "not found" would misreport a resolvable connection (Ansible reaches an actual connection ATTEMPT for those, failing with a connect error, not a resolution error).

SUPPORTED_CONNECTION_TYPES = Set {"local", "ssh", "smart", "paramiko_ssh", "ansible.builtin.local", "ansible.builtin.ssh", "ansible.builtin.smart", "ansible.builtin.paramiko_ssh", "ansible.legacy.local", "ansible.legacy.ssh", "ansible.legacy.smart", "ansible.legacy.paramiko_ssh"}

Connection types this engine can actually dispatch, matched CASE-SENSITIVELY exactly like Ansible's own plugin loader, which looks the name up verbatim against plugin filenames (no lowercasing anywhere in the resolution path - verified live against ansible-core 2.19.11: connection: Local fails the task with "the connection plugin 'Local' was not found", and so does any FQCN that names no real connection plugin, e.g. community.grafana.grafana). "smart" is Ansible's default alias resolving to ssh for a remote host; paramiko_ssh is still ssh for this engine's purposes.

WIRE_VAR_KEYS = ["ansible_connection", "ansible_host", "ansible_ssh_private_key_file", "ansible_ssh_common_args", "ansible_ssh_extra_args", "playbook_dir", "ansible_pkg_mgr", "ansible_python_interpreter", "discovered_interpreter_python"]

The vars payload of every pruned (non-full-vars) plugin config - what build_plugin_config sends over the wire instead of the whole vars context.

It must be enough, together with the config's own host entry, for the plugin PROCESS to resolve the same ConnectionSettings its own SSH calls dial with: the ansible_ssh_* args are the load-bearing half of issue #2 - a controller-only plugin (fetch's remote read, wait_for_connection's connection probe) runs as its own process and dials back out over ssh itself, so without ansible_ssh_common_args/ ansible_ssh_extra_args in here its transport resolved an empty args list and ssh walked every agent identity instead of honoring -o IdentitiesOnly=yes ("Too many authentication failures"). ansible_user/ansible_port/ansible_host do NOT need to repeat here: build_plugin_config writes their resolved values into the host entry ConnectionSettings falls back to.

Deliberately NOT the password keys (ansible_password/ ansible_ssh_pass/...): this JSON is also what crosses the wire to the REMOTE plugin binary, and a controller credential must not be shipped to the target - remote dispatch never dials out, so it has no use for one anyway. A controller-only plugin with PASSWORD auth therefore still cannot resolve a password in-process; that gap is outside issue #2's key/agent-auth scope.

Class methods

batch_steps_to_resend(interpreted : Hash(Int32, JSON::Any), module_names : Array(String), remote_user : String | Nil) : Array(Int32)

Which steps of a batch script must be sent again after some step found its plugin binary missing. Only the missing steps and the steps that never got a result are re-sent: every other step already ran on the target, and re-sending it repeats its side effects (a file: step reruns and reports ok because the first run already created the directory). Real ansible runs each task once, so its tasks are never re-sent.

Source
batch_upload_plugins_for_playbook(playbook : Playbook, inventory : Inventory, forks : Int32 = 5) : Hash(String, 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

Returns host name -> the SSH transport's own error detail for every host the pass could not reach. It deliberately does NOT print the UNREACHABLE! fatal line itself: ansible-playbook reports an unreachable host only at the task that fails (the implicit Gathering Facts task, or the play's first task when the play doesn't gather), under that task's banner - a fatal printed here came out BEFORE the PLAY [ banner and a second time at the task (live-verified vs ansible-core 2.19.11). The task-level reporting paths consume the returned details via TaskExecutor's unreachable_ssh_errors so the fatal line carries the real ssh error instead of a bare connection host.

Source
become_allow_same_user?

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

Source
become_chown_failure_result(exit_code : Int32, stderr : String, become_user : String | Nil, remote_user : String | Nil) : JSON::Any | Nil

ansible-core 2.19, connection user an admin (root is in the default admin_users) and become_user absent on the target: _fixup_perms2 (ansible/plugins/action/init.py:698-710) fails the task itself ("Task failed: Failed to change ownership of the temporary files ...") before the module ever runs, instead of letting sudo's own error surface. Krikri reaches the same state as sudo printing "sudo: unknown user <user>" and exiting nonzero, which interpret_remote_result alone would word as a generic plugin failure.

Source
become_chown_probe_failure(conn : ConnectionSettings, become : Bool, become_user : String | Nil) : JSON::Any | Nil

nil when the escalation is fine (or the probe cannot run); the ansible-worded failure when root cannot chown to an existing become_user. A nonexistent user is left to sudo's own error path.

Source
become_login_shell?(become_flags : String | Nil) : Bool

Whether a task's resolved become_flags ask sudo for a login shell (--login or -i as a whole token, Ansible's sudo plugin passes the flags through verbatim, so token position doesn't matter). Under -i sudo runs $SHELL -c <command> after sourcing the become user's login profile, so PATH (venv bin dirs etc.) comes from that profile exactly as it does under ansible-playbook.

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

Whether a become: task actually needs an escalation command.

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 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 Ansible succeeded. Found while reproducing an unrelated recap delta on a plain debian:trixie container, and confirmed with a two-task minimal repro against ansible-core 2.19.

Source
become_premature_end_result(output : String, stderr : String) : JSON::Any | Nil

sudo exits before the plugin ever runs (e.g. "sudo: a password is required"): ansible-core 2.19.11 books this as the local connection's become failure with the sudo stderr appended, not as unparseable module output. _connection_failure stays so failed_when: cannot reinterpret it; the display strips it. _ansible_raise_escape marks the RAISE class: ansible raises this out of handler.run() rather than returning it as a result, so an until:/retries: task never retries it (see run_until_retries' own escape). The key is dropped from displays and registered vars by the blanket _ansible_* strip.

Source
build_arch
Source
build_arch=(value : String)
Source
chown_probe_script(become_user : String) : String

Shell snippet for the probe: ansible's _fixup_perms2 chowns its temp files to the become user as the (root) connection user and fails the task when that is refused (e.g. a uid outside a rootless container's user-namespace map). krikri never chowns, so it asks the same question once per (host, become_user) instead.

Source
clear_cache
Source
connection_plugin_not_found?(raw : String) : Bool

Whether raw (the effective connection type: an inventory/role/ task-level ansible_connection value, or a task's own connection: keyword after substitution) names no connection plugin Ansible could resolve either - so a task using it must fail the way Ansible's TaskExecutor._get_connection does, instead of silently falling back to SSH. A collection-qualified name is checked against the controller's own collection paths exactly like Ansible's loader: a plugin that EXISTS there (containers.podman.podman, community.docker.docker) keeps the engine's long-standing SSH fallback - this engine can't exec inside a container target, but that's a transport approximation, not a resolution failure; a plugin that exists NOWHERE is the not-found verdict.

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
controller_os
Source
controller_os=(value : String)
Source
daemon_dispatch_unknown_result(ex : SSHManager::DaemonDispatchUnknownError) : JSON::Any

The result the solo daemon path returns when a request was dispatched but its response was lost or unparseable. Public (spec seam) because the SHAPE is the contract, and the shape splits by HOW the response was lost, mirroring Ansible:

  • the pipe/transport itself died or timed out on a cached daemon (ex.transport_lost?) - Ansible books a connection that dies while its module runs as UNREACHABLE: the host is dropped from the rest of the play and the run exits 4. Verified live against 2.19.11 (probe role kop_sshd_solo, Atlantic.net, 2026-10-08): real printed fatal: [host]: UNREACHABLE! with rc=4 and recap ok=2 unreachable=1, this engine printed a FAILED task with _connection_failure: true that ignore_errors: true then reduced to ignored=1, kept running every later task on the host, and exited 0. The task is still never re-executed (the unreachable result replaces it outright; nothing falls through to the one-shot path), so the double-apply guard holds.
  • a live daemon answered with unusable bytes - failed: true plus _connection_failure (the latter keeps failed_when:/changed_when: from reinterpreting a result no module produced), and the msg says explicitly why the task was not retried. ignore_errors: applies to this shape exactly as Ansible's own module-produced- garbage failure would.
Source
daemon_eligible?(plugin_name : String, become : Bool, become_flags : String | Nil = nil) : 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 -H -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
executable_path_for_spec=(path : String | Nil)

Lets a test point the "plugins/ next to the executable" resolution at a throwaway tree. MUST be restored (nil) by the test - while set, the default plugins/ lookup falls through to the cwd-relative ./bin/plugins/ fallback, so it stays correct for other code.

Source
execute_plugin(plugin_name : String, config : String, host : Host, vars : Hash(String, JSON::Any), become : Bool, become_user : String | Nil, task_timeout : Int64 | Nil = nil, become_flags : String | Nil = 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, task_timeout : Int64 | Nil = nil, become_flags : String | Nil = 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 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_become_would_fail?(local_dispatch : Bool, become : Bool, become_user : String | Nil, become_password : String | Nil, sudo_noninteractive : Bool) : Bool

Issue #498: ansible validates a module's own arguments INSIDE the module - after the become escalation - so on a controller whose sudo needs a password (with no become password configured) a task with an unsupported parameter fails as sudo's "Premature end of stream waiting for become success", never reaching an "Unsupported parameters" message. The executor consults this predicate to skip its controller-side module-level argspec check exactly when the local dispatch is about to fail that way, letting the normal dispatch produce the real-shaped failure. Pure: the caller passes the sudo -n probe's result in, so a local dispatch that would succeed (or never touches sudo) still gets the argspec check, and remote/controller-only tasks never pay for a probe.

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_plugin_path_for_spec(plugin_name : String, target_arch : String | Nil) : String

Clear uploaded plugins cache (for testing) Spec seams (the repo's *_for_spec convention) for the arch-aware decision logic - the privates they expose are pinned directly in the unit tests.

Source
local_plugin_stdin(config : String, become_password : String | Nil, interactive : Bool) : String

What goes into the local plugin process's stdin: the config, or <password>\n<config> when sudo will actually consume that first line. Public as a spec seam.

Source
local_sudo_argv(sudo_user : String, plugin_path : String, interactive : Bool, become_flags : String | Nil = nil) : Array(String)

sudo argv for the local (ansible_connection=local) become path. interactive is the probe result (a password is set AND sudo says one is required): sudo -S -p '' reads it off the very stdin pipe that carries the plugin's config, prefixed by #local_plugin_stdin - the password itself never appears in this argv. NOOPASSWD keeps the historical sudo -n with an untouched stdin, so -K with passwordless sudo behaves exactly as before. Public as a spec seam: pinned directly in the unit tests. become_flags is the task's resolved become_flags: keyword (or ansible_become_flags var): when it asks for a login shell (--login/-i), -i replaces -H - sudo then sources the become user's login profile before exec'ing the plugin, which is where a PATH pointing at a venv's bin dir comes from under ansible-playbook (issue #716: shell: nautobot-server migrate with become_flags: --login found the command there; plain sudo -H kept root's PATH and died with rc 127 "command not found"). Public as a spec seam: pinned directly in the unit tests.

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_arch(raw : String) : String

Canonical architecture names: uname -m spellings folded onto one form each (Debian-style amd64/arm64 and the kernel's x86_64/aarch64 name the same machines). Anything else passes through lowercased - armv7l etc. stay their own arch names, matching uname.

Source
normalize_module_result(result : JSON::Any) : JSON::Any

Controller-side result normalization - 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
platform_key(arch : String, os : String) : String

The per-arch lookup key for a target: the bare normalized arch when the target runs the controller's OS (or its OS is unknown), else "<os>-<arch>".

Source
record_host_state_for_spec(host_key : String, md5s : Hash(String, String), arch : String | Nil) : Nil
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
remaining_exec_timeout(started : Time::Instant | Nil, task_timeout : Int64 | Nil) : Int32

The exec_script timeout for one dispatch of a timeout: task: the task budget minus whatever earlier stages already spent after the clock started (the plugin upload happens BEFORE it), bounded below at 1s so a budget exhausted before the exec still gets one immediate-chance attempt instead of a zero/negative timeout. nil (no task timeout) keeps the transport's own default. Public as a spec seam - see deadline_exhausted?.

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 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, become_password : String | Nil = nil, become_flags : 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
staging_dir_tag(user : String) : String

Stable, collision-proof directory-name tag for a connecting user: the sanitized name stays human-readable, the hash suffix guarantees two distinct users never map to one directory (and that a hostile username can't alias an existing path). Shared with BatchScript's per-user batch parent, so the two remote staging trees always agree on one user -> one directory.

Source
sudo_noninteractive?(sudo_user : String) : Bool

sudo -n -u <user> -- true: does sudo accept this escalation with no password right now? Exit status only - sudo -n never prompts and never reads stdin, and a probe must NOT submit a wrong password (PAM failure counters/lockout). Failure to run sudo at all answers "needs a password" conservatively: the -S path then either works or fails the same way sudo -n would have.

Source
sudo_password_become_wrapper(plugin_path : String, become_user : String, become_password : String, become_flags : String | Nil = nil) : String

The become-password variant of the sudo -H -n -u <user> -- <path> wrapper, for when ansible_become_password/ansible_become_pass is set (-K/--ask-become-pass, --become-password-file, or the inventory var). It is ONE stdin-transparent command fragment, so every caller keeps piping the plugin's config into it exactly as before (echo <b64> | base64 -d | <target>, the batch script's per-step line, async's < <job>.cfg redirect) with no structural change anywhere.

How the password reaches sudo without ever touching argv:

  • The if condition probes with sudo -n ... true (never with a wrong password - that would feed PAM/faillock counters). Exit 0 means sudo needs no password here (NOOPASSWD - the -K with passwordless-sudo case), and the then-branch runs plain sudo -H -n with stdin passed straight through, so the config payload is byte-identical to a run without -K.
  • Otherwise the else-branch runs sudo -H -S -p '' and prepends <password>\n to the same stdin stream: sudo reads exactly that one line as the password and hands the rest (the config) on to the plugin. The password text lives in the script over SSH's stdin - never in any local or remote argv (printf is a shell builtin, single-quoted so no metacharacter in the password can escape). Known residual edge (accepted): if sudoers grants NOPASSWD only for this exact plugin path while REQUIRING a password for true, the probe answers "password needed", sudo then never prompts, and the password line would reach the plugin instead of the config - a contradictory sudoers setup, and it fails the task rather than mis-executing anything. Public as a spec seam: the command construction is pinned directly in the unit tests.

The feeder's stderr is suppressed (2>/dev/null on printf and cat): when the consumer exits without reading its stdin - sudo failing to exec the plugin (e.g. the plugin-upload race), or the plugin crashing before its first STDIN.gets_to_end - the feeder subshell can run after that exit and append bash's own "printf: write error: Broken pipe" / "cat: write error: Broken pipe" to the captured stderr. Real Ansible feeds the become password via run_command(data=...), where CPython ignores EPIPE on the stdin write, so silencing the feeder matches real output instead of masking a divergence (same rationale as java_cert_command's stdin feeder). sudo's own stderr still flows.

Source
target_arch_for_spec(host_key : String) : String | Nil
Source
task_end_cleanup_failure_result(module_result : JSON::Any, cleanup : NamedTuple(exit_code: Int32, stdout: String, stderr: String)) : JSON::Any

The task-end cleanup's verdict, kept pure so the replacement decision is testable without SSH: a transport-level failure of the cleanup exec replaces the module's own result with Ansible's unreachable shape (the finally-raised exception wins over a successful AND a failed module result alike), while every other cleanup outcome - success, or a nonzero exit that isn't the transport (Ansible only warns on those) - keeps the module's result untouched. failed: true rides along because this is the same wire shape interpret_remote_result stamps for a transport that died mid-module: the unreachable flag decides the task's booking and display, but result_failed_flag readers (the Gathering Facts booking, notably) must still see a failure.

Source
task_timeout_marker(task_timeout : Int64 | Nil) : JSON::Any

The _task_timeout marker result a deadline kill turns into: the executor (TaskExecutor#execute_task_once / handler dispatch) reads the marker key out of the plugin result and converts it into Ansible's own Timed-out task failure shape before display.

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