diff --git a/deploy/check-deploy-drift.mjs b/deploy/check-deploy-drift.mjs index e77cdf2..375274c 100644 --- a/deploy/check-deploy-drift.mjs +++ b/deploy/check-deploy-drift.mjs @@ -510,6 +510,13 @@ export function checkLayout(inject = {}) { }; // ① 任何 unit/drop-in 都不得引用源码目录 + // + // ★ `.bak` 也算在内。原先这里把它排除了,理由是"systemd 不加载 .bak"—— + // 运行时确实不加载,但后果是:2026-09-14 我向用户报"`/etc/systemd` 引用仓库 = 0 个文件", + // 而 `/etc/systemd/system/zcode.service.bak-20260912-145744` 里就躺着两行 + // `/home/program/agentmail/deploy/service-failure-notify.mjs`。 + // 那句话只对我自己划的那个圈成立 —— **判据的边界没说出口,就等于报了个假的 0**。 + // 留着 .bak 的代价也不是零:它们是"过期的旧真相",`grep` 到它的人会以为改动没生效。 const offenders = []; const walk = dir => { let entries = []; @@ -517,7 +524,7 @@ export function checkLayout(inject = {}) { for (const e of entries) { const full = `${dir}/${e.name}`; if (e.isDirectory()) walk(full); - else if (/\.(conf|service|timer)$/.test(e.name) && !e.name.includes('.bak')) { + else if (/\.(conf|service|timer)(\.bak.*)?$/.test(e.name)) { let text = ''; try { text = String(readFile(full, 'utf8')); } catch { continue; } if (text.includes(REPO)) offenders.push(full); @@ -525,7 +532,7 @@ export function checkLayout(inject = {}) { } }; walk(SYS); - push('没有任何 unit/drop-in 引用源码目录', offenders.length === 0, offenders.join(' ')); + push('没有任何 unit/drop-in/.bak 引用源码目录', offenders.length === 0, offenders.join(' ')); // ② 已安装单元与仓库副本一致(仓库是唯一真相) const drift = []; @@ -579,6 +586,25 @@ export function checkLayout(inject = {}) { } push('各服务的工作目录/可执行文件不在源码目录', badHosts.length === 0, badHosts.join(' ')); + // ⑤ 生产二进制里不得嵌源码路径(-trimpath)。 + // + // Go 默认把源文件的**绝对路径**编进二进制。2026-09-14 实测:换到标准目录部署之后, + // `/opt/agentmail/agentmail-gateway` 里仍有 57 处 `/home/program/agentmail/…` —— + // 构建脚本漏了 `-trimpath`(对照实验:同一份源码、同一个 go,带标志 0 处、不带 57 处)。 + // 这条是"运行时不再依赖源码目录"的**后半句**:依赖确实没了,但源仓库位置还印在产物上, + // 而且它会把"这个二进制是从哪份源码建的"变成只能靠推断的事。 + const BIN = '/opt/agentmail/agentmail-gateway'; + let binHits = -1; + let binNote = `读不到 ${BIN}(标准位置没有网关二进制)`; + try { + const text = String(readFile(BIN)); + binHits = text.split(REPO).length - 1; + binNote = binHits === 0 + ? `${BIN} 里一处都没有` + : `${BIN} 里有 ${binHits} 处 ${REPO}/…(重新构建即可清零:跑一次 redeploy-gateway.sh)`; + } catch { /* binHits 保持 -1 = 读不到 */ } + push('已安装的网关二进制不含源码路径(构建带 -trimpath)', binHits === 0, binNote); + // ⑥ 工作区干净度 —— **WARN,不参与退出码**。 // // 判据 ① 比的是「仓库工作区 → 快照」这一跳。它覆盖不到「HEAD → 工作区」 @@ -630,33 +656,58 @@ export function layoutSelfCheck() { '/etc/systemd/system': [{ name: 'x.service', isDirectory: () => false }], '/etc/systemd/system/x.service': 'ExecStart=/home/program/agentmail/bin/x', '/repo/systemd': [], - '/opt/agentmail/bin/service-failure-notify.mjs': 'x' + '/opt/agentmail/bin/service-failure-notify.mjs': 'x', + '/opt/agentmail/agentmail-gateway': 'fake-elf' + })); + // ① 的 .bak 分支:旧备份单元里躺着仓库路径,也必须报出来(原先它被过滤掉了)。 + const badBak = checkLayout(fake({ + '/etc/systemd/system': [{ name: 'z.service.bak-20260101-000000', isDirectory: () => false }], + '/etc/systemd/system/z.service.bak-20260101-000000': 'ExecStopPost=-/usr/bin/node /home/program/agentmail/deploy/x.mjs', + '/repo/systemd': [], + '/opt/agentmail/bin/service-failure-notify.mjs': 'x', + '/opt/agentmail/agentmail-gateway': 'fake-elf' + })); + // ⑤ 的坏样本:二进制里嵌着源码路径。 + const badBin = checkLayout(fake({ + '/etc/systemd/system': [], + '/repo/systemd': [], + '/opt/agentmail/bin/service-failure-notify.mjs': 'x', + '/opt/agentmail/agentmail-gateway': 'ELF…/home/program/agentmail/server/cmd/server/main.go…' })); const good = checkLayout(fake({ '/etc/systemd/system': [{ name: 'y.service', isDirectory: () => false }], '/etc/systemd/system/y.service': 'ExecStart=/opt/agentmail/agentmail-gateway', '/repo/systemd': [], - '/opt/agentmail/bin/service-failure-notify.mjs': 'x' + '/opt/agentmail/bin/service-failure-notify.mjs': 'x', + '/opt/agentmail/agentmail-gateway': 'ELF…github.com/agentmail/gateway/cmd/server…' })); const dirty = checkLayout({ ...fake({ '/etc/systemd/system': [], '/repo/systemd': [], - '/opt/agentmail/bin/service-failure-notify.mjs': 'x' + '/opt/agentmail/bin/service-failure-notify.mjs': 'x', + '/opt/agentmail/agentmail-gateway': 'fake-elf' }), ...fakeGit(' M src/pool.mjs\n') }); const clean = checkLayout({ ...fake({ '/etc/systemd/system': [], '/repo/systemd': [], - '/opt/agentmail/bin/service-failure-notify.mjs': 'x' + '/opt/agentmail/bin/service-failure-notify.mjs': 'x', + '/opt/agentmail/agentmail-gateway': 'fake-elf' }), ...fakeGit('') }); const unreadable = checkLayout({ ...fake({ '/etc/systemd/system': [], '/repo/systemd': [], - '/opt/agentmail/bin/service-failure-notify.mjs': 'x' + '/opt/agentmail/bin/service-failure-notify.mjs': 'x', + '/opt/agentmail/agentmail-gateway': 'fake-elf' }), git: () => { throw new Error('not a git repo'); } }); + const fifth = o => o.find(c => c.name.startsWith('已安装的网关二进制')); const sixth = o => o.find(c => c.name.startsWith('工作区干净')); return [ { name: '标准目录:引用源码目录的样本必须判红', ok: bad[0].ok === false }, + { name: '标准目录:.bak 里引用源码目录也必须判红', ok: badBak[0].ok === false }, { name: '标准目录:干净样本必须判绿', ok: good[0].ok === true }, + // 二进制那条两侧都要真:嵌了源码路径必须红,trimpath 的必须绿。 + { name: '网关二进制:嵌了源码路径必须判红', ok: fifth(badBin)?.ok === false }, + { name: '网关二进制:trimpath 过的必须判绿', ok: fifth(good)?.ok === true }, // 编号:id 从 1 连续排到 N,不许跳号(原先只有 ⑥ 带编号,读者会去找不存在的 ⑤)。 { name: '标准目录:id 连续编号,不跳号', ok: good.every((c, i) => c.id === String(i + 1)) }, // 「工作区干净」是"说出来但不改结论"的提示:脏 / 干净 / 读不到三种都被报出来, diff --git a/deploy/install.sh b/deploy/install.sh index ff06bf4..2fa92df 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -183,7 +183,7 @@ if [[ $CHECK_ONLY -eq 1 ]]; then 下面这些步骤干跑**没有执行**(它们要写工作区外的目录,正式安装需要 root 或先放行): · 前端产物 → server/internal/static/static/ - · 构建 Gateway(go vet + go test + go build -o server/agentmail-gateway) + · 构建 Gateway(go vet + go test + go build -trimpath -o server/agentmail-gateway) · install -m 0755 server/agentmail-gateway → /opt/agentmail/agentmail-gateway · 生成 /etc/agentmail/*.env(已存在的不覆盖)+ chmod 0600 · 装 systemd unit → /etc/systemd/system/,daemon-reload + enable --now @@ -204,8 +204,10 @@ cp -r "$REPO/client/electron/dist/." "$REPO/server/internal/static/static/" echo "==> 构建 Gateway(单二进制,内含前端 + SQLite)" # 先删再建:go build -o 到已存在的路径时可能拿到 stale 二进制(此坑中过多次) +# `-trimpath` 与 redeploy-gateway.sh 同源:不带它时 Go 会把源文件绝对路径编进 +# 生产二进制(2026-09-14 实测 57 处 /home/program/agentmail/…)。 rm -f "$REPO/server/agentmail-gateway" -( cd "$REPO/server" && go vet ./... && go test ./... && go build -o "$REPO/server/agentmail-gateway" ./cmd/server ) +( cd "$REPO/server" && go vet ./... && go test ./... && go build -trimpath -o "$REPO/server/agentmail-gateway" ./cmd/server ) echo "==> 安装到 $PREFIX" install -d "$PREFIX" "$PREFIX/data" "$ETC" diff --git a/deploy/prune-deploy-artifacts.sh b/deploy/prune-deploy-artifacts.sh index 1833156..9064903 100755 --- a/deploy/prune-deploy-artifacts.sh +++ b/deploy/prune-deploy-artifacts.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# 清理部署残留:旧网关二进制、旧插件快照、旧数据库/附件备份、/tmp 里的部署前备份。 +# 清理部署残留:旧网关二进制、旧插件快照、旧数据库/附件备份、/tmp 里的构建暂存与部署前备份。 # # # 为什么要脚本而不是手敲 rm # @@ -25,7 +25,7 @@ # 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 # 调保留窗口 +# KEEP_GATEWAY=2 KEEP_SNAPSHOTS=2 KEEP_BUILD_STAGES=0 bash ... --apply # 调保留窗口 set -uo pipefail ROOT="${AGENTMAIL_ROOT:-/opt/agentmail}" @@ -33,6 +33,7 @@ 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 @@ -117,6 +118,8 @@ if [ "${1:-}" = "--self-check" ]; then 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" + : > "$t/tmp/agentmail-gateway-build-2026010$i-000000" + touch -d "2026-01-0$i 00:00:00" "$t/tmp/agentmail-gateway-build-2026010$i-000000" done echo "$t" } @@ -131,7 +134,7 @@ if [ "${1:-}" = "--self-check" ]; then "$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 \ + 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 } @@ -168,6 +171,8 @@ if [ "${1:-}" = "--self-check" ]; then "$([ ! -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" @@ -261,6 +266,22 @@ else 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 份)" +mapfile -t stages < <(ls -1t "$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 diff --git a/deploy/redeploy-gateway.sh b/deploy/redeploy-gateway.sh index ccda0ed..2b50343 100755 --- a/deploy/redeploy-gateway.sh +++ b/deploy/redeploy-gateway.sh @@ -127,11 +127,16 @@ fi say "3. 构建二进制" STAGE="/tmp/agentmail-gateway-build-$TS" # 先删再建:go build -o 到已存在的路径时可能拿到 stale 二进制(此坑中过多次) +# ★ `-trimpath`:Go 默认把源文件的**绝对路径**编进二进制。2026-09-14 实测:本机 +# 历史二进制都是 trimpath 的(里面一处源码路径都没有),19:05 那次不是 —— +# 于是生产件 /opt/agentmail/agentmail-gateway 里躺着 57 处 /home/program/agentmail/…。 +# 改标准目录部署是为了"运行时不再依赖源码目录";没有 -trimpath 时这条只做到一半: +# 依赖确实没了,但**源仓库位置还印在产物上**。判据在 check-deploy-drift(标准目录 ⑤)。 run "rm -f '$STAGE'" if [ "$DRY_RUN" = 1 ]; then - printf ' [dry-run] (cd server && go build -o %s ./cmd/server)\n' "$STAGE" + printf ' [dry-run] (cd server && go build -trimpath -o %s ./cmd/server)\n' "$STAGE" else - ( cd "$REPO/server" && go build -o "$STAGE" ./cmd/server ) \ + ( cd "$REPO/server" && go build -trimpath -o "$STAGE" ./cmd/server ) \ || { bad "构建失败"; exit 1; } ok "构建完成 $(du -h "$STAGE" | cut -f1)" fi @@ -248,6 +253,13 @@ if [ "$CHECK_FAIL" -gt 0 ]; then fi say "结论: 自动项全绿" +# 构建暂存收尾。这份 24MB 副本只是为了「构建 → install」那一步的原子性,装完就没 +# 用了;原先没人删 ⇒ 每次部署在 `$TMPD` 留一份。2026-09-14 实测:7 份 / 162MB, +# 而 `$TMPD` 是 9.8G 的 tmpfs —— 占满后**连部署自己的第一步都跑不动** +# (go build 报 ENOSPC)。存量由 `deploy/prune-deploy-artifacts.sh` 按窗口收。 +# 只在这个**成功分支**上删:失败/回滚时那份二进制正是要留的现场,见上面的回滚分支。 +rm -f "$STAGE" +ok "构建暂存已收($STAGE)" echo " 回滚命令(留档 24 小时内有效):" [ -n "$BINBAK" ] && echo " install -m 0755 '$BINBAK' '$TARGET' && systemctl restart '$SERVICE'" [ -n "$DBBAK" ] && echo " install -m 0644 '$DBBAK' '$DB' # 先 systemctl stop"