// STRYKE-FLEET — PARALLEL EXPECT

4 sublibraries · transcripted PTY sessions · declarative playbooks · recipe corpus · multi-host fan-out · use Fleet pulls everything in one pass

Report GitHub Issues
// Color scheme

>_STRYKE-FLEET

Expect, but N sessions at once. Tcl/Expect automated one terminal; pexpect bolted multiprocessing on the side. stryke-fleet is the orchestration layer over stryke's native PTY builtins (pty_spawn, pty_expect, pty_expect_table, …) and its thread pool (pmap): transcripted sessions (Fleet::Session), declarative step lists (Fleet::Playbook), a recipe corpus for ssh / scp / sftp / ftp / telnet / sudo / su / passwd / psql / mysql / mongo / redis-cli / docker / npm login / gpg / openssl / installers / network gear (Fleet::Recipes), and one-playbook-N-targets fan-out (Fleet::Fanout). Pure stryke: no [ffi] table, no cdylib, no helper binary — just .stk modules loaded by stryke's normal package resolver.

The 4 sublibraries

#ModuleFileFnsHighlights
1Fleet::Sessionlib/Session.stk24open · send/send_line/send_lines/send_secret/send_control · expect · expect_exact (literal match) · expect_any · sendline_expect (send-then-expect atom) · branch · read · drain (collect trailing output until idle) · buffer · alive · eof · interact · close · transcript · events/matches/last_match · timed_out · transcript_text
2Fleet::Playbooklib/Playbook.stk4validate · run — ordered +{expect, send, branches, timeout, optional, send_matched, retries, retry_send, delay} steps; first non-optional timeout fails the run with the step name · dry_run (pure step preview) · concat (splice step lists)
3Fleet::Recipeslib/Recipes.stk49ssh_login · ssh_copy_id · sudo · doas · su · psql · mysql · mariadb · pg_dump · redis_cli · mongo · influx · yes_to_all · cisco_enable · telnet_login · sftp · scp · ftp_login · passwd_change · docker_login · npm_login · gpg_decrypt · openssl_pem · rsync · ssh_keygen · git_clone · kinit · ldapsearch · restic · borg · age · smbclient · vault_login · aws_configure · htpasswd · keytool · ansible_vault · sqlplus · op_signin · ssh_add · unzip_encrypted · cqlsh · cryptsetup · clickhouse_client · gcloud_auth · heroku_login · pass_insert · bw_unlock · names
4Fleet::Fanoutlib/Fanout.stk18run (N targets via pmap) · batch (bounded concurrency) · ssh (host-list convenience) · partition (ok/failed split) · count · all_ok · failed_cmds · ok_cmds · find (first result by cmd pattern) · pluck · pluck_ok · summarize (counts + error lines) · group_by (generic bucketing) · group_by_error (bucket failures by error) · group_by_output (bucket successes by output — config-drift) · grep_transcript (which hosts printed X) · retry_failed (re-run only the failures) · retry_until (re-run failures up to N times)

Color legend: green = module name · yellow = function count. Recipes are pure data generators — they never spawn anything, so they compose with @{...} splices and unit-test without a network.

Install

# From a release:
s pkg install -g github.com/MenkeTechnologies/stryke-fleet

# From a local checkout:
git clone https://github.com/MenkeTechnologies/stryke-fleet
cd stryke-fleet
s pkg install -g .              # installs into ~/.stryke/store/stryke-fleet@<version>/

# Or via Makefile:
make install

No cargo step. No cdylib. The installed store directory contains only stryke.toml + lib/*.stk — no compiled artifacts, fully portable across platforms.

Quick Start

use Fleet                                       # pulls all four sublibraries

# One session, transcripted
val $s = Fleet::Session::open("ssh user\@host")
Fleet::Session::expect($s, qr/password:/, 10)
Fleet::Session::send($s, "$pw\n")
Fleet::Session::close($s)

# Declarative playbook — first non-optional timeout fails the run
val $r = Fleet::Playbook::run("ssh user\@host", [
    @{ Fleet::Recipes::ssh_login(+{ password => $pw }) },
    +{ send   => "uptime\n" },
    +{ expect => qr/load average/, timeout => 15, name => "uptime output" },
    +{ send   => "exit\n" },
])
p $r->{ok} ? "done" : "failed: $r->{error}"
p "$_->{event} $_->{data}" for @{ $r->{transcript} }

# Branch tables — first match wins, optional action coderef
+{ branches => [
       +{ re => qr/\(yes\/no\)\?/, do => fn { "yes\n" } },
       +{ re => qr/password:/ },
   ],
   send_matched => 1 }

# The headline: one playbook, fifty hosts, parallel PTY sessions
val $results = Fleet::Fanout::ssh([@hosts],
    Fleet::Recipes::ssh_login(+{ password => $pw }))
val $part = Fleet::Fanout::partition($results)
p "ok: #{len @{$part->{ok}}}  failed: #{len @{$part->{failed}}}"

Pull only what you need with explicit sub-imports: use Fleet::Playbook, use Fleet::Recipes, etc. The umbrella use Fleet is just for convenience.

Fleet::Session — full reference

A session is a hashref +{ raw => <pty handle>, cmd => CMD, log => [] } returned by open. Every send, match, timeout, and close is appended to log in order, so a finished or failed session can be replayed with transcript / transcript_text. The bare pty_* builtins stay reachable through $s->{raw}; this layer only adds bookkeeping.

FunctionSignatureBehavior
openopen($cmd)Spawn $cmd under a PTY (pty_spawn). Returns the session hashref with an empty transcript.
sendsend($s, $text)Write $text to stdin verbatim; logs a send event with the text.
send_linesend_line($s, $text)Like send but guarantees a trailing newline (added unless $text already ends in one) — the common case for answering a prompt.
send_linessend_lines($s, $lines)Send each element of the $lines arrayref newline-terminated like send_line; one send event per line. Returns the count. Dies if $lines is not an arrayref.
send_secretsend_secret($s, $text)Transmit a password/token in full (trailing newline added) but record a redacted send event (data => "***", secret => 1) so credentials never leak into transcript dumps.
send_controlsend_control($s, $char)Send a control byte (pexpect sendcontrol): $char is a letter a–z (Ctrl-A…Ctrl-Z, e.g. "c"→0x03, "d"→0x04 EOF, "z"→0x1a) or one of @[\]^_?. Logs a send_control event with char + byte (raw byte never dumped). Unknown char dies.
expectexpect($s, $pat, $timeout = 30)Wait for $pat (qr// or string) in the output. Returns the matched text or undef on timeout; both outcomes are logged (match / timeout).
expect_exactexpect_exact($s, $text, $timeout = 30)Wait for the LITERAL $text (quote-meta'd with \Q…\E so regex metacharacters match as themselves) — the right primitive for prompts containing ., *, (, a $, or a literal (yes/no)?. Logged like expect.
expect_anyexpect_any($s, $pats, $timeout = 30)Wait for the FIRST of the $pats arrayref to appear. Returns +{ index => N, match => TEXT } for the pattern that hit, or undef on timeout. Built on branch; dies if $pats is not an arrayref.
sendline_expectsendline_expect($s, $text, $pat, $timeout = 30)Send $text as a line then immediately wait for $pat — the send-then-expect atom expect scripts repeat. Returns the matched text (undef on timeout); both the send and expect outcome are logged.
branchbranch($s, $branches, $timeout = 30)First-match-wins table (pty_expect_table). $branches is [+{ re => qr/.../, do => fn {...} }, ...] (do optional). Returns the action's return value when present, else the matched text; undef on timeout.
readread($s, $timeout = 5)Non-matching read of buffered output (pty_read).
draindrain($s, $idle = 1)Collect all remaining output until the child goes idle: repeated short-timeout reads, concatenated, stopping at the first empty read or EOF. Returns the accumulated string ("" if nothing buffered). Not logged.
bufferbuffer($s)Current unconsumed output buffer (pty_buffer).
alivealive($s)Is the child still running? (pty_alive)
eofeof($s)Has the child closed its side? (pty_eof)
interactinteract($s)Hand the PTY to the user for manual debugging (pty_interact).
closeclose($s)Kill the child and release the PTY (pty_close); logs a close event with the cmd.
transcripttranscript($s)The ordered event log: arrayref of +{ event, data } where event is send / match / timeout / close (or send_control).
eventsevents($s, $type)Transcript entries of one event type, in order.
matchesmatches($s)The data of every match event, in order.
last_matchlast_match($s)The data of the most recent match event, or undef.
timed_outtimed_out($s)1 if any expect/branch in this session timed out, else 0 — catches a degraded run even when an optional step let the playbook continue.
transcript_texttranscript_text($s)Render the transcript as one EVENT<TAB>DATA line per event, newlines in data escaped — the human-readable dump for a failed session.

Fleet::Playbook — full reference

A playbook is an arrayref of step hashrefs run in order against one PTY. run spawns and closes its own session when handed a string command, or drives a caller-supplied open Fleet::Session when handed a hashref (the caller then closes it).

FunctionSignatureBehavior
validatevalidate($steps)Die unless $steps is a well-formed playbook (arrayref of hashrefs, each with at least one of expect/branches/send; branches must be an arrayref; retries a non-negative integer; delay ≥ 0). Returns the step count.
dry_rundry_run($steps)Validate, then return a human-readable per-step listing (index, name, action, timeout/delay/retries/optional) without spawning anything. Drives fleet.stk previews.
concatconcat(@parts)Splice any number of step-list arrayrefs into one playbook (skipping undef parts), validate the combined list, and return the flat arrayref — the explicit form of the (@{ $a }, @{ $b }) recipe splice. Dies if any part is not an arrayref or any element is not a well-formed step.
runrun($cmd, $steps, $opts = +{})Run $steps against $cmd (string → session spawned here; hashref → an already-open session). Returns +{ ok, cmd, steps_run, transcript }, plus failed_step and error when ok == 0. A non-optional expect/branch timeout fails the run with the step name; the session is always closed unless the caller passed it in open. $opts->{timeout} sets the default per-step timeout.

Step keys

KeyMeaning
expectPattern (qr// or string) to wait for. A non-optional timeout fails the run.
branchesFirst-match-wins arrayref of +{ re, do } arms, run via Session::branch.
sendText written to stdin after the expect/branch (or alone, for a send-only step).
send_matchedWith branches: send the branch result back to the session.
timeoutSeconds for the step's expect/branch (default 30, or $opts->{timeout}).
nameDiagnostic label used in the failure error string.
optionalA timeout on this step is not a failure — the run continues.
retriesRe-attempt a timed-out expect/branch N more times (non-negative integer).
retry_sendString sent before each retry — a nudge like "\n".
delaySeconds (fractional ok) to sleep before the step runs.

Every step must carry at least one of expect / branches / send, or validate rejects it before anything spawns.

Fleet::Recipes — the corpus

Each recipe returns a plain playbook arrayref — pure data that composes with @{...} splices and unit-tests without a network. Options are passed as a single hashref. Where a recipe takes a prompt option it defaults to a generic shell-prompt regex; where it takes a password/passphrase it sends it with a trailing newline. Fleet::Recipes::names returns the full roster (48 entries).

RecipeRequired optsWhat it drives
ssh_login(none)Optional host-key confirmation, optional password prompt, then a shell prompt. Opts: password, prompt, timeout.
ssh_copy_idpasswordAccept host key (optional), answer remote password, expect the "Number of key(s) added" confirmation (optional).
sudopasswordAnswer the password prompt, then expect prompt.
doaspasswordOpenBSD/Alpine doas: send password (its prompt, generic "Password:" fallback), then the shell prompt.
supasswordSwitch user: answer password, then expect prompt.
psql(none)Optional password prompt, then the db=# prompt.
pg_dumppasswordlibpq "Password: " / "Password for user NAME: " prompt (pg_dump, pg_restore, psql -W).
mysql(none)Optional password prompt, then the mysql> prompt.
mariadb(none)Optional "Enter password:" then the MariaDB [...]> prompt.
mongo(none)Optional password prompt, then the > / rs0:PRIMARY> prompt.
redis_cli(none)The host:port> prompt; optional AUTH.
influxpasswordInfluxDB v1 client auth ("username:" optional, "password:"), then the > query prompt.
cqlsh(none)Cassandra cqlsh: optional password prompt, then the cqlsh> prompt.
clickhouse_clientpasswordAnswer the "Password for user (NAME):" prompt.
sqlplususer, passwordOracle sqlplus: "Enter user-name:" then "Enter password:".
telnet_loginuser, passwordlogin: → user, Password: → password, then prompt.
ftp_loginuser, passwordName: → user, Password: → password, then the ftp> prompt.
docker_loginuser, passwordUsername: → user, Password: → password, then the success line.
npm_loginuser, password, emailLegacy interactive npm login: Username / Password / Email prompts.
heroku_loginemail, passwordheroku login -i: email then password.
gcloud_authcodegcloud auth login --no-launch-browser: paste the authorization code.
op_signinpassword1Password CLI op signin: account master-password prompt.
bw_unlockpasswordBitwarden CLI bw unlock: master-password prompt (outputs a session token).
vault_loginpasswordvault login (userpass/ldap): hidden password prompt, then success banner.
cisco_enableenable_passwordCisco enable mode: > → enable → password → #.
scppasswordOptional host-key confirmation then a password prompt; the copy runs to completion.
sftp(none)Optional host-key confirmation, optional password, then the sftp> prompt.
rsyncpasswordrsync over ssh: host-key accept then password (same prompts as scp).
smbclientpasswordSMB password then the smb: \> interactive prompt.
git_clone(none)ssh-key passphrase and/or https username+password — provide whichever the transport needs; all steps optional.
passwd_changenew_passwordpasswd chain: optional current password (root skips), new password, retype, success. Opt: old_password.
gpg_decryptpassphrasegpg --decrypt / symmetric: answer the passphrase prompt.
openssl_pempassphraseopenssl PEM passphrase with verification ("Enter PEM pass phrase:" / "Verifying - ...").
ssh_keygen(none)Passphrase entered twice (empty passphrase ⇒ none); optional overwrite answers the "Overwrite (y/n)?" prompt.
ssh_addpassphrasessh-add: decrypt a private key by passphrase.
agepasswordage passphrase prompt (decrypt asks once; age -p asks twice — confirm step optional).
cryptsetuppasswordLUKS passphrase prompts for luksFormat (capital "YES" confirm + verify, both optional) and luksOpen.
keytoolpasswordJava keytool keystore password, plus a re-enter prompt when confirm => 1.
htpasswd(none)htpasswd -c: New password / Re-type new password. Opt: password.
ansible_vaultpasswordansible-vault: existing "Vault password:" prompt, or the new + confirm pair when new => 1.
pass_insertpasswordpass insert NAME: "Enter password for NAME:" then "Retype password for NAME:" (retype optional with -e).
unzip_encryptedpasswordunzip of an encrypted archive: the per-archive "password:" prompt.
kinitpasswordKerberos kinit: single "Password for principal@REALM:" prompt.
ldapsearchpasswordOpenLDAP simple-bind: "Enter LDAP Password: " prompt (ldapsearch -W).
aws_configuresecret_keyaws configure: access key, secret key, region (default ""), output format (default "json"). Opt: access_key.
resticpasswordrestic repository password (init asks twice — verify step optional).
borgpasswordBorgBackup repository passphrase (init asks twice — verify step optional).
yes_to_all(none)Answer up to $count yes/no-style prompts with $answer (default "y"), tolerating runs that ask fewer.
namesReturns all 48 recipe names — what s bin/fleet.stk recipes lists.

Fleet::Fanout — full reference

One playbook against N targets, each on its own PTY on its own pmap thread; results come back as an arrayref of Playbook::run result hashrefs in target order. The rest of the surface is triage over that result list.

FunctionSignatureBehavior
runrun($cmds, $steps, $opts = +{})Run $steps against every command in $cmds concurrently via pmap. Validates the playbook first; dies if $cmds is not an arrayref. $opts pass through to Playbook::run.
batchbatch($cmds, $steps, $batch_size, $opts = +{})Run in consecutive batches of $batch_size — parallel within a batch, sequential between — to bound concurrency so a large fleet doesn't exhaust fds/network. Flat result list in order. $batch_size must be ≥ 1.
sshssh($hosts, $steps, $opts = +{})Fan $steps out over $hosts as ssh HOST. Opts: user (login name), ssh_opts (extra flags string), plus Playbook opts.
partitionpartition($results)+{ ok => [...], failed => [...] } split.
countcount($results)+{ total, ok, failed } tally — the numbers-only core of summarize.
all_okall_ok($results)1 iff every result is ok (empty set is vacuously ok), else 0 — the boolean fleet-pass gate.
summarizesummarize($results)+{ total, ok, failed, errors => ["CMD: ERROR", ...] } — one line per failure.
failed_cmdsfailed_cmds($results)The cmd string of every failed result, in order.
ok_cmdsok_cmds($results)The cmd string of every ok result, in order — the hand-off list for "now run the next stage only against the hosts that passed".
findfind($results, $pattern)First result whose cmd matches $pattern (regex string or qr//), else undef — by-target lookup over a cmd-ordered list.
pluckpluck($results, $key)Map the $key coderef over all results → one extracted value per result, order preserved. $key required.
pluck_okpluck_ok($results, $key)Like pluck but only over ok results — the projection without the undefs a failed result would yield.
group_bygroup_by($results, $key)Bucket all results by a $key coderef → { "key" => [cmd, ...] }, cmd order preserved, undef key bucketed under "". The general primitive the two specialized groupers below build on.
group_by_errorgroup_by_error($results)Bucket failures by error string → "which hosts failed the same way". Successes ignored.
group_by_outputgroup_by_output($results, $key = undef)Bucket successes by captured output for config-drift detection (default key = last transcript data event; pass a $key for something finer).
grep_transcriptgrep_transcript($results, $pattern)Which targets printed $pattern anywhere in their transcript → arrayref of +{ cmd, ok, match } — catches things that didn't fail the playbook (a deprecation warning, a version banner).
retry_failedretry_failed($results, $steps, $opts = +{})Re-run only the failed entries once, in place; ok results pass through, re-runs are tagged retried => 1. Order preserved.
retry_untilretry_until($results, $steps, $max_attempts, $opts = +{})Re-run each failure up to $max_attempts times, stopping per-target on first success; needed retries carry retried => 1 and attempts => N. $max_attempts ≥ 1.

Examples

Four runnable scripts ship in examples/. The first three run anywhere — they drive a local sh/cat, no network — while parallel_ssh.stk needs real hosts.

FileRunWhat it shows
local_demo.stks examples/local_demo.stkA playbook drives a local sh (echo → expect → exit) and prints the transcript.
installer_autopilot.stks examples/installer_autopilot.stkyes_to_all answers a fake two-question installer simulated with sh.
orchestrate.stks examples/orchestrate.stkAll four layers in one flow against local cat: recipe splice + playbook + session transcript reads + fan-out summary.
parallel_ssh.stkFLEET_HOSTS="web1 web2 db1" FLEET_PASS=... s examples/parallel_ssh.stkThe headline: one ssh_login recipe, N hosts, parallel sessions, partitioned ok/failed results.
# orchestrate.stk, condensed — recipe splice into a playbook, then fan-out
val @plan = ( @{ Fleet::Recipes::yes_to_all(2) }, +{ send => "go\n" } )
p "spliced playbook: #{Fleet::Playbook::validate(\@plan)} steps"

val $res = Fleet::Fanout::run(["cat", "cat", "cat"], [
    +{ send => "ping\n" },
    +{ expect => qr/ping/, timeout => 5, name => "echo" },
])
val $sum = Fleet::Fanout::summarize($res)
p "fanout: #{$sum->{total}} targets, #{$sum->{ok}} ok, #{$sum->{failed}} failed"

Troubleshooting

  • A run failed — what happened? The result hashref carries the full transcript plus failed_step and error ("timeout on <step name>"). Dump it with Fleet::Session::transcript_text or read the +{ event, data } entries directly.
  • A prompt has regex metacharacters. Use expect_exact (or quotemeta the pattern) so a literal $, ., or (yes/no)? matches as itself instead of as a regex.
  • The prompt sometimes doesn't fire. Add retries with a retry_send => "\n" nudge, or mark the step optional when its absence is acceptable.
  • Credentials must not appear in logs. Use send_secret instead of send/send_line — the byte stream is transmitted in full but the transcript records a redacted *** event.
  • A large fleet exhausts file descriptors. Use Fleet::Fanout::batch with a $batch_size instead of run to bound concurrency.
  • Transient failures across the fleet. Wrap with retry_failed (one re-run) or retry_until($results, $steps, $max_attempts) (per-target cap); inspect group_by_error to see which hosts failed the same way.
  • Auditing what would be sent. Recipes are pure data — to_json the step list, or use Fleet::Playbook::dry_run for a per-step preview, before pointing it at production.

What's NOT in Here

By design — these are stryke builtins, so we don't re-wrap them. If a function in this library can be replaced with one builtin call, it's a bug.

CategoryBuiltins (call directly)
PTY primitivespty_spawn · pty_send · pty_read · pty_expect · pty_expect_table · pty_buffer · pty_alive · pty_eof · pty_close · pty_interact
Parallelismpmap · pgrep · ppool and the rest of the parallel suite
Remote dispatchcluster([...]) + pmap_on — persistent SSH worker pools for non-interactive remote compute; Fleet is for sessions that prompt back
Method-form sugarPtyHandle class (require "perl_pty_class.stk" from strykelang examples)

CLI

s bin/fleet.stk expect "sh -c 'sleep 1; echo ready'" ready 10   # spawn, wait, print match
s bin/fleet.stk exchange cat hello hello 5                      # spawn, send, wait
s bin/fleet.stk recipes                                         # list the recipe corpus
s bin/fleet.stk version
s bin/fleet.stk help

bin/fleet.stk covers one-shot expect/send loops from the shell. Anything with branches, retries, or more than two steps belongs in a playbook script — see examples/.

Tests

s test t/                       # assertions across every public function

t/test_fleet.stk is headless-CI safe by contract: every PTY assertion drives a local cat/sh — no network, no SSH, no target hosts (enforced by tests/repo-contract.sh). Recipes are asserted structurally as data. Exits TAP-style via test_run.

Layout

stryke-fleet/
├── stryke.toml                # pure-stryke package manifest (no [ffi])
├── Makefile                   # test / install / clean
├── LICENSE                    # MIT
├── lib/
│   ├── Fleet.stk              # `use Fleet` — pulls all four sublibs
│   ├── Session.stk            # `use Fleet::Session`  — transcripted PTY sessions
│   ├── Playbook.stk           # `use Fleet::Playbook` — declarative step runner
│   ├── Recipes.stk            # `use Fleet::Recipes`  — login/prompt corpus
│   └── Fanout.stk             # `use Fleet::Fanout`   — parallel multi-target runs
├── bin/
│   └── fleet.stk              # CLI front-end (one-shot expect/exchange)
├── t/
│   └── test_fleet.stk         # all-surface assertions (local processes only)
├── examples/
│   ├── local_demo.stk         # runnable anywhere — drives a local sh
│   ├── installer_autopilot.stk# yes_to_all against a fake installer
│   ├── parallel_ssh.stk       # the headline: recipe × N hosts, partitioned
│   └── orchestrate.stk        # all four layers: recipe + playbook + transcript + fanout
├── tests/                     # shell gate scripts (CI lints)
└── docs/                      # this site (GitHub Pages)

Sibling packages

Part of the stryke package family. Browse the others via the MenkeTechnologiesMeta umbrella repo: