postgresai 0.15.0-rc.8 → 0.16.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/bin/postgres-ai.ts +184 -31
- package/dist/bin/postgres-ai.js +386 -90
- package/lib/checkup-api.ts +75 -0
- package/lib/checkup-summary.ts +30 -0
- package/lib/checkup.ts +227 -21
- package/lib/metrics-loader.ts +10 -8
- package/lib/util.ts +10 -3
- package/package.json +1 -1
- package/scripts/embed-metrics.ts +7 -6
- package/test/checkup.integration.test.ts +55 -0
- package/test/checkup.test.ts +471 -1
- package/test/mcp-server.test.ts +4 -0
- package/test/monitoring.test.ts +128 -49
- package/test/schema-validation.test.ts +29 -0
- package/test/test-utils.ts +8 -0
- package/test/util.test.ts +44 -0
package/README.md
CHANGED
|
@@ -203,8 +203,11 @@ postgresai mon health [--wait <sec>] # Check monitoring services health
|
|
|
203
203
|
- `--demo` - Demo mode with sample database (testing only, cannot use with --api-key)
|
|
204
204
|
- `--api-key <key>` - Postgres AI API key for automated report uploads
|
|
205
205
|
- `--db-url <url>` - PostgreSQL connection URL to monitor (format: `postgresql://user:pass@host:port/db`)
|
|
206
|
+
- `--instance-id <uuid>` - Adopt a console-provisioned monitoring instance (also via the `PGAI_INSTANCE_ID` env var)
|
|
206
207
|
- `-y, --yes` - Accept all defaults and skip interactive prompts
|
|
207
208
|
|
|
209
|
+
When `--instance-id <uuid>` (or `PGAI_INSTANCE_ID`) is set, `local-install` forwards the id to the platform, which **adopts** the already-provisioned monitoring instance instead of self-registering a duplicate under an auto-created `postgres-ai-monitoring` project. The CLI then persists the adopted instance's real project to `.pgwatch-config`, so checkup reports upload alongside the rest of that instance's health data. Adoption is awaited (with one automatic retry); if it fails, the CLI warns and reports fall back to the default project until you re-run `local-install`. Without the flag, the legacy self-registration path is byte-for-byte unchanged.
|
|
210
|
+
|
|
208
211
|
`local-install` writes `.env` in the monitoring directory. It preserves existing `REPLICATOR_PASSWORD` and `VM_AUTH_*` values or generates new random ones when missing; `VM_AUTH_USERNAME` defaults to `vmauth` when absent. The replication password is used by the demo PostgreSQL standby replication user, and the VM auth credentials are required before Docker Compose can provision Grafana datasources. If you run `docker compose` directly or maintain `.env` yourself, set both VM auth values before upgrading. For rotation, run `VM_AUTH_PASSWORD="$(openssl rand -base64 18)" ./scripts/rotate-vm-auth.sh` from the monitoring directory so `.env`, `sink-prometheus`, and `grafana` update together.
|
|
209
212
|
|
|
210
213
|
#### Monitoring target databases (`mon targets` subgroup)
|
package/bin/postgres-ai.ts
CHANGED
|
@@ -24,7 +24,7 @@ import { createInterface } from "readline";
|
|
|
24
24
|
import * as childProcess from "child_process";
|
|
25
25
|
import { REPORT_GENERATORS, CHECK_INFO, generateAllReports } from "../lib/checkup";
|
|
26
26
|
import { getCheckupEntry } from "../lib/checkup-dictionary";
|
|
27
|
-
import { createCheckupReport, uploadCheckupReportJson, convertCheckupReportJsonToMarkdown, RpcError, formatRpcErrorForDisplay, withRetry } from "../lib/checkup-api";
|
|
27
|
+
import { createCheckupReport, uploadCheckupReportJson, convertCheckupReportJsonToMarkdown, RpcError, formatRpcErrorForDisplay, withRetry, verifyApiKey } from "../lib/checkup-api";
|
|
28
28
|
import { generateCheckSummary } from "../lib/checkup-summary";
|
|
29
29
|
import {
|
|
30
30
|
type Instance,
|
|
@@ -334,7 +334,13 @@ function prepareUploadConfig(
|
|
|
334
334
|
console.error("Tip: run 'postgresai auth' or pass --api-key / set PGAI_API_KEY");
|
|
335
335
|
return null; // Signal to exit
|
|
336
336
|
}
|
|
337
|
-
|
|
337
|
+
// No credentials and upload not explicitly requested: fall back to
|
|
338
|
+
// local-only mode, but say so prominently — skipping the upload silently
|
|
339
|
+
// hides the fact that results never reach the Console.
|
|
340
|
+
console.error("Notice: no API key configured — results will NOT be uploaded to PostgresAI.");
|
|
341
|
+
console.error(" To upload: run 'postgresai auth login' or pass --api-key / set PGAI_API_KEY.");
|
|
342
|
+
console.error(" To run locally without this notice, pass --no-upload.");
|
|
343
|
+
return undefined; // Skip upload, run checks locally
|
|
338
344
|
}
|
|
339
345
|
|
|
340
346
|
const cfg = config.readConfig();
|
|
@@ -2076,6 +2082,30 @@ program
|
|
|
2076
2082
|
const projectWasGenerated = uploadResult?.projectWasGenerated ?? false;
|
|
2077
2083
|
shouldUpload = !!uploadCfg;
|
|
2078
2084
|
|
|
2085
|
+
// Preflight: validate the configured API key with a cheap authenticated
|
|
2086
|
+
// call BEFORE connecting / running checks, so an invalid or expired token
|
|
2087
|
+
// fails in seconds instead of after minutes of wasted work (previously the
|
|
2088
|
+
// upload at the very end of the run was the first authenticated call).
|
|
2089
|
+
// Only a definitive 401/403 stops the run; a transient pre-flight failure
|
|
2090
|
+
// (network error, timeout, 5xx) warns and continues — the upload may still
|
|
2091
|
+
// succeed.
|
|
2092
|
+
if (uploadCfg) {
|
|
2093
|
+
const verification = await verifyApiKey({
|
|
2094
|
+
apiKey: uploadCfg.apiKey,
|
|
2095
|
+
apiBaseUrl: uploadCfg.apiBaseUrl,
|
|
2096
|
+
});
|
|
2097
|
+
if (verification.status === "invalid") {
|
|
2098
|
+
console.error(`Error: the configured API key was rejected by the PostgresAI API (HTTP ${verification.statusCode})`);
|
|
2099
|
+
console.error("Tip: run 'postgresai auth login' to re-authenticate, or pass a valid --api-key / set PGAI_API_KEY");
|
|
2100
|
+
console.error("Tip: pass --no-upload to run checks locally without uploading");
|
|
2101
|
+
process.exitCode = 1;
|
|
2102
|
+
return;
|
|
2103
|
+
}
|
|
2104
|
+
if (verification.status === "unknown") {
|
|
2105
|
+
console.error(`Warning: could not verify API key before running checks (${verification.detail}); continuing — upload will still be attempted`);
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2079
2109
|
// Connect and run checks
|
|
2080
2110
|
const adminConn = resolveAdminConnection({
|
|
2081
2111
|
conn,
|
|
@@ -2388,54 +2418,136 @@ function checkRunningContainers(): { running: boolean; containers: string[] } {
|
|
|
2388
2418
|
}
|
|
2389
2419
|
}
|
|
2390
2420
|
|
|
2421
|
+
/** Parsed result of v1.monitoring_instance_register. */
|
|
2422
|
+
interface MonitoringRegistration {
|
|
2423
|
+
instanceId?: string;
|
|
2424
|
+
projectId?: number;
|
|
2425
|
+
projectName?: string;
|
|
2426
|
+
created?: boolean;
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2391
2429
|
/**
|
|
2392
|
-
* Register monitoring instance with the API
|
|
2393
|
-
*
|
|
2430
|
+
* Register the monitoring instance with the API.
|
|
2431
|
+
*
|
|
2432
|
+
* Two modes (issue platform-all#311):
|
|
2433
|
+
* - With `instanceId` (console-provisioned installs; passed via
|
|
2434
|
+
* `--instance-id` / PGAI_INSTANCE_ID, wired from the provisioning flow
|
|
2435
|
+
* through SI/ansible): the platform ADOPTS the existing provisioned
|
|
2436
|
+
* instance instead of self-registering a duplicate under an auto-created
|
|
2437
|
+
* "postgres-ai-monitoring" project. The returned project_name is what the
|
|
2438
|
+
* reporter must upload to, so callers should await the result and persist
|
|
2439
|
+
* it. One automatic retry, since a lost adoption splits the health matrix
|
|
2440
|
+
* across two projects.
|
|
2441
|
+
* - Without it: legacy self-registration by project name.
|
|
2442
|
+
*
|
|
2443
|
+
* Never throws — registration is best-effort; returns null on failure.
|
|
2394
2444
|
*/
|
|
2395
|
-
function registerMonitoringInstance(
|
|
2445
|
+
async function registerMonitoringInstance(
|
|
2396
2446
|
apiKey: string,
|
|
2397
2447
|
projectName: string,
|
|
2398
|
-
opts?: { apiBaseUrl?: string; debug?: boolean }
|
|
2399
|
-
):
|
|
2448
|
+
opts?: { apiBaseUrl?: string; debug?: boolean; instanceId?: string; retries?: number; retryDelayMs?: number }
|
|
2449
|
+
): Promise<MonitoringRegistration | null> {
|
|
2400
2450
|
const { apiBaseUrl } = resolveBaseUrls(opts);
|
|
2401
2451
|
const url = `${apiBaseUrl}/rpc/monitoring_instance_register`;
|
|
2402
2452
|
const debug = opts?.debug;
|
|
2453
|
+
const instanceId = opts?.instanceId;
|
|
2454
|
+
const retries = opts?.retries ?? (instanceId ? 1 : 0);
|
|
2455
|
+
// Brief backoff before a retry so a transient 5xx / connection blip gets a
|
|
2456
|
+
// moment to recover; skipped before the first attempt. Tests pass 0.
|
|
2457
|
+
const retryDelayMs = opts?.retryDelayMs ?? 400;
|
|
2403
2458
|
|
|
2404
2459
|
if (debug) {
|
|
2405
2460
|
console.error(`\nDebug: Registering monitoring instance...`);
|
|
2406
2461
|
console.error(`Debug: POST ${url}`);
|
|
2407
|
-
console.error(`Debug: project_name=${projectName}`);
|
|
2462
|
+
console.error(`Debug: project_name=${projectName}${instanceId ? ` instance_id=${instanceId}` : ""}`);
|
|
2408
2463
|
}
|
|
2409
2464
|
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2465
|
+
const requestBody: Record<string, string> = {
|
|
2466
|
+
api_token: apiKey,
|
|
2467
|
+
project_name: projectName,
|
|
2468
|
+
};
|
|
2469
|
+
if (instanceId) {
|
|
2470
|
+
requestBody.instance_id = instanceId;
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
2474
|
+
if (attempt > 0 && retryDelayMs > 0) {
|
|
2475
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
2476
|
+
}
|
|
2477
|
+
try {
|
|
2478
|
+
const res = await fetch(url, {
|
|
2479
|
+
method: "POST",
|
|
2480
|
+
headers: {
|
|
2481
|
+
"Content-Type": "application/json",
|
|
2482
|
+
},
|
|
2483
|
+
body: JSON.stringify(requestBody),
|
|
2484
|
+
});
|
|
2422
2485
|
const body = await res.text().catch(() => "");
|
|
2423
2486
|
if (!res.ok) {
|
|
2424
2487
|
if (debug) {
|
|
2425
2488
|
console.error(`Debug: Monitoring registration failed: HTTP ${res.status}`);
|
|
2426
2489
|
console.error(`Debug: Response: ${body}`);
|
|
2427
2490
|
}
|
|
2428
|
-
|
|
2491
|
+
continue;
|
|
2429
2492
|
}
|
|
2430
2493
|
if (debug) {
|
|
2431
2494
|
console.error(`Debug: Monitoring registration response: ${body}`);
|
|
2432
2495
|
}
|
|
2433
|
-
|
|
2434
|
-
|
|
2496
|
+
try {
|
|
2497
|
+
const parsed = JSON.parse(body) as {
|
|
2498
|
+
instance_id?: unknown;
|
|
2499
|
+
project_id?: unknown;
|
|
2500
|
+
project_name?: unknown;
|
|
2501
|
+
created?: unknown;
|
|
2502
|
+
};
|
|
2503
|
+
// Runtime-check each field: the `as` cast above is compile-time only,
|
|
2504
|
+
// and a spoofed/older platform could return mistyped values. In
|
|
2505
|
+
// particular `project_id` must be a real number before we trust it
|
|
2506
|
+
// over the (string) project_name in the persistence decision below.
|
|
2507
|
+
return {
|
|
2508
|
+
instanceId: typeof parsed.instance_id === "string" ? parsed.instance_id : undefined,
|
|
2509
|
+
projectId: typeof parsed.project_id === "number" ? parsed.project_id : undefined,
|
|
2510
|
+
projectName: typeof parsed.project_name === "string" ? parsed.project_name : undefined,
|
|
2511
|
+
created: typeof parsed.created === "boolean" ? parsed.created : undefined,
|
|
2512
|
+
};
|
|
2513
|
+
} catch {
|
|
2514
|
+
return {};
|
|
2515
|
+
}
|
|
2516
|
+
} catch (err) {
|
|
2435
2517
|
if (debug) {
|
|
2436
|
-
console.error(`Debug: Monitoring registration error: ${err.message}`);
|
|
2518
|
+
console.error(`Debug: Monitoring registration error: ${(err as Error).message}`);
|
|
2437
2519
|
}
|
|
2438
|
-
}
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
return null;
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
/**
|
|
2526
|
+
* Decide what to persist as `.pgwatch-config`'s `project_name` from an
|
|
2527
|
+
* adoption response, or `null` if the response carries no usable project.
|
|
2528
|
+
*
|
|
2529
|
+
* Pure (no I/O) so the branch logic is unit-testable.
|
|
2530
|
+
*
|
|
2531
|
+
* - Prefers the numeric `project_id`: `checkup_report_create` resolves
|
|
2532
|
+
* "project" as id-or-name, and the id survives project renames (a name
|
|
2533
|
+
* match would miss after a rename and silently re-create the old name as a
|
|
2534
|
+
* fresh project). `project_id === 0` is still a valid id and is honored.
|
|
2535
|
+
* - Falls back to `project_name`, but only when it's a safe single-line token.
|
|
2536
|
+
* The value is server-supplied and written verbatim into a `key=value`
|
|
2537
|
+
* config file; a name containing `\r`, `\n`, or `=` could inject extra
|
|
2538
|
+
* config keys (config-file injection, CWE-93/74). Reject those rather than
|
|
2539
|
+
* risk it — over a trusted first-party endpoint this should never fire.
|
|
2540
|
+
*/
|
|
2541
|
+
const PROJECT_NAME_RE = /^[A-Za-z0-9._-]+$/;
|
|
2542
|
+
function resolveAdoptedProject(reg: MonitoringRegistration | null): string | null {
|
|
2543
|
+
if (!reg) return null;
|
|
2544
|
+
if (typeof reg.projectId === "number" && Number.isFinite(reg.projectId)) {
|
|
2545
|
+
return String(reg.projectId);
|
|
2546
|
+
}
|
|
2547
|
+
if (typeof reg.projectName === "string" && PROJECT_NAME_RE.test(reg.projectName)) {
|
|
2548
|
+
return reg.projectName;
|
|
2549
|
+
}
|
|
2550
|
+
return null;
|
|
2439
2551
|
}
|
|
2440
2552
|
|
|
2441
2553
|
/**
|
|
@@ -2596,8 +2708,12 @@ mon
|
|
|
2596
2708
|
.option("--db-url <url>", "PostgreSQL connection URL to monitor")
|
|
2597
2709
|
.option("--tag <tag>", "Docker image tag to use (e.g., 0.14.0, 0.14.0-dev.33)")
|
|
2598
2710
|
.option("--project <name>", "Docker Compose project name (default: postgres_ai)")
|
|
2711
|
+
.option(
|
|
2712
|
+
"--instance-id <uuid>",
|
|
2713
|
+
"adopt a console-provisioned monitoring instance instead of self-registering a new one (set automatically by the provisioning flow; PGAI_INSTANCE_ID env also works)"
|
|
2714
|
+
)
|
|
2599
2715
|
.option("-y, --yes", "accept all defaults and skip interactive prompts", false)
|
|
2600
|
-
.action(async (opts: { demo: boolean; apiKey?: string; dbUrl?: string; tag?: string; project?: string; yes: boolean }) => {
|
|
2716
|
+
.action(async (opts: { demo: boolean; apiKey?: string; dbUrl?: string; tag?: string; project?: string; instanceId?: string; yes: boolean }) => {
|
|
2601
2717
|
// Get apiKey from global program options (--api-key is defined globally)
|
|
2602
2718
|
// This is needed because Commander.js routes --api-key to the global option, not the subcommand's option
|
|
2603
2719
|
const globalOpts = program.opts<CliOptions>();
|
|
@@ -3009,13 +3125,49 @@ mon
|
|
|
3009
3125
|
}
|
|
3010
3126
|
console.log("✓ Services started\n");
|
|
3011
3127
|
|
|
3012
|
-
// Register monitoring instance with API (
|
|
3128
|
+
// Register monitoring instance with API (only if API key is configured).
|
|
3129
|
+
// Console-provisioned installs pass --instance-id (or PGAI_INSTANCE_ID):
|
|
3130
|
+
// the platform then ADOPTS the provisioned instance and tells us its real
|
|
3131
|
+
// project, which the reporter must upload to — so that path is awaited
|
|
3132
|
+
// and persisted; the legacy self-registration stays fire-and-forget
|
|
3133
|
+
// (issue platform-all#311).
|
|
3013
3134
|
if (apiKey && !opts.demo) {
|
|
3014
3135
|
const projectName = opts.project || "postgres-ai-monitoring";
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3136
|
+
const instanceId = opts.instanceId || process.env.PGAI_INSTANCE_ID;
|
|
3137
|
+
if (instanceId) {
|
|
3138
|
+
const reg = await registerMonitoringInstance(apiKey, projectName, {
|
|
3139
|
+
apiBaseUrl: globalOpts.apiBaseUrl,
|
|
3140
|
+
debug: !!process.env.DEBUG,
|
|
3141
|
+
instanceId,
|
|
3142
|
+
});
|
|
3143
|
+
const adoptedProject = resolveAdoptedProject(reg);
|
|
3144
|
+
if (adoptedProject != null) {
|
|
3145
|
+
// Point the reporter at the adopted instance's project so checkup
|
|
3146
|
+
// uploads land next to the rest of this instance's health data.
|
|
3147
|
+
updatePgwatchConfig(path.resolve(projectDir, ".pgwatch-config"), {
|
|
3148
|
+
project_name: adoptedProject,
|
|
3149
|
+
});
|
|
3150
|
+
// `created` distinguishes a fresh self-registration from adopting an
|
|
3151
|
+
// existing provisioned row; with an instance_id we expect adoption.
|
|
3152
|
+
const verb = reg?.created ? "Registered" : "Adopted";
|
|
3153
|
+
console.log(`✓ ${verb} monitoring instance (project: ${adoptedProject})\n`);
|
|
3154
|
+
} else if (reg) {
|
|
3155
|
+
// Request succeeded but carried no usable project field — don't claim
|
|
3156
|
+
// adoption, but don't report a hard failure either (no re-run needed).
|
|
3157
|
+
console.error(
|
|
3158
|
+
`⚠ Adopted provisioned instance ${instanceId} but the platform returned no project — reports will use project '${projectName}'`
|
|
3159
|
+
);
|
|
3160
|
+
} else {
|
|
3161
|
+
console.error(
|
|
3162
|
+
`⚠ Could not adopt provisioned instance ${instanceId} — reports will use project '${projectName}' until 'postgresai mon local-install' is re-run`
|
|
3163
|
+
);
|
|
3164
|
+
}
|
|
3165
|
+
} else {
|
|
3166
|
+
void registerMonitoringInstance(apiKey, projectName, {
|
|
3167
|
+
apiBaseUrl: globalOpts.apiBaseUrl,
|
|
3168
|
+
debug: !!process.env.DEBUG,
|
|
3169
|
+
});
|
|
3170
|
+
}
|
|
3019
3171
|
}
|
|
3020
3172
|
|
|
3021
3173
|
// Final summary
|
|
@@ -5309,3 +5461,4 @@ if (import.meta.main) {
|
|
|
5309
5461
|
// Exported for unit tests (the CLI surface above is unaffected; these are the
|
|
5310
5462
|
// same functions used by the `mon` commands).
|
|
5311
5463
|
export { refreshBundledComposeIfStale, readDeployedTag, isValidComposeYaml };
|
|
5464
|
+
export { registerMonitoringInstance, resolveAdoptedProject, type MonitoringRegistration };
|