Files
MailUI4Agents/deploy/prune-deploy-artifacts.sh
pi 9965ee2204 chore(deploy): 清理补两类残留(数据库/附件备份集、会话清理前备份)+ 在线库硬闸门 + 两侧自检
- 备份集:同一 <ts> 的库+附件包一起进出窗口;按文件名时间戳排序(mtime 被访问改过)
- 在线库永不入删除清单:del() 里 readlink -f 比对,命中即 FATAL 退出
- 每个插件只剩 current 时报「无回滚目标」(只报告,不造快照)
- --self-check 16 项:干净样本该删的删/该留的留/在线库不动;坏样本(备份指向在线库)必须拒跑;
  外加「自检没有碰生产根」的前后指纹比对
- 事故记录写进 DEV-TOOLING.md:自检传了 ROOT 而脚本读 AGENTMAIL_ROOT ⇒ --apply 打到生产根;
  半对不对更难认(PRUNE_TMP_DIR 那一路是对的),是 bash -x 露的马脚
2026-09-14 17:50:29 +08:00

289 lines
16 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# 清理部署残留:旧网关二进制、旧插件快照、旧数据库/附件备份、/tmp 里的部署前备份。
#
# # 为什么要脚本而不是手敲 rm
#
# 这些东西**天天在长**:每次 redeploy 留一个 24MB 的网关二进制、每个插件留一份快照
# (opencode/dsh 的快照含 node_modules,一份几十 MB)。2026-09-14 实测:
# /opt/agentmail 1.3G,其中旧二进制 ~790MB、插件快照 ~540MB。
# 手敲的清理不可复现、下次还得再想一遍,而且很容易漏掉"这一份还在被用"的判断。
#
# # 三条硬规矩
#
# ① **保留回滚窗口**:网关二进制留最新 N 份、每个插件留最新 N 份快照
# (`current` 永远保留)、同一时间戳的数据库/附件备份算**一个备份集**留最新 N 集。
# 发布纪律要求"有回滚目标",所以不是全清。
# ② ★ **绝不删正在使用的快照**:任何进程的 cmdline 里出现过的快照路径一律跳过。
# 删掉它 = 进程一重启就找不到自己的代码(而这类事故此前真发生过)。
# ③ ★★ **在线数据库永不入列**:`$ROOT/data/agentmail.db`(含 `-wal`/`-shm`)一旦出现在
# 删除清单里,脚本立刻**报错退出**,不做任何删除。备份删错了还能重建,
# 在线库删错了就是数据没了 —— 这条闸门必须比"我检查过了"硬。
# 判据 `--self-check` 两侧都验:干净样本删完在线库还在;把超窗口的备份换成
# 指向在线库的符号链接,脚本必须**拒跑**(不是"没删")。
#
# 用法:
# 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
freed_kb=0
removed=0
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` 杀掉自己那条命令是同一个坑。
# 所以先把本进程及其祖先链排除掉。
_SELF_CHAIN=" "
_p=$$
while [ -n "$_p" ] && [ "$_p" != "1" ] && [ "$_p" != "0" ]; do
_SELF_CHAIN="$_SELF_CHAIN$_p "
_p=$(awk '{print $4}' "/proc/$_p/stat" 2>/dev/null || echo "")
done
in_use() {
local needle="$1" p pid
for p in /proc/[0-9]*; do
pid=${p#/proc/}
[ -r "$p/cmdline" ] || continue
case "$_SELF_CHAIN" in *" $pid "*) continue;; esac
if tr '\0' ' ' <"$p/cmdline" 2>/dev/null | grep -qF "$needle"; then return 0; fi
local cwd; cwd=$(readlink "$p/cwd" 2>/dev/null || true)
case "$cwd" in *"$needle"*) return 0;; esac
done
return 1
}
del() {
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
[ "$i" -lt "$KEEP_GATEWAY" ] && { report " 保留" "${bins[$i]}"; continue; }
if in_use "${bins[$i]}"; then report " ★跳过(正在被使用)" "${bins[$i]}"; skipped=$((skipped+1)); continue; fi
del "${bins[$i]}"
done
echo "==> 插件快照(每个插件保留最新 $KEEP_SNAPSHOTS 份,current 永远保留)"
for plug in "$ROOT"/plugins/*/; do
[ -d "$plug" ] || continue
name=$(basename "$plug")
mapfile -t snaps < <(ls -1dt "$plug"*/ 2>/dev/null | sed 's:/$::')
for i in "${!snaps[@]}"; do
s="${snaps[$i]}"
[ "$(basename "$s")" = "current" ] && continue
# current 指向的那一份也必须保留
cur=$(readlink -f "$plug/current" 2>/dev/null || true)
[ "$cur" = "$(readlink -f "$s")" ] && { report " 保留" "$s(current 指向)"; continue; }
if [ "$i" -lt "$KEEP_SNAPSHOTS" ]; then report " 保留" "$s"; continue; fi
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
# 数据库/附件备份。两个来源各写各的时间戳,但都是"某一刻的库快照":
# · `$ROOT/backups/agentmail-<ts>.db` + `attachments-<ts>.tar.gz` —— deploy/reset-demo.sh
# 重置演示数据前留的档;
# · `$ROOT/data/agentmail.db.bak-<ts>` —— 早期手工留档。
# 同一个 `<ts>` 的文件算**一个备份集**(库和它的附件包必须同生共死,拆开留没有意义),
# 一起进出窗口。顺序按**文件名里的时间戳**排,不按 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 " 已确认:删除清单不含在线数据库 $(basename "$LIVE_DB_REAL")。"
else
echo " 干跑:将删除 $removed 项,可释放约 $((freed_kb/1024))MB;跳过(在用/不能判)$skipped 项"
echo " 已确认:删除清单不含在线数据库 $(basename "$LIVE_DB_REAL")。"
echo " 加 --apply 执行。"
fi