mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-02 06:58:52 +08:00
Package production dependencies per architecture for online and offline upgrades. Validate dependencies and isolated backend startup before readiness, repair once when permitted, and preserve service/data through failed preparation or rollback. Publish release bundles with master images and expose an opt-in develop build sharing the development index across Alpine and Debian images.
351 lines
13 KiB
JavaScript
351 lines
13 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' }),
|
|
);
|
|
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' }),
|
|
);
|
|
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' }),
|
|
);
|
|
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']);
|
|
});
|