postgresai 0.15.0-dev.6 → 0.15.0-dev.9

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.
@@ -3,10 +3,127 @@
3
3
  * Catches schema mismatches like pg_statistic in wrong schema.
4
4
  */
5
5
  import { describe, test, expect } from "bun:test";
6
- import { readFileSync } from "fs";
6
+ import { readdirSync, readFileSync } from "fs";
7
7
  import { resolve } from "path";
8
8
 
9
+ type PgwatchConfig = {
10
+ metrics: Record<string, unknown>;
11
+ presets?: Record<string, { metrics?: Record<string, unknown> }>;
12
+ };
13
+
14
+ type GrafanaDatasource = {
15
+ name?: unknown;
16
+ orgId?: unknown;
17
+ uid?: unknown;
18
+ editable?: unknown;
19
+ basicAuth?: unknown;
20
+ };
21
+
22
+ type GrafanaDatasourceConfig = {
23
+ deleteDatasources?: Array<{ name?: unknown; orgId?: unknown }>;
24
+ datasources?: GrafanaDatasource[];
25
+ };
26
+
27
+ type DockerComposeConfig = {
28
+ services?: Record<string, { restart?: unknown }>;
29
+ };
30
+
31
+ // These UIDs are referenced by provisioned Grafana dashboards.
32
+ // Changing them without updating dashboard JSON silently breaks panels.
33
+ const expectedGrafanaDatasourceUids = new Map([
34
+ ["PGWatch-PostgreSQL", "P031DD592934B2F1F"],
35
+ ["PGWatch-Prometheus", "P7A0D6631BB10B34F"],
36
+ ["Infinity", "aerffb0z8rjlsc"],
37
+ ]);
38
+
39
+ /**
40
+ * Normalizes raw Grafana datasource orgId values to a positive integer.
41
+ *
42
+ * Grafana treats omitted/null orgId as org 1 in the default single-org setup.
43
+ * Numeric strings are accepted because YAML or API callers may preserve them
44
+ * as strings. Booleans, objects, empty strings, non-positive values, and
45
+ * non-integers are rejected because Grafana cannot address those orgs.
46
+ */
47
+ const normalizeGrafanaOrgId = (orgId: unknown) => {
48
+ if (orgId === undefined || orgId === null) {
49
+ return 1;
50
+ }
51
+ if (typeof orgId === "boolean" || typeof orgId === "object" || orgId === "") {
52
+ throw new Error(`Invalid Grafana datasource orgId: ${orgId}`);
53
+ }
54
+
55
+ const normalizedOrgId = Number(orgId);
56
+ if (!Number.isInteger(normalizedOrgId) || normalizedOrgId < 1) {
57
+ throw new Error(`Invalid Grafana datasource orgId: ${orgId}`);
58
+ }
59
+ return normalizedOrgId;
60
+ };
61
+
62
+ // Normalize orgId so omitted/null orgId and explicit orgId: 1 map to the same
63
+ // datasource key, matching Grafana's implicit single-org behavior.
64
+ const datasourceKey = (name: unknown, orgId: unknown) =>
65
+ `${name}:${normalizeGrafanaOrgId(orgId)}`;
66
+
9
67
  const configDir = resolve(import.meta.dir, "../../config");
68
+ const metricsPath = resolve(configDir, "pgwatch-prometheus/metrics.yml");
69
+ const datasourcePath = resolve(
70
+ configDir,
71
+ "grafana/provisioning/datasources/datasources.yml"
72
+ );
73
+ const composePath = resolve(import.meta.dir, "../../docker-compose.yml");
74
+ const envExamplePath = resolve(import.meta.dir, "../../.env.example");
75
+ const dashboardDir = resolve(configDir, "grafana/dashboards");
76
+ const dashboardGeneratedMetricRefs = new Set([
77
+ // Exported by monitoring_flask_backend/app.py and joined into query dashboards.
78
+ "query_info",
79
+ ]);
80
+ const pgwatchMetricRefPattern = /\bpgwatch_([a-zA-Z_:][a-zA-Z0-9_:]*)\b/g;
81
+
82
+ const loadPgwatchConfig = () =>
83
+ Bun.YAML.parse(readFileSync(metricsPath, "utf8")) as PgwatchConfig;
84
+
85
+ const parseGrafanaDatasourceConfig = (content: string) =>
86
+ Bun.YAML.parse(content) as GrafanaDatasourceConfig;
87
+
88
+ const grafanaQueryStringKeys = new Set(["expr", "definition", "query"]);
89
+
90
+ const collectStringValuesByKeys = (
91
+ value: unknown,
92
+ keys: Set<string>
93
+ ): string[] => {
94
+ const matches: string[] = [];
95
+
96
+ const visit = (node: unknown) => {
97
+ if (Array.isArray(node)) {
98
+ for (const item of node) {
99
+ visit(item);
100
+ }
101
+ return;
102
+ }
103
+
104
+ if (node && typeof node === "object") {
105
+ const record = node as Record<string, unknown>;
106
+ for (const [recordKey, item] of Object.entries(record)) {
107
+ if (keys.has(recordKey) && typeof item === "string") {
108
+ matches.push(item);
109
+ }
110
+
111
+ visit(item);
112
+ }
113
+ }
114
+ };
115
+
116
+ visit(value);
117
+ return matches;
118
+ };
119
+
120
+ const resolveMetricReference = (
121
+ metricRef: string,
122
+ metricNamesByLength: string[]
123
+ ) =>
124
+ metricNamesByLength.find((metricName) =>
125
+ metricRef === metricName || metricRef.startsWith(`${metricName}_`)
126
+ );
10
127
 
11
128
  describe("Config consistency", () => {
12
129
  test("target-db/init.sql creates pg_statistic in postgres_ai schema", () => {
@@ -24,13 +141,212 @@ describe("Config consistency", () => {
24
141
  });
25
142
 
26
143
  test("pgwatch metrics.yml uses postgres_ai.pg_statistic", () => {
27
- const metricsYml = readFileSync(
28
- resolve(configDir, "pgwatch-prometheus/metrics.yml"),
29
- "utf8"
30
- );
144
+ const metricsYml = readFileSync(metricsPath, "utf8");
31
145
 
32
146
  // Should reference postgres_ai.pg_statistic, not public.pg_statistic
33
147
  expect(metricsYml).not.toMatch(/public\.pg_statistic/);
34
148
  expect(metricsYml).toMatch(/postgres_ai\.pg_statistic/);
35
149
  });
150
+
151
+ test("Grafana datasource YAML parser rejects malformed config", () => {
152
+ expect(() =>
153
+ parseGrafanaDatasourceConfig("apiVersion: 1\ndatasources:\n - name: [")
154
+ ).toThrow();
155
+ });
156
+
157
+ test("Grafana orgId normalization rejects invalid values", () => {
158
+ for (const orgId of [undefined, null, 1, "1"]) {
159
+ expect(normalizeGrafanaOrgId(orgId)).toBe(1);
160
+ }
161
+ expect(normalizeGrafanaOrgId("2")).toBe(2);
162
+
163
+ for (const orgId of [
164
+ 0,
165
+ -1,
166
+ true,
167
+ "",
168
+ 1.5,
169
+ "0",
170
+ "-1",
171
+ "1.5",
172
+ "abc",
173
+ NaN,
174
+ Infinity,
175
+ ]) {
176
+ expect(() => normalizeGrafanaOrgId(orgId)).toThrow();
177
+ }
178
+ });
179
+
180
+ test("datasources.yml delete entries match stable non-editable datasources", () => {
181
+ const datasourceConfig = parseGrafanaDatasourceConfig(
182
+ readFileSync(datasourcePath, "utf8")
183
+ );
184
+ const deleteDatasources = datasourceConfig.deleteDatasources ?? [];
185
+ const datasources = datasourceConfig.datasources ?? [];
186
+ const deletedKeys = new Set(
187
+ deleteDatasources.map((datasource) =>
188
+ datasourceKey(datasource.name, datasource.orgId)
189
+ )
190
+ );
191
+ const datasourceByNameAndOrg = new Map(
192
+ datasources.map((datasource) => [
193
+ datasourceKey(datasource.name, datasource.orgId),
194
+ datasource,
195
+ ])
196
+ );
197
+
198
+ expect(deleteDatasources.length).toBeGreaterThan(0);
199
+ expect(deleteDatasources.length).toBe(datasources.length);
200
+ expect(datasourceByNameAndOrg.size).toBe(datasources.length);
201
+
202
+ for (const deletedDatasource of deleteDatasources) {
203
+ const datasource = datasourceByNameAndOrg.get(
204
+ datasourceKey(deletedDatasource.name, deletedDatasource.orgId)
205
+ );
206
+ const expectedUid = expectedGrafanaDatasourceUids.get(
207
+ String(deletedDatasource.name)
208
+ );
209
+ expect(datasource).toBeDefined();
210
+ expect(expectedUid).toBeDefined();
211
+ expect(datasource?.uid).toBe(expectedUid);
212
+ expect(datasource?.editable).toBe(false);
213
+ }
214
+
215
+ for (const datasource of datasources) {
216
+ const expectedUid = expectedGrafanaDatasourceUids.get(
217
+ String(datasource.name)
218
+ );
219
+ expect(deletedKeys.has(datasourceKey(datasource.name, datasource.orgId))).toBe(
220
+ true
221
+ );
222
+ expect(expectedUid).toBeDefined();
223
+ expect(datasource.uid).toBe(expectedUid);
224
+ expect(datasource.editable).toBe(false);
225
+ }
226
+ });
227
+
228
+ test("Grafana Prometheus datasource requires VM auth environment", () => {
229
+ const datasourceConfig = parseGrafanaDatasourceConfig(
230
+ readFileSync(datasourcePath, "utf8")
231
+ );
232
+ const composeYml = readFileSync(composePath, "utf8");
233
+ const prometheusDatasource = datasourceConfig.datasources?.find(
234
+ (datasource) => datasource.name === "PGWatch-Prometheus"
235
+ ) as
236
+ | {
237
+ basicAuth?: unknown;
238
+ basicAuthUser?: unknown;
239
+ secureJsonData?: { basicAuthPassword?: unknown };
240
+ }
241
+ | undefined;
242
+
243
+ expect(prometheusDatasource?.basicAuth).toBe(true);
244
+ expect(prometheusDatasource?.basicAuthUser).toBe("${VM_AUTH_USERNAME}");
245
+ expect(prometheusDatasource?.secureJsonData?.basicAuthPassword).toBe(
246
+ "${VM_AUTH_PASSWORD}"
247
+ );
248
+ expect(composeYml).toContain(
249
+ "VM_AUTH_USERNAME: ${VM_AUTH_USERNAME:?VM_AUTH_USERNAME is required for Grafana datasource provisioning}"
250
+ );
251
+ expect(composeYml).toContain(
252
+ "VM_AUTH_PASSWORD: ${VM_AUTH_PASSWORD:?VM_AUTH_PASSWORD is required for Grafana datasource provisioning}"
253
+ );
254
+ });
255
+
256
+ test(".env.example documents direct Docker Compose credentials", () => {
257
+ const envExample = readFileSync(envExamplePath, "utf8");
258
+
259
+ expect(envExample).toContain("REPLICATOR_PASSWORD=");
260
+ expect(envExample).toContain("VM_AUTH_USERNAME=vmauth");
261
+ expect(envExample).toContain("VM_AUTH_PASSWORD=");
262
+ expect(envExample).toContain("openssl rand -hex 32");
263
+ expect(envExample).toContain("openssl rand -base64 18");
264
+ });
265
+
266
+ test("long-running Docker Compose services survive host reboot", () => {
267
+ const composeConfig = Bun.YAML.parse(
268
+ readFileSync(composePath, "utf8")
269
+ ) as DockerComposeConfig;
270
+ const services = composeConfig.services ?? {};
271
+
272
+ // All services should survive host reboot unless they are one-shot setup jobs.
273
+ const oneShotServices = new Set(["config-init", "sources-generator"]);
274
+ const serviceEntries = Object.entries(services);
275
+
276
+ expect(serviceEntries.length).toBeGreaterThan(0);
277
+
278
+ for (const serviceName of oneShotServices) {
279
+ expect(services[serviceName]).toBeDefined();
280
+ expect(services[serviceName]?.restart).toBeUndefined();
281
+ }
282
+
283
+ for (const [serviceName, serviceConfig] of serviceEntries) {
284
+ if (oneShotServices.has(serviceName)) {
285
+ continue;
286
+ }
287
+
288
+ expect(serviceConfig.restart).toBe("unless-stopped");
289
+ }
290
+ });
291
+
292
+ test("pgwatch presets only reference configured metrics", () => {
293
+ const pgwatchConfig = loadPgwatchConfig();
294
+ const metricNames = new Set(Object.keys(pgwatchConfig.metrics));
295
+ const presetNames = Object.keys(pgwatchConfig.presets ?? {});
296
+ const unknownPresetMetrics = new Set<string>();
297
+
298
+ expect(metricNames.size).toBeGreaterThan(0);
299
+ expect(presetNames.length).toBeGreaterThan(0);
300
+
301
+ for (const [presetName, preset] of Object.entries(pgwatchConfig.presets ?? {})) {
302
+ for (const metricName of Object.keys(preset.metrics ?? {})) {
303
+ if (!metricNames.has(metricName)) {
304
+ unknownPresetMetrics.add(`${presetName}.${metricName}`);
305
+ }
306
+ }
307
+ }
308
+
309
+ expect([...unknownPresetMetrics].sort()).toEqual([]);
310
+ });
311
+
312
+ test("Grafana dashboard pgwatch metric references exist in metrics.yml", () => {
313
+ const pgwatchConfig = loadPgwatchConfig();
314
+ const metricNamesByLength = Object.keys(pgwatchConfig.metrics).sort(
315
+ (a, b) => b.length - a.length
316
+ );
317
+ const dashboardFiles = readdirSync(dashboardDir).filter((file) =>
318
+ file.endsWith(".json")
319
+ );
320
+ const unknownDashboardMetrics = new Set<string>();
321
+ let observedDashboardMetricRefs = 0;
322
+
323
+ expect(metricNamesByLength.length).toBeGreaterThan(0);
324
+ expect(dashboardFiles.length).toBeGreaterThan(0);
325
+
326
+ for (const dashboardFile of dashboardFiles) {
327
+ const dashboard = JSON.parse(
328
+ readFileSync(resolve(dashboardDir, dashboardFile), "utf8")
329
+ );
330
+
331
+ for (const queryString of collectStringValuesByKeys(
332
+ dashboard,
333
+ grafanaQueryStringKeys
334
+ )) {
335
+ for (const match of queryString.matchAll(pgwatchMetricRefPattern)) {
336
+ observedDashboardMetricRefs += 1;
337
+ const metricRef = match[1];
338
+ if (dashboardGeneratedMetricRefs.has(metricRef)) {
339
+ continue;
340
+ }
341
+
342
+ if (!resolveMetricReference(metricRef, metricNamesByLength)) {
343
+ unknownDashboardMetrics.add(`${dashboardFile}: pgwatch_${metricRef}`);
344
+ }
345
+ }
346
+ }
347
+ }
348
+
349
+ expect(observedDashboardMetricRefs).toBeGreaterThan(0);
350
+ expect([...unknownDashboardMetrics].sort()).toEqual([]);
351
+ });
36
352
  });
@@ -10,6 +10,8 @@ import * as path from "path";
10
10
  import * as net from "net";
11
11
  import { Client } from "pg";
12
12
 
13
+ const TEST_TIMEOUT = 30000; // 30 seconds
14
+
13
15
  function sqlLiteral(value: string): string {
14
16
  return `'${String(value).replace(/'/g, "''")}'`;
15
17
  }
@@ -215,7 +217,7 @@ describe.skipIf(skipTests)("integration: prepare-db", () => {
215
217
  } finally {
216
218
  await pg.cleanup();
217
219
  }
218
- }, { timeout: 15000 });
220
+ }, { timeout: TEST_TIMEOUT });
219
221
 
220
222
  test("requires explicit monitoring password in non-interactive mode", async () => {
221
223
  pg = await createTempPostgres();
@@ -239,7 +241,7 @@ describe.skipIf(skipTests)("integration: prepare-db", () => {
239
241
  } finally {
240
242
  await pg.cleanup();
241
243
  }
242
- }, { timeout: 15000 });
244
+ }, { timeout: TEST_TIMEOUT });
243
245
 
244
246
  test(
245
247
  "fixes slightly-off permissions idempotently",
@@ -296,21 +298,19 @@ describe.skipIf(skipTests)("integration: prepare-db", () => {
296
298
  await pg.cleanup();
297
299
  }
298
300
  },
299
- { timeout: 15000 }
301
+ { timeout: TEST_TIMEOUT }
300
302
  );
301
303
 
302
- test(
303
- "reports nicely when lacking permissions",
304
- async () => {
305
- pg = await createTempPostgres();
304
+ test("reports nicely when lacking permissions", async () => {
305
+ pg = await createTempPostgres();
306
306
 
307
- try {
308
- // Create limited user that can connect but cannot create roles / grant.
309
- const limitedPw = "limitedpw";
310
- {
311
- const c = new Client({ connectionString: pg.adminUri });
312
- await c.connect();
313
- await c.query(`do $$ begin
307
+ try {
308
+ // Create limited user that can connect but cannot create roles / grant.
309
+ const limitedPw = "limitedpw";
310
+ {
311
+ const c = new Client({ connectionString: pg.adminUri });
312
+ await c.connect();
313
+ await c.query(`do $$ begin
314
314
  if not exists (select 1 from pg_roles where rolname='limited') then
315
315
  begin
316
316
  create role limited login password ${sqlLiteral(limitedPw)};
@@ -323,18 +323,16 @@ describe.skipIf(skipTests)("integration: prepare-db", () => {
323
323
  await c.end();
324
324
  }
325
325
 
326
- const limitedUri = `postgresql://limited:${limitedPw}@127.0.0.1:${pg.port}/testdb`;
327
- const r = runCliInit([limitedUri, "--password", "monpw", "--skip-optional-permissions"]);
328
- expect(r.status).not.toBe(0);
329
- expect(r.stderr).toMatch(/Error: prepare-db:/);
330
- expect(r.stderr).toMatch(/Failed at step "/);
331
- expect(r.stderr).toMatch(/Fix: connect as a superuser/i);
332
- } finally {
333
- await pg.cleanup();
334
- }
335
- },
336
- { timeout: 15000 }
337
- );
326
+ const limitedUri = `postgresql://limited:${limitedPw}@127.0.0.1:${pg.port}/testdb`;
327
+ const r = runCliInit([limitedUri, "--password", "monpw", "--skip-optional-permissions"]);
328
+ expect(r.status).not.toBe(0);
329
+ expect(r.stderr).toMatch(/Error: prepare-db:/);
330
+ expect(r.stderr).toMatch(/Failed at step "/);
331
+ expect(r.stderr).toMatch(/Fix: connect as a superuser/i);
332
+ } finally {
333
+ await pg.cleanup();
334
+ }
335
+ }, { timeout: TEST_TIMEOUT });
338
336
 
339
337
  test(
340
338
  "--verify returns 0 when ok and non-zero when missing",
@@ -372,7 +370,8 @@ describe.skipIf(skipTests)("integration: prepare-db", () => {
372
370
  } finally {
373
371
  await pg.cleanup();
374
372
  }
375
- }
373
+ },
374
+ { timeout: TEST_TIMEOUT }
376
375
  );
377
376
 
378
377
  // 15s timeout for PostgreSQL startup + two CLI init commands in slow CI
@@ -406,7 +405,7 @@ describe.skipIf(skipTests)("integration: prepare-db", () => {
406
405
  } finally {
407
406
  await pg.cleanup();
408
407
  }
409
- }, { timeout: 15000 });
408
+ }, { timeout: TEST_TIMEOUT });
410
409
 
411
410
  // 60s timeout for PostgreSQL startup + multiple SQL queries in slow CI
412
411
  test("explain_generic validates input and prevents SQL injection", async () => {