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 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)
@@ -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
- return undefined; // Skip upload silently
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 (non-blocking).
2393
- * Returns immediately, logs result in background.
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
- ): void {
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
- // Fire and forget - don't block the main flow
2411
- fetch(url, {
2412
- method: "POST",
2413
- headers: {
2414
- "Content-Type": "application/json",
2415
- },
2416
- body: JSON.stringify({
2417
- api_token: apiKey,
2418
- project_name: projectName,
2419
- }),
2420
- })
2421
- .then(async (res) => {
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
- return;
2491
+ continue;
2429
2492
  }
2430
2493
  if (debug) {
2431
2494
  console.error(`Debug: Monitoring registration response: ${body}`);
2432
2495
  }
2433
- })
2434
- .catch((err) => {
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 (non-blocking, only if API key is configured)
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
- registerMonitoringInstance(apiKey, projectName, {
3016
- apiBaseUrl: globalOpts.apiBaseUrl,
3017
- debug: !!process.env.DEBUG,
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 };