postgresai 0.16.0-rc.1 → 0.16.0-rc.3
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 +7630 -7420
- package/dist/sql/06.helpers.sql +0 -122
- package/dist/sql/sql/06.helpers.sql +0 -122
- package/lib/aas-onboard.ts +251 -0
- package/lib/init.ts +0 -8
- package/lib/supabase.ts +0 -18
- package/package.json +1 -1
- package/sql/06.helpers.sql +0 -122
- package/test/aas-onboard.test.ts +301 -0
- package/test/init.integration.test.ts +9 -79
- package/test/monitoring.test.ts +54 -3
package/sql/06.helpers.sql
CHANGED
|
@@ -2,126 +2,6 @@
|
|
|
2
2
|
-- These functions use SECURITY DEFINER to allow the monitoring user to perform
|
|
3
3
|
-- operations they don't have direct permissions for.
|
|
4
4
|
|
|
5
|
-
/*
|
|
6
|
-
* explain_generic
|
|
7
|
-
*
|
|
8
|
-
* Function to get generic explain plans with optional HypoPG index testing.
|
|
9
|
-
* Requires: PostgreSQL 16+ (for generic_plan option), HypoPG extension (optional).
|
|
10
|
-
*
|
|
11
|
-
* Security notes:
|
|
12
|
-
* - EXPLAIN without ANALYZE is read-only (plans but doesn't execute the query)
|
|
13
|
-
* - PostgreSQL's EXPLAIN only accepts a single statement (primary protection)
|
|
14
|
-
* - Input validation uses a simple heuristic to detect multiple statements
|
|
15
|
-
* (Note: may reject valid queries containing semicolons in string literals)
|
|
16
|
-
*
|
|
17
|
-
* Usage examples:
|
|
18
|
-
* -- Basic generic plan
|
|
19
|
-
* select postgres_ai.explain_generic('select * from users where id = $1');
|
|
20
|
-
*
|
|
21
|
-
* -- JSON format
|
|
22
|
-
* select postgres_ai.explain_generic('select * from users where id = $1', 'json');
|
|
23
|
-
*
|
|
24
|
-
* -- Test a hypothetical index
|
|
25
|
-
* select postgres_ai.explain_generic(
|
|
26
|
-
* 'select * from users where email = $1',
|
|
27
|
-
* 'text',
|
|
28
|
-
* 'create index on users (email)'
|
|
29
|
-
* );
|
|
30
|
-
*/
|
|
31
|
-
create or replace function postgres_ai.explain_generic(
|
|
32
|
-
in query text,
|
|
33
|
-
in format text default 'text',
|
|
34
|
-
in hypopg_index text default null,
|
|
35
|
-
out result text
|
|
36
|
-
)
|
|
37
|
-
language plpgsql
|
|
38
|
-
security definer
|
|
39
|
-
set search_path = pg_catalog, public
|
|
40
|
-
as $$
|
|
41
|
-
declare
|
|
42
|
-
v_line record;
|
|
43
|
-
v_lines text[] := '{}';
|
|
44
|
-
v_explain_query text;
|
|
45
|
-
v_hypo_result record;
|
|
46
|
-
v_version int;
|
|
47
|
-
v_hypopg_available boolean;
|
|
48
|
-
v_clean_query text;
|
|
49
|
-
begin
|
|
50
|
-
-- Check PostgreSQL version (generic_plan requires 16+)
|
|
51
|
-
select current_setting('server_version_num')::int into v_version;
|
|
52
|
-
|
|
53
|
-
if v_version < 160000 then
|
|
54
|
-
raise exception 'generic_plan requires PostgreSQL 16+, current version: %',
|
|
55
|
-
current_setting('server_version');
|
|
56
|
-
end if;
|
|
57
|
-
|
|
58
|
-
-- Input validation: reject empty queries
|
|
59
|
-
if query is null or trim(query) = '' then
|
|
60
|
-
raise exception 'query cannot be empty';
|
|
61
|
-
end if;
|
|
62
|
-
|
|
63
|
-
-- Input validation: detect multiple statements (defense-in-depth)
|
|
64
|
-
-- Note: This is a simple heuristic - EXPLAIN itself only accepts single statements
|
|
65
|
-
-- Limitation: Queries with semicolons inside string literals will be rejected
|
|
66
|
-
v_clean_query := trim(query);
|
|
67
|
-
if v_clean_query like '%;%' then
|
|
68
|
-
-- Strip trailing semicolon if present (common user convenience)
|
|
69
|
-
v_clean_query := regexp_replace(v_clean_query, ';\s*$', '');
|
|
70
|
-
-- If there's still a semicolon, reject (likely multiple statements or semicolon in string)
|
|
71
|
-
if v_clean_query like '%;%' then
|
|
72
|
-
raise exception 'query contains semicolon (multiple statements not allowed; note: semicolons in string literals are also not supported)';
|
|
73
|
-
end if;
|
|
74
|
-
end if;
|
|
75
|
-
|
|
76
|
-
-- Check if HypoPG extension is available
|
|
77
|
-
if hypopg_index is not null then
|
|
78
|
-
select exists(
|
|
79
|
-
select 1 from pg_extension where extname = 'hypopg'
|
|
80
|
-
) into v_hypopg_available;
|
|
81
|
-
|
|
82
|
-
if not v_hypopg_available then
|
|
83
|
-
raise exception 'HypoPG extension is required for hypothetical index testing but is not installed';
|
|
84
|
-
end if;
|
|
85
|
-
|
|
86
|
-
-- Create hypothetical index
|
|
87
|
-
select * into v_hypo_result from hypopg_create_index(hypopg_index);
|
|
88
|
-
raise notice 'Created hypothetical index: % (oid: %)',
|
|
89
|
-
v_hypo_result.indexname, v_hypo_result.indexrelid;
|
|
90
|
-
end if;
|
|
91
|
-
|
|
92
|
-
-- Build and execute EXPLAIN query
|
|
93
|
-
-- Note: EXPLAIN is read-only (plans but doesn't execute), making this safe
|
|
94
|
-
begin
|
|
95
|
-
if lower(format) = 'json' then
|
|
96
|
-
execute 'explain (verbose, settings, generic_plan, format json) ' || v_clean_query
|
|
97
|
-
into result;
|
|
98
|
-
else
|
|
99
|
-
for v_line in execute 'explain (verbose, settings, generic_plan) ' || v_clean_query loop
|
|
100
|
-
v_lines := array_append(v_lines, v_line."QUERY PLAN");
|
|
101
|
-
end loop;
|
|
102
|
-
result := array_to_string(v_lines, e'\n');
|
|
103
|
-
end if;
|
|
104
|
-
exception when others then
|
|
105
|
-
-- Clean up hypothetical index before re-raising
|
|
106
|
-
if hypopg_index is not null then
|
|
107
|
-
perform hypopg_reset();
|
|
108
|
-
end if;
|
|
109
|
-
raise;
|
|
110
|
-
end;
|
|
111
|
-
|
|
112
|
-
-- Clean up hypothetical index
|
|
113
|
-
if hypopg_index is not null then
|
|
114
|
-
perform hypopg_reset();
|
|
115
|
-
end if;
|
|
116
|
-
end;
|
|
117
|
-
$$;
|
|
118
|
-
|
|
119
|
-
comment on function postgres_ai.explain_generic(text, text, text) is
|
|
120
|
-
'Returns generic EXPLAIN plan with optional HypoPG index testing (requires PG16+)';
|
|
121
|
-
|
|
122
|
-
-- Grant execute to the monitoring user
|
|
123
|
-
grant execute on function postgres_ai.explain_generic(text, text, text) to {{ROLE_IDENT}};
|
|
124
|
-
|
|
125
5
|
/*
|
|
126
6
|
* table_describe
|
|
127
7
|
*
|
|
@@ -435,5 +315,3 @@ comment on function postgres_ai.table_describe(text) is
|
|
|
435
315
|
'Returns comprehensive table information in compact text format for LLM analysis';
|
|
436
316
|
|
|
437
317
|
grant execute on function postgres_ai.table_describe(text) to {{ROLE_IDENT}};
|
|
438
|
-
|
|
439
|
-
|
|
@@ -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
|
+
});
|
|
@@ -407,12 +407,14 @@ describe.skipIf(skipTests)("integration: prepare-db", () => {
|
|
|
407
407
|
}
|
|
408
408
|
}, { timeout: TEST_TIMEOUT });
|
|
409
409
|
|
|
410
|
-
//
|
|
411
|
-
|
|
410
|
+
// Security regression for #387: explain_generic was a SECURITY DEFINER helper that
|
|
411
|
+
// ran caller-supplied SQL through EXPLAIN. The planner folds IMMUTABLE/STABLE functions
|
|
412
|
+
// at plan time in the definer's (superuser) context, making it an RCE/data-exfil vector.
|
|
413
|
+
// It has been removed; prepare-db must NOT create it.
|
|
414
|
+
test("explain_generic is not created by prepare-db (RCE helper removed, #387)", async () => {
|
|
412
415
|
pg = await createTempPostgres();
|
|
413
416
|
|
|
414
417
|
try {
|
|
415
|
-
// Run init first
|
|
416
418
|
{
|
|
417
419
|
const r = runCliInit([pg.adminUri, "--password", "pw1", "--skip-optional-permissions"]);
|
|
418
420
|
expect(r.status).toBe(0);
|
|
@@ -422,82 +424,10 @@ describe.skipIf(skipTests)("integration: prepare-db", () => {
|
|
|
422
424
|
await c.connect();
|
|
423
425
|
|
|
424
426
|
try {
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
if (version < 160000) {
|
|
430
|
-
// Skip this test on older PostgreSQL versions
|
|
431
|
-
console.log("Skipping explain_generic tests: requires PostgreSQL 16+");
|
|
432
|
-
return;
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
// Test 1: Empty query should be rejected
|
|
436
|
-
await expect(
|
|
437
|
-
c.query("select postgres_ai.explain_generic('')")
|
|
438
|
-
).rejects.toThrow(/query cannot be empty/);
|
|
439
|
-
|
|
440
|
-
// Test 2: Null query should be rejected
|
|
441
|
-
await expect(
|
|
442
|
-
c.query("select postgres_ai.explain_generic(null)")
|
|
443
|
-
).rejects.toThrow(/query cannot be empty/);
|
|
444
|
-
|
|
445
|
-
// Test 3: Multiple statements (semicolon in middle) should be rejected
|
|
446
|
-
await expect(
|
|
447
|
-
c.query("select postgres_ai.explain_generic('select 1; select 2')")
|
|
448
|
-
).rejects.toThrow(/semicolon|multiple statements/i);
|
|
449
|
-
|
|
450
|
-
// Test 4: Trailing semicolon should be stripped and work
|
|
451
|
-
{
|
|
452
|
-
const res = await c.query("select postgres_ai.explain_generic('select 1;') as result");
|
|
453
|
-
expect(res.rows[0].result).toBeTruthy();
|
|
454
|
-
expect(res.rows[0].result).toMatch(/Result/i);
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
// Test 5: Valid query should work
|
|
458
|
-
{
|
|
459
|
-
const res = await c.query("select postgres_ai.explain_generic('select $1::int', 'text') as result");
|
|
460
|
-
expect(res.rows[0].result).toBeTruthy();
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
// Test 6: JSON format should work
|
|
464
|
-
{
|
|
465
|
-
const res = await c.query("select postgres_ai.explain_generic('select 1', 'json') as result");
|
|
466
|
-
const plan = JSON.parse(res.rows[0].result);
|
|
467
|
-
expect(Array.isArray(plan)).toBe(true);
|
|
468
|
-
expect(plan[0].Plan).toBeTruthy();
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
// Test 7: Whitespace-only query should be rejected
|
|
472
|
-
await expect(
|
|
473
|
-
c.query("select postgres_ai.explain_generic(' ')")
|
|
474
|
-
).rejects.toThrow(/query cannot be empty/);
|
|
475
|
-
|
|
476
|
-
// Test 8: Semicolon in string literal is rejected (documented limitation)
|
|
477
|
-
// Note: This is a known limitation - the simple heuristic cannot parse SQL strings
|
|
478
|
-
await expect(
|
|
479
|
-
c.query("select postgres_ai.explain_generic('select ''hello;world''')")
|
|
480
|
-
).rejects.toThrow(/semicolon/i);
|
|
481
|
-
|
|
482
|
-
// Test 9: SQL comments should work (no semicolons)
|
|
483
|
-
{
|
|
484
|
-
const res = await c.query("select postgres_ai.explain_generic('select 1 -- comment') as result");
|
|
485
|
-
expect(res.rows[0].result).toBeTruthy();
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
// Test 10: Escaped quotes should work (no semicolons)
|
|
489
|
-
{
|
|
490
|
-
const res = await c.query("select postgres_ai.explain_generic('select ''test''''s value''') as result");
|
|
491
|
-
expect(res.rows[0].result).toBeTruthy();
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
// Test 11: Case-insensitive format parameter
|
|
495
|
-
{
|
|
496
|
-
const res = await c.query("select postgres_ai.explain_generic('select 1', 'JSON') as result");
|
|
497
|
-
const plan = JSON.parse(res.rows[0].result);
|
|
498
|
-
expect(Array.isArray(plan)).toBe(true);
|
|
499
|
-
}
|
|
500
|
-
|
|
427
|
+
const res = await c.query(
|
|
428
|
+
"select count(*)::int as n from pg_proc where proname = 'explain_generic' and pronamespace = (select oid from pg_namespace where nspname = 'postgres_ai')"
|
|
429
|
+
);
|
|
430
|
+
expect(res.rows[0].n).toBe(0);
|
|
501
431
|
} finally {
|
|
502
432
|
await c.end();
|
|
503
433
|
}
|
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");
|