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 selected;
let metadata; let metadata;
if (action === 'apply') { 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 { try {
selected = await artifacts.selectedUpgrade( selected = await artifacts.selectedUpgrade(
tmp, tmp,
artifacts.releaseBranch(env), artifacts.releaseBranch(env),
env, env,
{ metadataOnly: true },
); );
metadata = await stagedMetadata(selected); metadata = await stagedMetadata(selected);
if (previous.status === 'ready') 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( const pointer = JSON.parse(
await fs.readFile(path.join(tmp, `upgrade-ready-${branch}.json`), 'utf8'), 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) if (ready.source !== selected.source || ready.static !== selected.static)
throw new Error(upgradeMessage('暂存升级就绪记录不匹配。', env)); 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) if (ready.sourceCommit !== manifest.sourceCommit)
throw new Error(upgradeMessage('暂存升级就绪记录不匹配。', env)); throw new Error(upgradeMessage('暂存升级就绪记录不匹配。', env));
if (ready.bundle) if (ready.bundle && !metadataOnly)
await require('./platformBundle.cjs').verifyStagedBundle(selected); await require('./platformBundle.cjs').verifyStagedBundle(selected);
if (ready.dependenciesChanged) if (ready.dependenciesChanged)
await fs.access(path.join(selected.source, 'node_modules')); 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 安装。 `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 个平台均完成归档、解包后复验及依赖试运行才发布渠道;过期工作流不能覆盖更新提交的渠道。 渠道索引为 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); const f = await fixture(t);
await startJob(f, 'download'); await startJob(f, 'download');
assert.equal((await finished(f.tmp)).status, 'ready'); 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'), path.join(f.tmp, pointer.directory, 'qinglong-static-master/build/app.js'),
'corrupted', '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(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) => { 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).downloadedBytes, 100);
assert.equal(reports.at(-1).percent, knownSize ? 100 : null); 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);
});