postgresai 0.17.0-dev.4 → 0.17.0-dev.6
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 +48 -7
- package/dist/bin/postgres-ai.js +1003 -289
- package/package.json +1 -1
package/dist/bin/postgres-ai.js
CHANGED
|
@@ -13533,7 +13533,7 @@ var {
|
|
|
13533
13533
|
// package.json
|
|
13534
13534
|
var package_default = {
|
|
13535
13535
|
name: "postgresai",
|
|
13536
|
-
version: "0.17.0-dev.
|
|
13536
|
+
version: "0.17.0-dev.6",
|
|
13537
13537
|
description: "postgres_ai CLI",
|
|
13538
13538
|
license: "Apache-2.0",
|
|
13539
13539
|
private: false,
|
|
@@ -16893,7 +16893,7 @@ var Result = import_lib.default.Result;
|
|
|
16893
16893
|
var TypeOverrides = import_lib.default.TypeOverrides;
|
|
16894
16894
|
var defaults = import_lib.default.defaults;
|
|
16895
16895
|
// package.json
|
|
16896
|
-
var version = "0.17.0-dev.
|
|
16896
|
+
var version = "0.17.0-dev.6";
|
|
16897
16897
|
var package_default2 = {
|
|
16898
16898
|
name: "postgresai",
|
|
16899
16899
|
version,
|
|
@@ -17169,6 +17169,22 @@ class HttpStatusError extends Error {
|
|
|
17169
17169
|
function isRetryableHttpStatus(status) {
|
|
17170
17170
|
return status >= 500 || status === 429;
|
|
17171
17171
|
}
|
|
17172
|
+
function isUnknownRpcSignature(status, text, rpc) {
|
|
17173
|
+
if (status !== 404)
|
|
17174
|
+
return false;
|
|
17175
|
+
try {
|
|
17176
|
+
const parsed = JSON.parse(text);
|
|
17177
|
+
return typeof parsed.message === "string" && /Could not find the .* function/.test(parsed.message) && parsed.message.includes(`${rpc}(`);
|
|
17178
|
+
} catch {
|
|
17179
|
+
return false;
|
|
17180
|
+
}
|
|
17181
|
+
}
|
|
17182
|
+
function positiveSeconds(v) {
|
|
17183
|
+
return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : null;
|
|
17184
|
+
}
|
|
17185
|
+
function asyncPollDelayMs(attempt) {
|
|
17186
|
+
return Math.min(1000 * 2 ** attempt, 15000);
|
|
17187
|
+
}
|
|
17172
17188
|
var DEFAULT_HTTP_REQUEST_TIMEOUT_MS = 25000;
|
|
17173
17189
|
|
|
17174
17190
|
class HttpRequestTimeoutError extends Error {
|
|
@@ -21466,7 +21482,8 @@ function summarizeF001(nodeData) {
|
|
|
21466
21482
|
return `Autovacuum config: ${parts.join(", ")} finding${fired.length > 1 ? "s" : ""}`;
|
|
21467
21483
|
};
|
|
21468
21484
|
if (severity === "CRITICAL" || severity === "WARNING") {
|
|
21469
|
-
|
|
21485
|
+
const top = fired.find((r) => r.severity === "CRITICAL") ?? fired.find((r) => r.severity === "WARNING");
|
|
21486
|
+
return { status: "warning", message: top?.conclusion ? `${summarize()}. ${top.conclusion}` : summarize() };
|
|
21470
21487
|
}
|
|
21471
21488
|
return { status: "info", message: summarize() };
|
|
21472
21489
|
}
|
|
@@ -23958,6 +23975,38 @@ function withCheckSummary(report) {
|
|
|
23958
23975
|
return report;
|
|
23959
23976
|
}
|
|
23960
23977
|
|
|
23978
|
+
// lib/checkup-upload.ts
|
|
23979
|
+
function responseId(fn, response, ...keys) {
|
|
23980
|
+
const answer = response ?? {};
|
|
23981
|
+
const id = Number(keys.map((key) => answer[key]).find((value) => value != null));
|
|
23982
|
+
if (!Number.isFinite(id) || id <= 0) {
|
|
23983
|
+
throw new Error(typeof answer.message === "string" ? answer.message : `Unexpected ${fn} response: ${JSON.stringify(response)}`);
|
|
23984
|
+
}
|
|
23985
|
+
return id;
|
|
23986
|
+
}
|
|
23987
|
+
async function createCheckupReport(rpc, params) {
|
|
23988
|
+
const fn = "checkup_report_create";
|
|
23989
|
+
const response = await rpc(fn, {
|
|
23990
|
+
access_token: params.apiKey,
|
|
23991
|
+
project: params.project,
|
|
23992
|
+
...params.status ? { status: params.status } : {}
|
|
23993
|
+
});
|
|
23994
|
+
return { reportId: responseId(fn, response, "report_id") };
|
|
23995
|
+
}
|
|
23996
|
+
async function uploadCheckupReportJson(rpc, params) {
|
|
23997
|
+
const fn = "checkup_report_file_post";
|
|
23998
|
+
const response = await rpc(fn, {
|
|
23999
|
+
access_token: params.apiKey,
|
|
24000
|
+
checkup_report_id: params.reportId,
|
|
24001
|
+
filename: params.filename,
|
|
24002
|
+
check_id: params.checkId,
|
|
24003
|
+
data: params.jsonText,
|
|
24004
|
+
type: "json",
|
|
24005
|
+
generate_issue: true
|
|
24006
|
+
});
|
|
24007
|
+
return { reportChunkId: responseId(fn, response, "report_chunck_id", "report_chunk_id") };
|
|
24008
|
+
}
|
|
24009
|
+
|
|
23961
24010
|
// lib/host-metrics.ts
|
|
23962
24011
|
import { createHash } from "node:crypto";
|
|
23963
24012
|
import { chmodSync as chmodSync3, lstatSync, mkdirSync as mkdirSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "node:fs";
|
|
@@ -27817,46 +27866,58 @@ async function verifyInitSetup(params) {
|
|
|
27817
27866
|
}
|
|
27818
27867
|
|
|
27819
27868
|
// lib/joe.ts
|
|
27869
|
+
var JOE_ASYNC_FALLBACK_WAIT_MS = 10 * 60 * 1000;
|
|
27870
|
+
var JOE_ASYNC_MAX_WAIT_MS = 60 * 60 * 1000;
|
|
27820
27871
|
async function callRpc(params) {
|
|
27872
|
+
return (await callRpcRaw(params)).data;
|
|
27873
|
+
}
|
|
27874
|
+
async function callRpcRaw(params) {
|
|
27821
27875
|
const { apiKey, apiBaseUrl, fn, body, operation, debug } = params;
|
|
27822
27876
|
if (!apiKey) {
|
|
27823
27877
|
throw new Error("API key is required");
|
|
27824
27878
|
}
|
|
27825
27879
|
const base = normalizeBaseUrl(apiBaseUrl);
|
|
27826
27880
|
const url = new URL(`${base}/rpc/${fn}`);
|
|
27827
|
-
const payload = JSON.stringify(body);
|
|
27828
27881
|
const headers = buildAuthHeaders(apiKey, params.orgScope);
|
|
27829
|
-
|
|
27830
|
-
const
|
|
27831
|
-
|
|
27832
|
-
|
|
27833
|
-
|
|
27834
|
-
|
|
27835
|
-
|
|
27836
|
-
|
|
27837
|
-
|
|
27838
|
-
|
|
27839
|
-
|
|
27840
|
-
|
|
27841
|
-
|
|
27842
|
-
|
|
27843
|
-
|
|
27844
|
-
|
|
27845
|
-
|
|
27846
|
-
|
|
27882
|
+
const send = async (bodyObj) => {
|
|
27883
|
+
const payload = JSON.stringify(bodyObj);
|
|
27884
|
+
if (debug) {
|
|
27885
|
+
const debugHeaders = { ...headers, "access-token": maskSecret(apiKey) };
|
|
27886
|
+
console.error(`Debug: POST URL: ${url.toString()}`);
|
|
27887
|
+
console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
|
|
27888
|
+
console.error(`Debug: Request body: ${redactSecretsForLog(payload)}`);
|
|
27889
|
+
}
|
|
27890
|
+
let response;
|
|
27891
|
+
const requestTimeout = requestTimeoutSignal(params.timeoutMs);
|
|
27892
|
+
try {
|
|
27893
|
+
response = await fetch(url.toString(), {
|
|
27894
|
+
method: "POST",
|
|
27895
|
+
headers,
|
|
27896
|
+
body: payload,
|
|
27897
|
+
signal: requestTimeout.signal
|
|
27898
|
+
});
|
|
27899
|
+
} catch (err) {
|
|
27900
|
+
if (isFetchTimeout(err)) {
|
|
27901
|
+
throw new HttpRequestTimeoutError(operation, requestTimeout.timeoutMs);
|
|
27902
|
+
}
|
|
27903
|
+
throw new Error(describeFetchError(operation, base, err));
|
|
27847
27904
|
}
|
|
27848
|
-
|
|
27849
|
-
|
|
27850
|
-
|
|
27851
|
-
|
|
27852
|
-
|
|
27853
|
-
|
|
27905
|
+
const text = await response.text();
|
|
27906
|
+
if (debug) {
|
|
27907
|
+
console.error(`Debug: Response status: ${response.status}`);
|
|
27908
|
+
console.error(`Debug: Response body: ${redactSecretsForLog(text)}`);
|
|
27909
|
+
}
|
|
27910
|
+
return { response, text };
|
|
27911
|
+
};
|
|
27912
|
+
let { response, text } = await send(body);
|
|
27913
|
+
if (params.fallbackBody && isUnknownRpcSignature(response.status, text, fn)) {
|
|
27914
|
+
({ response, text } = await send(params.fallbackBody));
|
|
27854
27915
|
}
|
|
27855
27916
|
if (!response.ok) {
|
|
27856
27917
|
throw new HttpStatusError(formatHttpError(operation, response.status, text, response.statusText), response.status);
|
|
27857
27918
|
}
|
|
27858
27919
|
try {
|
|
27859
|
-
return JSON.parse(text);
|
|
27920
|
+
return { data: JSON.parse(text), status: response.status };
|
|
27860
27921
|
} catch {
|
|
27861
27922
|
throw new Error(`${operation}: failed to parse response: ${redactSecretsForLog(text)}`);
|
|
27862
27923
|
}
|
|
@@ -27905,6 +27966,25 @@ function isNumericProjectRef(ref) {
|
|
|
27905
27966
|
}
|
|
27906
27967
|
|
|
27907
27968
|
// lib/connect.ts
|
|
27969
|
+
var money = (cents, currency) => currency.toLowerCase() === "usd" ? `$${(cents / 100).toFixed(2)}` : `${(cents / 100).toFixed(2)} ${currency.toUpperCase()}`;
|
|
27970
|
+
function priceText(q) {
|
|
27971
|
+
if (!q.billed && q.same_cluster)
|
|
27972
|
+
return `included: same database cluster as ${q.same_cluster}, no extra charge`;
|
|
27973
|
+
if (!q.billed)
|
|
27974
|
+
return q.free_slots.total ? `free (${q.free_slots.remaining} of ${q.free_slots.total} free slots)` : "free";
|
|
27975
|
+
const { amount, currency, interval } = q.price;
|
|
27976
|
+
const base = `${money(amount, currency)}/${interval} per database cluster (${q.plan} plan)${q.subscription ? `, cluster ${q.quantity + 1} on the subscription` : ""}`;
|
|
27977
|
+
if (q.promo?.valid && q.amount_after_promo !== undefined && q.amount_after_promo !== null) {
|
|
27978
|
+
const after = `${money(q.amount_after_promo, currency)}`;
|
|
27979
|
+
const how = `with ${q.promo.code} (${q.promo.discount_description})`;
|
|
27980
|
+
if (q.promo.duration === "forever")
|
|
27981
|
+
return `${after}/${interval} ${how}, instead of ${base}`;
|
|
27982
|
+
if (q.promo.duration === "repeating")
|
|
27983
|
+
return `${after}/${interval} for ${q.promo.duration_in_months} months ${how}, then ${base}`;
|
|
27984
|
+
return `${after} the first ${interval} ${how}, then ${base}`;
|
|
27985
|
+
}
|
|
27986
|
+
return base;
|
|
27987
|
+
}
|
|
27908
27988
|
var FIRST_CHECKUP_DELAY_MS = 30 * 60000;
|
|
27909
27989
|
var POLL_MS = 15000;
|
|
27910
27990
|
var PROVIDERS = ["clickhouse", "rds", "supabase", "self-managed"];
|
|
@@ -27960,8 +28040,12 @@ function stateOf(raw) {
|
|
|
27960
28040
|
|
|
27961
28041
|
class ClickhouseKeyError extends Error {
|
|
27962
28042
|
}
|
|
27963
|
-
var serverOf = (name) => name.split("/")[0];
|
|
27964
|
-
|
|
28043
|
+
var serverOf = (name) => name.split("/")[0].toLowerCase().replace(/\.(?=$|:)/, "");
|
|
28044
|
+
function clusterOf(url) {
|
|
28045
|
+
const u = parseUrl(url);
|
|
28046
|
+
return u?.hostname ? `${u.hostname.toLowerCase()}:${u.port || "5432"}` : undefined;
|
|
28047
|
+
}
|
|
28048
|
+
var errorText = (err) => err instanceof AggregateError && !err.message ? err.errors.map(errorText).join("; ") : err instanceof Error ? err.message : String(err);
|
|
27965
28049
|
var urlPassword = (u) => decodeURIComponent(u.password) || u.searchParams.get("password") || "";
|
|
27966
28050
|
var URL_PARAMS_KEPT = ["sslmode", "channel_binding", "application_name"];
|
|
27967
28051
|
var URL_PARAMS_TLS = ["sslrootcert", "sslcert", "sslkey", "uselibpqcompat"];
|
|
@@ -27976,6 +28060,22 @@ function checkUrlParams(url, agent) {
|
|
|
27976
28060
|
if (moved.length)
|
|
27977
28061
|
throw new Error(`The URL's query string sets ${moved.join(" and ")}: put the host and the port in the URL itself (postgresql://user:password@host:5432/dbname), so that the server prepared is the server monitored`);
|
|
27978
28062
|
}
|
|
28063
|
+
function storedPasswordNeeds(monitoringUrl) {
|
|
28064
|
+
const query = new URL(monitoringUrl).search.replace(/^\?/, "");
|
|
28065
|
+
const parts = query ? query.split("&") : [];
|
|
28066
|
+
const keys = parts.map((p) => p.split("=")[0]);
|
|
28067
|
+
const twice = URL_PARAMS_KEPT.filter((k) => keys.indexOf(k) !== keys.lastIndexOf(k));
|
|
28068
|
+
const tls = !twice.includes("sslmode") && parts.some((p) => /^sslmode=(require|verify-ca|verify-full)$/.test(p));
|
|
28069
|
+
const needs = [
|
|
28070
|
+
...tls ? [] : [`sslmode=require (or verify-full)${twice.includes("sslmode") ? ", once" : ""}`],
|
|
28071
|
+
...twice.filter((k) => k !== "sslmode").map((k) => `${k} once`),
|
|
28072
|
+
...parts.some((p) => !p) ? ["no stray &"] : [],
|
|
28073
|
+
...keys.some((k) => k && !URL_PARAMS_KEPT.includes(k)) ? ["the parameter names without %-escapes"] : []
|
|
28074
|
+
];
|
|
28075
|
+
return needs.length ? needs.join(" and ") : undefined;
|
|
28076
|
+
}
|
|
28077
|
+
var shellWord = (s) => /^[\w./:@%+=,-]+$/.test(s) ? s : `'${s.replace(/'/g, "'\\''")}'`;
|
|
28078
|
+
var disconnectEach = (names) => names.map((n) => `pgai disconnect ${shellWord(n)} --yes`).join(" and ");
|
|
27979
28079
|
function roleUrlFor(url, db, password, kept) {
|
|
27980
28080
|
const u = new URL(url);
|
|
27981
28081
|
u.username = DEFAULT_MONITORING_USER;
|
|
@@ -27984,6 +28084,7 @@ function roleUrlFor(url, db, password, kept) {
|
|
|
27984
28084
|
for (const name of [...u.searchParams.keys()])
|
|
27985
28085
|
if (!kept.includes(name))
|
|
27986
28086
|
u.searchParams.delete(name);
|
|
28087
|
+
u.search = u.search.replace(/@/g, "%40");
|
|
27987
28088
|
return u.toString();
|
|
27988
28089
|
}
|
|
27989
28090
|
var monitoringUrlFor = (url, db, password) => roleUrlFor(url, db, password, URL_PARAMS_KEPT);
|
|
@@ -28023,15 +28124,40 @@ function checkupLines(c) {
|
|
|
28023
28124
|
return [`Express checkup could not run: ${c.error}`];
|
|
28024
28125
|
const warnings = c.findings.filter((f) => f.status === "warning");
|
|
28025
28126
|
const ok = c.findings.filter((f) => f.status === "ok");
|
|
28127
|
+
const failed = c.failed ?? [];
|
|
28128
|
+
const counts = [`${warnings.length} warning${warnings.length === 1 ? "" : "s"}`, `${ok.length} ok`, `${c.info.length} info`, ...failed.length ? [`${failed.length} could not run`] : []];
|
|
28026
28129
|
return [
|
|
28027
|
-
`Express checkup while the box starts (${c.checks} checks: ${
|
|
28130
|
+
`Express checkup while the box starts (${c.checks} checks: ${counts.join(", ")}):`,
|
|
28028
28131
|
...warnings.map((f) => ` ${f.check_id} ${f.title}: ${f.message}`),
|
|
28029
28132
|
...ok.length ? [` ok: ${ok.map((f) => f.check_id).join(" ")}`] : [],
|
|
28030
|
-
...c.
|
|
28133
|
+
...c.info.length ? [` info: ${c.info.join(" ")}`] : [],
|
|
28134
|
+
...failed.length ? [` could not run: ${failed.join(" ")}`] : [],
|
|
28135
|
+
c.report_id ? `Saved as report ${c.report_id}: pgai reports files ${c.report_id}` : `Not saved to PostgresAI: ${c.upload_error}`,
|
|
28031
28136
|
"The full checkup (query analysis and trends) follows on the box."
|
|
28032
28137
|
];
|
|
28033
28138
|
}
|
|
28139
|
+
var boxState = (row) => row.status === "launch_requested" && row.registered_at ? "registered" : row.status ?? "starting";
|
|
28140
|
+
async function billingFor(q, name, opts, deps, show) {
|
|
28141
|
+
const billing = { price: priceText(q), requires_payment_method: q.requires_payment_method };
|
|
28142
|
+
if (opts.coupon) {
|
|
28143
|
+
billing.coupon = q.promo?.valid ? { code: q.promo.code, valid: true, description: q.promo.discount_description } : { code: opts.coupon, valid: false, error: q.promo?.error ?? "not valid" };
|
|
28144
|
+
}
|
|
28145
|
+
const billingPage = { ...billing, requires_payment_method: true, next: `Add a payment method at ${q.billing_url ?? deps.billingUrl(q.org_alias)}, then re-run` };
|
|
28146
|
+
const stop = (next) => ({ billing, billingPage, accepted: false, stop: { ...billing, next } });
|
|
28147
|
+
if (billing.coupon && !billing.coupon.valid) {
|
|
28148
|
+
return stop(`Promo code ${opts.coupon}: ${billing.coupon.error}. Nothing was changed: re-run with a valid code, or without ${opts.agent ? "coupon" : "--coupon"}`);
|
|
28149
|
+
}
|
|
28150
|
+
if (q.requires_payment_method)
|
|
28151
|
+
return { billing, billingPage, accepted: false, stop: billingPage };
|
|
28152
|
+
show(billing.price);
|
|
28153
|
+
if (q.billed && !opts.yes && !await deps.confirm(`Provision ${name}? (y/N): `)) {
|
|
28154
|
+
return stop(opts.agent ? `Call connect_database again with yes: true to accept ${billing.price}` : `Re-run with --yes to accept ${billing.price}`);
|
|
28155
|
+
}
|
|
28156
|
+
return { billing, billingPage, accepted: q.billed };
|
|
28157
|
+
}
|
|
28034
28158
|
async function connect(url, opts, deps) {
|
|
28159
|
+
const started = deps.now();
|
|
28160
|
+
const progress = (event, message, more = {}) => deps.progress({ event, elapsed_s: Math.round((deps.now() - started) / 1000), message, ...more });
|
|
28035
28161
|
const provider = opts.provider ?? detectCloudProvider(url);
|
|
28036
28162
|
if (!PROVIDERS.includes(provider))
|
|
28037
28163
|
throw new Error(`--provider must be one of: ${PROVIDERS.join(", ")}`);
|
|
@@ -28047,13 +28173,16 @@ async function connect(url, opts, deps) {
|
|
|
28047
28173
|
return { status: "action_required", provider, name, next: "--reset-password works with PostgresAI Cloud only (it checks what else monitors this server): connect without --self-hosted, or set PGAI_MON_PASSWORD" };
|
|
28048
28174
|
}
|
|
28049
28175
|
if (opts.selfHosted && deps.localStackRunning()) {
|
|
28050
|
-
return { status: "action_required", provider, name, next: "A monitoring stack already runs on this machine: add the database with pgai mon targets add
|
|
28176
|
+
return { status: "action_required", provider, name, next: "A monitoring stack already runs on this machine: add the database with PGAI_DB_URL='<postgres_ai_mon URL>' pgai mon targets add" };
|
|
28051
28177
|
}
|
|
28052
28178
|
const rows = opts.selfHosted ? [] : await deps.list();
|
|
28053
28179
|
let row = rows.find((d) => d.name === name && !disconnecting(d.status));
|
|
28054
28180
|
const fresh = !row;
|
|
28055
28181
|
let note = "";
|
|
28056
28182
|
let checkup;
|
|
28183
|
+
let billing = {};
|
|
28184
|
+
let billingPage;
|
|
28185
|
+
let accepted = false;
|
|
28057
28186
|
const findOrg = async () => {
|
|
28058
28187
|
try {
|
|
28059
28188
|
return await deps.clickhouseOrg(new URL(url).hostname, key.keyId, key.keySecret);
|
|
@@ -28082,62 +28211,154 @@ async function connect(url, opts, deps) {
|
|
|
28082
28211
|
}
|
|
28083
28212
|
ch = found;
|
|
28084
28213
|
}
|
|
28085
|
-
|
|
28086
|
-
|
|
28087
|
-
if (others.length) {
|
|
28088
|
-
return { status: "action_required", provider, name, next: `A new password for ${DEFAULT_MONITORING_USER} would cut off the monitoring of ${others.map((d) => d.name).join(", ")} on this server: set PGAI_MON_PASSWORD to its password instead` };
|
|
28089
|
-
}
|
|
28090
|
-
}
|
|
28091
|
-
deps.progress(`Preparing ${maskConnectionString(url)}`);
|
|
28092
|
-
const prepared = await deps.prepare(url, provider, { resetPassword: opts.resetPassword });
|
|
28093
|
-
if ("next" in prepared)
|
|
28094
|
-
return { status: "action_required", provider, name, ...prepared };
|
|
28095
|
-
if (prepared.note)
|
|
28096
|
-
note = `; ${prepared.note}`;
|
|
28097
|
-
if (opts.selfHosted) {
|
|
28098
|
-
await deps.selfHosted(prepared.monitoringUrl, key && ch ? { CLICKHOUSE_ORG_ID: ch.orgId, CLICKHOUSE_KEY_ID: key.keyId, CLICKHOUSE_KEY_SECRET: key.keySecret } : {});
|
|
28099
|
-
return { status: "connected", provider, name, dashboard_url: "http://localhost:3000", host_metrics: !!ch, next: `pgai mon health${note}` };
|
|
28100
|
-
}
|
|
28101
|
-
deps.progress(`Provisioning monitoring for ${name}`);
|
|
28102
|
-
const undo = async () => {
|
|
28103
|
-
if (prepared.generated)
|
|
28214
|
+
const undo = async (p) => {
|
|
28215
|
+
if ("generated" in p && p.generated)
|
|
28104
28216
|
await deps.unprepare(url).catch(() => false);
|
|
28105
28217
|
};
|
|
28106
|
-
|
|
28107
|
-
|
|
28108
|
-
|
|
28109
|
-
|
|
28110
|
-
|
|
28111
|
-
|
|
28112
|
-
|
|
28113
|
-
|
|
28114
|
-
|
|
28115
|
-
|
|
28116
|
-
|
|
28117
|
-
|
|
28218
|
+
let lockId;
|
|
28219
|
+
const unread = (d) => /^Monitoring [0-9a-f]{6}$/.test(d.name);
|
|
28220
|
+
const othersOnServer = (list) => list.filter((d) => d.name !== name && !disconnecting(d.status) && (unread(d) || serverOf(d.name) === serverOf(name)));
|
|
28221
|
+
const storedPassword = rows.some((d) => d.monitoring_password_stored && !/delet/.test(d.status ?? "") && serverOf(d.name) === serverOf(name)) || undefined;
|
|
28222
|
+
const cutOff = (others) => {
|
|
28223
|
+
const on = others.filter((d) => !unread(d)).map((d) => d.name).join(", ");
|
|
28224
|
+
const maybe = others.filter(unread).map((d) => d.name).join(", ");
|
|
28225
|
+
const what = [on && `would cut off the monitoring of ${on} on this server`, maybe && `may cut off the monitoring of ${maybe} (the platform could not read its URL, so its server is not known)`].filter(Boolean).join(", and ");
|
|
28226
|
+
const instead = [
|
|
28227
|
+
...storedPassword ? ["connect without --reset-password (PostgresAI keeps its password for this server, and sends it only over TLS)"] : [],
|
|
28228
|
+
"set PGAI_MON_PASSWORD to its password instead",
|
|
28229
|
+
`${disconnectEach(others.map((d) => d.name))} first`
|
|
28230
|
+
];
|
|
28231
|
+
return { status: "action_required", provider, name, next: `A new password for ${DEFAULT_MONITORING_USER} ${what}: ${instead.join(", or ")}` };
|
|
28232
|
+
};
|
|
28233
|
+
const others = othersOnServer(rows).filter((d) => !unread(d)).map((d) => d.name);
|
|
28234
|
+
if (opts.resetPassword && othersOnServer(rows).length)
|
|
28235
|
+
return cutOff(othersOnServer(rows));
|
|
28236
|
+
if (opts.coupon !== undefined && !opts.coupon.trim()) {
|
|
28237
|
+
return { status: "action_required", provider, name, next: `${opts.agent ? "coupon" : "--coupon"} is empty: pass a promotion code, or leave ${opts.agent ? "coupon" : "--coupon"} out` };
|
|
28238
|
+
}
|
|
28239
|
+
const prepOpts = { resetPassword: opts.resetPassword, ...storedPassword ? { storedPassword, others } : {} };
|
|
28240
|
+
if (!opts.selfHosted) {
|
|
28241
|
+
const probe = await deps.prepare(url, provider, { ...prepOpts, check: true });
|
|
28242
|
+
if ("next" in probe)
|
|
28243
|
+
return { status: "action_required", provider, name, ...probe };
|
|
28244
|
+
const b = await billingFor(await deps.quote(opts.coupon, clusterOf(url)), name, opts, deps, (price) => progress("billing", `Billing: ${price}`));
|
|
28245
|
+
if (b.stop)
|
|
28246
|
+
return { status: "action_required", provider, name, ...b.stop };
|
|
28247
|
+
({ billing, billingPage, accepted } = b);
|
|
28248
|
+
}
|
|
28249
|
+
if (opts.resetPassword) {
|
|
28250
|
+
let lock;
|
|
28251
|
+
try {
|
|
28252
|
+
lock = await deps.resetLock(serverOf(name));
|
|
28253
|
+
} catch (err) {
|
|
28254
|
+
if (err instanceof HttpStatusError && err.status === 409) {
|
|
28255
|
+
return { status: "action_required", provider, name, ...billing, next: `Another pgai connect --reset-password for ${serverOf(name)} is running: wait for it to finish, then re-run (a run that stopped frees the server 15 minutes after it started)` };
|
|
28256
|
+
}
|
|
28257
|
+
if (err instanceof HttpStatusError && err.status === 404) {
|
|
28258
|
+
return { status: "action_required", provider, name, ...billing, next: `This platform cannot lock the server for --reset-password yet: set PGAI_MON_PASSWORD to ${DEFAULT_MONITORING_USER}'s password instead` };
|
|
28259
|
+
}
|
|
28260
|
+
throw err;
|
|
28261
|
+
}
|
|
28262
|
+
if (typeof lock.lock_id !== "string" || !lock.lock_id)
|
|
28263
|
+
throw new Error("cloud_monitoring_reset_lock returned no lock_id: nothing was changed");
|
|
28264
|
+
lockId = lock.lock_id;
|
|
28265
|
+
}
|
|
28266
|
+
let prepared;
|
|
28267
|
+
let created;
|
|
28268
|
+
let unanswered = false;
|
|
28269
|
+
try {
|
|
28270
|
+
if (lockId) {
|
|
28271
|
+
const now = await deps.list();
|
|
28272
|
+
const same = now.find((d) => d.name === name && !disconnecting(d.status));
|
|
28273
|
+
if (same)
|
|
28274
|
+
return { status: "action_required", provider, name, id: same.id, ...billing, next: `${name} is already connected, and its monitoring uses the current password: pgai disconnect ${name} --yes first` };
|
|
28275
|
+
const others = othersOnServer(now);
|
|
28276
|
+
if (others.length)
|
|
28277
|
+
return { ...cutOff(others), ...billing };
|
|
28278
|
+
}
|
|
28279
|
+
progress("preparing", `Preparing ${maskConnectionString(url)}`);
|
|
28280
|
+
prepared = await deps.prepare(url, provider, prepOpts);
|
|
28281
|
+
if ("next" in prepared)
|
|
28282
|
+
return { status: "action_required", provider, name, ...billing, ...prepared };
|
|
28283
|
+
if (!("monitoringUrl" in prepared))
|
|
28284
|
+
throw new Error("prepare returned no monitoring URL");
|
|
28285
|
+
if (prepared.note)
|
|
28286
|
+
note = `; ${prepared.note}`;
|
|
28287
|
+
if (opts.selfHosted) {
|
|
28288
|
+
await deps.selfHosted(prepared.monitoringUrl, key && ch ? { CLICKHOUSE_ORG_ID: ch.orgId, CLICKHOUSE_KEY_ID: key.keyId, CLICKHOUSE_KEY_SECRET: key.keySecret } : {});
|
|
28289
|
+
return { status: "connected", provider, name, dashboard_url: "http://localhost:3000", host_metrics: !!ch, next: `pgai mon health${note}` };
|
|
28290
|
+
}
|
|
28291
|
+
progress("provisioning", `Provisioning monitoring for ${name}`);
|
|
28292
|
+
created = await deps.create({
|
|
28293
|
+
db_url: prepared.monitoringUrl,
|
|
28294
|
+
...provider === "clickhouse" ? { provider } : {},
|
|
28295
|
+
...key && ch ? { clickhouse_org_id: ch.orgId, clickhouse_key_id: key.keyId, clickhouse_key_secret: key.keySecret } : {},
|
|
28296
|
+
...opts.coupon ? { promo_code: opts.coupon } : {},
|
|
28297
|
+
...accepted ? { accept_price: true } : {}
|
|
28298
|
+
}).catch(async (err) => {
|
|
28299
|
+
if (err instanceof HttpStatusError && err.status >= 400 && err.status < 500)
|
|
28300
|
+
await undo(prepared);
|
|
28301
|
+
else
|
|
28302
|
+
unanswered = true;
|
|
28303
|
+
if (err instanceof HttpStatusError && err.status === 402)
|
|
28304
|
+
return /declined/i.test(err.message) ? "declined" : "payment";
|
|
28305
|
+
if (err instanceof HttpStatusError && err.status === 412)
|
|
28306
|
+
return "price";
|
|
28307
|
+
if (err instanceof HttpStatusError && err.status === 409 && "storedPassword" in prepared && prepared.storedPassword && /none is stored/.test(err.message))
|
|
28308
|
+
return "stored";
|
|
28309
|
+
throw err;
|
|
28310
|
+
});
|
|
28311
|
+
if (created === "payment")
|
|
28312
|
+
return { status: "action_required", provider, name, ...billingPage };
|
|
28313
|
+
if (created === "declined") {
|
|
28314
|
+
return { status: "action_required", provider, name, ...billingPage, next: billingPage.next.replace(/^Add a payment method at /, "The payment method on file was declined: update it at ") };
|
|
28315
|
+
}
|
|
28316
|
+
if (created === "price")
|
|
28317
|
+
return { status: "action_required", provider, name, ...billing, next: "The price changed since it was shown: re-run pgai connect to see it" };
|
|
28318
|
+
if (created === "stored") {
|
|
28319
|
+
const gone = `PostgresAI no longer keeps the password of ${DEFAULT_MONITORING_USER} for this server`;
|
|
28320
|
+
return { status: "action_required", provider, name, ...billing, next: opts.agent ? `${gone}: pass its URL as database_url, or run pgai connect <admin-url> --reset-password in a terminal` : `${gone}: re-run with --reset-password (anything else that logs in as ${DEFAULT_MONITORING_USER} then needs the new one), or set PGAI_MON_PASSWORD to its password` };
|
|
28321
|
+
}
|
|
28322
|
+
if (created.status === "failed") {
|
|
28323
|
+
await undo(prepared);
|
|
28324
|
+
return { status: "failed", provider, name, id: created.id, next: `${created.error} Re-run pgai connect later.` };
|
|
28325
|
+
}
|
|
28326
|
+
} finally {
|
|
28327
|
+
if (lockId && !unanswered)
|
|
28328
|
+
await deps.resetUnlock(lockId).catch(() => {});
|
|
28118
28329
|
}
|
|
28119
28330
|
row = { id: created.id, name: created.name, provider, status: created.status, dashboard_url: null, host_metrics: !!ch };
|
|
28120
|
-
|
|
28121
|
-
|
|
28122
|
-
|
|
28331
|
+
if (prepared.storedPassword) {
|
|
28332
|
+
progress("checkup", `Express checkup skipped: it runs only as ${DEFAULT_MONITORING_USER}, whose password PostgresAI keeps for this server. The full checkup follows on the box.`);
|
|
28333
|
+
} else {
|
|
28334
|
+
checkup = await deps.checkup(withLocalTls(prepared.monitoringUrl, url), created.name).catch((err) => ({ error: errorText(err) }));
|
|
28335
|
+
progress("checkup", checkupLines(checkup).join(`
|
|
28336
|
+
`), { checkup });
|
|
28337
|
+
}
|
|
28123
28338
|
}
|
|
28124
|
-
|
|
28339
|
+
let shown;
|
|
28340
|
+
for (const deadline = deps.now() + opts.waitMs;; ) {
|
|
28125
28341
|
const result = connectStatus(row, provider, fresh);
|
|
28126
|
-
|
|
28342
|
+
const state = boxState(row);
|
|
28343
|
+
if (state !== shown && (shown !== undefined || result.status === "provisioning"))
|
|
28344
|
+
progress("box", `Monitoring box: ${state}`, { state });
|
|
28345
|
+
shown = state;
|
|
28346
|
+
if (result.status !== "provisioning" || deps.now() >= deadline) {
|
|
28127
28347
|
if (result.status === "connected" && provider === "clickhouse" && !row.host_metrics) {
|
|
28128
28348
|
result.next += "; for CPU, memory and disk, disconnect and reconnect with --clickhouse-key <key-id>:<key-secret>";
|
|
28129
28349
|
}
|
|
28130
28350
|
const { next, ...rest } = result;
|
|
28131
|
-
return { ...rest, ...checkup ? { checkup } : {}, next: next + note };
|
|
28351
|
+
return { ...rest, ...billing, ...checkup ? { checkup } : {}, next: next + note };
|
|
28132
28352
|
}
|
|
28133
|
-
deps.progress(`Waiting for the monitoring box (${row.status ?? "starting"})`);
|
|
28134
28353
|
await deps.sleep(POLL_MS);
|
|
28135
28354
|
row = (await deps.list().catch(() => [])).find((d) => d.id === row.id) ?? row;
|
|
28136
28355
|
}
|
|
28137
28356
|
}
|
|
28138
|
-
function openConnection(url, opts) {
|
|
28357
|
+
async function openConnection(url, opts) {
|
|
28139
28358
|
const conn = resolveAdminConnection({ conn: url });
|
|
28140
|
-
|
|
28359
|
+
const fallback = !opts.agent && !!conn.sslFallbackEnabled;
|
|
28360
|
+
const { client, usedSsl } = await connectWithSslFallback(opts.Client ?? Client2, { ...conn, sslFallbackEnabled: fallback });
|
|
28361
|
+
return { client, usedSsl, refusedTls: fallback && !usedSsl };
|
|
28141
28362
|
}
|
|
28142
28363
|
async function dropMonitoringRole(client) {
|
|
28143
28364
|
for (const sql of [`drop role ${DEFAULT_MONITORING_USER}`, `drop owned by ${DEFAULT_MONITORING_USER}; drop role ${DEFAULT_MONITORING_USER}`]) {
|
|
@@ -28170,7 +28391,7 @@ async function prepareDatabase(url, provider, opts = {}) {
|
|
|
28170
28391
|
throw err;
|
|
28171
28392
|
}
|
|
28172
28393
|
};
|
|
28173
|
-
const { client } = await open(url).catch((err) => {
|
|
28394
|
+
const { client, usedSsl, refusedTls } = await open(url).catch((err) => {
|
|
28174
28395
|
if (/client password must be a string/.test(String(err?.message)))
|
|
28175
28396
|
throw new Error("The URL has no password (and PGPASSWORD is not set): pgai connect postgresql://user:password@host:5432/dbname");
|
|
28176
28397
|
throw err;
|
|
@@ -28184,7 +28405,7 @@ async function prepareDatabase(url, provider, opts = {}) {
|
|
|
28184
28405
|
and has_database_privilege(current_user, current_database(), 'CREATE')
|
|
28185
28406
|
and exists (select 1 from pg_extension where extname = 'pg_stat_statements')) as admin,
|
|
28186
28407
|
exists (select 1 from pg_roles where rolname = '${DEFAULT_MONITORING_USER}') as mon_exists,
|
|
28187
|
-
current_setting('scram_iterations', true) as iterations
|
|
28408
|
+
current_setting('scram_iterations', true) as iterations, current_setting('ssl') as ssl
|
|
28188
28409
|
from pg_roles where rolname = current_user`)).rows[0];
|
|
28189
28410
|
const notes = [caNote(url)].filter((n) => !!n);
|
|
28190
28411
|
const noted = () => notes.length ? { note: notes.join("; ") } : {};
|
|
@@ -28208,9 +28429,31 @@ async function prepareDatabase(url, provider, opts = {}) {
|
|
|
28208
28429
|
}
|
|
28209
28430
|
} else if (me.admin && pgProvider !== "supabase") {
|
|
28210
28431
|
const exists = `${DEFAULT_MONITORING_USER} already exists on this server`;
|
|
28432
|
+
const setPassword = `Set PGAI_MON_PASSWORD to the password of ${DEFAULT_MONITORING_USER}, or give it a new one: pgai connect <admin-url> --reset-password (anything else that logs in as ${DEFAULT_MONITORING_USER} then needs the new password)`;
|
|
28433
|
+
const needs = storedPasswordNeeds(monitoringUrlFor(url, me.db, ""));
|
|
28434
|
+
const noTls = refusedTls || !usedSsl && me.ssl === "off";
|
|
28435
|
+
const tlsOnly = `PostgresAI sends the password it keeps for ${DEFAULT_MONITORING_USER} only over TLS`;
|
|
28436
|
+
const others = opts.others?.length ? opts.others : undefined;
|
|
28437
|
+
const disconnectOthers = others ? disconnectEach(others) : "pgai disconnect the server's other databases (pgai databases)";
|
|
28438
|
+
const reconnectOthers = `(and connect ${others ? others.join(", ") : "them"} again with it)`;
|
|
28439
|
+
const noTlsWayOut = opts.agent ? `Pass its URL as database_url, or run pgai connect in a terminal with PGAI_MON_PASSWORD set to its password. If nobody has it, in a terminal: ${disconnectOthers}, then pgai connect <admin-url> --reset-password with PGAI_MON_PASSWORD set to a new one ${reconnectOthers}` : `Set PGAI_MON_PASSWORD to its password, or turn on TLS on the server (ssl = on) and put sslmode=require in the URL. If nobody has the password: ${disconnectOthers}, then re-run with --reset-password and PGAI_MON_PASSWORD set to a new one ${reconnectOthers}`;
|
|
28440
|
+
const reuse = me.mon_exists && !opts.resetPassword && !!opts.storedPassword && !(opts.agent ? "" : process.env.PGAI_MON_PASSWORD?.trim());
|
|
28441
|
+
if (reuse && needs) {
|
|
28442
|
+
if (noTls)
|
|
28443
|
+
return { next: `${exists}, but this server takes no TLS, and ${tlsOnly}. ${noTlsWayOut}` };
|
|
28444
|
+
return { next: `${exists}. ${opts.agent ? "database_url" : "The URL"} needs ${needs}: the password PostgresAI keeps for this server is sent only over TLS, to a URL with each parameter once` };
|
|
28445
|
+
}
|
|
28446
|
+
if (reuse) {
|
|
28447
|
+
if (opts.check)
|
|
28448
|
+
return { checked: true };
|
|
28449
|
+
const { password: unused } = await resolveMonitoringPassword({ monitoringUser: DEFAULT_MONITORING_USER });
|
|
28450
|
+
const plan = await buildInitPlan({ database: me.db, monitoringPassword: unused, iterations: Number(me.iterations), includeOptionalPermissions: true, provider: pgProvider, keepExistingPassword: true });
|
|
28451
|
+
await applyInitPlan({ client, plan });
|
|
28452
|
+
return { monitoringUrl: monitoringUrlFor(url, me.db, ""), storedPassword: true, ...noted() };
|
|
28453
|
+
}
|
|
28211
28454
|
if (me.mon_exists && opts.agent)
|
|
28212
28455
|
return { next: `${exists}. Pass its URL as database_url, or run pgai connect in a terminal with PGAI_MON_PASSWORD set to its password` };
|
|
28213
|
-
const
|
|
28456
|
+
const unset = !opts.storedPassword ? setPassword : noTls ? `This server takes no TLS, and ${tlsOnly}. ${noTlsWayOut}` : !needs ? `Unset PGAI_MON_PASSWORD: PostgresAI keeps the password of ${DEFAULT_MONITORING_USER} for this server` : `Unset PGAI_MON_PASSWORD, and the URL needs ${needs}: PostgresAI keeps the password of ${DEFAULT_MONITORING_USER} for this server, and sends it only over TLS, to a URL with each parameter once`;
|
|
28214
28457
|
const { password, generated } = await resolveMonitoringPassword({ passwordEnv: opts.agent ? undefined : process.env.PGAI_MON_PASSWORD, monitoringUser: DEFAULT_MONITORING_USER });
|
|
28215
28458
|
const reset = !!opts.resetPassword && me.mon_exists;
|
|
28216
28459
|
if (me.mon_exists && !reset) {
|
|
@@ -28220,13 +28463,16 @@ async function prepareDatabase(url, provider, opts = {}) {
|
|
|
28220
28463
|
try {
|
|
28221
28464
|
accepted = await logsIn(loginUrlFor(url, me.db, password));
|
|
28222
28465
|
} catch (err) {
|
|
28223
|
-
|
|
28466
|
+
const orUnset = opts.storedPassword && !noTls ? ", or unset PGAI_MON_PASSWORD: PostgresAI keeps its password for this server" : "";
|
|
28467
|
+
return { next: `${exists}, and PGAI_MON_PASSWORD could not be checked from this host (${err instanceof Error ? err.message : String(err)}). Run pgai connect from a host that ${DEFAULT_MONITORING_USER} may connect from${orUnset}` };
|
|
28224
28468
|
}
|
|
28225
28469
|
if (!accepted)
|
|
28226
|
-
return { next: `${exists} and PGAI_MON_PASSWORD is not its password. ${
|
|
28470
|
+
return { next: `${exists} and PGAI_MON_PASSWORD is not its password. ${unset}` };
|
|
28227
28471
|
if (await acceptsAnyPassword())
|
|
28228
28472
|
notes.push(unchecked("PGAI_MON_PASSWORD"));
|
|
28229
28473
|
}
|
|
28474
|
+
if (opts.check)
|
|
28475
|
+
return { checked: true };
|
|
28230
28476
|
const monitoringUrl = monitoringUrlFor(url, me.db, password);
|
|
28231
28477
|
const loginUrl = loginUrlFor(url, me.db, password);
|
|
28232
28478
|
const plan = await buildInitPlan({ database: me.db, monitoringPassword: password, iterations: Number(me.iterations), includeOptionalPermissions: true, provider: pgProvider, keepExistingPassword: !reset });
|
|
@@ -28255,14 +28501,43 @@ ${redactPasswordsInSql(s.sql)}`).join(`
|
|
|
28255
28501
|
}
|
|
28256
28502
|
async function expressCheckup(url, opts = {}) {
|
|
28257
28503
|
const { client } = await openConnection(url, opts);
|
|
28504
|
+
let reports;
|
|
28505
|
+
const failed = [];
|
|
28258
28506
|
try {
|
|
28259
|
-
|
|
28260
|
-
const reports = await generateAllReports(client, "node-01", undefined, (f) => failed.push(f.checkId));
|
|
28261
|
-
const findings = Object.values(reports).map((r) => ({ check_id: r.checkId, title: r.checkTitle, status: r.summary.status, message: r.summary.message })).filter((f) => f.status !== "info").sort((a, b) => Number(b.status === "warning") - Number(a.status === "warning"));
|
|
28262
|
-
return { checks: Object.keys(reports).length + failed.length, findings, ...failed.length ? { failed } : {} };
|
|
28507
|
+
reports = await generateAllReports(client, "node-01", undefined, (f) => failed.push(f.checkId));
|
|
28263
28508
|
} finally {
|
|
28264
28509
|
await client.end();
|
|
28265
28510
|
}
|
|
28511
|
+
const all = Object.values(reports).map((r) => ({ check_id: r.checkId, title: r.checkTitle, status: r.summary.status, message: r.summary.message }));
|
|
28512
|
+
const findings = all.filter((f) => f.status !== "info").sort((a, b) => Number(b.status === "warning") - Number(a.status === "warning"));
|
|
28513
|
+
const result = { checks: all.length + failed.length, findings, info: all.filter((f) => f.status === "info").map((f) => f.check_id), ...failed.length ? { failed } : {} };
|
|
28514
|
+
if (!opts.save)
|
|
28515
|
+
return result;
|
|
28516
|
+
try {
|
|
28517
|
+
return { ...result, report_id: await opts.save(reports) };
|
|
28518
|
+
} catch (err) {
|
|
28519
|
+
return { ...result, upload_error: errorText(err) };
|
|
28520
|
+
}
|
|
28521
|
+
}
|
|
28522
|
+
async function saveCheckupReport(rpc, accessToken, project, reports) {
|
|
28523
|
+
const { reportId } = await createCheckupReport(rpc, { apiKey: accessToken, project });
|
|
28524
|
+
const setStatus = (status) => rpc("checkup_report_status_update", { access_token: accessToken, report_id: reportId, status });
|
|
28525
|
+
try {
|
|
28526
|
+
for (const [checkId, report] of Object.entries(reports)) {
|
|
28527
|
+
await uploadCheckupReportJson(rpc, {
|
|
28528
|
+
apiKey: accessToken,
|
|
28529
|
+
reportId,
|
|
28530
|
+
filename: `${checkId}.json`,
|
|
28531
|
+
checkId,
|
|
28532
|
+
jsonText: JSON.stringify(report, null, 2)
|
|
28533
|
+
});
|
|
28534
|
+
}
|
|
28535
|
+
} catch (err) {
|
|
28536
|
+
await setStatus("failed").catch(() => {});
|
|
28537
|
+
throw err;
|
|
28538
|
+
}
|
|
28539
|
+
await setStatus("completed");
|
|
28540
|
+
return reportId;
|
|
28266
28541
|
}
|
|
28267
28542
|
async function clickhouseOrgFor(host, keyId, keySecret) {
|
|
28268
28543
|
const apiUrl = process.env.CLICKHOUSE_API_URL || "https://api.clickhouse.cloud";
|
|
@@ -28299,16 +28574,21 @@ function platformDeps(p) {
|
|
|
28299
28574
|
list: () => rpc("cloud_monitoring_list"),
|
|
28300
28575
|
create: (body) => rpc("cloud_monitoring_connect", body),
|
|
28301
28576
|
disconnect: (id) => rpc("cloud_monitoring_disconnect", { instance_id: id }),
|
|
28577
|
+
resetLock: (server) => rpc("cloud_monitoring_reset_lock", { server }),
|
|
28578
|
+
resetUnlock: (lockId) => rpc("cloud_monitoring_reset_unlock", { lock_id: lockId }),
|
|
28302
28579
|
prepare: (url, provider, o = {}) => prepareDatabase(url, provider, { ...o, agent: p.agent }),
|
|
28303
28580
|
unprepare: (url) => unprepareDatabase(url, { agent: p.agent }),
|
|
28304
28581
|
clickhouseOrg: clickhouseOrgFor,
|
|
28305
|
-
checkup: (url) => expressCheckup(url, { agent: p.agent }),
|
|
28582
|
+
checkup: (url, project) => expressCheckup(url, { agent: p.agent, save: (reports) => saveCheckupReport(rpc, p.apiKey, project, reports) }),
|
|
28583
|
+
quote: (coupon, cluster) => rpc("cloud_monitoring_quote", { ...coupon ? { promo_code: coupon } : {}, ...cluster ? { db_server: cluster } : {} }),
|
|
28584
|
+
billingUrl: (orgAlias) => `${p.uiBaseUrl}/${orgAlias}/billing`,
|
|
28306
28585
|
handoffUrl: async (provider) => {
|
|
28307
28586
|
const orgs = await listOrgs({ apiKey: p.apiKey, apiBaseUrl: p.apiBaseUrl });
|
|
28308
28587
|
const org = orgs.length === 1 ? orgs[0] : orgs.find((o) => o.alias === p.orgScope?.alias || o.org_id === p.orgScope?.id);
|
|
28309
28588
|
return `${p.uiBaseUrl}/${org?.alias ?? "<org>"}/monitoring/scale/create/${provider}`;
|
|
28310
28589
|
},
|
|
28311
|
-
sleep: (ms) => new Promise((r) => setTimeout(r, ms))
|
|
28590
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
28591
|
+
now: () => Date.now()
|
|
28312
28592
|
};
|
|
28313
28593
|
}
|
|
28314
28594
|
|
|
@@ -34995,6 +35275,8 @@ async function handleToolCall(req, rootOpts, extra) {
|
|
|
34995
35275
|
const result = await connect(url, {
|
|
34996
35276
|
provider: args.provider ? String(args.provider) : undefined,
|
|
34997
35277
|
clickhouseKey: args.clickhouse_key ? String(args.clickhouse_key) : undefined,
|
|
35278
|
+
coupon: args.coupon === undefined || args.coupon === null ? undefined : String(args.coupon),
|
|
35279
|
+
yes: args.yes === true,
|
|
34998
35280
|
waitMs: 0,
|
|
34999
35281
|
agent: true
|
|
35000
35282
|
}, {
|
|
@@ -35003,6 +35285,7 @@ async function handleToolCall(req, rootOpts, extra) {
|
|
|
35003
35285
|
throw new Error("A self-hosted stack is set up from the CLI: pgai connect <url> --self-hosted");
|
|
35004
35286
|
},
|
|
35005
35287
|
localStackRunning: () => false,
|
|
35288
|
+
confirm: async () => false,
|
|
35006
35289
|
progress: () => {}
|
|
35007
35290
|
});
|
|
35008
35291
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], isError: result.status === "failed" };
|
|
@@ -35568,7 +35851,7 @@ async function startMcpServer(rootOpts, extra) {
|
|
|
35568
35851
|
},
|
|
35569
35852
|
{
|
|
35570
35853
|
name: "connect_database",
|
|
35571
|
-
description: "Put a Postgres database under PostgresAI Cloud monitoring (same as `pgai connect`): prepares the monitoring role (an admin URL creates it; otherwise returns the SQL), provisions the monitoring box, and returns JSON with status (connected | provisioning | disconnecting | action_required | failed), dashboard_url and next (the exact next action). It does not wait for the box: call it again to see the status. Safe to call again. ClickHouse Managed Postgres, RDS and Supabase are detected from the host. The URL must carry its password, and only these query parameters: sslmode, channel_binding, application_name. PGAI_MON_PASSWORD, PGPASSWORD and CLICKHOUSE_KEY_ID + CLICKHOUSE_KEY_SECRET of the server process are not used (pass clickhouse_key), and a TLS failure is not retried in plaintext (say sslmode=disable for a server without TLS).",
|
|
35854
|
+
description: "Put a Postgres database under PostgresAI Cloud monitoring (same as `pgai connect`): prepares the monitoring role (an admin URL creates it; otherwise returns the SQL), provisions the monitoring box, and returns JSON with status (connected | provisioning | disconnecting | action_required | failed), dashboard_url and next (the exact next action). A billed box is provisioned only with yes: true: without it the result is action_required with the price (and requires_payment_method), so show the price to the user first. It does not wait for the box: call it again to see the status. Safe to call again. ClickHouse Managed Postgres, RDS and Supabase are detected from the host. The URL must carry its password, and only these query parameters: sslmode, channel_binding, application_name. PGAI_MON_PASSWORD, PGPASSWORD and CLICKHOUSE_KEY_ID + CLICKHOUSE_KEY_SECRET of the server process are not used (pass clickhouse_key), and a TLS failure is not retried in plaintext (say sslmode=disable for a server without TLS). For another database on a server PostgresAI already monitors for the org, an admin URL with sslmode=require (or verify-*), each query parameter once, is enough: PostgresAI fills in the password of postgres_ai_mon it keeps, over TLS only (on a server without TLS, pass the postgres_ai_mon URL; if nobody has its password, the user runs in a terminal: pgai disconnect the server's other databases, then pgai connect <admin-url> --reset-password with PGAI_MON_PASSWORD set to a new one, then connects them again with it).",
|
|
35572
35855
|
inputSchema: {
|
|
35573
35856
|
type: "object",
|
|
35574
35857
|
properties: {
|
|
@@ -35576,6 +35859,8 @@ async function startMcpServer(rootOpts, extra) {
|
|
|
35576
35859
|
database_url: { type: "string", description: "postgresql:// URL with the password in it (admin, or the postgres_ai_mon role)" },
|
|
35577
35860
|
provider: { type: "string", description: "clickhouse | rds | supabase | self-managed (default: detected from the host)" },
|
|
35578
35861
|
clickhouse_key: { type: "string", description: "ClickHouse Cloud API key as <key-id>:<key-secret> (Basic Service API Reader), for CPU, memory and disk" },
|
|
35862
|
+
yes: { type: "boolean", description: "Accept the price of a billed box. Without it, a billed box is not provisioned: the result has status action_required and the price; ask the user, then call again with yes: true" },
|
|
35863
|
+
coupon: { type: "string", description: "Stripe promotion code for the organization's monitoring subscription (applies when it is first created)" },
|
|
35579
35864
|
debug: { type: "boolean", description: "Enable verbose debug logs" }
|
|
35580
35865
|
},
|
|
35581
35866
|
required: ["database_url"],
|
|
@@ -36478,6 +36763,25 @@ function renderMarkdownForTerminal(md) {
|
|
|
36478
36763
|
}
|
|
36479
36764
|
|
|
36480
36765
|
// lib/connect.ts
|
|
36766
|
+
var money2 = (cents, currency) => currency.toLowerCase() === "usd" ? `$${(cents / 100).toFixed(2)}` : `${(cents / 100).toFixed(2)} ${currency.toUpperCase()}`;
|
|
36767
|
+
function priceText2(q) {
|
|
36768
|
+
if (!q.billed && q.same_cluster)
|
|
36769
|
+
return `included: same database cluster as ${q.same_cluster}, no extra charge`;
|
|
36770
|
+
if (!q.billed)
|
|
36771
|
+
return q.free_slots.total ? `free (${q.free_slots.remaining} of ${q.free_slots.total} free slots)` : "free";
|
|
36772
|
+
const { amount, currency, interval } = q.price;
|
|
36773
|
+
const base = `${money2(amount, currency)}/${interval} per database cluster (${q.plan} plan)${q.subscription ? `, cluster ${q.quantity + 1} on the subscription` : ""}`;
|
|
36774
|
+
if (q.promo?.valid && q.amount_after_promo !== undefined && q.amount_after_promo !== null) {
|
|
36775
|
+
const after = `${money2(q.amount_after_promo, currency)}`;
|
|
36776
|
+
const how = `with ${q.promo.code} (${q.promo.discount_description})`;
|
|
36777
|
+
if (q.promo.duration === "forever")
|
|
36778
|
+
return `${after}/${interval} ${how}, instead of ${base}`;
|
|
36779
|
+
if (q.promo.duration === "repeating")
|
|
36780
|
+
return `${after}/${interval} for ${q.promo.duration_in_months} months ${how}, then ${base}`;
|
|
36781
|
+
return `${after} the first ${interval} ${how}, then ${base}`;
|
|
36782
|
+
}
|
|
36783
|
+
return base;
|
|
36784
|
+
}
|
|
36481
36785
|
var FIRST_CHECKUP_DELAY_MS2 = 30 * 60000;
|
|
36482
36786
|
var POLL_MS2 = 15000;
|
|
36483
36787
|
var PROVIDERS2 = ["clickhouse", "rds", "supabase", "self-managed"];
|
|
@@ -36533,8 +36837,12 @@ function stateOf2(raw) {
|
|
|
36533
36837
|
|
|
36534
36838
|
class ClickhouseKeyError2 extends Error {
|
|
36535
36839
|
}
|
|
36536
|
-
var serverOf2 = (name) => name.split("/")[0];
|
|
36537
|
-
|
|
36840
|
+
var serverOf2 = (name) => name.split("/")[0].toLowerCase().replace(/\.(?=$|:)/, "");
|
|
36841
|
+
function clusterOf2(url) {
|
|
36842
|
+
const u = parseUrl2(url);
|
|
36843
|
+
return u?.hostname ? `${u.hostname.toLowerCase()}:${u.port || "5432"}` : undefined;
|
|
36844
|
+
}
|
|
36845
|
+
var errorText2 = (err) => err instanceof AggregateError && !err.message ? err.errors.map(errorText2).join("; ") : err instanceof Error ? err.message : String(err);
|
|
36538
36846
|
var urlPassword2 = (u) => decodeURIComponent(u.password) || u.searchParams.get("password") || "";
|
|
36539
36847
|
var URL_PARAMS_KEPT2 = ["sslmode", "channel_binding", "application_name"];
|
|
36540
36848
|
var URL_PARAMS_TLS2 = ["sslrootcert", "sslcert", "sslkey", "uselibpqcompat"];
|
|
@@ -36549,6 +36857,22 @@ function checkUrlParams2(url, agent) {
|
|
|
36549
36857
|
if (moved.length)
|
|
36550
36858
|
throw new Error(`The URL's query string sets ${moved.join(" and ")}: put the host and the port in the URL itself (postgresql://user:password@host:5432/dbname), so that the server prepared is the server monitored`);
|
|
36551
36859
|
}
|
|
36860
|
+
function storedPasswordNeeds2(monitoringUrl) {
|
|
36861
|
+
const query = new URL(monitoringUrl).search.replace(/^\?/, "");
|
|
36862
|
+
const parts = query ? query.split("&") : [];
|
|
36863
|
+
const keys = parts.map((p) => p.split("=")[0]);
|
|
36864
|
+
const twice = URL_PARAMS_KEPT2.filter((k) => keys.indexOf(k) !== keys.lastIndexOf(k));
|
|
36865
|
+
const tls = !twice.includes("sslmode") && parts.some((p) => /^sslmode=(require|verify-ca|verify-full)$/.test(p));
|
|
36866
|
+
const needs = [
|
|
36867
|
+
...tls ? [] : [`sslmode=require (or verify-full)${twice.includes("sslmode") ? ", once" : ""}`],
|
|
36868
|
+
...twice.filter((k) => k !== "sslmode").map((k) => `${k} once`),
|
|
36869
|
+
...parts.some((p) => !p) ? ["no stray &"] : [],
|
|
36870
|
+
...keys.some((k) => k && !URL_PARAMS_KEPT2.includes(k)) ? ["the parameter names without %-escapes"] : []
|
|
36871
|
+
];
|
|
36872
|
+
return needs.length ? needs.join(" and ") : undefined;
|
|
36873
|
+
}
|
|
36874
|
+
var shellWord2 = (s) => /^[\w./:@%+=,-]+$/.test(s) ? s : `'${s.replace(/'/g, "'\\''")}'`;
|
|
36875
|
+
var disconnectEach2 = (names) => names.map((n) => `pgai disconnect ${shellWord2(n)} --yes`).join(" and ");
|
|
36552
36876
|
function roleUrlFor2(url, db, password, kept) {
|
|
36553
36877
|
const u = new URL(url);
|
|
36554
36878
|
u.username = DEFAULT_MONITORING_USER;
|
|
@@ -36557,6 +36881,7 @@ function roleUrlFor2(url, db, password, kept) {
|
|
|
36557
36881
|
for (const name of [...u.searchParams.keys()])
|
|
36558
36882
|
if (!kept.includes(name))
|
|
36559
36883
|
u.searchParams.delete(name);
|
|
36884
|
+
u.search = u.search.replace(/@/g, "%40");
|
|
36560
36885
|
return u.toString();
|
|
36561
36886
|
}
|
|
36562
36887
|
var monitoringUrlFor2 = (url, db, password) => roleUrlFor2(url, db, password, URL_PARAMS_KEPT2);
|
|
@@ -36596,15 +36921,49 @@ function checkupLines2(c) {
|
|
|
36596
36921
|
return [`Express checkup could not run: ${c.error}`];
|
|
36597
36922
|
const warnings = c.findings.filter((f) => f.status === "warning");
|
|
36598
36923
|
const ok = c.findings.filter((f) => f.status === "ok");
|
|
36924
|
+
const failed = c.failed ?? [];
|
|
36925
|
+
const counts = [`${warnings.length} warning${warnings.length === 1 ? "" : "s"}`, `${ok.length} ok`, `${c.info.length} info`, ...failed.length ? [`${failed.length} could not run`] : []];
|
|
36599
36926
|
return [
|
|
36600
|
-
`Express checkup while the box starts (${c.checks} checks: ${
|
|
36927
|
+
`Express checkup while the box starts (${c.checks} checks: ${counts.join(", ")}):`,
|
|
36601
36928
|
...warnings.map((f) => ` ${f.check_id} ${f.title}: ${f.message}`),
|
|
36602
36929
|
...ok.length ? [` ok: ${ok.map((f) => f.check_id).join(" ")}`] : [],
|
|
36603
|
-
...c.
|
|
36930
|
+
...c.info.length ? [` info: ${c.info.join(" ")}`] : [],
|
|
36931
|
+
...failed.length ? [` could not run: ${failed.join(" ")}`] : [],
|
|
36932
|
+
c.report_id ? `Saved as report ${c.report_id}: pgai reports files ${c.report_id}` : `Not saved to PostgresAI: ${c.upload_error}`,
|
|
36604
36933
|
"The full checkup (query analysis and trends) follows on the box."
|
|
36605
36934
|
];
|
|
36606
36935
|
}
|
|
36936
|
+
function progressText(e) {
|
|
36937
|
+
const s = Math.round(e.elapsed_s);
|
|
36938
|
+
const elapsed = s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`;
|
|
36939
|
+
const [first, ...rest] = e.message.split(`
|
|
36940
|
+
`);
|
|
36941
|
+
const colon = first.endsWith(":") ? ":" : "";
|
|
36942
|
+
return [`${first.slice(0, first.length - colon.length)} (+${elapsed})${colon}`, ...rest].join(`
|
|
36943
|
+
`);
|
|
36944
|
+
}
|
|
36945
|
+
var boxState2 = (row) => row.status === "launch_requested" && row.registered_at ? "registered" : row.status ?? "starting";
|
|
36946
|
+
async function billingFor2(q, name, opts, deps, show) {
|
|
36947
|
+
const billing = { price: priceText2(q), requires_payment_method: q.requires_payment_method };
|
|
36948
|
+
if (opts.coupon) {
|
|
36949
|
+
billing.coupon = q.promo?.valid ? { code: q.promo.code, valid: true, description: q.promo.discount_description } : { code: opts.coupon, valid: false, error: q.promo?.error ?? "not valid" };
|
|
36950
|
+
}
|
|
36951
|
+
const billingPage = { ...billing, requires_payment_method: true, next: `Add a payment method at ${q.billing_url ?? deps.billingUrl(q.org_alias)}, then re-run` };
|
|
36952
|
+
const stop = (next) => ({ billing, billingPage, accepted: false, stop: { ...billing, next } });
|
|
36953
|
+
if (billing.coupon && !billing.coupon.valid) {
|
|
36954
|
+
return stop(`Promo code ${opts.coupon}: ${billing.coupon.error}. Nothing was changed: re-run with a valid code, or without ${opts.agent ? "coupon" : "--coupon"}`);
|
|
36955
|
+
}
|
|
36956
|
+
if (q.requires_payment_method)
|
|
36957
|
+
return { billing, billingPage, accepted: false, stop: billingPage };
|
|
36958
|
+
show(billing.price);
|
|
36959
|
+
if (q.billed && !opts.yes && !await deps.confirm(`Provision ${name}? (y/N): `)) {
|
|
36960
|
+
return stop(opts.agent ? `Call connect_database again with yes: true to accept ${billing.price}` : `Re-run with --yes to accept ${billing.price}`);
|
|
36961
|
+
}
|
|
36962
|
+
return { billing, billingPage, accepted: q.billed };
|
|
36963
|
+
}
|
|
36607
36964
|
async function connect2(url, opts, deps) {
|
|
36965
|
+
const started = deps.now();
|
|
36966
|
+
const progress = (event, message, more = {}) => deps.progress({ event, elapsed_s: Math.round((deps.now() - started) / 1000), message, ...more });
|
|
36608
36967
|
const provider = opts.provider ?? detectCloudProvider2(url);
|
|
36609
36968
|
if (!PROVIDERS2.includes(provider))
|
|
36610
36969
|
throw new Error(`--provider must be one of: ${PROVIDERS2.join(", ")}`);
|
|
@@ -36620,13 +36979,16 @@ async function connect2(url, opts, deps) {
|
|
|
36620
36979
|
return { status: "action_required", provider, name, next: "--reset-password works with PostgresAI Cloud only (it checks what else monitors this server): connect without --self-hosted, or set PGAI_MON_PASSWORD" };
|
|
36621
36980
|
}
|
|
36622
36981
|
if (opts.selfHosted && deps.localStackRunning()) {
|
|
36623
|
-
return { status: "action_required", provider, name, next: "A monitoring stack already runs on this machine: add the database with pgai mon targets add
|
|
36982
|
+
return { status: "action_required", provider, name, next: "A monitoring stack already runs on this machine: add the database with PGAI_DB_URL='<postgres_ai_mon URL>' pgai mon targets add" };
|
|
36624
36983
|
}
|
|
36625
36984
|
const rows = opts.selfHosted ? [] : await deps.list();
|
|
36626
36985
|
let row = rows.find((d) => d.name === name && !disconnecting2(d.status));
|
|
36627
36986
|
const fresh = !row;
|
|
36628
36987
|
let note = "";
|
|
36629
36988
|
let checkup;
|
|
36989
|
+
let billing = {};
|
|
36990
|
+
let billingPage;
|
|
36991
|
+
let accepted = false;
|
|
36630
36992
|
const findOrg = async () => {
|
|
36631
36993
|
try {
|
|
36632
36994
|
return await deps.clickhouseOrg(new URL(url).hostname, key.keyId, key.keySecret);
|
|
@@ -36655,62 +37017,154 @@ async function connect2(url, opts, deps) {
|
|
|
36655
37017
|
}
|
|
36656
37018
|
ch = found;
|
|
36657
37019
|
}
|
|
36658
|
-
|
|
36659
|
-
|
|
36660
|
-
if (others.length) {
|
|
36661
|
-
return { status: "action_required", provider, name, next: `A new password for ${DEFAULT_MONITORING_USER} would cut off the monitoring of ${others.map((d) => d.name).join(", ")} on this server: set PGAI_MON_PASSWORD to its password instead` };
|
|
36662
|
-
}
|
|
36663
|
-
}
|
|
36664
|
-
deps.progress(`Preparing ${maskConnectionString(url)}`);
|
|
36665
|
-
const prepared = await deps.prepare(url, provider, { resetPassword: opts.resetPassword });
|
|
36666
|
-
if ("next" in prepared)
|
|
36667
|
-
return { status: "action_required", provider, name, ...prepared };
|
|
36668
|
-
if (prepared.note)
|
|
36669
|
-
note = `; ${prepared.note}`;
|
|
36670
|
-
if (opts.selfHosted) {
|
|
36671
|
-
await deps.selfHosted(prepared.monitoringUrl, key && ch ? { CLICKHOUSE_ORG_ID: ch.orgId, CLICKHOUSE_KEY_ID: key.keyId, CLICKHOUSE_KEY_SECRET: key.keySecret } : {});
|
|
36672
|
-
return { status: "connected", provider, name, dashboard_url: "http://localhost:3000", host_metrics: !!ch, next: `pgai mon health${note}` };
|
|
36673
|
-
}
|
|
36674
|
-
deps.progress(`Provisioning monitoring for ${name}`);
|
|
36675
|
-
const undo = async () => {
|
|
36676
|
-
if (prepared.generated)
|
|
37020
|
+
const undo = async (p) => {
|
|
37021
|
+
if ("generated" in p && p.generated)
|
|
36677
37022
|
await deps.unprepare(url).catch(() => false);
|
|
36678
37023
|
};
|
|
36679
|
-
|
|
36680
|
-
|
|
36681
|
-
|
|
36682
|
-
|
|
36683
|
-
|
|
36684
|
-
|
|
36685
|
-
|
|
36686
|
-
|
|
36687
|
-
|
|
36688
|
-
|
|
36689
|
-
|
|
36690
|
-
|
|
37024
|
+
let lockId;
|
|
37025
|
+
const unread = (d) => /^Monitoring [0-9a-f]{6}$/.test(d.name);
|
|
37026
|
+
const othersOnServer = (list) => list.filter((d) => d.name !== name && !disconnecting2(d.status) && (unread(d) || serverOf2(d.name) === serverOf2(name)));
|
|
37027
|
+
const storedPassword = rows.some((d) => d.monitoring_password_stored && !/delet/.test(d.status ?? "") && serverOf2(d.name) === serverOf2(name)) || undefined;
|
|
37028
|
+
const cutOff = (others) => {
|
|
37029
|
+
const on = others.filter((d) => !unread(d)).map((d) => d.name).join(", ");
|
|
37030
|
+
const maybe = others.filter(unread).map((d) => d.name).join(", ");
|
|
37031
|
+
const what = [on && `would cut off the monitoring of ${on} on this server`, maybe && `may cut off the monitoring of ${maybe} (the platform could not read its URL, so its server is not known)`].filter(Boolean).join(", and ");
|
|
37032
|
+
const instead = [
|
|
37033
|
+
...storedPassword ? ["connect without --reset-password (PostgresAI keeps its password for this server, and sends it only over TLS)"] : [],
|
|
37034
|
+
"set PGAI_MON_PASSWORD to its password instead",
|
|
37035
|
+
`${disconnectEach2(others.map((d) => d.name))} first`
|
|
37036
|
+
];
|
|
37037
|
+
return { status: "action_required", provider, name, next: `A new password for ${DEFAULT_MONITORING_USER} ${what}: ${instead.join(", or ")}` };
|
|
37038
|
+
};
|
|
37039
|
+
const others = othersOnServer(rows).filter((d) => !unread(d)).map((d) => d.name);
|
|
37040
|
+
if (opts.resetPassword && othersOnServer(rows).length)
|
|
37041
|
+
return cutOff(othersOnServer(rows));
|
|
37042
|
+
if (opts.coupon !== undefined && !opts.coupon.trim()) {
|
|
37043
|
+
return { status: "action_required", provider, name, next: `${opts.agent ? "coupon" : "--coupon"} is empty: pass a promotion code, or leave ${opts.agent ? "coupon" : "--coupon"} out` };
|
|
37044
|
+
}
|
|
37045
|
+
const prepOpts = { resetPassword: opts.resetPassword, ...storedPassword ? { storedPassword, others } : {} };
|
|
37046
|
+
if (!opts.selfHosted) {
|
|
37047
|
+
const probe = await deps.prepare(url, provider, { ...prepOpts, check: true });
|
|
37048
|
+
if ("next" in probe)
|
|
37049
|
+
return { status: "action_required", provider, name, ...probe };
|
|
37050
|
+
const b = await billingFor2(await deps.quote(opts.coupon, clusterOf2(url)), name, opts, deps, (price) => progress("billing", `Billing: ${price}`));
|
|
37051
|
+
if (b.stop)
|
|
37052
|
+
return { status: "action_required", provider, name, ...b.stop };
|
|
37053
|
+
({ billing, billingPage, accepted } = b);
|
|
37054
|
+
}
|
|
37055
|
+
if (opts.resetPassword) {
|
|
37056
|
+
let lock;
|
|
37057
|
+
try {
|
|
37058
|
+
lock = await deps.resetLock(serverOf2(name));
|
|
37059
|
+
} catch (err) {
|
|
37060
|
+
if (err instanceof HttpStatusError && err.status === 409) {
|
|
37061
|
+
return { status: "action_required", provider, name, ...billing, next: `Another pgai connect --reset-password for ${serverOf2(name)} is running: wait for it to finish, then re-run (a run that stopped frees the server 15 minutes after it started)` };
|
|
37062
|
+
}
|
|
37063
|
+
if (err instanceof HttpStatusError && err.status === 404) {
|
|
37064
|
+
return { status: "action_required", provider, name, ...billing, next: `This platform cannot lock the server for --reset-password yet: set PGAI_MON_PASSWORD to ${DEFAULT_MONITORING_USER}'s password instead` };
|
|
37065
|
+
}
|
|
37066
|
+
throw err;
|
|
37067
|
+
}
|
|
37068
|
+
if (typeof lock.lock_id !== "string" || !lock.lock_id)
|
|
37069
|
+
throw new Error("cloud_monitoring_reset_lock returned no lock_id: nothing was changed");
|
|
37070
|
+
lockId = lock.lock_id;
|
|
37071
|
+
}
|
|
37072
|
+
let prepared;
|
|
37073
|
+
let created;
|
|
37074
|
+
let unanswered = false;
|
|
37075
|
+
try {
|
|
37076
|
+
if (lockId) {
|
|
37077
|
+
const now = await deps.list();
|
|
37078
|
+
const same = now.find((d) => d.name === name && !disconnecting2(d.status));
|
|
37079
|
+
if (same)
|
|
37080
|
+
return { status: "action_required", provider, name, id: same.id, ...billing, next: `${name} is already connected, and its monitoring uses the current password: pgai disconnect ${name} --yes first` };
|
|
37081
|
+
const others = othersOnServer(now);
|
|
37082
|
+
if (others.length)
|
|
37083
|
+
return { ...cutOff(others), ...billing };
|
|
37084
|
+
}
|
|
37085
|
+
progress("preparing", `Preparing ${maskConnectionString(url)}`);
|
|
37086
|
+
prepared = await deps.prepare(url, provider, prepOpts);
|
|
37087
|
+
if ("next" in prepared)
|
|
37088
|
+
return { status: "action_required", provider, name, ...billing, ...prepared };
|
|
37089
|
+
if (!("monitoringUrl" in prepared))
|
|
37090
|
+
throw new Error("prepare returned no monitoring URL");
|
|
37091
|
+
if (prepared.note)
|
|
37092
|
+
note = `; ${prepared.note}`;
|
|
37093
|
+
if (opts.selfHosted) {
|
|
37094
|
+
await deps.selfHosted(prepared.monitoringUrl, key && ch ? { CLICKHOUSE_ORG_ID: ch.orgId, CLICKHOUSE_KEY_ID: key.keyId, CLICKHOUSE_KEY_SECRET: key.keySecret } : {});
|
|
37095
|
+
return { status: "connected", provider, name, dashboard_url: "http://localhost:3000", host_metrics: !!ch, next: `pgai mon health${note}` };
|
|
37096
|
+
}
|
|
37097
|
+
progress("provisioning", `Provisioning monitoring for ${name}`);
|
|
37098
|
+
created = await deps.create({
|
|
37099
|
+
db_url: prepared.monitoringUrl,
|
|
37100
|
+
...provider === "clickhouse" ? { provider } : {},
|
|
37101
|
+
...key && ch ? { clickhouse_org_id: ch.orgId, clickhouse_key_id: key.keyId, clickhouse_key_secret: key.keySecret } : {},
|
|
37102
|
+
...opts.coupon ? { promo_code: opts.coupon } : {},
|
|
37103
|
+
...accepted ? { accept_price: true } : {}
|
|
37104
|
+
}).catch(async (err) => {
|
|
37105
|
+
if (err instanceof HttpStatusError && err.status >= 400 && err.status < 500)
|
|
37106
|
+
await undo(prepared);
|
|
37107
|
+
else
|
|
37108
|
+
unanswered = true;
|
|
37109
|
+
if (err instanceof HttpStatusError && err.status === 402)
|
|
37110
|
+
return /declined/i.test(err.message) ? "declined" : "payment";
|
|
37111
|
+
if (err instanceof HttpStatusError && err.status === 412)
|
|
37112
|
+
return "price";
|
|
37113
|
+
if (err instanceof HttpStatusError && err.status === 409 && "storedPassword" in prepared && prepared.storedPassword && /none is stored/.test(err.message))
|
|
37114
|
+
return "stored";
|
|
37115
|
+
throw err;
|
|
37116
|
+
});
|
|
37117
|
+
if (created === "payment")
|
|
37118
|
+
return { status: "action_required", provider, name, ...billingPage };
|
|
37119
|
+
if (created === "declined") {
|
|
37120
|
+
return { status: "action_required", provider, name, ...billingPage, next: billingPage.next.replace(/^Add a payment method at /, "The payment method on file was declined: update it at ") };
|
|
37121
|
+
}
|
|
37122
|
+
if (created === "price")
|
|
37123
|
+
return { status: "action_required", provider, name, ...billing, next: "The price changed since it was shown: re-run pgai connect to see it" };
|
|
37124
|
+
if (created === "stored") {
|
|
37125
|
+
const gone = `PostgresAI no longer keeps the password of ${DEFAULT_MONITORING_USER} for this server`;
|
|
37126
|
+
return { status: "action_required", provider, name, ...billing, next: opts.agent ? `${gone}: pass its URL as database_url, or run pgai connect <admin-url> --reset-password in a terminal` : `${gone}: re-run with --reset-password (anything else that logs in as ${DEFAULT_MONITORING_USER} then needs the new one), or set PGAI_MON_PASSWORD to its password` };
|
|
37127
|
+
}
|
|
37128
|
+
if (created.status === "failed") {
|
|
37129
|
+
await undo(prepared);
|
|
37130
|
+
return { status: "failed", provider, name, id: created.id, next: `${created.error} Re-run pgai connect later.` };
|
|
37131
|
+
}
|
|
37132
|
+
} finally {
|
|
37133
|
+
if (lockId && !unanswered)
|
|
37134
|
+
await deps.resetUnlock(lockId).catch(() => {});
|
|
36691
37135
|
}
|
|
36692
37136
|
row = { id: created.id, name: created.name, provider, status: created.status, dashboard_url: null, host_metrics: !!ch };
|
|
36693
|
-
|
|
36694
|
-
|
|
36695
|
-
|
|
37137
|
+
if (prepared.storedPassword) {
|
|
37138
|
+
progress("checkup", `Express checkup skipped: it runs only as ${DEFAULT_MONITORING_USER}, whose password PostgresAI keeps for this server. The full checkup follows on the box.`);
|
|
37139
|
+
} else {
|
|
37140
|
+
checkup = await deps.checkup(withLocalTls2(prepared.monitoringUrl, url), created.name).catch((err) => ({ error: errorText2(err) }));
|
|
37141
|
+
progress("checkup", checkupLines2(checkup).join(`
|
|
37142
|
+
`), { checkup });
|
|
37143
|
+
}
|
|
36696
37144
|
}
|
|
36697
|
-
|
|
37145
|
+
let shown;
|
|
37146
|
+
for (const deadline = deps.now() + opts.waitMs;; ) {
|
|
36698
37147
|
const result = connectStatus2(row, provider, fresh);
|
|
36699
|
-
|
|
37148
|
+
const state = boxState2(row);
|
|
37149
|
+
if (state !== shown && (shown !== undefined || result.status === "provisioning"))
|
|
37150
|
+
progress("box", `Monitoring box: ${state}`, { state });
|
|
37151
|
+
shown = state;
|
|
37152
|
+
if (result.status !== "provisioning" || deps.now() >= deadline) {
|
|
36700
37153
|
if (result.status === "connected" && provider === "clickhouse" && !row.host_metrics) {
|
|
36701
37154
|
result.next += "; for CPU, memory and disk, disconnect and reconnect with --clickhouse-key <key-id>:<key-secret>";
|
|
36702
37155
|
}
|
|
36703
37156
|
const { next, ...rest } = result;
|
|
36704
|
-
return { ...rest, ...checkup ? { checkup } : {}, next: next + note };
|
|
37157
|
+
return { ...rest, ...billing, ...checkup ? { checkup } : {}, next: next + note };
|
|
36705
37158
|
}
|
|
36706
|
-
deps.progress(`Waiting for the monitoring box (${row.status ?? "starting"})`);
|
|
36707
37159
|
await deps.sleep(POLL_MS2);
|
|
36708
37160
|
row = (await deps.list().catch(() => [])).find((d) => d.id === row.id) ?? row;
|
|
36709
37161
|
}
|
|
36710
37162
|
}
|
|
36711
|
-
function openConnection2(url, opts) {
|
|
37163
|
+
async function openConnection2(url, opts) {
|
|
36712
37164
|
const conn = resolveAdminConnection({ conn: url });
|
|
36713
|
-
|
|
37165
|
+
const fallback = !opts.agent && !!conn.sslFallbackEnabled;
|
|
37166
|
+
const { client, usedSsl } = await connectWithSslFallback(opts.Client ?? Client2, { ...conn, sslFallbackEnabled: fallback });
|
|
37167
|
+
return { client, usedSsl, refusedTls: fallback && !usedSsl };
|
|
36714
37168
|
}
|
|
36715
37169
|
async function dropMonitoringRole2(client) {
|
|
36716
37170
|
for (const sql of [`drop role ${DEFAULT_MONITORING_USER}`, `drop owned by ${DEFAULT_MONITORING_USER}; drop role ${DEFAULT_MONITORING_USER}`]) {
|
|
@@ -36743,7 +37197,7 @@ async function prepareDatabase2(url, provider, opts = {}) {
|
|
|
36743
37197
|
throw err;
|
|
36744
37198
|
}
|
|
36745
37199
|
};
|
|
36746
|
-
const { client } = await open(url).catch((err) => {
|
|
37200
|
+
const { client, usedSsl, refusedTls } = await open(url).catch((err) => {
|
|
36747
37201
|
if (/client password must be a string/.test(String(err?.message)))
|
|
36748
37202
|
throw new Error("The URL has no password (and PGPASSWORD is not set): pgai connect postgresql://user:password@host:5432/dbname");
|
|
36749
37203
|
throw err;
|
|
@@ -36757,7 +37211,7 @@ async function prepareDatabase2(url, provider, opts = {}) {
|
|
|
36757
37211
|
and has_database_privilege(current_user, current_database(), 'CREATE')
|
|
36758
37212
|
and exists (select 1 from pg_extension where extname = 'pg_stat_statements')) as admin,
|
|
36759
37213
|
exists (select 1 from pg_roles where rolname = '${DEFAULT_MONITORING_USER}') as mon_exists,
|
|
36760
|
-
current_setting('scram_iterations', true) as iterations
|
|
37214
|
+
current_setting('scram_iterations', true) as iterations, current_setting('ssl') as ssl
|
|
36761
37215
|
from pg_roles where rolname = current_user`)).rows[0];
|
|
36762
37216
|
const notes = [caNote2(url)].filter((n) => !!n);
|
|
36763
37217
|
const noted = () => notes.length ? { note: notes.join("; ") } : {};
|
|
@@ -36781,9 +37235,31 @@ async function prepareDatabase2(url, provider, opts = {}) {
|
|
|
36781
37235
|
}
|
|
36782
37236
|
} else if (me.admin && pgProvider !== "supabase") {
|
|
36783
37237
|
const exists = `${DEFAULT_MONITORING_USER} already exists on this server`;
|
|
37238
|
+
const setPassword = `Set PGAI_MON_PASSWORD to the password of ${DEFAULT_MONITORING_USER}, or give it a new one: pgai connect <admin-url> --reset-password (anything else that logs in as ${DEFAULT_MONITORING_USER} then needs the new password)`;
|
|
37239
|
+
const needs = storedPasswordNeeds2(monitoringUrlFor2(url, me.db, ""));
|
|
37240
|
+
const noTls = refusedTls || !usedSsl && me.ssl === "off";
|
|
37241
|
+
const tlsOnly = `PostgresAI sends the password it keeps for ${DEFAULT_MONITORING_USER} only over TLS`;
|
|
37242
|
+
const others = opts.others?.length ? opts.others : undefined;
|
|
37243
|
+
const disconnectOthers = others ? disconnectEach2(others) : "pgai disconnect the server's other databases (pgai databases)";
|
|
37244
|
+
const reconnectOthers = `(and connect ${others ? others.join(", ") : "them"} again with it)`;
|
|
37245
|
+
const noTlsWayOut = opts.agent ? `Pass its URL as database_url, or run pgai connect in a terminal with PGAI_MON_PASSWORD set to its password. If nobody has it, in a terminal: ${disconnectOthers}, then pgai connect <admin-url> --reset-password with PGAI_MON_PASSWORD set to a new one ${reconnectOthers}` : `Set PGAI_MON_PASSWORD to its password, or turn on TLS on the server (ssl = on) and put sslmode=require in the URL. If nobody has the password: ${disconnectOthers}, then re-run with --reset-password and PGAI_MON_PASSWORD set to a new one ${reconnectOthers}`;
|
|
37246
|
+
const reuse = me.mon_exists && !opts.resetPassword && !!opts.storedPassword && !(opts.agent ? "" : process.env.PGAI_MON_PASSWORD?.trim());
|
|
37247
|
+
if (reuse && needs) {
|
|
37248
|
+
if (noTls)
|
|
37249
|
+
return { next: `${exists}, but this server takes no TLS, and ${tlsOnly}. ${noTlsWayOut}` };
|
|
37250
|
+
return { next: `${exists}. ${opts.agent ? "database_url" : "The URL"} needs ${needs}: the password PostgresAI keeps for this server is sent only over TLS, to a URL with each parameter once` };
|
|
37251
|
+
}
|
|
37252
|
+
if (reuse) {
|
|
37253
|
+
if (opts.check)
|
|
37254
|
+
return { checked: true };
|
|
37255
|
+
const { password: unused } = await resolveMonitoringPassword({ monitoringUser: DEFAULT_MONITORING_USER });
|
|
37256
|
+
const plan = await buildInitPlan({ database: me.db, monitoringPassword: unused, iterations: Number(me.iterations), includeOptionalPermissions: true, provider: pgProvider, keepExistingPassword: true });
|
|
37257
|
+
await applyInitPlan({ client, plan });
|
|
37258
|
+
return { monitoringUrl: monitoringUrlFor2(url, me.db, ""), storedPassword: true, ...noted() };
|
|
37259
|
+
}
|
|
36784
37260
|
if (me.mon_exists && opts.agent)
|
|
36785
37261
|
return { next: `${exists}. Pass its URL as database_url, or run pgai connect in a terminal with PGAI_MON_PASSWORD set to its password` };
|
|
36786
|
-
const
|
|
37262
|
+
const unset = !opts.storedPassword ? setPassword : noTls ? `This server takes no TLS, and ${tlsOnly}. ${noTlsWayOut}` : !needs ? `Unset PGAI_MON_PASSWORD: PostgresAI keeps the password of ${DEFAULT_MONITORING_USER} for this server` : `Unset PGAI_MON_PASSWORD, and the URL needs ${needs}: PostgresAI keeps the password of ${DEFAULT_MONITORING_USER} for this server, and sends it only over TLS, to a URL with each parameter once`;
|
|
36787
37263
|
const { password, generated } = await resolveMonitoringPassword({ passwordEnv: opts.agent ? undefined : process.env.PGAI_MON_PASSWORD, monitoringUser: DEFAULT_MONITORING_USER });
|
|
36788
37264
|
const reset = !!opts.resetPassword && me.mon_exists;
|
|
36789
37265
|
if (me.mon_exists && !reset) {
|
|
@@ -36793,13 +37269,16 @@ async function prepareDatabase2(url, provider, opts = {}) {
|
|
|
36793
37269
|
try {
|
|
36794
37270
|
accepted = await logsIn(loginUrlFor2(url, me.db, password));
|
|
36795
37271
|
} catch (err) {
|
|
36796
|
-
|
|
37272
|
+
const orUnset = opts.storedPassword && !noTls ? ", or unset PGAI_MON_PASSWORD: PostgresAI keeps its password for this server" : "";
|
|
37273
|
+
return { next: `${exists}, and PGAI_MON_PASSWORD could not be checked from this host (${err instanceof Error ? err.message : String(err)}). Run pgai connect from a host that ${DEFAULT_MONITORING_USER} may connect from${orUnset}` };
|
|
36797
37274
|
}
|
|
36798
37275
|
if (!accepted)
|
|
36799
|
-
return { next: `${exists} and PGAI_MON_PASSWORD is not its password. ${
|
|
37276
|
+
return { next: `${exists} and PGAI_MON_PASSWORD is not its password. ${unset}` };
|
|
36800
37277
|
if (await acceptsAnyPassword())
|
|
36801
37278
|
notes.push(unchecked("PGAI_MON_PASSWORD"));
|
|
36802
37279
|
}
|
|
37280
|
+
if (opts.check)
|
|
37281
|
+
return { checked: true };
|
|
36803
37282
|
const monitoringUrl = monitoringUrlFor2(url, me.db, password);
|
|
36804
37283
|
const loginUrl = loginUrlFor2(url, me.db, password);
|
|
36805
37284
|
const plan = await buildInitPlan({ database: me.db, monitoringPassword: password, iterations: Number(me.iterations), includeOptionalPermissions: true, provider: pgProvider, keepExistingPassword: !reset });
|
|
@@ -36828,14 +37307,43 @@ ${redactPasswordsInSql(s.sql)}`).join(`
|
|
|
36828
37307
|
}
|
|
36829
37308
|
async function expressCheckup2(url, opts = {}) {
|
|
36830
37309
|
const { client } = await openConnection2(url, opts);
|
|
37310
|
+
let reports;
|
|
37311
|
+
const failed = [];
|
|
36831
37312
|
try {
|
|
36832
|
-
|
|
36833
|
-
const reports = await generateAllReports(client, "node-01", undefined, (f) => failed.push(f.checkId));
|
|
36834
|
-
const findings = Object.values(reports).map((r) => ({ check_id: r.checkId, title: r.checkTitle, status: r.summary.status, message: r.summary.message })).filter((f) => f.status !== "info").sort((a, b) => Number(b.status === "warning") - Number(a.status === "warning"));
|
|
36835
|
-
return { checks: Object.keys(reports).length + failed.length, findings, ...failed.length ? { failed } : {} };
|
|
37313
|
+
reports = await generateAllReports(client, "node-01", undefined, (f) => failed.push(f.checkId));
|
|
36836
37314
|
} finally {
|
|
36837
37315
|
await client.end();
|
|
36838
37316
|
}
|
|
37317
|
+
const all = Object.values(reports).map((r) => ({ check_id: r.checkId, title: r.checkTitle, status: r.summary.status, message: r.summary.message }));
|
|
37318
|
+
const findings = all.filter((f) => f.status !== "info").sort((a, b) => Number(b.status === "warning") - Number(a.status === "warning"));
|
|
37319
|
+
const result = { checks: all.length + failed.length, findings, info: all.filter((f) => f.status === "info").map((f) => f.check_id), ...failed.length ? { failed } : {} };
|
|
37320
|
+
if (!opts.save)
|
|
37321
|
+
return result;
|
|
37322
|
+
try {
|
|
37323
|
+
return { ...result, report_id: await opts.save(reports) };
|
|
37324
|
+
} catch (err) {
|
|
37325
|
+
return { ...result, upload_error: errorText2(err) };
|
|
37326
|
+
}
|
|
37327
|
+
}
|
|
37328
|
+
async function saveCheckupReport2(rpc, accessToken, project, reports) {
|
|
37329
|
+
const { reportId } = await createCheckupReport(rpc, { apiKey: accessToken, project });
|
|
37330
|
+
const setStatus = (status) => rpc("checkup_report_status_update", { access_token: accessToken, report_id: reportId, status });
|
|
37331
|
+
try {
|
|
37332
|
+
for (const [checkId, report] of Object.entries(reports)) {
|
|
37333
|
+
await uploadCheckupReportJson(rpc, {
|
|
37334
|
+
apiKey: accessToken,
|
|
37335
|
+
reportId,
|
|
37336
|
+
filename: `${checkId}.json`,
|
|
37337
|
+
checkId,
|
|
37338
|
+
jsonText: JSON.stringify(report, null, 2)
|
|
37339
|
+
});
|
|
37340
|
+
}
|
|
37341
|
+
} catch (err) {
|
|
37342
|
+
await setStatus("failed").catch(() => {});
|
|
37343
|
+
throw err;
|
|
37344
|
+
}
|
|
37345
|
+
await setStatus("completed");
|
|
37346
|
+
return reportId;
|
|
36839
37347
|
}
|
|
36840
37348
|
async function clickhouseOrgFor2(host, keyId, keySecret) {
|
|
36841
37349
|
const apiUrl = process.env.CLICKHOUSE_API_URL || "https://api.clickhouse.cloud";
|
|
@@ -36866,90 +37374,141 @@ async function clickhouseOrgFor2(host, keyId, keySecret) {
|
|
|
36866
37374
|
}
|
|
36867
37375
|
throw failed ?? notFound;
|
|
36868
37376
|
}
|
|
37377
|
+
function disconnectBilling(reply) {
|
|
37378
|
+
const r = reply ?? {};
|
|
37379
|
+
if (r.billing_warning)
|
|
37380
|
+
return `not released (${r.billing_warning}): contact support`;
|
|
37381
|
+
if (r.billing?.subscription === "canceled")
|
|
37382
|
+
return "subscription canceled: no further charges; the unused part of this period is credited (prorated)";
|
|
37383
|
+
if (r.billing?.subscription === "active" && typeof r.billing.quantity === "number") {
|
|
37384
|
+
return `${r.billing.quantity} ${r.billing.quantity === 1 ? "database cluster" : "database clusters"} left on the subscription`;
|
|
37385
|
+
}
|
|
37386
|
+
return;
|
|
37387
|
+
}
|
|
36869
37388
|
function platformDeps2(p) {
|
|
36870
37389
|
const rpc = (fn, body = {}) => callRpc({ apiKey: p.apiKey, apiBaseUrl: p.apiBaseUrl, fn, body, operation: fn.replace(/_/g, " "), debug: p.debug, orgScope: p.orgScope });
|
|
36871
37390
|
return {
|
|
36872
37391
|
list: () => rpc("cloud_monitoring_list"),
|
|
36873
37392
|
create: (body) => rpc("cloud_monitoring_connect", body),
|
|
36874
37393
|
disconnect: (id) => rpc("cloud_monitoring_disconnect", { instance_id: id }),
|
|
37394
|
+
resetLock: (server) => rpc("cloud_monitoring_reset_lock", { server }),
|
|
37395
|
+
resetUnlock: (lockId) => rpc("cloud_monitoring_reset_unlock", { lock_id: lockId }),
|
|
36875
37396
|
prepare: (url, provider, o = {}) => prepareDatabase2(url, provider, { ...o, agent: p.agent }),
|
|
36876
37397
|
unprepare: (url) => unprepareDatabase2(url, { agent: p.agent }),
|
|
36877
37398
|
clickhouseOrg: clickhouseOrgFor2,
|
|
36878
|
-
checkup: (url) => expressCheckup2(url, { agent: p.agent }),
|
|
37399
|
+
checkup: (url, project) => expressCheckup2(url, { agent: p.agent, save: (reports) => saveCheckupReport2(rpc, p.apiKey, project, reports) }),
|
|
37400
|
+
quote: (coupon, cluster) => rpc("cloud_monitoring_quote", { ...coupon ? { promo_code: coupon } : {}, ...cluster ? { db_server: cluster } : {} }),
|
|
37401
|
+
billingUrl: (orgAlias) => `${p.uiBaseUrl}/${orgAlias}/billing`,
|
|
36879
37402
|
handoffUrl: async (provider) => {
|
|
36880
37403
|
const orgs = await listOrgs({ apiKey: p.apiKey, apiBaseUrl: p.apiBaseUrl });
|
|
36881
37404
|
const org = orgs.length === 1 ? orgs[0] : orgs.find((o) => o.alias === p.orgScope?.alias || o.org_id === p.orgScope?.id);
|
|
36882
37405
|
return `${p.uiBaseUrl}/${org?.alias ?? "<org>"}/monitoring/scale/create/${provider}`;
|
|
36883
37406
|
},
|
|
36884
|
-
sleep: (ms) => new Promise((r) => setTimeout(r, ms))
|
|
37407
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
37408
|
+
now: () => Date.now()
|
|
36885
37409
|
};
|
|
36886
37410
|
}
|
|
36887
37411
|
|
|
36888
37412
|
// lib/joe.ts
|
|
36889
37413
|
var DEFAULT_BUDGET_MS = 25000;
|
|
36890
37414
|
var DEFAULT_POLL_INTERVAL_MS = 800;
|
|
37415
|
+
var JOE_ASYNC_FALLBACK_WAIT_MS2 = 10 * 60 * 1000;
|
|
37416
|
+
var JOE_ASYNC_MAX_WAIT_MS2 = 60 * 60 * 1000;
|
|
37417
|
+
var JOE_ASYNC_MAX_POLL_FAILURES = 5;
|
|
36891
37418
|
async function callRpc2(params) {
|
|
37419
|
+
return (await callRpcRaw2(params)).data;
|
|
37420
|
+
}
|
|
37421
|
+
async function callRpcRaw2(params) {
|
|
36892
37422
|
const { apiKey, apiBaseUrl, fn, body, operation, debug } = params;
|
|
36893
37423
|
if (!apiKey) {
|
|
36894
37424
|
throw new Error("API key is required");
|
|
36895
37425
|
}
|
|
36896
37426
|
const base = normalizeBaseUrl(apiBaseUrl);
|
|
36897
37427
|
const url = new URL(`${base}/rpc/${fn}`);
|
|
36898
|
-
const payload = JSON.stringify(body);
|
|
36899
37428
|
const headers = buildAuthHeaders(apiKey, params.orgScope);
|
|
36900
|
-
|
|
36901
|
-
const
|
|
36902
|
-
|
|
36903
|
-
|
|
36904
|
-
|
|
36905
|
-
|
|
36906
|
-
|
|
36907
|
-
|
|
36908
|
-
|
|
36909
|
-
|
|
36910
|
-
|
|
36911
|
-
|
|
36912
|
-
|
|
36913
|
-
|
|
36914
|
-
|
|
36915
|
-
|
|
36916
|
-
|
|
36917
|
-
|
|
37429
|
+
const send = async (bodyObj) => {
|
|
37430
|
+
const payload = JSON.stringify(bodyObj);
|
|
37431
|
+
if (debug) {
|
|
37432
|
+
const debugHeaders = { ...headers, "access-token": maskSecret(apiKey) };
|
|
37433
|
+
console.error(`Debug: POST URL: ${url.toString()}`);
|
|
37434
|
+
console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
|
|
37435
|
+
console.error(`Debug: Request body: ${redactSecretsForLog(payload)}`);
|
|
37436
|
+
}
|
|
37437
|
+
let response;
|
|
37438
|
+
const requestTimeout = requestTimeoutSignal(params.timeoutMs);
|
|
37439
|
+
try {
|
|
37440
|
+
response = await fetch(url.toString(), {
|
|
37441
|
+
method: "POST",
|
|
37442
|
+
headers,
|
|
37443
|
+
body: payload,
|
|
37444
|
+
signal: requestTimeout.signal
|
|
37445
|
+
});
|
|
37446
|
+
} catch (err) {
|
|
37447
|
+
if (isFetchTimeout(err)) {
|
|
37448
|
+
throw new HttpRequestTimeoutError(operation, requestTimeout.timeoutMs);
|
|
37449
|
+
}
|
|
37450
|
+
throw new Error(describeFetchError(operation, base, err));
|
|
36918
37451
|
}
|
|
36919
|
-
|
|
36920
|
-
|
|
36921
|
-
|
|
36922
|
-
|
|
36923
|
-
|
|
36924
|
-
|
|
37452
|
+
const text = await response.text();
|
|
37453
|
+
if (debug) {
|
|
37454
|
+
console.error(`Debug: Response status: ${response.status}`);
|
|
37455
|
+
console.error(`Debug: Response body: ${redactSecretsForLog(text)}`);
|
|
37456
|
+
}
|
|
37457
|
+
return { response, text };
|
|
37458
|
+
};
|
|
37459
|
+
let { response, text } = await send(body);
|
|
37460
|
+
if (params.fallbackBody && isUnknownRpcSignature(response.status, text, fn)) {
|
|
37461
|
+
({ response, text } = await send(params.fallbackBody));
|
|
36925
37462
|
}
|
|
36926
37463
|
if (!response.ok) {
|
|
36927
37464
|
throw new HttpStatusError(formatHttpError(operation, response.status, text, response.statusText), response.status);
|
|
36928
37465
|
}
|
|
36929
37466
|
try {
|
|
36930
|
-
return JSON.parse(text);
|
|
37467
|
+
return { data: JSON.parse(text), status: response.status };
|
|
36931
37468
|
} catch {
|
|
36932
37469
|
throw new Error(`${operation}: failed to parse response: ${redactSecretsForLog(text)}`);
|
|
36933
37470
|
}
|
|
36934
37471
|
}
|
|
36935
|
-
|
|
37472
|
+
function joeAsyncWaitMs(handle) {
|
|
37473
|
+
const expires = positiveSeconds(handle.expires_in_s);
|
|
37474
|
+
return Math.min(expires ? expires * 1000 : JOE_ASYNC_FALLBACK_WAIT_MS2, JOE_ASYNC_MAX_WAIT_MS2);
|
|
37475
|
+
}
|
|
37476
|
+
function asyncHandleCommandId(reply, operation) {
|
|
37477
|
+
const commandId = reply?.command_id;
|
|
37478
|
+
if (typeof commandId !== "string" || !/^[0-9]+$/.test(commandId)) {
|
|
37479
|
+
throw new Error(`${operation}: the platform accepted the command over the job channel but ` + `returned no command id: ${redactSecretsForLog(JSON.stringify(reply))}`);
|
|
37480
|
+
}
|
|
37481
|
+
return commandId;
|
|
37482
|
+
}
|
|
37483
|
+
async function startCommandDetailed(params) {
|
|
36936
37484
|
const { apiKey, apiBaseUrl, instanceId, command, debug, timeoutMs } = params;
|
|
36937
37485
|
if (!String(command ?? "").trim()) {
|
|
36938
37486
|
throw new Error("command text is required");
|
|
36939
37487
|
}
|
|
36940
|
-
const
|
|
37488
|
+
const operation = "Failed to run Joe command";
|
|
37489
|
+
const body = { instance_id: instanceId, command };
|
|
37490
|
+
const { data, status } = await callRpcRaw2({
|
|
36941
37491
|
apiKey,
|
|
36942
37492
|
apiBaseUrl,
|
|
36943
37493
|
fn: "joe_command_run",
|
|
36944
|
-
body: {
|
|
36945
|
-
|
|
37494
|
+
body: { ...body, accept_async: true },
|
|
37495
|
+
fallbackBody: body,
|
|
37496
|
+
operation,
|
|
36946
37497
|
debug,
|
|
36947
37498
|
timeoutMs
|
|
36948
37499
|
});
|
|
36949
|
-
if (
|
|
36950
|
-
|
|
37500
|
+
if (status === 202) {
|
|
37501
|
+
const handle = data ?? {};
|
|
37502
|
+
return {
|
|
37503
|
+
commandId: asyncHandleCommandId(data, operation),
|
|
37504
|
+
asyncWaitMs: joeAsyncWaitMs(handle),
|
|
37505
|
+
firstAnswerEstimateS: positiveSeconds(handle.first_answer_estimate_s)
|
|
37506
|
+
};
|
|
36951
37507
|
}
|
|
36952
|
-
|
|
37508
|
+
if (typeof data !== "string" || !/^[0-9]+$/.test(data)) {
|
|
37509
|
+
throw new Error(`${operation}: expected a command id string, got: ${redactSecretsForLog(JSON.stringify(data))}`);
|
|
37510
|
+
}
|
|
37511
|
+
return { commandId: data, asyncWaitMs: null, firstAnswerEstimateS: null };
|
|
36953
37512
|
}
|
|
36954
37513
|
async function getCommandOutput(params) {
|
|
36955
37514
|
const { apiKey, apiBaseUrl, commandId, debug, timeoutMs } = params;
|
|
@@ -37079,18 +37638,29 @@ function buildJoeCommandText(command, input = {}) {
|
|
|
37079
37638
|
var defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
37080
37639
|
async function runCommand(params) {
|
|
37081
37640
|
const { apiKey, apiBaseUrl, instanceId, command, debug } = params;
|
|
37082
|
-
const
|
|
37083
|
-
const pollIntervalMs = params.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
37641
|
+
const explicitBudgetMs = typeof params.budgetMs === "number" && Number.isFinite(params.budgetMs) && params.budgetMs >= 0 ? params.budgetMs : null;
|
|
37084
37642
|
const now = params.now ?? Date.now;
|
|
37085
37643
|
const sleep = params.sleep ?? defaultSleep;
|
|
37086
|
-
const
|
|
37644
|
+
const started = await startCommandDetailed({ apiKey, apiBaseUrl, instanceId, command, debug });
|
|
37645
|
+
const commandId = started.commandId;
|
|
37646
|
+
const budgetMs = explicitBudgetMs ?? started.asyncWaitMs ?? DEFAULT_BUDGET_MS;
|
|
37647
|
+
const pollDelayMs = (attempt) => params.pollIntervalMs ?? (started.asyncWaitMs === null ? DEFAULT_POLL_INTERVAL_MS : asyncPollDelayMs(attempt));
|
|
37087
37648
|
const deadline = now() + budgetMs;
|
|
37088
37649
|
let status = "pending";
|
|
37089
37650
|
const remainingRequestMs = () => Math.max(1, Math.min(DEFAULT_HTTP_REQUEST_TIMEOUT_MS, deadline - now()));
|
|
37090
37651
|
if (now() >= deadline) {
|
|
37091
|
-
return { commandId, status, output: null, budgetExpired: true };
|
|
37652
|
+
return { commandId, status, output: null, budgetExpired: true, budgetMs };
|
|
37092
37653
|
}
|
|
37093
|
-
|
|
37654
|
+
let announced = false;
|
|
37655
|
+
const announce = () => {
|
|
37656
|
+
if (announced || started.asyncWaitMs === null)
|
|
37657
|
+
return;
|
|
37658
|
+
announced = true;
|
|
37659
|
+
const estimate = started.firstAnswerEstimateS;
|
|
37660
|
+
console.error(`Queued on the Joe job channel (command ${commandId}` + (estimate ? `, reaches Joe in about ${estimate}s` : "") + `); waiting up to ${Math.round(budgetMs / 1000)}s...`);
|
|
37661
|
+
};
|
|
37662
|
+
let transientFailures = 0;
|
|
37663
|
+
for (let attempt = 0;; attempt++) {
|
|
37094
37664
|
let output;
|
|
37095
37665
|
try {
|
|
37096
37666
|
output = await getCommandOutput({
|
|
@@ -37102,25 +37672,28 @@ async function runCommand(params) {
|
|
|
37102
37672
|
});
|
|
37103
37673
|
} catch (err) {
|
|
37104
37674
|
if (err instanceof HttpRequestTimeoutError) {
|
|
37105
|
-
return { commandId, status, output: null, budgetExpired: true };
|
|
37675
|
+
return { commandId, status, output: null, budgetExpired: true, budgetMs };
|
|
37106
37676
|
}
|
|
37107
37677
|
if (err instanceof HttpStatusError && isRetryableHttpStatus(err.status)) {
|
|
37108
|
-
|
|
37109
|
-
|
|
37678
|
+
transientFailures += 1;
|
|
37679
|
+
if (now() >= deadline || started.asyncWaitMs !== null && transientFailures >= JOE_ASYNC_MAX_POLL_FAILURES) {
|
|
37680
|
+
return { commandId, status, output: null, budgetExpired: true, budgetMs };
|
|
37110
37681
|
}
|
|
37111
|
-
await sleep(
|
|
37682
|
+
await sleep(pollDelayMs(attempt));
|
|
37112
37683
|
continue;
|
|
37113
37684
|
}
|
|
37114
37685
|
throw err;
|
|
37115
37686
|
}
|
|
37687
|
+
transientFailures = 0;
|
|
37116
37688
|
status = output.status;
|
|
37117
37689
|
if (status === "ok" || status === "error") {
|
|
37118
|
-
return { commandId, status, output, budgetExpired: false };
|
|
37690
|
+
return { commandId, status, output, budgetExpired: false, budgetMs };
|
|
37119
37691
|
}
|
|
37120
37692
|
if (now() >= deadline) {
|
|
37121
|
-
return { commandId, status, output: null, budgetExpired: true };
|
|
37693
|
+
return { commandId, status, output: null, budgetExpired: true, budgetMs };
|
|
37122
37694
|
}
|
|
37123
|
-
|
|
37695
|
+
announce();
|
|
37696
|
+
await sleep(pollDelayMs(attempt));
|
|
37124
37697
|
}
|
|
37125
37698
|
}
|
|
37126
37699
|
async function executeJoeCommand(params) {
|
|
@@ -37305,22 +37878,6 @@ async function postDblabRpc(rpc, bodyObj, params) {
|
|
|
37305
37878
|
}
|
|
37306
37879
|
return { response, text };
|
|
37307
37880
|
}
|
|
37308
|
-
function isUnknownRpcSignature(status, text, rpc) {
|
|
37309
|
-
if (status !== 404)
|
|
37310
|
-
return false;
|
|
37311
|
-
try {
|
|
37312
|
-
const parsed = JSON.parse(text);
|
|
37313
|
-
return typeof parsed.message === "string" && /Could not find the .* function/.test(parsed.message) && parsed.message.includes(`${rpc}(`);
|
|
37314
|
-
} catch {
|
|
37315
|
-
return false;
|
|
37316
|
-
}
|
|
37317
|
-
}
|
|
37318
|
-
function positiveSeconds(v) {
|
|
37319
|
-
return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : null;
|
|
37320
|
-
}
|
|
37321
|
-
function dblabPollDelay(attempt) {
|
|
37322
|
-
return Math.min(1000 * 2 ** attempt, 15000);
|
|
37323
|
-
}
|
|
37324
37881
|
var DBLAB_ASYNC_FALLBACK_WAIT_MS = 15 * 60 * 1000;
|
|
37325
37882
|
var DBLAB_ASYNC_MAX_WAIT_MS = 60 * 60 * 1000;
|
|
37326
37883
|
var DBLAB_POLL_MAX_CONSECUTIVE_FAILURES = 5;
|
|
@@ -37407,7 +37964,7 @@ async function awaitDblabCall(handle, params) {
|
|
|
37407
37964
|
announced = true;
|
|
37408
37965
|
console.error(`Waiting for the DBLab instance to pick up the call (job ${jobId}` + (estimate ? `, typically ~${estimate}s` : "") + ")...");
|
|
37409
37966
|
}
|
|
37410
|
-
const delay =
|
|
37967
|
+
const delay = asyncPollDelayMs(attempt);
|
|
37411
37968
|
if (now() + delay >= deadline) {
|
|
37412
37969
|
throw new Error(`${operation}: timed out waiting for the DBLab instance (job ${jobId}, last status ${lastStatus}). ` + dblabNotResentAdvice(jobId));
|
|
37413
37970
|
}
|
|
@@ -40129,7 +40686,7 @@ function feedbackMessage() {
|
|
|
40129
40686
|
}
|
|
40130
40687
|
|
|
40131
40688
|
// bin/postgres-ai.ts
|
|
40132
|
-
import { createInterface } from "readline";
|
|
40689
|
+
import { createInterface as createInterface2 } from "readline";
|
|
40133
40690
|
import { Writable } from "stream";
|
|
40134
40691
|
import * as childProcess from "child_process";
|
|
40135
40692
|
|
|
@@ -42722,48 +43279,11 @@ async function verifyApiKey(params) {
|
|
|
42722
43279
|
clearTimeout(timer);
|
|
42723
43280
|
}
|
|
42724
43281
|
}
|
|
42725
|
-
async function
|
|
42726
|
-
|
|
42727
|
-
const bodyObj = {
|
|
42728
|
-
access_token: apiKey,
|
|
42729
|
-
project
|
|
42730
|
-
};
|
|
42731
|
-
if (status)
|
|
42732
|
-
bodyObj.status = status;
|
|
42733
|
-
const resp = await postRpc({
|
|
42734
|
-
apiKey,
|
|
42735
|
-
apiBaseUrl,
|
|
42736
|
-
rpcName: "checkup_report_create",
|
|
42737
|
-
bodyObj
|
|
42738
|
-
});
|
|
42739
|
-
const reportId = Number(resp?.report_id);
|
|
42740
|
-
if (!Number.isFinite(reportId) || reportId <= 0) {
|
|
42741
|
-
throw new Error(`Unexpected checkup_report_create response: ${JSON.stringify(resp)}`);
|
|
42742
|
-
}
|
|
42743
|
-
return { reportId };
|
|
43282
|
+
async function createCheckupReport2(params) {
|
|
43283
|
+
return createCheckupReport((rpcName, bodyObj) => postRpc({ ...params, rpcName, bodyObj }), params);
|
|
42744
43284
|
}
|
|
42745
|
-
async function
|
|
42746
|
-
|
|
42747
|
-
const bodyObj = {
|
|
42748
|
-
access_token: apiKey,
|
|
42749
|
-
checkup_report_id: reportId,
|
|
42750
|
-
filename,
|
|
42751
|
-
check_id: checkId,
|
|
42752
|
-
data: jsonText,
|
|
42753
|
-
type: "json",
|
|
42754
|
-
generate_issue: true
|
|
42755
|
-
};
|
|
42756
|
-
const resp = await postRpc({
|
|
42757
|
-
apiKey,
|
|
42758
|
-
apiBaseUrl,
|
|
42759
|
-
rpcName: "checkup_report_file_post",
|
|
42760
|
-
bodyObj
|
|
42761
|
-
});
|
|
42762
|
-
const chunkId = Number(resp?.report_chunck_id ?? resp?.report_chunk_id);
|
|
42763
|
-
if (!Number.isFinite(chunkId) || chunkId <= 0) {
|
|
42764
|
-
throw new Error(`Unexpected checkup_report_file_post response: ${JSON.stringify(resp)}`);
|
|
42765
|
-
}
|
|
42766
|
-
return { reportChunkId: chunkId };
|
|
43285
|
+
async function uploadCheckupReportJson2(params) {
|
|
43286
|
+
return uploadCheckupReportJson((rpcName, bodyObj) => postRpc({ ...params, rpcName, bodyObj }), params);
|
|
42767
43287
|
}
|
|
42768
43288
|
async function convertCheckupReportJsonToMarkdown(params) {
|
|
42769
43289
|
const { apiKey, apiBaseUrl, checkId, jsonPayload, reportType } = params;
|
|
@@ -42998,7 +43518,8 @@ function summarizeF0012(nodeData) {
|
|
|
42998
43518
|
return `Autovacuum config: ${parts.join(", ")} finding${fired.length > 1 ? "s" : ""}`;
|
|
42999
43519
|
};
|
|
43000
43520
|
if (severity === "CRITICAL" || severity === "WARNING") {
|
|
43001
|
-
|
|
43521
|
+
const top = fired.find((r) => r.severity === "CRITICAL") ?? fired.find((r) => r.severity === "WARNING");
|
|
43522
|
+
return { status: "warning", message: top?.conclusion ? `${summarize()}. ${top.conclusion}` : summarize() };
|
|
43002
43523
|
}
|
|
43003
43524
|
return { status: "info", message: summarize() };
|
|
43004
43525
|
}
|
|
@@ -43155,6 +43676,48 @@ function summarizeG0032(nodeData) {
|
|
|
43155
43676
|
};
|
|
43156
43677
|
}
|
|
43157
43678
|
|
|
43679
|
+
// lib/json-stderr.ts
|
|
43680
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
43681
|
+
import { createInterface } from "node:readline";
|
|
43682
|
+
import { format } from "node:util";
|
|
43683
|
+
function writeEvent(event) {
|
|
43684
|
+
process.stderr.write(`${JSON.stringify(event)}
|
|
43685
|
+
`);
|
|
43686
|
+
}
|
|
43687
|
+
function jsonConsole() {
|
|
43688
|
+
const { error, warn } = console;
|
|
43689
|
+
const log = (level) => (...args) => {
|
|
43690
|
+
const message = format(...args);
|
|
43691
|
+
writeEvent({ event: "log", level: /^\s*Debug:/.test(message) ? "debug" : /^\s*(?:\[[^\]]+\]\s*)?Warning:/.test(message) ? "warn" : level, message });
|
|
43692
|
+
};
|
|
43693
|
+
console.error = log("error");
|
|
43694
|
+
console.warn = log("warn");
|
|
43695
|
+
return () => {
|
|
43696
|
+
console.error = error;
|
|
43697
|
+
console.warn = warn;
|
|
43698
|
+
};
|
|
43699
|
+
}
|
|
43700
|
+
var LOGIN = /^(\s*(?:Login|VictoriaMetrics Auth): ).+$/;
|
|
43701
|
+
function runChild(command, args, env, json, source) {
|
|
43702
|
+
if (!json)
|
|
43703
|
+
return Promise.resolve(spawnSync(command, args, { stdio: ["ignore", 2, 2], env }).status);
|
|
43704
|
+
return new Promise((resolve) => {
|
|
43705
|
+
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"], env });
|
|
43706
|
+
for (const [stream, level] of [[child.stdout, "info"], [child.stderr, "error"]]) {
|
|
43707
|
+
createInterface({ input: stream }).on("line", (line) => {
|
|
43708
|
+
const message = line.replace(LOGIN, "$1***** (pgai mon show-grafana-credentials)");
|
|
43709
|
+
if (line.trim() !== "")
|
|
43710
|
+
writeEvent({ event: "log", level, source, message });
|
|
43711
|
+
});
|
|
43712
|
+
}
|
|
43713
|
+
child.on("error", (err) => {
|
|
43714
|
+
writeEvent({ event: "log", level: "error", source, message: err.message });
|
|
43715
|
+
resolve(null);
|
|
43716
|
+
});
|
|
43717
|
+
child.on("close", (code) => resolve(code));
|
|
43718
|
+
});
|
|
43719
|
+
}
|
|
43720
|
+
|
|
43158
43721
|
// lib/instances.ts
|
|
43159
43722
|
import * as fs10 from "fs";
|
|
43160
43723
|
class InstancesParseError2 extends Error {
|
|
@@ -43300,12 +43863,22 @@ function buildClientConfig(connStr, extra = {}) {
|
|
|
43300
43863
|
port: parsed.port ? Number(parsed.port) : undefined,
|
|
43301
43864
|
user: parsed.user,
|
|
43302
43865
|
password: parsed.password,
|
|
43303
|
-
database: parsed.database
|
|
43866
|
+
database: databaseName3(withoutSslmode(channelBinding.uri), parsed.database),
|
|
43304
43867
|
ssl: sslOptionFromSslmode(sslmode),
|
|
43305
43868
|
...enableChannelBinding ? { enableChannelBinding: true } : {},
|
|
43306
43869
|
...extra
|
|
43307
43870
|
};
|
|
43308
43871
|
}
|
|
43872
|
+
function databaseName3(connStr, parsed) {
|
|
43873
|
+
if (!parsed)
|
|
43874
|
+
return;
|
|
43875
|
+
try {
|
|
43876
|
+
const u = new URL(connStr);
|
|
43877
|
+
if (/^postgres(ql)?:$/.test(u.protocol))
|
|
43878
|
+
return decodeURIComponent(u.pathname.slice(1));
|
|
43879
|
+
} catch {}
|
|
43880
|
+
return parsed;
|
|
43881
|
+
}
|
|
43309
43882
|
function withoutSslmode(connStr) {
|
|
43310
43883
|
try {
|
|
43311
43884
|
const u = new URL(connStr);
|
|
@@ -43349,7 +43922,7 @@ Download: https://nodejs.org/`);
|
|
|
43349
43922
|
var rl = null;
|
|
43350
43923
|
function getReadline() {
|
|
43351
43924
|
if (!rl) {
|
|
43352
|
-
rl =
|
|
43925
|
+
rl = createInterface2({ input: process.stdin, output: process.stdout });
|
|
43353
43926
|
}
|
|
43354
43927
|
return rl;
|
|
43355
43928
|
}
|
|
@@ -43574,7 +44147,7 @@ async function execFilePromise(file, args, options = {}) {
|
|
|
43574
44147
|
});
|
|
43575
44148
|
});
|
|
43576
44149
|
}
|
|
43577
|
-
function
|
|
44150
|
+
function spawnSync3(cmd, args, options) {
|
|
43578
44151
|
const result = childProcess.spawnSync(cmd, args, {
|
|
43579
44152
|
stdio: options?.stdio === "inherit" ? "inherit" : "pipe",
|
|
43580
44153
|
env: options?.env,
|
|
@@ -43587,7 +44160,7 @@ function spawnSync2(cmd, args, options) {
|
|
|
43587
44160
|
stderr: typeof result.stderr === "string" ? result.stderr : ""
|
|
43588
44161
|
};
|
|
43589
44162
|
}
|
|
43590
|
-
function
|
|
44163
|
+
function spawn3(cmd, args, options) {
|
|
43591
44164
|
const proc = childProcess.spawn(cmd, args, {
|
|
43592
44165
|
stdio: options?.stdio ?? "pipe",
|
|
43593
44166
|
env: options?.env,
|
|
@@ -43618,7 +44191,7 @@ async function question(prompt) {
|
|
|
43618
44191
|
}
|
|
43619
44192
|
async function questionHidden(prompt) {
|
|
43620
44193
|
closeReadline();
|
|
43621
|
-
const hidden =
|
|
44194
|
+
const hidden = createInterface2({ input: process.stdin, output: new Writable({ write: (_chunk, _encoding, done) => done() }), terminal: true });
|
|
43622
44195
|
process.stdout.write(prompt);
|
|
43623
44196
|
return new Promise((resolve) => {
|
|
43624
44197
|
hidden.question("", (answer) => {
|
|
@@ -43749,7 +44322,7 @@ function prepareUploadConfig(opts, rootOpts, shouldUpload, uploadExplicitlyReque
|
|
|
43749
44322
|
}
|
|
43750
44323
|
async function uploadCheckupReports(uploadCfg, reports, spinner, logUpload) {
|
|
43751
44324
|
spinner.update("Creating remote checkup report");
|
|
43752
|
-
const created = await withRetry(() =>
|
|
44325
|
+
const created = await withRetry(() => createCheckupReport2({
|
|
43753
44326
|
apiKey: uploadCfg.apiKey,
|
|
43754
44327
|
apiBaseUrl: uploadCfg.apiBaseUrl,
|
|
43755
44328
|
project: uploadCfg.project
|
|
@@ -43762,7 +44335,7 @@ async function uploadCheckupReports(uploadCfg, reports, spinner, logUpload) {
|
|
|
43762
44335
|
for (const [checkId, report] of Object.entries(reports)) {
|
|
43763
44336
|
spinner.update(`Uploading ${checkId}.json`);
|
|
43764
44337
|
const jsonText = JSON.stringify(report, null, 2);
|
|
43765
|
-
const r = await withRetry(() =>
|
|
44338
|
+
const r = await withRetry(() => uploadCheckupReportJson2({
|
|
43766
44339
|
apiKey: uploadCfg.apiKey,
|
|
43767
44340
|
apiBaseUrl: uploadCfg.apiBaseUrl,
|
|
43768
44341
|
reportId,
|
|
@@ -44009,6 +44582,56 @@ function printResult(result, json) {
|
|
|
44009
44582
|
var program2 = new Command2;
|
|
44010
44583
|
program2.name("postgres-ai").description("PostgresAI CLI").version(package_default.version).option("--api-key <key>", "API key (overrides PGAI_API_KEY)").option("--api-base-url <url>", "API base URL for backend RPC (overrides PGAI_API_BASE_URL)").option("--ui-base-url <url>", "UI base URL for browser routes (overrides PGAI_UI_BASE_URL)").option("--storage-base-url <url>", "Storage base URL for file uploads (overrides PGAI_STORAGE_BASE_URL)");
|
|
44011
44584
|
program2.showHelpAfterError();
|
|
44585
|
+
program2.configureOutput({
|
|
44586
|
+
outputError: (text, write) => jsonConnectArgv() ? writeEvent({ event: "log", level: "error", message: text.trim().replace(/^(error: unknown option ')(--[^=]*|-[^-])[\s\S]*'(?=\n\(Did you mean [^\n]*\)$|$)/, "$1$2'") }) : write(text),
|
|
44587
|
+
writeErr: (text) => void (jsonConnectArgv() || process.stderr.write(text))
|
|
44588
|
+
});
|
|
44589
|
+
function splitArgv(options, argv) {
|
|
44590
|
+
const operands = [];
|
|
44591
|
+
const unknown = [];
|
|
44592
|
+
let dest = operands;
|
|
44593
|
+
let json = false;
|
|
44594
|
+
for (let i = 0;i < argv.length; i++) {
|
|
44595
|
+
const arg = argv[i];
|
|
44596
|
+
if (arg === "--") {
|
|
44597
|
+
dest.push(...dest === unknown ? argv.slice(i) : argv.slice(i + 1));
|
|
44598
|
+
break;
|
|
44599
|
+
}
|
|
44600
|
+
const option = options.find((o) => o.long === arg || o.short === arg);
|
|
44601
|
+
if (option) {
|
|
44602
|
+
if (option.required)
|
|
44603
|
+
i++;
|
|
44604
|
+
else if (option.long === "--json")
|
|
44605
|
+
json = true;
|
|
44606
|
+
continue;
|
|
44607
|
+
}
|
|
44608
|
+
if (options.some((o) => o.required && arg.startsWith(`${o.long}=`)))
|
|
44609
|
+
continue;
|
|
44610
|
+
if (dest === operands && arg.length > 1 && arg.startsWith("-"))
|
|
44611
|
+
dest = unknown;
|
|
44612
|
+
dest.push(arg);
|
|
44613
|
+
}
|
|
44614
|
+
return { operands, unknown, json };
|
|
44615
|
+
}
|
|
44616
|
+
function argvRun(argv = process.argv.slice(2)) {
|
|
44617
|
+
const root = splitArgv(program2.options, argv);
|
|
44618
|
+
let command = program2.commands.find((c) => c.name() === root.operands[0]);
|
|
44619
|
+
let rest = root.unknown;
|
|
44620
|
+
if (root.operands.length === 0) {
|
|
44621
|
+
const end = rest.indexOf("--");
|
|
44622
|
+
const at = rest.slice(0, end < 0 ? undefined : end).indexOf("connect");
|
|
44623
|
+
if (at >= 0)
|
|
44624
|
+
[command, rest] = [program2.commands.find((c) => c.name() === "connect"), rest.slice(at + 1)];
|
|
44625
|
+
}
|
|
44626
|
+
return { command, json: !!command && splitArgv(command.options, rest).json };
|
|
44627
|
+
}
|
|
44628
|
+
function jsonConnect(command, json) {
|
|
44629
|
+
return command?.parent === program2 && command.name() === "connect" && jsonOutput(json);
|
|
44630
|
+
}
|
|
44631
|
+
function jsonConnectArgv() {
|
|
44632
|
+
const run = argvRun();
|
|
44633
|
+
return jsonConnect(run.command, run.json);
|
|
44634
|
+
}
|
|
44012
44635
|
program2.addHelpText("after", () => `
|
|
44013
44636
|
\uD83D\uDCA1 Ideas / feedback: ${FEEDBACK_URL}
|
|
44014
44637
|
`);
|
|
@@ -44030,6 +44653,15 @@ function withOrgOptions(command) {
|
|
|
44030
44653
|
ORG_SCOPED_COMMANDS.add(command);
|
|
44031
44654
|
return command.option("--org <alias>", `organization alias (or ${ORG_ENV}); required with a global token`).option("--org-id <id>", `organization id (or ${ORG_ID_ENV}); alternative to --org`);
|
|
44032
44655
|
}
|
|
44656
|
+
var restoreConsole;
|
|
44657
|
+
program2.hook("preAction", (_thisCommand, actionCommand) => {
|
|
44658
|
+
if (jsonConnect(actionCommand, actionCommand.opts().json))
|
|
44659
|
+
restoreConsole = jsonConsole();
|
|
44660
|
+
});
|
|
44661
|
+
program2.hook("postAction", () => {
|
|
44662
|
+
restoreConsole?.();
|
|
44663
|
+
restoreConsole = undefined;
|
|
44664
|
+
});
|
|
44033
44665
|
program2.hook("preAction", (_thisCommand, actionCommand) => {
|
|
44034
44666
|
const initPointsToConnect = actionCommand.parent === program2 && actionCommand.name() === "init" && !interactive(actionCommand.opts().json);
|
|
44035
44667
|
if (!ORG_SCOPED_COMMANDS.has(actionCommand) || initPointsToConnect) {
|
|
@@ -44057,7 +44689,7 @@ program2.command("feedback").description("share ideas or feedback about the CLI"
|
|
|
44057
44689
|
if (opts.open) {
|
|
44058
44690
|
const openCommand = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
44059
44691
|
try {
|
|
44060
|
-
|
|
44692
|
+
spawn3(openCommand, [FEEDBACK_URL], { detached: true, stdio: "ignore" }).unref();
|
|
44061
44693
|
} catch {}
|
|
44062
44694
|
}
|
|
44063
44695
|
});
|
|
@@ -45342,7 +45974,7 @@ function ensureHostMetricsDir(projectDir) {
|
|
|
45342
45974
|
}
|
|
45343
45975
|
function isDockerRunning() {
|
|
45344
45976
|
try {
|
|
45345
|
-
const result =
|
|
45977
|
+
const result = spawnSync3("docker", ["info"], { stdio: "pipe", timeout: 5000 });
|
|
45346
45978
|
return result.status === 0;
|
|
45347
45979
|
} catch {
|
|
45348
45980
|
return false;
|
|
@@ -45355,7 +45987,7 @@ function shouldScaleOutNodeExporter(args) {
|
|
|
45355
45987
|
return !args.slice(upIndex + 1).some((a) => !a.startsWith("-"));
|
|
45356
45988
|
}
|
|
45357
45989
|
function getComposeCmd() {
|
|
45358
|
-
const tryCmd = (cmd, args) =>
|
|
45990
|
+
const tryCmd = (cmd, args) => spawnSync3(cmd, args, { stdio: "ignore", timeout: 5000 }).status === 0;
|
|
45359
45991
|
if (tryCmd("docker", ["compose", "version"]))
|
|
45360
45992
|
return ["docker", "compose"];
|
|
45361
45993
|
if (tryCmd("docker-compose", ["version"]))
|
|
@@ -45364,7 +45996,7 @@ function getComposeCmd() {
|
|
|
45364
45996
|
}
|
|
45365
45997
|
function checkRunningContainers() {
|
|
45366
45998
|
try {
|
|
45367
|
-
const result =
|
|
45999
|
+
const result = spawnSync3("docker", ["ps", "--filter", "name=grafana-with-datasources", "--filter", "name=pgwatch", "--format", "{{.Names}}"], { stdio: "pipe", encoding: "utf8", timeout: 5000 });
|
|
45368
46000
|
if (result.status === 0 && result.stdout) {
|
|
45369
46001
|
const containers = result.stdout.trim().split(`
|
|
45370
46002
|
`).filter(Boolean);
|
|
@@ -45709,7 +46341,7 @@ async function runCompose(args, grafanaPassword, extraEnv) {
|
|
|
45709
46341
|
finalArgs.push("--scale", "self-node-exporter=0");
|
|
45710
46342
|
}
|
|
45711
46343
|
return new Promise((resolve) => {
|
|
45712
|
-
const child =
|
|
46344
|
+
const child = spawn3(cmd[0], [...cmd.slice(1), "-f", composeFile, ...finalArgs], {
|
|
45713
46345
|
stdio: "inherit",
|
|
45714
46346
|
env,
|
|
45715
46347
|
cwd: projectDir
|
|
@@ -45899,7 +46531,7 @@ function emitConnect(result, json) {
|
|
|
45899
46531
|
process.exitCode = CONNECT_EXIT[result.status];
|
|
45900
46532
|
}
|
|
45901
46533
|
function failCloud(err, json) {
|
|
45902
|
-
printResult({ status: "failed", next:
|
|
46534
|
+
printResult({ status: "failed", next: errorText2(err) }, json);
|
|
45903
46535
|
process.exitCode = 1;
|
|
45904
46536
|
}
|
|
45905
46537
|
function signedIn(opts) {
|
|
@@ -45911,6 +46543,7 @@ function signedIn(opts) {
|
|
|
45911
46543
|
return !!cloudApi().apiKey;
|
|
45912
46544
|
}
|
|
45913
46545
|
async function runConnect(url, opts) {
|
|
46546
|
+
const json = jsonOutput(opts.json);
|
|
45914
46547
|
const name = (() => {
|
|
45915
46548
|
try {
|
|
45916
46549
|
return databaseName2(url);
|
|
@@ -45926,42 +46559,61 @@ async function runConnect(url, opts) {
|
|
|
45926
46559
|
if (!Number.isFinite(waitMinutes) || waitMinutes < 0) {
|
|
45927
46560
|
return emitConnect({ status: "failed", provider, name, next: "--wait must be a number of minutes (0 = do not wait)" }, opts.json);
|
|
45928
46561
|
}
|
|
45929
|
-
if (!opts.selfHosted && !signedIn(opts)) {
|
|
45930
|
-
return emitConnect({ status: "action_required", provider, name, next: "Sign in: pgai auth login (agents: set PGAI_API_KEY), then re-run" }, opts.json);
|
|
45931
|
-
}
|
|
45932
|
-
const api = cloudApi(opts.debug);
|
|
45933
46562
|
try {
|
|
46563
|
+
if (!opts.selfHosted && !signedIn(opts)) {
|
|
46564
|
+
return emitConnect({ status: "action_required", provider, name, next: "Sign in: pgai auth login (agents: set PGAI_API_KEY), then re-run" }, opts.json);
|
|
46565
|
+
}
|
|
46566
|
+
const api = cloudApi(opts.debug);
|
|
45934
46567
|
const result = await connect2(url, { ...opts, waitMs: waitMinutes * 60000 }, {
|
|
45935
46568
|
...api,
|
|
45936
46569
|
selfHosted: async (monitoringUrl, env) => {
|
|
45937
46570
|
const scope = getActiveOrgScope2();
|
|
45938
46571
|
const org = scope?.alias ? ["--org", scope.alias] : scope?.id ? ["--org-id", String(scope.id)] : [];
|
|
45939
46572
|
const project = name.replace(/[^A-Za-z0-9._-]+/g, "-");
|
|
45940
|
-
const
|
|
45941
|
-
|
|
45942
|
-
env: { ...process.env, ...env, PGAI_DB_URL: monitoringUrl, PGAI_API_BASE_URL: api.apiBaseUrl, ...api.apiKey ? { PGAI_API_KEY: api.apiKey } : {} }
|
|
45943
|
-
});
|
|
45944
|
-
if (r.status !== 0)
|
|
46573
|
+
const status = await runChild(process.execPath, [process.argv[1], "mon", "local-install", "-y", ...org, "--project", project], { ...process.env, ...env, PGAI_DB_URL: monitoringUrl, PGAI_API_BASE_URL: api.apiBaseUrl, ...api.apiKey ? { PGAI_API_KEY: api.apiKey } : {} }, json, "mon local-install");
|
|
46574
|
+
if (status !== 0)
|
|
45945
46575
|
throw new Error("mon local-install failed (see above)");
|
|
45946
46576
|
},
|
|
45947
46577
|
localStackRunning: () => checkRunningContainers().running,
|
|
45948
|
-
|
|
46578
|
+
confirm: async (q) => {
|
|
46579
|
+
if (!interactive(opts.json))
|
|
46580
|
+
return false;
|
|
46581
|
+
process.exitCode = 130;
|
|
46582
|
+
const answer = await question(q);
|
|
46583
|
+
process.exitCode = undefined;
|
|
46584
|
+
closeReadline();
|
|
46585
|
+
return /^(y|yes)$/i.test(answer.trim());
|
|
46586
|
+
},
|
|
46587
|
+
progress: (e) => json ? writeEvent(e) : console.error(progressText(e))
|
|
45949
46588
|
});
|
|
45950
46589
|
emitConnect(result, opts.json);
|
|
45951
46590
|
} catch (err) {
|
|
45952
|
-
emitConnect({ status: "failed", provider, name, next:
|
|
46591
|
+
emitConnect({ status: "failed", provider, name, next: errorText2(err) }, opts.json);
|
|
45953
46592
|
}
|
|
45954
46593
|
}
|
|
45955
|
-
withOrgOptions(program2.command("connect <database-url>")).description("put a database under PostgresAI's care: prepare it, provision monitoring, print the dashboard").option("--provider <provider>", "clickhouse | rds | supabase | self-managed (default: detected from the host)").option("--clickhouse-key <id:secret>", "ClickHouse Cloud API key (Basic Service API Reader) for CPU, memory and disk; or CLICKHOUSE_KEY_ID + CLICKHOUSE_KEY_SECRET").option("--self-hosted", "run the monitoring stack on this machine (mon local-install) instead of PostgresAI Cloud").option("--reset-password", "postgres_ai_mon exists and its password is lost: set a new one (admin URL; refused while another database on the server is monitored)").option("--wait <minutes>", "how long to wait for the monitoring box (0 = do not wait)", "20").option("-y, --yes", "never prompt").option("--json", "JSON output (default when stdout is not a TTY)").option("--debug", "print HTTP requests (secrets masked)").addHelpText("after", [
|
|
46594
|
+
withOrgOptions(program2.command("connect <database-url>")).description("put a database under PostgresAI's care: prepare it, provision monitoring, print the dashboard").option("--provider <provider>", "clickhouse | rds | supabase | self-managed (default: detected from the host)").option("--clickhouse-key <id:secret>", "ClickHouse Cloud API key (Basic Service API Reader) for CPU, memory and disk; or CLICKHOUSE_KEY_ID + CLICKHOUSE_KEY_SECRET").option("--self-hosted", "run the monitoring stack on this machine (mon local-install) instead of PostgresAI Cloud").option("--reset-password", "postgres_ai_mon exists and its password is lost: set a new one (admin URL; refused while another database on the server is monitored)").option("--wait <minutes>", "how long to wait for the monitoring box (0 = do not wait)", "20").option("--coupon <code>", "promotion code for the organization's monitoring subscription (applies when it is first created)").option("-y, --yes", "never prompt: accept the price of a billed box").option("--json", "JSON output (default when stdout is not a TTY)").option("--debug", "print HTTP requests (secrets masked)").addHelpText("after", [
|
|
45956
46595
|
"",
|
|
45957
46596
|
"Steps (each skipped when already done; safe to re-run): sign in, prepare the database",
|
|
45958
46597
|
"(an admin URL creates the postgres_ai_mon role; otherwise the SQL is printed), provision",
|
|
45959
46598
|
"the monitoring box, run the express checkup while it starts, wait, print the dashboard URL.",
|
|
45960
46599
|
"",
|
|
46600
|
+
"Billing is per Postgres cluster observed: another database in a cluster the organization already",
|
|
46601
|
+
'monitors is included. Once the URL is checked, connect shows the price (or "free (N of M free slots)"). A billed',
|
|
46602
|
+
"box is provisioned only when accepted: at the prompt, or with --yes. With no payment method",
|
|
46603
|
+
"it stops (exit 3) and names the console page to add one. --coupon applies a promotion code.",
|
|
46604
|
+
"",
|
|
45961
46605
|
'Exit codes: 0 connected or provisioning, 1 failed, 3 action required (see "next").',
|
|
45962
46606
|
"",
|
|
46607
|
+
"JSON output (--json, or stdout not a TTY): stdout is the result; stderr is one JSON event a",
|
|
46608
|
+
'line: the steps, and {"event":"log","level":"error"|"warn"|"info"|"debug","message":...}',
|
|
46609
|
+
'for any other text (an error, a warning, --debug); "source":"mon local-install" for that',
|
|
46610
|
+
"child's lines (the logins it prints are masked: pgai mon show-grafana-credentials shows them).",
|
|
46611
|
+
"",
|
|
45963
46612
|
"Environment: PGAI_API_KEY (instead of signing in), PGAI_MON_PASSWORD (the password of",
|
|
45964
|
-
"postgres_ai_mon when the role already exists; it is checked, never changed
|
|
46613
|
+
"postgres_ai_mon when the role already exists; it is checked, never changed; not needed",
|
|
46614
|
+
"for another database on a server PostgresAI already monitors for the org, with",
|
|
46615
|
+
"sslmode=require or verify-* in the URL: PostgresAI fills in the password it keeps (over TLS only), and",
|
|
46616
|
+
"the express checkup is skipped, as it runs only as postgres_ai_mon),",
|
|
45965
46617
|
"PGPASSWORD (the password for a URL without one),",
|
|
45966
46618
|
"CLICKHOUSE_KEY_ID + CLICKHOUSE_KEY_SECRET (instead of --clickhouse-key).",
|
|
45967
46619
|
"",
|
|
@@ -45972,6 +46624,9 @@ withOrgOptions(program2.command("connect <database-url>")).description("put a da
|
|
|
45972
46624
|
].join(`
|
|
45973
46625
|
`)).action(runConnect);
|
|
45974
46626
|
var interactive = (json) => !!process.stdin.isTTY && !!process.stdout.isTTY && !json;
|
|
46627
|
+
function jsonOutput(json) {
|
|
46628
|
+
return !!json || !process.stdout.isTTY;
|
|
46629
|
+
}
|
|
45975
46630
|
withOrgOptions(program2.command("init")).description("first run for a person at a terminal: sign in, ask for the database URL, then pgai connect").option("--json", "JSON output (init is interactive: prints the pgai connect command to use instead)").action(async (opts) => {
|
|
45976
46631
|
if (!interactive(opts.json)) {
|
|
45977
46632
|
return emitConnect({ status: "action_required", provider: "self-managed", name: "", next: "pgai init is for a person at a terminal; agents and scripts: pgai connect <database-url>" }, opts.json);
|
|
@@ -46014,8 +46669,8 @@ withOrgOptions(program2.command("disconnect <name>")).description("stop monitori
|
|
|
46014
46669
|
if (!opts.yes && !(process.stdin.isTTY && /^y/i.test(await question(`Disconnect ${row.name} and delete its monitoring box? (y/N): `)))) {
|
|
46015
46670
|
return emitConnect({ status: "action_required", provider: row.provider, name: row.name, id: row.id, next: `pgai disconnect ${row.name} --yes` }, opts.json);
|
|
46016
46671
|
}
|
|
46017
|
-
await cloudApi(opts.debug).disconnect(row.id);
|
|
46018
|
-
printResult({ status: "disconnected", name: row.name, id: row.id, next: row.host_metrics ? "Delete the ClickHouse Cloud API key you gave us" : "none" }, opts.json);
|
|
46672
|
+
const billing = disconnectBilling(await cloudApi(opts.debug).disconnect(row.id));
|
|
46673
|
+
printResult({ status: "disconnected", name: row.name, id: row.id, ...billing ? { billing } : {}, next: row.host_metrics ? "Delete the ClickHouse Cloud API key you gave us" : "none" }, opts.json);
|
|
46019
46674
|
} catch (err) {
|
|
46020
46675
|
failCloud(err, opts.json);
|
|
46021
46676
|
}
|
|
@@ -46601,9 +47256,9 @@ mon.command("health").description("health check for monitoring services").option
|
|
|
46601
47256
|
allHealthy = true;
|
|
46602
47257
|
for (const service of services) {
|
|
46603
47258
|
try {
|
|
46604
|
-
const result =
|
|
47259
|
+
const result = spawnSync3("docker", ["inspect", "-f", "{{.State.Status}}", service.container], { stdio: "pipe" });
|
|
46605
47260
|
const present = result.status === 0;
|
|
46606
|
-
const health = present && service.readHealth ? (
|
|
47261
|
+
const health = present && service.readHealth ? (spawnSync3("docker", ["inspect", "-f", "{{if .State.Health}}{{.State.Health.Status}}{{end}}", service.container], { stdio: "pipe" }).stdout || "").trim() : "";
|
|
46607
47262
|
const verdict = judgeServiceHealth(service, present, (result.stdout || "").trim(), health);
|
|
46608
47263
|
console.log(`${verdict.mark} ${service.name}: ${verdict.label}`);
|
|
46609
47264
|
if (!verdict.ok)
|
|
@@ -46871,7 +47526,7 @@ async function sinkPrometheusMaybeUp() {
|
|
|
46871
47526
|
} catch {
|
|
46872
47527
|
return true;
|
|
46873
47528
|
}
|
|
46874
|
-
const ps =
|
|
47529
|
+
const ps = spawnSync3(cmd[0], [...cmd.slice(1), "-f", composeFile, "ps", "-a", "-q", "--status", "running", "--status", "paused", "sink-prometheus"]);
|
|
46875
47530
|
return ps.status !== 0 || ps.stdout.trim() !== "";
|
|
46876
47531
|
}
|
|
46877
47532
|
async function reloadHostMetrics(projectDir, job, file) {
|
|
@@ -46906,14 +47561,36 @@ async function reloadHostMetrics(projectDir, job, file) {
|
|
|
46906
47561
|
}
|
|
46907
47562
|
return true;
|
|
46908
47563
|
}
|
|
47564
|
+
var CONN_STR_FORMAT = "Invalid connection string format: use postgresql://user:password@host[:port]/database";
|
|
46909
47565
|
function defaultTargetName(connStr) {
|
|
47566
|
+
if (!/^postgres(ql)?:\/\//.test(connStr))
|
|
47567
|
+
return { error: CONN_STR_FORMAT };
|
|
47568
|
+
if (/[\x00-\x1f\x7f]/.test(connStr))
|
|
47569
|
+
return { error: "Invalid connection string format: remove the control character (such as a CR from a CRLF file), or percent-encode it" };
|
|
47570
|
+
const at = connStr.lastIndexOf("@");
|
|
47571
|
+
if (at < 0)
|
|
47572
|
+
return { error: CONN_STR_FORMAT };
|
|
47573
|
+
if (!/^(?:[A-Za-z0-9\-._~!$&'()*+,;=:@]|%[0-9A-Fa-f]{2})*$/.test(connStr.slice(connStr.indexOf("//") + 2, at))) {
|
|
47574
|
+
return { error: "Invalid connection string format: percent-encode the user name and the password (all but ASCII letters, digits, '-', '.', '_' and '~'), and an '@' in the database name or the query" };
|
|
47575
|
+
}
|
|
47576
|
+
let url;
|
|
46910
47577
|
try {
|
|
46911
|
-
new URL(connStr);
|
|
47578
|
+
url = new URL(connStr);
|
|
46912
47579
|
} catch {
|
|
46913
|
-
return
|
|
46914
|
-
}
|
|
46915
|
-
|
|
46916
|
-
|
|
47580
|
+
return { error: CONN_STR_FORMAT };
|
|
47581
|
+
}
|
|
47582
|
+
if (!url.username)
|
|
47583
|
+
return { error: CONN_STR_FORMAT };
|
|
47584
|
+
if (/%3a/i.test(url.username))
|
|
47585
|
+
return { error: "Invalid connection string format: percent-encode the user name and the password one at a time, with a raw ':' between them" };
|
|
47586
|
+
if (!url.password)
|
|
47587
|
+
return { error: "Invalid connection string format: put the password in the URL: postgresql://user:password@host[:port]/database" };
|
|
47588
|
+
if (url.hostname.includes(":"))
|
|
47589
|
+
return { error: "Invalid connection string format: an IPv6 address is not supported as the host; use a host name" };
|
|
47590
|
+
const db = url.pathname.slice(1);
|
|
47591
|
+
if (!db)
|
|
47592
|
+
return { error: CONN_STR_FORMAT };
|
|
47593
|
+
return { name: `${url.hostname}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-") };
|
|
46917
47594
|
}
|
|
46918
47595
|
function parseEnvValue(content, key) {
|
|
46919
47596
|
const last = [...content.matchAll(new RegExp(`^[ \\t]*(?:export[ \\t]+)?${key}=(.*)$`, "gm"))].at(-1);
|
|
@@ -46958,7 +47635,7 @@ async function setRdsHostStats(projectDir, values) {
|
|
|
46958
47635
|
if (!cmd)
|
|
46959
47636
|
return true;
|
|
46960
47637
|
const { composeFile } = await resolveOrInitPaths();
|
|
46961
|
-
const ps =
|
|
47638
|
+
const ps = spawnSync3(cmd[0], [...cmd.slice(1), "-f", composeFile, "--profile", "rds", "ps", "-q", "--status", "running", "rds-host-stats"]);
|
|
46962
47639
|
if (ps.status !== 0) {
|
|
46963
47640
|
console.error("If rds-host-stats is running, recreate it: docker compose --profile rds up -d rds-host-stats");
|
|
46964
47641
|
return true;
|
|
@@ -47042,6 +47719,8 @@ async function syncVmalert(projectDir, apply, addOnly = false) {
|
|
|
47042
47719
|
setEnvValues(projectDir, { COMPOSE_PROFILES: composeProfilesValue(current, want, "host-metrics") });
|
|
47043
47720
|
return true;
|
|
47044
47721
|
}
|
|
47722
|
+
var looksLikeConnStr = (value) => /^postgres(ql)?:\/\//i.test(value);
|
|
47723
|
+
var isPlainTargetName = (value) => /^[A-Za-z0-9._=-]+$/.test(value) && !/(password|pwd)\s*=/i.test(value);
|
|
47045
47724
|
async function addTarget(file, projectDir, connStr, name, env, { apply = true } = {}) {
|
|
47046
47725
|
if (!connStr) {
|
|
47047
47726
|
console.error("Connection string required: postgresql://user:pass@host:port/db");
|
|
@@ -47051,15 +47730,15 @@ async function addTarget(file, projectDir, connStr, name, env, { apply = true }
|
|
|
47051
47730
|
const channelBinding = splitChannelBinding2(connStr);
|
|
47052
47731
|
connStr = channelBinding.uri;
|
|
47053
47732
|
const defaultName = defaultTargetName(connStr);
|
|
47054
|
-
if (
|
|
47055
|
-
console.error(
|
|
47733
|
+
if ("error" in defaultName) {
|
|
47734
|
+
console.error(defaultName.error);
|
|
47056
47735
|
process.exitCode = 1;
|
|
47057
47736
|
return false;
|
|
47058
47737
|
}
|
|
47059
47738
|
if (channelBinding.value !== null) {
|
|
47060
47739
|
console.error("Note: removed channel_binding from the connection string; the collector does not support it (TLS is kept)");
|
|
47061
47740
|
}
|
|
47062
|
-
const instanceName = name && name.trim() ? name.trim() : defaultName;
|
|
47741
|
+
const instanceName = name && name.trim() ? name.trim() : defaultName.name;
|
|
47063
47742
|
if (detectProvider(connStr) !== "clickhouse" && extractProjectRefFromUrl(connStr) && supabaseHostMetricsOn(projectDir, env) && !SUPABASE_TARGET_NAME_RE.test(instanceName)) {
|
|
47064
47743
|
console.error(SUPABASE_TARGET_NAME_ERROR);
|
|
47065
47744
|
process.exitCode = 1;
|
|
@@ -47140,8 +47819,43 @@ Re-running with the same name and connection string is safe; retry after fixing
|
|
|
47140
47819
|
|
|
47141
47820
|
Supabase: with PGAI_SUPABASE_HOST_METRICS=true (environment or .env), writes host-metrics/supabase-<name>.yml.
|
|
47142
47821
|
RDS instance endpoint: writes RDS_DB_INSTANCE_IDENTIFIER, AWS_REGION, PGAI_CLUSTER and PGAI_NODE_NAME to .env for rds-host-stats.
|
|
47822
|
+
|
|
47823
|
+
Environment:
|
|
47824
|
+
PGAI_DB_URL the connection string, unless a postgres:// or postgresql:// URL is given
|
|
47825
|
+
(keeps the password out of argv). The only argument is then the name, of
|
|
47826
|
+
ASCII letters, digits, '.', '_', '=' and '-', with no password= or pwd=;
|
|
47827
|
+
anything else is refused. Set it for this command only:
|
|
47828
|
+
PGAI_DB_URL='postgresql://user:pass@host:5432/db' postgres-ai mon targets add my-db
|
|
47829
|
+
Under sudo, pass it on stdin: sudo logs a variable kept with --preserve-env.
|
|
47830
|
+
printf '%s\\n' "$URL" | sudo sh -c \\
|
|
47831
|
+
'IFS= read -r PGAI_DB_URL; export PGAI_DB_URL; exec postgres-ai mon targets add my-db'
|
|
47832
|
+
sudo I/O logging (log_input in sudoers) records stdin: then read it from a
|
|
47833
|
+
file of mode 0600 in the root shell.
|
|
47834
|
+
sudo sh -c \\
|
|
47835
|
+
'IFS= read -r PGAI_DB_URL < /path/to/db-url; export PGAI_DB_URL; exec postgres-ai mon targets add my-db'
|
|
47143
47836
|
`).action(async (connStr, name) => {
|
|
47144
47837
|
const { instancesFile: file, projectDir } = await resolveOrInitPaths();
|
|
47838
|
+
const envUrl = process.env.PGAI_DB_URL || undefined;
|
|
47839
|
+
delete process.env.PGAI_DB_URL;
|
|
47840
|
+
if (envUrl && name === undefined && (connStr === undefined || !looksLikeConnStr(connStr))) {
|
|
47841
|
+
const arg = connStr?.trim();
|
|
47842
|
+
if (arg && !isPlainTargetName(arg)) {
|
|
47843
|
+
console.error("PGAI_DB_URL is set: pass only the target name (ASCII letters, digits, '.', '_', '=', '-'; no password= or pwd=)");
|
|
47844
|
+
process.exitCode = 1;
|
|
47845
|
+
return;
|
|
47846
|
+
}
|
|
47847
|
+
name = arg;
|
|
47848
|
+
connStr = envUrl;
|
|
47849
|
+
let user = "";
|
|
47850
|
+
if (!("error" in defaultTargetName(splitChannelBinding2(envUrl).uri))) {
|
|
47851
|
+
try {
|
|
47852
|
+
user = decodeURIComponent(new URL(envUrl).username);
|
|
47853
|
+
} catch {
|
|
47854
|
+
user = new URL(envUrl).username;
|
|
47855
|
+
}
|
|
47856
|
+
}
|
|
47857
|
+
console.error(`Using PGAI_DB_URL${user ? ` (user ${user})` : ""}`);
|
|
47858
|
+
}
|
|
47145
47859
|
await addTarget(file, projectDir, connStr, name, process.env);
|
|
47146
47860
|
});
|
|
47147
47861
|
targets.command("remove <name>").description("remove monitoring target database").action(async (name) => {
|
|
@@ -47341,7 +48055,7 @@ Opening browser for authentication...`);
|
|
|
47341
48055
|
${authUrl}
|
|
47342
48056
|
`);
|
|
47343
48057
|
const openCommand = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
47344
|
-
const browserProcess =
|
|
48058
|
+
const browserProcess = spawn3(openCommand, [authUrl], { detached: true, stdio: "ignore" });
|
|
47345
48059
|
browserProcess.on("error", () => {});
|
|
47346
48060
|
browserProcess.unref();
|
|
47347
48061
|
console.log("Waiting for authorization...");
|
|
@@ -48541,7 +49255,7 @@ function tryParseJson(s) {
|
|
|
48541
49255
|
}
|
|
48542
49256
|
}
|
|
48543
49257
|
function printJoeOutcome(outcome, json, budgetMs) {
|
|
48544
|
-
const effectiveBudgetMs = typeof budgetMs === "number" && Number.isFinite(budgetMs) ? budgetMs : DEFAULT_BUDGET_MS;
|
|
49258
|
+
const effectiveBudgetMs = Number.isFinite(outcome.budgetMs) ? outcome.budgetMs : typeof budgetMs === "number" && Number.isFinite(budgetMs) ? budgetMs : DEFAULT_BUDGET_MS;
|
|
48545
49259
|
const budgetSeconds = Math.round(effectiveBudgetMs / 1000);
|
|
48546
49260
|
if (outcome.budgetExpired) {
|
|
48547
49261
|
if (json) {
|