mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-03 16:19:11 +08:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c28343b36 | ||
|
|
176d572c47 | ||
|
|
b92621bdb8 | ||
|
|
da9c6a6bbb |
@@ -247,7 +247,7 @@ jobs:
|
||||
version: "8.3.1"
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "24"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
@@ -336,7 +336,7 @@ jobs:
|
||||
version: "8.3.1"
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "24"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
@@ -394,7 +394,7 @@ jobs:
|
||||
SOURCE_COMMIT=${{ github.sha }}
|
||||
network: host
|
||||
# Keep s390x on Debian: npm can hang under QEMU with Alpine (nodejs/docker-node#1973).
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
||||
platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/s390x
|
||||
context: .
|
||||
file: ./docker/Dockerfile.debian
|
||||
push: true
|
||||
@@ -423,7 +423,7 @@ jobs:
|
||||
version: "8.3.1"
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "24"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
@@ -495,7 +495,7 @@ jobs:
|
||||
version: "8.3.1"
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "24"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
@@ -538,7 +538,7 @@ jobs:
|
||||
SOURCE_COMMIT=${{ github.sha }}
|
||||
network: host
|
||||
# Keep s390x on Debian: npm can hang under QEMU with Alpine (nodejs/docker-node#1973).
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
||||
platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/s390x
|
||||
context: .
|
||||
file: ./docker/Dockerfile.debian310
|
||||
push: true
|
||||
@@ -662,7 +662,8 @@ jobs:
|
||||
# Both channels publish packages from this run's immutable image digests.
|
||||
if: ${{ !inputs.static_sync_only && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/develop') }}
|
||||
needs: [build-alpine, build-debian]
|
||||
runs-on: ubuntu-22.04
|
||||
# Node 24 can SIGILL while archiving ARM64 musl under x64 QEMU.
|
||||
runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-22.04' }}
|
||||
timeout-minutes: 90
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -679,13 +680,13 @@ 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/v7, libc: glibc, name: armv7-glibc }
|
||||
- { platform: linux/arm64, libc: glibc, name: arm64-glibc }
|
||||
- { platform: linux/ppc64le, libc: glibc, name: ppc64-glibc }
|
||||
- { platform: linux/s390x, libc: glibc, name: s390x-glibc }
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: docker/setup-qemu-action@v4
|
||||
if: ${{ matrix.platform != 'linux/arm64' }}
|
||||
with:
|
||||
cache-image: false
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
@@ -721,7 +722,7 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '24'
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: '8.3.1'
|
||||
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
- image: debian
|
||||
file: docker/Dockerfile.debian
|
||||
cache: debian
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
||||
platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/s390x
|
||||
- image: alpine-python3.10
|
||||
file: docker/Dockerfile.310
|
||||
cache: alpine-python3.10
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
- image: debian-python3.10
|
||||
file: docker/Dockerfile.debian310
|
||||
cache: debian-python3.10
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
||||
platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/s390x
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
|
||||
+85
-28
@@ -4,7 +4,6 @@ import taskLimit from './pLimit';
|
||||
import Logger from '../loaders/logger';
|
||||
import { ICron } from '../protos/cron';
|
||||
import { CrontabModel, CrontabStatus } from '../data/cron';
|
||||
import { killTask } from '../config/util';
|
||||
import { RunningInstanceModel, InstanceStatus } from '../data/runningInstance';
|
||||
import dayjs from 'dayjs';
|
||||
import { observeChildProcess, asError } from './childProcess';
|
||||
@@ -17,7 +16,7 @@ export function runCron(
|
||||
const execute = async () => {
|
||||
try {
|
||||
if (!isCurrent()) return;
|
||||
// Check if the cron is already running and stop it (only if multiple instances are not allowed)
|
||||
// A single-instance trigger must not interrupt work already in progress.
|
||||
try {
|
||||
const existingCron = await CrontabModel.findOne({
|
||||
where: { id: Number(cron.id) },
|
||||
@@ -30,35 +29,93 @@ export function runCron(
|
||||
const allowSingleInstances =
|
||||
existingCron?.allow_multiple_instances === 0;
|
||||
|
||||
if (
|
||||
allowSingleInstances &&
|
||||
existingCron &&
|
||||
existingCron.pid &&
|
||||
(existingCron.status === CrontabStatus.running ||
|
||||
existingCron.status === CrontabStatus.queued)
|
||||
) {
|
||||
Logger.info(
|
||||
t('[schedule][停止已运行任务] 任务ID: %s, PID: %s'),
|
||||
cron.id,
|
||||
existingCron.pid,
|
||||
);
|
||||
await killTask(existingCron.pid, true);
|
||||
// Mark old running instances as stopped
|
||||
const stoppedAt = dayjs().unix();
|
||||
await RunningInstanceModel.update(
|
||||
{ status: InstanceStatus.stopped, finished_at: stoppedAt },
|
||||
{
|
||||
if (allowSingleInstances) {
|
||||
// A queued manual run is an active reservation, even if its stored PID
|
||||
// belongs to a previous execution.
|
||||
if (
|
||||
existingCron.status === CrontabStatus.queued &&
|
||||
existingCron.pid
|
||||
) {
|
||||
Logger.info(
|
||||
'[schedule][Skip queued single-instance task] Task ID: %s, PID: %s',
|
||||
cron.id,
|
||||
existingCron.pid,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
existingCron.status === CrontabStatus.running &&
|
||||
existingCron.pid
|
||||
) {
|
||||
let missing = false;
|
||||
try {
|
||||
process.kill(existingCron.pid, 0);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'ESRCH') missing = true;
|
||||
else if (code !== 'EPERM') throw error;
|
||||
}
|
||||
if (!missing) {
|
||||
Logger.info(
|
||||
'[schedule][Skip active single-instance task] Task ID: %s, PID: %s',
|
||||
cron.id,
|
||||
existingCron.pid,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture old instance IDs before releasing the stale cron state so
|
||||
// a concurrent replacement cannot have its instance marked stopped.
|
||||
const staleInstances = await RunningInstanceModel.findAll({
|
||||
attributes: ['id'],
|
||||
where: {
|
||||
cron_id: Number(cron.id),
|
||||
pid: existingCron.pid,
|
||||
status: InstanceStatus.running,
|
||||
},
|
||||
},
|
||||
);
|
||||
// Update the status to idle after killing
|
||||
await CrontabModel.update(
|
||||
{ status: CrontabStatus.idle, pid: undefined },
|
||||
{ where: { id: Number(cron.id) } },
|
||||
);
|
||||
});
|
||||
if (!isCurrent()) return;
|
||||
const [recovered] = await CrontabModel.update(
|
||||
{
|
||||
status: CrontabStatus.idle,
|
||||
pid: null,
|
||||
queued_token: null,
|
||||
} as any,
|
||||
{
|
||||
where: {
|
||||
id: Number(cron.id),
|
||||
pid: existingCron.pid,
|
||||
status: CrontabStatus.running,
|
||||
...(existingCron.log_path !== undefined
|
||||
? { log_path: existingCron.log_path }
|
||||
: {}),
|
||||
...(existingCron.queued_token !== undefined
|
||||
? { queued_token: existingCron.queued_token }
|
||||
: {}),
|
||||
...(existingCron.last_execution_time !== undefined
|
||||
? { last_execution_time: existingCron.last_execution_time }
|
||||
: {}),
|
||||
...(existingCron.isDisabled !== undefined
|
||||
? { isDisabled: existingCron.isDisabled }
|
||||
: {}),
|
||||
allow_multiple_instances: 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (recovered !== 1) return;
|
||||
const staleIds = staleInstances.map((instance) => instance.id!);
|
||||
if (staleIds.length) {
|
||||
await RunningInstanceModel.update(
|
||||
{ status: InstanceStatus.stopped, finished_at: dayjs().unix() },
|
||||
{ where: { id: staleIds, status: InstanceStatus.running } },
|
||||
);
|
||||
}
|
||||
Logger.info(
|
||||
'[schedule][Recovered stale single-instance task] Task ID: %s, PID: %s',
|
||||
cron.id,
|
||||
existingCron.pid,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
@@ -69,7 +126,7 @@ export function runCron(
|
||||
throw error;
|
||||
}
|
||||
|
||||
// In particular, invalidation can happen while stopping a previous run.
|
||||
// Invalidation can happen during the asynchronous database check.
|
||||
if (!isCurrent()) return;
|
||||
Logger.info(
|
||||
t('[schedule][开始执行任务] 参数 %s'),
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# Run Node package installation natively on the builder. Node/npm can spin at
|
||||
# 100% CPU when Alpine s390x is emulated through QEMU.
|
||||
FROM --platform=$BUILDPLATFORM node:18-alpine3.18 AS builder
|
||||
FROM --platform=$BUILDPLATFORM node:24.18.1-alpine3.24 AS builder
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
@@ -32,7 +32,7 @@ RUN set -x \
|
||||
# Pin amd64 to upstream's musl runtime after native URL parser crashes with APK Node.
|
||||
FROM --platform=linux/amd64 node:24.18.1-alpine3.24@sha256:f70403e87646dc51b45295f4b8b70cdad0b63d2297c4c9899119b03f7af7a6b3 AS node-runtime-amd64
|
||||
|
||||
FROM python:3.11-alpine
|
||||
FROM python:3.11-alpine3.24
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
@@ -80,6 +80,7 @@ RUN --mount=type=bind,from=node-runtime-amd64,source=/usr/local/bin/node,target=
|
||||
cp /tmp/node-runtime-amd64 /usr/local/bin/node; \
|
||||
ln -sf /usr/local/bin/node /usr/bin/node; \
|
||||
fi \
|
||||
&& node --version | grep -E '^v24\.' \
|
||||
&& rm -rf /var/cache/apk/* \
|
||||
&& apk update \
|
||||
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Run Node package installation natively on the builder. Node/npm can spin at
|
||||
# 100% CPU when Alpine s390x is emulated through QEMU.
|
||||
FROM --platform=$BUILDPLATFORM node:18-alpine3.18 AS builder
|
||||
FROM --platform=$BUILDPLATFORM node:24.18.1-alpine3.24 AS builder
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
@@ -32,7 +32,7 @@ RUN set -x \
|
||||
# Pin amd64 to upstream's musl runtime after native URL parser crashes with APK Node.
|
||||
FROM --platform=linux/amd64 node:24.18.1-alpine3.24@sha256:f70403e87646dc51b45295f4b8b70cdad0b63d2297c4c9899119b03f7af7a6b3 AS node-runtime-amd64
|
||||
|
||||
FROM python:3.10-alpine
|
||||
FROM python:3.10-alpine3.24
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
@@ -80,6 +80,7 @@ RUN --mount=type=bind,from=node-runtime-amd64,source=/usr/local/bin/node,target=
|
||||
cp /tmp/node-runtime-amd64 /usr/local/bin/node; \
|
||||
ln -sf /usr/local/bin/node /usr/bin/node; \
|
||||
fi \
|
||||
&& node --version | grep -E '^v24\.' \
|
||||
&& rm -rf /var/cache/apk/* \
|
||||
&& apk update \
|
||||
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
# Node 20 Bookworm is the latest official Node image variant that covers the
|
||||
# full Debian build matrix, including arm/v7, ppc64le, and s390x.
|
||||
FROM node:20-bookworm-slim AS nodebuilder
|
||||
# Download and verify the target Node archive without running under QEMU.
|
||||
FROM --platform=$BUILDPLATFORM debian:bookworm-slim AS nodebuilder
|
||||
ARG TARGETARCH
|
||||
COPY docker/install-node-debian.sh /tmp/install-node-debian.sh
|
||||
RUN apt-get update && \
|
||||
apt-get install --no-install-recommends -y ca-certificates curl xz-utils && \
|
||||
sh /tmp/install-node-debian.sh "${TARGETARCH}"
|
||||
|
||||
FROM python:3.11.14-slim-bookworm AS builder
|
||||
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
|
||||
@@ -11,6 +15,7 @@ RUN set -x && \
|
||||
apt-get update && \
|
||||
apt-get install --no-install-recommends -y libatomic1 && \
|
||||
npm i -g pnpm@8.3.1 && \
|
||||
test "$(node --version)" = "v24.18.1" && \
|
||||
test "$(pnpm --version)" = "8.3.1" && \
|
||||
cd /tmp/build && \
|
||||
pnpm install --prod --frozen-lockfile
|
||||
@@ -71,6 +76,7 @@ RUN set -x && \
|
||||
git config --global user.name "qinglong" && \
|
||||
git config --global http.postBuffer 524288000 && \
|
||||
npm install -g pnpm@8.3.1 pm2 ts-node typescript@5 && \
|
||||
test "$(node --version)" = "v24.18.1" && \
|
||||
test "$(pnpm --version)" = "8.3.1" && \
|
||||
npm cache clean --force && \
|
||||
rm -rf /root/.cache && \
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
# Node 20 Bookworm is the latest official Node image variant that covers the
|
||||
# full Debian build matrix, including arm/v7, ppc64le, and s390x.
|
||||
FROM node:20-bookworm-slim AS nodebuilder
|
||||
# Download and verify the target Node archive without running under QEMU.
|
||||
FROM --platform=$BUILDPLATFORM debian:bookworm-slim AS nodebuilder
|
||||
ARG TARGETARCH
|
||||
COPY docker/install-node-debian.sh /tmp/install-node-debian.sh
|
||||
RUN apt-get update && \
|
||||
apt-get install --no-install-recommends -y ca-certificates curl xz-utils && \
|
||||
sh /tmp/install-node-debian.sh "${TARGETARCH}"
|
||||
|
||||
# Keep the full release matrix, including s390x, on the same immutable base.
|
||||
# Keep s390x on the same immutable Python base as the other Debian targets.
|
||||
# The floating 3.10-slim-bookworm tag no longer includes s390x.
|
||||
FROM python:3.10.19-slim-bookworm@sha256:23f63358922e79a794f71be8f3723c84e5ccca9638af3f74456dc73d6184499e AS builder
|
||||
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
|
||||
@@ -13,6 +17,7 @@ RUN set -x && \
|
||||
apt-get update && \
|
||||
apt-get install --no-install-recommends -y libatomic1 && \
|
||||
npm i -g pnpm@8.3.1 && \
|
||||
test "$(node --version)" = "v24.18.1" && \
|
||||
cd /tmp/build && \
|
||||
pnpm install --prod --frozen-lockfile
|
||||
|
||||
@@ -71,6 +76,7 @@ RUN set -x && \
|
||||
git config --global user.name "qinglong" && \
|
||||
git config --global http.postBuffer 524288000 && \
|
||||
npm install -g pnpm@8.3.1 pm2 ts-node typescript@5 && \
|
||||
test "$(node --version)" = "v24.18.1" && \
|
||||
npm cache clean --force && \
|
||||
rm -rf /root/.cache && \
|
||||
rm -rf /root/.npm && \
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# The official Node 24 Bookworm image does not cover s390x. Use the same
|
||||
# verified upstream release archive on every supported Debian architecture.
|
||||
version=24.18.1
|
||||
case "${1:?Missing Docker TARGETARCH}" in
|
||||
amd64) arch=x64; checksum=d6c664df3f3f61458e8c277585571328522d705166723a7c7823a9253a4d15a0 ;;
|
||||
arm64) arch=arm64; checksum=7201e3a09dc825bac57867c81913e2b8f0ef87d04cb9082af4cda82f6ff3d88c ;;
|
||||
ppc64le) arch=ppc64le; checksum=c47812c13862be7f6e71194c434c4d78a99096e72efb9c31a2627437e056d669 ;;
|
||||
s390x) arch=s390x; checksum=acf57bd726f35afc2ba8475194e5a8bd532c00e1a3b2436295b2fbdde199d0e9 ;;
|
||||
*) echo "Node ${version} has no supported Debian binary for TARGETARCH=$1" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
archive="node-v${version}-linux-${arch}.tar.xz"
|
||||
curl --fail --location --silent --show-error --retry 3 \
|
||||
"https://nodejs.org/dist/v${version}/${archive}" -o "/tmp/${archive}"
|
||||
printf '%s %s\n' "${checksum}" "/tmp/${archive}" | sha256sum --check --status
|
||||
tar -xJf "/tmp/${archive}" -C /usr/local --strip-components=1 --no-same-owner
|
||||
rm "/tmp/${archive}"
|
||||
# The target binary is executed and version-checked in the target builder/runtime stages.
|
||||
test -x /usr/local/bin/node
|
||||
@@ -18,7 +18,7 @@ gh workflow run build-docker-image.yml --ref develop
|
||||
|
||||
开发包只保留最新一个已验证批次:所有平台资产和渠道索引的大小、SHA256 校验成功后,删除其他名称匹配的开发构建 prerelease;不删除正式包、渠道索引或无法分类的旧 release。构建或上传失败不会删除上一套可用包。旧批次被删除后,已选中旧索引但尚未下载完的客户端可能需要重新发起下载;准备失败仍保留旧服务和数据。
|
||||
|
||||
开发和正式包都必须在 12 个平台完成归档、解包后复验及依赖试运行,并且全部上传资产的大小和 SHA256 复验通过后,才更新各自索引。平台范围与现有 Alpine/Debian 镜像一致:ARMv6 仅提供 musl 包,不提供 ARMv6 glibc 包。
|
||||
开发和正式包都必须在 11 个平台完成归档、解包后复验及依赖试运行,并且全部上传资产的大小和 SHA256 复验通过后,才更新各自索引。平台范围与现有 Alpine/Debian 镜像一致:ARMv6 和 ARMv7 仅提供 musl 包,Debian 不再发布 ARMv7 镜像或 glibc 平台包。
|
||||
|
||||
版本入口为 GitHub prerelease `upgrade-v<version>` 下的 `latest.json`;在线正式渠道保留 `upgrade-master/latest.json`,兼容现有客户端。二者指向同一套完整包,指定版本与 latest 不重复生成产物。索引记录渠道、版本、源码提交、构建批次、准确字节数、SHA256,以及 `upgrade-build-v<version>-<run-id>-<run-attempt>` 下的固定下载 URL。相同版本的镜像 tag 重新发布时,完整包也重新生成,版本和正式渠道索引同步覆盖到新批次;同一提交重跑也允许产生新内容。已选中旧索引的下载继续使用旧批次资产,避免覆盖文件导致哈希不匹配。master 的镜像构建和完整包发布整条流水线串行执行,避免镜像 tag 与完整包被并发重发交错覆盖;旧工作流明确重跑时也允许同版本、同提交重新发布。过期源码提交不能推进对应渠道或版本索引;平台包上传失败时不切换索引。
|
||||
|
||||
@@ -64,7 +64,7 @@ QL_DIR=/opt/qinglong QL_DATA_DIR=/opt/qinglong/data bash /opt/qinglong/shell/sta
|
||||
|
||||
`--install` 拒绝非空目录,不覆盖既有配置。该包安装青龙及其 Node 生产依赖;首次启动仍需已有 Python、Bash、OpenSSL、PM2、nginx 等相应运行环境,不会在线安装或替换它们。手动启动脚本会在启动工作进程前完成首次 gRPC 证书准备,避免慢设备生成密钥耗尽工作进程的就绪期限;已有证书继续复用。服务启动和系统自启按原手动安装流程配置。
|
||||
|
||||
ARMv6 小设备使用 `linux-armv6-musl.tar.gz`,要求已有兼容的 musl 系统及硬浮点 Node。ARMv6 glibc(例如 Raspbian/Raspberry Pi OS 32 位系统)不在平台包支持范围内,不能使用 ARMv7 或 musl 包替代;安装器仍会检查实际架构和 libc,拒绝不匹配的包。
|
||||
ARMv6 小设备使用 `linux-armv6-musl.tar.gz`,要求已有兼容的 musl 系统及硬浮点 Node。ARMv6 glibc(例如 Raspbian/Raspberry Pi OS 32 位系统)不在平台包支持范围内,不能使用其他架构或 musl 包替代;安装器仍会检查实际架构和 libc,拒绝不匹配的包。
|
||||
|
||||
## npm 面板包
|
||||
|
||||
@@ -89,7 +89,7 @@ master 发布任务等待上述测试通过,直接发布已验证的 `.tgz`,
|
||||
- 最低 Node 为 20.18.1,Node-API 至少 6;安装器只检查,不替换 Node。
|
||||
- 以 Node 实际运行架构、ARM 指令集和 libc 类型/版本选择包;不依据 Docker 架构别名猜测。
|
||||
- glibc、musl 分包,并记录构建系统 libc 下限。系统库仍以目标设备加载测试作为最后检查。
|
||||
- ARMv6 与 ARMv7 使用不同包名。ARMv6 在目标镜像中从源码编译 SQLite,禁止复用 generic `arm` 的 ARMv7 预编译件;构建需通过加载和内存库读写,失败则不发布渠道。
|
||||
- ARMv6 保留独立包名。ARMv6 在目标镜像中从源码编译 SQLite,禁止复用 generic `arm` 的 ARMv7 预编译件;构建需通过加载和内存库读写,失败则不发布渠道。
|
||||
- ARMv6 的归档构建、在线更新工作进程及独立离线安装器使用 `--jitless`,避开 ARM1176 上 V8 的优化编译崩溃;保留现有 Node 二进制,Node 24 及以上的 ARMv6 面板进程通过共享 `nodeRuntime` 使用 `--no-opt --no-sparkplug --no-maglev`,其他架构和较低 Node 版本保持原启动参数。ARMv6 的 gRPC 工作进程首次启动及重建最多等待 2 分钟;升级后的整体健康检查最多等待 5 分钟,覆盖慢设备按顺序启动数据库、调度与 HTTP 工作进程的耗时;其他平台仍为 60 秒,超时或版本校验失败仍执行回退。依赖加载与后端预启动使用现有 Node 的正常模式,使 Node 20.18.1 的内置 HTTP/WebAssembly 组件保持可用。预启动只有一个后端进程,按顺序初始化数据库、gRPC、HTTP,不启动 PM2/cluster;ARMv6 V8 堆上限 128 MiB、期限 5 分钟,其他平台为 192 MiB/60 秒。堆上限不是 RSS 总内存上限,系统仍需为现有服务和 OpenSSL 留出内存。依赖修复的包管理器及编译使用单并发、低网络并发和有界时长;ARMv6 包管理器采用 `--jitless`、256 MiB 堆上限,并优先复用版本匹配的现有 Node 头文件与实际运行时编译配置;超时、中断会终止整个进程组并清理临时目录。
|
||||
- tar 中允许 pnpm 的包内符号链接;拒绝绝对路径、越界路径、包外链接、硬链接、设备条目和经符号链接写入的条目。重启前再次校验依赖,暂存失败保留上一次可用的就绪包。
|
||||
|
||||
|
||||
+2
-2
@@ -111,7 +111,7 @@
|
||||
}
|
||||
},
|
||||
"overrides": {
|
||||
"sqlite3": "npm:@whyour/sqlite3@1.1.3",
|
||||
"sqlite3": "npm:@whyour/sqlite3@1.1.4",
|
||||
"@codemirror/state": "6.5.4",
|
||||
"@codemirror/view": "6.39.16",
|
||||
"proxy-addr": "2.0.8",
|
||||
@@ -159,7 +159,7 @@
|
||||
"request-ip": "3.3.0",
|
||||
"sequelize": "6.37.8",
|
||||
"sockjs": "^0.3.24",
|
||||
"sqlite3": "npm:@whyour/sqlite3@1.1.3",
|
||||
"sqlite3": "npm:@whyour/sqlite3@1.1.4",
|
||||
"toad-scheduler": "^3.0.1",
|
||||
"typedi": "^0.10.0",
|
||||
"undici": "^7.9.0",
|
||||
|
||||
Generated
+9
-9
@@ -1,7 +1,7 @@
|
||||
lockfileVersion: '6.0'
|
||||
|
||||
overrides:
|
||||
sqlite3: npm:@whyour/sqlite3@1.1.3
|
||||
sqlite3: npm:@whyour/sqlite3@1.1.4
|
||||
'@codemirror/state': 6.5.4
|
||||
'@codemirror/view': 6.39.16
|
||||
proxy-addr: 2.0.8
|
||||
@@ -120,13 +120,13 @@ dependencies:
|
||||
version: 3.3.0
|
||||
sequelize:
|
||||
specifier: 6.37.8
|
||||
version: 6.37.8(@whyour/sqlite3@1.1.3)
|
||||
version: 6.37.8(@whyour/sqlite3@1.1.4)
|
||||
sockjs:
|
||||
specifier: ^0.3.24
|
||||
version: 0.3.24
|
||||
sqlite3:
|
||||
specifier: npm:@whyour/sqlite3@1.1.3
|
||||
version: /@whyour/sqlite3@1.1.3
|
||||
specifier: npm:@whyour/sqlite3@1.1.4
|
||||
version: /@whyour/sqlite3@1.1.4
|
||||
tar:
|
||||
specifier: 7.5.20
|
||||
version: 7.5.20
|
||||
@@ -3193,7 +3193,7 @@ packages:
|
||||
resolution: {integrity: sha512-Ngs9jhElXN7efS9WvvCC/p6rXMbihna8eNLVBc421Zf+VcFd+pR4DOcS6yA9V22EKtAy4DEj7LtvEEIm0bb80A==}
|
||||
engines: {node: '>= 18'}
|
||||
dependencies:
|
||||
sqlite3: /@whyour/sqlite3@1.1.3
|
||||
sqlite3: /@whyour/sqlite3@1.1.4
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- supports-color
|
||||
@@ -5611,8 +5611,8 @@ packages:
|
||||
- supports-color
|
||||
dev: true
|
||||
|
||||
/@whyour/sqlite3@1.1.3:
|
||||
resolution: {integrity: sha512-VeiureU2gq11OMeSMWlpckNfbY5QF+V0KEpRi1Zqh/s5GEPgCazt06xIg528tPAwK0MS9KCUEyLb+dKDIIFoEg==}
|
||||
/@whyour/sqlite3@1.1.4:
|
||||
resolution: {integrity: sha512-MlWzqOdpZ0KPuBx/FYRrHZ8p3S0eCbaf2d8duDzWmfko/aky25Xoss67qDjFgbRjnwj9z0i0KNdNtQI02dnWKA==}
|
||||
requiresBuild: true
|
||||
peerDependenciesMeta:
|
||||
node-gyp:
|
||||
@@ -13903,7 +13903,7 @@ packages:
|
||||
engines: {node: '>= 10.0.0'}
|
||||
dev: false
|
||||
|
||||
/sequelize@6.37.8(@whyour/sqlite3@1.1.3):
|
||||
/sequelize@6.37.8(@whyour/sqlite3@1.1.4):
|
||||
resolution: {integrity: sha512-HJ0IQFqcTsTiqbEgiuioYFMSD00TP6Cz7zoTti+zVVBwVe9fEhev9cH6WnM3XU31+ABS356durAb99ZuOthnKw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
@@ -13948,7 +13948,7 @@ packages:
|
||||
retry-as-promised: 7.0.4
|
||||
semver: 7.7.4
|
||||
sequelize-pool: 7.1.0
|
||||
sqlite3: /@whyour/sqlite3@1.1.3
|
||||
sqlite3: /@whyour/sqlite3@1.1.4
|
||||
toposort-class: 1.0.1
|
||||
uuid: 8.3.2
|
||||
validator: 13.12.0
|
||||
|
||||
@@ -13,7 +13,6 @@ const platforms = [
|
||||
'linux-s390x-musl',
|
||||
'linux-ia32-musl',
|
||||
'linux-x64-glibc',
|
||||
'linux-armv7-glibc',
|
||||
'linux-arm64-glibc',
|
||||
'linux-ppc64-glibc',
|
||||
'linux-s390x-glibc',
|
||||
|
||||
@@ -591,7 +591,9 @@ 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, 11);
|
||||
assert.ok(index.packages['linux-armv7-musl']);
|
||||
assert.equal(index.packages['linux-armv7-glibc'], undefined);
|
||||
const armv6 = path.join(
|
||||
f.directory,
|
||||
`qinglong-${sourceCommit}-linux-armv6-musl.tar.gz`,
|
||||
|
||||
@@ -162,10 +162,12 @@ test('publishes every platform and installer before advancing version and legacy
|
||||
assert.equal(index.version, '2.24.0');
|
||||
assert.equal(index.buildId, '100-1');
|
||||
assert.equal(index.sourceCommit, f.options.commit);
|
||||
assert.equal(Object.keys(index.packages).length, 12);
|
||||
assert.equal(Object.keys(index.packages).length, 11);
|
||||
assert.ok(index.packages['linux-armv7-musl']);
|
||||
assert.equal(index.packages['linux-armv7-glibc'], undefined);
|
||||
assert.deepEqual(index, f.index('upgrade-v2.24.0'));
|
||||
const tag = 'upgrade-build-v2.24.0-100-1';
|
||||
assert.equal(f.state.releases.get(tag).size, 26);
|
||||
assert.equal(f.state.releases.get(tag).size, 24);
|
||||
for (const entry of [...Object.values(index.packages), index.installer]) {
|
||||
const url = new URL(entry.url),
|
||||
filename = path.basename(url.pathname);
|
||||
@@ -350,11 +352,13 @@ test('automatic development publication shares musl/glibc packages without chang
|
||||
const development = f.index('upgrade-develop');
|
||||
assert.equal(development.channel, 'develop');
|
||||
assert.equal(development.sourceCommit, f.state.developHead);
|
||||
assert.equal(Object.keys(development.packages).length, 12);
|
||||
assert.equal(Object.keys(development.packages).length, 11);
|
||||
assert.ok(development.packages['linux-x64-musl']);
|
||||
assert.ok(development.packages['linux-x64-glibc']);
|
||||
assert.ok(development.packages['linux-armv6-musl']);
|
||||
assert.equal(development.packages['linux-armv6-glibc'], undefined);
|
||||
assert.ok(development.packages['linux-armv7-musl']);
|
||||
assert.equal(development.packages['linux-armv7-glibc'], undefined);
|
||||
for (const entry of [
|
||||
...Object.values(development.packages),
|
||||
development.installer,
|
||||
@@ -362,10 +366,10 @@ test('automatic development publication shares musl/glibc packages without chang
|
||||
assert.ok(entry.url.includes('/upgrade-build-develop-v2.24.0-100-1/'));
|
||||
assert.deepEqual(f.index(), production);
|
||||
assert.deepEqual(f.index('upgrade-v2.24.0'), production);
|
||||
assert.equal(f.state.releases.get('upgrade-build-v2.24.0-100-1').size, 26);
|
||||
assert.equal(f.state.releases.get('upgrade-build-v2.24.0-100-1').size, 24);
|
||||
assert.equal(
|
||||
f.state.releases.get('upgrade-build-develop-v2.24.0-100-1').size,
|
||||
26,
|
||||
24,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
|
||||
function fixture(row, { claim = true, onSpawn = () => {} } = {}) {
|
||||
const spawned = [],
|
||||
cronWrites = [],
|
||||
instanceWrites = [],
|
||||
instanceReads = [];
|
||||
const { runCron } = load('back/shared/runCron.ts', {
|
||||
'./i18n': { t: (s) => s },
|
||||
'cross-spawn': {
|
||||
spawn: (cmd) => {
|
||||
spawned.push(cmd);
|
||||
onSpawn(row);
|
||||
return { pid: process.pid };
|
||||
},
|
||||
},
|
||||
'./pLimit': {
|
||||
runWithCronLimit: (_cron, execute) => execute(),
|
||||
removeQueuedCron() {},
|
||||
},
|
||||
'../loaders/logger': { info() {}, error() {} },
|
||||
'../data/cron': {
|
||||
CrontabModel: {
|
||||
findOne: async () => row,
|
||||
update: async (...args) => {
|
||||
cronWrites.push(args);
|
||||
if (!claim) return [0];
|
||||
Object.assign(row, args[0]);
|
||||
return [1];
|
||||
},
|
||||
},
|
||||
CrontabStatus: { running: 0, idle: 1, queued: 3 },
|
||||
},
|
||||
'../data/runningInstance': {
|
||||
RunningInstanceModel: {
|
||||
findAll: async (options) => {
|
||||
instanceReads.push(options);
|
||||
return [{ id: 7 }];
|
||||
},
|
||||
update: async (...args) => instanceWrites.push(args),
|
||||
},
|
||||
InstanceStatus: { running: 0, stopped: 2 },
|
||||
},
|
||||
dayjs: () => ({ unix: () => 123 }),
|
||||
'./childProcess': {
|
||||
observeChildProcess: () => ({ completed: Promise.resolve({ code: 0 }) }),
|
||||
asError: (e) => e,
|
||||
},
|
||||
});
|
||||
return {
|
||||
run: () => runCron('task example.py', { id: '1' }),
|
||||
spawned,
|
||||
cronWrites,
|
||||
instanceWrites,
|
||||
instanceReads,
|
||||
};
|
||||
}
|
||||
|
||||
function exitedPid() {
|
||||
const child = spawnSync(process.execPath, ['-e', '']);
|
||||
assert.equal(child.status, 0);
|
||||
assert.throws(() => process.kill(child.pid, 0), { code: 'ESRCH' });
|
||||
return child.pid;
|
||||
}
|
||||
|
||||
test('running single-instance task stays untouched while its process is alive', async () => {
|
||||
const f = fixture({
|
||||
allow_multiple_instances: 0,
|
||||
pid: process.pid,
|
||||
status: 0,
|
||||
});
|
||||
await f.run();
|
||||
assert.deepEqual(f.spawned, []);
|
||||
assert.deepEqual(f.cronWrites, []);
|
||||
assert.deepEqual(f.instanceWrites, []);
|
||||
});
|
||||
|
||||
test('queued single-instance task keeps its reservation even with an exited PID', async () => {
|
||||
const f = fixture({
|
||||
allow_multiple_instances: 0,
|
||||
pid: exitedPid(),
|
||||
status: 3,
|
||||
});
|
||||
await f.run();
|
||||
assert.deepEqual(f.spawned, []);
|
||||
assert.deepEqual(f.cronWrites, []);
|
||||
assert.deepEqual(f.instanceWrites, []);
|
||||
});
|
||||
|
||||
test('exited running task is recovered and the next trigger sees the new active run', async () => {
|
||||
const oldPid = exitedPid();
|
||||
const f = fixture(
|
||||
{
|
||||
allow_multiple_instances: 0,
|
||||
pid: oldPid,
|
||||
status: 0,
|
||||
log_path: 'old.log',
|
||||
queued_token: 'old-token',
|
||||
last_execution_time: 10,
|
||||
isDisabled: 0,
|
||||
},
|
||||
{
|
||||
onSpawn: (row) => {
|
||||
row.status = 0;
|
||||
row.pid = process.pid;
|
||||
},
|
||||
},
|
||||
);
|
||||
await f.run();
|
||||
await f.run();
|
||||
assert.equal(f.spawned.length, 1);
|
||||
assert.deepEqual(f.instanceReads, [
|
||||
{
|
||||
attributes: ['id'],
|
||||
where: { cron_id: 1, pid: oldPid, status: 0 },
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(f.cronWrites, [
|
||||
[
|
||||
{ status: 1, pid: null, queued_token: null },
|
||||
{
|
||||
where: {
|
||||
id: 1,
|
||||
pid: oldPid,
|
||||
status: 0,
|
||||
log_path: 'old.log',
|
||||
queued_token: 'old-token',
|
||||
last_execution_time: 10,
|
||||
isDisabled: 0,
|
||||
allow_multiple_instances: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
assert.deepEqual(f.instanceWrites, [
|
||||
[{ status: 2, finished_at: 123 }, { where: { id: [7], status: 0 } }],
|
||||
]);
|
||||
});
|
||||
|
||||
test('a lost stale-state claim does not spawn or change running instances', async () => {
|
||||
const f = fixture(
|
||||
{ allow_multiple_instances: 0, pid: exitedPid(), status: 0 },
|
||||
{ claim: false },
|
||||
);
|
||||
await f.run();
|
||||
assert.equal(f.cronWrites.length, 1);
|
||||
assert.deepEqual(f.spawned, []);
|
||||
assert.deepEqual(f.instanceWrites, []);
|
||||
});
|
||||
|
||||
test('EPERM means an existing process must still block a new run', async () => {
|
||||
const pid = exitedPid();
|
||||
const originalKill = process.kill;
|
||||
process.kill = (target, signal) => {
|
||||
if (target === pid && signal === 0)
|
||||
throw Object.assign(new Error('denied'), { code: 'EPERM' });
|
||||
return originalKill(target, signal);
|
||||
};
|
||||
try {
|
||||
const f = fixture({ allow_multiple_instances: 0, pid, status: 0 });
|
||||
await f.run();
|
||||
assert.deepEqual(f.spawned, []);
|
||||
assert.deepEqual(f.cronWrites, []);
|
||||
} finally {
|
||||
process.kill = originalKill;
|
||||
}
|
||||
});
|
||||
|
||||
test('single-instance still starts idle tasks with a stale stored PID', async () => {
|
||||
const f = fixture({
|
||||
allow_multiple_instances: 0,
|
||||
pid: exitedPid(),
|
||||
status: 1,
|
||||
});
|
||||
await f.run();
|
||||
assert.equal(f.spawned.length, 1);
|
||||
assert.deepEqual(f.cronWrites, []);
|
||||
});
|
||||
|
||||
test('multi-instance keeps its existing concurrent execution behavior', async () => {
|
||||
const f = fixture({
|
||||
allow_multiple_instances: 1,
|
||||
pid: process.pid,
|
||||
status: 0,
|
||||
});
|
||||
await f.run();
|
||||
assert.equal(f.spawned.length, 1);
|
||||
assert.deepEqual(f.cronWrites, []);
|
||||
});
|
||||
@@ -4,75 +4,6 @@ const load = require('../helpers/load-security-module.cjs');
|
||||
|
||||
const logger = { info() {}, error() {} };
|
||||
|
||||
function cronFixture(killTask) {
|
||||
const events = [];
|
||||
const { runCron } = load('back/shared/runCron.ts', {
|
||||
'cross-spawn': {
|
||||
spawn: () => {
|
||||
events.push('spawn');
|
||||
return {};
|
||||
},
|
||||
},
|
||||
'./childProcess': {
|
||||
observeChildProcess: () => ({ completed: Promise.resolve({ code: 0 }) }),
|
||||
asError: (e) => e,
|
||||
},
|
||||
'./pLimit': {
|
||||
runWithCronLimit: async (_, fn) => fn(),
|
||||
removeQueuedCron: () => events.push('release'),
|
||||
},
|
||||
'../loaders/logger': logger,
|
||||
'../config/util': { killTask },
|
||||
'../data/cron': {
|
||||
CrontabModel: {
|
||||
findOne: async () => ({
|
||||
pid: 100,
|
||||
status: 0,
|
||||
allow_multiple_instances: 0,
|
||||
}),
|
||||
update: async () => events.push('idle'),
|
||||
},
|
||||
CrontabStatus: { running: 0, idle: 1, queued: 3 },
|
||||
},
|
||||
'../data/runningInstance': {
|
||||
RunningInstanceModel: { update: async () => events.push('stopped') },
|
||||
InstanceStatus: { running: 0, stopped: 2 },
|
||||
},
|
||||
});
|
||||
return { run: () => runCron('ignored', { id: '1' }), events };
|
||||
}
|
||||
|
||||
test('single-instance replacement does not spawn or finalize when termination fails', async () => {
|
||||
const f = cronFixture(async () => {
|
||||
throw Error('EPERM');
|
||||
});
|
||||
await f.run();
|
||||
assert.deepEqual(f.events, ['release']);
|
||||
});
|
||||
|
||||
test('single-instance replacement waits for verified exit before spawning', async () => {
|
||||
let finish, entered, verified;
|
||||
const pending = new Promise((resolve) => {
|
||||
entered = resolve;
|
||||
});
|
||||
const gate = new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const f = cronFixture(async (pid, wait) => {
|
||||
assert.equal(pid, 100);
|
||||
verified = wait;
|
||||
entered();
|
||||
await gate;
|
||||
});
|
||||
const running = f.run();
|
||||
await pending;
|
||||
assert.deepEqual(f.events, []);
|
||||
finish();
|
||||
await running;
|
||||
assert.equal(verified, true);
|
||||
assert.deepEqual(f.events, ['stopped', 'idle', 'spawn', 'release']);
|
||||
});
|
||||
|
||||
function subscriptionFixture(killTask) {
|
||||
const updates = [];
|
||||
const Service = load('back/services/subscription.ts', {
|
||||
|
||||
Reference in New Issue
Block a user