import { join } from "node:path"; import type { DB, Kysely } from "@umt/db"; import { sql } from "@umt/db"; import type { Config } from "./config.ts"; export interface PreflightProblem { check: string; message: string; } export interface PreflightDeps { config: Config; db: Kysely; /** 测试可注入;默认取当前进程 uid(Windows 上为 null) */ uid?: number | null; } // 明显的占位符密钥:生产环境拒绝启动 const PLACEHOLDER = /change[-_]?me|replace[-_]?me|dev[-_]?only|example/i; /** 启动自检:返回问题列表,空数组表示通过。不打印变量值。 */ export async function runPreflight({ config, db, uid = Deno.uid() }: PreflightDeps): Promise { const problems: PreflightProblem[] = []; const prod = config.NODE_ENV === "production"; if (prod && PLACEHOLDER.test(config.SECRET_MASTER_KEY)) { problems.push({ check: "secret", message: "SECRET_MASTER_KEY 仍是占位符,生产环境必须换成随机串" }); } if (prod && uid === 0) { problems.push({ check: "user", message: "生产环境不允许以 root 运行" }); } try { await sql`select 1`.execute(db); } catch (err) { problems.push({ check: "db", message: `数据库不可达:${(err as Error).message}` }); } if (config.WEB_ROOT) { try { await Deno.stat(join(config.WEB_ROOT, "index.html")); } catch { problems.push({ check: "web", message: `WEB_ROOT 下没有 index.html: ${config.WEB_ROOT}` }); } } return problems; } // ── 存储自检:数据目录必须可写;库根 / 下载器映射路径不可写只告警 ── export interface MountedPath { kind: "library_root" | "path_mapping"; id: string; name: string; path: string; } export interface StorageWarning extends MountedPath { reason: string; } export interface StorageCheckResult { problems: PreflightProblem[]; warnings: StorageWarning[]; } const errText = (e: unknown) => (e as { code?: string }).code ?? (e as Error).message; /** 在目录里写一个探针文件再删掉;失败抛错 */ async function probeWrite(dir: string): Promise { const probe = join(dir, `.umt-write-probe-${Deno.pid}-${crypto.randomUUID().slice(0, 8)}`); await Deno.writeTextFile(probe, "probe\n"); await Deno.remove(probe); } /** 读出已启用的库根与下载器路径映射在本进程视角下的路径 */ export async function loadMountedPaths(db: Kysely): Promise { const roots = await db .selectFrom("library_root") .select(["id", "name", "local_path"]) .where("enabled", "=", true) .execute(); const maps = await db .selectFrom("path_mapping") .select(["id", "remote_prefix", "local_prefix"]) .where("enabled", "=", true) .where("view_kind", "=", "downloader") .execute(); return [ ...roots.map((r) => ({ kind: "library_root" as const, id: r.id, name: r.name, path: r.local_path })), ...maps.map((m) => ({ kind: "path_mapping" as const, id: m.id, name: m.remote_prefix, path: m.local_prefix })), ]; } /** * dataDir 下建 agent-sessions/backups/exports/tmp 并逐个探测可写(失败 = problem,阻止启动); * sessionDir 若在 dataDir 之外也单独探测;mounts 逐个检查可读可写(失败 = warning,不阻止启动)。 */ export async function checkStorage(opts: { dataDir: string; sessionDir: string; subdirs: readonly string[]; mounts: MountedPath[]; }): Promise { const problems: PreflightProblem[] = []; const warnings: StorageWarning[] = []; const dirs = [...opts.subdirs.map((s) => join(opts.dataDir, s))]; if (!dirs.includes(opts.sessionDir)) dirs.push(opts.sessionDir); for (const dir of dirs) { try { await Deno.mkdir(dir, { recursive: true }); await probeWrite(dir); } catch (e) { problems.push({ check: "data_dir", message: `数据目录不可写:${dir}(${errText(e)});检查卷挂载与 PUID/PGID` }); } } for (const m of opts.mounts) { try { const st = await Deno.stat(m.path); if (!st.isDirectory) throw Object.assign(new Error("不是目录"), { code: "ENOTDIR" }); for await (const _ of Deno.readDir(m.path)) break; } catch (e) { warnings.push({ ...m, reason: `不可读:${errText(e)}` }); continue; } try { await probeWrite(m.path); } catch (e) { warnings.push({ ...m, reason: `不可写:${errText(e)}` }); } } return { problems, warnings }; } /** 存储告警写审计(actor=system),失败不影响启动 */ export async function auditStorageWarnings(db: Kysely, warnings: StorageWarning[]): Promise { if (!warnings.length) return; await db .insertInto("audit_event") .values( warnings.map((w) => ({ actor_type: "system", actor_id: "preflight", action: "preflight.path_unavailable", target_type: w.kind, target_id: w.id, payload: JSON.stringify({ name: w.name, path: w.path, reason: w.reason, uid: Deno.uid() }), })), ) .execute(); }