feat: add monitor terminal ingest migration CLI
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-07-12-p17-monitor-central-persistence.
|
||||
// Responsibility: Read-only legacy SQLite snapshot export and bounded, explicit one-shot migration verification.
|
||||
import { createHash } from "node:crypto";
|
||||
import { closeSync, existsSync, openSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { Database } from "bun:sqlite";
|
||||
|
||||
import { stableJson } from "./monitor-terminal-ingest";
|
||||
|
||||
export const MONITOR_SQLITE_MIGRATION_SCHEMA = "2026-07-12-p17-1";
|
||||
|
||||
export interface MonitorSqliteSource {
|
||||
readonly sentinelId: string;
|
||||
readonly node: string;
|
||||
readonly lane: string;
|
||||
readonly path: string;
|
||||
readonly runtimeConfigRef: string;
|
||||
}
|
||||
|
||||
export interface MonitorSqliteSnapshot {
|
||||
readonly schema: string;
|
||||
readonly createdAt: string;
|
||||
readonly source: MonitorSqliteSource;
|
||||
readonly fingerprint: string;
|
||||
readonly integrity: string;
|
||||
readonly byteLength: number;
|
||||
readonly tables: readonly { readonly name: string; readonly sql: string; readonly rowCount: number; readonly rows: readonly Record<string, unknown>[] }[];
|
||||
readonly artifactLocator: { readonly path: string; readonly sha256: string; readonly byteLength: number };
|
||||
}
|
||||
|
||||
export interface MonitorMigrationReconciliation {
|
||||
readonly runs: number;
|
||||
readonly findings: number;
|
||||
readonly metadata: number;
|
||||
readonly payloads: number;
|
||||
readonly locators: number;
|
||||
readonly latest: number;
|
||||
}
|
||||
|
||||
export function planMonitorSqliteSources(sources: readonly MonitorSqliteSource[]): Record<string, unknown> {
|
||||
const rows = sources.map((source) => {
|
||||
const path = resolve(source.path);
|
||||
const state = existsSync(path) ? statSync(path) : null;
|
||||
return {
|
||||
sentinelId: source.sentinelId,
|
||||
node: source.node,
|
||||
lane: source.lane,
|
||||
path,
|
||||
runtimeConfigRef: source.runtimeConfigRef,
|
||||
exists: state !== null,
|
||||
isFile: state?.isFile() ?? false,
|
||||
byteLength: state?.size ?? null,
|
||||
mutation: false,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
});
|
||||
return { ok: true, command: "web-probe sentinel migration plan", sources: rows, sourceCount: rows.length, valuesRedacted: true };
|
||||
}
|
||||
|
||||
export function exportMonitorSqliteSnapshot(source: MonitorSqliteSource, outputPath: string): MonitorSqliteSnapshot {
|
||||
const path = assertReadOnlySqliteSource(source.path);
|
||||
const bytes = readFileSync(path);
|
||||
const fingerprint = `sha256:${sha256(bytes)}`;
|
||||
const database = new Database(path, { readonly: true });
|
||||
try {
|
||||
const integrity = String(database.query("PRAGMA integrity_check").get()?.integrity_check ?? "unknown");
|
||||
if (integrity !== "ok") throw new Error(`legacy SQLite integrity_check failed: ${integrity}`);
|
||||
const tables = database.query("SELECT name, sql FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all() as { name: string; sql: string }[];
|
||||
const snapshot: MonitorSqliteSnapshot = {
|
||||
schema: MONITOR_SQLITE_MIGRATION_SCHEMA,
|
||||
createdAt: new Date().toISOString(),
|
||||
source: { ...source, path },
|
||||
fingerprint,
|
||||
integrity,
|
||||
byteLength: bytes.byteLength,
|
||||
tables: tables.map((table) => ({
|
||||
name: table.name,
|
||||
sql: table.sql,
|
||||
rowCount: Number(database.query(`SELECT COUNT(*) AS count FROM ${quoteIdentifier(table.name)}`).get()?.count ?? 0),
|
||||
rows: database.query(`SELECT * FROM ${quoteIdentifier(table.name)}`).all().map(normalizeRow),
|
||||
})),
|
||||
artifactLocator: { path: resolve(outputPath), sha256: "", byteLength: 0 },
|
||||
};
|
||||
const locatorSha256 = `sha256:${sha256(Buffer.from(stableJson({ ...snapshot, artifactLocator: { ...snapshot.artifactLocator, sha256: "", byteLength: 0 } })))}`;
|
||||
const materialized = `${stableJson({ ...snapshot, artifactLocator: { ...snapshot.artifactLocator, sha256: locatorSha256, byteLength: 0 } })}\n`;
|
||||
writeSnapshot(outputPath, materialized);
|
||||
const artifact = readFileSync(outputPath);
|
||||
return { ...snapshot, artifactLocator: { path: resolve(outputPath), sha256: locatorSha256, byteLength: artifact.byteLength } };
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyMonitorSqliteSnapshot(snapshot: MonitorSqliteSnapshot): Record<string, unknown> {
|
||||
const expected = `sha256:${sha256(Buffer.from(stableJson({ ...snapshot, artifactLocator: { ...snapshot.artifactLocator, sha256: "", byteLength: 0 } })))} ` .trim();
|
||||
const reconciliation = reconcileTables(snapshot.tables);
|
||||
return {
|
||||
ok: snapshot.schema === MONITOR_SQLITE_MIGRATION_SCHEMA && snapshot.integrity === "ok" && snapshot.artifactLocator.sha256 === expected,
|
||||
command: "web-probe sentinel migration verify",
|
||||
fingerprint: snapshot.fingerprint,
|
||||
integrity: snapshot.integrity,
|
||||
reconciliation,
|
||||
artifactLocator: { ...snapshot.artifactLocator, valid: snapshot.artifactLocator.sha256 === expected },
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function reconcileTables(tables: readonly MonitorSqliteSnapshot["tables"][number][]): MonitorMigrationReconciliation {
|
||||
const count = (names: readonly string[]) => tables.filter((table) => names.includes(table.name)).reduce((total, table) => total + table.rowCount, 0);
|
||||
return {
|
||||
runs: count(["runs", "run"]),
|
||||
findings: count(["findings", "finding"]),
|
||||
metadata: count(["metadata", "run_metadata"]),
|
||||
payloads: count(["payloads", "run_payloads"]),
|
||||
locators: count(["artifact_locators", "locators"]),
|
||||
latest: count(["latest", "latest_runs"]),
|
||||
};
|
||||
}
|
||||
|
||||
function assertReadOnlySqliteSource(value: string): string {
|
||||
const path = resolve(value);
|
||||
const state = statSync(path);
|
||||
if (!state.isFile()) throw new Error(`legacy SQLite source is not a file: ${path}`);
|
||||
const descriptor = openSync(path, "r");
|
||||
closeSync(descriptor);
|
||||
return path;
|
||||
}
|
||||
|
||||
function writeSnapshot(path: string, content: string): void {
|
||||
const parent = dirname(resolve(path));
|
||||
if (!existsSync(parent)) throw new Error(`snapshot output directory does not exist: ${parent}`);
|
||||
writeFileSync(path, content);
|
||||
}
|
||||
|
||||
function quoteIdentifier(value: string): string {
|
||||
return `"${value.replaceAll("\"", "\"\"")}"`;
|
||||
}
|
||||
|
||||
function normalizeRow(value: unknown): Record<string, unknown> {
|
||||
const row = value as Record<string, unknown>;
|
||||
return Object.fromEntries(Object.entries(row).map(([key, item]) => [key, item instanceof Uint8Array ? Buffer.from(item).toString("base64") : item]));
|
||||
}
|
||||
|
||||
function sha256(value: Uint8Array): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
Reference in New Issue
Block a user