Files
qinglong/cli/src/internal/maintenance/upgradeArtifacts.cjs
T
whyour e8b6a30bd9 feat: ship verified platform upgrades with channel bundle releases
Package production dependencies per architecture for online and offline upgrades.
Validate dependencies and isolated backend startup before readiness, repair once
when permitted, and preserve service/data through failed preparation or rollback.

Publish release bundles with master images and expose an opt-in develop build
sharing the development index across Alpine and Debian images.
2026-10-18 22:19:49 +08:00

980 lines
32 KiB
JavaScript

// Shared by the image's Bash updater and the optional local CLI. Use only
// installed code to validate downloads; never execute a downloaded verifier.
const fs = require('node:fs/promises');
const path = require('node:path');
const { createHash, randomUUID } = require('node:crypto');
const { spawn } = require('node:child_process');
const http = require('node:http');
const { setTimeout: delay } = require('node:timers/promises');
const upgradeEnglish = {
'更新包下载及校验成功...': 'Update package downloaded and verified...',
'更新及启动检查成功...':
'Update and startup checks completed successfully...',
'更新失败: %s': 'Update failed: %s',
'构建清单无效。': 'Invalid or dirty build manifest',
'缺少构建产物: %s': 'Missing build output: %s',
'源码与构建依赖不一致。': 'Source and build lockfiles differ',
'构建包含非法文件: %s': 'Unsupported build entry: %s',
'构建文件缺失、损坏或混入旧文件。': 'Build checksum mismatch',
'已有更新或重载正在执行。': 'Another upgrade is running',
'GitHub 下载失败,切换 Gitee...': 'GitHub download failed; retrying Gitee',
'升级归档路径无效。': 'Invalid archive paths',
'升级归档包含符号链接。': 'Archive contains symlinks',
'暂存升级指针无效。': 'Invalid staged upgrade pointer',
'暂存目录无效。': 'Invalid staging directory',
'暂存升级就绪记录不匹配。': 'Invalid upgrade readiness',
'运行依赖已变化,请重新下载更新。':
'Installed dependencies changed; download the update again',
'保留备份: %s': 'Retained backup: %s',
'后端启动检查失败。': 'Backend failed health check',
'保留生命周期快照: %s': 'Retained lifecycle snapshot: %s',
};
function upgradeMessage(key, env = process.env, ...values) {
const template = env.QL_LANG === 'en' ? upgradeEnglish[key] ?? key : key;
let index = 0;
return template.replace(/%s/g, () => String(values[index++] ?? ''));
}
const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
const releaseBranch = (env) =>
['develop', 'debian-dev'].includes(env.QL_BRANCH) ? 'develop' : 'master';
async function manifestAt(staticRoot, env = process.env) {
const manifest = JSON.parse(
await fs.readFile(path.join(staticRoot, 'build-info.json'), 'utf8'),
);
if (
manifest.version !== 1 ||
manifest.dirty !== false ||
!/^[a-f0-9]{40}$/.test(manifest.sourceCommit) ||
!/^[a-f0-9]{64}$/.test(manifest.lockfileSha256) ||
!manifest.files ||
typeof manifest.files !== 'object' ||
Array.isArray(manifest.files)
)
throw new Error(upgradeMessage('构建清单无效。', env));
for (const required of ['build/app.js', 'dist/index.html'])
if (!Object.hasOwn(manifest.files, required))
throw new Error(upgradeMessage('缺少构建产物: %s', env, required));
return manifest;
}
async function verifyUpgrade(source, staticRoot, env = process.env) {
const manifest = await manifestAt(staticRoot, env);
if (
hash(await fs.readFile(path.join(source, 'pnpm-lock.yaml'))) !==
manifest.lockfileSha256
)
throw new Error(upgradeMessage('源码与构建依赖不一致。', env));
await fs.access(path.join(source, 'package.json'));
await fs.access(path.join(source, 'sample/config.sample.sh'));
const files = {};
async function visit(directory, prefix = '') {
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
const name = prefix + entry.name;
if (name === 'build-info.json') continue;
const filename = path.join(directory, entry.name);
if (entry.isDirectory()) await visit(filename, `${name}/`);
else if (entry.isFile()) files[name] = hash(await fs.readFile(filename));
else throw new Error(upgradeMessage('构建包含非法文件: %s', env, name));
}
}
await visit(staticRoot);
const actual = Object.keys(files).sort(),
expected = Object.keys(manifest.files).sort();
if (
JSON.stringify(actual) !== JSON.stringify(expected) ||
actual.some((name) => files[name] !== manifest.files[name])
)
throw new Error(upgradeMessage('构建文件缺失、损坏或混入旧文件。', env));
return manifest;
}
async function withUpgradeLock(tmp, operation, env = process.env) {
await fs.mkdir(tmp, { recursive: true });
const lock = path.join(tmp, 'upgrade.lock');
const token = randomUUID();
const owner = `owner-${process.pid}-${token}`;
const candidate = path.join(tmp, `upgrade-lock-${token}`);
let acquired = false;
await fs.mkdir(candidate, { mode: 0o700 });
try {
// Publish a nonempty directory atomically: a live lock can never be
// replaced, and a crash before publication cannot leave an ownerless lock.
await fs.writeFile(path.join(candidate, owner), '', {
flag: 'wx',
mode: 0o600,
});
for (;;) {
try {
await fs.rename(candidate, lock);
acquired = true;
break;
} catch (error) {
if (!['ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error;
}
let entries;
try {
entries = await fs.readdir(lock);
} catch (error) {
if (error.code === 'ENOENT') continue;
throw error;
}
if (!entries.length) continue;
const heldOwner = entries[0];
const match = /^owner-([1-9]\d*)-[a-f0-9-]{36}$/.exec(heldOwner);
let pid = match ? Number(match[1]) : 0;
if (entries.length === 1 && heldOwner === 'pid') {
try {
pid = Number(await fs.readFile(path.join(lock, heldOwner), 'utf8'));
} catch (error) {
if (error.code === 'ENOENT') continue;
throw error;
}
}
let stale = false;
if (
entries.length === 1 &&
Number.isSafeInteger(pid) &&
pid > 0 &&
pid <= 0x7fffffff
) {
try {
process.kill(pid, 0);
} catch (error) {
if (error.code === 'ESRCH') stale = true;
}
}
if (!stale)
throw new Error(upgradeMessage('已有更新或重载正在执行。', env));
// Removing the observed unique marker is a compare-and-delete. A
// contender cannot unlink the marker belonging to a replacement lock.
// Legacy pid files are supported; new owners never reuse that filename.
try {
await fs.unlink(path.join(lock, heldOwner));
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
}
return await operation();
} finally {
if (acquired) {
await fs.unlink(path.join(lock, owner)).catch((error) => {
if (error.code !== 'ENOENT') throw error;
});
await fs.rmdir(lock).catch((error) => {
if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code))
throw error;
});
}
await fs.rm(candidate, { recursive: true, force: true });
}
}
async function downloadWithProgress(
run,
archive,
url,
artifact,
onProgress,
quiet = true,
expected,
) {
const headers = `${archive}.headers`;
await fs.rm(archive, { force: true });
await fs.rm(headers, { force: true });
let sampling = Promise.resolve();
const sample = async (finished = false) => {
const bytes = (await fs.stat(archive).catch(() => undefined))?.size || 0;
const response = (await fs.readFile(headers, 'utf8').catch(() => ''))
.split(/(?=HTTP\/\S+ \d{3})/)
.at(-1);
const length = response?.match(/^content-length:\s*(\d+)\s*$/im);
const total =
expected?.size ||
(length && Number(length[1]) > 0 ? Number(length[1]) : null);
await onProgress({
phase: 'downloading',
artifact,
downloadedBytes: bytes,
totalBytes: total,
percent: total
? finished
? 100
: Math.min(99, Math.floor((bytes * 100) / total))
: null,
});
};
const timer = setInterval(() => {
sampling = sampling.then(() => sample());
// Observe failures immediately; propagate after the transfer has closed.
sampling.catch(() => {});
}, 1000);
try {
await run('curl', [
'--fail',
'--location',
...(quiet ? ['--silent', '--show-error'] : []),
'--proto',
/^http:\/\/(?:127\.0\.0\.1|\[::1\])(?::\d+)?\//.test(url)
? '=http,https'
: '=https',
'--proto-redir',
'=https',
'--connect-timeout',
'30',
'--max-time',
String(
artifact === 'qinglong-bundle' && expected?.size
? Math.max(600, Math.min(7200, Math.ceil(expected.size / 8192) + 120))
: 600,
),
'--speed-limit',
'1024',
'--speed-time',
'30',
'--dump-header',
headers,
'--output',
archive,
url,
]);
clearInterval(timer);
await sampling;
if (expected) {
const bytes = await fs.readFile(archive);
if (bytes.length !== expected.size || hash(bytes) !== expected.sha256)
throw new Error('Upgrade archive size or checksum mismatch');
}
await sample(true);
} finally {
clearInterval(timer);
await sampling;
}
}
async function stageUpgrade({
root,
tmp,
env,
mirror,
fallbackMirror,
run,
install,
signal,
onProgress,
}) {
const currentPackage = JSON.parse(
await fs
.readFile(path.join(root, 'package.json'), 'utf8')
.catch((error) => {
if (error.code !== 'ENOENT') throw error;
return '{}';
}),
);
if (env.QL_UPGRADE_FILE || currentPackage.qinglongUpgradeFormat === 2) {
return require('./platformBundle.cjs').stageBundle({
root,
tmp,
env,
run,
signal,
onProgress,
verifyStatic: verifyUpgrade,
download: downloadWithProgress,
});
}
const report = onProgress || (async () => {});
const workspace = await fs.mkdtemp(path.join(tmp, 'upgrade-'));
const branch = releaseBranch(env);
try {
// Metadata is optional for older releases and unavailable mirrors. Never
// invent a percentage when neither metadata nor HTTP supplies a total.
let info;
if (onProgress && mirror === 'github') {
try {
const readJson = async (url) => {
const filename = path.join(workspace, 'metadata.json');
await run('curl', [
'--fail',
'--silent',
'--show-error',
'--location',
'--proto',
'=https',
'--proto-redir',
'=https',
'--connect-timeout',
'5',
'--max-time',
'10',
'--max-filesize',
'1048576',
'--dump-header',
`${filename}.headers`,
'--output',
filename,
url,
]);
return JSON.parse(await fs.readFile(filename, 'utf8'));
};
const base = 'https://raw.githubusercontent.com/whyour/qinglong-static';
const current = await readJson(
`${base}/${branch}/build-info.json?t=${Date.now()}`,
);
if (!/^[a-f0-9]{40}$/.test(current.sourceCommit))
throw new Error('Invalid commit');
const candidate = await readJson(
`${base}/upgrade-info/${current.sourceCommit}.json`,
);
if (
candidate.version !== 1 ||
candidate.sourceCommit !== current.sourceCommit ||
!/^[a-f0-9]{40}$/.test(candidate.staticCommit) ||
!['qinglong-static', 'qinglong'].every((repo) => {
const entry = candidate.archives?.[repo];
return (
Number.isSafeInteger(entry?.size) &&
entry.size > 0 &&
/^[a-f0-9]{64}$/.test(entry.sha256)
);
})
)
throw new Error('Invalid archive metadata');
info = candidate;
} catch {
signal?.throwIfAborted();
}
}
async function extract(repository, ref) {
const archive = path.join(workspace, `${repository}.zip`);
const url =
mirror === 'github'
? `https://codeload.github.com/whyour/${repository}/zip/${ref}`
: `https://gitee.com/whyour/${repository}/repository/archive/${ref}.zip`;
const download = async (target, expected) => {
await report({
phase: 'downloading',
artifact: repository,
downloadedBytes: 0,
totalBytes: expected?.size || null,
percent: expected ? 0 : null,
});
await downloadWithProgress(
run,
archive,
target,
repository,
report,
Boolean(onProgress),
expected,
);
};
try {
await download(
url,
mirror === 'github' ? info?.archives[repository] : undefined,
);
} catch (error) {
signal?.throwIfAborted();
if (mirror !== 'github' || fallbackMirror !== 'gitee') throw error;
console.warn(upgradeMessage('GitHub 下载失败,切换 Gitee...', env));
await download(
`https://gitee.com/whyour/${repository}/repository/archive/${ref}.zip`,
);
}
await report({
phase: 'extracting',
artifact: repository,
percent: null,
});
const listing = await run('unzip', ['-Z1', archive], true);
const expected = `${repository}-${ref}`;
const names = listing.stdout.split('\n').filter(Boolean);
if (
!names.length ||
names.some(
(name) =>
name.includes('\\') ||
name.includes('\r') ||
name.startsWith('/') ||
name.split('/').includes('..') ||
name.split('/')[0] !== expected,
)
)
throw new Error(upgradeMessage('升级归档路径无效。', env));
const modes = await run('unzip', ['-Z', '-l', archive], true);
if (modes.stdout.split('\n').some((line) => /^l[rwx-]{9}\s/.test(line)))
throw new Error(upgradeMessage('升级归档包含符号链接。', env));
await run('unzip', ['-oq', archive, '-d', workspace]);
return path.join(workspace, expected);
}
// Static branches are mutable snapshots. Their manifest pins the exact
// source revision, avoiding a race between two independent branch reads.
const downloadedStatic = await extract(
'qinglong-static',
info?.staticCommit || branch,
);
const staticRoot = path.join(workspace, `qinglong-static-${branch}`);
if (downloadedStatic !== staticRoot)
await fs.rename(downloadedStatic, staticRoot);
const manifest = await manifestAt(staticRoot, env);
if (info && manifest.sourceCommit !== info.sourceCommit)
throw new Error(upgradeMessage('构建清单无效。', env));
const downloadedSource = await extract('qinglong', manifest.sourceCommit);
const source = path.join(workspace, `qinglong-${branch}`);
await fs.rename(downloadedSource, source);
await report({ phase: 'verifying', percent: null });
await verifyUpgrade(source, staticRoot, env);
let dependenciesChanged = false;
for (const name of ['package.json', 'pnpm-lock.yaml']) {
const current = await fs
.readFile(path.join(root, name))
.catch((error) => {
if (error.code !== 'ENOENT') throw error;
return Buffer.alloc(0);
});
if (!(await fs.readFile(path.join(source, name))).equals(current))
dependenciesChanged = true;
}
if (
!(await fs.stat(path.join(root, 'node_modules')).catch(() => undefined))
)
dependenciesChanged = true;
if (dependenciesChanged) {
await report({ phase: 'dependencies', percent: null });
await install(source);
await fs.access(path.join(source, 'node_modules'));
await verifyUpgrade(source, staticRoot, env);
}
signal?.throwIfAborted();
const selected = { source, static: staticRoot };
await fs.writeFile(
path.join(workspace, 'ready.json'),
JSON.stringify({
...selected,
root: path.resolve(root),
sourceCommit: manifest.sourceCommit,
dependenciesChanged,
}),
{ flag: 'wx', mode: 0o600 },
);
const pointer = path.join(workspace, 'pointer.json');
await fs.writeFile(
pointer,
JSON.stringify({ directory: path.basename(workspace) }),
{ flag: 'wx', mode: 0o600 },
);
const readyPointer = path.join(tmp, `upgrade-ready-${branch}.json`);
const previous = await fs
.readFile(readyPointer, 'utf8')
.catch(() => undefined);
signal?.throwIfAborted();
await fs.rename(pointer, readyPointer);
// Retire only our previous successful stage, after publishing its replacement.
try {
const directory = previous && JSON.parse(previous).directory;
if (
typeof directory === 'string' &&
/^upgrade-[A-Za-z0-9_-]+$/.test(directory) &&
directory !== path.basename(workspace)
)
await fs.rm(path.join(tmp, directory), {
recursive: true,
force: true,
});
} catch {
/* Cleanup cannot invalidate the newly published stage. */
}
return selected;
} catch (error) {
await fs.rm(workspace, { recursive: true, force: true });
throw error;
}
}
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'),
);
if (
typeof pointer.directory !== 'string' ||
!/^upgrade-[A-Za-z0-9_-]+$/.test(pointer.directory)
)
throw new Error(upgradeMessage('暂存升级指针无效。', env));
const workspace = path.join(tmp, pointer.directory);
const selected = {
source: path.join(workspace, `qinglong-${branch}`),
static: path.join(workspace, `qinglong-static-${branch}`),
};
for (const directory of [workspace, selected.source, selected.static]) {
const stat = await fs.lstat(directory);
if (!stat.isDirectory() || stat.isSymbolicLink())
throw new Error(upgradeMessage('暂存目录无效。', env));
}
const ready = JSON.parse(
await fs.readFile(path.join(workspace, 'ready.json'), 'utf8'),
);
if (ready.source !== selected.source || ready.static !== selected.static)
throw new Error(upgradeMessage('暂存升级就绪记录不匹配。', 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 && !metadataOnly)
await require('./platformBundle.cjs').verifyStagedBundle(selected);
if (ready.dependenciesChanged)
await fs.access(path.join(selected.source, 'node_modules'));
else {
const root = ready.root || env.QL_DIR;
if (
!root ||
!(
await fs.stat(path.join(root, 'node_modules')).catch(() => undefined)
)?.isDirectory()
)
throw new Error(upgradeMessage('运行依赖已变化,请重新下载更新。', env));
for (const name of ['package.json', 'pnpm-lock.yaml']) {
const current = await fs
.readFile(path.join(root, name))
.catch(() => undefined);
if (
!current ||
!(await fs.readFile(path.join(selected.source, name))).equals(current)
)
throw new Error(
upgradeMessage('运行依赖已变化,请重新下载更新。', env),
);
}
}
return selected;
}
async function replaceAndReload(replacements, lifecycle, env = process.env) {
const changed = [];
let attemptedStart = false;
lifecycle.signal?.throwIfAborted();
try {
await lifecycle.stop();
for (const { source, target } of replacements) {
lifecycle.signal?.throwIfAborted();
await fs.mkdir(path.dirname(target), { recursive: true });
const backup = `${target}.ql-backup-${randomUUID()}`;
const existed = !!(await fs.lstat(target).catch((error) => {
if (error.code !== 'ENOENT') throw error;
return undefined;
}));
if (existed) {
try {
await fs.rename(target, backup);
} catch (error) {
if (error.code !== 'EXDEV') throw error;
try {
await fs.cp(target, backup, {
recursive: true,
verbatimSymlinks: true,
});
} catch (copyError) {
await fs.rm(backup, { recursive: true, force: true });
throw copyError;
}
changed.push({ target, backup, existed });
await fs.rm(target, { recursive: true });
}
}
if (changed.at(-1)?.target !== target)
changed.push({ target, backup, existed });
await fs.cp(source, target, {
recursive: true,
verbatimSymlinks: true,
force: false,
errorOnExist: true,
});
}
lifecycle.signal?.throwIfAborted();
attemptedStart = true;
await lifecycle.start();
lifecycle.signal?.throwIfAborted();
} catch (error) {
// Do not modify files under a still-running failed startup. Keep backups
// for recovery if stopping the new process itself fails.
if (attemptedStart) await lifecycle.stop(true);
for (const item of changed.reverse()) {
await fs.rm(item.target, { recursive: true, force: true });
if (item.existed) {
try {
await fs.rename(item.backup, item.target);
} catch (restoreError) {
if (restoreError.code !== 'EXDEV') throw restoreError;
await fs.cp(item.backup, item.target, {
recursive: true,
verbatimSymlinks: true,
});
await fs.rm(item.backup, { recursive: true });
}
}
}
await lifecycle.start(true);
throw error;
}
for (const item of changed)
if (item.existed)
await fs
.rm(item.backup, { recursive: true, force: true })
.catch(() =>
console.error(upgradeMessage('保留备份: %s', env, item.backup)),
);
}
async function reloadSystem({
root,
data,
tmp,
staticRoot,
config,
env,
lifecycle,
additionalReplacements = [],
}) {
const selected = await selectedUpgrade(tmp, releaseBranch(env), env);
const reserved = new Set(['data', '.tmp', 'static', '.git', '.env']);
const relative = path.relative(root, data);
if (relative && !relative.startsWith('..') && !path.isAbsolute(relative))
reserved.add(relative.split(path.sep)[0]);
const replacements = (await fs.readdir(selected.source))
.filter((name) => !reserved.has(name))
.map((name) => ({
source: path.join(selected.source, name),
target: path.join(root, name),
}));
replacements.push(
{ source: selected.static, target: staticRoot },
{
source: path.join(selected.source, 'sample/config.sample.sh'),
target: path.join(config, 'config.sample.sh'),
},
);
replacements.push(...additionalReplacements);
await replaceAndReload(replacements, lifecycle, env);
// Cleanup after a committed installation must not report a failed update.
await fs
.rm(path.join(tmp, `upgrade-ready-${releaseBranch(env)}.json`), {
force: true,
})
.catch(() => {});
await fs
.rm(path.dirname(selected.source), { recursive: true, force: true })
.catch(() => {});
}
function startupHealthTimeout(runtime = process) {
// ARMv6 starts the database, scheduler and HTTP workers sequentially. Give
// slow devices time to become healthy before deciding to restore a backup.
return runtime.arch === 'arm' &&
Number(runtime.config.variables.arm_version) === 6
? 300000
: 60000;
}
async function waitForHealth(env, timeout = startupHealthTimeout(), signal) {
const port = Number(env.QlPort || 5700);
let base = env.QlBaseUrl || '';
if (base && !base.startsWith('/')) base = `/${base}`;
if (base.endsWith('/')) base = base.slice(0, -1);
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
signal?.throwIfAborted();
const healthy = await new Promise((resolve) => {
let timer;
const finish = (healthy) => {
clearTimeout(timer);
resolve(healthy);
};
const req = http.get(
{ hostname: '127.0.0.1', port, path: `${base}/api/health`, signal },
(res) => {
let body = '';
res.on('data', (chunk) => {
body += chunk;
if (body.length > 65536) req.destroy();
});
res.on('end', () => {
try {
const result = JSON.parse(body);
finish(
res.statusCode === 200 &&
result.code === 200 &&
result.data?.status === 'ok',
);
} catch {
finish(false);
}
});
res.on('error', () => finish(false));
},
);
// Socket inactivity timeouts do not bound a response that keeps streaming.
timer = setTimeout(
() => req.destroy(new Error('Health probe timed out')),
Math.min(1000, Math.max(1, deadline - Date.now())),
);
req.on('error', () => finish(false));
});
signal?.throwIfAborted();
if (healthy) return;
const remaining = deadline - Date.now();
if (remaining > 0) {
try {
await delay(Math.min(500, remaining), undefined, { signal });
} catch (error) {
signal?.throwIfAborted();
throw error;
}
}
}
throw new Error(upgradeMessage('后端启动检查失败。', env));
}
function runProcess(env, program, args, capture = false, signal) {
if (signal?.aborted) return Promise.reject(signal.reason);
return new Promise((resolve, reject) => {
const child = spawn(program, args, {
env,
detached: process.platform !== 'win32',
// Pipes keep close pending until children have released their output.
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let aborted = false;
let failure;
let escalation;
const kill = (name) => {
if (!child.pid) return;
try {
process.kill(
process.platform === 'win32' ? child.pid : -child.pid,
name,
);
} catch (error) {
if (error.code === 'EPERM') {
child.kill(name);
return;
}
if (error.code !== 'ESRCH') throw error;
}
};
const abort = () => {
if (aborted) return;
aborted = true;
kill('SIGTERM');
escalation = setTimeout(() => kill('SIGKILL'), 1000);
escalation.unref();
};
child.stdout.on('data', (chunk) => {
if (capture) stdout += chunk;
else process.stdout.write(chunk);
});
child.stderr.on('data', (chunk) => process.stderr.write(chunk));
child.once('error', (error) => {
failure = error;
});
child.once('exit', () => {
// The shell can exit before a descendant that ignores SIGTERM.
if (aborted) kill('SIGKILL');
});
child.once('close', (code) => {
if (aborted) kill('SIGKILL');
clearTimeout(escalation);
signal?.removeEventListener('abort', abort);
if (aborted) reject(signal.reason);
else if (failure) reject(failure);
else if (code === 0) resolve({ stdout });
else reject(new Error(`${program} failed (exit ${code})`));
});
signal?.addEventListener('abort', abort, { once: true });
if (signal?.aborted) abort();
});
}
async function withLifecycleHooks(root, tmp, env, operation) {
const helpers = await fs.readFile(path.join(root, 'shell/share.sh'), 'utf8');
const environment = await fs.readFile(
path.join(root, 'shell/env.sh'),
'utf8',
);
const directory = await fs.mkdtemp(path.join(tmp, 'upgrade-hooks-'));
try {
// Keep the installed functions available through replacement and rollback.
// Their pkill patterns must never become part of the bash command line.
const snapshot = path.join(directory, 'lifecycle.sh');
await fs.writeFile(
snapshot,
`${helpers}\n${environment}\nimport_config
case "$1" in
stop) delete_pm2 ;;
start) reload_pm2 ;;
install) npm_install_2 "$2"; exit "$exit_status" ;;
*) printf '%s\\n' 'Unknown lifecycle operation' >&2; exit 2 ;;
esac
`,
{
flag: 'wx',
mode: 0o600,
},
);
// Offline selection and worker ownership belong to this operation only.
// Passing them to PM2 would pin future online updates to the old local file.
const lifecycleEnv = { ...env };
for (const name of [
'QL_UPGRADE_FILE',
'QL_UPGRADE_REPAIR_DEPS',
'QL_UPGRADE_LAUNCHER_PID',
])
delete lifecycleEnv[name];
const hook = (operation, args = [], signal) =>
runProcess(
lifecycleEnv,
'bash',
['--', snapshot, operation, ...args],
false,
signal,
);
return await operation(hook);
} finally {
await fs
.rm(directory, { recursive: true, force: true })
.catch(() =>
console.error(upgradeMessage('保留生命周期快照: %s', env, directory)),
);
}
}
async function main() {
const env = process.env,
root = env.QL_DIR;
if (!root || !path.isAbsolute(root))
throw new Error('QL_DIR must be absolute.');
const tmp = env.dir_tmp || path.join(root, '.tmp');
const controller = new AbortController();
for (const name of ['SIGINT', 'SIGTERM'])
process.on(name, () =>
controller.abort(new Error(`Upgrade interrupted (${name})`)),
);
const run = (program, args, capture) =>
runProcess(env, program, args, capture, controller.signal);
await withUpgradeLock(
tmp,
() =>
withLifecycleHooks(root, tmp, env, async (hook) => {
let job;
const lifecycle = {
signal: controller.signal,
stop: async () => {
await job?.onStop();
await hook('stop');
},
start: async (recover) => {
await hook('start', [], recover ? undefined : controller.signal);
await waitForHealth(
env,
undefined,
recover ? undefined : controller.signal,
);
await job?.onStart(recover);
},
};
if (process.argv[2] === 'update') {
await stageUpgrade({
root,
tmp,
env,
mirror: process.argv[3],
// The Bash updater chooses its source automatically. An explicitly
// selected CLI mirror does not opt into this network-only failover.
fallbackMirror: 'gitee',
run,
signal: controller.signal,
install: (source) => hook('install', [source], controller.signal),
});
console.log(upgradeMessage('更新包下载及校验成功...', env));
if (process.argv[4] !== 'true') return;
} else if (process.argv[2] !== 'reload')
throw new Error('Unknown upgrade command.');
const apply = async (preparedJob) => {
job = preparedJob;
await reloadSystem({
root,
tmp,
env,
data: env.dir_data || path.join(root, 'data'),
staticRoot: env.dir_static || path.join(root, 'static'),
config: env.dir_config || path.join(root, 'data/config'),
lifecycle,
});
};
// Legacy reloads can carry only this self-contained Bash verifier.
// Complete-bundle installs also track manual applies without a job file.
const hasPanelJob = await fs
.access(path.join(tmp, 'panel-upgrade.json'))
.then(() => true)
.catch((error) => {
if (error.code !== 'ENOENT') throw error;
return false;
});
const packageInfo = hasPanelJob
? {}
: JSON.parse(
await fs
.readFile(path.join(root, 'package.json'), 'utf8')
.catch((error) => {
if (error.code !== 'ENOENT') throw error;
return '{}';
}),
);
if (hasPanelJob || packageInfo.qinglongUpgradeFormat === 2) {
const selected = await selectedUpgrade(tmp, releaseBranch(env), env, {
metadataOnly: true,
});
await require('./panelUpgrade.cjs').withPreparedJob(
tmp,
env,
selected,
apply,
);
} else await apply();
console.log(upgradeMessage('更新及启动检查成功...', env));
}),
env,
);
}
module.exports = {
downloadWithProgress,
upgradeMessage,
releaseBranch,
verifyUpgrade,
withUpgradeLock,
stageUpgrade,
selectedUpgrade,
replaceAndReload,
reloadSystem,
startupHealthTimeout,
waitForHealth,
runProcess,
withLifecycleHooks,
main,
};
if (require.main === module)
main().catch((error) => {
console.error(upgradeMessage('更新失败: %s', process.env, error.message));
process.exitCode = 1;
});