mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-04 00:42:01 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8df528123 | ||
|
|
eac7a47f30 | ||
|
|
71a13a1779 | ||
|
|
4040747936 | ||
|
|
035fe8c5d2 |
@@ -212,6 +212,9 @@ const messages: Record<string, Record<string, string>> = {
|
||||
日志设置为忽略: 'Log set to ignore',
|
||||
定时规则不能为空: 'Schedule rule cannot be empty',
|
||||
无效的定时规则: 'Invalid schedule rule',
|
||||
定时规则必须是字符串: 'Schedule rule must be a string',
|
||||
额外定时规则必须是对象: 'Extra schedule must be an object',
|
||||
额外定时规则必须是数组: 'Extra schedules must be an array',
|
||||
'日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符':
|
||||
'Log name can only contain Chinese characters, letters, numbers, underscores, hyphens, dots, and path separators',
|
||||
日志名称不能超过100个字符: 'Log name cannot exceed 100 characters',
|
||||
|
||||
@@ -20,8 +20,10 @@ export const scheduleSchema = Joi.string()
|
||||
.required()
|
||||
.custom(validateSchedule)
|
||||
.messages({
|
||||
'any.required': '定时规则不能为空',
|
||||
'any.invalid': '无效的定时规则',
|
||||
'string.empty': '定时规则不能为空',
|
||||
'string.base': '定时规则必须是字符串',
|
||||
});
|
||||
|
||||
export const commonCronSchema = {
|
||||
@@ -30,7 +32,27 @@ export const commonCronSchema = {
|
||||
schedule: scheduleSchema,
|
||||
labels: Joi.array().optional().allow(null),
|
||||
sub_id: Joi.number().optional().allow(null),
|
||||
extra_schedules: Joi.array().optional().allow(null),
|
||||
extra_schedules: Joi.array()
|
||||
.items(
|
||||
Joi.object({
|
||||
schedule: Joi.string()
|
||||
.required()
|
||||
.custom((value, helpers) =>
|
||||
isValidCronSchedule(value) ? value : helpers.error('any.invalid'),
|
||||
)
|
||||
.messages({
|
||||
'any.required': '定时规则不能为空',
|
||||
'any.invalid': '无效的定时规则',
|
||||
'string.empty': '定时规则不能为空',
|
||||
'string.base': '定时规则必须是字符串',
|
||||
}),
|
||||
})
|
||||
.unknown(true)
|
||||
.messages({ 'object.base': '额外定时规则必须是对象' }),
|
||||
)
|
||||
.optional()
|
||||
.allow(null)
|
||||
.messages({ 'array.base': '额外定时规则必须是数组' }),
|
||||
task_before: Joi.string().optional().allow('').allow(null),
|
||||
task_after: Joi.string().optional().allow('').allow(null),
|
||||
log_name: Joi.string()
|
||||
|
||||
Generated
+76
-2
@@ -7,6 +7,7 @@
|
||||
"": {
|
||||
"name": "@whyour/qinglong-cli",
|
||||
"version": "0.1.2",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"ql": "dist/npm/ql.js"
|
||||
},
|
||||
@@ -14,12 +15,12 @@
|
||||
"@types/node": "22.19.0",
|
||||
"commander": "15.0.0",
|
||||
"esbuild": "0.28.2",
|
||||
"tar": "7.5.20",
|
||||
"typescript": "5.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"license": "Apache-2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.2",
|
||||
@@ -463,6 +464,19 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/fs-minipass": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"minipass": "^7.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.0.tgz",
|
||||
@@ -473,6 +487,16 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "15.0.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
|
||||
@@ -525,6 +549,46 @@
|
||||
"@esbuild/win32-x64": "0.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/minizlib": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
|
||||
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minipass": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.20",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz",
|
||||
"integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/fs-minipass": "^4.0.0",
|
||||
"chownr": "^3.0.0",
|
||||
"minipass": "^7.1.2",
|
||||
"minizlib": "^3.1.0",
|
||||
"yallist": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",
|
||||
@@ -545,6 +609,16 @@
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -26,7 +26,8 @@
|
||||
"commander": "15.0.0",
|
||||
"esbuild": "0.28.2",
|
||||
"typescript": "5.2.2",
|
||||
"@types/node": "22.19.0"
|
||||
"@types/node": "22.19.0",
|
||||
"tar": "7.5.20"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
+11
-4
@@ -17,10 +17,17 @@ fs.copyFileSync(
|
||||
path.join(root, 'src/internal/maintenance/upgradeArtifacts.cjs'),
|
||||
path.join(root, 'dist/internal/maintenance/upgradeArtifacts.cjs'),
|
||||
);
|
||||
fs.copyFileSync(
|
||||
path.join(root, 'src/internal/maintenance/platformBundle.cjs'),
|
||||
path.join(root, 'dist/internal/maintenance/platformBundle.cjs'),
|
||||
);
|
||||
// The operational CLI may live outside the panel and has no panel node_modules.
|
||||
require('esbuild').buildSync({
|
||||
entryPoints: [path.join(root, 'src/internal/maintenance/platformBundle.cjs')],
|
||||
outfile: path.join(root, 'dist/internal/maintenance/platformBundle.cjs'),
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
target: 'node20',
|
||||
minify: true,
|
||||
legalComments: 'inline',
|
||||
});
|
||||
require('esbuild').buildSync({
|
||||
entryPoints: [path.join(root, 'src/shared/cli/commander.ts')],
|
||||
outfile: path.join(root, 'dist/shared/cli/commander.js'),
|
||||
|
||||
@@ -6,6 +6,7 @@ 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');
|
||||
@@ -431,6 +432,7 @@ async function runJob({ root, tmp, env = process.env }, id) {
|
||||
|
||||
module.exports = {
|
||||
getStatus,
|
||||
saveState: save,
|
||||
startJob,
|
||||
runJob,
|
||||
withPanelLifecycle,
|
||||
@@ -442,11 +444,15 @@ 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, [__filename, 'worker', id], {
|
||||
env: { ...process.env, QL_UPGRADE_LAUNCHER_PID: String(process.pid) },
|
||||
detached: true,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
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);
|
||||
|
||||
@@ -7,6 +7,12 @@ const { execFile } = require('node:child_process');
|
||||
const { promisify } = require('node:util');
|
||||
const tar = require('tar');
|
||||
const execute = promisify(execFile);
|
||||
// Heavy archive parsing can hit unsupported V8 JIT paths on ARM1176. Keep
|
||||
// upgrade workers interpreted without changing the installed Node or server.
|
||||
const nodeRuntimeArgs = (runtime = process) =>
|
||||
runtime.arch === 'arm' && Number(runtime.config.variables.arm_version) === 6
|
||||
? ['--jitless']
|
||||
: [];
|
||||
const sha256 = (bytes) =>
|
||||
crypto.createHash('sha256').update(bytes).digest('hex');
|
||||
const inside = (root, filename) => {
|
||||
@@ -198,7 +204,7 @@ async function smokeDependencies(source, signal) {
|
||||
db.serialize(()=>{db.run('CREATE TABLE verify_bundle(value INTEGER)');db.run('INSERT INTO verify_bundle VALUES(42)');
|
||||
db.get('SELECT value FROM verify_bundle',(err,row)=>{if(err||row.value!==42){console.error(err||'SQLite readback failed');process.exitCode=1;}db.close();});});
|
||||
`;
|
||||
await execute(process.execPath, ['-e', probe], {
|
||||
await execute(process.execPath, [...nodeRuntimeArgs(), '-e', probe], {
|
||||
cwd: source,
|
||||
env: { ...process.env, NODE_PATH: '', NODE_OPTIONS: '' },
|
||||
timeout: 30000,
|
||||
@@ -457,6 +463,7 @@ async function verifyStagedBundle(selected) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
nodeRuntimeArgs,
|
||||
atLeast,
|
||||
runtimeTarget,
|
||||
targetKey,
|
||||
|
||||
@@ -226,7 +226,11 @@ async function downloadWithProgress(
|
||||
'--connect-timeout',
|
||||
'30',
|
||||
'--max-time',
|
||||
'600',
|
||||
String(
|
||||
artifact === 'qinglong-bundle' && expected?.size
|
||||
? Math.max(600, Math.min(7200, Math.ceil(expected.size / 8192) + 120))
|
||||
: 600,
|
||||
),
|
||||
'--speed-limit',
|
||||
'1024',
|
||||
'--speed-time',
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
test('the standalone operational CLI verifier carries its archive parser without panel dependencies', (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-cli-bundle-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const filename = path.join(directory, 'platformBundle.cjs');
|
||||
fs.copyFileSync(
|
||||
path.resolve(
|
||||
__dirname,
|
||||
'../../dist/internal/maintenance/platformBundle.cjs',
|
||||
),
|
||||
filename,
|
||||
);
|
||||
const code = `
|
||||
const Module=require('node:module'),load=Module._load;
|
||||
Module._load=function(name,...args){
|
||||
if(name!==process.argv[1]&&!name.startsWith('node:')&&!Module.builtinModules.includes(name))throw new Error('External dependency: '+name);
|
||||
return load.call(this,name,...args);
|
||||
};
|
||||
const bundle=require(process.argv[1]);
|
||||
if(typeof bundle.stageBundle!=='function')throw new Error('Missing verifier');
|
||||
bundle.runtimeTarget().then(target=>console.log(bundle.targetKey(target))).catch(e=>{console.error(e);process.exitCode=1});
|
||||
`;
|
||||
const result = spawnSync(process.execPath, ['-e', code, filename], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, NODE_PATH: '' },
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, new RegExp(`^${process.platform}-`));
|
||||
});
|
||||
@@ -7,7 +7,7 @@ ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
USER root
|
||||
# The generic "arm" sqlite release is ARMv7. ARMv6 must compile its own addon.
|
||||
RUN if [ "$TARGETARCH/$TARGETVARIANT" = arm/v6 ]; then \
|
||||
RUN ulimit -c 0; if [ "$TARGETARCH/$TARGETVARIANT" = arm/v6 ]; then \
|
||||
apk add --no-cache --virtual .ql-bundle-build build-base binutils python3 nodejs-dev && \
|
||||
cd "$(readlink -f /ql/node_modules/sqlite3)" && \
|
||||
CFLAGS='-march=armv6 -mfpu=vfp -mfloat-abi=hard' \
|
||||
@@ -15,9 +15,16 @@ RUN if [ "$TARGETARCH/$TARGETVARIANT" = arm/v6 ]; then \
|
||||
npm_config_nodedir=/usr npm_config_build_from_source=true QEMU_CPU=arm1176 \
|
||||
node -e 'require("node:child_process").execFileSync(process.execPath,[require.resolve("@mapbox/node-pre-gyp/bin/node-pre-gyp"),"rebuild","--napi_build_version=6"],{stdio:"inherit"})' && \
|
||||
readelf -A lib/binding/napi-v6-linux-arm/node_sqlite3.node | grep -E 'Tag_CPU_arch: v6($|K)' && \
|
||||
rm -rf build && \
|
||||
apk del .ql-bundle-build; \
|
||||
fi
|
||||
RUN if [ "$TARGETARCH/$TARGETVARIANT" = arm/v6 ]; then export QEMU_CPU=arm1176; fi; \
|
||||
node /ql/scripts/build-upgrade-bundle.cjs /ql /out
|
||||
RUN ulimit -c 0; if [ "$TARGETARCH/$TARGETVARIANT" = arm/v6 ]; then \
|
||||
export QEMU_CPU=arm1176; \
|
||||
node --jitless /ql/scripts/build-upgrade-bundle.cjs /ql /out && \
|
||||
for archive in /out/*.tar.gz; do \
|
||||
node /ql/shell/install-bundle.cjs --root /tmp/ql-bundle-offline-smoke --file "$archive" --install && \
|
||||
rm -rf /tmp/ql-bundle-offline-smoke || exit 1; \
|
||||
done; \
|
||||
else node /ql/scripts/build-upgrade-bundle.cjs /ql /out; fi
|
||||
FROM scratch
|
||||
COPY --from=bundle /out/ /
|
||||
|
||||
@@ -6,9 +6,9 @@ CI 从同次构建的不可变镜像摘要导出完整 tar.gz,包含源码中
|
||||
|
||||
`package.json` 中 `qinglongUpgradeFormat: 2` 表示使用完整包。旧版升级器仍可通过原归档路径完成首次迁移;完整包版本不会在平台包缺失、损坏或不兼容时退回设备端 npm 安装。
|
||||
|
||||
面板下载任务与本地离线入口共用 `platformBundle.cjs`:平台匹配 → 归档安全检查 → 全文件/链接/权限和锁文件校验 → SQLite/Sequelize/cron-parser/gRPC/undici 加载及 SQLite 内存库读写 → 发布就绪指针。重启前重新验证完整载荷。原有服务停止、文件备份、启动健康检查及失败恢复仍由共享升级引擎处理。
|
||||
面板下载任务与本地离线入口共用 `platformBundle.cjs`:平台匹配 → 归档安全检查 → 全文件/链接/权限和锁文件校验 → SQLite/Sequelize/cron-parser/gRPC/undici 加载及 SQLite 内存库读写 → 发布就绪指针。重启前重新验证完整载荷。离线升级同步面板任务状态,并与在线任务互斥;中断准备时保留之前的可用就绪包。原有服务停止、文件备份、启动健康检查及失败恢复仍由共享升级引擎处理。
|
||||
|
||||
渠道索引为 GitHub prerelease `upgrade-develop` 或 `upgrade-master` 下的 `latest.json`。索引记录准确字节数、SHA256 和不可变 `upgrade-<commit>` release 资产 URL。12 个平台均构建及试运行成功才发布渠道;过期工作流不能覆盖更新提交的渠道。
|
||||
渠道索引为 GitHub prerelease `upgrade-develop` 或 `upgrade-master` 下的 `latest.json`。索引记录准确字节数、SHA256 和不可变 `upgrade-<commit>` release 资产 URL。12 个平台均完成归档、解包后复验及依赖试运行才发布渠道;过期工作流不能覆盖更新提交的渠道。
|
||||
|
||||
## 手动离线安装和升级
|
||||
|
||||
@@ -42,6 +42,7 @@ QL_DIR=/opt/qinglong bash /opt/qinglong/shell/start-simplify.sh
|
||||
- 以 Node 实际运行架构、ARM 指令集和 libc 类型/版本选择包;不依据 Docker 架构别名猜测。
|
||||
- glibc、musl 分包,并记录构建系统 libc 下限。系统库仍以目标设备加载测试作为最后检查。
|
||||
- ARMv6 与 ARMv7 使用不同包名。ARMv6 在目标镜像中从源码编译 SQLite,禁止复用 generic `arm` 的 ARMv7 预编译件;构建需通过加载和内存库读写,失败则不发布渠道。
|
||||
- ARMv6 的归档构建、在线更新工作进程及独立离线安装器使用 `--jitless`,避开 ARM1176 上 V8 的优化编译崩溃;保留现有 Node 二进制,正常面板进程的启动参数不变。依赖加载和 SQLite 读写仍由现有 Node 的独立进程验证。
|
||||
- tar 中允许 pnpm 的包内符号链接;拒绝绝对路径、越界路径、包外链接、硬链接、设备条目和经符号链接写入的条目。重启前再次校验依赖,暂存失败保留上一次可用的就绪包。
|
||||
|
||||
自托管镜像可设置 `QL_UPGRADE_INDEX_URL` 指向兼容索引;要求 HTTPS,测试时仅放行 loopback HTTP。索引和所有平台归档应部署到同一可访问源,避免依赖额外的 raw 域名。
|
||||
|
||||
@@ -6,6 +6,19 @@ const {
|
||||
verifyUpgrade,
|
||||
} = require('../cli/src/internal/maintenance/upgradeArtifacts.cjs');
|
||||
|
||||
async function normalizeModes(directory) {
|
||||
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
|
||||
const filename = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await fs.chmod(filename, 0o755);
|
||||
await normalizeModes(filename);
|
||||
} else if (entry.isFile()) {
|
||||
const stat = await fs.stat(filename);
|
||||
await fs.chmod(filename, 0o644 | (stat.mode & 0o111));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function buildBundle(root, output) {
|
||||
root = path.resolve(root);
|
||||
output = path.resolve(output);
|
||||
@@ -46,6 +59,8 @@ async function buildBundle(root, output) {
|
||||
recursive: true,
|
||||
verbatimSymlinks: true,
|
||||
});
|
||||
await normalizeModes(source);
|
||||
await normalizeModes(staticRoot);
|
||||
const manifest = {
|
||||
format: 2,
|
||||
sourceCommit: build.sourceCommit,
|
||||
@@ -69,6 +84,7 @@ async function buildBundle(root, output) {
|
||||
path.join(payload, 'bundle.json'),
|
||||
JSON.stringify(manifest),
|
||||
);
|
||||
console.log('Bundle inventory verified; writing archive');
|
||||
const filename = `qinglong-${build.sourceCommit}-${bundle.targetKey(
|
||||
target,
|
||||
)}.tar.gz`;
|
||||
@@ -82,6 +98,19 @@ async function buildBundle(root, output) {
|
||||
},
|
||||
['qinglong'],
|
||||
);
|
||||
// Verify the bytes users receive, not just the pre-archive build directory.
|
||||
const roundtrip = path.join(workspace, 'roundtrip');
|
||||
await fs.mkdir(roundtrip);
|
||||
console.log('Verifying archived paths, permissions and checksums');
|
||||
await bundle.extractArchive(path.join(output, filename), roundtrip);
|
||||
const extracted = {
|
||||
source: path.join(roundtrip, 'qinglong/source'),
|
||||
static: path.join(roundtrip, 'qinglong/static'),
|
||||
};
|
||||
await bundle.verifyPayload(extracted, manifest, target);
|
||||
await verifyUpgrade(extracted.source, extracted.static);
|
||||
await bundle.smokeDependencies(extracted.source);
|
||||
console.log('Archived production dependencies and SQLite verified');
|
||||
const bytes = await fs.readFile(path.join(output, filename));
|
||||
const record = {
|
||||
format: 2,
|
||||
@@ -102,7 +131,7 @@ async function buildBundle(root, output) {
|
||||
await fs.rm(workspace, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
module.exports = { buildBundle };
|
||||
module.exports = { buildBundle, normalizeModes };
|
||||
if (require.main === module)
|
||||
buildBundle(process.argv[2] || '/ql', process.argv[3] || '/out').catch(
|
||||
(e) => {
|
||||
|
||||
+229
-110
@@ -3,11 +3,42 @@
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { randomUUID } = require('node:crypto');
|
||||
const { promisify } = require('node:util');
|
||||
const execute = promisify(require('node:child_process').execFile);
|
||||
const { parseArgs } = require('node:util');
|
||||
const artifacts = require('../cli/src/internal/maintenance/upgradeArtifacts.cjs');
|
||||
const jobs = require('../cli/src/internal/maintenance/panelUpgrade.cjs');
|
||||
const {
|
||||
nodeRuntimeArgs,
|
||||
} = require('../cli/src/internal/maintenance/platformBundle.cjs');
|
||||
|
||||
async function runInstallerMain(argv = process.argv.slice(2)) {
|
||||
const flags = nodeRuntimeArgs();
|
||||
if (!flags.length || process.execArgv.includes('--jitless'))
|
||||
return installBundle(argv);
|
||||
const child = require('node:child_process').spawn(
|
||||
process.execPath,
|
||||
[...flags, __filename, ...argv],
|
||||
{ stdio: 'inherit', env: process.env },
|
||||
);
|
||||
const interrupt = () => child.kill('SIGINT');
|
||||
const terminate = () => child.kill('SIGTERM');
|
||||
process.once('SIGINT', interrupt);
|
||||
process.once('SIGTERM', terminate);
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
child.once('error', reject);
|
||||
child.once('exit', (code, signal) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`Offline installer exited: ${signal || code}`));
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
process.removeListener('SIGINT', interrupt);
|
||||
process.removeListener('SIGTERM', terminate);
|
||||
}
|
||||
}
|
||||
|
||||
async function installBundle(argv = process.argv.slice(2), env = process.env) {
|
||||
const { values } = parseArgs({
|
||||
@@ -68,126 +99,214 @@ async function installBundle(argv = process.argv.slice(2), env = process.env) {
|
||||
? await fs.mkdtemp(path.join(os.tmpdir(), 'ql-offline-install-'))
|
||||
: undefined;
|
||||
const tmp = freshWorkspace || path.join(root, '.tmp');
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort(new Error('离线升级已中断'));
|
||||
process.once('SIGINT', abort);
|
||||
process.once('SIGTERM', abort);
|
||||
try {
|
||||
return await artifacts.withUpgradeLock(
|
||||
tmp,
|
||||
async () => {
|
||||
const selected = await artifacts.stageUpgrade({
|
||||
root,
|
||||
tmp,
|
||||
env: environment,
|
||||
run: (program, args, capture) =>
|
||||
artifacts.runProcess(environment, program, args, capture),
|
||||
onProgress: async (p) => console.log(p.phase),
|
||||
install: async () => {
|
||||
throw new Error('Offline bundles never install npm dependencies');
|
||||
},
|
||||
});
|
||||
if (values['download-only']) {
|
||||
console.log('更新已就绪;执行 ql reload system 应用更新。');
|
||||
return selected;
|
||||
}
|
||||
if (values.install) {
|
||||
// Build beside the destination, then atomically replace only an empty root.
|
||||
await fs.mkdir(path.dirname(root), { recursive: true });
|
||||
const publication = await fs.mkdtemp(
|
||||
path.join(path.dirname(root), '.qinglong-install-'),
|
||||
);
|
||||
try {
|
||||
await fs.cp(selected.source, publication, {
|
||||
recursive: true,
|
||||
verbatimSymlinks: true,
|
||||
});
|
||||
await fs.cp(selected.static, path.join(publication, 'static'), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.copyFile(
|
||||
path.join(publication, '.env.example'),
|
||||
path.join(publication, '.env'),
|
||||
require('node:fs').constants.COPYFILE_EXCL,
|
||||
);
|
||||
await fs.chmod(path.join(publication, '.env'), 0o600);
|
||||
await fs.rename(publication, root);
|
||||
} finally {
|
||||
await fs.rm(publication, { recursive: true, force: true });
|
||||
}
|
||||
await fs.rm(path.dirname(selected.source), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
await fs.rm(
|
||||
path.join(
|
||||
tmp,
|
||||
`upgrade-ready-${artifacts.releaseBranch(environment)}.json`,
|
||||
),
|
||||
{ force: true },
|
||||
);
|
||||
console.log(
|
||||
`离线安装完成,保留现有 Node ${
|
||||
process.version
|
||||
}。启动服务:QL_DIR=${JSON.stringify(root)} bash ${JSON.stringify(
|
||||
path.join(root, 'shell/start-simplify.sh'),
|
||||
)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
return jobs.withPanelLifecycle(
|
||||
root,
|
||||
tmp,
|
||||
environment,
|
||||
async (hook, prepareSelection) => {
|
||||
const expected = await jobs.stagedMetadata(selected);
|
||||
const previous = await jobs
|
||||
.readRunningBuild(environment)
|
||||
.catch(() => undefined);
|
||||
const selection = await prepareSelection?.(selected);
|
||||
try {
|
||||
return await artifacts.reloadSystem({
|
||||
root,
|
||||
tmp,
|
||||
env: environment,
|
||||
data: environment.dir_data || path.join(root, 'data'),
|
||||
staticRoot: path.join(root, 'static'),
|
||||
config:
|
||||
environment.dir_config || path.join(root, 'data/config'),
|
||||
lifecycle: {
|
||||
stop: () => hook('stop'),
|
||||
start: async (recover) => {
|
||||
await hook('start');
|
||||
await artifacts.waitForHealth(environment);
|
||||
if (!recover)
|
||||
await jobs.verifyRunningVersion(
|
||||
environment,
|
||||
expected,
|
||||
previous,
|
||||
);
|
||||
},
|
||||
},
|
||||
additionalReplacements: selection
|
||||
? [{ source: selection.source, target: selection.target }]
|
||||
: [],
|
||||
});
|
||||
} finally {
|
||||
if (selection)
|
||||
await fs.rm(selection.directory, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
const operation = () =>
|
||||
artifacts.withUpgradeLock(
|
||||
tmp,
|
||||
async () => {
|
||||
let state;
|
||||
const report = async (patch) => {
|
||||
if (patch.phase) console.log(patch.phase);
|
||||
if (state) {
|
||||
state = {
|
||||
...state,
|
||||
...patch,
|
||||
pid: process.pid,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
await jobs.saveState(tmp, state);
|
||||
}
|
||||
},
|
||||
};
|
||||
if (!values.install) {
|
||||
const previous = await jobs.getStatus(tmp);
|
||||
if (['starting', 'running', 'restarting'].includes(previous.status))
|
||||
throw new Error('已有更新或重载正在执行。');
|
||||
state = {
|
||||
id: randomUUID(),
|
||||
action: values['download-only'] ? 'download' : 'apply',
|
||||
status: 'running',
|
||||
phase: 'verifying',
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
await report({});
|
||||
await fs.writeFile(
|
||||
path.join(tmp, 'panel-upgrade.log'),
|
||||
'Offline bundle verification\n',
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
const selected = await artifacts.stageUpgrade({
|
||||
root,
|
||||
tmp,
|
||||
env: environment,
|
||||
run: (program, args, capture) =>
|
||||
artifacts.runProcess(
|
||||
environment,
|
||||
program,
|
||||
args,
|
||||
capture,
|
||||
controller.signal,
|
||||
),
|
||||
signal: controller.signal,
|
||||
onProgress: report,
|
||||
install: async () => {
|
||||
throw new Error(
|
||||
'Offline bundles never install npm dependencies',
|
||||
);
|
||||
},
|
||||
});
|
||||
if (state)
|
||||
await report({
|
||||
status: 'ready',
|
||||
phase: 'ready',
|
||||
...(await jobs.stagedMetadata(selected)),
|
||||
});
|
||||
if (values['download-only']) {
|
||||
console.log('更新已就绪;执行 ql reload system 应用更新。');
|
||||
return selected;
|
||||
}
|
||||
if (values.install) {
|
||||
// Build beside the destination, then atomically replace only an empty root.
|
||||
await fs.mkdir(path.dirname(root), { recursive: true });
|
||||
const publication = await fs.mkdtemp(
|
||||
path.join(path.dirname(root), '.qinglong-install-'),
|
||||
);
|
||||
try {
|
||||
await fs.chmod(publication, 0o755);
|
||||
await fs.cp(selected.source, publication, {
|
||||
recursive: true,
|
||||
verbatimSymlinks: true,
|
||||
});
|
||||
await fs.cp(selected.static, path.join(publication, 'static'), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.copyFile(
|
||||
path.join(publication, '.env.example'),
|
||||
path.join(publication, '.env'),
|
||||
require('node:fs').constants.COPYFILE_EXCL,
|
||||
);
|
||||
await fs.chmod(path.join(publication, '.env'), 0o600);
|
||||
controller.signal.throwIfAborted();
|
||||
await fs.rename(publication, root);
|
||||
} finally {
|
||||
await fs.rm(publication, { recursive: true, force: true });
|
||||
}
|
||||
await fs.rm(path.dirname(selected.source), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
await fs.rm(
|
||||
path.join(
|
||||
tmp,
|
||||
`upgrade-ready-${artifacts.releaseBranch(environment)}.json`,
|
||||
),
|
||||
{ force: true },
|
||||
);
|
||||
console.log(
|
||||
`离线安装完成,保留现有 Node ${
|
||||
process.version
|
||||
}。启动服务:QL_DIR=${JSON.stringify(
|
||||
root,
|
||||
)} bash ${JSON.stringify(
|
||||
path.join(root, 'shell/start-simplify.sh'),
|
||||
)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const result = await jobs.withPanelLifecycle(
|
||||
root,
|
||||
tmp,
|
||||
environment,
|
||||
async (hook, prepareSelection) => {
|
||||
const expected = await jobs.stagedMetadata(selected);
|
||||
const previous = await jobs
|
||||
.readRunningBuild(environment)
|
||||
.catch(() => undefined);
|
||||
const selection = await prepareSelection?.(selected);
|
||||
try {
|
||||
return await artifacts.reloadSystem({
|
||||
root,
|
||||
tmp,
|
||||
env: environment,
|
||||
data: environment.dir_data || path.join(root, 'data'),
|
||||
staticRoot: path.join(root, 'static'),
|
||||
config:
|
||||
environment.dir_config || path.join(root, 'data/config'),
|
||||
lifecycle: {
|
||||
signal: controller.signal,
|
||||
stop: async () => {
|
||||
await report({
|
||||
status: 'restarting',
|
||||
phase: 'restarting',
|
||||
});
|
||||
await hook('stop', [], controller.signal);
|
||||
},
|
||||
start: async (recover) => {
|
||||
await hook(
|
||||
'start',
|
||||
[],
|
||||
recover ? undefined : controller.signal,
|
||||
);
|
||||
await artifacts.waitForHealth(
|
||||
environment,
|
||||
60000,
|
||||
recover ? undefined : controller.signal,
|
||||
);
|
||||
if (!recover)
|
||||
await jobs.verifyRunningVersion(
|
||||
environment,
|
||||
expected,
|
||||
previous,
|
||||
);
|
||||
},
|
||||
},
|
||||
additionalReplacements: selection
|
||||
? [{ source: selection.source, target: selection.target }]
|
||||
: [],
|
||||
});
|
||||
} finally {
|
||||
if (selection)
|
||||
await fs.rm(selection.directory, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
controller.signal,
|
||||
);
|
||||
await report({ status: 'completed', phase: 'completed' });
|
||||
console.log('升级成功,已保留现有 Node 并运行更新后的版本。');
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (state) await report({ status: 'failed', error: error.message });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
environment,
|
||||
);
|
||||
return values.install
|
||||
? await operation()
|
||||
: await artifacts.withUpgradeLock(
|
||||
path.join(tmp, 'panel-job-launch'),
|
||||
operation,
|
||||
environment,
|
||||
);
|
||||
},
|
||||
environment,
|
||||
);
|
||||
} finally {
|
||||
process.removeListener('SIGINT', abort);
|
||||
process.removeListener('SIGTERM', abort);
|
||||
if (freshWorkspace)
|
||||
await fs.rm(freshWorkspace, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { installBundle };
|
||||
module.exports = { installBundle, runInstallerMain };
|
||||
if (require.main === module)
|
||||
installBundle().catch((error) => {
|
||||
runInstallerMain().catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
@@ -744,6 +744,7 @@
|
||||
"更新已就绪,重启后应用新版本": "Update ready. Restart to apply the new version.",
|
||||
"更新在后台进行,可以继续使用面板": "The update runs in the background. You can keep using the panel.",
|
||||
"下载构建产物": "Downloading application files",
|
||||
"下载更新包": "Downloading update package",
|
||||
"下载源码": "Downloading source files",
|
||||
"正在获取下载大小": "Total size unavailable",
|
||||
"查看更新日志": "View update log",
|
||||
|
||||
@@ -744,6 +744,7 @@
|
||||
"更新已就绪,重启后应用新版本": "更新已就绪,重启后应用新版本",
|
||||
"更新在后台进行,可以继续使用面板": "更新在后台进行,可以继续使用面板",
|
||||
"下载构建产物": "下载构建产物",
|
||||
"下载更新包": "下载更新包",
|
||||
"下载源码": "下载源码",
|
||||
"正在获取下载大小": "正在获取下载大小",
|
||||
"查看更新日志": "查看更新日志",
|
||||
|
||||
@@ -167,7 +167,9 @@ const CheckUpdate = ({ systemInfo }: any) => {
|
||||
<div className="upgrade-progress-meta">
|
||||
<Typography.Text type="secondary">
|
||||
{intl.get(
|
||||
job.artifact === 'qinglong-static'
|
||||
job.artifact === 'qinglong-bundle'
|
||||
? '下载更新包'
|
||||
: job.artifact === 'qinglong-static'
|
||||
? '下载构建产物'
|
||||
: '下载源码',
|
||||
)}
|
||||
|
||||
@@ -43,3 +43,127 @@ test('cron validation identifies invalid fields', () => {
|
||||
['allow_multiple_instances'],
|
||||
);
|
||||
});
|
||||
|
||||
test('extra schedules preserve optional values, compatible syntax and metadata', () => {
|
||||
for (const extra_schedules of [
|
||||
undefined,
|
||||
null,
|
||||
[],
|
||||
[{ schedule: '0 6/6 * * *', remarks: 'legacy metadata' }],
|
||||
[{ schedule: '0 0 * JAN MON#2' }, { schedule: '@hourly' }],
|
||||
]) {
|
||||
const result = schema.validate({
|
||||
command: 'task valid.js',
|
||||
schedule: '01 7 * * *',
|
||||
extra_schedules,
|
||||
});
|
||||
assert.equal(result.error, undefined, JSON.stringify(extra_schedules));
|
||||
assert.deepEqual(result.value.extra_schedules, extra_schedules);
|
||||
}
|
||||
for (const schedule of ['@boot', '@once 1792245000']) {
|
||||
assert.equal(
|
||||
schema.validate({ command: 'task valid.js', schedule }).error,
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('create and update reject invalid extra schedules before calling the service', async (t) => {
|
||||
const express = require('express');
|
||||
const { errors } = require('celebrate');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const mutations = [];
|
||||
const service = {
|
||||
async create(value) {
|
||||
mutations.push(value);
|
||||
return { id: 1, ...value };
|
||||
},
|
||||
async update(value) {
|
||||
mutations.push(value);
|
||||
return value;
|
||||
},
|
||||
};
|
||||
const route = load('back/api/cron.ts', {
|
||||
typedi: {
|
||||
Container: {
|
||||
get: (key) => (key === 'logger' ? { error() {} } : service),
|
||||
},
|
||||
},
|
||||
'../services/cron': class {},
|
||||
'../services/cronView': class {},
|
||||
'../validation/schedule': { commonCronSchema },
|
||||
'../data/runningInstance': {},
|
||||
'../shared/i18n': { t: (value) => value },
|
||||
'../schedule/client': { readiness: { async ensureReady() {} } },
|
||||
});
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
route.default(app);
|
||||
app.use(errors());
|
||||
app.use((error, _req, res, _next) =>
|
||||
res.status(error.status || 500).json({ message: error.message }),
|
||||
);
|
||||
const server = app.listen(0, '127.0.0.1');
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('listening', resolve);
|
||||
server.once('error', reject);
|
||||
});
|
||||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||||
|
||||
for (const method of ['POST', 'PUT']) {
|
||||
const payload = {
|
||||
command: 'task valid.js',
|
||||
schedule: '01 7 * * *',
|
||||
...(method === 'PUT' ? { id: 1 } : {}),
|
||||
};
|
||||
const request = (body) =>
|
||||
fetch(`http://127.0.0.1:${server.address().port}/crons`, {
|
||||
method,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
for (const extra_schedules of [
|
||||
[{ schedule: '60 * * * *' }],
|
||||
[{ schedule: '0 0 31 2,4 *' }],
|
||||
[{ schedule: '' }],
|
||||
[{ schedule: 12 }],
|
||||
[{ schedule: '@boot' }],
|
||||
[{ schedule: '@once 1792245000' }],
|
||||
[{}],
|
||||
[null],
|
||||
['0 * * * *'],
|
||||
[{ schedule: '0 * * * *' }, { schedule: 'invalid' }],
|
||||
]) {
|
||||
const before = mutations.length;
|
||||
const response = await request({ ...payload, extra_schedules });
|
||||
assert.equal(
|
||||
response.status,
|
||||
400,
|
||||
`${method}: ${JSON.stringify(extra_schedules)}`,
|
||||
);
|
||||
const body = await response.json();
|
||||
assert.ok(
|
||||
body.validation.body.keys.some((key) =>
|
||||
key.startsWith('extra_schedules.'),
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
mutations.length,
|
||||
before,
|
||||
'invalid input must not mutate tasks',
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
(await request({ ...payload, schedule: '60 * * * *' })).status,
|
||||
400,
|
||||
);
|
||||
const extra_schedules = [{ schedule: '0 6/6 * * *', remarks: 'keep' }];
|
||||
const response = await request({ ...payload, extra_schedules });
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(
|
||||
(await response.json()).data.extra_schedules,
|
||||
extra_schedules,
|
||||
);
|
||||
}
|
||||
assert.equal(mutations.length, 2);
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ const {
|
||||
} = require('../../back/validation/schedule');
|
||||
const upgrade = require('../../cli/src/internal/maintenance/upgradeArtifacts.cjs');
|
||||
|
||||
function responseFor(schema, value) {
|
||||
function responseFor(schema, value, fullResponse = false) {
|
||||
const validation = schema.validate(value).error;
|
||||
assert.ok(validation);
|
||||
const error = new CelebrateError('Validation failed', { celebrated: true });
|
||||
@@ -41,7 +41,7 @@ function responseFor(schema, value) {
|
||||
validationErrors(error, {}, res, (err) => {
|
||||
throw err;
|
||||
});
|
||||
return response.validation.body.message;
|
||||
return fullResponse ? response : response.validation.body.message;
|
||||
}
|
||||
|
||||
test('compiled validation schemas follow live language changes without altering field paths', () => {
|
||||
@@ -75,6 +75,100 @@ test('compiled validation schemas follow live language changes without altering
|
||||
setLang('zh');
|
||||
});
|
||||
|
||||
test('cron structure errors switch languages while retaining status and field paths', (t) => {
|
||||
const schema = Joi.object(commonCronSchema);
|
||||
const empty = ['定时规则不能为空', 'Schedule rule cannot be empty'];
|
||||
const string = ['定时规则必须是字符串', 'Schedule rule must be a string'];
|
||||
const object = ['额外定时规则必须是对象', 'Extra schedule must be an object'];
|
||||
const array = ['额外定时规则必须是数组', 'Extra schedules must be an array'];
|
||||
const invalid = ['无效的定时规则', 'Invalid schedule rule'];
|
||||
const cases = [
|
||||
[{ schedule: undefined }, 'schedule', empty],
|
||||
[{ schedule: '' }, 'schedule', empty],
|
||||
[{ schedule: 12 }, 'schedule', string],
|
||||
[{ schedule: null }, 'schedule', string],
|
||||
[{ extra_schedules: [{}] }, 'extra_schedules.0.schedule', empty],
|
||||
[
|
||||
{ extra_schedules: [{ schedule: '' }] },
|
||||
'extra_schedules.0.schedule',
|
||||
empty,
|
||||
],
|
||||
[
|
||||
{ extra_schedules: [{ schedule: 12 }] },
|
||||
'extra_schedules.0.schedule',
|
||||
string,
|
||||
],
|
||||
[
|
||||
{ extra_schedules: [{ schedule: null }] },
|
||||
'extra_schedules.0.schedule',
|
||||
string,
|
||||
],
|
||||
[
|
||||
{ extra_schedules: [{ schedule: 'invalid' }] },
|
||||
'extra_schedules.0.schedule',
|
||||
invalid,
|
||||
],
|
||||
[
|
||||
{ extra_schedules: [{ schedule: '0 * * * *' }, {}] },
|
||||
'extra_schedules.1.schedule',
|
||||
empty,
|
||||
],
|
||||
...[null, '0 * * * *', 12, []].map((item) => [
|
||||
{ extra_schedules: [item] },
|
||||
'extra_schedules.0',
|
||||
object,
|
||||
]),
|
||||
...['0 * * * *', {}, 12].map((value) => [
|
||||
{ extra_schedules: value },
|
||||
'extra_schedules',
|
||||
array,
|
||||
]),
|
||||
];
|
||||
t.after(() => setLang('zh', false));
|
||||
for (const lang of ['en', 'zh', 'en']) {
|
||||
setLang(lang, false);
|
||||
for (const [value, field, messages] of cases) {
|
||||
const response = responseFor(
|
||||
schema,
|
||||
{
|
||||
command: 'task valid.js',
|
||||
schedule: '0 * * * *',
|
||||
...value,
|
||||
},
|
||||
true,
|
||||
);
|
||||
assert.equal(response.statusCode, 400);
|
||||
assert.equal(
|
||||
response.validation.body.message,
|
||||
messages[lang === 'zh' ? 0 : 1],
|
||||
`${lang}: ${field}`,
|
||||
);
|
||||
assert.deepEqual(response.validation.body.keys, [field]);
|
||||
}
|
||||
const multiple = responseFor(
|
||||
schema.prefs({ abortEarly: false }),
|
||||
{
|
||||
command: 'task valid.js',
|
||||
schedule: '0 * * * *',
|
||||
extra_schedules: [{}, { schedule: 12 }, null],
|
||||
},
|
||||
true,
|
||||
);
|
||||
assert.equal(multiple.statusCode, 400);
|
||||
assert.equal(
|
||||
multiple.validation.body.message,
|
||||
[empty, string, object]
|
||||
.map((messages) => messages[lang === 'zh' ? 0 : 1])
|
||||
.join('. '),
|
||||
);
|
||||
assert.deepEqual(multiple.validation.body.keys, [
|
||||
'extra_schedules.0.schedule',
|
||||
'extra_schedules.1.schedule',
|
||||
'extra_schedules.2',
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test('history, upload, initialization and proxy messages resolve dynamically and preserve user text', () => {
|
||||
const keys = [
|
||||
'请先初始化',
|
||||
|
||||
@@ -6,6 +6,39 @@ const os = require('node:os');
|
||||
const tar = require('tar');
|
||||
const bundle = require('../../cli/src/internal/maintenance/platformBundle.cjs');
|
||||
const artifacts = require('../../shell/upgrade.cjs');
|
||||
|
||||
test('ARMv6 offline entry re-executes with an interpreted Node while preserving CLI errors', async (t) => {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-armv6-entry-'));
|
||||
t.after(() => fs.rm(directory, { recursive: true, force: true }));
|
||||
const preload = path.join(directory, 'armv6.cjs');
|
||||
const observed = path.join(directory, 'child.json');
|
||||
await fs.writeFile(
|
||||
preload,
|
||||
`if(process.execArgv.includes('--jitless'))require('node:fs').writeFileSync(${JSON.stringify(
|
||||
observed,
|
||||
)},JSON.stringify({args:process.execArgv,execPath:process.execPath}));else{Object.defineProperty(process,'arch',{value:'arm'});Object.defineProperty(process,'config',{value:{variables:{arm_version:6}}});}`,
|
||||
);
|
||||
const { execFile } = require('node:child_process');
|
||||
const result = await new Promise((resolve) =>
|
||||
execFile(
|
||||
process.execPath,
|
||||
[path.resolve(__dirname, '../../shell/install-bundle.cjs')],
|
||||
{
|
||||
timeout: 10000,
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_OPTIONS: '--require ' + JSON.stringify(preload),
|
||||
},
|
||||
},
|
||||
(error, stdout, stderr) => resolve({ error, stdout, stderr }),
|
||||
),
|
||||
);
|
||||
assert.equal(result.error.code, 1);
|
||||
const child = JSON.parse(await fs.readFile(observed));
|
||||
assert.ok(child.args.includes('--jitless'));
|
||||
assert.equal(child.execPath, process.execPath);
|
||||
assert.match(result.stderr, /Usage: node install-bundle.cjs/);
|
||||
});
|
||||
const {
|
||||
writeManifest,
|
||||
sourceCommit,
|
||||
@@ -351,3 +384,200 @@ test('fresh offline installation needs no package manager, keeps Node and publis
|
||||
'keep',
|
||||
);
|
||||
});
|
||||
|
||||
test('offline staging replaces stale panel readiness and refuses an active online job', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await fs.writeFile(path.join(f.source, 'version.yaml'), 'version: 2.24.0\n');
|
||||
await fs.writeFile(
|
||||
path.join(f.source, 'node_modules/sqlite3/index.js'),
|
||||
'exports.Database=class {serialize(fn){fn()}run(){}get(sql,cb){cb(null,{value:42})}close(){}};',
|
||||
);
|
||||
for (const name of ['sequelize', 'cron-parser', '@grpc/grpc-js', 'undici']) {
|
||||
const dir = path.join(f.source, 'node_modules', name);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(path.join(dir, 'index.js'), 'module.exports={};');
|
||||
}
|
||||
f.manifest.files = await bundle.inventory({
|
||||
source: f.source,
|
||||
static: f.staticRoot,
|
||||
});
|
||||
await f.pack();
|
||||
await fs.mkdir(path.join(f.root, 'shell'));
|
||||
await fs.writeFile(
|
||||
path.join(f.root, 'shell/share.sh'),
|
||||
'import_config() { :; }\n',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(f.root, 'shell/env.sh'),
|
||||
'export QL_BRANCH=develop\n',
|
||||
);
|
||||
const stateFile = path.join(f.tmp, 'panel-upgrade.json');
|
||||
await fs.writeFile(
|
||||
stateFile,
|
||||
JSON.stringify({
|
||||
id: 'previous-online-job',
|
||||
status: 'ready',
|
||||
phase: 'ready',
|
||||
}),
|
||||
);
|
||||
const install = require('../../shell/install-bundle.cjs').installBundle;
|
||||
await install(['--root', f.root, '--file', f.archive, '--download-only']);
|
||||
const state = JSON.parse(await fs.readFile(stateFile, 'utf8'));
|
||||
assert.equal(state.status, 'ready');
|
||||
assert.equal(state.sourceCommit, sourceCommit);
|
||||
assert.notEqual(state.id, 'previous-online-job');
|
||||
assert.equal(state.version, '2.24.0');
|
||||
const pointer = await fs.readFile(
|
||||
path.join(f.tmp, 'upgrade-ready-develop.json'),
|
||||
'utf8',
|
||||
);
|
||||
await fs.writeFile(
|
||||
stateFile,
|
||||
JSON.stringify({
|
||||
id: 'live-online-job',
|
||||
status: 'running',
|
||||
pid: process.pid,
|
||||
startedAt: Date.now(),
|
||||
}),
|
||||
);
|
||||
await assert.rejects(
|
||||
install(['--root', f.root, '--file', f.archive, '--download-only']),
|
||||
/已有更新/,
|
||||
);
|
||||
assert.equal(
|
||||
JSON.parse(await fs.readFile(stateFile, 'utf8')).id,
|
||||
'live-online-job',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.tmp, 'upgrade-ready-develop.json'), 'utf8'),
|
||||
pointer,
|
||||
);
|
||||
});
|
||||
|
||||
test('interrupting offline staging records failure, preserves prior readiness and releases both locks', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const { spawn } = require('node:child_process');
|
||||
await fs.writeFile(path.join(f.source, 'version.yaml'), 'version: 2.24.0\n');
|
||||
await fs.writeFile(
|
||||
path.join(f.source, 'node_modules/sqlite3/index.js'),
|
||||
'exports.Database=class {serialize(fn){fn()}run(){}get(sql,cb){setTimeout(()=>cb(null,{value:42}),10000)}close(){}};',
|
||||
);
|
||||
for (const name of ['sequelize', 'cron-parser', '@grpc/grpc-js', 'undici']) {
|
||||
const dir = path.join(f.source, 'node_modules', name);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(path.join(dir, 'index.js'), 'module.exports={};');
|
||||
}
|
||||
f.manifest.files = await bundle.inventory({
|
||||
source: f.source,
|
||||
static: f.staticRoot,
|
||||
});
|
||||
await f.pack();
|
||||
await fs.mkdir(path.join(f.root, 'shell'));
|
||||
await fs.writeFile(
|
||||
path.join(f.root, 'shell/share.sh'),
|
||||
'import_config() { :; }\n',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(f.root, 'shell/env.sh'),
|
||||
'export QL_BRANCH=develop\n',
|
||||
);
|
||||
const pointer = path.join(f.tmp, 'upgrade-ready-develop.json');
|
||||
await fs.writeFile(pointer, 'previous-good-readiness');
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
path.resolve(__dirname, '../../shell/install-bundle.cjs'),
|
||||
'--root',
|
||||
f.root,
|
||||
'--file',
|
||||
f.archive,
|
||||
'--download-only',
|
||||
],
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
t.after(() => {
|
||||
if (child.exitCode === null) child.kill('SIGKILL');
|
||||
});
|
||||
const exited = new Promise((resolve, reject) => {
|
||||
child.once('exit', resolve);
|
||||
child.once('error', reject);
|
||||
});
|
||||
let seen = false;
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const state = await fs
|
||||
.readFile(path.join(f.tmp, 'panel-upgrade.json'), 'utf8')
|
||||
.then(JSON.parse)
|
||||
.catch(() => ({}));
|
||||
if (state.phase === 'dependencies') {
|
||||
seen = true;
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
}
|
||||
assert.ok(seen);
|
||||
child.kill('SIGTERM');
|
||||
assert.equal(await exited, 1);
|
||||
assert.equal(await fs.readFile(pointer, 'utf8'), 'previous-good-readiness');
|
||||
assert.equal(
|
||||
JSON.parse(
|
||||
await fs.readFile(path.join(f.tmp, 'panel-upgrade.json'), 'utf8'),
|
||||
).status,
|
||||
'failed',
|
||||
);
|
||||
await assert.rejects(fs.access(path.join(f.tmp, 'upgrade.lock')));
|
||||
await assert.rejects(
|
||||
fs.access(path.join(f.tmp, 'panel-job-launch/upgrade.lock')),
|
||||
);
|
||||
});
|
||||
|
||||
test('portable release archives preserve the permission manifest after normalizing image scripts', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const script = path.join(f.source, 'sample/config.sample.sh');
|
||||
await fs.chmod(script, 0o777);
|
||||
const { normalizeModes } = require('../../scripts/build-upgrade-bundle.cjs');
|
||||
await normalizeModes(f.source);
|
||||
await normalizeModes(f.staticRoot);
|
||||
assert.equal((await fs.stat(script)).mode & 0o777, 0o755);
|
||||
f.manifest.files = await bundle.inventory({
|
||||
source: f.source,
|
||||
static: f.staticRoot,
|
||||
});
|
||||
await f.pack();
|
||||
const extracted = path.join(f.directory, 'roundtrip');
|
||||
await fs.mkdir(extracted);
|
||||
await bundle.extractArchive(f.archive, extracted);
|
||||
const selected = {
|
||||
source: path.join(extracted, 'qinglong/source'),
|
||||
static: path.join(extracted, 'qinglong/static'),
|
||||
};
|
||||
await bundle.verifyPayload(selected, f.manifest, f.target);
|
||||
await fs.chmod(path.join(selected.source, 'sample/config.sample.sh'), 0o777);
|
||||
await assert.rejects(
|
||||
bundle.verifyPayload(selected, f.manifest, f.target),
|
||||
/checksums/,
|
||||
);
|
||||
});
|
||||
|
||||
test('full dependency bundles get a bounded slow-link download budget instead of the old ten-minute limit', async (t) => {
|
||||
const f = await fixture(t);
|
||||
let observed;
|
||||
await assert.rejects(
|
||||
artifacts.downloadWithProgress(
|
||||
async (program, args) => {
|
||||
assert.equal(program, 'curl');
|
||||
observed = Number(args[args.indexOf('--max-time') + 1]);
|
||||
assert.ok(observed > 600 && observed <= 7200);
|
||||
assert.equal(args[args.indexOf('--speed-limit') + 1], '1024');
|
||||
throw new Error('stop before transfer');
|
||||
},
|
||||
path.join(f.directory, 'slow.tar.gz'),
|
||||
'https://example.invalid/package.tar.gz',
|
||||
'qinglong-bundle',
|
||||
async () => {},
|
||||
true,
|
||||
{ size: 24 * 1024 * 1024, sha256: 'a'.repeat(64) },
|
||||
),
|
||||
/stop before transfer/,
|
||||
);
|
||||
assert.ok(observed);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user