Skip to content

Commit f3c7a01

Browse files
committed
Merge branch 'fix/demo-monitoring-targets' into 'main'
fix(mon): configure demo monitoring target during local-install --demo Closes #151 See merge request postgres-ai/postgresai!223
2 parents 7cb6c58 + 5ce3c34 commit f3c7a01

8 files changed

Lines changed: 215 additions & 13 deletions

File tree

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,9 @@ volumes/
7878
.pgwatch-config
7979

8080
.env.hypothesis/
81+
82+
# Runtime instances config (created from instances.demo.yml or by CLI)
83+
instances.yml
84+
85+
# Build artifact: copied from repo root into cli/ during build
86+
cli/instances.demo.yml

cli/.npmignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package-lock.json
2+
node_modules/
3+
coverage/
4+
5+
# Auto-generated at build time (do not commit)
6+
lib/metrics-embedded.ts
7+
lib/checkup-dictionary-embedded.ts

cli/bin/postgres-ai.ts

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import * as yaml from "js-yaml";
77
import * as fs from "fs";
88
import * as path from "path";
99
import * as os from "os";
10+
import { fileURLToPath } from "url";
1011
import * as crypto from "node:crypto";
1112
import { Client } from "pg";
1213
import { startMcpServer } from "../lib/mcp-server";
@@ -503,7 +504,11 @@ async function ensureDefaultMonitoringProject(): Promise<PathResolution> {
503504
}
504505
}
505506

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.lstatSync(instancesFile).isDirectory()) {
510+
fs.rmSync(instancesFile, { recursive: true, force: true });
511+
}
507512
if (!fs.existsSync(instancesFile)) {
508513
const header =
509514
"# PostgreSQL instances to monitor\n" +
@@ -2302,7 +2307,7 @@ mon
23022307
console.log("This will install, configure, and start the monitoring system\n");
23032308

23042309
// Ensure we have a project directory with docker-compose.yml even if running from elsewhere
2305-
const { projectDir } = await resolveOrInitPaths();
2310+
const { projectDir, instancesFile: instancesPath } = await resolveOrInitPaths();
23062311
console.log(`Project directory: ${projectDir}\n`);
23072312

23082313
// Save project name to .pgwatch-config if provided (used by reporter container)
@@ -2525,7 +2530,38 @@ mon
25252530
}
25262531
}
25272532
} else {
2528-
console.log("Step 2: Demo mode enabled - using included demo PostgreSQL database\n");
2533+
// Demo mode: configure instances.yml from the bundled demo template.
2534+
//
2535+
// Side effects:
2536+
// - Writes instancesPath (instances.yml next to docker-compose.yml)
2537+
// - If Docker previously bind-mounted instances.yml as a directory, removes it first.
2538+
//
2539+
// Failure modes:
2540+
// - Exits with code 1 if instances.demo.yml is not found in any candidate path.
2541+
// This is fatal because starting without a target produces empty dashboards that
2542+
// look like a bug rather than a misconfiguration.
2543+
//
2544+
// Template search order (import.meta.url is resolved at runtime, not baked in at build):
2545+
// 1. npm layout: dist/bin/../../instances.demo.yml → package-root/instances.demo.yml
2546+
// 2. dev layout: cli/bin/../../../instances.demo.yml → repo-root/instances.demo.yml
2547+
console.log("Step 2: Demo mode enabled - using included demo PostgreSQL database");
2548+
const currentDir = path.dirname(fileURLToPath(import.meta.url));
2549+
const demoCandidates = [
2550+
path.resolve(currentDir, "..", "..", "instances.demo.yml"), // npm: dist/bin -> package root
2551+
path.resolve(currentDir, "..", "..", "..", "instances.demo.yml"), // dev: cli/bin -> repo root
2552+
];
2553+
const demoSrc = demoCandidates.find(p => fs.existsSync(p));
2554+
if (demoSrc) {
2555+
// Remove directory artifact left by Docker bind-mounts before copying
2556+
if (fs.existsSync(instancesPath) && fs.lstatSync(instancesPath).isDirectory()) {
2557+
fs.rmSync(instancesPath, { recursive: true, force: true });
2558+
}
2559+
fs.copyFileSync(demoSrc, instancesPath);
2560+
console.log("✓ Demo monitoring target configured\n");
2561+
} else {
2562+
console.error(`Error: instances.demo.yml not found — cannot configure demo target.\nSearched: ${demoCandidates.join(", ")}\n`);
2563+
process.exit(1);
2564+
}
25292565
}
25302566

25312567
// Step 3: Update configuration
@@ -2880,7 +2916,7 @@ mon
28802916
console.log(`Project Directory: ${projectDir}`);
28812917
console.log(`Docker Compose File: ${composeFile}`);
28822918
console.log(`Instances File: ${instancesFile}`);
2883-
if (fs.existsSync(instancesFile)) {
2919+
if (fs.existsSync(instancesFile) && !fs.lstatSync(instancesFile).isDirectory()) {
28842920
console.log("\nInstances configuration:\n");
28852921
const text = fs.readFileSync(instancesFile, "utf8");
28862922
process.stdout.write(text);
@@ -3096,7 +3132,7 @@ targets
30963132
.description("list monitoring target databases")
30973133
.action(async () => {
30983134
const { instancesFile: instancesPath, projectDir } = await resolveOrInitPaths();
3099-
if (!fs.existsSync(instancesPath)) {
3135+
if (!fs.existsSync(instancesPath) || fs.lstatSync(instancesPath).isDirectory()) {
31003136
console.error(`instances.yml not found in ${projectDir}`);
31013137
process.exitCode = 1;
31023138
return;
@@ -3162,7 +3198,7 @@ targets
31623198

31633199
// Check if instance already exists
31643200
try {
3165-
if (fs.existsSync(file)) {
3201+
if (fs.existsSync(file) && !fs.lstatSync(file).isDirectory()) {
31663202
const content = fs.readFileSync(file, "utf8");
31673203
const instances = yaml.load(content) as Instance[] | null || [];
31683204
if (Array.isArray(instances)) {
@@ -3176,15 +3212,19 @@ targets
31763212
}
31773213
} catch (err) {
31783214
// If YAML parsing fails, fall back to simple check
3179-
const content = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
3215+
const isFile = fs.existsSync(file) && !fs.lstatSync(file).isDirectory();
3216+
const content = isFile ? fs.readFileSync(file, "utf8") : "";
31803217
if (new RegExp(`^- name: ${instanceName}$`, "m").test(content)) {
31813218
console.error(`Monitoring target '${instanceName}' already exists`);
31823219
process.exitCode = 1;
31833220
return;
31843221
}
31853222
}
31863223

3187-
// Add new instance
3224+
// Add new instance — if instances.yml is a directory (Docker artifact), replace it with a file
3225+
if (fs.existsSync(file) && fs.lstatSync(file).isDirectory()) {
3226+
fs.rmSync(file, { recursive: true, force: true });
3227+
}
31883228
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`;
31893229
const content = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
31903230
fs.appendFileSync(file, (content && !/\n$/.test(content) ? "\n" : "") + body, "utf8");
@@ -3195,7 +3235,7 @@ targets
31953235
.description("remove monitoring target database")
31963236
.action(async (name: string) => {
31973237
const { instancesFile: file } = await resolveOrInitPaths();
3198-
if (!fs.existsSync(file)) {
3238+
if (!fs.existsSync(file) || fs.lstatSync(file).isDirectory()) {
31993239
console.error("instances.yml not found");
32003240
process.exitCode = 1;
32013241
return;
@@ -3232,7 +3272,7 @@ targets
32323272
.description("test monitoring target database connectivity")
32333273
.action(async (name: string) => {
32343274
const { instancesFile: instancesPath } = await resolveOrInitPaths();
3235-
if (!fs.existsSync(instancesPath)) {
3275+
if (!fs.existsSync(instancesPath) || fs.lstatSync(instancesPath).isDirectory()) {
32363276
console.error("instances.yml not found");
32373277
process.exitCode = 1;
32383278
return;

cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
"embed-metrics": "bun run scripts/embed-metrics.ts",
2929
"embed-checkup-dictionary": "bun run scripts/embed-checkup-dictionary.ts",
3030
"embed-all": "bun run embed-metrics && bun run embed-checkup-dictionary",
31-
"build": "bun run embed-all && bun build ./bin/postgres-ai.ts --outdir ./dist/bin --target node && node -e \"const fs=require('fs');const f='./dist/bin/postgres-ai.js';fs.writeFileSync(f,fs.readFileSync(f,'utf8').replace('#!/usr/bin/env bun','#!/usr/bin/env node'))\" && cp -r ./sql ./dist/sql",
31+
"build": "bun run embed-all && bun build ./bin/postgres-ai.ts --outdir ./dist/bin --target node && node -e \"const fs=require('fs');const f='./dist/bin/postgres-ai.js';fs.writeFileSync(f,fs.readFileSync(f,'utf8').replace('#!/usr/bin/env bun','#!/usr/bin/env node'))\" && cp -r ./sql ./dist/sql && cp ../instances.demo.yml ./instances.demo.yml",
3232
"prepublishOnly": "npm run build",
3333
"start": "bun ./bin/postgres-ai.ts --help",
3434
"start:node": "node ./dist/bin/postgres-ai.js --help",

cli/test/init.test.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
2-
import { resolve } from "path";
2+
import path, { resolve } from "path";
33
import * as fs from "fs";
44
import * as os from "os";
55

@@ -1070,6 +1070,69 @@ describe("CLI commands", () => {
10701070
expect(r.stderr).toMatch(/Reports will be generated locally only/);
10711071
});
10721072

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+
// resolvePaths() walks cwd() up to find docker-compose.yml, so instances.yml
1077+
// is written next to docker-compose.yml in the repo root.
1078+
const repoRoot = resolve(import.meta.dir, "..", "..");
1079+
const instancesPath = path.join(repoRoot, "instances.yml");
1080+
// Remove instances.yml if it exists — use rmSync to handle both files and
1081+
// directories (the EISDIR test may have left a directory here if it failed).
1082+
if (fs.existsSync(instancesPath)) fs.rmSync(instancesPath, { recursive: true, force: true });
1083+
try {
1084+
const r = runCli(["mon", "local-install", "--demo"]);
1085+
expect(r.stdout).toMatch(/Demo mode enabled/);
1086+
expect(r.stdout).toMatch(/Demo monitoring target configured/);
1087+
// Verify instances.yml was actually written with the demo target
1088+
expect(fs.existsSync(instancesPath)).toBe(true);
1089+
const content = fs.readFileSync(instancesPath, "utf8");
1090+
expect(content).toContain("name: target_database");
1091+
expect(content).toContain("conn_str: postgresql://monitor:monitor_pass@target-db:5432/target_database");
1092+
} finally {
1093+
// Clean up — instances.yml is gitignored so safe to remove
1094+
if (fs.existsSync(instancesPath)) fs.rmSync(instancesPath, { recursive: true, force: true });
1095+
}
1096+
});
1097+
1098+
test("cli: mon local-install --demo exits with code 1 when instances.demo.yml is missing", () => {
1099+
// Regression: if instances.demo.yml cannot be found in any candidate path, the CLI
1100+
// must exit with a non-zero code and a descriptive error (not silently create empty dashboards).
1101+
const repoRoot = resolve(import.meta.dir, "..", "..");
1102+
const demoFile = path.join(repoRoot, "instances.demo.yml");
1103+
const tempBackup = path.join(os.tmpdir(), `instances.demo.yml.test-backup-${Date.now()}`);
1104+
// Temporarily move instances.demo.yml so neither candidate path resolves
1105+
fs.copyFileSync(demoFile, tempBackup);
1106+
fs.unlinkSync(demoFile);
1107+
try {
1108+
const r = runCli(["mon", "local-install", "--demo"]);
1109+
expect(r.status).not.toBe(0);
1110+
expect(r.stderr).toContain("instances.demo.yml not found");
1111+
} finally {
1112+
// Restore the file — critical to do before any assertion can throw
1113+
if (!fs.existsSync(demoFile)) fs.copyFileSync(tempBackup, demoFile);
1114+
fs.rmSync(tempBackup, { force: true });
1115+
}
1116+
});
1117+
1118+
test("cli: mon local-install --demo with EISDIR recovers instances.yml", () => {
1119+
// Docker bind-mounts create missing paths as directories; the CLI must handle this.
1120+
const repoRoot = resolve(import.meta.dir, "..", "..");
1121+
const instancesPath = path.join(repoRoot, "instances.yml");
1122+
// Create instances.yml as a directory (simulating Docker artifact)
1123+
if (fs.existsSync(instancesPath)) fs.rmSync(instancesPath, { recursive: true, force: true });
1124+
fs.mkdirSync(instancesPath);
1125+
try {
1126+
const r = runCli(["mon", "local-install", "--demo"]);
1127+
expect(r.stdout).toMatch(/Demo monitoring target configured/);
1128+
expect(fs.statSync(instancesPath).isFile()).toBe(true);
1129+
const content = fs.readFileSync(instancesPath, "utf8");
1130+
expect(content).toContain("name: target_database");
1131+
} finally {
1132+
if (fs.existsSync(instancesPath)) fs.rmSync(instancesPath, { recursive: true, force: true });
1133+
}
1134+
});
1135+
10731136
test("cli: mon local-install --demo with global --api-key shows error", () => {
10741137
// When --demo is used with global --api-key, it should still be detected and error
10751138
const r = runCli([

cli/test/monitoring.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,3 +259,81 @@ describe("registerMonitoringInstance", () => {
259259
expect(fetchCalls[0].url).toBe("https://custom.api.com/v2/rpc/monitoring_instance_register");
260260
});
261261
});
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 sed token substituted by generate-pgwatch-sources.sh; values: postgres, prometheus
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).toMatch(/^instances\.yml$/m);
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+
});

instances.yml renamed to instances.demo.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
# Demo PostgreSQL instance for `pgai mon local-install --demo`
2+
# This file is copied to instances.yml during demo mode setup.
3+
14
- name: target_database
25
conn_str: postgresql://monitor:monitor_pass@target-db:5432/target_database
36
preset_metrics: full
@@ -8,4 +11,4 @@
811
env: demo
912
cluster: local
1013
node_name: node-01
11-
sink_type: ~sink_type~
14+
sink_type: ~sink_type~ # sed token substituted by generate-pgwatch-sources.sh; values: postgres, prometheus

tests/e2e.cli.sh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ set -e
77
CLI_CMD="node ./cli/dist/bin/postgres-ai.js"
88
MON_CMD="$CLI_CMD mon"
99

10+
# Ensure instances.yml exists as a file (instances.demo.yml is the tracked template)
11+
if [[ ! -f instances.yml ]]; then
12+
cp instances.demo.yml instances.yml || { echo "ERROR: instances.demo.yml missing — cannot seed instances.yml" >&2; exit 1; }
13+
fi
14+
1015
echo "=== Testing service commands ==="
1116
$MON_CMD check || true
1217
$MON_CMD config || true

0 commit comments

Comments
 (0)