feat(ops): W4-B single-container packaging, DATA_DIR, backup/restore

- DATA_DIR (/data: agent-sessions, backups, exports, tmp); entrypoint chowns it
  to PUID/PGID then drops privileges via gosu; /app binary stays root read-only
- compile with --allow-read --allow-write so mounted library roots are writable
- preflight: DATA_DIR/session dir must be writable (fatal); enabled library
  roots and downloader path mappings checked read/write (warn + audit)
- --role export-mappings (mapping manifest JSON, --compare row counts) and
  --role read-only-on (automation.readOnlyReconcile=true + audit)
- scripts/backup.sh (pg_dump custom + mappings + agent sessions, SHA256SUMS,
  BACKUP_KEEP rotation) and scripts/restore.sh (checksum, refuses with live
  connections, psql single-transaction replay compatible with PG16, read-only
  reconcile on, row-count verification)
- compose: app + postgres only, data volume, env example with key generation
- docs: runbook deploy.md, backup-restore.md with verification record

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 21:27:55 +08:00
1 parent 8c6a922cfb
commit 34e8751b45
18 files changed
+918 -43

No files matched your search

+104
View File
@@ -43,3 +43,107 @@ export async function runPreflight({ config, db, uid = Deno.uid() }: PreflightDe
}
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<void> {
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<DB>): Promise<MountedPath[]> {
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<StorageCheckResult> {
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<DB>, warnings: StorageWarning[]): Promise<void> {
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();
}