| … | … | @@ -0,0 +1,120 @@ |
| 1 | // Regression: a staged update must run with the env ITS OWN manifest declares. |
| 2 | // |
| 3 | // The supervisor re-resolves the child env every spawn (child-env.js) — but it did so BEFORE applying a |
| 4 | // staged update, so it read the manifest of the release being replaced. Every staged OTA (the normal |
| 5 | // path while an app runs) therefore started the new code with the previous release's defaults.env, and a |
| 6 | // release that added or changed an env default took effect only one update later. |
| 7 | |
| 8 | import test from 'brittle' |
| 9 | import process from '#node:process' |
| 10 | import fs from '#node:fs' |
| 11 | import { join } from '#node:path' |
| 12 | import { tmpdir } from '#node:os' |
| 13 | import { spawn, spawnSync } from '#node:child_process' |
| 14 | import { fileURLToPath } from '#node:url' |
| 15 | |
| 16 | import { TreeStore } from '../lib/store.js' |
| 17 | import { materializeAtomic, lockPathFor, stagedPathFor } from '../lib/materializers/atomic.js' |
| 18 | |
| 19 | const SUPERVISOR = fileURLToPath(new URL('../lib/supervisor.js', import.meta.url)) |
| 20 | const NODE_MODULES = fileURLToPath(new URL('../node_modules', import.meta.url)) |
| 21 | const HAS_FLOCK = spawnSync('flock', ['--version']).status === 0 |
| 22 | |
| 23 | // An app tree for `version` whose INSTALL MANIFEST declares `defaults.env.WHICH = 'env-' + version`, as a |
| 24 | // real release's manifest declares its env. The entry records "<code version>:<env it was given>". |
| 25 | function appMaterializer (version, marker) { |
| 26 | return { |
| 27 | materialize ({ store, appName }) { |
| 28 | const dir = store.appPath(appName) |
| 29 | fs.mkdirSync(dir, { recursive: true }) |
| 30 | fs.writeFileSync(join(dir, 'package.json'), JSON.stringify({ |
| 31 | type: 'module', |
| 32 | imports: { |
| 33 | '#node:fs': { bare: 'bare-fs', default: 'fs' }, |
| 34 | '#node:process': { bare: 'bare-process', default: 'process' } |
| 35 | } |
| 36 | })) |
| 37 | fs.writeFileSync(join(dir, '.pgh-install.json'), JSON.stringify({ name: appName, version, defaults: { env: { WHICH: 'env-' + version } } })) |
| 38 | fs.symlinkSync(NODE_MODULES, join(dir, 'node_modules')) |
| 39 | fs.writeFileSync(join(dir, 'entry.js'), |
| 40 | 'import fs from "#node:fs"\n' + |
| 41 | 'import process from "#node:process"\n' + |
| 42 | 'fs.appendFileSync(' + JSON.stringify(marker) + ', ' + JSON.stringify(version + ':') + ' + process.env.WHICH + "\\n")\n' + |
| 43 | 'setTimeout(() => process.exit(75), 300)\n') |
| 44 | return { referenced: [], rootTarget: dir, depCount: 0 } |
| 45 | } |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | // Hold the app's shared lock the way a running app does, so an install STAGES. |
| 50 | function holdShared (lockPath) { |
| 51 | return new Promise((resolve, reject) => { |
| 52 | fs.writeFileSync(lockPath, '', { flag: 'a' }) |
| 53 | const p = spawn('sh', ['-c', 'exec 9>>"$0"; flock -s 9; echo held; exec sleep 60', lockPath]) |
| 54 | p.stdout.on('data', (d) => { if (String(d).includes('held')) resolve(p) }) |
| 55 | p.on('error', reject) |
| 56 | }) |
| 57 | } |
| 58 | |
| 59 | async function stage (store, version, marker) { |
| 60 | const holder = await holdShared(lockPathFor(store, 'app')) |
| 61 | try { |
| 62 | const r = materializeAtomic(store, appMaterializer(version, marker), { installName: 'app' }) |
| 63 | if (!r.staged) throw new Error('setup: version ' + version + ' was not staged') |
| 64 | } finally { |
| 65 | holder.kill('SIGKILL') |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | const runs = (marker) => (fs.existsSync(marker) ? fs.readFileSync(marker, 'utf8').split('\n').filter(Boolean) : []) |
| 70 | async function until (fn, ms) { |
| 71 | const end = Date.now() + ms |
| 72 | while (Date.now() < end) { if (fn()) return true; await new Promise((r) => setTimeout(r, 100)) } |
| 73 | return false |
| 74 | } |
| 75 | |
| 76 | test('⛔ a STAGED update runs with ITS OWN manifest env, not the previous release\'s', { skip: !HAS_FLOCK }, async (t) => { |
| 77 | const root = fs.mkdtempSync(join(tmpdir(), 'sup-staged-env-')) |
| 78 | const storeRoot = join(root, 'store') |
| 79 | const store = new TreeStore({ root: storeRoot }) |
| 80 | store.init() |
| 81 | const marker = join(root, 'runs.txt') |
| 82 | |
| 83 | materializeAtomic(store, appMaterializer('1', marker), { installName: 'app' }) |
| 84 | const live = store.appPath('app') |
| 85 | |
| 86 | const sup = spawn(process.execPath, [SUPERVISOR], { |
| 87 | cwd: live, |
| 88 | stdio: ['ignore', 'ignore', 'pipe'], |
| 89 | env: { |
| 90 | ...process.env, |
| 91 | XDG_RUNTIME_DIR: join(root, 'run'), |
| 92 | PGH_STORE_ROOT: storeRoot, |
| 93 | PGH_SUP_NAME: 'app', |
| 94 | PGH_SUP_ENTRYPOINT: join(live, 'entry.js'), |
| 95 | PGH_SUP_RUNTIME: 'node', |
| 96 | PGH_SUP_RUNTIME_PATH: process.execPath, |
| 97 | PGH_SUP_ARGS_JSON: '[]', |
| 98 | PGH_SUP_CHILD_ENV: JSON.stringify({ ...process.env, WHICH: 'env-1' }) |
| 99 | } |
| 100 | }) |
| 101 | let supLog = '' |
| 102 | sup.stderr.on('data', (d) => { supLog += d }) |
| 103 | t.teardown(async () => { |
| 104 | try { sup.kill('SIGTERM') } catch {} |
| 105 | await new Promise((r) => setTimeout(r, 500)) |
| 106 | try { sup.kill('SIGKILL') } catch {} |
| 107 | fs.rmSync(root, { recursive: true, force: true }) |
| 108 | }) |
| 109 | |
| 110 | t.ok(await until(() => runs(marker).includes('1:env-1'), 10000), 'version 1 ran with its env') |
| 111 | |
| 112 | // 2026-10-14, favela-gateway 0.3.0 on stortron: the release added a role in defaults.env, the update |
| 113 | // was staged, and the respawned child ran the new code with GATEWAY_ROLES from 0.2.1 — the supervisor |
| 114 | // resolved the env from the live manifest BEFORE applyStaged() swapped the new one in. |
| 115 | await stage(store, '2', marker) |
| 116 | const ran = await until(() => runs(marker).some((l) => l.startsWith('2:')), 10000) |
| 117 | t.ok(ran, 'version 2 ran') |
| 118 | t.ok(runs(marker).includes('2:env-2'), 'with version 2\'s manifest env — got ' + JSON.stringify(runs(marker).filter((l) => l.startsWith('2:')))) |
| 119 | if (!runs(marker).includes('2:env-2')) t.comment('supervisor log: ' + supLog.split('\n').filter((l) => /staged|applied|env/.test(l)).slice(-4).join(' | ')) |
| 120 | }) |