295 lines
12 KiB
TypeScript
295 lines
12 KiB
TypeScript
import { parseArgs } from "node:util";
|
||
import denoJson from "../deno.json" with { type: "json" };
|
||
import type { Config } from "./config.ts";
|
||
import type { MappingExport } from "./ops.ts";
|
||
|
||
// api / worker / worker:agent / all 是常驻角色(worker = io 组:文件 / 网络 / 维护三条道 + outbox 中继 +
|
||
// 模块后台循环;worker:agent = 只跑 Agent 会话任务;all = api + 两组 worker 同进程);其余是容器入口 / 备份恢复脚本用的一次性子命令:
|
||
// migrate | preflight | probe
|
||
// export-mappings --out <file.json> [--compare <基准清单.json>] 映射清单导出(可与基准比行数)
|
||
// read-only-on [--reason <文本>] 打开 automation.readOnlyReconcile
|
||
// rotate-master-key 用 env SECRET_MASTER_KEY_NEW 重新加密 secret 表
|
||
// (逐行事务、幂等可重跑;步骤与回滚见 docs/runbook/auth.md)
|
||
// fsworker fsproxy 执行子进程(编译二进制里由 fsproxy 以
|
||
// `<自身> --role fsworker` 拉起,stdin 一条请求)
|
||
// sandbox 沙箱子进程(src/sandbox:撤销全部权限后解析不可信
|
||
// 字节,stdin 一条请求,stdout 一条结果)
|
||
const Roles = [
|
||
"api",
|
||
"worker",
|
||
"worker:agent",
|
||
"all",
|
||
"migrate",
|
||
"preflight",
|
||
"probe",
|
||
"export-mappings",
|
||
"read-only-on",
|
||
"rotate-master-key",
|
||
"fsworker",
|
||
"sandbox",
|
||
] as const;
|
||
type Role = (typeof Roles)[number];
|
||
const OneShot = ["migrate", "preflight", "export-mappings", "read-only-on", "rotate-master-key"] as const;
|
||
type OneShotRole = (typeof OneShot)[number];
|
||
const isOneShot = (r: Role): r is OneShotRole => (OneShot as readonly Role[]).includes(r);
|
||
|
||
const { values } = parseArgs({
|
||
args: Deno.args,
|
||
options: {
|
||
role: { type: "string", default: "all" },
|
||
out: { type: "string" },
|
||
compare: { type: "string" },
|
||
reason: { type: "string" },
|
||
},
|
||
strict: true,
|
||
});
|
||
const role = values.role as Role;
|
||
if (!Roles.includes(role)) {
|
||
console.error(`--role 必须是 ${Roles.join("|")}`);
|
||
Deno.exit(2);
|
||
}
|
||
|
||
// fsproxy 执行子进程:必须在加载 db/任务引擎/模块之前分派——每个文件操作起一次,只加载 fs 操作表,启动保持轻量
|
||
if (role === "fsworker") {
|
||
const { fsWorkerMain } = await import("@umt/adapters/fsworker");
|
||
await fsWorkerMain();
|
||
Deno.exit(0);
|
||
}
|
||
|
||
// 沙箱子进程:同样在加载其余模块前分派,只加载沙箱任务表
|
||
if (role === "sandbox") {
|
||
const { sandboxWorkerMain } = await import("./sandbox/worker.ts");
|
||
await sandboxWorkerMain();
|
||
Deno.exit(0);
|
||
}
|
||
|
||
// 健康探测:只打本机回环端口,不需要完整配置(HEALTHCHECK 用,镜像里不装 curl)
|
||
if (role === "probe") {
|
||
const port = Deno.env.get("PORT") ?? "4380";
|
||
try {
|
||
const res = await fetch(`http://127.0.0.1:${port}/api/v1/health`, { signal: AbortSignal.timeout(3000) });
|
||
const body = (await res.json()) as { data?: { status?: string } };
|
||
Deno.exit(res.ok && body.data?.status === "ok" ? 0 : 1);
|
||
} catch {
|
||
Deno.exit(1);
|
||
}
|
||
}
|
||
|
||
// 其余依赖动态加载(顺序同原静态 import),让上面的 fsworker / probe 分支不必求值整张模块图
|
||
const { createDb, migrateToLatest } = await import("@umt/db");
|
||
const { migrateEngineSchema } = await import("@umt/tasks");
|
||
const { DATA_SUBDIRS, dataPaths, loadConfig } = await import("./config.ts");
|
||
const { bootstrap } = await import("./context.ts");
|
||
const { createApp } = await import("./http/app.ts");
|
||
const { PgEventBus } = await import("./http/sse-pg.ts");
|
||
const { createLogger } = await import("./logger.ts");
|
||
const { SERVER_MODULES } = await import("./modules/index.ts");
|
||
const { diffCounts, enableReadOnlyReconcile, exportMappings, writeMappingExport } = await import("./ops.ts");
|
||
const { auditStorageWarnings, checkStorage, loadMountedPaths, runPreflight } = await import("./preflight.ts");
|
||
const { startWorker } = await import("./worker/index.ts");
|
||
const { laneOverridesFromEnv } = await import("./worker/lanes.ts");
|
||
const { installSandboxParsers } = await import("./sandbox/install.ts");
|
||
// 不可信字节(种子等)的解析改走沙箱子进程;一次性子命令也装上,无副作用
|
||
installSandboxParsers();
|
||
|
||
let config: Config;
|
||
try {
|
||
config = loadConfig();
|
||
} catch (err) {
|
||
console.error((err as Error).message);
|
||
Deno.exit(2);
|
||
}
|
||
|
||
// 运行期写入统一落在 DATA_DIR;Agent 会话目录经环境变量传给 agent 模块(它只读 AGENT_SESSION_DIR)
|
||
const paths = dataPaths(config);
|
||
Deno.env.set("AGENT_SESSION_DIR", paths.sessionDir);
|
||
|
||
const logger = createLogger({
|
||
level: config.LOG_LEVEL,
|
||
pretty: config.NODE_ENV === "development" && Deno.stdout.isTerminal(),
|
||
});
|
||
const db = createDb({ connectionString: config.DATABASE_URL });
|
||
|
||
if (isOneShot(role)) {
|
||
let code = 0;
|
||
try {
|
||
code = await runOneShot(role);
|
||
} catch (err) {
|
||
logger.error({ err }, `${role} 失败`);
|
||
code = 1;
|
||
} finally {
|
||
await db.destroy();
|
||
}
|
||
Deno.exit(code);
|
||
}
|
||
|
||
async function runOneShot(r: OneShotRole): Promise<number> {
|
||
switch (r) {
|
||
case "rotate-master-key": {
|
||
if (!config.SECRET_MASTER_KEY_NEW) {
|
||
logger.error("缺少环境变量 SECRET_MASTER_KEY_NEW(新主密钥);SECRET_MASTER_KEY 保持旧密钥");
|
||
return 2;
|
||
}
|
||
const { rotateMasterKey } = await import("./modules/auth-settings/provided.ts");
|
||
const res = await rotateMasterKey({ db, logger }, config.SECRET_MASTER_KEY, config.SECRET_MASTER_KEY_NEW);
|
||
return res.failed.length ? 1 : 0;
|
||
}
|
||
case "migrate": {
|
||
const { results } = await migrateToLatest(db);
|
||
for (const m of results ?? []) logger.info({ migration: m.migrationName, status: m.status }, "migration");
|
||
logger.info({ applied: results?.length ?? 0 }, "迁移完成");
|
||
// 任务引擎自己的表(openworkflow schema);api/worker 进程启动时不再迁移
|
||
await migrateEngineSchema(config.DATABASE_URL);
|
||
logger.info("任务引擎迁移完成");
|
||
return 0;
|
||
}
|
||
case "preflight": {
|
||
const problems = await runPreflight({ config, db });
|
||
// 库/下载目录由用户挂载:不可读写只告警(并写审计),不阻止启动;数据目录不可写才阻止
|
||
const mounts = problems.some((p) => p.check === "db") ? [] : await loadMountedPaths(db);
|
||
const storage = await checkStorage({ ...paths, subdirs: DATA_SUBDIRS, mounts });
|
||
problems.push(...storage.problems);
|
||
for (const w of storage.warnings) {
|
||
logger.warn({ kind: w.kind, id: w.id, path: w.path }, `挂载路径不可用:${w.name} ${w.reason}`);
|
||
}
|
||
await auditStorageWarnings(db, storage.warnings).catch((err) => logger.warn({ err }, "存储告警写审计失败"));
|
||
// 验收开关误设提示(验收 07 S-10):只有 UMT_FS_TEST_HOOKS=1 时才真正生效
|
||
if (Deno.env.get("UMT_FS_TEST_HANG_AFTER_BYTES"))
|
||
logger.warn(
|
||
{ active: Deno.env.get("UMT_FS_TEST_HOOKS") === "1" },
|
||
"设置了 UMT_FS_TEST_HANG_AFTER_BYTES(fsproxy 挂死验收开关);生产环境不要设置,生效时所有数据类文件操作都会卡死到被 SIGKILL",
|
||
);
|
||
for (const p of problems) logger.error({ check: p.check }, p.message);
|
||
if (problems.length) return 1;
|
||
logger.info(
|
||
{
|
||
dataDir: paths.dataDir,
|
||
sessionDir: paths.sessionDir,
|
||
mounts: mounts.length,
|
||
warnings: storage.warnings.length,
|
||
},
|
||
"启动自检通过",
|
||
);
|
||
return 0;
|
||
}
|
||
case "export-mappings": {
|
||
const out = values.out ?? `${paths.exportsDir}/mappings-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
|
||
const data = await exportMappings(db, denoJson.version);
|
||
await writeMappingExport(out, data);
|
||
logger.info({ out, counts: data.counts }, "映射清单已导出");
|
||
if (!values.compare) return 0;
|
||
const base = JSON.parse(await Deno.readTextFile(values.compare)) as MappingExport;
|
||
const diff = diffCounts(base.counts, data.counts);
|
||
if (diff.length) {
|
||
logger.error({ diff }, "映射清单行数与基准不一致");
|
||
return 3;
|
||
}
|
||
logger.info({ compare: values.compare }, "映射清单行数与基准一致");
|
||
return 0;
|
||
}
|
||
case "read-only-on":
|
||
await enableReadOnlyReconcile(db, values.reason ?? "restore");
|
||
logger.info("已打开只读对账模式 automation.readOnlyReconcile=true");
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
// SSE 总线:sse_event 表 + LISTEN/NOTIFY,api 与 worker 分进程时事件互相可见
|
||
let bus: Awaited<ReturnType<typeof PgEventBus.start>>;
|
||
try {
|
||
bus = await PgEventBus.start({
|
||
db,
|
||
connectionString: config.DATABASE_URL,
|
||
logger: logger.child({ component: "sse-bus" }),
|
||
});
|
||
} catch (err) {
|
||
logger.error({ err }, "SSE 总线启动失败");
|
||
await db.destroy();
|
||
Deno.exit(1);
|
||
}
|
||
const stops: Array<() => Promise<void>> = [];
|
||
|
||
// 装配 ServerContext:模块注册任务/能力,连接任务引擎(api 角色也要:入队、信号、取消)
|
||
let booted: Awaited<ReturnType<typeof bootstrap>>;
|
||
try {
|
||
booted = await bootstrap({ role, config, logger, db, events: bus, modules: SERVER_MODULES });
|
||
} catch (err) {
|
||
logger.error({ err }, "启动装配失败");
|
||
await db.destroy();
|
||
Deno.exit(1);
|
||
}
|
||
const { ctx, outbox, engine } = booted;
|
||
|
||
if (role === "api" || role === "all") {
|
||
const app = createApp({
|
||
config,
|
||
logger,
|
||
bus,
|
||
db,
|
||
role,
|
||
version: denoJson.version,
|
||
...(config.WEB_ROOT ? { webRoot: config.WEB_ROOT } : {}),
|
||
modules: SERVER_MODULES,
|
||
ctx,
|
||
});
|
||
const ac = new AbortController();
|
||
const server = Deno.serve(
|
||
{
|
||
port: config.PORT,
|
||
hostname: config.HOST,
|
||
signal: ac.signal,
|
||
onListen: ({ port }) =>
|
||
logger.info(
|
||
{ port, role, webRoot: config.WEB_ROOT ?? null },
|
||
`API 已启动 http://localhost:${port}/api/v1/health`,
|
||
),
|
||
},
|
||
app.fetch,
|
||
);
|
||
stops.push(async () => {
|
||
// abort 后 Deno.serve 会断开 SSE 等长连接,finished 在所有连接关闭后 resolve
|
||
ac.abort();
|
||
await server.finished;
|
||
});
|
||
if (role === "api") {
|
||
// 纯 api 进程不走 worker 的模块 stop:这里停 system 模块的心跳写入器,把本进程的心跳行标 stopped_at
|
||
const sys = SERVER_MODULES.find((m) => m.name === "system");
|
||
if (sys?.stop) stops.push(async () => await sys.stop?.());
|
||
}
|
||
}
|
||
if (role === "worker" || role === "worker:agent" || role === "all") {
|
||
const worker = await startWorker({
|
||
logger,
|
||
heartbeatMs: config.WORKER_HEARTBEAT_MS,
|
||
bus,
|
||
runtime: {
|
||
ctx,
|
||
modules: SERVER_MODULES,
|
||
setRelay: outbox.setRelay,
|
||
group: role === "all" ? "split" : role === "worker" ? "io" : "agent",
|
||
tasks: booted.tasks.all(),
|
||
agentConcurrency: config.WORKER_AGENT_CONCURRENCY,
|
||
// 三条道的并发读 设置 → 自动化 → 高级(热生效);环境变量设了就覆盖(旧 WORKER_CONCURRENCY → 网络道)
|
||
lanes: { overrides: laneOverridesFromEnv(config) },
|
||
},
|
||
});
|
||
stops.push(worker.stop);
|
||
}
|
||
|
||
let stopping = false;
|
||
async function shutdown(signal: Deno.Signal) {
|
||
if (stopping) return;
|
||
stopping = true;
|
||
logger.info({ signal }, "正在退出");
|
||
// 兜底:10 秒内没退干净就强退,避免容器 stop 超时被 SIGKILL
|
||
const force = setTimeout(() => Deno.exit(1), 10_000);
|
||
await Promise.allSettled(stops.map((s) => s()));
|
||
await engine.stop().catch((err) => logger.error({ err }, "任务引擎关闭失败"));
|
||
await bus.stop().catch(() => {});
|
||
await db.destroy();
|
||
clearTimeout(force);
|
||
Deno.exit(0);
|
||
}
|
||
Deno.addSignalListener("SIGINT", () => void shutdown("SIGINT"));
|
||
Deno.addSignalListener("SIGTERM", () => void shutdown("SIGTERM"));
|