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/dist/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
|
-
|
|
@@ -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,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hands-off AAS auto-onboarding for `mon local-install` (platform-all #338).
|
|
3
|
+
*
|
|
4
|
+
* After the monitoring stack is up and the instance is adopted, the CLI arms
|
|
5
|
+
* AAS collection without an operator step:
|
|
6
|
+
* 1. mint a `pgai-aas-collect` Grafana Viewer service-account token on the
|
|
7
|
+
* LOCAL Grafana (the CLI holds the admin password),
|
|
8
|
+
* 2. resolve the numeric Prometheus datasource id,
|
|
9
|
+
* 3. read the (cluster, node_name) labels straight from the pgwatch target
|
|
10
|
+
* config the CLI itself wrote (buildInstance's custom_tags) — no live
|
|
11
|
+
* series query, so no waiters>0 timing dependency,
|
|
12
|
+
* 4. hand all of it to the platform via the API-token RPC
|
|
13
|
+
* v1.monitoring_instance_aas_register, which encrypts the token and stores
|
|
14
|
+
* the AAS state keys (it makes no outbound Grafana call of its own).
|
|
15
|
+
*
|
|
16
|
+
* Best-effort, exactly like registerMonitoringInstance: never throws, returns a
|
|
17
|
+
* result the caller logs. The plaintext SA token only ever lives in locals.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { loadInstances } from "./instances";
|
|
21
|
+
import { resolveBaseUrls } from "./util";
|
|
22
|
+
|
|
23
|
+
const SA_NAME = "pgai-aas-collect";
|
|
24
|
+
|
|
25
|
+
/** Local Grafana base URL (published on the monitoring host). Overridable for tests/odd setups. */
|
|
26
|
+
function grafanaBaseUrl(): string {
|
|
27
|
+
return (process.env.PGAI_GRAFANA_LOCAL_URL || "http://localhost:3000").replace(/\/+$/, "");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function grafanaAdminUser(): string {
|
|
31
|
+
// The monitoring stack's compose hardcodes the Grafana admin user to
|
|
32
|
+
// "monitor" (GF_SECURITY_ADMIN_USER: monitor), so default to that rather than
|
|
33
|
+
// Grafana's stock "admin" — otherwise AAS arming logs in as the wrong user
|
|
34
|
+
// and every datasource lookup 401s. An explicit env override still wins.
|
|
35
|
+
return process.env.GF_SECURITY_ADMIN_USER || "monitor";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Parse a vcpus input (flag/env) to a non-negative integer; 0 = "unknown" fallback. */
|
|
39
|
+
export function parseVcpus(raw: string | number | undefined | null): number {
|
|
40
|
+
if (raw === undefined || raw === null || raw === "") return 0;
|
|
41
|
+
const n = typeof raw === "number" ? raw : parseInt(String(raw).trim(), 10);
|
|
42
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Read the single enabled target's (cluster, node_name) from the pgwatch
|
|
47
|
+
* instances file. Returns null when it can't be determined unambiguously
|
|
48
|
+
* (0 or >1 enabled targets) — AAS onboards exactly one (cluster, node) pair.
|
|
49
|
+
*/
|
|
50
|
+
export function resolveAasLabels(instancesPath: string): { cluster: string; node: string } | null {
|
|
51
|
+
let instances;
|
|
52
|
+
try {
|
|
53
|
+
instances = loadInstances(instancesPath);
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const enabled = instances.filter((i) => i.is_enabled !== false);
|
|
58
|
+
if (enabled.length !== 1) return null;
|
|
59
|
+
const tags = (enabled[0].custom_tags || {}) as Record<string, unknown>;
|
|
60
|
+
const cluster = typeof tags.cluster === "string" && tags.cluster ? tags.cluster : "default";
|
|
61
|
+
const node = typeof tags.node_name === "string" && tags.node_name ? tags.node_name : enabled[0].name;
|
|
62
|
+
if (!cluster || !node) return null;
|
|
63
|
+
return { cluster, node };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function grafanaApi(
|
|
67
|
+
method: string,
|
|
68
|
+
pathPart: string,
|
|
69
|
+
adminPassword: string,
|
|
70
|
+
body?: unknown
|
|
71
|
+
): Promise<Response> {
|
|
72
|
+
const auth = Buffer.from(`${grafanaAdminUser()}:${adminPassword}`).toString("base64");
|
|
73
|
+
return fetch(`${grafanaBaseUrl()}${pathPart}`, {
|
|
74
|
+
method,
|
|
75
|
+
headers: { "Content-Type": "application/json", Authorization: `Basic ${auth}` },
|
|
76
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Find-or-create the pgai-aas-collect Viewer service account on the local
|
|
82
|
+
* Grafana and mint a fresh glsa_ token. Returns the token or null on any failure.
|
|
83
|
+
*
|
|
84
|
+
* We deliberately do NOT prune prior tokens: deleting them here is racy — a
|
|
85
|
+
* concurrent or repeated install could delete the token the platform currently
|
|
86
|
+
* holds (stored encrypted), silently 401-ing collection until the next register.
|
|
87
|
+
* The unique mint name already avoids 409s, and orphaned Viewer tokens are
|
|
88
|
+
* benign; token hygiene is left to a separate, non-racy mechanism.
|
|
89
|
+
*/
|
|
90
|
+
export async function mintAasServiceAccountToken(
|
|
91
|
+
adminPassword: string,
|
|
92
|
+
debug = false
|
|
93
|
+
): Promise<string | null> {
|
|
94
|
+
const log = (m: string) => debug && console.error(`Debug: AAS SA mint: ${m}`);
|
|
95
|
+
try {
|
|
96
|
+
let saId: number | null = null;
|
|
97
|
+
|
|
98
|
+
const search = await grafanaApi("GET", `/api/serviceaccounts/search?query=${SA_NAME}`, adminPassword);
|
|
99
|
+
if (search.ok) {
|
|
100
|
+
const data = (await search.json().catch(() => null)) as { serviceAccounts?: Array<{ id?: unknown; name?: unknown }> } | null;
|
|
101
|
+
const found = (data?.serviceAccounts || []).find((s) => s.name === SA_NAME);
|
|
102
|
+
if (found && typeof found.id === "number") saId = found.id;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (saId == null) {
|
|
106
|
+
const created = await grafanaApi("POST", "/api/serviceaccounts", adminPassword, { name: SA_NAME, role: "Viewer" });
|
|
107
|
+
if (!created.ok) {
|
|
108
|
+
log(`create SA failed: HTTP ${created.status}`);
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
const cj = (await created.json().catch(() => null)) as { id?: unknown } | null;
|
|
112
|
+
if (typeof cj?.id !== "number") return null;
|
|
113
|
+
saId = cj.id;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Unique token name avoids a 409 on a pre-existing name (no prune needed).
|
|
117
|
+
const mint = await grafanaApi("POST", `/api/serviceaccounts/${saId}/tokens`, adminPassword, {
|
|
118
|
+
name: `aas-collect-${Date.now()}`,
|
|
119
|
+
role: "Viewer",
|
|
120
|
+
});
|
|
121
|
+
if (!mint.ok) {
|
|
122
|
+
log(`mint token failed: HTTP ${mint.status}`);
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
const mj = (await mint.json().catch(() => null)) as { key?: unknown } | null;
|
|
126
|
+
return typeof mj?.key === "string" ? mj.key : null;
|
|
127
|
+
} catch (err) {
|
|
128
|
+
log((err as Error).message);
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Resolve the single Prometheus-typed datasource's numeric id on the local
|
|
135
|
+
* Grafana. The monitoring stack's VictoriaMetrics datasource is type
|
|
136
|
+
* "prometheus" (VM speaks PromQL), and the stack registers exactly one such
|
|
137
|
+
* datasource — the same one the collector queries. 0 / API-not-ready → null
|
|
138
|
+
* (a provisioning transient — the readiness loop retries); >1 → "ambiguous"
|
|
139
|
+
* (a permanent misconfiguration — the loop stops at once), matching
|
|
140
|
+
* v1.aas_onboard's >1 skip.
|
|
141
|
+
*/
|
|
142
|
+
export async function resolveDatasourceId(adminPassword: string, debug = false): Promise<number | "ambiguous" | null> {
|
|
143
|
+
try {
|
|
144
|
+
const res = await grafanaApi("GET", "/api/datasources", adminPassword);
|
|
145
|
+
if (!res.ok) return null;
|
|
146
|
+
const list = (await res.json().catch(() => [])) as Array<{ id?: unknown; type?: unknown }>;
|
|
147
|
+
const prom = list.filter((d) => d.type === "prometheus");
|
|
148
|
+
if (prom.length > 1) {
|
|
149
|
+
// >1 is a permanent misconfiguration, not a provisioning transient: the
|
|
150
|
+
// datasource count only grows as Grafana provisions, so retrying can never
|
|
151
|
+
// resolve it. Signal a definitive skip so the readiness loop bails at once.
|
|
152
|
+
if (debug) console.error(`Debug: AAS: ${prom.length} prometheus datasources (ambiguous); not retrying`);
|
|
153
|
+
return "ambiguous";
|
|
154
|
+
}
|
|
155
|
+
if (prom.length === 0) {
|
|
156
|
+
if (debug) console.error(`Debug: AAS: no prometheus datasource resolvable yet`);
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
return typeof prom[0].id === "number" ? prom[0].id : null;
|
|
160
|
+
} catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface AasRegisterResult {
|
|
166
|
+
ok: boolean;
|
|
167
|
+
reason?: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Arm hands-off AAS collection for an adopted monitoring instance. Best-effort:
|
|
172
|
+
* never throws; returns {ok:false, reason} on any failure so the caller can log
|
|
173
|
+
* a non-fatal warning. Mirrors registerMonitoringInstance's API-call shape.
|
|
174
|
+
*/
|
|
175
|
+
export async function registerAasCollection(
|
|
176
|
+
apiKey: string,
|
|
177
|
+
instanceId: string,
|
|
178
|
+
opts: {
|
|
179
|
+
grafanaPassword: string;
|
|
180
|
+
instancesPath: string;
|
|
181
|
+
vcpus: number;
|
|
182
|
+
apiBaseUrl?: string;
|
|
183
|
+
debug?: boolean;
|
|
184
|
+
fetchImpl?: typeof fetch;
|
|
185
|
+
// Grafana-readiness polling for the datasource lookup (Grafana has just
|
|
186
|
+
// been started by `compose up`). Defaults: 20 attempts × 3s.
|
|
187
|
+
datasourceMaxAttempts?: number;
|
|
188
|
+
datasourceRetryDelayMs?: number;
|
|
189
|
+
}
|
|
190
|
+
): Promise<AasRegisterResult> {
|
|
191
|
+
const debug = !!opts.debug;
|
|
192
|
+
try {
|
|
193
|
+
if (!apiKey || !instanceId) return { ok: false, reason: "missing api key or instance id" };
|
|
194
|
+
|
|
195
|
+
const labels = resolveAasLabels(opts.instancesPath);
|
|
196
|
+
if (!labels) return { ok: false, reason: "could not determine a single (cluster, node_name) target" };
|
|
197
|
+
|
|
198
|
+
// Grafana was just started by `compose up`; it needs time to create its
|
|
199
|
+
// admin user, provision datasources, and serve its API. Querying too early
|
|
200
|
+
// makes the datasource lookup fail transiently, so poll until it resolves
|
|
201
|
+
// (best-effort, capped — the install never blocks on this).
|
|
202
|
+
const maxAttempts = opts.datasourceMaxAttempts ?? 20;
|
|
203
|
+
const retryDelayMs = opts.datasourceRetryDelayMs ?? 3000;
|
|
204
|
+
let datasourceId: number | null = null;
|
|
205
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
206
|
+
const resolved = await resolveDatasourceId(opts.grafanaPassword, debug);
|
|
207
|
+
if (typeof resolved === "number") { datasourceId = resolved; break; }
|
|
208
|
+
// "ambiguous" (>1 prometheus datasource) is permanent — retrying can't fix
|
|
209
|
+
// it, so stop polling immediately instead of waiting out the whole budget.
|
|
210
|
+
if (resolved === "ambiguous") break;
|
|
211
|
+
if (attempt < maxAttempts) {
|
|
212
|
+
if (debug) console.error(`Debug: AAS: datasource not resolvable yet (attempt ${attempt}/${maxAttempts}); waiting for Grafana…`);
|
|
213
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (datasourceId == null) return { ok: false, reason: "could not resolve the Prometheus datasource id" };
|
|
217
|
+
|
|
218
|
+
const saToken = await mintAasServiceAccountToken(opts.grafanaPassword, debug);
|
|
219
|
+
if (!saToken) return { ok: false, reason: "could not mint a Grafana service-account token" };
|
|
220
|
+
|
|
221
|
+
const { apiBaseUrl } = resolveBaseUrls({ apiBaseUrl: opts.apiBaseUrl });
|
|
222
|
+
const url = `${apiBaseUrl}/rpc/monitoring_instance_aas_register`;
|
|
223
|
+
const doFetch = opts.fetchImpl || fetch;
|
|
224
|
+
if (debug) console.error(`Debug: AAS: POST ${url} (cluster=${labels.cluster}, node=${labels.node}, vcpus=${opts.vcpus}, ds=${datasourceId})`);
|
|
225
|
+
|
|
226
|
+
const res = await doFetch(url, {
|
|
227
|
+
method: "POST",
|
|
228
|
+
headers: { "Content-Type": "application/json" },
|
|
229
|
+
body: JSON.stringify({
|
|
230
|
+
api_token: apiKey,
|
|
231
|
+
instance_id: instanceId,
|
|
232
|
+
sa_token: saToken,
|
|
233
|
+
cluster_name: labels.cluster,
|
|
234
|
+
node_name: labels.node,
|
|
235
|
+
vcpus: opts.vcpus,
|
|
236
|
+
datasource_id: datasourceId,
|
|
237
|
+
}),
|
|
238
|
+
});
|
|
239
|
+
if (!res.ok) {
|
|
240
|
+
// Log status only — never the response body: a platform could echo the
|
|
241
|
+
// request payload (incl. sa_token) in an error body, which must not reach
|
|
242
|
+
// the user's debug log.
|
|
243
|
+
if (debug) console.error(`Debug: AAS register failed: HTTP ${res.status}`);
|
|
244
|
+
return { ok: false, reason: `platform returned HTTP ${res.status}` };
|
|
245
|
+
}
|
|
246
|
+
return { ok: true };
|
|
247
|
+
} catch (err) {
|
|
248
|
+
if (debug) console.error(`Debug: AAS register error: ${(err as Error).message}`);
|
|
249
|
+
return { ok: false, reason: (err as Error).message };
|
|
250
|
+
}
|
|
251
|
+
}
|
package/lib/init.ts
CHANGED
|
@@ -924,14 +924,6 @@ export async function verifyInitSetup(params: {
|
|
|
924
924
|
}
|
|
925
925
|
|
|
926
926
|
// Check for helper functions
|
|
927
|
-
const explainFnRes = await params.client.query(
|
|
928
|
-
"select has_function_privilege($1, 'postgres_ai.explain_generic(text, text, text)', 'EXECUTE') as ok",
|
|
929
|
-
[role]
|
|
930
|
-
);
|
|
931
|
-
if (!explainFnRes.rows?.[0]?.ok) {
|
|
932
|
-
missingRequired.push("EXECUTE on postgres_ai.explain_generic(text, text, text)");
|
|
933
|
-
}
|
|
934
|
-
|
|
935
927
|
const tableDescribeFnRes = await params.client.query(
|
|
936
928
|
"select has_function_privilege($1, 'postgres_ai.table_describe(text)', 'EXECUTE') as ok",
|
|
937
929
|
[role]
|
package/lib/supabase.ts
CHANGED
|
@@ -733,24 +733,6 @@ export async function verifyInitSetupViaSupabase(params: {
|
|
|
733
733
|
}
|
|
734
734
|
|
|
735
735
|
// Check helper functions - first verify they exist to avoid has_function_privilege errors
|
|
736
|
-
const explainFnExistsRes = await params.client.query(
|
|
737
|
-
"SELECT oid FROM pg_proc WHERE proname = 'explain_generic' AND pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'postgres_ai')",
|
|
738
|
-
true
|
|
739
|
-
);
|
|
740
|
-
if (explainFnExistsRes.rowCount === 0) {
|
|
741
|
-
missingRequired.push("function postgres_ai.explain_generic exists");
|
|
742
|
-
} else {
|
|
743
|
-
const explainFnRes = await params.client.query(
|
|
744
|
-
`SELECT has_function_privilege('${escapeLiteral(role)}', 'postgres_ai.explain_generic(text, text, text)', 'EXECUTE') as ok`,
|
|
745
|
-
true
|
|
746
|
-
);
|
|
747
|
-
if (!explainFnRes.rows?.[0]?.ok) {
|
|
748
|
-
missingRequired.push(
|
|
749
|
-
"EXECUTE on postgres_ai.explain_generic(text, text, text)"
|
|
750
|
-
);
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
|
|
754
736
|
const tableDescribeFnExistsRes = await params.client.query(
|
|
755
737
|
"SELECT oid FROM pg_proc WHERE proname = 'table_describe' AND pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'postgres_ai')",
|
|
756
738
|
true
|