- 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]>
154 lines
4.7 KiB
TypeScript
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();
|
|
});
|
|
}
|