fix: verify staged upgrades in the background before restarting

This commit is contained in:
whyour
2026-10-18 05:30:21 +08:00
parent a8d09a3c14
commit 2cd5e0b06c
4 changed files with 36 additions and 8 deletions
@@ -221,12 +221,14 @@ async function startJob({ root, tmp, env = process.env }, action) {
let selected;
let metadata;
if (action === 'apply') {
// Fail before launching a process or stopping the working panel.
// Keep requests short on slow devices. The detached worker performs
// complete file/dependency verification before it can stop the panel.
try {
selected = await artifacts.selectedUpgrade(
tmp,
artifacts.releaseBranch(env),
env,
{ metadataOnly: true },
);
metadata = await stagedMetadata(selected);
if (previous.status === 'ready')
@@ -495,7 +495,12 @@ async function stageUpgrade({
}
}
async function selectedUpgrade(tmp, branch, env = process.env) {
async function selectedUpgrade(
tmp,
branch,
env = process.env,
{ metadataOnly = false } = {},
) {
const pointer = JSON.parse(
await fs.readFile(path.join(tmp, `upgrade-ready-${branch}.json`), 'utf8'),
);
@@ -519,10 +524,12 @@ async function selectedUpgrade(tmp, branch, env = process.env) {
);
if (ready.source !== selected.source || ready.static !== selected.static)
throw new Error(upgradeMessage('暂存升级就绪记录不匹配。', env));
const manifest = await verifyUpgrade(selected.source, selected.static, env);
const manifest = metadataOnly
? await manifestAt(selected.static, env)
: await verifyUpgrade(selected.source, selected.static, env);
if (ready.sourceCommit !== manifest.sourceCommit)
throw new Error(upgradeMessage('暂存升级就绪记录不匹配。', env));
if (ready.bundle)
if (ready.bundle && !metadataOnly)
await require('./platformBundle.cjs').verifyStagedBundle(selected);
if (ready.dependenciesChanged)
await fs.access(path.join(selected.source, 'node_modules'));
+1 -1
View File
@@ -6,7 +6,7 @@ CI 从同次构建的不可变镜像摘要导出完整 tar.gz,包含源码中
`package.json` 中 `qinglongUpgradeFormat: 2` 表示使用完整包。旧版升级器仍可通过原归档路径完成首次迁移;完整包版本不会在平台包缺失、损坏或不兼容时退回设备端 npm 安装。
面板下载任务与本地离线入口共用 `platformBundle.cjs`:平台匹配 → 归档安全检查 → 全文件/链接/权限和锁文件校验 → SQLite/Sequelize/cron-parser/gRPC/undici 加载及 SQLite 内存库读写 → 发布就绪指针。重启前重新验证完整载荷。离线升级同步面板任务状态,并与在线任务互斥;中断准备时保留之前的可用就绪包。原有服务停止、文件备份、启动健康检查及失败恢复仍由共享升级引擎处理。
面板下载任务与本地离线入口共用 `platformBundle.cjs`:平台匹配 → 归档安全检查 → 全文件/链接/权限和锁文件校验 → SQLite/Sequelize/cron-parser/gRPC/undici 加载及 SQLite 内存库读写 → 发布就绪指针。点击重启时,接口只检查就绪记录并提交后台任务;完整文件与依赖复验由独立工作进程执行,复验通过后才停止旧服务,避免小设备的预检耗时超过 HTTP 请求期限。离线升级同步面板任务状态,并与在线任务互斥;中断准备时保留之前的可用就绪包。原有服务停止、文件备份、启动健康检查及失败恢复仍由共享升级引擎处理。
渠道索引为 GitHub prerelease `upgrade-develop` 或 `upgrade-master` 下的 `latest.json`。索引记录准确字节数、SHA256 和不可变 `upgrade-<commit>` release 资产 URL。13 个平台均完成归档、解包后复验及依赖试运行才发布渠道;过期工作流不能覆盖更新提交的渠道。
+22 -3
View File
@@ -186,7 +186,7 @@ test('detached download survives its initiating request and is idempotent; apply
);
});
test('invalid staged payload is rejected before spawning a restart', async (t) => {
test('invalid staged payload fails asynchronously before stopping the panel', async (t) => {
const f = await fixture(t);
await startJob(f, 'download');
assert.equal((await finished(f.tmp)).status, 'ready');
@@ -197,9 +197,16 @@ test('invalid staged payload is rejected before spawning a restart', async (t) =
path.join(f.tmp, pointer.directory, 'qinglong-static-master/build/app.js'),
'corrupted',
);
await assert.rejects(startJob(f, 'apply'), /checksum|损坏/);
const queued = await startJob(f, 'apply');
assert.equal(queued.status, 'starting');
const failed = await finished(f.tmp);
assert.equal(failed.status, 'failed');
assert.match(failed.error, /checksum|损坏/);
assert.equal(syncFs.existsSync(path.join(f.root, 'stops')), false);
assert.equal((await getStatus(f.tmp)).status, 'failed');
assert.equal(
await fs.readFile(path.join(f.root, 'version.yaml'), 'utf8'),
'version: 2.23.0',
);
});
test('failed download persists its failure without stopping the panel and can be retried', async (t) => {
@@ -467,3 +474,15 @@ for (const knownSize of [true, false])
assert.equal(reports.at(-1).downloadedBytes, 100);
assert.equal(reports.at(-1).percent, knownSize ? 100 : null);
});
test('asynchronous apply still rejects unsafe stage pointers before dispatch', async (t) => {
const f = await fixture(t);
await startJob(f, 'download');
assert.equal((await finished(f.tmp)).status, 'ready');
await fs.writeFile(
path.join(f.tmp, 'upgrade-ready-master.json'),
JSON.stringify({ directory: '../outside' }),
);
await assert.rejects(startJob(f, 'apply'), /指针|pointer/);
assert.equal(syncFs.existsSync(path.join(f.root, 'stops')), false);
});