Files
UHD_Media_Tool/apps/server/src/ops.ts
T
rootandClaude Opus 5.5 34e8751b45 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]>
2026-09-27 21:27:55 +08:00

154 lines
4.7 KiB
TypeScript

import { dirname } from "node:path";
import type { DB, Kysely } from "@umt/db";
import { sql } from "@umt/db";
// 运维一次性子命令(备份/恢复脚本调用):映射清单导出、恢复后打开只读对账。
// 映射清单是"路径不携带完整身份"前提下的关键资产(需求 §8.5):数据库之外再留一份可读 JSON,
// 用于人工核对与恢复演练的行数校验;不含任何密钥(secret 表只随 pg_dump 走,主密钥永不入备份)。
/** 各表导出的关键列 */
export const MAPPING_TABLES = {
library_root: ["id", "name", "media_kind", "local_path", "management_mode", "enabled", "sentinel_file"],
path_mapping: [
"id",
"view_kind",
"downloader_instance_id",
"media_server_instance_id",
"library_root_id",
"remote_prefix",
"local_prefix",
"priority",
"enabled",
],
media_entity: ["id", "kind", "title", "original_title", "year", "status", "merged_into_id", "updated_at"],
external_identity: [
"id",
"media_entity_id",
"content_unit_id",
"provider",
"entity_type",
"external_id",
"status",
"evidence_level",
"locked",
"revision",
],
path_binding: [
"id",
"media_entity_id",
"content_unit_id",
"season_number",
"binding_kind",
"library_root_id",
"relative_path",
"template_version",
"status",
"superseded_by_id",
],
asset_location: [
"id",
"media_asset_id",
"location_kind",
"library_root_id",
"downloader_instance_id",
"relative_path",
"status",
"is_primary",
"size_bytes",
"inode",
"device",
"mtime",
],
acquisition_slot: [
"id",
"media_entity_id",
"content_unit_id",
"subscription_id",
"policy_revision_id",
"name",
"status",
"current_asset_id",
"revision",
],
organization_record: [
"id",
"media_entity_id",
"content_unit_id",
"download_task_id",
"source_downloader_instance_id",
"source_path",
"target_library_root_id",
"target_path",
"action",
"status",
"plan_fingerprint",
"finished_at",
],
} as const satisfies Partial<Record<keyof DB, readonly string[]>>;
export type MappingTable = keyof typeof MAPPING_TABLES;
export interface MappingExport {
format: "umt-mappings/1";
exportedAt: string;
appVersion: string;
counts: Record<MappingTable, number>;
tables: Record<MappingTable, Record<string, unknown>[]>;
}
/** 按主键顺序导出映射清单 */
export async function exportMappings(db: Kysely<DB>, appVersion: string): Promise<MappingExport> {
const counts = {} as Record<MappingTable, number>;
const tables = {} as Record<MappingTable, Record<string, unknown>[]>;
for (const [table, cols] of Object.entries(MAPPING_TABLES) as [MappingTable, readonly string[]][]) {
const { rows } = await sql<
Record<string, unknown>
>`select ${sql.join(cols.map((c) => sql.ref(c)))} from ${sql.table(table)} order by id`.execute(db);
tables[table] = rows;
counts[table] = rows.length;
}
return { format: "umt-mappings/1", exportedAt: new Date().toISOString(), appVersion, counts, tables };
}
/** 写 JSON(先写临时文件再改名,避免半截文件) */
export async function writeMappingExport(path: string, data: MappingExport): Promise<void> {
await Deno.mkdir(dirname(path), { recursive: true });
const tmp = `${path}.partial`;
await Deno.writeTextFile(tmp, `${JSON.stringify(data, null, 2)}\n`);
await Deno.rename(tmp, path);
}
/** 两份清单逐表比行数,返回不一致的表 */
export function diffCounts(
expected: Record<string, number>,
actual: Record<string, number>,
): Array<{ table: string; expected: number; actual: number }> {
const out: Array<{ table: string; expected: number; actual: number }> = [];
for (const table of new Set([...Object.keys(expected), ...Object.keys(actual)])) {
const e = expected[table] ?? -1;
const a = actual[table] ?? -1;
if (e !== a) out.push({ table, expected: e, actual: a });
}
return out;
}
/** 打开 automation.readOnlyReconcile(保留该分组其它字段)并写审计 */
export async function enableReadOnlyReconcile(db: Kysely<DB>, reason: string): Promise<void> {
await db.transaction().execute(async (trx) => {
await sql`insert into settings (key, value, updated_at)
values ('automation', ${JSON.stringify({ readOnlyReconcile: true })}::jsonb, now())
on conflict (key) do update set value = settings.value || excluded.value, updated_at = now()`.execute(trx);
await trx
.insertInto("audit_event")
.values({
actor_type: "system",
actor_id: "restore",
action: "ops.read_only_reconcile_on",
target_type: "settings",
target_id: "automation",
payload: JSON.stringify({ reason }),
})
.execute();
});
}