Files
qinglong/test/back/upgrade-prestart.test.cjs

497 lines
20 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,
cleanSqliteBuildArtifacts,
} = 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('successful legacy repair publishes only portable runtime files from a pnpm alias', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-repair-portable-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const source = path.join(root, 'source'),
bin = path.join(root, 'bin');
const outside = path.join(root, 'outside');
await fs.mkdir(path.join(source, 'node_modules'), { recursive: true });
await fs.mkdir(bin);
await fs.mkdir(outside);
await fs.writeFile(path.join(outside, 'keep'), 'unrelated file');
await fs.writeFile(
path.join(source, 'node_modules/old'),
'previous dependency',
);
await fs.writeFile(
path.join(source, 'package.json'),
JSON.stringify({
packageManager: 'pnpm@8.3.1',
dependencies: { sqlite3: 'npm:@whyour/sqlite3@1.1.2' },
}),
);
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.log('8.3.1'); process.exit(0); }
const pkg = 'node_modules/.pnpm/@whyour+sqlite3@1.1.2/node_modules/@whyour/sqlite3';
if (process.argv[2] === 'install') {
fs.mkdirSync(pkg + '/lib/binding/napi-v6-linux-arm', {recursive:true});
fs.writeFileSync(pkg + '/package.json', JSON.stringify({binary:{module_path:'./lib/binding/napi-v{napi_build_version}-{platform}-{arch}'}}));
fs.symlinkSync('.pnpm/@whyour+sqlite3@1.1.2/node_modules/@whyour/sqlite3', 'node_modules/sqlite3');
} else if (process.argv[2] === 'rebuild') {
fs.writeFileSync(pkg + '/lib/binding/napi-v6-linux-arm/node_sqlite3.node', 'compiled addon');
for (const name of ['build', 'build-tmp-napi-v6']) {
fs.mkdirSync(pkg + '/' + name + '/node_gyp_bins', {recursive:true});
fs.symlinkSync('/usr/bin/python3', pkg + '/' + name + '/node_gyp_bins/python3');
}
fs.symlinkSync(${JSON.stringify(outside)}, pkg + '/build-tmp-napi-v3');
} else { throw Error('unexpected command'); }
`,
{ mode: 0o755 },
);
await repairDependencies(source, {
target: { arch: 'armv6' },
env: { PATH: bin },
});
const sqlite = path.join(source, 'node_modules/sqlite3');
assert.equal(
await fs.readFile(
path.join(sqlite, 'lib/binding/napi-v6-linux-arm/node_sqlite3.node'),
'utf8',
),
'compiled addon',
);
for (const name of ['build', 'build-tmp-napi-v3', 'build-tmp-napi-v6'])
await assert.rejects(fs.lstat(path.join(sqlite, name)), { code: 'ENOENT' });
assert.equal(
await fs.readFile(path.join(outside, 'keep'), 'utf8'),
'unrelated file',
);
await assert.rejects(fs.access(path.join(source, 'node_modules/old')), {
code: 'ENOENT',
});
const {
inventory,
} = require('../../cli/src/internal/maintenance/platformBundle.cjs');
await inventory({ source });
});
test('cleanup retains sqlite3 runtimes loaded from build/Release and never follows a build symlink', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-sqlite-release-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const sqlite = path.join(root, 'sqlite3'), outside = path.join(root, 'outside');
await fs.mkdir(path.join(sqlite, 'build/Release'), { recursive: true });
await fs.mkdir(path.join(sqlite, 'build/node_gyp_bins'));
await fs.mkdir(path.join(outside, 'node_gyp_bins'), { recursive: true });
await fs.writeFile(path.join(outside, 'node_gyp_bins/keep'), 'untouched');
await fs.writeFile(path.join(sqlite, 'package.json'), JSON.stringify({ name: 'sqlite3', binary: { napi_versions: [3, 6] } }));
await fs.writeFile(path.join(sqlite, 'build/Release/node_sqlite3.node'), 'runtime addon');
await fs.symlink('/usr/bin/python3', path.join(sqlite, 'build/node_gyp_bins/python3'));
await fs.symlink(outside, path.join(sqlite, 'build-tmp-napi-v3'));
await cleanSqliteBuildArtifacts(sqlite);
assert.equal(await fs.readFile(path.join(sqlite, 'build/Release/node_sqlite3.node'), 'utf8'), 'runtime addon');
await assert.rejects(fs.access(path.join(sqlite, 'build/node_gyp_bins')), { code: 'ENOENT' });
await assert.rejects(fs.lstat(path.join(sqlite, 'build-tmp-napi-v3')), { code: 'ENOENT' });
assert.equal(await fs.readFile(path.join(outside, 'node_gyp_bins/keep'), 'utf8'), 'untouched');
await require('../../cli/src/internal/maintenance/platformBundle.cjs').inventory({ source: sqlite });
});
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']);
});