mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-03 16:19:11 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b95d6583ca | ||
|
|
27a9c35809 | ||
|
|
af93fb6cf0 | ||
|
|
2218c2c175 | ||
|
|
32c20ba556 | ||
|
|
b92621bdb8 | ||
|
|
da9c6a6bbb | ||
|
|
d895fbba36 | ||
|
|
6862ad4f6c | ||
|
|
b8110c52eb |
@@ -247,7 +247,7 @@ jobs:
|
|||||||
version: "8.3.1"
|
version: "8.3.1"
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "24"
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
cache-dependency-path: pnpm-lock.yaml
|
cache-dependency-path: pnpm-lock.yaml
|
||||||
|
|
||||||
@@ -336,7 +336,7 @@ jobs:
|
|||||||
version: "8.3.1"
|
version: "8.3.1"
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "24"
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
cache-dependency-path: pnpm-lock.yaml
|
cache-dependency-path: pnpm-lock.yaml
|
||||||
|
|
||||||
@@ -394,7 +394,7 @@ jobs:
|
|||||||
SOURCE_COMMIT=${{ github.sha }}
|
SOURCE_COMMIT=${{ github.sha }}
|
||||||
network: host
|
network: host
|
||||||
# Keep s390x on Debian: npm can hang under QEMU with Alpine (nodejs/docker-node#1973).
|
# 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: .
|
context: .
|
||||||
file: ./docker/Dockerfile.debian
|
file: ./docker/Dockerfile.debian
|
||||||
push: true
|
push: true
|
||||||
@@ -423,7 +423,7 @@ jobs:
|
|||||||
version: "8.3.1"
|
version: "8.3.1"
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "24"
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
cache-dependency-path: pnpm-lock.yaml
|
cache-dependency-path: pnpm-lock.yaml
|
||||||
|
|
||||||
@@ -495,7 +495,7 @@ jobs:
|
|||||||
version: "8.3.1"
|
version: "8.3.1"
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "24"
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
cache-dependency-path: pnpm-lock.yaml
|
cache-dependency-path: pnpm-lock.yaml
|
||||||
|
|
||||||
@@ -538,7 +538,7 @@ jobs:
|
|||||||
SOURCE_COMMIT=${{ github.sha }}
|
SOURCE_COMMIT=${{ github.sha }}
|
||||||
network: host
|
network: host
|
||||||
# Keep s390x on Debian: npm can hang under QEMU with Alpine (nodejs/docker-node#1973).
|
# 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: .
|
context: .
|
||||||
file: ./docker/Dockerfile.debian310
|
file: ./docker/Dockerfile.debian310
|
||||||
push: true
|
push: true
|
||||||
@@ -662,7 +662,8 @@ jobs:
|
|||||||
# Both channels publish packages from this run's immutable image digests.
|
# 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') }}
|
if: ${{ !inputs.static_sync_only && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/develop') }}
|
||||||
needs: [build-alpine, build-debian]
|
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
|
timeout-minutes: 90
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -679,13 +680,13 @@ jobs:
|
|||||||
- { platform: linux/s390x, libc: musl, name: s390x-musl }
|
- { platform: linux/s390x, libc: musl, name: s390x-musl }
|
||||||
- { platform: linux/386, libc: musl, name: ia32-musl }
|
- { platform: linux/386, libc: musl, name: ia32-musl }
|
||||||
- { platform: linux/amd64, libc: glibc, name: x64-glibc }
|
- { 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/arm64, libc: glibc, name: arm64-glibc }
|
||||||
- { platform: linux/ppc64le, libc: glibc, name: ppc64-glibc }
|
- { platform: linux/ppc64le, libc: glibc, name: ppc64-glibc }
|
||||||
- { platform: linux/s390x, libc: glibc, name: s390x-glibc }
|
- { platform: linux/s390x, libc: glibc, name: s390x-glibc }
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
- uses: docker/setup-qemu-action@v4
|
- uses: docker/setup-qemu-action@v4
|
||||||
|
if: ${{ matrix.platform != 'linux/arm64' }}
|
||||||
with:
|
with:
|
||||||
cache-image: false
|
cache-image: false
|
||||||
- uses: docker/setup-buildx-action@v4
|
- uses: docker/setup-buildx-action@v4
|
||||||
@@ -721,7 +722,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '24'
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: pnpm/action-setup@v6
|
||||||
with:
|
with:
|
||||||
version: '8.3.1'
|
version: '8.3.1'
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ on:
|
|||||||
- '.github/workflows/release-readiness.yml'
|
- '.github/workflows/release-readiness.yml'
|
||||||
- 'package.json'
|
- 'package.json'
|
||||||
- 'pnpm-lock.yaml'
|
- 'pnpm-lock.yaml'
|
||||||
|
- 'docker/Dockerfile*'
|
||||||
|
- 'shell/node-runtime-probe.cjs'
|
||||||
|
- 'scripts/verify-panel-package.cjs'
|
||||||
|
- 'scripts/verify-image-node-layers.py'
|
||||||
|
- 'test/back/node-runtime-probe.test.cjs'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -41,6 +46,13 @@ jobs:
|
|||||||
path: static/
|
path: static/
|
||||||
include-hidden-files: true
|
include-hidden-files: true
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
- name: Pack the verified npm archive for isolated runtime validation
|
||||||
|
run: node scripts/verify-panel-package.cjs pack "$RUNNER_TEMP/npm-panel"
|
||||||
|
- uses: actions/upload-artifact@v6
|
||||||
|
with:
|
||||||
|
name: release-readiness-npm-panel
|
||||||
|
path: ${{ runner.temp }}/npm-panel/
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
images:
|
images:
|
||||||
needs: [regression, static]
|
needs: [regression, static]
|
||||||
@@ -57,7 +69,7 @@ jobs:
|
|||||||
- image: debian
|
- image: debian
|
||||||
file: docker/Dockerfile.debian
|
file: docker/Dockerfile.debian
|
||||||
cache: 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
|
- image: alpine-python3.10
|
||||||
file: docker/Dockerfile.310
|
file: docker/Dockerfile.310
|
||||||
cache: alpine-python3.10
|
cache: alpine-python3.10
|
||||||
@@ -65,7 +77,7 @@ jobs:
|
|||||||
- image: debian-python3.10
|
- image: debian-python3.10
|
||||||
file: docker/Dockerfile.debian310
|
file: docker/Dockerfile.debian310
|
||||||
cache: debian-python3.10
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
@@ -121,16 +133,47 @@ jobs:
|
|||||||
docker run --rm --network none --entrypoint node \
|
docker run --rm --network none --entrypoint node \
|
||||||
qinglong-readiness:${{ matrix.image }} \
|
qinglong-readiness:${{ matrix.image }} \
|
||||||
-e 'const assert=require("node:assert/strict"); const {createRequire}=require("node:module"); for(const [name,want] of Object.entries({"@grpc/grpc-js":"1.14.5",compression:"1.8.2",multer:"2.4.0",sequelize:"6.37.8"}))assert.equal(require(name+"/package.json").version,want); const expressRequire=createRequire(require.resolve("express")); assert.equal(expressRequire("proxy-addr/package.json").version,"2.0.8"); const sockRequire=createRequire(require.resolve("sockjs")); assert.equal(sockRequire("websocket-driver/package.json").version,"0.7.5"); require("sqlite3"); require("nodemailer"); require("undici"); console.log("Minimal production patches and runtime imports verified")'
|
-e 'const assert=require("node:assert/strict"); const {createRequire}=require("node:module"); for(const [name,want] of Object.entries({"@grpc/grpc-js":"1.14.5",compression:"1.8.2",multer:"2.4.0",sequelize:"6.37.8"}))assert.equal(require(name+"/package.json").version,want); const expressRequire=createRequire(require.resolve("express")); assert.equal(expressRequire("proxy-addr/package.json").version,"2.0.8"); const sockRequire=createRequire(require.resolve("sockjs")); assert.equal(sockRequire("websocket-driver/package.json").version,"0.7.5"); require("sqlite3"); require("nodemailer"); require("undici"); console.log("Minimal production patches and runtime imports verified")'
|
||||||
- name: Export native Debian image for isolated dev validation
|
- name: Load ARMv6 image for SQLite and Node regression checks
|
||||||
if: matrix.image == 'debian'
|
if: startsWith(matrix.image, 'alpine')
|
||||||
run: |
|
uses: docker/build-push-action@v7
|
||||||
docker save qinglong-readiness:debian | gzip -1 > release-readiness-debian-image.tar.gz
|
|
||||||
sha256sum release-readiness-debian-image.tar.gz > release-readiness-debian-image.sha256
|
|
||||||
docker image inspect qinglong-readiness:debian > release-readiness-debian-image.json
|
|
||||||
- uses: actions/upload-artifact@v6
|
|
||||||
if: matrix.image == 'debian'
|
|
||||||
with:
|
with:
|
||||||
name: release-readiness-debian-image
|
context: .
|
||||||
path: release-readiness-debian-image.*
|
file: ${{ matrix.file }}
|
||||||
|
platforms: linux/arm/v6
|
||||||
|
build-args: |
|
||||||
|
QL_URL=${{ steps.source.outputs.url }}
|
||||||
|
QL_BRANCH=${{ steps.source.outputs.branch }}
|
||||||
|
SOURCE_COMMIT=${{ steps.source.outputs.sha }}
|
||||||
|
tags: qinglong-readiness:${{ matrix.image }}-armv6
|
||||||
|
load: true
|
||||||
|
push: false
|
||||||
|
provenance: false
|
||||||
|
sbom: false
|
||||||
|
- name: Verify ARMv6 SQLite on an ARM1176 CPU without networking
|
||||||
|
if: startsWith(matrix.image, 'alpine')
|
||||||
|
run: |
|
||||||
|
docker run --rm --network none --platform linux/arm/v6 \
|
||||||
|
-e QEMU_CPU=arm1176 --entrypoint node \
|
||||||
|
qinglong-readiness:${{ matrix.image }}-armv6 --jitless \
|
||||||
|
-e 'const assert=require("node:assert/strict"); const sqlite3=require("sqlite3"); const db=new sqlite3.Database(":memory:"); db.get("SELECT 42 AS n",(error,row)=>{assert.ifError(error); assert.equal(row.n,42); db.close()});'
|
||||||
|
- name: Check Node binaries in every serialized image layer
|
||||||
|
if: startsWith(matrix.image, 'alpine')
|
||||||
|
run: |
|
||||||
|
docker save qinglong-readiness:${{ matrix.image }} -o "$RUNNER_TEMP/node-layers-amd64.tar"
|
||||||
|
docker save qinglong-readiness:${{ matrix.image }}-armv6 -o "$RUNNER_TEMP/node-layers-arm.tar"
|
||||||
|
python3 scripts/verify-image-node-layers.py "$RUNNER_TEMP/node-layers-amd64.tar" amd64
|
||||||
|
python3 scripts/verify-image-node-layers.py "$RUNNER_TEMP/node-layers-arm.tar" arm
|
||||||
|
rm "$RUNNER_TEMP/node-layers-amd64.tar" "$RUNNER_TEMP/node-layers-arm.tar"
|
||||||
|
- name: Export native image for isolated dev validation
|
||||||
|
if: matrix.image == 'debian' || matrix.image == 'alpine'
|
||||||
|
run: |
|
||||||
|
docker save qinglong-readiness:${{ matrix.image }} | gzip -1 > release-readiness-${{ matrix.image }}-image.tar.gz
|
||||||
|
sha256sum release-readiness-${{ matrix.image }}-image.tar.gz > release-readiness-${{ matrix.image }}-image.sha256
|
||||||
|
docker image inspect qinglong-readiness:${{ matrix.image }} > release-readiness-${{ matrix.image }}-image.json
|
||||||
|
- uses: actions/upload-artifact@v6
|
||||||
|
if: matrix.image == 'debian' || matrix.image == 'alpine'
|
||||||
|
with:
|
||||||
|
name: release-readiness-${{ matrix.image }}-image
|
||||||
|
path: release-readiness-${{ matrix.image }}-image.*
|
||||||
compression-level: 0
|
compression-level: 0
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|||||||
+85
-28
@@ -4,7 +4,6 @@ import taskLimit from './pLimit';
|
|||||||
import Logger from '../loaders/logger';
|
import Logger from '../loaders/logger';
|
||||||
import { ICron } from '../protos/cron';
|
import { ICron } from '../protos/cron';
|
||||||
import { CrontabModel, CrontabStatus } from '../data/cron';
|
import { CrontabModel, CrontabStatus } from '../data/cron';
|
||||||
import { killTask } from '../config/util';
|
|
||||||
import { RunningInstanceModel, InstanceStatus } from '../data/runningInstance';
|
import { RunningInstanceModel, InstanceStatus } from '../data/runningInstance';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { observeChildProcess, asError } from './childProcess';
|
import { observeChildProcess, asError } from './childProcess';
|
||||||
@@ -17,7 +16,7 @@ export function runCron(
|
|||||||
const execute = async () => {
|
const execute = async () => {
|
||||||
try {
|
try {
|
||||||
if (!isCurrent()) return;
|
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 {
|
try {
|
||||||
const existingCron = await CrontabModel.findOne({
|
const existingCron = await CrontabModel.findOne({
|
||||||
where: { id: Number(cron.id) },
|
where: { id: Number(cron.id) },
|
||||||
@@ -30,35 +29,93 @@ export function runCron(
|
|||||||
const allowSingleInstances =
|
const allowSingleInstances =
|
||||||
existingCron?.allow_multiple_instances === 0;
|
existingCron?.allow_multiple_instances === 0;
|
||||||
|
|
||||||
if (
|
if (allowSingleInstances) {
|
||||||
allowSingleInstances &&
|
// A queued manual run is an active reservation, even if its stored PID
|
||||||
existingCron &&
|
// belongs to a previous execution.
|
||||||
existingCron.pid &&
|
if (
|
||||||
(existingCron.status === CrontabStatus.running ||
|
existingCron.status === CrontabStatus.queued &&
|
||||||
existingCron.status === CrontabStatus.queued)
|
existingCron.pid
|
||||||
) {
|
) {
|
||||||
Logger.info(
|
Logger.info(
|
||||||
t('[schedule][停止已运行任务] 任务ID: %s, PID: %s'),
|
'[schedule][Skip queued single-instance task] Task ID: %s, PID: %s',
|
||||||
cron.id,
|
cron.id,
|
||||||
existingCron.pid,
|
existingCron.pid,
|
||||||
);
|
);
|
||||||
await killTask(existingCron.pid, true);
|
return;
|
||||||
// Mark old running instances as stopped
|
}
|
||||||
const stoppedAt = dayjs().unix();
|
if (
|
||||||
await RunningInstanceModel.update(
|
existingCron.status === CrontabStatus.running &&
|
||||||
{ status: InstanceStatus.stopped, finished_at: stoppedAt },
|
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: {
|
where: {
|
||||||
cron_id: Number(cron.id),
|
cron_id: Number(cron.id),
|
||||||
|
pid: existingCron.pid,
|
||||||
status: InstanceStatus.running,
|
status: InstanceStatus.running,
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
);
|
if (!isCurrent()) return;
|
||||||
// Update the status to idle after killing
|
const [recovered] = await CrontabModel.update(
|
||||||
await CrontabModel.update(
|
{
|
||||||
{ status: CrontabStatus.idle, pid: undefined },
|
status: CrontabStatus.idle,
|
||||||
{ where: { id: Number(cron.id) } },
|
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) {
|
} catch (error) {
|
||||||
Logger.error(
|
Logger.error(
|
||||||
@@ -69,7 +126,7 @@ export function runCron(
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
// In particular, invalidation can happen while stopping a previous run.
|
// Invalidation can happen during the asynchronous database check.
|
||||||
if (!isCurrent()) return;
|
if (!isCurrent()) return;
|
||||||
Logger.info(
|
Logger.info(
|
||||||
t('[schedule][开始执行任务] 参数 %s'),
|
t('[schedule][开始执行任务] 参数 %s'),
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const backend = read(path.resolve(__dirname, '../app.js'));
|
|||||||
const root = process.env.QL_DIR || path.resolve(__dirname, '../../..');
|
const root = process.env.QL_DIR || path.resolve(__dirname, '../../..');
|
||||||
const version = read(path.join(root, 'version.yaml'))
|
const version = read(path.join(root, 'version.yaml'))
|
||||||
?.toString()
|
?.toString()
|
||||||
.match(/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+)/m)?.[1];
|
.match(/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?)/m)?.[1];
|
||||||
|
|
||||||
export const runtimeBuild = Object.freeze({
|
export const runtimeBuild = Object.freeze({
|
||||||
instanceId: randomUUID(),
|
instanceId: randomUUID(),
|
||||||
|
|||||||
@@ -61,7 +61,9 @@ async function withPanelLifecycle(root, tmp, env, operation, signal) {
|
|||||||
|
|
||||||
async function targetVersion(source) {
|
async function targetVersion(source) {
|
||||||
const text = await fs.readFile(path.join(source, 'version.yaml'), 'utf8');
|
const text = await fs.readFile(path.join(source, 'version.yaml'), 'utf8');
|
||||||
const version = text.match(/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+)/m)?.[1];
|
const version = text.match(
|
||||||
|
/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?)/m,
|
||||||
|
)?.[1];
|
||||||
if (!version) throw new Error('Invalid update version');
|
if (!version) throw new Error('Invalid update version');
|
||||||
return version;
|
return version;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,9 @@ async function prestartBackend(
|
|||||||
.digest('hex');
|
.digest('hex');
|
||||||
const version = (
|
const version = (
|
||||||
await fs.readFile(path.join(selected.source, 'version.yaml'), 'utf8')
|
await fs.readFile(path.join(selected.source, 'version.yaml'), 'utf8')
|
||||||
).match(/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+)/m)?.[1];
|
).match(
|
||||||
|
/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?)/m,
|
||||||
|
)?.[1];
|
||||||
// An allowlist prevents NODE_OPTIONS, startup hooks, real data paths and
|
// An allowlist prevents NODE_OPTIONS, startup hooks, real data paths and
|
||||||
// package-manager settings from escaping into the disposable backend.
|
// package-manager settings from escaping into the disposable backend.
|
||||||
const env = {
|
const env = {
|
||||||
|
|||||||
+28
-5
@@ -1,8 +1,9 @@
|
|||||||
# Run Node package installation natively on the builder. Node/npm can spin at
|
# Run Node package installation natively on the builder. Node/npm can spin at
|
||||||
# 100% CPU when Alpine s390x is emulated through QEMU.
|
# 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 TARGETARCH
|
||||||
|
ARG TARGETVARIANT
|
||||||
ENV NPM_CONFIG_PREFIX=/opt/node-global
|
ENV NPM_CONFIG_PREFIX=/opt/node-global
|
||||||
ENV PATH=/opt/node-global/bin:${PATH}
|
ENV PATH=/opt/node-global/bin:${PATH}
|
||||||
|
|
||||||
@@ -17,12 +18,24 @@ RUN set -x \
|
|||||||
ppc64le) NODE_ARCH=ppc64 ;; \
|
ppc64le) NODE_ARCH=ppc64 ;; \
|
||||||
*) NODE_ARCH="${TARGETARCH}" ;; \
|
*) NODE_ARCH="${TARGETARCH}" ;; \
|
||||||
esac \
|
esac \
|
||||||
|
&& case "${TARGETARCH}/${TARGETVARIANT:-}" in \
|
||||||
|
arm/v6) NODE_LIBC=musl-armv6 ;; \
|
||||||
|
arm/v7) NODE_LIBC=musl-armv7 ;; \
|
||||||
|
arm/*) echo "Unsupported ARM variant: ${TARGETVARIANT:-unknown}" >&2; exit 1 ;; \
|
||||||
|
*) NODE_LIBC=musl ;; \
|
||||||
|
esac \
|
||||||
&& npm_config_target_platform=linux \
|
&& npm_config_target_platform=linux \
|
||||||
npm_config_target_arch="${NODE_ARCH}" \
|
npm_config_target_arch="${NODE_ARCH}" \
|
||||||
npm_config_target_libc=musl \
|
npm_config_target_libc="${NODE_LIBC}" \
|
||||||
pnpm install --prod --frozen-lockfile
|
pnpm install --prod --frozen-lockfile --side-effects-cache=false
|
||||||
|
|
||||||
FROM python:3.11-alpine
|
# 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-alpine3.24
|
||||||
|
|
||||||
|
ARG TARGETARCH
|
||||||
|
ARG TARGETVARIANT
|
||||||
|
|
||||||
ARG QL_MAINTAINER="whyour"
|
ARG QL_MAINTAINER="whyour"
|
||||||
LABEL maintainer="${QL_MAINTAINER}"
|
LABEL maintainer="${QL_MAINTAINER}"
|
||||||
@@ -44,7 +57,8 @@ EXPOSE 5700
|
|||||||
COPY --from=builder /opt/node-global/lib/node_modules/. /usr/local/lib/node_modules/
|
COPY --from=builder /opt/node-global/lib/node_modules/. /usr/local/lib/node_modules/
|
||||||
COPY --from=builder /opt/node-global/bin/. /usr/local/bin/
|
COPY --from=builder /opt/node-global/bin/. /usr/local/bin/
|
||||||
|
|
||||||
RUN set -x \
|
RUN --mount=type=bind,from=node-runtime-amd64,source=/usr/local/bin/node,target=/tmp/node-runtime-amd64 \
|
||||||
|
set -x \
|
||||||
&& apk update -f \
|
&& apk update -f \
|
||||||
&& apk upgrade \
|
&& apk upgrade \
|
||||||
&& apk --no-cache add -f bash \
|
&& apk --no-cache add -f bash \
|
||||||
@@ -62,6 +76,11 @@ RUN set -x \
|
|||||||
netcat-openbsd \
|
netcat-openbsd \
|
||||||
unzip \
|
unzip \
|
||||||
npm \
|
npm \
|
||||||
|
&& if [ "$TARGETARCH" = amd64 ]; then \
|
||||||
|
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/* \
|
&& rm -rf /var/cache/apk/* \
|
||||||
&& apk update \
|
&& apk update \
|
||||||
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||||
@@ -86,6 +105,10 @@ COPY static/ /ql/static/
|
|||||||
COPY docker/verify-build.cjs docker/build-manifest.cjs /tmp/
|
COPY docker/verify-build.cjs docker/build-manifest.cjs /tmp/
|
||||||
COPY --from=builder /tmp/build/pnpm-lock.yaml /tmp/dependency-lock.yaml
|
COPY --from=builder /tmp/build/pnpm-lock.yaml /tmp/dependency-lock.yaml
|
||||||
RUN cd ${QL_DIR} && node /tmp/verify-build.cjs /tmp/dependency-lock.yaml
|
RUN cd ${QL_DIR} && node /tmp/verify-build.cjs /tmp/dependency-lock.yaml
|
||||||
|
RUN if [ "$TARGETARCH" = arm ] && [ "${TARGETVARIANT:-}" = v6 ]; then \
|
||||||
|
QEMU_CPU=arm1176 node --jitless ${QL_DIR}/shell/node-runtime-probe.cjs; \
|
||||||
|
else node ${QL_DIR}/shell/node-runtime-probe.cjs; fi
|
||||||
|
|
||||||
|
|
||||||
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
|
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
|
||||||
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
|
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
|
||||||
|
|||||||
+28
-5
@@ -1,8 +1,9 @@
|
|||||||
# Run Node package installation natively on the builder. Node/npm can spin at
|
# Run Node package installation natively on the builder. Node/npm can spin at
|
||||||
# 100% CPU when Alpine s390x is emulated through QEMU.
|
# 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 TARGETARCH
|
||||||
|
ARG TARGETVARIANT
|
||||||
ENV NPM_CONFIG_PREFIX=/opt/node-global
|
ENV NPM_CONFIG_PREFIX=/opt/node-global
|
||||||
ENV PATH=/opt/node-global/bin:${PATH}
|
ENV PATH=/opt/node-global/bin:${PATH}
|
||||||
|
|
||||||
@@ -17,12 +18,24 @@ RUN set -x \
|
|||||||
ppc64le) NODE_ARCH=ppc64 ;; \
|
ppc64le) NODE_ARCH=ppc64 ;; \
|
||||||
*) NODE_ARCH="${TARGETARCH}" ;; \
|
*) NODE_ARCH="${TARGETARCH}" ;; \
|
||||||
esac \
|
esac \
|
||||||
|
&& case "${TARGETARCH}/${TARGETVARIANT:-}" in \
|
||||||
|
arm/v6) NODE_LIBC=musl-armv6 ;; \
|
||||||
|
arm/v7) NODE_LIBC=musl-armv7 ;; \
|
||||||
|
arm/*) echo "Unsupported ARM variant: ${TARGETVARIANT:-unknown}" >&2; exit 1 ;; \
|
||||||
|
*) NODE_LIBC=musl ;; \
|
||||||
|
esac \
|
||||||
&& npm_config_target_platform=linux \
|
&& npm_config_target_platform=linux \
|
||||||
npm_config_target_arch="${NODE_ARCH}" \
|
npm_config_target_arch="${NODE_ARCH}" \
|
||||||
npm_config_target_libc=musl \
|
npm_config_target_libc="${NODE_LIBC}" \
|
||||||
pnpm install --prod --frozen-lockfile
|
pnpm install --prod --frozen-lockfile --side-effects-cache=false
|
||||||
|
|
||||||
FROM python:3.10-alpine
|
# 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-alpine3.24
|
||||||
|
|
||||||
|
ARG TARGETARCH
|
||||||
|
ARG TARGETVARIANT
|
||||||
|
|
||||||
ARG QL_MAINTAINER="whyour"
|
ARG QL_MAINTAINER="whyour"
|
||||||
LABEL maintainer="${QL_MAINTAINER}"
|
LABEL maintainer="${QL_MAINTAINER}"
|
||||||
@@ -44,7 +57,8 @@ EXPOSE 5700
|
|||||||
COPY --from=builder /opt/node-global/lib/node_modules/. /usr/local/lib/node_modules/
|
COPY --from=builder /opt/node-global/lib/node_modules/. /usr/local/lib/node_modules/
|
||||||
COPY --from=builder /opt/node-global/bin/. /usr/local/bin/
|
COPY --from=builder /opt/node-global/bin/. /usr/local/bin/
|
||||||
|
|
||||||
RUN set -x \
|
RUN --mount=type=bind,from=node-runtime-amd64,source=/usr/local/bin/node,target=/tmp/node-runtime-amd64 \
|
||||||
|
set -x \
|
||||||
&& apk update -f \
|
&& apk update -f \
|
||||||
&& apk upgrade \
|
&& apk upgrade \
|
||||||
&& apk --no-cache add -f bash \
|
&& apk --no-cache add -f bash \
|
||||||
@@ -62,6 +76,11 @@ RUN set -x \
|
|||||||
netcat-openbsd \
|
netcat-openbsd \
|
||||||
unzip \
|
unzip \
|
||||||
npm \
|
npm \
|
||||||
|
&& if [ "$TARGETARCH" = amd64 ]; then \
|
||||||
|
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/* \
|
&& rm -rf /var/cache/apk/* \
|
||||||
&& apk update \
|
&& apk update \
|
||||||
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||||
@@ -86,6 +105,10 @@ COPY static/ /ql/static/
|
|||||||
COPY docker/verify-build.cjs docker/build-manifest.cjs /tmp/
|
COPY docker/verify-build.cjs docker/build-manifest.cjs /tmp/
|
||||||
COPY --from=builder /tmp/build/pnpm-lock.yaml /tmp/dependency-lock.yaml
|
COPY --from=builder /tmp/build/pnpm-lock.yaml /tmp/dependency-lock.yaml
|
||||||
RUN cd ${QL_DIR} && node /tmp/verify-build.cjs /tmp/dependency-lock.yaml
|
RUN cd ${QL_DIR} && node /tmp/verify-build.cjs /tmp/dependency-lock.yaml
|
||||||
|
RUN if [ "$TARGETARCH" = arm ] && [ "${TARGETVARIANT:-}" = v6 ]; then \
|
||||||
|
QEMU_CPU=arm1176 node --jitless ${QL_DIR}/shell/node-runtime-probe.cjs; \
|
||||||
|
else node ${QL_DIR}/shell/node-runtime-probe.cjs; fi
|
||||||
|
|
||||||
|
|
||||||
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
|
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
|
||||||
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
|
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
# Node 20 Bookworm is the latest official Node image variant that covers the
|
# Download and verify the target Node archive without running under QEMU.
|
||||||
# full Debian build matrix, including arm/v7, ppc64le, and s390x.
|
FROM --platform=$BUILDPLATFORM debian:bookworm-slim AS nodebuilder
|
||||||
FROM node:20-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
|
FROM python:3.11.14-slim-bookworm AS builder
|
||||||
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
|
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
|
||||||
@@ -11,6 +15,7 @@ RUN set -x && \
|
|||||||
apt-get update && \
|
apt-get update && \
|
||||||
apt-get install --no-install-recommends -y libatomic1 && \
|
apt-get install --no-install-recommends -y libatomic1 && \
|
||||||
npm i -g pnpm@8.3.1 && \
|
npm i -g pnpm@8.3.1 && \
|
||||||
|
test "$(node --version)" = "v24.18.1" && \
|
||||||
test "$(pnpm --version)" = "8.3.1" && \
|
test "$(pnpm --version)" = "8.3.1" && \
|
||||||
cd /tmp/build && \
|
cd /tmp/build && \
|
||||||
pnpm install --prod --frozen-lockfile
|
pnpm install --prod --frozen-lockfile
|
||||||
@@ -71,6 +76,7 @@ RUN set -x && \
|
|||||||
git config --global user.name "qinglong" && \
|
git config --global user.name "qinglong" && \
|
||||||
git config --global http.postBuffer 524288000 && \
|
git config --global http.postBuffer 524288000 && \
|
||||||
npm install -g pnpm@8.3.1 pm2 ts-node typescript@5 && \
|
npm install -g pnpm@8.3.1 pm2 ts-node typescript@5 && \
|
||||||
|
test "$(node --version)" = "v24.18.1" && \
|
||||||
test "$(pnpm --version)" = "8.3.1" && \
|
test "$(pnpm --version)" = "8.3.1" && \
|
||||||
npm cache clean --force && \
|
npm cache clean --force && \
|
||||||
rm -rf /root/.cache && \
|
rm -rf /root/.cache && \
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
# Node 20 Bookworm is the latest official Node image variant that covers the
|
# Download and verify the target Node archive without running under QEMU.
|
||||||
# full Debian build matrix, including arm/v7, ppc64le, and s390x.
|
FROM --platform=$BUILDPLATFORM debian:bookworm-slim AS nodebuilder
|
||||||
FROM node:20-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.
|
# The floating 3.10-slim-bookworm tag no longer includes s390x.
|
||||||
FROM python:3.10.19-slim-bookworm@sha256:23f63358922e79a794f71be8f3723c84e5ccca9638af3f74456dc73d6184499e AS builder
|
FROM python:3.10.19-slim-bookworm@sha256:23f63358922e79a794f71be8f3723c84e5ccca9638af3f74456dc73d6184499e AS builder
|
||||||
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
|
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
|
||||||
@@ -13,6 +17,7 @@ RUN set -x && \
|
|||||||
apt-get update && \
|
apt-get update && \
|
||||||
apt-get install --no-install-recommends -y libatomic1 && \
|
apt-get install --no-install-recommends -y libatomic1 && \
|
||||||
npm i -g pnpm@8.3.1 && \
|
npm i -g pnpm@8.3.1 && \
|
||||||
|
test "$(node --version)" = "v24.18.1" && \
|
||||||
cd /tmp/build && \
|
cd /tmp/build && \
|
||||||
pnpm install --prod --frozen-lockfile
|
pnpm install --prod --frozen-lockfile
|
||||||
|
|
||||||
@@ -71,6 +76,7 @@ RUN set -x && \
|
|||||||
git config --global user.name "qinglong" && \
|
git config --global user.name "qinglong" && \
|
||||||
git config --global http.postBuffer 524288000 && \
|
git config --global http.postBuffer 524288000 && \
|
||||||
npm install -g pnpm@8.3.1 pm2 ts-node typescript@5 && \
|
npm install -g pnpm@8.3.1 pm2 ts-node typescript@5 && \
|
||||||
|
test "$(node --version)" = "v24.18.1" && \
|
||||||
npm cache clean --force && \
|
npm cache clean --force && \
|
||||||
rm -rf /root/.cache && \
|
rm -rf /root/.cache && \
|
||||||
rm -rf /root/.npm && \
|
rm -rf /root/.npm && \
|
||||||
|
|||||||
@@ -6,25 +6,11 @@ ENV QL_BUNDLE_BASE_IMAGE=${BASE_IMAGE}
|
|||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
ARG TARGETVARIANT
|
ARG TARGETVARIANT
|
||||||
USER root
|
USER root
|
||||||
# The generic "arm" sqlite release is ARMv7. ARMv6 must compile its own addon.
|
RUN ulimit -c 0; \
|
||||||
RUN ulimit -c 0; if [ "$TARGETARCH/$TARGETVARIANT" = arm/v6 ]; then \
|
if [ "$TARGETARCH/$TARGETVARIANT" = arm/v6 ]; then \
|
||||||
apk add --no-cache --virtual .ql-bundle-build build-base binutils python3 nodejs-dev && \
|
|
||||||
cd "$(readlink -f /ql/node_modules/sqlite3)" && \
|
|
||||||
CFLAGS='-march=armv6 -mfpu=vfp -mfloat-abi=hard' \
|
|
||||||
CXXFLAGS='-march=armv6 -mfpu=vfp -mfloat-abi=hard' \
|
|
||||||
npm_config_nodedir=/usr npm_config_build_from_source=true QEMU_CPU=arm1176 \
|
|
||||||
node -e 'require("node:child_process").execFileSync(process.execPath,[require.resolve("@mapbox/node-pre-gyp/bin/node-pre-gyp"),"rebuild","--napi_build_version=6"],{stdio:"inherit"})' && \
|
|
||||||
readelf -A lib/binding/napi-v6-linux-arm/node_sqlite3.node | grep -E 'Tag_CPU_arch: v6($|K)' && \
|
|
||||||
rm -rf build && \
|
|
||||||
apk del .ql-bundle-build; \
|
|
||||||
fi
|
|
||||||
RUN ulimit -c 0; if [ "$TARGETARCH/$TARGETVARIANT" = arm/v6 ]; then \
|
|
||||||
export QEMU_CPU=arm1176; \
|
export QEMU_CPU=arm1176; \
|
||||||
node --jitless /ql/scripts/build-upgrade-bundle.cjs /ql /out && \
|
set -- --jitless; \
|
||||||
for archive in /out/*.tar.gz; do \
|
fi; \
|
||||||
node /ql/shell/install-bundle.cjs --root /tmp/ql-bundle-offline-smoke --file "$archive" --install && \
|
node "$@" /ql/scripts/build-upgrade-bundle.cjs /ql /out
|
||||||
rm -rf /tmp/ql-bundle-offline-smoke || exit 1; \
|
|
||||||
done; \
|
|
||||||
else node /ql/scripts/build-upgrade-bundle.cjs /ql /out; fi
|
|
||||||
FROM scratch
|
FROM scratch
|
||||||
COPY --from=bundle /out/ /
|
COPY --from=bundle /out/ /
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -14,11 +14,11 @@ CI 从同次构建的不可变镜像摘要导出完整 tar.gz,包含源码中
|
|||||||
gh workflow run build-docker-image.yml --ref develop
|
gh workflow run build-docker-image.yml --ref develop
|
||||||
```
|
```
|
||||||
|
|
||||||
开发包更新共用的 `upgrade-develop/latest.json`。`develop` 和 `debian-dev` 镜像都使用该索引,按实际 Node 架构和 musl/glibc 选择包,无需设置测试专用下载源。开发批次位于 `upgrade-build-develop-v<version>-<run-id>-<run-attempt>`,不会改写正式版本或 `upgrade-master`。同一开发渠道的镜像构建和包发布串行执行,与 master 独立。
|
开发包只使用 `upgrade-develop` 这一个 prerelease。`develop` 和 `debian-dev` 镜像读取其中的 `latest.json`,按实际 Node 架构和 musl/glibc 选择包,无需设置测试专用下载源。新批次先以带构建 ID 的文件名上传并校验,再切换 `latest.json`;索引仍保留开发版本供客户端判断更新。开发渠道不会改写正式版本或 `upgrade-master`。同一开发渠道的镜像构建和包发布串行执行,与 master 独立。
|
||||||
|
|
||||||
开发包只保留最新一个已验证批次:所有平台资产和渠道索引的大小、SHA256 校验成功后,删除其他名称匹配的开发构建 prerelease;不删除正式包、渠道索引或无法分类的旧 release。构建或上传失败不会删除上一套可用包。旧批次被删除后,已选中旧索引但尚未下载完的客户端可能需要重新发起下载;准备失败仍保留旧服务和数据。
|
开发包只保留最新一个已验证批次:所有平台资产和渠道索引的大小、SHA256 校验成功后,清理 `upgrade-develop` 中旧批次资产及旧命名的开发构建 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 与完整包被并发重发交错覆盖;旧工作流明确重跑时也允许同版本、同提交重新发布。过期源码提交不能推进对应渠道或版本索引;平台包上传失败时不切换索引。
|
版本入口为 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 证书准备,避免慢设备生成密钥耗尽工作进程的就绪期限;已有证书继续复用。服务启动和系统自启按原手动安装流程配置。
|
`--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 面板包
|
## npm 面板包
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ master 发布任务等待上述测试通过,直接发布已验证的 `.tgz`,
|
|||||||
- 最低 Node 为 20.18.1,Node-API 至少 6;安装器只检查,不替换 Node。
|
- 最低 Node 为 20.18.1,Node-API 至少 6;安装器只检查,不替换 Node。
|
||||||
- 以 Node 实际运行架构、ARM 指令集和 libc 类型/版本选择包;不依据 Docker 架构别名猜测。
|
- 以 Node 实际运行架构、ARM 指令集和 libc 类型/版本选择包;不依据 Docker 架构别名猜测。
|
||||||
- glibc、musl 分包,并记录构建系统 libc 下限。系统库仍以目标设备加载测试作为最后检查。
|
- 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 头文件与实际运行时编译配置;超时、中断会终止整个进程组并清理临时目录。
|
- 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 的包内符号链接;拒绝绝对路径、越界路径、包外链接、硬链接、设备条目和经符号链接写入的条目。重启前再次校验依赖,暂存失败保留上一次可用的就绪包。
|
- tar 中允许 pnpm 的包内符号链接;拒绝绝对路径、越界路径、包外链接、硬链接、设备条目和经符号链接写入的条目。重启前再次校验依赖,暂存失败保留上一次可用的就绪包。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# 青龙 2.24.0-dev.0 开发候选
|
||||||
|
|
||||||
|
这是 `develop` 渠道的开发构建,上一正式版本为 2.23.0。此版本尚未正式发布。
|
||||||
|
|
||||||
|
## 本轮变化
|
||||||
|
|
||||||
|
- Alpine 与 Debian 镜像统一使用 Node.js 24;SQLite 依赖升级至 1.1.4。
|
||||||
|
- Alpine 镜像保留 arm/v6 和 arm/v7,Debian 镜像暂不提供 arm/v7。
|
||||||
|
- ARM64 升级包改用原生 runner 构建,避免在 x64 QEMU 下归档时发生异常。
|
||||||
|
|
||||||
|
升级前请备份完整持久化数据,并在目标设备验证脚本、依赖和通知。正式发布说明将在候选验收后整理。
|
||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@whyour/qinglong",
|
"name": "@whyour/qinglong",
|
||||||
"packageManager": "pnpm@8.3.1",
|
"packageManager": "pnpm@8.3.1",
|
||||||
"version": "2.23.0",
|
"version": "2.24.0-dev.0",
|
||||||
"description": "Timed task management platform supporting Python3, JavaScript, Shell, Typescript",
|
"description": "Timed task management platform supporting Python3, JavaScript, Shell, Typescript",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"sqlite3": "npm:@whyour/sqlite3@1.1.2",
|
"sqlite3": "npm:@whyour/sqlite3@1.1.4",
|
||||||
"@codemirror/state": "6.5.4",
|
"@codemirror/state": "6.5.4",
|
||||||
"@codemirror/view": "6.39.16",
|
"@codemirror/view": "6.39.16",
|
||||||
"proxy-addr": "2.0.8",
|
"proxy-addr": "2.0.8",
|
||||||
@@ -159,7 +159,7 @@
|
|||||||
"request-ip": "3.3.0",
|
"request-ip": "3.3.0",
|
||||||
"sequelize": "6.37.8",
|
"sequelize": "6.37.8",
|
||||||
"sockjs": "^0.3.24",
|
"sockjs": "^0.3.24",
|
||||||
"sqlite3": "npm:@whyour/sqlite3@1.1.2",
|
"sqlite3": "npm:@whyour/sqlite3@1.1.4",
|
||||||
"toad-scheduler": "^3.0.1",
|
"toad-scheduler": "^3.0.1",
|
||||||
"typedi": "^0.10.0",
|
"typedi": "^0.10.0",
|
||||||
"undici": "^7.9.0",
|
"undici": "^7.9.0",
|
||||||
|
|||||||
Generated
+10
-10
@@ -1,7 +1,7 @@
|
|||||||
lockfileVersion: '6.0'
|
lockfileVersion: '6.0'
|
||||||
|
|
||||||
overrides:
|
overrides:
|
||||||
sqlite3: npm:@whyour/sqlite3@1.1.2
|
sqlite3: npm:@whyour/sqlite3@1.1.4
|
||||||
'@codemirror/state': 6.5.4
|
'@codemirror/state': 6.5.4
|
||||||
'@codemirror/view': 6.39.16
|
'@codemirror/view': 6.39.16
|
||||||
proxy-addr: 2.0.8
|
proxy-addr: 2.0.8
|
||||||
@@ -120,13 +120,13 @@ dependencies:
|
|||||||
version: 3.3.0
|
version: 3.3.0
|
||||||
sequelize:
|
sequelize:
|
||||||
specifier: 6.37.8
|
specifier: 6.37.8
|
||||||
version: 6.37.8(@whyour/sqlite3@1.1.2)
|
version: 6.37.8(@whyour/sqlite3@1.1.4)
|
||||||
sockjs:
|
sockjs:
|
||||||
specifier: ^0.3.24
|
specifier: ^0.3.24
|
||||||
version: 0.3.24
|
version: 0.3.24
|
||||||
sqlite3:
|
sqlite3:
|
||||||
specifier: npm:@whyour/sqlite3@1.1.2
|
specifier: npm:@whyour/sqlite3@1.1.4
|
||||||
version: /@whyour/sqlite3@1.1.2
|
version: /@whyour/sqlite3@1.1.4
|
||||||
tar:
|
tar:
|
||||||
specifier: 7.5.20
|
specifier: 7.5.20
|
||||||
version: 7.5.20
|
version: 7.5.20
|
||||||
@@ -3193,7 +3193,7 @@ packages:
|
|||||||
resolution: {integrity: sha512-Ngs9jhElXN7efS9WvvCC/p6rXMbihna8eNLVBc421Zf+VcFd+pR4DOcS6yA9V22EKtAy4DEj7LtvEEIm0bb80A==}
|
resolution: {integrity: sha512-Ngs9jhElXN7efS9WvvCC/p6rXMbihna8eNLVBc421Zf+VcFd+pR4DOcS6yA9V22EKtAy4DEj7LtvEEIm0bb80A==}
|
||||||
engines: {node: '>= 18'}
|
engines: {node: '>= 18'}
|
||||||
dependencies:
|
dependencies:
|
||||||
sqlite3: /@whyour/sqlite3@1.1.2
|
sqlite3: /@whyour/sqlite3@1.1.4
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- encoding
|
- encoding
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -5611,14 +5611,15 @@ packages:
|
|||||||
- supports-color
|
- supports-color
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/@whyour/sqlite3@1.1.2:
|
/@whyour/sqlite3@1.1.4:
|
||||||
resolution: {integrity: sha512-OY24/OJMqLHIGolo4GCFW4lrJKAoOB+ZqERXb7u7DKZQz7QR3f0GALE99BZWQBSZEVVZ/StVeAb2fCkMfTtuDQ==}
|
resolution: {integrity: sha512-MlWzqOdpZ0KPuBx/FYRrHZ8p3S0eCbaf2d8duDzWmfko/aky25Xoss67qDjFgbRjnwj9z0i0KNdNtQI02dnWKA==}
|
||||||
requiresBuild: true
|
requiresBuild: true
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
node-gyp:
|
node-gyp:
|
||||||
optional: true
|
optional: true
|
||||||
dependencies:
|
dependencies:
|
||||||
'@mapbox/node-pre-gyp': 1.0.11
|
'@mapbox/node-pre-gyp': 1.0.11
|
||||||
|
detect-libc: 1.0.3
|
||||||
node-addon-api: 4.3.0
|
node-addon-api: 4.3.0
|
||||||
tar: 6.2.1
|
tar: 6.2.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
@@ -7601,7 +7602,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==}
|
resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==}
|
||||||
engines: {node: '>=0.10'}
|
engines: {node: '>=0.10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
dev: true
|
|
||||||
|
|
||||||
/detect-libc@2.1.2:
|
/detect-libc@2.1.2:
|
||||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
@@ -13903,7 +13903,7 @@ packages:
|
|||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/sequelize@6.37.8(@whyour/sqlite3@1.1.2):
|
/sequelize@6.37.8(@whyour/sqlite3@1.1.4):
|
||||||
resolution: {integrity: sha512-HJ0IQFqcTsTiqbEgiuioYFMSD00TP6Cz7zoTti+zVVBwVe9fEhev9cH6WnM3XU31+ABS356durAb99ZuOthnKw==}
|
resolution: {integrity: sha512-HJ0IQFqcTsTiqbEgiuioYFMSD00TP6Cz7zoTti+zVVBwVe9fEhev9cH6WnM3XU31+ABS356durAb99ZuOthnKw==}
|
||||||
engines: {node: '>=10.0.0'}
|
engines: {node: '>=10.0.0'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -13948,7 +13948,7 @@ packages:
|
|||||||
retry-as-promised: 7.0.4
|
retry-as-promised: 7.0.4
|
||||||
semver: 7.7.4
|
semver: 7.7.4
|
||||||
sequelize-pool: 7.1.0
|
sequelize-pool: 7.1.0
|
||||||
sqlite3: /@whyour/sqlite3@1.1.2
|
sqlite3: /@whyour/sqlite3@1.1.4
|
||||||
toposort-class: 1.0.1
|
toposort-class: 1.0.1
|
||||||
uuid: 8.3.2
|
uuid: 8.3.2
|
||||||
validator: 13.12.0
|
validator: 13.12.0
|
||||||
|
|||||||
+184
-112
@@ -13,17 +13,17 @@ const platforms = [
|
|||||||
'linux-s390x-musl',
|
'linux-s390x-musl',
|
||||||
'linux-ia32-musl',
|
'linux-ia32-musl',
|
||||||
'linux-x64-glibc',
|
'linux-x64-glibc',
|
||||||
'linux-armv7-glibc',
|
|
||||||
'linux-arm64-glibc',
|
'linux-arm64-glibc',
|
||||||
'linux-ppc64-glibc',
|
'linux-ppc64-glibc',
|
||||||
'linux-s390x-glibc',
|
'linux-s390x-glibc',
|
||||||
];
|
];
|
||||||
function releaseIndex(directory, commit, repository, options = {}) {
|
function releaseIndex(directory, commit, repository, options = {}) {
|
||||||
const { version, releaseTag = `upgrade-${commit}` } = options;
|
const { version, releaseTag = `upgrade-${commit}`, assetBuildId } = options;
|
||||||
if (
|
if (
|
||||||
!/^[a-f0-9]{40}$/.test(commit) ||
|
!/^[a-f0-9]{40}$/.test(commit) ||
|
||||||
!/^[\w.-]+\/[\w.-]+$/.test(repository) ||
|
!/^[\w.-]+\/[\w.-]+$/.test(repository) ||
|
||||||
!/^[\w.+-]+$/.test(releaseTag)
|
!/^[\w.+-]+$/.test(releaseTag) ||
|
||||||
|
(assetBuildId && !/^\d+-\d+$/.test(assetBuildId))
|
||||||
)
|
)
|
||||||
throw new Error('Invalid release source');
|
throw new Error('Invalid release source');
|
||||||
const packages = {};
|
const packages = {};
|
||||||
@@ -48,11 +48,45 @@ function releaseIndex(directory, commit, repository, options = {}) {
|
|||||||
sha256: record.sha256,
|
sha256: record.sha256,
|
||||||
url: `https://github.com/${repository}/releases/download/${encodeURIComponent(
|
url: `https://github.com/${repository}/releases/download/${encodeURIComponent(
|
||||||
releaseTag,
|
releaseTag,
|
||||||
)}/${filename}`,
|
)}/${
|
||||||
|
assetBuildId
|
||||||
|
? filename.replace(/\.tar\.gz$/, `-${assetBuildId}.tar.gz`)
|
||||||
|
: filename
|
||||||
|
}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { format: 2, sourceCommit: commit, packages };
|
return { format: 2, sourceCommit: commit, packages };
|
||||||
}
|
}
|
||||||
|
function stageDevelopmentAssets(directory, commit, buildId) {
|
||||||
|
const staged = fs.mkdtempSync(path.join(directory, '.upgrade-develop-'));
|
||||||
|
const assets = [];
|
||||||
|
try {
|
||||||
|
for (const target of platforms) {
|
||||||
|
const filename = `qinglong-${commit}-${target}.tar.gz`;
|
||||||
|
const archiveName = filename.replace(/\.tar\.gz$/, `-${buildId}.tar.gz`);
|
||||||
|
const archive = path.join(staged, archiveName);
|
||||||
|
fs.linkSync(path.join(directory, filename), archive);
|
||||||
|
const record = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(directory, filename + '.json')),
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
archive + '.json',
|
||||||
|
JSON.stringify({
|
||||||
|
...record,
|
||||||
|
filename: archiveName,
|
||||||
|
}) + '\n',
|
||||||
|
);
|
||||||
|
assets.push(archive, archive + '.json');
|
||||||
|
}
|
||||||
|
const installer = path.join(staged, `install-bundle-${buildId}.cjs`);
|
||||||
|
fs.linkSync(path.join(directory, 'install-bundle.cjs'), installer);
|
||||||
|
assets.push(installer);
|
||||||
|
return { staged, assets };
|
||||||
|
} catch (error) {
|
||||||
|
fs.rmSync(staged, { recursive: true, force: true });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
function publishBundles({
|
function publishBundles({
|
||||||
directory,
|
directory,
|
||||||
commit,
|
commit,
|
||||||
@@ -76,13 +110,14 @@ function publishBundles({
|
|||||||
// unchanged. Only the version/channel pointer is overwritten, never bytes
|
// unchanged. Only the version/channel pointer is overwritten, never bytes
|
||||||
// that an in-flight download has already selected by hash.
|
// that an in-flight download has already selected by hash.
|
||||||
const development = branch === 'develop';
|
const development = branch === 'develop';
|
||||||
const tag = `upgrade-build-${
|
const tag = development
|
||||||
development ? 'develop-' : ''
|
? 'upgrade-develop'
|
||||||
}v${version}-${buildId}`;
|
: `upgrade-build-v${version}-${buildId}`;
|
||||||
const index = {
|
const index = {
|
||||||
...releaseIndex(directory, commit, repository, {
|
...releaseIndex(directory, commit, repository, {
|
||||||
version,
|
version,
|
||||||
releaseTag: tag,
|
releaseTag: tag,
|
||||||
|
assetBuildId: development ? buildId : undefined,
|
||||||
}),
|
}),
|
||||||
version,
|
version,
|
||||||
buildId,
|
buildId,
|
||||||
@@ -94,7 +129,7 @@ function publishBundles({
|
|||||||
sha256: sha256(installer),
|
sha256: sha256(installer),
|
||||||
url: `https://github.com/${repository}/releases/download/${encodeURIComponent(
|
url: `https://github.com/${repository}/releases/download/${encodeURIComponent(
|
||||||
tag,
|
tag,
|
||||||
)}/install-bundle.cjs`,
|
)}/${development ? `install-bundle-${buildId}.cjs` : 'install-bundle.cjs'}`,
|
||||||
};
|
};
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(directory, 'latest.json'),
|
path.join(directory, 'latest.json'),
|
||||||
@@ -114,7 +149,9 @@ function publishBundles({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const notes =
|
const notes =
|
||||||
`Verified platform bundles for v${version}, source ${commit}, build ${buildId}. Existing Node runtime is retained.\n\n` +
|
`${
|
||||||
|
development ? 'Development' : 'Verified'
|
||||||
|
} platform bundles for v${version}, source ${commit}, build ${buildId}. Existing Node runtime is retained.\n\n` +
|
||||||
Object.entries(index.packages)
|
Object.entries(index.packages)
|
||||||
.map(([target, entry]) => `- [${target}](${entry.url})`)
|
.map(([target, entry]) => `- [${target}](${entry.url})`)
|
||||||
.join('\n') +
|
.join('\n') +
|
||||||
@@ -148,118 +185,153 @@ function publishBundles({
|
|||||||
console.log('Newer source exists; leaving version and channel unchanged');
|
console.log('Newer source exists; leaving version and channel unchanged');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
ensureRelease(tag, `Platform upgrade v${version} (${buildId})`);
|
const staged = development
|
||||||
const assets = platforms
|
? stageDevelopmentAssets(directory, commit, buildId)
|
||||||
.flatMap((target) => {
|
: null;
|
||||||
const archive = path.join(
|
try {
|
||||||
directory,
|
|
||||||
`qinglong-${commit}-${target}.tar.gz`,
|
|
||||||
);
|
|
||||||
return [archive, archive + '.json'];
|
|
||||||
})
|
|
||||||
.concat(
|
|
||||||
path.join(directory, 'install-bundle.cjs'),
|
|
||||||
path.join(directory, 'latest.json'),
|
|
||||||
);
|
|
||||||
const published = lookup(tag);
|
|
||||||
const missing = [];
|
|
||||||
const matches = (asset, file) => {
|
|
||||||
const bytes = fs.readFileSync(file);
|
|
||||||
return (
|
|
||||||
asset?.size === bytes.length && asset.digest === `sha256:${sha256(bytes)}`
|
|
||||||
);
|
|
||||||
};
|
|
||||||
for (const file of assets) {
|
|
||||||
const previous = published.assets.find(
|
|
||||||
(a) => a.name === path.basename(file),
|
|
||||||
);
|
|
||||||
if (!previous) missing.push(file);
|
|
||||||
else if (!matches(previous, file))
|
|
||||||
throw new Error(
|
|
||||||
`Immutable release asset differs: ${path.basename(file)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (missing.length)
|
|
||||||
gh('release', 'upload', tag, ...missing, '--repo', repository);
|
|
||||||
const uploaded = lookup(tag);
|
|
||||||
if (
|
|
||||||
!assets.every((file) =>
|
|
||||||
matches(
|
|
||||||
uploaded.assets.find((a) => a.name === path.basename(file)),
|
|
||||||
file,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
throw new Error('Published platform bundle verification failed');
|
|
||||||
|
|
||||||
// Each channel serializes its image builds and pointer writes as one release.
|
|
||||||
// A rerun of an earlier workflow is allowed to replace the same
|
|
||||||
// version/commit; an obsolete source commit must never advance either alias.
|
|
||||||
const aliases = development
|
|
||||||
? ['upgrade-develop']
|
|
||||||
: [`upgrade-v${version}`, 'upgrade-master'];
|
|
||||||
for (const ref of aliases) {
|
|
||||||
if (!currentSource()) {
|
|
||||||
console.log('Newer source exists; leaving version and channel unchanged');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
ensureRelease(
|
ensureRelease(
|
||||||
ref,
|
tag,
|
||||||
ref === `upgrade-${branch}`
|
development
|
||||||
? `Upgrade channel ${branch}`
|
? 'Upgrade channel develop'
|
||||||
: `Platform upgrade v${version}`,
|
: `Platform upgrade v${version} (${buildId})`,
|
||||||
);
|
);
|
||||||
gh(
|
const assets =
|
||||||
'release',
|
staged?.assets ||
|
||||||
'upload',
|
platforms
|
||||||
ref,
|
.flatMap((target) => {
|
||||||
path.join(directory, 'latest.json'),
|
const archive = path.join(
|
||||||
'--repo',
|
directory,
|
||||||
repository,
|
`qinglong-${commit}-${target}.tar.gz`,
|
||||||
'--clobber',
|
);
|
||||||
);
|
return [archive, archive + '.json'];
|
||||||
gh('release', 'edit', ref, '--repo', repository, '--notes', notes);
|
})
|
||||||
|
.concat(
|
||||||
|
path.join(directory, 'install-bundle.cjs'),
|
||||||
|
path.join(directory, 'latest.json'),
|
||||||
|
);
|
||||||
|
const published = lookup(tag);
|
||||||
|
const missing = [];
|
||||||
|
const matches = (asset, file) => {
|
||||||
|
const bytes = fs.readFileSync(file);
|
||||||
|
return (
|
||||||
|
asset?.size === bytes.length &&
|
||||||
|
asset.digest === `sha256:${sha256(bytes)}`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
for (const file of assets) {
|
||||||
|
const previous = published.assets.find(
|
||||||
|
(a) => a.name === path.basename(file),
|
||||||
|
);
|
||||||
|
if (!previous) missing.push(file);
|
||||||
|
else if (!matches(previous, file))
|
||||||
|
throw new Error(
|
||||||
|
`Immutable release asset differs: ${path.basename(file)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (missing.length)
|
||||||
|
gh('release', 'upload', tag, ...missing, '--repo', repository);
|
||||||
|
const uploaded = lookup(tag);
|
||||||
if (
|
if (
|
||||||
!matches(
|
!assets.every((file) =>
|
||||||
lookup(ref)?.assets.find((a) => a.name === 'latest.json'),
|
matches(
|
||||||
path.join(directory, 'latest.json'),
|
uploaded.assets.find((a) => a.name === path.basename(file)),
|
||||||
|
file,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
throw new Error('Published channel index verification failed');
|
throw new Error('Published platform bundle verification failed');
|
||||||
}
|
|
||||||
// Only prune development releases after the complete new batch and its
|
// Each channel serializes its image builds and pointer writes as one release.
|
||||||
// channel pointer are verified. Production versions and aliases are retained.
|
// A rerun of an earlier workflow is allowed to replace the same
|
||||||
if (development && currentSource()) {
|
// version/commit; an obsolete source commit must never advance either alias.
|
||||||
const pages = JSON.parse(
|
const aliases = development
|
||||||
gh(
|
? ['upgrade-develop']
|
||||||
'api',
|
: [`upgrade-v${version}`, 'upgrade-master'];
|
||||||
`repos/${repository}/releases?per_page=100`,
|
for (const ref of aliases) {
|
||||||
'--paginate',
|
if (!currentSource()) {
|
||||||
'--slurp',
|
console.log(
|
||||||
),
|
'Newer source exists; leaving version and channel unchanged',
|
||||||
);
|
|
||||||
for (const release of pages.flat()) {
|
|
||||||
if (
|
|
||||||
release.tag_name !== tag &&
|
|
||||||
/^upgrade-build-develop-v\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?-\d+-\d+$/.test(
|
|
||||||
release.tag_name,
|
|
||||||
) &&
|
|
||||||
release.prerelease === true &&
|
|
||||||
release.draft === false
|
|
||||||
) {
|
|
||||||
if (!currentSource()) break;
|
|
||||||
gh(
|
|
||||||
'release',
|
|
||||||
'delete',
|
|
||||||
release.tag_name,
|
|
||||||
'--repo',
|
|
||||||
repository,
|
|
||||||
'--yes',
|
|
||||||
);
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ensureRelease(
|
||||||
|
ref,
|
||||||
|
ref === `upgrade-${branch}`
|
||||||
|
? `Upgrade channel ${branch}`
|
||||||
|
: `Platform upgrade v${version}`,
|
||||||
|
);
|
||||||
|
gh(
|
||||||
|
'release',
|
||||||
|
'upload',
|
||||||
|
ref,
|
||||||
|
path.join(directory, 'latest.json'),
|
||||||
|
'--repo',
|
||||||
|
repository,
|
||||||
|
'--clobber',
|
||||||
|
);
|
||||||
|
gh('release', 'edit', ref, '--repo', repository, '--notes', notes);
|
||||||
|
if (
|
||||||
|
!matches(
|
||||||
|
lookup(ref)?.assets.find((a) => a.name === 'latest.json'),
|
||||||
|
path.join(directory, 'latest.json'),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new Error('Published channel index verification failed');
|
||||||
|
}
|
||||||
|
// Only prune development releases after the complete new batch and its
|
||||||
|
// channel pointer are verified. Production versions and aliases are retained.
|
||||||
|
if (development && currentSource()) {
|
||||||
|
const currentAssets = new Set(assets.map((file) => path.basename(file)));
|
||||||
|
for (const asset of lookup(tag).assets) {
|
||||||
|
if (
|
||||||
|
!currentAssets.has(asset.name) &&
|
||||||
|
(/^qinglong-[a-f0-9]{40}-linux-[\w-]+-\d+-\d+\.tar\.gz(?:\.json)?$/.test(
|
||||||
|
asset.name,
|
||||||
|
) ||
|
||||||
|
/^install-bundle-\d+-\d+\.cjs$/.test(asset.name))
|
||||||
|
) {
|
||||||
|
if (!currentSource()) break;
|
||||||
|
gh(
|
||||||
|
'api',
|
||||||
|
'-X',
|
||||||
|
'DELETE',
|
||||||
|
`repos/${repository}/releases/assets/${asset.id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const pages = JSON.parse(
|
||||||
|
gh(
|
||||||
|
'api',
|
||||||
|
`repos/${repository}/releases?per_page=100`,
|
||||||
|
'--paginate',
|
||||||
|
'--slurp',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const release of pages.flat()) {
|
||||||
|
if (
|
||||||
|
release.tag_name !== tag &&
|
||||||
|
/^upgrade-build-develop-(?:v\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?-)?\d+-\d+$/.test(
|
||||||
|
release.tag_name,
|
||||||
|
) &&
|
||||||
|
release.prerelease === true &&
|
||||||
|
release.draft === false
|
||||||
|
) {
|
||||||
|
if (!currentSource()) break;
|
||||||
|
gh(
|
||||||
|
'release',
|
||||||
|
'delete',
|
||||||
|
release.tag_name,
|
||||||
|
'--repo',
|
||||||
|
repository,
|
||||||
|
'--yes',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
|
} finally {
|
||||||
|
if (staged) fs.rmSync(staged.staged, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
module.exports = { releaseIndex, platforms, publishBundles };
|
module.exports = { releaseIndex, platforms, publishBundles };
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Check serialized Docker layers, including files hidden by later deletions."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import tarfile
|
||||||
|
|
||||||
|
archive, architecture = sys.argv[1:]
|
||||||
|
assert architecture in {"amd64", "arm"}
|
||||||
|
nodes = []
|
||||||
|
with tarfile.open(archive) as image:
|
||||||
|
manifests = json.load(image.extractfile("manifest.json"))
|
||||||
|
assert len(manifests) == 1, "Expected one platform image"
|
||||||
|
manifest = manifests[0]
|
||||||
|
config = json.load(image.extractfile(manifest["Config"]))
|
||||||
|
assert config["architecture"] == architecture
|
||||||
|
for name in manifest["Layers"]:
|
||||||
|
with tarfile.open(fileobj=image.extractfile(name), mode="r|*") as layer:
|
||||||
|
for member in layer:
|
||||||
|
filename = member.name.removeprefix("./")
|
||||||
|
assert not (
|
||||||
|
filename == "tmp/node-runtime-amd64" and member.size > 0
|
||||||
|
), "Temporary amd64 Node remains in an immutable layer"
|
||||||
|
if filename not in {"usr/local/bin/node", "usr/bin/node"}:
|
||||||
|
continue
|
||||||
|
if not member.isfile():
|
||||||
|
continue
|
||||||
|
with layer.extractfile(member) as binary:
|
||||||
|
header = binary.read(20)
|
||||||
|
assert header[:4] == b"\x7fELF", "Expected a native Node binary"
|
||||||
|
digest = hashlib.sha256(header)
|
||||||
|
while chunk := binary.read(1024 * 1024):
|
||||||
|
digest.update(chunk)
|
||||||
|
endian = "little" if header[5] == 1 else "big"
|
||||||
|
machine = int.from_bytes(header[18:20], endian)
|
||||||
|
assert machine == {"amd64": 62, "arm": 40}[architecture], (
|
||||||
|
"Wrong Node architecture in an image layer"
|
||||||
|
)
|
||||||
|
nodes.append({"path": filename, "size": member.size, "sha256": digest.hexdigest()})
|
||||||
|
assert len(nodes) == 1, "Expected exactly one retained Node binary across all layers"
|
||||||
|
assert nodes[0]["path"] == (
|
||||||
|
"usr/local/bin/node" if architecture == "amd64" else "usr/bin/node"
|
||||||
|
)
|
||||||
|
print(json.dumps({"pass": True, "architecture": architecture, "layers": len(manifest["Layers"]), "nodes": nodes}))
|
||||||
@@ -23,7 +23,7 @@ const run = (program, args, options = {}) => execute(program, args, {
|
|||||||
const requiredFiles = [
|
const requiredFiles = [
|
||||||
'.env.example', 'back/protos/api.proto', 'ecosystem.config.js', 'version.yaml',
|
'.env.example', 'back/protos/api.proto', 'ecosystem.config.js', 'version.yaml',
|
||||||
'shell/upgrade.cjs', 'shell/install-bundle.cjs', 'shell/install-package.cjs',
|
'shell/upgrade.cjs', 'shell/install-bundle.cjs', 'shell/install-package.cjs',
|
||||||
'shell/upgrade-lock.yaml', 'shell/start.sh', 'shell/start-simplify.sh',
|
'shell/upgrade-lock.yaml', 'shell/node-runtime-probe.cjs', 'shell/start.sh', 'shell/start-simplify.sh',
|
||||||
'shell/task.sh', 'shell/preload/client.js', 'shell/preload/client.py',
|
'shell/task.sh', 'shell/preload/client.js', 'shell/preload/client.py',
|
||||||
'shell/preload/sitecustomize.js', 'shell/preload/sitecustomize.py',
|
'shell/preload/sitecustomize.js', 'shell/preload/sitecustomize.py',
|
||||||
'shell/preload/esm-loader.mjs', 'shell/preload/notificationAudit.js',
|
'shell/preload/esm-loader.mjs', 'shell/preload/notificationAudit.js',
|
||||||
@@ -191,6 +191,12 @@ async function verifyPanel(input, output) {
|
|||||||
const globalRoot = (await run('npm', ['root', '--global', '--prefix', prefix], { cwd: temp, env })).stdout.trim();
|
const globalRoot = (await run('npm', ['root', '--global', '--prefix', prefix], { cwd: temp, env })).stdout.trim();
|
||||||
root = path.join(globalRoot, '@whyour/qinglong');
|
root = path.join(globalRoot, '@whyour/qinglong');
|
||||||
env.QL_DIR = root;
|
env.QL_DIR = root;
|
||||||
|
const runtimeProbes = [];
|
||||||
|
const checkRuntime = async () => {
|
||||||
|
const result = await run(process.execPath, [path.join(root, 'shell/node-runtime-probe.cjs')], { cwd: temp, env, timeout: 540000 });
|
||||||
|
runtimeProbes.push(JSON.parse(result.stdout.trim()));
|
||||||
|
};
|
||||||
|
await checkRuntime();
|
||||||
// Use a subprocess: cached candidate modules must not mask missing files
|
// Use a subprocess: cached candidate modules must not mask missing files
|
||||||
// after old-package installation and the real npm overwrite below.
|
// after old-package installation and the real npm overwrite below.
|
||||||
await run(process.execPath, ['-e', `const root=process.argv[1],p=require('node:path');const m=require(root+'/package.json');for(const target of Object.values(m.bin))require('node:fs').accessSync(p.join(root,target));for(const name of ['platformBundle','upgradePrestart','panelUpgrade'])require(root+'/cli/src/internal/maintenance/'+name+'.cjs');require(root+'/shell/upgrade.cjs').verifyUpgrade(root,p.join(root,'static')).catch(e=>{console.error(e);process.exitCode=1});`, root], { env, cwd: temp });
|
await run(process.execPath, ['-e', `const root=process.argv[1],p=require('node:path');const m=require(root+'/package.json');for(const target of Object.values(m.bin))require('node:fs').accessSync(p.join(root,target));for(const name of ['platformBundle','upgradePrestart','panelUpgrade'])require(root+'/cli/src/internal/maintenance/'+name+'.cjs');require(root+'/shell/upgrade.cjs').verifyUpgrade(root,p.join(root,'static')).catch(e=>{console.error(e);process.exitCode=1});`, root], { env, cwd: temp });
|
||||||
@@ -287,6 +293,7 @@ async function verifyPanel(input, output) {
|
|||||||
const applying = await api('update/system', 'PUT');
|
const applying = await api('update/system', 'PUT');
|
||||||
await job(applying.id, 'completed');
|
await job(applying.id, 'completed');
|
||||||
const afterOnline = await healthy();
|
const afterOnline = await healthy();
|
||||||
|
await checkRuntime();
|
||||||
assert.notEqual(afterOnline.build.instanceId, original.build.instanceId);
|
assert.notEqual(afterOnline.build.instanceId, original.build.instanceId);
|
||||||
assert.equal(afterOnline.build.sourceCommit, fixture.sourceCommit);
|
assert.equal(afterOnline.build.sourceCommit, fixture.sourceCommit);
|
||||||
await fs.access(path.join(root, 'node_modules/.pnpm'));
|
await fs.access(path.join(root, 'node_modules/.pnpm'));
|
||||||
@@ -302,6 +309,7 @@ async function verifyPanel(input, output) {
|
|||||||
assert.equal((await healthy()).build.instanceId, afterOnline.build.instanceId);
|
assert.equal((await healthy()).build.instanceId, afterOnline.build.instanceId);
|
||||||
await run(path.join(prefix, 'bin/ql'), ['reload', 'system'], { cwd: temp, env, timeout: 240000 });
|
await run(path.join(prefix, 'bin/ql'), ['reload', 'system'], { cwd: temp, env, timeout: 240000 });
|
||||||
const afterOffline = await healthy();
|
const afterOffline = await healthy();
|
||||||
|
await checkRuntime();
|
||||||
assert.notEqual(afterOffline.build.instanceId, afterOnline.build.instanceId);
|
assert.notEqual(afterOffline.build.instanceId, afterOnline.build.instanceId);
|
||||||
assert.equal(afterOffline.build.sourceCommit, fixture.sourceCommit);
|
assert.equal(afterOffline.build.sourceCommit, fixture.sourceCommit);
|
||||||
await fs.access(path.join(root, 'node_modules/.pnpm'));
|
await fs.access(path.join(root, 'node_modules/.pnpm'));
|
||||||
@@ -325,7 +333,7 @@ async function verifyPanel(input, output) {
|
|||||||
customRootEnvironmentPreservedAutomatically: true,
|
customRootEnvironmentPreservedAutomatically: true,
|
||||||
officialPlatformBundle: { baseImage: fixture.baseImage, sourceCommit: fixture.sourceCommit, sha256: fixture.sha256 },
|
officialPlatformBundle: { baseImage: fixture.baseImage, sourceCommit: fixture.sourceCommit, sha256: fixture.sha256 },
|
||||||
migratedFromNpmToOfficialPnpmLayout: true,
|
migratedFromNpmToOfficialPnpmLayout: true,
|
||||||
existingNodePreserved: true,
|
existingNodePreserved: true, nativeUrlRuntimeProbes: runtimeProbes,
|
||||||
frontendAndBackendStarted: true, checksumFailureKeptOldService: true,
|
frontendAndBackendStarted: true, checksumFailureKeptOldService: true,
|
||||||
prestartBeforeReady: true, onlineActualRestart: true,
|
prestartBeforeReady: true, onlineActualRestart: true,
|
||||||
offlinePreparationDidNotContactIndex: true, offlineBashActualRestart: true,
|
offlinePreparationDidNotContactIndex: true, offlineBashActualRestart: true,
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { createHash } = require('node:crypto');
|
||||||
|
const { spawnSync } = require('node:child_process');
|
||||||
|
|
||||||
|
// Run in new processes: a healthy parent can outlive a broken native runtime.
|
||||||
|
function probeNodeRuntime(executable = process.execPath) {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-node-url-'));
|
||||||
|
const source = `
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { pathToFileURL, fileURLToPath } = require('node:url');
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
assert.equal(new URL('/任务?q=' + i, 'https://example.com').hostname, 'example.com');
|
||||||
|
assert.equal(new URL('https://例子.测试').hostname, 'xn--fsqu00a.xn--0zwm56d');
|
||||||
|
assert.equal(new URL('http://[::1]:5700').port, '5700');
|
||||||
|
const filename = '/tmp/中文 path/' + i + '.cjs';
|
||||||
|
assert.equal(fileURLToPath(pathToFileURL(filename)), filename);
|
||||||
|
}
|
||||||
|
console.log('QINGLONG_NODE_URL_OK');
|
||||||
|
`;
|
||||||
|
const file = path.join(directory, 'probe.cjs');
|
||||||
|
fs.writeFileSync(file, source);
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const args = process.execArgv.includes('--jitless') ? ['--jitless'] : [];
|
||||||
|
args.push(...(i % 2 ? [file] : ['-e', source]));
|
||||||
|
const child = spawnSync(executable, args, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
// Multi-architecture CI runs this same probe through QEMU.
|
||||||
|
timeout: 120000,
|
||||||
|
maxBuffer: 65536,
|
||||||
|
env: { ...process.env, NODE_OPTIONS: '' },
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
child.status,
|
||||||
|
0,
|
||||||
|
`Node URL runtime probe failed (${i % 2 ? 'file' : 'eval'}): signal=${
|
||||||
|
child.signal
|
||||||
|
}; status=${child.status}; ${child.error?.message || child.stderr}`,
|
||||||
|
);
|
||||||
|
assert.match(child.stdout, /QINGLONG_NODE_URL_OK/);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
node: process.version,
|
||||||
|
architecture: process.arch,
|
||||||
|
ada: process.versions.ada,
|
||||||
|
processes: 4,
|
||||||
|
urlIterationsPerProcess: 1000,
|
||||||
|
nodeSha256: createHash('sha256')
|
||||||
|
.update(fs.readFileSync(executable))
|
||||||
|
.digest('hex'),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { probeNodeRuntime };
|
||||||
|
if (require.main === module) console.log(JSON.stringify(probeNodeRuntime()));
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
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 { probeNodeRuntime } = require('../../shell/node-runtime-probe.cjs');
|
||||||
|
|
||||||
|
test('the retained Node handles URL, IDNA and file paths in fresh eval and file processes', () => {
|
||||||
|
const result = probeNodeRuntime();
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.processes, 4);
|
||||||
|
assert.match(result.nodeSha256, /^[0-9a-f]{64}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a native child crash fails the runtime probe instead of accepting its healthy parent', (t) => {
|
||||||
|
if (process.platform === 'win32') return t.skip('POSIX crash fixture');
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-node-crash-'));
|
||||||
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||||
|
const executable = path.join(directory, 'node');
|
||||||
|
fs.writeFileSync(executable, '#!/bin/sh\nkill -SEGV $$\n', { mode: 0o700 });
|
||||||
|
assert.throws(
|
||||||
|
() => probeNodeRuntime(executable),
|
||||||
|
/Node URL runtime probe failed.*SIGSEGV/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emulated Node probes have a bounded two-minute budget and reject a timeout', (t) => {
|
||||||
|
const childProcess = require('node:child_process');
|
||||||
|
const modulePath = require.resolve('../../shell/node-runtime-probe.cjs');
|
||||||
|
let directory;
|
||||||
|
const makeDirectory = fs.mkdtempSync;
|
||||||
|
t.mock.method(fs, 'mkdtempSync', (...args) => {
|
||||||
|
directory = makeDirectory(...args);
|
||||||
|
return directory;
|
||||||
|
});
|
||||||
|
t.mock.method(childProcess, 'spawnSync', (_program, args, options) => {
|
||||||
|
assert.equal(options.timeout, 120000);
|
||||||
|
assert.equal(options.env.NODE_OPTIONS, '');
|
||||||
|
return { status: null, signal: 'SIGTERM', error: Object.assign(new Error('probe timed out'), { code: 'ETIMEDOUT' }) };
|
||||||
|
});
|
||||||
|
delete require.cache[modulePath];
|
||||||
|
t.after(() => { delete require.cache[modulePath]; });
|
||||||
|
const probe = require(modulePath).probeNodeRuntime;
|
||||||
|
assert.throws(() => probe(), /Node URL runtime probe failed.*SIGTERM.*probe timed out/);
|
||||||
|
assert.ok(directory);
|
||||||
|
assert.equal(fs.existsSync(directory), false, 'Timed-out probes must remove their temporary source files');
|
||||||
|
});
|
||||||
@@ -19,7 +19,7 @@ const {
|
|||||||
sourceCommit,
|
sourceCommit,
|
||||||
} = require('../helpers/upgrade-fixture.cjs');
|
} = require('../helpers/upgrade-fixture.cjs');
|
||||||
|
|
||||||
async function fixture(t) {
|
async function fixture(t, targetVersion = '2.24.0') {
|
||||||
const base = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-panel-job-'));
|
const base = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-panel-job-'));
|
||||||
const root = path.join(base, 'panel'),
|
const root = path.join(base, 'panel'),
|
||||||
tmp = path.join(root, '.tmp');
|
tmp = path.join(root, '.tmp');
|
||||||
@@ -30,7 +30,10 @@ async function fixture(t) {
|
|||||||
await fs.writeFile(name, contents);
|
await fs.writeFile(name, contents);
|
||||||
};
|
};
|
||||||
for (const dir of [root, source]) {
|
for (const dir of [root, source]) {
|
||||||
await write(path.join(dir, 'package.json'), '{"version":"2.24.0"}');
|
await write(
|
||||||
|
path.join(dir, 'package.json'),
|
||||||
|
JSON.stringify({ version: targetVersion }),
|
||||||
|
);
|
||||||
await write(path.join(dir, 'pnpm-lock.yaml'), 'lock');
|
await write(path.join(dir, 'pnpm-lock.yaml'), 'lock');
|
||||||
await write(path.join(dir, 'sample/config.sample.sh'), '# config');
|
await write(path.join(dir, 'sample/config.sample.sh'), '# config');
|
||||||
}
|
}
|
||||||
@@ -39,7 +42,7 @@ async function fixture(t) {
|
|||||||
'installed dependency',
|
'installed dependency',
|
||||||
);
|
);
|
||||||
await write(path.join(root, 'version.yaml'), 'version: 2.23.0');
|
await write(path.join(root, 'version.yaml'), 'version: 2.23.0');
|
||||||
await write(path.join(source, 'version.yaml'), 'version: 2.24.0');
|
await write(path.join(source, 'version.yaml'), `version: ${targetVersion}`);
|
||||||
await write(path.join(staticRoot, 'build/app.js'), 'new backend');
|
await write(path.join(staticRoot, 'build/app.js'), 'new backend');
|
||||||
writeManifest(source, staticRoot);
|
writeManifest(source, staticRoot);
|
||||||
for (const repo of ['qinglong', 'qinglong-static'])
|
for (const repo of ['qinglong', 'qinglong-static'])
|
||||||
@@ -81,7 +84,7 @@ fs.writeFileSync(args[args.indexOf('--output') + 1], buffer);
|
|||||||
);
|
);
|
||||||
await fs.chmod(path.join(base, 'bin/curl'), 0o700);
|
await fs.chmod(path.join(base, 'bin/curl'), 0o700);
|
||||||
const runtime = {
|
const runtime = {
|
||||||
version: '2.24.0',
|
version: targetVersion,
|
||||||
sourceCommit,
|
sourceCommit,
|
||||||
instanceId: 'after-restart',
|
instanceId: 'after-restart',
|
||||||
parentPid: 100002,
|
parentPid: 100002,
|
||||||
@@ -186,6 +189,21 @@ test('detached download survives its initiating request and is idempotent; apply
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('prerelease version is retained through download and restart verification', async (t) => {
|
||||||
|
const f = await fixture(t, '2.24.0-dev.0');
|
||||||
|
await startJob(f, 'download');
|
||||||
|
const ready = await finished(f.tmp);
|
||||||
|
assert.equal(ready.status, 'ready', ready.log);
|
||||||
|
assert.equal(ready.version, '2.24.0-dev.0');
|
||||||
|
await startJob(f, 'apply');
|
||||||
|
const complete = await finished(f.tmp);
|
||||||
|
assert.equal(complete.status, 'completed', complete.log);
|
||||||
|
assert.equal(
|
||||||
|
await fs.readFile(path.join(f.root, 'version.yaml'), 'utf8'),
|
||||||
|
'version: 2.24.0-dev.0',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('invalid staged payload fails asynchronously before stopping the panel', async (t) => {
|
test('invalid staged payload fails asynchronously before stopping the panel', async (t) => {
|
||||||
const f = await fixture(t);
|
const f = await fixture(t);
|
||||||
await startJob(f, 'download');
|
await startJob(f, 'download');
|
||||||
|
|||||||
@@ -591,7 +591,9 @@ test('publishing requires every platform and validates all immutable archive has
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const index = releaseIndex(f.directory, sourceCommit, repository);
|
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(
|
const armv6 = path.join(
|
||||||
f.directory,
|
f.directory,
|
||||||
`qinglong-${sourceCommit}-linux-armv6-musl.tar.gz`,
|
`qinglong-${sourceCommit}-linux-armv6-musl.tar.gz`,
|
||||||
|
|||||||
@@ -66,6 +66,17 @@ function fixture(t) {
|
|||||||
const gh = (...args) => {
|
const gh = (...args) => {
|
||||||
state.calls.push(args);
|
state.calls.push(args);
|
||||||
if (args[0] === 'api') {
|
if (args[0] === 'api') {
|
||||||
|
if (args[1] === '-X' && args[2] === 'DELETE') {
|
||||||
|
if (state.failDelete) throw new Error('Delete unavailable');
|
||||||
|
const id = Number(args[3].split('/').at(-1));
|
||||||
|
for (const release of state.releases.values())
|
||||||
|
for (const asset of release.values())
|
||||||
|
if (asset.id === id) {
|
||||||
|
release.delete(asset.name);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
const route = args[1];
|
const route = args[1];
|
||||||
if (route.endsWith('/git/ref/heads/master')) return state.head;
|
if (route.endsWith('/git/ref/heads/master')) return state.head;
|
||||||
if (route.endsWith('/git/ref/heads/develop')) return state.developHead;
|
if (route.endsWith('/git/ref/heads/develop')) return state.developHead;
|
||||||
@@ -162,10 +173,12 @@ test('publishes every platform and installer before advancing version and legacy
|
|||||||
assert.equal(index.version, '2.24.0');
|
assert.equal(index.version, '2.24.0');
|
||||||
assert.equal(index.buildId, '100-1');
|
assert.equal(index.buildId, '100-1');
|
||||||
assert.equal(index.sourceCommit, f.options.commit);
|
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'));
|
assert.deepEqual(index, f.index('upgrade-v2.24.0'));
|
||||||
const tag = 'upgrade-build-v2.24.0-100-1';
|
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]) {
|
for (const entry of [...Object.values(index.packages), index.installer]) {
|
||||||
const url = new URL(entry.url),
|
const url = new URL(entry.url),
|
||||||
filename = path.basename(url.pathname);
|
filename = path.basename(url.pathname);
|
||||||
@@ -335,38 +348,58 @@ test('existing master clients can migrate from indexes without version/build met
|
|||||||
assert.equal(f.index().buildId, '100-2');
|
assert.equal(f.index().buildId, '100-2');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('automatic development publication shares musl/glibc packages without changing version or latest production assets', (t) => {
|
test('automatic development publication keeps its prerelease version separate from production assets', (t) => {
|
||||||
const f = fixture(t);
|
const f = fixture(t);
|
||||||
f.publish();
|
f.publish();
|
||||||
const production = f.index();
|
const production = f.index();
|
||||||
f.write(f.state.developHead, 'development');
|
const developmentVersion = '2.24.0-dev.0';
|
||||||
|
f.write(f.state.developHead, 'development', developmentVersion);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
f.publish({
|
f.publish({
|
||||||
branch: 'develop',
|
branch: 'develop',
|
||||||
commit: f.state.developHead,
|
commit: f.state.developHead,
|
||||||
|
version: developmentVersion,
|
||||||
}),
|
}),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
const development = f.index('upgrade-develop');
|
const development = f.index('upgrade-develop');
|
||||||
assert.equal(development.channel, 'develop');
|
assert.equal(development.channel, 'develop');
|
||||||
|
assert.equal(development.version, developmentVersion);
|
||||||
assert.equal(development.sourceCommit, f.state.developHead);
|
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-musl']);
|
||||||
assert.ok(development.packages['linux-x64-glibc']);
|
assert.ok(development.packages['linux-x64-glibc']);
|
||||||
assert.ok(development.packages['linux-armv6-musl']);
|
assert.ok(development.packages['linux-armv6-musl']);
|
||||||
assert.equal(development.packages['linux-armv6-glibc'], undefined);
|
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 [
|
for (const entry of [
|
||||||
...Object.values(development.packages),
|
...Object.values(development.packages),
|
||||||
development.installer,
|
development.installer,
|
||||||
])
|
])
|
||||||
assert.ok(entry.url.includes('/upgrade-build-develop-v2.24.0-100-1/'));
|
assert.ok(entry.url.includes('/upgrade-develop/'));
|
||||||
|
for (const entry of Object.values(development.packages))
|
||||||
|
assert.ok(entry.url.endsWith('-100-1.tar.gz'));
|
||||||
|
assert.ok(development.installer.url.endsWith('/install-bundle-100-1.cjs'));
|
||||||
|
assert.ok(
|
||||||
|
f.state.calls.some(
|
||||||
|
(args) =>
|
||||||
|
args[0] === 'release' &&
|
||||||
|
args[1] === 'create' &&
|
||||||
|
args[2] === 'upgrade-develop' &&
|
||||||
|
args[args.indexOf('--title') + 1] === 'Upgrade channel develop',
|
||||||
|
),
|
||||||
|
);
|
||||||
assert.deepEqual(f.index(), production);
|
assert.deepEqual(f.index(), production);
|
||||||
assert.deepEqual(f.index('upgrade-v2.24.0'), 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(
|
assert.equal(f.state.releases.get('upgrade-develop').size, 24);
|
||||||
f.state.releases.get('upgrade-build-develop-v2.24.0-100-1').size,
|
assert.ok(!f.state.releases.has('upgrade-build-develop-100-1'));
|
||||||
26,
|
const archiveName = path.basename(development.packages['linux-x64-musl'].url);
|
||||||
|
const sidecar = JSON.parse(
|
||||||
|
f.state.releases.get('upgrade-develop').get(`${archiveName}.json`).bytes,
|
||||||
);
|
);
|
||||||
|
assert.equal(sidecar.filename, archiveName);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('development builds cannot publish obsolete development source', (t) => {
|
test('development builds cannot publish obsolete development source', (t) => {
|
||||||
@@ -416,14 +449,21 @@ for (const fault of [
|
|||||||
const options = { branch: 'develop', commit: f.state.developHead };
|
const options = { branch: 'develop', commit: f.state.developHead };
|
||||||
f.write(options.commit, 'development');
|
f.write(options.commit, 'development');
|
||||||
f.publish(options);
|
f.publish(options);
|
||||||
const oldTag = 'upgrade-build-develop-v2.24.0-100-1';
|
const oldTag = 'upgrade-build-develop-100-1';
|
||||||
|
const legacyTag = 'upgrade-build-develop-v2.24.0-99-1';
|
||||||
|
f.state.releases.set(oldTag, new Map());
|
||||||
|
f.state.releases.set(legacyTag, new Map());
|
||||||
|
const oldAsset = Array.from(
|
||||||
|
f.state.releases.get('upgrade-develop').keys(),
|
||||||
|
).find((name) => name.endsWith('-100-1.tar.gz'));
|
||||||
|
assert.ok(oldAsset);
|
||||||
// Never prune production, channel aliases, legacy/unclassified releases,
|
// Never prune production, channel aliases, legacy/unclassified releases,
|
||||||
// drafts or a non-prerelease even when its name resembles a dev build.
|
// drafts or a non-prerelease even when its name resembles a dev build.
|
||||||
const protectedTags = [
|
const protectedTags = [
|
||||||
'upgrade-86c37f426a5ba9945d6af1910a7885a26f77e4bc',
|
'upgrade-86c37f426a5ba9945d6af1910a7885a26f77e4bc',
|
||||||
'v2.24.0',
|
'v2.24.0',
|
||||||
'upgrade-build-develop-v2.24.0-10-1',
|
'upgrade-build-develop-10-1',
|
||||||
'upgrade-build-develop-v2.24.0-11-1',
|
'upgrade-build-develop-11-1',
|
||||||
];
|
];
|
||||||
for (const tag of protectedTags) f.state.releases.set(tag, new Map());
|
for (const tag of protectedTags) f.state.releases.set(tag, new Map());
|
||||||
f.state.releaseMetadata = {
|
f.state.releaseMetadata = {
|
||||||
@@ -441,7 +481,7 @@ for (const fault of [
|
|||||||
assert.equal(f.publish({ ...options, buildId: '101-1' }), true);
|
assert.equal(f.publish({ ...options, buildId: '101-1' }), true);
|
||||||
assert.equal(f.index('upgrade-develop').buildId, '101-1');
|
assert.equal(f.index('upgrade-develop').buildId, '101-1');
|
||||||
const deletion = f.state.calls.findIndex(
|
const deletion = f.state.calls.findIndex(
|
||||||
(a) => a[0] === 'release' && a[1] === 'delete',
|
(a) => a[0] === 'api' && a[1] === '-X' && a[2] === 'DELETE',
|
||||||
);
|
);
|
||||||
const aliasEdit = f.state.calls.findLastIndex(
|
const aliasEdit = f.state.calls.findLastIndex(
|
||||||
(a) => a[0] === 'release' && a[1] === 'edit',
|
(a) => a[0] === 'release' && a[1] === 'edit',
|
||||||
@@ -455,9 +495,15 @@ for (const fault of [
|
|||||||
a[0] === 'api' && a[1].includes('/releases/tags/upgrade-develop'),
|
a[0] === 'api' && a[1].includes('/releases/tags/upgrade-develop'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
assert.ok(f.state.releases.has('upgrade-build-develop-v2.24.0-101-1'));
|
assert.ok(f.state.releases.has('upgrade-develop'));
|
||||||
|
assert.ok(!f.state.releases.has('upgrade-build-develop-101-1'));
|
||||||
}
|
}
|
||||||
assert.equal(f.state.releases.has(oldTag), Boolean(fault));
|
assert.equal(f.state.releases.has(oldTag), Boolean(fault));
|
||||||
|
assert.equal(f.state.releases.has(legacyTag), Boolean(fault));
|
||||||
|
assert.equal(
|
||||||
|
f.state.releases.get('upgrade-develop').has(oldAsset),
|
||||||
|
Boolean(fault),
|
||||||
|
);
|
||||||
for (const tag of protectedTags) assert.ok(f.state.releases.has(tag));
|
for (const tag of protectedTags) assert.ok(f.state.releases.has(tag));
|
||||||
assert.deepEqual(f.index(), production);
|
assert.deepEqual(f.index(), production);
|
||||||
assert.deepEqual(f.index('upgrade-v2.24.0'), production);
|
assert.deepEqual(f.index('upgrade-v2.24.0'), production);
|
||||||
@@ -468,17 +514,21 @@ test('failed development cleanup is retryable without overwriting immutable asse
|
|||||||
const options = { branch: 'develop', commit: f.state.developHead };
|
const options = { branch: 'develop', commit: f.state.developHead };
|
||||||
f.write(options.commit, 'development');
|
f.write(options.commit, 'development');
|
||||||
f.publish(options);
|
f.publish(options);
|
||||||
|
const oldAsset = Array.from(
|
||||||
|
f.state.releases.get('upgrade-develop').keys(),
|
||||||
|
).find((name) => name.endsWith('-100-1.tar.gz'));
|
||||||
|
assert.ok(oldAsset);
|
||||||
f.state.failDelete = true;
|
f.state.failDelete = true;
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => f.publish({ ...options, buildId: '101-1' }),
|
() => f.publish({ ...options, buildId: '101-1' }),
|
||||||
/Delete unavailable/,
|
/Delete unavailable/,
|
||||||
);
|
);
|
||||||
assert.equal(f.index('upgrade-develop').buildId, '101-1');
|
assert.equal(f.index('upgrade-develop').buildId, '101-1');
|
||||||
assert.ok(f.state.releases.has('upgrade-build-develop-v2.24.0-100-1'));
|
assert.ok(f.state.releases.get('upgrade-develop').has(oldAsset));
|
||||||
f.state.failDelete = false;
|
f.state.failDelete = false;
|
||||||
f.publish({ ...options, buildId: '101-1' });
|
f.publish({ ...options, buildId: '101-1' });
|
||||||
assert.ok(!f.state.releases.has('upgrade-build-develop-v2.24.0-100-1'));
|
assert.ok(!f.state.releases.get('upgrade-develop').has(oldAsset));
|
||||||
assert.ok(f.state.releases.has('upgrade-build-develop-v2.24.0-101-1'));
|
assert.equal(f.state.releases.get('upgrade-develop').size, 24);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a newer development commit during upload preserves the published channel and its old package', (t) => {
|
test('a newer development commit during upload preserves the published channel and its old package', (t) => {
|
||||||
@@ -487,10 +537,14 @@ test('a newer development commit during upload preserves the published channel a
|
|||||||
f.write(options.commit, 'development');
|
f.write(options.commit, 'development');
|
||||||
f.publish(options);
|
f.publish(options);
|
||||||
const before = f.index('upgrade-develop');
|
const before = f.index('upgrade-develop');
|
||||||
|
const oldAsset = Array.from(
|
||||||
|
f.state.releases.get('upgrade-develop').keys(),
|
||||||
|
).find((name) => name.endsWith('-100-1.tar.gz'));
|
||||||
|
assert.ok(oldAsset);
|
||||||
f.state.changeDevelopHeadAfterUpload = true;
|
f.state.changeDevelopHeadAfterUpload = true;
|
||||||
assert.equal(f.publish({ ...options, buildId: '101-1' }), false);
|
assert.equal(f.publish({ ...options, buildId: '101-1' }), false);
|
||||||
assert.deepEqual(f.index('upgrade-develop'), before);
|
assert.deepEqual(f.index('upgrade-develop'), before);
|
||||||
assert.ok(f.state.releases.has('upgrade-build-develop-v2.24.0-100-1'));
|
assert.ok(f.state.releases.get('upgrade-develop').has(oldAsset));
|
||||||
assert.ok(
|
assert.ok(
|
||||||
!f.state.calls.some((a) => a[0] === 'release' && a[1] === 'delete'),
|
!f.state.calls.some((a) => a[0] === 'release' && a[1] === 'delete'),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ test('a running process retains its own build identity when installed files chan
|
|||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-runtime-build-'));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-runtime-build-'));
|
||||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||||
fs.mkdirSync(path.join(root, 'static/build'), { recursive: true });
|
fs.mkdirSync(path.join(root, 'static/build'), { recursive: true });
|
||||||
fs.writeFileSync(path.join(root, 'version.yaml'), 'version: 2.24.0');
|
fs.writeFileSync(path.join(root, 'version.yaml'), 'version: 2.24.0-dev.0');
|
||||||
fs.writeFileSync(path.join(root, 'static/build/app.js'), 'old backend');
|
fs.writeFileSync(path.join(root, 'static/build/app.js'), 'old backend');
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(root, 'static/build-info.json'),
|
path.join(root, 'static/build-info.json'),
|
||||||
@@ -45,7 +45,7 @@ test('a running process retains its own build identity when installed files chan
|
|||||||
JSON.stringify({ sourceCommit: 'b'.repeat(40) }),
|
JSON.stringify({ sourceCommit: 'b'.repeat(40) }),
|
||||||
);
|
);
|
||||||
fs.writeFileSync(path.join(root, 'version.yaml'), 'version: 2.25.0');
|
fs.writeFileSync(path.join(root, 'version.yaml'), 'version: 2.25.0');
|
||||||
assert.equal(running.version, '2.24.0');
|
assert.equal(running.version, '2.24.0-dev.0');
|
||||||
assert.equal(running.sourceCommit, 'a'.repeat(40));
|
assert.equal(running.sourceCommit, 'a'.repeat(40));
|
||||||
assert.equal(
|
assert.equal(
|
||||||
running.backendSha256,
|
running.backendSha256,
|
||||||
|
|||||||
@@ -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() {} };
|
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) {
|
function subscriptionFixture(killTask) {
|
||||||
const updates = [];
|
const updates = [];
|
||||||
const Service = load('back/services/subscription.ts', {
|
const Service = load('back/services/subscription.ts', {
|
||||||
|
|||||||
@@ -10,8 +10,12 @@ const {
|
|||||||
repairDependencies,
|
repairDependencies,
|
||||||
} = require('../../cli/src/internal/maintenance/upgradePrestart.cjs');
|
} = require('../../cli/src/internal/maintenance/upgradePrestart.cjs');
|
||||||
|
|
||||||
for (const exitCode of [0, 1])
|
for (const [version, exitCode] of [
|
||||||
test(`prestart exit ${exitCode} reaps children retaining output pipes before removing its workspace`, async (t) => {
|
['2.23.0', 0],
|
||||||
|
['2.23.0', 1],
|
||||||
|
['2.24.0-dev.0', 0],
|
||||||
|
])
|
||||||
|
test(`prestart ${version} exit ${exitCode} reaps children retaining output pipes before removing its workspace`, async (t) => {
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-probe-exit-'));
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-probe-exit-'));
|
||||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||||
const source = path.join(root, 'source'),
|
const source = path.join(root, 'source'),
|
||||||
@@ -19,7 +23,7 @@ for (const exitCode of [0, 1])
|
|||||||
survivor = path.join(root, 'survivor');
|
survivor = path.join(root, 'survivor');
|
||||||
await fs.mkdir(path.join(source, 'node_modules'), { recursive: true });
|
await fs.mkdir(path.join(source, 'node_modules'), { recursive: true });
|
||||||
await fs.mkdir(path.join(statics, 'build/bootstrap'), { recursive: true });
|
await fs.mkdir(path.join(statics, 'build/bootstrap'), { recursive: true });
|
||||||
await fs.writeFile(path.join(source, 'version.yaml'), 'version: 2.23.0');
|
await fs.writeFile(path.join(source, 'version.yaml'), `version: ${version}`);
|
||||||
await fs.writeFile(path.join(statics, 'build/app.js'), 'backend');
|
await fs.writeFile(path.join(statics, 'build/app.js'), 'backend');
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(statics, 'build-info.json'),
|
path.join(statics, 'build-info.json'),
|
||||||
@@ -36,7 +40,7 @@ for (const exitCode of [0, 1])
|
|||||||
)}],{stdio:'inherit'});
|
)}],{stdio:'inherit'});
|
||||||
process.send({type:'upgrade-probe',ok:${
|
process.send({type:'upgrade-probe',ok:${
|
||||||
exitCode === 0
|
exitCode === 0
|
||||||
},message:'simulated prestart failure',build:{sourceCommit:'a'.repeat(40),version:'2.23.0',backendSha256:crypto.createHash('sha256').update('backend').digest('hex')}},()=>process.exit(${exitCode}));
|
},message:'simulated prestart failure',build:{sourceCommit:'a'.repeat(40),version:${JSON.stringify(version)},backendSha256:crypto.createHash('sha256').update('backend').digest('hex')}},()=>process.exit(${exitCode}));
|
||||||
`,
|
`,
|
||||||
);
|
);
|
||||||
const operation = prestartBackend(
|
const operation = prestartBackend(
|
||||||
|
|||||||
+8
-14
@@ -1,15 +1,9 @@
|
|||||||
version: 2.23.0
|
version: 2.24.0-dev.0
|
||||||
changeLogLink: https://github.com/whyour/qinglong/blob/master/docs/releases/2.23.0.md
|
changeLogLink: https://github.com/whyour/qinglong/blob/develop/docs/releases/2.24.0-dev.0.md
|
||||||
publishTime: "2026-10-11 12:27:02"
|
publishTime: "2026-10-25 19:22:54"
|
||||||
changeLog: |
|
changeLog: |
|
||||||
1. 新增脚本历史版本查看、对比与恢复,支持 CLI 和 OpenAPI;恢复前检查当前内容哈希,避免覆盖并发修改
|
1. 开发候选 2.24.0-dev.0,尚未正式发布;以 2.23.0 为上一正式版本
|
||||||
2. 普通任务与订阅调度迁移到 node-cron,保留旧规则兼容及日期/星期 OR 语义,补充宏和夏令时说明
|
2. Alpine 与 Debian 镜像统一使用 Node.js 24,升级 sqlite3 至 1.1.4
|
||||||
3. 修复已失效排队任务仍被执行的问题,完善任务进程超时、停止和 Shell 环境传递
|
3. Alpine 保留 arm/v6 和 arm/v7;Debian 暂不提供 arm/v7 镜像
|
||||||
4. 修复系统令牌主动续期与认证缓存并发更新;Shell API 遇到 401 时刷新令牌并重试一次
|
4. ARM64 升级包在原生 runner 构建,避免 x64 QEMU 下的归档异常
|
||||||
5. 降低任务启动开销,JS/Python 通知依赖改为首次调用时加载
|
5. 升级前备份完整数据,并在目标设备验证脚本、依赖和通知
|
||||||
6. 加固环境变量、订阅参数、文件访问和 SSH 配置输入,改善资源冲突与 CLI 错误反馈
|
|
||||||
7. 修复页面升级、ql update 与静态资源版本同步,完善离线备份和升级产物校验
|
|
||||||
8. 修复 Debian 依赖安装、通知与调度回归,补齐前端中英文翻译
|
|
||||||
9. 最小修补 HTTP 压缩、上传、gRPC、代理 IP、WebSocket 和 JSON 查询相关依赖漏洞
|
|
||||||
10. 远程 CLI 更新至 0.1.2,新增脚本历史命令,最低 Node.js 版本仍为 22.12
|
|
||||||
11. 本次为 Python 3.10 兼容镜像的最后一个版本,发布后停止维护;升级前备份完整数据并验证脚本、依赖和通知
|
|
||||||
|
|||||||
Reference in New Issue
Block a user