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.
@@ -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
-
@@ -28,7 +28,11 @@ function grafanaBaseUrl(): string {
28
28
  }
29
29
 
30
30
  function grafanaAdminUser(): string {
31
- return process.env.GF_SECURITY_ADMIN_USER || "admin";
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";
32
36
  }
33
37
 
34
38
  /** Parse a vcpus input (flag/env) to a non-negative integer; 0 = "unknown" fallback. */
@@ -130,17 +134,26 @@ export async function mintAasServiceAccountToken(
130
134
  * Resolve the single Prometheus-typed datasource's numeric id on the local
131
135
  * Grafana. The monitoring stack's VictoriaMetrics datasource is type
132
136
  * "prometheus" (VM speaks PromQL), and the stack registers exactly one such
133
- * datasource — the same one the collector queries. >1 or 0 → null (skip),
134
- * matching v1.aas_onboard's discovery contract.
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.
135
141
  */
136
- export async function resolveDatasourceId(adminPassword: string, debug = false): Promise<number | null> {
142
+ export async function resolveDatasourceId(adminPassword: string, debug = false): Promise<number | "ambiguous" | null> {
137
143
  try {
138
144
  const res = await grafanaApi("GET", "/api/datasources", adminPassword);
139
145
  if (!res.ok) return null;
140
146
  const list = (await res.json().catch(() => [])) as Array<{ id?: unknown; type?: unknown }>;
141
147
  const prom = list.filter((d) => d.type === "prometheus");
142
- if (prom.length !== 1) {
143
- if (debug) console.error(`Debug: AAS: expected 1 prometheus datasource, found ${prom.length}`);
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`);
144
157
  return null;
145
158
  }
146
159
  return typeof prom[0].id === "number" ? prom[0].id : null;
@@ -169,6 +182,10 @@ export async function registerAasCollection(
169
182
  apiBaseUrl?: string;
170
183
  debug?: boolean;
171
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;
172
189
  }
173
190
  ): Promise<AasRegisterResult> {
174
191
  const debug = !!opts.debug;
@@ -178,7 +195,24 @@ export async function registerAasCollection(
178
195
  const labels = resolveAasLabels(opts.instancesPath);
179
196
  if (!labels) return { ok: false, reason: "could not determine a single (cluster, node_name) target" };
180
197
 
181
- const datasourceId = await resolveDatasourceId(opts.grafanaPassword, debug);
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
+ }
182
216
  if (datasourceId == null) return { ok: false, reason: "could not resolve the Prometheus datasource id" };
183
217
 
184
218
  const saToken = await mintAasServiceAccountToken(opts.grafanaPassword, debug);