Files
qinglong/test/back/panel-upgrade.test.cjs
T

489 lines
18 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const syncFs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const http = require('node:http');
const { execFileSync } = require('node:child_process');
const { randomUUID } = require('node:crypto');
const { setTimeout: delay } = require('node:timers/promises');
const {
startJob,
getStatus,
runJob,
} = require('../../cli/src/internal/maintenance/panelUpgrade.cjs');
const { downloadWithProgress } = require('../../shell/upgrade.cjs');
const {
writeManifest,
sourceCommit,
} = require('../helpers/upgrade-fixture.cjs');
async function fixture(t) {
const base = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-panel-job-'));
const root = path.join(base, 'panel'),
tmp = path.join(root, '.tmp');
const source = path.join(base, `qinglong-${sourceCommit}`);
const staticRoot = path.join(base, 'qinglong-static-master');
const write = async (name, contents) => {
await fs.mkdir(path.dirname(name), { recursive: true });
await fs.writeFile(name, contents);
};
for (const dir of [root, source]) {
await write(path.join(dir, 'package.json'), '{"version":"2.24.0"}');
await write(path.join(dir, 'pnpm-lock.yaml'), 'lock');
await write(path.join(dir, 'sample/config.sample.sh'), '# config');
}
await write(
path.join(root, 'node_modules/dependency'),
'installed dependency',
);
await write(path.join(root, 'version.yaml'), 'version: 2.23.0');
await write(path.join(source, 'version.yaml'), 'version: 2.24.0');
await write(path.join(staticRoot, 'build/app.js'), 'new backend');
writeManifest(source, staticRoot);
for (const repo of ['qinglong', 'qinglong-static'])
execFileSync(
'zip',
[
'-qr',
path.join(base, `${repo}.zip`),
repo === 'qinglong' ? path.basename(source) : path.basename(staticRoot),
],
{ cwd: base },
);
await write(
path.join(root, 'shell/share.sh'),
`
import_config() { :; }
delete_pm2() { printf stopped >> "$QL_DIR/stops"; }
reload_pm2() { printf started >> "$QL_DIR/starts"; }
npm_install_2() { mkdir -p "$1/node_modules"; printf installed > "$1/node_modules/dependency"; exit_status=0; }
`,
);
await write(path.join(root, 'shell/env.sh'), '');
await write(
path.join(base, 'bin/curl'),
`#!${process.execPath}
const fs = require('node:fs'), path = require('node:path');
const args = process.argv.slice(2);
if (process.env.CHECK_WORKER_PARENT === 'true') {
const { execFileSync } = require('node:child_process');
const parent = Number(execFileSync('ps', ['-o', 'ppid=', '-p', String(process.ppid)], { encoding: 'utf8' }).trim());
fs.writeFileSync(path.join(process.env.ARCHIVES, 'worker-parent.json'), JSON.stringify({ worker: process.ppid, parent }));
}
const repo = args.at(-1).includes('/qinglong-static/') ? 'qinglong-static' : 'qinglong';
if (process.env.FAIL_DOWNLOAD === 'true') process.exit(22);
const buffer = fs.readFileSync(path.join(process.env.ARCHIVES, repo + '.zip'));
fs.writeFileSync(args[args.indexOf('--dump-header') + 1], 'HTTP/1.1 200 OK\\r\\nContent-Length: ' + buffer.length + '\\r\\n\\r\\n');
fs.writeFileSync(args[args.indexOf('--output') + 1], buffer);
`,
);
await fs.chmod(path.join(base, 'bin/curl'), 0o700);
const runtime = {
version: '2.24.0',
sourceCommit,
instanceId: 'after-restart',
parentPid: 100002,
backendSha256: JSON.parse(
await fs.readFile(path.join(staticRoot, 'build-info.json'), 'utf8'),
).files['build/app.js'],
};
const server = http.createServer((req, res) => {
res.setHeader('Content-Type', 'application/json');
res.end(
JSON.stringify({
code: 200,
data: req.url.endsWith('/api/system')
? { version: runtime.version }
: {
status: 'ok',
build: {
...runtime,
instanceId: syncFs.existsSync(path.join(root, 'starts'))
? runtime.instanceId
: 'before-restart',
parentPid: syncFs.existsSync(path.join(root, 'starts'))
? runtime.parentPid
: 100001,
},
},
}),
);
});
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
const env = {
...process.env,
PATH: `${base}/bin:${process.env.PATH}`,
ARCHIVES: base,
QL_BRANCH: 'master',
QlPort: String(server.address().port),
};
t.after(async () => {
server.close();
await fs.rm(base, { recursive: true, force: true });
});
return { root, tmp, env, base, runtime };
}
async function finished(tmp) {
for (let i = 0; i < 200; i++) {
const status = await getStatus(tmp);
if (['ready', 'completed', 'failed'].includes(status.status)) return status;
await delay(25);
}
assert.fail('Worker did not finish');
}
test('panel jobs are reparented before running outside the initiating backend process tree', async (t) => {
const f = await fixture(t);
f.env.CHECK_WORKER_PARENT = 'true';
await startJob(f, 'download');
const ready = await finished(f.tmp);
assert.equal(ready.status, 'ready', ready.log);
const relationship = JSON.parse(
await fs.readFile(path.join(f.base, 'worker-parent.json'), 'utf8'),
);
assert.equal(relationship.worker, ready.pid);
assert.notEqual(
relationship.parent,
process.pid,
'PM2 must not find the upgrade worker in the backend process tree',
);
});
test('detached download survives its initiating request and is idempotent; applying is explicit', async (t) => {
const f = await fixture(t);
const [first, duplicate] = await Promise.all([
startJob(f, 'download'),
startJob(f, 'download'),
]);
assert.equal(first.id, duplicate.id);
const ready = await finished(f.tmp);
assert.equal(ready.status, 'ready', ready.log);
assert.equal(ready.version, '2.24.0');
assert.equal(ready.sourceCommit, sourceCommit);
assert.equal(syncFs.existsSync(path.join(f.root, 'stops')), false);
assert.equal((await startJob(f, 'download')).id, ready.id);
const applying = await startJob(f, 'apply');
assert.notEqual(applying.id, ready.id);
const complete = await finished(f.tmp);
assert.equal(complete.status, 'completed', complete.log);
assert.equal(
await fs.readFile(path.join(f.root, 'stops'), 'utf8'),
'stopped',
);
assert.equal(
await fs.readFile(path.join(f.root, 'version.yaml'), 'utf8'),
'version: 2.24.0',
);
assert.equal(
await fs.readFile(path.join(f.root, 'node_modules/dependency'), 'utf8'),
'installed dependency',
);
});
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');
const pointer = JSON.parse(
await fs.readFile(path.join(f.tmp, 'upgrade-ready-master.json'), 'utf8'),
);
await fs.writeFile(
path.join(f.tmp, pointer.directory, 'qinglong-static-master/build/app.js'),
'corrupted',
);
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 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) => {
const f = await fixture(t);
await startJob(
{ ...f, env: { ...f.env, FAIL_DOWNLOAD: 'true' } },
'download',
);
const failed = await finished(f.tmp);
assert.equal(failed.status, 'failed');
assert.match(failed.log, /curl failed/);
assert.equal(syncFs.existsSync(path.join(f.root, 'stops')), false);
await startJob(f, 'download');
assert.equal((await finished(f.tmp)).status, 'ready');
});
test('orphaned jobs become retryable and status log reads are bounded', async (t) => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-orphan-job-'));
t.after(() => fs.rm(tmp, { recursive: true, force: true }));
await fs.writeFile(
path.join(tmp, 'panel-upgrade.json'),
JSON.stringify({ status: 'starting', startedAt: 0 }),
);
await fs.writeFile(path.join(tmp, 'panel-upgrade.log'), 'x'.repeat(100000));
const result = await getStatus(tmp);
assert.equal(result.status, 'failed');
assert.equal(Buffer.byteLength(result.log), 32768);
});
test('a healthy backend with the wrong version rolls the update back', async (t) => {
const f = await fixture(t);
await startJob(f, 'download');
assert.equal((await finished(f.tmp)).status, 'ready');
f.runtime.version = '2.23.0';
await startJob(f, 'apply');
const failed = await finished(f.tmp);
assert.equal(failed.status, 'failed', failed.log);
assert.match(failed.error, /版本/);
assert.equal(
await fs.readFile(path.join(f.root, 'version.yaml'), 'utf8'),
'version: 2.23.0',
);
assert.equal(
await fs.readFile(path.join(f.root, 'starts'), 'utf8'),
'startedstarted',
);
});
for (const [name, patch] of [
['same version but old commit', { sourceCommit: 'b'.repeat(40) }],
['same commit but wrong backend', { backendSha256: '0'.repeat(64) }],
['process did not restart', { instanceId: 'before-restart' }],
['only the HTTP worker restarted', { parentPid: 100001 }],
])
test(`a healthy backend cannot claim update success: ${name}`, async (t) => {
const f = await fixture(t);
await startJob(f, 'download');
assert.equal((await finished(f.tmp)).status, 'ready');
Object.assign(f.runtime, patch);
await startJob(f, 'apply');
const status = await finished(f.tmp);
assert.equal(status.status, 'failed', status.log);
assert.match(status.error, /版本/);
assert.equal(
await fs.readFile(path.join(f.root, 'version.yaml'), 'utf8'),
'version: 2.23.0',
);
});
for (const name of ['package.json', 'pnpm-lock.yaml', 'node_modules'])
test(`reused dependencies are checked again before stopping: ${name}`, async (t) => {
const f = await fixture(t);
await startJob(f, 'download');
assert.equal((await finished(f.tmp)).status, 'ready');
if (name === 'node_modules')
await fs.rm(path.join(f.root, name), { recursive: true });
else await fs.writeFile(path.join(f.root, name), 'changed after download');
await assert.rejects(startJob(f, 'apply'), /依赖已变化/);
assert.equal(syncFs.existsSync(path.join(f.root, 'stops')), false);
});
test('changed dependencies are fully staged and installed instead of reusing the current tree', async (t) => {
const f = await fixture(t);
await fs.writeFile(path.join(f.root, 'pnpm-lock.yaml'), 'old lock');
await startJob(f, 'download');
assert.equal((await finished(f.tmp)).status, 'ready');
await startJob(f, 'apply');
const status = await finished(f.tmp);
assert.equal(status.status, 'completed', status.log);
assert.equal(
await fs.readFile(path.join(f.root, 'node_modules/dependency'), 'utf8'),
'installed',
);
assert.equal(
await fs.readFile(path.join(f.root, 'pnpm-lock.yaml'), 'utf8'),
'lock',
);
});
async function replaceStage(f) {
const pointer = path.join(f.tmp, 'upgrade-ready-master.json');
const selected = JSON.parse(await fs.readFile(pointer, 'utf8'));
const directory = `upgrade-${randomUUID()}`;
const workspace = path.join(f.tmp, directory);
await fs.cp(path.join(f.tmp, selected.directory), workspace, {
recursive: true,
});
const readyFile = path.join(workspace, 'ready.json');
const ready = JSON.parse(await fs.readFile(readyFile, 'utf8'));
ready.source = path.join(workspace, 'qinglong-master');
ready.static = path.join(workspace, 'qinglong-static-master');
await fs.writeFile(readyFile, JSON.stringify(ready));
await fs.writeFile(pointer, JSON.stringify({ directory }));
}
test('replacing even a same-commit stage requires explicit preparation again', async (t) => {
const f = await fixture(t);
await startJob(f, 'download');
assert.equal((await finished(f.tmp)).status, 'ready');
await replaceStage(f);
await assert.rejects(startJob(f, 'apply'), /暂存更新已变化/);
assert.equal(syncFs.existsSync(path.join(f.root, 'stops')), false);
});
test('the worker revalidates its accepted target under the upgrade lock', async (t) => {
const f = await fixture(t);
await startJob(f, 'download');
const ready = await finished(f.tmp);
assert.equal(ready.status, 'ready');
const accepted = {
...ready,
id: randomUUID(),
status: 'starting',
action: 'apply',
};
delete accepted.log;
await fs.writeFile(
path.join(f.tmp, 'panel-upgrade.json'),
JSON.stringify(accepted),
);
await replaceStage(f);
await runJob(f, accepted.id);
const failed = await getStatus(f.tmp);
assert.equal(failed.status, 'failed');
assert.match(failed.error, /暂存更新已变化/);
assert.equal(syncFs.existsSync(path.join(f.root, 'stops')), false);
});
test('an in-progress download cannot be reported as an accepted restart', async (t) => {
const f = await fixture(t);
await fs.mkdir(f.tmp, { recursive: true });
await fs.writeFile(
path.join(f.tmp, 'panel-upgrade.json'),
JSON.stringify({
id: randomUUID(),
status: 'running',
action: 'download',
pid: process.pid,
}),
);
await assert.rejects(startJob(f, 'apply'), /已有更新/);
assert.equal(syncFs.existsSync(path.join(f.root, 'stops')), false);
});
async function selectCli(f) {
const cliRoot = path.join(f.base, 'selected-cli');
const write = async (relative, contents) => {
const file = path.join(cliRoot, 'dist/internal', relative);
await fs.mkdir(path.dirname(file), { recursive: true });
await fs.writeFile(file, contents);
};
await write(
'runtime/context.js',
`exports.createContext = (values, env) => ({ root: values.root, env, paths: { file_config_user: values.root + '/data/config/config.sh', dir_preload: values.root + '/shell/preload' } }); exports.sourceEnvironment = async env => env;`,
);
await write(
'runtime/cancellation.js',
`exports.cancellableOperation = async (signal, operation) => { signal?.throwIfAborted(); return operation(); };`,
);
await write(
'maintenance/operator.js',
`
const fs = require('node:fs/promises'), path = require('node:path');
exports.stopPanel = ctx => fs.appendFile(path.join(ctx.root, 'cli-stops'), 'stopped');
exports.startPanel = ctx => fs.appendFile(path.join(ctx.root, 'starts'), 'started');
exports.installPanelDependencies = async (ctx, source) => {
await fs.mkdir(path.join(source, 'node_modules'), { recursive: true });
await fs.writeFile(path.join(source, 'node_modules/dependency'), 'selected-cli');
};`,
);
await write(
'maintenance/upgradeSelection.js',
`
const fs = require('node:fs/promises'), path = require('node:path');
exports.prepareUpgradeSelection = async (ctx) => {
const directory = await fs.mkdtemp(path.join(ctx.root, '.tmp/selected-loader-'));
const target = path.join(ctx.root, 'static/build/loaders/deps.js');
const source = path.join(directory, 'deps.js');
await fs.copyFile(target, source);
return { directory, source, target };
};`,
);
const loader = path.join(f.root, 'static/build/loaders/deps.js');
await fs.mkdir(path.dirname(loader), { recursive: true });
await fs.writeFile(loader, '// selected CLI loader');
f.env.QL_CLI_ROOT = cliRoot;
return loader;
}
for (const fail of [false, true])
test(`selected CLI lifecycle and loader survive panel updating; rollback=${fail}`, async (t) => {
const f = await fixture(t);
const loader = await selectCli(f);
await fs.writeFile(path.join(f.root, 'pnpm-lock.yaml'), 'old lock');
await startJob(f, 'download');
const ready = await finished(f.tmp);
assert.equal(ready.status, 'ready', ready.log);
if (fail) f.runtime.sourceCommit = 'b'.repeat(40);
await startJob(f, 'apply');
const status = await finished(f.tmp);
assert.equal(status.status, fail ? 'failed' : 'completed', status.log);
assert.equal(await fs.readFile(loader, 'utf8'), '// selected CLI loader');
assert.equal(
syncFs.existsSync(path.join(f.root, 'stops')),
false,
'legacy broad shell stop was not used',
);
assert.match(
await fs.readFile(path.join(f.root, 'cli-stops'), 'utf8'),
/stopped/,
);
if (!fail)
assert.equal(
await fs.readFile(path.join(f.root, 'node_modules/dependency'), 'utf8'),
'selected-cli',
);
});
for (const knownSize of [true, false])
test(`download progress measures real bytes and handles unknown totals; known=${knownSize}`, async (t) => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-progress-'));
t.after(() => fs.rm(tmp, { recursive: true, force: true }));
const reports = [];
await downloadWithProgress(
async (program, args) => {
assert.equal(program, 'curl');
await fs.writeFile(
args[args.indexOf('--dump-header') + 1],
`HTTP/1.1 302 Found\r\nContent-Length: 1\r\n\r\nHTTP/2 200 OK\r\n${
knownSize ? 'Content-Length: 100\r\n' : ''
}\r\n`,
);
const archive = args[args.indexOf('--output') + 1];
await fs.writeFile(archive, Buffer.alloc(50));
await delay(1150);
await fs.writeFile(archive, Buffer.alloc(100));
},
path.join(tmp, 'file.zip'),
'https://example.test/file.zip',
'qinglong',
async (report) => reports.push(report),
);
assert.ok(reports.some((r) => r.downloadedBytes === 50));
assert.equal(reports[0].percent, knownSize ? 50 : null);
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);
});