aps-agent/apps/desktop/upgrade-nonce.cjs

338 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* APS 桌面端「升级 nonce 续接」协调器(round-46 KK,矩阵 103 最后本地项)。
*
* 背景(round-43~45 链路):
* - sidecar 每次启动/重启都会轮换进程 nonce(x-aps-sidecar-nonce,见 sidecar.cjs),
* 升级安装(NSIS /S)会替换安装目录内的 sidecar 产物,运行中的 sidecar 可能随之中断,
* 由 SidecarManager 以「新进程 + 新 nonce」重启并触发 main 的 'restarted' 事件;
* - updater.applyUpdate 安装后健康检查使用的是调用时(升级前/升级中)拿到的旧 nonce,
* 无法保证能通过升级后新网关的 nonce 门禁(缺失/不匹配 → 403,见 probeHealth);
* - round-45 HH 已让 applyUpdate 回传 report.nonce、接受 sidecarNonce 续接参数;
* 本模块补齐 main 侧接线:sidecar 重启后把「新 nonce」交给待验证的升级流程重跑
* probeHealth(升级后健康检查 + 版本比对)。
*
* 本模块把「pending 升级状态机 + sidecar restarted 后重验证」的核心逻辑抽成可注入协调器
* createUpgradeNonceCoordinator({ verifyHealth, onComplete, onFailure }),不依赖 Electron,
* 可直接 node --test 单测(apps/desktop/test/upgrade-nonce.test.cjs)。Electron main
* (main.cjs)负责接线:
* - apply-update 前 setPending()(status='installing');
* - applyUpdate 返回后 markInstalled()(status='verifying',保留 pending 等待最终校验);
* - sidecar 'restarted' 事件调用 onSidecarRestarted({ origin, nonce }):
* 成功 → onComplete(上报 audit,携带新 nonce)并清空 pending;
* 失败 → onFailure(本地 JSONL 留痕)并清空 pending;
* 无 pending / 已终结 / 已过期 → no-op(不误伤无关重启)。
*
* 版本比对语义:与 updater.probeHealth(round-45)一致——expectedVersion 由调用方显式
* 提供时才做严格版本比对(不匹配 → HEALTH_VERSION_MISMATCH 失败留痕);未提供时退化为
* 仅校验新网关接受新 nonce 且 HTTP 200(升级安装期内 sidecar 重启仍由旧 main 进程注入
* APS_APP_VERSION,此时 targetVersion 比对会误报,故不自动取 targetVersion 作比较值)。
*
* 状态机:
* (null) --setPending--> installing
* installing --markInstalled--> verifying
* installing --markFailed--> failed
* verifying --onSidecarRestarted(+verifyHealth 成功)--> verified(onComplete 后清空)
* verifying --onSidecarRestarted(+verifyHealth 失败)--> failed(onFailure 后清空)
* verifying --超过 maxPendingMs--> stale(清空,no-op)
* installing 期间收到 restarted 事件 → 延迟记录(deferredRestart),markInstalled 后立即重验证,
* 避免「升级安装期间 sidecar 重启」这个真实场景被漏掉。
*/
const fs = require('fs');
const path = require('path');
const UPGRADE_NONCE_EVENT_VERIFIED = 'upgrade.health-verified';
const UPGRADE_NONCE_EVENT_FAILED = 'upgrade.health-verify-failed';
/** 把任意错误归一化为 { code, message, details }(UpgradeError 保留 code/details)。 */
function normalizeUpgradeError(error) {
if (error && typeof error === 'object' && (error.code || error.message)) {
return {
code: error.code || 'UNKNOWN',
message: error.message || String(error),
details: error.details ?? null,
};
}
return { code: 'UNKNOWN', message: String(error), details: null };
}
/**
* 创建升级 nonce 续接协调器(单 pending 槽;桌面单实例,同时只有一个升级)。
*
* @param {object} options
* @param {Function} options.verifyHealth 必填:升级后健康检查,签名
* async ({ origin, nonce, expectedVersion, pending }) => health(失败抛错)。
* main 侧通常直接包 probeHealth:({ origin, nonce, expectedVersion }) =>
* probeHealth({ base: origin, expectedVersion, sidecarNonce: nonce })。
* @param {Function} [options.onComplete] async ({ pending, health, nonce }) => void
* 重验证成功回调(上报 audit,携带新 nonce);抛错不阻断状态机(已标记完成)。
* @param {Function} [options.onFailure] async ({ pending, error }) => void
* 重验证失败回调(本地日志/审计留痕);抛错不阻断状态机(已标记失败)。
* @param {object} [options.logger] console 兼容 logger(默认 console)。
* @param {number} [options.maxPendingMs] pending 从 startedAt 起的有效期,超过后
* restarted 事件视为过期 no-op(默认 10 分钟,覆盖 NSIS 安装 + 应用重启耗时)。
* @param {Function} [options.now] 时钟注入(默认 Date.now),便于测试。
* @returns {{
* setPending, getPending, getLastResult, clearPending,
* markInstalled, markFailed, onSidecarRestarted,
* }}
*/
function createUpgradeNonceCoordinator(options = {}) {
const {
verifyHealth,
onComplete = async () => {},
onFailure = async () => {},
logger = console,
maxPendingMs = 10 * 60 * 1000,
now = Date.now,
statePath = null,
} = options;
if (typeof verifyHealth !== 'function') {
throw new TypeError('createUpgradeNonceCoordinator: verifyHealth 必须为函数');
}
let pending = null;
let lastResult = null;
// ---- 跨进程持久化(round-48):pending 升级状态落盘,应用重启后可续接 ----
function writeState() {
if (!statePath) return;
try {
const dir = path.dirname(statePath);
fs.mkdirSync(dir, { recursive: true });
const tmp = statePath + '.tmp';
fs.writeFileSync(tmp, JSON.stringify({ pending, lastResult, updatedAt: new Date(now()).toISOString() }), 'utf8');
fs.renameSync(tmp, statePath);
} catch (error) {
logger.warn?.('[upgrade-nonce] 持久化升级状态失败:', error.message);
}
}
function readState() {
if (!statePath) return null;
try {
const raw = fs.readFileSync(statePath, 'utf8');
return JSON.parse(raw);
} catch {
return null;
}
}
/** 工厂初始化:恢复未过期的 pending(应用重启后 status 视为 verifying,等待新 sidecar 补验)。 */
function restoreState() {
const saved = readState();
if (!saved || !saved.pending) return;
const restored = saved.pending;
const startedAtMs = Date.parse(restored.startedAt || '');
const expired = Number.isFinite(startedAtMs) && (now() - startedAtMs) > maxPendingMs;
if (expired) {
logger.warn?.('[upgrade-nonce] 恢复时发现升级状态已过期,丢弃');
return;
}
if (restored.status === 'installing' || restored.status === 'verifying') {
pending = { ...restored, status: 'verifying', deferredRestart: null };
lastResult = saved.lastResult || null;
logger.info?.('[upgrade-nonce] 已恢复待验证升级:', pending.id, '等待新 sidecar 补验');
}
}
restoreState();
/** 进行中的 pending(安装中/待验证)——已终结的 pending 可被新升级替换。 */
function isActive(target) {
return Boolean(target && (target.status === 'installing' || target.status === 'verifying'));
}
function settle(status, extra = {}) {
const snapshot = {
...pending,
...extra,
status,
settledAt: new Date(now()).toISOString(),
};
lastResult = snapshot;
pending = null;
writeState();
return snapshot;
}
/**
* 登记待验证升级(apply-update 前调用)。
* @param {object} upgrade 至少含 id;可选 currentVersion / targetVersion /
* expectedVersion / healthBase / audit(审计凭据快照)/ startedAt。
* @returns {object|null} 登记的 pending;已有进行中 pending 时返回 null(不覆盖)。
*/
function setPending(upgrade = {}) {
if (isActive(pending)) {
logger.warn?.('[upgrade-nonce] 已有进行中的升级待验证,忽略新 setPending:', upgrade.id);
return null;
}
pending = {
status: 'installing',
deferredRestart: null,
preliminaryHealth: null,
appliedNonce: null,
...upgrade,
startedAt: upgrade.startedAt || new Date(now()).toISOString(),
};
lastResult = null;
writeState();
return pending;
}
/** 当前 pending(可能为已终结的 failed,供观测;下个 setPending 会替换)。 */
function getPending() {
return pending;
}
/** 最近一次终结结果(verified/failed/stale 快照),用于审计/日志。 */
function getLastResult() {
return lastResult;
}
/** 清空 pending(id 缺省时强制清空;指定时仅清空匹配 id)。 */
function clearPending(id) {
if (!pending) return false;
if (id === undefined || pending.id === id) {
pending = null;
writeState();
return true;
}
return false;
}
/**
* applyUpdate 成功返回后调用:installing → verifying。
* applyUpdate 自带的健康检查(旧/当前 nonce)视为初步校验;真正的最终校验
* 等 sidecar 重启(新 nonce)由 onSidecarRestarted 驱动。若安装期间已收到过
* restarted 事件(deferredRestart),这里会立即用新 nonce 补做最终校验。
*
* @param {object} args
* @param {string} [args.id] pending id(缺省对应当前 pending)
* @param {object} [args.report] applyUpdate 返回值
* @param {boolean} [args.deferFinalVerify] true(默认)保留 pending 等待 restarted;
* 无 sidecar 环境(如 dev)传 false 直接按初步校验完成并清空。
* @returns {Promise<object>} pending 或终结结果
*/
async function markInstalled({ id, report = {}, deferFinalVerify = true } = {}) {
if (!pending || (id !== undefined && pending.id !== id) || pending.status !== 'installing') {
return pending;
}
if (report && report.ok === false) {
return markFailed({ id, error: new Error('applyUpdate 返回 ok=false') });
}
pending.status = 'verifying';
pending.targetVersion = report.targetVersion || pending.targetVersion || null;
pending.appliedNonce = report.nonce || pending.appliedNonce || null;
pending.preliminaryHealth = report.health || null;
// 注意:不做 expectedVersion ← targetVersion 自动回填——旧 main 进程重启的 sidecar
// 仍上报旧 APS_APP_VERSION,自动比对会误报失败;版本比对由调用方显式提供。
const deferred = pending.deferredRestart;
pending.deferredRestart = null;
if (deferred && deferFinalVerify) {
return onSidecarRestarted(deferred);
}
if (!deferFinalVerify) {
const result = settle('verified', {
health: pending.preliminaryHealth || null,
nonce: pending.appliedNonce || null,
verifiedVia: 'preliminary',
});
return result;
}
writeState();
return pending;
}
/**
* applyUpdate 抛错(含升级失败自动回滚)后调用:installing → failed。
* 仅记录状态供观测(renderer 已收到错误);不调用 onFailure(那是 restarted
* 重验证失败路径的语义),也不清空——下个 setPending 会替换。
*/
function markFailed({ id, error }) {
if (!pending || (id !== undefined && pending.id !== id)) return pending;
pending.status = 'failed';
pending.error = normalizeUpgradeError(error);
writeState();
return pending;
}
/**
* 核心入口:sidecar 'restarted' 事件后调用,用新 nonce 重跑升级后健康检查。
* @param {object} event { origin, nonce, at }
* @returns {Promise<{handled:boolean, ok?:boolean, reason?:string, health?, nonce?, error?, pending?}>}
*/
async function onSidecarRestarted({ origin, nonce, at } = {}) {
if (!pending) return { handled: false, reason: 'no-pending' };
if (pending.status === 'installing') {
// 安装尚在进行(applyUpdate 未返回):只记录,等 markInstalled 后补做最终校验
pending.deferredRestart = { origin, nonce, at: at || new Date(now()).toISOString() };
return { handled: false, reason: 'installing-deferred' };
}
if (pending.status !== 'verifying') {
return { handled: false, reason: 'already-settled', status: pending.status };
}
const elapsed = now() - Date.parse(pending.startedAt);
if (Number.isFinite(elapsed) && elapsed > maxPendingMs) {
logger.warn?.('[upgrade-nonce] pending 升级已过期(', elapsed, 'ms >', maxPendingMs, 'ms),跳过续接');
const stale = settle('stale', { reason: 'expired' });
return { handled: false, reason: 'stale', pending: stale };
}
return handleSidecarRestarted({ origin, nonce, at: at || new Date(now()).toISOString() });
}
async function handleSidecarRestarted({ origin, nonce, at }) {
const target = pending;
target.verifyAttempt = { origin, nonce, at };
try {
const health = await verifyHealth({
origin,
nonce,
expectedVersion: target.expectedVersion || null,
pending: target,
});
target.status = 'verified';
target.health = health;
target.nonce = nonce; // 本次续接实际使用的新 nonce(可追溯)
target.verifiedAt = at;
try {
await onComplete({ pending: target, health, nonce });
} catch (error) {
logger.error?.('[upgrade-nonce] onComplete 失败(升级已标记完成):', error);
}
const result = settle('verified', { health, nonce, verifiedVia: 'sidecar-restart' });
return { handled: true, ok: true, health, nonce, pending: result };
} catch (error) {
const normalized = normalizeUpgradeError(error);
target.status = 'failed';
target.error = normalized;
target.nonce = nonce; // 记录尝试使用的 nonce(可追溯)
target.verifiedAt = at;
try {
await onFailure({ pending: target, error: normalized });
} catch (failureError) {
logger.error?.('[upgrade-nonce] onFailure 失败(状态仍标记失败):', failureError);
}
const result = settle('failed', { error: normalized, nonce, verifiedVia: 'sidecar-restart' });
logger.error?.('[upgrade-nonce] sidecar 重启后升级健康检查失败(新 nonce 续接):', normalized.message);
return { handled: true, ok: false, error: normalized, nonce, pending: result };
}
}
return {
setPending,
getPending,
getLastResult,
clearPending,
markInstalled,
markFailed,
onSidecarRestarted,
};
}
module.exports = {
createUpgradeNonceCoordinator,
normalizeUpgradeError,
UPGRADE_NONCE_EVENT_VERIFIED,
UPGRADE_NONCE_EVENT_FAILED,
};