mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-03 16:19:11 +08:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8ffa48c32 | ||
|
|
c63ff6598e | ||
|
|
0a96118f28 | ||
|
|
f911876d5c | ||
|
|
047fdcbaba | ||
|
|
27cebdc770 | ||
|
|
24b8be04c9 | ||
|
|
c8df528123 |
@@ -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 }}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/ /
|
||||
Executable
+7
@@ -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 / "$@"
|
||||
@@ -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 域名。
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'),
|
||||
)}`,
|
||||
|
||||
@@ -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...')"
|
||||
|
||||
@@ -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 = [
|
||||
'请先初始化',
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user