postgresai 0.16.0-rc.4 → 0.16.0
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 +154 -0
- package/dist/bin/postgres-ai.js +2911 -255
- package/package.json +12 -3
- package/schemas/A002.schema.json +63 -0
- package/schemas/A003.schema.json +73 -0
- package/schemas/A004.schema.json +81 -0
- package/schemas/A007.schema.json +71 -0
- package/schemas/A013.schema.json +61 -0
- package/schemas/D001.schema.json +71 -0
- package/schemas/D004.schema.json +136 -0
- package/schemas/F001.schema.json +73 -0
- package/schemas/F002.schema.json +108 -0
- package/schemas/F003.schema.json +138 -0
- package/schemas/F004.schema.json +125 -0
- package/schemas/F005.schema.json +131 -0
- package/schemas/F009.schema.json +155 -0
- package/schemas/G001.schema.json +135 -0
- package/schemas/G003.schema.json +90 -0
- package/schemas/H001.schema.json +141 -0
- package/schemas/H002.schema.json +129 -0
- package/schemas/H004.schema.json +128 -0
- package/schemas/I001.schema.json +149 -0
- package/schemas/K001.schema.json +161 -0
- package/schemas/K003.schema.json +163 -0
- package/schemas/K004.schema.json +110 -0
- package/schemas/K005.schema.json +110 -0
- package/schemas/K006.schema.json +110 -0
- package/schemas/K007.schema.json +110 -0
- package/schemas/K008.schema.json +110 -0
- package/schemas/M001.schema.json +119 -0
- package/schemas/M002.schema.json +110 -0
- package/schemas/M003.schema.json +128 -0
- package/schemas/N001.schema.json +161 -0
- package/schemas/query.schema.json +62 -0
- package/CHANGELOG.md +0 -11
- package/bin/postgres-ai.ts +0 -5578
- package/bun.lock +0 -258
- package/bunfig.toml +0 -20
- package/lib/aas-onboard.ts +0 -251
- package/lib/auth-server.ts +0 -285
- package/lib/checkup-api.ts +0 -526
- package/lib/checkup-dictionary.ts +0 -103
- package/lib/checkup-summary.ts +0 -338
- package/lib/checkup.ts +0 -2261
- package/lib/config.ts +0 -171
- package/lib/init.ts +0 -1152
- package/lib/instances.ts +0 -245
- package/lib/issues.ts +0 -1060
- package/lib/mcp-server.ts +0 -667
- package/lib/metrics-loader.ts +0 -134
- package/lib/pkce.ts +0 -79
- package/lib/reports.ts +0 -373
- package/lib/storage.ts +0 -367
- package/lib/supabase.ts +0 -826
- package/lib/util.ts +0 -134
- package/packages/postgres-ai/README.md +0 -26
- package/packages/postgres-ai/bin/postgres-ai.js +0 -27
- package/packages/postgres-ai/package.json +0 -27
- package/scripts/embed-checkup-dictionary.ts +0 -115
- package/scripts/embed-metrics.ts +0 -160
- package/scripts/generate-release-notes.ts +0 -668
- package/test/PERMISSION_CHECK_TEST_SUMMARY.md +0 -139
- package/test/aas-onboard.test.ts +0 -301
- package/test/auth.test.ts +0 -287
- package/test/checkup.integration.test.ts +0 -413
- package/test/checkup.test.ts +0 -3626
- package/test/compose-cmd.test.ts +0 -120
- package/test/config-consistency.test.ts +0 -352
- package/test/init.integration.test.ts +0 -438
- package/test/init.test.ts +0 -1816
- package/test/issues.cli.test.ts +0 -1162
- package/test/issues.test.ts +0 -456
- package/test/mcp-server.test.ts +0 -2530
- package/test/monitoring.test.ts +0 -746
- package/test/permission-check-sql.test.ts +0 -116
- package/test/reports.cli.test.ts +0 -793
- package/test/reports.test.ts +0 -977
- package/test/schema-validation.test.ts +0 -231
- package/test/storage.test.ts +0 -935
- package/test/supabase.test.ts +0 -709
- package/test/targets-add-config.test.ts +0 -28
- package/test/test-utils.ts +0 -190
- package/test/upgrade.test.ts +0 -1056
- package/test/util.test.ts +0 -44
- package/tsconfig.json +0 -20
package/test/upgrade.test.ts
DELETED
|
@@ -1,1056 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
|
2
|
-
import { resolve } from "path";
|
|
3
|
-
import * as fs from "fs";
|
|
4
|
-
import * as os from "os";
|
|
5
|
-
|
|
6
|
-
// 30 seconds timeout for tests that spawn CLI processes
|
|
7
|
-
// This accommodates file I/O operations and process startup overhead in CI environments
|
|
8
|
-
const TEST_TIMEOUT = 30000;
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Run CLI command in a specific directory.
|
|
12
|
-
*
|
|
13
|
-
* @param args - CLI arguments to pass to postgres-ai (e.g., ["mon", "local-install", "--yes"])
|
|
14
|
-
* @param cwd - Working directory where the command should run
|
|
15
|
-
* @param env - Optional environment variables to override (merged with process.env)
|
|
16
|
-
* @returns Object containing:
|
|
17
|
-
* - status: Process exit code (0 = success)
|
|
18
|
-
* - stdout: Standard output as string
|
|
19
|
-
* - stderr: Standard error as string
|
|
20
|
-
*/
|
|
21
|
-
function runCliInDir(args: string[], cwd: string, env: Record<string, string | undefined> = {}) {
|
|
22
|
-
const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
|
|
23
|
-
const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
|
|
24
|
-
const result = Bun.spawnSync([bunBin, cliPath, ...args], {
|
|
25
|
-
env: { ...process.env, ...env },
|
|
26
|
-
cwd,
|
|
27
|
-
});
|
|
28
|
-
return {
|
|
29
|
-
status: result.exitCode,
|
|
30
|
-
stdout: new TextDecoder().decode(result.stdout),
|
|
31
|
-
stderr: new TextDecoder().decode(result.stderr),
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
describe("upgrade workflow", () => {
|
|
36
|
-
/**
|
|
37
|
-
* These tests verify the upgrade process documented in README.md:
|
|
38
|
-
* 1. Update CLI (npm install -g postgresai@latest)
|
|
39
|
-
* 2. Stop services (postgresai mon stop)
|
|
40
|
-
* 3. Re-run local-install which updates .env with new version
|
|
41
|
-
* 4. Verify services (postgresai mon status/health)
|
|
42
|
-
*
|
|
43
|
-
* Since Docker can't run in unit tests, we focus on testing the
|
|
44
|
-
* configuration update behavior which is the core of the upgrade process.
|
|
45
|
-
*/
|
|
46
|
-
|
|
47
|
-
let tempDir: string;
|
|
48
|
-
|
|
49
|
-
beforeAll(() => {
|
|
50
|
-
tempDir = fs.mkdtempSync(resolve(os.tmpdir(), "pgai-upgrade-test-"));
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
afterAll(() => {
|
|
54
|
-
if (tempDir && fs.existsSync(tempDir)) {
|
|
55
|
-
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
56
|
-
}
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
test("upgrade updates PGAI_TAG from old version to CLI version", () => {
|
|
60
|
-
// Simulate existing installation with old version
|
|
61
|
-
const testDir = resolve(tempDir, "upgrade-tag-test");
|
|
62
|
-
fs.mkdirSync(testDir, { recursive: true });
|
|
63
|
-
|
|
64
|
-
// Create .env with old version (simulating pre-upgrade state)
|
|
65
|
-
fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=0.13.0\n");
|
|
66
|
-
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
|
|
67
|
-
fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
|
|
68
|
-
|
|
69
|
-
// Run local-install (simulating upgrade after CLI update)
|
|
70
|
-
// The --yes flag skips interactive prompts
|
|
71
|
-
// Note: Command will fail at Docker step (no Docker in CI), but .env is updated before that
|
|
72
|
-
runCliInDir(
|
|
73
|
-
["mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"],
|
|
74
|
-
testDir,
|
|
75
|
-
{ PGAI_TAG: undefined }
|
|
76
|
-
);
|
|
77
|
-
|
|
78
|
-
// Read the updated .env (written before Docker operations)
|
|
79
|
-
const envContent = fs.readFileSync(resolve(testDir, ".env"), "utf8");
|
|
80
|
-
|
|
81
|
-
// The old version should be replaced with the CLI version
|
|
82
|
-
expect(envContent).not.toMatch(/PGAI_TAG=0\.13\.0/);
|
|
83
|
-
// Should have a valid version tag (either semver or dev version)
|
|
84
|
-
expect(envContent).toMatch(/PGAI_TAG=\d+\.\d+\.\d+|PGAI_TAG=0\.0\.0-dev/);
|
|
85
|
-
}, { timeout: TEST_TIMEOUT });
|
|
86
|
-
|
|
87
|
-
test("upgrade preserves Grafana password", () => {
|
|
88
|
-
const testDir = resolve(tempDir, "upgrade-password-test");
|
|
89
|
-
fs.mkdirSync(testDir, { recursive: true });
|
|
90
|
-
|
|
91
|
-
// Simulate existing installation with password
|
|
92
|
-
fs.writeFileSync(resolve(testDir, ".env"),
|
|
93
|
-
"PGAI_TAG=0.12.0\nGF_SECURITY_ADMIN_PASSWORD=my-secure-password-123\n");
|
|
94
|
-
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
|
|
95
|
-
fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
|
|
96
|
-
|
|
97
|
-
// Run local-install (upgrade)
|
|
98
|
-
// Note: Command will fail at Docker step (no Docker in CI), but .env is updated before that
|
|
99
|
-
runCliInDir(
|
|
100
|
-
["mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"],
|
|
101
|
-
testDir,
|
|
102
|
-
{ PGAI_TAG: undefined }
|
|
103
|
-
);
|
|
104
|
-
|
|
105
|
-
const envContent = fs.readFileSync(resolve(testDir, ".env"), "utf8");
|
|
106
|
-
|
|
107
|
-
// Password should be preserved
|
|
108
|
-
expect(envContent).toMatch(/GF_SECURITY_ADMIN_PASSWORD=my-secure-password-123/);
|
|
109
|
-
// Tag should be updated
|
|
110
|
-
expect(envContent).not.toMatch(/PGAI_TAG=0\.12\.0/);
|
|
111
|
-
}, { timeout: TEST_TIMEOUT });
|
|
112
|
-
|
|
113
|
-
test("upgrade preserves custom registry", () => {
|
|
114
|
-
const testDir = resolve(tempDir, "upgrade-registry-test");
|
|
115
|
-
fs.mkdirSync(testDir, { recursive: true });
|
|
116
|
-
|
|
117
|
-
// Simulate existing installation with custom registry
|
|
118
|
-
fs.writeFileSync(resolve(testDir, ".env"),
|
|
119
|
-
"PGAI_TAG=0.11.0\nPGAI_REGISTRY=registry.example.com/postgres-ai\n");
|
|
120
|
-
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
|
|
121
|
-
fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
|
|
122
|
-
|
|
123
|
-
// Run local-install (upgrade)
|
|
124
|
-
// Note: Command will fail at Docker step (no Docker in CI), but .env is updated before that
|
|
125
|
-
runCliInDir(
|
|
126
|
-
["mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"],
|
|
127
|
-
testDir,
|
|
128
|
-
{ PGAI_TAG: undefined }
|
|
129
|
-
);
|
|
130
|
-
|
|
131
|
-
const envContent = fs.readFileSync(resolve(testDir, ".env"), "utf8");
|
|
132
|
-
|
|
133
|
-
// Registry should be preserved
|
|
134
|
-
expect(envContent).toMatch(/PGAI_REGISTRY=registry\.example\.com\/postgres-ai/);
|
|
135
|
-
// Tag should be updated
|
|
136
|
-
expect(envContent).not.toMatch(/PGAI_TAG=0\.11\.0/);
|
|
137
|
-
}, { timeout: TEST_TIMEOUT });
|
|
138
|
-
|
|
139
|
-
test("upgrade preserves all settings together", () => {
|
|
140
|
-
const testDir = resolve(tempDir, "upgrade-all-settings-test");
|
|
141
|
-
fs.mkdirSync(testDir, { recursive: true });
|
|
142
|
-
|
|
143
|
-
// Simulate existing installation with all settings
|
|
144
|
-
fs.writeFileSync(resolve(testDir, ".env"),
|
|
145
|
-
"PGAI_TAG=0.10.0\nPGAI_REGISTRY=my.registry.io\nGF_SECURITY_ADMIN_PASSWORD=super-secret\n");
|
|
146
|
-
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
|
|
147
|
-
fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
|
|
148
|
-
|
|
149
|
-
// Run local-install (upgrade)
|
|
150
|
-
// Note: Command will fail at Docker step (no Docker in CI), but .env is updated before that
|
|
151
|
-
runCliInDir(
|
|
152
|
-
["mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"],
|
|
153
|
-
testDir,
|
|
154
|
-
{ PGAI_TAG: undefined }
|
|
155
|
-
);
|
|
156
|
-
|
|
157
|
-
const envContent = fs.readFileSync(resolve(testDir, ".env"), "utf8");
|
|
158
|
-
|
|
159
|
-
// All settings should be preserved
|
|
160
|
-
expect(envContent).toMatch(/PGAI_REGISTRY=my\.registry\.io/);
|
|
161
|
-
expect(envContent).toMatch(/GF_SECURITY_ADMIN_PASSWORD=super-secret/);
|
|
162
|
-
// Tag should be updated to new version
|
|
163
|
-
expect(envContent).not.toMatch(/PGAI_TAG=0\.10\.0/);
|
|
164
|
-
expect(envContent).toMatch(/PGAI_TAG=/);
|
|
165
|
-
}, { timeout: TEST_TIMEOUT });
|
|
166
|
-
|
|
167
|
-
test("upgrade with --tag flag uses specified version", () => {
|
|
168
|
-
const testDir = resolve(tempDir, "upgrade-custom-tag-test");
|
|
169
|
-
fs.mkdirSync(testDir, { recursive: true });
|
|
170
|
-
|
|
171
|
-
// Simulate existing installation
|
|
172
|
-
fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=0.9.0\n");
|
|
173
|
-
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
|
|
174
|
-
fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
|
|
175
|
-
|
|
176
|
-
// Run local-install with specific tag (for rollback or specific version upgrade)
|
|
177
|
-
// Note: Command will fail at Docker step (no Docker in CI), but .env is updated before that
|
|
178
|
-
const result = runCliInDir(
|
|
179
|
-
["mon", "local-install", "--tag", "0.14.0-beta.5", "--db-url", "postgresql://u:p@h:5432/d", "--yes"],
|
|
180
|
-
testDir,
|
|
181
|
-
{ PGAI_TAG: undefined }
|
|
182
|
-
);
|
|
183
|
-
|
|
184
|
-
const envContent = fs.readFileSync(resolve(testDir, ".env"), "utf8");
|
|
185
|
-
|
|
186
|
-
// Should use the specified tag
|
|
187
|
-
expect(envContent).toMatch(/PGAI_TAG=0\.14\.0-beta\.5/);
|
|
188
|
-
// Stdout should confirm the tag being used (happens before Docker step)
|
|
189
|
-
expect(result.stdout).toMatch(/Using image tag: 0\.14\.0-beta\.5/);
|
|
190
|
-
}, { timeout: TEST_TIMEOUT });
|
|
191
|
-
|
|
192
|
-
test("upgrade preserves .pgwatch-config file", () => {
|
|
193
|
-
const testDir = resolve(tempDir, "upgrade-config-test");
|
|
194
|
-
fs.mkdirSync(testDir, { recursive: true });
|
|
195
|
-
|
|
196
|
-
// Simulate existing installation with config file
|
|
197
|
-
fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=0.8.0\n");
|
|
198
|
-
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
|
|
199
|
-
fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
|
|
200
|
-
fs.writeFileSync(resolve(testDir, ".pgwatch-config"),
|
|
201
|
-
"api_key=test-api-key-12345\ngrafana_password=existing-password\n");
|
|
202
|
-
|
|
203
|
-
// Run local-install (upgrade)
|
|
204
|
-
// Note: Command will fail at Docker step (no Docker in CI), but config file is preserved
|
|
205
|
-
runCliInDir(
|
|
206
|
-
["mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"],
|
|
207
|
-
testDir,
|
|
208
|
-
{ PGAI_TAG: undefined }
|
|
209
|
-
);
|
|
210
|
-
|
|
211
|
-
// Config file should still exist
|
|
212
|
-
expect(fs.existsSync(resolve(testDir, ".pgwatch-config"))).toBe(true);
|
|
213
|
-
|
|
214
|
-
const configContent = fs.readFileSync(resolve(testDir, ".pgwatch-config"), "utf8");
|
|
215
|
-
// Grafana password should be preserved (not overwritten since it exists)
|
|
216
|
-
expect(configContent).toMatch(/grafana_password=existing-password/);
|
|
217
|
-
}, { timeout: TEST_TIMEOUT });
|
|
218
|
-
|
|
219
|
-
test("instances.yml is preserved during upgrade", () => {
|
|
220
|
-
const testDir = resolve(tempDir, "upgrade-instances-test");
|
|
221
|
-
fs.mkdirSync(testDir, { recursive: true });
|
|
222
|
-
|
|
223
|
-
// Simulate existing installation with instances
|
|
224
|
-
fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=0.7.0\n");
|
|
225
|
-
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
|
|
226
|
-
|
|
227
|
-
const instancesContent = `# PostgreSQL instances to monitor
|
|
228
|
-
- name: production-db
|
|
229
|
-
conn_str: postgresql://monitor:pass@prod.example.com:5432/mydb
|
|
230
|
-
preset_metrics: full
|
|
231
|
-
is_enabled: true
|
|
232
|
-
`;
|
|
233
|
-
fs.writeFileSync(resolve(testDir, "instances.yml"), instancesContent);
|
|
234
|
-
|
|
235
|
-
// Note: local-install in production mode clears instances.yml
|
|
236
|
-
// This is intentional behavior - upgrade should use 'mon start' not 'local-install'
|
|
237
|
-
// for preserving instances. Testing that the file exists after operation.
|
|
238
|
-
// Note: Command will fail at Docker step (no Docker in CI), but file is created
|
|
239
|
-
runCliInDir(
|
|
240
|
-
["mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"],
|
|
241
|
-
testDir,
|
|
242
|
-
{ PGAI_TAG: undefined }
|
|
243
|
-
);
|
|
244
|
-
|
|
245
|
-
// instances.yml should exist (content may be reset by local-install)
|
|
246
|
-
expect(fs.existsSync(resolve(testDir, "instances.yml"))).toBe(true);
|
|
247
|
-
}, { timeout: TEST_TIMEOUT });
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
describe("upgrade error handling", () => {
|
|
251
|
-
/**
|
|
252
|
-
* Tests for edge cases and error scenarios in the upgrade workflow.
|
|
253
|
-
* These ensure the CLI handles incomplete or malformed configurations gracefully.
|
|
254
|
-
*/
|
|
255
|
-
|
|
256
|
-
let tempDir: string;
|
|
257
|
-
|
|
258
|
-
beforeAll(() => {
|
|
259
|
-
tempDir = fs.mkdtempSync(resolve(os.tmpdir(), "pgai-upgrade-error-test-"));
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
afterAll(() => {
|
|
263
|
-
if (tempDir && fs.existsSync(tempDir)) {
|
|
264
|
-
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
265
|
-
}
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
test("local-install creates .env if missing", () => {
|
|
269
|
-
const testDir = resolve(tempDir, "missing-env-test");
|
|
270
|
-
fs.mkdirSync(testDir, { recursive: true });
|
|
271
|
-
|
|
272
|
-
// Only create docker-compose.yml (no .env)
|
|
273
|
-
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
|
|
274
|
-
|
|
275
|
-
// Run local-install without existing .env
|
|
276
|
-
// Note: Command will fail at Docker step (no Docker in CI), but .env is created before that
|
|
277
|
-
runCliInDir(
|
|
278
|
-
["mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"],
|
|
279
|
-
testDir,
|
|
280
|
-
{ PGAI_TAG: undefined }
|
|
281
|
-
);
|
|
282
|
-
|
|
283
|
-
// .env should be created (before Docker operations fail)
|
|
284
|
-
expect(fs.existsSync(resolve(testDir, ".env"))).toBe(true);
|
|
285
|
-
|
|
286
|
-
const envContent = fs.readFileSync(resolve(testDir, ".env"), "utf8");
|
|
287
|
-
expect(envContent).toMatch(/PGAI_TAG=/);
|
|
288
|
-
expect(envContent).toMatch(/REPLICATOR_PASSWORD=[a-f0-9]{64}/);
|
|
289
|
-
expect(envContent).toMatch(/VM_AUTH_USERNAME=vmauth/);
|
|
290
|
-
expect(envContent).toMatch(/^VM_AUTH_PASSWORD=[A-Za-z0-9+/]+={0,2}\s*$/m);
|
|
291
|
-
}, { timeout: TEST_TIMEOUT });
|
|
292
|
-
|
|
293
|
-
test("local-install handles .env without PGAI_TAG line", () => {
|
|
294
|
-
const testDir = resolve(tempDir, "no-tag-line-test");
|
|
295
|
-
fs.mkdirSync(testDir, { recursive: true });
|
|
296
|
-
|
|
297
|
-
// Create .env without PGAI_TAG (only has other settings)
|
|
298
|
-
fs.writeFileSync(resolve(testDir, ".env"), "GF_SECURITY_ADMIN_PASSWORD=old-password\nREPLICATOR_PASSWORD=existing-repl\nVM_AUTH_USERNAME=existing-vm-user\nVM_AUTH_PASSWORD=existing-vm-pass\n");
|
|
299
|
-
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
|
|
300
|
-
fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
|
|
301
|
-
|
|
302
|
-
// Run local-install
|
|
303
|
-
// Note: Command will fail at Docker step (no Docker in CI), but .env is updated before that
|
|
304
|
-
runCliInDir(
|
|
305
|
-
["mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"],
|
|
306
|
-
testDir,
|
|
307
|
-
{ PGAI_TAG: undefined }
|
|
308
|
-
);
|
|
309
|
-
|
|
310
|
-
const envContent = fs.readFileSync(resolve(testDir, ".env"), "utf8");
|
|
311
|
-
// Should add PGAI_TAG to the file
|
|
312
|
-
expect(envContent).toMatch(/PGAI_TAG=/);
|
|
313
|
-
// Should preserve existing settings
|
|
314
|
-
expect(envContent).toMatch(/GF_SECURITY_ADMIN_PASSWORD=old-password/);
|
|
315
|
-
expect(envContent).toMatch(/REPLICATOR_PASSWORD=existing-repl/);
|
|
316
|
-
expect(envContent).toMatch(/VM_AUTH_USERNAME=existing-vm-user/);
|
|
317
|
-
expect(envContent).toMatch(/VM_AUTH_PASSWORD=existing-vm-pass/);
|
|
318
|
-
}, { timeout: TEST_TIMEOUT });
|
|
319
|
-
|
|
320
|
-
test("local-install strips only matching quotes from VM auth values", () => {
|
|
321
|
-
const testDir = resolve(tempDir, "quoted-vm-auth-test");
|
|
322
|
-
fs.mkdirSync(testDir, { recursive: true });
|
|
323
|
-
|
|
324
|
-
fs.writeFileSync(
|
|
325
|
-
resolve(testDir, ".env"),
|
|
326
|
-
"VM_AUTH_USERNAME=\"quoted-vm-user\"\nVM_AUTH_PASSWORD='quoted-vm-pass'\n"
|
|
327
|
-
);
|
|
328
|
-
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
|
|
329
|
-
fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
|
|
330
|
-
|
|
331
|
-
runCliInDir(
|
|
332
|
-
["mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"],
|
|
333
|
-
testDir,
|
|
334
|
-
{ PGAI_TAG: undefined }
|
|
335
|
-
);
|
|
336
|
-
|
|
337
|
-
const envContent = fs.readFileSync(resolve(testDir, ".env"), "utf8");
|
|
338
|
-
expect(envContent).toMatch(/VM_AUTH_USERNAME=quoted-vm-user/);
|
|
339
|
-
expect(envContent).toMatch(/VM_AUTH_PASSWORD=quoted-vm-pass/);
|
|
340
|
-
}, { timeout: TEST_TIMEOUT });
|
|
341
|
-
|
|
342
|
-
test("local-install handles same version (no-op scenario)", () => {
|
|
343
|
-
const testDir = resolve(tempDir, "same-version-test");
|
|
344
|
-
fs.mkdirSync(testDir, { recursive: true });
|
|
345
|
-
|
|
346
|
-
// First, run local-install to get the current CLI version
|
|
347
|
-
fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=0.0.0-placeholder\n");
|
|
348
|
-
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
|
|
349
|
-
fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
|
|
350
|
-
|
|
351
|
-
// Note: Command will fail at Docker step (no Docker in CI), but .env is updated before that
|
|
352
|
-
runCliInDir(
|
|
353
|
-
["mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"],
|
|
354
|
-
testDir,
|
|
355
|
-
{ PGAI_TAG: undefined }
|
|
356
|
-
);
|
|
357
|
-
|
|
358
|
-
const firstEnv = fs.readFileSync(resolve(testDir, ".env"), "utf8");
|
|
359
|
-
expect(firstEnv).toMatch(/PGAI_TAG=/);
|
|
360
|
-
|
|
361
|
-
// Run again with same version - should update .env identically
|
|
362
|
-
runCliInDir(
|
|
363
|
-
["mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"],
|
|
364
|
-
testDir,
|
|
365
|
-
{ PGAI_TAG: undefined }
|
|
366
|
-
);
|
|
367
|
-
|
|
368
|
-
// .env should still have a valid tag
|
|
369
|
-
const finalEnv = fs.readFileSync(resolve(testDir, ".env"), "utf8");
|
|
370
|
-
expect(finalEnv).toMatch(/PGAI_TAG=/);
|
|
371
|
-
}, { timeout: TEST_TIMEOUT });
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
describe("upgrade CLI commands", () => {
|
|
375
|
-
test("mon stop command exists and shows help", () => {
|
|
376
|
-
const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
|
|
377
|
-
const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
|
|
378
|
-
const result = Bun.spawnSync([bunBin, cliPath, "mon", "stop", "--help"], {
|
|
379
|
-
env: process.env,
|
|
380
|
-
});
|
|
381
|
-
|
|
382
|
-
expect(result.exitCode).toBe(0);
|
|
383
|
-
const stdout = new TextDecoder().decode(result.stdout);
|
|
384
|
-
expect(stdout).toMatch(/stop monitoring services/i);
|
|
385
|
-
}, { timeout: TEST_TIMEOUT });
|
|
386
|
-
|
|
387
|
-
test("mon start command exists and shows help", () => {
|
|
388
|
-
const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
|
|
389
|
-
const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
|
|
390
|
-
const result = Bun.spawnSync([bunBin, cliPath, "mon", "start", "--help"], {
|
|
391
|
-
env: process.env,
|
|
392
|
-
});
|
|
393
|
-
|
|
394
|
-
expect(result.exitCode).toBe(0);
|
|
395
|
-
const stdout = new TextDecoder().decode(result.stdout);
|
|
396
|
-
expect(stdout).toMatch(/start monitoring services/i);
|
|
397
|
-
}, { timeout: TEST_TIMEOUT });
|
|
398
|
-
|
|
399
|
-
test("mon status command exists and shows help", () => {
|
|
400
|
-
const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
|
|
401
|
-
const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
|
|
402
|
-
const result = Bun.spawnSync([bunBin, cliPath, "mon", "status", "--help"], {
|
|
403
|
-
env: process.env,
|
|
404
|
-
});
|
|
405
|
-
|
|
406
|
-
expect(result.exitCode).toBe(0);
|
|
407
|
-
const stdout = new TextDecoder().decode(result.stdout);
|
|
408
|
-
expect(stdout).toMatch(/status/i);
|
|
409
|
-
}, { timeout: TEST_TIMEOUT });
|
|
410
|
-
|
|
411
|
-
test("mon health command exists and shows help", () => {
|
|
412
|
-
const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
|
|
413
|
-
const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
|
|
414
|
-
const result = Bun.spawnSync([bunBin, cliPath, "mon", "health", "--help"], {
|
|
415
|
-
env: process.env,
|
|
416
|
-
});
|
|
417
|
-
|
|
418
|
-
expect(result.exitCode).toBe(0);
|
|
419
|
-
const stdout = new TextDecoder().decode(result.stdout);
|
|
420
|
-
expect(stdout).toMatch(/health/i);
|
|
421
|
-
}, { timeout: TEST_TIMEOUT });
|
|
422
|
-
});
|
|
423
|
-
|
|
424
|
-
describe("in-place upgrade env migration (mon update / update-config)", () => {
|
|
425
|
-
/**
|
|
426
|
-
* Regression tests for the 0.14 -> 0.15 in-place upgrade gap (#203).
|
|
427
|
-
*
|
|
428
|
-
* Before this fix, a user who installed at 0.14 and ran the documented
|
|
429
|
-
* upgrade flow (`pgai mon update`) ended up with a .env file that lacked
|
|
430
|
-
* VM_AUTH_USERNAME / VM_AUTH_PASSWORD, so sink-prometheus exited with:
|
|
431
|
-
*
|
|
432
|
-
* fatal cannot read "/postgres_ai_configs/prometheus/prometheus.yml":
|
|
433
|
-
* cannot expand environment variables: missing "VM_AUTH_USERNAME" env var
|
|
434
|
-
*
|
|
435
|
-
* `mon update` and `mon update-config` now migrate .env additively before
|
|
436
|
-
* doing anything else.
|
|
437
|
-
*/
|
|
438
|
-
|
|
439
|
-
let tempDir: string;
|
|
440
|
-
|
|
441
|
-
beforeAll(() => {
|
|
442
|
-
tempDir = fs.mkdtempSync(resolve(os.tmpdir(), "pgai-upgrade-env-migration-"));
|
|
443
|
-
});
|
|
444
|
-
|
|
445
|
-
afterAll(() => {
|
|
446
|
-
if (tempDir && fs.existsSync(tempDir)) {
|
|
447
|
-
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
448
|
-
}
|
|
449
|
-
});
|
|
450
|
-
|
|
451
|
-
test("mon update-config appends missing VM_AUTH_USERNAME / VM_AUTH_PASSWORD to a 0.14-shaped .env", () => {
|
|
452
|
-
const testDir = resolve(tempDir, "update-config-0.14-env");
|
|
453
|
-
fs.mkdirSync(testDir, { recursive: true });
|
|
454
|
-
|
|
455
|
-
// 0.14-shaped .env: PGAI_TAG present, VM_AUTH_* absent.
|
|
456
|
-
fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=0.14.0\nGF_SECURITY_ADMIN_PASSWORD=user-set-grafana-pw\n");
|
|
457
|
-
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
|
|
458
|
-
fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
|
|
459
|
-
|
|
460
|
-
// The compose run will fail (no Docker in CI), but env migration runs first.
|
|
461
|
-
runCliInDir(["mon", "update-config"], testDir, { PGAI_TAG: undefined });
|
|
462
|
-
|
|
463
|
-
const envContent = fs.readFileSync(resolve(testDir, ".env"), "utf8");
|
|
464
|
-
|
|
465
|
-
// Existing values must be preserved verbatim.
|
|
466
|
-
expect(envContent).toMatch(/^PGAI_TAG=0\.14\.0$/m);
|
|
467
|
-
expect(envContent).toMatch(/^GF_SECURITY_ADMIN_PASSWORD=user-set-grafana-pw$/m);
|
|
468
|
-
|
|
469
|
-
// New required keys must be appended (vmauth username + non-empty base64 password).
|
|
470
|
-
expect(envContent).toMatch(/^VM_AUTH_USERNAME=vmauth$/m);
|
|
471
|
-
expect(envContent).toMatch(/^VM_AUTH_PASSWORD=[A-Za-z0-9+/]+={0,2}$/m);
|
|
472
|
-
|
|
473
|
-
// REPLICATOR_PASSWORD was introduced earlier and is also part of the contract.
|
|
474
|
-
expect(envContent).toMatch(/^REPLICATOR_PASSWORD=[a-f0-9]{64}$/m);
|
|
475
|
-
}, { timeout: TEST_TIMEOUT });
|
|
476
|
-
|
|
477
|
-
test("mon update appends missing VM_AUTH_USERNAME / VM_AUTH_PASSWORD to a 0.14-shaped .env", () => {
|
|
478
|
-
const testDir = resolve(tempDir, "update-0.14-env");
|
|
479
|
-
fs.mkdirSync(testDir, { recursive: true });
|
|
480
|
-
|
|
481
|
-
fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=0.14.0\n");
|
|
482
|
-
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
|
|
483
|
-
fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
|
|
484
|
-
|
|
485
|
-
// mon update will fail (no Docker in CI, no git repo), but env migration runs first.
|
|
486
|
-
const result = runCliInDir(["mon", "update"], testDir, { PGAI_TAG: undefined });
|
|
487
|
-
|
|
488
|
-
const envContent = fs.readFileSync(resolve(testDir, ".env"), "utf8");
|
|
489
|
-
|
|
490
|
-
expect(envContent).toMatch(/^PGAI_TAG=0\.14\.0$/m);
|
|
491
|
-
expect(envContent).toMatch(/^VM_AUTH_USERNAME=vmauth$/m);
|
|
492
|
-
expect(envContent).toMatch(/^VM_AUTH_PASSWORD=[A-Za-z0-9+/]+={0,2}$/m);
|
|
493
|
-
|
|
494
|
-
// The migration step should print what it added so the user can see it.
|
|
495
|
-
expect(result.stdout).toMatch(/Added missing \.env keys/);
|
|
496
|
-
expect(result.stdout).toMatch(/VM_AUTH_USERNAME/);
|
|
497
|
-
expect(result.stdout).toMatch(/VM_AUTH_PASSWORD/);
|
|
498
|
-
}, { timeout: TEST_TIMEOUT });
|
|
499
|
-
|
|
500
|
-
test("mon update preserves existing VM_AUTH_* values (no rotation)", () => {
|
|
501
|
-
const testDir = resolve(tempDir, "update-preserve-vm-auth");
|
|
502
|
-
fs.mkdirSync(testDir, { recursive: true });
|
|
503
|
-
|
|
504
|
-
// User already has VM auth configured (e.g. set up via rotate-vm-auth.sh).
|
|
505
|
-
fs.writeFileSync(
|
|
506
|
-
resolve(testDir, ".env"),
|
|
507
|
-
"PGAI_TAG=0.15.0\nVM_AUTH_USERNAME=custom-user\nVM_AUTH_PASSWORD=custom-pw-do-not-rotate\nREPLICATOR_PASSWORD=" +
|
|
508
|
-
"a".repeat(64) +
|
|
509
|
-
"\n",
|
|
510
|
-
);
|
|
511
|
-
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
|
|
512
|
-
fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
|
|
513
|
-
|
|
514
|
-
const result = runCliInDir(["mon", "update"], testDir, { PGAI_TAG: undefined });
|
|
515
|
-
|
|
516
|
-
const envContent = fs.readFileSync(resolve(testDir, ".env"), "utf8");
|
|
517
|
-
|
|
518
|
-
expect(envContent).toMatch(/^VM_AUTH_USERNAME=custom-user$/m);
|
|
519
|
-
expect(envContent).toMatch(/^VM_AUTH_PASSWORD=custom-pw-do-not-rotate$/m);
|
|
520
|
-
expect(envContent).toMatch(/^REPLICATOR_PASSWORD=a{64}$/m);
|
|
521
|
-
|
|
522
|
-
// When nothing is missing, the migration step should say so.
|
|
523
|
-
expect(result.stdout).toMatch(/\.env is up to date/);
|
|
524
|
-
}, { timeout: TEST_TIMEOUT });
|
|
525
|
-
|
|
526
|
-
test("mon update-config handles a .env that doesn't end with a newline", () => {
|
|
527
|
-
const testDir = resolve(tempDir, "update-config-no-trailing-newline");
|
|
528
|
-
fs.mkdirSync(testDir, { recursive: true });
|
|
529
|
-
|
|
530
|
-
// No trailing newline - migration must add one before appending new keys
|
|
531
|
-
// or we'd produce e.g. `PGAI_TAG=0.14.0VM_AUTH_USERNAME=vmauth`.
|
|
532
|
-
fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=0.14.0");
|
|
533
|
-
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
|
|
534
|
-
fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
|
|
535
|
-
|
|
536
|
-
runCliInDir(["mon", "update-config"], testDir, { PGAI_TAG: undefined });
|
|
537
|
-
|
|
538
|
-
const envContent = fs.readFileSync(resolve(testDir, ".env"), "utf8");
|
|
539
|
-
|
|
540
|
-
expect(envContent).toMatch(/^PGAI_TAG=0\.14\.0$/m);
|
|
541
|
-
expect(envContent).toMatch(/^VM_AUTH_USERNAME=vmauth$/m);
|
|
542
|
-
// No key should be glued onto the previous line.
|
|
543
|
-
expect(envContent).not.toMatch(/PGAI_TAG=0\.14\.0VM_AUTH_USERNAME/);
|
|
544
|
-
}, { timeout: TEST_TIMEOUT });
|
|
545
|
-
});
|
|
546
|
-
|
|
547
|
-
describe("in-place upgrade compose refresh (non-git npx upgrade)", () => {
|
|
548
|
-
/**
|
|
549
|
-
* Regression tests for the GA-blocking 0.14 -> 0.15 npx-upgrade gap (#186).
|
|
550
|
-
*
|
|
551
|
-
* The documented npx upgrade (`npm i -g postgresai@latest` then `pgai mon update`)
|
|
552
|
-
* only ever refreshed docker-compose.yml for *git* checkouts (via `git pull`).
|
|
553
|
-
* npx/global-npm installs are non-git, so the OLD 0.14 docker-compose.yml was
|
|
554
|
-
* retained while PGAI_TAG advanced to 0.15. VM basic auth is new in 0.15, so the
|
|
555
|
-
* retained 0.14 compose never wires VM_AUTH_* into the sink-prometheus
|
|
556
|
-
* (VictoriaMetrics) service. The 0.15 configs image ships a prometheus.yml that
|
|
557
|
-
* templates %{VM_AUTH_USERNAME}, so VictoriaMetrics aborts on boot:
|
|
558
|
-
*
|
|
559
|
-
* Exited (255): missing "VM_AUTH_USERNAME" env var
|
|
560
|
-
*
|
|
561
|
-
* -> all dashboards are dataless after upgrade. This hits every existing npx
|
|
562
|
-
* self-hosted user and blocks 0.15.0 GA.
|
|
563
|
-
*
|
|
564
|
-
* The fix: for NON-GIT installs, refresh the CLI-owned docker-compose.yml from the
|
|
565
|
-
* target ref when it is stale, backing up the prior compose first, and never
|
|
566
|
-
* touching user-owned files (.env / instances.yml).
|
|
567
|
-
*
|
|
568
|
-
* Hermetic seam: PGAI_COMPOSE_SOURCE points the refresh at a local fixture file
|
|
569
|
-
* (instead of fetching over the network) so these tests are offline-safe. The
|
|
570
|
-
* seam is only honored when NODE_ENV === "test" (bun sets this automatically;
|
|
571
|
-
* we also set it explicitly), so it is never reachable in a user environment.
|
|
572
|
-
*/
|
|
573
|
-
|
|
574
|
-
// A valid target 0.15 compose fixture: it carries the VM_AUTH_* wiring that the
|
|
575
|
-
// stale 0.14 compose lacks AND the keystone `sink-prometheus` service the
|
|
576
|
-
// validator requires. Self-contained so the test doesn't depend on the live
|
|
577
|
-
// repo compose changing under it.
|
|
578
|
-
const TARGET_0_15_COMPOSE = [
|
|
579
|
-
"version: '3.8'",
|
|
580
|
-
"services:",
|
|
581
|
-
" sink-prometheus:",
|
|
582
|
-
" image: victoriametrics/victoria-metrics:v1.140.0",
|
|
583
|
-
" container_name: sink-prometheus",
|
|
584
|
-
" environment:",
|
|
585
|
-
" - VM_AUTH_USERNAME=${VM_AUTH_USERNAME:-}",
|
|
586
|
-
" - VM_AUTH_PASSWORD=${VM_AUTH_PASSWORD:-}",
|
|
587
|
-
" grafana:",
|
|
588
|
-
" image: grafana/grafana:11.0.0",
|
|
589
|
-
"",
|
|
590
|
-
].join("\n");
|
|
591
|
-
|
|
592
|
-
// A 0.14-shaped compose: a real-ish stack with ZERO VM_AUTH wiring.
|
|
593
|
-
const STALE_0_14_COMPOSE = [
|
|
594
|
-
"version: '3.8'",
|
|
595
|
-
"services:",
|
|
596
|
-
" sink-prometheus:",
|
|
597
|
-
" image: victoriametrics/victoria-metrics:v1.115.0",
|
|
598
|
-
" container_name: sink-prometheus",
|
|
599
|
-
" ports:",
|
|
600
|
-
' - "9090:8428"',
|
|
601
|
-
" grafana:",
|
|
602
|
-
" image: grafana/grafana:11.0.0",
|
|
603
|
-
"",
|
|
604
|
-
].join("\n");
|
|
605
|
-
|
|
606
|
-
let tempDir: string;
|
|
607
|
-
// Path to a written-out copy of TARGET_0_15_COMPOSE, used as the fetch fixture.
|
|
608
|
-
let targetComposePath: string;
|
|
609
|
-
|
|
610
|
-
const listBackups = (dir: string): string[] =>
|
|
611
|
-
fs.readdirSync(dir).filter((f) => f.startsWith("docker-compose.yml.bak")).sort();
|
|
612
|
-
|
|
613
|
-
beforeAll(() => {
|
|
614
|
-
tempDir = fs.mkdtempSync(resolve(os.tmpdir(), "pgai-upgrade-compose-refresh-"));
|
|
615
|
-
targetComposePath = resolve(tempDir, "fixture-target-compose.yml");
|
|
616
|
-
fs.writeFileSync(targetComposePath, TARGET_0_15_COMPOSE);
|
|
617
|
-
});
|
|
618
|
-
|
|
619
|
-
afterAll(() => {
|
|
620
|
-
if (tempDir && fs.existsSync(tempDir)) {
|
|
621
|
-
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
622
|
-
}
|
|
623
|
-
});
|
|
624
|
-
|
|
625
|
-
test("non-git upgrade refreshes a stale docker-compose.yml to the target version (VM_AUTH wiring), preserves instances.yml, and writes a backup labeled with the OLD tag", () => {
|
|
626
|
-
const testDir = resolve(tempDir, "update-config-stale-compose");
|
|
627
|
-
fs.mkdirSync(testDir, { recursive: true });
|
|
628
|
-
|
|
629
|
-
// Arrange: a 0.14-shaped, NON-GIT install. Old compose has no VM_AUTH wiring.
|
|
630
|
-
fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=0.14.0\n");
|
|
631
|
-
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), STALE_0_14_COMPOSE);
|
|
632
|
-
fs.writeFileSync(
|
|
633
|
-
resolve(testDir, "instances.yml"),
|
|
634
|
-
"# PostgreSQL instances to monitor\n- name: keep-me\n conn_str: postgresql://m:p@h:5432/db\n",
|
|
635
|
-
);
|
|
636
|
-
|
|
637
|
-
// Sanity: the stale compose really lacks VM_AUTH wiring before the upgrade.
|
|
638
|
-
expect(fs.readFileSync(resolve(testDir, "docker-compose.yml"), "utf8")).not.toMatch(/VM_AUTH_USERNAME/);
|
|
639
|
-
|
|
640
|
-
// Act: documented in-place upgrade step on a non-git install. The compose `run`
|
|
641
|
-
// will fail (no Docker in CI), but the compose refresh runs first. Point the
|
|
642
|
-
// refresh at the local fixture so the test is hermetic (no network).
|
|
643
|
-
runCliInDir(["mon", "update-config"], testDir, {
|
|
644
|
-
NODE_ENV: "test",
|
|
645
|
-
PGAI_TAG: undefined,
|
|
646
|
-
PGAI_COMPOSE_SOURCE: targetComposePath,
|
|
647
|
-
});
|
|
648
|
-
|
|
649
|
-
// Assert: deployed compose is REFRESHED to the target version (VM_AUTH wired).
|
|
650
|
-
const after = fs.readFileSync(resolve(testDir, "docker-compose.yml"), "utf8");
|
|
651
|
-
expect(after).toMatch(/VM_AUTH_USERNAME/);
|
|
652
|
-
expect(after).toMatch(/sink-prometheus/);
|
|
653
|
-
|
|
654
|
-
// Assert: instances.yml is PRESERVED (user-owned file untouched).
|
|
655
|
-
expect(fs.readFileSync(resolve(testDir, "instances.yml"), "utf8")).toMatch(/keep-me/);
|
|
656
|
-
|
|
657
|
-
// Assert: exactly one backup, labeled with the OLD (0.14.0) tag, containing
|
|
658
|
-
// the pristine pre-upgrade compose. Name is `bak-<oldtag>-<hash8>`.
|
|
659
|
-
const backups = listBackups(testDir);
|
|
660
|
-
expect(backups.length).toBe(1);
|
|
661
|
-
expect(backups[0]).toMatch(/^docker-compose\.yml\.bak-0\.14\.0-[0-9a-f]{8}$/);
|
|
662
|
-
expect(fs.readFileSync(resolve(testDir, backups[0]!), "utf8")).toBe(STALE_0_14_COMPOSE);
|
|
663
|
-
}, { timeout: TEST_TIMEOUT });
|
|
664
|
-
|
|
665
|
-
test("non-git upgrade via `mon update` also refreshes a stale docker-compose.yml", () => {
|
|
666
|
-
const testDir = resolve(tempDir, "update-stale-compose");
|
|
667
|
-
fs.mkdirSync(testDir, { recursive: true });
|
|
668
|
-
|
|
669
|
-
fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=0.14.0\n");
|
|
670
|
-
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), STALE_0_14_COMPOSE);
|
|
671
|
-
fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n- name: keep-me\n");
|
|
672
|
-
|
|
673
|
-
runCliInDir(["mon", "update"], testDir, {
|
|
674
|
-
NODE_ENV: "test",
|
|
675
|
-
PGAI_TAG: undefined,
|
|
676
|
-
PGAI_COMPOSE_SOURCE: targetComposePath,
|
|
677
|
-
});
|
|
678
|
-
|
|
679
|
-
const after = fs.readFileSync(resolve(testDir, "docker-compose.yml"), "utf8");
|
|
680
|
-
expect(after).toMatch(/VM_AUTH_USERNAME/);
|
|
681
|
-
expect(fs.readFileSync(resolve(testDir, "instances.yml"), "utf8")).toMatch(/keep-me/);
|
|
682
|
-
const backups = listBackups(testDir);
|
|
683
|
-
expect(backups.length).toBe(1);
|
|
684
|
-
expect(backups[0]).toMatch(/^docker-compose\.yml\.bak-0\.14\.0-[0-9a-f]{8}$/);
|
|
685
|
-
}, { timeout: TEST_TIMEOUT });
|
|
686
|
-
|
|
687
|
-
test("git checkouts are left untouched (compose managed by git pull)", () => {
|
|
688
|
-
const testDir = resolve(tempDir, "git-checkout-no-refresh");
|
|
689
|
-
fs.mkdirSync(testDir, { recursive: true });
|
|
690
|
-
fs.mkdirSync(resolve(testDir, ".git"), { recursive: true });
|
|
691
|
-
|
|
692
|
-
fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=0.14.0\n");
|
|
693
|
-
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), STALE_0_14_COMPOSE);
|
|
694
|
-
fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
|
|
695
|
-
|
|
696
|
-
runCliInDir(["mon", "update-config"], testDir, {
|
|
697
|
-
NODE_ENV: "test",
|
|
698
|
-
PGAI_TAG: undefined,
|
|
699
|
-
PGAI_COMPOSE_SOURCE: targetComposePath,
|
|
700
|
-
});
|
|
701
|
-
|
|
702
|
-
// Compose must be unchanged for git checkouts, and no backup written.
|
|
703
|
-
expect(fs.readFileSync(resolve(testDir, "docker-compose.yml"), "utf8")).toBe(STALE_0_14_COMPOSE);
|
|
704
|
-
expect(listBackups(testDir)).toEqual([]);
|
|
705
|
-
}, { timeout: TEST_TIMEOUT });
|
|
706
|
-
|
|
707
|
-
test("already-current compose is not rewritten and no backup is created (idempotent)", () => {
|
|
708
|
-
const testDir = resolve(tempDir, "already-current-compose");
|
|
709
|
-
fs.mkdirSync(testDir, { recursive: true });
|
|
710
|
-
|
|
711
|
-
fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=0.14.0\n");
|
|
712
|
-
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), TARGET_0_15_COMPOSE);
|
|
713
|
-
fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
|
|
714
|
-
|
|
715
|
-
runCliInDir(["mon", "update-config"], testDir, {
|
|
716
|
-
NODE_ENV: "test",
|
|
717
|
-
PGAI_TAG: undefined,
|
|
718
|
-
PGAI_COMPOSE_SOURCE: targetComposePath,
|
|
719
|
-
});
|
|
720
|
-
|
|
721
|
-
// Content already matches target -> no rewrite, no backup.
|
|
722
|
-
expect(fs.readFileSync(resolve(testDir, "docker-compose.yml"), "utf8")).toBe(TARGET_0_15_COMPOSE);
|
|
723
|
-
expect(listBackups(testDir)).toEqual([]);
|
|
724
|
-
}, { timeout: TEST_TIMEOUT });
|
|
725
|
-
|
|
726
|
-
test("a trailing-newline-only difference is NOT treated as stale (no churn, no backup)", () => {
|
|
727
|
-
const testDir = resolve(tempDir, "trailing-newline-noop");
|
|
728
|
-
fs.mkdirSync(testDir, { recursive: true });
|
|
729
|
-
|
|
730
|
-
// Deployed compose == target but with extra trailing whitespace/newlines.
|
|
731
|
-
const deployed = TARGET_0_15_COMPOSE + "\n\n";
|
|
732
|
-
fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=0.14.0\n");
|
|
733
|
-
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), deployed);
|
|
734
|
-
fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
|
|
735
|
-
|
|
736
|
-
runCliInDir(["mon", "update-config"], testDir, {
|
|
737
|
-
NODE_ENV: "test",
|
|
738
|
-
PGAI_TAG: undefined,
|
|
739
|
-
PGAI_COMPOSE_SOURCE: targetComposePath,
|
|
740
|
-
});
|
|
741
|
-
|
|
742
|
-
// Whitespace-only delta must not trigger a rewrite or a backup.
|
|
743
|
-
expect(fs.readFileSync(resolve(testDir, "docker-compose.yml"), "utf8")).toBe(deployed);
|
|
744
|
-
expect(listBackups(testDir)).toEqual([]);
|
|
745
|
-
}, { timeout: TEST_TIMEOUT });
|
|
746
|
-
|
|
747
|
-
describe("fetched payload validation (BLOCKER #186 hardening)", () => {
|
|
748
|
-
/**
|
|
749
|
-
* A 200 response can still carry a NON-compose body (HTML login/captcha/
|
|
750
|
-
* proxy/maintenance page) or an empty/garbage body. Such a payload must
|
|
751
|
-
* NEVER clobber a working docker-compose.yml — that would be strictly worse
|
|
752
|
-
* than keeping the stale-but-valid one. The refresh must validate the fetched
|
|
753
|
-
* text and, on failure, behave EXACTLY like a clean fetch failure: keep the
|
|
754
|
-
* existing compose, write NO backup, warn, no-op.
|
|
755
|
-
*/
|
|
756
|
-
|
|
757
|
-
test("an HTML (non-compose) 200 body leaves the deployed compose UNCHANGED, writes NO backup, and warns", () => {
|
|
758
|
-
const testDir = resolve(tempDir, "garbage-html-body");
|
|
759
|
-
fs.mkdirSync(testDir, { recursive: true });
|
|
760
|
-
|
|
761
|
-
// The "fetched" payload is an HTML login/proxy page served with a 200.
|
|
762
|
-
const htmlBody = "<!DOCTYPE html>\n<html><head><title>Sign in</title></head>\n<body>Please log in to continue.</body></html>\n";
|
|
763
|
-
const htmlSource = resolve(testDir, "html-source.html");
|
|
764
|
-
fs.writeFileSync(htmlSource, htmlBody);
|
|
765
|
-
|
|
766
|
-
fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=0.14.0\n");
|
|
767
|
-
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), STALE_0_14_COMPOSE);
|
|
768
|
-
fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
|
|
769
|
-
|
|
770
|
-
const result = runCliInDir(["mon", "update-config"], testDir, {
|
|
771
|
-
NODE_ENV: "test",
|
|
772
|
-
PGAI_TAG: undefined,
|
|
773
|
-
PGAI_COMPOSE_SOURCE: htmlSource,
|
|
774
|
-
});
|
|
775
|
-
|
|
776
|
-
// The working compose must be left intact — NOT clobbered with HTML.
|
|
777
|
-
expect(fs.readFileSync(resolve(testDir, "docker-compose.yml"), "utf8")).toBe(STALE_0_14_COMPOSE);
|
|
778
|
-
// No backup is written when nothing is refreshed.
|
|
779
|
-
expect(listBackups(testDir)).toEqual([]);
|
|
780
|
-
// The user is warned that no valid compose was retrieved.
|
|
781
|
-
expect(result.stderr).toMatch(/Could not refresh docker-compose\.yml/);
|
|
782
|
-
}, { timeout: TEST_TIMEOUT });
|
|
783
|
-
|
|
784
|
-
test("a YAML body WITHOUT a sink-prometheus service is rejected (compose untouched, no backup)", () => {
|
|
785
|
-
const testDir = resolve(tempDir, "yaml-missing-keystone");
|
|
786
|
-
fs.mkdirSync(testDir, { recursive: true });
|
|
787
|
-
|
|
788
|
-
// Valid YAML, has services, but lacks the keystone sink-prometheus service.
|
|
789
|
-
const wrongCompose = "version: '3.8'\nservices:\n grafana:\n image: grafana/grafana:11.0.0\n";
|
|
790
|
-
const wrongSource = resolve(testDir, "wrong-compose.yml");
|
|
791
|
-
fs.writeFileSync(wrongSource, wrongCompose);
|
|
792
|
-
|
|
793
|
-
fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=0.14.0\n");
|
|
794
|
-
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), STALE_0_14_COMPOSE);
|
|
795
|
-
fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
|
|
796
|
-
|
|
797
|
-
runCliInDir(["mon", "update-config"], testDir, {
|
|
798
|
-
NODE_ENV: "test",
|
|
799
|
-
PGAI_TAG: undefined,
|
|
800
|
-
PGAI_COMPOSE_SOURCE: wrongSource,
|
|
801
|
-
});
|
|
802
|
-
|
|
803
|
-
expect(fs.readFileSync(resolve(testDir, "docker-compose.yml"), "utf8")).toBe(STALE_0_14_COMPOSE);
|
|
804
|
-
expect(listBackups(testDir)).toEqual([]);
|
|
805
|
-
}, { timeout: TEST_TIMEOUT });
|
|
806
|
-
|
|
807
|
-
test("an empty fetched body leaves the deployed compose intact and writes NO backup", () => {
|
|
808
|
-
const testDir = resolve(tempDir, "empty-body");
|
|
809
|
-
fs.mkdirSync(testDir, { recursive: true });
|
|
810
|
-
|
|
811
|
-
const emptySource = resolve(testDir, "empty.yml");
|
|
812
|
-
fs.writeFileSync(emptySource, "");
|
|
813
|
-
|
|
814
|
-
fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=0.14.0\n");
|
|
815
|
-
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), STALE_0_14_COMPOSE);
|
|
816
|
-
fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
|
|
817
|
-
|
|
818
|
-
runCliInDir(["mon", "update-config"], testDir, {
|
|
819
|
-
NODE_ENV: "test",
|
|
820
|
-
PGAI_TAG: undefined,
|
|
821
|
-
PGAI_COMPOSE_SOURCE: emptySource,
|
|
822
|
-
});
|
|
823
|
-
|
|
824
|
-
expect(fs.readFileSync(resolve(testDir, "docker-compose.yml"), "utf8")).toBe(STALE_0_14_COMPOSE);
|
|
825
|
-
expect(listBackups(testDir)).toEqual([]);
|
|
826
|
-
}, { timeout: TEST_TIMEOUT });
|
|
827
|
-
});
|
|
828
|
-
|
|
829
|
-
test("repeated update-config runs preserve the FIRST/pristine backup (no overwrite)", () => {
|
|
830
|
-
const testDir = resolve(tempDir, "backup-collision");
|
|
831
|
-
fs.mkdirSync(testDir, { recursive: true });
|
|
832
|
-
|
|
833
|
-
// PGAI_TAG stays 0.14.0 across update-config runs (it doesn't advance there).
|
|
834
|
-
fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=0.14.0\n");
|
|
835
|
-
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), STALE_0_14_COMPOSE);
|
|
836
|
-
fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
|
|
837
|
-
|
|
838
|
-
// First run: refreshes to target, backs up the pristine 0.14 compose.
|
|
839
|
-
runCliInDir(["mon", "update-config"], testDir, {
|
|
840
|
-
NODE_ENV: "test",
|
|
841
|
-
PGAI_TAG: undefined,
|
|
842
|
-
PGAI_COMPOSE_SOURCE: targetComposePath,
|
|
843
|
-
});
|
|
844
|
-
const afterFirst = listBackups(testDir);
|
|
845
|
-
expect(afterFirst.length).toBe(1);
|
|
846
|
-
const pristineBackup = afterFirst[0]!;
|
|
847
|
-
expect(fs.readFileSync(resolve(testDir, pristineBackup), "utf8")).toBe(STALE_0_14_COMPOSE);
|
|
848
|
-
|
|
849
|
-
// Now the deployed compose IS the target. Simulate a second drift back to a
|
|
850
|
-
// (different) stale compose, then run update-config again with the SAME tag.
|
|
851
|
-
const SECOND_STALE = STALE_0_14_COMPOSE.replace("v1.115.0", "v1.116.0");
|
|
852
|
-
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), SECOND_STALE);
|
|
853
|
-
|
|
854
|
-
runCliInDir(["mon", "update-config"], testDir, {
|
|
855
|
-
NODE_ENV: "test",
|
|
856
|
-
PGAI_TAG: undefined,
|
|
857
|
-
PGAI_COMPOSE_SOURCE: targetComposePath,
|
|
858
|
-
});
|
|
859
|
-
|
|
860
|
-
// The FIRST/pristine backup must still be intact (not overwritten), and the
|
|
861
|
-
// second distinct old content gets its own backup (unique by content hash).
|
|
862
|
-
expect(fs.readFileSync(resolve(testDir, pristineBackup), "utf8")).toBe(STALE_0_14_COMPOSE);
|
|
863
|
-
const afterSecond = listBackups(testDir);
|
|
864
|
-
expect(afterSecond.length).toBe(2);
|
|
865
|
-
expect(afterSecond).toContain(pristineBackup);
|
|
866
|
-
}, { timeout: TEST_TIMEOUT });
|
|
867
|
-
|
|
868
|
-
test("local-install labels the backup with the OLD tag, not the new CLI version", () => {
|
|
869
|
-
const testDir = resolve(tempDir, "local-install-old-tag-backup");
|
|
870
|
-
fs.mkdirSync(testDir, { recursive: true });
|
|
871
|
-
|
|
872
|
-
// 0.14 install. local-install rewrites .env PGAI_TAG to the CLI version
|
|
873
|
-
// BEFORE the compose refresh; the backup must still reflect the OLD (0.14.0)
|
|
874
|
-
// tag, not the new one.
|
|
875
|
-
fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=0.14.0\n");
|
|
876
|
-
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), STALE_0_14_COMPOSE);
|
|
877
|
-
fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
|
|
878
|
-
|
|
879
|
-
runCliInDir(
|
|
880
|
-
["mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"],
|
|
881
|
-
testDir,
|
|
882
|
-
{
|
|
883
|
-
NODE_ENV: "test",
|
|
884
|
-
PGAI_TAG: undefined,
|
|
885
|
-
PGAI_COMPOSE_SOURCE: targetComposePath,
|
|
886
|
-
},
|
|
887
|
-
);
|
|
888
|
-
|
|
889
|
-
// .env PGAI_TAG was advanced to the new CLI version...
|
|
890
|
-
const envAfter = fs.readFileSync(resolve(testDir, ".env"), "utf8");
|
|
891
|
-
expect(envAfter).not.toMatch(/PGAI_TAG=0\.14\.0/);
|
|
892
|
-
|
|
893
|
-
// ...but the backup of the OLD compose must be labeled with 0.14.0, NOT the
|
|
894
|
-
// new tag, and must contain the pristine pre-upgrade compose.
|
|
895
|
-
const backups = listBackups(testDir);
|
|
896
|
-
expect(backups.length).toBe(1);
|
|
897
|
-
expect(backups[0]).toMatch(/^docker-compose\.yml\.bak-0\.14\.0-[0-9a-f]{8}$/);
|
|
898
|
-
expect(fs.readFileSync(resolve(testDir, backups[0]!), "utf8")).toBe(STALE_0_14_COMPOSE);
|
|
899
|
-
}, { timeout: TEST_TIMEOUT });
|
|
900
|
-
|
|
901
|
-
test("a fetch failure (no payload retrieved) leaves the deployed compose intact, writes NO backup, warns, and does not crash", () => {
|
|
902
|
-
// Contract bullet: "Best-effort — a fetch failure warns and keeps the
|
|
903
|
-
// existing compose." Force fetchTargetCompose() -> null hermetically by
|
|
904
|
-
// pointing the test seam at a path that does not exist. This is the network
|
|
905
|
-
// -down / GitLab-5xx branch: it must NOT turn a metrics-only outage into a
|
|
906
|
-
// hard CLI failure, and must never clobber the stale-but-valid compose.
|
|
907
|
-
const testDir = resolve(tempDir, "fetch-failure-null");
|
|
908
|
-
fs.mkdirSync(testDir, { recursive: true });
|
|
909
|
-
|
|
910
|
-
fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=0.14.0\n");
|
|
911
|
-
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), STALE_0_14_COMPOSE);
|
|
912
|
-
fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
|
|
913
|
-
|
|
914
|
-
const result = runCliInDir(["mon", "update-config"], testDir, {
|
|
915
|
-
NODE_ENV: "test",
|
|
916
|
-
PGAI_TAG: undefined,
|
|
917
|
-
// Nonexistent fixture path -> fetchTargetCompose() returns null.
|
|
918
|
-
PGAI_COMPOSE_SOURCE: resolve(testDir, "does-not-exist.yml"),
|
|
919
|
-
});
|
|
920
|
-
|
|
921
|
-
// The working compose must be untouched, and no backup written.
|
|
922
|
-
expect(fs.readFileSync(resolve(testDir, "docker-compose.yml"), "utf8")).toBe(STALE_0_14_COMPOSE);
|
|
923
|
-
expect(listBackups(testDir)).toEqual([]);
|
|
924
|
-
// The user is warned, and the CLI does not crash on the env-migration step.
|
|
925
|
-
expect(result.stderr).toMatch(/Could not refresh docker-compose\.yml/);
|
|
926
|
-
}, { timeout: TEST_TIMEOUT });
|
|
927
|
-
|
|
928
|
-
test("no deployed compose yet is a clean no-op: returns false, writes nothing, no backup", async () => {
|
|
929
|
-
// Guard: `if (!fs.existsSync(composeFile)) return false` keeps the refresh
|
|
930
|
-
// from racing with the green-field bootstrap path. NOTE: this branch is not
|
|
931
|
-
// reachable through the black-box `mon update-config` flow — resolveOrInitPaths
|
|
932
|
-
// bootstraps (fetches) a compose BEFORE the refresh runs, so by then the file
|
|
933
|
-
// always exists. We therefore exercise the guard directly against the exported
|
|
934
|
-
// helper, which is the only faithful, hermetic way to cover it.
|
|
935
|
-
const { refreshBundledComposeIfStale } = await import("../bin/postgres-ai.ts");
|
|
936
|
-
|
|
937
|
-
const testDir = resolve(tempDir, "no-deployed-compose");
|
|
938
|
-
fs.mkdirSync(testDir, { recursive: true });
|
|
939
|
-
fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=0.14.0\n");
|
|
940
|
-
fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
|
|
941
|
-
// Intentionally NO docker-compose.yml.
|
|
942
|
-
|
|
943
|
-
process.env.NODE_ENV = "test";
|
|
944
|
-
process.env.PGAI_COMPOSE_SOURCE = targetComposePath;
|
|
945
|
-
const refreshed = await refreshBundledComposeIfStale(testDir);
|
|
946
|
-
|
|
947
|
-
// No compose on disk -> no-op: returns false, materializes no compose, and
|
|
948
|
-
// writes no backup. The bootstrap path owns the first fetch.
|
|
949
|
-
expect(refreshed).toBe(false);
|
|
950
|
-
expect(fs.existsSync(resolve(testDir, "docker-compose.yml"))).toBe(false);
|
|
951
|
-
expect(listBackups(testDir)).toEqual([]);
|
|
952
|
-
}, { timeout: TEST_TIMEOUT });
|
|
953
|
-
|
|
954
|
-
test("git checkout is a no-op even with a stale compose (direct helper guard)", async () => {
|
|
955
|
-
// Companion guard: `.git` present -> the repo manages the compose via
|
|
956
|
-
// `git pull`, so the helper must return false and touch nothing. Covered
|
|
957
|
-
// black-box too (below), but pinned here against the exported helper.
|
|
958
|
-
const { refreshBundledComposeIfStale } = await import("../bin/postgres-ai.ts");
|
|
959
|
-
|
|
960
|
-
const testDir = resolve(tempDir, "git-checkout-helper-noop");
|
|
961
|
-
fs.mkdirSync(resolve(testDir, ".git"), { recursive: true });
|
|
962
|
-
fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=0.14.0\n");
|
|
963
|
-
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), STALE_0_14_COMPOSE);
|
|
964
|
-
|
|
965
|
-
process.env.NODE_ENV = "test";
|
|
966
|
-
process.env.PGAI_COMPOSE_SOURCE = targetComposePath;
|
|
967
|
-
const refreshed = await refreshBundledComposeIfStale(testDir);
|
|
968
|
-
|
|
969
|
-
expect(refreshed).toBe(false);
|
|
970
|
-
expect(fs.readFileSync(resolve(testDir, "docker-compose.yml"), "utf8")).toBe(STALE_0_14_COMPOSE);
|
|
971
|
-
expect(listBackups(testDir)).toEqual([]);
|
|
972
|
-
}, { timeout: TEST_TIMEOUT });
|
|
973
|
-
|
|
974
|
-
test("backup falls back to a timestamp suffix when .env has no PGAI_TAG line", () => {
|
|
975
|
-
// When readDeployedTag() returns null (no PGAI_TAG to label the backup with),
|
|
976
|
-
// the backup name falls back to an ISO-8601 timestamp suffix
|
|
977
|
-
// (`bak-<YYYY-MM-DDTHH-MM-SS-mmmZ>-<hash8>`). Every other test seeds
|
|
978
|
-
// PGAI_TAG=0.14.0, so this branch was previously unexercised.
|
|
979
|
-
const testDir = resolve(tempDir, "timestamp-suffix-fallback");
|
|
980
|
-
fs.mkdirSync(testDir, { recursive: true });
|
|
981
|
-
|
|
982
|
-
// .env exists (non-git install) but carries NO PGAI_TAG line.
|
|
983
|
-
fs.writeFileSync(resolve(testDir, ".env"), "GRAFANA_PASSWORD=secret\n");
|
|
984
|
-
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), STALE_0_14_COMPOSE);
|
|
985
|
-
fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
|
|
986
|
-
|
|
987
|
-
runCliInDir(["mon", "update-config"], testDir, {
|
|
988
|
-
NODE_ENV: "test",
|
|
989
|
-
PGAI_TAG: undefined,
|
|
990
|
-
PGAI_COMPOSE_SOURCE: targetComposePath,
|
|
991
|
-
});
|
|
992
|
-
|
|
993
|
-
// The compose was refreshed, and the single backup is labeled with a
|
|
994
|
-
// timestamp suffix (NOT a tag), still uniquified by the content hash.
|
|
995
|
-
const backups = listBackups(testDir);
|
|
996
|
-
expect(backups.length).toBe(1);
|
|
997
|
-
expect(backups[0]).toMatch(/^docker-compose\.yml\.bak-\d{4}-\d{2}-\d{2}T[\dZ-]+-[0-9a-f]{8}$/);
|
|
998
|
-
expect(fs.readFileSync(resolve(testDir, backups[0]!), "utf8")).toBe(STALE_0_14_COMPOSE);
|
|
999
|
-
}, { timeout: TEST_TIMEOUT });
|
|
1000
|
-
|
|
1001
|
-
test("a malformed/hostile PGAI_TAG is rejected and the backup falls back to a timestamp suffix (no path traversal)", () => {
|
|
1002
|
-
// readDeployedTag() validates the tag against a conservative charset before
|
|
1003
|
-
// it flows into the backup filename, so a path-traversal-shaped value cannot
|
|
1004
|
-
// escape projectDir; it falls back to the timestamp suffix instead.
|
|
1005
|
-
const testDir = resolve(tempDir, "hostile-tag-rejected");
|
|
1006
|
-
fs.mkdirSync(testDir, { recursive: true });
|
|
1007
|
-
|
|
1008
|
-
fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=../../../../tmp/evil\n");
|
|
1009
|
-
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), STALE_0_14_COMPOSE);
|
|
1010
|
-
fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
|
|
1011
|
-
|
|
1012
|
-
runCliInDir(["mon", "update-config"], testDir, {
|
|
1013
|
-
NODE_ENV: "test",
|
|
1014
|
-
PGAI_TAG: undefined,
|
|
1015
|
-
PGAI_COMPOSE_SOURCE: targetComposePath,
|
|
1016
|
-
});
|
|
1017
|
-
|
|
1018
|
-
// The single backup stays inside projectDir with a timestamp suffix — the
|
|
1019
|
-
// traversal value never reaches the filename.
|
|
1020
|
-
const backups = listBackups(testDir);
|
|
1021
|
-
expect(backups.length).toBe(1);
|
|
1022
|
-
expect(backups[0]).toMatch(/^docker-compose\.yml\.bak-\d{4}-\d{2}-\d{2}T[\dZ-]+-[0-9a-f]{8}$/);
|
|
1023
|
-
// Nothing was written outside projectDir.
|
|
1024
|
-
expect(fs.existsSync("/tmp/evil")).toBe(false);
|
|
1025
|
-
}, { timeout: TEST_TIMEOUT });
|
|
1026
|
-
|
|
1027
|
-
test("local-install sanitizes the OLD tag it passes in: a hostile PGAI_TAG falls back to a timestamp suffix", () => {
|
|
1028
|
-
// local-install captures the OLD .env PGAI_TAG and passes it to the refresh as
|
|
1029
|
-
// `oldTag`, BYPASSING readDeployedTag. Sanitization must therefore happen
|
|
1030
|
-
// centrally inside the helper so a hostile tag on THIS path also cannot escape
|
|
1031
|
-
// projectDir or land literal `/`/quote chars in the backup filename.
|
|
1032
|
-
const testDir = resolve(tempDir, "local-install-hostile-old-tag");
|
|
1033
|
-
fs.mkdirSync(testDir, { recursive: true });
|
|
1034
|
-
|
|
1035
|
-
fs.writeFileSync(resolve(testDir, ".env"), 'PGAI_TAG="../../../../tmp/evil"\n');
|
|
1036
|
-
fs.writeFileSync(resolve(testDir, "docker-compose.yml"), STALE_0_14_COMPOSE);
|
|
1037
|
-
fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
|
|
1038
|
-
|
|
1039
|
-
runCliInDir(
|
|
1040
|
-
["mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"],
|
|
1041
|
-
testDir,
|
|
1042
|
-
{
|
|
1043
|
-
NODE_ENV: "test",
|
|
1044
|
-
PGAI_TAG: undefined,
|
|
1045
|
-
PGAI_COMPOSE_SOURCE: targetComposePath,
|
|
1046
|
-
},
|
|
1047
|
-
);
|
|
1048
|
-
|
|
1049
|
-
// The hostile oldTag never reaches the filename: the single backup stays
|
|
1050
|
-
// inside projectDir with a timestamp suffix, and nothing escapes to /tmp.
|
|
1051
|
-
const backups = listBackups(testDir);
|
|
1052
|
-
expect(backups.length).toBe(1);
|
|
1053
|
-
expect(backups[0]).toMatch(/^docker-compose\.yml\.bak-\d{4}-\d{2}-\d{2}T[\dZ-]+-[0-9a-f]{8}$/);
|
|
1054
|
-
expect(fs.existsSync("/tmp/evil")).toBe(false);
|
|
1055
|
-
}, { timeout: TEST_TIMEOUT });
|
|
1056
|
-
});
|