Files
qinglong/cli/src/internal/maintenance/panelUpgrade.cjs
T

488 lines
16 KiB
JavaScript

// Persistent panel jobs run independently of HTTP requests and browser sessions.
const fs = require('node:fs/promises');
const path = require('node:path');
const { randomUUID } = require('node:crypto');
const { spawn } = require('node:child_process');
const http = require('node:http');
const { setTimeout: delay } = require('node:timers/promises');
const artifacts = require('./upgradeArtifacts.cjs');
const { nodeRuntimeArgs } = require('./platformBundle.cjs');
const active = new Set(['starting', 'running', 'restarting']);
const stateFile = (tmp) => path.join(tmp, 'panel-upgrade.json');
const logFile = (tmp) => path.join(tmp, 'panel-upgrade.log');
const launches = new Map();
async function withPanelLifecycle(root, tmp, env, operation, signal) {
if (!env.QL_CLI_ROOT)
return artifacts.withLifecycleHooks(root, tmp, env, operation);
if (!path.isAbsolute(env.QL_CLI_ROOT))
throw new Error('QL_CLI_ROOT must be absolute');
const internal = path.join(env.QL_CLI_ROOT, 'dist/internal');
// Keep the selected installation's operators loaded across file replacement.
const { createContext, sourceEnvironment } = require(path.join(
internal,
'runtime/context.js',
));
const { cancellableOperation } = require(path.join(
internal,
'runtime/cancellation.js',
));
const operator = require(path.join(internal, 'maintenance/operator.js'));
const { prepareUpgradeSelection } = require(path.join(
internal,
'maintenance/upgradeSelection.js',
));
const context = createContext(
{ root, 'data-dir': env.dir_data || env.QL_DATA_DIR },
env,
);
context.env = await sourceEnvironment(
context.env,
[
context.paths.file_config_user,
path.join(context.paths.dir_preload, 'lang_env.sh'),
],
[],
{ signal },
);
const hook = (name, args = [], signal) =>
cancellableOperation(signal, () => {
if (name === 'install')
return operator.installPanelDependencies(context, args[0]);
if (name === 'stop') return operator.stopPanel(context);
if (name === 'start') return operator.startPanel(context);
throw new Error('Invalid lifecycle operation');
});
return operation(hook, (selected) =>
prepareUpgradeSelection(context, selected.source, selected.static),
);
}
async function targetVersion(source) {
const text = await fs.readFile(path.join(source, 'version.yaml'), 'utf8');
const version = text.match(/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+)/m)?.[1];
if (!version) throw new Error('Invalid update version');
return version;
}
async function readRunningBuild(env) {
let base = env.QlBaseUrl || '';
if (base && !base.startsWith('/')) base = `/${base}`;
base = base.replace(/\/$/, '');
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => request.destroy(new Error('Version check timed out')),
5000,
);
const request = http.get(
{
hostname: '127.0.0.1',
port: Number(env.QlPort || 5700),
path: `${base}/api/health`,
},
(response) => {
let body = '';
response.on('data', (chunk) => {
body += chunk;
if (body.length > 65536)
request.destroy(new Error('Invalid version response'));
});
response.on('error', reject);
response.on('end', () => {
try {
const result = JSON.parse(body);
if (
response.statusCode !== 200 ||
result.code !== 200 ||
result.data?.status !== 'ok'
)
throw new Error('后端启动检查失败。');
resolve(result.data.build);
} catch (error) {
reject(error);
}
});
},
);
request.on('error', reject);
request.on('close', () => clearTimeout(timer));
});
}
async function verifyRunningVersion(env, expected, previousBuild) {
const build = await readRunningBuild(env);
if (
!build ||
!build.instanceId ||
build.instanceId === previousBuild?.instanceId ||
(previousBuild?.parentPid !== undefined &&
build.parentPid === previousBuild.parentPid) ||
build.version !== expected.version ||
build.sourceCommit !== expected.sourceCommit ||
build.backendSha256 !== expected.backendSha256
)
throw new Error('启动版本与更新包不一致');
}
async function stagedMetadata(selected) {
const manifest = JSON.parse(
await fs.readFile(path.join(selected.static, 'build-info.json'), 'utf8'),
);
return {
version: await targetVersion(selected.source),
sourceCommit: manifest.sourceCommit,
backendSha256: manifest.files['build/app.js'],
stageDirectory: path.basename(path.dirname(selected.source)),
};
}
function assertSelected(expected, actual) {
for (const key of [
'sourceCommit',
'backendSha256',
'stageDirectory',
'version',
]) {
if (expected[key] !== actual[key])
throw new Error('暂存更新已变化,请重新下载更新。');
}
}
async function save(tmp, state) {
const temporary = `${stateFile(tmp)}.${randomUUID()}`;
await fs.writeFile(temporary, JSON.stringify(state), { mode: 0o600 });
await fs.rename(temporary, stateFile(tmp));
}
async function readState(tmp) {
try {
return JSON.parse(await fs.readFile(stateFile(tmp), 'utf8'));
} catch (error) {
if (error.code === 'ENOENT') return { status: 'idle' };
throw error;
}
}
async function getStatus(tmp) {
const state = await readState(tmp);
if (active.has(state.status)) {
let alive = false;
if (Number.isSafeInteger(state.pid) && state.pid > 0) {
try {
process.kill(state.pid, 0);
alive = true;
} catch (error) {
alive = error.code === 'EPERM';
}
} else alive = Date.now() - state.startedAt < 10000;
if (!alive) {
state.status = 'failed';
state.error = '升级任务意外中断,请重试';
}
}
// Bound reads even when an installer produces a very large log.
const handle = await fs.open(logFile(tmp), 'r').catch(() => undefined);
if (handle) {
try {
const { size } = await handle.stat();
const buffer = Buffer.alloc(Math.min(size, 32768));
const { bytesRead } = await handle.read(
buffer,
0,
buffer.length,
Math.max(0, size - buffer.length),
);
state.log = buffer.subarray(0, bytesRead).toString();
} finally {
await handle.close();
}
}
return state;
}
async function startJob({ root, tmp, env = process.env }, action) {
if (!['download', 'apply'].includes(action))
throw new Error('Invalid upgrade action');
await fs.mkdir(tmp, { recursive: true });
const preceding = launches.get(tmp) || Promise.resolve();
const launching = preceding
.catch(() => {})
.then(() =>
artifacts.withUpgradeLock(
path.join(tmp, 'panel-job-launch'),
async () => {
const previous = await getStatus(tmp);
if (active.has(previous.status)) {
if (previous.action !== action)
throw new Error('已有更新或重载正在执行。');
return previous;
}
let selected;
let metadata;
if (action === 'apply') {
// 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')
assertSelected(previous, metadata);
} catch (error) {
const { log, ...record } = previous;
await save(tmp, {
...record,
status: 'failed',
phase: 'verifying',
error: error.message,
});
throw error;
}
} else if (previous.status === 'ready') return previous;
const state = {
id: randomUUID(),
action,
status: 'starting',
phase: action === 'apply' ? 'verifying' : 'downloading',
startedAt: Date.now(),
...metadata,
};
await save(tmp, state);
const log = await fs.open(logFile(tmp), 'w', 0o600);
try {
const child = spawn(
process.execPath,
[__filename, 'launch', state.id],
{
env: { ...env, QL_DIR: root, dir_tmp: tmp },
detached: true,
stdio: ['ignore', log.fd, log.fd],
},
);
await new Promise((resolve, reject) => {
child.once('exit', (code, signal) => {
if (code === 0) resolve();
else
reject(
new Error(`Upgrade launcher exited (${signal || code})`),
);
});
child.once('error', reject);
});
child.unref();
} catch (error) {
await save(tmp, {
...state,
status: 'failed',
error: error.message,
});
throw error;
} finally {
await log.close();
}
return state;
},
env,
),
);
launches.set(tmp, launching);
try {
return await launching;
} finally {
if (launches.get(tmp) === launching) launches.delete(tmp);
}
}
async function runJob({ root, tmp, env = process.env }, id) {
let state = await readState(tmp);
if (state.id !== id || state.status !== 'starting')
throw new Error('Stale upgrade job');
const controller = new AbortController();
const abort = () => controller.abort(new Error('升级任务意外中断,请重试'));
process.once('SIGTERM', abort);
process.once('SIGINT', abort);
// Serialize progress writes so an older tick cannot overwrite ready/completed.
let writes = Promise.resolve();
const report = (patch) => {
state = { ...state, ...patch, pid: process.pid, updatedAt: Date.now() };
const snapshot = { ...state };
writes = writes.then(() => save(tmp, snapshot));
return writes;
};
try {
await report({ status: 'running' });
let terminal;
await artifacts.withUpgradeLock(
tmp,
() =>
withPanelLifecycle(
root,
tmp,
env,
async (hook, prepareSelection) => {
const run = (program, args, capture) =>
artifacts.runProcess(
env,
program,
args,
capture,
controller.signal,
);
if (state.action === 'download') {
const selected = await artifacts.stageUpgrade({
root,
tmp,
env,
mirror: 'github',
fallbackMirror: 'gitee',
run,
signal: controller.signal,
onProgress: report,
install: (source) =>
hook('install', [source], controller.signal),
});
terminal = {
status: 'ready',
phase: 'ready',
...(await stagedMetadata(selected)),
};
} else {
const selected = await artifacts.selectedUpgrade(
tmp,
artifacts.releaseBranch(env),
env,
);
assertSelected(state, await stagedMetadata(selected));
const previousBuild = await readRunningBuild(env).catch(
() => undefined,
);
const selection = await prepareSelection?.(selected);
try {
await artifacts.reloadSystem({
root,
tmp,
env,
data:
env.dir_data || env.QL_DATA_DIR || path.join(root, 'data'),
staticRoot: env.dir_static || path.join(root, 'static'),
config:
env.dir_config ||
path.join(
env.QL_DATA_DIR || path.join(root, 'data'),
'config',
),
lifecycle: {
signal: controller.signal,
stop: async () => {
await report({
status: 'restarting',
phase: 'restarting',
});
await hook('stop');
},
start: async (recover) => {
await hook(
'start',
[],
recover ? undefined : controller.signal,
);
await artifacts.waitForHealth(
env,
undefined,
recover ? undefined : controller.signal,
);
if (!recover)
await verifyRunningVersion(env, state, previousBuild);
},
},
additionalReplacements: selection
? [{ source: selection.source, target: selection.target }]
: [],
});
} finally {
if (selection)
await fs.rm(selection.directory, {
recursive: true,
force: true,
});
}
terminal = { status: 'completed', phase: 'completed' };
}
},
controller.signal,
),
env,
);
await report(terminal);
} catch (error) {
console.error(error);
// A failed write must not prevent recording the final failure.
await writes.catch(() => {});
writes = Promise.resolve();
await report({ status: 'failed', error: error.message });
} finally {
process.removeListener('SIGTERM', abort);
process.removeListener('SIGINT', abort);
await writes;
}
}
module.exports = {
getStatus,
saveState: save,
startJob,
runJob,
withPanelLifecycle,
readRunningBuild,
verifyRunningVersion,
stagedMetadata,
};
async function launchWorker(id) {
// detached creates a session but keeps the original parent. PM2 kills the
// backend's entire descendant tree, so an intermediate launcher must exit
// before the worker is allowed to stop that backend.
const child = spawn(
process.execPath,
[...nodeRuntimeArgs(), __filename, 'worker', id],
{
env: { ...process.env, QL_UPGRADE_LAUNCHER_PID: String(process.pid) },
detached: true,
stdio: 'inherit',
},
);
await new Promise((resolve, reject) => {
child.once('spawn', resolve);
child.once('error', reject);
});
child.unref();
}
async function runOrphanedWorker(id) {
const launcher = Number(process.env.QL_UPGRADE_LAUNCHER_PID);
const deadline = Date.now() + 10000;
while (process.platform !== 'win32' && process.ppid === launcher) {
if (Date.now() >= deadline)
throw new Error('Upgrade launcher did not exit');
await delay(10);
}
const root = process.env.QL_DIR;
await runJob(
{ root, tmp: process.env.dir_tmp || path.join(root, '.tmp') },
id,
);
}
if (require.main === module && ['launch', 'worker'].includes(process.argv[2])) {
const operation =
process.argv[2] === 'launch' ? launchWorker : runOrphanedWorker;
operation(process.argv[3]).catch((error) => {
console.error(error);
process.exitCode = 1;
});
}