diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d442da..a8e0fe5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.3.0] - 2026-06-30 + +### Added +- Multiple targets in a single run. Mark several hosts with `TAB` in the fzf + picker, enter several numbers in the menu, or pass a comma-separated list to + `--target` (which may also be repeated). The payload is deployed to each + target in turn. +- `--keep-going` / `-k`: with multiple targets, continue after a target fails + and report a summary at the end. By default deployment **stops at the first + failure**; the exit code is non-zero if any target failed. + +### Changed +- The deploy plan lists every selected target. `--dry-run` shows the per-target + commands once with a `` placeholder instead of repeating them. +- Each target gets its own short-lived multiplexed SSH connection (one auth per + host), torn down before moving on or on abort. + ## [1.2.0] - 2026-06-25 ### Added diff --git a/README.md b/README.md index 1218342..aba92f9 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ You keep small provisioning / maintenance scripts on your machine and want to ru ## Features - **Host picker** from `~/.ssh/config` — fuzzy ([fzf](https://github.com/junegunn/fzf)) with a live `ssh -G` preview, or a numbered-menu fallback when fzf isn't installed. +- **Multiple targets in one run** — mark several hosts (`TAB` in fzf, several numbers in the menu, or `-t web01,web02`); deploys to each in turn, **stopping at the first failure** (or `--keep-going` to push through and get a summary). - **Uses your existing SSH setup** — keys, jump hosts, YubiKey/FIDO, ports — nothing to reconfigure. Manual `user@host` always available. - **Safe by default** — a deploy plan + `y/N` confirm (skip with `-y`), and a `--dry-run` that prints the exact commands without touching anything. - **Readable output** — colors that auto-disable when piped, on `--no-color`, or when `NO_COLOR` is set. @@ -64,18 +65,21 @@ ARGUMENTS payload local script to run as root on the target (required) OPTIONS - -t, --target HOST deploy to this ssh alias / user@host (skip the picker) + -t, --target HOSTS deploy to these ssh aliases / user@hosts (comma-separated) -y, --yes don't ask for confirmation -n, --dry-run show what would happen; don't copy or run + -k, --keep-going with multiple targets, continue after a failure (default: stop) -c, --config FILE ssh config to read (default: ~/.ssh/config) --no-color disable colored output -h, --help show this help -V, --version show version EXAMPLES - ssh-deploy setup.sh # pick a host (fzf/menu), then deploy - ssh-deploy -t web01 setup.sh # straight to a known host - ssh-deploy -n -t web01 setup.sh # dry-run: show the plan, change nothing + ssh-deploy setup.sh # pick host(s) (fzf/menu), then deploy + ssh-deploy -t web01 setup.sh # straight to a known host + ssh-deploy -t web01,web02 setup.sh # several hosts, stop on first error + ssh-deploy -k -t web01,web02 setup.sh # keep going even if one fails + ssh-deploy -n -t web01 setup.sh # dry-run: show the plan, change nothing ``` Full reference: `man ssh-deploy` (installed by Homebrew; documents exit status, files, and environment too). @@ -83,11 +87,11 @@ Full reference: `man ssh-deploy` (installed by Homebrew; documents exit status, ## How it works 1. Parse `~/.ssh/config` (or `--config FILE`) for `Host` aliases (wildcard/pattern entries are skipped; `Include` files aren't followed). The config is **optional** — with no `~/.ssh/config` and no `--config`, the picker is skipped and you're prompted for a target (or pass `--target`). When a config is present it's also used for the actual `scp`/`ssh` connection. -2. Pick one (fzf preview shows the resolved `ssh -G` hostname/user/port/key) or pass `--target`; manual `user@host` is always an option. -3. Print the deploy plan and ask to confirm (`-y` to skip, `-n` to only preview). -4. Open one shared SSH connection (so the password / key-touch happens **once**), `mktemp` a private file under `/tmp` on the target, and `scp` the payload into it — no predictable name, no symlink/clobber race. +2. Pick one or more (fzf preview shows the resolved `ssh -G` hostname/user/port/key; `TAB` marks several) or pass `--target` (comma-separated); manual `user@host` is always an option. +3. Print the deploy plan — listing every target — and ask to confirm (`-y` to skip, `-n` to only preview). +4. For each target, open one shared SSH connection (so the password / key-touch happens **once per host**), `mktemp` a private file under `/tmp` on the target, and `scp` the payload into it — no predictable name, no symlink/clobber race. 5. `ssh -t "sudo bash ; rm -f "` — the TTY lets the `sudo` password (and any key touch) work, and the temp copy is always removed. -6. Exit with the remote script's exit code. +6. Stop at the first target whose script fails (or continue with `--keep-going`), and exit with the remote script's exit code — non-zero if any target failed. ## Security diff --git a/ssh-deploy b/ssh-deploy index df76d47..b42ca8e 100755 --- a/ssh-deploy +++ b/ssh-deploy @@ -10,25 +10,28 @@ # License: MIT — see LICENSE # set -uo pipefail -VERSION=1.2.0 +VERSION=1.3.0 # ---------------------------------------------------------------- defaults / args SSH_CONFIG="$HOME/.ssh/config" PAYLOAD="" -opt_target="" opt_yes=0 opt_dryrun=0 opt_color=1 want_help=0 config_explicit=0 +opt_target="" opt_yes=0 opt_dryrun=0 opt_color=1 opt_keepgoing=0 want_help=0 config_explicit=0 while [ $# -gt 0 ]; do case "$1" in - -h|--help) want_help=1; shift ;; - -V|--version) echo "ssh-deploy $VERSION"; exit 0 ;; - -y|--yes) opt_yes=1; shift ;; - -n|--dry-run) opt_dryrun=1; shift ;; - --no-color) opt_color=0; shift ;; - -t|--target) opt_target="${2:-}"; [ -n "$opt_target" ] || { echo "--target needs a value" >&2; exit 2; }; shift 2 ;; - -c|--config) SSH_CONFIG="${2:-}"; [ -n "$SSH_CONFIG" ] || { echo "--config needs a value" >&2; exit 2; }; config_explicit=1; shift 2 ;; - --) shift; break ;; - -*) echo "unknown option: $1 (try --help)" >&2; exit 2 ;; - *) PAYLOAD="$1"; shift ;; + -h|--help) want_help=1; shift ;; + -V|--version) echo "ssh-deploy $VERSION"; exit 0 ;; + -y|--yes) opt_yes=1; shift ;; + -n|--dry-run) opt_dryrun=1; shift ;; + -k|--keep-going) opt_keepgoing=1; shift ;; + --no-color) opt_color=0; shift ;; + # -t may be given more than once and/or as a comma-separated list; the values + # accumulate into one comma-joined string that add_targets() later splits. + -t|--target) tv="${2:-}"; [ -n "$tv" ] || { echo "--target needs a value" >&2; exit 2; }; opt_target="${opt_target:+$opt_target,}$tv"; shift 2 ;; + -c|--config) SSH_CONFIG="${2:-}"; [ -n "$SSH_CONFIG" ] || { echo "--config needs a value" >&2; exit 2; }; config_explicit=1; shift 2 ;; + --) shift; break ;; + -*) echo "unknown option: $1 (try --help)" >&2; exit 2 ;; + *) PAYLOAD="$1"; shift ;; esac done @@ -62,18 +65,21 @@ ${BOLD}ARGUMENTS${RST} payload local script to run as root on the target ${DIM}(required)${RST} ${BOLD}OPTIONS${RST} - ${CYN}-t, --target HOST${RST} deploy to this ssh alias / user@host (skip the picker) + ${CYN}-t, --target HOSTS${RST} deploy to these ssh aliases / user@hosts ${DIM}(comma-separated; skip the picker)${RST} ${CYN}-y, --yes${RST} don't ask for confirmation ${CYN}-n, --dry-run${RST} show what would happen; don't copy or run + ${CYN}-k, --keep-going${RST} with multiple targets, continue after a failure ${DIM}(default: stop)${RST} ${CYN}-c, --config FILE${RST} ssh config to read ${DIM}(default: ~/.ssh/config)${RST} ${CYN}--no-color${RST} disable colored output ${CYN}-h, --help${RST} show this help ${CYN}-V, --version${RST} show version ${BOLD}EXAMPLES${RST} - ssh-deploy setup.sh ${DIM}# pick a host (fzf/menu), then deploy${RST} - ssh-deploy -t web01 setup.sh ${DIM}# straight to a known host${RST} - ssh-deploy -n -t web01 setup.sh ${DIM}# dry-run: show the plan, change nothing${RST} + ssh-deploy setup.sh ${DIM}# pick host(s) (fzf/menu), then deploy${RST} + ssh-deploy -t web01 setup.sh ${DIM}# straight to a known host${RST} + ssh-deploy -t web01,web02 setup.sh ${DIM}# deploy to several, stop on first error${RST} + ssh-deploy -k -t web01,web02 setup.sh${DIM} # keep going even if one fails${RST} + ssh-deploy -n -t web01 setup.sh ${DIM}# dry-run: show the plan, change nothing${RST} EOF } @@ -119,62 +125,94 @@ print_hosts() { # ---------------------------------------------------------------- payload check [ -f "$PAYLOAD" ] || die "payload not found: ${BOLD}$PAYLOAD${RST}" -# ---------------------------------------------------------------- choose target +# ---------------------------------------------------------------- choose target(s) +# Append comma- or space-separated tokens to the global targets array. +add_targets() { + local s="${1//,/ }" tok + # shellcheck disable=SC2086 # intentional word-splitting on the normalized list + for tok in $s; do [ -n "$tok" ] && targets+=("$tok"); done +} + pick_fzf() { - local lines="" idx choice preview + local lines="" idx preview for ((idx=0; idx<${#aliases[@]}; idx++)); do lines+="${aliases[$idx]}"$'\t'"${hosts[$idx]}"$'\n' done lines+="[manual]"$'\t'"type a target by hand"$'\n' # resolve against the same config the deploy will use preview="ssh -G -F '$SSH_CONFIG' {1} 2>/dev/null | grep -iE '^(hostname|user|port|identityfile) ' || echo '(manual entry)'" - choice=$(printf '%s' "$lines" | fzf \ + # --multi: TAB marks several hosts; fzf echoes each selected line verbatim. + printf '%s' "$lines" | fzf --multi \ --delimiter='\t' --with-nth=1,2 --ansi \ --prompt='deploy ▸ ' --height='50%' --reverse --border \ - --header='enter = deploy esc = cancel' \ + --header='tab = mark enter = deploy esc = cancel' \ --preview "$preview" \ - --preview-window='right:48%') || return 1 - [ -n "$choice" ] || return 1 - printf '%s' "${choice%%$'\t'*}" + --preview-window='right:48%' || return 1 } -target="" +targets=() if [ -n "$opt_target" ]; then - target="$opt_target" + add_targets "$opt_target" elif [ ${#aliases[@]} -eq 0 ]; then if [ -f "$SSH_CONFIG" ]; then warn "no host aliases in $SSH_CONFIG" else warn "no ssh config at $SSH_CONFIG — enter a target manually"; fi - printf '%sTarget (user@host):%s ' "$BOLD" "$RST"; read -r target + printf '%sTarget(s) (user@host, comma-separated):%s ' "$BOLD" "$RST"; read -r line; add_targets "$line" elif command -v fzf >/dev/null 2>&1; then sel="$(pick_fzf)" || die "cancelled." - if [ "$sel" = "[manual]" ]; then printf '%sTarget (user@host):%s ' "$BOLD" "$RST"; read -r target - else target="$sel"; fi + [ -n "$sel" ] || die "no target chosen." + while IFS= read -r ln; do + [ -n "$ln" ] || continue + a="${ln%%$'\t'*}" + if [ "$a" = "[manual]" ]; then + printf '%sTarget(s) (user@host, comma-separated):%s ' "$BOLD" "$RST"; read -r m; add_targets "$m" + else + targets+=("$a") + fi + done </dev/null && [ "$n" -le ${#aliases[@]} ]; then target="${aliases[$((n-1))]}" - else die "invalid selection."; fi + printf '%sNumber(s):%s ' "$BOLD" "$RST"; read -r nums + nums="${nums//,/ }" + # shellcheck disable=SC2086 # intentional word-splitting on the entered numbers + for n in $nums; do + if [ "$n" = "$(( ${#aliases[@]} + 1 ))" ]; then + printf '%sTarget(s) (user@host, comma-separated):%s ' "$BOLD" "$RST"; read -r m; add_targets "$m" + elif [ "$n" -ge 1 ] 2>/dev/null && [ "$n" -le ${#aliases[@]} ]; then + targets+=("${aliases[$((n-1))]}") + else + die "invalid selection: $n" + fi + done fi -[ -n "$target" ] || die "no target chosen." +[ ${#targets[@]} -gt 0 ] || die "no target chosen." # ---------------------------------------------------------------- plan + confirm base="$(basename "$PAYLOAD")" remote_tmpl="/tmp/ssh-deploy.XXXXXX" title "deploy plan" -printf " %sPayload%s %s\n" "$DIM" "$RST" "$PAYLOAD" -printf " %sTarget %s %s%s%s\n" "$DIM" "$RST" "$BOLD$CYN" "$target" "$RST" +printf " %sPayload%s %s\n" "$DIM" "$RST" "$PAYLOAD" +if [ ${#targets[@]} -eq 1 ]; then + printf " %sTarget %s %s%s%s\n" "$DIM" "$RST" "$BOLD$CYN" "${targets[0]}" "$RST" +else + printf " %sTargets%s %s%d%s %s(%s)%s\n" "$DIM" "$RST" "$BOLD" "${#targets[@]}" "$RST" \ + "$DIM" "$([ "$opt_keepgoing" = "1" ] && echo 'keep-going' || echo 'stop on first error')" "$RST" + for t in "${targets[@]}"; do printf " %s•%s %s%s%s\n" "$DIM" "$RST" "$BOLD$CYN" "$t" "$RST"; done +fi printf " %sRemote %s %s %s(mktemp; sudo bash, then removed)%s\n" "$DIM" "$RST" "$remote_tmpl" "$DIM" "$RST" if [ "$opt_dryrun" = "1" ]; then + # The per-target commands are identical bar the host, so show them once with a + # placeholder rather than repeating them for every target listed above. warn "dry-run — nothing deployed" F=""; [ -f "$SSH_CONFIG" ] && F="-F $SSH_CONFIG " - info "remote=\$(ssh ${F}$target 'mktemp $remote_tmpl')" - info "scp ${F}\"$PAYLOAD\" \"$target:\$remote\"" - info "ssh ${F}-t $target \"sudo bash \$remote; rm -f \$remote\"" + info "remote=\$(ssh ${F} 'mktemp $remote_tmpl')" + info "scp ${F}\"$PAYLOAD\" \":\$remote\"" + info "ssh ${F}-t \"sudo bash \$remote; rm -f \$remote\"" exit 0 fi @@ -184,27 +222,63 @@ if [ "$opt_yes" != "1" ]; then fi # ---------------------------------------------------------------- deploy -# Share one SSH connection for mktemp + scp + run, so authentication (password -# or key touch) happens once and all three operations reuse it. The control +# Each target gets its own short-lived multiplexed SSH connection: one auth +# (password / key touch) per host, shared by mktemp + scp + run. The control # socket lives under /tmp to keep the path short (Unix socket length limit). -ctl_dir="$(mktemp -d /tmp/ssh-deploy.XXXXXX)" || die "could not create control dir." -ssh_opts=(-o ControlMaster=auto -o "ControlPath=$ctl_dir/c" -o ControlPersist=30s) -# only pass -F when the config exists — ssh errors out on a missing -F file -[ -f "$SSH_CONFIG" ] && ssh_opts+=(-F "$SSH_CONFIG") -trap 'ssh "${ssh_opts[@]}" -O exit "$target" 2>/dev/null; rm -rf "$ctl_dir"' EXIT - -echo -step "copying ${BOLD}$base${RST} → ${CYN}$target${RST}" -# Create the remote file atomically (mktemp, mode 600): no predictable /tmp -# name, no symlink/clobber race, and the path carries no shell-special chars. -# shellcheck disable=SC2029 # remote_tmpl is a fixed template — expanding it client-side is intended -remote="$(ssh "${ssh_opts[@]}" "$target" "mktemp $remote_tmpl")" || die "could not create remote temp file." -[ -n "$remote" ] || die "remote mktemp returned an empty path." -scp "${ssh_opts[@]}" -q "$PAYLOAD" "$target:$remote" || die "scp failed." -ok "copied" -step "running on ${CYN}$target${RST} ${DIM}(sudo)${RST}" -hr -ssh "${ssh_opts[@]}" -t "$target" "sudo bash '$remote'; r=\$?; rm -f '$remote'; exit \$r" -rc=$? -hr -if [ "$rc" -eq 0 ]; then ok "done on ${BOLD}$target${RST}"; else die "finished with errors (rc=$rc) on $target"; fi +# cur_ctl/cur_target/ssh_opts track the in-flight connection so the EXIT trap can +# tear it down even if we abort mid-deploy. +cur_ctl="" cur_target="" ssh_opts=() +close_master() { + [ -n "$cur_ctl" ] || return 0 + ssh "${ssh_opts[@]}" -O exit "$cur_target" 2>/dev/null + rm -rf "$cur_ctl" + cur_ctl="" +} +trap close_master EXIT + +deploy_one() { + local target="$1" remote rc + cur_ctl="$(mktemp -d /tmp/ssh-deploy.XXXXXX)" || { warn "could not create control dir for $target"; return 1; } + cur_target="$target" + ssh_opts=(-o ControlMaster=auto -o "ControlPath=$cur_ctl/c" -o ControlPersist=30s) + # only pass -F when the config exists — ssh errors out on a missing -F file + [ -f "$SSH_CONFIG" ] && ssh_opts+=(-F "$SSH_CONFIG") + + echo + step "copying ${BOLD}$base${RST} → ${CYN}$target${RST}" + # Create the remote file atomically (mktemp, mode 600): no predictable /tmp + # name, no symlink/clobber race, and the path carries no shell-special chars. + # shellcheck disable=SC2029 # remote_tmpl is a fixed template — expanding it client-side is intended + remote="$(ssh "${ssh_opts[@]}" "$target" "mktemp $remote_tmpl")" || { warn "could not create remote temp file on $target"; close_master; return 1; } + [ -n "$remote" ] || { warn "remote mktemp returned an empty path on $target"; close_master; return 1; } + scp "${ssh_opts[@]}" -q "$PAYLOAD" "$target:$remote" || { warn "scp to $target failed"; close_master; return 1; } + ok "copied" + step "running on ${CYN}$target${RST} ${DIM}(sudo)${RST}" + hr + ssh "${ssh_opts[@]}" -t "$target" "sudo bash '$remote'; r=\$?; rm -f '$remote'; exit \$r" + rc=$? + hr + close_master + return "$rc" +} + +last_rc=0 failed=() +for t in "${targets[@]}"; do + if deploy_one "$t"; then + ok "done on ${BOLD}$t${RST}" + else + last_rc=$? + failed+=("$t") + if [ "$opt_keepgoing" = "1" ]; then + warn "errors (rc=$last_rc) on $t — continuing" + else + die "finished with errors (rc=$last_rc) on $t" + fi + fi +done + +if [ ${#failed[@]} -gt 0 ]; then + die "completed with failures (rc=$last_rc) on: ${failed[*]}" +fi +[ ${#targets[@]} -gt 1 ] && ok "all ${#targets[@]} targets succeeded" +exit 0 diff --git a/ssh-deploy.1 b/ssh-deploy.1 index e930817..69f96a5 100644 --- a/ssh-deploy.1 +++ b/ssh-deploy.1 @@ -1,6 +1,6 @@ .\" Manpage for ssh-deploy. .\" Keep the .TH version in sync with VERSION in the ssh-deploy script. -.TH SSH-DEPLOY 1 "2026-06-25" "ssh-deploy 1.2.0" "User Commands" +.TH SSH-DEPLOY 1 "2026-06-30" "ssh-deploy 1.3.0" "User Commands" .SH NAME ssh\-deploy \- push a local script to an SSH host and run it there as root .SH SYNOPSIS @@ -23,6 +23,17 @@ A manual .I user@host entry is always available. .PP +Several targets may be selected at once (mark multiple hosts with +.B TAB +in the +.BR fzf (1) +picker, give several numbers in the menu, or pass a comma\-separated list to +.BR \-\-target ). +The payload is then deployed to each target in turn. By default deployment stops +at the first target whose script fails; +.B \-\-keep\-going +continues through the rest and reports a summary at the end. +.PP Before anything is copied, .B ssh\-deploy prints a deploy plan (payload, target, and remote path) and asks for @@ -55,14 +66,19 @@ and connections. .SH OPTIONS .TP -.BR \-t ", " \-\-target " " \fIHOST\fR -Deploy to this SSH alias or -.I user@host -and skip the host picker. +.BR \-t ", " \-\-target " " \fIHOSTS\fR +Deploy to these SSH aliases or +.IR user@host es, +skipping the host picker. Accepts a comma\-separated list and may be given more +than once; the values accumulate. .TP .BR \-y ", " \-\-yes Do not ask for confirmation before deploying. .TP +.BR \-k ", " \-\-keep\-going +When deploying to multiple targets, continue after a target fails instead of +stopping. The exit status is non\-zero if any target failed. +.TP .BR \-n ", " \-\-dry\-run Show what would happen \(em the plan and the exact commands \(em without copying or running anything. @@ -108,6 +124,12 @@ failed, the deploy was cancelled, or the remote script exited non\-zero). When the remote script itself fails, its exit status is reported in the error message; the process exit code is .BR 1 . +With multiple targets, the exit code is +.B 1 +if any target failed \(em immediately on the first failure, or after all targets +when +.B \-\-keep\-going +is used. .SH FILES .TP .I ~/.ssh/config @@ -143,6 +165,14 @@ ssh\-deploy \-t web01 setup.sh .EE .RE .PP +Deploy to several hosts, continuing even if one fails: +.PP +.RS +.EX +ssh\-deploy \-k \-t web01,web02,db setup.sh +.EE +.RE +.PP Preview the plan and commands without changing anything: .PP .RS diff --git a/test/ssh-deploy.bats b/test/ssh-deploy.bats index 2fe1440..a1bfdb7 100644 --- a/test/ssh-deploy.bats +++ b/test/ssh-deploy.bats @@ -64,7 +64,7 @@ teardown() { @test "--version prints name and version" { run "$SCRIPT" --version [ "$status" -eq 0 ] - [ "$output" = "ssh-deploy 1.2.0" ] + [ "$output" = "ssh-deploy 1.3.0" ] } @test "--help shows usage and exits 0" { @@ -122,7 +122,7 @@ teardown() { run bash -c "printf 'myhost\n' | '$SCRIPT' --no-color -y '$PAYLOAD'" [ "$status" -eq 0 ] [[ "$output" == *"enter a target manually"* ]] - [[ "$output" == *"Target (user@host)"* ]] + [[ "$output" == *"Target(s) (user@host"* ]] [[ "$output" == *"done on"* ]] grep -q "myhost" "$SSHLOG" ! grep -q -- "-F" "$SSHLOG" @@ -199,3 +199,61 @@ EOS [ "$status" -eq 1 ] [[ "$output" == *"errors"* ]] } + +# --- multiple targets -------------------------------------------------------- + +# Stub whose remote run fails only on web01 (mktemp/scp still succeed), so the +# multi-target failure tests can tell stop-on-error from --keep-going. +stub_fail_web01() { + cat > "$STUB/ssh" <<'EOS' +#!/usr/bin/env bash +echo "ssh $*" >> "$SSHLOG" +for a in "$@"; do case "$a" in *mktemp*) echo "/tmp/ssh-deploy.testXXXX"; exit 0 ;; esac; done +run=0 web01=0 +for a in "$@"; do + case "$a" in *"sudo bash"*) run=1 ;; esac + case "$a" in web01) web01=1 ;; esac +done +[ "$run" = 1 ] && [ "$web01" = 1 ] && exit 7 +exit 0 +EOS + chmod +x "$STUB/ssh" +} + +@test "comma-separated -t deploys to every target" { + run "$SCRIPT" --no-color -c "$CONFIG" -y -t web01,db "$PAYLOAD" + [ "$status" -eq 0 ] + [[ "$output" == *"done on"*"web01"* ]] + [[ "$output" == *"done on"*"db"* ]] + [[ "$output" == *"all 2 targets succeeded"* ]] + grep -q -- "-t web01 " "$SSHLOG" + grep -q -- "-t db " "$SSHLOG" +} + +@test "stop on first error: later targets are not touched" { + stub_fail_web01 + run "$SCRIPT" --no-color -c "$CONFIG" -y -t web01,db "$PAYLOAD" + [ "$status" -eq 1 ] + [[ "$output" == *"finished with errors"*"web01"* ]] + ! grep -q "db" "$SSHLOG" # stopped before reaching the second host +} + +@test "--keep-going continues past a failure and reports a summary" { + stub_fail_web01 + run "$SCRIPT" --no-color -c "$CONFIG" -y -k -t web01,db "$PAYLOAD" + [ "$status" -eq 1 ] + [[ "$output" == *"continuing"* ]] + [[ "$output" == *"completed with failures"*"web01"* ]] + [[ "$output" == *"done on"*"db"* ]] + grep -q -- "-t db " "$SSHLOG" # second host was still attempted +} + +@test "dry-run with multiple targets lists them and shows the plan once" { + run "$SCRIPT" --no-color -c "$CONFIG" -n -t web01,db "$PAYLOAD" + [ "$status" -eq 0 ] + [[ "$output" == *"web01"* ]] + [[ "$output" == *"db"* ]] + [[ "$output" == *""* ]] # generic template, not per-target + [[ "$output" == *"scp -F"* ]] + [ ! -s "$SSHLOG" ] +}