deploy: 标准目录部署 —— 运行时不再依赖源码目录
用户注意到:「当前 agentmail 是在源码目录部署的,应当改为标准目录部署」。 查证后有三处实证(都不是猜测): 1. ★ **失败通知钩子执行的是仓库里的脚本** (`/home/program/agentmail/deploy/service-failure-notify.mjs`,8 处引用: 4 个 drop-in + zcode/zcode-mail-bridge 单元 + agentmail-failure-flush)。 仓库一挪/一改名,故障通知就**静默失效** —— 而那条管线正是用来报告服务故障的。 2. **opencode-serve 的 cwd 就是源码目录**(`WorkingDirectory=/home/program/agentmail`)。 3. ★ **仓库里的 `deploy/*.service` 是旧的源码目录版本**(ExecStart 指向 `/home/program/agentmail/plugins/...`),而机器上的已被改过 —— 也就是说 **谁跑一次 install.sh 就会把部署退回源码目录**。drop-in 更是只存在于 /etc 里, 仓库完全没有它们。 ## 改动 - **唯一真相**:`deploy/systemd/` 镜像 systemd 目录结构,收进全部单元与 drop-in (8 个单元 + 12 个 drop-in),路径全部改到 `/opt`。 - 运行时脚本装到 **`/opt/agentmail/bin/service-failure-notify.mjs`**(自包含, 无相对导入);`install.sh` 与 `redeploy-gateway.sh` 都会幂等地装它。 - opencode 的 cwd 改为 `/opt/agentmail`(与网关一致),已重启生效 (`/proc/<pid>/cwd` 已核)。 - 删掉 `deploy/*.service` 的旧副本,避免两个真相。 - dsh 的 `cordis.patch.yml` 注释里的安装示例也改到标准位置(运行时用的是环境变量, 那条注释是唯一残留)。 ## 判据(`deploy/check-deploy-drift.mjs` 新增「标准目录部署」四条 + 自检) ① 任何 unit/drop-in 都不得引用源码目录;② 已安装单元与 `deploy/systemd/` 逐字节一致; ③ 通知脚本在标准位置且可执行;④ 各服务的 cwd/ExecStart 不在源码目录 (homeagent/dsh/zcode 是**别的产品**的标准位置,按白名单放行)。 自检两个样本:引用源码目录的必须红、干净样本必须绿(证明不是恒真)。 顺带修掉一处**真漂移**:仓库里 dsh 的 `dist/index.js` 落后于部署件(改了 src 没重建), 重建后 `check-deploy-drift` 报「四个宿主都在跑当前代码」。 ## 复核 - `/etc/systemd/system/` 引用仓库:**0** 个文件;`/opt/agentmail` 下只剩旧二进制/备份里 的构建路径(Go 嵌的源码路径,无害)与一条注释。 - 四个宿主都在跑当前代码;标准目录四项全绿。 - 全部服务 active,opencode/网关 cwd 均已在安装根下。
This commit is contained in:
@ -45,17 +45,7 @@
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
lstatSync,
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
writeFileSync,
|
||||
mkdirSync,
|
||||
rmSync,
|
||||
realpathSync
|
||||
} from 'node:fs';
|
||||
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { join, relative, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { tmpdir } from 'node:os';
|
||||
@ -410,12 +400,145 @@ export function selfCheck() {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准目录部署检查(2026-09-14)。
|
||||
*
|
||||
* 用户注意到:「当前 agentmail 是在源码目录部署的,应当改为标准目录部署」。
|
||||
* 当时有三处实证:① 失败通知钩子执行的是**仓库里**的脚本(仓库一挪,故障通知静默
|
||||
* 失效);② opencode 服务的 cwd 就是源码目录;③ 仓库里的 `deploy/*.service` 是旧的
|
||||
* 源码目录版本,而机器上的已被改过 —— 谁跑一次 install.sh 就把部署退回源码目录。
|
||||
*
|
||||
* 现在:单元与 drop-in 的唯一真相是 `deploy/systemd/`(镜像 systemd 目录结构),
|
||||
* 运行时脚本装在 `/opt/agentmail/bin/`,服务不依赖仓库是否存在。
|
||||
*
|
||||
* @param {object} [inject] 注入点(判据自检时喂假文件系统)
|
||||
*/
|
||||
export function checkLayout(inject = {}) {
|
||||
const readdir = inject.readdir ?? readdirSync;
|
||||
const readFile = inject.readFile ?? readFileSync;
|
||||
const exists = inject.exists ?? existsSync;
|
||||
const stat = inject.stat ?? statSync;
|
||||
const out = [];
|
||||
const push = (name, ok, note = '') => out.push({ name, ok, note });
|
||||
|
||||
const SYS = '/etc/systemd/system';
|
||||
const REPO = '/home/program/agentmail';
|
||||
// 自有进程必须住在安装根下;其余宿主有它们自己的标准位置(不是本项目的源码目录)
|
||||
const HOST_ALLOW = {
|
||||
'homeagent.service': '/home/newqqagent',
|
||||
'dsh.service': '/usr/bin/dsh',
|
||||
'zcode.service': '/opt/ZCode'
|
||||
};
|
||||
|
||||
// ① 任何 unit/drop-in 都不得引用源码目录
|
||||
const offenders = [];
|
||||
const walk = dir => {
|
||||
let entries = [];
|
||||
try { entries = readdir(dir, { withFileTypes: true }); } catch { return; }
|
||||
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')) {
|
||||
let text = '';
|
||||
try { text = String(readFile(full, 'utf8')); } catch { continue; }
|
||||
if (text.includes(REPO)) offenders.push(full);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(SYS);
|
||||
push('没有任何 unit/drop-in 引用源码目录', offenders.length === 0, offenders.join(' '));
|
||||
|
||||
// ② 已安装单元与仓库副本一致(仓库是唯一真相)
|
||||
const drift = [];
|
||||
const repoUnits = inject.repoUnits ?? new URL('../systemd', import.meta.url).pathname.replace(/\/$/, '');
|
||||
const compare = dir => {
|
||||
let entries = [];
|
||||
try { entries = readdir(dir, { withFileTypes: true }); } catch { return; }
|
||||
for (const e of entries) {
|
||||
const full = `${dir}/${e.name}`;
|
||||
if (e.isDirectory()) compare(full);
|
||||
else {
|
||||
const rel = full.slice(repoUnits.length + 1);
|
||||
const live = `${SYS}/${rel}`;
|
||||
if (!exists(live)) { drift.push(`${rel}(缺)`); continue; }
|
||||
let a = '', b = '';
|
||||
try { a = String(readFile(full, 'utf8')); b = String(readFile(live, 'utf8')); } catch { continue; }
|
||||
if (a !== b) drift.push(rel);
|
||||
}
|
||||
}
|
||||
};
|
||||
compare(repoUnits);
|
||||
push('已安装单元与 deploy/systemd/ 一致', drift.length === 0, drift.join(' '));
|
||||
|
||||
// ③ 通知脚本在标准位置且可执行
|
||||
const script = '/opt/agentmail/bin/service-failure-notify.mjs';
|
||||
let scriptOk = false;
|
||||
let note = script;
|
||||
try {
|
||||
const st = stat(script);
|
||||
scriptOk = st.isFile() && (st.mode & 0o111) !== 0;
|
||||
if (!scriptOk) note = `${script} 缺执行位`;
|
||||
} catch { note = `${script} 不存在`; }
|
||||
push('故障通知脚本装在 /opt/agentmail/bin/ 且可执行', scriptOk, note);
|
||||
|
||||
// ④ 自有服务的 cwd / ExecStart 不得落在源码目录
|
||||
const badHosts = [];
|
||||
for (const unit of ['agentmail-gateway.service', 'pi-mail-bridge.service', 'opencode-serve.service',
|
||||
'zcode-mail-bridge.service', 'homeagent.service', 'dsh.service', 'zcode.service']) {
|
||||
let text = '';
|
||||
try { text = String(readFile(`${SYS}/${unit}`, 'utf8')); } catch { continue; }
|
||||
const cwd = (text.match(/WorkingDirectory=(.+)/) || [])[1]?.trim() ?? '';
|
||||
const exec = (text.match(/ExecStart=(.+)/) || [])[1]?.trim() ?? '';
|
||||
const allow = HOST_ALLOW[unit];
|
||||
if (allow) {
|
||||
// 用 includes 而不是 startsWith:zcode 的 ExecStart 是
|
||||
// `dbus-run-session -- xvfb-run … /opt/ZCode/zcode …`,宿主路径在中间。
|
||||
if (!text.includes(allow)) badHosts.push(`${unit}(不在 ${allow})`);
|
||||
continue;
|
||||
}
|
||||
if (cwd.startsWith(REPO) || exec.startsWith(REPO)) badHosts.push(`${unit}(${cwd || exec})`);
|
||||
}
|
||||
push('各服务的工作目录/可执行文件不在源码目录', badHosts.length === 0, badHosts.join(' '));
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 标准目录那组自检:坏样本必须红、干净样本必须绿(证明它不是恒真)。 */
|
||||
export function layoutSelfCheck() {
|
||||
const fake = map => ({
|
||||
readdir: dir => map[dir] ?? [],
|
||||
readFile: p => {
|
||||
if (!(p in map)) throw new Error('ENOENT');
|
||||
return map[p];
|
||||
},
|
||||
exists: p => p in map,
|
||||
stat: () => ({ isFile: () => true, mode: 0o755 }),
|
||||
repoUnits: '/repo/systemd'
|
||||
});
|
||||
const bad = checkLayout(fake({
|
||||
'/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'
|
||||
}));
|
||||
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'
|
||||
}));
|
||||
return [
|
||||
{ name: '标准目录:引用源码目录的样本必须判红', ok: bad[0].ok === false },
|
||||
{ name: '标准目录:干净样本必须判绿', ok: good[0].ok === true }
|
||||
];
|
||||
}
|
||||
|
||||
function main() {
|
||||
const json = process.argv.includes('--json');
|
||||
const wantSelfCheck = process.argv.includes('--self-check');
|
||||
|
||||
if (wantSelfCheck) {
|
||||
const checks = selfCheck();
|
||||
const checks = selfCheck().concat(layoutSelfCheck());
|
||||
if (json) console.log(JSON.stringify({ selfCheck: checks }, null, 2));
|
||||
else {
|
||||
console.log('判据自检(先证明检查器能发现差异):');
|
||||
@ -428,20 +551,25 @@ function main() {
|
||||
}
|
||||
}
|
||||
|
||||
const layout = checkLayout();
|
||||
const layoutBad = layout.filter(c => !c.ok);
|
||||
|
||||
const results = HOSTS.map(checkHost);
|
||||
if (json) {
|
||||
console.log(JSON.stringify({ hosts: results }, null, 2));
|
||||
console.log(JSON.stringify({ hosts: results, layout }, null, 2));
|
||||
} else {
|
||||
console.log('\n部署漂移检查(快照 vs 仓库 HEAD,以及进程到底在跑哪份代码):');
|
||||
for (const r of results) {
|
||||
console.log(`\n ${r.host}(${r.plugin},加载方式 ${r.load})${r.stale ? ' ⚠️ 漂移' : ''}`);
|
||||
for (const c of r.checks) console.log(` ${c.ok ? '通过' : '失败'} ${c.name}${c.note ? ' — ' + c.note : ''}`);
|
||||
}
|
||||
console.log('\n 标准目录部署:');
|
||||
for (const c of layout) console.log(` ${c.ok ? '通过' : '失败'} ${c.name}${c.note ? ' — ' + c.note : ''}`);
|
||||
const stale = results.filter(r => r.stale);
|
||||
console.log(`\n 结论:${stale.length === 0 ? '四个宿主都在跑当前代码' : `${stale.length} 个宿主需要重新部署/重启`}`);
|
||||
for (const r of stale) console.log(` ✗ ${r.host}:node deploy/redeploy-plugin.sh ${r.host}`);
|
||||
}
|
||||
process.exit(results.some(r => r.stale) ? 1 : 0);
|
||||
process.exit(results.some(r => r.stale) || layoutBad.length ? 1 : 0);
|
||||
}
|
||||
|
||||
// 仅在被直接执行时跑 main(被 import 时只导出,供测试调用)
|
||||
|
||||
Reference in New Issue
Block a user