diff --git a/.github/workflows/build-docker-image.yml b/.github/workflows/build-docker-image.yml index d94cceb0..19c1f2cc 100644 --- a/.github/workflows/build-docker-image.yml +++ b/.github/workflows/build-docker-image.yml @@ -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}-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 }} diff --git a/docker/Dockerfile.armv6-glibc b/docker/Dockerfile.armv6-glibc new file mode 100644 index 00000000..be172852 --- /dev/null +++ b/docker/Dockerfile.armv6-glibc @@ -0,0 +1,38 @@ +# 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 proot && \ + rm -rf /var/lib/apt/lists/* +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 && \ + 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 && \ + 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.xz -o /tmp/node.tar.xz && \ + echo '02f4c973a1e432d91f8509b5268d26f3a10f4af33312907d9f43ba2483f14d34 /tmp/node.tar.xz' | sha256sum -c - && \ + tar -xJf /tmp/node.tar.xz -C /armv6/usr/local --strip-components=1 && rm /tmp/node.tar.xz +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"] diff --git a/docker/Dockerfile.upgrade-bundle-armv6-glibc b/docker/Dockerfile.upgrade-bundle-armv6-glibc new file mode 100644 index 00000000..0c073ac3 --- /dev/null +++ b/docker/Dockerfile.upgrade-bundle-armv6-glibc @@ -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/ / diff --git a/docker/armv6-run b/docker/armv6-run new file mode 100755 index 00000000..4d6a2ff7 --- /dev/null +++ b/docker/armv6-run @@ -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 / "$@" diff --git a/docs/development/platform-upgrade-bundles.md b/docs/development/platform-upgrade-bundles.md index 227f3043..f9165f1c 100644 --- a/docs/development/platform-upgrade-bundles.md +++ b/docs/development/platform-upgrade-bundles.md @@ -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-` release 资产 URL。12 个平台均完成归档、解包后复验及依赖试运行才发布渠道;过期工作流不能覆盖更新提交的渠道。 +渠道索引为 GitHub prerelease `upgrade-develop` 或 `upgrade-master` 下的 `latest.json`。索引记录准确字节数、SHA256 和不可变 `upgrade-` release 资产 URL。13 个平台均完成归档、解包后复验及依赖试运行才发布渠道;过期工作流不能覆盖更新提交的渠道。 ## 手动离线安装和升级 @@ -36,11 +36,14 @@ QL_DIR=/opt/qinglong bash /opt/qinglong/shell/start-simplify.sh `--install` 拒绝非空目录,不覆盖既有配置。该包安装青龙及其 Node 生产依赖;首次启动仍需已有 Python、Bash、PM2、nginx 等相应运行环境,不会在线安装或替换它们。服务启动和系统自启按原手动安装流程配置。 +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 的独立进程验证。 - tar 中允许 pnpm 的包内符号链接;拒绝绝对路径、越界路径、包外链接、硬链接、设备条目和经符号链接写入的条目。重启前再次校验依赖,暂存失败保留上一次可用的就绪包。 diff --git a/scripts/publish-upgrade-bundles.cjs b/scripts/publish-upgrade-bundles.cjs index 6155b52d..d323cdeb 100644 --- a/scripts/publish-upgrade-bundles.cjs +++ b/scripts/publish-upgrade-bundles.cjs @@ -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', diff --git a/test/back/platform-bundle.test.cjs b/test/back/platform-bundle.test.cjs index b5ab5e03..ac73bd38 100644 --- a/test/back/platform-bundle.test.cjs +++ b/test/back/platform-bundle.test.cjs @@ -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',