class

Krikri::SSHManager

Inherits Reference < Object

Constants

DEFAULT_EXEC_TIMEOUT_SECONDS = 3600

Default per-command execution timeout for #exec/#exec_script/ #daemon_send. Real Ansible has NO default command-duration limit at all - a foreground task runs until it completes, however long that takes (only async: tasks get an explicit max duration); what actually detects a genuinely dead/unreachable host is the SSH connection's own keepalive (ServerAliveInterval=60 x ServerAliveCountMax=3 below, ~180s to a hard disconnect), which this timeout duplicates and undercuts. Previously 300s (5 minutes)

  • too short for entirely ordinary, legitimately slow real-world tasks: found live re-benchmarking buluma.netdata (round 163 regression check) - its own installer genuinely compiles from source and took a confirmed 1536s (~25.6 minutes) on the real ansible-playbook side; crystal's identical task was killed at exactly 300s ("SSH command timed out... did not exit even after being killed") despite the remote command still actively running and eventually would have succeeded. The same 300s cap likely also explains 2 earlier "flaky"-looking incidents this session (robertdebock.luks, geerlingguy.java - both ordinary dnf install calls that occasionally took a bit over 300s on a slow mirror/cold host, not a genuine hang) that were chased down as one-off infra flakiness via isolated re-tests rather than recognized as this same root cause at the time. Raised to a generous but still-bounded 1 hour - long enough for realistic compile-from-source/large- package-install tasks, while the SSH keepalive above still catches a truly dead connection in ~3 minutes regardless of this value.
MAX_DAEMON_FAILURES = 3

Class methods

close_all

Close all connections

Source
close_all_daemons

Graceful shutdown for every still-open daemon connection, called once at the end of a run. Closes every daemon's stdin first (each one sees EOF and exits cleanly via plugin_daemon.cr's own rescue IO::EOFError - no explicit "goodbye" message needed), THEN waits once for the whole batch rather than once per connection, force-killing stragglers only after that single grace period - materially faster than #kill_daemon's per-connection path would be for a multi-host run.

Source
close_connection(host : String, user : String, port : Int32 | Nil = nil) : Nil

Close specific connection

Source
connection_level_exception?(ex : Exception) : Bool

Exception counterpart of #connection_level_failure? - an scp/rsync/ ssh failure surfaces as a raised "Failed to upload/download ..." whose detail embeds ssh's own stderr, so the same pattern list decides whether the exception means "the transport never got there" (UNREACHABLE in real Ansible) or something else that must keep propagating (a missing local file, a staging-dir safety refusal, an engine bug). Message-only, no exit code to gate on.

Source
connection_level_failure?(exit_code : Int32, stderr : String) : Bool

True when exit_code/stderr look like the SSH transport itself failing - ssh never reached or never authenticated to the host - as opposed to a remote plugin crashing after a successful connection. Public so PluginManager's remote-result interpretation and the spec suite share one pattern list (see CONNECTION_FAILURE_PATTERNS for what deliberately does NOT match).

Source
control_path_dir

Read-only accessor for diagnostics/specs - the stable control-socket directory itself carries no secrets, and the spec suite pins its pid-independence (the exact property f500.ufw's warm-rerun lockout hinged on).

Source
daemon_send(host : String, user : String, port : Int32 | Nil, remote_binary_path : String, module_name : String, config : JSON::Any, identity_file : String | Nil = nil, timeout : Int32 = DEFAULT_EXEC_TIMEOUT_SECONDS, become_user : String | Nil = nil) : JSON::Any

Sends one request and returns the plugin's own JSON result, unwrapped - unlike exec/exec_script, there is no exit-code-vs- stdout arbitration to do here (that was a one-shot-process concept; interpret_remote_result doesn't apply to a persistent pipe), the daemon's response IS the plugin's real output.

On ANY failure (spawn error, broken pipe, timeout, malformed response) the connection is torn down and NOT retried here - the exception propagates to PluginManager, whose job is to catch it and fall back to execute_remote_plugin's existing, already- proven per-task path for that one call. This is deliberately the WHOLE reconnect story: a stale daemon (e.g. after ansible. builtin.reboot killed the SSH session mid-play) fails exactly once, falls back safely for that one task, and a fresh daemon gets lazily spawned the next time this host needs one - no explicit reboot-awareness needed anywhere in this method.

Source
daemon_send_batch(host : String, user : String, port : Int32 | Nil, remote_binary_path : String, steps : Array(NamedTuple(module_name: String, config: JSON::Any, ignore_errors: Bool)), identity_file : String | Nil = nil, timeout : Int32 = DEFAULT_EXEC_TIMEOUT_SECONDS, become_user : String | Nil = nil) : Hash(Int32, JSON::Any)

Perf item 3: send a whole batch of steps as ONE daemon request. Same connection, same framing and the same rescue-and-let-the-caller-fall-back contract as #daemon_send above; only the payload shape differs.

Returns the plugin results keyed by the step's index in steps. An index that is ABSENT never ran - the daemon stopped at an earlier failing step, exactly as BatchScript.parse reports a script that halted. Callers must honour that the same way they already do for the script transport.

Every step here must agree on become_user, because that is what selects which resident daemon (and therefore which user) serves them - see TaskExecutor#run_batch_steps, which does the partitioning.

Source
daemon_unavailable?(host : String, user : String, port : Int32 | Nil, become_user : String | Nil) : Bool

Whether a daemon for this key is worth attempting at all. Public so PluginManager can skip the attempt before building a request, rather than learning about it from a raised exception.

Source
download(host : String, user : String, remote_path : String, local_path : String, port : Int32 | Nil = nil, identity_file : String | Nil = nil)

Download file from remote host via SCP

Source
exec(host : String, user : String, command : String, port : Int32 | Nil = nil, timeout : Int32 = DEFAULT_EXEC_TIMEOUT_SECONDS, identity_file : String | Nil = nil) : NamedTuple(exit_code: Int32, stdout: String, stderr: String)

Execute command on remote host port nil means "let ssh resolve it" (no -p flag; ~/.ssh/config and /etc/ssh/ssh_config apply) - only an explicit port overrides.

Source
exec_script(host : String, user : String, script : String, port : Int32 | Nil = nil, timeout : Int32 = DEFAULT_EXEC_TIMEOUT_SECONDS, identity_file : String | Nil = nil) : NamedTuple(exit_code: Int32, stdout: String, stderr: String)

Runs script on the remote host via ssh ... bash -s, piped over that single invocation's own stdin - used by TaskExecutor's batch path (batching, on by default; --no-batching disables it) to run several plugin invocations in one SSH round trip. Deliberately separate from exec: exec wraps a command through bash -c <string>, which has no stdin of its own to carry a whole script through; this uses bash -s (reads its script from stdin) specifically so the caller can hand over a multi-step script without needing to escape it into a single command-line string.

Source
init
Source
reset_connection(host_name : String) : Nil

meta: reset_connection - drops this host's persistent connection state so the next task opens fresh connections. Two layers exist here: the resident plugin daemons (one ssh process per host/user/port/become_user) and ssh's own ControlMaster socket underneath them. Both are dropped for every key matching the host; the ssh -O exit is best-effort (no master socket = no-op).

Source
reset_stats

Reset statistics

Source
rsync_upload(host : String, user : String, local_path : String, remote_path : String, port : Int32 | Nil = nil, mode : Int32 = 420, identity_file : String | Nil = nil) : Bool

Upload file using rsync (more efficient for incremental updates) Returns true if successful, false if rsync not available or failed

Source
rsync_upload_batch(host : String, user : String, local_files : Array(String), remote_dir : String, port : Int32 | Nil = nil, mode : Int32 = 493, identity_file : String | Nil = nil) : Bool

Batch upload multiple files using rsync (most efficient) Returns true if successful, false if rsync not available or failed

Source
run_with_timeout(process : Process, timeout_seconds : Int32, &block : Process -> NamedTuple(exit_code: Int32, stdout: String, stderr: String)) : NamedTuple(exit_code: Int32, stdout: String, stderr: String)

Runs block (given the just-spawned process) on a separate fiber and bounds it to timeout_seconds wall-clock time - both exec and exec_script accepted a timeout: parameter that was never actually enforced anywhere, leaving every blocking Process call (#wait, and for exec_script, the write of a potentially large script into the process's own stdin pipe) able to hang forever with no escape hatch at all. -o ServerAliveInterval=/ ServerAliveCountMax= only bound an established SSH session going idle - they do nothing for a local pipe write blocking because nothing is reading the other end (the remote host went unreachable mid-handshake, before ssh itself even started forwarding stdin), which is a local blocking syscall the SSH protocol's own keepalive machinery never sees. Found running konstruktoid-hardening's newly-batched (0.9.155) 411-item "Find possible suid binaries" step: the whole krikri-playbook process sat silent, producing no further output and no error, until an external timeout wrapper eventually killed it - a real host/ network hang, not a task bug, and previously nothing inside krikri-playbook itself could ever recover from it.

On timeout, SIGKILLs the process and gives it a further 5s to actually exit and report a status before giving up entirely (a hard bound of its own, in case even the kill signal doesn't unblock the fiber - e.g. if it's wedged in kernel-level uninter- ruptible I/O, vanishingly rare but not impossible).

Fiber lifecycle on the timeout path (audited, not assumed): the SIGKILL guarantees the child dies, which unblocks the spawned fiber's #wait (or errors its stdin pipe write with EPIPE), so the fiber completes and its result lands in the capacity-1 channel - either received within the 5s grace (the killed process's real exit status is then returned, e.g. 137) or, if even that doesn't arrive in time, discarded when this method returns and the channel is garbage. Either way the fiber terminates on its own; nothing leaks permanently.

Public (not private) so the timeout semantics can be spec'd directly against a real local process - see spec/unit/ssh_manager_timeout_spec.cr.

Source
stats

Get connection pool statistics

Source
upload(host : String, user : String, local_path : String, remote_path : String, port : Int32 | Nil = nil, mode : Int32 | Nil = 420, identity_file : String | Nil = nil, recursive : Bool = false)

Upload file to remote host via SCP

Source

Nested types