From 2cd5e0b06c736f7c5c719656c08b916d3e5ec31d Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 18 Oct 2026 05:30:21 +0800 Subject: [PATCH] fix: verify staged upgrades in the background before restarting --- cli/src/internal/maintenance/panelUpgrade.cjs | 4 ++- .../internal/maintenance/upgradeArtifacts.cjs | 13 +++++++--- docs/development/platform-upgrade-bundles.md | 2 +- test/back/panel-upgrade.test.cjs | 25 ++++++++++++++++--- 4 files changed, 36 insertions(+), 8 deletions(-) diff --git a/cli/src/internal/maintenance/panelUpgrade.cjs b/cli/src/internal/maintenance/panelUpgrade.cjs index 34ba1ca7..c6f184f0 100644 --- a/cli/src/internal/maintenance/panelUpgrade.cjs +++ b/cli/src/internal/maintenance/panelUpgrade.cjs @@ -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') diff --git a/cli/src/internal/maintenance/upgradeArtifacts.cjs b/cli/src/internal/maintenance/upgradeArtifacts.cjs index 18d2f658..ab2b76cd 100644 --- a/cli/src/internal/maintenance/upgradeArtifacts.cjs +++ b/cli/src/internal/maintenance/upgradeArtifacts.cjs @@ -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')); diff --git a/docs/development/platform-upgrade-bundles.md b/docs/development/platform-upgrade-bundles.md index e2bc9d64..801d4e87 100644 --- a/docs/development/platform-upgrade-bundles.md +++ b/docs/development/platform-upgrade-bundles.md @@ -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-` release 资产 URL。13 个平台均完成归档、解包后复验及依赖试运行才发布渠道;过期工作流不能覆盖更新提交的渠道。 diff --git a/test/back/panel-upgrade.test.cjs b/test/back/panel-upgrade.test.cjs index e38394ea..2d283466 100644 --- a/test/back/panel-upgrade.test.cjs +++ b/test/back/panel-upgrade.test.cjs @@ -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); +});