morphit/apps/ops-cli/scripts/upgrade-frontend-deploy-smoke.ts

484 lines
20 KiB
TypeScript

/**
* upgrade-frontend-deploy-smoke (cp211; publish logic reworked beta11).
*
* `morphit-ops upgrade` rebuilds + redeploys the static web frontend (the
* Node services run from TS source via tsx, so only the SvelteKit `vite
* build` output needs rebuilding + publishing). This smoke covers:
*
* - resolveWebRoot: MORPHIT_WEB_ROOT override + the /var/www/morphit-frontend
* default (matching docs/RUN-A-MORPHIT-NODE.md §5).
* - deployFrontendBuild: a REAL filesystem round-trip against temp dirs —
* fresh deploy lands index.html + nested assets; an overwrite updates
* index.html while leaving unrelated existing files; a missing build or a
* build with no index.html throws (so the caller rolls back rather than
* leaving a wrecked site live).
* - planFrontendDeploy: the publish decision matrix from two signals (web
* root exists; a container bind-mounts the build dir).
* - the bind-mount detection helpers (normalizeMountPath, parseMountSources,
* containerMountsBuildDir) that identify the frontend container by the
* apps/web/build mount it carries — NOT by a container name or compose
* file. beta11 replaces cp236, whose `morphit-frontend`-name +
* repo-example-compose assumptions broke on real deployments (a compose
* project names the container `<project>-frontend-1`, e.g.
* `bunkerweb-frontend-1`, and recreating it from the repo's example
* compose crash-looped on a cert path the operator's real stack didn't
* share). Restarting the exact container we detect — by its mount — fixes
* both.
*
* Plus structural wiring assertions on upgrade.ts: the run flow actually
* builds apps/web, calls deployFrontendBuild, honors MORPHIT_WEB_ROOT, restores
* the previous frontend on rollback, detects the frontend container by mount,
* restarts it, and tells the operator (in the y/N prompt) that the frontend
* will be redeployed. (The full upgrade flow needs a real release + systemd +
* docker, exercised on the operator's box; these are the portable guards
* against the deploy logic regressing.)
*/
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
resolveWebRoot,
deployFrontendBuild,
planFrontendDeploy,
normalizeMountPath,
parseMountSources,
containerMountsBuildDir,
parseVerifyJsonVersion,
classifyFrontendVerify
} from '../src/commands/upgrade.ts';
let pass = 0;
let fail = 0;
const ok = (m: string) => {
pass++;
console.log(` \u2713 ${m}`);
};
const bad = (m: string, d = '') => {
fail++;
console.log(` \u2717 ${m}`);
if (d) console.log(` ${d}`);
};
// ─── FD-1/2: resolveWebRoot ─────────────────────────────────────────
{
const def = resolveWebRoot({});
if (def === '/var/www/morphit-frontend') {
ok('FD-1 resolveWebRoot default is /var/www/morphit-frontend');
} else {
bad('FD-1', `default was ${def}`);
}
const override = resolveWebRoot({ MORPHIT_WEB_ROOT: '/srv/site' });
const trimmed = resolveWebRoot({ MORPHIT_WEB_ROOT: ' /srv/site ' });
const empty = resolveWebRoot({ MORPHIT_WEB_ROOT: ' ' });
if (override === '/srv/site' && trimmed === '/srv/site' && empty === '/var/www/morphit-frontend') {
ok('FD-2 resolveWebRoot honors override, trims whitespace, falls back on empty');
} else {
bad('FD-2', `override=${override} trimmed=${trimmed} empty=${empty}`);
}
}
// ─── FD-3/4/5/6: deployFrontendBuild filesystem round-trip ──────────
function tmp(prefix: string): string {
return mkdtempSync(join(tmpdir(), prefix));
}
// FD-3 fresh deploy: build → empty web root.
{
const build = tmp('mfd-build-');
const web = tmp('mfd-web-');
mkdirSync(join(build, 'sub'), { recursive: true });
writeFileSync(join(build, 'index.html'), '<!doctype html>NEW');
writeFileSync(join(build, 'sub', 'app.js'), 'console.log(1)');
try {
deployFrontendBuild(build, web);
const idx = existsSync(join(web, 'index.html'))
? readFileSync(join(web, 'index.html'), 'utf8')
: '';
const asset = existsSync(join(web, 'sub', 'app.js'));
if (idx === '<!doctype html>NEW' && asset) {
ok('FD-3 fresh deploy copies index.html + nested asset into the web root');
} else {
bad('FD-3', `index="${idx}" assetPresent=${asset}`);
}
} catch (e) {
bad('FD-3 threw unexpectedly', String(e));
} finally {
rmSync(build, { recursive: true, force: true });
rmSync(web, { recursive: true, force: true });
}
}
// FD-4 overwrite: web root already has an OLD index.html + an unrelated file.
{
const build = tmp('mfd-build-');
const web = tmp('mfd-web-');
writeFileSync(join(build, 'index.html'), 'NEW-BUILD');
writeFileSync(join(web, 'index.html'), 'OLD-BUILD');
writeFileSync(join(web, 'robots.txt'), 'User-agent: *'); // operator-placed, unrelated
try {
deployFrontendBuild(build, web);
const idx = readFileSync(join(web, 'index.html'), 'utf8');
const robotsKept = existsSync(join(web, 'robots.txt'));
if (idx === 'NEW-BUILD' && robotsKept) {
ok('FD-4 overwrite updates index.html and leaves unrelated existing files in place');
} else {
bad('FD-4', `index="${idx}" robotsKept=${robotsKept}`);
}
} catch (e) {
bad('FD-4 threw unexpectedly', String(e));
} finally {
rmSync(build, { recursive: true, force: true });
rmSync(web, { recursive: true, force: true });
}
}
// FD-5 missing build dir → throw.
{
const web = tmp('mfd-web-');
try {
deployFrontendBuild(join(tmpdir(), 'mfd-does-not-exist-' + Date.now()), web);
bad('FD-5', 'expected a throw for a missing build dir');
} catch {
ok('FD-5 throws when the build dir is missing (caller rolls back)');
} finally {
rmSync(web, { recursive: true, force: true });
}
}
// FD-6 build present but no index.html → throw.
{
const build = tmp('mfd-build-');
const web = tmp('mfd-web-');
writeFileSync(join(build, 'app.js'), 'x'); // assets but NO index.html
try {
deployFrontendBuild(build, web);
bad('FD-6', 'expected a throw when the build has no index.html');
} catch {
ok('FD-6 throws when the build produced no index.html (caller rolls back)');
} finally {
rmSync(build, { recursive: true, force: true });
rmSync(web, { recursive: true, force: true });
}
}
// ─── FD-9/10/11/12: planFrontendDeploy decision matrix (beta11) ─────
// The web build now ALWAYS runs; planFrontendDeploy decides how to PUBLISH
// it from two signals: bare-metal web root present, and the NAME of the
// container bind-mounting the build dir (or null). Exhaust the four cases.
{
const WR = '/var/www/morphit-frontend';
const BD = '/opt/morphit/apps/web/build';
// FD-9 bare-metal: web root exists, no container → copy only.
const bare = planFrontendDeploy({
webRootExists: true,
frontendContainer: null,
webRoot: WR,
buildDir: BD
});
if (bare.copyToWebRoot && bare.restartContainer === null && bare.warn === null) {
ok('FD-9 bare-metal (web root, no container) → copy to web root, no warning');
} else {
bad('FD-9', JSON.stringify(bare));
}
// FD-10 containerized: no web root, container present → restart that
// container only, no warn. This is the regression case: pre-cp236 the
// frontend was silently skipped here. The container name is whatever
// `docker ps` reported — here a compose-style `bunkerweb-frontend-1`,
// which cp236's hardcoded `morphit-frontend` filter would have MISSED.
const bw = planFrontendDeploy({
webRootExists: false,
frontendContainer: 'bunkerweb-frontend-1',
webRoot: WR,
buildDir: BD
});
if (
!bw.copyToWebRoot &&
bw.restartContainer === 'bunkerweb-frontend-1' &&
bw.warn === null
) {
ok('FD-10 containerized (compose-named container, no web root) → restart that container, no warning');
} else {
bad('FD-10 (frontend would be silently skipped / wrong container!)', JSON.stringify(bw));
}
// FD-11 both present → do both, no warning. (Container named the
// canonical `morphit-frontend` here — also detected purely by mount.)
const both = planFrontendDeploy({
webRootExists: true,
frontendContainer: 'morphit-frontend',
webRoot: WR,
buildDir: BD
});
if (both.copyToWebRoot && both.restartContainer === 'morphit-frontend' && both.warn === null) {
ok('FD-11 both targets → copy AND restart container, no warning');
} else {
bad('FD-11', JSON.stringify(both));
}
// FD-12 neither → no publish action, and a warning that names the build dir.
const neither = planFrontendDeploy({
webRootExists: false,
frontendContainer: null,
webRoot: WR,
buildDir: BD
});
if (
!neither.copyToWebRoot &&
neither.restartContainer === null &&
neither.warn !== null &&
neither.warn.includes(BD)
) {
ok('FD-12 neither target → no publish, warns with the build dir path');
} else {
bad('FD-12', JSON.stringify(neither));
}
}
// ─── FD-16/17/18/19: bind-mount detection helpers (beta11) ──────────
// These are the robust, name-agnostic signal that a container serves OUR
// frontend build: it bind-mounts <install>/apps/web/build. cp236 matched a
// hardcoded container name instead and broke on real deployments.
{
// FD-16 normalizeMountPath: strip a single trailing slash, keep bare "/",
// trim surrounding whitespace.
const n1 = normalizeMountPath('/opt/morphit/apps/web/build/');
const n2 = normalizeMountPath('/opt/morphit/apps/web/build');
const n3 = normalizeMountPath(' /opt/morphit/apps/web/build ');
const n4 = normalizeMountPath('/');
if (
n1 === '/opt/morphit/apps/web/build' &&
n2 === '/opt/morphit/apps/web/build' &&
n3 === '/opt/morphit/apps/web/build' &&
n4 === '/'
) {
ok('FD-16 normalizeMountPath strips a trailing slash, trims, keeps bare "/"');
} else {
bad('FD-16', `n1=${n1} n2=${n2} n3=${n3} n4=${n4}`);
}
// FD-17 parseMountSources: split the `docker inspect --format` newline
// list, trim each, drop blank lines (the template emits a trailing \n).
const sources = parseMountSources(
'/etc/letsencrypt\n/opt/morphit/apps/web/build\n\n /var/log/nginx \n'
);
if (
sources.length === 3 &&
sources[0] === '/etc/letsencrypt' &&
sources[1] === '/opt/morphit/apps/web/build' &&
sources[2] === '/var/log/nginx'
) {
ok('FD-17 parseMountSources splits, trims, and drops blank lines');
} else {
bad('FD-17', JSON.stringify(sources));
}
// FD-18 containerMountsBuildDir: exact match (with trailing-slash
// normalization on both sides) hits; a parent, a sibling, and an
// unrelated mount all miss (no false positives from prefix matching).
const BD = '/opt/morphit/apps/web/build';
const hit = containerMountsBuildDir(['/etc/letsencrypt', '/opt/morphit/apps/web/build/'], BD);
const parentMiss = containerMountsBuildDir(['/opt/morphit/apps/web'], BD);
const siblingMiss = containerMountsBuildDir(['/opt/morphit/apps/web/build-old'], BD);
const unrelatedMiss = containerMountsBuildDir(['/etc/letsencrypt', '/var/log/nginx'], BD);
const emptyMiss = containerMountsBuildDir([], BD);
if (hit && !parentMiss && !siblingMiss && !unrelatedMiss && !emptyMiss) {
ok('FD-18 containerMountsBuildDir matches the exact build dir only (no parent/sibling/prefix false positives)');
} else {
bad(
'FD-18',
`hit=${hit} parentMiss=${parentMiss} siblingMiss=${siblingMiss} unrelatedMiss=${unrelatedMiss} emptyMiss=${emptyMiss}`
);
}
// FD-19 name-agnostic detection proof: a container whose inspect output
// lists the build dir among OTHER mounts is detected regardless of its
// name; a container mounting only a sibling dir is not. This is exactly
// what lets us find `bunkerweb-frontend-1` (or any name) by its mount.
const realStackInspect =
'/etc/letsencrypt/live/morphit.io\n/opt/morphit/apps/web/build\n/var/cache/bunkerweb\n';
const decoyInspect = '/opt/morphit/apps/web/build-staging\n/var/log\n';
const detected = containerMountsBuildDir(parseMountSources(realStackInspect), BD);
const notDetected = containerMountsBuildDir(parseMountSources(decoyInspect), BD);
if (detected && !notDetected) {
ok('FD-19 a container is detected by its apps/web/build mount among others, name-agnostically (and a decoy mount is not)');
} else {
bad('FD-19', `detected=${detected} notDetected=${notDetected}`);
}
}
// ─── FD-7/8/13/14/20: structural wiring assertions on upgrade.ts ────
{
const upgradeSrc = readFileSync(
join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'commands', 'upgrade.ts'),
'utf8'
);
const checks: Array<{ id: string; re: RegExp; desc: string }> = [
{
id: 'FD-7a',
re: /resolveWebRoot\(process\.env\)/,
desc: 'runUpgrade resolves the web root via resolveWebRoot(process.env)'
},
{
id: 'FD-7b',
re: /runOrThrow\(\s*'npm',\s*\['run',\s*'build'\][\s\S]*?'apps',\s*'web'/,
desc: 'runUpgrade builds apps/web (npm run build, cwd apps/web)'
},
{
id: 'FD-7c',
re: /deployFrontendBuild\(\s*buildDir,\s*webRoot\s*\)/,
desc: 'runUpgrade calls deployFrontendBuild(buildDir, webRoot)'
},
{
id: 'FD-7d',
re: /cpSync\(web\.webRootBackup, web\.webRoot/,
desc: 'rollback restores the previous web frontend from its backup'
},
{
id: 'FD-8',
re: /redeploy the web frontend/,
desc: 'the confirmation prompt tells the operator the frontend will be redeployed'
},
{
id: 'FD-13',
re: /planFrontendDeploy\(\{[\s\S]*?frontendContainer:/,
desc: 'runUpgrade decides how to publish via planFrontendDeploy({ frontendContainer, ... })'
},
{
id: 'FD-14a',
re: /findFrontendContainer\(buildDir\)/,
desc: 'runUpgrade detects the frontend container by the build-dir mount (findFrontendContainer)'
},
{
id: 'FD-14b',
re: /restartFrontendContainer\(plan\.restartContainer\)/,
desc: 'runUpgrade restarts the detected container (restartFrontendContainer)'
},
{
id: 'FD-14c',
re: /docker['"\],\s]+inspect[\s\S]*?\.Mounts[\s\S]*?\.Source/,
desc: 'findFrontendContainer inspects container .Mounts .Source'
}
];
for (const c of checks) {
if (c.re.test(upgradeSrc)) {
ok(`${c.id} ${c.desc}`);
} else {
bad(c.id, `wiring missing: ${c.desc}`);
}
}
// FD-20 (beta11 regression guard): cp236's name/compose-based approach is
// fully GONE — no `recreateBunkerwebFrontend`, no `bunkerwebFrontendPresent`,
// no hardcoded `name=^/morphit-frontend$` docker filter, no
// `--force-recreate frontend`. Their presence would mean the old broken
// publish path crept back.
const ghosts: Array<{ re: RegExp; what: string }> = [
{ re: /recreateBunkerwebFrontend/, what: 'recreateBunkerwebFrontend()' },
{ re: /bunkerwebFrontendPresent/, what: 'bunkerwebFrontendPresent()' },
{ re: /name=\^\/morphit-frontend\$/, what: 'hardcoded morphit-frontend docker filter' },
{ re: /--force-recreate/, what: 'docker compose --force-recreate' }
];
const ghostHits = ghosts.filter((g) => g.re.test(upgradeSrc)).map((g) => g.what);
if (ghostHits.length === 0) {
ok('FD-20 cp236 name/compose-based publish path fully removed (no recreate/name-filter/force-recreate ghosts)');
} else {
bad('FD-20 stale cp236 publish path present', ghostHits.join(', '));
}
// FD-15 (cp236 regression guard): the web build must be UNCONDITIONAL.
// The original bug nested `npm run build` inside an `if (webRoot exists)`
// else, so a container-served host (no /var/www/morphit-frontend) silently
// skipped the frontend rebuild. Assert (a) the old skip text is gone and
// (b) the build is NOT gated behind a webRoot-existence else.
const oldSkipText = /skipping the frontend redeploy/.test(upgradeSrc);
const buildGatedBehindWebRoot =
/if\s*\(\s*!existsSync\(webRoot\)\s*\)[\s\S]*?else[\s\S]*?runOrThrow\(\s*'npm',\s*\['run',\s*'build'\]/.test(
upgradeSrc
);
if (!oldSkipText && !buildGatedBehindWebRoot) {
ok('FD-15 web build is unconditional (not skipped when the web root is absent)');
} else {
bad(
'FD-15 web build is conditional again — container-served hosts would silently skip it',
`oldSkipText=${oldSkipText} buildGatedBehindWebRoot=${buildGatedBehindWebRoot}`
);
}
}
// ─── FD-21/22: served-frontend freshness verification (beta14) ──────
{
// parseVerifyJsonVersion pulls the `morphit_version` field out of
// build/verify.json — the SAME field scripts/build-verify-json.mjs writes
// and apps/web .../about-this-instance reads. (The old morphit-<token> SW
// grep never survived minification, so the check always came back
// "unknown"; reading the WRONG json field — a bare `version` — silently did
// the exact same thing, which is what shipped in the first beta18 cut.
// FD-21c below is the cross-file drift guard against that recurring.)
// Fixture uses the REAL verify.json shape, not a hand-fabricated one.
const verifyJson = JSON.stringify({
schema_version: 1,
morphit_version: '1.0.0-beta.24',
git_commit: null,
operator_tag: null,
built_at: '2026-06-15T00:00:00.000Z',
hash_manifest: {}
});
const v = parseVerifyJsonVersion(verifyJson);
if (v === '1.0.0-beta.24') ok('FD-21a parseVerifyJsonVersion extracts morphit_version from the real verify.json shape');
else bad('FD-21a', `got ${v}`);
if (
parseVerifyJsonVersion('not json') === null &&
parseVerifyJsonVersion('{"no":"version"}') === null &&
// A bare `version` field must NOT satisfy it — the real file keys on
// morphit_version. This negative is what would have caught the bug.
parseVerifyJsonVersion('{"version":"1.0.0-beta.24"}') === null
)
ok('FD-21b parseVerifyJsonVersion returns null on bad/missing/wrong-field json');
else bad('FD-21b', 'expected null on bad/missing/wrong-field version');
// FD-21c — DRIFT GUARD: the generator (writes), the upgrade parser (reads),
// and the about-this-instance page (reads) must all key on the SAME field.
// The first beta18 cut shipped a generator writing `morphit_version` and a
// parser reading `.version` → always null → "unknown" forever. Assert the
// literal field name is present in all three sources so they can't drift.
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
const FIELD = 'morphit_version';
const genSrc = readFileSync(join(repoRoot, 'scripts', 'build-verify-json.mjs'), 'utf8');
const parserSrc = readFileSync(join(repoRoot, 'apps', 'ops-cli', 'src', 'commands', 'upgrade.ts'), 'utf8');
const aboutSrc = readFileSync(
join(repoRoot, 'apps', 'web', 'src', 'routes', '[lang]', 'about-this-instance', '+page.svelte'),
'utf8'
);
const genWrites = genSrc.includes(`${FIELD}:`); // payload key
const parserReads = parserSrc.includes(`.${FIELD}`) || parserSrc.includes(`{ ${FIELD}?:`);
const aboutReads = aboutSrc.includes(FIELD);
if (genWrites && parserReads && aboutReads)
ok(`FD-21c verify.json field "${FIELD}" agrees across generator, upgrade parser, and about page`);
else bad('FD-21c', `field drift: generator=${genWrites} parser=${parserReads} about=${aboutReads}`);
// classifyFrontendVerify: fresh / stale / unknown.
if (classifyFrontendVerify('abc', 'abc') === 'fresh') ok('FD-22a equal versions → fresh (snackbar will fire)');
else bad('FD-22a');
if (classifyFrontendVerify('newbuild', 'oldbuild') === 'stale')
ok('FD-22b served ≠ built → stale (snackbar blocked, warn)');
else bad('FD-22b');
if (classifyFrontendVerify(null, 'x') === 'unknown' && classifyFrontendVerify('x', null) === 'unknown')
ok('FD-22c either version unknown → unknown (best-effort note)');
else bad('FD-22c');
}
console.log('');
console.log(`${pass} passed, ${fail} failed`);
if (fail > 0) {
console.log('\u2717 upgrade-frontend-deploy smoke FAILED');
process.exit(1);
}
console.log('\u2713 upgrade rebuilds + redeploys the static frontend, with rollback');
console.log(`\u2713 all ${pass} upgrade-frontend-deploy scenarios passed`);