Skip to content

Commit 9487e29

Browse files
committed
Merge branch 'feat/register-adopt-instance' into 'main'
feat(cli): mon local-install --instance-id adopts the provisioned monitoring instance See merge request postgres-ai/postgresai!313
2 parents b0082a6 + 089f294 commit 9487e29

3 files changed

Lines changed: 283 additions & 78 deletions

File tree

cli/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,8 +203,11 @@ postgresai mon health [--wait <sec>] # Check monitoring services health
203203
- `--demo` - Demo mode with sample database (testing only, cannot use with --api-key)
204204
- `--api-key <key>` - Postgres AI API key for automated report uploads
205205
- `--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)
206207
- `-y, --yes` - Accept all defaults and skip interactive prompts
207208

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+
208211
`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.
209212

210213
#### Monitoring target databases (`mon targets` subgroup)

cli/bin/postgres-ai.ts

Lines changed: 152 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2388,54 +2388,136 @@ function checkRunningContainers(): { running: boolean; containers: string[] } {
23882388
}
23892389
}
23902390

2391+
/** Parsed result of v1.monitoring_instance_register. */
2392+
interface MonitoringRegistration {
2393+
instanceId?: string;
2394+
projectId?: number;
2395+
projectName?: string;
2396+
created?: boolean;
2397+
}
2398+
23912399
/**
2392-
* Register monitoring instance with the API (non-blocking).
2393-
* Returns immediately, logs result in background.
2400+
* Register the monitoring instance with the API.
2401+
*
2402+
* Two modes (issue platform-all#311):
2403+
* - With `instanceId` (console-provisioned installs; passed via
2404+
* `--instance-id` / PGAI_INSTANCE_ID, wired from the provisioning flow
2405+
* through SI/ansible): the platform ADOPTS the existing provisioned
2406+
* instance instead of self-registering a duplicate under an auto-created
2407+
* "postgres-ai-monitoring" project. The returned project_name is what the
2408+
* reporter must upload to, so callers should await the result and persist
2409+
* it. One automatic retry, since a lost adoption splits the health matrix
2410+
* across two projects.
2411+
* - Without it: legacy self-registration by project name.
2412+
*
2413+
* Never throws — registration is best-effort; returns null on failure.
23942414
*/
2395-
function registerMonitoringInstance(
2415+
async function registerMonitoringInstance(
23962416
apiKey: string,
23972417
projectName: string,
2398-
opts?: { apiBaseUrl?: string; debug?: boolean }
2399-
): void {
2418+
opts?: { apiBaseUrl?: string; debug?: boolean; instanceId?: string; retries?: number; retryDelayMs?: number }
2419+
): Promise<MonitoringRegistration | null> {
24002420
const { apiBaseUrl } = resolveBaseUrls(opts);
24012421
const url = `${apiBaseUrl}/rpc/monitoring_instance_register`;
24022422
const debug = opts?.debug;
2423+
const instanceId = opts?.instanceId;
2424+
const retries = opts?.retries ?? (instanceId ? 1 : 0);
2425+
// Brief backoff before a retry so a transient 5xx / connection blip gets a
2426+
// moment to recover; skipped before the first attempt. Tests pass 0.
2427+
const retryDelayMs = opts?.retryDelayMs ?? 400;
24032428

24042429
if (debug) {
24052430
console.error(`\nDebug: Registering monitoring instance...`);
24062431
console.error(`Debug: POST ${url}`);
2407-
console.error(`Debug: project_name=${projectName}`);
2432+
console.error(`Debug: project_name=${projectName}${instanceId ? ` instance_id=${instanceId}` : ""}`);
24082433
}
24092434

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) => {
2435+
const requestBody: Record<string, string> = {
2436+
api_token: apiKey,
2437+
project_name: projectName,
2438+
};
2439+
if (instanceId) {
2440+
requestBody.instance_id = instanceId;
2441+
}
2442+
2443+
for (let attempt = 0; attempt <= retries; attempt++) {
2444+
if (attempt > 0 && retryDelayMs > 0) {
2445+
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
2446+
}
2447+
try {
2448+
const res = await fetch(url, {
2449+
method: "POST",
2450+
headers: {
2451+
"Content-Type": "application/json",
2452+
},
2453+
body: JSON.stringify(requestBody),
2454+
});
24222455
const body = await res.text().catch(() => "");
24232456
if (!res.ok) {
24242457
if (debug) {
24252458
console.error(`Debug: Monitoring registration failed: HTTP ${res.status}`);
24262459
console.error(`Debug: Response: ${body}`);
24272460
}
2428-
return;
2461+
continue;
24292462
}
24302463
if (debug) {
24312464
console.error(`Debug: Monitoring registration response: ${body}`);
24322465
}
2433-
})
2434-
.catch((err) => {
2466+
try {
2467+
const parsed = JSON.parse(body) as {
2468+
instance_id?: unknown;
2469+
project_id?: unknown;
2470+
project_name?: unknown;
2471+
created?: unknown;
2472+
};
2473+
// Runtime-check each field: the `as` cast above is compile-time only,
2474+
// and a spoofed/older platform could return mistyped values. In
2475+
// particular `project_id` must be a real number before we trust it
2476+
// over the (string) project_name in the persistence decision below.
2477+
return {
2478+
instanceId: typeof parsed.instance_id === "string" ? parsed.instance_id : undefined,
2479+
projectId: typeof parsed.project_id === "number" ? parsed.project_id : undefined,
2480+
projectName: typeof parsed.project_name === "string" ? parsed.project_name : undefined,
2481+
created: typeof parsed.created === "boolean" ? parsed.created : undefined,
2482+
};
2483+
} catch {
2484+
return {};
2485+
}
2486+
} catch (err) {
24352487
if (debug) {
2436-
console.error(`Debug: Monitoring registration error: ${err.message}`);
2488+
console.error(`Debug: Monitoring registration error: ${(err as Error).message}`);
24372489
}
2438-
});
2490+
}
2491+
}
2492+
return null;
2493+
}
2494+
2495+
/**
2496+
* Decide what to persist as `.pgwatch-config`'s `project_name` from an
2497+
* adoption response, or `null` if the response carries no usable project.
2498+
*
2499+
* Pure (no I/O) so the branch logic is unit-testable.
2500+
*
2501+
* - Prefers the numeric `project_id`: `checkup_report_create` resolves
2502+
* "project" as id-or-name, and the id survives project renames (a name
2503+
* match would miss after a rename and silently re-create the old name as a
2504+
* fresh project). `project_id === 0` is still a valid id and is honored.
2505+
* - Falls back to `project_name`, but only when it's a safe single-line token.
2506+
* The value is server-supplied and written verbatim into a `key=value`
2507+
* config file; a name containing `\r`, `\n`, or `=` could inject extra
2508+
* config keys (config-file injection, CWE-93/74). Reject those rather than
2509+
* risk it — over a trusted first-party endpoint this should never fire.
2510+
*/
2511+
const PROJECT_NAME_RE = /^[A-Za-z0-9._-]+$/;
2512+
function resolveAdoptedProject(reg: MonitoringRegistration | null): string | null {
2513+
if (!reg) return null;
2514+
if (typeof reg.projectId === "number" && Number.isFinite(reg.projectId)) {
2515+
return String(reg.projectId);
2516+
}
2517+
if (typeof reg.projectName === "string" && PROJECT_NAME_RE.test(reg.projectName)) {
2518+
return reg.projectName;
2519+
}
2520+
return null;
24392521
}
24402522

24412523
/**
@@ -2596,8 +2678,12 @@ mon
25962678
.option("--db-url <url>", "PostgreSQL connection URL to monitor")
25972679
.option("--tag <tag>", "Docker image tag to use (e.g., 0.14.0, 0.14.0-dev.33)")
25982680
.option("--project <name>", "Docker Compose project name (default: postgres_ai)")
2681+
.option(
2682+
"--instance-id <uuid>",
2683+
"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)"
2684+
)
25992685
.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 }) => {
2686+
.action(async (opts: { demo: boolean; apiKey?: string; dbUrl?: string; tag?: string; project?: string; instanceId?: string; yes: boolean }) => {
26012687
// Get apiKey from global program options (--api-key is defined globally)
26022688
// This is needed because Commander.js routes --api-key to the global option, not the subcommand's option
26032689
const globalOpts = program.opts<CliOptions>();
@@ -3009,13 +3095,49 @@ mon
30093095
}
30103096
console.log("✓ Services started\n");
30113097

3012-
// Register monitoring instance with API (non-blocking, only if API key is configured)
3098+
// Register monitoring instance with API (only if API key is configured).
3099+
// Console-provisioned installs pass --instance-id (or PGAI_INSTANCE_ID):
3100+
// the platform then ADOPTS the provisioned instance and tells us its real
3101+
// project, which the reporter must upload to — so that path is awaited
3102+
// and persisted; the legacy self-registration stays fire-and-forget
3103+
// (issue platform-all#311).
30133104
if (apiKey && !opts.demo) {
30143105
const projectName = opts.project || "postgres-ai-monitoring";
3015-
registerMonitoringInstance(apiKey, projectName, {
3016-
apiBaseUrl: globalOpts.apiBaseUrl,
3017-
debug: !!process.env.DEBUG,
3018-
});
3106+
const instanceId = opts.instanceId || process.env.PGAI_INSTANCE_ID;
3107+
if (instanceId) {
3108+
const reg = await registerMonitoringInstance(apiKey, projectName, {
3109+
apiBaseUrl: globalOpts.apiBaseUrl,
3110+
debug: !!process.env.DEBUG,
3111+
instanceId,
3112+
});
3113+
const adoptedProject = resolveAdoptedProject(reg);
3114+
if (adoptedProject != null) {
3115+
// Point the reporter at the adopted instance's project so checkup
3116+
// uploads land next to the rest of this instance's health data.
3117+
updatePgwatchConfig(path.resolve(projectDir, ".pgwatch-config"), {
3118+
project_name: adoptedProject,
3119+
});
3120+
// `created` distinguishes a fresh self-registration from adopting an
3121+
// existing provisioned row; with an instance_id we expect adoption.
3122+
const verb = reg?.created ? "Registered" : "Adopted";
3123+
console.log(`✓ ${verb} monitoring instance (project: ${adoptedProject})\n`);
3124+
} else if (reg) {
3125+
// Request succeeded but carried no usable project field — don't claim
3126+
// adoption, but don't report a hard failure either (no re-run needed).
3127+
console.error(
3128+
`⚠ Adopted provisioned instance ${instanceId} but the platform returned no project — reports will use project '${projectName}'`
3129+
);
3130+
} else {
3131+
console.error(
3132+
`⚠ Could not adopt provisioned instance ${instanceId} — reports will use project '${projectName}' until 'postgresai mon local-install' is re-run`
3133+
);
3134+
}
3135+
} else {
3136+
void registerMonitoringInstance(apiKey, projectName, {
3137+
apiBaseUrl: globalOpts.apiBaseUrl,
3138+
debug: !!process.env.DEBUG,
3139+
});
3140+
}
30193141
}
30203142

30213143
// Final summary
@@ -5309,3 +5431,4 @@ if (import.meta.main) {
53095431
// Exported for unit tests (the CLI surface above is unaffected; these are the
53105432
// same functions used by the `mon` commands).
53115433
export { refreshBundledComposeIfStale, readDeployedTag, isValidComposeYaml };
5434+
export { registerMonitoringInstance, resolveAdoptedProject, type MonitoringRegistration };

0 commit comments

Comments
 (0)