Closes the gap between v0.6.7's ssh_exec/ssh_status primitives and the local
nc_* tools, so Bryan's two motivating workflows compose cleanly:
1. "Compare the ADT site NetConfig on qa to dev"
2. "Grab smat files from dev and bring to qa for regression testing"
ssh_pull, ssh_push (lib/ssh-helper.sh + larry.sh):
scp via the existing ControlMaster socket — no second auth, no second TCP
handshake. Master-not-open and missing-remote-file paths fail with explicit
messages ("open the master with /ssh-setup <alias> first"). Pull caches to
/tmp/larry-pulls/<alias>.<basename>.<hash-of-remote-path> when local_path is
omitted, so repeat pulls of the same remote file are idempotent. Validates
byte counts post-transfer to catch partial transfers.
ssh_pull_smat (lib/ssh-helper.sh + larry.sh):
Cloverleaf-aware smatdb pull. Full mode scp's the entire .smatdb;
sampled mode (days_back=N) runs sqlite3 server-side via ssh_exec to extract
up to 1000 recent messages as TSV with base64-encoded MessageContent blobs
(verified end-to-end with a synthetic smatdb fixture matching nc-msgs.sh's
smat_msgs schema). Avoids transferring multi-GB archives when only N
samples are needed.
nc_diff_interface tool (newly wired):
Promotes lib/nc-diff-interface.sh into the LLM-callable tool surface. Used
by the new /nc-diff-env slash command for workflow #1.
nc_regression cross-env (lib/nc-regression.sh + larry.sh):
source_ssh_alias / target_ssh_alias args. Phase 1 (discovery) and Phase 2
(sample) run via ssh_exec + ssh_pull / ssh_pull_smat against the source
alias. Phase 3/4 (route_test) push inputs over and pull outputs back via
ssh_push / ssh_pull. Phases 5/6 (diff + summary) stay local. Reports
reference the SSH alias names rather than raw user@host strings.
/nc-diff-env and /nc-regression-env slash commands (larry.sh):
Templated prompts to Larry-the-LLM that explicitly cite the motivating
workflows, call out ssh_status / ssh_pull / nc_diff_interface and the
nc_regression cross-env fields. Registered in _LARRY_SLASH_CMDS +
_LARRY_SLASH_CMDS_DESC + /help per v0.6.7 patterns.
Bug fix unearthed during cross-env work:
lib/nc-regression.sh phase_5 / phase_6 used printf 'FORMAT' where FORMAT
begins with '- '. bash 3.2 (macOS default) reads the leading '-' as a bad
option and emits nothing — silently dropping the entire "Configuration"
section of regression-summary.md. Switched the affected lines to
printf -- 'FORMAT' so the format string is unambiguous.
Tool/slash surface deltas vs v0.6.7:
Tools: 31 → 35 (+ssh_pull, +ssh_push, +ssh_pull_smat, +nc_diff_interface)
Slash commands: 34 → 36 (+/nc-diff-env, +/nc-regression-env)
Updated tool descriptions for read_file, grep_files, nc_msgs to point at
ssh_pull / ssh_pull_smat as the cross-env pre-step so Larry-the-LLM picks
the right chain on the first attempt.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
457 lines
19 KiB
Bash
Executable File
457 lines
19 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# ssh-helper.sh — secure SSH command execution via ControlMaster.
|
||
#
|
||
# Architecture:
|
||
# • Hosts configured in $LARRY_HOME/.ssh-hosts.tsv as
|
||
# alias \t user@host \t port
|
||
# • Passwords stored at $LARRY_HOME/.ssh-creds/<alias>, mode 0600.
|
||
# The password file is the single point of truth — to rotate (daily-changing
|
||
# passwords) just overwrite the file with the new one and re-run 'setup'.
|
||
# • sshpass reads the password via -f (file), so it never lands in argv or
|
||
# environment where Larry the LLM (or other processes via /proc) could see it.
|
||
# • The first 'setup' call opens a long-lived SSH ControlMaster connection
|
||
# (default ControlPersist=8h). Subsequent 'exec' calls multiplex through
|
||
# the master socket and need no password.
|
||
# • Larry's tool layer only sees: alias, command, command_output.
|
||
# Never the password. Never the user@host (unless added to the alias list).
|
||
#
|
||
# Subcommands:
|
||
# hosts list configured hosts
|
||
# add <alias> <user@host[:port]> add a host to the alias list
|
||
# remove <alias> remove an alias (also clears cred + socket)
|
||
# pass <alias> set/update the password (hidden interactive)
|
||
# setup <alias> open ControlMaster (uses stored password ONCE)
|
||
# close <alias> close ControlMaster
|
||
# status [alias] show open masters / cred presence
|
||
# exec <alias> <command...> run command via master (returns output)
|
||
# pull <alias> <remote> [local] scp remote → local via existing master
|
||
# push <alias> <local> <remote> scp local → remote via existing master
|
||
# pull-smat <alias> <site> <thread> [days_back]
|
||
# pull a thread's smatdb (full) or sample
|
||
# recent messages from it (sampled, TSV b64)
|
||
# help print this help
|
||
|
||
set -u
|
||
set -o pipefail
|
||
|
||
LARRY_HOME="${LARRY_HOME:-$HOME/.larry}"
|
||
SSH_HOSTS_FILE="$LARRY_HOME/.ssh-hosts.tsv"
|
||
SSH_CREDS_DIR="$LARRY_HOME/.ssh-creds"
|
||
SSH_SOCKETS_DIR="$LARRY_HOME/.ssh-sockets"
|
||
SSH_CONTROL_PERSIST="${LARRY_SSH_CONTROL_PERSIST:-8h}"
|
||
|
||
die() { printf 'ssh-helper: %s\n' "$*" >&2; exit 1; }
|
||
warn() { printf 'ssh-helper: warn: %s\n' "$*" >&2; }
|
||
ok() { printf 'ssh-helper: %s\n' "$*"; }
|
||
|
||
ensure_layout() {
|
||
mkdir -p "$LARRY_HOME" "$SSH_CREDS_DIR" "$SSH_SOCKETS_DIR" 2>/dev/null
|
||
chmod 700 "$LARRY_HOME" "$SSH_CREDS_DIR" "$SSH_SOCKETS_DIR" 2>/dev/null || true
|
||
if [ ! -f "$SSH_HOSTS_FILE" ]; then
|
||
umask 077
|
||
printf 'alias\taddr\tport\n' > "$SSH_HOSTS_FILE"
|
||
chmod 600 "$SSH_HOSTS_FILE"
|
||
fi
|
||
}
|
||
|
||
# read_host_addr ALIAS → echoes "ADDR\tPORT" or empty
|
||
read_host_addr() {
|
||
local alias="$1"
|
||
[ -f "$SSH_HOSTS_FILE" ] || { printf ''; return 1; }
|
||
awk -F'\t' -v a="$alias" 'NR>1 && $1==a { print $2 "\t" $3; exit }' < "$SSH_HOSTS_FILE"
|
||
}
|
||
|
||
require_sshpass() {
|
||
command -v sshpass >/dev/null 2>&1 \
|
||
|| die "sshpass not on PATH — install it (apt install sshpass / brew install sshpass) and retry"
|
||
}
|
||
|
||
cmd_help() {
|
||
sed -n '4,30p' "$0"
|
||
}
|
||
|
||
cmd_hosts() {
|
||
ensure_layout
|
||
if [ "$(wc -l < "$SSH_HOSTS_FILE")" -le 1 ]; then
|
||
echo "no hosts configured. Add with: ssh-helper.sh add <alias> <user@host[:port]>"
|
||
return 0
|
||
fi
|
||
printf 'alias user@host port cred master\n'
|
||
printf '%s\n' '───── ───────── ──── ──── ──────'
|
||
awk -F'\t' 'NR>1' "$SSH_HOSTS_FILE" | while IFS=$'\t' read -r alias addr port; do
|
||
local cred_state="–"
|
||
[ -f "$SSH_CREDS_DIR/$alias" ] && cred_state="✓"
|
||
local master_state="–"
|
||
local sock="$SSH_SOCKETS_DIR/$alias.sock"
|
||
if [ -S "$sock" ] && ssh -S "$sock" -O check -p "$port" "$addr" 2>/dev/null; then
|
||
master_state="open"
|
||
fi
|
||
printf '%-20s%-52s%-6s%-6s%s\n' "$alias" "$addr" "${port:-22}" "$cred_state" "$master_state"
|
||
done
|
||
}
|
||
|
||
cmd_add() {
|
||
local alias="${1:-}" target="${2:-}"
|
||
[ -n "$alias" ] && [ -n "$target" ] || die "usage: add <alias> <user@host[:port]>"
|
||
[[ "$target" =~ ^[^@[:space:]]+@[^:[:space:]]+(:[0-9]+)?$ ]] \
|
||
|| die "target must look like user@host or user@host:port"
|
||
local addr port
|
||
if [[ "$target" == *:* ]]; then
|
||
addr="${target%:*}"
|
||
port="${target##*:}"
|
||
else
|
||
addr="$target"
|
||
port="22"
|
||
fi
|
||
ensure_layout
|
||
# Reject duplicates (use 'remove' first)
|
||
if awk -F'\t' -v a="$alias" 'NR>1 && $1==a { found=1; exit } END { exit !found }' "$SSH_HOSTS_FILE"; then
|
||
die "alias '$alias' already exists. Use 'remove $alias' first."
|
||
fi
|
||
umask 077
|
||
printf '%s\t%s\t%s\n' "$alias" "$addr" "$port" >> "$SSH_HOSTS_FILE"
|
||
chmod 600 "$SSH_HOSTS_FILE"
|
||
ok "added $alias → $addr (port $port). Next: ssh-helper.sh pass $alias"
|
||
}
|
||
|
||
cmd_remove() {
|
||
local alias="${1:-}"
|
||
[ -n "$alias" ] || die "usage: remove <alias>"
|
||
ensure_layout
|
||
local tmp; tmp=$(mktemp)
|
||
awk -F'\t' -v a="$alias" 'NR==1 || $1!=a' "$SSH_HOSTS_FILE" > "$tmp" && mv "$tmp" "$SSH_HOSTS_FILE"
|
||
chmod 600 "$SSH_HOSTS_FILE"
|
||
# Close + clean master socket and cred
|
||
cmd_close "$alias" 2>/dev/null || true
|
||
rm -f "$SSH_CREDS_DIR/$alias" "$SSH_SOCKETS_DIR/$alias.sock" 2>/dev/null
|
||
ok "removed $alias (cred + socket cleared)"
|
||
}
|
||
|
||
cmd_pass() {
|
||
local alias="${1:-}"
|
||
[ -n "$alias" ] || die "usage: pass <alias>"
|
||
local addr_port; addr_port=$(read_host_addr "$alias")
|
||
[ -n "$addr_port" ] || die "no such alias: $alias (run 'add' first)"
|
||
ensure_layout
|
||
printf 'Password for %s (input is hidden; press Enter when done): ' "$alias" >&2
|
||
local pw=""
|
||
stty -echo 2>/dev/null
|
||
IFS= read -r pw </dev/tty || true
|
||
stty echo 2>/dev/null
|
||
echo "" >&2
|
||
[ -n "$pw" ] || die "no password entered"
|
||
umask 077
|
||
# NO trailing newline — sshpass -f expects raw password as full file content
|
||
printf '%s' "$pw" > "$SSH_CREDS_DIR/$alias"
|
||
chmod 600 "$SSH_CREDS_DIR/$alias"
|
||
ok "password saved to $SSH_CREDS_DIR/$alias (mode 0600). Next: ssh-helper.sh setup $alias"
|
||
}
|
||
|
||
cmd_setup() {
|
||
local alias="${1:-}"
|
||
[ -n "$alias" ] || die "usage: setup <alias>"
|
||
local addr_port; addr_port=$(read_host_addr "$alias")
|
||
[ -n "$addr_port" ] || die "no such alias: $alias"
|
||
local addr port
|
||
addr=$(printf '%s' "$addr_port" | cut -f1)
|
||
port=$(printf '%s' "$addr_port" | cut -f2)
|
||
ensure_layout
|
||
local sock="$SSH_SOCKETS_DIR/$alias.sock"
|
||
if [ -S "$sock" ] && ssh -S "$sock" -O check -p "$port" "$addr" 2>/dev/null; then
|
||
ok "master already open for $alias ($addr:$port)"
|
||
return 0
|
||
fi
|
||
local credfile="$SSH_CREDS_DIR/$alias"
|
||
[ -f "$credfile" ] || die "no password set for $alias — run 'pass $alias' first"
|
||
require_sshpass
|
||
ok "opening ssh master for $alias ($addr:$port) — ControlPersist=$SSH_CONTROL_PERSIST..."
|
||
if sshpass -f "$credfile" ssh \
|
||
-o "ControlMaster=yes" \
|
||
-o "ControlPath=$sock" \
|
||
-o "ControlPersist=$SSH_CONTROL_PERSIST" \
|
||
-o "StrictHostKeyChecking=accept-new" \
|
||
-o "ConnectTimeout=10" \
|
||
-p "$port" \
|
||
-N -f \
|
||
"$addr" 2>/tmp/larry-ssh-setup.err; then
|
||
if ssh -S "$sock" -O check -p "$port" "$addr" 2>/dev/null; then
|
||
ok "✓ master open: $alias → $addr:$port (socket: $sock)"
|
||
rm -f /tmp/larry-ssh-setup.err
|
||
return 0
|
||
fi
|
||
fi
|
||
printf 'ssh-helper: setup failed. sshpass/ssh stderr:\n' >&2
|
||
cat /tmp/larry-ssh-setup.err >&2 2>/dev/null
|
||
rm -f /tmp/larry-ssh-setup.err
|
||
return 1
|
||
}
|
||
|
||
cmd_close() {
|
||
local alias="${1:-}"
|
||
[ -n "$alias" ] || die "usage: close <alias>"
|
||
local addr_port; addr_port=$(read_host_addr "$alias") || addr_port=""
|
||
local sock="$SSH_SOCKETS_DIR/$alias.sock"
|
||
if [ -S "$sock" ] && [ -n "$addr_port" ]; then
|
||
local addr port
|
||
addr=$(printf '%s' "$addr_port" | cut -f1)
|
||
port=$(printf '%s' "$addr_port" | cut -f2)
|
||
ssh -S "$sock" -O exit -p "$port" "$addr" 2>/dev/null || true
|
||
fi
|
||
rm -f "$sock"
|
||
ok "closed master for $alias"
|
||
}
|
||
|
||
cmd_status() {
|
||
ensure_layout
|
||
if [ -n "${1:-}" ]; then
|
||
local alias="$1"
|
||
local addr_port; addr_port=$(read_host_addr "$alias")
|
||
[ -n "$addr_port" ] || die "no such alias: $alias"
|
||
local addr port
|
||
addr=$(printf '%s' "$addr_port" | cut -f1)
|
||
port=$(printf '%s' "$addr_port" | cut -f2)
|
||
local sock="$SSH_SOCKETS_DIR/$alias.sock"
|
||
printf 'alias: %s\naddr: %s\nport: %s\ncred: %s\nsocket: %s\nstatus: ' \
|
||
"$alias" "$addr" "$port" \
|
||
"$([ -f "$SSH_CREDS_DIR/$alias" ] && echo present || echo missing)" \
|
||
"$sock"
|
||
if [ -S "$sock" ] && ssh -S "$sock" -O check -p "$port" "$addr" 2>/dev/null; then
|
||
echo "master OPEN"
|
||
else
|
||
echo "no master (run setup)"
|
||
fi
|
||
return 0
|
||
fi
|
||
cmd_hosts
|
||
}
|
||
|
||
cmd_exec() {
|
||
local alias="${1:-}"
|
||
[ -n "$alias" ] || die "usage: exec <alias> <command...>"
|
||
shift
|
||
local cmd="$*"
|
||
[ -n "$cmd" ] || die "no command given"
|
||
local addr_port; addr_port=$(read_host_addr "$alias")
|
||
[ -n "$addr_port" ] || die "no such alias: $alias"
|
||
local addr port
|
||
addr=$(printf '%s' "$addr_port" | cut -f1)
|
||
port=$(printf '%s' "$addr_port" | cut -f2)
|
||
local sock="$SSH_SOCKETS_DIR/$alias.sock"
|
||
if [ ! -S "$sock" ] || ! ssh -S "$sock" -O check -p "$port" "$addr" 2>/dev/null; then
|
||
die "no open master for $alias — run 'setup $alias' first"
|
||
fi
|
||
# Multiplexed; no password needed.
|
||
ssh -S "$sock" -p "$port" -o BatchMode=yes "$addr" "$cmd"
|
||
}
|
||
|
||
# ── v0.6.8: scp helpers that multiplex via the existing ControlMaster ────────
|
||
# We use ssh's ControlPath/ControlMaster=no for scp (scp reads ssh-style options
|
||
# via -o), so the file transfer rides the open master and needs no second auth.
|
||
# Resolve ADDR/PORT/SOCK for an alias; die if master not open. Sets globals:
|
||
# _RH_ADDR _RH_PORT _RH_SOCK
|
||
_resolve_open_master() {
|
||
local alias="$1"
|
||
local addr_port; addr_port=$(read_host_addr "$alias")
|
||
[ -n "$addr_port" ] || die "no such alias: $alias"
|
||
_RH_ADDR=$(printf '%s' "$addr_port" | cut -f1)
|
||
_RH_PORT=$(printf '%s' "$addr_port" | cut -f2)
|
||
_RH_SOCK="$SSH_SOCKETS_DIR/$alias.sock"
|
||
if [ ! -S "$_RH_SOCK" ] || ! ssh -S "$_RH_SOCK" -O check -p "$_RH_PORT" "$_RH_ADDR" 2>/dev/null; then
|
||
die "no open master for $alias — open it with /ssh-setup $alias first"
|
||
fi
|
||
}
|
||
|
||
# Deterministic local cache path for ssh_pull.
|
||
# /tmp/larry-pulls/<alias>.<basename>.<short-hash-of-remote-path>
|
||
_pull_cache_path() {
|
||
local alias="$1" remote="$2"
|
||
local base; base=$(basename -- "$remote" 2>/dev/null)
|
||
[ -z "$base" ] && base="file"
|
||
# 8-char hex hash of full remote path. We try the most common hashers in
|
||
# turn; on a stripped box without any, fall back to a length+checksum proxy
|
||
# so the path is still deterministic per <alias,remote_path>.
|
||
local hash=""
|
||
if command -v shasum >/dev/null 2>&1; then
|
||
hash=$(printf '%s' "$remote" | shasum -a 1 2>/dev/null | cut -c1-8)
|
||
elif command -v sha1sum >/dev/null 2>&1; then
|
||
hash=$(printf '%s' "$remote" | sha1sum 2>/dev/null | cut -c1-8)
|
||
elif command -v md5sum >/dev/null 2>&1; then
|
||
hash=$(printf '%s' "$remote" | md5sum 2>/dev/null | cut -c1-8)
|
||
else
|
||
hash=$(printf '%s' "$remote" | cksum 2>/dev/null | awk '{printf "%08x", $1}' | cut -c1-8)
|
||
fi
|
||
[ -z "$hash" ] && hash="00000000"
|
||
mkdir -p /tmp/larry-pulls 2>/dev/null
|
||
printf '/tmp/larry-pulls/%s.%s.%s' "$alias" "$base" "$hash"
|
||
}
|
||
|
||
cmd_pull() {
|
||
local alias="${1:-}" remote="${2:-}" local_path="${3:-}"
|
||
[ -n "$alias" ] && [ -n "$remote" ] || die "usage: pull <alias> <remote_path> [local_path]"
|
||
_resolve_open_master "$alias"
|
||
[ -z "$local_path" ] && local_path=$(_pull_cache_path "$alias" "$remote")
|
||
mkdir -p "$(dirname "$local_path")" 2>/dev/null
|
||
|
||
# Get remote file size up-front for a partial-transfer sanity check.
|
||
local remote_size=""
|
||
remote_size=$(ssh -S "$_RH_SOCK" -p "$_RH_PORT" -o BatchMode=yes "$_RH_ADDR" \
|
||
"wc -c < $(printf '%q' "$remote") 2>/dev/null" 2>/dev/null | tr -d ' ')
|
||
if [ -z "$remote_size" ] || ! [[ "$remote_size" =~ ^[0-9]+$ ]]; then
|
||
die "remote file not found or not readable: $remote"
|
||
fi
|
||
|
||
# scp via the existing master: -o ControlPath=... -o ControlMaster=no
|
||
local scp_err; scp_err=$(mktemp 2>/dev/null || echo "/tmp/larry-scp.err.$$")
|
||
if scp -q \
|
||
-o "ControlPath=$_RH_SOCK" \
|
||
-o "ControlMaster=no" \
|
||
-o "BatchMode=yes" \
|
||
-P "$_RH_PORT" \
|
||
"$_RH_ADDR:$remote" "$local_path" 2>"$scp_err"; then
|
||
local got; got=$(wc -c < "$local_path" 2>/dev/null | tr -d ' ')
|
||
if [ "$got" != "$remote_size" ]; then
|
||
rm -f "$scp_err"
|
||
die "partial transfer: remote=$remote_size bytes, local=$got bytes ($local_path)"
|
||
fi
|
||
rm -f "$scp_err"
|
||
ok "pulled $alias:$remote → $local_path ($got bytes)"
|
||
# Print only the local path on the final line so callers (tool layer) can
|
||
# capture it deterministically with `tail -1` or similar.
|
||
printf '%s\n' "$local_path"
|
||
return 0
|
||
fi
|
||
local rc=$?
|
||
printf 'ssh-helper: scp pull failed (rc=%d):\n' "$rc" >&2
|
||
cat "$scp_err" >&2 2>/dev/null
|
||
rm -f "$scp_err"
|
||
return 1
|
||
}
|
||
|
||
cmd_push() {
|
||
local alias="${1:-}" local_path="${2:-}" remote="${3:-}"
|
||
[ -n "$alias" ] && [ -n "$local_path" ] && [ -n "$remote" ] \
|
||
|| die "usage: push <alias> <local_path> <remote_path>"
|
||
[ -f "$local_path" ] || die "local file not found: $local_path"
|
||
_resolve_open_master "$alias"
|
||
|
||
local local_size; local_size=$(wc -c < "$local_path" 2>/dev/null | tr -d ' ')
|
||
local scp_err; scp_err=$(mktemp 2>/dev/null || echo "/tmp/larry-scp.err.$$")
|
||
if scp -q \
|
||
-o "ControlPath=$_RH_SOCK" \
|
||
-o "ControlMaster=no" \
|
||
-o "BatchMode=yes" \
|
||
-P "$_RH_PORT" \
|
||
"$local_path" "$_RH_ADDR:$remote" 2>"$scp_err"; then
|
||
# Validate via remote wc -c.
|
||
local got
|
||
got=$(ssh -S "$_RH_SOCK" -p "$_RH_PORT" -o BatchMode=yes "$_RH_ADDR" \
|
||
"wc -c < $(printf '%q' "$remote") 2>/dev/null" 2>/dev/null | tr -d ' ')
|
||
if [ "$got" != "$local_size" ]; then
|
||
rm -f "$scp_err"
|
||
die "partial transfer: local=$local_size bytes, remote=$got bytes ($alias:$remote)"
|
||
fi
|
||
rm -f "$scp_err"
|
||
ok "pushed $local_path → $alias:$remote ($got bytes)"
|
||
return 0
|
||
fi
|
||
local rc=$?
|
||
printf 'ssh-helper: scp push failed (rc=%d):\n' "$rc" >&2
|
||
cat "$scp_err" >&2 2>/dev/null
|
||
rm -f "$scp_err"
|
||
return 1
|
||
}
|
||
|
||
# pull-smat: smart pull for a Cloverleaf thread's .smatdb file.
|
||
# Two modes:
|
||
# Full pull: pull-smat <alias> <site> <thread>
|
||
# Locates $HCISITEDIR/exec/processes/*/<thread>.smatdb on the
|
||
# remote via find, then scp's the entire .smatdb file.
|
||
# Sampled: pull-smat <alias> <site> <thread> <days_back>
|
||
# Runs sqlite3 server-side, extracts up to 1000 most-recent
|
||
# messages from the last <days_back> days, encodes each
|
||
# MessageContent BLOB as base64, returns TSV:
|
||
# unix_ts<TAB>direction<TAB>type<TAB>source<TAB>dest<TAB>message_blob_b64
|
||
# The schema (table=smat_msgs, columns Time/Type/SourceConn/
|
||
# DestConn/MessageContent) is the same one nc-msgs.sh uses.
|
||
cmd_pull_smat() {
|
||
local alias="${1:-}" site="${2:-}" thread="${3:-}" days_back="${4:-}"
|
||
[ -n "$alias" ] && [ -n "$site" ] && [ -n "$thread" ] \
|
||
|| die "usage: pull-smat <alias> <site> <thread> [days_back]"
|
||
_resolve_open_master "$alias"
|
||
|
||
# Discover the remote .smatdb path. We rely on HCIROOT being exported in
|
||
# the remote shell rc (typical Cloverleaf user profile), else SITEDIR is
|
||
# taken as <HCIROOT>/<site> via ssh-resolved $HCIROOT. We do the find
|
||
# remotely to avoid hard-coding process directory names.
|
||
local find_cmd
|
||
find_cmd='set -e; SDIR="${HCISITEDIR:-${HCIROOT:-}/'"$site"'}"; '
|
||
find_cmd+='[ -d "$SDIR" ] || { echo "ERROR: sitedir not found on remote: $SDIR" >&2; exit 2; }; '
|
||
find_cmd+='F=$(find "$SDIR/exec/processes" -maxdepth 2 -type f -name "'"$thread"'.smatdb" 2>/dev/null | head -1); '
|
||
find_cmd+='[ -n "$F" ] || F=$(find "$SDIR" -type f -name "'"$thread"'.smatdb" 2>/dev/null | head -1); '
|
||
find_cmd+='[ -n "$F" ] || { echo "ERROR: no smatdb found for thread '"$thread"' under $SDIR" >&2; exit 3; }; '
|
||
find_cmd+='printf "%s\n" "$F"'
|
||
|
||
local remote_smatdb
|
||
remote_smatdb=$(ssh -S "$_RH_SOCK" -p "$_RH_PORT" -o BatchMode=yes "$_RH_ADDR" "$find_cmd" 2>&1 | tail -1)
|
||
case "$remote_smatdb" in
|
||
ERROR:*|'') die "remote smatdb lookup failed: $remote_smatdb" ;;
|
||
esac
|
||
|
||
if [ -z "$days_back" ]; then
|
||
# Full mode: scp the whole .smatdb file.
|
||
local local_path
|
||
local_path=$(_pull_cache_path "$alias" "$remote_smatdb")
|
||
cmd_pull "$alias" "$remote_smatdb" "$local_path"
|
||
return $?
|
||
fi
|
||
|
||
# Sampled mode: run sqlite3 on the remote, return TSV with b64-encoded blobs.
|
||
# base64 -w0 is GNU coreutils; on BSD use plain base64 (no -w). We accept
|
||
# whichever is present; the awk in the SQL pipeline strips internal newlines
|
||
# for sturdy TSV.
|
||
#
|
||
# Output line shape (each message):
|
||
# <unix_ts_s>\t<direction>\t<type>\t<source>\t<dest>\t<b64-of-MessageContent>
|
||
# `direction` is "in" when DestConn=thread, else "out" (best-effort heuristic).
|
||
local sample_cmd
|
||
sample_cmd='set -e; '
|
||
sample_cmd+='which sqlite3 >/dev/null 2>&1 || { echo "ERROR: sqlite3 not on remote PATH" >&2; exit 4; }; '
|
||
sample_cmd+='B64() { if base64 --help 2>&1 | grep -q -- " -w"; then base64 -w0; else base64 | tr -d "\n"; fi; }; '
|
||
# Note: sqlite3 ".mode tabs" prints rows tab-separated; we redirect blob via
|
||
# writefile() into temp files, then base64 each. That avoids any binary
|
||
# mangling in the sqlite3 -ascii path. Approach: select rowids, then for each
|
||
# rowid pull MessageContent into a per-row temp file, b64 it inline.
|
||
sample_cmd+='TMP=$(mktemp -d); trap "rm -rf $TMP" EXIT; '
|
||
sample_cmd+='CUTOFF_MS=$(( ( $(date +%s) - '"$days_back"' * 86400 ) * 1000 )); '
|
||
sample_cmd+='sqlite3 "'"$remote_smatdb"'" "SELECT rowid, Time, IFNULL(Type,\"\"), IFNULL(SourceConn,\"\"), IFNULL(DestConn,\"\") FROM smat_msgs WHERE Time >= $CUTOFF_MS ORDER BY Time DESC LIMIT 1000" '
|
||
sample_cmd+='| while IFS="|" read -r rid tm typ src dst; do '
|
||
sample_cmd+=' blobfile="$TMP/$rid.bin"; '
|
||
sample_cmd+=' sqlite3 "'"$remote_smatdb"'" "SELECT writefile(\"$blobfile\", MessageContent) FROM smat_msgs WHERE rowid=$rid" >/dev/null 2>&1; '
|
||
sample_cmd+=' if [ "$dst" = "'"$thread"'" ]; then dir="in"; else dir="out"; fi; '
|
||
sample_cmd+=' printf "%s\t%s\t%s\t%s\t%s\t" "$(( tm / 1000 ))" "$dir" "$typ" "$src" "$dst"; '
|
||
sample_cmd+=' B64 < "$blobfile"; '
|
||
sample_cmd+=' printf "\n"; '
|
||
sample_cmd+='done; '
|
||
sample_cmd+='TOTAL=$(sqlite3 "'"$remote_smatdb"'" "SELECT COUNT(*) FROM smat_msgs WHERE Time >= $CUTOFF_MS"); '
|
||
sample_cmd+='RETURNED=$(sqlite3 "'"$remote_smatdb"'" "SELECT MIN(1000, COUNT(*)) FROM smat_msgs WHERE Time >= $CUTOFF_MS"); '
|
||
sample_cmd+='echo "# smatdb=$(basename '"$remote_smatdb"') days_back='"$days_back"' total_in_window=$TOTAL returned=$RETURNED truncated=$([ "$TOTAL" -gt 1000 ] && echo yes || echo no)" >&2'
|
||
|
||
ssh -S "$_RH_SOCK" -p "$_RH_PORT" -o BatchMode=yes "$_RH_ADDR" "$sample_cmd"
|
||
}
|
||
|
||
case "${1:-help}" in
|
||
hosts|list) shift; cmd_hosts ;;
|
||
add) shift; cmd_add "$@" ;;
|
||
remove|rm) shift; cmd_remove "$@" ;;
|
||
pass|passwd) shift; cmd_pass "$@" ;;
|
||
setup|open) shift; cmd_setup "$@" ;;
|
||
close|exit) shift; cmd_close "$@" ;;
|
||
status) shift; cmd_status "$@" ;;
|
||
exec|run) shift; cmd_exec "$@" ;;
|
||
pull) shift; cmd_pull "$@" ;;
|
||
push) shift; cmd_push "$@" ;;
|
||
pull-smat) shift; cmd_pull_smat "$@" ;;
|
||
-h|--help|help) cmd_help ;;
|
||
*) die "unknown subcommand: ${1:-} (run with --help)" ;;
|
||
esac
|