pi 建议用 PATH shim 覆盖 `del()` 的失败路径("假 rm 放 PATH 前面,只对窗口外那一个目标失败")。
我 09-14 否掉了它,并在两处注释里写下理由:「`in_use` 会把命令行里含该路径的进程判成在用」。
**这个理由我没验证过,而实测它不成立。**
## 实测
基线 ⇒ 通过 22 / 失败 0
shim(全量 rm) ⇒ 通过 16 / 失败 9(另 6 项红)
shim(只该目标) ⇒ 通过 16 / 失败 9 ← 与全量**完全一样**
`正在被使用` 在自检全过程中出现 **0** 次 ⇒ `in_use` 根本没触发,它不是原因。
## 真因:夹具与"窗口外那一个"撞了同一个时间戳
自检夹具 `mktree` 建的就是 `$T/agentmail-gateway.bak-20260101-000000`,
而"窗口外该删的那一个"**也是它**(`:210/:221` 断言它被删)。
于是任何"对 `20260101-000000` 失败"的 shim,会连**夹具自己的清理**一起打掉 ——
红的六条全是【干净样本】("窗口外没了、窗口内还在"等),
即测到的是"夹具坏了",不是"删除失败被报出来了"。
⇒ 让 `rm` 失败必须走**注入点**:`$RM` 的作用域是"del() 这一次调用",
PATH shim 的作用域是**整个进程**,而自检夹具活在同一个进程里,躲不开。
**结论(用 `$RM`)当初就是对的,理由说错了** —— 与 pi 这轮纠正我的形状完全相同。
## 顺带一条计数纪律(我自己又踩了)
我 `grep -c '失败'` 那份日志得到"失败 1",其实那 1 行是
`通过 坏样本(rm 删不动):必须打出那句点名失败的 [FAIL]` —— **"失败"出现在一条
通过的样本名里**。按行首标记精数 ⇒ **22/22 全绿**。
与 `grep -c 用例名` 数出假数、`# Subtest:` 头那两次同族:**判据锚在了元文本上**。
437 lines
26 KiB
Bash
Executable File
437 lines
26 KiB
Bash
Executable File
#!/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 KEEP_BUILD_STAGES=0 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}"
|
||
KEEP_BUILD_STAGES="${KEEP_BUILD_STAGES:-1}"
|
||
TMPD="${PRUNE_TMP_DIR:-/tmp}"
|
||
APPLY=0
|
||
[ "${1:-}" = "--apply" ] && APPLY=1
|
||
# 只影响 `del()` 用哪个删除命令(默认真 rm)。自检用它喂"删不动"的样本。
|
||
RM="${RM:-rm}"
|
||
|
||
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}
|
||
# ★ `rm` 的结果必须看:本脚本是 `set -uo pipefail`(**没有 -e**),
|
||
# 于是 `rm -rf` 失败(权限、只读挂载、immutable)之后脚本照样往下走,
|
||
# 先打"删除 …(NMB)"、收尾再汇总"**已删除 N 项,释放约 X MB**" ——
|
||
# **一次失败之后仍然产出成功措辞**(pi 评审 2026-09-14 抓到,实测确认)。
|
||
# 这与 `redeploy-plugin.sh` 里"被拒之后照样打 `[ OK ]`"是同一族,也是本仓库
|
||
# 反复记录的那个病:从失败里产出一份看着正常的报告。
|
||
# 所以:判失败即报错并 `exit 2`(环境/权限问题),且 `removed`/`freed_kb`
|
||
# **只在成功后才累加**,收尾汇总不再虚报。
|
||
if [ "$APPLY" = "1" ]; then
|
||
# `$RM` 只是为了**让自检能喂一个"删不动"的失败**(见 --self-check 那一段):
|
||
# 默认就是 `rm`,生产行为逐字不变。
|
||
#
|
||
# ★ 为什么**不**用 PATH shim(pi 2026-09-14 建议过,我实测后否掉,2026-09-21 补正)
|
||
#
|
||
# 我原先在这里写的原因是"`in_use` 会把命令行里含该路径的进程判成在用" ——
|
||
# **那是我没量过的推断,而且量下来是错的**:自检跑完 `正在被使用` 出现 **0** 次,
|
||
# `in_use` 根本没触发。真正的失败原因是**夹具与该路径撞了同一个时间戳**:
|
||
#
|
||
# 自检夹具建的就是 `$T/agentmail-gateway.bak-20260101-000000`(`mktree`),
|
||
# 而「窗口外那一个」恰恰也是它 —— 于是任何"对 `20260101-000000` 失败"的 shim
|
||
# 会连**夹具自己的清理**一起打掉。实测(两种 shim 都试过):
|
||
# 基线 ⇒ 通过 22 / 失败 1
|
||
# shim(全 rm) ⇒ 通过 16 / 失败 9
|
||
# shim(只该目标)⇒ 通过 16 / 失败 9 ← 与全量 shim **一样**,因为目标就是夹具
|
||
# 红的六条还都是【干净样本】("窗口外没了、窗口内还在"等)—— 即 shim 把
|
||
# **本应绿的样本**打红,测到的是"夹具坏了"而不是"删除失败被报出来了"。
|
||
#
|
||
# ⇒ 让 `rm` 失败这件事,只有**注入点**能精确限定在"del() 这一次调用"上;
|
||
# PATH shim 的作用域是**整个进程**,而自检夹具活在同一个进程里,躲不开。
|
||
if ! "$RM" -rf "$path"; then
|
||
printf '
|
||
[FAIL] 删不掉:%s(权限/只读挂载/immutable?)\n' "$path" >&2
|
||
# ⚠️ 这句**不要**把收尾汇总的原话("已删除 N 项…")抄进来:
|
||
# 抄进来之后 `grep -c '已删除'` 会命中**这句报错自己**,
|
||
# 于是"有没有虚报成功"这个检查把自己的措辞当成了证据
|
||
# (与"`grep -c 用例名` 数出假数"是同一族:判据锚在了元文本上)。
|
||
printf ' 已停在中途 —— 后面的判断没跑,收尾汇总也不会执行。\n' >&2
|
||
exit 2
|
||
fi
|
||
# 删完还要**抽验**:`rm -rf` 对某些情况会"成功"却留东西(例如非空目录里
|
||
# 有不可删项时,某些实现返回 0)。存在即报错,别信退出码单一信号。
|
||
if [ -e "$path" ]; then
|
||
printf '
|
||
[FAIL] `rm -rf` 报了成功但 %s 还在\n' "$path" >&2
|
||
exit 2
|
||
fi
|
||
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"
|
||
# ★ 必须是**有内容的目录**,不能是 `: >` 造的空文件!
|
||
# 真实的构建暂存是 `mktemp -d` 出来的目录、里面有 `agentmail-gateway` 等产物。
|
||
# 夹具原先用 `: >` 造**普通文件**,而这个形状**恰好把下面那个 bug 藏住了**:
|
||
# `ls -1t <多个目录>` 会打 `路径:` 头 + 内容(不是裸名字),于是 collect 拿到
|
||
# 一堆不是文件名的行 ⇒ 每个都"文件名无时间戳"⇒ 全跳过 ⇒ 一个都不删,
|
||
# 而判据 197 行只查"窗口内的还在、窗口外的不在",用的是**文件名**判的,
|
||
# 于是**夹具自己认了错形状、判据自己放行了**(与 `user-question` 那次
|
||
# "判据的量纲没覆盖真实形状"是同一族)。改成真目录后这条 bug 立刻可见。
|
||
mkdir -p "$t/tmp/agentmail-gateway-build-2026010$i-000000"
|
||
: > "$t/tmp/agentmail-gateway-build-2026010$i-000000/agentmail-gateway"
|
||
touch -d "2026-01-0$i 00:00:00" "$t/tmp/agentmail-gateway-build-2026010$i-000000"
|
||
done
|
||
echo "$t"
|
||
}
|
||
# 给"删除失败"那条样本用的最小树:两个构建暂存(窗口 1 份 ⇒ 旧的应被删)
|
||
mkdtree_fail() {
|
||
local t; t="$(mktemp -d)"
|
||
mkdir -p "$t/data" "$t/tmp"
|
||
for ts in 20260101-000000 20260105-000000; do
|
||
mkdir -p "$t/tmp/agentmail-gateway-build-$ts"
|
||
: > "$t/tmp/agentmail-gateway-build-$ts/agentmail-gateway"
|
||
done
|
||
touch -d "2026-01-01 00:00:00" "$t/tmp/agentmail-gateway-build-20260101-000000"
|
||
touch -d "2026-01-05 00:00:00" "$t/tmp/agentmail-gateway-build-20260105-000000"
|
||
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 KEEP_BUILD_STAGES=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)"
|
||
ck "干净样本:/tmp 构建暂存只留窗口内那 1 份" \
|
||
"$([ -e "$T/tmp/agentmail-gateway-build-20260103-000000" ] && [ ! -e "$T/tmp/agentmail-gateway-build-20260101-000000" ] && 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)"
|
||
# 自检最要紧的一条:生产根在这两轮自检里**一个文件都没动**。
|
||
# ★ `del()` 的**失败分支**:喂一个"删不动"的 rm,verifying 三件事 ——
|
||
# 退出码 2(环境问题,不是 1)、报错点名那条路径、且**不虚报成功**。
|
||
#
|
||
# 为什么必须单独喂失败:这条分支平时不跑,而"错误分支的区分力只有喂它一个失败才存在"。
|
||
# 这一整轮修的恰好都是"错误分支第一次被调用时报错了地方"(`describeEnvError` 只覆盖
|
||
# 六处写点之一、被拒之后照样打 `[ OK ]`、go 的环境问题被说成代码问题)。
|
||
#
|
||
# 早先我以为覆盖不了它:本机以 root 跑,权限拦不住 `rm`;`unshare -r` 被拒
|
||
# (`/proc/self/uid_map: Permission denied`);tmpfs 无 `chattr +i`。
|
||
# pi 指出第三条路是**让 rm 自己失败**,而不是去改机器的权限 —— 于是有了 `$RM` 覆盖点。
|
||
# ★ 但"让 rm 失败"必须走**注入点**,不能走 PATH shim:shim 的作用域是整个进程,
|
||
# 而下面这些夹具(`mktree`)活在同一个进程里、且用的**就是** `20260101-000000`
|
||
# 这个时间戳 ⇒ shim 会把夹具自己的清理一起打掉,红的全是【干净样本】。
|
||
# 实测:基线 22 通过/1 失败;两种 shim 都退化成 16 通过/9 失败(详见 `del()` 里的注释)。
|
||
T2="$(mkdtree_fail)"
|
||
cat > "$T2/fake-rm" <<'FAKERM'
|
||
#!/usr/bin/env bash
|
||
case "$*" in *20260101-000000*) echo "fake rm: 模拟删除失败" >&2; exit 1;; esac
|
||
exec /bin/rm "$@"
|
||
FAKERM
|
||
chmod +x "$T2/fake-rm"
|
||
out2="$(AGENTMAIL_ROOT="$T2" PRUNE_TMP_DIR="$T2/tmp" KEEP_BUILD_STAGES=1 RM="$T2/fake-rm" \
|
||
bash "$0" --apply 2>&1)"; rc2=$?
|
||
# ⚠️ 注意这条**单独看是弱的**:变异验证时(把 `del()` 的失败判定整个去掉)
|
||
# 它**照样通过** —— 因为脚本在别处也有按 2 退出的路径。留着它是因为"退 2 而不是 1"
|
||
# 这个口径本身要守(环境问题 ≠ 判据失败),但真正的区分力来自下面两条断言。
|
||
ck "坏样本(rm 删不动):按环境问题退出(实际退出码 $rc2)" "$([ "$rc2" = 2 ] && echo 1 || echo 0)"
|
||
ck "坏样本(rm 删不动):必须打出那句点名失败的 [FAIL]" \
|
||
"$(printf '%s' "$out2" | grep -qF '[FAIL] 删不掉' && echo 1 || echo 0)"
|
||
ck "坏样本(rm 删不动):报错要点名删不掉的那条路径" \
|
||
"$(printf '%s' "$out2" | grep -qF '删不掉' && printf '%s' "$out2" | grep -qF 'agentmail-gateway-build-20260101-000000' && echo 1 || echo 0)"
|
||
ck "坏样本(rm 删不动):不许虚报成功(收尾汇总不该出现)" \
|
||
"$(printf '%s' "$out2" | grep -qE '^ 已删除 [0-9]+ 项' && echo 0 || echo 1)"
|
||
ck "坏样本(rm 删不动):那条路径必须还在" "$([ -e "$T2/tmp/agentmail-gateway-build-20260101-000000" ] && 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]}"
|
||
# ★ **整集一起判"在用"**,不能只跳那一个文件(pi 评审 2026-09-14 指出,实测确认)。
|
||
# 原先 `in_use "$f"` 命中就 `continue` —— 只跳过**那一个**,同一 `<ts>` 的其余成员
|
||
# 照删。备份集被拆开,与这段自己的注释("拆开留没有意义")相反;
|
||
# 在**删除**那一支(`i >= KEEP_DB_BACKUPS`)后果更难看:正在被使用的那个留了下来,
|
||
# 而同一时间戳的 `-wal`/`-shm` 被删掉 —— **"留"的那份配上被删的兄弟才是完整一套**,
|
||
# 结果留下的是残的。所以先算一次整集的在用状态,再决定整集动不动。
|
||
set_in_use=0
|
||
for f in ${SET_MEMBERS[$ts]}; do
|
||
if in_use "$f"; then set_in_use=1; break; fi
|
||
done
|
||
if [ "$set_in_use" -eq 1 ]; then
|
||
report " ★跳过整集(有成员正在被使用)" "$ts"
|
||
for f in ${SET_MEMBERS[$ts]}; do skipped=$((skipped+1)); done
|
||
continue
|
||
fi
|
||
for f in ${SET_MEMBERS[$ts]}; do
|
||
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
|
||
|
||
# 构建暂存:`redeploy-gateway.sh` 先把网关构建到 `$TMPD` 再 `install` 过去(为了原子
|
||
# 替换),**用完没有任何人删过它** —— 每次部署留下一个 24MB。2026-09-14 实测 7 份
|
||
# / 162MB。这不是"几百 MB 而已":本机 `$TMPD` 是 9.8G 的 tmpfs,当时已被占满
|
||
# (可用 0 字节),连 `go build` 都进不去 —— 而部署的第一步就是构建。
|
||
#
|
||
# 窗口留 1 份而不是 0:正常路径下部署脚本自己会收(见 redeploy-gateway.sh 末尾),
|
||
# 留下的只可能是**失败**的那次;失败时那一份正好用来事后查,删了就没现场了。
|
||
echo "==> $TMPD 的构建暂存(保留最新 $KEEP_BUILD_STAGES 份)"
|
||
# ★ `-d` 是**必须的**,不是风格:`agentmail-gateway-build-*` 是**目录**(`mktemp -d` 造的),
|
||
# 而 `ls -1t <多个目录>` 的默认行为是**列出每个目录的内容并打 `路径:` 头** ——
|
||
# 于是 `mapfile` 收到的是一堆 `…000000:`、`agentmail-gateway` 这样的行,
|
||
# 全都不是文件名 ⇒ 后面 `basename | grep -oE '[0-9]{8}-[0-9]{6}'` 取不到时间戳
|
||
# ⇒ 每个都判"文件名无时间戳,判定不了"⇒ **一个都不删**。
|
||
# 后果正是本段注释开头写的那个问题("每次部署留下一个 24MB")**从来没被清理过**:
|
||
# 2026-09-14 实测 /tmp 里确实还躺着 1 份 24MB(`…-20260914-190530`),
|
||
# 而 162MB 那次的现场早就没了 —— 也就是说这段清理**一直是空转的**。
|
||
# 之所以一直没人发现:自检夹具用 `: >` 造的是**普通文件**,形状与生产不一致(已一并修正)。
|
||
mapfile -t stages < <(ls -1dt "$TMPD"/agentmail-gateway-build-* 2>/dev/null)
|
||
for i in "${!stages[@]}"; do
|
||
[ "$i" -lt "$KEEP_BUILD_STAGES" ] && { report " 保留" "${stages[$i]}"; continue; }
|
||
# 二进制可能正被某个部署进程 install 到一半 —— 与快照同理,用过的路径一律先问一句。
|
||
if in_use "${stages[$i]}"; then report " ★跳过(正在被使用)" "${stages[$i]}"; skipped=$((skipped+1)); continue; fi
|
||
del "${stages[$i]}"
|
||
done
|
||
|
||
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
|