feat: refactor online update to synchronous web-process download
TallyNote release / linux-x64 (push) Failing after 3m12s
TallyNote release / linux-x64 (push) Failing after 3m12s
- Download happens in web process (non-root) with real-time progress - Root runner only handles privileged apply (stop/backup/switch/restart) - Eliminates 'waiting for system scheduler' stuck state - Frontend shows download bytes/speed/percentage with cancel button - Staged download triggers apply request file for root runner - systemd timeout reduced from 32min to 5min (no download phase) - Tests adapted for synchronous download flow release: 1.3.0
This commit is contained in:
+137
-1
@@ -1,5 +1,5 @@
|
||||
import { lstatSync, realpathSync, readFileSync, unlinkSync } from "node:fs";
|
||||
import { chmod, mkdir, rename, writeFile } from "node:fs/promises";
|
||||
import { chmod, mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { createPublicKey, randomUUID, verify as verifySignature } from "node:crypto";
|
||||
import type Database from "better-sqlite3";
|
||||
@@ -8,10 +8,13 @@ import { AppError } from "./errors.js";
|
||||
import type { AppConfig } from "./config.js";
|
||||
import {
|
||||
detectPlatform,
|
||||
downloadReleaseAsset,
|
||||
extractSafeArchive,
|
||||
fetchReleaseBytes,
|
||||
fetchReleaseMetadata,
|
||||
fetchReleaseText,
|
||||
isNewerVersion,
|
||||
normalizeReleasePermissions,
|
||||
parseSemver,
|
||||
runtimeHashFromLockfile,
|
||||
sanitizeAssetName,
|
||||
@@ -679,3 +682,136 @@ export function cancelUpdateJob(
|
||||
}
|
||||
return { cancelled: false, message: "取消失败,任务状态可能已改变" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Download, verify and stage a release archive in the web process (non-root).
|
||||
* The root runner only needs to apply (stop/backup/switch/restart) afterwards.
|
||||
*
|
||||
* This function runs asynchronously outside the request lifecycle. It updates
|
||||
* the job row in the database so the frontend can poll progress. On success it
|
||||
* writes an apply request file so the systemd path unit triggers the runner.
|
||||
*/
|
||||
export async function downloadAndStageUpdate(
|
||||
database: Database.Database,
|
||||
config: AppConfig,
|
||||
jobId: string,
|
||||
adminId: string,
|
||||
version: string,
|
||||
assetUrl: string,
|
||||
assetName: string,
|
||||
expectedSha256: string,
|
||||
metadataUrl: string,
|
||||
): Promise<void> {
|
||||
const stagingBase = path.resolve(config.stagingDir);
|
||||
const workspace = path.join(stagingBase, `update-${jobId}`);
|
||||
try {
|
||||
await mkdir(workspace, { recursive: true, mode: 0o700 });
|
||||
const archiveName = assetName.endsWith(".tar.gz") || assetName.endsWith(".tgz") ? assetName : `${assetName}.tar.gz`;
|
||||
const archivePath = path.join(workspace, archiveName);
|
||||
|
||||
// Claim the job: transition queued -> downloading. If the job was
|
||||
// cancelled or claimed by another caller, abort immediately.
|
||||
const claim = database.prepare(
|
||||
"UPDATE update_jobs SET status='downloading', download_started_at=?, started_at=?, download_path=?, updated_at=? WHERE id=? AND status='queued'",
|
||||
).run(Date.now(), Date.now(), path.basename(archivePath), Date.now(), jobId);
|
||||
if (claim.changes !== 1) return;
|
||||
|
||||
const progressStartedAt = Date.now();
|
||||
let lastProgressWrite = 0;
|
||||
const downloaded = await downloadReleaseAsset(assetUrl, archivePath, {
|
||||
allowedHosts: config.updateAllowedHosts,
|
||||
baseUrl: config.updateMetadataUrl,
|
||||
maxBytes: config.updateMaxBytes,
|
||||
timeoutMs: config.updateTimeoutMs,
|
||||
onProgress: (downloadedBytes, totalBytes) => {
|
||||
const now = Date.now();
|
||||
if (now - lastProgressWrite < 250) return;
|
||||
lastProgressWrite = now;
|
||||
const elapsed = Math.max(1, now - progressStartedAt);
|
||||
const speedBps = Math.round(downloadedBytes * 1000 / elapsed);
|
||||
database.prepare(
|
||||
"UPDATE update_jobs SET downloaded_bytes=?, size_bytes=COALESCE(?, size_bytes), download_speed_bps=?, updated_at=? WHERE id=? AND status='downloading'",
|
||||
).run(downloadedBytes, totalBytes, speedBps, now, jobId);
|
||||
},
|
||||
});
|
||||
|
||||
// Final progress write
|
||||
const finishedAt = Date.now();
|
||||
const elapsed = Math.max(1, finishedAt - progressStartedAt);
|
||||
database.prepare(
|
||||
"UPDATE update_jobs SET downloaded_bytes=?, size_bytes=?, download_speed_bps=?, updated_at=? WHERE id=? AND status='downloading'",
|
||||
).run(downloaded.size, downloaded.size, Math.round(downloaded.size * 1000 / elapsed), finishedAt, jobId);
|
||||
|
||||
// SHA-256 verification
|
||||
database.prepare(
|
||||
"UPDATE update_jobs SET status='verifying', actual_sha256=?, size_bytes=?, updated_at=? WHERE id=? AND status='downloading'",
|
||||
).run(downloaded.sha256, downloaded.size, Date.now(), jobId);
|
||||
|
||||
if (expectedSha256 && downloaded.sha256 !== expectedSha256) {
|
||||
throw new Error("更新文件 SHA-256 校验失败");
|
||||
}
|
||||
|
||||
// Extract archive to payload directory
|
||||
const payloadDir = path.join(workspace, "payload");
|
||||
await extractSafeArchive(archivePath, payloadDir);
|
||||
await normalizeReleasePermissions(payloadDir);
|
||||
|
||||
// Verify payload contains dist directory
|
||||
const { lstat } = await import("node:fs/promises");
|
||||
const payloadInfo = await lstat(path.join(payloadDir, "dist")).catch(() => null);
|
||||
if (!payloadInfo?.isDirectory() || payloadInfo.isSymbolicLink()) {
|
||||
throw new Error("发布包缺少 dist 目录");
|
||||
}
|
||||
|
||||
// Transition to staged
|
||||
const staged = database.prepare(
|
||||
"UPDATE update_jobs SET status='staged', actual_sha256=?, size_bytes=?, download_path=?, updated_at=? WHERE id=? AND status IN ('verifying', 'downloading')",
|
||||
).run(downloaded.sha256, downloaded.size, workspace, Date.now(), jobId);
|
||||
if (staged.changes !== 1) return; // cancelled
|
||||
|
||||
// Write apply request file for the root runner
|
||||
await writeUpdateRequest(config, {
|
||||
jobId,
|
||||
operation: "apply",
|
||||
version,
|
||||
metadataUrl,
|
||||
assetUrl,
|
||||
assetName,
|
||||
expectedSha256,
|
||||
requestedAt: Date.now(),
|
||||
currentLink: config.currentLink,
|
||||
releasesDir: config.releasesDir,
|
||||
dataDir: config.dataDir,
|
||||
stagedPath: workspace,
|
||||
});
|
||||
|
||||
writeAudit(database, {
|
||||
requestId: `download:${jobId}`,
|
||||
actorAdminId: adminId,
|
||||
action: "update.staged",
|
||||
targetType: "update",
|
||||
targetId: jobId,
|
||||
after: { version, sha256: downloaded.sha256, size: downloaded.size },
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "下载或校验失败";
|
||||
try {
|
||||
database.prepare(
|
||||
"UPDATE update_jobs SET status='failed', error_message=?, updated_at=? WHERE id=? AND status IN ('queued', 'downloading', 'verifying')",
|
||||
).run(message, Date.now(), jobId);
|
||||
writeAudit(database, {
|
||||
requestId: `download:${jobId}`,
|
||||
actorAdminId: adminId,
|
||||
action: "update.download_failed",
|
||||
targetType: "update",
|
||||
targetId: jobId,
|
||||
outcome: "failure",
|
||||
metadata: { error: message },
|
||||
});
|
||||
} catch {
|
||||
// The database may be closed (e.g. during test cleanup or process
|
||||
// shutdown). The workspace cleanup below still runs unconditionally.
|
||||
}
|
||||
await rm(workspace, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user