From 9965ee22040e7c7544952173ed90fdb00a4a1b62 Mon Sep 17 00:00:00 2001 From: pi Date: Mon, 14 Sep 2026 17:50:29 +0800 Subject: [PATCH] =?UTF-8?q?chore(deploy):=20=E6=B8=85=E7=90=86=E8=A1=A5?= =?UTF-8?q?=E4=B8=A4=E7=B1=BB=E6=AE=8B=E7=95=99=EF=BC=88=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BA=93/=E9=99=84=E4=BB=B6=E5=A4=87=E4=BB=BD=E9=9B=86?= =?UTF-8?q?=E3=80=81=E4=BC=9A=E8=AF=9D=E6=B8=85=E7=90=86=E5=89=8D=E5=A4=87?= =?UTF-8?q?=E4=BB=BD=EF=BC=89+=20=E5=9C=A8=E7=BA=BF=E5=BA=93=E7=A1=AC?= =?UTF-8?q?=E9=97=B8=E9=97=A8=20+=20=E4=B8=A4=E4=BE=A7=E8=87=AA=E6=A3=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 备份集:同一 的库+附件包一起进出窗口;按文件名时间戳排序(mtime 被访问改过) - 在线库永不入删除清单:del() 里 readlink -f 比对,命中即 FATAL 退出 - 每个插件只剩 current 时报「无回滚目标」(只报告,不造快照) - --self-check 16 项:干净样本该删的删/该留的留/在线库不动;坏样本(备份指向在线库)必须拒跑; 外加「自检没有碰生产根」的前后指纹比对 - 事故记录写进 DEV-TOOLING.md:自检传了 ROOT 而脚本读 AGENTMAIL_ROOT ⇒ --apply 打到生产根; 半对不对更难认(PRUNE_TMP_DIR 那一路是对的),是 bash -x 露的马脚 --- deploy/prune-deploy-artifacts.sh | 203 +++++++++++++++++++++++++++++-- 1 file changed, 192 insertions(+), 11 deletions(-) diff --git a/deploy/prune-deploy-artifacts.sh b/deploy/prune-deploy-artifacts.sh index c5efb55..1833156 100755 --- a/deploy/prune-deploy-artifacts.sh +++ b/deploy/prune-deploy-artifacts.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# 清理部署残留:旧网关二进制、旧插件快照、/tmp 里的部署前数据库备份。 +# 清理部署残留:旧网关二进制、旧插件快照、旧数据库/附件备份、/tmp 里的部署前备份。 # # # 为什么要脚本而不是手敲 rm # @@ -8,23 +8,32 @@ # /opt/agentmail 1.3G,其中旧二进制 ~790MB、插件快照 ~540MB。 # 手敲的清理不可复现、下次还得再想一遍,而且很容易漏掉"这一份还在被用"的判断。 # -# # 两条硬规矩 +# # 三条硬规矩 # # ① **保留回滚窗口**:网关二进制留最新 N 份、每个插件留最新 N 份快照 -# (`current` 永远保留)。发布纪律要求"有回滚目标",所以不是全清。 +# (`current` 永远保留)、同一时间戳的数据库/附件备份算**一个备份集**留最新 N 集。 +# 发布纪律要求"有回滚目标",所以不是全清。 # ② ★ **绝不删正在使用的快照**:任何进程的 cmdline 里出现过的快照路径一律跳过。 # 删掉它 = 进程一重启就找不到自己的代码(而这类事故此前真发生过)。 +# ③ ★★ **在线数据库永不入列**:`$ROOT/data/agentmail.db`(含 `-wal`/`-shm`)一旦出现在 +# 删除清单里,脚本立刻**报错退出**,不做任何删除。备份删错了还能重建, +# 在线库删错了就是数据没了 —— 这条闸门必须比"我检查过了"硬。 +# 判据 `--self-check` 两侧都验:干净样本删完在线库还在;把超窗口的备份换成 +# 指向在线库的符号链接,脚本必须**拒跑**(不是"没删")。 # # 用法: -# bash deploy/prune-deploy-artifacts.sh # 干跑,只报告 -# bash deploy/prune-deploy-artifacts.sh --apply # 真删 -# KEEP_GATEWAY=2 KEEP_SNAPSHOTS=2 bash ... --apply # 调保留窗口 +# bash deploy/prune-deploy-artifacts.sh # 干跑,只报告 +# bash deploy/prune-deploy-artifacts.sh --apply # 真删 +# bash deploy/prune-deploy-artifacts.sh --self-check # 自检:坏样本必须红、干净样本必须绿 +# KEEP_GATEWAY=2 KEEP_SNAPSHOTS=2 bash ... --apply # 调保留窗口 set -uo pipefail ROOT="${AGENTMAIL_ROOT:-/opt/agentmail}" KEEP_GATEWAY="${KEEP_GATEWAY:-3}" KEEP_SNAPSHOTS="${KEEP_SNAPSHOTS:-3}" +KEEP_DB_BACKUPS="${KEEP_DB_BACKUPS:-1}" KEEP_TMP_DB="${KEEP_TMP_DB:-2}" +TMPD="${PRUNE_TMP_DIR:-/tmp}" APPLY=0 [ "${1:-}" = "--apply" ] && APPLY=1 @@ -34,6 +43,9 @@ skipped=0 report() { printf ' %s %s\n' "$1" "$2"; } +# 在线库:唯一的"碰了就回不来"的东西。 +LIVE_DB_REAL="$(readlink -f "$ROOT/data/agentmail.db" 2>/dev/null || echo "$ROOT/data/agentmail.db")" + # 收集所有"正在被进程使用"的路径(cmdline + cwd),用来做安全闸。 # 自己这条命令的 cmdline 里就可能含这个路径(例如调用方把路径写在命令行里), # 于是"检查在用"会永远为真 —— 与 `pkill -f` 杀掉自己那条命令是同一个坑。 @@ -59,13 +71,124 @@ in_use() { } del() { - local path="$1" + local path="$1" real + real="$(readlink -f "$path" 2>/dev/null || echo "$path")" + case "$real" in + "$LIVE_DB_REAL"|"$LIVE_DB_REAL-wal"|"$LIVE_DB_REAL-shm") + printf '\n [FATAL] 删除清单里出现在线数据库:%s(真实路径 %s)\n' "$path" "$real" >&2 + printf ' 脚本的判定有 bug —— 拒绝执行,删一个备份可以重建,删在线库回不来。\n' >&2 + exit 1;; + esac local kb; kb=$(du -sk "$path" 2>/dev/null | cut -f1); kb=${kb:-0} if [ "$APPLY" = "1" ]; then rm -rf "$path"; fi freed_kb=$((freed_kb + kb)); removed=$((removed + 1)) report " $([ "$APPLY" = 1 ] && echo 删除 || echo 待删)" "$path($((kb/1024))MB)" } +# ---------------------------------------------------------------- 自检 +# 判据本身也要有判据:干净样本必须绿(该删的删、该留的留、在线库不动), +# 坏样本必须红(把超窗口的备份换成指向在线库的符号链接 → 必须拒跑)。 +# 自检跑在临时假根上,`$TMPD` 也指到假根里 —— 绝不碰真机上的东西。 +if [ "${1:-}" = "--self-check" ]; then + fails=0; total=0 + ck() { total=$((total+1)); if [ "$2" = "1" ]; then echo " 通过 $1"; else echo " 失败 $1"; fails=$((fails+1)); fi; } + + mktree() { + local t; t="$(mktemp -d "$TMPD/am-prune-selftest-XXXXXX")" + mkdir -p "$t/data" "$t/backups" "$t/tmp" \ + "$t/plugins/demo/20260101-000000" "$t/plugins/demo/20260102-000000" \ + "$t/plugins/demo/20260103-000000" "$t/plugins/demo/20260104-000000" + ln -s 20260104-000000 "$t/plugins/demo/current" + : > "$t/agentmail-gateway" + # mtime 必须各不同:`ls -1t` 对同一秒的文件靠名字兜底排序, + # 早期版本的自检就是因此把"保留哪一份"判成反的(脚本没错,鱼缸错了)。 + for i in 1 2 3 4; do + echo x > "$t/agentmail-gateway.bak-2026010$i-000000" + touch -d "2026-01-0$i 00:00:00" "$t/agentmail-gateway.bak-2026010$i-000000" + touch -d "2026-01-0$i 00:00:00" "$t/plugins/demo/2026010$i-000000" + done + : > "$t/data/agentmail.db" # ← 在线库(假) + : > "$t/data/agentmail.db-wal" + : > "$t/data/agentmail.db.bak-20260105-000000" # 最新备份集 → 保留 + : > "$t/backups/agentmail-20251201-000000.db" # 老备份集 → 整集删(连同附件包与 -shm/-wal) + echo yy > "$t/backups/agentmail-20251201-000000.db-shm" + : > "$t/backups/agentmail-20251201-000000.db-wal" + : > "$t/backups/attachments-20251201-000000.tar.gz" + for i in 1 2 3; do + : > "$t/tmp/agentmail-pre-deploy-2026010$i-000000.db" + touch -d "2026-01-0$i 00:00:00" "$t/tmp/agentmail-pre-deploy-2026010$i-000000.db" + done + echo "$t" + } + run() { + local t="$1" mode="${2:-}" + # ★ 自检只允许在**自己刚建的临时假根**上跑。这条闸门是事后补的,因为它真出过事: + # 早先 `run()` 传的是 `ROOT=…`,而脚本读的是 `AGENTMAIL_ROOT` —— 前缀赋值看起来 + # "传进去了"(`$TMPD` 那一路用的是 PRUNE_TMP_DIR,确实进去了),于是自检的 + # `--apply` 打在了**生产根**上:删掉一批回滚备份。教训不是"下次小心", + # 而是自检必须**结构上**碰不到生产:传对变量名 + 一条根目录必须在临时区的硬闸门。 + case "$t" in + "$TMPD"/am-prune-selftest-*) ;; + *) echo " [FATAL] 自检根目录不在临时区:$t —— 拒跑" >&2; exit 1;; + esac + AGENTMAIL_ROOT="$t" PRUNE_TMP_DIR="$t/tmp" KEEP_GATEWAY=2 KEEP_SNAPSHOTS=2 KEEP_DB_BACKUPS=1 KEEP_TMP_DB=1 \ + bash "$0" $mode + } + + echo + echo "判据自检(先证明清理器能分辨该删/该留,再证明它拦得住误删):" + + # 自检前后对比生产根的清单指纹 —— "自检不会碰生产"要有判据,不靠自觉。 + _tree_sig() { + # 在线库自身的大小一直在变(WAL 在写),所以把 live 三件套排除掉再看指纹, + # 否则这条判据会因为别人的正常写入而无关红。 + find "$1" -maxdepth 3 \ + \( -path "$1/data/attachments" -o -name 'agentmail.db' -o -name 'agentmail.db-wal' -o -name 'agentmail.db-shm' \) -prune -o \ + -printf '%p %s\n' 2>/dev/null | sort + } + SIG_BEFORE="$(_tree_sig "$ROOT")" + + T="$(mktree)" + out="$(run "$T")"; rc=$? # 干跑:看报告 + ck "干净样本:干跑成功(退出码 $rc)" "$([ "$rc" = 0 ] && echo 1 || echo 0)" + ck "干净样本:窗口内的网关二进制保留" "$(printf '%s' "$out" | grep -qF "保留 $T/agentmail-gateway.bak-20260104-000000" && echo 1 || echo 0)" + ck "干净样本:窗口外的网关二进制待删" "$(printf '%s' "$out" | grep -qF "待删 $T/agentmail-gateway.bak-20260101-000000" && echo 1 || echo 0)" + ck "干净样本:窗口外的插件快照待删" "$(printf '%s' "$out" | grep -qF "待删 $T/plugins/demo/20260101-000000" && echo 1 || echo 0)" + ck "干净样本:老备份集整集待删(含附件包)" \ + "$(printf '%s' "$out" | grep -qF "待删 $T/backups/attachments-20251201-000000.tar.gz" && echo 1 || echo 0)" + ck "干净样本:最新备份集保留" "$(printf '%s' "$out" | grep -qF "保留 $T/data/agentmail.db.bak-20260105-000000" && echo 1 || echo 0)" + ck "干净样本:在线库不在清单里" "$(printf '%s' "$out" | grep -qE '/data/agentmail\.db(' && echo 0 || echo 1)" + + run "$T" --apply >/dev/null; rc=$? # 真删:看结果 + ck "干净样本:真删成功(退出码 $rc)" "$([ "$rc" = 0 ] && echo 1 || echo 0)" + ck "干净样本:在线库与它的 -wal 仍在" "$([ -f "$T/data/agentmail.db" ] && [ -f "$T/data/agentmail.db-wal" ] && echo 1 || echo 0)" + ck "干净样本:窗口外没了、窗口内还在" \ + "$([ ! -e "$T/agentmail-gateway.bak-20260101-000000" ] && [ -e "$T/agentmail-gateway.bak-20260104-000000" ] && echo 1 || echo 0)" + ck "干净样本:老备份集整集删除、最新备份集保留" \ + "$([ ! -e "$T/backups/agentmail-20251201-000000.db" ] && [ ! -e "$T/backups/attachments-20251201-000000.tar.gz" ] && [ -e "$T/data/agentmail.db.bak-20260105-000000" ] && echo 1 || echo 0)" + ck "干净样本:/tmp 备份只留窗口内那 1 份" \ + "$([ -e "$T/tmp/agentmail-pre-deploy-20260103-000000.db" ] && [ ! -e "$T/tmp/agentmail-pre-deploy-20260101-000000.db" ] && echo 1 || echo 0)" + + # 坏样本:超窗口的那个备份是指向在线库的符号链接 —— 必须**拒跑**,而不是"删了才发现" + B="$(mktree)"; rm -f "$B/backups/agentmail-20251201-000000.db" + ln -s "$B/data/agentmail.db" "$B/backups/agentmail-20251201-000000.db" + bout="$(run "$B" --apply 2>&1)"; brc=$? + ck "坏样本(备份是指向在线库的链接):必须拒跑(实际退出码 $brc)" "$([ "$brc" != 0 ] && echo 1 || echo 0)" + ck "坏样本:拒绝理由要点名在线数据库" "$(printf '%s' "$bout" | grep -q "在线数据库" && echo 1 || echo 0)" + ck "坏样本:在线库仍在" "$([ -f "$B/data/agentmail.db" ] && echo 1 || echo 0)" + # 自检最要紧的一条:生产根在这两轮自检里**一个文件都没动**。 + ck "自检没有碰生产根($ROOT 清单不变)" "$([ "$(_tree_sig "$ROOT")" = "$SIG_BEFORE" ] && echo 1 || echo 0)" + + rm -rf "$T" "$B" + echo + if [ "$fails" != 0 ]; then + echo " 判据自检失败 $fails/$total 项 —— 清理器本身不可信,不要用它删东西" >&2 + exit 1 + fi + echo " 判据自检全绿($total 项)。" + exit 0 +fi + echo "==> 网关旧二进制(保留最新 $KEEP_GATEWAY 份)" mapfile -t bins < <(ls -1t "$ROOT"/agentmail-gateway.bak-* 2>/dev/null) for i in "${!bins[@]}"; do @@ -89,19 +212,77 @@ for plug in "$ROOT"/plugins/*/; do if in_use "$s"; then report " ★跳过(正在被使用)" "$s"; skipped=$((skipped+1)); continue; fi del "$s" done + # 回滚窗口的含义:每个插件除了 `current` 之外还应该留着至少一份"上一版", + # 否则回滚没有目标(发布纪律第四章)。这里只报告,**不造快照** —— 造不出来的东西 + # 不该假装有。 + tsnaps=0 + for s in "${snaps[@]}"; do [ "$(basename "$s")" = "current" ] || tsnaps=$((tsnaps+1)); done + if [ "$tsnaps" -le 1 ]; then report " ⚠ 无回滚目标(只有 current 那一份)" "$name"; fi done -echo "==> /tmp 的部署前数据库备份(保留最新 $KEEP_TMP_DB 份)" -mapfile -t dbs < <(ls -1t /tmp/agentmail-pre-deploy-*.db 2>/dev/null) +# 数据库/附件备份。两个来源各写各的时间戳,但都是"某一刻的库快照": +# · `$ROOT/backups/agentmail-.db` + `attachments-.tar.gz` —— deploy/reset-demo.sh +# 重置演示数据前留的档; +# · `$ROOT/data/agentmail.db.bak-` —— 早期手工留档。 +# 同一个 `` 的文件算**一个备份集**(库和它的附件包必须同生共死,拆开留没有意义), +# 一起进出窗口。顺序按**文件名里的时间戳**排,不按 mtime —— mtime 会被"顺手打开看一眼" +# 改掉(实测:09-02 的两个备份被 09-08 的一次访问改了 mtime)。 +# 没有时间戳的文件一律不碰:判定不了就不删(宁可漏,不可误删)。 +echo "==> 旧数据库/附件备份(保留最新 $KEEP_DB_BACKUPS 个备份集)" +declare -A SET_MEMBERS=() +for f in "$ROOT"/backups/* "$ROOT"/data/*.bak-*; do + [ -e "$f" ] || continue + ts="$(printf '%s' "$(basename "$f")" | grep -oE '[0-9]{8}-[0-9]{6}' | head -1 || true)" + if [ -z "$ts" ]; then + report " 跳过(文件名无时间戳,判定不了)" "$f"; skipped=$((skipped+1)); continue + fi + SET_MEMBERS[$ts]="${SET_MEMBERS[$ts]:-} $f" +done +if [ "${#SET_MEMBERS[@]}" -eq 0 ]; then + report " (没有备份集)" "" +else + mapfile -t sets < <(printf '%s\n' "${!SET_MEMBERS[@]}" | sort -r) + for i in "${!sets[@]}"; do + ts="${sets[$i]}" + for f in ${SET_MEMBERS[$ts]}; do + if in_use "$f"; then report " ★跳过(正在被使用)" "$f"; skipped=$((skipped+1)); continue; fi + if [ "$i" -lt "$KEEP_DB_BACKUPS" ]; then + case "$f" in + # 保留集里的 `-shm`/`-wal` 是"打开看过一眼"的残渣:`-shm` 是内存索引, + # sqlite 自己会重建;`-wal` 只在**非空**时才可能含未合并写入,非空就不动它。 + *-shm) del "$f";; + *-wal) if [ -s "$f" ]; then report " *跳过(wal 非空,可能含未合并写入)" "$f"; skipped=$((skipped+1)); else del "$f"; fi;; + *) report " 保留" "$f";; + esac + else + del "$f" + fi + done + done +fi + +echo "==> $TMPD 的部署前数据库备份(保留最新 $KEEP_TMP_DB 份)" +mapfile -t dbs < <(ls -1t "$TMPD"/agentmail-pre-deploy-*.db 2>/dev/null) for i in "${!dbs[@]}"; do [ "$i" -lt "$KEEP_TMP_DB" ] && { report " 保留" "${dbs[$i]}"; continue; } del "${dbs[$i]}" done +# 会话清理(prune-test-sessions.sh)前的备份是**那次删除**唯一的回滚点, +# 所以另算一条窗口、且窗口到了也要说清楚它是什么再删。 +echo "==> $TMPD 的会话清理前备份(保留最新 $KEEP_TMP_DB 份)" +mapfile -t prunedbs < <(ls -1t "$TMPD"/agentmail-pre-prune-*.db 2>/dev/null) +for i in "${!prunedbs[@]}"; do + [ "$i" -lt "$KEEP_TMP_DB" ] && { report " 保留(会话归档的回滚点)" "${prunedbs[$i]}"; continue; } + del "${prunedbs[$i]}" +done + echo if [ "$APPLY" = "1" ]; then - echo " 已删除 $removed 项,释放约 $((freed_kb/1024))MB;跳过(在用)$skipped 项" + echo " 已删除 $removed 项,释放约 $((freed_kb/1024))MB;跳过(在用/不能判)$skipped 项" + echo " 已确认:删除清单不含在线数据库 $(basename "$LIVE_DB_REAL")。" else - echo " 干跑:将删除 $removed 项,可释放约 $((freed_kb/1024))MB;跳过(在用)$skipped 项" + echo " 干跑:将删除 $removed 项,可释放约 $((freed_kb/1024))MB;跳过(在用/不能判)$skipped 项" + echo " 已确认:删除清单不含在线数据库 $(basename "$LIVE_DB_REAL")。" echo " 加 --apply 执行。" fi