postgresai 0.15.0-rc.6 → 0.15.0-rc.8
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 +2 -2
- package/bin/postgres-ai.ts +242 -4
- package/dist/bin/postgres-ai.js +105 -6
- package/package.json +1 -1
- package/test/upgrade.test.ts +511 -0
package/README.md
CHANGED
|
@@ -10,9 +10,9 @@ Command-line interface for PostgresAI monitoring and database management.
|
|
|
10
10
|
npm install -g postgresai
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
For reproducible installs, pin the 0.15 release explicitly:
|
|
14
14
|
```bash
|
|
15
|
-
npm install -g postgresai@
|
|
15
|
+
npm install -g postgresai@0.15.0
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
Note: in this repository, `cli/package.json` uses a placeholder version (`0.0.0-dev.0`). The real published version is set by the git tag in CI when publishing to npm.
|
package/bin/postgres-ai.ts
CHANGED
|
@@ -590,6 +590,208 @@ async function ensureDefaultMonitoringProject(): Promise<PathResolution> {
|
|
|
590
590
|
return { fs, path, projectDir, composeFile, instancesFile };
|
|
591
591
|
}
|
|
592
592
|
|
|
593
|
+
/**
|
|
594
|
+
* Sanitize a PGAI_TAG value before it is interpolated into the compose backup
|
|
595
|
+
* filename (`docker-compose.yml.bak-<tag>-<hash8>`). The value flows straight
|
|
596
|
+
* into a filename via path.resolve, so it is quote-stripped and validated
|
|
597
|
+
* against a conservative charset: a malformed or hostile tag (e.g.
|
|
598
|
+
* `../../../tmp/x`, or one carrying a path separator) is rejected to null so it
|
|
599
|
+
* can never escape projectDir or otherwise poison the backup path. Callers fall
|
|
600
|
+
* back to a timestamp suffix when this returns null.
|
|
601
|
+
*
|
|
602
|
+
* Applied centrally to BOTH tag sources — the .env read (readDeployedTag) and
|
|
603
|
+
* the OLD tag passed in by callers that rewrite .env first (e.g. local-install)
|
|
604
|
+
* — so neither path can bypass the validation.
|
|
605
|
+
*/
|
|
606
|
+
function sanitizeTagForBackup(tag: string | null | undefined): string | null {
|
|
607
|
+
if (tag == null) return null;
|
|
608
|
+
const stripped = stripMatchingQuotes(tag);
|
|
609
|
+
return /^[A-Za-z0-9._-]{1,64}$/.test(stripped) ? stripped : null;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Read the deployed PGAI_TAG out of a project's .env (returns null if absent or
|
|
614
|
+
* if the value fails {@link sanitizeTagForBackup}). Used only to compute the
|
|
615
|
+
* compose backup file suffix; callers fall back to a timestamp when this is null.
|
|
616
|
+
*/
|
|
617
|
+
function readDeployedTag(projectDir: string): string | null {
|
|
618
|
+
const envFile = path.resolve(projectDir, ".env");
|
|
619
|
+
if (!fs.existsSync(envFile)) return null;
|
|
620
|
+
const m = fs.readFileSync(envFile, "utf8").match(/^PGAI_TAG=(.+)$/m);
|
|
621
|
+
return m ? sanitizeTagForBackup(m[1]) : null;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Validate that a freshly fetched payload is genuinely a postgres_ai
|
|
626
|
+
* docker-compose.yml, NOT an HTML login/captcha/proxy/maintenance page that a
|
|
627
|
+
* 200 response can still carry. `downloadText` only checks `response.ok`, so a
|
|
628
|
+
* non-compose 200 body would otherwise silently clobber a working compose with
|
|
629
|
+
* junk — strictly worse than keeping the stale-but-valid one.
|
|
630
|
+
*
|
|
631
|
+
* Requires:
|
|
632
|
+
* - non-empty, and not an obvious HTML document (<!DOCTYPE / <html ...);
|
|
633
|
+
* - parseable YAML resolving to an object;
|
|
634
|
+
* - a `services` map that contains the keystone `sink-prometheus` service
|
|
635
|
+
* (the very service whose VM_AUTH wiring this refresh exists to deliver).
|
|
636
|
+
*/
|
|
637
|
+
function isValidComposeYaml(text: string): boolean {
|
|
638
|
+
const trimmed = text.trim();
|
|
639
|
+
if (!trimmed) return false;
|
|
640
|
+
// Cheap early reject for HTML error/login/proxy pages served with a 200.
|
|
641
|
+
if (/^<(?:!doctype|html|\?xml)\b/i.test(trimmed)) return false;
|
|
642
|
+
|
|
643
|
+
let doc: unknown;
|
|
644
|
+
try {
|
|
645
|
+
doc = yaml.load(trimmed);
|
|
646
|
+
} catch {
|
|
647
|
+
return false;
|
|
648
|
+
}
|
|
649
|
+
if (!doc || typeof doc !== "object") return false;
|
|
650
|
+
const services = (doc as { services?: unknown }).services;
|
|
651
|
+
if (!services || typeof services !== "object") return false;
|
|
652
|
+
// The keystone service must be present — this is what carries the VM_AUTH_*
|
|
653
|
+
// wiring that the whole refresh exists to deliver.
|
|
654
|
+
return Object.prototype.hasOwnProperty.call(services, "sink-prometheus");
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Fetch the target docker-compose.yml for a given list of candidate refs.
|
|
659
|
+
*
|
|
660
|
+
* Test-only seam (PGAI_COMPOSE_SOURCE): when NODE_ENV === "test" AND the var is
|
|
661
|
+
* set to a local file path, the compose is read from disk instead of fetched
|
|
662
|
+
* over the network — keeping the refresh hermetically testable offline without
|
|
663
|
+
* exposing a local-file→compose injection surface in a normal user environment.
|
|
664
|
+
* The production path (GitLab raw fetch) is otherwise unchanged.
|
|
665
|
+
*
|
|
666
|
+
* Returns null if nothing could be obtained.
|
|
667
|
+
*/
|
|
668
|
+
async function fetchTargetCompose(refs: string[]): Promise<string | null> {
|
|
669
|
+
const localSource = process.env.PGAI_COMPOSE_SOURCE;
|
|
670
|
+
if (process.env.NODE_ENV === "test" && localSource && localSource.trim()) {
|
|
671
|
+
try {
|
|
672
|
+
return fs.readFileSync(localSource.trim(), "utf8");
|
|
673
|
+
} catch {
|
|
674
|
+
return null;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
for (const ref of refs) {
|
|
679
|
+
const url = `https://gitlab.com/postgres-ai/postgres_ai/-/raw/${encodeURIComponent(ref)}/docker-compose.yml`;
|
|
680
|
+
try {
|
|
681
|
+
return await downloadText(url);
|
|
682
|
+
} catch {
|
|
683
|
+
// try next ref
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
return null;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Compare two compose documents ignoring trailing-whitespace-only differences,
|
|
691
|
+
* so a lone trailing-newline delta doesn't trigger needless churn + a backup.
|
|
692
|
+
*/
|
|
693
|
+
function composeContentEqual(a: string, b: string): boolean {
|
|
694
|
+
return a.replace(/\s+$/, "") === b.replace(/\s+$/, "");
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Refresh the bundled, CLI-owned docker-compose.yml for NON-GIT installs when it
|
|
699
|
+
* is stale relative to the target stack version (the CLI's own pkg.version).
|
|
700
|
+
*
|
|
701
|
+
* Why this exists: docker-compose.yml is a version-coupled static asset. 0.15
|
|
702
|
+
* added VM basic auth, wiring VM_AUTH_* into the sink-prometheus (VictoriaMetrics)
|
|
703
|
+
* service and the Grafana datasource. `mon update` already refreshes the compose
|
|
704
|
+
* for git checkouts via `git pull`, and green-field installs fetch it once via
|
|
705
|
+
* ensureDefaultMonitoringProject(). But npx / global-npm upgrades are non-git and
|
|
706
|
+
* only advance PGAI_TAG — leaving the OLD compose in place. That mismatch crashes
|
|
707
|
+
* sink-prometheus (`missing "VM_AUTH_USERNAME" env var`) and blanks all dashboards.
|
|
708
|
+
*
|
|
709
|
+
* Contract:
|
|
710
|
+
* - No-op for git checkouts (.git present) — they refresh via `git pull`.
|
|
711
|
+
* - No-op when there is no deployed compose yet (bootstrap path handles it).
|
|
712
|
+
* - No-op when the deployed compose content already matches the target.
|
|
713
|
+
* - No-op (treated exactly like a fetch failure) when the fetched payload does
|
|
714
|
+
* not validate as a real compose — keep the existing compose, no backup,
|
|
715
|
+
* warn, return false. Prevents an HTML/proxy 200 body from clobbering a
|
|
716
|
+
* working compose with junk.
|
|
717
|
+
* - Backs up the prior compose before overwriting and NEVER overwrites an
|
|
718
|
+
* existing backup, so the first/pristine compose is always preserved across
|
|
719
|
+
* repeated runs (the backup name is uniquified by old-content hash).
|
|
720
|
+
* - Touches ONLY docker-compose.yml. Never .env / instances.yml / .pgwatch-config.
|
|
721
|
+
* - Best-effort: a fetch/validation failure warns and keeps the existing
|
|
722
|
+
* compose (the upgrade still proceeds) — we must not turn a metrics-only
|
|
723
|
+
* outage into a hard CLI failure.
|
|
724
|
+
*
|
|
725
|
+
* @param projectDir monitoring project directory.
|
|
726
|
+
* @param oldTag the PGAI_TAG of the *deployed* (pre-upgrade) compose, used to
|
|
727
|
+
* label the backup. Callers that rewrite .env's PGAI_TAG before this runs
|
|
728
|
+
* (e.g. local-install) MUST pass the captured OLD tag here so the backup
|
|
729
|
+
* reflects the OLD version. When omitted, it is read from the project's .env.
|
|
730
|
+
* @returns true if the compose was refreshed, false otherwise.
|
|
731
|
+
*/
|
|
732
|
+
async function refreshBundledComposeIfStale(projectDir: string, oldTag?: string | null): Promise<boolean> {
|
|
733
|
+
// Git checkouts manage docker-compose.yml via the repo itself (`git pull`).
|
|
734
|
+
if (fs.existsSync(path.resolve(projectDir, ".git"))) return false;
|
|
735
|
+
|
|
736
|
+
const composeFile = path.resolve(projectDir, "docker-compose.yml");
|
|
737
|
+
// Nothing deployed yet -> the green-field bootstrap path handles fetching it.
|
|
738
|
+
if (!fs.existsSync(composeFile)) return false;
|
|
739
|
+
|
|
740
|
+
const refs = [
|
|
741
|
+
process.env.PGAI_PROJECT_REF,
|
|
742
|
+
pkg.version,
|
|
743
|
+
`v${pkg.version}`,
|
|
744
|
+
].filter((v): v is string => Boolean(v && v.trim()));
|
|
745
|
+
|
|
746
|
+
const fetched = await fetchTargetCompose(refs);
|
|
747
|
+
// Validate BEFORE doing anything destructive: an empty body, a fetch failure,
|
|
748
|
+
// or a non-compose 200 (HTML login/proxy/maintenance page) are all treated
|
|
749
|
+
// identically — keep the existing compose, write no backup, warn, no-op.
|
|
750
|
+
if (!fetched || !isValidComposeYaml(fetched)) {
|
|
751
|
+
console.error(`⚠ Could not refresh docker-compose.yml to ${pkg.version} (no valid compose was retrieved).`);
|
|
752
|
+
console.error(" Keeping the existing compose. If dashboards are blank after upgrade, re-run this command once network is available.");
|
|
753
|
+
return false;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Compare on-disk CONTENT against the freshly fetched target (whitespace-only
|
|
757
|
+
// diffs ignored). This is correct regardless of when PGAI_TAG was rewritten in
|
|
758
|
+
// .env (local-install rewrites it before this runs), so we never rely on a
|
|
759
|
+
// possibly-stale tag heuristic.
|
|
760
|
+
const existing = fs.readFileSync(composeFile, "utf8");
|
|
761
|
+
if (composeContentEqual(existing, fetched)) return false; // already current
|
|
762
|
+
|
|
763
|
+
// Label the backup with the OLD (deployed) tag. Callers that already rewrote
|
|
764
|
+
// .env pass it in (raw); otherwise read it from .env. Sanitize centrally so the
|
|
765
|
+
// caller-supplied oldTag (e.g. local-install's previousTag) cannot bypass the
|
|
766
|
+
// filename validation — a hostile/malformed tag falls back to the timestamp.
|
|
767
|
+
const deployedTag = sanitizeTagForBackup(oldTag ?? readDeployedTag(projectDir));
|
|
768
|
+
const tagPart = deployedTag ?? new Date().toISOString().replace(/[:.]/g, "-");
|
|
769
|
+
// Uniquify with a short hash of the OLD content so repeated runs (e.g.
|
|
770
|
+
// update-config, where PGAI_TAG never advances) cannot overwrite the first,
|
|
771
|
+
// pristine backup. Always preserve the original compose.
|
|
772
|
+
const oldHash = crypto.createHash("sha256").update(existing).digest("hex").slice(0, 8);
|
|
773
|
+
const backup = path.resolve(projectDir, `docker-compose.yml.bak-${tagPart}-${oldHash}`);
|
|
774
|
+
let backupName: string | null = null;
|
|
775
|
+
try {
|
|
776
|
+
// "wx" => fail if the backup already exists, so we never clobber a prior
|
|
777
|
+
// backup. If this exact old content was already backed up, that's fine —
|
|
778
|
+
// the pristine copy is already on disk under the same name.
|
|
779
|
+
fs.writeFileSync(backup, existing, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
780
|
+
backupName = path.basename(backup);
|
|
781
|
+
} catch (err) {
|
|
782
|
+
const e = err as NodeJS.ErrnoException;
|
|
783
|
+
if (e && e.code === "EEXIST") {
|
|
784
|
+
// Identical old content already backed up under this name — keep it.
|
|
785
|
+
backupName = path.basename(backup);
|
|
786
|
+
}
|
|
787
|
+
// Any other error: non-fatal, proceed with the refresh even without a backup.
|
|
788
|
+
}
|
|
789
|
+
fs.writeFileSync(composeFile, fetched, { encoding: "utf8", mode: 0o600 });
|
|
790
|
+
const backupNote = backupName ? ` (backup: ${backupName})` : "";
|
|
791
|
+
console.log(`✓ Refreshed docker-compose.yml to ${pkg.version}${backupNote}`);
|
|
792
|
+
return true;
|
|
793
|
+
}
|
|
794
|
+
|
|
593
795
|
/**
|
|
594
796
|
* Get configuration from various sources
|
|
595
797
|
* @param opts - Command line options
|
|
@@ -2427,10 +2629,15 @@ mon
|
|
|
2427
2629
|
let existingReplicatorPassword: string | null = null;
|
|
2428
2630
|
let existingVmAuthUsername: string | null = null;
|
|
2429
2631
|
let existingVmAuthPassword: string | null = null;
|
|
2632
|
+
// Capture the OLD (deployed) tag BEFORE we rewrite PGAI_TAG below, so the
|
|
2633
|
+
// compose-refresh backup is labeled with the version being upgraded FROM.
|
|
2634
|
+
let previousTag: string | null = null;
|
|
2430
2635
|
|
|
2431
2636
|
if (fs.existsSync(envFile)) {
|
|
2432
2637
|
const existingEnv = fs.readFileSync(envFile, "utf8");
|
|
2433
2638
|
// Extract existing values (except tag - always use CLI version)
|
|
2639
|
+
const previousTagMatch = existingEnv.match(/^PGAI_TAG=(.+)$/m);
|
|
2640
|
+
if (previousTagMatch) previousTag = previousTagMatch[1].trim();
|
|
2434
2641
|
const registryMatch = existingEnv.match(/^PGAI_REGISTRY=(.+)$/m);
|
|
2435
2642
|
if (registryMatch) existingRegistry = registryMatch[1].trim();
|
|
2436
2643
|
const pwdMatch = existingEnv.match(/^GF_SECURITY_ADMIN_PASSWORD=(.+)$/m);
|
|
@@ -2463,6 +2670,14 @@ mon
|
|
|
2463
2670
|
envLines.push(`VM_AUTH_PASSWORD=${existingVmAuthPassword || crypto.randomBytes(18).toString("base64")}`);
|
|
2464
2671
|
fs.writeFileSync(envFile, envLines.join("\n") + "\n", { encoding: "utf8", mode: 0o600 });
|
|
2465
2672
|
|
|
2673
|
+
// Non-git upgrade safety: bring the CLI-owned compose up to the target version
|
|
2674
|
+
// so newly-required service wiring (e.g. VM_AUTH_* on sink-prometheus) is present.
|
|
2675
|
+
// Compares deployed compose CONTENT against the target, so it's correct even
|
|
2676
|
+
// though PGAI_TAG was just rewritten above. No-op for git checkouts / when current.
|
|
2677
|
+
// Pass the OLD tag (captured before the .env rewrite) so the backup is labeled
|
|
2678
|
+
// with the version we're upgrading FROM, not the new one.
|
|
2679
|
+
await refreshBundledComposeIfStale(projectDir, previousTag);
|
|
2680
|
+
|
|
2466
2681
|
if (opts.tag) {
|
|
2467
2682
|
console.log(`Using image tag: ${imageTag}\n`);
|
|
2468
2683
|
}
|
|
@@ -3069,6 +3284,12 @@ mon
|
|
|
3069
3284
|
console.log("(existing values were preserved; missing keys filled with safe defaults)\n");
|
|
3070
3285
|
}
|
|
3071
3286
|
|
|
3287
|
+
// Non-git installs: refresh the CLI-owned compose so it matches the target
|
|
3288
|
+
// stack version. Otherwise newly-required service wiring (e.g. VM_AUTH_* on
|
|
3289
|
+
// sink-prometheus, added in 0.15) is missing and VictoriaMetrics crashes.
|
|
3290
|
+
// No-op for git checkouts and when the compose already matches.
|
|
3291
|
+
await refreshBundledComposeIfStale(projectDir);
|
|
3292
|
+
|
|
3072
3293
|
const code = await runCompose(["run", "--rm", "sources-generator"]);
|
|
3073
3294
|
if (code !== 0) process.exitCode = code;
|
|
3074
3295
|
});
|
|
@@ -3120,7 +3341,14 @@ mon
|
|
|
3120
3341
|
const { stdout: pullOut } = await execFilePromise("git", ["pull", "origin", currentBranch]);
|
|
3121
3342
|
console.log(pullOut);
|
|
3122
3343
|
} else {
|
|
3123
|
-
|
|
3344
|
+
// npx / global-npm installs are non-git: `git pull` can't refresh the
|
|
3345
|
+
// compose, so bring the CLI-owned docker-compose.yml up to the target
|
|
3346
|
+
// version in place (with a backup). This is what wires VM_AUTH_* into
|
|
3347
|
+
// sink-prometheus for users upgrading from a pre-0.15 (no-VM-auth) stack.
|
|
3348
|
+
// The helper logs only when it actually refreshes/warns, so don't
|
|
3349
|
+
// pre-announce a refresh that may turn out to be a no-op.
|
|
3350
|
+
console.log("(not a git checkout — checking bundled docker-compose.yml)");
|
|
3351
|
+
await refreshBundledComposeIfStale(projectDir);
|
|
3124
3352
|
}
|
|
3125
3353
|
|
|
3126
3354
|
// Step 3: pull new images.
|
|
@@ -5068,6 +5296,16 @@ mcp
|
|
|
5068
5296
|
}
|
|
5069
5297
|
});
|
|
5070
5298
|
|
|
5071
|
-
|
|
5072
|
-
|
|
5073
|
-
|
|
5299
|
+
// Only parse argv when run as the CLI entrypoint (npx / `bun postgres-ai.ts`).
|
|
5300
|
+
// When the module is imported (e.g. unit tests exercising the exported helpers),
|
|
5301
|
+
// skip the auto-parse so importing doesn't kick off the whole command tree.
|
|
5302
|
+
// `import.meta.main` is honored both under bun and in the node-targeted build.
|
|
5303
|
+
if (import.meta.main) {
|
|
5304
|
+
program.parseAsync(process.argv).finally(() => {
|
|
5305
|
+
closeReadline();
|
|
5306
|
+
});
|
|
5307
|
+
}
|
|
5308
|
+
|
|
5309
|
+
// Exported for unit tests (the CLI surface above is unaffected; these are the
|
|
5310
|
+
// same functions used by the `mon` commands).
|
|
5311
|
+
export { refreshBundledComposeIfStale, readDeployedTag, isValidComposeYaml };
|
package/dist/bin/postgres-ai.js
CHANGED
|
@@ -13423,7 +13423,7 @@ var {
|
|
|
13423
13423
|
// package.json
|
|
13424
13424
|
var package_default = {
|
|
13425
13425
|
name: "postgresai",
|
|
13426
|
-
version: "0.15.0-rc.
|
|
13426
|
+
version: "0.15.0-rc.8",
|
|
13427
13427
|
description: "postgres_ai CLI",
|
|
13428
13428
|
license: "Apache-2.0",
|
|
13429
13429
|
private: false,
|
|
@@ -16254,7 +16254,7 @@ var Result = import_lib.default.Result;
|
|
|
16254
16254
|
var TypeOverrides = import_lib.default.TypeOverrides;
|
|
16255
16255
|
var defaults = import_lib.default.defaults;
|
|
16256
16256
|
// package.json
|
|
16257
|
-
var version = "0.15.0-rc.
|
|
16257
|
+
var version = "0.15.0-rc.8";
|
|
16258
16258
|
var package_default2 = {
|
|
16259
16259
|
name: "postgresai",
|
|
16260
16260
|
version,
|
|
@@ -33887,6 +33887,91 @@ async function ensureDefaultMonitoringProject() {
|
|
|
33887
33887
|
}
|
|
33888
33888
|
return { fs: fs8, path: path7, projectDir, composeFile, instancesFile };
|
|
33889
33889
|
}
|
|
33890
|
+
function sanitizeTagForBackup(tag) {
|
|
33891
|
+
if (tag == null)
|
|
33892
|
+
return null;
|
|
33893
|
+
const stripped = stripMatchingQuotes(tag);
|
|
33894
|
+
return /^[A-Za-z0-9._-]{1,64}$/.test(stripped) ? stripped : null;
|
|
33895
|
+
}
|
|
33896
|
+
function readDeployedTag(projectDir) {
|
|
33897
|
+
const envFile = path7.resolve(projectDir, ".env");
|
|
33898
|
+
if (!fs8.existsSync(envFile))
|
|
33899
|
+
return null;
|
|
33900
|
+
const m = fs8.readFileSync(envFile, "utf8").match(/^PGAI_TAG=(.+)$/m);
|
|
33901
|
+
return m ? sanitizeTagForBackup(m[1]) : null;
|
|
33902
|
+
}
|
|
33903
|
+
function isValidComposeYaml(text) {
|
|
33904
|
+
const trimmed = text.trim();
|
|
33905
|
+
if (!trimmed)
|
|
33906
|
+
return false;
|
|
33907
|
+
if (/^<(?:!doctype|html|\?xml)\b/i.test(trimmed))
|
|
33908
|
+
return false;
|
|
33909
|
+
let doc2;
|
|
33910
|
+
try {
|
|
33911
|
+
doc2 = load(trimmed);
|
|
33912
|
+
} catch {
|
|
33913
|
+
return false;
|
|
33914
|
+
}
|
|
33915
|
+
if (!doc2 || typeof doc2 !== "object")
|
|
33916
|
+
return false;
|
|
33917
|
+
const services = doc2.services;
|
|
33918
|
+
if (!services || typeof services !== "object")
|
|
33919
|
+
return false;
|
|
33920
|
+
return Object.prototype.hasOwnProperty.call(services, "sink-prometheus");
|
|
33921
|
+
}
|
|
33922
|
+
async function fetchTargetCompose(refs) {
|
|
33923
|
+
const localSource = process.env.PGAI_COMPOSE_SOURCE;
|
|
33924
|
+
if (false) {}
|
|
33925
|
+
for (const ref of refs) {
|
|
33926
|
+
const url = `https://gitlab.com/postgres-ai/postgres_ai/-/raw/${encodeURIComponent(ref)}/docker-compose.yml`;
|
|
33927
|
+
try {
|
|
33928
|
+
return await downloadText(url);
|
|
33929
|
+
} catch {}
|
|
33930
|
+
}
|
|
33931
|
+
return null;
|
|
33932
|
+
}
|
|
33933
|
+
function composeContentEqual(a, b) {
|
|
33934
|
+
return a.replace(/\s+$/, "") === b.replace(/\s+$/, "");
|
|
33935
|
+
}
|
|
33936
|
+
async function refreshBundledComposeIfStale(projectDir, oldTag) {
|
|
33937
|
+
if (fs8.existsSync(path7.resolve(projectDir, ".git")))
|
|
33938
|
+
return false;
|
|
33939
|
+
const composeFile = path7.resolve(projectDir, "docker-compose.yml");
|
|
33940
|
+
if (!fs8.existsSync(composeFile))
|
|
33941
|
+
return false;
|
|
33942
|
+
const refs = [
|
|
33943
|
+
process.env.PGAI_PROJECT_REF,
|
|
33944
|
+
package_default.version,
|
|
33945
|
+
`v${package_default.version}`
|
|
33946
|
+
].filter((v) => Boolean(v && v.trim()));
|
|
33947
|
+
const fetched = await fetchTargetCompose(refs);
|
|
33948
|
+
if (!fetched || !isValidComposeYaml(fetched)) {
|
|
33949
|
+
console.error(`\u26A0 Could not refresh docker-compose.yml to ${package_default.version} (no valid compose was retrieved).`);
|
|
33950
|
+
console.error(" Keeping the existing compose. If dashboards are blank after upgrade, re-run this command once network is available.");
|
|
33951
|
+
return false;
|
|
33952
|
+
}
|
|
33953
|
+
const existing = fs8.readFileSync(composeFile, "utf8");
|
|
33954
|
+
if (composeContentEqual(existing, fetched))
|
|
33955
|
+
return false;
|
|
33956
|
+
const deployedTag = sanitizeTagForBackup(oldTag ?? readDeployedTag(projectDir));
|
|
33957
|
+
const tagPart = deployedTag ?? new Date().toISOString().replace(/[:.]/g, "-");
|
|
33958
|
+
const oldHash = crypto2.createHash("sha256").update(existing).digest("hex").slice(0, 8);
|
|
33959
|
+
const backup = path7.resolve(projectDir, `docker-compose.yml.bak-${tagPart}-${oldHash}`);
|
|
33960
|
+
let backupName = null;
|
|
33961
|
+
try {
|
|
33962
|
+
fs8.writeFileSync(backup, existing, { encoding: "utf8", mode: 384, flag: "wx" });
|
|
33963
|
+
backupName = path7.basename(backup);
|
|
33964
|
+
} catch (err) {
|
|
33965
|
+
const e = err;
|
|
33966
|
+
if (e && e.code === "EEXIST") {
|
|
33967
|
+
backupName = path7.basename(backup);
|
|
33968
|
+
}
|
|
33969
|
+
}
|
|
33970
|
+
fs8.writeFileSync(composeFile, fetched, { encoding: "utf8", mode: 384 });
|
|
33971
|
+
const backupNote = backupName ? ` (backup: ${backupName})` : "";
|
|
33972
|
+
console.log(`\u2713 Refreshed docker-compose.yml to ${package_default.version}${backupNote}`);
|
|
33973
|
+
return true;
|
|
33974
|
+
}
|
|
33890
33975
|
function getConfig(opts) {
|
|
33891
33976
|
let apiKey = opts.apiKey || process.env.PGAI_API_KEY || "";
|
|
33892
33977
|
if (!apiKey) {
|
|
@@ -35329,8 +35414,12 @@ mon.command("local-install").description("install local monitoring stack (genera
|
|
|
35329
35414
|
let existingReplicatorPassword = null;
|
|
35330
35415
|
let existingVmAuthUsername = null;
|
|
35331
35416
|
let existingVmAuthPassword = null;
|
|
35417
|
+
let previousTag = null;
|
|
35332
35418
|
if (fs8.existsSync(envFile)) {
|
|
35333
35419
|
const existingEnv = fs8.readFileSync(envFile, "utf8");
|
|
35420
|
+
const previousTagMatch = existingEnv.match(/^PGAI_TAG=(.+)$/m);
|
|
35421
|
+
if (previousTagMatch)
|
|
35422
|
+
previousTag = previousTagMatch[1].trim();
|
|
35334
35423
|
const registryMatch = existingEnv.match(/^PGAI_REGISTRY=(.+)$/m);
|
|
35335
35424
|
if (registryMatch)
|
|
35336
35425
|
existingRegistry = registryMatch[1].trim();
|
|
@@ -35361,6 +35450,7 @@ mon.command("local-install").description("install local monitoring stack (genera
|
|
|
35361
35450
|
fs8.writeFileSync(envFile, envLines.join(`
|
|
35362
35451
|
`) + `
|
|
35363
35452
|
`, { encoding: "utf8", mode: 384 });
|
|
35453
|
+
await refreshBundledComposeIfStale(projectDir, previousTag);
|
|
35364
35454
|
if (opts.tag) {
|
|
35365
35455
|
console.log(`Using image tag: ${imageTag}
|
|
35366
35456
|
`);
|
|
@@ -35878,6 +35968,7 @@ mon.command("update-config").description("apply monitoring services configuratio
|
|
|
35878
35968
|
console.log(`(existing values were preserved; missing keys filled with safe defaults)
|
|
35879
35969
|
`);
|
|
35880
35970
|
}
|
|
35971
|
+
await refreshBundledComposeIfStale(projectDir);
|
|
35881
35972
|
const code = await runCompose(["run", "--rm", "sources-generator"]);
|
|
35882
35973
|
if (code !== 0)
|
|
35883
35974
|
process.exitCode = code;
|
|
@@ -35915,7 +36006,8 @@ mon.command("update").description("update monitoring stack (migrate .env, pull i
|
|
|
35915
36006
|
const { stdout: pullOut } = await execFilePromise("git", ["pull", "origin", currentBranch]);
|
|
35916
36007
|
console.log(pullOut);
|
|
35917
36008
|
} else {
|
|
35918
|
-
console.log("(not a git checkout \u2014
|
|
36009
|
+
console.log("(not a git checkout \u2014 checking bundled docker-compose.yml)");
|
|
36010
|
+
await refreshBundledComposeIfStale(projectDir);
|
|
35919
36011
|
}
|
|
35920
36012
|
console.log(`
|
|
35921
36013
|
Updating Docker images...`);
|
|
@@ -37417,6 +37509,13 @@ mcp.command("install [client]").description("install MCP server configuration fo
|
|
|
37417
37509
|
process.exitCode = 1;
|
|
37418
37510
|
}
|
|
37419
37511
|
});
|
|
37420
|
-
|
|
37421
|
-
|
|
37422
|
-
|
|
37512
|
+
if (__require.main == __require.module) {
|
|
37513
|
+
program2.parseAsync(process.argv).finally(() => {
|
|
37514
|
+
closeReadline();
|
|
37515
|
+
});
|
|
37516
|
+
}
|
|
37517
|
+
export {
|
|
37518
|
+
refreshBundledComposeIfStale,
|
|
37519
|
+
readDeployedTag,
|
|
37520
|
+
isValidComposeYaml
|
|
37521
|
+
};
|
package/package.json
CHANGED
package/test/upgrade.test.ts
CHANGED
|
@@ -543,3 +543,514 @@ describe("in-place upgrade env migration (mon update / update-config)", () => {
|
|
|
543
543
|
expect(envContent).not.toMatch(/PGAI_TAG=0\.14\.0VM_AUTH_USERNAME/);
|
|
544
544
|
}, { timeout: TEST_TIMEOUT });
|
|
545
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
|
+
});
|