postgresai 0.16.0-dev.0 → 0.16.0-dev.10
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 +84 -1
- package/bin/postgres-ai.ts +789 -6
- package/bun.lock +4 -4
- package/dist/bin/postgres-ai.js +2646 -283
- package/dist/sql/06.helpers.sql +0 -122
- package/dist/sql/sql/06.helpers.sql +0 -122
- package/lib/aas-onboard.ts +41 -7
- package/lib/dblab.ts +449 -0
- package/lib/init.ts +0 -8
- package/lib/joe.ts +761 -0
- package/lib/mcp-server.ts +625 -0
- package/lib/supabase.ts +0 -18
- package/lib/util.ts +125 -18
- package/package.json +1 -1
- package/sql/06.helpers.sql +0 -122
- package/test/aas-onboard.test.ts +84 -0
- package/test/dblab.cli.test.ts +373 -0
- package/test/dblab.test.ts +488 -0
- package/test/e2e/pgai-e2e-smoke.sh +192 -0
- package/test/init.integration.test.ts +9 -79
- package/test/joe.cli.test.ts +469 -0
- package/test/joe.test.ts +858 -0
- package/test/monitoring.test.ts +54 -3
- package/test/util.test.ts +111 -1
package/lib/util.ts
CHANGED
|
@@ -28,45 +28,111 @@ function isHtmlContent(text: string): boolean {
|
|
|
28
28
|
*/
|
|
29
29
|
const AUTH_REMEDIATION_HINT = "Run 'postgresai auth' to (re)authenticate, or set/update PGAI_API_KEY.";
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Standard HTTP reason phrases we should NOT treat as a server-authored
|
|
33
|
+
* message: when the reason phrase equals the stock text for the status, it
|
|
34
|
+
* carries no extra information, so we prefer the friendlier generic label.
|
|
35
|
+
*/
|
|
36
|
+
const STANDARD_REASON_PHRASES: Record<number, string> = {
|
|
37
|
+
400: "Bad Request",
|
|
38
|
+
401: "Unauthorized",
|
|
39
|
+
403: "Forbidden",
|
|
40
|
+
404: "Not Found",
|
|
41
|
+
408: "Request Timeout",
|
|
42
|
+
409: "Conflict",
|
|
43
|
+
413: "Payload Too Large",
|
|
44
|
+
429: "Too Many Requests",
|
|
45
|
+
500: "Internal Server Error",
|
|
46
|
+
502: "Bad Gateway",
|
|
47
|
+
503: "Service Unavailable",
|
|
48
|
+
504: "Gateway Timeout",
|
|
49
|
+
};
|
|
50
|
+
|
|
31
51
|
/**
|
|
32
52
|
* Format an HTTP error response into a clean, developer-friendly message.
|
|
33
53
|
* Handles HTML error pages (e.g., from Cloudflare) by showing just the status code and message.
|
|
34
54
|
* For 401 responses, appends a remediation hint pointing at `postgresai auth`.
|
|
55
|
+
*
|
|
56
|
+
* The platform's PostgREST layer uses the `PTxyz` custom-status convention:
|
|
57
|
+
* a raised `PT403`/`PT404`/… maps to the HTTP status and delivers the RPC's
|
|
58
|
+
* user-facing message in the HTTP **reason phrase** (`response.statusText`),
|
|
59
|
+
* NOT the JSON body — the body carries only `hint`/`details` (no `message`).
|
|
60
|
+
* So callers pass `statusText` and it is preferred over the built-in generic
|
|
61
|
+
* label. Headline precedence: JSON body `message` → custom reason phrase →
|
|
62
|
+
* generic label; the JSON `details` (plural, PostgREST's spelling) is shown as
|
|
63
|
+
* a supplementary line.
|
|
35
64
|
*/
|
|
36
|
-
export function formatHttpError(
|
|
37
|
-
|
|
38
|
-
|
|
65
|
+
export function formatHttpError(
|
|
66
|
+
operation: string,
|
|
67
|
+
status: number,
|
|
68
|
+
responseBody?: string,
|
|
69
|
+
statusText?: string
|
|
70
|
+
): string {
|
|
71
|
+
const generic = HTTP_STATUS_MESSAGES[status] || "Request failed";
|
|
39
72
|
const remediation = status === 401 ? `\n${AUTH_REMEDIATION_HINT}` : "";
|
|
40
73
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if (isHtmlContent(responseBody)) {
|
|
44
|
-
// Just use the status message, don't append HTML
|
|
45
|
-
return errMsg + remediation;
|
|
46
|
-
}
|
|
74
|
+
let bodyMessage: string | undefined;
|
|
75
|
+
let bodyDetails: string | undefined;
|
|
47
76
|
|
|
48
|
-
|
|
77
|
+
if (responseBody && !isHtmlContent(responseBody)) {
|
|
78
|
+
// If it's HTML (like Cloudflare error pages), we fall through with no
|
|
79
|
+
// parsed fields and never dump the raw HTML.
|
|
49
80
|
try {
|
|
50
81
|
const errObj = JSON.parse(responseBody);
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
82
|
+
const message = errObj.message ?? errObj.error;
|
|
83
|
+
if (typeof message === "string" && message.trim().length > 0) {
|
|
84
|
+
bodyMessage = message.trim();
|
|
85
|
+
}
|
|
86
|
+
// PostgREST spells it `details` (plural); accept `detail` too.
|
|
87
|
+
const details = errObj.details ?? errObj.detail;
|
|
88
|
+
if (typeof details === "string" && details.trim().length > 0) {
|
|
89
|
+
bodyDetails = details.trim();
|
|
57
90
|
}
|
|
58
91
|
} catch {
|
|
59
|
-
// Plain text error -
|
|
92
|
+
// Plain text error - treat it as the details line if short and useful.
|
|
60
93
|
const trimmed = responseBody.trim();
|
|
61
94
|
if (trimmed.length > 0 && trimmed.length < 500) {
|
|
62
|
-
|
|
95
|
+
bodyDetails = trimmed;
|
|
63
96
|
}
|
|
64
97
|
}
|
|
65
98
|
}
|
|
66
99
|
|
|
100
|
+
// A custom reason phrase (PTxyz message) is meaningful only when it differs
|
|
101
|
+
// from the stock HTTP reason phrase for this status.
|
|
102
|
+
const trimmedReason = statusText?.trim();
|
|
103
|
+
const reasonPhrase =
|
|
104
|
+
trimmedReason &&
|
|
105
|
+
trimmedReason !== STANDARD_REASON_PHRASES[status] &&
|
|
106
|
+
trimmedReason !== generic
|
|
107
|
+
? trimmedReason
|
|
108
|
+
: undefined;
|
|
109
|
+
|
|
110
|
+
const headline = bodyMessage ?? reasonPhrase ?? generic;
|
|
111
|
+
let errMsg = `${operation}: HTTP ${status} - ${headline}`;
|
|
112
|
+
if (bodyDetails && bodyDetails !== headline) {
|
|
113
|
+
errMsg += `\n${bodyDetails}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
67
116
|
return errMsg + remediation;
|
|
68
117
|
}
|
|
69
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Turn a low-level `fetch` failure into an actionable message. Node's fetch
|
|
121
|
+
* (undici) throws a `TypeError('fetch failed')` and stashes the real cause
|
|
122
|
+
* (`ECONNREFUSED`, DNS failure, `bad port`, TLS error, …) in `err.cause` — the
|
|
123
|
+
* opaque top-level message on its own tells the user nothing. This surfaces the
|
|
124
|
+
* cause and the URL that could not be reached, e.g.
|
|
125
|
+
* "Failed to list projects: could not reach http://127.0.0.1:1 (ECONNREFUSED)"
|
|
126
|
+
*/
|
|
127
|
+
export function describeFetchError(operation: string, url: string, err: unknown): string {
|
|
128
|
+
const cause = (err as { cause?: { code?: string; message?: string } } | null | undefined)?.cause;
|
|
129
|
+
const detail =
|
|
130
|
+
cause?.code ||
|
|
131
|
+
cause?.message ||
|
|
132
|
+
(err instanceof Error && err.message ? err.message : String(err));
|
|
133
|
+
return `${operation}: could not reach ${url} (${detail})`;
|
|
134
|
+
}
|
|
135
|
+
|
|
70
136
|
export function maskSecret(secret: string): string {
|
|
71
137
|
if (!secret) return "";
|
|
72
138
|
if (secret.length <= 8) return "****";
|
|
@@ -74,6 +140,47 @@ export function maskSecret(secret: string): string {
|
|
|
74
140
|
return `${secret.slice(0, Math.min(12, secret.length - 8))}${"*".repeat(Math.max(4, secret.length - 16))}${secret.slice(-4)}`;
|
|
75
141
|
}
|
|
76
142
|
|
|
143
|
+
/** Keys whose values are credentials: any `password`-containing key (db.password,
|
|
144
|
+
* db_password, dbPassword, …) and the DBLab connection string (`connStr`, which
|
|
145
|
+
* embeds the password). Matched case-insensitively at any depth. */
|
|
146
|
+
const SENSITIVE_LOG_KEY = /password|connstr/i;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Redact known credential fields from a serialized JSON payload before it is
|
|
150
|
+
* written to a debug log — the body-side counterpart of the `maskSecret`
|
|
151
|
+
* masking the `access-token` header already gets. Debug logging is reachable
|
|
152
|
+
* by MCP callers (`debug: true` is a caller-controlled tool argument), and
|
|
153
|
+
* DBLab bodies carry live credentials: `--db-password` rides in the clone
|
|
154
|
+
* create request (`data.db.password`), and clone create/status replies return
|
|
155
|
+
* the clone's `db.password` / `db.connStr`.
|
|
156
|
+
*
|
|
157
|
+
* A fixed placeholder is used instead of `maskSecret` because short passwords
|
|
158
|
+
* would leak most of their characters through partial masking. Non-JSON input
|
|
159
|
+
* is returned unchanged.
|
|
160
|
+
*/
|
|
161
|
+
export function redactSecretsForLog(text: string): string {
|
|
162
|
+
let parsed: unknown;
|
|
163
|
+
try {
|
|
164
|
+
parsed = JSON.parse(text);
|
|
165
|
+
} catch {
|
|
166
|
+
return text;
|
|
167
|
+
}
|
|
168
|
+
const walk = (node: unknown): unknown => {
|
|
169
|
+
if (Array.isArray(node)) {
|
|
170
|
+
return node.map(walk);
|
|
171
|
+
}
|
|
172
|
+
if (node && typeof node === "object") {
|
|
173
|
+
const out: Record<string, unknown> = {};
|
|
174
|
+
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
|
|
175
|
+
out[key] = SENSITIVE_LOG_KEY.test(key) && value != null ? "[REDACTED]" : walk(value);
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
return node;
|
|
180
|
+
};
|
|
181
|
+
return JSON.stringify(walk(parsed));
|
|
182
|
+
}
|
|
183
|
+
|
|
77
184
|
|
|
78
185
|
export interface RootOptsLike {
|
|
79
186
|
apiBaseUrl?: string;
|
package/package.json
CHANGED
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
|
-
|
package/test/aas-onboard.test.ts
CHANGED
|
@@ -198,6 +198,8 @@ describe("registerAasCollection", () => {
|
|
|
198
198
|
installFetch({ prometheusCount: n });
|
|
199
199
|
const r = await registerAasCollection("apikey-1", "inst-123", {
|
|
200
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,
|
|
201
203
|
});
|
|
202
204
|
expect(r.ok).toBe(false);
|
|
203
205
|
expect(r.reason).toContain("datasource");
|
|
@@ -205,6 +207,88 @@ describe("registerAasCollection", () => {
|
|
|
205
207
|
}
|
|
206
208
|
});
|
|
207
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
|
+
|
|
208
292
|
test("mint returning no key → ok:false, no RPC call", async () => {
|
|
209
293
|
installFetch({ mintKey: null });
|
|
210
294
|
const r = await registerAasCollection("apikey-1", "inst-123", {
|