diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d34e93..b7a1cf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -290,6 +290,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 of the canonical BBO identity, so a count-only change at an unchanged price/size is a distinct quote. ### Changed +- Installer (`scripts/connect*.sh`) now detects an **existing edge-connect instance** before + installing: if a container named `$DZ_NAME` (default `doublezero-edge-connect`) already exists on + the host, the installer warns (naming the instance's env/image, since all three installers share + `$DZ_NAME` — so e.g. the testnet installer flags a live *mainnet* container) and prompts to + **reinstall or cancel** instead of silently colliding with the live tunnel/ports. On reinstall it + prints "Uninstalling existing instance..." and, for a running instance, tears it down **gracefully + via `docker stop`** — the container entrypoint's SIGTERM trap runs a bounded `doublezero + disconnect` (releasing the GRE tunnel/routes/on-chain session) before doublezerod is killed — + rather than a raw, unbounded `docker exec … disconnect`; the stop is `timeout`-guarded so a wedged + or restarting container can't hang the installer. After removal it verifies the `doublezero1` + tunnel interface is actually gone from the host netns and warns loudly if it lingers (orphaned + session). Interactively, declining aborts and leaves the instance untouched; non-interactively + (`DZ_ASSUME_YES=1`, or no usable TTY) it reinstalls, preserving the previous silent-reinstall + behaviour for automation. TTY detection probes an actual `/dev/tty` open rather than trusting + `-r`, so a headless run with no controlling terminal is classified correctly. The env/image + labelling is best-effort — a `docker inspect` that fails mid-teardown (container removed between + detection and inspect, or a daemon blip) no longer aborts the installer under `set -o pipefail`. + Applied identically to all three installers; covered by `tests/scripts/reinstall_existing.bats`. - `dz_depth_dropped_total` now carries a `publisher` label (the dropped copy's source class), symmetric with `dz_depth_admitted_total`, so a lagging publisher losing the book race is directly visible (#66). This changes the label set of an existing series — exact-label matchers diff --git a/scripts/connect-devnet.sh b/scripts/connect-devnet.sh index 4f0f2f2..34a75bf 100755 --- a/scripts/connect-devnet.sh +++ b/scripts/connect-devnet.sh @@ -362,6 +362,72 @@ if ! command -v docker >/dev/null 2>&1; then fi $SUDO docker info >/dev/null 2>&1 || die "Docker is installed but the daemon isn't reachable. Start it (e.g. 'sudo systemctl start docker') and re-run." +# ---------------------------------------------------------------------------- +# 3b. existing instance? reinstall (graceful teardown) or cancel +# ---------------------------------------------------------------------------- +# A prior run leaves a long-lived container (--restart unless-stopped) that holds +# the DoubleZero tunnel and the WS sink. Re-running the installer over a live +# instance would collide (same container name, same tunnel, same host ports), so +# detect one up front and tear it down cleanly first. +reinstall_existing_instance() { + $SUDO docker ps -a -q --filter "name=^${DZ_NAME}$" 2>/dev/null | grep -q . || return 0 + + local running="" existing_env="" existing_img="" + $SUDO docker ps -q --filter "name=^${DZ_NAME}$" 2>/dev/null | grep -q . && running=1 + # Label the victim: all three installers share DZ_NAME, so e.g. the testnet + # installer can find a live *mainnet* container -- name the network/image so the + # operator isn't asked to destroy an unidentified instance. + existing_env="$($SUDO docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$DZ_NAME" 2>/dev/null | sed -n 's/^DZ_ENV=//p' | head -1 || true)" + existing_img="$($SUDO docker inspect -f '{{.Config.Image}}' "$DZ_NAME" 2>/dev/null || true)" + warn "An edge-connect instance ('$DZ_NAME'${existing_env:+, env=$existing_env}${existing_img:+, image=$existing_img}) already exists on this host${running:+ and is running}." + + # Decide whether to reinstall, keeping the three cases distinct so we neither + # break headless automation nor mislabel a genuine decline: + # DZ_ASSUME_YES=1 -> reinstall (skip the prompt) + # interactive decline -> abort (the operator said no) + # no usable TTY, !yes -> reinstall, but say so (pre-3b behaviour was a silent + # reinstall; keep automation working rather than abort) + # A readable /dev/tty inode (-r) can still fail to OPEN with no controlling + # terminal (cron/systemd/`curl|bash` without a tty), so probe an actual open + # rather than trusting -r (and skip confirm()'s own tty read in that case). + if [ "$DZ_ASSUME_YES" = 1 ]; then + : + elif { : <"$TTY"; } 2>/dev/null; then + confirm "Reinstall? This disconnects and removes the existing instance" \ + || die "Cancelled: leaving the existing instance in place (manage it with 'sudo docker logs $DZ_NAME')." + else + warn "No terminal to prompt on and DZ_ASSUME_YES is unset; reinstalling to preserve non-interactive behaviour (set DZ_ASSUME_YES=1 to silence this, or run interactively to be asked first)." + fi + + info "Uninstalling existing instance..." + if [ -n "$running" ]; then + # Graceful: `docker stop` sends SIGTERM, which the container entrypoint traps to + # run a bounded `doublezero disconnect` (only if a tunnel is up) before tearing + # doublezerod down -- releasing the GRE tunnel/routes/on-chain session cleanly. + # The container's --stop-timeout (60s) bounds the stop; the outer `timeout` + # guards a wedged docker CLI / restarting container so we can never hang forever. + info "Stopping it gracefully (disconnecting the DoubleZero tunnel)..." + local stop_ok=1 + if command -v timeout >/dev/null 2>&1; then + $SUDO timeout 90 docker stop "$DZ_NAME" >/dev/null 2>&1 || stop_ok=0 + else + $SUDO docker stop "$DZ_NAME" >/dev/null 2>&1 || stop_ok=0 + fi + [ "$stop_ok" = 1 ] || warn "Could not stop the existing container cleanly (timed out or errored); forcing removal. Its GRE tunnel/routes may be orphaned in the host network namespace -- check 'doublezero status' / 'ip link' and disconnect manually if connectivity is off." + fi + $SUDO docker rm -f "$DZ_NAME" >/dev/null 2>&1 || true + + # Version-independent confirmation that the tunnel actually came down: the + # container ran with --network host, so a failed disconnect leaves the doublezero1 + # interface (and its routes/on-chain session) orphaned in the host netns. Warn + # loudly if it lingers -- the fresh connect below usually recreates it, but a + # leftover old tunnel means the previous session wasn't released. + if command -v ip >/dev/null 2>&1 && $SUDO ip link show doublezero1 >/dev/null 2>&1; then + warn "The DoubleZero tunnel interface (doublezero1) is still present after teardown; the previous instance may not have disconnected cleanly. Its on-chain session/routes could be orphaned -- verify with 'doublezero status'." + fi +} +reinstall_existing_instance + # ---------------------------------------------------------------------------- # 4. host kernel / network prep (host-side; safe to attempt) # ---------------------------------------------------------------------------- diff --git a/scripts/connect-testnet.sh b/scripts/connect-testnet.sh index dc89b14..4aa600e 100755 --- a/scripts/connect-testnet.sh +++ b/scripts/connect-testnet.sh @@ -357,6 +357,72 @@ if ! command -v docker >/dev/null 2>&1; then fi $SUDO docker info >/dev/null 2>&1 || die "Docker is installed but the daemon isn't reachable. Start it (e.g. 'sudo systemctl start docker') and re-run." +# ---------------------------------------------------------------------------- +# 3b. existing instance? reinstall (graceful teardown) or cancel +# ---------------------------------------------------------------------------- +# A prior run leaves a long-lived container (--restart unless-stopped) that holds +# the DoubleZero tunnel and the WS sink. Re-running the installer over a live +# instance would collide (same container name, same tunnel, same host ports), so +# detect one up front and tear it down cleanly first. +reinstall_existing_instance() { + $SUDO docker ps -a -q --filter "name=^${DZ_NAME}$" 2>/dev/null | grep -q . || return 0 + + local running="" existing_env="" existing_img="" + $SUDO docker ps -q --filter "name=^${DZ_NAME}$" 2>/dev/null | grep -q . && running=1 + # Label the victim: all three installers share DZ_NAME, so e.g. the testnet + # installer can find a live *mainnet* container -- name the network/image so the + # operator isn't asked to destroy an unidentified instance. + existing_env="$($SUDO docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$DZ_NAME" 2>/dev/null | sed -n 's/^DZ_ENV=//p' | head -1 || true)" + existing_img="$($SUDO docker inspect -f '{{.Config.Image}}' "$DZ_NAME" 2>/dev/null || true)" + warn "An edge-connect instance ('$DZ_NAME'${existing_env:+, env=$existing_env}${existing_img:+, image=$existing_img}) already exists on this host${running:+ and is running}." + + # Decide whether to reinstall, keeping the three cases distinct so we neither + # break headless automation nor mislabel a genuine decline: + # DZ_ASSUME_YES=1 -> reinstall (skip the prompt) + # interactive decline -> abort (the operator said no) + # no usable TTY, !yes -> reinstall, but say so (pre-3b behaviour was a silent + # reinstall; keep automation working rather than abort) + # A readable /dev/tty inode (-r) can still fail to OPEN with no controlling + # terminal (cron/systemd/`curl|bash` without a tty), so probe an actual open + # rather than trusting -r (and skip confirm()'s own tty read in that case). + if [ "$DZ_ASSUME_YES" = 1 ]; then + : + elif { : <"$TTY"; } 2>/dev/null; then + confirm "Reinstall? This disconnects and removes the existing instance" \ + || die "Cancelled: leaving the existing instance in place (manage it with 'sudo docker logs $DZ_NAME')." + else + warn "No terminal to prompt on and DZ_ASSUME_YES is unset; reinstalling to preserve non-interactive behaviour (set DZ_ASSUME_YES=1 to silence this, or run interactively to be asked first)." + fi + + info "Uninstalling existing instance..." + if [ -n "$running" ]; then + # Graceful: `docker stop` sends SIGTERM, which the container entrypoint traps to + # run a bounded `doublezero disconnect` (only if a tunnel is up) before tearing + # doublezerod down -- releasing the GRE tunnel/routes/on-chain session cleanly. + # The container's --stop-timeout (60s) bounds the stop; the outer `timeout` + # guards a wedged docker CLI / restarting container so we can never hang forever. + info "Stopping it gracefully (disconnecting the DoubleZero tunnel)..." + local stop_ok=1 + if command -v timeout >/dev/null 2>&1; then + $SUDO timeout 90 docker stop "$DZ_NAME" >/dev/null 2>&1 || stop_ok=0 + else + $SUDO docker stop "$DZ_NAME" >/dev/null 2>&1 || stop_ok=0 + fi + [ "$stop_ok" = 1 ] || warn "Could not stop the existing container cleanly (timed out or errored); forcing removal. Its GRE tunnel/routes may be orphaned in the host network namespace -- check 'doublezero status' / 'ip link' and disconnect manually if connectivity is off." + fi + $SUDO docker rm -f "$DZ_NAME" >/dev/null 2>&1 || true + + # Version-independent confirmation that the tunnel actually came down: the + # container ran with --network host, so a failed disconnect leaves the doublezero1 + # interface (and its routes/on-chain session) orphaned in the host netns. Warn + # loudly if it lingers -- the fresh connect below usually recreates it, but a + # leftover old tunnel means the previous session wasn't released. + if command -v ip >/dev/null 2>&1 && $SUDO ip link show doublezero1 >/dev/null 2>&1; then + warn "The DoubleZero tunnel interface (doublezero1) is still present after teardown; the previous instance may not have disconnected cleanly. Its on-chain session/routes could be orphaned -- verify with 'doublezero status'." + fi +} +reinstall_existing_instance + # ---------------------------------------------------------------------------- # 4. host kernel / network prep (host-side; safe to attempt) # ---------------------------------------------------------------------------- diff --git a/scripts/connect.sh b/scripts/connect.sh index a8b1461..97cceb2 100755 --- a/scripts/connect.sh +++ b/scripts/connect.sh @@ -357,6 +357,72 @@ if ! command -v docker >/dev/null 2>&1; then fi $SUDO docker info >/dev/null 2>&1 || die "Docker is installed but the daemon isn't reachable. Start it (e.g. 'sudo systemctl start docker') and re-run." +# ---------------------------------------------------------------------------- +# 3b. existing instance? reinstall (graceful teardown) or cancel +# ---------------------------------------------------------------------------- +# A prior run leaves a long-lived container (--restart unless-stopped) that holds +# the DoubleZero tunnel and the WS sink. Re-running the installer over a live +# instance would collide (same container name, same tunnel, same host ports), so +# detect one up front and tear it down cleanly first. +reinstall_existing_instance() { + $SUDO docker ps -a -q --filter "name=^${DZ_NAME}$" 2>/dev/null | grep -q . || return 0 + + local running="" existing_env="" existing_img="" + $SUDO docker ps -q --filter "name=^${DZ_NAME}$" 2>/dev/null | grep -q . && running=1 + # Label the victim: all three installers share DZ_NAME, so e.g. the testnet + # installer can find a live *mainnet* container -- name the network/image so the + # operator isn't asked to destroy an unidentified instance. + existing_env="$($SUDO docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$DZ_NAME" 2>/dev/null | sed -n 's/^DZ_ENV=//p' | head -1 || true)" + existing_img="$($SUDO docker inspect -f '{{.Config.Image}}' "$DZ_NAME" 2>/dev/null || true)" + warn "An edge-connect instance ('$DZ_NAME'${existing_env:+, env=$existing_env}${existing_img:+, image=$existing_img}) already exists on this host${running:+ and is running}." + + # Decide whether to reinstall, keeping the three cases distinct so we neither + # break headless automation nor mislabel a genuine decline: + # DZ_ASSUME_YES=1 -> reinstall (skip the prompt) + # interactive decline -> abort (the operator said no) + # no usable TTY, !yes -> reinstall, but say so (pre-3b behaviour was a silent + # reinstall; keep automation working rather than abort) + # A readable /dev/tty inode (-r) can still fail to OPEN with no controlling + # terminal (cron/systemd/`curl|bash` without a tty), so probe an actual open + # rather than trusting -r (and skip confirm()'s own tty read in that case). + if [ "$DZ_ASSUME_YES" = 1 ]; then + : + elif { : <"$TTY"; } 2>/dev/null; then + confirm "Reinstall? This disconnects and removes the existing instance" \ + || die "Cancelled: leaving the existing instance in place (manage it with 'sudo docker logs $DZ_NAME')." + else + warn "No terminal to prompt on and DZ_ASSUME_YES is unset; reinstalling to preserve non-interactive behaviour (set DZ_ASSUME_YES=1 to silence this, or run interactively to be asked first)." + fi + + info "Uninstalling existing instance..." + if [ -n "$running" ]; then + # Graceful: `docker stop` sends SIGTERM, which the container entrypoint traps to + # run a bounded `doublezero disconnect` (only if a tunnel is up) before tearing + # doublezerod down -- releasing the GRE tunnel/routes/on-chain session cleanly. + # The container's --stop-timeout (60s) bounds the stop; the outer `timeout` + # guards a wedged docker CLI / restarting container so we can never hang forever. + info "Stopping it gracefully (disconnecting the DoubleZero tunnel)..." + local stop_ok=1 + if command -v timeout >/dev/null 2>&1; then + $SUDO timeout 90 docker stop "$DZ_NAME" >/dev/null 2>&1 || stop_ok=0 + else + $SUDO docker stop "$DZ_NAME" >/dev/null 2>&1 || stop_ok=0 + fi + [ "$stop_ok" = 1 ] || warn "Could not stop the existing container cleanly (timed out or errored); forcing removal. Its GRE tunnel/routes may be orphaned in the host network namespace -- check 'doublezero status' / 'ip link' and disconnect manually if connectivity is off." + fi + $SUDO docker rm -f "$DZ_NAME" >/dev/null 2>&1 || true + + # Version-independent confirmation that the tunnel actually came down: the + # container ran with --network host, so a failed disconnect leaves the doublezero1 + # interface (and its routes/on-chain session) orphaned in the host netns. Warn + # loudly if it lingers -- the fresh connect below usually recreates it, but a + # leftover old tunnel means the previous session wasn't released. + if command -v ip >/dev/null 2>&1 && $SUDO ip link show doublezero1 >/dev/null 2>&1; then + warn "The DoubleZero tunnel interface (doublezero1) is still present after teardown; the previous instance may not have disconnected cleanly. Its on-chain session/routes could be orphaned -- verify with 'doublezero status'." + fi +} +reinstall_existing_instance + # ---------------------------------------------------------------------------- # 4. host kernel / network prep (host-side; safe to attempt) # ---------------------------------------------------------------------------- diff --git a/tests/scripts/reinstall_existing.bats b/tests/scripts/reinstall_existing.bats new file mode 100644 index 0000000..f0505de --- /dev/null +++ b/tests/scripts/reinstall_existing.bats @@ -0,0 +1,166 @@ +#!/usr/bin/env bats +# +# Coverage for the "existing instance" guard (section 3b) added to the +# connect*.sh installers: when a container named $DZ_NAME already exists the +# installer must tear it down cleanly (graceful `docker stop` -> SIGTERM -> +# entrypoint disconnect) before starting a fresh one, and it must not touch a +# host that has no prior instance. +# +# The shared _helpers docker stub answers every `ps` with an id, so it can't tell +# a RUNNING instance from a STOPPED one. These tests install a smarter docker stub +# that distinguishes `ps -a -q` (exists?) from `ps -q` (running?) via STUB_EXISTS / +# STUB_RUNNING, and — crucially — flips to "container is up" once `docker run` has +# fired, so the installer's own post-run readiness loop still passes. +# +# As with the other script tests, the REAL, unmodified installer is driven +# end-to-end through a stub-first PATH; we assert on the argv it handed `docker`. + +load _helpers + +setup() { + STUB_BIN="$BATS_TEST_TMPDIR/bin" + DOCKER_LOG="$BATS_TEST_TMPDIR/docker.log" + KEYFILE="$BATS_TEST_TMPDIR/id.json" + export DOCKER_LOG + : >"$DOCKER_LOG" + printf '[%s]' "$(seq -s, 64 | sed 's/[0-9]*/0/g')" >"$KEYFILE" + make_stubs "$STUB_BIN" + install_reinstall_docker_stub "$STUB_BIN" +} + +# A docker stub that models an existing instance whose running-state is scripted, +# and that reports the *new* container as up after `docker run`. +install_reinstall_docker_stub() { + cat >"$1/docker" <<'EOF' +#!/usr/bin/env bash +printf 'docker %s\n' "$*" >>"$DOCKER_LOG" +case "$1" in + info) exit 0 ;; + logs) echo "doublezerod ready"; exit 0 ;; + inspect) + # Model `docker inspect` failing mid-teardown (container removed between the + # `ps -a` detection and the inspect, or a daemon blip). Otherwise emit + # realistic, MULTI-line env so the DZ_ENV `sed | head -1` closes the pipe + # early -- the exact shape that, without `|| true`, trips `set -o pipefail`. + [ "${STUB_INSPECT_FAIL:-0}" = 1 ] && exit 1 + case "$*" in + *Config.Env*) printf 'PATH=/usr/bin\nDZ_ENV=%s\nRUST_LOG=info\nHOME=/root\n' "${STUB_ENV:-mainnet-beta}" ;; + *Config.Image*) echo "ghcr.io/malbeclabs/doublezero-edge-connect:${STUB_ENV:-mainnet-beta}" ;; + esac + exit 0 ;; + run) : >"$DOCKER_LOG.ran"; exit 0 ;; # remember the fresh container now exists + ps) + # After `docker run`, every ps refers to the new container -> it's up (so the + # installer's "Container exited early" guard passes). + if [ -f "$DOCKER_LOG.ran" ]; then echo "newcontainerid"; exit 0; fi + # Before run: `-a` is the "exists?" probe, otherwise the "running?" probe. + case " $* " in + *" -a "*) [ "${STUB_EXISTS:-1}" = 1 ] && echo oldcontainerid ;; + *) [ "${STUB_RUNNING:-1}" = 1 ] && echo oldcontainerid ;; + esac + exit 0 ;; + *) exit 0 ;; +esac +EOF + chmod +x "$1/docker" +} + +# line number of the first log entry matching a fixed string (empty if none) +first_line() { grep -nF "$1" "$DOCKER_LOG" | head -1 | cut -d: -f1; } + +@test "running instance: stops it (graceful) before removing, then reaches docker run (all scripts)" { + local fails=0 + for s in "${SCRIPTS[@]}"; do + : >"$DOCKER_LOG"; rm -f "$DOCKER_LOG.ran" + local err="$BATS_TEST_TMPDIR/$s.err" + ( common_env; export STUB_EXISTS=1 STUB_RUNNING=1; bash "$SCRIPTS_DIR/$s.sh" ) 2>"$err" + status=$? + if [ "$status" -ne 0 ]; then echo "# $s.sh exited $status"; sed 's/^/# /' "$err"; fails=1; continue; fi + if ! grep -qi 'already exists' "$err"; then echo "# $s.sh never warned an instance already exists"; fails=1; continue; fi + # The warning must name the victim's env + image (network-aware prompt). + if ! grep -qi 'env=mainnet-beta' "$err" || ! grep -qi 'image=ghcr.io/malbeclabs' "$err"; then + echo "# $s.sh warning didn't identify the existing instance's env/image:"; sed 's/^/# /' "$err"; fails=1; continue + fi + local stop_l rm_l run_l + stop_l="$(first_line 'docker stop ')"; rm_l="$(first_line 'docker rm -f ')"; run_l="$(first_line 'docker run ')" + if [ -z "$stop_l" ]; then echo "# $s.sh did not 'docker stop' the running instance"; fails=1; continue; fi + if [ -z "$rm_l" ] || [ -z "$run_l" ]; then echo "# $s.sh missing docker rm / docker run"; fails=1; continue; fi + if [ "$stop_l" -ge "$rm_l" ]; then echo "# $s.sh removed before stopping (stop@$stop_l rm@$rm_l)"; fails=1; continue; fi + if [ "$rm_l" -ge "$run_l" ]; then echo "# $s.sh ran before tearing the old one down"; fails=1; continue; fi + done + [ "$fails" -eq 0 ] +} + +@test "stopped instance: removes without stopping, then reaches docker run (all scripts)" { + local fails=0 + for s in "${SCRIPTS[@]}"; do + : >"$DOCKER_LOG"; rm -f "$DOCKER_LOG.ran" + local err="$BATS_TEST_TMPDIR/$s.err" + ( common_env; export STUB_EXISTS=1 STUB_RUNNING=0; bash "$SCRIPTS_DIR/$s.sh" ) 2>"$err" + status=$? + if [ "$status" -ne 0 ]; then echo "# $s.sh exited $status"; sed 's/^/# /' "$err"; fails=1; continue; fi + if ! grep -qi 'already exists' "$err"; then echo "# $s.sh never warned an instance already exists"; fails=1; continue; fi + # A stopped container can't be `docker stop`ped for a graceful disconnect. + if grep -q '^docker stop ' "$DOCKER_LOG"; then echo "# $s.sh tried to stop a non-running instance"; fails=1; continue; fi + if [ -z "$(first_line 'docker rm -f ')" ]; then echo "# $s.sh did not remove the stopped instance"; fails=1; continue; fi + if ! grep -q '^docker run ' "$DOCKER_LOG"; then echo "# $s.sh never reached docker run"; fails=1; continue; fi + done + [ "$fails" -eq 0 ] +} + +@test "docker inspect failing mid-teardown does not abort the installer (all scripts)" { + # The env/image labels are best-effort: a `docker inspect` failure (container + # removed between the `ps -a` detection and the inspect, or a daemon blip) must + # NOT abort the installer under `set -euo pipefail` (regression: the DZ_ENV + # assignment's pipeline needs `|| true`, matching its DZ_IMAGE sibling). The + # instance is still detected, warned about, and reinstalled -- just unlabelled. + local fails=0 + for s in "${SCRIPTS[@]}"; do + : >"$DOCKER_LOG"; rm -f "$DOCKER_LOG.ran" + local err="$BATS_TEST_TMPDIR/$s.err" + ( common_env; export STUB_EXISTS=1 STUB_RUNNING=1 STUB_INSPECT_FAIL=1; bash "$SCRIPTS_DIR/$s.sh" ) 2>"$err" + status=$? + if [ "$status" -ne 0 ]; then echo "# $s.sh exited $status when docker inspect failed"; sed 's/^/# /' "$err"; fails=1; continue; fi + if ! grep -qi 'already exists' "$err"; then echo "# $s.sh aborted before the 'already exists' warning"; sed 's/^/# /' "$err"; fails=1; continue; fi + if ! grep -q '^docker run ' "$DOCKER_LOG"; then echo "# $s.sh never reached docker run"; fails=1; continue; fi + done + [ "$fails" -eq 0 ] +} + +@test "no existing instance: no warn, no stop, reaches docker run (all scripts)" { + local fails=0 + for s in "${SCRIPTS[@]}"; do + : >"$DOCKER_LOG"; rm -f "$DOCKER_LOG.ran" + local err="$BATS_TEST_TMPDIR/$s.err" + ( common_env; export STUB_EXISTS=0; bash "$SCRIPTS_DIR/$s.sh" ) 2>"$err" + status=$? + if [ "$status" -ne 0 ]; then echo "# $s.sh exited $status"; sed 's/^/# /' "$err"; fails=1; continue; fi + if grep -qi 'already exists' "$err"; then echo "# $s.sh warned about an instance that does not exist"; fails=1; continue; fi + if grep -q '^docker stop ' "$DOCKER_LOG"; then echo "# $s.sh stopped a non-existent instance"; fails=1; continue; fi + if ! grep -q '^docker run ' "$DOCKER_LOG"; then echo "# $s.sh never reached docker run"; fails=1; continue; fi + done + [ "$fails" -eq 0 ] +} + +@test "running instance, no TTY, DZ_ASSUME_YES unset: reinstalls (not abort) and says so (all scripts)" { + # Pre-3b a headless re-run silently reinstalled; the guard must preserve that + # (reinstall + a loud notice), NOT abort automation that never set DZ_ASSUME_YES. + command -v setsid >/dev/null 2>&1 || skip "setsid not available to drop the controlling TTY" + local fails=0 + for s in "${SCRIPTS[@]}"; do + : >"$DOCKER_LOG"; rm -f "$DOCKER_LOG.ran" + local err="$BATS_TEST_TMPDIR/$s.err" + ( + common_env; unset DZ_ASSUME_YES; export STUB_EXISTS=1 STUB_RUNNING=1 + # setsid detaches the controlling terminal so [ -r /dev/tty ] is false; + # -w waits for the child (bare setsid forks and returns immediately); + # timeout guards against a hang if that assumption ever breaks (a prompt). + timeout 30 setsid -w bash "$SCRIPTS_DIR/$s.sh" "$err" + status=$? + if [ "$status" -ne 0 ]; then echo "# $s.sh exited $status (headless re-run should reinstall, not abort)"; sed 's/^/# /' "$err"; fails=1; continue; fi + if ! grep -qi 'no terminal to prompt' "$err"; then echo "# $s.sh didn't announce the headless reinstall"; sed 's/^/# /' "$err"; fails=1; continue; fi + if ! grep -q '^docker run ' "$DOCKER_LOG"; then echo "# $s.sh never reached docker run"; fails=1; continue; fi + done + [ "$fails" -eq 0 ] +}