Skip to content

Commit 4089de6

Browse files
committed
feat(cli): mon local-install arms hands-off AAS auto-collection (platform-all#338)
1 parent 7999304 commit 4089de6

4 files changed

Lines changed: 588 additions & 1 deletion

File tree

cli/bin/postgres-ai.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { startMcpServer } from "../lib/mcp-server";
1414
import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue, createIssue, updateIssue, updateIssueComment, fetchActionItem, fetchActionItems, createActionItem, updateActionItem, type ConfigChange } from "../lib/issues";
1515
import { fetchReports, fetchAllReports, fetchReportFiles, fetchReportFileData, renderMarkdownForTerminal, parseFlexibleDate } from "../lib/reports";
1616
import { resolveBaseUrls } from "../lib/util";
17+
import { registerAasCollection, parseVcpus } from "../lib/aas-onboard";
1718
import { uploadFile, downloadFile, buildMarkdownLink, uploadAttachments, appendAttachmentsToContent } from "../lib/storage";
1819
import { applyInitPlan, applyUninitPlan, buildInitPlan, buildUninitPlan, checkCurrentUserPermissions, connectWithSslFallback, DEFAULT_MONITORING_USER, formatPermissionCheckMessages, KNOWN_PROVIDERS, redactPasswordsInSql, resolveAdminConnection, resolveMonitoringPassword, validateProvider, verifyInitSetup } from "../lib/init";
1920
import { SupabaseClient, resolveSupabaseConfig, extractProjectRefFromUrl, applyInitPlanViaSupabase, verifyInitSetupViaSupabase, fetchPoolerDatabaseUrl, type PgCompatibleError } from "../lib/supabase";
@@ -2712,8 +2713,12 @@ mon
27122713
"--instance-id <uuid>",
27132714
"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)"
27142715
)
2716+
.option(
2717+
"--vcpus <n>",
2718+
"source DB vCPU count used for AAS zone thresholds (set automatically by the provisioning flow; PGAI_VCPUS env also works). Omit or 0 = unknown — AAS collection stays off until a real value is set."
2719+
)
27152720
.option("-y, --yes", "accept all defaults and skip interactive prompts", false)
2716-
.action(async (opts: { demo: boolean; apiKey?: string; dbUrl?: string; tag?: string; project?: string; instanceId?: string; yes: boolean }) => {
2721+
.action(async (opts: { demo: boolean; apiKey?: string; dbUrl?: string; tag?: string; project?: string; instanceId?: string; vcpus?: string; yes: boolean }) => {
27172722
// Get apiKey from global program options (--api-key is defined globally)
27182723
// This is needed because Commander.js routes --api-key to the global option, not the subcommand's option
27192724
const globalOpts = program.opts<CliOptions>();
@@ -3162,6 +3167,27 @@ mon
31623167
`⚠ Could not adopt provisioned instance ${instanceId} — reports will use project '${projectName}' until 'postgresai mon local-install' is re-run`
31633168
);
31643169
}
3170+
3171+
// Best-effort: arm hands-off AAS auto-collection for this adopted
3172+
// instance. Mints a Grafana Viewer SA on the LOCAL Grafana, resolves
3173+
// the datasource id + (cluster, node_name) labels from the pgwatch
3174+
// config we wrote, and hands the platform a finished token via the
3175+
// API-token RPC (v1.monitoring_instance_aas_register). Never fatal —
3176+
// it can be enabled later by re-running local-install.
3177+
const aas = await registerAasCollection(apiKey, instanceId, {
3178+
grafanaPassword,
3179+
instancesPath,
3180+
vcpus: parseVcpus(opts.vcpus ?? process.env.PGAI_VCPUS),
3181+
apiBaseUrl: globalOpts.apiBaseUrl,
3182+
debug: !!process.env.DEBUG,
3183+
});
3184+
if (aas.ok) {
3185+
console.log("✓ AAS auto-collection registered\n");
3186+
} else {
3187+
console.error(
3188+
`⚠ AAS auto-collection not registered (${aas.reason}); it can be enabled later by re-running 'postgresai mon local-install'\n`
3189+
);
3190+
}
31653191
} else {
31663192
void registerMonitoringInstance(apiKey, projectName, {
31673193
apiBaseUrl: globalOpts.apiBaseUrl,

cli/lib/aas-onboard.ts

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
/**
2+
* Hands-off AAS auto-onboarding for `mon local-install` (platform-all #338).
3+
*
4+
* After the monitoring stack is up and the instance is adopted, the CLI arms
5+
* AAS collection without an operator step:
6+
* 1. mint a `pgai-aas-collect` Grafana Viewer service-account token on the
7+
* LOCAL Grafana (the CLI holds the admin password),
8+
* 2. resolve the numeric Prometheus datasource id,
9+
* 3. read the (cluster, node_name) labels straight from the pgwatch target
10+
* config the CLI itself wrote (buildInstance's custom_tags) — no live
11+
* series query, so no waiters>0 timing dependency,
12+
* 4. hand all of it to the platform via the API-token RPC
13+
* v1.monitoring_instance_aas_register, which encrypts the token and stores
14+
* the AAS state keys (it makes no outbound Grafana call of its own).
15+
*
16+
* Best-effort, exactly like registerMonitoringInstance: never throws, returns a
17+
* result the caller logs. The plaintext SA token only ever lives in locals.
18+
*/
19+
20+
import { loadInstances } from "./instances";
21+
import { resolveBaseUrls } from "./util";
22+
23+
const SA_NAME = "pgai-aas-collect";
24+
25+
/** Local Grafana base URL (published on the monitoring host). Overridable for tests/odd setups. */
26+
function grafanaBaseUrl(): string {
27+
return (process.env.PGAI_GRAFANA_LOCAL_URL || "http://localhost:3000").replace(/\/+$/, "");
28+
}
29+
30+
function grafanaAdminUser(): string {
31+
// The monitoring stack's compose hardcodes the Grafana admin user to
32+
// "monitor" (GF_SECURITY_ADMIN_USER: monitor), so default to that rather than
33+
// Grafana's stock "admin" — otherwise AAS arming logs in as the wrong user
34+
// and every datasource lookup 401s. An explicit env override still wins.
35+
return process.env.GF_SECURITY_ADMIN_USER || "monitor";
36+
}
37+
38+
/** Parse a vcpus input (flag/env) to a non-negative integer; 0 = "unknown" fallback. */
39+
export function parseVcpus(raw: string | number | undefined | null): number {
40+
if (raw === undefined || raw === null || raw === "") return 0;
41+
const n = typeof raw === "number" ? raw : parseInt(String(raw).trim(), 10);
42+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
43+
}
44+
45+
/**
46+
* Read the single enabled target's (cluster, node_name) from the pgwatch
47+
* instances file. Returns null when it can't be determined unambiguously
48+
* (0 or >1 enabled targets) — AAS onboards exactly one (cluster, node) pair.
49+
*/
50+
export function resolveAasLabels(instancesPath: string): { cluster: string; node: string } | null {
51+
let instances;
52+
try {
53+
instances = loadInstances(instancesPath);
54+
} catch {
55+
return null;
56+
}
57+
const enabled = instances.filter((i) => i.is_enabled !== false);
58+
if (enabled.length !== 1) return null;
59+
const tags = (enabled[0].custom_tags || {}) as Record<string, unknown>;
60+
const cluster = typeof tags.cluster === "string" && tags.cluster ? tags.cluster : "default";
61+
const node = typeof tags.node_name === "string" && tags.node_name ? tags.node_name : enabled[0].name;
62+
if (!cluster || !node) return null;
63+
return { cluster, node };
64+
}
65+
66+
async function grafanaApi(
67+
method: string,
68+
pathPart: string,
69+
adminPassword: string,
70+
body?: unknown
71+
): Promise<Response> {
72+
const auth = Buffer.from(`${grafanaAdminUser()}:${adminPassword}`).toString("base64");
73+
return fetch(`${grafanaBaseUrl()}${pathPart}`, {
74+
method,
75+
headers: { "Content-Type": "application/json", Authorization: `Basic ${auth}` },
76+
body: body === undefined ? undefined : JSON.stringify(body),
77+
});
78+
}
79+
80+
/**
81+
* Find-or-create the pgai-aas-collect Viewer service account on the local
82+
* Grafana and mint a fresh glsa_ token. Returns the token or null on any failure.
83+
*
84+
* We deliberately do NOT prune prior tokens: deleting them here is racy — a
85+
* concurrent or repeated install could delete the token the platform currently
86+
* holds (stored encrypted), silently 401-ing collection until the next register.
87+
* The unique mint name already avoids 409s, and orphaned Viewer tokens are
88+
* benign; token hygiene is left to a separate, non-racy mechanism.
89+
*/
90+
export async function mintAasServiceAccountToken(
91+
adminPassword: string,
92+
debug = false
93+
): Promise<string | null> {
94+
const log = (m: string) => debug && console.error(`Debug: AAS SA mint: ${m}`);
95+
try {
96+
let saId: number | null = null;
97+
98+
const search = await grafanaApi("GET", `/api/serviceaccounts/search?query=${SA_NAME}`, adminPassword);
99+
if (search.ok) {
100+
const data = (await search.json().catch(() => null)) as { serviceAccounts?: Array<{ id?: unknown; name?: unknown }> } | null;
101+
const found = (data?.serviceAccounts || []).find((s) => s.name === SA_NAME);
102+
if (found && typeof found.id === "number") saId = found.id;
103+
}
104+
105+
if (saId == null) {
106+
const created = await grafanaApi("POST", "/api/serviceaccounts", adminPassword, { name: SA_NAME, role: "Viewer" });
107+
if (!created.ok) {
108+
log(`create SA failed: HTTP ${created.status}`);
109+
return null;
110+
}
111+
const cj = (await created.json().catch(() => null)) as { id?: unknown } | null;
112+
if (typeof cj?.id !== "number") return null;
113+
saId = cj.id;
114+
}
115+
116+
// Unique token name avoids a 409 on a pre-existing name (no prune needed).
117+
const mint = await grafanaApi("POST", `/api/serviceaccounts/${saId}/tokens`, adminPassword, {
118+
name: `aas-collect-${Date.now()}`,
119+
role: "Viewer",
120+
});
121+
if (!mint.ok) {
122+
log(`mint token failed: HTTP ${mint.status}`);
123+
return null;
124+
}
125+
const mj = (await mint.json().catch(() => null)) as { key?: unknown } | null;
126+
return typeof mj?.key === "string" ? mj.key : null;
127+
} catch (err) {
128+
log((err as Error).message);
129+
return null;
130+
}
131+
}
132+
133+
/**
134+
* Resolve the single Prometheus-typed datasource's numeric id on the local
135+
* Grafana. The monitoring stack's VictoriaMetrics datasource is type
136+
* "prometheus" (VM speaks PromQL), and the stack registers exactly one such
137+
* datasource — the same one the collector queries. 0 / API-not-ready → null
138+
* (a provisioning transient — the readiness loop retries); >1 → "ambiguous"
139+
* (a permanent misconfiguration — the loop stops at once), matching
140+
* v1.aas_onboard's >1 skip.
141+
*/
142+
export async function resolveDatasourceId(adminPassword: string, debug = false): Promise<number | "ambiguous" | null> {
143+
try {
144+
const res = await grafanaApi("GET", "/api/datasources", adminPassword);
145+
if (!res.ok) return null;
146+
const list = (await res.json().catch(() => [])) as Array<{ id?: unknown; type?: unknown }>;
147+
const prom = list.filter((d) => d.type === "prometheus");
148+
if (prom.length > 1) {
149+
// >1 is a permanent misconfiguration, not a provisioning transient: the
150+
// datasource count only grows as Grafana provisions, so retrying can never
151+
// resolve it. Signal a definitive skip so the readiness loop bails at once.
152+
if (debug) console.error(`Debug: AAS: ${prom.length} prometheus datasources (ambiguous); not retrying`);
153+
return "ambiguous";
154+
}
155+
if (prom.length === 0) {
156+
if (debug) console.error(`Debug: AAS: no prometheus datasource resolvable yet`);
157+
return null;
158+
}
159+
return typeof prom[0].id === "number" ? prom[0].id : null;
160+
} catch {
161+
return null;
162+
}
163+
}
164+
165+
export interface AasRegisterResult {
166+
ok: boolean;
167+
reason?: string;
168+
}
169+
170+
/**
171+
* Arm hands-off AAS collection for an adopted monitoring instance. Best-effort:
172+
* never throws; returns {ok:false, reason} on any failure so the caller can log
173+
* a non-fatal warning. Mirrors registerMonitoringInstance's API-call shape.
174+
*/
175+
export async function registerAasCollection(
176+
apiKey: string,
177+
instanceId: string,
178+
opts: {
179+
grafanaPassword: string;
180+
instancesPath: string;
181+
vcpus: number;
182+
apiBaseUrl?: string;
183+
debug?: boolean;
184+
fetchImpl?: typeof fetch;
185+
// Grafana-readiness polling for the datasource lookup (Grafana has just
186+
// been started by `compose up`). Defaults: 20 attempts × 3s.
187+
datasourceMaxAttempts?: number;
188+
datasourceRetryDelayMs?: number;
189+
}
190+
): Promise<AasRegisterResult> {
191+
const debug = !!opts.debug;
192+
try {
193+
if (!apiKey || !instanceId) return { ok: false, reason: "missing api key or instance id" };
194+
195+
const labels = resolveAasLabels(opts.instancesPath);
196+
if (!labels) return { ok: false, reason: "could not determine a single (cluster, node_name) target" };
197+
198+
// Grafana was just started by `compose up`; it needs time to create its
199+
// admin user, provision datasources, and serve its API. Querying too early
200+
// makes the datasource lookup fail transiently, so poll until it resolves
201+
// (best-effort, capped — the install never blocks on this).
202+
const maxAttempts = opts.datasourceMaxAttempts ?? 20;
203+
const retryDelayMs = opts.datasourceRetryDelayMs ?? 3000;
204+
let datasourceId: number | null = null;
205+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
206+
const resolved = await resolveDatasourceId(opts.grafanaPassword, debug);
207+
if (typeof resolved === "number") { datasourceId = resolved; break; }
208+
// "ambiguous" (>1 prometheus datasource) is permanent — retrying can't fix
209+
// it, so stop polling immediately instead of waiting out the whole budget.
210+
if (resolved === "ambiguous") break;
211+
if (attempt < maxAttempts) {
212+
if (debug) console.error(`Debug: AAS: datasource not resolvable yet (attempt ${attempt}/${maxAttempts}); waiting for Grafana…`);
213+
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
214+
}
215+
}
216+
if (datasourceId == null) return { ok: false, reason: "could not resolve the Prometheus datasource id" };
217+
218+
const saToken = await mintAasServiceAccountToken(opts.grafanaPassword, debug);
219+
if (!saToken) return { ok: false, reason: "could not mint a Grafana service-account token" };
220+
221+
const { apiBaseUrl } = resolveBaseUrls({ apiBaseUrl: opts.apiBaseUrl });
222+
const url = `${apiBaseUrl}/rpc/monitoring_instance_aas_register`;
223+
const doFetch = opts.fetchImpl || fetch;
224+
if (debug) console.error(`Debug: AAS: POST ${url} (cluster=${labels.cluster}, node=${labels.node}, vcpus=${opts.vcpus}, ds=${datasourceId})`);
225+
226+
const res = await doFetch(url, {
227+
method: "POST",
228+
headers: { "Content-Type": "application/json" },
229+
body: JSON.stringify({
230+
api_token: apiKey,
231+
instance_id: instanceId,
232+
sa_token: saToken,
233+
cluster_name: labels.cluster,
234+
node_name: labels.node,
235+
vcpus: opts.vcpus,
236+
datasource_id: datasourceId,
237+
}),
238+
});
239+
if (!res.ok) {
240+
// Log status only — never the response body: a platform could echo the
241+
// request payload (incl. sa_token) in an error body, which must not reach
242+
// the user's debug log.
243+
if (debug) console.error(`Debug: AAS register failed: HTTP ${res.status}`);
244+
return { ok: false, reason: `platform returned HTTP ${res.status}` };
245+
}
246+
return { ok: true };
247+
} catch (err) {
248+
if (debug) console.error(`Debug: AAS register error: ${(err as Error).message}`);
249+
return { ok: false, reason: (err as Error).message };
250+
}
251+
}

0 commit comments

Comments
 (0)