postgresai 0.16.0-rc.0 → 0.16.0-rc.2
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/bin/postgres-ai.ts +98 -7
- package/dist/bin/postgres-ai.js +7631 -7408
- package/lib/aas-onboard.ts +251 -0
- package/package.json +1 -1
- package/test/aas-onboard.test.ts +301 -0
- package/test/monitoring.test.ts +54 -3
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
import { addInstanceToFile, buildInstance } from "../lib/instances";
|
|
6
|
+
import {
|
|
7
|
+
parseVcpus,
|
|
8
|
+
resolveAasLabels,
|
|
9
|
+
registerAasCollection,
|
|
10
|
+
} from "../lib/aas-onboard";
|
|
11
|
+
|
|
12
|
+
/** Minimal Response-like stub for mocking fetch. */
|
|
13
|
+
function res(ok: boolean, status: number, jsonBody: unknown, textBody = ""): Response {
|
|
14
|
+
return {
|
|
15
|
+
ok,
|
|
16
|
+
status,
|
|
17
|
+
json: async () => jsonBody,
|
|
18
|
+
text: async () => textBody,
|
|
19
|
+
} as unknown as Response;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("parseVcpus", () => {
|
|
23
|
+
test("non-positive / junk → 0 (the 'unknown' fallback)", () => {
|
|
24
|
+
expect(parseVcpus(undefined)).toBe(0);
|
|
25
|
+
expect(parseVcpus(null)).toBe(0);
|
|
26
|
+
expect(parseVcpus("")).toBe(0);
|
|
27
|
+
expect(parseVcpus("0")).toBe(0);
|
|
28
|
+
expect(parseVcpus("-4")).toBe(0);
|
|
29
|
+
expect(parseVcpus("abc")).toBe(0);
|
|
30
|
+
});
|
|
31
|
+
test("positive values → integer", () => {
|
|
32
|
+
expect(parseVcpus("16")).toBe(16);
|
|
33
|
+
expect(parseVcpus(8)).toBe(8);
|
|
34
|
+
expect(parseVcpus("12.9")).toBe(12);
|
|
35
|
+
expect(parseVcpus(" 4 ")).toBe(4);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("resolveAasLabels", () => {
|
|
40
|
+
let dir: string;
|
|
41
|
+
let file: string;
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), "aas-labels-"));
|
|
44
|
+
file = path.join(dir, "instances.yml");
|
|
45
|
+
});
|
|
46
|
+
afterEach(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
47
|
+
|
|
48
|
+
test("single enabled target → its (cluster, node_name) from custom_tags", () => {
|
|
49
|
+
addInstanceToFile(file, buildInstance("appdb", "postgresql://u@h:5432/db"));
|
|
50
|
+
expect(resolveAasLabels(file)).toEqual({ cluster: "default", node: "appdb" });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("no targets → null", () => {
|
|
54
|
+
fs.writeFileSync(file, "# empty\n");
|
|
55
|
+
expect(resolveAasLabels(file)).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("more than one enabled target → null (cannot disambiguate)", () => {
|
|
59
|
+
addInstanceToFile(file, buildInstance("a", "postgresql://u@h:5432/a"));
|
|
60
|
+
addInstanceToFile(file, buildInstance("b", "postgresql://u@h:5432/b"));
|
|
61
|
+
expect(resolveAasLabels(file)).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("missing file → null (no throw)", () => {
|
|
65
|
+
expect(resolveAasLabels(path.join(dir, "nope.yml"))).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("registerAasCollection", () => {
|
|
70
|
+
let dir: string;
|
|
71
|
+
let instancesPath: string;
|
|
72
|
+
let fetchSpy: ReturnType<typeof spyOn>;
|
|
73
|
+
let calls: Array<{ url: string; method: string; body?: string }>;
|
|
74
|
+
|
|
75
|
+
// Route a fetch by URL+method to canned Grafana/RPC responses. Options let a
|
|
76
|
+
// test exercise the existing-SA branch, datasource ambiguity, a keyless mint,
|
|
77
|
+
// and RPC success/failure.
|
|
78
|
+
function installFetch(opts: {
|
|
79
|
+
rpc?: { ok: boolean; status: number; text?: string };
|
|
80
|
+
existingSa?: boolean; // search finds an existing pgai-aas-collect SA
|
|
81
|
+
prometheusCount?: number; // # of prometheus-typed datasources (default 1)
|
|
82
|
+
mintKey?: string | null; // token .key; null => mint returns no key
|
|
83
|
+
} = {}) {
|
|
84
|
+
const rpc = opts.rpc ?? { ok: true, status: 200 };
|
|
85
|
+
const existingSa = opts.existingSa ?? false;
|
|
86
|
+
const promCount = opts.prometheusCount ?? 1;
|
|
87
|
+
const mintKey = opts.mintKey === undefined ? "glsa_mock_token_xyz" : opts.mintKey;
|
|
88
|
+
calls = [];
|
|
89
|
+
fetchSpy = spyOn(globalThis, "fetch").mockImplementation((async (input: unknown, init?: { method?: string; body?: string }) => {
|
|
90
|
+
const url = String(input);
|
|
91
|
+
const method = (init?.method || "GET").toUpperCase();
|
|
92
|
+
calls.push({ url, method, body: init?.body });
|
|
93
|
+
if (url.includes("/api/serviceaccounts/search"))
|
|
94
|
+
return res(true, 200, existingSa ? { serviceAccounts: [{ id: 99, name: "pgai-aas-collect" }] } : { serviceAccounts: [] });
|
|
95
|
+
if (url.match(/\/tokens$/) && method === "POST") return res(true, 200, mintKey === null ? {} : { key: mintKey });
|
|
96
|
+
if (url.endsWith("/api/serviceaccounts") && method === "POST") return res(true, 201, { id: 42, name: "pgai-aas-collect" });
|
|
97
|
+
if (url.includes("/api/datasources")) {
|
|
98
|
+
const dss: Array<Record<string, unknown>> = [];
|
|
99
|
+
for (let i = 0; i < promCount; i++) dss.push({ id: 8 + i, uid: `prom${i}`, type: "prometheus" });
|
|
100
|
+
dss.push({ id: 3, uid: "loki1", type: "loki" });
|
|
101
|
+
return res(true, 200, dss);
|
|
102
|
+
}
|
|
103
|
+
if (url.includes("/rpc/monitoring_instance_aas_register")) return res(rpc.ok, rpc.status, {}, rpc.text || "");
|
|
104
|
+
return res(false, 404, {});
|
|
105
|
+
}) as unknown as typeof fetch);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
beforeEach(() => {
|
|
109
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), "aas-reg-"));
|
|
110
|
+
instancesPath = path.join(dir, "instances.yml");
|
|
111
|
+
addInstanceToFile(instancesPath, buildInstance("appdb", "postgresql://u@h:5432/db"));
|
|
112
|
+
});
|
|
113
|
+
afterEach(() => {
|
|
114
|
+
fetchSpy?.mockRestore();
|
|
115
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("happy path: mints SA, resolves datasource, POSTs the RPC with the right body", async () => {
|
|
119
|
+
installFetch();
|
|
120
|
+
const r = await registerAasCollection("apikey-1", "inst-123", {
|
|
121
|
+
grafanaPassword: "pw",
|
|
122
|
+
instancesPath,
|
|
123
|
+
vcpus: 16,
|
|
124
|
+
apiBaseUrl: "https://api.test",
|
|
125
|
+
});
|
|
126
|
+
expect(r.ok).toBe(true);
|
|
127
|
+
|
|
128
|
+
const rpc = calls.find((c) => c.url.includes("/rpc/monitoring_instance_aas_register"));
|
|
129
|
+
expect(rpc).toBeDefined();
|
|
130
|
+
expect(rpc!.url).toBe("https://api.test/rpc/monitoring_instance_aas_register");
|
|
131
|
+
const body = JSON.parse(rpc!.body!);
|
|
132
|
+
expect(body).toMatchObject({
|
|
133
|
+
api_token: "apikey-1",
|
|
134
|
+
instance_id: "inst-123",
|
|
135
|
+
sa_token: "glsa_mock_token_xyz",
|
|
136
|
+
cluster_name: "default",
|
|
137
|
+
node_name: "appdb",
|
|
138
|
+
vcpus: 16,
|
|
139
|
+
datasource_id: 8, // the prometheus one, not loki
|
|
140
|
+
});
|
|
141
|
+
// a fresh SA was created (search found none) and a token minted on its id.
|
|
142
|
+
expect(calls.some((c) => c.url.endsWith("/api/serviceaccounts") && c.method === "POST")).toBe(true);
|
|
143
|
+
expect(calls.some((c) => c.url.match(/\/serviceaccounts\/42\/tokens$/) && c.method === "POST")).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("platform error → ok:false, reason carries the status (best-effort, no throw)", async () => {
|
|
147
|
+
installFetch({ rpc: { ok: false, status: 403, text: "forbidden" } });
|
|
148
|
+
const r = await registerAasCollection("apikey-1", "inst-123", {
|
|
149
|
+
grafanaPassword: "pw",
|
|
150
|
+
instancesPath,
|
|
151
|
+
vcpus: 16,
|
|
152
|
+
apiBaseUrl: "https://api.test",
|
|
153
|
+
});
|
|
154
|
+
expect(r.ok).toBe(false);
|
|
155
|
+
expect(r.reason).toContain("403");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("no resolvable target → ok:false and NO outbound calls (labels checked first)", async () => {
|
|
159
|
+
installFetch();
|
|
160
|
+
const empty = path.join(dir, "empty.yml");
|
|
161
|
+
fs.writeFileSync(empty, "# none\n");
|
|
162
|
+
const r = await registerAasCollection("apikey-1", "inst-123", {
|
|
163
|
+
grafanaPassword: "pw",
|
|
164
|
+
instancesPath: empty,
|
|
165
|
+
vcpus: 16,
|
|
166
|
+
apiBaseUrl: "https://api.test",
|
|
167
|
+
});
|
|
168
|
+
expect(r.ok).toBe(false);
|
|
169
|
+
expect(r.reason).toContain("cluster");
|
|
170
|
+
expect(calls.length).toBe(0); // bailed before any HTTP
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("missing api key / instance id → ok:false, no calls", async () => {
|
|
174
|
+
installFetch();
|
|
175
|
+
const r = await registerAasCollection("", "inst-123", {
|
|
176
|
+
grafanaPassword: "pw",
|
|
177
|
+
instancesPath,
|
|
178
|
+
vcpus: 0,
|
|
179
|
+
apiBaseUrl: "https://api.test",
|
|
180
|
+
});
|
|
181
|
+
expect(r.ok).toBe(false);
|
|
182
|
+
expect(calls.length).toBe(0);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("existing service account is reused (no create), token minted on its id", async () => {
|
|
186
|
+
installFetch({ existingSa: true });
|
|
187
|
+
const r = await registerAasCollection("apikey-1", "inst-123", {
|
|
188
|
+
grafanaPassword: "pw", instancesPath, vcpus: 8, apiBaseUrl: "https://api.test",
|
|
189
|
+
});
|
|
190
|
+
expect(r.ok).toBe(true);
|
|
191
|
+
expect(calls.some((c) => c.url.endsWith("/api/serviceaccounts") && c.method === "POST")).toBe(false);
|
|
192
|
+
expect(calls.some((c) => c.url.match(/\/serviceaccounts\/99\/tokens$/) && c.method === "POST")).toBe(true);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("absent or ambiguous (>1) prometheus datasource → ok:false, no RPC call", async () => {
|
|
196
|
+
for (const n of [0, 2]) {
|
|
197
|
+
fetchSpy?.mockRestore();
|
|
198
|
+
installFetch({ prometheusCount: n });
|
|
199
|
+
const r = await registerAasCollection("apikey-1", "inst-123", {
|
|
200
|
+
grafanaPassword: "pw", instancesPath, vcpus: 8, apiBaseUrl: "https://api.test",
|
|
201
|
+
// 0/>1 is a definitive skip; cap the readiness retry so the test stays fast.
|
|
202
|
+
datasourceMaxAttempts: 2, datasourceRetryDelayMs: 0,
|
|
203
|
+
});
|
|
204
|
+
expect(r.ok).toBe(false);
|
|
205
|
+
expect(r.reason).toContain("datasource");
|
|
206
|
+
expect(calls.some((c) => c.url.includes("/rpc/monitoring_instance_aas_register"))).toBe(false);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("polls the datasource until Grafana is ready, then registers", async () => {
|
|
211
|
+
// Grafana isn't ready on the first probes (no prometheus datasource yet),
|
|
212
|
+
// then it provisions — the readiness retry must keep going and then succeed.
|
|
213
|
+
let dsProbes = 0;
|
|
214
|
+
calls = [];
|
|
215
|
+
fetchSpy = spyOn(globalThis, "fetch").mockImplementation((async (input: unknown, init?: { method?: string; body?: string }) => {
|
|
216
|
+
const url = String(input);
|
|
217
|
+
const method = (init?.method || "GET").toUpperCase();
|
|
218
|
+
calls.push({ url, method, body: init?.body });
|
|
219
|
+
if (url.includes("/api/serviceaccounts/search")) return res(true, 200, { serviceAccounts: [] });
|
|
220
|
+
if (url.match(/\/tokens$/) && method === "POST") return res(true, 200, { key: "glsa_mock" });
|
|
221
|
+
if (url.endsWith("/api/serviceaccounts") && method === "POST") return res(true, 201, { id: 42 });
|
|
222
|
+
if (url.includes("/api/datasources")) {
|
|
223
|
+
dsProbes++;
|
|
224
|
+
return dsProbes < 3
|
|
225
|
+
? res(true, 200, [{ id: 3, type: "loki" }]) // not ready yet
|
|
226
|
+
: res(true, 200, [{ id: 8, type: "prometheus" }, { id: 3, type: "loki" }]);
|
|
227
|
+
}
|
|
228
|
+
if (url.includes("/rpc/monitoring_instance_aas_register")) return res(true, 200, {});
|
|
229
|
+
return res(false, 404, {});
|
|
230
|
+
}) as unknown as typeof fetch);
|
|
231
|
+
|
|
232
|
+
const r = await registerAasCollection("apikey-1", "inst-123", {
|
|
233
|
+
grafanaPassword: "pw", instancesPath, vcpus: 8, apiBaseUrl: "https://api.test",
|
|
234
|
+
datasourceMaxAttempts: 6, datasourceRetryDelayMs: 0,
|
|
235
|
+
});
|
|
236
|
+
expect(r.ok).toBe(true);
|
|
237
|
+
expect(dsProbes).toBeGreaterThanOrEqual(3); // kept polling past the not-ready probes
|
|
238
|
+
const rpc = calls.find((c) => c.url.includes("/rpc/monitoring_instance_aas_register"));
|
|
239
|
+
expect(rpc).toBeDefined();
|
|
240
|
+
expect(JSON.parse(rpc!.body!).datasource_id).toBe(8);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test(">1 prometheus datasource is a definitive skip: one probe, no retry", async () => {
|
|
244
|
+
// The >1 case is permanent (the datasource count only grows), so the
|
|
245
|
+
// readiness loop must bail after a single probe, not burn its whole budget.
|
|
246
|
+
let dsProbes = 0;
|
|
247
|
+
calls = [];
|
|
248
|
+
fetchSpy = spyOn(globalThis, "fetch").mockImplementation((async (input: unknown, init?: { method?: string; body?: string }) => {
|
|
249
|
+
const url = String(input);
|
|
250
|
+
const method = (init?.method || "GET").toUpperCase();
|
|
251
|
+
calls.push({ url, method, body: init?.body });
|
|
252
|
+
if (url.includes("/api/datasources")) {
|
|
253
|
+
dsProbes++;
|
|
254
|
+
return res(true, 200, [{ id: 8, type: "prometheus" }, { id: 9, type: "prometheus" }, { id: 3, type: "loki" }]);
|
|
255
|
+
}
|
|
256
|
+
return res(false, 404, {});
|
|
257
|
+
}) as unknown as typeof fetch);
|
|
258
|
+
|
|
259
|
+
const r = await registerAasCollection("apikey-1", "inst-123", {
|
|
260
|
+
grafanaPassword: "pw", instancesPath, vcpus: 8, apiBaseUrl: "https://api.test",
|
|
261
|
+
datasourceMaxAttempts: 5, datasourceRetryDelayMs: 0,
|
|
262
|
+
});
|
|
263
|
+
expect(r.ok).toBe(false);
|
|
264
|
+
expect(r.reason).toContain("datasource");
|
|
265
|
+
expect(dsProbes).toBe(1); // bailed after one probe; did NOT retry 5x
|
|
266
|
+
expect(calls.some((c) => c.url.includes("/rpc/monitoring_instance_aas_register"))).toBe(false);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("never-ready datasource: polls exactly maxAttempts times, then ok:false", async () => {
|
|
270
|
+
// Bounds the readiness loop: a never-appearing datasource must probe exactly
|
|
271
|
+
// maxAttempts times (N probes, N-1 sleeps) and then give up — not loop forever.
|
|
272
|
+
let dsProbes = 0;
|
|
273
|
+
calls = [];
|
|
274
|
+
fetchSpy = spyOn(globalThis, "fetch").mockImplementation((async (input: unknown, init?: { method?: string; body?: string }) => {
|
|
275
|
+
const url = String(input);
|
|
276
|
+
const method = (init?.method || "GET").toUpperCase();
|
|
277
|
+
calls.push({ url, method, body: init?.body });
|
|
278
|
+
if (url.includes("/api/datasources")) { dsProbes++; return res(true, 200, [{ id: 3, type: "loki" }]); } // never a prometheus
|
|
279
|
+
return res(false, 404, {});
|
|
280
|
+
}) as unknown as typeof fetch);
|
|
281
|
+
|
|
282
|
+
const r = await registerAasCollection("apikey-1", "inst-123", {
|
|
283
|
+
grafanaPassword: "pw", instancesPath, vcpus: 8, apiBaseUrl: "https://api.test",
|
|
284
|
+
datasourceMaxAttempts: 3, datasourceRetryDelayMs: 0,
|
|
285
|
+
});
|
|
286
|
+
expect(r.ok).toBe(false);
|
|
287
|
+
expect(r.reason).toContain("datasource");
|
|
288
|
+
expect(dsProbes).toBe(3); // bounded: exactly maxAttempts probes
|
|
289
|
+
expect(calls.some((c) => c.url.includes("/rpc/monitoring_instance_aas_register"))).toBe(false);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("mint returning no key → ok:false, no RPC call", async () => {
|
|
293
|
+
installFetch({ mintKey: null });
|
|
294
|
+
const r = await registerAasCollection("apikey-1", "inst-123", {
|
|
295
|
+
grafanaPassword: "pw", instancesPath, vcpus: 8, apiBaseUrl: "https://api.test",
|
|
296
|
+
});
|
|
297
|
+
expect(r.ok).toBe(false);
|
|
298
|
+
expect(r.reason).toContain("service-account token");
|
|
299
|
+
expect(calls.some((c) => c.url.includes("/rpc/monitoring_instance_aas_register"))).toBe(false);
|
|
300
|
+
});
|
|
301
|
+
});
|
package/test/monitoring.test.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
import {
|
|
20
20
|
registerMonitoringInstance,
|
|
21
21
|
resolveAdoptedProject,
|
|
22
|
+
planMonitoringRegistration,
|
|
22
23
|
} from "../bin/postgres-ai";
|
|
23
24
|
|
|
24
25
|
/**
|
|
@@ -241,12 +242,13 @@ describe("registerMonitoringInstance", () => {
|
|
|
241
242
|
});
|
|
242
243
|
|
|
243
244
|
// Issue platform-all#311: console-provisioned installs pass instance_id so
|
|
244
|
-
// the platform adopts the provisioned instance
|
|
245
|
-
//
|
|
245
|
+
// the platform adopts the provisioned instance and returns its real project
|
|
246
|
+
// — the CLI sends NO project_name on the adopt path (the hardcoded
|
|
247
|
+
// "postgres-ai-monitoring" default was removed).
|
|
246
248
|
test("includes instance_id in body when adopting a provisioned instance", async () => {
|
|
247
249
|
const instanceId = "019eb300-3f2a-7a75-b54d-4f10572b25b8";
|
|
248
250
|
|
|
249
|
-
await registerMonitoringInstance("key",
|
|
251
|
+
await registerMonitoringInstance("key", undefined, opts({ instanceId }));
|
|
250
252
|
|
|
251
253
|
const body = JSON.parse(fetchCalls[0].options.body as string);
|
|
252
254
|
expect(body.instance_id).toBe(instanceId);
|
|
@@ -255,6 +257,22 @@ describe("registerMonitoringInstance", () => {
|
|
|
255
257
|
expect(headers["instance-id"]).toBeUndefined();
|
|
256
258
|
});
|
|
257
259
|
|
|
260
|
+
test("omits project_name from the body when undefined (adopt path)", async () => {
|
|
261
|
+
await registerMonitoringInstance("key", undefined, opts({ instanceId: "i" }));
|
|
262
|
+
|
|
263
|
+
const body = JSON.parse(fetchCalls[0].options.body as string);
|
|
264
|
+
// The adopt path sends no name; the platform returns the real project.
|
|
265
|
+
expect("project_name" in body).toBe(false);
|
|
266
|
+
expect(body.api_token).toBe("key");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("omits project_name from the body when empty/whitespace", async () => {
|
|
270
|
+
await registerMonitoringInstance("key", " ", opts({ instanceId: "i" }));
|
|
271
|
+
|
|
272
|
+
const body = JSON.parse(fetchCalls[0].options.body as string);
|
|
273
|
+
expect("project_name" in body).toBe(false);
|
|
274
|
+
});
|
|
275
|
+
|
|
258
276
|
test("omits instance_id from the body for legacy self-registration", async () => {
|
|
259
277
|
await registerMonitoringInstance("key", "my-project", opts());
|
|
260
278
|
|
|
@@ -262,6 +280,8 @@ describe("registerMonitoringInstance", () => {
|
|
|
262
280
|
// PostgREST matches the 3-arg function via its default — the key must be
|
|
263
281
|
// ABSENT (not null) so legacy CLIs and the new one hit the same overload.
|
|
264
282
|
expect("instance_id" in body).toBe(false);
|
|
283
|
+
// A real project name still rides in the body for legacy registration.
|
|
284
|
+
expect(body.project_name).toBe("my-project");
|
|
265
285
|
});
|
|
266
286
|
|
|
267
287
|
test("a 200 with {project_id, project_name} returns a populated result", async () => {
|
|
@@ -323,6 +343,37 @@ describe("registerMonitoringInstance", () => {
|
|
|
323
343
|
});
|
|
324
344
|
});
|
|
325
345
|
|
|
346
|
+
describe("planMonitoringRegistration — mon local-install registration decision", () => {
|
|
347
|
+
test("instance id present → adopt, no project name required", () => {
|
|
348
|
+
const plan = planMonitoringRegistration({ instanceId: "i-123" });
|
|
349
|
+
expect(plan.kind).toBe("adopt");
|
|
350
|
+
expect(plan.projectName).toBeUndefined();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("instance id present + project → adopt, carries the trimmed name", () => {
|
|
354
|
+
const plan = planMonitoringRegistration({ instanceId: "i-123", project: " prod-db " });
|
|
355
|
+
expect(plan.kind).toBe("adopt");
|
|
356
|
+
expect(plan.projectName).toBe("prod-db");
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("no instance id + no project → error-missing-project (exit 1 path)", () => {
|
|
360
|
+
const plan = planMonitoringRegistration({});
|
|
361
|
+
expect(plan.kind).toBe("error-missing-project");
|
|
362
|
+
expect(plan.projectName).toBeUndefined();
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test("no instance id + empty/whitespace project → error-missing-project", () => {
|
|
366
|
+
expect(planMonitoringRegistration({ project: "" }).kind).toBe("error-missing-project");
|
|
367
|
+
expect(planMonitoringRegistration({ project: " " }).kind).toBe("error-missing-project");
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("no instance id + real project → legacy self-register with the trimmed name", () => {
|
|
371
|
+
const plan = planMonitoringRegistration({ project: " my-project " });
|
|
372
|
+
expect(plan.kind).toBe("self-register");
|
|
373
|
+
expect(plan.projectName).toBe("my-project");
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
326
377
|
describe("resolveAdoptedProject — what gets persisted to .pgwatch-config", () => {
|
|
327
378
|
test("prefers the numeric project_id over the name (survives renames)", () => {
|
|
328
379
|
expect(resolveAdoptedProject({ projectId: 42, projectName: "prod-db" })).toBe("42");
|