fix: synchronize runtime locales and harden online updates

Translate UI, scheduler, validation, preload and maintenance messages dynamically, and propagate locale changes between workers. Harden update checks, downloads and terminal states, verify Debian pnpm, and ignore generated test artifacts.
This commit is contained in:
whyour
2026-10-17 07:07:09 +08:00
parent 8305825ffd
commit ef348b445b
72 changed files with 1733 additions and 553 deletions
+8 -8
View File
@@ -82,7 +82,7 @@ export const nodePreloadSource: string = [
'{ if [ -n "$inline" ]; then eval "$inline"; fi; } &&',
"printf '%s\\\\n' '__sitecustomize__' &&",
'node -e \'require("fs").writeFileSync(process.argv[1], JSON.stringify(process.env))\' "$output"`;',
" if (task_before) console.log('执行前置命令\\n');",
" if (task_before) console.log(process.env.QL_LANG === 'en' ? 'Executing before command\\n' : '执行前置命令\\n');",
' const hookArgs = JSON.parse(process.env.QL_CLI_PRELOAD_ARGS || JSON.stringify([fileName]));',
" const res = execFileSync('/bin/bash', ['-c', commands, 'ql-before', file_task_before, tempFile, task_before || '', ...hookArgs], {",
" encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024,",
@@ -115,7 +115,7 @@ export const nodePreloadSource: string = [
' console.log(output);',
' }',
' if (task_before) {',
" console.log('执行前置命令结束\\n');",
" console.log(process.env.QL_LANG === 'en' ? 'Before command finished\\n' : '执行前置命令结束\\n');",
' }',
' } catch (error) {',
" if (!error.message.includes('spawnSync /bin/bash E2BIG')) {",
@@ -124,7 +124,7 @@ export const nodePreloadSource: string = [
' // environment variable is too large',
' }',
' if (task_before) {',
" console.log('执行前置命令结束\\n');",
" console.log(process.env.QL_LANG === 'en' ? 'Before command finished\\n' : '执行前置命令结束\\n');",
' }',
' }',
'',
@@ -214,7 +214,7 @@ export const pythonPreloadSource: string = [
' ',
' task_before = os.getenv("task_before", "")',
' if task_before:',
' print("执行前置命令\\n")',
' print("Executing before command\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令\\n")',
' command = """file="$1"; output="$2"; inline="$3"; shift 3',
'source "$file" "$@" &&',
'{ if [ -n "$inline" ]; then eval "$inline"; fi; } &&',
@@ -244,12 +244,12 @@ export const pythonPreloadSource: string = [
' if len(output) > 0:',
' print(output)',
' if task_before:',
' print("执行前置命令结束\\n")',
' print("Before command finished\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\\n")',
'',
' except subprocess.CalledProcessError as error:',
' print(f"\\ue926 run task before error: {error}")',
' if task_before:',
' print("执行前置命令结束\\n")',
' print("Before command finished\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\\n")',
' except OSError as error:',
' error_message = str(error)',
' if "Argument list too long" not in error_message:',
@@ -257,11 +257,11 @@ export const pythonPreloadSource: string = [
' # else:',
' # environment variable is too large',
' if task_before:',
' print("执行前置命令结束\\n")',
' print("Before command finished\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\\n")',
' except Exception as error:',
' print(f"\\ue926 run task before error: {error}")',
' if task_before:',
' print("执行前置命令结束\\n")',
' print("Before command finished\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\\n")',
'',
' import task_before',
'',
+39 -23
View File
@@ -11,13 +11,21 @@ import { installPanelDependencies, startPanel, stopPanel } from './operator';
// The Bash image updater and local CLI must enforce the same build provenance.
const artifacts = require('./upgradeArtifacts.cjs') as {
releaseBranch(env: NodeJS.ProcessEnv): string;
verifyUpgrade(source: string, staticRoot: string): Promise<unknown>;
verifyUpgrade(
source: string,
staticRoot: string,
env?: NodeJS.ProcessEnv,
): Promise<unknown>;
waitForHealth(
env: NodeJS.ProcessEnv,
timeout?: number,
signal?: AbortSignal,
): Promise<void>;
withUpgradeLock<T>(tmp: string, operation: () => Promise<T>): Promise<T>;
withUpgradeLock<T>(
tmp: string,
operation: () => Promise<T>,
env?: NodeJS.ProcessEnv,
): Promise<T>;
stageUpgrade(options: {
root: string;
tmp: string;
@@ -30,6 +38,7 @@ const artifacts = require('./upgradeArtifacts.cjs') as {
selectedUpgrade(
tmp: string,
branch: string,
env?: NodeJS.ProcessEnv,
): Promise<{ source: string; static: string }>;
};
@@ -69,17 +78,20 @@ export async function stageUpgrade(
context: LocalContext,
mirror: 'github' | 'gitee',
): Promise<{ source: string; static: string }> {
return artifacts.withUpgradeLock(context.paths.dir_tmp!, () =>
artifacts.stageUpgrade({
root: context.root,
tmp: context.paths.dir_tmp!,
env: context.env,
mirror,
signal: operationSignal(),
run: (program, args, capture) =>
checkedProcess(program, args, { env: context.env, capture }),
install: (source) => installPanelDependencies(context, source),
}),
return artifacts.withUpgradeLock(
context.paths.dir_tmp!,
() =>
artifacts.stageUpgrade({
root: context.root,
tmp: context.paths.dir_tmp!,
env: context.env,
mirror,
signal: operationSignal(),
run: (program, args, capture) =>
checkedProcess(program, args, { env: context.env, capture }),
install: (source) => installPanelDependencies(context, source),
}),
context.env,
);
}
@@ -89,6 +101,7 @@ async function selectedUpgrade(
return artifacts.selectedUpgrade(
context.paths.dir_tmp!,
releaseBranch(context),
context.env,
);
}
@@ -259,7 +272,7 @@ export async function reloadPanel(
const staticRoot = selected.static;
await validateTree(source, context.env);
await validateTree(staticRoot, context.env);
await artifacts.verifyUpgrade(source, staticRoot);
await artifacts.verifyUpgrade(source, staticRoot, context.env);
const entries = await fs.readdir(source);
const reserved = new Set(['data', '.tmp', 'static', '.git', '.env']);
const relativeData = path.relative(context.root, context.data);
@@ -284,15 +297,18 @@ export async function reloadPanel(
if (selection)
replacements.push({ source: selection.source, target: selection.target });
try {
return await artifacts.withUpgradeLock(context.paths.dir_tmp!, () =>
replaceAndReload(context, replacements, {
stop: stopPanel,
start: async (ctx) => {
const service = await startPanel(ctx);
await artifacts.waitForHealth(ctx.env, 60000, operationSignal());
return service;
},
}),
return await artifacts.withUpgradeLock(
context.paths.dir_tmp!,
() =>
replaceAndReload(context, replacements, {
stop: stopPanel,
start: async (ctx) => {
const service = await startPanel(ctx);
await artifacts.waitForHealth(ctx.env, 60000, operationSignal());
return service;
},
}),
context.env,
);
} finally {
if (selection)
+138 -88
View File
@@ -7,11 +7,39 @@ 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',
'保留备份: %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) {
async function manifestAt(staticRoot, env = process.env) {
const manifest = JSON.parse(
await fs.readFile(path.join(staticRoot, 'build-info.json'), 'utf8'),
);
@@ -24,22 +52,20 @@ async function manifestAt(staticRoot) {
typeof manifest.files !== 'object' ||
Array.isArray(manifest.files)
)
throw new Error('Invalid or dirty build manifest / 构建清单无效。');
throw new Error(upgradeMessage('构建清单无效。', env));
for (const required of ['build/app.js', 'dist/index.html'])
if (!Object.hasOwn(manifest.files, required))
throw new Error(`Missing build output / 缺少构建产物: ${required}`);
throw new Error(upgradeMessage('缺少构建产物: %s', env, required));
return manifest;
}
async function verifyUpgrade(source, staticRoot) {
const manifest = await manifestAt(staticRoot);
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(
'Source and build lockfiles differ / 源码与构建依赖不一致。',
);
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 = {};
@@ -50,8 +76,7 @@ async function verifyUpgrade(source, staticRoot) {
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(`Unsupported build entry / 构建包含非法文件: ${name}`);
else throw new Error(upgradeMessage('构建包含非法文件: %s', env, name));
}
}
await visit(staticRoot);
@@ -61,13 +86,11 @@ async function verifyUpgrade(source, staticRoot) {
JSON.stringify(actual) !== JSON.stringify(expected) ||
actual.some((name) => files[name] !== manifest.files[name])
)
throw new Error(
'Build checksum mismatch / 构建文件缺失、损坏或混入旧文件。',
);
throw new Error(upgradeMessage('构建文件缺失、损坏或混入旧文件。', env));
return manifest;
}
async function withUpgradeLock(tmp, operation) {
async function withUpgradeLock(tmp, operation, env = process.env) {
await fs.mkdir(tmp, { recursive: true });
const lock = path.join(tmp, 'upgrade.lock');
const token = randomUUID();
@@ -123,9 +146,7 @@ async function withUpgradeLock(tmp, operation) {
}
}
if (!stale)
throw new Error(
'Another upgrade is running / 已有更新或重载正在执行。',
);
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.
@@ -150,7 +171,16 @@ async function withUpgradeLock(tmp, operation) {
}
}
async function stageUpgrade({ root, tmp, env, mirror, run, install, signal }) {
async function stageUpgrade({
root,
tmp,
env,
mirror,
fallbackMirror,
run,
install,
signal,
}) {
const workspace = await fs.mkdtemp(path.join(tmp, 'upgrade-'));
const branch = releaseBranch(env);
try {
@@ -158,23 +188,38 @@ async function stageUpgrade({ root, tmp, env, mirror, run, install, signal }) {
const archive = path.join(workspace, `${repository}.zip`);
const url =
mirror === 'github'
? `https://github.com/whyour/${repository}/archive/${ref}.zip`
? `https://codeload.github.com/whyour/${repository}/zip/${ref}`
: `https://gitee.com/whyour/${repository}/repository/archive/${ref}.zip`;
await run('curl', [
'--fail',
'--location',
'--proto',
'=https',
'--proto-redir',
'=https',
'--connect-timeout',
'30',
'--max-time',
'600',
'--output',
archive,
url,
]);
const download = (target) =>
run('curl', [
'--fail',
'--location',
'--proto',
'=https',
'--proto-redir',
'=https',
'--connect-timeout',
'30',
'--max-time',
'600',
'--speed-limit',
'1024',
'--speed-time',
'30',
'--output',
archive,
target,
]);
try {
await download(url);
} 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`,
);
}
const listing = await run('unzip', ['-Z1', archive], true);
const expected = `${repository}-${ref}`;
const names = listing.stdout.split('\n').filter(Boolean);
@@ -189,21 +234,21 @@ async function stageUpgrade({ root, tmp, env, mirror, run, install, signal }) {
name.split('/')[0] !== expected,
)
)
throw new Error('Invalid archive paths / 升级归档路径无效。');
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('Archive contains symlinks / 升级归档包含符号链接。');
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 staticRoot = await extract('qinglong-static', branch);
const manifest = await manifestAt(staticRoot);
const manifest = await manifestAt(staticRoot, env);
const downloadedSource = await extract('qinglong', manifest.sourceCommit);
const source = path.join(workspace, `qinglong-${branch}`);
await fs.rename(downloadedSource, source);
await verifyUpgrade(source, staticRoot);
await verifyUpgrade(source, staticRoot, env);
let dependenciesChanged = false;
for (const name of ['package.json', 'pnpm-lock.yaml']) {
const current = await fs
@@ -222,7 +267,7 @@ async function stageUpgrade({ root, tmp, env, mirror, run, install, signal }) {
if (dependenciesChanged) {
await install(source);
await fs.access(path.join(source, 'node_modules'));
await verifyUpgrade(source, staticRoot);
await verifyUpgrade(source, staticRoot, env);
}
signal?.throwIfAborted();
const selected = { source, static: staticRoot };
@@ -269,7 +314,7 @@ async function stageUpgrade({ root, tmp, env, mirror, run, install, signal }) {
}
}
async function selectedUpgrade(tmp, branch) {
async function selectedUpgrade(tmp, branch, env = process.env) {
const pointer = JSON.parse(
await fs.readFile(path.join(tmp, `upgrade-ready-${branch}.json`), 'utf8'),
);
@@ -277,7 +322,7 @@ async function selectedUpgrade(tmp, branch) {
typeof pointer.directory !== 'string' ||
!/^upgrade-[A-Za-z0-9_-]+$/.test(pointer.directory)
)
throw new Error('Invalid staged upgrade pointer / 暂存升级指针无效。');
throw new Error(upgradeMessage('暂存升级指针无效。', env));
const workspace = path.join(tmp, pointer.directory);
const selected = {
source: path.join(workspace, `qinglong-${branch}`),
@@ -286,22 +331,22 @@ async function selectedUpgrade(tmp, branch) {
for (const directory of [workspace, selected.source, selected.static]) {
const stat = await fs.lstat(directory);
if (!stat.isDirectory() || stat.isSymbolicLink())
throw new Error('Invalid staging directory / 暂存目录无效。');
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('Invalid upgrade readiness / 暂存升级就绪记录不匹配。');
const manifest = await verifyUpgrade(selected.source, selected.static);
throw new Error(upgradeMessage('暂存升级就绪记录不匹配。', env));
const manifest = await verifyUpgrade(selected.source, selected.static, env);
if (ready.sourceCommit !== manifest.sourceCommit)
throw new Error('Invalid upgrade readiness / 暂存升级就绪记录不匹配。');
throw new Error(upgradeMessage('暂存升级就绪记录不匹配。', env));
if (ready.dependenciesChanged)
await fs.access(path.join(selected.source, 'node_modules'));
return selected;
}
async function replaceAndReload(replacements, lifecycle) {
async function replaceAndReload(replacements, lifecycle, env = process.env) {
const changed = [];
let attemptedStart = false;
lifecycle.signal?.throwIfAborted();
@@ -373,7 +418,7 @@ async function replaceAndReload(replacements, lifecycle) {
await fs
.rm(item.backup, { recursive: true, force: true })
.catch(() =>
console.error(`Retained backup / 保留备份: ${item.backup}`),
console.error(upgradeMessage('保留备份: %s', env, item.backup)),
);
}
@@ -386,7 +431,7 @@ async function reloadSystem({
env,
lifecycle,
}) {
const selected = await selectedUpgrade(tmp, releaseBranch(env));
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))
@@ -404,7 +449,7 @@ async function reloadSystem({
target: path.join(config, 'config.sample.sh'),
},
);
await replaceAndReload(replacements, lifecycle);
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`), {
@@ -472,7 +517,7 @@ async function waitForHealth(env, timeout = 60000, signal) {
}
}
}
throw new Error('Backend failed health check / 后端启动检查失败。');
throw new Error(upgradeMessage('后端启动检查失败。', env));
}
function runProcess(env, program, args, capture = false, signal) {
@@ -575,9 +620,7 @@ esac
await fs
.rm(directory, { recursive: true, force: true })
.catch(() =>
console.error(
`Retained lifecycle snapshot / 保留生命周期快照: ${directory}`,
),
console.error(upgradeMessage('保留生命周期快照: %s', env, directory)),
);
}
}
@@ -595,49 +638,56 @@ async function main() {
);
const run = (program, args, capture) =>
runProcess(env, program, args, capture, controller.signal);
await withUpgradeLock(tmp, () =>
withLifecycleHooks(root, tmp, env, async (hook) => {
const lifecycle = {
signal: controller.signal,
stop: () => hook('stop'),
start: async (recover) => {
await hook('start', [], recover ? undefined : controller.signal);
await waitForHealth(
await withUpgradeLock(
tmp,
() =>
withLifecycleHooks(root, tmp, env, async (hook) => {
const lifecycle = {
signal: controller.signal,
stop: () => hook('stop'),
start: async (recover) => {
await hook('start', [], recover ? undefined : controller.signal);
await waitForHealth(
env,
60000,
recover ? undefined : controller.signal,
);
},
};
if (process.argv[2] === 'update') {
await stageUpgrade({
root,
tmp,
env,
60000,
recover ? undefined : controller.signal,
);
},
};
if (process.argv[2] === 'update') {
await stageUpgrade({
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.');
await reloadSystem({
root,
tmp,
env,
mirror: process.argv[3],
run,
signal: controller.signal,
install: (source) => hook('install', [source], controller.signal),
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,
});
console.log('更新包下载及校验成功...');
if (process.argv[4] !== 'true') return;
} else if (process.argv[2] !== 'reload')
throw new Error('Unknown upgrade command.');
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,
});
console.log('更新及启动检查成功...');
}),
console.log(upgradeMessage('更新及启动检查成功...', env));
}),
env,
);
}
module.exports = {
upgradeMessage,
releaseBranch,
verifyUpgrade,
withUpgradeLock,
@@ -652,6 +702,6 @@ module.exports = {
};
if (require.main === module)
main().catch((error) => {
console.error(`更新失败: ${error.message}`);
console.error(upgradeMessage('更新失败: %s', process.env, error.message));
process.exitCode = 1;
});
@@ -214,8 +214,13 @@ async function reconcile(
if (added || removed) {
try {
await api.call('system/notify', 'PUT', {
title: `${namespace} 订阅同步`,
content: `新增 ${added} 个任务,删除 ${removed} 个任务`,
title: translate(context.env, '%s 订阅同步', namespace),
content: translate(
context.env,
'新增 %s 个任务,删除 %s 个任务',
added,
removed,
),
});
} catch {
operationOutput(