mirror of
https://github.com/whyour/qinglong.git
synced 2026-10-16 22:35:32 +08:00
fix: 修复页面升级、ql update 与静态资源版本同步 (#3086)
* fix: validate panel upgrades and synchronize versioned static builds * fix: add static-only mirror backfill workflow * fix: avoid lifecycle self-kill and stop cancelled upgrade groups * fix: build images when the shared upgrade engine changes * fix: dispatch upgrade lifecycle through a fixed script * fix: reclaim upgrade locks safely and normalize health probes * fix: match backend base URL normalization exactly
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
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 http = require('node:http');
|
||||
const { spawn, execFileSync } = require('node:child_process');
|
||||
const {
|
||||
writeManifest,
|
||||
sourceCommit,
|
||||
} = require('../helpers/upgrade-fixture.cjs');
|
||||
const { waitForHealth, withUpgradeLock } = require('../../shell/upgrade.cjs');
|
||||
|
||||
const share = `
|
||||
export dir_root="$QL_DIR" dir_shell="$QL_DIR/shell" dir_tmp="$QL_DIR/.tmp"
|
||||
export dir_data="$QL_DIR/data" dir_static="$QL_DIR/static" dir_log="$QL_DIR/data/log" dir_config="$QL_DIR/data/config"
|
||||
load_ql_envs() { :; }
|
||||
import_config() { :; }
|
||||
fix_config() { :; }
|
||||
make_dir() { mkdir -p "$1"; }
|
||||
format_timestamp() { date +%s; }
|
||||
format_time() { printf '%s' "$2"; }
|
||||
t() { printf "$@"; }
|
||||
npm_install_2() {
|
||||
printf installed > "$QL_DIR/installed"
|
||||
exit_status="$DEP_STATUS"
|
||||
mkdir -p "$1/node_modules"
|
||||
printf new > "$1/node_modules/dependency"
|
||||
}
|
||||
delete_pm2() { printf stopped >> "$QL_DIR/stops"; }
|
||||
reload_pm2() {
|
||||
printf started >> "$QL_DIR/starts"
|
||||
if [[ "$FAIL_START" == 'true' ]] && [[ ! -f "$QL_DIR/failed-once" ]]; then
|
||||
touch "$QL_DIR/failed-once"; return 1
|
||||
fi
|
||||
}
|
||||
`;
|
||||
|
||||
function write(root, name, content) {
|
||||
const filename = path.join(root, name);
|
||||
fs.mkdirSync(path.dirname(filename), { recursive: true });
|
||||
fs.writeFileSync(filename, content);
|
||||
return filename;
|
||||
}
|
||||
|
||||
function run(program, args, options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const cp = spawn(program, args, {
|
||||
...options,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '',
|
||||
stderr = '';
|
||||
cp.stdout.on('data', (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
cp.stderr.on('data', (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
cp.on('error', reject);
|
||||
cp.on('close', (code) => resolve({ code, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
async function fixture(t, options = {}) {
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-shell-upgrade-'));
|
||||
t.after(() => fs.rmSync(base, { recursive: true, force: true }));
|
||||
const root = path.join(base, 'panel'),
|
||||
payload = path.join(base, 'payload'),
|
||||
archives = path.join(base, 'archives');
|
||||
const branch = ['develop', 'debian-dev'].includes(options.branch)
|
||||
? 'develop'
|
||||
: 'master';
|
||||
const source = path.join(payload, `qinglong-${sourceCommit}`),
|
||||
staticRoot = path.join(payload, `qinglong-static-${branch}`);
|
||||
const packageJson = '{"version":"2.22.0"}';
|
||||
for (const target of [root, source]) {
|
||||
write(target, 'shell/update.sh', fs.readFileSync('shell/update.sh'));
|
||||
write(target, 'shell/upgrade.cjs', fs.readFileSync('shell/upgrade.cjs'));
|
||||
write(
|
||||
target,
|
||||
'cli/src/internal/maintenance/upgradeArtifacts.cjs',
|
||||
fs.readFileSync('cli/src/internal/maintenance/upgradeArtifacts.cjs'),
|
||||
);
|
||||
write(target, 'shell/share.sh', share);
|
||||
write(target, 'shell/api.sh', '');
|
||||
write(target, 'shell/env.sh', '');
|
||||
write(target, 'sample/config.sample.sh', 'sample');
|
||||
write(target, 'package.json', packageJson);
|
||||
write(
|
||||
target,
|
||||
'pnpm-lock.yaml',
|
||||
target === source && options.lockChange ? 'new lock' : 'old lock',
|
||||
);
|
||||
write(target, 'back/app.ts', target === source ? 'new' : 'old');
|
||||
write(
|
||||
target,
|
||||
'version.yaml',
|
||||
target === source ? 'version: 2.22.0' : 'version: 2.21.0',
|
||||
);
|
||||
}
|
||||
write(root, 'node_modules/dependency', 'old');
|
||||
write(root, '.env', 'private setting');
|
||||
write(root, 'data/config/config.sh', 'custom');
|
||||
write(root, 'static/build/app.js', 'old backend');
|
||||
write(root, 'static/dist/index.html', 'old frontend');
|
||||
write(root, '.tmp/import-in-progress', 'keep');
|
||||
write(staticRoot, 'build/app.js', 'new backend');
|
||||
write(staticRoot, 'dist/index.html', '<script src="umi.js"></script>');
|
||||
write(staticRoot, 'dist/umi.js', 'dashboard');
|
||||
write(source, '.env.example', 'new example');
|
||||
writeManifest(source, staticRoot);
|
||||
const manifestFile = path.join(staticRoot, 'build-info.json');
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestFile));
|
||||
if (options.manifest === 'missing') fs.unlinkSync(manifestFile);
|
||||
else if (options.manifest === 'dirty')
|
||||
fs.writeFileSync(
|
||||
manifestFile,
|
||||
JSON.stringify({ ...manifest, dirty: true }),
|
||||
);
|
||||
else if (options.manifest === 'lock')
|
||||
fs.writeFileSync(
|
||||
manifestFile,
|
||||
JSON.stringify({ ...manifest, lockfileSha256: '0'.repeat(64) }),
|
||||
);
|
||||
else if (options.manifest === 'commit')
|
||||
fs.writeFileSync(
|
||||
manifestFile,
|
||||
JSON.stringify({ ...manifest, sourceCommit: 'b'.repeat(40) }),
|
||||
);
|
||||
else if (options.manifest === 'checksum')
|
||||
write(staticRoot, 'dist/umi.js', 'stale frontend');
|
||||
else if (options.manifest === 'extra')
|
||||
write(staticRoot, 'build/stale.js', 'stale backend');
|
||||
else if (options.manifest === 'frontend')
|
||||
fs.unlinkSync(path.join(staticRoot, 'dist/index.html'));
|
||||
fs.mkdirSync(archives);
|
||||
for (const repo of ['qinglong', 'qinglong-static']) {
|
||||
const archive = path.join(archives, `${repo}.zip`);
|
||||
execFileSync(
|
||||
'zip',
|
||||
[
|
||||
'-qr',
|
||||
archive,
|
||||
`${repo}-${repo === 'qinglong' ? sourceCommit : branch}`,
|
||||
],
|
||||
{ cwd: payload },
|
||||
);
|
||||
if (options.corrupt === repo) fs.writeFileSync(archive, 'invalid zip');
|
||||
}
|
||||
const curl = write(
|
||||
base,
|
||||
'bin/curl',
|
||||
`#!${process.execPath}
|
||||
const fs=require('node:fs'),path=require('node:path'),args=process.argv.slice(2);
|
||||
if(args.includes('--head')) process.exit(7);
|
||||
const repo=args.at(-1).includes('/qinglong-static/')?'qinglong-static':'qinglong';
|
||||
fs.appendFileSync(process.env.TRACE,args.at(-1)+'\\n');
|
||||
if(process.env.FAIL_DOWNLOAD===repo) process.exit(22);
|
||||
fs.copyFileSync(path.join(process.env.ARCHIVES,repo+'.zip'),args[args.indexOf('--output')+1]);
|
||||
`,
|
||||
);
|
||||
fs.chmodSync(curl, 0o755);
|
||||
const server = http.createServer((req, res) =>
|
||||
res.end('{"code":200,"data":{"status":"ok"}}'),
|
||||
);
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||||
const env = {
|
||||
...process.env,
|
||||
QL_DIR: root,
|
||||
QL_BRANCH: options.branch || 'master',
|
||||
PATH: `${path.join(base, 'bin')}:${process.env.PATH}`,
|
||||
QlPort: String(server.address().port),
|
||||
ARCHIVES: archives,
|
||||
TRACE: path.join(base, 'trace'),
|
||||
real_time: options.tee ? 'false' : 'true',
|
||||
FAIL_DOWNLOAD: options.download || '',
|
||||
DEP_STATUS: options.depFail ? '1' : '0',
|
||||
FAIL_START: options.failStart ? 'true' : 'false',
|
||||
};
|
||||
return {
|
||||
root,
|
||||
env,
|
||||
execute: (...args) =>
|
||||
run('bash', [path.join(root, 'shell/update.sh'), ...args], { env }),
|
||||
read: (name) => fs.readFileSync(path.join(root, name), 'utf8'),
|
||||
};
|
||||
}
|
||||
|
||||
for (const options of [
|
||||
{ download: 'qinglong' },
|
||||
{ download: 'qinglong-static', tee: true },
|
||||
{ corrupt: 'qinglong' },
|
||||
{ corrupt: 'qinglong-static', tee: true },
|
||||
...[
|
||||
'missing',
|
||||
'dirty',
|
||||
'lock',
|
||||
'commit',
|
||||
'checksum',
|
||||
'extra',
|
||||
'frontend',
|
||||
].map((manifest) => ({ manifest })),
|
||||
{ lockChange: true, depFail: true, tee: true },
|
||||
])
|
||||
test(`failed update preserves the running panel: ${JSON.stringify(
|
||||
options,
|
||||
)}`, async (t) => {
|
||||
const f = await fixture(t, options);
|
||||
const result = await f.execute('update');
|
||||
assert.notEqual(result.code, 0, result.stdout + result.stderr);
|
||||
assert.equal(f.read('version.yaml'), 'version: 2.21.0');
|
||||
assert.equal(f.read('node_modules/dependency'), 'old');
|
||||
assert.equal(f.read('static/dist/index.html'), 'old frontend');
|
||||
assert.equal(fs.existsSync(path.join(f.root, 'stops')), false);
|
||||
assert.equal(f.read('.tmp/import-in-progress'), 'keep');
|
||||
assert.deepEqual(fs.readdirSync(path.join(f.root, '.tmp')), [
|
||||
'import-in-progress',
|
||||
]);
|
||||
});
|
||||
|
||||
for (const branch of ['master', 'develop', 'debian', 'debian-dev'])
|
||||
test(`download-only then reload installs matching artifacts and lockfile dependencies: ${branch}`, async (t) => {
|
||||
const f = await fixture(t, { branch, lockChange: true, tee: true });
|
||||
let result = await f.execute('update', 'false');
|
||||
assert.equal(result.code, 0, result.stdout + result.stderr);
|
||||
assert.equal(f.read('version.yaml'), 'version: 2.21.0');
|
||||
assert.equal(fs.existsSync(path.join(f.root, 'stops')), false);
|
||||
assert.match(
|
||||
fs.readFileSync(f.env.TRACE, 'utf8'),
|
||||
new RegExp(`qinglong/repository/archive/${sourceCommit}\\.zip`),
|
||||
);
|
||||
result = await f.execute('reload', 'system');
|
||||
assert.equal(result.code, 0, result.stdout + result.stderr);
|
||||
assert.equal(f.read('node_modules/dependency'), 'new');
|
||||
assert.equal(f.read('pnpm-lock.yaml'), 'new lock');
|
||||
assert.equal(f.read('static/dist/umi.js'), 'dashboard');
|
||||
assert.equal(f.read('back/app.ts'), 'new');
|
||||
assert.equal(f.read('.env'), 'private setting');
|
||||
assert.equal(f.read('.env.example'), 'new example');
|
||||
assert.equal(f.read('data/config/config.sh'), 'custom');
|
||||
assert.deepEqual(fs.readdirSync(path.join(f.root, '.tmp')), [
|
||||
'import-in-progress',
|
||||
]);
|
||||
assert.ok(
|
||||
!fs.readdirSync(f.root).some((name) => name.includes('ql-backup')),
|
||||
);
|
||||
});
|
||||
|
||||
test('a failed start rolls code, frontend and node_modules back and exits nonzero through tee', async (t) => {
|
||||
const f = await fixture(t, { lockChange: true, failStart: true, tee: true });
|
||||
const result = await f.execute('update');
|
||||
assert.notEqual(result.code, 0);
|
||||
assert.equal(f.read('version.yaml'), 'version: 2.21.0');
|
||||
assert.equal(f.read('node_modules/dependency'), 'old');
|
||||
assert.equal(f.read('static/dist/index.html'), 'old frontend');
|
||||
assert.equal(f.read('starts'), 'startedstarted');
|
||||
assert.ok(!fs.readdirSync(f.root).some((name) => name.includes('ql-backup')));
|
||||
});
|
||||
|
||||
test('reload revalidates staged files before stopping the old panel', async (t) => {
|
||||
const f = await fixture(t);
|
||||
assert.equal((await f.execute('update', 'false')).code, 0);
|
||||
const directory = JSON.parse(
|
||||
f.read('.tmp/upgrade-ready-master.json'),
|
||||
).directory;
|
||||
write(
|
||||
f.root,
|
||||
`.tmp/${directory}/qinglong-static-master/dist/umi.js`,
|
||||
'corrupt',
|
||||
);
|
||||
assert.notEqual((await f.execute('reload', 'system')).code, 0);
|
||||
assert.equal(fs.existsSync(path.join(f.root, 'stops')), false);
|
||||
});
|
||||
|
||||
test('a new successful stage retires only the previous ready workspace', async (t) => {
|
||||
const f = await fixture(t);
|
||||
assert.equal((await f.execute('update', 'false')).code, 0);
|
||||
const previous = JSON.parse(
|
||||
f.read('.tmp/upgrade-ready-master.json'),
|
||||
).directory;
|
||||
assert.equal((await f.execute('update', 'false')).code, 0);
|
||||
const current = JSON.parse(
|
||||
f.read('.tmp/upgrade-ready-master.json'),
|
||||
).directory;
|
||||
assert.notEqual(previous, current);
|
||||
assert.equal(fs.existsSync(path.join(f.root, '.tmp', previous)), false);
|
||||
assert.equal(f.read('.tmp/import-in-progress'), 'keep');
|
||||
});
|
||||
|
||||
test('failed health checks and concurrent upgrades are rejected', async (t) => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-upgrade-lock-'));
|
||||
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
await withUpgradeLock(tmp, async () => {
|
||||
await assert.rejects(
|
||||
withUpgradeLock(tmp, async () => {}),
|
||||
/Another upgrade/,
|
||||
);
|
||||
});
|
||||
assert.deepEqual(fs.readdirSync(tmp), []);
|
||||
const server = http.createServer((req, res) => {
|
||||
res.statusCode = 503;
|
||||
res.end('{"code":503}');
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||||
await assert.rejects(
|
||||
waitForHealth({ QlPort: String(server.address().port) }, 20),
|
||||
/health check/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { execFileSync, spawnSync } = require('node:child_process');
|
||||
const { writeManifest } = require('../helpers/upgrade-fixture.cjs');
|
||||
|
||||
test('static mirror receives complete orphan snapshots of each requested branch and reports rejection', (t) => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-static-mirror-'));
|
||||
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
const source = path.join(tmp, 'source'),
|
||||
target = path.join(tmp, 'target.git');
|
||||
const git = (...args) =>
|
||||
execFileSync('git', args, { encoding: 'utf8', stdio: 'pipe' }).trim();
|
||||
git('init', source);
|
||||
git('init', '--bare', target);
|
||||
git('-C', source, 'config', 'user.name', 'Fixture');
|
||||
git('-C', source, 'config', 'user.email', 'fixture@example.invalid');
|
||||
const script = path.resolve('scripts/sync-static.sh');
|
||||
const env = { ...process.env, QL_STATIC_SOURCE: source };
|
||||
for (const branch of ['master', 'develop']) {
|
||||
git('-C', source, 'checkout', '--orphan', branch);
|
||||
fs.writeFileSync(path.join(source, 'build-info.json'), branch);
|
||||
git('-C', source, 'add', '.');
|
||||
git('-C', source, 'commit', '-m', branch);
|
||||
execFileSync('bash', [script, target, branch], { env, stdio: 'pipe' });
|
||||
assert.equal(
|
||||
git('-C', target, 'rev-parse', branch),
|
||||
git('-C', source, 'rev-parse', branch),
|
||||
);
|
||||
assert.equal(
|
||||
git('-C', target, 'rev-parse', '--is-shallow-repository'),
|
||||
'false',
|
||||
);
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(target, 'hooks/pre-receive'),
|
||||
'#!/bin/sh\nexit 1\n',
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
fs.writeFileSync(path.join(source, 'build-info.json'), 'next');
|
||||
git('-C', source, 'add', '.');
|
||||
git('-C', source, 'commit', '-m', 'next');
|
||||
assert.notEqual(
|
||||
spawnSync('bash', [script, target, 'develop'], { env }).status,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('mirror jobs select the built branch, retain hidden artifacts and propagate failures', () => {
|
||||
const yaml = require('js-yaml');
|
||||
const workflow = yaml.load(
|
||||
fs.readFileSync('.github/workflows/build-docker-image.yml', 'utf8'),
|
||||
);
|
||||
for (const name of ['static_gitee', 'static_gitlab']) {
|
||||
const job = workflow.jobs[name],
|
||||
step = job.steps.find((step) => step.run);
|
||||
assert.equal(job.needs, 'build-static');
|
||||
assert.equal(step.env.STATIC_BRANCH, '${{ github.ref_name }}');
|
||||
assert.equal(step.env.STATIC_REF_TYPE, '${{ github.ref_type }}');
|
||||
assert.match(step.run, /set -euo pipefail/);
|
||||
assert.match(step.run, /bash scripts\/sync-static.sh.*"\$STATIC_BRANCH"/);
|
||||
assert.doesNotMatch(step.run, /warning|set \+e|--depth/);
|
||||
}
|
||||
const publish = workflow.jobs['build-static'].steps.find(
|
||||
(step) => step.name === 'copy to static repo',
|
||||
);
|
||||
assert.equal(publish.env.GITHUB_REF_TYPE, '${{ github.ref_type }}');
|
||||
assert.match(publish.run, /bash scripts\/publish-static.sh/);
|
||||
assert.match(
|
||||
fs.readFileSync('scripts/publish-static.sh', 'utf8'),
|
||||
/cp -a static\/\./,
|
||||
);
|
||||
});
|
||||
|
||||
test('version static snapshots retain a matching branch and Git tag on all three remotes', (t) => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-version-static-'));
|
||||
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
const source = path.join(tmp, 'source');
|
||||
const git = (...args) =>
|
||||
execFileSync('git', args, { encoding: 'utf8', stdio: 'pipe' }).trim();
|
||||
git('init', source);
|
||||
git('-C', source, 'config', 'user.name', 'Fixture');
|
||||
git('-C', source, 'config', 'user.email', 'fixture@example.invalid');
|
||||
fs.writeFileSync(path.join(source, 'version.yaml'), 'version: 2.22.0\n');
|
||||
fs.writeFileSync(path.join(source, '.gitignore'), 'static/\n');
|
||||
fs.writeFileSync(path.join(source, 'pnpm-lock.yaml'), 'fixture lock\n');
|
||||
git('-C', source, 'add', '.');
|
||||
git('-C', source, 'commit', '-m', 'source');
|
||||
const staticRoot = path.join(source, 'static');
|
||||
for (const filename of [
|
||||
'build/app.js',
|
||||
'dist/index.html',
|
||||
'dist/.well-known/config',
|
||||
]) {
|
||||
fs.mkdirSync(path.dirname(path.join(staticRoot, filename)), {
|
||||
recursive: true,
|
||||
});
|
||||
fs.writeFileSync(path.join(staticRoot, filename), filename);
|
||||
}
|
||||
writeManifest(source, staticRoot);
|
||||
const manifestFile = path.join(staticRoot, 'build-info.json');
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestFile));
|
||||
manifest.sourceCommit = git('-C', source, 'rev-parse', 'HEAD');
|
||||
fs.writeFileSync(manifestFile, JSON.stringify(manifest));
|
||||
const remotes = ['github', 'gitlab', 'gitee'].map((name) =>
|
||||
path.join(tmp, `${name}.git`),
|
||||
);
|
||||
for (const remote of remotes) git('init', '--bare', remote);
|
||||
const publish = path.resolve('scripts/publish-static.sh'),
|
||||
sync = path.resolve('scripts/sync-static.sh');
|
||||
const options = { cwd: source, stdio: 'pipe', env: process.env };
|
||||
execFileSync('bash', [publish, remotes[0], 'master', 'branch'], options);
|
||||
execFileSync('bash', [publish, remotes[0], 'v2.22.0', 'tag'], options);
|
||||
for (const remote of remotes.slice(1))
|
||||
execFileSync('bash', [sync, remote, 'v2.22.0', 'tag'], {
|
||||
...options,
|
||||
env: { ...process.env, QL_STATIC_SOURCE: remotes[0] },
|
||||
});
|
||||
const snapshot = git('-C', remotes[0], 'rev-parse', 'refs/heads/v2.22.0');
|
||||
for (const remote of remotes) {
|
||||
assert.equal(
|
||||
git('-C', remote, 'rev-parse', 'refs/heads/v2.22.0'),
|
||||
snapshot,
|
||||
);
|
||||
assert.equal(git('-C', remote, 'rev-parse', 'refs/tags/v2.22.0'), snapshot);
|
||||
assert.equal(
|
||||
git('-C', remote, 'show', 'v2.22.0:dist/.well-known/config'),
|
||||
'dist/.well-known/config',
|
||||
);
|
||||
}
|
||||
assert.notEqual(
|
||||
spawnSync('bash', [publish, remotes[0], 'v2.23.0', 'tag'], options).status,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
spawnSync('git', [
|
||||
'-C',
|
||||
remotes[0],
|
||||
'rev-parse',
|
||||
'--verify',
|
||||
'refs/tags/v2.23.0',
|
||||
]).status,
|
||||
128,
|
||||
);
|
||||
});
|
||||
|
||||
test('static backfill uses existing snapshots and skips all source, build and image publication jobs', () => {
|
||||
const workflow = require('js-yaml').load(
|
||||
fs.readFileSync('.github/workflows/build-docker-image.yml', 'utf8'),
|
||||
);
|
||||
assert.equal(
|
||||
workflow.on.workflow_dispatch.inputs.static_sync_only.default,
|
||||
false,
|
||||
);
|
||||
const job = workflow.jobs['sync-existing-static'];
|
||||
assert.match(job.if, /workflow_dispatch.*inputs.static_sync_only/);
|
||||
assert.deepEqual(job.strategy.matrix.mirror, ['gitlab', 'gitee']);
|
||||
const sync = job.steps.find((step) => step.run);
|
||||
assert.equal(sync.env.STATIC_REF, '${{ inputs.static_ref }}');
|
||||
assert.equal(sync.env.STATIC_REF_TYPE, '${{ inputs.static_ref_type }}');
|
||||
assert.match(sync.run, /bash scripts\/sync-static.sh/);
|
||||
assert.doesNotMatch(sync.run, /publish-static|docker|pub.sh/);
|
||||
for (const name of ['validate', 'code_gitlab', 'code_gitee', 'build-static'])
|
||||
assert.match(workflow.jobs[name].if, /!inputs.static_sync_only/);
|
||||
// Downstream jobs require the skipped build; none bypass dependency failures.
|
||||
for (const name of [
|
||||
'static_gitlab',
|
||||
'static_gitee',
|
||||
'build-alpine',
|
||||
'build-debian',
|
||||
'build-alpine310',
|
||||
'build-debian310',
|
||||
]) {
|
||||
assert.equal(workflow.jobs[name].needs, 'build-static');
|
||||
assert.doesNotMatch(workflow.jobs[name].if || '', /always\(/);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
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 http = require('node:http');
|
||||
const { randomUUID } = require('node:crypto');
|
||||
const { spawn } = require('node:child_process');
|
||||
const { withUpgradeLock, waitForHealth } = require('../../shell/upgrade.cjs');
|
||||
|
||||
async function temporary(t) {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-upgrade-guards-'));
|
||||
t.after(() => fs.rm(tmp, { recursive: true, force: true }));
|
||||
return tmp;
|
||||
}
|
||||
|
||||
for (const legacy of [true, false])
|
||||
test(
|
||||
`concurrent reclamation of a ${
|
||||
legacy ? 'legacy lock' : 'unique owner lock'
|
||||
} cannot remove a new owner`,
|
||||
{ timeout: 5000 },
|
||||
async (t) => {
|
||||
const tmp = await temporary(t);
|
||||
const lock = path.join(tmp, 'upgrade.lock');
|
||||
const owner = legacy ? 'pid' : `owner-99999999-${randomUUID()}`;
|
||||
await fs.mkdir(lock);
|
||||
await fs.writeFile(path.join(lock, owner), legacy ? '99999999' : '');
|
||||
const original = {
|
||||
readFile: fs.readFile,
|
||||
readdir: fs.readdir,
|
||||
rm: fs.rm,
|
||||
unlink: fs.unlink,
|
||||
};
|
||||
let readCount = 0,
|
||||
removeCount = 0,
|
||||
active = 0,
|
||||
maximum = 0;
|
||||
let readyReaders, firstEntered, release;
|
||||
const readers = new Promise((resolve) => {
|
||||
readyReaders = resolve;
|
||||
});
|
||||
const entered = new Promise((resolve) => {
|
||||
firstEntered = resolve;
|
||||
});
|
||||
const held = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const synchronize = async () => {
|
||||
if (++readCount === 2) readyReaders();
|
||||
await readers;
|
||||
};
|
||||
t.mock.method(fs, 'readFile', async (...args) => {
|
||||
const value = await original.readFile(...args);
|
||||
if (legacy && args[0] === path.join(lock, owner) && readCount < 2)
|
||||
await synchronize();
|
||||
return value;
|
||||
});
|
||||
t.mock.method(fs, 'readdir', async (...args) => {
|
||||
const value = await original.readdir(...args);
|
||||
if (!legacy && args[0] === lock && readCount < 2) await synchronize();
|
||||
return value;
|
||||
});
|
||||
t.mock.method(fs, 'rm', async (...args) => {
|
||||
if (args[0] === lock && ++removeCount === 2) await entered;
|
||||
return original.rm(...args);
|
||||
});
|
||||
t.mock.method(fs, 'unlink', async (...args) => {
|
||||
if (args[0] === path.join(lock, owner) && ++removeCount === 2)
|
||||
await entered;
|
||||
return original.unlink(...args);
|
||||
});
|
||||
const operation = async () => {
|
||||
maximum = Math.max(maximum, ++active);
|
||||
firstEntered();
|
||||
if (active === 1) await held;
|
||||
active--;
|
||||
};
|
||||
const attempts = [
|
||||
withUpgradeLock(tmp, operation),
|
||||
withUpgradeLock(tmp, operation),
|
||||
].map((attempt) =>
|
||||
attempt.then(
|
||||
() => ({ ok: true }),
|
||||
(error) => ({ error }),
|
||||
),
|
||||
);
|
||||
try {
|
||||
await entered;
|
||||
const contender = await Promise.race(attempts);
|
||||
assert.match(contender.error?.message || '', /Another upgrade/);
|
||||
assert.equal(maximum, 1);
|
||||
assert.equal((await original.readdir(lock)).length, 1);
|
||||
} finally {
|
||||
release();
|
||||
await Promise.all(attempts);
|
||||
}
|
||||
assert.deepEqual(await original.readdir(tmp), []);
|
||||
},
|
||||
);
|
||||
|
||||
test('an empty abandoned lock is acquired atomically and a failed operation releases its owner', async (t) => {
|
||||
const tmp = await temporary(t);
|
||||
await fs.mkdir(path.join(tmp, 'upgrade.lock'));
|
||||
await assert.rejects(
|
||||
withUpgradeLock(tmp, async () => {
|
||||
await assert.rejects(
|
||||
withUpgradeLock(tmp, async () => assert.fail('concurrent operation')),
|
||||
/Another upgrade/,
|
||||
);
|
||||
throw new Error('operation failed');
|
||||
}),
|
||||
/operation failed/,
|
||||
);
|
||||
await withUpgradeLock(tmp, async () => {});
|
||||
assert.deepEqual(await fs.readdir(tmp), []);
|
||||
});
|
||||
|
||||
test('release never deletes another owner after the original lock was replaced', async (t) => {
|
||||
const tmp = await temporary(t);
|
||||
const lock = path.join(tmp, 'upgrade.lock');
|
||||
const replacement = `owner-${process.pid}-${randomUUID()}`;
|
||||
await withUpgradeLock(tmp, async () => {
|
||||
await fs.rm(lock, { recursive: true });
|
||||
await fs.mkdir(lock);
|
||||
await fs.writeFile(path.join(lock, replacement), '');
|
||||
});
|
||||
assert.deepEqual(await fs.readdir(lock), [replacement]);
|
||||
});
|
||||
|
||||
test(
|
||||
'a lock left by a killed process can be reclaimed by a new process',
|
||||
{ timeout: 5000 },
|
||||
async (t) => {
|
||||
const tmp = await temporary(t);
|
||||
const worker = path.join(tmp, 'worker.cjs');
|
||||
await fs.writeFile(
|
||||
worker,
|
||||
`
|
||||
const { withUpgradeLock } = require(${JSON.stringify(
|
||||
path.resolve('shell/upgrade.cjs'),
|
||||
)});
|
||||
withUpgradeLock(process.argv[2], async () => {
|
||||
process.stdout.write('owned\\n');
|
||||
await new Promise(() => setInterval(() => {}, 1000));
|
||||
}).catch(error => { process.stderr.write(error.message); process.exitCode = 1; });
|
||||
`,
|
||||
);
|
||||
const child = spawn(process.execPath, [worker, tmp], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
t.after(() => {
|
||||
if (child.exitCode === null && child.signalCode === null)
|
||||
child.kill('SIGKILL');
|
||||
});
|
||||
const closed = new Promise((resolve, reject) => {
|
||||
child.once('error', reject);
|
||||
child.once('close', resolve);
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
child.stdout.once('data', resolve);
|
||||
child.once('error', reject);
|
||||
child.once('exit', () =>
|
||||
reject(new Error('worker exited before acquiring its lock')),
|
||||
);
|
||||
});
|
||||
child.kill('SIGKILL');
|
||||
await closed;
|
||||
await withUpgradeLock(tmp, async () => {
|
||||
await assert.rejects(
|
||||
withUpgradeLock(tmp, async () => {}),
|
||||
/Another upgrade/,
|
||||
);
|
||||
});
|
||||
assert.deepEqual(await fs.readdir(tmp), ['worker.cjs']);
|
||||
},
|
||||
);
|
||||
|
||||
async function healthServer(t, handler) {
|
||||
const server = http.createServer(handler);
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
t.after(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
server.close(resolve);
|
||||
server.closeAllConnections();
|
||||
}),
|
||||
);
|
||||
return String(server.address().port);
|
||||
}
|
||||
|
||||
test('health checks normalize supported URL prefixes exactly like the backend', async (t) => {
|
||||
const requests = [];
|
||||
const port = await healthServer(t, (req, res) => {
|
||||
requests.push(req.url);
|
||||
if (
|
||||
['/api/health', '/panel/api/health', '/panel//api/health'].includes(
|
||||
req.url,
|
||||
)
|
||||
)
|
||||
res.end('{"code":200,"data":{"status":"ok"}}');
|
||||
else {
|
||||
res.statusCode = 404;
|
||||
res.end('{}');
|
||||
}
|
||||
});
|
||||
for (const prefix of [
|
||||
'',
|
||||
'/',
|
||||
'panel',
|
||||
'/panel',
|
||||
'panel/',
|
||||
'/panel/',
|
||||
'panel//',
|
||||
'/panel//',
|
||||
])
|
||||
await waitForHealth({ QlPort: port, QlBaseUrl: prefix }, 100);
|
||||
assert.deepEqual(requests, [
|
||||
'/api/health',
|
||||
'/api/health',
|
||||
...Array(4).fill('/panel/api/health'),
|
||||
...Array(2).fill('/panel//api/health'),
|
||||
]);
|
||||
});
|
||||
|
||||
test(
|
||||
'health timeout bounds a continuously streaming response',
|
||||
{ timeout: 3000 },
|
||||
async (t) => {
|
||||
const port = await healthServer(t, (req, res) => {
|
||||
const timer = setInterval(() => res.write(' '), 10);
|
||||
res.once('close', () => clearInterval(timer));
|
||||
});
|
||||
const began = Date.now();
|
||||
await assert.rejects(waitForHealth({ QlPort: port }, 80), /health check/);
|
||||
assert.ok(
|
||||
Date.now() - began < 1000,
|
||||
'deadline must not depend on socket inactivity',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
for (const streaming of [false, true])
|
||||
test(
|
||||
`cancellation interrupts ${
|
||||
streaming ? 'an active response' : 'the retry delay'
|
||||
} and releases the upgrade lock`,
|
||||
{ timeout: 3000 },
|
||||
async (t) => {
|
||||
const tmp = await temporary(t);
|
||||
const controller = new AbortController();
|
||||
const reason = new Error('cancelled health check');
|
||||
const port = await healthServer(t, (req, res) => {
|
||||
if (streaming) res.write('{');
|
||||
else {
|
||||
res.statusCode = 503;
|
||||
res.end('{}');
|
||||
}
|
||||
setTimeout(() => controller.abort(reason), 30);
|
||||
});
|
||||
const began = Date.now();
|
||||
await assert.rejects(
|
||||
withUpgradeLock(tmp, () =>
|
||||
waitForHealth({ QlPort: port }, 5000, controller.signal),
|
||||
),
|
||||
(error) => error === reason,
|
||||
);
|
||||
assert.ok(
|
||||
Date.now() - began < 400,
|
||||
'cancel must interrupt the current wait',
|
||||
);
|
||||
await withUpgradeLock(tmp, async () => {});
|
||||
assert.deepEqual(await fs.readdir(tmp), []);
|
||||
},
|
||||
);
|
||||
|
||||
test('unhealthy, malformed and oversized health responses fail within the deadline', async (t) => {
|
||||
for (const body of ['{}', '{', ' '.repeat(65537)]) {
|
||||
const port = await healthServer(t, (req, res) => res.end(body));
|
||||
await assert.rejects(waitForHealth({ QlPort: port }, 20), /health check/);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
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 { randomUUID } = require('node:crypto');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const {
|
||||
runProcess,
|
||||
withLifecycleHooks,
|
||||
withUpgradeLock,
|
||||
} = require('../../shell/upgrade.cjs');
|
||||
|
||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
async function assertStopped(pid) {
|
||||
for (let attempt = 0; attempt < 50; attempt++) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
} catch (error) {
|
||||
if (error.code === 'ESRCH') return;
|
||||
throw error;
|
||||
}
|
||||
const state = spawnSync('ps', ['-o', 'stat=', '-p', String(pid)], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
// Linux PID 1 can retain a dead orphan until it is reaped. It can also
|
||||
// disappear between kill(0) and ps; neither state is a running installer.
|
||||
if (
|
||||
(state.status === 1 && !state.stdout.trim()) ||
|
||||
/^Z/.test(state.stdout.trim())
|
||||
)
|
||||
return;
|
||||
assert.equal(state.status, 0, state.stderr);
|
||||
await delay(20);
|
||||
}
|
||||
assert.fail(`test process ${pid} is still running`);
|
||||
}
|
||||
|
||||
async function fixture(t, directory) {
|
||||
const base = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'ql-upgrade-lifecycle-'),
|
||||
);
|
||||
t.after(() => fs.rm(base, { recursive: true, force: true }));
|
||||
const root = directory ? path.join(base, directory) : base;
|
||||
const tmp = path.join(root, '.tmp');
|
||||
await fs.mkdir(tmp, { recursive: true });
|
||||
await fs.mkdir(path.join(root, 'shell/lang'), { recursive: true });
|
||||
await fs.writeFile(path.join(root, 'shell/lang/zh.sh'), '');
|
||||
await fs.writeFile(
|
||||
path.join(root, 'shell/env.sh'),
|
||||
await fs.readFile('shell/env.sh'),
|
||||
);
|
||||
return { root, tmp, env: { ...process.env, QL_DIR: root } };
|
||||
}
|
||||
|
||||
test('installed stop/start hooks do not match their own pkill and survive shell replacement', async (t) => {
|
||||
const f = await fixture(t);
|
||||
// Only the test's random path can match; never signal a running panel.
|
||||
const marker = `review/${randomUUID()}/app.js`;
|
||||
const helpers = (await fs.readFile('shell/share.sh', 'utf8')).replaceAll(
|
||||
'static/build/app.js',
|
||||
marker,
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(f.root, 'shell/share.sh'),
|
||||
`${helpers}
|
||||
pm2() { printf '%s\\n' "$*" >> "$QL_DIR/pm2-calls"; }
|
||||
${process.platform === 'darwin' ? 'pkill() { command pkill -a "$@"; }' : ''}
|
||||
`,
|
||||
);
|
||||
await withUpgradeLock(f.tmp, () =>
|
||||
withLifecycleHooks(f.root, f.tmp, f.env, async (hook) => {
|
||||
await fs.writeFile(path.join(f.root, 'shell/share.sh'), 'exit 99');
|
||||
await hook('stop');
|
||||
await hook('start');
|
||||
assert.match(
|
||||
await fs.readFile(path.join(f.root, 'pm2-calls'), 'utf8'),
|
||||
/startOrGracefulReload/,
|
||||
);
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(await fs.readdir(f.tmp), []);
|
||||
});
|
||||
|
||||
test(
|
||||
'the installed Node fallback starts successfully and its stop hook kills only the test backend',
|
||||
{ timeout: 10000 },
|
||||
async (t) => {
|
||||
const f = await fixture(t);
|
||||
const marker = `review/${randomUUID()}/app.js`;
|
||||
let pid;
|
||||
t.after(() => {
|
||||
if (pid) {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch (error) {
|
||||
if (error.code !== 'ESRCH') throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
const helpers = (await fs.readFile('shell/share.sh', 'utf8')).replaceAll(
|
||||
'static/build/app.js',
|
||||
marker,
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(f.root, 'shell/share.sh'),
|
||||
`${helpers}
|
||||
pm2() { return 1; }
|
||||
${process.platform === 'darwin' ? 'pkill() { command pkill -a "$@"; }' : ''}
|
||||
`,
|
||||
);
|
||||
await fs.mkdir(path.dirname(path.join(f.root, marker)), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.mkdir(path.join(f.root, 'data/log'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(f.root, marker),
|
||||
`require('node:fs').writeFileSync(process.env.QL_DIR + '/backend-pid', String(process.pid)); setInterval(() => {}, 1000);`,
|
||||
);
|
||||
await withUpgradeLock(f.tmp, () =>
|
||||
withLifecycleHooks(f.root, f.tmp, f.env, async (hook) => {
|
||||
await hook('start');
|
||||
for (let attempt = 0; attempt < 150; attempt++) {
|
||||
try {
|
||||
pid = Number(
|
||||
await fs.readFile(path.join(f.root, 'backend-pid'), 'utf8'),
|
||||
);
|
||||
break;
|
||||
} catch {
|
||||
await delay(20);
|
||||
}
|
||||
}
|
||||
assert.ok(pid, 'fallback backend started');
|
||||
process.kill(pid, 0);
|
||||
await hook('stop');
|
||||
await assertStopped(pid);
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
for (const { redirect, ignoreShell } of [
|
||||
{ redirect: false, ignoreShell: false },
|
||||
{ redirect: true, ignoreShell: false },
|
||||
{ redirect: false, ignoreShell: true },
|
||||
])
|
||||
test(
|
||||
`cancelled installation stops descendants before releasing its lock (${JSON.stringify(
|
||||
{ redirect, ignoreShell },
|
||||
)})`,
|
||||
{ timeout: 10000 },
|
||||
async (t) => {
|
||||
const f = await fixture(t);
|
||||
const controller = new AbortController();
|
||||
let pid;
|
||||
t.after(() => {
|
||||
controller.abort();
|
||||
if (pid) {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch (error) {
|
||||
if (error.code !== 'ESRCH') throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
const worker = path.join(f.root, 'installer.cjs');
|
||||
await fs.writeFile(
|
||||
worker,
|
||||
`
|
||||
const fs = require('node:fs'), path = require('node:path');
|
||||
process.on('SIGTERM', () => {});
|
||||
fs.writeFileSync(path.join(process.env.QL_DIR, 'installer-pid'), String(process.pid));
|
||||
setInterval(() => fs.appendFileSync(path.join(process.env.QL_DIR, 'heartbeat'), 'x'), 20);
|
||||
`,
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(f.root, 'shell/share.sh'),
|
||||
`import_config() { :; }
|
||||
npm_install_2() {
|
||||
${ignoreShell ? "trap '' TERM" : ':'}
|
||||
"$TEST_NODE" "$1"${redirect ? ' >/dev/null 2>&1' : ''}
|
||||
exit_status=$?
|
||||
}
|
||||
`,
|
||||
);
|
||||
const operation = withUpgradeLock(f.tmp, () =>
|
||||
withLifecycleHooks(
|
||||
f.root,
|
||||
f.tmp,
|
||||
{ ...f.env, TEST_NODE: process.execPath },
|
||||
(hook) => hook('install', [worker], controller.signal),
|
||||
),
|
||||
);
|
||||
// Attach rejection handling before aborting the in-flight operation.
|
||||
const rejection = assert.rejects(operation, /cancelled installation/);
|
||||
for (let attempt = 0; attempt < 150; attempt++) {
|
||||
try {
|
||||
pid = Number(
|
||||
await fs.readFile(path.join(f.root, 'installer-pid'), 'utf8'),
|
||||
);
|
||||
await fs.access(path.join(f.root, 'heartbeat'));
|
||||
break;
|
||||
} catch {
|
||||
await delay(20);
|
||||
}
|
||||
}
|
||||
assert.ok(pid, 'installer started');
|
||||
await fs.access(path.join(f.tmp, 'upgrade.lock'));
|
||||
controller.abort(new Error('cancelled installation'));
|
||||
await rejection;
|
||||
assert.deepEqual(await fs.readdir(f.tmp), []);
|
||||
const before = await fs.readFile(path.join(f.root, 'heartbeat'), 'utf8');
|
||||
await delay(100);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'heartbeat'), 'utf8'),
|
||||
before,
|
||||
);
|
||||
await assertStopped(pid);
|
||||
},
|
||||
);
|
||||
|
||||
test('an already cancelled command does not spawn and a spawn failure releases the upgrade lock', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error('cancelled before spawn'));
|
||||
const script = path.join(f.root, 'never-run.sh');
|
||||
await fs.writeFile(script, 'touch "$QL_DIR/spawned"');
|
||||
await assert.rejects(
|
||||
runProcess(f.env, 'bash', ['--', script], false, controller.signal),
|
||||
/cancelled before spawn/,
|
||||
);
|
||||
await assert.rejects(fs.access(path.join(f.root, 'spawned')), {
|
||||
code: 'ENOENT',
|
||||
});
|
||||
await assert.rejects(
|
||||
withUpgradeLock(f.tmp, () =>
|
||||
runProcess(f.env, path.join(f.root, 'missing-program'), []),
|
||||
),
|
||||
{ code: 'ENOENT' },
|
||||
);
|
||||
assert.deepEqual(await fs.readdir(f.tmp), []);
|
||||
});
|
||||
|
||||
test('fixed lifecycle dispatch treats spaces, quotes and command substitutions in paths as data', async (t) => {
|
||||
const special =
|
||||
'panel spaces \u0027"; $(touch "$TEST_MARKER"); `touch "$TEST_MARKER"`';
|
||||
const f = await fixture(t, special);
|
||||
const marker = path.join(f.root, 'injected');
|
||||
const source = path.join(f.root, 'source ' + special);
|
||||
await fs.writeFile(
|
||||
path.join(f.root, 'shell/share.sh'),
|
||||
`
|
||||
import_config() { :; }
|
||||
delete_pm2() { printf stopped > "$QL_DIR/stopped"; }
|
||||
reload_pm2() { printf started > "$QL_DIR/started"; }
|
||||
npm_install_2() { printf '%s' "$1" > "$QL_DIR/installed"; exit_status=0; }
|
||||
`,
|
||||
);
|
||||
await withUpgradeLock(f.tmp, () =>
|
||||
withLifecycleHooks(
|
||||
f.root,
|
||||
f.tmp,
|
||||
{ ...f.env, TEST_MARKER: marker },
|
||||
async (hook) => {
|
||||
await hook('stop');
|
||||
await hook('start');
|
||||
await hook('install', [source]);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'stopped'), 'utf8'),
|
||||
'stopped',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'started'), 'utf8'),
|
||||
'started',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'installed'), 'utf8'),
|
||||
source,
|
||||
);
|
||||
await assert.rejects(fs.access(marker), { code: 'ENOENT' });
|
||||
await assert.rejects(hook('touch "$TEST_MARKER"'), /exit 2/);
|
||||
await assert.rejects(fs.access(marker), { code: 'ENOENT' });
|
||||
},
|
||||
),
|
||||
);
|
||||
assert.deepEqual(await fs.readdir(f.tmp), []);
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
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 { replaceAndReload } = require('../../shell/upgrade.cjs');
|
||||
|
||||
async function fixture(t) {
|
||||
const root = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'ql-upgrade-transaction-'),
|
||||
);
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const source = path.join(root, 'source'),
|
||||
target = path.join(root, 'target');
|
||||
for (const directory of [source, target]) await fs.mkdir(directory);
|
||||
await fs.writeFile(path.join(source, 'version'), 'new');
|
||||
await fs.writeFile(path.join(target, 'version'), 'old');
|
||||
return { root, source, target };
|
||||
}
|
||||
|
||||
for (const failure of ['backup', 'remove', 'replacement', 'start'])
|
||||
test(`Bash transaction restores the full original after an overlay ${failure} failure`, async (t) => {
|
||||
const f = await fixture(t);
|
||||
const rename = fs.rename,
|
||||
cp = fs.cp,
|
||||
rm = fs.rm;
|
||||
t.mock.method(fs, 'rename', async (source, target) => {
|
||||
if (source === f.target || source.includes('.ql-backup-')) {
|
||||
const error = new Error('overlay rename');
|
||||
error.code = 'EXDEV';
|
||||
throw error;
|
||||
}
|
||||
return rename(source, target);
|
||||
});
|
||||
let injected = false;
|
||||
t.mock.method(fs, 'cp', async (source, target, options) => {
|
||||
if (
|
||||
!injected &&
|
||||
((failure === 'backup' && source === f.target) ||
|
||||
(failure === 'replacement' && source === f.source))
|
||||
) {
|
||||
injected = true;
|
||||
await fs.mkdir(target, { recursive: true });
|
||||
await fs.writeFile(path.join(target, 'partial'), 'partial');
|
||||
throw new Error('copy failed');
|
||||
}
|
||||
return cp(source, target, options);
|
||||
});
|
||||
t.mock.method(fs, 'rm', async (target, options) => {
|
||||
if (!injected && failure === 'remove' && target === f.target) {
|
||||
injected = true;
|
||||
await fs.unlink(path.join(target, 'version'));
|
||||
throw new Error('remove failed');
|
||||
}
|
||||
return rm(target, options);
|
||||
});
|
||||
let starts = 0;
|
||||
await assert.rejects(
|
||||
replaceAndReload([f], {
|
||||
stop: async () => {},
|
||||
start: async () => {
|
||||
if (++starts === 1 && failure === 'start')
|
||||
throw new Error('start failed');
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.target, 'version'), 'utf8'),
|
||||
'old',
|
||||
);
|
||||
},
|
||||
}),
|
||||
/failed/,
|
||||
);
|
||||
assert.deepEqual(await fs.readdir(f.target), ['version']);
|
||||
assert.ok(
|
||||
!(await fs.readdir(f.root)).some((name) => name.includes('.ql-backup-')),
|
||||
);
|
||||
});
|
||||
|
||||
test('interrupted upgrade recovers the previous service outside cancellation', async (t) => {
|
||||
const f = await fixture(t),
|
||||
controller = new AbortController();
|
||||
let starts = 0;
|
||||
await assert.rejects(
|
||||
replaceAndReload([f], {
|
||||
signal: controller.signal,
|
||||
stop: async () => {},
|
||||
start: async (recover) => {
|
||||
if (++starts === 1) {
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.target, 'version'), 'utf8'),
|
||||
'new',
|
||||
);
|
||||
controller.abort(new Error('interrupted'));
|
||||
} else {
|
||||
assert.equal(recover, true);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.target, 'version'), 'utf8'),
|
||||
'old',
|
||||
);
|
||||
}
|
||||
},
|
||||
}),
|
||||
/interrupted/,
|
||||
);
|
||||
assert.equal(starts, 2);
|
||||
});
|
||||
|
||||
test('failed stop during rollback retains backups without overwriting running files', async (t) => {
|
||||
const f = await fixture(t);
|
||||
let stops = 0;
|
||||
await assert.rejects(
|
||||
replaceAndReload([f], {
|
||||
stop: async () => {
|
||||
if (++stops === 2) throw new Error('stop failed');
|
||||
},
|
||||
start: async () => {
|
||||
throw new Error('start failed');
|
||||
},
|
||||
}),
|
||||
/stop failed/,
|
||||
);
|
||||
const backup = (await fs.readdir(f.root)).find((name) =>
|
||||
name.includes('.ql-backup-'),
|
||||
);
|
||||
assert.ok(backup);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, backup, 'version'), 'utf8'),
|
||||
'old',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.target, 'version'), 'utf8'),
|
||||
'new',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { collectBuildFiles } = require('../../docker/build-manifest.cjs');
|
||||
const sourceCommit = 'a'.repeat(40);
|
||||
|
||||
function writeManifest(source, staticRoot) {
|
||||
const lockfile = path.join(source, 'pnpm-lock.yaml');
|
||||
if (!fs.existsSync(lockfile)) fs.writeFileSync(lockfile, 'fixture lock\n');
|
||||
fs.mkdirSync(path.join(staticRoot, 'dist'), { recursive: true });
|
||||
if (!fs.existsSync(path.join(staticRoot, 'dist/index.html')))
|
||||
fs.writeFileSync(
|
||||
path.join(staticRoot, 'dist/index.html'),
|
||||
'<div id="root"></div>',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(staticRoot, 'build-info.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
dirty: false,
|
||||
sourceCommit,
|
||||
lockfileSha256: createHash('sha256')
|
||||
.update(fs.readFileSync(lockfile))
|
||||
.digest('hex'),
|
||||
files: collectBuildFiles(staticRoot),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = { writeManifest, sourceCommit };
|
||||
Reference in New Issue
Block a user