summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xartifacts/etf-recovery-candidate/ops-drill2.sh372
-rwxr-xr-xartifacts/etf-recovery-candidate/ops/deploy.sh329
-rwxr-xr-xartifacts/etf-recovery-candidate/ops/rollback.sh102
3 files changed, 803 insertions, 0 deletions
diff --git a/artifacts/etf-recovery-candidate/ops-drill2.sh b/artifacts/etf-recovery-candidate/ops-drill2.sh
new file mode 100755
index 0000000..e4a4caf
--- /dev/null
+++ b/artifacts/etf-recovery-candidate/ops-drill2.sh
@@ -0,0 +1,372 @@
+#!/bin/sh
+# 受控替身演练 v2(对应复审条目 1-4)。禁止生产操作。
+# 退出语义:FAILED>0 → exit 1(不再恒 exit0)。
+# 注入点:fake install/rsync/sed/systemctl(stop,start,restart)/curl(health)。
+# 覆盖:A1/A2 precheck 硬拦;B 成功部署+手动 rollback 验收;H 并发锁;
+# C install 失败、D rsync 失败、E env(sed) 失败、F restart/health 失败
+# → 自动回滚且环境+发布物与部署前逐字节一致;
+# I 停服后发现新任务 → 恢复旧服务不替换;RID 不覆盖既有备份。
+# RA restore 中 stop 失败且无法确认服务已停 → 拒绝写任何文件并保持
+# env/binary/dist 逐字节不变;RB1/RB2 停后的 DB 读取错误或 DB_PATH
+# 缺失 → 不深层 exit,恢复旧服务且无任何 swap。
+set -eu
+HERE=$(dirname "$0")
+T="$(cd "$HERE" && pwd)/ops-drill2"
+PASSED=0; FAILED=0
+ok() { PASSED=$((PASSED+1)); printf 'PASS: %s\n' "$1"; }
+fail() { FAILED=$((FAILED+1)); printf 'FAIL: %s\n' "$1"; }
+fx() { if [ "$2" = "$3" ]; then ok "$1"; else fail "$1 (got '$2' want '$3')"; fi; }
+startswith() { case "$2" in "$1"*) true ;; *) false ;; esac; }
+
+rm -rf "$T"; mkdir -p "$T/bin"
+
+cat > "$T/bin/fake-systemctl" <<'SH'
+#!/bin/sh
+action=""; unit=""
+for a in "$@"; do
+ case "$a" in --user|--*) continue ;; esac
+ if [ -z "$action" ]; then action=$a; else unit=$a; fi
+done
+case "$action $unit" in
+ "stop fake-service")
+ [ "$FAKE_STOP_FAIL" = "1" ] && exit 1
+ printf 'stop\n' >> "$STATE_DIR/.audit.log"
+ rm -f "$STATE_DIR/.active"
+ rm -f "$STATE_DIR/.stopped"
+ : > "$STATE_DIR/.stopped"
+ [ "$FAKE_HEALTH_ON_STOP" = "1" ] && : > "$STATE_DIR/.healthfail"
+ if [ -n "${FAKE_STOP_HOOK:-}" ]; then "$FAKE_STOP_HOOK"; fi
+ exit 0 ;;
+ "start fake-service")
+ # deploy's own start may be injected (FAKE_START_FAIL) ONCE — a later
+ # start attempt (rollback path) must succeed so rollback stays verifiable
+ if [ "$FAKE_START_FAIL" = "1" ] && [ ! -e "$STATE_DIR/.startfail.seen" ]; then
+ : > "$STATE_DIR/.startfail.seen"; exit 1
+ fi
+ printf 'start\n' >> "$STATE_DIR/.audit.log"
+ if [ "${DEPLOY_PHASE:-}" = "restore" ]; then
+ rm -f "$STATE_DIR/.healthfail"
+ rm -rf "$STATE_DIR/.fakehealth.recovered"; : > "$STATE_DIR/.fakehealth.recovered"
+ fi
+ : > "$STATE_DIR/.active"; rm -f "$STATE_DIR/.stopped"; exit 0 ;;
+ "restart fake-service")
+ [ "$FAKE_RESTART_FAIL" = "1" ] && exit 1
+ printf 'restart\n' >> "$STATE_DIR/.audit.log"
+ rm -f "$STATE_DIR/.healthfail"
+ rm -rf "$STATE_DIR/.fakehealth.recovered"
+ : > "$STATE_DIR/.fakehealth.recovered"
+ : > "$STATE_DIR/.active"; exit 0 ;;
+ "is-active fake-service")
+ [ -e "$STATE_DIR/.active" ] && exit 0 || { echo inactive; exit 3; } ;;
+ *) exit 1 ;;
+esac
+SH
+cat > "$T/bin/fake-docker" <<'SH'
+#!/bin/sh
+case "$*" in *"image inspect"*) exit 0 ;; *) exit 1 ;; esac
+SH
+cat > "$T/bin/fake-curl" <<'SH'
+#!/bin/sh
+if [ -e "$STATE_DIR/.healthfail" ]; then exit 7; fi
+if [ "$FAKE_HEALTH" = "bad" ]; then
+ if [ -e "$STATE_DIR/.fakehealth.recovered" ]; then
+ printf '{"status":"ok","ai_configured":true,"version":"0.1.0","worker_available":true}'
+ exit 0
+ fi
+ if [ ! -e "$STATE_DIR/.healthfail" ]; then : > "$STATE_DIR/.healthfail"; fi
+ printf '{"status":"bad","worker_available":true}'
+ exit 0
+fi
+printf '{"status":"ok","ai_configured":true,"version":"0.1.0","worker_available":true}'
+exit 0
+SH
+cat > "$T/bin/fake-install" <<'SH'
+#!/bin/sh
+if [ "$INJECT" = "install" ]; then echo "install: injected failure" >&2; exit 1; fi
+m=0755; src=$1; dst=$2
+if [ "$1" = "-m" ]; then m=$2; src=$3; dst=$4; fi
+cat "$src" > "$dst" || exit 1
+chmod "$m" "$dst" && exit 0
+exit 1
+SH
+cat > "$T/bin/fake-cp" <<'SH'
+#!/bin/sh
+if [ "$INJECT" = "backup-cp" ]; then echo "cp: injected failure" >&2; exit 1; fi
+exec /bin/cp "$@"
+SH
+cat > "$T/bin/fake-mv" <<'SH'
+#!/bin/sh
+if [ "$INJECT" = "backup-manifest" ]; then echo "mv: injected failure" >&2; exit 1; fi
+exec /bin/mv "$@"
+SH
+cat > "$T/bin/fake-rsync" <<'SH'
+#!/bin/sh
+# backup-stage failures are injected separately (INJECT=backup-rsync);
+# a plain rsync failure must fire only once, in the swap phase
+if [ "$INJECT" = "backup-rsync" ]; then echo "rsync: injected failure" >&2; exit 1; fi
+if [ "$INJECT" = "rsync" ] && [ "${DEPLOY_PHASE:-}" = "swap" ] && [ ! -e "$STATE_DIR/.rsyncfail.seen" ]; then
+ : > "$STATE_DIR/.rsyncfail.seen"
+ echo "rsync: injected failure" >&2; exit 1
+fi
+exec /usr/bin/rsync "$@"
+SH
+cat > "$T/bin/fake-sed" <<'SH'
+#!/bin/sh
+if [ "$INJECT" = "env" ]; then echo "sed: injected failure" >&2; exit 1; fi
+exec /usr/bin/sed "$@"
+SH
+cat > "$T/bin/newwork.sh" <<SH
+#!/bin/sh
+python3 - "$T/prod.sqlite3" <<'PY'
+import sqlite3, sys
+con = sqlite3.connect(sys.argv[1])
+con.execute("INSERT INTO runs VALUES ('running')")
+con.commit()
+PY
+SH
+chmod +x "$T/bin/"*
+
+set_env_fixture() {
+ rm -rf "$T/state" "$T/release" "$T/repo" "$T/backups"
+ mkdir -p "$T/state" "$T/release/frontend" "$T/repo/server/target/release" "$T/repo/frontend/dist" "$T/backups"
+ printf 'binary-live-prev' > "$T/release/strategy-lab-server"
+ chmod 755 "$T/release/strategy-lab-server"
+ printf '<html>prev-dist</html>' > "$T/release/frontend/index.html"
+ printf 'binary-the-current-one' > "$T/repo/server/target/release/strategy-lab-server"
+ chmod 755 "$T/repo/server/target/release/strategy-lab-server"
+ printf '<html>candidate</html>' > "$T/repo/frontend/dist/index.html"
+ python3 - "$T" <<'PY'
+import sqlite3, sys
+con = sqlite3.connect(sys.argv[1] + "/prod.sqlite3")
+con.execute("DROP TABLE IF EXISTS datasets")
+con.execute("DROP TABLE IF EXISTS runs")
+con.execute("CREATE TABLE datasets (status TEXT)")
+con.execute("CREATE TABLE runs (status TEXT)")
+con.commit()
+PY
+ printf 'BIND=127.0.0.1:18789\nDB_PATH=%s/prod.sqlite3\nDATA_DIR=%s/data\nWORKER_IMAGE=strategy-lab-worker:local\n' "$T" "$T" > "$T/state/runtime.env"
+ : > "$T/state/.active"
+}
+
+common_env() {
+ STATE_DIR="$T/state" RELEASE_DIR="$T/release" BACKUP_ROOT="$T/backups" \
+ REPO_ROOT="$T" SERVER_BIN_SRC="$T/repo/server/target/release/strategy-lab-server" \
+ DIST_SRC="$T/repo/frontend/dist" SERVICE=fake-service \
+ SYSTEMCTL_CMD="$T/bin/fake-systemctl" DOCKER_CMD="$T/bin/fake-docker" \
+ CURL_CMD="$T/bin/fake-curl" RSYNC_CMD="$T/bin/fake-rsync" \
+ INSTALL_CMD="$T/bin/fake-install" SED_CMD="$T/bin/fake-sed" \
+ CP_CMD="$T/bin/fake-cp" MV_CMD="$T/bin/fake-mv" \
+ export STATE_DIR RELEASE_DIR BACKUP_ROOT REPO_ROOT SERVER_BIN_SRC DIST_SRC \
+ SERVICE SYSTEMCTL_CMD DOCKER_CMD CURL_CMD RSYNC_CMD INSTALL_CMD SED_CMD \
+ CP_CMD MV_CMD
+}
+
+deploy_out() {
+ common_env
+ IMAGE_ID=$1; export IMAGE_ID
+ ( cd "$HERE/ops" && ./deploy.sh ) 2>&1
+}
+
+# --- A1 datasets ---
+set_env_fixture
+python3 - "$T/prod.sqlite3" <<'PY'
+import sqlite3, sys
+con = sqlite3.connect(sys.argv[1]); con.execute("INSERT INTO datasets VALUES ('running')"); con.commit()
+PY
+out=$(deploy_out sha256:img0 || true)
+printf '%s' "$out" | grep -q "in-flight datasets" && ok "A1 precheck blocks in-flight datasets" || fail "A1: $out"
+fx "A1 env untouched" "$(grep '^WORKER_IMAGE=' "$T/state/runtime.env")" "WORKER_IMAGE=strategy-lab-worker:local"
+fx "A1 release untouched" "$(cat "$T/release/frontend/index.html")" "<html>prev-dist</html>"
+
+# --- A2 runs ---
+set_env_fixture
+python3 - "$T/prod.sqlite3" <<'PY'
+import sqlite3, sys
+con = sqlite3.connect(sys.argv[1]); con.execute("INSERT INTO runs VALUES ('running')"); con.commit()
+PY
+out=$(deploy_out sha256:img0 || true)
+printf '%s' "$out" | grep -q "in-flight runs" && ok "A2 precheck blocks in-flight runs" || fail "A2: $out"
+
+# --- I: new work discovered after stop ---
+set_env_fixture
+fx "I00 sanity" "$(cat "$T/release/frontend/index.html")" "<html>prev-dist</html>"
+out=$(FAKE_STOP_HOOK="$T/bin/newwork.sh" deploy_out sha256:img-a || true)
+printf '%s' "$out" | grep -q "restored old service WITHOUT replacement" && ok "I race branch restores old service" || { fail "I: $out"; printf '%s' "$out" | tail -5; }
+fx "I env untouched" "$(grep '^WORKER_IMAGE=' "$T/state/runtime.env")" "WORKER_IMAGE=strategy-lab-worker:local"
+fx "I release untouched" "$(cat "$T/release/frontend/index.html")" "<html>prev-dist</html>"
+printf '%s' "$out" | grep -q "MANUAL CHECK" && fail "I old service restart must succeed" || ok "I old service restarted via fake systemctl start"
+
+# --- B: happy deploy ---
+set_env_fixture
+out=$(deploy_out sha256:img-1) || { fail "B deploy died: $out"; }
+printf '%s' "$out" | grep -q "phase 6/6 deploy" && ok "B deploy completed" || fail "B: $(printf '%s' "$out" | tail -4)"
+n=$(ls "$T/backups" 2>/dev/null | wc -l)
+fx "B exactly one RID" "$n" "1"
+R1=$(ls "$T/backups")
+fx "B env pins immutable ID" "$(grep '^WORKER_IMAGE=' "$T/state/runtime.env")" "WORKER_IMAGE=sha256:img-1"
+fx "B binary swapped" "$(cat "$T/release/strategy-lab-server")" "binary-the-current-one"
+fx "B dist swapped" "$(cat "$T/release/frontend/index.html")" "<html>candidate</html>"
+[ -x "$T/release/strategy-lab-server" ] && ok "B binary executable" || fail "B not executable"
+[ -f "$T/backups/$R1/release-manifest.txt" ] && ok "B manifest exists" || fail "B manifest missing"
+[ -f "$T/backups/$R1/complete" ] && ok "B backup has complete marker" || fail "B complete marker missing"
+
+# --- RID uniqueness: deploying the SAME image ID again is refused (no overwrite) ---
+out=$(deploy_out sha256:img-1 || true)
+printf '%s' "$out" | grep -q "already pins" && ok "RID: re-pin of same ID refused (no overwrite)" || fail "RID refusal: $out"
+n=$(ls "$T/backups" 2>/dev/null | wc -l)
+fx "RID: backup count still exactly one" "$n" "1"
+
+# --- G: manual rollback.sh $RID verifies everything ---
+out=$( ( common_env; cd "$HERE/ops" && ./rollback.sh "$R1" ) 2>&1 ) || { fail "G manual rollback died: $out"; }
+printf '%s' "$out" | grep -q "restored .*env verified" && ok "G manual rollback verified" || fail "G: $out"
+fx "G env = manifest previous image (pre-deploy state)" "$(grep '^WORKER_IMAGE=' "$T/state/runtime.env")" "WORKER_IMAGE=strategy-lab-worker:local"
+fx "G release restored to backup (pre-deploy) state" "$(cat "$T/release/strategy-lab-server")" "binary-live-prev"
+fx "G dist restored to pre-deploy backup" "$(cat "$T/release/frontend/index.html")" "<html>prev-dist</html>"
+audit=$(cat "$T/state/.audit.log")
+printf '%s\n' "$audit" | head -1 | grep -qx stop && ok "G rollback stops service FIRST (no writes under a running binary)" || fail "G audit first line not stop: $(printf '%s' "$audit")"
+printf '%s\n' "$audit" | tail -1 | grep -qx start && ok "G rollback restarts service LAST" || fail "G audit last line not start: $(printf '%s' "$audit")"
+[ -e "$T/state/.active" ] && ok "G manual rollback leaves service ACTIVE" || fail "G: service not active after manual rollback"
+
+# --- H: concurrent deploy refused by deploy.lock ---
+rm -f "$T/state/deploy.lock"
+set_env_fixture
+( common_env >/dev/null 2>&1
+ exec 9>"$T/state/deploy.lock"; flock 9
+ sleep 1.5 ) & HOLD_PID=$!
+sleep 0.4
+out2=$( ( common_env >/dev/null; IMAGE_ID=sha256:img-h; export IMAGE_ID; cd "$HERE/ops" && ./deploy.sh ) 2>&1 || true)
+wait $HOLD_PID || true
+printf '%s' "$out2" | grep -q "deploy.lock" && ok "H concurrent deploy refused" || fail "H: $out2"
+
+# --- C: install failure inside swap ---
+set_env_fixture
+out=$(INJECT=install deploy_out sha256:img-c || true) || true
+printf '%s' "$out" | grep -q "install .* failed" && ok "C install failure detected" || fail "C: $out"
+printf '%s' "$out" | grep -q "restored artifacts verified (env + sha256)" && ok "C auto-rollback verified" || fail "C rollback: $(printf '%s' "$out" | tail -4)"
+fx "C binary restored" "$(cat "$T/release/strategy-lab-server")" "binary-live-prev"
+fx "C dist restored" "$(cat "$T/release/frontend/index.html")" "<html>prev-dist</html>"
+fx "C env restored" "$(grep '^WORKER_IMAGE=' "$T/state/runtime.env")" "WORKER_IMAGE=strategy-lab-worker:local"
+[ -f "$T/backups/$(ls "$T/backups")/release-manifest.txt" ] && ok "C manifest" || fail "C manifest"
+
+# --- D: rsync failure, ONE-SHOT in the swap phase -> auto rollback healthy ---
+set_env_fixture
+out=$(INJECT=rsync deploy_out sha256:img-d) || rc_d=$?
+[ "$out" = "0" ] || [ "${rc_d:-nonzero}" != "0" ] && ok "D deploy exits non-zero" || fail "D exit expected nonzero"
+printf '%s' "$out" | grep -q "rsync .* failed" && ok "D rsync failure detected" || fail "D: $out"
+printf '%s' "$out" | grep -q "restored artifacts verified (env + sha256)" && ok "D auto-rollback verified" || fail "D rollback: $(printf '%s' "$out" | tail -4)"
+fx "D dist restored" "$(cat "$T/release/frontend/index.html")" "<html>prev-dist</html>"
+fx "D binary restored" "$(cat "$T/release/strategy-lab-server")" "binary-live-prev"
+fx "D env restored" "$(grep '^WORKER_IMAGE=' "$T/state/runtime.env")" "WORKER_IMAGE=strategy-lab-worker:local"
+[ -e "$T/state/.active" ] && ok "D old service ACTIVE after rollback" || fail "D: old service not active"
+[ -e "$T/backups/$(ls "$T/backups")/complete" ] && ok "D backup was complete before swap" || fail "D backup lacked complete marker"
+
+# --- BD1/BD2/BD3: backup-stage failure (cp / rsync / manifest) ---
+# Service must STAY active (backup happens before stop), artifacts must be
+# byte-identical, exit non-zero, and NO complete backup may exist.
+for inj in backup-cp backup-rsync backup-manifest; do
+ set_env_fixture
+ env_before=$(sha256sum "$T/state/runtime.env" | awk '{print $1}')
+ bin_before=$(sha256sum "$T/release/strategy-lab-server" | awk '{print $1}')
+ dist_before=$(sha256sum "$T/release/frontend/index.html" | awk '{print $1}')
+ out=$(INJECT="$inj" deploy_out sha256:img-bd || true)
+ [ -e "$T/state/.active" ] && ok "$inj: old service ACTIVE (no downtime)" || fail "$inj: old service down"
+ fx "$inj: env unchanged" "$(sha256sum "$T/state/runtime.env" | awk '{print $1}')" "$env_before"
+ fx "$inj: binary unchanged" "$(sha256sum "$T/release/strategy-lab-server" | awk '{print $1}')" "$bin_before"
+ fx "$inj: dist unchanged" "$(sha256sum "$T/release/frontend/index.html" | awk '{print $1}')" "$dist_before"
+ [ -e "$T/backups/$(ls "$T/backups")/complete" ] && fail "$inj: INCOMPLETE backup must not have complete marker" || ok "$inj: no complete-marker backup"
+done
+
+# --- E: env(sed) failure → env byte-identical ---
+set_env_fixture
+env_before=$(sha256sum "$T/state/runtime.env" | awk '{print $1}')
+out=$(INJECT=env deploy_out sha256:img-e || true)
+env_after=$(sha256sum "$T/state/runtime.env" | awk '{print $1}')
+fx "E env byte-identical" "$env_before" "$env_after"
+printf '%s' "$out" | grep -q "restored artifacts verified" && ok "E auto-rollback verified (env unchanged byte-identical)" || fail "E: $(printf '%s' "$out" | tail -4)"
+fx "E env back to old image" "$(grep '^WORKER_IMAGE=' "$T/state/runtime.env")" "WORKER_IMAGE=strategy-lab-worker:local"
+
+# --- F2: restart failure ---
+set_env_fixture
+out=$(FAKE_START_FAIL=1 deploy_out sha256:img-f || true)
+printf '%s' "$out" | grep -q "restored artifacts verified" && ok "F start-service failure -> rollback" || fail "F1: $(printf '%s' "$out" | tail -4)"
+fx "F binary identical" "$(cat "$T/release/strategy-lab-server")" "binary-live-prev"
+fx "F dist identical" "$(cat "$T/release/frontend/index.html")" "<html>prev-dist</html>"
+fx "F env identical" "$(grep '^WORKER_IMAGE=' "$T/state/runtime.env")" "WORKER_IMAGE=strategy-lab-worker:local"
+
+# --- F3: health failure (curl level + bad status JSON) ---
+set_env_fixture
+set_env_fixture 2>/dev/null; out=$(FAKE_HEALTH_ON_STOP=1 deploy_out sha256:img-g || true)
+printf '%s' "$out" | grep -q "restored artifacts verified" && ok "F health(unreach) -> rollback" || fail "F3: $(printf '%s' "$out" | tail -4)"
+set_env_fixture
+out=$(FAKE_HEALTH=bad deploy_out sha256:img-h2 || true)
+printf '%s' "$out" | grep -q "restored artifacts verified" && ok "F health(bad-json) -> rollback" || fail "F4: $(printf '%s' "$out" | tail -4)"
+fx "F4 env identical after health failure" "$(grep '^WORKER_IMAGE=' "$T/state/runtime.env")" "WORKER_IMAGE=strategy-lab-worker:local"
+
+# --- RA: restore must REFUSE to write files when stop cannot be confirmed ---
+set_env_fixture
+out=$(deploy_out sha256:img-ra) || { fail "RA setup deploy died: $out"; }
+RID_RA=$(ls "$T/backups")
+env_before=$(sha256sum "$T/state/runtime.env" | awk '{print $1}')
+bin_before=$(sha256sum "$T/release/strategy-lab-server" | awk '{print $1}')
+dist_before=$(sha256sum "$T/release/frontend/index.html" | awk '{print $1}')
+rc_ra=0
+out=$( { common_env
+ FAKE_STOP_FAIL=1; export FAKE_STOP_FAIL
+ IMAGE_ID=sha256:img-ra2; export IMAGE_ID
+ cd "$HERE/ops" && ./deploy.sh restore "$RID_RA"; } 2>&1 ) || rc_ra=$?
+[ "$rc_ra" -ne 0 ] && ok "RA restore exits non-zero when stop fails" || fail "RA restore exited 0: $out"
+printf '%s' "$out" | grep -q "refusing" && ok "RA explicit refusal message" || fail "RA msg: $out"
+fx "RA env unchanged" "$(sha256sum "$T/state/runtime.env" | awk '{print $1}')" "$env_before"
+fx "RA binary unchanged" "$(sha256sum "$T/release/strategy-lab-server" | awk '{print $1}')" "$bin_before"
+fx "RA dist unchanged" "$(sha256sum "$T/release/frontend/index.html" | awk '{print $1}')" "$dist_before"
+[ -e "$T/state/.active" ] && ok "RA service still ACTIVE (recoverable files preserved)" || fail "RA service not active"
+
+# --- RB1: DB read error after stop -> old service restored, NO swap ---
+set_env_fixture
+env_before=$(sha256sum "$T/state/runtime.env" | awk '{print $1}')
+bin_before=$(sha256sum "$T/release/strategy-lab-server" | awk '{print $1}')
+dist_before=$(sha256sum "$T/release/frontend/index.html" | awk '{print $1}')
+cat > "$T/bin/breakdb.sh" <<SH
+#!/bin/sh
+rm -f "$T/prod.sqlite3"
+SH
+chmod +x "$T/bin/breakdb.sh"
+rc_rb=0
+out=$(FAKE_STOP_HOOK="$T/bin/breakdb.sh" deploy_out sha256:img-rb) 2>&1 || rc_rb=$?
+[ "$rc_rb" -ne 0 ] && ok "RB1 deploy exits non-zero on DB read error" || fail "RB1 exited 0: $out"
+printf '%s' "$out" | grep -q "restored old service WITHOUT replacement" && ok "RB1 old service restored after DB read error" || { fail "RB1: $out"; printf '%s' "$out" | tail -5; }
+[ -e "$T/state/.active" ] && ok "RB1 old service ACTIVE" || fail "RB1 service down"
+fx "RB1 env unchanged" "$(sha256sum "$T/state/runtime.env" | awk '{print $1}')" "$env_before"
+fx "RB1 binary unchanged" "$(sha256sum "$T/release/strategy-lab-server" | awk '{print $1}')" "$bin_before"
+fx "RB1 dist unchanged" "$(sha256sum "$T/release/frontend/index.html" | awk '{print $1}')" "$dist_before"
+
+# --- RB2: DB_PATH key missing after stop -> old service restored, NO swap ---
+set_env_fixture
+env_before=$(sha256sum "$T/state/runtime.env" | awk '{print $1}')
+bin_before=$(sha256sum "$T/release/strategy-lab-server" | awk '{print $1}')
+dist_before=$(sha256sum "$T/release/frontend/index.html" | awk '{print $1}')
+cat > "$T/bin/dropdbpath.sh" <<SH
+#!/bin/sh
+sed '/^DB_PATH=/d' "$T/state/runtime.env" > "$T/state/runtime.env.tmp"
+mv "$T/state/runtime.env.tmp" "$T/state/runtime.env"
+printf 'DB_PATH_REMOVED=1\n' >> "$T/state/runtime.env"
+SH
+chmod +x "$T/bin/dropdbpath.sh"
+rc_rc=0
+out=$( { FAKE_STOP_HOOK="$T/bin/dropdbpath.sh" deploy_out sha256:img-rc; } 2>&1 ) || rc_rc=$?
+[ "$rc_rc" -ne 0 ] && ok "RB2 deploy exits non-zero on missing DB_PATH" || fail "RB2 exited 0: $out"
+printf '%s' "$out" | grep -q "restored old service WITHOUT replacement" && ok "RB2 old service restored after DB_PATH missing" || { fail "RB2: $out"; printf '%s' "$out" | tail -5; }
+[ -e "$T/state/.active" ] && ok "RB2 old service ACTIVE" || fail "RB2 service down"
+# the stop hook removed DB_PATH (that is the injected fault); assert the OTHER
+# keys survived byte-for-byte — no swap touched runtime.env beyond that fault
+fx "RB2 env rest unchanged" \
+ "$(grep -E '^(BIND|DATA_DIR|WORKER_IMAGE)=' "$T/state/runtime.env")" \
+ "BIND=127.0.0.1:18789
+DATA_DIR=$T/data
+WORKER_IMAGE=strategy-lab-worker:local"
+fx "RB2 binary unchanged" "$(sha256sum "$T/release/strategy-lab-server" | awk '{print $1}')" "$bin_before"
+fx "RB2 dist unchanged" "$(sha256sum "$T/release/frontend/index.html" | awk '{print $1}')" "$dist_before"
+
+echo "=== PASSED=$PASSED FAILED=$FAILED ==="
+[ $FAILED -eq 0 ] || exit 1
+exit 0
diff --git a/artifacts/etf-recovery-candidate/ops/deploy.sh b/artifacts/etf-recovery-candidate/ops/deploy.sh
new file mode 100755
index 0000000..822c90e
--- /dev/null
+++ b/artifacts/etf-recovery-candidate/ops/deploy.sh
@@ -0,0 +1,329 @@
+#!/bin/sh
+# ==============================================================
+# Strategy Lab — 部署脚本 v2(供 leader 审查/在隔离替身环境演练)
+# *** 本脚本不会由开发会话在生产执行 ***
+# 针对 leader 复审意见(2026-09-17)的修正:
+# R1 每个关键命令显式检查返回码,绝不依赖 set -e 在 && / if ! 上下文的不可靠性。
+# R2 竞态窗口:先 systemctl stop 服务 → 重新硬检查 in-flight(服务停止后不再产生
+# 新任务)→ 才 swap。停后发现新任务:立即恢复旧服务(发布物未被动过)并退出,
+# 任务状态原样保留。
+# R3 restore 入口也取同一把锁;回滚后验证 ①服务 active ②/api/health JSON
+# status=="ok" ③恢复后的发布物 sha256 == release-manifest.txt 里的旧哈希。
+# 备份在发布目录外、0700;单一 RELEASE_ID;同秒碰撞自动加后缀,绝不覆盖既有备份。
+#
+# 受控替身(演练/审查可注入):
+# SYSTEMCTL_CMD/DOCKER_CMD/CURL_CMD/RSYNC_CMD/INSTALL_CMD 均可指向替身。
+# *不含生产破坏性操作,禁止在无 STAGE_DIR 环境下执行(见 require_prod_paths)。
+set -eu
+
+SYSTEMCTL_CMD=${SYSTEMCTL_CMD:-systemctl}
+DOCKER_CMD=${DOCKER_CMD:-docker}
+CURL_CMD=${CURL_CMD:-curl}
+INSTALL_CMD=${INSTALL_CMD:-install}
+SED_CMD=${SED_CMD:-sed}
+RSYNC_CMD=${RSYNC_CMD:-rsync}
+CP_CMD=${CP_CMD:-cp}
+MV_CMD=${MV_CMD:-mv}
+
+STATE_DIR=${STATE_DIR:-/home/somhairle/.local/share/strategy-lab-production}
+RELEASE_DIR=${RELEASE_DIR:-$STATE_DIR/release}
+BACKUP_ROOT=${BACKUP_ROOT:-$STATE_DIR/release-backups}
+REPO_ROOT=${REPO_ROOT:?REPO_ROOT must be set (absolute path to strategy-lab checkout)}
+SERVICE=${SERVICE:-strategy-lab-production}
+SERVER_BIN_SRC=${SERVER_BIN_SRC:-$REPO_ROOT/server/target/release/strategy-lab-server}
+DIST_SRC=${DIST_SRC:-$REPO_ROOT/frontend/dist}
+IMAGE_ID=${IMAGE_ID:?IMAGE_ID must be the immutable image ID, e.g. sha256:...}
+
+msg() { printf '[deploy] %s\n' "$1"; }
+note() { printf '[deploy] NOTE: %s\n' "$1" >&2; }
+die() { printf '[deploy] FATAL: %s\n' "$1" >&2; exit 1; }
+
+# ---------------- explicit-rc helpers (R1) ----------------
+run_cp() { "$CP_CMD" -p "$1" "$2" || { note "cp -p $1 -> $2 failed"; return 1; }; }
+run_sed() { "$SED_CMD" -i "$@" || { note "sed env replacement failed"; return 1; }; }
+run_install(){ "$INSTALL_CMD" -m 0755 "$1" "$2" || { note "install $1 -> $2 failed"; return 1; }; }
+run_rsync() { "$RSYNC_CMD" "$@" || { note "rsync $* failed"; return 1; } }
+run_verchown_mode() {
+ chmod "$1" "$2" || { note "chmod $1 $2 failed"; return 1; }
+}
+# manifest is written to a tmp file and atomically moved into place; a failed
+# mv (=> incomplete manifest) never looks like a finished backup
+fin_stage() {
+ "$MV_CMD" "$1" "$2" || { note "finalize $2 failed"; return 1; }
+}
+
+db_scalar() {
+ # $1 = db path, $2 = sql (single scalar). sqlite3 if present, else python3 stdlib.
+ if command -v sqlite3 >/dev/null 2>&1; then
+ sqlite3 "$1" "$2"
+ else
+ python3 - "$1" "$2" <<'PY' || die "db_scalar failed on $1"
+import sqlite3, sys
+c = sqlite3.connect(sys.argv[1])
+row = c.execute(sys.argv[2]).fetchone()
+print(row[0] if row is not None else 0)
+PY
+ fi
+}
+
+env_value() {
+ grep -E "^$1=" "$STATE_DIR/runtime.env" | head -1 | cut -d= -f2-
+}
+
+service_active() { "$SYSTEMCTL_CMD" --user is-active "$SERVICE" >/dev/null 2>&1; }
+service_stop() { "$SYSTEMCTL_CMD" --user stop "$SERVICE" || return 1; }
+service_start() { "$SYSTEMCTL_CMD" --user start "$SERVICE" || { note "service start failed"; return 1; }; }
+
+inflight_datasets_sql() {
+ printf "SELECT COUNT(*) FROM datasets WHERE status IN ('pending','running')"
+}
+inflight_runs_sql() {
+ printf "SELECT COUNT(*) FROM runs WHERE status IN ('queued','running')"
+}
+# Returns:
+# 0 = no in-flight work
+# 1 = in-flight work present (or cannot be ruled out)
+# 2 = environment/DB error (missing DB_PATH, unreadable DB) — caller MUST
+# treat this as "unknown state", never as a clean pass. This function
+# NEVER exits the shell: after a service stop, a deep die() here would
+# bypass the restore branch and strand the old service in downtime.
+check_no_inflight() {
+ DB_PATH=$(env_value DB_PATH) || { note "DB_PATH missing in runtime.env"; return 2; }
+ DB_PATH=${DB_PATH:-}
+ [ -n "$DB_PATH" ] || { note "DB_PATH empty in runtime.env"; return 2; }
+ [ -f "$DB_PATH" ] || { note "DB file missing: $DB_PATH"; return 2; }
+ if ! n=$(db_scalar "$DB_PATH" "$(inflight_datasets_sql)"); then
+ note "read datasets failed"
+ return 2
+ fi
+ [ "$n" = "0" ] || { note "in-flight datasets: $n"; return 1; }
+ if ! n=$(db_scalar "$DB_PATH" "$(inflight_runs_sql)"); then
+ note "read runs failed"
+ return 2
+ fi
+ [ "$n" = "0" ] || { note "in-flight runs: $n"; return 1; }
+ return 0
+}
+
+precheck_live() {
+ [ -n "$IMAGE_ID" ] || die "IMAGE_ID required"
+ "$DOCKER_CMD" image inspect "$IMAGE_ID" >/dev/null 2>&1 || die "image not present: $IMAGE_ID"
+ [ "$(env_value WORKER_IMAGE)" != "$IMAGE_ID" ] || die "runtime.env already pins $IMAGE_ID"
+ [ -x "$SERVER_BIN_SRC" ] || die "missing server binary: $SERVER_BIN_SRC"
+ [ -f "$DIST_SRC/index.html" ] || { die "missing frontend dist: $DIST_SRC"; return 1; }
+ service_active || die "$SERVICE not active; resolve before deploy (start it or inspect)"
+ check_no_inflight || die "in-flight work; deploy blocked"
+ msg "precheck(live) OK"
+}
+
+precheck_stopped() {
+ service_active && { note "service still active after stop"; return 1; }
+ check_no_inflight || { note "new in-flight work discovered after stop"; return 2; }
+ msg "precheck(stopped) OK"
+}
+
+release_unique_backup_dir() {
+ rid_base=$(date -u +%Y%m%d-%H%M%S)
+ rid="$rid_base"
+ sfx=a
+ while [ -e "$BACKUP_ROOT/$rid" ]; do
+ rid="$rid_base-$sfx"
+ sfx=$(python3 -c "import sys,random;print(chr(ord('a')+random.randrange(26)))" 2>/dev/null || printf '%s' "$sfx")
+ done
+ RELEASE_ID=$rid
+ printf '%s' "$rid"
+}
+
+backup() {
+ release_unique_backup_dir >/dev/null 2>&1 || die "release id generation failed"
+ [ -n "$RELEASE_ID" ] || die "empty RELEASE_ID"
+ BACKUP_DIR=$BACKUP_ROOT/$RELEASE_ID
+ if [ -e "$BACKUP_DIR" ]; then
+ die "backup would overwrite existing $BACKUP_DIR (refusing)"
+ fi
+ mkdir -p "$BACKUP_DIR" || die "mkdir backup failed"
+ run_verchown_mode 700 "$BACKUP_ROOT"
+ run_verchown_mode 700 "$BACKUP_DIR"
+ run_cp "$STATE_DIR/runtime.env" "$BACKUP_DIR/runtime.env" || { mv "$BACKUP_DIR" "$BACKUP_DIR.broken.$RELEASE_ID" 2>/dev/null; die "backup: runtime.env copy failed"; }
+ run_verchown_mode 600 "$BACKUP_DIR/runtime.env"
+ run_rsync -a "$RELEASE_DIR/" "$BACKUP_DIR/release/" || { mv "$BACKUP_DIR" "$BACKUP_DIR.broken.$RELEASE_ID" 2>/dev/null; die "backup: release copy failed"; }
+ {
+ printf 'release_id=%s\ncreated_at=%s\n' "$RELEASE_ID" "$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
+ printf 'image_id_to_use=%s\nprevious_worker_image=%s\nprevious_server=%s\nprevious_frontend=%s\n' \
+ "$IMAGE_ID" "$(env_value WORKER_IMAGE)" \
+ "$RELEASE_DIR/strategy-lab-server" "$RELEASE_DIR/frontend"
+ sha256sum "$RELEASE_DIR/strategy-lab-server" 2>/dev/null | awk '{print "previous_server_sha256=" $1}' || printf 'previous_server_sha256=unknown\n'
+ } > "$BACKUP_DIR/release-manifest.txt.tmp" || { mv "$BACKUP_DIR" "$BACKUP_DIR.broken.$RELEASE_ID" 2>/dev/null; die "backup: manifest write failed"; }
+ # completeness check BEFORE the final marker: restore refuses backups
+ # without the 'complete' marker, so never mark anything half-copied.
+ [ -f "$BACKUP_DIR/runtime.env" ] && [ -f "$BACKUP_DIR/release/strategy-lab-server" ] \
+ || { mv "$BACKUP_DIR" "$BACKUP_DIR.broken.$RELEASE_ID" 2>/dev/null; die "backup incomplete; refusing to mark"; }
+ grep -q '^previous_server_sha256=' "$BACKUP_DIR/release-manifest.txt.tmp" \
+ || { mv "$BACKUP_DIR" "$BACKUP_DIR.broken.$RELEASE_ID" 2>/dev/null; die "backup manifest missing previous_server_sha256"; }
+
+ fin_stage "$BACKUP_DIR/release-manifest.txt.tmp" "$BACKUP_DIR/release-manifest.txt" \
+ || { mv "$BACKUP_DIR" "$BACKUP_DIR.broken.$RELEASE_ID" 2>/dev/null; die "backup: manifest finalize failed"; }
+ run_verchown_mode 600 "$BACKUP_DIR/release-manifest.txt"
+ : > "$BACKUP_DIR/complete" || die "backup marker failed"
+ run_verchown_mode 600 "$BACKUP_DIR/complete"
+ msg "backup $RELEASE_ID -> $BACKUP_DIR (complete)"
+}
+
+set_worker_image_id() {
+ rc=0
+ run_sed "s|^WORKER_IMAGE=.*|WORKER_IMAGE=${IMAGE_ID}|" "$STATE_DIR/runtime.env" || rc=$?
+ [ "$rc" -eq 0 ] || return "$rc"
+ run_verchown_mode 600 "$STATE_DIR/runtime.env" || return 1
+ [ "$(env_value WORKER_IMAGE)" = "$IMAGE_ID" ] || {
+ note "runtime.env replacement verification failed"
+ return 1
+ }
+ return 0
+}
+
+swap_release() {
+ run_install "$SERVER_BIN_SRC" "$RELEASE_DIR/strategy-lab-server" || return 1
+ # --checksum: dist contents must win even when size+mtime collide (drill
+ # regression: same-size files with identical mtime were silently skipped)
+ run_rsync -a --delete --checksum "$DIST_SRC/" "$RELEASE_DIR/frontend/" || return 1
+ return 0
+}
+
+health_ok() {
+ BIND=$(env_value BIND) || return 1
+ body=$("$CURL_CMD" -fsS -m 5 "http://$BIND/api/health" 2>/dev/null) || return 1
+ printf '%s' "$body" | grep -q '"status":"ok"' || return 1
+ return 0
+}
+
+wait_health() {
+ i=1
+ while [ $i -le 20 ]; do
+ if health_ok; then msg "health OK"; return 0; fi
+ sleep 1
+ i=$((i + 1))
+ done
+ return 1
+}
+
+restore_release_id() {
+ rid=${1:?usage: deploy.sh restore <release_id>}
+ bd=$BACKUP_ROOT/$rid
+ DEPLOY_PHASE=restore; export DEPLOY_PHASE
+ [ -d "$bd" ] || die "no backup for release id: $rid"
+ [ -f "$bd/complete" ] || die "backup incomplete (no complete marker): $bd"
+ [ -f "$bd/runtime.env" ] || die "backup incomplete: runtime.env"
+ [ -f "$bd/release/strategy-lab-server" ] || die "backup incomplete: release"
+ # R3: restore takes the same deploy.lock unless the caller already holds it
+ if [ "${DEPLOY_LOCK_HELD:-0}" != "1" ]; then
+ exec 9>"$STATE_DIR/deploy.lock"
+ flock -n 9 || die "another deploy/rollback holds deploy.lock"
+ fi
+ # --checksum: dist contents must win even when size+mtime collide
+ # touch on-disk artifacts only while the (old/new) binary is not running:
+ # stop first, then replace files, then start again and verify health.
+ # If stop cannot be CONFIRMED (command failed or service still active),
+ # refuse to write ANY file: the running binary may still be using them.
+ # The restore is non-destructive here — the caller can retry after
+ # resolving the stop problem, and all existing files remain recoverable.
+ if ! "$SYSTEMCTL_CMD" --user stop "$SERVICE" 2>/dev/null; then
+ # stop reported failure: only safe to continue if service is verifiably down
+ if service_active; then
+ die "stop failed and $SERVICE still ACTIVE — refusing to restore files (nothing was written; backup $rid intact and recoverable)"
+ fi
+ note "stop reported failure but service verified inactive; continuing"
+ fi
+ service_active && die "could not confirm $SERVICE stopped — refusing to restore files (nothing was written; backup $rid intact and recoverable)"
+ run_cp "$bd/runtime.env" "$STATE_DIR/runtime.env"
+ run_verchown_mode 600 "$STATE_DIR/runtime.env"
+ run_rsync -a --delete --checksum "$bd/release/" "$RELEASE_DIR/" || die "restore release failed"
+ service_start || die "service start after restore failed"
+ sleep 2
+ service_active || die "service not active after restore"
+ wait_health || die "health failed after restore"
+ verify_restored "$bd"
+ msg "restored $rid"
+}
+
+# verify restored artifacts hash vs manifest
+verify_restored() {
+ bd=$1
+ w=$(grep -E '^previous_worker_image=' "$bd/release-manifest.txt" | sed 's/^previous_worker_image=//') || die "manifest corrupt"
+ [ "$(env_value WORKER_IMAGE)" = "$w" ] || die "restored env mismatches manifest"
+ h=$(grep -E '^previous_server_sha256=' "$bd/release-manifest.txt" | sed 's/^previous_server_sha256=//')
+ now=$(sha256sum "$RELEASE_DIR/strategy-lab-server" | awk '{print $1}')
+ [ "$h" = "$now" ] || die "restored server hash mismatch: $now != $h"
+ msg "restored artifacts verified (env + sha256)"
+}
+
+# ---------------- main deploy flow ----------------
+main_deploy() {
+ exec 9>"$STATE_DIR/deploy.lock"
+ flock -n 9 || die "another deploy/rollback is running (deploy.lock held)"
+
+ msg "phase 1/6 precheck (live service)"
+ precheck_live
+
+ # Backup BEFORE stopping: a backup failure must never turn into downtime —
+ # the service keeps running on untouched artifacts and the deploy simply
+ # refuses.
+ msg "phase 2/6 backup (env + release artifacts, service still up)"
+ DEPLOY_PHASE=backup; export DEPLOY_PHASE
+ backup
+
+ msg "phase 3/6 stop service (block NEW work)"
+ service_stop || die "service stop failed"
+ # R1: capture the REAL return code of the stopped-state checks — not an
+ # &&-chain / if ! — so a `2` (= new work discovered) is distinguishable
+ # from plain failure and the caller can restore the old service untouched.
+ rc=0
+ precheck_stopped || rc=$?
+ # A DB/environment error (rc=2) after stop is treated the same as the race
+ # branch: we cannot prove the system is quiet, so restore the old service
+ # untouched rather than swapping files in an unverifiable state.
+ if [ "$rc" -ne 0 ]; then
+ if [ "$rc" = "2" ]; then
+ "$SYSTEMCTL_CMD" --user start "$SERVICE" || note "could NOT re-start old service — MANUAL CHECK NEEDED"
+ note "restored old service WITHOUT replacement (post-stop check errored: DB/env unreadable)"
+ note "backup dir kept for inspection only: $BACKUP_ROOT/$RELEASE_ID"
+ exit 1
+ fi
+ # rc=1: new work arrived between checks; the backup above is discarded (it
+ # recorded pre-inflight state and nothing was replaced)
+ "$SYSTEMCTL_CMD" --user start "$SERVICE" || note "could NOT re-start old service — MANUAL CHECK NEEDED"
+ note "restored old service WITHOUT replacement (new tasks preserved)"
+ note "backup dir kept for inspection only: $BACKUP_ROOT/$RELEASE_ID (release kept; runtime.env already restored bytes)"
+ exit 1
+ fi
+
+ msg "phase 4/6 swap (env WORKER_IMAGE + binary + dist)"
+ # R1: each step rc-checked independently (also inside an && chain)
+ DEPLOY_PHASE=swap; export DEPLOY_PHASE
+ if ! set_worker_image_id; then
+ note "env update failed"; DEPLOY_LOCK_HELD=1 restore_release_id "$RELEASE_ID"; exit 1
+ fi
+ if ! swap_release; then
+ note "release swap failed"; DEPLOY_LOCK_HELD=1 restore_release_id "$RELEASE_ID"; exit 1
+ fi
+
+ msg "phase 5/6 start service + health"
+ if ! "$SYSTEMCTL_CMD" --user start "$SERVICE"; then
+ note "service start failed"; DEPLOY_LOCK_HELD=1 restore_release_id "$RELEASE_ID"; exit 1
+ fi
+ sleep 2
+ if ! wait_health; then
+ note "health failed; rolling back to $RELEASE_ID"
+ DEPLOY_LOCK_HELD=1 restore_release_id "$RELEASE_ID"
+ exit 1
+ fi
+
+ msg "phase 6/6 deploy $RELEASE_ID complete (image $IMAGE_ID)"
+ msg "rollback id: $RELEASE_ID (rollback.sh $RELEASE_ID)"
+}
+
+case "${1:-deploy}" in
+ deploy) main_deploy ;;
+ restore) shift; restore_release_id "$@" ;;
+ precheck) precheck_live ;;
+ *) die "usage: deploy.sh [deploy|restore <id>|precheck]" ;;
+esac
diff --git a/artifacts/etf-recovery-candidate/ops/rollback.sh b/artifacts/etf-recovery-candidate/ops/rollback.sh
new file mode 100755
index 0000000..095330e
--- /dev/null
+++ b/artifacts/etf-recovery-candidate/ops/rollback.sh
@@ -0,0 +1,102 @@
+#!/bin/sh
+# Strategy Lab 回滚 v3(供 leader 审查/执行;开发会话不操作生产)。
+# 备份位于 <STATE_DIR>/release-backups/<RELEASE_ID>/,且只在写入了 complete
+# 标记后才可用;没有 complete 标记的备份一律拒绝恢复。
+# R3:取同一把 deploy.lock;顺序:in-flight 检查 → 停服务 → 替换在线文件
+# (运行中绝不写二进制/前端)→ 启动 → is-active + /api/health JSON
+# status=="ok" + sha256 与 runtime.env 较验一致。
+# 用法:
+# rollback.sh <RELEASE_ID> # 精确恢复该 id
+# rollback.sh list # 只读列表(不取锁、不动任何东西)
+set -eu
+
+SYSTEMCTL_CMD=${SYSTEMCTL_CMD:-systemctl}
+CURL_CMD=${CURL_CMD:-curl}
+CP_CMD=${CP_CMD:-cp}
+MV_CMD=${MV_CMD:-mv}
+STATE_DIR=${STATE_DIR:-/home/somhairle/.local/share/strategy-lab-production}
+RELEASE_DIR=${RELEASE_DIR:-$STATE_DIR/release}
+BACKUP_ROOT=${BACKUP_ROOT:-$STATE_DIR/release-backups}
+SERVICE=${SERVICE:-strategy-lab-production}
+
+msg() { printf '[rollback] %s\n' "$1"; }
+die() { printf '[rollback] FATAL: %s\n' "$1" >&2; exit 1; }
+
+env_value() { grep -E "^$1=" "$STATE_DIR/runtime.env" | head -1 | cut -d= -f2-; }
+
+db_scalar() {
+ if command -v sqlite3 >/dev/null 2>&1; then
+ sqlite3 "$1" "$2"
+ else
+ python3 - "$1" "$2" <<'PY' || die "db_scalar failed"
+import sqlite3, sys
+c = sqlite3.connect(sys.argv[1])
+row = c.execute(sys.argv[2]).fetchone()
+print(row[0] if row is not None else 0)
+PY
+ fi
+}
+
+if [ "${1:-}" = "list" ]; then
+ for d in "$BACKUP_ROOT"/*/; do
+ [ -d "$d" ] || continue
+ img=$(grep -E '^image_id_to_use=' "$d/release-manifest.txt" 2>/dev/null | sed 's/^image_id_to_use=//')
+ done_mark=""
+ [ -f "$d/complete" ] && done_mark=" COMPLETE"
+ printf '%s\timage=%s%s\n' "$(basename "$d")" "$img" "$done_mark"
+ done
+ exit 0
+fi
+
+rid=${1:?usage: rollback.sh <RELEASE_ID>|list}
+bd=$BACKUP_ROOT/$rid
+[ -d "$bd" ] || die "no backup for release id: $rid"
+[ -f "$bd/complete" ] || die "backup incomplete (no complete marker): $bd"
+[ -f "$bd/runtime.env" ] || die "backup incomplete: runtime.env"
+[ -f "$bd/release/strategy-lab-server" ] || die "backup incomplete: release"
+
+# R3: same lock as deploy
+exec 9>"$STATE_DIR/deploy.lock"
+flock -n 9 || die "another deploy/rollback is running (deploy.lock held)"
+
+# Refuse while user work is in-flight (a restore must not strand mid-run state)
+DB_PATH=$(grep -E '^DB_PATH=' "$STATE_DIR/runtime.env" | head -1 | cut -s -d= -f2-)
+if [ -n "${DB_PATH:-}" ]; then
+ n=$(db_scalar "$DB_PATH" "SELECT COUNT(*) FROM runs WHERE status IN ('queued','running')")
+ [ "$n" = "0" ] || die "in-flight runs: $n — cancel/finish before rollback"
+fi
+
+old_img=$(grep -E '^previous_worker_image=' "$bd/release-manifest.txt" 2>/dev/null | sed 's/^previous_worker_image=//') || true
+
+# NEVER write on-disk artifacts while a binary is executing it: stop first.
+"$SYSTEMCTL_CMD" --user stop "$SERVICE" || die "stop before restore failed"
+
+cp -p "$bd/runtime.env" "$STATE_DIR/runtime.env" || die "restore env failed"
+chmod 600 "$STATE_DIR/runtime.env"
+# --checksum: dist contents must win even when size+mtime collide
+rsync -a --delete --checksum "$bd/release/" "$RELEASE_DIR/" || {
+ # files may be half-restored — bring back what the backup declares as service
+ "$SYSTEMCTL_CMD" --user start "$SERVICE" 2>/dev/null || true
+ die "restore release failed"
+}
+
+"$SYSTEMCTL_CMD" --user start "$SERVICE" || die "service start after restore failed"
+sleep 2
+if ! "$SYSTEMCTL_CMD" --user is-active "$SERVICE" >/dev/null 2>&1; then
+ die "service not active after restore; check journalctl --user -u $SERVICE"
+fi
+
+# health JSON must report status ok
+BIND=$(grep -E '^BIND=' "$STATE_DIR/runtime.env" | head -1 | cut -d= -f2-)
+body=$("$CURL_CMD" -fsS -m 5 "http://$BIND/api/health" 2>/dev/null) || die "health unreachable"
+printf '%s' "$body" | grep -q '"status":"ok"' || die "health not ok: $body"
+
+# Release-artifact identity: hash must equal the manifest's recorded old value
+want_hash=$(grep -E '^previous_server_sha256=' "$bd/release-manifest.txt" | sed 's/^previous_server_sha256=//')
+now_hash=$(sha256sum "$RELEASE_DIR/strategy-lab-server" | awk '{print $1}')
+[ "$want_hash" = "$now_hash" ] || die "restored server hash mismatch: $now_hash != $want_hash"
+
+# env identity
+[ "$(env_value WORKER_IMAGE)" = "$old_img" ] || die "restored env WORKER_IMAGE mismatch: $(env_value WORKER_IMAGE) != $old_img"
+
+msg "restored $rid: env verified, artifact sha256 verified, health ok."