mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-02 15:56:59 +08:00
feat: ship verified platform upgrades with channel bundle releases
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.
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const ts = require('typescript');
|
||||
|
||||
const source = ts.createSourceFile(
|
||||
'app.ts',
|
||||
fs.readFileSync('back/app.ts', 'utf8'),
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
);
|
||||
const application = source.statements.find(
|
||||
(node) => ts.isClassDeclaration(node) && node.name.text === 'Application',
|
||||
);
|
||||
const method = application.members.find(
|
||||
(node) => node.name?.getText(source) === 'startMasterProcess',
|
||||
);
|
||||
const compiled = ts.transpileModule(
|
||||
`class Application { ${method.getText(
|
||||
source,
|
||||
)} }\nmodule.exports = Application;`,
|
||||
{
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2020,
|
||||
},
|
||||
},
|
||||
).outputText;
|
||||
|
||||
for (const [arch, armVersion, timeout] of [
|
||||
['arm', 6, 120000],
|
||||
['arm', 7, 30000],
|
||||
['arm64', undefined, 30000],
|
||||
]) {
|
||||
test(`${arch} v${
|
||||
armVersion ?? ''
|
||||
} waits for scheduler readiness before HTTP and job recovery`, async () => {
|
||||
const cluster = new EventEmitter();
|
||||
const module = { exports: {} };
|
||||
const logger = { info() {}, warn() {}, error() {} };
|
||||
new Function(
|
||||
'module',
|
||||
'cluster',
|
||||
'Logger',
|
||||
'errStack',
|
||||
'process',
|
||||
compiled,
|
||||
)(module, cluster, logger, String, {
|
||||
arch,
|
||||
config: { variables: { arm_version: armVersion } },
|
||||
exit() {
|
||||
assert.fail('unexpected application exit');
|
||||
},
|
||||
});
|
||||
const app = new module.exports();
|
||||
app.workerMetadataMap = new Map();
|
||||
const forks = [],
|
||||
waits = [],
|
||||
notifications = [];
|
||||
let ready;
|
||||
app.forkWorker = (serviceType) => {
|
||||
const worker = {
|
||||
id: forks.length + 1,
|
||||
process: { pid: 100 + forks.length },
|
||||
send: (message) => notifications.push(message),
|
||||
};
|
||||
forks.push({ serviceType, worker });
|
||||
app.workerMetadataMap.set(worker.id, { serviceType });
|
||||
return worker;
|
||||
};
|
||||
app.waitForWorkerReady = (worker, milliseconds) => {
|
||||
waits.push({ worker, milliseconds });
|
||||
return new Promise((resolve) => {
|
||||
ready = resolve;
|
||||
});
|
||||
};
|
||||
app.setupMasterShutdown = () => {};
|
||||
app.startMasterProcess();
|
||||
assert.deepEqual(
|
||||
forks.map((fork) => fork.serviceType),
|
||||
['grpc'],
|
||||
);
|
||||
assert.equal(waits[0].milliseconds, timeout);
|
||||
ready();
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(
|
||||
forks.map((fork) => fork.serviceType),
|
||||
['grpc', 'http'],
|
||||
);
|
||||
cluster.emit('exit', forks[0].worker, 1);
|
||||
assert.equal(waits[1].milliseconds, timeout);
|
||||
assert.deepEqual(notifications, ['scheduler-unavailable']);
|
||||
ready();
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(notifications, [
|
||||
'scheduler-unavailable',
|
||||
'reregister-crons',
|
||||
]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const { execFile } = require('node:child_process');
|
||||
const { promisify } = require('node:util');
|
||||
const execute = promisify(execFile);
|
||||
const {
|
||||
manualUpgradeFixture,
|
||||
} = require('../helpers/manual-upgrade-fixture.cjs');
|
||||
const jobs = require('../../cli/src/internal/maintenance/panelUpgrade.cjs');
|
||||
|
||||
for (const previousStatus of ['ready', 'failed', 'completed', 'idle', 'absent'])
|
||||
for (const wrongVersion of [false, true])
|
||||
test(`manual application records the selected package after ${previousStatus}; wrong version=${wrongVersion}`, async (t) => {
|
||||
const f = await manualUpgradeFixture(t, wrongVersion);
|
||||
await fs.writeFile(
|
||||
path.join(f.root, 'package.json'),
|
||||
'{"qinglongUpgradeFormat":2}',
|
||||
);
|
||||
const file = path.join(f.tmp, 'panel-upgrade.json');
|
||||
if (previousStatus === 'absent') await fs.unlink(file);
|
||||
else
|
||||
await fs.writeFile(
|
||||
file,
|
||||
JSON.stringify({
|
||||
...(await f.state()),
|
||||
status: previousStatus,
|
||||
error: 'previous preparation failed',
|
||||
version: '9.9.9',
|
||||
sourceCommit: 'f'.repeat(40),
|
||||
backendSha256: 'f'.repeat(64),
|
||||
stageDirectory: 'upgrade-other',
|
||||
}),
|
||||
);
|
||||
const operation = execute(
|
||||
process.execPath,
|
||||
[path.resolve('shell/upgrade.cjs'), 'reload'],
|
||||
{ env: f.env, timeout: 15000 },
|
||||
);
|
||||
if (wrongVersion)
|
||||
await assert.rejects(operation, /启动版本与更新包不一致/);
|
||||
else await operation;
|
||||
const state = await f.state();
|
||||
assert.equal(state.status, wrongVersion ? 'failed' : 'completed');
|
||||
assert.equal(state.sourceCommit, f.expected.sourceCommit);
|
||||
assert.equal(state.stageDirectory, f.expected.stageDirectory);
|
||||
assert.equal(state.version, f.expected.version);
|
||||
assert.notEqual(state.id, 'offline-prepared');
|
||||
assert.equal(state.action, 'apply');
|
||||
assert.notEqual(state.error, 'previous preparation failed');
|
||||
if (!wrongVersion) assert.equal(state.error, undefined);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'static/build/app.js'), 'utf8'),
|
||||
wrongVersion ? 'old backend' : 'new backend',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'data/keep'), 'utf8'),
|
||||
'preserve data',
|
||||
);
|
||||
});
|
||||
|
||||
test('manual application rejects a selection that no longer matches the ready pointer', async (t) => {
|
||||
const f = await manualUpgradeFixture(t);
|
||||
let called = false;
|
||||
await assert.rejects(
|
||||
jobs.withPreparedJob(
|
||||
f.tmp,
|
||||
f.env,
|
||||
{ ...f.selected, source: f.selected.source + '-stale' },
|
||||
async () => {
|
||||
called = true;
|
||||
},
|
||||
),
|
||||
/暂存更新已变化/,
|
||||
);
|
||||
assert.equal(called, false);
|
||||
assert.equal((await f.state()).status, 'ready');
|
||||
});
|
||||
|
||||
test('legacy manual reload without a job or bundle capability stays self-contained', async (t) => {
|
||||
const f = await manualUpgradeFixture(t);
|
||||
await fs.unlink(path.join(f.tmp, 'panel-upgrade.json'));
|
||||
await fs.unlink(path.join(f.tmp, 'upgrade-ready-develop.json'));
|
||||
const result = await jobs.withPreparedJob(
|
||||
f.tmp,
|
||||
f.env,
|
||||
f.selected,
|
||||
async (job) => {
|
||||
assert.equal(job, undefined);
|
||||
return 'legacy';
|
||||
},
|
||||
);
|
||||
assert.equal(result, 'legacy');
|
||||
await assert.rejects(fs.access(path.join(f.tmp, 'panel-upgrade.json')), {
|
||||
code: 'ENOENT',
|
||||
});
|
||||
});
|
||||
|
||||
for (const wrongVersion of [false, true]) {
|
||||
test(`Bash reload consumes offline readiness and records the actual result; wrong version=${wrongVersion}`, async (t) => {
|
||||
const f = await manualUpgradeFixture(t, wrongVersion);
|
||||
const operation = execute(
|
||||
process.execPath,
|
||||
[path.resolve('shell/upgrade.cjs'), 'reload'],
|
||||
{ env: f.env, timeout: 15000 },
|
||||
);
|
||||
if (wrongVersion) await assert.rejects(operation, /启动版本与更新包不一致/);
|
||||
else await operation;
|
||||
const state = await f.state();
|
||||
assert.equal(state.status, wrongVersion ? 'failed' : 'completed');
|
||||
assert.equal(state.action, 'apply');
|
||||
assert.notEqual(state.id, 'offline-prepared');
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'static/build/app.js'), 'utf8'),
|
||||
wrongVersion ? 'old backend' : 'new backend',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, '.env'), 'utf8'),
|
||||
'preserve configuration',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'data/keep'), 'utf8'),
|
||||
'preserve data',
|
||||
);
|
||||
if (wrongVersion)
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'node_modules/old'), 'utf8'),
|
||||
'old dependency',
|
||||
);
|
||||
else
|
||||
await assert.rejects(
|
||||
fs.access(path.join(f.tmp, 'upgrade-ready-develop.json')),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('manual application holds the launch lock and does not allow a competing panel job', async (t) => {
|
||||
const f = await manualUpgradeFixture(t);
|
||||
await assert.rejects(
|
||||
jobs.withPreparedJob(f.tmp, f.env, f.selected, async (job) => {
|
||||
assert.equal((await f.state()).status, 'running');
|
||||
await assert.rejects(
|
||||
jobs.startJob({ root: f.root, tmp: f.tmp, env: f.env }, 'apply'),
|
||||
/已有更新/,
|
||||
);
|
||||
await job.onStop();
|
||||
assert.equal((await f.state()).status, 'restarting');
|
||||
throw new Error('cancel manual operation');
|
||||
}),
|
||||
/cancel manual operation/,
|
||||
);
|
||||
assert.equal((await f.state()).status, 'failed');
|
||||
await assert.rejects(
|
||||
fs.access(path.join(f.tmp, 'panel-job-launch/upgrade.lock')),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const {
|
||||
manualUpgradeFixture,
|
||||
} = require('../helpers/manual-upgrade-fixture.cjs');
|
||||
const artifacts = require('../../cli/src/internal/maintenance/upgradeArtifacts.cjs');
|
||||
const { installBundle } = require('../../shell/install-bundle.cjs');
|
||||
|
||||
for (const scenario of ['cancel-new-start', 'both-starts-fail'])
|
||||
test(`offline application restores files and records failure: ${scenario}`, async (t) => {
|
||||
const f = await manualUpgradeFixture(t);
|
||||
t.mock.method(artifacts, 'stageUpgrade', async () => f.selected);
|
||||
const journal = path.join(f.root, 'lifecycle.jsonl');
|
||||
await fs.writeFile(
|
||||
path.join(f.root, 'lifecycle.cjs'),
|
||||
`
|
||||
const fs=require('node:fs'),path=require('node:path');
|
||||
const root=process.env.QL_DIR,op=process.argv[2];
|
||||
const version=fs.readFileSync(path.join(root,'static/build/app.js'),'utf8');
|
||||
fs.appendFileSync(${JSON.stringify(
|
||||
journal,
|
||||
)},JSON.stringify({op,version})+'\\n');
|
||||
if(op==='start') {
|
||||
if(${JSON.stringify(scenario)}==='both-starts-fail') process.exit(9);
|
||||
if(version==='new backend') {
|
||||
process.kill(Number(process.env.TEST_INSTALLER_PID),'SIGTERM');
|
||||
setTimeout(()=>{},10000);
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(f.root, 'shell/share.sh'),
|
||||
`
|
||||
import_config(){ :; }
|
||||
delete_pm2(){ "$TEST_NODE" "$QL_DIR/lifecycle.cjs" stop; }
|
||||
reload_pm2(){ "$TEST_NODE" "$QL_DIR/lifecycle.cjs" start; }
|
||||
`,
|
||||
);
|
||||
await assert.rejects(
|
||||
installBundle(
|
||||
['--root', f.root, '--file', path.join(f.root, 'unused.tar.gz')],
|
||||
{
|
||||
...f.env,
|
||||
TEST_NODE: process.execPath,
|
||||
TEST_INSTALLER_PID: String(process.pid),
|
||||
},
|
||||
),
|
||||
scenario === 'cancel-new-start' ? /离线升级已中断/ : /failed/,
|
||||
);
|
||||
const calls = (await fs.readFile(journal, 'utf8'))
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map(JSON.parse);
|
||||
assert.deepEqual(calls, [
|
||||
{ op: 'stop', version: 'old backend' },
|
||||
{ op: 'start', version: 'new backend' },
|
||||
{ op: 'stop', version: 'new backend' },
|
||||
{ op: 'start', version: 'old backend' },
|
||||
]);
|
||||
assert.equal((await f.state()).status, 'failed');
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'static/build/app.js'), 'utf8'),
|
||||
'old backend',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'node_modules/old'), 'utf8'),
|
||||
'old dependency',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, '.env'), 'utf8'),
|
||||
'preserve configuration',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'data/keep'), 'utf8'),
|
||||
'preserve data',
|
||||
);
|
||||
await fs.access(path.join(f.tmp, 'upgrade-ready-develop.json'));
|
||||
await assert.rejects(fs.access(path.join(f.tmp, 'upgrade.lock')), {
|
||||
code: 'ENOENT',
|
||||
});
|
||||
await assert.rejects(
|
||||
fs.access(path.join(f.tmp, 'panel-job-launch/upgrade.lock')),
|
||||
{ code: 'ENOENT' },
|
||||
);
|
||||
assert.ok(
|
||||
!(await fs.readdir(f.root)).some((n) => n.includes('.ql-backup-')),
|
||||
);
|
||||
});
|
||||
@@ -186,7 +186,7 @@ test('detached download survives its initiating request and is idempotent; apply
|
||||
);
|
||||
});
|
||||
|
||||
test('invalid staged payload is rejected before spawning a restart', async (t) => {
|
||||
test('invalid staged payload fails asynchronously before stopping the panel', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await startJob(f, 'download');
|
||||
assert.equal((await finished(f.tmp)).status, 'ready');
|
||||
@@ -197,9 +197,16 @@ test('invalid staged payload is rejected before spawning a restart', async (t) =
|
||||
path.join(f.tmp, pointer.directory, 'qinglong-static-master/build/app.js'),
|
||||
'corrupted',
|
||||
);
|
||||
await assert.rejects(startJob(f, 'apply'), /checksum|损坏/);
|
||||
const queued = await startJob(f, 'apply');
|
||||
assert.equal(queued.status, 'starting');
|
||||
const failed = await finished(f.tmp);
|
||||
assert.equal(failed.status, 'failed');
|
||||
assert.match(failed.error, /checksum|损坏/);
|
||||
assert.equal(syncFs.existsSync(path.join(f.root, 'stops')), false);
|
||||
assert.equal((await getStatus(f.tmp)).status, 'failed');
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'version.yaml'), 'utf8'),
|
||||
'version: 2.23.0',
|
||||
);
|
||||
});
|
||||
|
||||
test('failed download persists its failure without stopping the panel and can be retried', async (t) => {
|
||||
@@ -467,3 +474,15 @@ for (const knownSize of [true, false])
|
||||
assert.equal(reports.at(-1).downloadedBytes, 100);
|
||||
assert.equal(reports.at(-1).percent, knownSize ? 100 : null);
|
||||
});
|
||||
|
||||
test('asynchronous apply still rejects unsafe stage pointers before dispatch', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await startJob(f, 'download');
|
||||
assert.equal((await finished(f.tmp)).status, 'ready');
|
||||
await fs.writeFile(
|
||||
path.join(f.tmp, 'upgrade-ready-master.json'),
|
||||
JSON.stringify({ directory: '../outside' }),
|
||||
);
|
||||
await assert.rejects(startJob(f, 'apply'), /指针|pointer/);
|
||||
assert.equal(syncFs.existsSync(path.join(f.root, 'stops')), false);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,814 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const tar = require('tar');
|
||||
const bundle = require('../../cli/src/internal/maintenance/platformBundle.cjs');
|
||||
const artifacts = require('../../shell/upgrade.cjs');
|
||||
|
||||
for (const failure of ['prestart', 'repair'])
|
||||
test(`failed ${failure} preserves the installed dependencies and previous ready pointer`, async (t) => {
|
||||
const f = await fixture(t);
|
||||
const pointer = path.join(f.tmp, 'upgrade-ready-develop.json');
|
||||
await fs.writeFile(pointer, 'previous-ready');
|
||||
await fs.mkdir(path.join(f.root, 'node_modules'));
|
||||
await fs.writeFile(
|
||||
path.join(f.root, 'node_modules/keep'),
|
||||
'old dependency',
|
||||
);
|
||||
let repairs = 0;
|
||||
await assert.rejects(
|
||||
bundle.stageBundle({
|
||||
root: f.root,
|
||||
tmp: f.tmp,
|
||||
env: {
|
||||
QL_BRANCH: 'develop',
|
||||
QL_UPGRADE_FILE: f.archive,
|
||||
QL_UPGRADE_REPAIR_DEPS: '1',
|
||||
},
|
||||
verifyStatic: artifacts.verifyUpgrade,
|
||||
probe: async () => {},
|
||||
prestart: async () => {
|
||||
if (failure === 'prestart') throw new Error('SQLITE_CANTOPEN');
|
||||
throw Object.assign(new Error("Cannot find module 'decimal.js'"), {
|
||||
code: 'MODULE_NOT_FOUND',
|
||||
});
|
||||
},
|
||||
repair: async (source) => {
|
||||
repairs++;
|
||||
await fs.writeFile(
|
||||
path.join(source, 'node_modules/partial-repair'),
|
||||
'partial',
|
||||
);
|
||||
throw new Error('registry unavailable');
|
||||
},
|
||||
}),
|
||||
failure === 'prestart' ? /SQLITE_CANTOPEN/ : /registry unavailable/,
|
||||
);
|
||||
assert.equal(repairs, failure === 'prestart' ? 0 : 1);
|
||||
assert.equal(await fs.readFile(pointer, 'utf8'), 'previous-ready');
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'node_modules/keep'), 'utf8'),
|
||||
'old dependency',
|
||||
);
|
||||
assert.deepEqual(await fs.readdir(f.tmp), ['upgrade-ready-develop.json']);
|
||||
});
|
||||
|
||||
test('ready is published only after prestart; dependency failure repairs once and keeps application hashes', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const pointer = path.join(f.tmp, 'upgrade-ready-develop.json');
|
||||
await fs.writeFile(pointer, 'previous-ready');
|
||||
let probes = 0,
|
||||
repairs = 0,
|
||||
starts = 0;
|
||||
const options = {
|
||||
root: f.root,
|
||||
tmp: f.tmp,
|
||||
env: {
|
||||
QL_BRANCH: 'develop',
|
||||
QL_UPGRADE_FILE: f.archive,
|
||||
QL_UPGRADE_REPAIR_DEPS: '1',
|
||||
},
|
||||
verifyStatic: artifacts.verifyUpgrade,
|
||||
probe: async () => {
|
||||
if (++probes === 1)
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
'dlopen node_modules/sqlite3/node.node: invalid ELF header',
|
||||
),
|
||||
{ code: 'ERR_DLOPEN_FAILED' },
|
||||
);
|
||||
},
|
||||
repair: async (source) => {
|
||||
repairs++;
|
||||
await fs.writeFile(
|
||||
path.join(source, 'node_modules/sqlite3/repaired'),
|
||||
'rebuilt for this device',
|
||||
);
|
||||
},
|
||||
prestart: async () => {
|
||||
starts++;
|
||||
assert.equal(await fs.readFile(pointer, 'utf8'), 'previous-ready');
|
||||
},
|
||||
};
|
||||
const selected = await bundle.stageBundle(options);
|
||||
assert.equal(repairs, 1);
|
||||
assert.equal(probes, 2);
|
||||
assert.equal(starts, 1);
|
||||
const receipt = JSON.parse(
|
||||
await fs.readFile(
|
||||
path.join(path.dirname(selected.source), 'prestart.json'),
|
||||
),
|
||||
);
|
||||
assert.ok(receipt.completedAt);
|
||||
assert.ok(
|
||||
receipt.dependencyFiles[
|
||||
'source/node_modules/.pnpm/sqlite/node_modules/sqlite3/repaired'
|
||||
],
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(selected.source, 'version.yaml'),
|
||||
'version: 9.9.9',
|
||||
);
|
||||
await assert.rejects(bundle.verifyStagedBundle(selected), /checksums/);
|
||||
});
|
||||
|
||||
test('offline dependency failure never repairs by default; a failed retry keeps old readiness', async (t) => {
|
||||
const f = await fixture(t),
|
||||
pointer = path.join(f.tmp, 'upgrade-ready-develop.json');
|
||||
await fs.writeFile(pointer, 'previous-ready');
|
||||
let repairs = 0,
|
||||
starts = 0;
|
||||
const options = {
|
||||
root: f.root,
|
||||
tmp: f.tmp,
|
||||
env: { QL_BRANCH: 'develop', QL_UPGRADE_FILE: f.archive },
|
||||
verifyStatic: artifacts.verifyUpgrade,
|
||||
probe: async () => {},
|
||||
prestart: async () => {
|
||||
starts++;
|
||||
throw Object.assign(new Error("Cannot find module 'decimal.js'"), {
|
||||
code: 'MODULE_NOT_FOUND',
|
||||
});
|
||||
},
|
||||
repair: async () => {
|
||||
repairs++;
|
||||
},
|
||||
run: () => assert.fail('offline must not access network'),
|
||||
};
|
||||
await assert.rejects(
|
||||
bundle.stageBundle(options),
|
||||
/no network access attempted/,
|
||||
);
|
||||
assert.equal(repairs, 0);
|
||||
await assert.rejects(
|
||||
bundle.stageBundle({
|
||||
...options,
|
||||
env: { ...options.env, QL_UPGRADE_REPAIR_DEPS: '1' },
|
||||
}),
|
||||
/Cannot find module/,
|
||||
);
|
||||
assert.equal(repairs, 1);
|
||||
assert.equal(starts, 3);
|
||||
assert.equal(await fs.readFile(pointer, 'utf8'), 'previous-ready');
|
||||
assert.deepEqual(await fs.readdir(f.tmp), ['upgrade-ready-develop.json']);
|
||||
});
|
||||
|
||||
test('dependency repair cannot change verified source or static files', async (t) => {
|
||||
const f = await fixture(t);
|
||||
let starts = 0;
|
||||
await assert.rejects(
|
||||
bundle.stageBundle({
|
||||
root: f.root,
|
||||
tmp: f.tmp,
|
||||
env: {
|
||||
QL_BRANCH: 'develop',
|
||||
QL_UPGRADE_FILE: f.archive,
|
||||
QL_UPGRADE_REPAIR_DEPS: '1',
|
||||
},
|
||||
verifyStatic: artifacts.verifyUpgrade,
|
||||
probe: async () => {
|
||||
throw Object.assign(new Error("Cannot find module 'decimal.js'"), {
|
||||
code: 'MODULE_NOT_FOUND',
|
||||
});
|
||||
},
|
||||
repair: async (source) =>
|
||||
fs.writeFile(path.join(source, 'version.yaml'), 'version: 9.9.9'),
|
||||
prestart: async () => {
|
||||
starts++;
|
||||
},
|
||||
}),
|
||||
/checksums/,
|
||||
);
|
||||
assert.equal(starts, 0);
|
||||
assert.deepEqual(await fs.readdir(f.tmp), []);
|
||||
});
|
||||
|
||||
test('ARMv6 offline entry re-executes with an interpreted Node while preserving CLI errors', async (t) => {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-armv6-entry-'));
|
||||
t.after(() => fs.rm(directory, { recursive: true, force: true }));
|
||||
const preload = path.join(directory, 'armv6.cjs');
|
||||
const observed = path.join(directory, 'child.json');
|
||||
await fs.writeFile(
|
||||
preload,
|
||||
`if(process.execArgv.includes('--jitless'))require('node:fs').writeFileSync(${JSON.stringify(
|
||||
observed,
|
||||
)},JSON.stringify({args:process.execArgv,execPath:process.execPath}));else{Object.defineProperty(process,'arch',{value:'arm'});Object.defineProperty(process,'config',{value:{variables:{arm_version:6}}});}`,
|
||||
);
|
||||
const { execFile } = require('node:child_process');
|
||||
const result = await new Promise((resolve) =>
|
||||
execFile(
|
||||
process.execPath,
|
||||
[path.resolve(__dirname, '../../shell/install-bundle.cjs')],
|
||||
{
|
||||
timeout: 10000,
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_OPTIONS: '--require ' + JSON.stringify(preload),
|
||||
},
|
||||
},
|
||||
(error, stdout, stderr) => resolve({ error, stdout, stderr }),
|
||||
),
|
||||
);
|
||||
assert.equal(result.error.code, 1);
|
||||
const child = JSON.parse(await fs.readFile(observed));
|
||||
assert.ok(child.args.includes('--jitless'));
|
||||
assert.equal(child.execPath, process.execPath);
|
||||
assert.match(result.stderr, /Usage: node install-bundle.cjs/);
|
||||
});
|
||||
const {
|
||||
writeManifest,
|
||||
sourceCommit,
|
||||
} = require('../helpers/upgrade-fixture.cjs');
|
||||
const {
|
||||
releaseIndex,
|
||||
platforms,
|
||||
} = require('../../scripts/publish-upgrade-bundles.cjs');
|
||||
|
||||
async function fixture(t) {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-bundle-test-'));
|
||||
t.after(() => fs.rm(directory, { recursive: true, force: true }));
|
||||
const root = path.join(directory, 'installed'),
|
||||
tmp = path.join(root, '.tmp');
|
||||
const payload = path.join(directory, 'qinglong');
|
||||
const source = path.join(payload, 'source'),
|
||||
staticRoot = path.join(payload, 'static');
|
||||
await fs.mkdir(tmp, { recursive: true });
|
||||
await fs.mkdir(
|
||||
path.join(source, 'node_modules/.pnpm/sqlite/node_modules/sqlite3'),
|
||||
{ recursive: true },
|
||||
);
|
||||
await fs.symlink(
|
||||
'.pnpm/sqlite/node_modules/sqlite3',
|
||||
path.join(source, 'node_modules/sqlite3'),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(source, 'node_modules/sqlite3/package.json'),
|
||||
'{"name":"sqlite3"}',
|
||||
);
|
||||
await fs.mkdir(path.join(source, 'sample'));
|
||||
await fs.writeFile(path.join(source, 'sample/config.sample.sh'), '# config');
|
||||
await fs.writeFile(
|
||||
path.join(source, 'package.json'),
|
||||
'{"version":"2.24.0","qinglongUpgradeFormat":2}',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(root, 'package.json'),
|
||||
'{"qinglongUpgradeFormat":2}',
|
||||
);
|
||||
await fs.mkdir(path.join(staticRoot, 'build'), { recursive: true });
|
||||
await fs.writeFile(path.join(staticRoot, 'build/app.js'), 'backend');
|
||||
await fs.writeFile(path.join(source, 'version.yaml'), 'version: 2.24.0\n');
|
||||
await fs.mkdir(path.join(staticRoot, 'build/bootstrap'));
|
||||
await fs.writeFile(
|
||||
path.join(staticRoot, 'build/bootstrap/upgradeProbe.js'),
|
||||
`
|
||||
const fs=require('node:fs'),path=require('node:path'),crypto=require('node:crypto');
|
||||
if(process.env.NODE_OPTIONS || process.env.NODE_PATH || process.env.QL_CLI_ROOT)throw new Error('Leaked host environment');
|
||||
const info=JSON.parse(fs.readFileSync(path.join(__dirname,'../../build-info.json')));
|
||||
process.send({type:'upgrade-probe',ok:true,build:{version:'2.24.0',sourceCommit:info.sourceCommit,
|
||||
backendSha256:crypto.createHash('sha256').update(fs.readFileSync(path.join(__dirname,'../app.js'))).digest('hex')}});
|
||||
process.disconnect();
|
||||
`,
|
||||
);
|
||||
writeManifest(source, staticRoot);
|
||||
const target = await bundle.runtimeTarget();
|
||||
const manifest = {
|
||||
format: 2,
|
||||
sourceCommit,
|
||||
lockfileSha256: bundle.sha256(
|
||||
await fs.readFile(path.join(source, 'pnpm-lock.yaml')),
|
||||
),
|
||||
target: { ...target, libcMinimum: target.libcVersion },
|
||||
nodeMinimum: '20.18.1',
|
||||
nodeApi: 6,
|
||||
files: await bundle.inventory({ source, static: staticRoot }),
|
||||
};
|
||||
const archive = path.join(directory, 'package.tar.gz');
|
||||
const pack = async () => {
|
||||
await fs.writeFile(
|
||||
path.join(payload, 'bundle.json'),
|
||||
JSON.stringify(manifest),
|
||||
);
|
||||
await tar.c(
|
||||
{
|
||||
file: archive,
|
||||
cwd: directory,
|
||||
gzip: true,
|
||||
portable: true,
|
||||
noMtime: true,
|
||||
},
|
||||
['qinglong'],
|
||||
);
|
||||
};
|
||||
await pack();
|
||||
return {
|
||||
directory,
|
||||
root,
|
||||
tmp,
|
||||
source,
|
||||
staticRoot,
|
||||
target,
|
||||
manifest,
|
||||
archive,
|
||||
pack,
|
||||
};
|
||||
}
|
||||
|
||||
test('online and offline stage the same complete payload, preserve pnpm links and never install dependencies', async (t) => {
|
||||
const f = await fixture(t),
|
||||
bytes = await fs.readFile(f.archive),
|
||||
progress = [];
|
||||
const entry = {
|
||||
size: bytes.length,
|
||||
sha256: bundle.sha256(bytes),
|
||||
url: 'https://example.invalid/immutable.tar.gz',
|
||||
};
|
||||
let calls = 0,
|
||||
probes = 0;
|
||||
const run = async (program, args) => {
|
||||
assert.equal(program, 'curl');
|
||||
calls++;
|
||||
const output = args[args.indexOf('--output') + 1];
|
||||
if (args.at(-1).endsWith('latest.json'))
|
||||
await fs.writeFile(
|
||||
output,
|
||||
JSON.stringify({
|
||||
format: 2,
|
||||
sourceCommit,
|
||||
packages: { [bundle.targetKey(f.target)]: entry },
|
||||
}),
|
||||
);
|
||||
else {
|
||||
await fs.writeFile(output, bytes);
|
||||
await fs.writeFile(
|
||||
args[args.indexOf('--dump-header') + 1],
|
||||
'HTTP/2 200 OK\r\n\r\n',
|
||||
);
|
||||
}
|
||||
};
|
||||
const options = {
|
||||
root: f.root,
|
||||
tmp: f.tmp,
|
||||
env: { QL_BRANCH: 'develop' },
|
||||
run,
|
||||
verifyStatic: artifacts.verifyUpgrade,
|
||||
download: artifacts.downloadWithProgress,
|
||||
probe: async () => {
|
||||
probes++;
|
||||
},
|
||||
onProgress: async (p) => progress.push(p),
|
||||
};
|
||||
const online = await bundle.stageBundle(options);
|
||||
const first = await bundle.inventory(online);
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(probes, 1);
|
||||
assert.ok(progress.some((p) => p.percent === 100));
|
||||
assert.ok(progress.some((p) => p.percent === 0));
|
||||
const offline = await bundle.stageBundle({
|
||||
...options,
|
||||
env: { QL_BRANCH: 'develop', QL_UPGRADE_FILE: f.archive },
|
||||
run: () => assert.fail('offline network access'),
|
||||
});
|
||||
assert.equal(probes, 2);
|
||||
assert.deepEqual(await bundle.inventory(offline), first);
|
||||
assert.equal(
|
||||
await fs.readlink(path.join(offline.source, 'node_modules/sqlite3')),
|
||||
'.pnpm/sqlite/node_modules/sqlite3',
|
||||
);
|
||||
await assert.rejects(fs.access(online.source));
|
||||
});
|
||||
|
||||
test('native target compatibility distinguishes ARMv6/ARMv7 and libc and retains the existing Node requirement', async (t) => {
|
||||
const f = await fixture(t),
|
||||
target = {
|
||||
platform: 'linux',
|
||||
arch: 'armv6',
|
||||
libc: 'musl',
|
||||
libcVersion: '1.2.5',
|
||||
};
|
||||
const manifest = {
|
||||
...f.manifest,
|
||||
target: { ...target, libcMinimum: '1.2.4' },
|
||||
};
|
||||
bundle.assertCompatible(manifest, target, '20.20.2');
|
||||
const glibcTarget = {
|
||||
...target,
|
||||
libc: 'glibc',
|
||||
libcVersion: '2.31',
|
||||
endianness: 'LE',
|
||||
armFloatAbi: 'hard',
|
||||
};
|
||||
const glibcManifest = {
|
||||
...manifest,
|
||||
target: { ...glibcTarget, libcMinimum: '2.31' },
|
||||
};
|
||||
bundle.assertCompatible(glibcManifest, glibcTarget, '20.18.1');
|
||||
assert.equal(bundle.targetKey(glibcTarget), 'linux-armv6-glibc');
|
||||
assert.throws(
|
||||
() =>
|
||||
bundle.assertCompatible(glibcManifest, {
|
||||
...glibcTarget,
|
||||
libcVersion: '2.30',
|
||||
}),
|
||||
/requires glibc/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
bundle.assertCompatible(glibcManifest, {
|
||||
...glibcTarget,
|
||||
armFloatAbi: 'soft',
|
||||
}),
|
||||
/floating-point ABI mismatch/,
|
||||
);
|
||||
assert.throws(
|
||||
() => bundle.assertCompatible(manifest, { ...target, arch: 'armv7' }),
|
||||
/platform mismatch/,
|
||||
);
|
||||
assert.throws(
|
||||
() => bundle.assertCompatible(manifest, { ...target, libc: 'glibc' }),
|
||||
/platform mismatch/,
|
||||
);
|
||||
assert.throws(
|
||||
() => bundle.assertCompatible(manifest, target, '18.20.0'),
|
||||
/requires Node/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
bundle.assertCompatible(manifest, { ...target, libcVersion: '1.1.24' }),
|
||||
/requires musl/,
|
||||
);
|
||||
});
|
||||
|
||||
test('dependency tampering and payload link escapes are rejected', async (t) => {
|
||||
const f = await fixture(t),
|
||||
selected = { source: f.source, static: f.staticRoot };
|
||||
await bundle.verifyPayload(selected, f.manifest, f.target);
|
||||
await fs.writeFile(
|
||||
path.join(f.source, 'node_modules/sqlite3/package.json'),
|
||||
'{"tampered":true}',
|
||||
);
|
||||
await assert.rejects(
|
||||
bundle.verifyPayload(selected, f.manifest, f.target),
|
||||
/checksums/,
|
||||
);
|
||||
await fs.symlink('/etc/passwd', path.join(f.source, 'outside'));
|
||||
await assert.rejects(bundle.inventory(selected), /escapes/);
|
||||
});
|
||||
|
||||
test('archive traversal, absolute links and writes through internal symlinks are rejected before extraction', async (t) => {
|
||||
const f = await fixture(t),
|
||||
dest = path.join(f.directory, 'extract');
|
||||
await fs.mkdir(dest);
|
||||
await fs.symlink('/etc', path.join(f.directory, 'qinglong/bad'));
|
||||
await tar.c({ cwd: f.directory, file: f.archive, gzip: true }, [
|
||||
'qinglong/bad',
|
||||
]);
|
||||
await assert.rejects(bundle.extractArchive(f.archive, dest), /symlink/);
|
||||
await tar.c(
|
||||
{ cwd: f.directory, file: f.archive, gzip: true, prefix: '../outside' },
|
||||
['qinglong/source/package.json'],
|
||||
);
|
||||
await assert.rejects(bundle.extractArchive(f.archive, dest), /Unsafe/);
|
||||
await fs.mkdir(path.join(f.directory, 'qinglong/real'));
|
||||
await fs.writeFile(path.join(f.directory, 'qinglong/real/file'), 'value');
|
||||
await fs.symlink('real', path.join(f.directory, 'qinglong/link'));
|
||||
await tar.c({ cwd: f.directory, file: f.archive, gzip: true }, [
|
||||
'qinglong/link',
|
||||
'qinglong/link/file',
|
||||
]);
|
||||
await assert.rejects(
|
||||
bundle.extractArchive(f.archive, dest),
|
||||
/through a symlink/,
|
||||
);
|
||||
assert.deepEqual(await fs.readdir(dest), []);
|
||||
});
|
||||
|
||||
test('an incomplete or corrupt platform download cannot replace an existing ready pointer', async (t) => {
|
||||
const f = await fixture(t),
|
||||
pointer = path.join(f.tmp, 'upgrade-ready-develop.json');
|
||||
await fs.writeFile(pointer, 'previous-ready');
|
||||
await assert.rejects(
|
||||
bundle.stageBundle({
|
||||
root: f.root,
|
||||
tmp: f.tmp,
|
||||
env: { QL_BRANCH: 'develop', QL_UPGRADE_FILE: f.archive },
|
||||
verifyStatic: artifacts.verifyUpgrade,
|
||||
probe: async () => {
|
||||
throw new Error('native addon failed');
|
||||
},
|
||||
}),
|
||||
/native addon failed/,
|
||||
);
|
||||
assert.equal(await fs.readFile(pointer, 'utf8'), 'previous-ready');
|
||||
assert.equal(
|
||||
(await fs.readdir(f.tmp)).filter((n) => n.startsWith('upgrade-')).length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('publishing requires every platform and validates all immutable archive hashes', async (t) => {
|
||||
const f = await fixture(t),
|
||||
repository = 'whyour/qinglong';
|
||||
assert.throws(() => releaseIndex(f.directory, sourceCommit, repository));
|
||||
for (const target of platforms) {
|
||||
const filename = `qinglong-${sourceCommit}-${target}.tar.gz`,
|
||||
bytes = Buffer.from(target);
|
||||
await fs.writeFile(path.join(f.directory, filename), bytes);
|
||||
await fs.writeFile(
|
||||
path.join(f.directory, filename + '.json'),
|
||||
JSON.stringify({
|
||||
format: 2,
|
||||
sourceCommit,
|
||||
target,
|
||||
filename,
|
||||
size: bytes.length,
|
||||
sha256: bundle.sha256(bytes),
|
||||
}),
|
||||
);
|
||||
}
|
||||
const index = releaseIndex(f.directory, sourceCommit, repository);
|
||||
assert.equal(Object.keys(index.packages).length, 13);
|
||||
const armv6 = path.join(
|
||||
f.directory,
|
||||
`qinglong-${sourceCommit}-linux-armv6-glibc.tar.gz`,
|
||||
);
|
||||
const armv6Bytes = await fs.readFile(armv6);
|
||||
await fs.rm(armv6);
|
||||
assert.throws(
|
||||
() => releaseIndex(f.directory, sourceCommit, repository),
|
||||
/ENOENT/,
|
||||
);
|
||||
await fs.writeFile(armv6, armv6Bytes);
|
||||
await fs.appendFile(
|
||||
path.join(f.directory, `qinglong-${sourceCommit}-${platforms[0]}.tar.gz`),
|
||||
'corrupt',
|
||||
);
|
||||
assert.throws(
|
||||
() => releaseIndex(f.directory, sourceCommit, repository),
|
||||
/Unverified/,
|
||||
);
|
||||
});
|
||||
|
||||
test('fresh offline installation needs no package manager, keeps Node and publishes a complete directory', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await fs.writeFile(path.join(f.source, '.env.example'), 'QlPort=5700\n');
|
||||
await fs.writeFile(path.join(f.source, 'version.yaml'), 'version: 2.24.0\n');
|
||||
await fs.writeFile(
|
||||
path.join(f.source, 'node_modules/sqlite3/index.js'),
|
||||
`exports.Database=class {serialize(fn){fn()}run(){}get(sql,cb){cb(null,{value:42})}close(){}};`,
|
||||
);
|
||||
for (const name of ['sequelize', 'cron-parser', '@grpc/grpc-js', 'undici']) {
|
||||
const dir = path.join(f.source, 'node_modules', name);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(path.join(dir, 'index.js'), 'module.exports={};');
|
||||
}
|
||||
f.manifest.files = await bundle.inventory({
|
||||
source: f.source,
|
||||
static: f.staticRoot,
|
||||
});
|
||||
await f.pack();
|
||||
const destination = path.join(f.directory, 'fresh'),
|
||||
node = process.execPath;
|
||||
await require('../../shell/install-bundle.cjs').installBundle(
|
||||
['--root', destination, '--file', f.archive, '--install'],
|
||||
{ ...process.env, PATH: '', QL_BRANCH: 'develop' },
|
||||
);
|
||||
assert.equal(process.execPath, node);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(destination, '.env'), 'utf8'),
|
||||
'QlPort=5700\n',
|
||||
);
|
||||
assert.equal(
|
||||
(await fs.stat(path.join(destination, '.env'))).mode & 0o777,
|
||||
0o600,
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(destination, 'static/build/app.js'), 'utf8'),
|
||||
'backend',
|
||||
);
|
||||
assert.ok(
|
||||
(
|
||||
await fs.stat(path.join(destination, 'node_modules/sqlite3/package.json'))
|
||||
).isFile(),
|
||||
);
|
||||
assert.equal(
|
||||
(await fs.readdir(f.directory)).filter((n) =>
|
||||
n.startsWith('.qinglong-install-'),
|
||||
).length,
|
||||
0,
|
||||
);
|
||||
await fs.writeFile(path.join(destination, 'keep-user-data'), 'keep');
|
||||
await assert.rejects(
|
||||
require('../../shell/install-bundle.cjs').installBundle([
|
||||
'--root',
|
||||
destination,
|
||||
'--file',
|
||||
f.archive,
|
||||
'--install',
|
||||
]),
|
||||
/empty installation directory/,
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(destination, 'keep-user-data'), 'utf8'),
|
||||
'keep',
|
||||
);
|
||||
});
|
||||
|
||||
test('offline staging replaces stale panel readiness and refuses an active online job', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await fs.writeFile(path.join(f.source, 'version.yaml'), 'version: 2.24.0\n');
|
||||
await fs.writeFile(
|
||||
path.join(f.source, 'node_modules/sqlite3/index.js'),
|
||||
'exports.Database=class {serialize(fn){fn()}run(){}get(sql,cb){cb(null,{value:42})}close(){}};',
|
||||
);
|
||||
for (const name of ['sequelize', 'cron-parser', '@grpc/grpc-js', 'undici']) {
|
||||
const dir = path.join(f.source, 'node_modules', name);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(path.join(dir, 'index.js'), 'module.exports={};');
|
||||
}
|
||||
f.manifest.files = await bundle.inventory({
|
||||
source: f.source,
|
||||
static: f.staticRoot,
|
||||
});
|
||||
await f.pack();
|
||||
await fs.mkdir(path.join(f.root, 'shell'));
|
||||
await fs.writeFile(
|
||||
path.join(f.root, 'shell/share.sh'),
|
||||
'import_config() { :; }\n',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(f.root, 'shell/env.sh'),
|
||||
'export QL_BRANCH=develop\n',
|
||||
);
|
||||
const stateFile = path.join(f.tmp, 'panel-upgrade.json');
|
||||
await fs.writeFile(
|
||||
stateFile,
|
||||
JSON.stringify({
|
||||
id: 'previous-online-job',
|
||||
status: 'ready',
|
||||
phase: 'ready',
|
||||
}),
|
||||
);
|
||||
const install = require('../../shell/install-bundle.cjs').installBundle;
|
||||
await install(['--root', f.root, '--file', f.archive, '--download-only']);
|
||||
const state = JSON.parse(await fs.readFile(stateFile, 'utf8'));
|
||||
assert.equal(state.status, 'ready');
|
||||
assert.equal(state.sourceCommit, sourceCommit);
|
||||
assert.notEqual(state.id, 'previous-online-job');
|
||||
assert.equal(state.version, '2.24.0');
|
||||
const pointer = await fs.readFile(
|
||||
path.join(f.tmp, 'upgrade-ready-develop.json'),
|
||||
'utf8',
|
||||
);
|
||||
await fs.writeFile(
|
||||
stateFile,
|
||||
JSON.stringify({
|
||||
id: 'live-online-job',
|
||||
status: 'running',
|
||||
pid: process.pid,
|
||||
startedAt: Date.now(),
|
||||
}),
|
||||
);
|
||||
await assert.rejects(
|
||||
install(['--root', f.root, '--file', f.archive, '--download-only']),
|
||||
/已有更新/,
|
||||
);
|
||||
assert.equal(
|
||||
JSON.parse(await fs.readFile(stateFile, 'utf8')).id,
|
||||
'live-online-job',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.tmp, 'upgrade-ready-develop.json'), 'utf8'),
|
||||
pointer,
|
||||
);
|
||||
});
|
||||
|
||||
test('interrupting offline staging records failure, preserves prior readiness and releases both locks', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const { spawn } = require('node:child_process');
|
||||
await fs.writeFile(path.join(f.source, 'version.yaml'), 'version: 2.24.0\n');
|
||||
await fs.writeFile(
|
||||
path.join(f.source, 'node_modules/sqlite3/index.js'),
|
||||
'exports.Database=class {serialize(fn){fn()}run(){}get(sql,cb){setTimeout(()=>cb(null,{value:42}),10000)}close(){}};',
|
||||
);
|
||||
for (const name of ['sequelize', 'cron-parser', '@grpc/grpc-js', 'undici']) {
|
||||
const dir = path.join(f.source, 'node_modules', name);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(path.join(dir, 'index.js'), 'module.exports={};');
|
||||
}
|
||||
f.manifest.files = await bundle.inventory({
|
||||
source: f.source,
|
||||
static: f.staticRoot,
|
||||
});
|
||||
await f.pack();
|
||||
await fs.mkdir(path.join(f.root, 'shell'));
|
||||
await fs.writeFile(
|
||||
path.join(f.root, 'shell/share.sh'),
|
||||
'import_config() { :; }\n',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(f.root, 'shell/env.sh'),
|
||||
'export QL_BRANCH=develop\n',
|
||||
);
|
||||
const pointer = path.join(f.tmp, 'upgrade-ready-develop.json');
|
||||
await fs.writeFile(pointer, 'previous-good-readiness');
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
path.resolve(__dirname, '../../shell/install-bundle.cjs'),
|
||||
'--root',
|
||||
f.root,
|
||||
'--file',
|
||||
f.archive,
|
||||
'--download-only',
|
||||
],
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
t.after(() => {
|
||||
if (child.exitCode === null) child.kill('SIGKILL');
|
||||
});
|
||||
const exited = new Promise((resolve, reject) => {
|
||||
child.once('exit', resolve);
|
||||
child.once('error', reject);
|
||||
});
|
||||
let seen = false;
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const state = await fs
|
||||
.readFile(path.join(f.tmp, 'panel-upgrade.json'), 'utf8')
|
||||
.then(JSON.parse)
|
||||
.catch(() => ({}));
|
||||
if (state.phase === 'dependencies') {
|
||||
seen = true;
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
}
|
||||
assert.ok(seen);
|
||||
child.kill('SIGTERM');
|
||||
assert.equal(await exited, 1);
|
||||
assert.equal(await fs.readFile(pointer, 'utf8'), 'previous-good-readiness');
|
||||
assert.equal(
|
||||
JSON.parse(
|
||||
await fs.readFile(path.join(f.tmp, 'panel-upgrade.json'), 'utf8'),
|
||||
).status,
|
||||
'failed',
|
||||
);
|
||||
await assert.rejects(fs.access(path.join(f.tmp, 'upgrade.lock')));
|
||||
await assert.rejects(
|
||||
fs.access(path.join(f.tmp, 'panel-job-launch/upgrade.lock')),
|
||||
);
|
||||
});
|
||||
|
||||
test('portable release archives preserve the permission manifest after normalizing image scripts', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const script = path.join(f.source, 'sample/config.sample.sh');
|
||||
await fs.chmod(script, 0o777);
|
||||
const { normalizeModes } = require('../../scripts/build-upgrade-bundle.cjs');
|
||||
await normalizeModes(f.source);
|
||||
await normalizeModes(f.staticRoot);
|
||||
assert.equal((await fs.stat(script)).mode & 0o777, 0o755);
|
||||
f.manifest.files = await bundle.inventory({
|
||||
source: f.source,
|
||||
static: f.staticRoot,
|
||||
});
|
||||
await f.pack();
|
||||
const extracted = path.join(f.directory, 'roundtrip');
|
||||
await fs.mkdir(extracted);
|
||||
await bundle.extractArchive(f.archive, extracted);
|
||||
const selected = {
|
||||
source: path.join(extracted, 'qinglong/source'),
|
||||
static: path.join(extracted, 'qinglong/static'),
|
||||
};
|
||||
await bundle.verifyPayload(selected, f.manifest, f.target);
|
||||
await fs.chmod(path.join(selected.source, 'sample/config.sample.sh'), 0o777);
|
||||
await assert.rejects(
|
||||
bundle.verifyPayload(selected, f.manifest, f.target),
|
||||
/checksums/,
|
||||
);
|
||||
});
|
||||
|
||||
test('full dependency bundles get a bounded slow-link download budget instead of the old ten-minute limit', async (t) => {
|
||||
const f = await fixture(t);
|
||||
let observed;
|
||||
await assert.rejects(
|
||||
artifacts.downloadWithProgress(
|
||||
async (program, args) => {
|
||||
assert.equal(program, 'curl');
|
||||
observed = Number(args[args.indexOf('--max-time') + 1]);
|
||||
assert.ok(observed > 600 && observed <= 7200);
|
||||
assert.equal(args[args.indexOf('--speed-limit') + 1], '1024');
|
||||
throw new Error('stop before transfer');
|
||||
},
|
||||
path.join(f.directory, 'slow.tar.gz'),
|
||||
'https://example.invalid/package.tar.gz',
|
||||
'qinglong-bundle',
|
||||
async () => {},
|
||||
true,
|
||||
{ size: 24 * 1024 * 1024, sha256: 'a'.repeat(64) },
|
||||
),
|
||||
/stop before transfer/,
|
||||
);
|
||||
assert.ok(observed);
|
||||
});
|
||||
@@ -0,0 +1,398 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const {
|
||||
sha256,
|
||||
} = require('../../cli/src/internal/maintenance/platformBundle.cjs');
|
||||
const {
|
||||
platforms,
|
||||
publishBundles,
|
||||
} = require('../../scripts/publish-upgrade-bundles.cjs');
|
||||
|
||||
function fixture(t) {
|
||||
const directory = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql-publish-bundles-'),
|
||||
);
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const state = {
|
||||
releases: new Map(),
|
||||
head: 'a'.repeat(40),
|
||||
developHead: 'c'.repeat(40),
|
||||
calls: [],
|
||||
nextId: 1,
|
||||
};
|
||||
const options = {
|
||||
directory,
|
||||
commit: state.head,
|
||||
repository: 'whyour/qinglong',
|
||||
branch: 'master',
|
||||
version: '2.24.0',
|
||||
buildId: '100-1',
|
||||
};
|
||||
const write = (
|
||||
commit = options.commit,
|
||||
content = 'first',
|
||||
version = options.version,
|
||||
) => {
|
||||
for (const target of platforms) {
|
||||
const filename = `qinglong-${commit}-${target}.tar.gz`;
|
||||
const bytes = Buffer.from(`${version}/${content}/${target}`);
|
||||
fs.writeFileSync(path.join(directory, filename), bytes);
|
||||
fs.writeFileSync(
|
||||
path.join(directory, filename + '.json'),
|
||||
JSON.stringify({
|
||||
format: 2,
|
||||
sourceCommit: commit,
|
||||
version,
|
||||
target,
|
||||
filename,
|
||||
size: bytes.length,
|
||||
sha256: sha256(bytes),
|
||||
}),
|
||||
);
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(directory, 'install-bundle.cjs'),
|
||||
`// ${content} installer`,
|
||||
);
|
||||
};
|
||||
const notFound = () => {
|
||||
throw Object.assign(new Error('Not found'), {
|
||||
stderr: 'gh: Not Found (HTTP 404)',
|
||||
});
|
||||
};
|
||||
const gh = (...args) => {
|
||||
state.calls.push(args);
|
||||
if (args[0] === 'api') {
|
||||
const route = args[1];
|
||||
if (route.endsWith('/git/ref/heads/master')) return state.head;
|
||||
if (route.endsWith('/git/ref/heads/develop')) return state.developHead;
|
||||
if (route.includes('/releases/tags/')) {
|
||||
const tag = decodeURIComponent(route.split('/releases/tags/')[1]);
|
||||
if (state.lookupFailure === tag)
|
||||
throw Object.assign(new Error('Unavailable'), { stderr: 'HTTP 503' });
|
||||
const release = state.releases.get(tag);
|
||||
if (!release) return notFound();
|
||||
return JSON.stringify({
|
||||
assets: Array.from(release.values(), (a) => ({
|
||||
name: a.name,
|
||||
id: a.id,
|
||||
size: a.bytes.length,
|
||||
digest: a.digest,
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (route.includes('/releases/assets/')) {
|
||||
const id = Number(route.split('/').at(-1));
|
||||
for (const release of state.releases.values())
|
||||
for (const asset of release.values())
|
||||
if (asset.id === id) return asset.bytes.toString();
|
||||
return notFound();
|
||||
}
|
||||
}
|
||||
if (args[0] === 'release' && args[1] === 'create') {
|
||||
assert.ok(!state.releases.has(args[2]));
|
||||
state.releases.set(args[2], new Map());
|
||||
return '';
|
||||
}
|
||||
if (args[0] === 'release' && args[1] === 'upload') {
|
||||
const tag = args[2],
|
||||
release = state.releases.get(tag);
|
||||
assert.ok(release);
|
||||
const files = args.slice(3, args.indexOf('--repo'));
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
if (state.failBatchUpload && files.length > 1 && i === 4)
|
||||
throw new Error('Upload interrupted');
|
||||
const name = path.basename(files[i]),
|
||||
bytes = fs.readFileSync(files[i]);
|
||||
assert.ok(
|
||||
!release.has(name) || args.includes('--clobber'),
|
||||
'Batch assets must not be overwritten',
|
||||
);
|
||||
release.set(name, {
|
||||
name,
|
||||
id: state.nextId++,
|
||||
bytes: Buffer.from(bytes),
|
||||
digest: `sha256:${sha256(bytes)}`,
|
||||
});
|
||||
}
|
||||
if (files.length > 1) {
|
||||
if (state.corruptUploadedBatch)
|
||||
release.get(path.basename(files[0])).digest = 'sha256:wrong';
|
||||
if (state.changeHeadAfterUpload) state.head = 'b'.repeat(40);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
if (args[0] === 'release' && args[1] === 'edit') return '';
|
||||
throw new Error(`Unexpected gh call: ${JSON.stringify(args)}`);
|
||||
};
|
||||
const publish = (overrides = {}) =>
|
||||
publishBundles({ ...options, gh, ...overrides });
|
||||
const index = (tag = 'upgrade-master') =>
|
||||
JSON.parse(state.releases.get(tag).get('latest.json').bytes);
|
||||
write();
|
||||
return { directory, state, options, write, publish, index };
|
||||
}
|
||||
|
||||
test('publishes every platform and installer before advancing version and legacy master indexes', (t) => {
|
||||
const f = fixture(t);
|
||||
assert.equal(f.publish(), true);
|
||||
const index = f.index();
|
||||
assert.equal(index.version, '2.24.0');
|
||||
assert.equal(index.buildId, '100-1');
|
||||
assert.equal(index.sourceCommit, f.options.commit);
|
||||
assert.equal(Object.keys(index.packages).length, 13);
|
||||
assert.deepEqual(index, f.index('upgrade-v2.24.0'));
|
||||
const tag = 'upgrade-build-v2.24.0-100-1';
|
||||
assert.equal(f.state.releases.get(tag).size, 28);
|
||||
for (const entry of [...Object.values(index.packages), index.installer]) {
|
||||
const url = new URL(entry.url),
|
||||
filename = path.basename(url.pathname);
|
||||
assert.ok(url.pathname.includes(`/releases/download/${tag}/`));
|
||||
const bytes = f.state.releases.get(tag).get(filename).bytes;
|
||||
assert.equal(bytes.length, entry.size);
|
||||
assert.equal(sha256(bytes), entry.sha256);
|
||||
}
|
||||
const writes = f.state.calls.filter(
|
||||
(a) => a[0] === 'release' && a[1] === 'upload',
|
||||
);
|
||||
assert.deepEqual(
|
||||
writes.map((a) => a[2]),
|
||||
[tag, 'upgrade-v2.24.0', 'upgrade-master'],
|
||||
);
|
||||
assert.ok(writes.slice(1).every((a) => a.includes('--clobber')));
|
||||
});
|
||||
|
||||
for (const sameCommit of [false, true])
|
||||
test(`same-version replacement updates both indexes and retains in-flight downloads (${
|
||||
sameCommit ? 'same commit rerun' : 'new commit'
|
||||
})`, (t) => {
|
||||
const f = fixture(t);
|
||||
f.publish();
|
||||
const before = f.index();
|
||||
const oldAssets = new Map(
|
||||
Array.from(
|
||||
f.state.releases.get('upgrade-build-v2.24.0-100-1'),
|
||||
([name, a]) => [name, a.bytes.toString('base64')],
|
||||
),
|
||||
);
|
||||
const commit = sameCommit ? f.options.commit : 'b'.repeat(40);
|
||||
f.state.head = commit;
|
||||
f.write(commit, 'replacement');
|
||||
assert.equal(f.publish({ commit, buildId: '100-2' }), true);
|
||||
const after = f.index();
|
||||
assert.equal(after.version, before.version);
|
||||
assert.equal(after.buildId, '100-2');
|
||||
assert.equal(after.sourceCommit, commit);
|
||||
assert.deepEqual(after, f.index('upgrade-v2.24.0'));
|
||||
for (const target of platforms) {
|
||||
assert.notEqual(after.packages[target].url, before.packages[target].url);
|
||||
assert.notEqual(
|
||||
after.packages[target].sha256,
|
||||
before.packages[target].sha256,
|
||||
);
|
||||
}
|
||||
assert.notEqual(after.installer.url, before.installer.url);
|
||||
assert.notEqual(after.installer.sha256, before.installer.sha256);
|
||||
assert.deepEqual(
|
||||
new Map(
|
||||
Array.from(
|
||||
f.state.releases.get('upgrade-build-v2.24.0-100-1'),
|
||||
([name, a]) => [name, a.bytes.toString('base64')],
|
||||
),
|
||||
),
|
||||
oldAssets,
|
||||
);
|
||||
});
|
||||
|
||||
test('retrying a publication is idempotent, but changed bytes require a new build ID', (t) => {
|
||||
const f = fixture(t);
|
||||
f.publish();
|
||||
const count = () =>
|
||||
f.state.calls.filter(
|
||||
(a) =>
|
||||
a[0] === 'release' &&
|
||||
a[1] === 'upload' &&
|
||||
a[2] === 'upgrade-build-v2.24.0-100-1',
|
||||
).length;
|
||||
assert.equal(count(), 1);
|
||||
f.publish();
|
||||
assert.equal(count(), 1);
|
||||
f.write(f.options.commit, 'changed within the same attempt');
|
||||
assert.throws(() => f.publish(), /Immutable release asset differs/);
|
||||
assert.equal(f.index().buildId, '100-1');
|
||||
});
|
||||
|
||||
for (const fault of ['missing', 'corrupt', 'wrong-version'])
|
||||
test(`incomplete, corrupt or wrong-version packages never publish (${fault})`, (t) => {
|
||||
const f = fixture(t);
|
||||
const archive = path.join(
|
||||
f.directory,
|
||||
`qinglong-${f.options.commit}-linux-armv6-glibc.tar.gz`,
|
||||
);
|
||||
if (fault === 'missing') fs.rmSync(archive);
|
||||
else if (fault === 'corrupt') fs.appendFileSync(archive, 'corrupt');
|
||||
else {
|
||||
const record = JSON.parse(fs.readFileSync(archive + '.json'));
|
||||
record.version = '2.23.0';
|
||||
fs.writeFileSync(archive + '.json', JSON.stringify(record));
|
||||
}
|
||||
assert.throws(() => f.publish(), /ENOENT|Unverified/);
|
||||
assert.equal(f.state.calls.length, 0);
|
||||
});
|
||||
|
||||
for (const fault of ['failBatchUpload', 'corruptUploadedBatch'])
|
||||
test(`failed batch publication keeps both existing indexes (${fault})`, (t) => {
|
||||
const f = fixture(t);
|
||||
f.publish();
|
||||
const previous = f.index();
|
||||
f.write(f.options.commit, 'next');
|
||||
f.state[fault] = true;
|
||||
assert.throws(
|
||||
() => f.publish({ buildId: '100-2' }),
|
||||
/Upload interrupted|Published platform bundle verification failed/,
|
||||
);
|
||||
assert.deepEqual(f.index(), previous);
|
||||
assert.deepEqual(f.index('upgrade-v2.24.0'), previous);
|
||||
});
|
||||
|
||||
test('stale commits do not publish; a source change during upload cannot advance indexes', (t) => {
|
||||
const f = fixture(t);
|
||||
f.publish();
|
||||
const before = f.index();
|
||||
f.state.head = 'b'.repeat(40);
|
||||
assert.equal(f.publish({ buildId: '101-1' }), false);
|
||||
assert.ok(!f.state.releases.has('upgrade-build-v2.24.0-101-1'));
|
||||
f.state.head = f.options.commit;
|
||||
f.state.changeHeadAfterUpload = true;
|
||||
assert.equal(f.publish({ buildId: '102-1' }), false);
|
||||
assert.deepEqual(f.index(), before);
|
||||
assert.deepEqual(f.index('upgrade-v2.24.0'), before);
|
||||
});
|
||||
|
||||
test('an explicitly rerun earlier workflow may replace the same version and commit', (t) => {
|
||||
const f = fixture(t);
|
||||
f.publish({ buildId: '101-1' });
|
||||
const before = f.index();
|
||||
f.write(f.options.commit, 'rebuild from an earlier run');
|
||||
assert.equal(f.publish({ buildId: '100-2' }), true);
|
||||
assert.equal(f.index().buildId, '100-2');
|
||||
assert.deepEqual(f.index(), f.index('upgrade-v2.24.0'));
|
||||
assert.notEqual(
|
||||
f.index().packages[platforms[0]].sha256,
|
||||
before.packages[platforms[0]].sha256,
|
||||
);
|
||||
});
|
||||
|
||||
test('non-master branches, invalid build IDs and API failures cannot overwrite published versions', (t) => {
|
||||
const f = fixture(t);
|
||||
for (const branch of ['develop', 'v2.24.0', 'feature/test'])
|
||||
assert.throws(() => f.publish({ branch }), /Only master/);
|
||||
for (const buildId of [undefined, '100', '100-1/other'])
|
||||
assert.throws(() => f.publish({ buildId }), /Invalid bundle build ID/);
|
||||
assert.throws(
|
||||
() => f.publish({ version: '../bad' }),
|
||||
/Invalid bundle version/,
|
||||
);
|
||||
assert.equal(f.state.calls.length, 0);
|
||||
f.publish();
|
||||
const before = f.index();
|
||||
f.state.lookupFailure = 'upgrade-v2.24.0';
|
||||
assert.throws(() => f.publish({ buildId: '100-2' }), /Unavailable/);
|
||||
assert.deepEqual(f.index(), before);
|
||||
});
|
||||
|
||||
test('existing master clients can migrate from indexes without version/build metadata', (t) => {
|
||||
const f = fixture(t);
|
||||
f.publish();
|
||||
const asset = f.state.releases.get('upgrade-master').get('latest.json');
|
||||
const legacy = f.index();
|
||||
delete legacy.version;
|
||||
delete legacy.buildId;
|
||||
asset.bytes = Buffer.from(JSON.stringify(legacy));
|
||||
assert.equal(f.publish({ buildId: '100-2' }), true);
|
||||
assert.equal(f.index().buildId, '100-2');
|
||||
});
|
||||
|
||||
test('explicit development publication shares musl/glibc packages without changing version or latest production assets', (t) => {
|
||||
const f = fixture(t);
|
||||
f.publish();
|
||||
const production = f.index();
|
||||
f.write(f.state.developHead, 'development');
|
||||
assert.equal(
|
||||
f.publish({
|
||||
branch: 'develop',
|
||||
commit: f.state.developHead,
|
||||
allowDevelopment: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
const development = f.index('upgrade-develop');
|
||||
assert.equal(development.channel, 'develop');
|
||||
assert.equal(development.sourceCommit, f.state.developHead);
|
||||
assert.equal(Object.keys(development.packages).length, 13);
|
||||
assert.ok(development.packages['linux-x64-musl']);
|
||||
assert.ok(development.packages['linux-x64-glibc']);
|
||||
assert.ok(development.packages['linux-armv6-musl']);
|
||||
assert.ok(development.packages['linux-armv6-glibc']);
|
||||
for (const entry of [
|
||||
...Object.values(development.packages),
|
||||
development.installer,
|
||||
])
|
||||
assert.ok(entry.url.includes('/upgrade-build-develop-v2.24.0-100-1/'));
|
||||
assert.deepEqual(f.index(), production);
|
||||
assert.deepEqual(f.index('upgrade-v2.24.0'), production);
|
||||
assert.equal(f.state.releases.get('upgrade-build-v2.24.0-100-1').size, 28);
|
||||
assert.equal(
|
||||
f.state.releases.get('upgrade-build-develop-v2.24.0-100-1').size,
|
||||
28,
|
||||
);
|
||||
});
|
||||
|
||||
test('development builds require explicit boolean opt-in and cannot publish obsolete development source', (t) => {
|
||||
const f = fixture(t);
|
||||
for (const allowDevelopment of [false, undefined, 'true'])
|
||||
assert.throws(
|
||||
() => f.publish({ branch: 'develop', allowDevelopment }),
|
||||
/explicitly requested/,
|
||||
);
|
||||
assert.throws(
|
||||
() => f.publish({ branch: 'feature/test', allowDevelopment: true }),
|
||||
/Only master/,
|
||||
);
|
||||
assert.equal(f.state.calls.length, 0);
|
||||
f.write(f.state.developHead, 'development');
|
||||
const obsolete = f.state.developHead;
|
||||
f.state.developHead = 'd'.repeat(40);
|
||||
assert.equal(
|
||||
f.publish({ branch: 'develop', commit: obsolete, allowDevelopment: true }),
|
||||
false,
|
||||
);
|
||||
assert.equal(f.state.releases.size, 0);
|
||||
});
|
||||
|
||||
for (const fault of ['failBatchUpload', 'corruptUploadedBatch'])
|
||||
test(`failed manual development publication preserves both channels (${fault})`, (t) => {
|
||||
const f = fixture(t);
|
||||
f.publish();
|
||||
const production = f.index();
|
||||
const options = {
|
||||
branch: 'develop',
|
||||
commit: f.state.developHead,
|
||||
allowDevelopment: true,
|
||||
};
|
||||
f.write(options.commit, 'first development');
|
||||
f.publish(options);
|
||||
const previous = f.index('upgrade-develop');
|
||||
f.write(options.commit, 'next development');
|
||||
f.state[fault] = true;
|
||||
assert.throws(
|
||||
() => f.publish({ ...options, buildId: '100-2' }),
|
||||
/Upload interrupted|Published platform bundle verification failed/,
|
||||
);
|
||||
assert.deepEqual(f.index('upgrade-develop'), previous);
|
||||
assert.deepEqual(f.index(), production);
|
||||
assert.deepEqual(f.index('upgrade-v2.24.0'), production);
|
||||
});
|
||||
@@ -23,6 +23,9 @@ function fixture() {
|
||||
let invalidations = 0;
|
||||
const module = { exports: {} };
|
||||
const localRequire = createRequire(path.resolve('back/app.ts'));
|
||||
const middlewareRequire = createRequire(
|
||||
path.resolve('back/loaders/httpApplication.ts'),
|
||||
);
|
||||
const mocks = {
|
||||
'./config': { cors: { origin: ['http://localhost'] } },
|
||||
'./loaders/logger': { error() {}, warn() {}, info() {} },
|
||||
@@ -37,6 +40,33 @@ function fixture() {
|
||||
new Function('require', 'module', 'exports', 'process', compiled)(
|
||||
(name) => {
|
||||
required.push(name);
|
||||
if (name === './loaders/httpApplication') {
|
||||
const helper = { exports: {} };
|
||||
const code = ts.transpileModule(
|
||||
fs.readFileSync('back/loaders/httpApplication.ts', 'utf8'),
|
||||
{
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2020,
|
||||
esModuleInterop: true,
|
||||
},
|
||||
},
|
||||
).outputText;
|
||||
new Function('require', 'module', 'exports', code)(
|
||||
(dependency) => {
|
||||
required.push(dependency);
|
||||
const alias = dependency.startsWith('../')
|
||||
? './' + dependency.slice(3)
|
||||
: dependency;
|
||||
return Object.hasOwn(mocks, alias)
|
||||
? mocks[alias]
|
||||
: middlewareRequire(dependency);
|
||||
},
|
||||
helper,
|
||||
helper.exports,
|
||||
);
|
||||
return helper.exports;
|
||||
}
|
||||
return Object.hasOwn(mocks, name) ? mocks[name] : localRequire(name);
|
||||
},
|
||||
module,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const startup = fs.readFileSync('shell/start-simplify.sh', 'utf8');
|
||||
const preparation = startup.slice(
|
||||
startup.indexOf('\nfix_config\n'),
|
||||
startup.indexOf('log_with_style "INFO" "$(t \'🔄'),
|
||||
);
|
||||
for (const fails of [false, true]) {
|
||||
test(`manual startup ${
|
||||
fails
|
||||
? 'stops on certificate failure'
|
||||
: 'awaits certificates before starting supervision'
|
||||
}`, (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-start-certs-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const module = path.join(root, 'static/build/config/grpcCerts.js');
|
||||
fs.mkdirSync(path.dirname(module), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
module,
|
||||
`exports.initGrpcCerts = () => new Promise((resolve, reject) => setTimeout(() => {
|
||||
if (process.env.QA_CERT_FAILURE === '1') return reject(new Error('certificate preparation failed'));
|
||||
require('node:fs').writeFileSync(require('node:path').join(process.env.QL_DIR, 'certs-ready'), 'ready');
|
||||
resolve();
|
||||
}, 50));`,
|
||||
);
|
||||
const result = spawnSync(
|
||||
'/bin/bash',
|
||||
[
|
||||
'-c',
|
||||
`set -e
|
||||
fix_config(){ :; }
|
||||
pm2(){ test -f "$QL_DIR/certs-ready"; touch "$QL_DIR/supervisor-started"; }
|
||||
${preparation}`,
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
QL_DIR: root,
|
||||
QA_CERT_FAILURE: fails ? '1' : '0',
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(result.status, fails ? 1 : 0, result.stderr);
|
||||
assert.equal(fs.existsSync(path.join(root, 'supervisor-started')), !fails);
|
||||
if (fails) assert.match(result.stderr, /certificate preparation failed/);
|
||||
});
|
||||
}
|
||||
@@ -393,7 +393,10 @@ done
|
||||
QL_MIRROR_NOTIFY_REPO: 'fixture/source',
|
||||
QL_MIRROR_NOTIFY_ON_START: 'false',
|
||||
QL_MIRROR_INCLUDE_VERSION_BRANCH: 'false',
|
||||
QL_MIRROR_RETRY_AFTER_SECONDS: '1',
|
||||
// Bash SECONDS has one-second resolution. Two ticks guarantee a real
|
||||
// wait across a second boundary; a single tick may take only milliseconds.
|
||||
QL_MIRROR_RETRY_AFTER_SECONDS: '2',
|
||||
QL_MIRROR_WAIT_SECONDS: '8',
|
||||
QL_TEST_COUNT: countFile,
|
||||
QL_TEST_MIRROR: mirror,
|
||||
},
|
||||
@@ -403,6 +406,14 @@ done
|
||||
Date.now() - started >= 900,
|
||||
'notification should wait for the sync interval',
|
||||
);
|
||||
const retry = result.stdout.indexOf('Retrying the GitHub notification');
|
||||
assert.ok(retry >= 0, result.stdout);
|
||||
const elapsed = [
|
||||
...result.stdout
|
||||
.slice(0, retry)
|
||||
.matchAll(/Waiting for mirror \(attempt \d+, (\d+)\/8s\)/g),
|
||||
].at(-1);
|
||||
assert.ok(elapsed && Number(elapsed[1]) >= 2, result.stdout);
|
||||
assert.equal(fs.readFileSync(countFile, 'utf8'), 'notification\n');
|
||||
assert.equal(git('-C', source, 'rev-list', '--count', triggerRef), '1');
|
||||
assert.equal(git('-C', source, 'rev-parse', 'develop'), snapshot);
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const yaml = require('js-yaml');
|
||||
|
||||
const workflow = yaml.load(
|
||||
fs.readFileSync('.github/workflows/build-docker-image.yml', 'utf8'),
|
||||
);
|
||||
// These conditions use GitHub's boolean operators and format(), which have the
|
||||
// same semantics for the boolean/string contexts exercised here.
|
||||
const evaluate = (expression, github, inputs = {}) => {
|
||||
const run = (code) =>
|
||||
vm.runInNewContext(code, {
|
||||
github: { run_id: '100', run_attempt: '1', ...github },
|
||||
inputs,
|
||||
format: (template, ...values) =>
|
||||
template.replace(/\{(\d+)\}/g, (_, i) => values[i]),
|
||||
});
|
||||
const exact = expression.match(/^\$\{\{([\s\S]*?)\}\}$/);
|
||||
return exact
|
||||
? run(exact[1])
|
||||
: expression.replace(/\$\{\{([\s\S]*?)\}\}/g, (_, code) =>
|
||||
String(run(code)),
|
||||
);
|
||||
};
|
||||
|
||||
test('both bundle jobs require master or an explicit development dispatch', () => {
|
||||
assert.equal(
|
||||
workflow.on.workflow_dispatch.inputs.build_development_bundles.default,
|
||||
false,
|
||||
);
|
||||
const cases = [
|
||||
['push', 'refs/heads/develop', {}, false],
|
||||
['push', 'refs/heads/develop', { build_development_bundles: true }, false],
|
||||
['workflow_dispatch', 'refs/heads/develop', {}, false],
|
||||
[
|
||||
'workflow_dispatch',
|
||||
'refs/heads/develop',
|
||||
{ build_development_bundles: true },
|
||||
true,
|
||||
],
|
||||
[
|
||||
'workflow_dispatch',
|
||||
'refs/heads/develop',
|
||||
{ build_development_bundles: true, static_sync_only: true },
|
||||
false,
|
||||
],
|
||||
['push', 'refs/heads/master', {}, true],
|
||||
['workflow_dispatch', 'refs/heads/master', {}, true],
|
||||
[
|
||||
'workflow_dispatch',
|
||||
'refs/heads/master',
|
||||
{ static_sync_only: true },
|
||||
false,
|
||||
],
|
||||
['push', 'refs/tags/v2.24.0', {}, false],
|
||||
[
|
||||
'workflow_dispatch',
|
||||
'refs/tags/v2.24.0',
|
||||
{ build_development_bundles: true },
|
||||
false,
|
||||
],
|
||||
];
|
||||
for (const name of ['build-upgrade-bundles', 'publish-upgrade-bundles'])
|
||||
for (const [event_name, ref, inputs, expected] of cases)
|
||||
assert.equal(
|
||||
Boolean(evaluate(workflow.jobs[name].if, { event_name, ref }, inputs)),
|
||||
expected,
|
||||
`${name}: ${event_name} ${ref} ${JSON.stringify(inputs)}`,
|
||||
);
|
||||
assert.deepEqual(workflow.jobs['build-upgrade-bundles'].needs, [
|
||||
'build-alpine',
|
||||
'build-debian',
|
||||
]);
|
||||
assert.ok(
|
||||
workflow.jobs['build-upgrade-bundles'].steps.some(
|
||||
(step) =>
|
||||
step.env?.ALPINE_DIGEST ===
|
||||
'${{ needs.build-alpine.outputs.image-digest }}' &&
|
||||
step.env?.DEBIAN_DIGEST ===
|
||||
'${{ needs.build-debian.outputs.image-digest }}',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('manual development releases serialize separately while normal develop CI remains independent', () => {
|
||||
const group = (event_name, ref, inputs = {}, run_id = '100') =>
|
||||
evaluate(workflow.concurrency.group, { event_name, ref, run_id }, inputs);
|
||||
const master = group('push', 'refs/heads/master');
|
||||
const development = group('workflow_dispatch', 'refs/heads/develop', {
|
||||
build_development_bundles: true,
|
||||
});
|
||||
assert.equal(
|
||||
development,
|
||||
group(
|
||||
'workflow_dispatch',
|
||||
'refs/heads/develop',
|
||||
{ build_development_bundles: true },
|
||||
'101',
|
||||
),
|
||||
);
|
||||
assert.equal(master, group('push', 'refs/heads/master', {}, '101'));
|
||||
assert.notEqual(master, development);
|
||||
const push = group('push', 'refs/heads/develop');
|
||||
assert.notEqual(push, development);
|
||||
assert.notEqual(push, group('push', 'refs/heads/develop', {}, '101'));
|
||||
assert.equal(workflow.concurrency['cancel-in-progress'], false);
|
||||
});
|
||||
@@ -283,3 +283,54 @@ test('unhealthy, malformed and oversized health responses fail within the deadli
|
||||
await assert.rejects(waitForHealth({ QlPort: port }, 20), /health check|后端启动检查失败/);
|
||||
}
|
||||
});
|
||||
|
||||
test('ARMv6 startup can become healthy after the standard one-minute deadline', async (t) => {
|
||||
const { EventEmitter } = require('node:events');
|
||||
const descriptors = ['arch', 'config'].map((key) => [
|
||||
key,
|
||||
Object.getOwnPropertyDescriptor(process, key),
|
||||
]);
|
||||
t.after(() => {
|
||||
for (const [key, descriptor] of descriptors)
|
||||
Object.defineProperty(process, key, descriptor);
|
||||
});
|
||||
Object.defineProperty(process, 'arch', { value: 'arm', configurable: true });
|
||||
Object.defineProperty(process, 'config', {
|
||||
value: { variables: { arm_version: 6 } },
|
||||
configurable: true,
|
||||
});
|
||||
let now = 0;
|
||||
let probes = 0;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
t.mock.method(http, 'get', (_options, callback) => {
|
||||
const request = new EventEmitter();
|
||||
request.destroy = (error) => request.emit('error', error);
|
||||
queueMicrotask(() => {
|
||||
const response = new EventEmitter();
|
||||
response.statusCode = ++probes === 1 ? 503 : 200;
|
||||
callback(response);
|
||||
now = probes * 70000;
|
||||
response.emit(
|
||||
'data',
|
||||
JSON.stringify({
|
||||
code: response.statusCode,
|
||||
data: { status: probes > 1 ? 'ok' : 'starting' },
|
||||
}),
|
||||
);
|
||||
response.emit('end');
|
||||
});
|
||||
return request;
|
||||
});
|
||||
await waitForHealth({});
|
||||
assert.equal(probes, 2);
|
||||
assert.equal(now, 140000);
|
||||
const { startupHealthTimeout } = require('../../shell/upgrade.cjs');
|
||||
assert.equal(
|
||||
startupHealthTimeout({
|
||||
arch: 'arm',
|
||||
config: { variables: { arm_version: 7 } },
|
||||
}),
|
||||
60000,
|
||||
);
|
||||
assert.equal(startupHealthTimeout({ arch: 'arm64' }), 60000);
|
||||
});
|
||||
|
||||
@@ -286,3 +286,46 @@ npm_install_2() { printf '%s' "$1" > "$QL_DIR/installed"; exit_status=0; }
|
||||
);
|
||||
assert.deepEqual(await fs.readdir(f.tmp), []);
|
||||
});
|
||||
|
||||
test('offline selection and worker ownership do not leak into the restarted backend', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await fs.writeFile(
|
||||
path.join(f.root, 'shell/share.sh'),
|
||||
`${await fs.readFile('shell/share.sh', 'utf8')}
|
||||
pm2() { if [ "$1" = startOrGracefulReload ]; then node "$QL_DIR/report-runtime.cjs"; fi; }
|
||||
`,
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(f.root, 'report-runtime.cjs'),
|
||||
`
|
||||
require('node:fs').writeFileSync(process.env.QL_DIR + '/runtime.json', JSON.stringify({
|
||||
file: process.env.QL_UPGRADE_FILE ?? null,
|
||||
repair: process.env.QL_UPGRADE_REPAIR_DEPS ?? null,
|
||||
launcher: process.env.QL_UPGRADE_LAUNCHER_PID ?? null,
|
||||
custom: process.env.QA_USER_SETTING,
|
||||
branch: process.env.QL_BRANCH,
|
||||
}));
|
||||
`,
|
||||
);
|
||||
const env = Object.freeze({
|
||||
...f.env,
|
||||
QL_UPGRADE_FILE: path.join(f.root, 'offline.tar.gz'),
|
||||
QL_UPGRADE_REPAIR_DEPS: '1',
|
||||
QL_UPGRADE_LAUNCHER_PID: '12345',
|
||||
QA_USER_SETTING: 'preserved',
|
||||
QL_BRANCH: 'develop',
|
||||
});
|
||||
await withLifecycleHooks(f.root, f.tmp, env, (hook) => hook('start'));
|
||||
assert.deepEqual(
|
||||
JSON.parse(await fs.readFile(path.join(f.root, 'runtime.json'))),
|
||||
{
|
||||
file: null,
|
||||
repair: null,
|
||||
launcher: null,
|
||||
custom: 'preserved',
|
||||
branch: 'develop',
|
||||
},
|
||||
);
|
||||
assert.equal(env.QL_UPGRADE_FILE, path.join(f.root, 'offline.tar.gz'));
|
||||
assert.equal(env.QL_UPGRADE_REPAIR_DEPS, '1');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
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']);
|
||||
});
|
||||
Reference in New Issue
Block a user