Files
qinglong/test/back/upgrade-prestart.test.cjs
whyour 15d54866a8 feat: migrate SQLite runtime and verify complete upgrade bundles
Use MikroORM's official Node SQLite dialect with bounded database isolation, preserve legacy schema and data for rollback, and require Node 22.17 or newer. Update Debian runtimes and retain ARMv6 musl compatibility without a SQLite addon.

Complete short archive writes, stop orphaned SQL workers after owner death, preserve buffered startup requests, and prestart the archived backend on every platform. Cover real package installation, migration, failure recovery, and legacy database compatibility.

Keep authentication storage failures distinct from expired sessions with a pinned Keyv runtime, propagated cache errors, and retryable HTTP 503 responses. Preserve genuine 401 failures and verify legacy cache bytes, initialization safety, and session recovery.
2026-10-22 13:47:26 +08:00

406 lines
15 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const {
prestartBackend,
dependencyFailure,
probeLimits,
repairDependencies,
} = require('../../cli/src/internal/maintenance/upgradePrestart.cjs');
for (const exitCode of [0, 1])
test(`prestart exit ${exitCode} reaps children retaining output pipes before removing its workspace`, async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-probe-exit-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const source = path.join(root, 'source'),
statics = path.join(root, 'static'),
survivor = path.join(root, 'survivor');
await fs.mkdir(path.join(source, 'node_modules'), { recursive: true });
await fs.mkdir(path.join(statics, 'build/bootstrap'), { recursive: true });
await fs.writeFile(path.join(source, 'version.yaml'), 'version: 2.23.0');
await fs.writeFile(path.join(statics, 'build/app.js'), 'backend');
await fs.writeFile(
path.join(statics, 'build-info.json'),
JSON.stringify({ sourceCommit: 'a'.repeat(40) }),
);
await fs.writeFile(
path.join(statics, 'build/bootstrap/upgradeProbe.js'),
`
const fs=require('node:fs'),crypto=require('node:crypto');
require('node:child_process').spawn(process.execPath,['-e',${JSON.stringify(
`setTimeout(()=>require('node:fs').writeFileSync(${JSON.stringify(
survivor,
)},'escaped'),600);`,
)}],{stdio:'inherit'});
process.send({type:'upgrade-probe',ok:${
exitCode === 0
},message:'simulated prestart failure',build:{sourceCommit:'a'.repeat(40),version:'2.23.0',backendSha256:crypto.createHash('sha256').update('backend').digest('hex')}},()=>process.exit(${exitCode}));
`,
);
const operation = prestartBackend(
{ source, static: statics },
{ target: { arch: 'armv6' }, limits: { heapMb: 128, timeoutMs: 3000 } },
);
if (exitCode) await assert.rejects(operation, /simulated prestart failure/);
else await operation;
assert.deepEqual((await fs.readdir(root)).sort(), ['source', 'static']);
await new Promise((r) => setTimeout(r, 700));
await assert.rejects(fs.access(survivor), { code: 'ENOENT' });
});
for (const phase of ['install', 'rebuild'])
test(`dependency repair ${phase} failure preserves original dependencies, cleans workspace and uses configured proxy`, async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-repair-failure-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const source = path.join(root, 'source'),
bin = path.join(root, 'bin');
await fs.mkdir(path.join(source, 'node_modules'), { recursive: true });
await fs.mkdir(bin);
await fs.writeFile(
path.join(source, 'package.json'),
JSON.stringify({
packageManager: 'pnpm@8.3.1',
dependencies: { sqlite3: 'legacy-fixture' },
}),
);
await fs.writeFile(path.join(source, 'pnpm-lock.yaml'), 'frozen lock');
await fs.writeFile(path.join(source, 'node_modules/old'), 'preserved');
const proxy = path.join(root, 'network.json');
await fs.writeFile(
path.join(bin, 'pnpm'),
`#!${process.execPath}
const fs=require('node:fs');
if(process.argv[2]==='--version') {console.log('8.3.1');process.exit(0);}
fs.writeFileSync(${JSON.stringify(
proxy,
)},JSON.stringify({https:process.env.HTTPS_PROXY,no:process.env.no_proxy,options:process.env.NODE_OPTIONS}));
fs.mkdirSync('node_modules',{recursive:true});fs.writeFileSync('node_modules/partial','partial');
if(process.argv[2]===${JSON.stringify(
phase,
)}) {console.error('simulated repair failure');process.exit(12);}
`,
{ mode: 0o755 },
);
await assert.rejects(
repairDependencies(source, {
target: { arch: 'armv6' },
env: {
PATH: bin,
HTTPS_PROXY: 'http://127.0.0.1:7890',
no_proxy: 'localhost,127.0.0.1',
NODE_OPTIONS: '--require untrusted',
},
}),
/simulated repair failure/,
);
assert.equal(
await fs.readFile(path.join(source, 'node_modules/old'), 'utf8'),
'preserved',
);
assert.deepEqual(await fs.readdir(path.join(source, 'node_modules')), [
'old',
]);
assert.deepEqual((await fs.readdir(root)).sort(), [
'bin',
'network.json',
'source',
]);
const network = JSON.parse(await fs.readFile(proxy));
assert.equal(network.https, 'http://127.0.0.1:7890');
assert.equal(network.no, 'localhost,127.0.0.1');
assert.doesNotMatch(network.options, /untrusted/);
});
test('cancelled dependency repair kills descendants and retains the original tree', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-repair-cancel-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const source = path.join(root, 'source'),
bin = path.join(root, 'bin'),
started = path.join(root, 'started'),
survivor = path.join(root, 'survivor');
await fs.mkdir(path.join(source, 'node_modules'), { recursive: true });
await fs.mkdir(bin);
await fs.writeFile(
path.join(source, 'package.json'),
JSON.stringify({
packageManager: 'pnpm@8.3.1',
dependencies: { sqlite3: 'legacy-fixture' },
}),
);
await fs.writeFile(path.join(source, 'pnpm-lock.yaml'), 'frozen lock');
await fs.writeFile(path.join(source, 'node_modules/old'), 'preserved');
await fs.writeFile(
path.join(bin, 'pnpm'),
`#!${process.execPath}
if(process.argv[2]==='--version'){console.log('8.3.1');process.exit(0);}
require('node:child_process').spawn(process.execPath,['-e',${JSON.stringify(
`process.on('SIGTERM',()=>{});require('node:fs').writeFileSync(${JSON.stringify(
started,
)},'started');setTimeout(()=>require('node:fs').writeFileSync(${JSON.stringify(
survivor,
)},'escaped'),1400);`,
)}],{stdio:'inherit'});
setInterval(()=>{},1000);
`,
{ mode: 0o755 },
);
const controller = new AbortController();
const operation = repairDependencies(source, {
target: { arch: 'armv6' },
env: { PATH: bin },
signal: controller.signal,
});
const rejection = assert.rejects(operation, /cancel repair/);
let seen = false;
for (let i = 0; i < 150; i++) {
if (await fs.stat(started).catch(() => null)) {
seen = true;
break;
}
await new Promise((r) => setTimeout(r, 20));
}
controller.abort(new Error('cancel repair'));
await rejection;
assert.ok(seen);
assert.equal(
await fs.readFile(path.join(source, 'node_modules/old'), 'utf8'),
'preserved',
);
assert.deepEqual((await fs.readdir(root)).sort(), [
'bin',
'source',
'started',
]);
await new Promise((r) => setTimeout(r, 1500));
await assert.rejects(fs.access(survivor), { code: 'ENOENT' });
});
test('real Node resolution failures distinguish broken dependency entries and internal imports from backend source', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-missing-entry-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const directory = path.join(root, 'node_modules/broken-entry');
await fs.mkdir(directory, { recursive: true });
await fs.writeFile(
path.join(directory, 'package.json'),
JSON.stringify({ main: 'missing.js' }),
);
const load = require('node:module').createRequire(
path.join(root, 'package.json'),
);
assert.throws(
() => load('broken-entry'),
(error) => dependencyFailure(error),
);
await fs.writeFile(
path.join(directory, 'missing.js'),
"require('./missing-internal-file')",
);
assert.throws(
() => load('broken-entry'),
(error) => dependencyFailure(error),
);
await fs.writeFile(
path.join(root, 'api.js'),
"require('./missing-api-route')",
);
assert.throws(
() => load('./api.js'),
(error) => !dependencyFailure(error),
);
});
test('ARMv6 repair isolates configuration, uses frozen install, one compiler job and interpreted Node', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-repair-armv6-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const source = path.join(root, 'source'),
bin = path.join(root, 'bin');
await fs.mkdir(source);
await fs.mkdir(bin);
await fs.writeFile(
path.join(source, 'package.json'),
JSON.stringify({
packageManager: 'pnpm@8.3.1',
dependencies: { sqlite3: 'legacy-fixture' },
}),
);
await fs.writeFile(path.join(source, 'pnpm-lock.yaml'), 'frozen lock');
await fs.writeFile(
path.join(bin, 'pnpm'),
`#!${process.execPath}
const fs=require('node:fs');
if(process.argv[2]==='--version') {console.error('harmless runtime warning'); console.log('8.3.1');}
else if(process.argv[2]==='install') {
if(!process.argv.includes('--frozen-lockfile')||!process.argv.includes('--prod')) throw Error('not frozen production install');
fs.mkdirSync('node_modules');fs.writeFileSync('node_modules/flags.json',JSON.stringify({
args:process.argv,options:process.env.NODE_OPTIONS,source:process.env.npm_config_build_from_source,
processConfig:process.env.npm_config_force_process_config,jobs:process.env.npm_config_jobs,make:process.env.MAKEFLAGS
}));
} else if(process.argv.slice(2).join(' ')!=='rebuild sqlite3 @whyour/sqlite3') throw Error('unexpected command');
`,
{ mode: 0o755 },
);
await repairDependencies(source, {
target: { arch: 'armv6' },
env: {
PATH: bin,
NODE_OPTIONS: '--require untrusted',
npm_config_nodedir: '/wrong-node',
},
});
const flags = JSON.parse(
await fs.readFile(path.join(source, 'node_modules/flags.json')),
);
assert.match(flags.options, /--jitless/);
assert.match(flags.options, /max-old-space-size=256/);
assert.equal(flags.source, 'true');
assert.equal(flags.processConfig, 'true');
assert.equal(flags.jobs, '1');
assert.equal(flags.make, '-j1');
assert.deepEqual((await fs.readdir(root)).sort(), ['bin', 'source']);
});
test('repair classification excludes application, platform, config, database, OOM and timeouts', () => {
for (const error of [
{ code: 'MODULE_NOT_FOUND', message: "Cannot find module './api/missing'" },
{ message: 'Bundle platform mismatch' },
{ message: 'SQLITE_CANTOPEN' },
{ message: 'Upgrade prestart timed out' },
{
code: 'ERR_DLOPEN_FAILED',
message: 'node_modules/sqlite3/node.node',
signal: 'SIGKILL',
},
{ message: 'JavaScript heap out of memory' },
])
assert.equal(dependencyFailure(error), false, JSON.stringify(error));
assert.equal(
dependencyFailure({
code: 'MODULE_NOT_FOUND',
message: "Cannot find module 'decimal.js'",
}),
true,
);
assert.equal(
dependencyFailure({
code: 'ERR_DLOPEN_FAILED',
message: 'dlopen /tmp/node_modules/sqlite3/node.node: invalid ELF header',
}),
true,
);
assert.equal(
dependencyFailure({
message:
'Could not locate the bindings file /tmp/node_modules/sqlite3/bindings',
}),
true,
);
assert.deepEqual(probeLimits({ arch: 'armv6' }), {
heapMb: 128,
timeoutMs: 300000,
});
});
test('prestart deadline kills its process group and removes temporary data', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-prestart-timeout-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const source = path.join(root, 'source'),
statics = path.join(root, 'static');
await fs.mkdir(path.join(source, 'node_modules'), { recursive: true });
await fs.writeFile(path.join(source, 'version.yaml'), 'version: 2.23.0');
await fs.mkdir(path.join(statics, 'build/bootstrap'), { recursive: true });
await fs.writeFile(path.join(statics, 'build/app.js'), 'backend');
await fs.writeFile(
path.join(statics, 'build-info.json'),
JSON.stringify({ sourceCommit: 'a'.repeat(40) }),
);
const survivor = path.join(root, 'survivor');
await fs.writeFile(
path.join(statics, 'build/bootstrap/upgradeProbe.js'),
`
require('node:child_process').spawn(process.execPath,['-e',${JSON.stringify(
`setTimeout(()=>require('node:fs').writeFileSync(${JSON.stringify(
survivor,
)},'escaped'),2200)`,
)}],{stdio:'ignore'});
setInterval(()=>{},1000);
`,
);
await assert.rejects(
prestartBackend(
{ source, static: statics },
{
target: { arch: 'armv6' },
limits: { heapMb: 128, timeoutMs: 1000 },
},
),
/timed out/,
);
assert.deepEqual((await fs.readdir(root)).sort(), ['source', 'static']);
await new Promise((resolve) => setTimeout(resolve, 1600));
await assert.rejects(fs.access(survivor), { code: 'ENOENT' });
});
test('repair refuses an unsupported package manager before invoking a network command', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-repair-lock-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const source = path.join(root, 'source');
await fs.mkdir(source);
await fs.writeFile(path.join(source, 'package.json'), '{}');
await assert.rejects(
repairDependencies(source, {
target: { arch: 'armv6' },
env: { PATH: '' },
}),
/Unsupported frozen-lockfile/,
);
assert.deepEqual(await fs.readdir(root), ['source']);
});
test('MikroORM dependency repair uses the frozen production tree without running a native rebuild', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-repair-mikro-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const source = path.join(root, 'source'),
bin = path.join(root, 'bin');
await fs.mkdir(path.join(source, 'node_modules'), { recursive: true });
await fs.mkdir(bin);
await fs.writeFile(
path.join(source, 'package.json'),
JSON.stringify({
packageManager: 'pnpm@8.3.1',
dependencies: { '@mikro-orm/core': '7.2.5', '@mikro-orm/sql': '7.2.5' },
}),
);
await fs.writeFile(path.join(source, 'pnpm-lock.yaml'), 'frozen lock');
await fs.writeFile(path.join(source, 'node_modules/broken'), 'old');
await fs.writeFile(
path.join(bin, 'pnpm'),
`#!${process.execPath}
const fs=require('node:fs');
if(process.argv[2]==='--version') console.log('8.3.1');
else if(process.argv[2]==='install') {
if(!['--prod','--frozen-lockfile','--ignore-scripts'].every(x=>process.argv.includes(x))) throw Error('unsafe install');
fs.mkdirSync('node_modules');fs.writeFileSync('node_modules/repaired','new');
} else throw Error('MikroORM must not rebuild sqlite3');
`,
{ mode: 0o755 },
);
await repairDependencies(source, {
target: { arch: 'armv6' },
env: { PATH: bin },
});
assert.equal(
await fs.readFile(path.join(source, 'node_modules/repaired'), 'utf8'),
'new',
);
await assert.rejects(fs.access(path.join(source, 'node_modules/broken')), {
code: 'ENOENT',
});
assert.equal(
await fs.readFile(path.join(source, 'pnpm-lock.yaml'), 'utf8'),
'frozen lock',
);
assert.deepEqual((await fs.readdir(root)).sort(), ['bin', 'source']);
});