mirror of
https://github.com/whyour/qinglong.git
synced 2026-10-17 07:20:51 +08:00
* 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
289 lines
9.2 KiB
JavaScript
289 lines
9.2 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs/promises');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { 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), []);
|
|
});
|