Krikri::BatchScript
Wire protocol for task batching (on by default; --no-batching disables it): builds the bash script that runs several plugin invocations in one SSH round trip, and parses the results back out.
Both directions use base64 framing, never marker strings mixed into a plugin's own stdout/stderr - a shell/command task's captured output could in principle contain anything, including a string that collides with a hand-picked delimiter. Base64's own alphabet structurally can't produce that collision, so this is immune to it rather than merely unlikely to hit it.
The generated script is meant to be piped via a single ssh ... bash -s invocation's stdin. Each step's config is embedded in the script
itself (also base64) rather than streamed separately over that same
stdin - interleaving "script source" and "step data" on one stdin
stream is a well-known footgun (bash's own script-reading can read
ahead in blocks, not strictly line-by-line, corrupting the boundary
between where the script ends and data begins). A single
self-contained script sidesteps that entirely.
Assumes GNU coreutils base64 (supports -w0, no line wrapping) on
the target - consistent with this project's existing assumption of a
Debian/RHEL-family Linux target elsewhere (dpkg/rpm detection, etc.).
Constants
/var/tmp, not /tmp - see PluginManager.remote_plugin_dir for
why (some hardening roles remount /tmp as a fresh, empty tmpfs
mid-play, which would silently wipe this batch's own per-step
output files out from under it).
awk program (POSIX awk, run against one step's stdout file) that answers "does this result JSON have a TOP-LEVEL "failed": true?"
- exits 0 when it does. Tracks JSON string/escape state and brace depth so "failed" keys nested inside result values (uri:'s parsed response body, string-valued fields carrying escaped copies) never trip it. The result is always a single-line compact JSON object, so per-line state resets are harmless.
Class methods
Builds the full script for one batch. batch_id should be unique per invocation (avoids any risk of colliding with a leftover directory from an earlier batch against the same host). remote_user is the connecting SSH user - it names the per-user parent directory (see #remote_dir_prefix).
Parses a batch script's stdout (as captured from the single SSH invocation that ran it) back into per-step results, keyed by the step's original index. A step whose index is missing never ran (the script halted before reaching it, or the whole batch never started)
- callers must not treat a missing entry as an error on its own.
Per-connecting-user parent for batch working dirs, sharing the
plugin staging dir's <user>-<hash> tag (PluginManager.staging_dir_tag):
/var/tmp is sticky-world, so the old single shared parent was an
entirely predictable path any local user could pre-create before
krikri's first run (the CVE-2014-3498 class), and two krikri users
on one host fought over it. Per-user, that cross-user race
disappears; the generated script's owner/symlink checks stay as
defense in depth. Lives under the plugin staging base (not
beside it) so this script's 0700 chmod can never clobber the
staging base's deliberately traversable 0711 (a become_user:
exec reaches binaries through it).
Stamps the steps a batch script that exited NONZERO never produced
results for, once the transport-death stamping above has had its
turn - the script's own pre-step aborts are the case it exists for,
the staging-safety refusals (exit 75: a batch directory that is a
file, a symlink or foreign-owned, or one a full disk simply failed
to create) above all. The script refuses BEFORE any step runs, so
#parse recovers nothing; unlabeled, every member of the group then
falls through execute_batch_group's "no entry = skipped" contract.
Atlantic round 29896654 (0xRedpoll.ludus_mythic_teamserver, issue
#834) turned exactly that into the worst possible report: seventeen
consecutive tasks printed skipping: and the run exited 0 while
real Ansible - one ssh round trip per task - fails the task in
flight (its own temporary-directory creation fails the same way on
a full disk) and halts the host. The stamped steps carry the
script's own evidence; interpret_remote_result books them as failed
tasks (the refusal stderr names krikri's batch dir, never the ssh
transport), the first member's failure halts the host, and the
members after it stay silent exactly like real's post-failure
tasks.
A completed script always exits 0 (a failing step dumps and exits 0), so any nonzero exit leaves at least one step without a result of its own making - including a mid-script death ssh reports with a non-255 status (the transport-death stamp only answers 255). Steps whose results DID arrive (streamed before the death, or a fail-fast dump - though a fail-fast always exits 0) are left untouched.
Public (spec seam) like stamp_missing_on_transport_death, for the same reason.
Stamps the steps whose results never arrived when the SSH transport died mid-script (ssh's own 255 exit), leaving the steps that DID complete - their streamed results are in parsed via #parse - untouched. Real Ansible runs every task as its own ssh round trip, so a mid-play sshd death books the already-completed tasks as ok and only the task in flight as UNREACHABLE; without this stamping the missing steps would have no entry at all, which execute_batch_group's contract reads as "skipped", silently NOT running tasks the playbook asked for.
The 255 gate is what makes this safe: a batch script that COMPLETED always exits 0 (a failing step dumps and exits 0; the staging- safety refusals exit 75), so 255 can only come from ssh itself. Public (spec seam) because run_batch_script applies it to live transport output and the partial-recovery contract is worth pinning down directly.