postgresai 0.15.0 → 0.16.0-dev.1
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 +210 -31
- package/dist/bin/postgres-ai.js +7749 -7248
- package/lib/aas-onboard.ts +251 -0
- 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/aas-onboard.test.ts +301 -0
- 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/test/monitoring.test.ts
CHANGED
|
@@ -16,6 +16,10 @@ import {
|
|
|
16
16
|
extractSslmode,
|
|
17
17
|
InstancesParseError,
|
|
18
18
|
} from "../lib/instances";
|
|
19
|
+
import {
|
|
20
|
+
registerMonitoringInstance,
|
|
21
|
+
resolveAdoptedProject,
|
|
22
|
+
} from "../bin/postgres-ai";
|
|
19
23
|
|
|
20
24
|
/**
|
|
21
25
|
* Test updatePgwatchConfig function behavior.
|
|
@@ -190,14 +194,20 @@ describe("updatePgwatchConfig", () => {
|
|
|
190
194
|
describe("registerMonitoringInstance", () => {
|
|
191
195
|
let originalFetch: typeof global.fetch;
|
|
192
196
|
let fetchCalls: Array<{ url: string; options: RequestInit }>;
|
|
197
|
+
// Each test sets `respond(call) => Response`; defaults to a 200 with no body.
|
|
198
|
+
let respond: (call: { url: string; options: RequestInit }) => Response;
|
|
199
|
+
const apiBaseUrl = "https://api.example.com";
|
|
200
|
+
const registerUrl = `${apiBaseUrl}/rpc/monitoring_instance_register`;
|
|
193
201
|
|
|
194
202
|
beforeEach(() => {
|
|
195
203
|
originalFetch = global.fetch;
|
|
196
204
|
fetchCalls = [];
|
|
197
|
-
|
|
205
|
+
respond = () => new Response(JSON.stringify({ project_id: 7 }), { status: 200 });
|
|
206
|
+
// Mock fetch to capture calls and return the test-configured response.
|
|
198
207
|
global.fetch = async (url: RequestInfo | URL, options?: RequestInit) => {
|
|
199
|
-
|
|
200
|
-
|
|
208
|
+
const call = { url: url.toString(), options: options || {} };
|
|
209
|
+
fetchCalls.push(call);
|
|
210
|
+
return respond(call);
|
|
201
211
|
};
|
|
202
212
|
});
|
|
203
213
|
|
|
@@ -205,72 +215,141 @@ describe("registerMonitoringInstance", () => {
|
|
|
205
215
|
global.fetch = originalFetch;
|
|
206
216
|
});
|
|
207
217
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
await fetch(`${apiBaseUrl}/rpc/monitoring_instance_register`, {
|
|
215
|
-
method: "POST",
|
|
216
|
-
headers: {
|
|
217
|
-
"Content-Type": "application/json",
|
|
218
|
-
},
|
|
219
|
-
body: JSON.stringify({
|
|
220
|
-
api_token: apiKey,
|
|
221
|
-
project_name: projectName,
|
|
222
|
-
}),
|
|
223
|
-
});
|
|
218
|
+
// retryDelayMs: 0 keeps the retry path instant under test.
|
|
219
|
+
const opts = (extra?: Record<string, unknown>) => ({ apiBaseUrl, retryDelayMs: 0, ...extra });
|
|
220
|
+
|
|
221
|
+
test("posts api_token + project_name in the body (never in headers), legacy mode", async () => {
|
|
222
|
+
await registerMonitoringInstance("secret-key-12345", "my-project", opts());
|
|
224
223
|
|
|
225
224
|
expect(fetchCalls.length).toBe(1);
|
|
226
|
-
expect(fetchCalls[0].url).toBe(
|
|
225
|
+
expect(fetchCalls[0].url).toBe(registerUrl);
|
|
227
226
|
expect(fetchCalls[0].options.method).toBe("POST");
|
|
228
227
|
|
|
229
228
|
const headers = fetchCalls[0].options.headers as Record<string, string>;
|
|
230
229
|
expect(headers["Content-Type"]).toBe("application/json");
|
|
231
|
-
//
|
|
230
|
+
// API key only in body, never in an access-token header (security review).
|
|
232
231
|
expect(headers["access-token"]).toBeUndefined();
|
|
233
232
|
|
|
234
233
|
const body = JSON.parse(fetchCalls[0].options.body as string);
|
|
235
|
-
expect(body.api_token).toBe("
|
|
234
|
+
expect(body.api_token).toBe("secret-key-12345");
|
|
236
235
|
expect(body.project_name).toBe("my-project");
|
|
237
236
|
});
|
|
238
237
|
|
|
239
|
-
test("
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
await fetch(`${apiBaseUrl}/rpc/monitoring_instance_register`, {
|
|
245
|
-
method: "POST",
|
|
246
|
-
headers: {
|
|
247
|
-
"Content-Type": "application/json",
|
|
248
|
-
},
|
|
249
|
-
body: JSON.stringify({
|
|
250
|
-
api_token: apiKey,
|
|
251
|
-
project_name: projectName,
|
|
252
|
-
}),
|
|
253
|
-
});
|
|
238
|
+
test("honors a custom apiBaseUrl for the endpoint path", async () => {
|
|
239
|
+
await registerMonitoringInstance("key", "proj", opts({ apiBaseUrl: "https://custom.api.com/v2" }));
|
|
240
|
+
expect(fetchCalls[0].url).toBe("https://custom.api.com/v2/rpc/monitoring_instance_register");
|
|
241
|
+
});
|
|
254
242
|
|
|
243
|
+
// Issue platform-all#311: console-provisioned installs pass instance_id so
|
|
244
|
+
// the platform adopts the provisioned instance instead of self-registering
|
|
245
|
+
// a duplicate under an auto-created "postgres-ai-monitoring" project.
|
|
246
|
+
test("includes instance_id in body when adopting a provisioned instance", async () => {
|
|
247
|
+
const instanceId = "019eb300-3f2a-7a75-b54d-4f10572b25b8";
|
|
248
|
+
|
|
249
|
+
await registerMonitoringInstance("key", "postgres-ai-monitoring", opts({ instanceId }));
|
|
250
|
+
|
|
251
|
+
const body = JSON.parse(fetchCalls[0].options.body as string);
|
|
252
|
+
expect(body.instance_id).toBe(instanceId);
|
|
253
|
+
// instance_id rides in the body next to api_token — never in headers.
|
|
255
254
|
const headers = fetchCalls[0].options.headers as Record<string, string>;
|
|
256
|
-
|
|
257
|
-
|
|
255
|
+
expect(headers["instance-id"]).toBeUndefined();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("omits instance_id from the body for legacy self-registration", async () => {
|
|
259
|
+
await registerMonitoringInstance("key", "my-project", opts());
|
|
258
260
|
|
|
259
|
-
// Verify token is in body
|
|
260
261
|
const body = JSON.parse(fetchCalls[0].options.body as string);
|
|
261
|
-
|
|
262
|
+
// PostgREST matches the 3-arg function via its default — the key must be
|
|
263
|
+
// ABSENT (not null) so legacy CLIs and the new one hit the same overload.
|
|
264
|
+
expect("instance_id" in body).toBe(false);
|
|
262
265
|
});
|
|
263
266
|
|
|
264
|
-
test("
|
|
265
|
-
|
|
267
|
+
test("a 200 with {project_id, project_name} returns a populated result", async () => {
|
|
268
|
+
respond = () => new Response(JSON.stringify({ project_id: 42, project_name: "prod-db", created: false }), { status: 200 });
|
|
266
269
|
|
|
267
|
-
await
|
|
268
|
-
method: "POST",
|
|
269
|
-
headers: { "Content-Type": "application/json" },
|
|
270
|
-
body: JSON.stringify({ api_token: "key", project_name: "proj" }),
|
|
271
|
-
});
|
|
270
|
+
const reg = await registerMonitoringInstance("key", "p", opts({ instanceId: "i" }));
|
|
272
271
|
|
|
273
|
-
expect(
|
|
272
|
+
expect(reg).toEqual({ instanceId: undefined, projectId: 42, projectName: "prod-db", created: false });
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("a non-JSON 200 returns {} (success, but no fields) — not null", async () => {
|
|
276
|
+
respond = () => new Response("<html>oops</html>", { status: 200 });
|
|
277
|
+
|
|
278
|
+
const reg = await registerMonitoringInstance("key", "p", opts({ instanceId: "i" }));
|
|
279
|
+
|
|
280
|
+
expect(reg).toEqual({});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("mistyped fields are dropped at runtime (project_id must be a number)", async () => {
|
|
284
|
+
// A spoofed/older platform returning a string id must not poison the
|
|
285
|
+
// persistence decision, which prefers a numeric project_id.
|
|
286
|
+
respond = () => new Response(JSON.stringify({ project_id: "13", project_name: "ok-name", created: "yes" }), { status: 200 });
|
|
287
|
+
|
|
288
|
+
const reg = await registerMonitoringInstance("key", "p", opts({ instanceId: "i" }));
|
|
289
|
+
|
|
290
|
+
expect(reg).toEqual({ instanceId: undefined, projectId: undefined, projectName: "ok-name", created: undefined });
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("when adopting, a first non-OK response triggers one retry that succeeds", async () => {
|
|
294
|
+
respond = (call) => {
|
|
295
|
+
// First attempt 503, second 200.
|
|
296
|
+
return fetchCalls.length === 1
|
|
297
|
+
? new Response("upstream down", { status: 503 })
|
|
298
|
+
: new Response(JSON.stringify({ project_id: 9 }), { status: 200 });
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const reg = await registerMonitoringInstance("key", "p", opts({ instanceId: "i" }));
|
|
302
|
+
|
|
303
|
+
expect(fetchCalls.length).toBe(2);
|
|
304
|
+
expect(reg).toEqual({ instanceId: undefined, projectId: 9, projectName: undefined, created: undefined });
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("when adopting, two failures exhaust the retry and return null", async () => {
|
|
308
|
+
respond = () => new Response("upstream down", { status: 503 });
|
|
309
|
+
|
|
310
|
+
const reg = await registerMonitoringInstance("key", "p", opts({ instanceId: "i" }));
|
|
311
|
+
|
|
312
|
+
expect(fetchCalls.length).toBe(2); // one initial + one retry
|
|
313
|
+
expect(reg).toBeNull();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("legacy mode does NOT retry — a single failure returns null after one attempt", async () => {
|
|
317
|
+
respond = () => new Response("upstream down", { status: 503 });
|
|
318
|
+
|
|
319
|
+
const reg = await registerMonitoringInstance("key", "p", opts());
|
|
320
|
+
|
|
321
|
+
expect(fetchCalls.length).toBe(1);
|
|
322
|
+
expect(reg).toBeNull();
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
describe("resolveAdoptedProject — what gets persisted to .pgwatch-config", () => {
|
|
327
|
+
test("prefers the numeric project_id over the name (survives renames)", () => {
|
|
328
|
+
expect(resolveAdoptedProject({ projectId: 42, projectName: "prod-db" })).toBe("42");
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("project_id === 0 is a valid id and is honored", () => {
|
|
332
|
+
expect(resolveAdoptedProject({ projectId: 0, projectName: "prod-db" })).toBe("0");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("falls back to project_name when there is no id", () => {
|
|
336
|
+
expect(resolveAdoptedProject({ projectName: "prod-db" })).toBe("prod-db");
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("returns null for a null response", () => {
|
|
340
|
+
expect(resolveAdoptedProject(null)).toBeNull();
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("returns null for a fieldless (succeeded-but-empty) response", () => {
|
|
344
|
+
expect(resolveAdoptedProject({})).toBeNull();
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test("rejects a project_name with a newline (config-file injection, CWE-93)", () => {
|
|
348
|
+
expect(resolveAdoptedProject({ projectName: "prod\ninjected=evil" })).toBeNull();
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("rejects a project_name containing '='", () => {
|
|
352
|
+
expect(resolveAdoptedProject({ projectName: "a=b" })).toBeNull();
|
|
274
353
|
});
|
|
275
354
|
});
|
|
276
355
|
|
|
@@ -70,6 +70,35 @@ describe("Schema validation", () => {
|
|
|
70
70
|
});
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
// F003 (Autovacuum: dead tuples) - test empty and with data
|
|
74
|
+
test("F003 validates with empty data", async () => {
|
|
75
|
+
const mockClient = createMockClient({ deadTuplesRows: [] });
|
|
76
|
+
const report = await checkup.REPORT_GENERATORS.F003(mockClient as any, "node-01");
|
|
77
|
+
validateAgainstSchema(report, "F003");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("F003 validates with sample data", async () => {
|
|
81
|
+
const mockClient = createMockClient({
|
|
82
|
+
deadTuplesRows: [
|
|
83
|
+
{
|
|
84
|
+
tag_schemaname: "public",
|
|
85
|
+
tag_relname: "events",
|
|
86
|
+
n_live_tup: "6361538",
|
|
87
|
+
n_dead_tup: "8270000",
|
|
88
|
+
dead_pct: 56.52,
|
|
89
|
+
last_autovacuum: "0",
|
|
90
|
+
last_vacuum: "1704067200",
|
|
91
|
+
autovacuum_count: "0",
|
|
92
|
+
vacuum_count: "1",
|
|
93
|
+
autovacuum_disabled: 1,
|
|
94
|
+
table_size_b: "2147483648",
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
});
|
|
98
|
+
const report = await checkup.REPORT_GENERATORS.F003(mockClient as any, "node-01");
|
|
99
|
+
validateAgainstSchema(report, "F003");
|
|
100
|
+
});
|
|
101
|
+
|
|
73
102
|
// Settings reports (D004, F001, G001) - single test each
|
|
74
103
|
for (const checkId of ["D004", "F001", "G001"]) {
|
|
75
104
|
test(`${checkId} validates against schema`, async () => {
|
package/test/test-utils.ts
CHANGED
|
@@ -17,6 +17,7 @@ export interface MockClientOptions {
|
|
|
17
17
|
redundantIndexesRows?: any[];
|
|
18
18
|
tableBloatRows?: any[];
|
|
19
19
|
indexBloatRows?: any[];
|
|
20
|
+
deadTuplesRows?: any[];
|
|
20
21
|
vacuumStatsRows?: any[];
|
|
21
22
|
deadlockStatsRows?: any[];
|
|
22
23
|
pgStatStatementsExtensionRows?: any[];
|
|
@@ -58,6 +59,7 @@ export function createMockClient(options: MockClientOptions = {}) {
|
|
|
58
59
|
redundantIndexesRows = [],
|
|
59
60
|
tableBloatRows = [],
|
|
60
61
|
indexBloatRows = [],
|
|
62
|
+
deadTuplesRows = [],
|
|
61
63
|
vacuumStatsRows = [],
|
|
62
64
|
deadlockStatsRows = [{ deadlocks: "0", conflicts: "0", stats_reset: null }],
|
|
63
65
|
pgStatStatementsExtensionRows = [],
|
|
@@ -119,6 +121,12 @@ export function createMockClient(options: MockClientOptions = {}) {
|
|
|
119
121
|
if (sql.includes("redundant_indexes_grouped") && sql.includes("columns like")) {
|
|
120
122
|
return { rows: redundantIndexesRows };
|
|
121
123
|
}
|
|
124
|
+
// F003: dead tuples metric from metrics.yml.
|
|
125
|
+
// Must be matched BEFORE the F004/F005 vacuum-stats route below: the
|
|
126
|
+
// pg_dead_tuples SQL also contains "pg_stat_user_tables" and "last_vacuum".
|
|
127
|
+
if (sql.includes("autovacuum_disabled") && sql.includes("n_dead_tup")) {
|
|
128
|
+
return { rows: deadTuplesRows };
|
|
129
|
+
}
|
|
122
130
|
// F004/F005: bloat metrics from metrics.yml
|
|
123
131
|
if (sql.includes("tag_idxname") && sql.includes("bloat_size")) {
|
|
124
132
|
return { rows: indexBloatRows };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { formatHttpError } from "../lib/util";
|
|
4
|
+
|
|
5
|
+
describe("formatHttpError", () => {
|
|
6
|
+
test("appends auth remediation hint on 401 with JSON body", () => {
|
|
7
|
+
const msg = formatHttpError(
|
|
8
|
+
"Failed to fetch issues",
|
|
9
|
+
401,
|
|
10
|
+
'{"hint": "Please check validity of token", "details": "Invalid token"}'
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
expect(msg).toContain("HTTP 401");
|
|
14
|
+
expect(msg).toContain("Run 'postgresai auth'");
|
|
15
|
+
expect(msg).toContain("PGAI_API_KEY");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("appends auth remediation hint on 401 with HTML body (early-return path)", () => {
|
|
19
|
+
const msg = formatHttpError("Failed to fetch issues", 401, "<html><body>Unauthorized</body></html>");
|
|
20
|
+
|
|
21
|
+
expect(msg).toContain("HTTP 401");
|
|
22
|
+
expect(msg).toContain("Run 'postgresai auth'");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("appends auth remediation hint on 401 without body", () => {
|
|
26
|
+
const msg = formatHttpError("Failed to fetch issues", 401);
|
|
27
|
+
|
|
28
|
+
expect(msg).toContain("Run 'postgresai auth'");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("does not append auth hint for non-401 statuses", () => {
|
|
32
|
+
expect(formatHttpError("Failed to fetch issues", 403, '{"message": "denied"}')).not.toContain(
|
|
33
|
+
"Run 'postgresai auth'"
|
|
34
|
+
);
|
|
35
|
+
expect(formatHttpError("Failed to fetch issues", 500)).not.toContain("Run 'postgresai auth'");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("keeps structured JSON error details before the hint", () => {
|
|
39
|
+
const msg = formatHttpError("Failed to fetch issues", 401, '{"message": "Invalid token"}');
|
|
40
|
+
|
|
41
|
+
expect(msg.indexOf("Invalid token")).toBeGreaterThan(-1);
|
|
42
|
+
expect(msg.indexOf("Invalid token")).toBeLessThan(msg.indexOf("Run 'postgresai auth'"));
|
|
43
|
+
});
|
|
44
|
+
});
|