postgresai 0.15.0-dev.4 → 0.15.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/bin/postgres-ai.ts +127 -12
- package/dist/bin/postgres-ai.js +117 -17
- package/package.json +1 -1
- package/test/compose-cmd.test.ts +120 -0
- package/test/init.test.ts +8 -0
- package/test/monitoring.test.ts +78 -0
package/bin/postgres-ai.ts
CHANGED
|
@@ -7,6 +7,7 @@ import * as yaml from "js-yaml";
|
|
|
7
7
|
import * as fs from "fs";
|
|
8
8
|
import * as path from "path";
|
|
9
9
|
import * as os from "os";
|
|
10
|
+
import { fileURLToPath } from "url";
|
|
10
11
|
import * as crypto from "node:crypto";
|
|
11
12
|
import { Client } from "pg";
|
|
12
13
|
import { startMcpServer } from "../lib/mcp-server";
|
|
@@ -503,7 +504,11 @@ async function ensureDefaultMonitoringProject(): Promise<PathResolution> {
|
|
|
503
504
|
}
|
|
504
505
|
}
|
|
505
506
|
|
|
506
|
-
// Ensure instances.yml exists as a FILE (avoid Docker creating a directory)
|
|
507
|
+
// Ensure instances.yml exists as a FILE (avoid Docker creating a directory).
|
|
508
|
+
// Docker bind-mounts create missing paths as directories; replace if so.
|
|
509
|
+
if (fs.existsSync(instancesFile) && fs.statSync(instancesFile).isDirectory()) {
|
|
510
|
+
fs.rmSync(instancesFile, { recursive: true, force: true });
|
|
511
|
+
}
|
|
507
512
|
if (!fs.existsSync(instancesFile)) {
|
|
508
513
|
const header =
|
|
509
514
|
"# PostgreSQL instances to monitor\n" +
|
|
@@ -2071,13 +2076,16 @@ function isDockerRunning(): boolean {
|
|
|
2071
2076
|
}
|
|
2072
2077
|
|
|
2073
2078
|
/**
|
|
2074
|
-
* Get docker compose command
|
|
2079
|
+
* Get docker compose command.
|
|
2080
|
+
* Prefer "docker compose" (V2 plugin) over legacy "docker-compose" (V1 standalone)
|
|
2081
|
+
* because docker-compose V1 (<=1.29) is incompatible with modern Docker engines
|
|
2082
|
+
* (KeyError: 'ContainerConfig' on container recreation).
|
|
2075
2083
|
*/
|
|
2076
2084
|
function getComposeCmd(): string[] | null {
|
|
2077
2085
|
const tryCmd = (cmd: string, args: string[]): boolean =>
|
|
2078
2086
|
spawnSync(cmd, args, { stdio: "ignore", timeout: 5000 } as Parameters<typeof spawnSync>[2]).status === 0;
|
|
2079
|
-
if (tryCmd("docker-compose", ["version"])) return ["docker-compose"];
|
|
2080
2087
|
if (tryCmd("docker", ["compose", "version"])) return ["docker", "compose"];
|
|
2088
|
+
if (tryCmd("docker-compose", ["version"])) return ["docker-compose"];
|
|
2081
2089
|
return null;
|
|
2082
2090
|
}
|
|
2083
2091
|
|
|
@@ -2235,6 +2243,26 @@ async function runCompose(args: string[], grafanaPassword?: string): Promise<num
|
|
|
2235
2243
|
}
|
|
2236
2244
|
}
|
|
2237
2245
|
|
|
2246
|
+
// Load VM auth credentials from .env if not already set
|
|
2247
|
+
const envFilePath = path.resolve(projectDir, ".env");
|
|
2248
|
+
if (fs.existsSync(envFilePath)) {
|
|
2249
|
+
try {
|
|
2250
|
+
const envContent = fs.readFileSync(envFilePath, "utf8");
|
|
2251
|
+
if (!env.VM_AUTH_USERNAME) {
|
|
2252
|
+
const m = envContent.match(/^VM_AUTH_USERNAME=([^\r\n]+)/m);
|
|
2253
|
+
if (m) env.VM_AUTH_USERNAME = m[1].trim().replace(/^["']|["']$/g, '');
|
|
2254
|
+
}
|
|
2255
|
+
if (!env.VM_AUTH_PASSWORD) {
|
|
2256
|
+
const m = envContent.match(/^VM_AUTH_PASSWORD=([^\r\n]+)/m);
|
|
2257
|
+
if (m) env.VM_AUTH_PASSWORD = m[1].trim().replace(/^["']|["']$/g, '');
|
|
2258
|
+
}
|
|
2259
|
+
} catch (err) {
|
|
2260
|
+
if (process.env.DEBUG) {
|
|
2261
|
+
console.warn(`Warning: Could not read VM auth from .env: ${err instanceof Error ? err.message : String(err)}`);
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2238
2266
|
// On macOS, self-node-exporter can't mount host root filesystem - skip it
|
|
2239
2267
|
const finalArgs = [...args];
|
|
2240
2268
|
if (process.platform === "darwin" && args.includes("up")) {
|
|
@@ -2279,7 +2307,7 @@ mon
|
|
|
2279
2307
|
console.log("This will install, configure, and start the monitoring system\n");
|
|
2280
2308
|
|
|
2281
2309
|
// Ensure we have a project directory with docker-compose.yml even if running from elsewhere
|
|
2282
|
-
const { projectDir } = await resolveOrInitPaths();
|
|
2310
|
+
const { projectDir, instancesFile: instancesPath } = await resolveOrInitPaths();
|
|
2283
2311
|
console.log(`Project directory: ${projectDir}\n`);
|
|
2284
2312
|
|
|
2285
2313
|
// Save project name to .pgwatch-config if provided (used by reporter container)
|
|
@@ -2502,7 +2530,26 @@ mon
|
|
|
2502
2530
|
}
|
|
2503
2531
|
}
|
|
2504
2532
|
} else {
|
|
2505
|
-
|
|
2533
|
+
// Demo mode: copy bundled instances.demo.yml → instances.yml so the demo target is active
|
|
2534
|
+
console.log("Step 2: Demo mode enabled - using included demo PostgreSQL database");
|
|
2535
|
+
// Use import.meta.url instead of __dirname — bundlers bake in __dirname at build time.
|
|
2536
|
+
// Check multiple candidate paths (npm package vs repo dev layout).
|
|
2537
|
+
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
|
2538
|
+
const demoCandidates = [
|
|
2539
|
+
path.resolve(currentDir, "..", "..", "instances.demo.yml"), // npm: dist/bin -> package root
|
|
2540
|
+
path.resolve(currentDir, "..", "..", "..", "instances.demo.yml"), // dev: cli/bin -> repo root
|
|
2541
|
+
];
|
|
2542
|
+
const demoSrc = demoCandidates.find(p => fs.existsSync(p));
|
|
2543
|
+
if (demoSrc) {
|
|
2544
|
+
// Remove directory artifact left by Docker bind-mounts
|
|
2545
|
+
if (fs.existsSync(instancesPath) && fs.statSync(instancesPath).isDirectory()) {
|
|
2546
|
+
fs.rmSync(instancesPath, { recursive: true, force: true });
|
|
2547
|
+
}
|
|
2548
|
+
fs.copyFileSync(demoSrc, instancesPath);
|
|
2549
|
+
console.log("✓ Demo monitoring target configured\n");
|
|
2550
|
+
} else {
|
|
2551
|
+
console.error(`⚠ instances.demo.yml not found — demo target not configured (searched: ${demoCandidates.join(", ")})\n`);
|
|
2552
|
+
}
|
|
2506
2553
|
}
|
|
2507
2554
|
|
|
2508
2555
|
// Step 3: Update configuration
|
|
@@ -2518,6 +2565,8 @@ mon
|
|
|
2518
2565
|
console.log(opts.demo ? "Step 4: Configuring Grafana security..." : "Step 4: Configuring Grafana security...");
|
|
2519
2566
|
const cfgPath = path.resolve(projectDir, ".pgwatch-config");
|
|
2520
2567
|
let grafanaPassword = "";
|
|
2568
|
+
let vmAuthUsername = "";
|
|
2569
|
+
let vmAuthPassword = "";
|
|
2521
2570
|
|
|
2522
2571
|
try {
|
|
2523
2572
|
if (fs.existsSync(cfgPath)) {
|
|
@@ -2556,7 +2605,53 @@ mon
|
|
|
2556
2605
|
grafanaPassword = "demo";
|
|
2557
2606
|
}
|
|
2558
2607
|
|
|
2608
|
+
// Generate VictoriaMetrics auth credentials
|
|
2609
|
+
try {
|
|
2610
|
+
const envFile = path.resolve(projectDir, ".env");
|
|
2611
|
+
|
|
2612
|
+
// Read existing VM auth from .env if present
|
|
2613
|
+
if (fs.existsSync(envFile)) {
|
|
2614
|
+
const envContent = fs.readFileSync(envFile, "utf8");
|
|
2615
|
+
const userMatch = envContent.match(/^VM_AUTH_USERNAME=([^\r\n]+)/m);
|
|
2616
|
+
const passMatch = envContent.match(/^VM_AUTH_PASSWORD=([^\r\n]+)/m);
|
|
2617
|
+
if (userMatch) vmAuthUsername = userMatch[1].trim().replace(/^["']|["']$/g, '');
|
|
2618
|
+
if (passMatch) vmAuthPassword = passMatch[1].trim().replace(/^["']|["']$/g, '');
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
if (!vmAuthUsername || !vmAuthPassword) {
|
|
2622
|
+
console.log("Generating VictoriaMetrics auth credentials...");
|
|
2623
|
+
vmAuthUsername = vmAuthUsername || "vmauth";
|
|
2624
|
+
if (!vmAuthPassword) {
|
|
2625
|
+
const { stdout: vmPass } = await execPromise("openssl rand -base64 12 | tr -d '\n'");
|
|
2626
|
+
vmAuthPassword = vmPass.trim();
|
|
2627
|
+
}
|
|
2628
|
+
|
|
2629
|
+
// Update .env file with VM auth credentials
|
|
2630
|
+
let envContent = "";
|
|
2631
|
+
if (fs.existsSync(envFile)) {
|
|
2632
|
+
envContent = fs.readFileSync(envFile, "utf8");
|
|
2633
|
+
}
|
|
2634
|
+
const envLines = envContent.split(/\r?\n/)
|
|
2635
|
+
.filter((l) => !/^VM_AUTH_USERNAME=/.test(l) && !/^VM_AUTH_PASSWORD=/.test(l))
|
|
2636
|
+
.filter((l, i, arr) => !(i === arr.length - 1 && l === ''));
|
|
2637
|
+
envLines.push(`VM_AUTH_USERNAME=${vmAuthUsername}`);
|
|
2638
|
+
envLines.push(`VM_AUTH_PASSWORD=${vmAuthPassword}`);
|
|
2639
|
+
fs.writeFileSync(envFile, envLines.join("\n") + "\n", { encoding: "utf8", mode: 0o600 });
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
console.log("✓ VictoriaMetrics auth configured\n");
|
|
2643
|
+
} catch (error) {
|
|
2644
|
+
console.error("⚠ Could not generate VictoriaMetrics auth credentials automatically");
|
|
2645
|
+
if (process.env.DEBUG) {
|
|
2646
|
+
console.warn(` ${error instanceof Error ? error.message : String(error)}`);
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2559
2650
|
// Step 5: Start services
|
|
2651
|
+
// Remove stopped containers left by "run --rm" dependencies (e.g. config-init)
|
|
2652
|
+
// to avoid docker-compose v1 'ContainerConfig' error on recreation.
|
|
2653
|
+
// Best-effort: ignore exit code — container may not exist, failure here is non-fatal.
|
|
2654
|
+
await runCompose(["rm", "-f", "-s", "config-init"]);
|
|
2560
2655
|
console.log("Step 5: Starting monitoring services...");
|
|
2561
2656
|
const code2 = await runCompose(["up", "-d", "--force-recreate"], grafanaPassword);
|
|
2562
2657
|
if (code2 !== 0) {
|
|
@@ -2606,6 +2701,9 @@ mon
|
|
|
2606
2701
|
console.log("🚀 MAIN ACCESS POINT - Start here:");
|
|
2607
2702
|
console.log(" Grafana Dashboard: http://localhost:3000");
|
|
2608
2703
|
console.log(` Login: monitor / ${grafanaPassword}`);
|
|
2704
|
+
if (vmAuthUsername && vmAuthPassword) {
|
|
2705
|
+
console.log(` VictoriaMetrics Auth: ${vmAuthUsername} / ${vmAuthPassword}`);
|
|
2706
|
+
}
|
|
2609
2707
|
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
|
|
2610
2708
|
});
|
|
2611
2709
|
|
|
@@ -2806,7 +2904,7 @@ mon
|
|
|
2806
2904
|
console.log(`Project Directory: ${projectDir}`);
|
|
2807
2905
|
console.log(`Docker Compose File: ${composeFile}`);
|
|
2808
2906
|
console.log(`Instances File: ${instancesFile}`);
|
|
2809
|
-
if (fs.existsSync(instancesFile)) {
|
|
2907
|
+
if (fs.existsSync(instancesFile) && !fs.statSync(instancesFile).isDirectory()) {
|
|
2810
2908
|
console.log("\nInstances configuration:\n");
|
|
2811
2909
|
const text = fs.readFileSync(instancesFile, "utf8");
|
|
2812
2910
|
process.stdout.write(text);
|
|
@@ -3022,7 +3120,7 @@ targets
|
|
|
3022
3120
|
.description("list monitoring target databases")
|
|
3023
3121
|
.action(async () => {
|
|
3024
3122
|
const { instancesFile: instancesPath, projectDir } = await resolveOrInitPaths();
|
|
3025
|
-
if (!fs.existsSync(instancesPath)) {
|
|
3123
|
+
if (!fs.existsSync(instancesPath) || fs.statSync(instancesPath).isDirectory()) {
|
|
3026
3124
|
console.error(`instances.yml not found in ${projectDir}`);
|
|
3027
3125
|
process.exitCode = 1;
|
|
3028
3126
|
return;
|
|
@@ -3088,7 +3186,7 @@ targets
|
|
|
3088
3186
|
|
|
3089
3187
|
// Check if instance already exists
|
|
3090
3188
|
try {
|
|
3091
|
-
if (fs.existsSync(file)) {
|
|
3189
|
+
if (fs.existsSync(file) && !fs.statSync(file).isDirectory()) {
|
|
3092
3190
|
const content = fs.readFileSync(file, "utf8");
|
|
3093
3191
|
const instances = yaml.load(content) as Instance[] | null || [];
|
|
3094
3192
|
if (Array.isArray(instances)) {
|
|
@@ -3102,7 +3200,8 @@ targets
|
|
|
3102
3200
|
}
|
|
3103
3201
|
} catch (err) {
|
|
3104
3202
|
// If YAML parsing fails, fall back to simple check
|
|
3105
|
-
const
|
|
3203
|
+
const isFile = fs.existsSync(file) && !fs.statSync(file).isDirectory();
|
|
3204
|
+
const content = isFile ? fs.readFileSync(file, "utf8") : "";
|
|
3106
3205
|
if (new RegExp(`^- name: ${instanceName}$`, "m").test(content)) {
|
|
3107
3206
|
console.error(`Monitoring target '${instanceName}' already exists`);
|
|
3108
3207
|
process.exitCode = 1;
|
|
@@ -3110,7 +3209,10 @@ targets
|
|
|
3110
3209
|
}
|
|
3111
3210
|
}
|
|
3112
3211
|
|
|
3113
|
-
// Add new instance
|
|
3212
|
+
// Add new instance — if instances.yml is a directory (Docker artifact), replace it with a file
|
|
3213
|
+
if (fs.existsSync(file) && fs.statSync(file).isDirectory()) {
|
|
3214
|
+
fs.rmSync(file, { recursive: true, force: true });
|
|
3215
|
+
}
|
|
3114
3216
|
const body = `- name: ${instanceName}\n conn_str: ${connStr}\n preset_metrics: full\n custom_metrics:\n is_enabled: true\n group: default\n custom_tags:\n env: production\n cluster: default\n node_name: ${instanceName}\n sink_type: ~sink_type~\n`;
|
|
3115
3217
|
const content = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
3116
3218
|
fs.appendFileSync(file, (content && !/\n$/.test(content) ? "\n" : "") + body, "utf8");
|
|
@@ -3121,7 +3223,7 @@ targets
|
|
|
3121
3223
|
.description("remove monitoring target database")
|
|
3122
3224
|
.action(async (name: string) => {
|
|
3123
3225
|
const { instancesFile: file } = await resolveOrInitPaths();
|
|
3124
|
-
if (!fs.existsSync(file)) {
|
|
3226
|
+
if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) {
|
|
3125
3227
|
console.error("instances.yml not found");
|
|
3126
3228
|
process.exitCode = 1;
|
|
3127
3229
|
return;
|
|
@@ -3158,7 +3260,7 @@ targets
|
|
|
3158
3260
|
.description("test monitoring target database connectivity")
|
|
3159
3261
|
.action(async (name: string) => {
|
|
3160
3262
|
const { instancesFile: instancesPath } = await resolveOrInitPaths();
|
|
3161
|
-
if (!fs.existsSync(instancesPath)) {
|
|
3263
|
+
if (!fs.existsSync(instancesPath) || fs.statSync(instancesPath).isDirectory()) {
|
|
3162
3264
|
console.error("instances.yml not found");
|
|
3163
3265
|
process.exitCode = 1;
|
|
3164
3266
|
return;
|
|
@@ -3625,6 +3727,19 @@ mon
|
|
|
3625
3727
|
console.log(" URL: http://localhost:3000");
|
|
3626
3728
|
console.log(" Username: monitor");
|
|
3627
3729
|
console.log(` Password: ${password}`);
|
|
3730
|
+
|
|
3731
|
+
// Show VM auth credentials from .env
|
|
3732
|
+
const envFile = path.resolve(projectDir, ".env");
|
|
3733
|
+
if (fs.existsSync(envFile)) {
|
|
3734
|
+
const envContent = fs.readFileSync(envFile, "utf8");
|
|
3735
|
+
const vmUser = envContent.match(/^VM_AUTH_USERNAME=([^\r\n]+)/m);
|
|
3736
|
+
const vmPass = envContent.match(/^VM_AUTH_PASSWORD=([^\r\n]+)/m);
|
|
3737
|
+
if (vmUser && vmPass) {
|
|
3738
|
+
console.log("\nVictoriaMetrics credentials:");
|
|
3739
|
+
console.log(` Username: ${vmUser[1].trim().replace(/^["']|["']$/g, '')}`);
|
|
3740
|
+
console.log(` Password: ${vmPass[1].trim().replace(/^["']|["']$/g, '')}`);
|
|
3741
|
+
}
|
|
3742
|
+
}
|
|
3628
3743
|
console.log("");
|
|
3629
3744
|
});
|
|
3630
3745
|
|
package/dist/bin/postgres-ai.js
CHANGED
|
@@ -13151,7 +13151,7 @@ var {
|
|
|
13151
13151
|
// package.json
|
|
13152
13152
|
var package_default = {
|
|
13153
13153
|
name: "postgresai",
|
|
13154
|
-
version: "0.15.0-dev.
|
|
13154
|
+
version: "0.15.0-dev.6",
|
|
13155
13155
|
description: "postgres_ai CLI",
|
|
13156
13156
|
license: "Apache-2.0",
|
|
13157
13157
|
private: false,
|
|
@@ -15965,6 +15965,7 @@ var safeDump = renamed("safeDump", "dump");
|
|
|
15965
15965
|
import * as fs6 from "fs";
|
|
15966
15966
|
import * as path6 from "path";
|
|
15967
15967
|
import * as os3 from "os";
|
|
15968
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
15968
15969
|
import * as crypto2 from "crypto";
|
|
15969
15970
|
|
|
15970
15971
|
// node_modules/pg/esm/index.mjs
|
|
@@ -15981,7 +15982,7 @@ var Result = import_lib.default.Result;
|
|
|
15981
15982
|
var TypeOverrides = import_lib.default.TypeOverrides;
|
|
15982
15983
|
var defaults = import_lib.default.defaults;
|
|
15983
15984
|
// package.json
|
|
15984
|
-
var version = "0.15.0-dev.
|
|
15985
|
+
var version = "0.15.0-dev.6";
|
|
15985
15986
|
var package_default2 = {
|
|
15986
15987
|
name: "postgresai",
|
|
15987
15988
|
version,
|
|
@@ -29890,6 +29891,9 @@ async function ensureDefaultMonitoringProject() {
|
|
|
29890
29891
|
throw new Error(`Failed to bootstrap docker-compose.yml: ${msg}`);
|
|
29891
29892
|
}
|
|
29892
29893
|
}
|
|
29894
|
+
if (fs6.existsSync(instancesFile) && fs6.statSync(instancesFile).isDirectory()) {
|
|
29895
|
+
fs6.rmSync(instancesFile, { recursive: true, force: true });
|
|
29896
|
+
}
|
|
29893
29897
|
if (!fs6.existsSync(instancesFile)) {
|
|
29894
29898
|
const header = `# PostgreSQL instances to monitor
|
|
29895
29899
|
` + `# Add your instances using: pgai mon targets add <connection-string> <name>
|
|
@@ -31138,10 +31142,10 @@ function isDockerRunning() {
|
|
|
31138
31142
|
}
|
|
31139
31143
|
function getComposeCmd() {
|
|
31140
31144
|
const tryCmd = (cmd, args) => spawnSync2(cmd, args, { stdio: "ignore", timeout: 5000 }).status === 0;
|
|
31141
|
-
if (tryCmd("docker-compose", ["version"]))
|
|
31142
|
-
return ["docker-compose"];
|
|
31143
31145
|
if (tryCmd("docker", ["compose", "version"]))
|
|
31144
31146
|
return ["docker", "compose"];
|
|
31147
|
+
if (tryCmd("docker-compose", ["version"]))
|
|
31148
|
+
return ["docker-compose"];
|
|
31145
31149
|
return null;
|
|
31146
31150
|
}
|
|
31147
31151
|
function checkRunningContainers() {
|
|
@@ -31259,6 +31263,26 @@ async function runCompose(args, grafanaPassword) {
|
|
|
31259
31263
|
}
|
|
31260
31264
|
}
|
|
31261
31265
|
}
|
|
31266
|
+
const envFilePath = path6.resolve(projectDir, ".env");
|
|
31267
|
+
if (fs6.existsSync(envFilePath)) {
|
|
31268
|
+
try {
|
|
31269
|
+
const envContent = fs6.readFileSync(envFilePath, "utf8");
|
|
31270
|
+
if (!env.VM_AUTH_USERNAME) {
|
|
31271
|
+
const m = envContent.match(/^VM_AUTH_USERNAME=([^\r\n]+)/m);
|
|
31272
|
+
if (m)
|
|
31273
|
+
env.VM_AUTH_USERNAME = m[1].trim().replace(/^["']|["']$/g, "");
|
|
31274
|
+
}
|
|
31275
|
+
if (!env.VM_AUTH_PASSWORD) {
|
|
31276
|
+
const m = envContent.match(/^VM_AUTH_PASSWORD=([^\r\n]+)/m);
|
|
31277
|
+
if (m)
|
|
31278
|
+
env.VM_AUTH_PASSWORD = m[1].trim().replace(/^["']|["']$/g, "");
|
|
31279
|
+
}
|
|
31280
|
+
} catch (err) {
|
|
31281
|
+
if (process.env.DEBUG) {
|
|
31282
|
+
console.warn(`Warning: Could not read VM auth from .env: ${err instanceof Error ? err.message : String(err)}`);
|
|
31283
|
+
}
|
|
31284
|
+
}
|
|
31285
|
+
}
|
|
31262
31286
|
const finalArgs = [...args];
|
|
31263
31287
|
if (process.platform === "darwin" && args.includes("up")) {
|
|
31264
31288
|
finalArgs.push("--scale", "self-node-exporter=0");
|
|
@@ -31286,7 +31310,7 @@ mon.command("local-install").description("install local monitoring stack (genera
|
|
|
31286
31310
|
`);
|
|
31287
31311
|
console.log(`This will install, configure, and start the monitoring system
|
|
31288
31312
|
`);
|
|
31289
|
-
const { projectDir } = await resolveOrInitPaths();
|
|
31313
|
+
const { projectDir, instancesFile: instancesPath } = await resolveOrInitPaths();
|
|
31290
31314
|
console.log(`Project directory: ${projectDir}
|
|
31291
31315
|
`);
|
|
31292
31316
|
if (opts.project) {
|
|
@@ -31397,13 +31421,13 @@ Use demo mode without API key: postgres-ai mon local-install --demo`);
|
|
|
31397
31421
|
if (!opts.demo) {
|
|
31398
31422
|
console.log(`Step 2: Add PostgreSQL Instance to Monitor
|
|
31399
31423
|
`);
|
|
31400
|
-
const { instancesFile:
|
|
31424
|
+
const { instancesFile: instancesPath2, projectDir: projectDir2 } = await resolveOrInitPaths();
|
|
31401
31425
|
const emptyInstancesContent = `# PostgreSQL instances to monitor
|
|
31402
31426
|
# Add your instances using: postgres-ai mon targets add
|
|
31403
31427
|
|
|
31404
31428
|
`;
|
|
31405
|
-
fs6.writeFileSync(
|
|
31406
|
-
console.log(`Instances file: ${
|
|
31429
|
+
fs6.writeFileSync(instancesPath2, emptyInstancesContent, "utf8");
|
|
31430
|
+
console.log(`Instances file: ${instancesPath2}`);
|
|
31407
31431
|
console.log(`Project directory: ${projectDir2}
|
|
31408
31432
|
`);
|
|
31409
31433
|
if (opts.dbUrl) {
|
|
@@ -31434,7 +31458,7 @@ Use demo mode without API key: postgres-ai mon local-install --demo`);
|
|
|
31434
31458
|
node_name: ${instanceName}
|
|
31435
31459
|
sink_type: ~sink_type~
|
|
31436
31460
|
`;
|
|
31437
|
-
fs6.appendFileSync(
|
|
31461
|
+
fs6.appendFileSync(instancesPath2, body, "utf8");
|
|
31438
31462
|
console.log(`\u2713 Monitoring target '${instanceName}' added
|
|
31439
31463
|
`);
|
|
31440
31464
|
console.log("Testing connection to the added instance...");
|
|
@@ -31489,7 +31513,7 @@ You can provide either:`);
|
|
|
31489
31513
|
node_name: ${instanceName}
|
|
31490
31514
|
sink_type: ~sink_type~
|
|
31491
31515
|
`;
|
|
31492
|
-
fs6.appendFileSync(
|
|
31516
|
+
fs6.appendFileSync(instancesPath2, body, "utf8");
|
|
31493
31517
|
console.log(`\u2713 Monitoring target '${instanceName}' added
|
|
31494
31518
|
`);
|
|
31495
31519
|
console.log("Testing connection to the added instance...");
|
|
@@ -31517,8 +31541,24 @@ You can provide either:`);
|
|
|
31517
31541
|
}
|
|
31518
31542
|
}
|
|
31519
31543
|
} else {
|
|
31520
|
-
console.log(
|
|
31544
|
+
console.log("Step 2: Demo mode enabled - using included demo PostgreSQL database");
|
|
31545
|
+
const currentDir = path6.dirname(fileURLToPath2(import.meta.url));
|
|
31546
|
+
const demoCandidates = [
|
|
31547
|
+
path6.resolve(currentDir, "..", "..", "instances.demo.yml"),
|
|
31548
|
+
path6.resolve(currentDir, "..", "..", "..", "instances.demo.yml")
|
|
31549
|
+
];
|
|
31550
|
+
const demoSrc = demoCandidates.find((p) => fs6.existsSync(p));
|
|
31551
|
+
if (demoSrc) {
|
|
31552
|
+
if (fs6.existsSync(instancesPath) && fs6.statSync(instancesPath).isDirectory()) {
|
|
31553
|
+
fs6.rmSync(instancesPath, { recursive: true, force: true });
|
|
31554
|
+
}
|
|
31555
|
+
fs6.copyFileSync(demoSrc, instancesPath);
|
|
31556
|
+
console.log(`\u2713 Demo monitoring target configured
|
|
31521
31557
|
`);
|
|
31558
|
+
} else {
|
|
31559
|
+
console.error(`\u26A0 instances.demo.yml not found \u2014 demo target not configured (searched: ${demoCandidates.join(", ")})
|
|
31560
|
+
`);
|
|
31561
|
+
}
|
|
31522
31562
|
}
|
|
31523
31563
|
console.log(opts.demo ? "Step 3: Updating configuration..." : "Step 3: Updating configuration...");
|
|
31524
31564
|
const code1 = await runCompose(["run", "--rm", "sources-generator"]);
|
|
@@ -31531,6 +31571,8 @@ You can provide either:`);
|
|
|
31531
31571
|
console.log(opts.demo ? "Step 4: Configuring Grafana security..." : "Step 4: Configuring Grafana security...");
|
|
31532
31572
|
const cfgPath = path6.resolve(projectDir, ".pgwatch-config");
|
|
31533
31573
|
let grafanaPassword = "";
|
|
31574
|
+
let vmAuthUsername = "";
|
|
31575
|
+
let vmAuthPassword = "";
|
|
31534
31576
|
try {
|
|
31535
31577
|
if (fs6.existsSync(cfgPath)) {
|
|
31536
31578
|
const stats = fs6.statSync(cfgPath);
|
|
@@ -31568,6 +31610,45 @@ You can provide either:`);
|
|
|
31568
31610
|
`);
|
|
31569
31611
|
grafanaPassword = "demo";
|
|
31570
31612
|
}
|
|
31613
|
+
try {
|
|
31614
|
+
const envFile2 = path6.resolve(projectDir, ".env");
|
|
31615
|
+
if (fs6.existsSync(envFile2)) {
|
|
31616
|
+
const envContent = fs6.readFileSync(envFile2, "utf8");
|
|
31617
|
+
const userMatch = envContent.match(/^VM_AUTH_USERNAME=([^\r\n]+)/m);
|
|
31618
|
+
const passMatch = envContent.match(/^VM_AUTH_PASSWORD=([^\r\n]+)/m);
|
|
31619
|
+
if (userMatch)
|
|
31620
|
+
vmAuthUsername = userMatch[1].trim().replace(/^["']|["']$/g, "");
|
|
31621
|
+
if (passMatch)
|
|
31622
|
+
vmAuthPassword = passMatch[1].trim().replace(/^["']|["']$/g, "");
|
|
31623
|
+
}
|
|
31624
|
+
if (!vmAuthUsername || !vmAuthPassword) {
|
|
31625
|
+
console.log("Generating VictoriaMetrics auth credentials...");
|
|
31626
|
+
vmAuthUsername = vmAuthUsername || "vmauth";
|
|
31627
|
+
if (!vmAuthPassword) {
|
|
31628
|
+
const { stdout: vmPass } = await execPromise(`openssl rand -base64 12 | tr -d '
|
|
31629
|
+
'`);
|
|
31630
|
+
vmAuthPassword = vmPass.trim();
|
|
31631
|
+
}
|
|
31632
|
+
let envContent = "";
|
|
31633
|
+
if (fs6.existsSync(envFile2)) {
|
|
31634
|
+
envContent = fs6.readFileSync(envFile2, "utf8");
|
|
31635
|
+
}
|
|
31636
|
+
const envLines2 = envContent.split(/\r?\n/).filter((l) => !/^VM_AUTH_USERNAME=/.test(l) && !/^VM_AUTH_PASSWORD=/.test(l)).filter((l, i2, arr) => !(i2 === arr.length - 1 && l === ""));
|
|
31637
|
+
envLines2.push(`VM_AUTH_USERNAME=${vmAuthUsername}`);
|
|
31638
|
+
envLines2.push(`VM_AUTH_PASSWORD=${vmAuthPassword}`);
|
|
31639
|
+
fs6.writeFileSync(envFile2, envLines2.join(`
|
|
31640
|
+
`) + `
|
|
31641
|
+
`, { encoding: "utf8", mode: 384 });
|
|
31642
|
+
}
|
|
31643
|
+
console.log(`\u2713 VictoriaMetrics auth configured
|
|
31644
|
+
`);
|
|
31645
|
+
} catch (error2) {
|
|
31646
|
+
console.error("\u26A0 Could not generate VictoriaMetrics auth credentials automatically");
|
|
31647
|
+
if (process.env.DEBUG) {
|
|
31648
|
+
console.warn(` ${error2 instanceof Error ? error2.message : String(error2)}`);
|
|
31649
|
+
}
|
|
31650
|
+
}
|
|
31651
|
+
await runCompose(["rm", "-f", "-s", "config-init"]);
|
|
31571
31652
|
console.log("Step 5: Starting monitoring services...");
|
|
31572
31653
|
const code2 = await runCompose(["up", "-d", "--force-recreate"], grafanaPassword);
|
|
31573
31654
|
if (code2 !== 0) {
|
|
@@ -31615,6 +31696,9 @@ You can provide either:`);
|
|
|
31615
31696
|
console.log("\uD83D\uDE80 MAIN ACCESS POINT - Start here:");
|
|
31616
31697
|
console.log(" Grafana Dashboard: http://localhost:3000");
|
|
31617
31698
|
console.log(` Login: monitor / ${grafanaPassword}`);
|
|
31699
|
+
if (vmAuthUsername && vmAuthPassword) {
|
|
31700
|
+
console.log(` VictoriaMetrics Auth: ${vmAuthUsername} / ${vmAuthPassword}`);
|
|
31701
|
+
}
|
|
31618
31702
|
console.log(`\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
31619
31703
|
`);
|
|
31620
31704
|
});
|
|
@@ -31761,7 +31845,7 @@ mon.command("config").description("show monitoring services configuration").acti
|
|
|
31761
31845
|
console.log(`Project Directory: ${projectDir}`);
|
|
31762
31846
|
console.log(`Docker Compose File: ${composeFile}`);
|
|
31763
31847
|
console.log(`Instances File: ${instancesFile}`);
|
|
31764
|
-
if (fs6.existsSync(instancesFile)) {
|
|
31848
|
+
if (fs6.existsSync(instancesFile) && !fs6.statSync(instancesFile).isDirectory()) {
|
|
31765
31849
|
console.log(`
|
|
31766
31850
|
Instances configuration:
|
|
31767
31851
|
`);
|
|
@@ -31936,7 +32020,7 @@ mon.command("check").description("monitoring services system readiness check").a
|
|
|
31936
32020
|
var targets = mon.command("targets").description("manage databases to monitor");
|
|
31937
32021
|
targets.command("list").description("list monitoring target databases").action(async () => {
|
|
31938
32022
|
const { instancesFile: instancesPath, projectDir } = await resolveOrInitPaths();
|
|
31939
|
-
if (!fs6.existsSync(instancesPath)) {
|
|
32023
|
+
if (!fs6.existsSync(instancesPath) || fs6.statSync(instancesPath).isDirectory()) {
|
|
31940
32024
|
console.error(`instances.yml not found in ${projectDir}`);
|
|
31941
32025
|
process.exitCode = 1;
|
|
31942
32026
|
return;
|
|
@@ -31991,7 +32075,7 @@ targets.command("add [connStr] [name]").description("add monitoring target datab
|
|
|
31991
32075
|
const db = m[5];
|
|
31992
32076
|
const instanceName = name && name.trim() ? name.trim() : `${host}-${db}`.replace(/[^a-zA-Z0-9-]/g, "-");
|
|
31993
32077
|
try {
|
|
31994
|
-
if (fs6.existsSync(file)) {
|
|
32078
|
+
if (fs6.existsSync(file) && !fs6.statSync(file).isDirectory()) {
|
|
31995
32079
|
const content2 = fs6.readFileSync(file, "utf8");
|
|
31996
32080
|
const instances = load(content2) || [];
|
|
31997
32081
|
if (Array.isArray(instances)) {
|
|
@@ -32004,13 +32088,17 @@ targets.command("add [connStr] [name]").description("add monitoring target datab
|
|
|
32004
32088
|
}
|
|
32005
32089
|
}
|
|
32006
32090
|
} catch (err) {
|
|
32007
|
-
const
|
|
32091
|
+
const isFile = fs6.existsSync(file) && !fs6.statSync(file).isDirectory();
|
|
32092
|
+
const content2 = isFile ? fs6.readFileSync(file, "utf8") : "";
|
|
32008
32093
|
if (new RegExp(`^- name: ${instanceName}$`, "m").test(content2)) {
|
|
32009
32094
|
console.error(`Monitoring target '${instanceName}' already exists`);
|
|
32010
32095
|
process.exitCode = 1;
|
|
32011
32096
|
return;
|
|
32012
32097
|
}
|
|
32013
32098
|
}
|
|
32099
|
+
if (fs6.existsSync(file) && fs6.statSync(file).isDirectory()) {
|
|
32100
|
+
fs6.rmSync(file, { recursive: true, force: true });
|
|
32101
|
+
}
|
|
32014
32102
|
const body = `- name: ${instanceName}
|
|
32015
32103
|
conn_str: ${connStr}
|
|
32016
32104
|
preset_metrics: full
|
|
@@ -32030,7 +32118,7 @@ targets.command("add [connStr] [name]").description("add monitoring target datab
|
|
|
32030
32118
|
});
|
|
32031
32119
|
targets.command("remove <name>").description("remove monitoring target database").action(async (name) => {
|
|
32032
32120
|
const { instancesFile: file } = await resolveOrInitPaths();
|
|
32033
|
-
if (!fs6.existsSync(file)) {
|
|
32121
|
+
if (!fs6.existsSync(file) || fs6.statSync(file).isDirectory()) {
|
|
32034
32122
|
console.error("instances.yml not found");
|
|
32035
32123
|
process.exitCode = 1;
|
|
32036
32124
|
return;
|
|
@@ -32059,7 +32147,7 @@ targets.command("remove <name>").description("remove monitoring target database"
|
|
|
32059
32147
|
});
|
|
32060
32148
|
targets.command("test <name>").description("test monitoring target database connectivity").action(async (name) => {
|
|
32061
32149
|
const { instancesFile: instancesPath } = await resolveOrInitPaths();
|
|
32062
|
-
if (!fs6.existsSync(instancesPath)) {
|
|
32150
|
+
if (!fs6.existsSync(instancesPath) || fs6.statSync(instancesPath).isDirectory()) {
|
|
32063
32151
|
console.error("instances.yml not found");
|
|
32064
32152
|
process.exitCode = 1;
|
|
32065
32153
|
return;
|
|
@@ -32426,6 +32514,18 @@ Grafana credentials:`);
|
|
|
32426
32514
|
console.log(" URL: http://localhost:3000");
|
|
32427
32515
|
console.log(" Username: monitor");
|
|
32428
32516
|
console.log(` Password: ${password}`);
|
|
32517
|
+
const envFile = path6.resolve(projectDir, ".env");
|
|
32518
|
+
if (fs6.existsSync(envFile)) {
|
|
32519
|
+
const envContent = fs6.readFileSync(envFile, "utf8");
|
|
32520
|
+
const vmUser = envContent.match(/^VM_AUTH_USERNAME=([^\r\n]+)/m);
|
|
32521
|
+
const vmPass = envContent.match(/^VM_AUTH_PASSWORD=([^\r\n]+)/m);
|
|
32522
|
+
if (vmUser && vmPass) {
|
|
32523
|
+
console.log(`
|
|
32524
|
+
VictoriaMetrics credentials:`);
|
|
32525
|
+
console.log(` Username: ${vmUser[1].trim().replace(/^["']|["']$/g, "")}`);
|
|
32526
|
+
console.log(` Password: ${vmPass[1].trim().replace(/^["']|["']$/g, "")}`);
|
|
32527
|
+
}
|
|
32528
|
+
}
|
|
32429
32529
|
console.log("");
|
|
32430
32530
|
});
|
|
32431
32531
|
function interpretEscapes2(str2) {
|
package/package.json
CHANGED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Test getComposeCmd() selection logic.
|
|
5
|
+
* WARNING: This replicates the logic from postgres-ai.ts. If the production
|
|
6
|
+
* function changes, this replica must be updated to match.
|
|
7
|
+
* Since the function is internal to postgres-ai.ts, we replicate its logic
|
|
8
|
+
* with an injectable command checker (same pattern as monitoring.test.ts).
|
|
9
|
+
*/
|
|
10
|
+
function getComposeCmd(
|
|
11
|
+
tryCmd: (cmd: string, args: string[]) => boolean,
|
|
12
|
+
): string[] | null {
|
|
13
|
+
if (tryCmd("docker", ["compose", "version"])) return ["docker", "compose"];
|
|
14
|
+
if (tryCmd("docker-compose", ["version"])) return ["docker-compose"];
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("getComposeCmd", () => {
|
|
19
|
+
test("prefers docker compose V2 when both are available", () => {
|
|
20
|
+
const result = getComposeCmd(() => true);
|
|
21
|
+
expect(result).toEqual(["docker", "compose"]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("falls back to docker-compose V1 when V2 is unavailable", () => {
|
|
25
|
+
const result = getComposeCmd((cmd, args) => {
|
|
26
|
+
// V2 plugin fails, V1 standalone succeeds
|
|
27
|
+
if (cmd === "docker" && args[0] === "compose") return false;
|
|
28
|
+
if (cmd === "docker-compose") return true;
|
|
29
|
+
return false;
|
|
30
|
+
});
|
|
31
|
+
expect(result).toEqual(["docker-compose"]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("returns null when neither is available", () => {
|
|
35
|
+
const result = getComposeCmd(() => false);
|
|
36
|
+
expect(result).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("does not check V1 when V2 succeeds", () => {
|
|
40
|
+
const calls: Array<{ cmd: string; args: string[] }> = [];
|
|
41
|
+
getComposeCmd((cmd, args) => {
|
|
42
|
+
calls.push({ cmd, args });
|
|
43
|
+
return cmd === "docker" && args[0] === "compose";
|
|
44
|
+
});
|
|
45
|
+
expect(calls).toHaveLength(1);
|
|
46
|
+
expect(calls[0]).toEqual({ cmd: "docker", args: ["compose", "version"] });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("checks V2 first, then V1", () => {
|
|
50
|
+
const calls: Array<{ cmd: string; args: string[] }> = [];
|
|
51
|
+
getComposeCmd((cmd, args) => {
|
|
52
|
+
calls.push({ cmd, args });
|
|
53
|
+
return false;
|
|
54
|
+
});
|
|
55
|
+
expect(calls).toHaveLength(2);
|
|
56
|
+
expect(calls[0]).toEqual({ cmd: "docker", args: ["compose", "version"] });
|
|
57
|
+
expect(calls[1]).toEqual({ cmd: "docker-compose", args: ["version"] });
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Test the monitoring startup sequence's container cleanup logic.
|
|
63
|
+
* Before "up --force-recreate", stopped containers from "run --rm" dependencies
|
|
64
|
+
* (e.g. config-init) must be removed to avoid docker-compose v1's
|
|
65
|
+
* KeyError: 'ContainerConfig' bug.
|
|
66
|
+
*
|
|
67
|
+
* We replicate the relevant sequence from the monitoring start command
|
|
68
|
+
* with an injectable runCompose to verify ordering and error tolerance.
|
|
69
|
+
*/
|
|
70
|
+
async function monitoringStartSequence(
|
|
71
|
+
runCompose: (args: string[]) => Promise<number>,
|
|
72
|
+
): Promise<number> {
|
|
73
|
+
// Best-effort: remove stopped containers left by "run --rm" dependencies
|
|
74
|
+
await runCompose(["rm", "-f", "-s", "config-init"]);
|
|
75
|
+
// Start services
|
|
76
|
+
const code = await runCompose(["up", "-d", "--force-recreate"]);
|
|
77
|
+
return code;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe("monitoring start: config-init cleanup", () => {
|
|
81
|
+
test("calls rm before up", async () => {
|
|
82
|
+
const calls: string[][] = [];
|
|
83
|
+
await monitoringStartSequence(async (args) => {
|
|
84
|
+
calls.push(args);
|
|
85
|
+
return 0;
|
|
86
|
+
});
|
|
87
|
+
expect(calls).toHaveLength(2);
|
|
88
|
+
expect(calls[0]).toEqual(["rm", "-f", "-s", "config-init"]);
|
|
89
|
+
expect(calls[1]).toEqual(["up", "-d", "--force-recreate"]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("continues to up even when rm fails", async () => {
|
|
93
|
+
const calls: string[][] = [];
|
|
94
|
+
await monitoringStartSequence(async (args) => {
|
|
95
|
+
calls.push(args);
|
|
96
|
+
// rm returns non-zero (container doesn't exist)
|
|
97
|
+
if (args[0] === "rm") return 1;
|
|
98
|
+
return 0;
|
|
99
|
+
});
|
|
100
|
+
expect(calls).toHaveLength(2);
|
|
101
|
+
expect(calls[0][0]).toBe("rm");
|
|
102
|
+
expect(calls[1][0]).toBe("up");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("returns up exit code, not rm exit code", async () => {
|
|
106
|
+
// rm fails but up succeeds → overall success
|
|
107
|
+
const result1 = await monitoringStartSequence(async (args) => {
|
|
108
|
+
if (args[0] === "rm") return 1;
|
|
109
|
+
return 0;
|
|
110
|
+
});
|
|
111
|
+
expect(result1).toBe(0);
|
|
112
|
+
|
|
113
|
+
// rm succeeds but up fails → overall failure
|
|
114
|
+
const result2 = await monitoringStartSequence(async (args) => {
|
|
115
|
+
if (args[0] === "up") return 2;
|
|
116
|
+
return 0;
|
|
117
|
+
});
|
|
118
|
+
expect(result2).toBe(2);
|
|
119
|
+
});
|
|
120
|
+
});
|
package/test/init.test.ts
CHANGED
|
@@ -1070,6 +1070,14 @@ describe("CLI commands", () => {
|
|
|
1070
1070
|
expect(r.stderr).toMatch(/Reports will be generated locally only/);
|
|
1071
1071
|
});
|
|
1072
1072
|
|
|
1073
|
+
test("cli: mon local-install --demo configures demo monitoring target", () => {
|
|
1074
|
+
// --demo should copy instances.demo.yml to instances.yml and print confirmation.
|
|
1075
|
+
// The command will fail later (no Docker), but we verify the demo target step succeeded.
|
|
1076
|
+
const r = runCli(["mon", "local-install", "--demo"]);
|
|
1077
|
+
expect(r.stdout).toMatch(/Demo mode enabled/);
|
|
1078
|
+
expect(r.stdout).toMatch(/Demo monitoring target configured/);
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1073
1081
|
test("cli: mon local-install --demo with global --api-key shows error", () => {
|
|
1074
1082
|
// When --demo is used with global --api-key, it should still be detected and error
|
|
1075
1083
|
const r = runCli([
|
package/test/monitoring.test.ts
CHANGED
|
@@ -259,3 +259,81 @@ describe("registerMonitoringInstance", () => {
|
|
|
259
259
|
expect(fetchCalls[0].url).toBe("https://custom.api.com/v2/rpc/monitoring_instance_register");
|
|
260
260
|
});
|
|
261
261
|
});
|
|
262
|
+
|
|
263
|
+
describe("demo mode instances.demo.yml", () => {
|
|
264
|
+
const repoRoot = path.resolve(import.meta.dir, "..", "..");
|
|
265
|
+
|
|
266
|
+
test("instances.demo.yml exists in repo root", () => {
|
|
267
|
+
const demoFile = path.join(repoRoot, "instances.demo.yml");
|
|
268
|
+
expect(fs.existsSync(demoFile)).toBe(true);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("instances.demo.yml contains demo target connection", () => {
|
|
272
|
+
const demoFile = path.join(repoRoot, "instances.demo.yml");
|
|
273
|
+
const content = fs.readFileSync(demoFile, "utf8");
|
|
274
|
+
expect(content).toContain("name: target_database");
|
|
275
|
+
expect(content).toContain("conn_str: postgresql://monitor:monitor_pass@target-db:5432/target_database");
|
|
276
|
+
expect(content).toContain("is_enabled: true");
|
|
277
|
+
expect(content).toContain("preset_metrics: full");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("instances.demo.yml has required YAML structure", () => {
|
|
281
|
+
const demoFile = path.join(repoRoot, "instances.demo.yml");
|
|
282
|
+
const content = fs.readFileSync(demoFile, "utf8");
|
|
283
|
+
// Verify it's a YAML list (starts with "- name:")
|
|
284
|
+
expect(content).toMatch(/^- name: target_database/m);
|
|
285
|
+
// Verify required fields are present with correct indentation
|
|
286
|
+
expect(content).toMatch(/^\s+conn_str:/m);
|
|
287
|
+
expect(content).toMatch(/^\s+preset_metrics: full/m);
|
|
288
|
+
expect(content).toMatch(/^\s+is_enabled: true/m);
|
|
289
|
+
// ~sink_type~ is a placeholder replaced per-sink by generate-pgwatch-sources.sh
|
|
290
|
+
expect(content).toMatch(/^\s+sink_type: ~sink_type~/m);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("instances.yml is gitignored (not tracked)", () => {
|
|
294
|
+
const gitignore = fs.readFileSync(path.join(repoRoot, ".gitignore"), "utf8");
|
|
295
|
+
expect(gitignore).toContain("instances.yml");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("demo config can be copied to instances.yml in temp dir", () => {
|
|
299
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "demo-install-test-"));
|
|
300
|
+
try {
|
|
301
|
+
const demoSrc = path.join(repoRoot, "instances.demo.yml");
|
|
302
|
+
const instancesDest = path.join(tempDir, "instances.yml");
|
|
303
|
+
|
|
304
|
+
fs.copyFileSync(demoSrc, instancesDest);
|
|
305
|
+
|
|
306
|
+
expect(fs.existsSync(instancesDest)).toBe(true);
|
|
307
|
+
const content = fs.readFileSync(instancesDest, "utf8");
|
|
308
|
+
expect(content).toContain("name: target_database");
|
|
309
|
+
expect(content).toContain("conn_str: postgresql://monitor:monitor_pass@target-db:5432/target_database");
|
|
310
|
+
} finally {
|
|
311
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("demo config copy overwrites directory at instances.yml path", () => {
|
|
316
|
+
// Docker bind-mounts create missing paths as directories; the copy must handle this
|
|
317
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "demo-eisdir-test-"));
|
|
318
|
+
try {
|
|
319
|
+
const demoSrc = path.join(repoRoot, "instances.demo.yml");
|
|
320
|
+
const instancesDest = path.join(tempDir, "instances.yml");
|
|
321
|
+
|
|
322
|
+
// Simulate Docker creating a directory at instances.yml path
|
|
323
|
+
fs.mkdirSync(instancesDest);
|
|
324
|
+
expect(fs.statSync(instancesDest).isDirectory()).toBe(true);
|
|
325
|
+
|
|
326
|
+
// The fix: remove directory then copy
|
|
327
|
+
if (fs.statSync(instancesDest).isDirectory()) {
|
|
328
|
+
fs.rmSync(instancesDest, { recursive: true, force: true });
|
|
329
|
+
}
|
|
330
|
+
fs.copyFileSync(demoSrc, instancesDest);
|
|
331
|
+
|
|
332
|
+
expect(fs.statSync(instancesDest).isFile()).toBe(true);
|
|
333
|
+
const content = fs.readFileSync(instancesDest, "utf8");
|
|
334
|
+
expect(content).toContain("name: target_database");
|
|
335
|
+
} finally {
|
|
336
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
});
|