Compare commits

...
Author SHA1 Message Date
whyour f8ffa48c32 fix: prepare manual-install certificates before starting workers 2026-10-18 03:01:33 +08:00
whyour c63ff6598e fix: probe production dependencies with the normal Node runtime 2026-10-18 02:51:46 +08:00
whyour 0a96118f28 ci: isolate ARMv6 bootstrap services and use the gzip Node SDK 2026-10-18 02:23:07 +08:00
whyour f911876d5c fix: use a modern pinned tracer for ARMv6 validation 2026-10-18 02:12:07 +08:00
whyour 047fdcbaba fix: include the data directory in offline startup instructions 2026-10-18 02:00:15 +08:00
whyour 27cebdc770 ci: keep ARMv6 validation images in the existing registry package 2026-10-18 01:53:56 +08:00
whyour 24b8be04c9 feat: support ARMv6 glibc upgrade bundles 2026-10-18 01:50:59 +08:00
whyour c8df528123 fix: validate extra schedules and localize validation errors
Reject invalid extra_schedules with HTTP 400 before create or update reaches the task service. Preserve omitted, null and empty extras, supported cron syntax, and additional metadata.

Localize missing rules, invalid string and array types, and non-object extra entries through the existing per-request translation middleware. Cover POST/PUT rejection without mutations, dynamic English/Chinese switching, and stable error paths.

Validation: 9 related regression tests passed on current develop; backend build passed. The API validation fix also passed full regression and isolated develop/debian-dev acceptance.

Refs #3099
2026-10-18 01:08:36 +08:00
15 changed files with 448 additions and 11 deletions
+19 -2
View File
@@ -575,7 +575,10 @@ jobs:
build-upgrade-bundles:
needs: [build-alpine, build-debian]
runs-on: ubuntu-22.04
timeout-minutes: 45
timeout-minutes: 90
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
@@ -588,6 +591,7 @@ jobs:
- { platform: linux/s390x, libc: musl, name: s390x-musl }
- { platform: linux/386, libc: musl, name: ia32-musl }
- { platform: linux/amd64, libc: glibc, name: x64-glibc }
- { platform: linux/arm/v6, libc: glibc, name: armv6-glibc }
- { platform: linux/arm/v7, libc: glibc, name: armv7-glibc }
- { platform: linux/arm64, libc: glibc, name: arm64-glibc }
- { platform: linux/ppc64le, libc: glibc, name: ppc64-glibc }
@@ -602,6 +606,7 @@ jobs:
env:
ALPINE_DIGEST: ${{ needs.build-alpine.outputs.image-digest }}
DEBIAN_DIGEST: ${{ needs.build-debian.outputs.image-digest }}
GH_TOKEN: ${{ github.token }}
BUNDLE_LIBC: ${{ matrix.libc }}
BUNDLE_PLATFORM: ${{ matrix.platform }}
run: |
@@ -609,7 +614,19 @@ jobs:
digest="$ALPINE_DIGEST"
if [ "$BUNDLE_LIBC" = glibc ]; then digest="$DEBIAN_DIGEST"; fi
test -n "$digest"
docker buildx build --platform "$BUNDLE_PLATFORM" --build-arg "BASE_IMAGE=ghcr.io/${GITHUB_REPOSITORY}@${digest}" --file docker/Dockerfile.upgrade-bundle --output type=local,dest=output/upgrade .
image="ghcr.io/${GITHUB_REPOSITORY}@${digest}"
if [ "$BUNDLE_PLATFORM/$BUNDLE_LIBC" = linux/arm/v6/glibc ]; then
# This amd64 helper executes a genuine Raspbian ARMv6 userspace under
# ARM1176 QEMU. It is a CI validator, not a user runtime or bundle.
echo "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
validator="ghcr.io/${GITHUB_REPOSITORY}:upgrade-armv6-glibc-validator-${GITHUB_SHA}"
mkdir -p output/validator
docker buildx build --platform linux/amd64 --build-arg "SOURCE_IMAGE=$image" --file docker/Dockerfile.armv6-glibc --target runtime --tag "$validator" --push --metadata-file output/validator/image.json .
validator_digest="$(jq -er '."containerimage.digest"' output/validator/image.json)"
docker buildx build --platform linux/amd64 --build-arg "BASE_IMAGE=${validator%:*}@${validator_digest}" --file docker/Dockerfile.upgrade-bundle-armv6-glibc --output type=local,dest=output/upgrade .
else
docker buildx build --platform "$BUNDLE_PLATFORM" --build-arg "BASE_IMAGE=$image" --file docker/Dockerfile.upgrade-bundle --output type=local,dest=output/upgrade .
fi
- uses: actions/upload-artifact@v6
with:
name: upgrade-${{ matrix.name }}-${{ github.sha }}
+3
View File
@@ -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',
+23 -1
View File
@@ -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()
@@ -204,7 +204,9 @@ 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, [...nodeRuntimeArgs(), '-e', probe], {
// Match the server's normal runtime. Node 20.18's built-in HTTP parser needs
// WebAssembly, which --jitless disables; only archive workers need that flag.
await execute(process.execPath, ['-e', probe], {
cwd: source,
env: { ...process.env, NODE_PATH: '', NODE_OPTIONS: '' },
timeout: 30000,
+45
View File
@@ -0,0 +1,45 @@
# ARMv7 is a source of application files only. None of its system libraries or
# Node binaries are executed or copied into the ARMv6 runtime.
ARG SOURCE_IMAGE
FROM --platform=linux/arm/v7 ${SOURCE_IMAGE} AS source
# PRoot keeps ARM1176 execution isolated, without installing host binfmt handlers.
FROM --platform=linux/amd64 debian:bookworm-slim@sha256:7c7b2c966bc9ee8cedfeef67e0e279108992c77681fa595db4a9d65c06ccc587 AS runtime
COPY --from=source /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
RUN sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list.d/debian.sources && \
apt-get update && apt-get install -y --no-install-recommends \
debootstrap gpg gpgv ca-certificates curl qemu-user-static && \
rm -rf /var/lib/apt/lists/*
# Debian's PRoot 5.1 predates faccessat2, which modern QEMU uses for guest
# access checks. Pin the upstream static tracer instead of misresolving paths.
RUN curl -fsSL https://github.com/proot-me/proot/releases/download/v5.5.0/proot -o /usr/local/bin/proot && \
echo 'db185300a9daabbce86aca7632d8c7fdc4249f6f153171954013b70c5f491ec0 /usr/local/bin/proot' | sha256sum -c - && \
chmod 755 /usr/local/bin/proot && proot --version
RUN curl -fsSL https://archive.raspbian.org/raspbian.public.key -o /tmp/raspbian.key && \
echo 'ca59cd4f2bcbc3a1d41ba6815a02a8dc5c175467a59bd87edeac458f4a5345de /tmp/raspbian.key' | sha256sum -c - && \
gpg --dearmor -o /usr/share/keyrings/raspbian.gpg /tmp/raspbian.key && \
debootstrap --foreign --arch=armhf --variant=minbase \
--keyring=/usr/share/keyrings/raspbian.gpg \
bullseye /armv6 https://archive.raspbian.org/raspbian
COPY docker/armv6-run /usr/local/bin/armv6-run
RUN chmod 755 /usr/local/bin/armv6-run && \
DEBOOTSTRAP_DIR=/debootstrap armv6-run /debootstrap/debootstrap --second-stage && \
cp /usr/share/keyrings/raspbian.gpg /armv6/usr/share/keyrings/raspbian.gpg && \
printf '%s\n' 'deb [signed-by=/usr/share/keyrings/raspbian.gpg] https://archive.raspbian.org/raspbian bullseye main' > /armv6/etc/apt/sources.list && \
mkdir -p /armv6/etc/ssl/certs && \
cp /etc/ssl/certs/ca-certificates.crt /armv6/etc/ssl/certs/ca-certificates.crt && \
printf '#!/bin/sh\nexit 101\n' > /armv6/usr/sbin/policy-rc.d && \
chmod 755 /armv6/usr/sbin/policy-rc.d && \
armv6-run /bin/sh -ec 'apt-get update; DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential binutils python3 ca-certificates curl git nginx procps jq sudo; rm -rf /var/lib/apt/lists/*'
# This Node is a pinned CI/test prerequisite, never part of the upgrade archive.
# Build against the minimum supported Node, including its bundled C++ headers.
RUN curl -fsSL https://unofficial-builds.nodejs.org/download/release/v20.18.1/node-v20.18.1-linux-armv6l.tar.gz -o /tmp/node.tar.gz && \
echo 'd4343a3d0fe23ddbd785537271bb0b60435edd50f5fba5bc580134592d51f011 /tmp/node.tar.gz' | sha256sum -c - && \
tar -xzf /tmp/node.tar.gz -C /armv6/usr/local --strip-components=1 && rm /tmp/node.tar.gz
COPY --from=source /ql/ /armv6/ql/
# PM2 is JavaScript. Keep the ARMv6 Node/npm and the Raspbian system libraries.
COPY --from=source /usr/local/lib/node_modules/pm2/ /armv6/usr/local/lib/node_modules/pm2/
RUN ln -s ../lib/node_modules/pm2/bin/pm2 /armv6/usr/local/bin/pm2 && \
armv6-run /bin/bash -ec 'ulimit -c 0; cd "$(readlink -f /ql/node_modules/sqlite3)"; CFLAGS="-march=armv6 -mfpu=vfp -mfloat-abi=hard" CXXFLAGS="-march=armv6 -mfpu=vfp -mfloat-abi=hard" npm_config_nodedir=/usr/local npm_config_build_from_source=true node --jitless -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)"; readelf -A lib/binding/napi-v6-linux-arm/node_sqlite3.node | grep "Tag_ABI_VFP_args: VFP registers"; rm -rf build; node --jitless -e '\''const p=process; if(p.config.variables.arm_version!=="6" && p.config.variables.arm_version!==6) process.exit(1); console.log(JSON.stringify({node:p.version,arm:p.config.variables.arm_version,float:p.config.variables.arm_float_abi,glibc:p.report.getReport().header.glibcVersionRuntime}));'\'''
LABEL qinglong.qa.runtime="armv6-glibc-arm1176"
ENTRYPOINT ["/usr/local/bin/armv6-run"]
@@ -0,0 +1,13 @@
# An immutable digest of the CI-only ARM1176 validator, built in the same run.
ARG BASE_IMAGE
FROM ${BASE_IMAGE} AS bundle
ARG BASE_IMAGE
ENV QL_BUNDLE_BASE_IMAGE=${BASE_IMAGE}
RUN ulimit -c 0; armv6-run /bin/bash -ec \
'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; \
done'
FROM scratch
COPY --from=bundle /armv6/out/ /
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
set -eu
export PROOT_NO_SECCOMP=1 QEMU_CPU=arm1176
# Bind only kernel interfaces; all application files and writable state remain
# inside this container's ARMv6 root. Child processes also pass through QEMU.
exec proot -0 -q '/usr/bin/qemu-arm-static -cpu arm1176' \
-r /armv6 -b /proc -b /dev -w / "$@"
+7 -4
View File
@@ -8,7 +8,7 @@ CI 从同次构建的不可变镜像摘要导出完整 tar.gz,包含源码中
面板下载任务与本地离线入口共用 `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。13 个平台均完成归档、解包后复验及依赖试运行才发布渠道;过期工作流不能覆盖更新提交的渠道。
## 手动离线安装和升级
@@ -31,18 +31,21 @@ ql reload system
```sh
node install-bundle.cjs --root /opt/qinglong --file /absolute/path/package.tar.gz --install
QL_DIR=/opt/qinglong bash /opt/qinglong/shell/start-simplify.sh
QL_DIR=/opt/qinglong QL_DATA_DIR=/opt/qinglong/data bash /opt/qinglong/shell/start-simplify.sh
```
`--install` 拒绝非空目录,不覆盖既有配置。该包安装青龙及其 Node 生产依赖;首次启动仍需已有 Python、Bash、PM2、nginx 等相应运行环境,不会在线安装或替换它们。服务启动和系统自启按原手动安装流程配置。
`--install` 拒绝非空目录,不覆盖既有配置。该包安装青龙及其 Node 生产依赖;首次启动仍需已有 Python、Bash、OpenSSL、PM2、nginx 等相应运行环境,不会在线安装或替换它们。手动启动脚本会在启动工作进程前完成首次 gRPC 证书准备,避免慢设备生成密钥耗尽工作进程的就绪期限;已有证书继续复用。服务启动和系统自启按原手动安装流程配置。
ARMv6 glibc 小设备(例如 Raspberry Pi 1/Zero 的 Raspbian/Raspberry Pi OS 32 位系统)选择 `linux-armv6-glibc.tar.gz`,以同一安装器执行上述离线命令。该包要求 glibc >= 2.31、ARMv6 硬浮点 Node >= 20.18.1,不适用于 ARMv5、软浮点 Node 或旧版 glibc。安装和升级均不需要 Docker,也不会下载或替换 Node。
## 平台兼容
- 最低 Node 为 20.18.1,Node-API 至少 6;安装器只检查,不替换 Node。
- 以 Node 实际运行架构、ARM 指令集和 libc 类型/版本选择包;不依据 Docker 架构别名猜测。
- glibc、musl 分包,并记录构建系统 libc 下限。系统库仍以目标设备加载测试作为最后检查。
- ARMv6 glibc 使用签名校验的 Raspbian bullseye(glibc 2.31)构建环境,以固定 SHA256 的 ARMv6 Node 20.18.1 头文件编译 SQLite,强制 `-march=armv6 -mfpu=vfp -mfloat-abi=hard`。CI 检查 ELF 的 ARMv6 和硬浮点标记,并在 QEMU ARM1176 下完成归档复验、SQLite 读写和空目录离线安装;验证辅助镜像按不可变摘要记录,不进入用户安装包。Debian ARMv7 镜像仅提供同次构建的应用文件,不复用其 Node、libc 或 SQLite 二进制。
- ARMv6 与 ARMv7 使用不同包名。ARMv6 在目标镜像中从源码编译 SQLite,禁止复用 generic `arm` 的 ARMv7 预编译件;构建需通过加载和内存库读写,失败则不发布渠道。
- ARMv6 的归档构建、在线更新工作进程及独立离线安装器使用 `--jitless`,避开 ARM1176 上 V8 的优化编译崩溃;保留现有 Node 二进制,正常面板进程的启动参数不变。依赖加载和 SQLite 读写仍由现有 Node 的独立进程验证。
- ARMv6 的归档构建、在线更新工作进程及独立离线安装器使用 `--jitless`,避开 ARM1176 上 V8 的优化编译崩溃;保留现有 Node 二进制,正常面板进程的启动参数不变。依赖加载和 SQLite 读写仍由现有 Node 的正常模式独立进程验证,使 Node 20.18.1 的内置 HTTP/WebAssembly 组件保持可用。
- tar 中允许 pnpm 的包内符号链接;拒绝绝对路径、越界路径、包外链接、硬链接、设备条目和经符号链接写入的条目。重启前再次校验依赖,暂存失败保留上一次可用的就绪包。
自托管镜像可设置 `QL_UPGRADE_INDEX_URL` 指向兼容索引;要求 HTTPS,测试时仅放行 loopback HTTP。索引和所有平台归档应部署到同一可访问源,避免依赖额外的 raw 域名。
+1
View File
@@ -13,6 +13,7 @@ const platforms = [
'linux-s390x-musl',
'linux-ia32-musl',
'linux-x64-glibc',
'linux-armv6-glibc',
'linux-armv7-glibc',
'linux-arm64-glibc',
'linux-ppc64-glibc',
+2
View File
@@ -212,6 +212,8 @@ async function installBundle(argv = process.argv.slice(2), env = process.env) {
process.version
}。启动服务:QL_DIR=${JSON.stringify(
root,
)} QL_DATA_DIR=${JSON.stringify(
path.join(root, 'data'),
)} bash ${JSON.stringify(
path.join(root, 'shell/start-simplify.sh'),
)}`,
+11
View File
@@ -52,6 +52,17 @@ make_dir /etc/nginx/conf.d
make_dir /run/nginx
fix_config
# Slow devices may need more than the worker's readiness window to generate
# first-boot certificates. Reuse the regular initializer before starting workers.
node <<'QL_PREPARE_GRPC_CERTS'
require(require('node:path').join(process.env.QL_DIR, 'static/build/config/grpcCerts.js'))
.initGrpcCerts()
.catch((error) => {
console.error(error);
process.exitCode = 1;
});
QL_PREPARE_GRPC_CERTS
pm2 l &>/dev/null
log_with_style "INFO" "$(t '🔄 2. 启动 nginx...')"
+124
View File
@@ -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);
});
+96 -2
View File
@@ -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 = [
'请先初始化',
+41 -1
View File
@@ -202,6 +202,35 @@ test('native target compatibility distinguishes ARMv6/ARMv7 and libc and retains
target: { ...target, libcMinimum: '1.2.4' },
};
bundle.assertCompatible(manifest, target, '20.20.2');
const glibcTarget = {
...target,
libc: 'glibc',
libcVersion: '2.31',
endianness: 'LE',
armFloatAbi: 'hard',
};
const glibcManifest = {
...manifest,
target: { ...glibcTarget, libcMinimum: '2.31' },
};
bundle.assertCompatible(glibcManifest, glibcTarget, '20.18.1');
assert.equal(bundle.targetKey(glibcTarget), 'linux-armv6-glibc');
assert.throws(
() =>
bundle.assertCompatible(glibcManifest, {
...glibcTarget,
libcVersion: '2.30',
}),
/requires glibc/,
);
assert.throws(
() =>
bundle.assertCompatible(glibcManifest, {
...glibcTarget,
armFloatAbi: 'soft',
}),
/floating-point ABI mismatch/,
);
assert.throws(
() => bundle.assertCompatible(manifest, { ...target, arch: 'armv7' }),
/platform mismatch/,
@@ -309,7 +338,18 @@ test('publishing requires every platform and validates all immutable archive has
);
}
const index = releaseIndex(f.directory, sourceCommit, repository);
assert.equal(Object.keys(index.packages).length, 12);
assert.equal(Object.keys(index.packages).length, 13);
const armv6 = path.join(
f.directory,
`qinglong-${sourceCommit}-linux-armv6-glibc.tar.gz`,
);
const armv6Bytes = await fs.readFile(armv6);
await fs.rm(armv6);
assert.throws(
() => releaseIndex(f.directory, sourceCommit, repository),
/ENOENT/,
);
await fs.writeFile(armv6, armv6Bytes);
await fs.appendFile(
path.join(f.directory, `qinglong-${sourceCommit}-${platforms[0]}.tar.gz`),
'corrupt',
@@ -0,0 +1,53 @@
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');
const startup = fs.readFileSync('shell/start-simplify.sh', 'utf8');
const preparation = startup.slice(
startup.indexOf('\nfix_config\n'),
startup.indexOf('log_with_style "INFO" "$(t \'🔄'),
);
for (const fails of [false, true]) {
test(`manual startup ${
fails
? 'stops on certificate failure'
: 'awaits certificates before starting supervision'
}`, (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-start-certs-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const module = path.join(root, 'static/build/config/grpcCerts.js');
fs.mkdirSync(path.dirname(module), { recursive: true });
fs.writeFileSync(
module,
`exports.initGrpcCerts = () => new Promise((resolve, reject) => setTimeout(() => {
if (process.env.QA_CERT_FAILURE === '1') return reject(new Error('certificate preparation failed'));
require('node:fs').writeFileSync(require('node:path').join(process.env.QL_DIR, 'certs-ready'), 'ready');
resolve();
}, 50));`,
);
const result = spawnSync(
'/bin/bash',
[
'-c',
`set -e
fix_config(){ :; }
pm2(){ test -f "$QL_DIR/certs-ready"; touch "$QL_DIR/supervisor-started"; }
${preparation}`,
],
{
encoding: 'utf8',
env: {
...process.env,
QL_DIR: root,
QA_CERT_FAILURE: fails ? '1' : '0',
},
},
);
assert.equal(result.status, fails ? 1 : 0, result.stderr);
assert.equal(fs.existsSync(path.join(root, 'supervisor-started')), !fails);
if (fails) assert.match(result.stderr, /certificate preparation failed/);
});
}