mirror of
https://github.com/whyour/qinglong.git
synced 2026-10-18 18:33:06 +08:00
feat: stage online upgrades before verified restarts
Download updates asynchronously with persistent progress and validate matching source and build artifacts before an explicit restart. Keep upgrade workers alive across service restarts, verify the new runtime, and recover the browser with a one-time success notice. Publish immutable archive sizes and checksums for accurate download progress. Align the update dialog, hide unavailable actions during transfers, and show the sidebar notice only for active, ready or failed jobs. Constrain long account names with ellipsis while preserving avatar and collapse controls.
This commit is contained in:
@@ -0,0 +1,469 @@
|
||||
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 is rejected before spawning a restart', 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',
|
||||
);
|
||||
await assert.rejects(startJob(f, 'apply'), /checksum|损坏/);
|
||||
assert.equal(syncFs.existsSync(path.join(f.root, 'stops')), false);
|
||||
assert.equal((await getStatus(f.tmp)).status, 'failed');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
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 { createHash } = require('node:crypto');
|
||||
const ts = require('typescript');
|
||||
|
||||
function load(root) {
|
||||
const module = { exports: {} };
|
||||
const output = ts.transpileModule(
|
||||
fs.readFileSync('back/shared/runtimeBuild.ts', 'utf8'),
|
||||
{
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2020,
|
||||
esModuleInterop: true,
|
||||
},
|
||||
},
|
||||
).outputText;
|
||||
new Function('require', 'module', 'exports', '__dirname', 'process', output)(
|
||||
require,
|
||||
module,
|
||||
module.exports,
|
||||
path.join(root, 'static/build/shared'),
|
||||
{ env: { QL_DIR: root } },
|
||||
);
|
||||
return module.exports.runtimeBuild;
|
||||
}
|
||||
|
||||
test('a running process retains its own build identity when installed files change', (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-runtime-build-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
fs.mkdirSync(path.join(root, 'static/build'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'version.yaml'), 'version: 2.24.0');
|
||||
fs.writeFileSync(path.join(root, 'static/build/app.js'), 'old backend');
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'static/build-info.json'),
|
||||
JSON.stringify({ sourceCommit: 'a'.repeat(40) }),
|
||||
);
|
||||
const running = load(root);
|
||||
fs.writeFileSync(path.join(root, 'static/build/app.js'), 'new backend');
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'static/build-info.json'),
|
||||
JSON.stringify({ sourceCommit: 'b'.repeat(40) }),
|
||||
);
|
||||
fs.writeFileSync(path.join(root, 'version.yaml'), 'version: 2.25.0');
|
||||
assert.equal(running.version, '2.24.0');
|
||||
assert.equal(running.sourceCommit, 'a'.repeat(40));
|
||||
assert.equal(
|
||||
running.backendSha256,
|
||||
createHash('sha256').update('old backend').digest('hex'),
|
||||
);
|
||||
const restarted = load(root);
|
||||
assert.equal(restarted.version, '2.25.0');
|
||||
assert.equal(restarted.sourceCommit, 'b'.repeat(40));
|
||||
assert.notEqual(restarted.backendSha256, running.backendSha256);
|
||||
assert.notEqual(restarted.instanceId, running.instanceId);
|
||||
assert.equal(Object.isFrozen(running), true);
|
||||
});
|
||||
|
||||
test('unbuilt development environments keep health available without claiming a verified build', (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-runtime-unbuilt-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
assert.equal(load(root).sourceCommit, undefined);
|
||||
fs.mkdirSync(path.join(root, 'static'), { recursive: true });
|
||||
for (const content of ['broken', 'null', '42']) {
|
||||
fs.writeFileSync(path.join(root, 'static/build-info.json'), content);
|
||||
assert.equal(load(root).backendSha256, undefined);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
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 { execFileSync } = require('node:child_process');
|
||||
const { createHash } = require('node:crypto');
|
||||
const {
|
||||
publishUpgradeInfo,
|
||||
} = require('../../scripts/publish-upgrade-info.cjs');
|
||||
const {
|
||||
stageUpgrade,
|
||||
downloadWithProgress,
|
||||
} = require('../../shell/upgrade.cjs');
|
||||
const { writeManifest } = require('../helpers/upgrade-fixture.cjs');
|
||||
const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
||||
|
||||
function fixture(t) {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-download-info-'));
|
||||
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
const git = (...args) =>
|
||||
execFileSync('git', args, { encoding: 'utf8', stdio: 'pipe' }).trim();
|
||||
const source = path.join(tmp, 'source'),
|
||||
built = path.join(tmp, 'built'),
|
||||
remote = path.join(tmp, 'remote.git');
|
||||
for (const dir of [source]) {
|
||||
git('init', '-b', 'develop', dir);
|
||||
git('-C', dir, 'config', 'user.name', 'Fixture');
|
||||
git('-C', dir, 'config', 'user.email', 'fixture@example.invalid');
|
||||
}
|
||||
fs.mkdirSync(path.join(source, 'sample'));
|
||||
fs.writeFileSync(path.join(source, 'package.json'), '{"version":"2.24.0"}');
|
||||
fs.writeFileSync(path.join(source, 'pnpm-lock.yaml'), 'lock');
|
||||
fs.writeFileSync(path.join(source, 'sample/config.sample.sh'), '# config');
|
||||
git('-C', source, 'add', '.');
|
||||
git('-C', source, 'commit', '-m', 'source');
|
||||
const sourceCommit = git('-C', source, 'rev-parse', 'HEAD');
|
||||
fs.mkdirSync(path.join(built, 'build'), { recursive: true });
|
||||
fs.writeFileSync(path.join(built, 'build/app.js'), 'backend');
|
||||
writeManifest(source, built);
|
||||
// Fixture helper defaults to its fixed source revision.
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(built, 'build-info.json')),
|
||||
);
|
||||
manifest.sourceCommit = sourceCommit;
|
||||
fs.writeFileSync(
|
||||
path.join(built, 'build-info.json'),
|
||||
JSON.stringify(manifest),
|
||||
);
|
||||
git('init', '-b', 'develop', built);
|
||||
git('-C', built, 'config', 'user.name', 'Fixture');
|
||||
git('-C', built, 'config', 'user.email', 'fixture@example.invalid');
|
||||
git('-C', built, 'add', '.');
|
||||
git('-C', built, 'commit', '-m', 'built');
|
||||
const staticCommit = git('-C', built, 'rev-parse', 'HEAD');
|
||||
git('init', '--bare', remote);
|
||||
git('-C', built, 'push', remote, 'develop');
|
||||
const archives = {};
|
||||
for (const [repo, dir, commit] of [
|
||||
['qinglong', source, sourceCommit],
|
||||
['qinglong-static', built, staticCommit],
|
||||
])
|
||||
archives[repo] = execFileSync('git', [
|
||||
'-C',
|
||||
dir,
|
||||
'archive',
|
||||
'--format=zip',
|
||||
`--prefix=${repo}-${commit}/`,
|
||||
commit,
|
||||
]);
|
||||
const execute = (program, args, options) => {
|
||||
if (program !== 'curl') return execFileSync(program, args, options);
|
||||
const url = args.at(-1),
|
||||
repo = url.includes('/qinglong-static/') ? 'qinglong-static' : 'qinglong';
|
||||
assert.ok(url.endsWith(repo === 'qinglong' ? sourceCommit : staticCommit));
|
||||
fs.writeFileSync(args[args.indexOf('--output') + 1], archives[repo]);
|
||||
return '';
|
||||
};
|
||||
return {
|
||||
tmp,
|
||||
source,
|
||||
built,
|
||||
remote,
|
||||
git,
|
||||
sourceCommit,
|
||||
staticCommit,
|
||||
archives,
|
||||
execute,
|
||||
};
|
||||
}
|
||||
|
||||
test('published archive metadata retains history and supplies percentages without Content-Length', async (t) => {
|
||||
const f = fixture(t);
|
||||
const info = publishUpgradeInfo(
|
||||
f.remote,
|
||||
'develop',
|
||||
f.sourceCommit,
|
||||
f.execute,
|
||||
);
|
||||
for (const [repo, bytes] of Object.entries(f.archives)) {
|
||||
assert.equal(info.archives[repo].size, bytes.length);
|
||||
assert.equal(info.archives[repo].sha256, digest(bytes));
|
||||
}
|
||||
const stored = JSON.parse(
|
||||
f.git('-C', f.remote, 'show', `upgrade-info:${f.sourceCommit}.json`),
|
||||
);
|
||||
assert.deepEqual(stored, info);
|
||||
const first = f.git('-C', f.remote, 'rev-parse', 'upgrade-info');
|
||||
publishUpgradeInfo(f.remote, 'develop', f.sourceCommit, f.execute);
|
||||
assert.equal(f.git('-C', f.remote, 'rev-parse', 'upgrade-info^'), first);
|
||||
const root = path.join(f.tmp, 'panel'),
|
||||
tmp = path.join(root, '.tmp');
|
||||
fs.mkdirSync(tmp, { recursive: true });
|
||||
fs.mkdirSync(path.join(root, 'node_modules'));
|
||||
for (const name of ['package.json', 'pnpm-lock.yaml'])
|
||||
fs.copyFileSync(path.join(f.source, name), path.join(root, name));
|
||||
const reports = [],
|
||||
urls = [];
|
||||
const run = async (program, args) => {
|
||||
if (program !== 'curl')
|
||||
return { stdout: execFileSync(program, args, { encoding: 'utf8' }) };
|
||||
const url = args.at(-1);
|
||||
urls.push(url);
|
||||
const out = args[args.indexOf('--output') + 1];
|
||||
if (new URL(url).pathname.endsWith('/build-info.json'))
|
||||
fs.writeFileSync(out, JSON.stringify({ sourceCommit: f.sourceCommit }));
|
||||
else if (url.endsWith('.json')) fs.writeFileSync(out, JSON.stringify(info));
|
||||
else {
|
||||
const repo = url.includes('/qinglong-static/')
|
||||
? 'qinglong-static'
|
||||
: 'qinglong';
|
||||
fs.writeFileSync(out, f.archives[repo]);
|
||||
}
|
||||
fs.writeFileSync(
|
||||
args[args.indexOf('--dump-header') + 1],
|
||||
'HTTP/2 200 OK\r\n\r\n',
|
||||
);
|
||||
};
|
||||
await stageUpgrade({
|
||||
root,
|
||||
tmp,
|
||||
env: { QL_BRANCH: 'develop' },
|
||||
mirror: 'github',
|
||||
run,
|
||||
install: () => assert.fail('no changed dependencies'),
|
||||
onProgress: async (r) => reports.push(r),
|
||||
});
|
||||
assert.ok(urls.some((url) => url.endsWith(`/zip/${f.staticCommit}`)));
|
||||
for (const artifact of ['qinglong', 'qinglong-static']) {
|
||||
assert.ok(reports.some((r) => r.artifact === artifact && r.percent === 0));
|
||||
assert.ok(
|
||||
reports.some((r) => r.artifact === artifact && r.percent === 100),
|
||||
);
|
||||
}
|
||||
const pointer = JSON.parse(
|
||||
fs.readFileSync(path.join(tmp, 'upgrade-ready-develop.json')),
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(
|
||||
path.join(
|
||||
tmp,
|
||||
pointer.directory,
|
||||
'qinglong-static-develop/build-info.json',
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('publisher rejects a static artifact from a different source before publishing metadata', (t) => {
|
||||
const f = fixture(t);
|
||||
assert.throws(
|
||||
() =>
|
||||
publishUpgradeInfo(
|
||||
f.remote,
|
||||
'develop',
|
||||
'a'.repeat(40),
|
||||
(program, args, options) => {
|
||||
if (program === 'curl') {
|
||||
fs.writeFileSync(
|
||||
args[args.indexOf('--output') + 1],
|
||||
f.archives['qinglong-static'],
|
||||
);
|
||||
return '';
|
||||
}
|
||||
return execFileSync(program, args, options);
|
||||
},
|
||||
),
|
||||
/does not match/,
|
||||
);
|
||||
assert.equal(f.git('-C', f.remote, 'branch', '--list', 'upgrade-info'), '');
|
||||
});
|
||||
|
||||
for (const mismatch of ['size', 'sha256'])
|
||||
test(`archive metadata ${mismatch} mismatch never reports completion`, async (t) => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-wrong-download-'));
|
||||
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
const bytes = Buffer.alloc(100),
|
||||
reports = [];
|
||||
await assert.rejects(
|
||||
downloadWithProgress(
|
||||
async (program, args) => {
|
||||
fs.writeFileSync(args[args.indexOf('--output') + 1], bytes);
|
||||
},
|
||||
path.join(tmp, 'file.zip'),
|
||||
'https://example.invalid/file.zip',
|
||||
'qinglong',
|
||||
async (r) => reports.push(r),
|
||||
true,
|
||||
{
|
||||
size: mismatch === 'size' ? 101 : 100,
|
||||
sha256: mismatch === 'sha256' ? '0'.repeat(64) : digest(bytes),
|
||||
},
|
||||
),
|
||||
/checksum mismatch/,
|
||||
);
|
||||
assert.ok(!reports.some((r) => r.percent === 100));
|
||||
});
|
||||
|
||||
test('published size gives intermediate real percentages when the response omits its length', async (t) => {
|
||||
const { setTimeout: delay } = require('node:timers/promises');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-sized-download-'));
|
||||
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
const bytes = Buffer.alloc(100),
|
||||
reports = [];
|
||||
await downloadWithProgress(
|
||||
async (program, args) => {
|
||||
const out = args[args.indexOf('--output') + 1];
|
||||
fs.writeFileSync(
|
||||
args[args.indexOf('--dump-header') + 1],
|
||||
'HTTP/2 200 OK\r\n\r\n',
|
||||
);
|
||||
fs.writeFileSync(out, bytes.subarray(0, 50));
|
||||
await delay(1150);
|
||||
fs.writeFileSync(out, bytes);
|
||||
},
|
||||
path.join(tmp, 'file.zip'),
|
||||
'https://example.invalid/file.zip',
|
||||
'qinglong',
|
||||
async (r) => reports.push(r),
|
||||
true,
|
||||
{ size: 100, sha256: digest(bytes) },
|
||||
);
|
||||
assert.ok(reports.some((r) => r.downloadedBytes === 50 && r.percent === 50));
|
||||
assert.equal(reports.at(-1).percent, 100);
|
||||
});
|
||||
Reference in New Issue
Block a user