From 6bac8676c29d20acd410acadb797959a210e9d15 Mon Sep 17 00:00:00 2001 From: AgentRun Codex Date: Mon, 13 Jul 2026 05:39:44 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20Monitor=20Host=20P?= =?UTF-8?q?G=20=E7=A9=BA=E5=BA=93=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/platform-db/postgres-nc01.yaml | 7 + scripts/src/platform-db-monitor-reset.test.ts | 60 +++++++ .../platform-db-nc01-monitor-config.test.ts | 1 + scripts/src/platform-db.ts | 160 ++++++++++++++++++ 4 files changed, 228 insertions(+) create mode 100644 scripts/src/platform-db-monitor-reset.test.ts diff --git a/config/platform-db/postgres-nc01.yaml b/config/platform-db/postgres-nc01.yaml index 0294ea2d..29ba644d 100644 --- a/config/platform-db/postgres-nc01.yaml +++ b/config/platform-db/postgres-nc01.yaml @@ -213,6 +213,13 @@ objects: locale: C.UTF-8 extensions: [] +managedOperations: + monitorReset: + database: web_probe_monitor + owner: web_probe_monitor + schema: monitor + secretRef: platform-db/web-probe-monitor-nc01-db.env + exports: connectionStrings: - name: agentrun-nc01-v02-database-url diff --git a/scripts/src/platform-db-monitor-reset.test.ts b/scripts/src/platform-db-monitor-reset.test.ts new file mode 100644 index 00000000..cb5e72eb --- /dev/null +++ b/scripts/src/platform-db-monitor-reset.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test"; +import { rootPath } from "./config"; +import { readYamlRecord } from "./platform-infra-ops-library"; +import { monitorResetScript, monitorStatusScript, runPlatformDbCommand } from "./platform-db"; + +const configPath = "config/platform-db/postgres-nc01.yaml"; +const parsed = readYamlRecord>(rootPath(configPath), "postgres-host-cluster"); +const pg = { objects: parsed.objects }; +const target = parsed.managedOperations.monitorReset; + +describe("platform-db Monitor 专属空库入口", () => { + test("plan 精确显示 YAML 目标且默认零写入", async () => { + const result = await runPlatformDbCommand({} as never, ["postgres", "monitor", "plan", "--config", configPath]); + + expect(result).toMatchObject({ + ok: true, + target: "web-probe-monitor", + config: configPath, + database: "web_probe_monitor", + owner: "web_probe_monitor", + schema: "monitor", + secretRef: "platform-db/web-probe-monitor-nc01-db.env", + mutation: false, + valuesPrinted: false, + }); + expect(JSON.stringify(result)).not.toContain("PASSWORD"); + expect(JSON.stringify(result)).not.toContain("DATABASE_URL"); + }); + + test("reset 未确认时只返回计划", async () => { + const result = await runPlatformDbCommand({} as never, ["postgres", "monitor", "reset", "--config", configPath]); + + expect(result).toMatchObject({ ok: true, mutation: false, database: "web_probe_monitor" }); + expect(result).not.toHaveProperty("confirmed"); + }); + + test("确认脚本只重建 Monitor database 并应用 central schema", () => { + const script = monitorResetScript(pg as never, target); + + expect(script).toContain("database='web_probe_monitor'"); + expect(script).toContain("owner='web_probe_monitor'"); + expect(script).toContain('drop database if exists "web_probe_monitor";'); + expect(script).toContain("create schema if not exists monitor;"); + expect(script).toContain('set role "web_probe_monitor";'); + expect(script).not.toContain("drop database agentrun_v02"); + expect(script).not.toContain("drop database decision_center"); + }); + + test("status/reset 验证 schema ready、业务 0 行与其他数据库 OID 不变", () => { + const status = monitorStatusScript(pg as never, target); + const reset = monitorResetScript(pg as never, target); + + expect(status).toContain('\"schemaReady\":%s'); + expect(status).toContain('\"businessRows\":%s'); + expect(status).toContain('\"siblingDatabaseOids\":\"%s\"'); + expect(reset).toContain('[ "$before" != "$after" ]'); + expect(reset).toContain('[ "$rows" != "0" ]'); + expect(reset).toContain('\"siblingDatabasesUnchanged\":true'); + }); +}); diff --git a/scripts/src/platform-db-nc01-monitor-config.test.ts b/scripts/src/platform-db-nc01-monitor-config.test.ts index 4da08779..8df91c41 100644 --- a/scripts/src/platform-db-nc01-monitor-config.test.ts +++ b/scripts/src/platform-db-nc01-monitor-config.test.ts @@ -35,6 +35,7 @@ describe("NC01 Host PG Monitor declaration", () => { consumers: [{ scope: "hwlab-web-probe-monitor", secret: "hwlab-web-probe-monitor-db", key: "DATABASE_URL" }], }); expect(monitorExport.render.format).not.toContain("web_probe_monitor:"); + expect(config.managedOperations.monitorReset).toEqual({ database: "web_probe_monitor", owner: "web_probe_monitor", schema: "monitor", secretRef: "platform-db/web-probe-monitor-nc01-db.env" }); }); test("既有应用 role 保持原有独立 sourceRef", () => { diff --git a/scripts/src/platform-db.ts b/scripts/src/platform-db.ts index c261b541..51734a14 100644 --- a/scripts/src/platform-db.ts +++ b/scripts/src/platform-db.ts @@ -5,6 +5,7 @@ import { dirname, isAbsolute, join } from "node:path"; import type { UniDeskConfig } from "./config"; import { repoRoot, rootPath } from "./config"; import { startJob } from "./jobs"; +import { MONITOR_CENTRAL_MIGRATIONS, MONITOR_CENTRAL_SCHEMA_VERSION } from "./monitor-central-persistence"; import { runSshCommandCapture, type SshCaptureResult } from "./ssh"; import { compactText, fingerprintEnvValues as fingerprintValues, parseEnvFile, parseJsonOutput } from "./platform-infra-ops-library"; @@ -21,6 +22,13 @@ interface PlatformDbOptions { raw: boolean; } +interface MonitorResetConfig { + database: string; + owner: string; + schema: string; + secretRef: string; +} + interface PostgresHostConfig { configPath: string; version: number; @@ -139,6 +147,7 @@ interface PostgresHostConfig { encryption: { enabled: boolean }; }; }; + managedOperations?: { monitorReset: MonitorResetConfig }; } interface PgHbaRule { @@ -300,6 +309,9 @@ export function platformDbHelp(): unknown { `bun scripts/cli.ts platform-db postgres apply --config ${defaultConfigPath} --dry-run`, `bun scripts/cli.ts platform-db postgres apply --config ${defaultConfigPath} --confirm`, `bun scripts/cli.ts platform-db postgres apply --config ${defaultConfigPath} --confirm --wait`, + "bun scripts/cli.ts platform-db postgres monitor plan --config config/platform-db/postgres-nc01.yaml", + "bun scripts/cli.ts platform-db postgres monitor status --config config/platform-db/postgres-nc01.yaml", + "bun scripts/cli.ts platform-db postgres monitor reset --config config/platform-db/postgres-nc01.yaml --confirm", ], description: "Manage YAML-declared host-native PostgreSQL for UniDesk platform state. The PK01 declaration uses PostgreSQL 16 for Sub2API and does not deploy k3s.", safety: { @@ -307,6 +319,7 @@ export function platformDbHelp(): unknown { defaultMutation: false, exportSecrets: "export-secrets only materializes YAML-declared local Secret source/export files; it does not touch the remote PostgreSQL service.", apply: "apply --confirm returns an async UniDesk job; the job uses short trans calls and a remote PK01 job instead of keeping one long SSH session open.", + monitorReset: "monitor reset is restricted by owning YAML to the dedicated web_probe_monitor database and requires --confirm; it cannot accept a database name or arbitrary SQL.", redaction: "Passwords and full DATABASE_URL values are never printed; output is limited to key names, presence, fingerprints and state.", noK3s: "PK01 PostgreSQL is host-systemd, not Docker or k3s.", }, @@ -316,6 +329,11 @@ export function platformDbHelp(): unknown { export async function runPlatformDbCommand(config: UniDeskConfig, args: string[]): Promise> { const [target, action = "plan"] = args; if (target !== "postgres") return unsupported(args); + if (action === "monitor") { + const monitorAction = args[2] ?? "plan"; + const options = parseOptions(args.slice(3)); + return await monitorCommand(config, monitorAction, options); + } const options = parseOptions(args.slice(2)); if (action === "plan") return await plan(config, options); if (action === "status") return await status(config, options); @@ -324,6 +342,46 @@ export async function runPlatformDbCommand(config: UniDeskConfig, args: string[] return unsupported(args); } +async function monitorCommand(config: UniDeskConfig, action: string, options: PlatformDbOptions): Promise> { + if (!(["plan", "status", "reset"] as const).includes(action as "plan" | "status" | "reset")) return unsupported(["postgres", "monitor", action]); + const pg = readPostgresHostConfig(options.configPath); + const target = requireMonitorResetConfig(pg); + const base = { + action: `platform-db-postgres-monitor-${action}`, + target: "web-probe-monitor", + config: pg.configPath, + database: target.database, + owner: target.owner, + schema: target.schema, + schemaVersion: MONITOR_CENTRAL_SCHEMA_VERSION, + secretRef: target.secretRef, + valuesPrinted: false, + }; + if (action === "plan" || (action === "reset" && !options.confirm)) { + return { + ok: true, + ...base, + mutation: false, + plan: monitorResetPlan(target), + next: { + status: `bun scripts/cli.ts platform-db postgres monitor status --config ${pg.configPath}`, + confirm: `bun scripts/cli.ts platform-db postgres monitor reset --config ${pg.configPath} --confirm`, + }, + }; + } + const script = action === "status" ? monitorStatusScript(pg, target) : monitorResetScript(pg, target); + const capture = await runSshCommandCapture(config, pg.node.route, ["sh"], script); + const parsed = parseJsonOutput(capture.stdout); + return { + ok: capture.exitCode === 0 && parsed?.ok === true, + ...base, + mutation: action === "reset", + ...(action === "reset" ? { confirmed: true } : {}), + state: parsed, + remote: compactCapture(capture, { full: options.full || capture.exitCode !== 0 }), + }; +} + function unsupported(args: string[]): Record { return { ok: false, @@ -589,6 +647,7 @@ function readPostgresHostConfig(pathArg: string): PostgresHostConfig { const objects = objectField(parsed, "objects", configPath); const exportsConfig = objectField(parsed, "exports", configPath); const backup = objectField(parsed, "backup", configPath); + const managedOperations = parsed.managedOperations === undefined ? null : objectField(parsed, "managedOperations", configPath); const logicalDump = objectField(backup, "logicalDump", `${configPath}.backup`); const destination = objectField(logicalDump, "destination", `${configPath}.backup.logicalDump`); const encryption = objectField(logicalDump, "encryption", `${configPath}.backup.logicalDump`); @@ -618,6 +677,13 @@ function readPostgresHostConfig(pathArg: string): PostgresHostConfig { databaseNames.add(database.name); } const connectionStrings = arrayOfRecords(exportsConfig.connectionStrings, `${configPath}.exports.connectionStrings`).map((item, index) => connectionStringExport(item, `${configPath}.exports.connectionStrings[${index}]`)); + const monitorReset = managedOperations === null ? null : monitorResetConfig(objectField(managedOperations, "monitorReset", `${configPath}.managedOperations`), `${configPath}.managedOperations.monitorReset`); + if (monitorReset !== null) { + const database = databases.find((item) => item.name === monitorReset.database); + if (monitorReset.database !== "web_probe_monitor" || monitorReset.owner !== "web_probe_monitor" || monitorReset.schema !== "monitor" || database?.owner !== monitorReset.owner) { + throw new Error(`${configPath}.managedOperations.monitorReset is restricted to the dedicated web_probe_monitor database/schema`); + } + } return { configPath: relativeConfigPath(configPath), version, @@ -729,6 +795,16 @@ function readPostgresHostConfig(pathArg: string): PostgresHostConfig { }, }, }, + ...(monitorReset === null ? {} : { managedOperations: { monitorReset } }), + }; +} + +function monitorResetConfig(item: Record, path: string): MonitorResetConfig { + return { + database: pgIdentifier(stringField(item, "database", path), `${path}.database`), + owner: pgIdentifier(stringField(item, "owner", path), `${path}.owner`), + schema: pgIdentifier(stringField(item, "schema", path), `${path}.schema`), + secretRef: stringField(item, "secretRef", path), }; } @@ -988,6 +1064,90 @@ function configSummary(pg: PostgresHostConfig): Record { }; } +function requireMonitorResetConfig(pg: PostgresHostConfig): MonitorResetConfig { + const target = pg.managedOperations?.monitorReset; + if (target === undefined) throw new Error(`${pg.configPath}.managedOperations.monitorReset is required`); + return target; +} + +function monitorResetPlan(target: MonitorResetConfig): string[] { + return [ + `terminate connections only to ${target.database}`, + `drop and recreate only ${target.database} owned by ${target.owner}`, + `apply central Monitor schema ${MONITOR_CENTRAL_SCHEMA_VERSION} as ${target.owner}`, + "verify schema ready and Monitor business row count is 0", + "verify sibling YAML-declared database OIDs are unchanged", + ]; +} + +export function monitorStatusScript(pg: PostgresHostConfig, target: MonitorResetConfig): string { + const siblings = pg.objects.databases.filter((database) => database.name !== target.database).map((database) => database.name).join(","); + const siblingArray = pg.objects.databases.filter((database) => database.name !== target.database).map((database) => sqlLiteral(database.name)).join(", "); + return `${monitorScriptPrelude()} +database=${shellQuote(target.database)} +schema_version=${shellQuote(MONITOR_CENTRAL_SCHEMA_VERSION)} +siblings=${shellQuote(siblings)} +database_oid=$(sudo -n runuser -u postgres -- psql -At -d postgres -c "select oid from pg_database where datname = ${sqlLiteral(target.database)};") +sibling_oids=$(sudo -n runuser -u postgres -- psql -At -d postgres -c "select coalesce(string_agg(datname || ':' || oid, ',' order by datname), '') from pg_database where datname in (${siblingArray});") +schema_ready=false +schema_version_observed="" +business_rows="" +if [ -n "$database_oid" ]; then + migration_table=$(sudo -n runuser -u postgres -- psql -At -d "$database" -c "select coalesce(to_regclass('monitor.schema_migrations')::text, '');") + if [ -n "$migration_table" ]; then + schema_version_observed=$(sudo -n runuser -u postgres -- psql -At -d "$database" -c "select coalesce((select version from monitor.schema_migrations order by applied_at desc limit 1), '');") + fi + if [ "$schema_version_observed" = "$schema_version" ]; then + schema_ready=true + business_rows=$(sudo -n runuser -u postgres -- psql -At -d "$database" -c "select (select count(*) from monitor.runners) + (select count(*) from monitor.runs) + (select count(*) from monitor.run_payloads) + (select count(*) from monitor.findings) + (select count(*) from monitor.artifact_locators) + (select count(*) from monitor.timeline_events) + (select count(*) from monitor.runtime_metadata) + (select count(*) from monitor.import_manifests);") + fi +fi +empty=false +ready=false +if [ "$schema_ready" = true ] && [ "$business_rows" = "0" ]; then empty=true; ready=true; fi +printf '{"ok":true,"databaseExists":%s,"schemaReady":%s,"empty":%s,"ready":%s,"databaseOid":"%s","schemaVersion":"%s","businessRows":%s,"siblingDatabaseOids":"%s"}\n' "$([ -n "$database_oid" ] && printf true || printf false)" "$schema_ready" "$empty" "$ready" "$database_oid" "$schema_version_observed" "$([ -n "$business_rows" ] && printf '%s' "$business_rows" || printf null)" "$sibling_oids" +`; +} + +export function monitorResetScript(pg: PostgresHostConfig, target: MonitorResetConfig): string { + const siblings = pg.objects.databases.filter((database) => database.name !== target.database).map((database) => database.name).join(","); + const siblingArray = pg.objects.databases.filter((database) => database.name !== target.database).map((database) => sqlLiteral(database.name)).join(", "); + const migrations = MONITOR_CENTRAL_MIGRATIONS.map((statement) => `${statement};`).join("\n"); + return `${monitorScriptPrelude()} +database=${shellQuote(target.database)} +owner=${shellQuote(target.owner)} +siblings=${shellQuote(siblings)} +before=$(sudo -n runuser -u postgres -- psql -At -d postgres -c "select coalesce(string_agg(datname || ':' || oid, ',' order by datname), '') from pg_database where datname in (${siblingArray});") +sudo -n runuser -u postgres -- psql -v ON_ERROR_STOP=1 -d postgres <<'SQL' +select pg_terminate_backend(pid) from pg_stat_activity where datname = ${sqlLiteral(target.database)} and pid <> pg_backend_pid(); +drop database if exists "${target.database}"; +create database "${target.database}" owner "${target.owner}" encoding 'UTF8' locale 'C.UTF-8' template template0; +SQL +sudo -n runuser -u postgres -- psql -v ON_ERROR_STOP=1 -d "$database" <<'SQL' +set role "${target.owner}"; +${migrations} +insert into monitor.schema_migrations (version, applied_at) values (${sqlLiteral(MONITOR_CENTRAL_SCHEMA_VERSION)}, now()) on conflict (version) do nothing; +SQL +after=$(sudo -n runuser -u postgres -- psql -At -d postgres -c "select coalesce(string_agg(datname || ':' || oid, ',' order by datname), '') from pg_database where datname in (${siblingArray});") +rows=$(sudo -n runuser -u postgres -- psql -At -d "$database" -c "select (select count(*) from monitor.runners) + (select count(*) from monitor.runs) + (select count(*) from monitor.run_payloads) + (select count(*) from monitor.findings) + (select count(*) from monitor.artifact_locators) + (select count(*) from monitor.timeline_events) + (select count(*) from monitor.runtime_metadata) + (select count(*) from monitor.import_manifests);") +version=$(sudo -n runuser -u postgres -- psql -At -d "$database" -c "select version from monitor.schema_migrations order by applied_at desc limit 1;") +if [ "$before" != "$after" ] || [ "$rows" != "0" ] || [ "$version" != ${shellQuote(MONITOR_CENTRAL_SCHEMA_VERSION)} ]; then exit 1; fi +printf '{"ok":true,"database":"%s","schema":"monitor","schemaVersion":"%s","businessRows":0,"siblingDatabasesUnchanged":true}\n' "$database" "$version" +`; +} + +function monitorScriptPrelude(): string { + return `set -eu +if ! command -v sudo >/dev/null 2>&1 || ! sudo -n true >/dev/null 2>&1; then + printf '{"ok":false,"error":"sudo-noninteractive-unavailable"}\n' + exit 1 +fi`; +} + +function sqlLiteral(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + function inspectSecrets(pg: PostgresHostConfig, materialize: boolean): SecretInspection { const root = secretRoot(pg); const materials = new Map();