postgresai 0.15.0 → 0.16.0-dev.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 +3 -0
- package/bin/postgres-ai.ts +210 -31
- package/dist/bin/postgres-ai.js +7730 -7250
- package/lib/aas-onboard.ts +217 -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 +217 -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
|
@@ -0,0 +1,217 @@
|
|
|
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
|
+
});
|
|
202
|
+
expect(r.ok).toBe(false);
|
|
203
|
+
expect(r.reason).toContain("datasource");
|
|
204
|
+
expect(calls.some((c) => c.url.includes("/rpc/monitoring_instance_aas_register"))).toBe(false);
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("mint returning no key → ok:false, no RPC call", async () => {
|
|
209
|
+
installFetch({ mintKey: null });
|
|
210
|
+
const r = await registerAasCollection("apikey-1", "inst-123", {
|
|
211
|
+
grafanaPassword: "pw", instancesPath, vcpus: 8, apiBaseUrl: "https://api.test",
|
|
212
|
+
});
|
|
213
|
+
expect(r.ok).toBe(false);
|
|
214
|
+
expect(r.reason).toContain("service-account token");
|
|
215
|
+
expect(calls.some((c) => c.url.includes("/rpc/monitoring_instance_aas_register"))).toBe(false);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -324,6 +324,61 @@ describe.skipIf(!!skipReason)("checkup integration: express mode schema compatib
|
|
|
324
324
|
expect(typeof nodeResult.data).toBe("object");
|
|
325
325
|
});
|
|
326
326
|
|
|
327
|
+
test("F003 flags a table with dead tuples and per-table disabled autovacuum", async () => {
|
|
328
|
+
// Reproduce the footgun the check exists for: a table with autovacuum
|
|
329
|
+
// disabled via reloptions accumulating dead tuples from UPDATE/DELETE.
|
|
330
|
+
await client.query(`
|
|
331
|
+
CREATE TABLE f003_dead_tuples_test (id int PRIMARY KEY, payload text);
|
|
332
|
+
ALTER TABLE f003_dead_tuples_test SET (autovacuum_enabled = false);
|
|
333
|
+
INSERT INTO f003_dead_tuples_test SELECT g, repeat('x', 50) FROM generate_series(1, 20000) g;
|
|
334
|
+
UPDATE f003_dead_tuples_test SET payload = payload || 'y';
|
|
335
|
+
`);
|
|
336
|
+
|
|
337
|
+
try {
|
|
338
|
+
// Cumulative stats are flushed asynchronously; poll until the dead
|
|
339
|
+
// tuples from the UPDATE become visible in pg_stat_user_tables.
|
|
340
|
+
await waitFor(async () => {
|
|
341
|
+
const r = await client.query(
|
|
342
|
+
"select n_dead_tup from pg_stat_user_tables where relname = 'f003_dead_tuples_test'"
|
|
343
|
+
);
|
|
344
|
+
if (!r.rows.length || parseInt(r.rows[0].n_dead_tup, 10) < 20000) {
|
|
345
|
+
throw new Error("dead tuple stats not flushed yet");
|
|
346
|
+
}
|
|
347
|
+
}, { timeoutMs: 15000, intervalMs: 250 });
|
|
348
|
+
|
|
349
|
+
const report = await checkup.REPORT_GENERATORS.F003(client, "test-node");
|
|
350
|
+
validateAgainstSchema(report, "F003");
|
|
351
|
+
|
|
352
|
+
const nodeResult = report.results["test-node"];
|
|
353
|
+
const dbName = Object.keys(nodeResult.data)[0];
|
|
354
|
+
const dbData = nodeResult.data[dbName] as any;
|
|
355
|
+
|
|
356
|
+
const table = dbData.dead_tuples_tables.find(
|
|
357
|
+
(t: any) => t.table_name === "f003_dead_tuples_test"
|
|
358
|
+
);
|
|
359
|
+
expect(table).toBeDefined();
|
|
360
|
+
expect(table.autovacuum_disabled).toBe(true);
|
|
361
|
+
expect(table.n_dead_tup).toBeGreaterThanOrEqual(20000);
|
|
362
|
+
expect(table.dead_pct).toBeGreaterThanOrEqual(checkup.F003_DEAD_PCT_MIN);
|
|
363
|
+
// 20k dead tuples is below F003_DEAD_TUPLES_MIN (100k), so the
|
|
364
|
+
// dead-tuple thresholds must NOT fire, but the disabled-autovacuum
|
|
365
|
+
// flag must (>= 10k tuples with autovacuum off).
|
|
366
|
+
expect(table.exceeds_dead_tuple_thresholds).toBe(false);
|
|
367
|
+
expect(table.autovacuum_disabled_flagged).toBe(true);
|
|
368
|
+
expect(dbData.autovacuum_disabled_count).toBeGreaterThanOrEqual(1);
|
|
369
|
+
expect(
|
|
370
|
+
dbData.conclusions.some((c: string) => c.includes("f003_dead_tuples_test"))
|
|
371
|
+
).toBe(true);
|
|
372
|
+
expect(
|
|
373
|
+
dbData.recommendations.some((r: string) =>
|
|
374
|
+
r.includes('alter table "public"."f003_dead_tuples_test" reset (autovacuum_enabled);')
|
|
375
|
+
)
|
|
376
|
+
).toBe(true);
|
|
377
|
+
} finally {
|
|
378
|
+
await client.query("DROP TABLE IF EXISTS f003_dead_tuples_test;");
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
|
|
327
382
|
test("CLI --markdown flag works without API key", async () => {
|
|
328
383
|
// Test that --markdown works even without an API key
|
|
329
384
|
const connString = `postgresql://postgres@${pg.socketDir}:${pg.port}/postgres`;
|