Merge pull request #1903 from pikasTech/feat/1873-monitor-db-reset
Pipelines as Code CI / hwlab-web-probe-sentinel-nc01- Success
Pipelines as Code CI / platform-infra-gitea-nc01- Success
Pipelines as Code CI / unidesk-host- Success

feat: 增加 Monitor Host PG 空库受控入口
This commit is contained in:
Lyon
2026-07-13 14:01:45 +08:00
committed by GitHub
4 changed files with 246 additions and 0 deletions
+7
View File
@@ -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
@@ -0,0 +1,70 @@
import { describe, expect, test } from "bun:test";
import { rootPath } from "./config";
import { readYamlRecord } from "./platform-infra-ops-library";
import { monitorResetScript, monitorStatusScript, monitorStatusSucceeded, runPlatformDbCommand } from "./platform-db";
const configPath = "config/platform-db/postgres-nc01.yaml";
const parsed = readYamlRecord<Record<string, any>>(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');
});
test("status ready=true 且远端退出 0 时成功", () => {
expect(monitorStatusSucceeded(0, { ok: true, ready: true, schemaReady: true, empty: true, businessRows: 0 })).toBe(true);
});
test("status ready=false 时失败,即使远端误返回退出 0", () => {
expect(monitorStatusSucceeded(0, { ok: false, ready: false, schemaReady: true, empty: false, businessRows: 1 })).toBe(false);
expect(monitorStatusSucceeded(0, { ok: true, ready: false, schemaReady: false, empty: false, businessRows: null })).toBe(false);
expect(monitorStatusSucceeded(1, { ok: false, ready: false })).toBe(false);
});
});
@@ -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", () => {
+168
View File
@@ -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<Record<string, unknown>> {
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,53 @@ export async function runPlatformDbCommand(config: UniDeskConfig, args: string[]
return unsupported(args);
}
async function monitorCommand(config: UniDeskConfig, action: string, options: PlatformDbOptions): Promise<Record<string, unknown>> {
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);
const commandOk = action === "status"
? monitorStatusSucceeded(capture.exitCode, parsed)
: capture.exitCode === 0 && parsed?.ok === true;
return {
ok: commandOk,
...base,
mutation: action === "reset",
...(action === "reset" ? { confirmed: true } : {}),
state: parsed,
remote: compactCapture(capture, { full: options.full || capture.exitCode !== 0 }),
};
}
export function monitorStatusSucceeded(exitCode: number, parsed: Record<string, unknown> | null): boolean {
return exitCode === 0 && parsed?.ok === true && parsed.ready === true;
}
function unsupported(args: string[]): Record<string, unknown> {
return {
ok: false,
@@ -589,6 +654,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 +684,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 +802,16 @@ function readPostgresHostConfig(pathArg: string): PostgresHostConfig {
},
},
},
...(monitorReset === null ? {} : { managedOperations: { monitorReset } }),
};
}
function monitorResetConfig(item: Record<string, unknown>, 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 +1071,91 @@ function configSummary(pg: PostgresHostConfig): Record<string, unknown> {
};
}
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":%s,"databaseExists":%s,"schemaReady":%s,"empty":%s,"ready":%s,"databaseOid":"%s","schemaVersion":"%s","businessRows":%s,"siblingDatabaseOids":"%s"}\n' "$ready" "$([ -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"
if [ "$ready" != true ]; then exit 1; fi
`;
}
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<string, SecretMaterial>();