Compare commits

..
Author SHA1 Message Date
whyour 930fabd332 ci: add opt-in development upgrade bundle releases 2026-10-18 21:45:09 +08:00
whyour 4b4350a838 ci: publish version upgrade bundles with master images 2026-10-18 21:17:03 +08:00
whyour 86c37f426a feat: ship preflight-verified online and offline platform upgrades
Build complete platform tar.gz bundles with production dependencies for 13
Linux architecture/libc targets, including ARMv6 glibc and musl. Share archive,
payload, runtime and dependency verification across online preparation and
manual offline installation while retaining the existing Node runtime.

Publish readiness only after an isolated backend starts successfully. Repair
verified dependency failures once with the frozen lockfile; offline repair
requires explicit opt-in. Preserve configured proxies and bound memory,
concurrency, process lifetimes and cleanup on small devices.

Verify the running build after explicit restart, restore prior application
files and dependencies on failure, and retain backups when the failed service
cannot stop. Complete cancellation recovery outside the aborted signal, reap
descendants as their supervisor exits, and keep operation-only upgrade
variables out of both Bash and CLI service startup environments.

Keep dialog/sidebar states consistent, hide completed notices, and retain
menu/user controls in narrow desktop viewports with long-name truncation.

Validation: 791 backend/frontend tests passed (8 platform skips), 294 CLI
tests passed (5 platform skips), backend/CLI type checks and standalone offline
installer build. On dev, 29 additional Linux fault regressions passed without
external networking, including real Node/PM2 SIGTERM rollback. Earlier full
bundle application and ARM1176/glibc prestart acceptance remain recorded.

Make the mirror notification timing regression respect Bash SECONDS integer
resolution: require two clock ticks and assert the logged retry threshold.
Validated with Node 20 full regression and 12 repeated boundary runs.
2026-10-18 17:28:18 +08:00
53 changed files with 406 additions and 2496 deletions
+48 -107
View File
@@ -11,7 +11,6 @@ on:
- "cli/src/internal/maintenance/platformBundle.cjs"
- "cli/src/internal/maintenance/upgradePrestart.cjs"
- "cli/src/internal/maintenance/panelUpgrade.cjs"
- "cli/src/internal/maintenance/nodeRuntime.cjs"
- "!.github/workflows/cli-package.yml"
branches:
- "master"
@@ -20,6 +19,10 @@ on:
- "v*"
workflow_dispatch:
inputs:
build_development_bundles:
description: Develop only - build and publish shared develop/debian-dev upgrade packages
type: boolean
default: false
static_sync_only:
description: Synchronize an existing static snapshot to GitLab and verify Gitee
type: boolean
@@ -39,10 +42,10 @@ permissions:
# Keep master image tags and their bundle indexes in one serialized release.
# A rerun may intentionally replace a version, including from the same commit.
# Develop image builds and their shared bundle index serialize separately.
# Git-tag builds and static synchronization remain independent.
# Manual development bundle releases are serialized independently of master.
# Ordinary develop pushes and Git-tag builds remain independent.
concurrency:
group: version-images-${{ github.ref == 'refs/heads/master' && 'master' || (github.ref == 'refs/heads/develop' && !inputs.static_sync_only && 'develop') || format('{0}-{1}', github.run_id, github.run_attempt) }}
group: version-images-${{ github.ref == 'refs/heads/master' && 'master' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/develop' && inputs.build_development_bundles && 'develop-bundles') || format('{0}-{1}', github.run_id, github.run_attempt) }}
cancel-in-progress: false
jobs:
@@ -80,7 +83,7 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version-file: ".nvmrc"
node-version: "20"
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
@@ -91,9 +94,6 @@ jobs:
pnpm build:back
pnpm build:info
- name: Pack the npm panel archive from the verified build
run: node scripts/verify-panel-package.cjs pack "$RUNNER_TEMP/npm-panel"
- uses: actions/upload-artifact@v6
with:
name: qinglong-static-${{ github.sha }}
@@ -101,31 +101,19 @@ jobs:
if-no-files-found: error
include-hidden-files: true
- uses: actions/upload-artifact@v6
with:
name: qinglong-npm-${{ github.sha }}
path: ${{ runner.temp }}/npm-panel/
if-no-files-found: error
retention-days: 14
- name: copy to static repo
env:
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
GITHUB_BRANCH: ${{ github.ref_name }}
GITHUB_REF_TYPE: ${{ github.ref_type }}
STATIC_TOKEN: ${{ secrets.API_TOKEN }}
run: |
: "${STATIC_TOKEN:?Missing static repository token}"
bash scripts/publish-static.sh "https://x-access-token:${STATIC_TOKEN}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_REF_TYPE"
bash scripts/publish-static.sh "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_REF_TYPE"
- name: publish verified upgrade download sizes
env:
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
GITHUB_BRANCH: ${{ github.ref_name }}
STATIC_TOKEN: ${{ secrets.API_TOKEN }}
run: |
: "${STATIC_TOKEN:?Missing static repository token}"
node scripts/publish-upgrade-info.cjs "https://x-access-token:${STATIC_TOKEN}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_SHA"
run: node scripts/publish-upgrade-info.cjs "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_SHA"
static_gitlab:
needs: build-static
@@ -550,78 +538,30 @@ jobs:
cache-from: type=registry,ref=whyour/qinglong:cache-debian-python3.10
cache-to: type=registry,ref=whyour/qinglong:cache-debian-python3.10,mode=max
# One unmodified amd64 package for migration acceptance only. This does not
# publish bundles or advance development/formal channel indexes.
build-npm-migration-bundle:
needs: build-debian
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
- uses: docker/setup-buildx-action@v4
- name: Export the official platform package from this run's image digest
env:
IMAGE_DIGEST: ${{ needs.build-debian.outputs.image-digest }}
run: |
set -euo pipefail
[[ "$IMAGE_DIGEST" =~ ^sha256:[a-f0-9]{64}$ ]]
image="ghcr.io/${GITHUB_REPOSITORY}@${IMAGE_DIGEST}"
docker buildx build --platform linux/amd64 --build-arg "BASE_IMAGE=$image" --file docker/Dockerfile.upgrade-bundle --output "type=local,dest=$RUNNER_TEMP/npm-migration-bundle" .
- uses: actions/upload-artifact@v6
with:
name: npm-migration-bundle-${{ github.sha }}
path: ${{ runner.temp }}/npm-migration-bundle/*
if-no-files-found: error
retention-days: 3
verify-npm-package:
needs: [build-static, build-npm-migration-bundle]
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
node: ['20.18.1', '24']
container: node:${{ matrix.node }}-bookworm
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v6
with:
name: qinglong-npm-${{ github.sha }}
path: ${{ runner.temp }}/npm-panel/
- uses: actions/download-artifact@v6
with:
name: npm-migration-bundle-${{ github.sha }}
path: ${{ runner.temp }}/npm-migration-bundle/
- name: Install isolated service test tools
run: |
apt-get update
apt-get install -y --no-install-recommends python3 openssl curl jq nginx procps
# The published legacy launcher resets PATH to standard directories.
# /usr/local is confined to this disposable job container; PM2_HOME
# remains private to each verification run.
npm install --global --prefix /usr/local --no-audit --no-fund pm2@6.0.14
- name: Install and upgrade the exact npm archive
env:
QL_PANEL_PACKAGE_TEST_ISOLATED: '1'
QL_PANEL_TEST_PM2: /usr/local/bin/pm2
QL_PANEL_TEST_BUNDLE_DIR: ${{ runner.temp }}/npm-migration-bundle
run: node scripts/verify-panel-package.cjs verify "$RUNNER_TEMP/npm-panel" "$RUNNER_TEMP/npm-panel-results"
- uses: actions/upload-artifact@v6
with:
name: qinglong-npm-verification-${{ matrix.node }}-${{ github.sha }}
path: ${{ runner.temp }}/npm-panel-results/
if-no-files-found: error
retention-days: 14
publish:
if: ${{ github.repository == 'whyour/qinglong' && github.ref == 'refs/heads/master' }}
needs: [build-alpine, build-debian, build-alpine310, build-debian310, verify-npm-package]
needs: [build-alpine, build-debian, build-alpine310, build-debian310]
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: "8.3.1"
- uses: actions/setup-node@v3
with:
node-version: "20"
cache: "pnpm"
- name: build front and back
run: |
pnpm install --frozen-lockfile
pnpm build:front
pnpm build:back
- name: Set up Node for trusted publishing
uses: actions/setup-node@v6
with:
@@ -629,38 +569,25 @@ jobs:
registry-url: https://registry.npmjs.org
package-manager-cache: false
- uses: actions/download-artifact@v6
with:
name: qinglong-npm-${{ github.sha }}
path: npm-panel/
- name: Publish npm package with OIDC
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
archives=(npm-panel/*.tgz)
if [ "${#archives[@]}" -ne 1 ]; then
echo '::error::Expected exactly one verified panel npm archive.'
exit 1
fi
archive="${archives[0]}"
metadata=$(tar -xOf "$archive" package/package.json)
name=$(jq -er '.name | select(. == "@whyour/qinglong")' <<< "$metadata")
version=$(jq -er '.version | select(type == "string")' <<< "$metadata")
name=$(node -p 'require("./package.json").name')
version=$(node -p 'require("./package.json").version')
if npm view "$name@$version" version --json --registry=https://registry.npmjs.org > "$RUNNER_TEMP/npm-version.json" 2> "$RUNNER_TEMP/npm-version-error.log"; then
jq -e --arg version "$version" '. == $version' "$RUNNER_TEMP/npm-version.json" > /dev/null
echo "::notice::$name@$version is already published; skipping npm publication for this image rebuild."
elif jq -e '.error.code == "E404"' "$RUNNER_TEMP/npm-version.json" > /dev/null; then
npm publish "$archive" --ignore-scripts --access public --registry=https://registry.npmjs.org
npm publish --access public --registry=https://registry.npmjs.org
else
echo '::error::Could not check the published npm version; refusing to publish.'
exit 1
fi
build-upgrade-bundles:
# 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') }}
# Development packages are opt-in; both channels use this run's image digests.
if: ${{ !inputs.static_sync_only && (github.ref == 'refs/heads/master' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/develop' && inputs.build_development_bundles)) }}
needs: [build-alpine, build-debian]
runs-on: ubuntu-22.04
timeout-minutes: 90
@@ -679,6 +606,7 @@ jobs:
- { platform: linux/s390x, libc: musl, name: s390x-musl }
- { platform: linux/386, libc: musl, name: ia32-musl }
- { platform: linux/amd64, libc: glibc, name: x64-glibc }
- { platform: linux/arm/v6, libc: glibc, name: armv6-glibc }
- { platform: linux/arm/v7, libc: glibc, name: armv7-glibc }
- { platform: linux/arm64, libc: glibc, name: arm64-glibc }
- { platform: linux/ppc64le, libc: glibc, name: ppc64-glibc }
@@ -693,6 +621,7 @@ jobs:
env:
ALPINE_DIGEST: ${{ needs.build-alpine.outputs.image-digest }}
DEBIAN_DIGEST: ${{ needs.build-debian.outputs.image-digest }}
GH_TOKEN: ${{ github.token }}
BUNDLE_LIBC: ${{ matrix.libc }}
BUNDLE_PLATFORM: ${{ matrix.platform }}
run: |
@@ -701,7 +630,18 @@ jobs:
if [ "$BUNDLE_LIBC" = glibc ]; then digest="$DEBIAN_DIGEST"; fi
test -n "$digest"
image="ghcr.io/${GITHUB_REPOSITORY}@${digest}"
docker buildx build --platform "$BUNDLE_PLATFORM" --build-arg "BASE_IMAGE=$image" --file docker/Dockerfile.upgrade-bundle --output type=local,dest=output/upgrade .
if [ "$BUNDLE_PLATFORM/$BUNDLE_LIBC" = linux/arm/v6/glibc ]; then
# This amd64 helper executes a genuine Raspbian ARMv6 userspace under
# ARM1176 QEMU. It is a CI validator, not a user runtime or bundle.
echo "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
validator="ghcr.io/${GITHUB_REPOSITORY}:upgrade-armv6-glibc-validator-${GITHUB_SHA}"
mkdir -p output/validator
docker buildx build --platform linux/amd64 --build-arg "SOURCE_IMAGE=$image" --file docker/Dockerfile.armv6-glibc --target runtime --tag "$validator" --push --metadata-file output/validator/image.json .
validator_digest="$(jq -er '."containerimage.digest"' output/validator/image.json)"
docker buildx build --platform linux/amd64 --build-arg "BASE_IMAGE=${validator%:*}@${validator_digest}" --file docker/Dockerfile.upgrade-bundle-armv6-glibc --output type=local,dest=output/upgrade .
else
docker buildx build --platform "$BUNDLE_PLATFORM" --build-arg "BASE_IMAGE=$image" --file docker/Dockerfile.upgrade-bundle --output type=local,dest=output/upgrade .
fi
- uses: actions/upload-artifact@v6
with:
name: upgrade-${{ matrix.name }}-${{ github.sha }}
@@ -709,7 +649,7 @@ jobs:
if-no-files-found: error
publish-upgrade-bundles:
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.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/develop' && inputs.build_development_bundles)) }}
needs: build-upgrade-bundles
runs-on: ubuntu-latest
permissions:
@@ -736,4 +676,5 @@ jobs:
- name: Publish complete packages before advancing the channel
env:
GH_TOKEN: ${{ github.token }}
QL_BUILD_DEVELOPMENT_BUNDLES: ${{ inputs.build_development_bundles }}
run: node scripts/publish-upgrade-bundles.cjs output/upgrade "$GITHUB_SHA" "$GITHUB_REPOSITORY" "$GITHUB_REF_NAME"
+8 -51
View File
@@ -6,11 +6,6 @@ on:
- '.github/workflows/release-readiness.yml'
- 'package.json'
- '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:
permissions:
@@ -46,13 +41,6 @@ jobs:
path: static/
include-hidden-files: true
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:
needs: [regression, static]
@@ -133,47 +121,16 @@ jobs:
docker run --rm --network none --entrypoint node \
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")'
- name: Load ARMv6 image for SQLite and Node regression checks
if: startsWith(matrix.image, 'alpine')
uses: docker/build-push-action@v7
with:
context: .
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')
- name: Export native Debian image for isolated dev validation
if: matrix.image == 'debian'
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
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' || matrix.image == 'alpine'
if: matrix.image == 'debian'
with:
name: release-readiness-${{ matrix.image }}-image
path: release-readiness-${{ matrix.image }}-image.*
name: release-readiness-debian-image
path: release-readiness-debian-image.*
compression-level: 0
if-no-files-found: error
-1
View File
@@ -29,7 +29,6 @@ __pycache__
/shell/preload/*-notify.json
/shell/preload/__ql_notify__.*
/shell/preload/lang_env.sh
/shell/upgrade-lock.yaml
.deepseek/
.claude/
+22
View File
@@ -0,0 +1,22 @@
/.tmp/
/.github/
/.vscode/
/.history/
/back/**/*.ts
/back/**/*.json
/cli/
/data/
/src/
/static/**/*.js.map
/static/**/*.gz
/.editorconfig
/.gitignore
/.prettierignore
/.prettierrc
/.umirc.ts
/nodemon.json
/pnpm-lock.yaml
/tsconfig.back.json
/tsconfig.json
/typings.d.ts
/.env
-1
View File
@@ -1 +0,0 @@
22
+2 -29
View File
@@ -4,25 +4,6 @@ import 'reflect-metadata';
import dns from 'dns';
import http from 'http';
// The first scheduler connection can outlive its short RPC deadline. Let the
// normal single-flight recovery finish within the supervised startup budget.
async function waitForUpgradeScheduler(
readiness: { recover(): Promise<boolean> },
timeoutMs: number,
retryMs = 250,
) {
const deadline = Date.now() + timeoutMs;
do {
if (await readiness.recover()) return;
const remaining = deadline - Date.now();
if (remaining <= 0) break;
await new Promise((resolve) =>
setTimeout(resolve, Math.min(retryMs, remaining)),
);
} while (Date.now() < deadline);
throw new Error('Upgrade scheduler readiness failed');
}
async function probeUpgrade() {
if (!process.send || process.env.QL_UPGRADE_PROBE !== '1')
throw new Error('Upgrade probe requires its isolated supervisor');
@@ -60,16 +41,8 @@ async function probeUpgrade() {
config.grpcPort = await grpc.initialize();
const { default: cronClient } = await import('../schedule/client');
cronClient.readiness.configure(async () => {});
await waitForUpgradeScheduler(
cronClient.readiness,
process.arch === 'arm' &&
Number(
(process.config.variables as { arm_version?: string | number })
.arm_version,
) === 6
? 60000
: 10000,
);
if (!(await cronClient.readiness.recover()))
throw new Error('Upgrade scheduler readiness failed');
const app = await createHttpApplication();
loadExpress({ app });
const listener = await server.initialize(app, 0);
+28 -85
View File
@@ -4,6 +4,7 @@ import taskLimit from './pLimit';
import Logger from '../loaders/logger';
import { ICron } from '../protos/cron';
import { CrontabModel, CrontabStatus } from '../data/cron';
import { killTask } from '../config/util';
import { RunningInstanceModel, InstanceStatus } from '../data/runningInstance';
import dayjs from 'dayjs';
import { observeChildProcess, asError } from './childProcess';
@@ -16,7 +17,7 @@ export function runCron(
const execute = async () => {
try {
if (!isCurrent()) return;
// A single-instance trigger must not interrupt work already in progress.
// Check if the cron is already running and stop it (only if multiple instances are not allowed)
try {
const existingCron = await CrontabModel.findOne({
where: { id: Number(cron.id) },
@@ -29,93 +30,35 @@ export function runCron(
const allowSingleInstances =
existingCron?.allow_multiple_instances === 0;
if (allowSingleInstances) {
// A queued manual run is an active reservation, even if its stored PID
// belongs to a previous execution.
if (
existingCron.status === CrontabStatus.queued &&
existingCron.pid
) {
Logger.info(
'[schedule][Skip queued single-instance task] Task ID: %s, PID: %s',
cron.id,
existingCron.pid,
);
return;
}
if (
existingCron.status === CrontabStatus.running &&
existingCron.pid
) {
let missing = false;
try {
process.kill(existingCron.pid, 0);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ESRCH') missing = true;
else if (code !== 'EPERM') throw error;
}
if (!missing) {
Logger.info(
'[schedule][Skip active single-instance task] Task ID: %s, PID: %s',
cron.id,
existingCron.pid,
);
return;
}
// Capture old instance IDs before releasing the stale cron state so
// a concurrent replacement cannot have its instance marked stopped.
const staleInstances = await RunningInstanceModel.findAll({
attributes: ['id'],
if (
allowSingleInstances &&
existingCron &&
existingCron.pid &&
(existingCron.status === CrontabStatus.running ||
existingCron.status === CrontabStatus.queued)
) {
Logger.info(
t('[schedule][停止已运行任务] 任务ID: %s, PID: %s'),
cron.id,
existingCron.pid,
);
await killTask(existingCron.pid, true);
// Mark old running instances as stopped
const stoppedAt = dayjs().unix();
await RunningInstanceModel.update(
{ status: InstanceStatus.stopped, finished_at: stoppedAt },
{
where: {
cron_id: Number(cron.id),
pid: existingCron.pid,
status: InstanceStatus.running,
},
});
if (!isCurrent()) return;
const [recovered] = await CrontabModel.update(
{
status: CrontabStatus.idle,
pid: null,
queued_token: null,
} as any,
{
where: {
id: Number(cron.id),
pid: existingCron.pid,
status: CrontabStatus.running,
...(existingCron.log_path !== undefined
? { log_path: existingCron.log_path }
: {}),
...(existingCron.queued_token !== undefined
? { queued_token: existingCron.queued_token }
: {}),
...(existingCron.last_execution_time !== undefined
? { last_execution_time: existingCron.last_execution_time }
: {}),
...(existingCron.isDisabled !== undefined
? { isDisabled: existingCron.isDisabled }
: {}),
allow_multiple_instances: 0,
},
},
);
if (recovered !== 1) return;
const staleIds = staleInstances.map((instance) => instance.id!);
if (staleIds.length) {
await RunningInstanceModel.update(
{ status: InstanceStatus.stopped, finished_at: dayjs().unix() },
{ where: { id: staleIds, status: InstanceStatus.running } },
);
}
Logger.info(
'[schedule][Recovered stale single-instance task] Task ID: %s, PID: %s',
cron.id,
existingCron.pid,
);
}
},
);
// Update the status to idle after killing
await CrontabModel.update(
{ status: CrontabStatus.idle, pid: undefined },
{ where: { id: Number(cron.id) } },
);
}
} catch (error) {
Logger.error(
@@ -126,7 +69,7 @@ export function runCron(
throw error;
}
// Invalidation can happen during the asynchronous database check.
// In particular, invalidation can happen while stopping a previous run.
if (!isCurrent()) return;
Logger.info(
t('[schedule][开始执行任务] 参数 %s'),
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@whyour/qinglong-cli",
"version": "0.1.3",
"version": "0.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@whyour/qinglong-cli",
"version": "0.1.3",
"version": "0.1.2",
"license": "Apache-2.0",
"bin": {
"ql": "dist/npm/ql.js"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@whyour/qinglong-cli",
"version": "0.1.3",
"version": "0.1.2",
"description": "Authenticated remote management CLI for QingLong 2.x",
"type": "commonjs",
"bin": {
-4
View File
@@ -21,10 +21,6 @@ fs.copyFileSync(
path.join(root, 'src/internal/maintenance/panelUpgrade.cjs'),
path.join(root, 'dist/internal/maintenance/panelUpgrade.cjs'),
);
fs.copyFileSync(
path.join(root, 'src/internal/maintenance/nodeRuntime.cjs'),
path.join(root, 'dist/internal/maintenance/nodeRuntime.cjs'),
);
// The operational CLI may live outside the panel and has no panel node_modules.
require('esbuild').buildSync({
entryPoints: [path.join(root, 'src/internal/maintenance/platformBundle.cjs')],
@@ -1,18 +0,0 @@
// Node 24's ARMv6 JS compiler tiers can trap on ARM1176. Keep WebAssembly
// enabled for fetch/undici; --jitless disables their HTTP parser as well.
const armv6ServerArgs = Object.freeze([
'--no-opt',
'--no-sparkplug',
'--no-maglev',
]);
function serverRuntimeArgs(runtime = process) {
return runtime.arch === 'arm' &&
Number(runtime.config.variables.arm_version) === 6 &&
Number(runtime.versions.node.split('.')[0]) >= 24
? [...armv6ServerArgs]
: [];
}
module.exports = { serverRuntimeArgs, armv6ServerArgs };
if (require.main === module)
process.stdout.write(serverRuntimeArgs().join(' '));
+2 -10
View File
@@ -7,10 +7,6 @@ import type { LocalContext } from '../runtime/context';
import { atomicWrite } from '../runtime/files';
import { runProcess, checkedProcess } from '../runtime/process';
import { findDirectBackendPids } from '../runtime/backendProcesses';
const { serverRuntimeArgs, armv6ServerArgs } = require('./nodeRuntime.cjs') as {
serverRuntimeArgs(): string[];
armv6ServerArgs: readonly string[];
};
export async function repairConfiguration(
context: LocalContext,
@@ -121,11 +117,7 @@ async function stopDirectBackend(context: LocalContext): Promise<void> {
for (const line of result.stdout.split('\n')) {
const match = line.trim().match(/^(\d+)\s+(\S+)\s+(.*)$/);
if (!match || !/^node(?:\d+)?$/.test(path.basename(match[2]!))) continue;
if (
match[3] !== entry &&
match[3] !== `${armv6ServerArgs.join(' ')} ${entry}`
)
continue;
if (match[3] !== entry) continue;
const pid = Number(match[1]);
if (pid === process.pid) continue;
try {
@@ -187,7 +179,7 @@ export async function startPanel(
0o600,
);
try {
const child = spawn(process.execPath, [...serverRuntimeArgs(), entry], {
const child = spawn(process.execPath, [entry], {
...options,
detached: true,
stdio: ['ignore', log.fd, log.fd],
@@ -6,7 +6,6 @@ const os = require('node:os');
const { execFile } = require('node:child_process');
const { promisify } = require('node:util');
const tar = require('tar');
const { serverRuntimeArgs } = require('./nodeRuntime.cjs');
const {
prestartBackend,
dependencyFailure,
@@ -212,12 +211,10 @@ async function smokeDependencies(source, signal) {
`;
// Match the server's normal runtime. Node 20.18's built-in HTTP parser needs
// WebAssembly, which --jitless disables; only archive workers need that flag.
await execute(process.execPath, [...serverRuntimeArgs(process), '-e', probe], {
await execute(process.execPath, ['-e', probe], {
cwd: source,
env: { ...process.env, NODE_PATH: '', NODE_OPTIONS: '' },
// ARM1176 devices need the same startup allowance as the backend probe.
// Keep this bounded and cancellable without forcing an unnecessary repair.
timeout: nodeRuntimeArgs().length ? 300000 : 30000,
timeout: 30000,
signal,
});
}
@@ -3,7 +3,6 @@ const path = require('node:path');
const { spawn } = require('node:child_process');
const crypto = require('node:crypto');
const os = require('node:os');
const { serverRuntimeArgs } = require('./nodeRuntime.cjs');
function probeLimits(target) {
const small = target.arch === 'armv6';
@@ -123,7 +122,7 @@ async function prestartBackend(
// one backend process. Keep normal V8 mode for Node 20's HTTP/WASM support.
const child = spawn(
process.execPath,
[...serverRuntimeArgs(), `--max-old-space-size=${limits.heapMb}`, entry],
[`--max-old-space-size=${limits.heapMb}`, entry],
{
cwd: workspace,
env,
+4 -14
View File
@@ -1,8 +1,5 @@
import fs from 'node:fs/promises';
import path from 'node:path';
const { armv6ServerArgs } = require('../maintenance/nodeRuntime.cjs') as {
armv6ServerArgs: readonly string[];
};
// Linux preserves argv boundaries in cmdline. Resolve relative entrypoints from
// each process's cwd, not from the operator's cwd or a broad ps substring match.
@@ -30,18 +27,11 @@ export async function findDirectBackendPids(
);
const args = command.split('\0');
if (args.at(-1) === '') args.pop();
// Accept only the two launch shapes we own. Never treat eval, preload or
// application arguments containing this path as a running backend.
const scriptIndex =
args.length === 2
? 1
: args.length === armv6ServerArgs.length + 2 &&
armv6ServerArgs.every((flag, i) => args[i + 1] === flag)
? args.length - 1
: -1;
if (scriptIndex < 0 || !/^node(?:\d+)?$/.test(path.basename(args[0]!)))
// Both the legacy fallback and the TS fallback launch one script without
// Node flags or application arguments. Reject other command shapes.
if (args.length !== 2 || !/^node(?:\d+)?$/.test(path.basename(args[0]!)))
continue;
const script = args[scriptIndex]!;
const script = args[1]!;
if (!script) continue;
const absolute = path.isAbsolute(script)
? script
-183
View File
@@ -1,183 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const os = require('node:os');
const vm = require('node:vm');
const { createRequire } = require('node:module');
const { createContext } = require('../../dist/internal/runtime/context');
const { replaceAndReload } = require('../../dist/internal/maintenance/upgrade');
const runtime = require('../../dist/internal/maintenance/nodeRuntime.cjs');
// Change only runtime selection; execute the compiled operator, actual child
// Node and process discovery, without pretending the host CPU is ARM.
async function operatorFor(arch, armVersion, node) {
const filename = require.resolve('../../dist/internal/maintenance/operator');
const localRequire = createRequire(filename);
const module = { exports: {} };
const flags = runtime.serverRuntimeArgs({
arch,
config: { variables: { arm_version: armVersion } },
versions: { node },
});
vm.runInNewContext(
await fs.readFile(filename, 'utf8'),
{
module,
exports: module.exports,
process,
setTimeout,
clearTimeout,
require(name) {
return name === './nodeRuntime.cjs'
? { ...runtime, serverRuntimeArgs: () => [...flags] }
: localRequire(name);
},
},
{ filename },
);
return { ...module.exports, flags };
}
for (const [arch, arm, node] of [
['arm', 6, '24.18.1'],
['arm', 6, '20.18.1'],
['arm', 6, '22.20.0'],
['arm', 7, '24.18.1'],
['arm64', undefined, '24.18.1'],
['x64', undefined, '24.18.1'],
])
test(`CLI direct startup and stop use compatible arguments: ${arch}/${arm}/${node}`, async (t) => {
const operator = await operatorFor(arch, arm, node);
const root = await fs.realpath(
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-runtime-start-')),
);
const context = createContext(
{ root },
{ PATH: process.platform === 'linux' ? '' : '/usr/bin:/bin', QL_LANG: 'en' },
);
const entry = path.join(context.paths.dir_static, 'build/app.js');
let pid;
t.after(async () => {
if (pid) {
try {
process.kill(pid, 'SIGKILL');
} catch {}
}
await fs.rm(root, { recursive: true, force: true });
});
await fs.mkdir(path.dirname(entry), { recursive: true });
await fs.writeFile(
entry,
`require('fs').writeFileSync(${JSON.stringify(
path.join(root, 'runtime.json'),
)}, JSON.stringify({args:process.execArgv,pid:process.pid}));setInterval(()=>{},1000);`,
);
const started = await operator.startPanel(context);
pid = started.pid;
const report = JSON.parse(
await fs.readFile(path.join(root, 'runtime.json')),
);
assert.equal(started.manager, 'node');
assert.deepEqual(report.args, operator.flags);
assert.equal(report.pid, pid);
await operator.stopPanel(context);
for (let n = 0; n < 100; n++) {
try {
process.kill(pid, 0);
} catch (e) {
if (e.code === 'ESRCH') {
pid = undefined;
break;
}
throw e;
}
await new Promise((resolve) => setTimeout(resolve, 20));
}
assert.equal(
pid,
undefined,
'the exact backend must stop even with Node flags',
);
});
test(
'ARMv6 CLI replacement stops flagged new backend before restoring old backend and data',
{
skip: process.platform !== 'linux',
timeout: 20000,
},
async (t) => {
const operator = await operatorFor('arm', 6, '24.18.1');
const root = await fs.realpath(
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-arm-rollback-')),
);
const context = createContext({ root }, { PATH: '', QL_LANG: 'en' });
const entry = path.join(context.paths.dir_static, 'build/app.js');
const incoming = path.join(root, 'incoming.js'),
journal = path.join(root, 'journal.jsonl');
const record = (version) =>
`const fs=require('fs');fs.appendFileSync(${JSON.stringify(
journal,
)},JSON.stringify({version:${JSON.stringify(
version,
)},pid:process.pid,args:process.execArgv})+'\\n');setInterval(()=>{},1000);`;
await fs.mkdir(path.dirname(entry), { recursive: true });
await fs.mkdir(context.data, { recursive: true });
await fs.writeFile(path.join(root, '.env'), 'KEEP=custom\n', {
mode: 0o600,
});
await fs.writeFile(path.join(context.data, 'keep'), 'old data');
await fs.writeFile(entry, record('old'));
await fs.writeFile(incoming, record('new'));
const pids = [];
t.after(async () => {
for (const pid of pids) {
try {
process.kill(pid, 'SIGKILL');
} catch {}
}
await fs.rm(root, { recursive: true, force: true });
});
pids.push((await operator.startPanel(context)).pid);
await assert.rejects(
replaceAndReload(context, [{ source: incoming, target: entry }], {
stop: (ctx) => operator.stopPanel(ctx),
start: async (ctx, recover) => {
const result = await operator.startPanel(ctx);
pids.push(result.pid);
if (!recover) throw new Error('injected new-version health failure');
},
}),
/injected new-version health failure/,
);
const launches = (await fs.readFile(journal, 'utf8'))
.trim()
.split('\n')
.map(JSON.parse);
assert.deepEqual(
launches.map((x) => x.version),
['old', 'new', 'old'],
);
for (const row of launches) assert.deepEqual(row.args, operator.flags);
for (const row of launches.slice(0, -1))
assert.throws(() => process.kill(row.pid, 0), { code: 'ESRCH' });
process.kill(launches.at(-1).pid, 0);
assert.equal(
await fs.readFile(path.join(root, '.env'), 'utf8'),
'KEEP=custom\n',
);
assert.equal((await fs.stat(path.join(root, '.env'))).mode & 0o777, 0o600);
assert.equal(
await fs.readFile(path.join(context.data, 'keep'), 'utf8'),
'old data',
);
assert.equal(await fs.readFile(entry, 'utf8'), record('old'));
assert.ok(
!(await fs.readdir(path.dirname(entry))).some((x) =>
x.includes('ql-backup'),
),
);
await operator.stopPanel(context);
},
);
+3 -47
View File
@@ -3,46 +3,7 @@ const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const os = require('node:os');
const {
findDirectBackendPids,
} = require('../../dist/internal/runtime/backendProcesses');
test('backend discovery accepts only owned compatibility flags and the exact installation entry', async (t) => {
const root = await fs.realpath(
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-flagged-proc-')),
);
t.after(() => fs.rm(root, { recursive: true, force: true }));
const proc = path.join(root, 'proc'),
panel = path.join(root, 'panel with spaces');
const other = path.join(root, 'other/static/build/app.js');
const entry = path.join(panel, 'static/build/app.js');
for (const file of [entry, other]) {
await fs.mkdir(path.dirname(file), { recursive: true });
await fs.writeFile(file, '');
}
const flags = ['--no-opt', '--no-sparkplug', '--no-maglev'];
const cases = [
[['/usr/bin/node', ...flags, entry], true],
[['node24', ...flags, 'static/build/app.js'], true],
[['node', ...flags, other], false],
[['node', '-e', entry], false],
[['node', '-r', entry], false],
[['node', ...flags, entry, 'argument'], false],
[['node', '--no-opt', entry], false],
[['bash', ...flags, entry], false],
[['node', ...flags, '-e', entry], false],
];
const expected = [];
for (const [i, [args, matches]] of cases.entries()) {
const pid = 910001 + i,
directory = path.join(proc, String(pid));
await fs.mkdir(directory, { recursive: true });
await fs.writeFile(path.join(directory, 'cmdline'), args.join('\0') + '\0');
await fs.symlink(panel, path.join(directory, 'cwd'));
if (matches) expected.push(pid);
}
assert.deepEqual((await findDirectBackendPids(entry, proc)).sort(), expected);
});
const { findDirectBackendPids } = require('../../dist/internal/runtime/backendProcesses');
test('Linux backend discovery scopes legacy relative and absolute commands to one installation', async (t) => {
const root = await fs.realpath(
@@ -103,10 +64,7 @@ test(
const { spawn } = require('node:child_process');
const { once } = require('node:events');
const { createContext } = require('../../dist/internal/runtime/context');
const {
stopPanel,
startPanel,
} = require('../../dist/internal/maintenance/operator');
const { stopPanel, startPanel } = require('../../dist/internal/maintenance/operator');
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-live-proc-'));
const children = [];
let fallbackPid;
@@ -265,9 +223,7 @@ for (const language of ['zh', 'en', 'unsupported']) {
);
t.after(() => fs.rm(root, { recursive: true, force: true }));
const { createContext } = require('../../dist/internal/runtime/context');
const {
startPanel,
} = require('../../dist/internal/maintenance/operator');
const { startPanel } = require('../../dist/internal/maintenance/operator');
const context = createContext({ root }, { PATH: '', QL_LANG: language });
const entry = path.join(context.paths.dir_static, 'build/app.js');
await fs.mkdir(path.dirname(entry), { recursive: true });
+3 -25
View File
@@ -3,7 +3,6 @@
FROM --platform=$BUILDPLATFORM node:18-alpine3.18 AS builder
ARG TARGETARCH
ARG TARGETVARIANT
ENV NPM_CONFIG_PREFIX=/opt/node-global
ENV PATH=/opt/node-global/bin:${PATH}
@@ -18,25 +17,13 @@ RUN set -x \
ppc64le) NODE_ARCH=ppc64 ;; \
*) NODE_ARCH="${TARGETARCH}" ;; \
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_arch="${NODE_ARCH}" \
npm_config_target_libc="${NODE_LIBC}" \
pnpm install --prod --frozen-lockfile --side-effects-cache=false
# 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
npm_config_target_libc=musl \
pnpm install --prod --frozen-lockfile
FROM python:3.11-alpine
ARG TARGETARCH
ARG TARGETVARIANT
ARG QL_MAINTAINER="whyour"
LABEL maintainer="${QL_MAINTAINER}"
ARG QL_URL=https://github.com/${QL_MAINTAINER}/qinglong.git
@@ -57,8 +44,7 @@ EXPOSE 5700
COPY --from=builder /opt/node-global/lib/node_modules/. /usr/local/lib/node_modules/
COPY --from=builder /opt/node-global/bin/. /usr/local/bin/
RUN --mount=type=bind,from=node-runtime-amd64,source=/usr/local/bin/node,target=/tmp/node-runtime-amd64 \
set -x \
RUN set -x \
&& apk update -f \
&& apk upgrade \
&& apk --no-cache add -f bash \
@@ -76,10 +62,6 @@ RUN --mount=type=bind,from=node-runtime-amd64,source=/usr/local/bin/node,target=
netcat-openbsd \
unzip \
npm \
&& if [ "$TARGETARCH" = amd64 ]; then \
cp /tmp/node-runtime-amd64 /usr/local/bin/node; \
ln -sf /usr/local/bin/node /usr/bin/node; \
fi \
&& rm -rf /var/cache/apk/* \
&& apk update \
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
@@ -104,10 +86,6 @@ COPY static/ /ql/static/
COPY docker/verify-build.cjs docker/build-manifest.cjs /tmp/
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 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 \
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
+3 -25
View File
@@ -3,7 +3,6 @@
FROM --platform=$BUILDPLATFORM node:18-alpine3.18 AS builder
ARG TARGETARCH
ARG TARGETVARIANT
ENV NPM_CONFIG_PREFIX=/opt/node-global
ENV PATH=/opt/node-global/bin:${PATH}
@@ -18,25 +17,13 @@ RUN set -x \
ppc64le) NODE_ARCH=ppc64 ;; \
*) NODE_ARCH="${TARGETARCH}" ;; \
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_arch="${NODE_ARCH}" \
npm_config_target_libc="${NODE_LIBC}" \
pnpm install --prod --frozen-lockfile --side-effects-cache=false
# 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
npm_config_target_libc=musl \
pnpm install --prod --frozen-lockfile
FROM python:3.10-alpine
ARG TARGETARCH
ARG TARGETVARIANT
ARG QL_MAINTAINER="whyour"
LABEL maintainer="${QL_MAINTAINER}"
ARG QL_URL=https://github.com/${QL_MAINTAINER}/qinglong.git
@@ -57,8 +44,7 @@ EXPOSE 5700
COPY --from=builder /opt/node-global/lib/node_modules/. /usr/local/lib/node_modules/
COPY --from=builder /opt/node-global/bin/. /usr/local/bin/
RUN --mount=type=bind,from=node-runtime-amd64,source=/usr/local/bin/node,target=/tmp/node-runtime-amd64 \
set -x \
RUN set -x \
&& apk update -f \
&& apk upgrade \
&& apk --no-cache add -f bash \
@@ -76,10 +62,6 @@ RUN --mount=type=bind,from=node-runtime-amd64,source=/usr/local/bin/node,target=
netcat-openbsd \
unzip \
npm \
&& if [ "$TARGETARCH" = amd64 ]; then \
cp /tmp/node-runtime-amd64 /usr/local/bin/node; \
ln -sf /usr/local/bin/node /usr/bin/node; \
fi \
&& rm -rf /var/cache/apk/* \
&& apk update \
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
@@ -104,10 +86,6 @@ COPY static/ /ql/static/
COPY docker/verify-build.cjs docker/build-manifest.cjs /tmp/
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 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 \
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
+46
View File
@@ -0,0 +1,46 @@
# ARMv7 is a source of application files only. None of its system libraries or
# Node binaries are executed or copied into the ARMv6 runtime.
ARG SOURCE_IMAGE
FROM --platform=linux/arm/v7 ${SOURCE_IMAGE} AS source
# PRoot keeps ARM1176 execution isolated, without installing host binfmt handlers.
FROM --platform=linux/amd64 debian:bookworm-slim@sha256:7c7b2c966bc9ee8cedfeef67e0e279108992c77681fa595db4a9d65c06ccc587 AS runtime
COPY --from=source /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
RUN sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list.d/debian.sources && \
apt-get update && apt-get install -y --no-install-recommends \
debootstrap gpg gpgv ca-certificates curl qemu-user-static && \
rm -rf /var/lib/apt/lists/*
# Debian's PRoot 5.1 predates faccessat2, which modern QEMU uses for guest
# access checks. Pin the upstream static tracer instead of misresolving paths.
RUN curl -fsSL https://github.com/proot-me/proot/releases/download/v5.5.0/proot -o /usr/local/bin/proot && \
echo 'db185300a9daabbce86aca7632d8c7fdc4249f6f153171954013b70c5f491ec0 /usr/local/bin/proot' | sha256sum -c - && \
chmod 755 /usr/local/bin/proot && proot --version
RUN curl -fsSL https://archive.raspbian.org/raspbian.public.key -o /tmp/raspbian.key && \
echo 'ca59cd4f2bcbc3a1d41ba6815a02a8dc5c175467a59bd87edeac458f4a5345de /tmp/raspbian.key' | sha256sum -c - && \
gpg --dearmor -o /usr/share/keyrings/raspbian.gpg /tmp/raspbian.key && \
debootstrap --foreign --arch=armhf --variant=minbase \
--keyring=/usr/share/keyrings/raspbian.gpg \
bullseye /armv6 https://archive.raspbian.org/raspbian
COPY docker/armv6-run /usr/local/bin/armv6-run
RUN chmod 755 /usr/local/bin/armv6-run && \
DEBOOTSTRAP_DIR=/debootstrap armv6-run /debootstrap/debootstrap --second-stage && \
cp /usr/share/keyrings/raspbian.gpg /armv6/usr/share/keyrings/raspbian.gpg && \
printf '%s\n' 'deb [signed-by=/usr/share/keyrings/raspbian.gpg] https://archive.raspbian.org/raspbian bullseye main' > /armv6/etc/apt/sources.list && \
mkdir -p /armv6/etc/ssl/certs && \
cp /etc/ssl/certs/ca-certificates.crt /armv6/etc/ssl/certs/ca-certificates.crt && \
printf '127.0.0.1 localhost\n::1 localhost\n' > /armv6/etc/hosts && \
printf '#!/bin/sh\nexit 101\n' > /armv6/usr/sbin/policy-rc.d && \
chmod 755 /armv6/usr/sbin/policy-rc.d && \
armv6-run /bin/sh -ec 'apt-get update; DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential binutils python3 ca-certificates curl git nginx procps jq sudo; rm -rf /var/lib/apt/lists/*'
# This Node is a pinned CI/test prerequisite, never part of the upgrade archive.
# Build against the minimum supported Node, including its bundled C++ headers.
RUN curl -fsSL https://unofficial-builds.nodejs.org/download/release/v20.18.1/node-v20.18.1-linux-armv6l.tar.gz -o /tmp/node.tar.gz && \
echo 'd4343a3d0fe23ddbd785537271bb0b60435edd50f5fba5bc580134592d51f011 /tmp/node.tar.gz' | sha256sum -c - && \
tar -xzf /tmp/node.tar.gz -C /armv6/usr/local --strip-components=1 && rm /tmp/node.tar.gz
COPY --from=source /ql/ /armv6/ql/
# PM2 is JavaScript. Keep the ARMv6 Node/npm and the Raspbian system libraries.
COPY --from=source /usr/local/lib/node_modules/pm2/ /armv6/usr/local/lib/node_modules/pm2/
RUN ln -s ../lib/node_modules/pm2/bin/pm2 /armv6/usr/local/bin/pm2 && \
armv6-run /bin/bash -ec 'ulimit -c 0; cd "$(readlink -f /ql/node_modules/sqlite3)"; CFLAGS="-march=armv6 -mfpu=vfp -mfloat-abi=hard" CXXFLAGS="-march=armv6 -mfpu=vfp -mfloat-abi=hard" npm_config_nodedir=/usr/local npm_config_build_from_source=true node --jitless -e '\''require("node:child_process").execFileSync(process.execPath,[require.resolve("@mapbox/node-pre-gyp/bin/node-pre-gyp"),"rebuild","--napi_build_version=6"],{stdio:"inherit"})'\''; readelf -A lib/binding/napi-v6-linux-arm/node_sqlite3.node | grep -E "Tag_CPU_arch: v6($|K)"; readelf -A lib/binding/napi-v6-linux-arm/node_sqlite3.node | grep "Tag_ABI_VFP_args: VFP registers"; rm -rf build; node --jitless -e '\''const p=process; if(p.config.variables.arm_version!=="6" && p.config.variables.arm_version!==6) process.exit(1); console.log(JSON.stringify({node:p.version,arm:p.config.variables.arm_version,float:p.config.variables.arm_float_abi,glibc:p.report.getReport().header.glibcVersionRuntime}));'\'''
LABEL qinglong.qa.runtime="armv6-glibc-arm1176"
ENTRYPOINT ["/usr/local/bin/armv6-run"]
+19 -5
View File
@@ -6,11 +6,25 @@ ENV QL_BUNDLE_BASE_IMAGE=${BASE_IMAGE}
ARG TARGETARCH
ARG TARGETVARIANT
USER root
RUN ulimit -c 0; \
if [ "$TARGETARCH/$TARGETVARIANT" = arm/v6 ]; then \
# The generic "arm" sqlite release is ARMv7. ARMv6 must compile its own addon.
RUN ulimit -c 0; 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; \
set -- --jitless; \
fi; \
node "$@" /ql/scripts/build-upgrade-bundle.cjs /ql /out
node --jitless /ql/scripts/build-upgrade-bundle.cjs /ql /out && \
for archive in /out/*.tar.gz; do \
node /ql/shell/install-bundle.cjs --root /tmp/ql-bundle-offline-smoke --file "$archive" --install && \
rm -rf /tmp/ql-bundle-offline-smoke || exit 1; \
done; \
else node /ql/scripts/build-upgrade-bundle.cjs /ql /out; fi
FROM scratch
COPY --from=bundle /out/ /
@@ -0,0 +1,13 @@
# An immutable digest of the CI-only ARM1176 validator, built in the same run.
ARG BASE_IMAGE
FROM ${BASE_IMAGE} AS bundle
ARG BASE_IMAGE
ENV QL_BUNDLE_BASE_IMAGE=${BASE_IMAGE}
RUN ulimit -c 0; armv6-run /bin/bash -ec \
'node --jitless /ql/scripts/build-upgrade-bundle.cjs /ql /out; \
for archive in /out/*.tar.gz; do \
node /ql/shell/install-bundle.cjs --root /tmp/ql-bundle-offline-smoke --file "$archive" --install; \
rm -rf /tmp/ql-bundle-offline-smoke; \
done'
FROM scratch
COPY --from=bundle /armv6/out/ /
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
set -eu
export PROOT_NO_SECCOMP=1 QEMU_CPU=arm1176
# Bind only kernel interfaces; all application files and writable state remain
# inside this container's ARMv6 root. Child processes also pass through QEMU.
exec proot -0 -q '/usr/bin/qemu-arm-static -cpu arm1176' \
-r /armv6 -b /proc -b /dev -w / "$@"
+7 -26
View File
@@ -8,17 +8,15 @@ CI 从同次构建的不可变镜像摘要导出完整 tar.gz,包含源码中
面板下载任务与本地离线入口共用 `platformBundle.cjs`:平台匹配 → 归档安全检查 → 全文件/链接/权限和锁文件校验 → SQLite/Sequelize/cron-parser/gRPC/undici 加载及 SQLite 内存库读写 → 隔离后端预启动 → 发布就绪指针。预启动使用现有 Node、临时数据目录和 loopback 随机端口,运行真实数据库建表/迁移和事务读写、gRPC 服务,以及与正常启动共用的 HTTP 中间件、路由和 WebSocket 初始化,再通过 `/api/health` 确认双服务与目标版本、提交和后端摘要;不运行任务、订阅、通知、用户启动钩子和系统依赖安装。临时库不是用户数据库副本,因此实际应用后的健康检查和回退仍不可省略。点击重启时,接口只检查就绪记录并提交后台任务;完整文件与依赖复验由独立工作进程执行,复验通过后才停止旧服务,避免小设备的预检耗时超过 HTTP 请求期限。离线升级同步面板任务状态,并与在线任务互斥;中断准备时保留之前的可用就绪包。原有服务停止、文件备份、启动健康检查及失败恢复仍由共享升级引擎处理。
正式升级包跟随 master 同一次版本镜像构建;develop 的普通提交也自动构建开发包。两者都直接使用该次 Alpine/Debian 镜像的不可变 digest。Git tag 工作流和 `static_sync_only` 不生成完整包。需要重建当前开发包时,可手动运行同一工作流:
正式升级包跟随 master 同一次版本镜像构建,直接使用该次 Alpine/Debian 镜像的不可变 digest;develop 的普通提交和 Git tag 工作流不生成或发布完整包。开发包需要手动运行现有镜像工作流:选择 develop,并勾选 `build_development_bundles`(默认为 false,不能与 `static_sync_only` 同时启用)。也可执行:
```sh
gh workflow run build-docker-image.yml --ref develop
gh workflow run build-docker-image.yml --ref develop -f build_development_bundles=true
```
开发包更新共用的 `upgrade-develop/latest.json`。`develop` 和 `debian-dev` 镜像都使用该索引,按实际 Node 架构和 musl/glibc 选择包,无需设置测试专用下载源。开发批次位于 `upgrade-build-develop-v<version>-<run-id>-<run-attempt>`,不会改写正式版本或 `upgrade-master`。同一开发渠道的镜像构建和包发布串行执行,与 master 独立。
手动运行会构建该次 develop 提交的两种镜像,并从输出的固定 digest 导出完整包,更新共用的 `upgrade-develop/latest.json`。`develop` 和 `debian-dev` 镜像都使用该索引,按实际 Node 架构和 musl/glibc 选择包,无需设置测试专用下载源。开发批次位于 `upgrade-build-develop-v<version>-<run-id>-<run-attempt>`,不会改写正式版本或 `upgrade-master`。同一开发渠道的手动发布串行执行,普通 develop CI 不受该队列影响。
开发包只保留最新一个已验证批次:所有平台资产和渠道索引的大小、SHA256 校验成功后,删除其他名称匹配的开发构建 prerelease;不删除正式包、渠道索引或无法分类的旧 release。构建或上传失败不会删除上一套可用包。旧批次被删除后,已选中旧索引但尚未下载完的客户端可能需要重新发起下载;准备失败仍保留旧服务和数据。
开发和正式包都必须在 12 个平台完成归档、解包后复验及依赖试运行,并且全部上传资产的大小和 SHA256 复验通过后,才更新各自索引。平台范围与现有 Alpine/Debian 镜像一致:ARMv6 仅提供 musl 包,不提供 ARMv6 glibc 包。
开发和正式包都必须在 13 个平台完成归档、解包后复验及依赖试运行,并且全部上传资产的大小和 SHA256 复验通过后,才更新各自索引。ARMv6 glibc 继续使用同次 Debian 应用源码及专用 ARM1176 构建和验证环境。
版本入口为 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,33 +62,16 @@ QL_DIR=/opt/qinglong QL_DATA_DIR=/opt/qinglong/data bash /opt/qinglong/shell/sta
`--install` 拒绝非空目录,不覆盖既有配置。该包安装青龙及其 Node 生产依赖;首次启动仍需已有 Python、Bash、OpenSSL、PM2、nginx 等相应运行环境,不会在线安装或替换它们。手动启动脚本会在启动工作进程前完成首次 gRPC 证书准备,避免慢设备生成密钥耗尽工作进程的就绪期限;已有证书继续复用。服务启动和系统自启按原手动安装流程配置。
ARMv6 小设备使用 `linux-armv6-musl.tar.gz`,要求已有兼容的 musl 系统及硬浮点 Node。ARMv6 glibc(例如 Raspbian/Raspberry Pi OS 32 位系统)不在平台包支持范围内,不能使用 ARMv7 或 musl 包替代;安装器仍会检查实际架构和 libc,拒绝不匹配的包。
## npm 面板包
`@whyour/qinglong` 仍通过 npm 安装生产依赖;它包含运行脚本、gRPC proto、升级引擎和完整前后端构建,不包含用户数据、Node、开发 CLI 或生产 `node_modules`。`@whyour/qinglong-cli` 是单独的远程管理包。
npm 会排除根目录的 `pnpm-lock.yaml`。主包的原生 `npm pack/publish` 在 `prepack` 中核对当前 Git 提交、干净工作区、锁文件及完整构建文件的哈希,通过后才将锁文件复制为 `shell/upgrade-lock.yaml`,安装时由仅使用 Node 内置模块的 `postinstall` 校验 SHA256 后恢复标准路径;源代码安装和 Docker 的最小依赖构建阶段继续使用原有锁文件。归档使用精确运行文件白名单,避免打入本地生成的预加载配置、缓存、测试和验收输出。静态构建中的 source map 与 gzip 文件也必须完整保留,否则清单复验会失败。
`package.json#files` 是 npm 包内容的唯一项目规则,不再维护根 `.npmignore`:npm 在根包使用 `files` 时会停用该目录的 `.npmignore` 和 `.gitignore` 规则。打包测试在没有 `.npmignore` 的独立源码目录中注入私有配置、密钥占位文件、缓存及生成的预加载配置,执行真实 `npm pack` 和安装,检查运行文件完整、私有文件及其内容不进入归档。此规则只用于 npm 包,完整平台 tar.gz 仍由平台包构建器处理。
干净源码完成 `pnpm build:front`、`pnpm build:back`、`pnpm build:info` 后,运行 `node scripts/verify-panel-package.cjs pack /tmp/qinglong-npm` 生成并检查真实 npm 归档。CI 在一次性 Linux 容器中,以 Node 20.18.1 和 Node 24 安装同一个归档,使用真实生产依赖和已安装的 `qinglong`、`ql` 入口,验证启动、损坏下载保留旧服务、在线准备与重启、离线准备与 Bash 重启及账号、配置、环境变量、任务和脚本保留。测试还安装完整性摘要固定的已发布 `@whyour/qinglong@2.23.0`,在旧服务中建立真实数据,再用新 `.tgz` 执行 npm 覆盖安装,验证账号、配置、环境变量、任务和脚本保留。之后升级到同次 Debian 镜像不可变 digest 通过正式 `Dockerfile.upgrade-bundle` 导出的原始平台包,确认运行目录变为 pnpm 布局。该单平台验收包仅上传短期 CI artifact,不发布 Release,也不推进开发或正式更新索引。
Umi 4.4.4 的构建依赖仍使用 Node 24 已移除的 `http_parser`。前端源码构建使用 `.nvmrc` 中的 Node 22,CI 从该文件读取版本,Vercel 前后端项目均配置为 Node 22.x。`package.json` 不声明 `engines.node`,避免 Vercel 用开放的最低运行版本范围覆盖项目的构建版本;`pnpm build:front` 直接执行构建,不下载或切换 Node。面板运行时仍支持 Node 20.18.1 及以上,平台包的清单与安装器独立校验最低 Node 要求,npm 实装验收继续覆盖 Node 20/24。
服务集成测试只能在专用容器中运行:设置 `QL_PANEL_PACKAGE_TEST_ISOLATED=1`、指向隔离 PM2 的 `QL_PANEL_TEST_PM2` 和指向正式构建脚本输出目录的 `QL_PANEL_TEST_BUNDLE_DIR`,然后执行 `node scripts/verify-panel-package.cjs verify /tmp/qinglong-npm /tmp/qinglong-npm-results`。容器需已有 Bash、curl、OpenSSL、Python、jq、nginx 和 procps。旧正式包会重置 PATH,因此验收容器将 PM2 6.0.14 安装到 `/usr/local/bin/pm2`,并以此设置 `QL_PANEL_TEST_PM2`;隔离通过专属容器和独立 `PM2_HOME` 保证。测试创建独立的 npm prefix、HOME、PM2_HOME 和数据目录,结束后清理。
通过 npm 覆盖升级前,应先停止服务,执行新包安装,再启动服务。正常 npm 安装的 `postinstall` 会从当前路径对应的 npm 旧包临时目录中自动保留 `.env`,逐字节恢复为权限 `0600` 的普通文件,不需要手工备份和恢复;新目录中已有的 `.env` 不会被覆盖。只读取包名匹配的旧包,不扫描其他安装或备份目录;异常配置(如符号链接)会使安装失败,由 npm 回退旧包,避免静默丢失配置。此机制需要启用 npm 安装脚本,不能使用 `--ignore-scripts`。持久数据仍必须放在包目录以外的 `QL_DATA_DIR`。验收直接检查真实旧 npm 包覆盖安装后配置自动保留,并校验外置用户数据。在线和离线平台包升级仍由事务保留现有 `.env`。
master 发布任务等待上述测试通过,直接发布已验证的 `.tgz`,不重新构建或打包。develop 只生成测试归档与验收记录,不发布 npm。
ARMv6 glibc 小设备(例如 Raspberry Pi 1/Zero 的 Raspbian/Raspberry Pi OS 32 位系统)选择 `linux-armv6-glibc.tar.gz`,以同一安装器执行上述离线命令。该包要求 glibc >= 2.31、ARMv6 硬浮点 Node >= 20.18.1,不适用于 ARMv5、软浮点 Node 或旧版 glibc。安装和升级均不需要 Docker,也不会下载或替换 Node。
## 平台兼容
- 最低 Node 为 20.18.1,Node-API 至少 6;安装器只检查,不替换 Node。
- 以 Node 实际运行架构、ARM 指令集和 libc 类型/版本选择包;不依据 Docker 架构别名猜测。
- glibc、musl 分包,并记录构建系统 libc 下限。系统库仍以目标设备加载测试作为最后检查。
- ARMv6 glibc 使用签名校验的 Raspbian bullseye(glibc 2.31)构建环境,以固定 SHA256 的 ARMv6 Node 20.18.1 头文件编译 SQLite,强制 `-march=armv6 -mfpu=vfp -mfloat-abi=hard`。CI 检查 ELF 的 ARMv6 和硬浮点标记,并在 QEMU ARM1176 下完成归档复验、SQLite 读写和空目录离线安装;验证辅助镜像按不可变摘要记录,不进入用户安装包。Debian ARMv7 镜像仅提供同次构建的应用文件,不复用其 Node、libc 或 SQLite 二进制。
- ARMv6 与 ARMv7 使用不同包名。ARMv6 在目标镜像中从源码编译 SQLite,禁止复用 generic `arm` 的 ARMv7 预编译件;构建需通过加载和内存库读写,失败则不发布渠道。
- ARMv6 的归档构建、在线更新工作进程及独立离线安装器使用 `--jitless`,避开 ARM1176 上 V8 的优化编译崩溃;保留现有 Node 二进制,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 二进制,正常面板进程的启动参数不变。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 的包内符号链接;拒绝绝对路径、越界路径、包外链接、硬链接、设备条目和经符号链接写入的条目。重启前再次校验依赖,暂存失败保留上一次可用的就绪包。
自托管镜像可设置 `QL_UPGRADE_INDEX_URL` 指向兼容索引;要求 HTTPS,测试时仅放行 loopback HTTP。索引和所有平台归档应部署到同一可访问源,避免依赖额外的 raw 域名。
-3
View File
@@ -1,6 +1,4 @@
const isContainer = process.env.QL_CONTAINER === 'true';
const runtimeArgs =
require('./cli/src/internal/maintenance/nodeRuntime.cjs').serverRuntimeArgs();
module.exports = {
apps: [
@@ -20,7 +18,6 @@ module.exports = {
// visible through `docker logs` before Winston is initialized.
error_file: isContainer ? '/proc/1/fd/2' : undefined,
script: 'static/build/app.js',
...(runtimeArgs.length ? { node_args: runtimeArgs } : {}),
env: {
http_proxy: '',
https_proxy: '',
+3 -41
View File
@@ -12,42 +12,6 @@
"bugs": {
"url": "https://github.com/whyour/qinglong/issues"
},
"files": [
".env.example",
"back/protos/api.proto",
"cli/src/internal/maintenance/*.cjs",
"ecosystem.config.js",
"README*.md",
"sample/*.sample.*",
"sample/notify.js",
"sample/notify.py",
"sample/ql_sample.js",
"sample/ql_sample.py",
"shell/*.cjs",
"shell/api.sh",
"shell/bot.sh",
"shell/check.sh",
"shell/env.sh",
"shell/lang/*.sh",
"shell/node_path_cache.sh",
"shell/otask.sh",
"shell/preload/client.js",
"shell/preload/client.py",
"shell/preload/esm-loader.mjs",
"shell/preload/notificationAudit.js",
"shell/preload/sitecustomize.js",
"shell/preload/sitecustomize.py",
"shell/rmlog.sh",
"shell/share.sh",
"shell/start-simplify.sh",
"shell/start.sh",
"shell/task.sh",
"shell/task-timeout.sh",
"shell/update.sh",
"shell/upgrade-lock.yaml",
"static/",
"version.yaml"
],
"scripts": {
"build:cli": "node cli/scripts/build.cjs",
"check:cli": "npm --prefix cli run check",
@@ -64,9 +28,7 @@
"panel": "npm run build:back && node static/build/app.js",
"gen:proto": "protoc --experimental_allow_proto3_optional --plugin=./node_modules/.bin/protoc-gen-ts_proto ./back/protos/*.proto --ts_proto_out=./ --ts_proto_opt=outputServices=grpc-js,env=node,esModuleInterop=true,snakeToCamel=false",
"prettier": "prettier --write '**/*.{js,jsx,tsx,ts,less,md,json}'",
"prepack": "node scripts/prepare-npm-package.cjs",
"postpack": "node scripts/prepare-npm-package.cjs --clean",
"postinstall": "node -e \"const fs = require('fs'); if (fs.existsSync('shell/install-package.cjs')) require('./shell/install-package.cjs').installPackage(); else fs.accessSync('pnpm-lock.yaml')\" && (max setup 2>/dev/null || true)",
"postinstall": "max setup 2>/dev/null || true",
"start:simple": "bash shell/start-simplify.sh"
},
"gitHooks": {
@@ -111,7 +73,7 @@
}
},
"overrides": {
"sqlite3": "npm:@whyour/sqlite3@1.1.3",
"sqlite3": "npm:@whyour/sqlite3@1.1.2",
"@codemirror/state": "6.5.4",
"@codemirror/view": "6.39.16",
"proxy-addr": "2.0.8",
@@ -159,7 +121,7 @@
"request-ip": "3.3.0",
"sequelize": "6.37.8",
"sockjs": "^0.3.24",
"sqlite3": "npm:@whyour/sqlite3@1.1.3",
"sqlite3": "npm:@whyour/sqlite3@1.1.2",
"toad-scheduler": "^3.0.1",
"typedi": "^0.10.0",
"undici": "^7.9.0",
+10 -10
View File
@@ -1,7 +1,7 @@
lockfileVersion: '6.0'
overrides:
sqlite3: npm:@whyour/sqlite3@1.1.3
sqlite3: npm:@whyour/sqlite3@1.1.2
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.16
proxy-addr: 2.0.8
@@ -120,13 +120,13 @@ dependencies:
version: 3.3.0
sequelize:
specifier: 6.37.8
version: 6.37.8(@whyour/sqlite3@1.1.3)
version: 6.37.8(@whyour/sqlite3@1.1.2)
sockjs:
specifier: ^0.3.24
version: 0.3.24
sqlite3:
specifier: npm:@whyour/sqlite3@1.1.3
version: /@whyour/sqlite3@1.1.3
specifier: npm:@whyour/sqlite3@1.1.2
version: /@whyour/sqlite3@1.1.2
tar:
specifier: 7.5.20
version: 7.5.20
@@ -3193,7 +3193,7 @@ packages:
resolution: {integrity: sha512-Ngs9jhElXN7efS9WvvCC/p6rXMbihna8eNLVBc421Zf+VcFd+pR4DOcS6yA9V22EKtAy4DEj7LtvEEIm0bb80A==}
engines: {node: '>= 18'}
dependencies:
sqlite3: /@whyour/sqlite3@1.1.3
sqlite3: /@whyour/sqlite3@1.1.2
transitivePeerDependencies:
- encoding
- supports-color
@@ -5611,15 +5611,14 @@ packages:
- supports-color
dev: true
/@whyour/sqlite3@1.1.3:
resolution: {integrity: sha512-VeiureU2gq11OMeSMWlpckNfbY5QF+V0KEpRi1Zqh/s5GEPgCazt06xIg528tPAwK0MS9KCUEyLb+dKDIIFoEg==}
/@whyour/sqlite3@1.1.2:
resolution: {integrity: sha512-OY24/OJMqLHIGolo4GCFW4lrJKAoOB+ZqERXb7u7DKZQz7QR3f0GALE99BZWQBSZEVVZ/StVeAb2fCkMfTtuDQ==}
requiresBuild: true
peerDependenciesMeta:
node-gyp:
optional: true
dependencies:
'@mapbox/node-pre-gyp': 1.0.11
detect-libc: 1.0.3
node-addon-api: 4.3.0
tar: 6.2.1
optionalDependencies:
@@ -7602,6 +7601,7 @@ packages:
resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==}
engines: {node: '>=0.10'}
hasBin: true
dev: true
/detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
@@ -13903,7 +13903,7 @@ packages:
engines: {node: '>= 10.0.0'}
dev: false
/sequelize@6.37.8(@whyour/sqlite3@1.1.3):
/sequelize@6.37.8(@whyour/sqlite3@1.1.2):
resolution: {integrity: sha512-HJ0IQFqcTsTiqbEgiuioYFMSD00TP6Cz7zoTti+zVVBwVe9fEhev9cH6WnM3XU31+ABS356durAb99ZuOthnKw==}
engines: {node: '>=10.0.0'}
peerDependencies:
@@ -13948,7 +13948,7 @@ packages:
retry-as-promised: 7.0.4
semver: 7.7.4
sequelize-pool: 7.1.0
sqlite3: /@whyour/sqlite3@1.1.3
sqlite3: /@whyour/sqlite3@1.1.2
toposort-class: 1.0.1
uuid: 8.3.2
validator: 13.12.0
+6 -18
View File
@@ -1313,40 +1313,30 @@ def add_notify_function():
def report_notification(channel, title, content, success):
report_notifications([(channel, success)], title, content)
def report_notifications(results, title, content):
ql_dir = os.getenv("QL_DIR")
if not ql_dir or not results:
if not ql_dir:
return
payload = [{"title": str(title or "")[:512], "content": str(content or "")[:8192],
payload = {"title": str(title or "")[:512], "content": str(content or "")[:8192],
"success": success, "notifyType": channel,
"scriptName": os.path.basename(__import__("sys").argv[0]), "taskId": os.getenv("ID", "")}
for channel, success in results]
code = """const fs=require('node:fs'),path=require('node:path');
const report=require(path.join(process.env.QL_DIR,'shell/preload/notificationAudit.js'));
report(JSON.parse(fs.readFileSync(0,'utf8'))).catch(()=>{});"""
try:
# One Node startup/TLS connection per send, with room for cold startup
# before the reporter's bounded RPC deadline on busy Alpine hosts.
subprocess.run(["node", "-e", code], input=json.dumps(payload), text=True,
capture_output=True, timeout=10)
capture_output=True, timeout=3)
except Exception:
pass # Audit availability never changes notification delivery.
def audited_notification(mode, title, content, results=None):
def audited_notification(mode, title, content):
result = None
try:
result = mode(title, content)
except Exception:
result = False
print(f"{mode.__name__} 推送失败")
if results is None:
report_notification(mode.__name__, title, content, result is True)
else:
results.append((mode.__name__, result is True))
report_notification(mode.__name__, title, content, result is True)
def send(title: str, content: str, ignore_default_config: bool = False, **kwargs):
@@ -1372,14 +1362,12 @@ def send(title: str, content: str, ignore_default_config: bool = False, **kwargs
content += "\n\n" + one() if hitokoto not in [False, "false"] else ""
notify_function = add_notify_function()
results = []
ts = [
threading.Thread(target=audited_notification, args=(mode, title, content, results), name=mode.__name__)
threading.Thread(target=audited_notification, args=(mode, title, content), name=mode.__name__)
for mode in notify_function
]
[t.start() for t in ts]
[t.join() for t in ts]
report_notifications(results, title, content)
def main():
-20
View File
@@ -1,20 +0,0 @@
// npm-packlist always excludes the root pnpm lockfile, even with a files entry.
// Carry a checked copy under a runtime filename and restore it after installation.
const fs = require('node:fs');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const root = path.resolve(__dirname, '..');
const copy = path.join(root, 'shell/upgrade-lock.yaml');
if (process.argv.includes('--clean')) {
fs.rmSync(copy, { force: true });
} else {
if (execFileSync('git', ['-C', root, 'status', '--porcelain', '--untracked-files=all'], { encoding: 'utf8' }).trim())
throw new Error('Pack only from a clean source checkout');
// The native npm pack/publish hooks must enforce the same checks as CI:
// current commit, clean build, lockfile and every output file's checksum.
// Capture stdout so npm pack --json remains machine-readable.
execFileSync(process.execPath, [path.join(root, 'docker/verify-build.cjs')], { cwd: root });
const bytes = fs.readFileSync(path.join(root, 'pnpm-lock.yaml'));
fs.rmSync(copy, { force: true });
fs.writeFileSync(copy, bytes, { flag: 'wx', mode: 0o644 });
}
+12 -41
View File
@@ -13,6 +13,7 @@ const platforms = [
'linux-s390x-musl',
'linux-ia32-musl',
'linux-x64-glibc',
'linux-armv6-glibc',
'linux-armv7-glibc',
'linux-arm64-glibc',
'linux-ppc64-glibc',
@@ -60,14 +61,20 @@ function publishBundles({
branch,
version,
buildId,
allowDevelopment = false,
gh = (...args) =>
execFileSync('gh', args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
}),
}) {
if (!['master', 'develop'].includes(branch))
throw new Error('Only master or develop images publish bundles');
if (
branch !== 'master' &&
!(branch === 'develop' && allowDevelopment === true)
)
throw new Error(
'Only master or explicitly requested development images publish bundles',
);
if (!/^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.test(version || ''))
throw new Error('Invalid bundle version');
if (!/^\d+-\d+$/.test(buildId || ''))
@@ -219,45 +226,6 @@ function publishBundles({
'--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 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;
}
@@ -278,5 +246,8 @@ if (require.main === module) {
branch,
version,
buildId: `${process.env.GITHUB_RUN_ID}-${process.env.GITHUB_RUN_ATTEMPT}`,
allowDevelopment:
process.env.GITHUB_EVENT_NAME === 'workflow_dispatch' &&
process.env.QL_BUILD_DEVELOPMENT_BUNDLES === 'true',
});
}
-44
View File
@@ -1,44 +0,0 @@
"""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}))
-377
View File
@@ -1,377 +0,0 @@
// Run service tests only inside a disposable Linux container or CI job.
// Unlike source-tree tests, these load the published entrypoints and dependencies.
const fs = require('node:fs/promises');
const sync = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const http = require('node:http');
const net = require('node:net');
const assert = require('node:assert/strict');
const { createHash, randomUUID } = require('node:crypto');
const { createRequire } = require('node:module');
const { execFile } = require('node:child_process');
const { promisify } = require('node:util');
const { setTimeout: delay } = require('node:timers/promises');
const execute = promisify(execFile);
const repository = path.resolve(__dirname, '..');
const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
const read = async (file) => JSON.parse(await fs.readFile(file, 'utf8'));
const run = (program, args, options = {}) => execute(program, args, {
encoding: 'utf8', maxBuffer: 8 * 1024 * 1024, timeout: 120000,
...options,
});
const requiredFiles = [
'.env.example', 'back/protos/api.proto', 'ecosystem.config.js', 'version.yaml',
'shell/upgrade.cjs', 'shell/install-bundle.cjs', 'shell/install-package.cjs',
'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/preload/sitecustomize.js', 'shell/preload/sitecustomize.py',
'shell/preload/esm-loader.mjs', 'shell/preload/notificationAudit.js',
'sample/config.sample.sh', 'sample/auth.sample.json',
'static/build-info.json', 'static/build/app.js', 'static/build/token.js',
'static/build/bootstrap/upgradeProbe.js',
'static/dist/index.html',
...['upgradeArtifacts', 'platformBundle', 'upgradePrestart', 'panelUpgrade', 'nodeRuntime']
.map((name) => `cli/src/internal/maintenance/${name}.cjs`),
];
function checkPackedFiles(files) {
const names = new Set(files.map((file) => file.path));
for (const name of requiredFiles) assert.ok(names.has(name), `Missing npm runtime: ${name}`);
for (const name of names) {
assert.ok(/^(?:package\.json$|LICENSE$|README[^/]*\.md$|\.env\.example$|ecosystem\.config\.js$|version\.yaml$|back\/protos\/api\.proto$|cli\/(?:README[^/]*\.md$|src\/internal\/maintenance\/[^/]+\.cjs$)|sample\/|shell\/|static\/)/.test(name), `Unexpected npm file: ${name}`);
assert.ok(!/^shell\/(?:pub\.sh$|preload\/(?:env\.|notify\.|__ql_notify__\.|lang_env\.sh|.*-notify\.json))/.test(name), `Generated or release-only runtime file: ${name}`);
assert.ok(!/(?:^|\/)__pycache__\/|\.pyc$|(?:^|\/)\.DS_Store$/.test(name), name);
assert.notEqual(name, 'sample/tool.ts');
}
}
async function packPanel(output) {
const git = (...args) => run('git', args, { cwd: repository });
assert.equal((await git('status', '--porcelain', '--untracked-files=all')).stdout.trim(), '', 'Pack only from a clean source checkout');
await run(process.execPath, [path.join(repository, 'docker/verify-build.cjs')], { cwd: repository });
await fs.mkdir(output, { recursive: true });
const cache = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-npm-pack-cache-'));
try {
const result = await run('npm', ['pack', '--json', '--cache', cache, '--pack-destination', output], { cwd: repository });
const [manifest] = JSON.parse(result.stdout);
checkPackedFiles(manifest.files);
assert.equal(manifest.name, '@whyour/qinglong');
const build = await read(path.join(repository, 'static/build-info.json'));
const names = new Set(manifest.files.map((file) => file.path));
for (const name of Object.keys(build.files)) assert.ok(names.has(`static/${name}`), `npm filtered a verified build artifact: ${name}`);
const receipt = {
filename: manifest.filename, sha256: hash(await fs.readFile(path.join(output, manifest.filename))),
sourceCommit: build.sourceCommit, size: manifest.size, fileCount: manifest.files.length,
};
await fs.writeFile(path.join(output, 'package-receipt.json'), JSON.stringify(receipt, null, 2));
console.log(JSON.stringify(receipt));
return receipt;
} finally {
await fs.rm(cache, { recursive: true, force: true });
// Also recover an interrupted npm pack whose postpack hook did not execute.
await fs.rm(path.join(repository, 'shell/upgrade-lock.yaml'), { force: true });
}
}
async function unusedPort() {
const server = net.createServer();
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const port = server.address().port;
await new Promise((resolve) => server.close(resolve));
return port;
}
// Consume the unmodified output of Dockerfile.upgrade-bundle / buildBundle.
async function platformFixture(root, directory, expectedCommit) {
assert.ok(directory, 'Set QL_PANEL_TEST_BUNDLE_DIR to the official platform bundle output');
const names = (await fs.readdir(directory)).filter((name) => name.endsWith('.tar.gz'));
assert.equal(names.length, 1, 'Expected one official platform package');
const archive = path.resolve(directory, names[0]);
const record = await read(archive + '.json');
const bytes = await fs.readFile(archive);
assert.equal(record.format, 2);
assert.equal(record.sourceCommit, expectedCommit);
assert.equal(record.filename, names[0]);
assert.equal(record.sha256, hash(bytes));
assert.equal(record.size, bytes.length);
assert.match(record.baseImage, /^ghcr\.io\/[^@]+@sha256:[a-f0-9]{64}$/);
const local = createRequire(path.join(root, 'package.json'));
const bundle = local('./cli/src/internal/maintenance/platformBundle.cjs');
assert.equal(record.target, bundle.targetKey(await bundle.runtimeTarget()));
return { ...record, archive };
}
async function legacyArchive(temp, env) {
const directory = path.join(temp, 'legacy');
await fs.mkdir(directory);
const result = await run('npm', ['pack', '@whyour/qinglong@2.23.0', '--ignore-scripts', '--json', '--registry=https://registry.npmjs.org', '--pack-destination', directory, '--cache', path.join(temp, 'cache')], { cwd: temp, env, timeout: 300000 });
const [record] = JSON.parse(result.stdout);
assert.equal(record.name, '@whyour/qinglong');
assert.equal(record.version, '2.23.0');
assert.equal(record.filename, 'whyour-qinglong-2.23.0.tgz');
const archive = path.join(directory, record.filename);
const integrity = 'sha512-' + createHash('sha512').update(await fs.readFile(archive)).digest('base64');
assert.equal(integrity, 'sha512-x6B9s1/SRKkHe7jRQtb3LzMluJ3O3PAAssrr/5jaFMMl7DZvABSEiqkkZMu0r1BNtiCF72uj8OqXN9pCnF2WVw==', 'Published legacy package integrity changed');
return { archive, integrity, version: record.version };
}
async function verifyPanel(input, output) {
assert.equal(process.platform, 'linux', 'Service tests require Linux');
assert.equal(process.env.QL_PANEL_PACKAGE_TEST_ISOLATED, '1', 'Run only inside a disposable Linux container: QL_PANEL_PACKAGE_TEST_ISOLATED=1');
const stat = await fs.stat(input);
if (stat.isDirectory()) {
const names = (await fs.readdir(input)).filter((name) => name.endsWith('.tgz'));
assert.equal(names.length, 1, 'Expected one exact npm archive');
input = path.join(input, names[0]);
}
const expected = await read(path.join(path.dirname(input), 'package-receipt.json'));
assert.equal(path.basename(input), expected.filename);
assert.equal(hash(await fs.readFile(input)), expected.sha256, 'npm archive differs from its build receipt');
assert.equal((await fs.stat(input)).size, expected.size);
assert.match(expected.sourceCommit, /^[a-f0-9]{40}$/);
if (process.env.GITHUB_SHA) assert.equal(expected.sourceCommit, process.env.GITHUB_SHA);
const nodeSha = hash(await fs.readFile(process.execPath));
const pm2 = process.env.QL_PANEL_TEST_PM2;
assert.ok(pm2 && path.isAbsolute(pm2), 'Set QL_PANEL_TEST_PM2 to an isolated PM2 executable');
const temp = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'ql-npm-install-')));
const home = path.join(temp, 'home'), prefix = path.join(temp, 'prefix'), data = path.join(temp, 'data');
await fs.mkdir(home);
await fs.mkdir(data);
const port = await unusedPort();
let grpcPort = await unusedPort();
while (grpcPort === port) grpcPort = await unusedPort();
const env = { ...process.env, HOME: home, PM2_HOME: path.join(temp, '.pm2'),
QL_DATA_DIR: data, QL_BRANCH: 'develop', NODE_ENV: 'production', QL_CONTAINER: 'false',
BACK_PORT: String(port), GRPC_PORT: String(grpcPort), QlPort: String(port), QlGrpcPort: String(grpcPort),
BIND_HOST: '127.0.0.1', BIND_HOST_GRPC: '127.0.0.1',
NODE_PATH: '', NODE_OPTIONS: '',
PATH: [path.join(prefix, 'bin'), path.dirname(pm2), process.env.PATH].join(path.delimiter),
};
for (const key of ['QL_CLI_ROOT', 'QL_DIR', 'QL_UPGRADE_FILE', 'QL_UPGRADE_REPAIR_DEPS', 'QL_UPGRADE_LAUNCHER_PID', 'QL_UPGRADE_INDEX_URL']) delete env[key];
let root, fixtureServer, token;
const api = async (route, method = 'GET', body, anonymous = false) => {
const response = await fetch(`http://127.0.0.1:${port}/api/${route}`, {
method, headers: { 'Content-Type': 'application/json', ...(!anonymous && token ? { Authorization: `Bearer ${token}` } : {}) },
body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(5000),
});
const result = await response.json();
assert.equal(result.code, 200, `${method} ${route}: ${result.message}`);
return result.data;
};
const healthy = async () => {
const deadline = Date.now() + 120000;
while (Date.now() < deadline) {
try { const value = await api('health', 'GET', undefined, true); if (value.status === 'ok') return value; } catch {}
await delay(300);
}
throw new Error('Installed npm backend did not become healthy');
};
const job = async (id, status) => {
const deadline = Date.now() + 240000;
while (Date.now() < deadline) {
try {
const value = await api('system/update-status');
assert.equal(value.id, id);
if (value.status === status) return value;
if (value.status === 'failed' && status !== 'failed') throw new Error(value.error);
} catch (error) {
if (!/fetch failed|ECONNRESET|ECONNREFUSED|aborted|timeout/i.test(error.message)) throw error;
}
await delay(250);
}
throw new Error(`Installed npm update never reached ${status}`);
};
try {
const install = (archive) => run('npm', ['install', '--global', '--prefix', prefix, '--force', '--omit=dev', '--no-audit', '--no-fund', '--cache', path.join(temp, 'cache'), archive], { cwd: temp, env, timeout: 600000 });
const start = () => run(path.join(prefix, 'bin/qinglong'), ['reload'], { cwd: temp, env, timeout: 180000 });
const stop = () => run(pm2, ['delete', 'ecosystem.config.js'], { cwd: root, env });
console.log(`Installing actual npm archive with production lifecycle scripts on ${process.version}`);
await install(input);
const globalRoot = (await run('npm', ['root', '--global', '--prefix', prefix], { cwd: temp, env })).stdout.trim();
root = path.join(globalRoot, '@whyour/qinglong');
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
// 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 });
env.QL_DATA_DIR = path.join(temp, 'fresh/data');
await fs.mkdir(env.QL_DATA_DIR, { recursive: true });
console.log('Starting the freshly installed npm package');
await start();
const fresh = await healthy();
assert.equal(fresh.build.sourceCommit, expected.sourceCommit);
assert.equal(fresh.services.grpc, true);
assert.match(await fetch(`http://127.0.0.1:${port}/`).then((r) => r.text()), /<html/i);
await stop();
await fs.rm(path.join(temp, 'fresh'), { recursive: true, force: true });
env.QL_DATA_DIR = data;
console.log('Installing the integrity-pinned published legacy npm package');
const legacy = await legacyArchive(temp, env);
await install(legacy.archive);
assert.equal((await read(path.join(root, 'package.json'))).version, legacy.version);
await assert.rejects(fs.access(path.join(root, 'cli/src/internal/maintenance/platformBundle.cjs')), { code: 'ENOENT' });
const legacyBackendSha = hash(await fs.readFile(path.join(root, 'static/build/app.js')));
await start();
await healthy();
const credentials = { username: 'npm_package_verification', password: `Test_${randomUUID()}!` };
await api('user/init', 'PUT', credentials, true);
token = (await api('user/login', 'POST', credentials, true)).token;
const nonce = randomUUID();
await api('envs', 'POST', [{ name: 'NPM_PACKAGE_PRESERVE', value: nonce }]);
const script = path.join(data, 'scripts/npm-package-check.js');
await fs.writeFile(script, `console.log(${JSON.stringify(nonce)});\n`);
const cron = await api('crons', 'POST', { name: 'npm package retained task', command: `node ${script}`, schedule: '0 0 1 1 *' });
const userConfig = path.join(data, 'config/config.sh');
await fs.appendFile(userConfig, `\n# retained npm package setting ${nonce}\n`);
const userConfigSha = hash(await fs.readFile(userConfig));
const preservedData = async () => {
assert.equal((await api('user')).username, credentials.username);
assert.ok((await api('envs')).some((x) => x.name === 'NPM_PACKAGE_PRESERVE' && x.value === nonce));
assert.equal((await api(`crons/${cron.id}`)).command, cron.command);
assert.equal(hash(await fs.readFile(userConfig)), userConfigSha);
assert.equal(await fs.readFile(script, 'utf8'), `console.log(${JSON.stringify(nonce)});\n`);
};
await preservedData();
console.log('Stopping legacy service and verifying npm automatically preserves its .env during overwrite');
await stop();
await fs.appendFile(path.join(root, '.env'), `\n# retained custom npm environment ${nonce}\n`);
const savedEnv = await fs.readFile(path.join(root, '.env'));
await install(input);
assert.deepEqual(await fs.readFile(path.join(root, '.env')), savedEnv);
assert.equal((await fs.stat(path.join(root, '.env'))).mode & 0o777, 0o600);
const configSha = hash(savedEnv);
const local = createRequire(path.join(root, 'package.json'));
const artifacts = local('./shell/upgrade.cjs');
const build = await artifacts.verifyUpgrade(root, path.join(root, 'static'));
assert.equal(build.sourceCommit, expected.sourceCommit);
assert.notEqual(hash(await fs.readFile(path.join(root, 'static/build/app.js'))), legacyBackendSha);
assert.equal(hash(await fs.readFile(path.join(root, 'pnpm-lock.yaml'))), build.lockfileSha256);
const fixture = await platformFixture(root, process.env.QL_PANEL_TEST_BUNDLE_DIR, expected.sourceCommit);
let badChecksum = true, requests = 0;
fixtureServer = http.createServer((request, response) => {
requests++;
if (request.url === '/latest.json') {
response.setHeader('Content-Type', 'application/json');
response.end(JSON.stringify({ format: 2, sourceCommit: fixture.sourceCommit, packages: { [fixture.target]: { size: fixture.size, sha256: badChecksum ? '0'.repeat(64) : fixture.sha256, url: `http://127.0.0.1:${fixtureServer.address().port}/bundle.tar.gz` } } }));
} else if (request.url === '/bundle.tar.gz') {
response.setHeader('Content-Length', fixture.size);
sync.createReadStream(fixture.archive).pipe(response);
} else { response.statusCode = 404; response.end(); }
});
await new Promise((resolve) => fixtureServer.listen(0, '127.0.0.1', resolve));
env.QL_UPGRADE_INDEX_URL = `http://127.0.0.1:${fixtureServer.address().port}/latest.json`;
console.log('Starting the overwritten npm package against legacy user data');
await start();
const original = await healthy();
assert.equal(original.build.sourceCommit, build.sourceCommit);
assert.equal(original.services.grpc, true);
token = (await api('user/login', 'POST', credentials, true)).token;
const preserved = async () => {
await preservedData();
assert.equal(hash(await fs.readFile(path.join(root, '.env'))), configSha);
};
await preserved();
console.log('Testing a rejected download through the installed panel API');
const rejected = await api('system/update', 'PUT');
assert.match((await job(rejected.id, 'failed')).error, /checksum|校验/i);
assert.equal((await healthy()).build.instanceId, original.build.instanceId);
await preserved();
badChecksum = false;
console.log('Testing installed panel download, prestart and explicit application');
const accepted = await api('system/update', 'PUT');
const ready = await job(accepted.id, 'ready');
assert.equal((await healthy()).build.instanceId, original.build.instanceId);
const pointer = await read(path.join(root, '.tmp/upgrade-ready-develop.json'));
const receipt = await read(path.join(root, '.tmp', pointer.directory, 'prestart.json'));
assert.ok(receipt.completedAt <= ready.updatedAt, 'Readiness was recorded before backend prestart');
const applying = await api('update/system', 'PUT');
await job(applying.id, 'completed');
const afterOnline = await healthy();
await checkRuntime();
assert.notEqual(afterOnline.build.instanceId, original.build.instanceId);
assert.equal(afterOnline.build.sourceCommit, fixture.sourceCommit);
await fs.access(path.join(root, 'node_modules/.pnpm'));
assert.match(await fs.realpath(path.join(root, 'node_modules/sqlite3')), /[/\\]node_modules[/\\]\.pnpm[/\\]/);
await fs.access(path.join(root, 'scripts/build-upgrade-bundle.cjs'));
await artifacts.verifyUpgrade(root, path.join(root, 'static'));
assert.match(await fetch(`http://127.0.0.1:${port}/`).then((r) => r.text()), /<html/i);
await preserved();
console.log('Testing offline installer and Bash reload from the installed npm package');
const requestsBeforeOffline = requests;
await run(process.execPath, [path.join(root, 'shell/install-bundle.cjs'), '--root', root, '--file', fixture.archive, '--download-only'], { cwd: temp, env, timeout: 240000 });
assert.equal(requests, requestsBeforeOffline, 'Offline preparation contacted the index');
assert.equal((await healthy()).build.instanceId, afterOnline.build.instanceId);
await run(path.join(prefix, 'bin/ql'), ['reload', 'system'], { cwd: temp, env, timeout: 240000 });
const afterOffline = await healthy();
await checkRuntime();
assert.notEqual(afterOffline.build.instanceId, afterOnline.build.instanceId);
assert.equal(afterOffline.build.sourceCommit, fixture.sourceCommit);
await fs.access(path.join(root, 'node_modules/.pnpm'));
assert.equal(hash(await fs.readFile(process.execPath)), nodeSha);
assert.equal((await api('system/update-status')).status, 'completed');
await preserved();
await api('crons/run', 'PUT', [cron.id]);
let taskExecuted = false;
for (let i = 0; i < 100; i++) {
const log = String(await api(`crons/${cron.id}/log`));
if (log.includes(nonce) && /完成|执行结束|finished|completed/i.test(log)) { taskExecuted = true; break; }
await delay(300);
}
assert.ok(taskExecuted, 'Retained task did not execute after npm package upgrades');
const result = {
node: process.version, platform: fixture.target,
archiveSha256: hash(await fs.readFile(input)), sourceCommit: build.sourceCommit,
installedProductionDependencies: true, restoredLockVerified: true,
legacyNpmVersion: legacy.version, legacyNpmIntegrity: legacy.integrity,
legacyNpmOverwriteInstalled: true, legacyExternalDataPreserved: true,
customRootEnvironmentPreservedAutomatically: true,
officialPlatformBundle: { baseImage: fixture.baseImage, sourceCommit: fixture.sourceCommit, sha256: fixture.sha256 },
migratedFromNpmToOfficialPnpmLayout: true,
existingNodePreserved: true, nativeUrlRuntimeProbes: runtimeProbes,
frontendAndBackendStarted: true, checksumFailureKeptOldService: true,
prestartBeforeReady: true, onlineActualRestart: true,
offlinePreparationDidNotContactIndex: true, offlineBashActualRestart: true,
accountEnvironmentConfigurationAndScriptsPreserved: true, retainedTaskExecuted: true,
initialBuild: original.build, afterOnline: afterOnline.build, afterOffline: afterOffline.build,
};
if (output) {
await fs.mkdir(output, { recursive: true });
await fs.writeFile(path.join(output, `verification-${process.version}.json`), JSON.stringify(result, null, 2));
}
console.log(JSON.stringify(result, null, 2));
return result;
} catch (error) {
if (root) {
for (const file of ['qinglong-error.log', 'qinglong-out.log']) {
const text = await fs.readFile(path.join(env.PM2_HOME, 'logs', file), 'utf8').catch(() => '');
if (text) console.error(text.slice(-6000));
}
}
throw error;
} finally {
if (fixtureServer) {
fixtureServer.closeAllConnections();
await new Promise((resolve) => fixtureServer.close(resolve));
}
await run(pm2, ['kill'], { env, cwd: temp }).catch(() => {});
await run('nginx', ['-s', 'quit'], { env, cwd: temp }).catch(() => {});
await fs.rm(temp, { recursive: true, force: true });
}
}
module.exports = { checkPackedFiles, packPanel, verifyPanel };
if (require.main === module) {
const [action, input, output] = process.argv.slice(2);
const operation = action === 'pack' && input
? packPanel(path.resolve(input))
: action === 'verify' && input
? verifyPanel(path.resolve(input), output && path.resolve(output))
: Promise.reject(new Error('Usage: node scripts/verify-panel-package.cjs pack OUTPUT | verify ARCHIVE_OR_DIRECTORY [RESULT_DIRECTORY]'));
operation.catch((error) => { console.error(error); process.exitCode = 1; });
}
+3 -15
View File
@@ -47,19 +47,7 @@ fi
for requested_ref in "${refs[@]}"; do
git check-ref-format "$requested_ref"
done
# Ubuntu 26.04's default timeout is from uutils. We require GNU's process-group
# termination so git transport children cannot keep the query pipes open.
timeout_command=''
for candidate in timeout gnutimeout gtimeout; do
if version=$("$candidate" --version 2>/dev/null) && [[ "$version" == *'(GNU coreutils)'* ]]; then
timeout_command=$candidate
break
fi
done
if [[ -z "$timeout_command" ]]; then
echo 'GNU timeout is required (timeout, gnutimeout or gtimeout)' >&2
exit 2
fi
command -v timeout >/dev/null || { echo 'GNU timeout is required' >&2; exit 2; }
if [[ -n "$notify_repo" ]]; then
if [[ ! "$notify_repo" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then
echo 'QL_MIRROR_NOTIFY_REPO must be a GitHub owner/repository' >&2
@@ -74,7 +62,7 @@ notification_command() {
local budget=$((notification_deadline - SECONDS))
if (( budget <= 0 )); then return 1; fi
if (( budget > 10 )); then budget=10; fi
"$timeout_command" --signal=KILL "${budget}s" "$@"
timeout --signal=KILL "${budget}s" "$@"
}
notify_static_mirror() {
@@ -168,7 +156,7 @@ while (( SECONDS < wait_seconds )); do
fi
budget=$query_timeout
if (( budget > remaining )); then budget=$remaining; fi
if ! output=$("$timeout_command" --signal=KILL "${budget}s" git ls-remote --refs \
if ! output=$(timeout --signal=KILL "${budget}s" git ls-remote --refs \
"${urls[$index]}" "${refs[@]}" | LC_ALL=C sort); then
echo "::warning::Could not read ${labels[$index]} refs on attempt $attempt; retrying"
ready=false
-74
View File
@@ -1,74 +0,0 @@
const fs = require('node:fs');
const path = require('node:path');
const { createHash, randomUUID } = require('node:crypto');
function restorePackageLock(root = path.resolve(__dirname, '..')) {
const copy = path.join(root, 'shell/upgrade-lock.yaml');
const destination = path.join(root, 'pnpm-lock.yaml');
if (!fs.existsSync(copy)) {
// Source checkouts already have the canonical lock; no built artifacts yet.
if (fs.existsSync(destination)) return;
throw new Error('Missing npm package upgrade lockfile');
}
const bytes = fs.readFileSync(copy);
const manifest = JSON.parse(
fs.readFileSync(path.join(root, 'static/build-info.json'), 'utf8'),
);
if (
manifest.dirty !== false ||
manifest.lockfileSha256 !== createHash('sha256').update(bytes).digest('hex')
)
throw new Error('npm package lockfile does not match its build manifest');
const temporary = path.join(root, `.upgrade-lock-${randomUUID()}`);
try {
fs.writeFileSync(temporary, bytes, { flag: 'wx', mode: 0o644 });
fs.renameSync(temporary, destination);
} finally {
fs.rmSync(temporary, { force: true });
}
}
function restoreEnvironment(root = path.resolve(__dirname, '..')) {
// npm's Arborist keeps this exact retirement directory until lifecycle scripts
// succeed, then deletes it. Never search other installations or sibling backups.
const suffix = createHash('sha1').update(root).digest('base64')
.replace(/[^a-zA-Z0-9]+/g, '').slice(0, 8);
const retired = path.join(path.dirname(root), `.${path.basename(root)}-${suffix}`);
const destination = path.join(root, '.env');
const exists = (file) => {
try { return fs.lstatSync(file); }
catch (error) { if (error.code === 'ENOENT') return; throw error; }
};
if (exists(destination)) return;
const directory = exists(retired);
if (!directory) return;
if (!directory.isDirectory()) throw new Error('Invalid npm retirement directory');
const metadata = path.join(retired, 'package.json');
if (!exists(metadata)?.isFile()) throw new Error('Invalid retired npm package');
if (JSON.parse(fs.readFileSync(metadata, 'utf8')).name !== '@whyour/qinglong')
throw new Error('Unexpected retired npm package');
const source = path.join(retired, '.env');
const stat = exists(source);
if (!stat) return;
if (!stat.isFile()) throw new Error('Retired npm .env must be a regular file');
const descriptor = fs.openSync(source, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
const temporary = path.join(root, `.upgrade-env-${randomUUID()}`);
try {
if (!fs.fstatSync(descriptor).isFile()) throw new Error('Invalid retired npm .env');
fs.writeFileSync(temporary, fs.readFileSync(descriptor), { flag: 'wx', mode: 0o600 });
// Linking publishes a complete file without replacing a concurrently created .env.
try { fs.linkSync(temporary, destination); }
catch (error) { if (error.code !== 'EEXIST') throw error; }
} finally {
fs.closeSync(descriptor);
fs.rmSync(temporary, { force: true });
}
}
function installPackage(root = path.resolve(__dirname, '..')) {
restorePackageLock(root);
restoreEnvironment(root);
}
module.exports = { restorePackageLock, restoreEnvironment, installPackage };
if (require.main === module) installPackage();
-62
View File
@@ -1,62 +0,0 @@
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()));
+8 -16
View File
@@ -27,22 +27,14 @@ module.exports = async function reportNotificationAudit(payload) {
credentials,
{ 'grpc.enable_http_proxy': 0 },
);
// Python sends all channel results together so they share startup and TLS.
const records = Array.isArray(payload) ? payload : [payload];
const deadline = new Date(Date.now() + 5000);
await Promise.all(
records.map(
(record) =>
new Promise((resolve) => {
client.RecordNotifyLog(
record,
new grpc.Metadata(),
{ deadline },
() => resolve(),
);
}),
),
);
await new Promise((resolve) => {
client.RecordNotifyLog(
payload,
new grpc.Metadata(),
{ deadline: new Date(Date.now() + 1500) },
() => resolve(),
);
});
} catch (_) {
// Missing certificates, old panels and offline services are best effort.
} finally {
+2 -15
View File
@@ -294,18 +294,6 @@ delete_pm2() {
pkill -f "node.*static/build/app.js" 2>/dev/null || true
}
load_node_runtime_args() {
ql_node_runtime_args=()
local runtime_args
local helper="$dir_root/cli/src/internal/maintenance/nodeRuntime.cjs"
if [[ -f "$helper" ]]; then
runtime_args=$(node "$helper") || return $?
if [[ -n "$runtime_args" ]]; then
read -r -a ql_node_runtime_args <<<"$runtime_args"
fi
fi
}
reload_pm2() {
cd "$dir_root" || return $?
restore_env_vars
@@ -317,7 +305,6 @@ reload_pm2() {
local exit_code=$?
t '警告: PM2 启动失败 (退出码: %s),可能是由于硬件不兼容' "$exit_code"
t '正在尝试直接使用 Node.js 启动服务...'
load_node_runtime_args || return $?
# Kill any existing node processes for qinglong
pkill -f "node.*static/build/app.js" 2>/dev/null || true
@@ -325,9 +312,9 @@ reload_pm2() {
# Start node directly in the background. In containers, keep application
# output on the container streams instead of duplicating Winston logs on disk.
if [[ "$QL_CONTAINER" == "true" ]]; then
nohup node "${ql_node_runtime_args[@]}" static/build/app.js >/proc/1/fd/1 2>/proc/1/fd/2 &
nohup node static/build/app.js >/proc/1/fd/1 2>/proc/1/fd/2 &
else
nohup node "${ql_node_runtime_args[@]}" static/build/app.js >"$dir_log/qinglong.log" 2>&1 &
nohup node static/build/app.js >"$dir_log/qinglong.log" 2>&1 &
fi
local node_pid=$!
+2 -3
View File
@@ -54,8 +54,7 @@ fix_config
# Slow devices may need more than the worker's readiness window to generate
# first-boot certificates. Reuse the regular initializer before starting workers.
load_node_runtime_args
node "${ql_node_runtime_args[@]}" <<'QL_PREPARE_GRPC_CERTS'
node <<'QL_PREPARE_GRPC_CERTS'
require(require('node:path').join(process.env.QL_DIR, 'static/build/config/grpcCerts.js'))
.initGrpcCerts()
.catch((error) => {
@@ -64,7 +63,7 @@ require(require('node:path').join(process.env.QL_DIR, 'static/build/config/grpcC
});
QL_PREPARE_GRPC_CERTS
pm2 l &>/dev/null || true
pm2 l &>/dev/null
log_with_style "INFO" "$(t '🔄 2. 启动 nginx...')"
nginx -s reload 2>/dev/null || nginx -c /etc/nginx/nginx.conf
-24
View File
@@ -50,30 +50,6 @@ test('mirror verification reads matching refs without changing either repository
assert.equal(git('-C', source, 'status', '--porcelain'), '');
});
test('mirror verification selects GNU timeout when the default is another implementation', (t) => {
const { tmp, source, mirror } = fixture(t);
git('-C', source, 'push', mirror, 'develop');
const realTimeout = ['timeout', 'gnutimeout', 'gtimeout'].find((name) => {
const result = spawnSync(name, ['--version'], { encoding: 'utf8' });
return result.status === 0 && result.stdout.includes('(GNU coreutils)');
});
assert.ok(realTimeout, 'The mirror script requires GNU timeout');
const bin = path.join(tmp, 'bin');
fs.mkdirSync(bin);
fs.symlinkSync(execFileSync('which', [realTimeout], { encoding: 'utf8' }).trim(), path.join(bin, 'gnutimeout'));
fs.symlinkSync(execFileSync('which', ['git'], { encoding: 'utf8' }).trim(), path.join(bin, 'git'));
fs.symlinkSync(execFileSync('which', ['sort'], { encoding: 'utf8' }).trim(), path.join(bin, 'sort'));
fs.writeFileSync(path.join(bin, 'timeout'), '#!/bin/sh\nif [ "$1" = --version ]; then echo "timeout (uutils coreutils)"; exit 0; fi\nexit 98\n', { mode: 0o755 });
const args = [script, source, mirror, 'develop'];
const result = spawnSync('/bin/bash', args, options({ PATH: bin }));
assert.equal(result.status, 0, result.stderr + result.stdout);
assert.match(result.stdout, /Mirror matches the latest source refs/);
fs.unlinkSync(path.join(bin, 'gnutimeout'));
const unsupported = spawnSync('/bin/bash', args, options({ PATH: bin }));
assert.equal(unsupported.status, 2, unsupported.stderr + unsupported.stdout);
assert.match(unsupported.stderr, /GNU timeout is required/);
});
test('mirror polling follows a source that advances while waiting', async (t) => {
const { source, mirror } = fixture(t);
git('-C', source, 'push', mirror, 'develop');
-47
View File
@@ -1,47 +0,0 @@
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');
});
-72
View File
@@ -1,72 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const {
serverRuntimeArgs,
} = require('../../cli/src/internal/maintenance/nodeRuntime.cjs');
test('server compatibility flags target ARMv6 Node 24 while retaining WebAssembly and the existing Node', () => {
for (const [arch, armVersion, version, enabled] of [
['arm', '6', '24.18.1', true],
['arm', 6, '24.18.1', true],
['arm', 6, '20.18.1', false],
['arm', 6, '22.20.0', false],
['arm', 7, '24.18.1', false],
['arm64', undefined, '24.18.1', false],
['x64', undefined, '24.18.1', false],
]) {
const flags = serverRuntimeArgs({
arch,
config: { variables: { arm_version: armVersion } },
versions: { node: version },
});
assert.deepEqual(
flags,
enabled ? ['--no-opt', '--no-sparkplug', '--no-maglev'] : [],
);
assert.ok(!flags.includes('--jitless'));
}
});
test('the normal PM2 application uses the same server arguments as prestart', () => {
const app = require('../../ecosystem.config.js').apps.find(
(x) => x.name === 'qinglong',
);
const flags = serverRuntimeArgs();
assert.deepEqual(app.node_args || [], flags);
if (!flags.length) assert.equal(app.node_args, undefined);
});
test('ARMv6 server flags retain real HTTP, fetch and undici parsing', () => {
const { spawnSync } = require('node:child_process');
const flags = serverRuntimeArgs({
arch: 'arm',
config: { variables: { arm_version: 6 } },
versions: { node: '24.18.1' },
});
const result = spawnSync(
process.execPath,
[
...flags,
'-e',
`
const http=require('http'),assert=require('assert/strict');
assert.equal(typeof WebAssembly,'object');
const server=http.createServer((req,res)=>res.end('parser-ok'));
server.listen(0,'127.0.0.1',async()=>{
try {
const url='http://127.0.0.1:'+server.address().port;
assert.equal(await (await fetch(url)).text(),'parser-ok');
assert.equal(await (await require('undici').request(url)).body.text(),'parser-ok');
} catch(e){console.error(e);process.exitCode=1;}
finally{server.closeAllConnections();server.close();}
});
`,
],
{
encoding: 'utf8',
timeout: 10000,
env: { ...process.env, NODE_OPTIONS: '' },
},
);
assert.equal(result.status, 0, result.stderr || result.error?.message);
});
-256
View File
@@ -1,256 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const os = require('node:os');
const { createHash } = require('node:crypto');
const { execFile } = require('node:child_process');
const { promisify } = require('node:util');
const execute = promisify(execFile);
const { checkPackedFiles } = require('../../scripts/verify-panel-package.cjs');
const { restorePackageLock } = require('../../shell/install-package.cjs');
const { restoreEnvironment } = require('../../shell/install-package.cjs');
async function packageFixture(t) {
const temp = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'ql-npm-contract-')));
t.after(() => fs.rm(temp, { recursive: true, force: true }));
const root = path.join(temp, 'source'), output = path.join(temp, 'archives');
await fs.mkdir(root);
await fs.mkdir(output);
const copy = async (name) => {
await fs.mkdir(path.dirname(path.join(root, name)), { recursive: true });
await fs.copyFile(path.resolve(name), path.join(root, name));
};
for (const name of ['.gitignore', 'docker/verify-build.cjs', 'docker/build-manifest.cjs', '.env.example', 'ecosystem.config.js', 'version.yaml', 'README.md', 'LICENSE', 'scripts/prepare-npm-package.cjs', 'back/protos/api.proto']) await copy(name);
for (const directory of ['shell', 'sample', 'cli/src/internal/maintenance']) {
await fs.cp(directory, path.join(root, directory), {
recursive: true,
filter: (source) => !/[/\\](?:__pycache__|node_modules)(?:[/\\]|$)|[/\\]preload[/\\](?:env\.|notify\.|__ql_notify__\.|lang_env\.sh)/.test(source),
});
}
const metadata = JSON.parse(await fs.readFile('package.json', 'utf8'));
// The structural test stays offline; full production installation and service
// upgrades run against the unmodified package in verify-panel-package.cjs.
metadata.dependencies = {};
metadata.devDependencies = {};
await fs.writeFile(path.join(root, 'package.json'), JSON.stringify(metadata));
const lock = 'lockfileVersion: 6.0\n';
await fs.writeFile(path.join(root, 'pnpm-lock.yaml'), lock);
const files = {};
for (const name of ['build/app.js', 'build/token.js', 'build/bootstrap/upgradeProbe.js', 'dist/index.html', 'build/app.js.map', 'dist/app.js.gz']) {
const bytes = name.endsWith('.html') ? '<html>pack fixture</html>' : '{}';
await fs.mkdir(path.dirname(path.join(root, 'static', name)), { recursive: true });
await fs.writeFile(path.join(root, 'static', name), bytes);
files[name] = createHash('sha256').update(bytes).digest('hex');
}
for (const name of ['.env', '.env.local', '.env.production', '.aws/credentials', '.ssh/id_rsa', '.vscode/settings.json', '.github/workflows/private.yml', '.tmp/private.json', '.DS_Store', 'output/private.json', 'data/config/auth.json', 'back/private.json', 'back/private.ts', 'sample/tool.ts', 'sample/private.json', 'sample/__pycache__/notify.pyc', 'shell/preload/env.js', 'shell/preload/env.py', 'shell/preload/notify.js', 'shell/preload/__ql_notify__.js', 'shell/preload/lang_env.sh', 'shell/preload/task-notify.json', 'shell/preload/__pycache__/env.pyc', 'cli/dist/npm/ql.js', 'cli/src/internal/private.cjs']) {
await fs.mkdir(path.dirname(path.join(root, name)), { recursive: true });
await fs.writeFile(path.join(root, name), 'DO_NOT_PUBLISH');
}
await fs.writeFile(path.join(root, '.npmrc'), '# DO_NOT_PUBLISH\n');
const env = { ...process.env, HOME: temp, NODE_ENV: 'production' };
await execute('git', ['init', '-q'], { cwd: root, env });
await execute('git', ['add', '-A'], { cwd: root, env });
await execute('git', ['-c', 'user.name=Package Test', '-c', 'user.email=test@example.invalid', '-c', 'core.hooksPath=/dev/null', 'commit', '-qm', 'fixture'], { cwd: root, env });
await fs.writeFile(path.join(root, 'static/build-info.json'), JSON.stringify({
version: 1, dirty: false, sourceCommit: (await execute('git', ['rev-parse', 'HEAD'], { cwd: root })).stdout.trim(), files,
lockfileSha256: createHash('sha256').update(lock).digest('hex'),
}));
const pack = () => execute('npm', ['pack', '--json', '--cache', path.join(temp, 'cache'), '--pack-destination', output], { cwd: root, env, timeout: 60000, maxBuffer: 4 * 1024 * 1024 });
return { root, output, temp, env, lock, files, pack };
}
test('real npm pack excludes local state and its installed lifecycle restores verified runtime files', async (t) => {
const { root, output, temp, env, lock, files, pack } = await packageFixture(t);
// The whitelist must work by itself, even for artifacts ignored by Git.
await assert.rejects(fs.access(path.join(root, '.npmignore')), { code: 'ENOENT' });
const packed = await pack();
const [archive] = JSON.parse(packed.stdout);
checkPackedFiles(archive.files);
const names = new Set(archive.files.map((file) => file.path));
assert.ok(names.has('shell/upgrade-lock.yaml'));
for (const name of Object.keys(files)) assert.ok(names.has('static/' + name));
await assert.rejects(fs.access(path.join(root, 'shell/upgrade-lock.yaml')), { code: 'ENOENT' });
const prefix = path.join(temp, 'installed');
await execute('npm', ['install', '--offline', '--omit=dev', '--no-audit', '--no-fund', '--cache', path.join(temp, 'cache'), '--prefix', prefix, path.join(output, archive.filename)], { cwd: temp, env, timeout: 60000 });
const installed = path.join(prefix, 'node_modules/@whyour/qinglong');
for (const { path: name } of archive.files)
assert.equal((await fs.readFile(path.join(installed, name))).includes(Buffer.from('DO_NOT_PUBLISH')), false, `Private fixture content in npm package: ${name}`);
await assert.rejects(fs.access(path.join(installed, '.env')), { code: 'ENOENT' });
assert.equal(await fs.readFile(path.join(installed, 'pnpm-lock.yaml'), 'utf8'), lock);
assert.equal(typeof require(path.join(installed, 'shell/upgrade.cjs')).stageUpgrade, 'function');
assert.ok((await fs.stat(path.join(installed, 'shell/update.sh'))).mode & 0o111);
for (const [name, file] of Object.entries(JSON.parse(await fs.readFile(path.join(installed, 'package.json'), 'utf8')).bin)) {
await fs.access(path.join(prefix, 'node_modules/.bin', name));
assert.ok((await fs.stat(path.join(installed, file))).mode & 0o111, file);
}
await fs.writeFile(path.join(installed, 'shell/upgrade-lock.yaml'), 'tampered');
assert.throws(() => restorePackageLock(installed), /does not match/);
assert.equal(await fs.readFile(path.join(installed, 'pnpm-lock.yaml'), 'utf8'), lock);
assert.ok(!(await fs.readdir(installed)).some((name) => name.startsWith('.upgrade-lock-')));
});
test('postinstall still supports minimal Docker dependency stages before shell files are copied', async (t) => {
const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-npm-builder-'));
t.after(() => fs.rm(temp, { recursive: true, force: true }));
const metadata = JSON.parse(await fs.readFile('package.json', 'utf8'));
await fs.writeFile(path.join(temp, 'package.json'), JSON.stringify({ scripts: { postinstall: metadata.scripts.postinstall } }));
await fs.writeFile(path.join(temp, 'pnpm-lock.yaml'), 'source lock');
await execute('npm', ['run', 'postinstall', '--cache', path.join(temp, 'cache')], { cwd: temp, timeout: 30000 });
});
test('a missing npm runtime lockfile fails instead of claiming an installed package is ready', async (t) => {
const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-npm-lock-missing-'));
t.after(() => fs.rm(temp, { recursive: true, force: true }));
assert.throws(() => restorePackageLock(temp), /Missing npm package upgrade lockfile/);
});
test('real npm overwrites preserve .env automatically and failed installs restore the old package', async (t) => {
const { root, output, temp, env, pack } = await packageFixture(t);
const [packed] = JSON.parse((await pack()).stdout);
const archive = path.join(output, packed.filename);
const legacy = path.join(temp, 'legacy');
await fs.mkdir(legacy);
await fs.writeFile(path.join(legacy, 'package.json'), JSON.stringify({ name: '@whyour/qinglong', version: '2.22.0' }));
await fs.writeFile(path.join(legacy, 'old-package-marker'), 'legacy');
const [old] = JSON.parse((await execute('npm', ['pack', '--ignore-scripts', '--json', '--cache', path.join(temp, 'cache')], { cwd: legacy, env })).stdout);
const broken = path.join(temp, 'broken');
await fs.mkdir(broken);
await fs.cp(root, broken, { recursive: true });
await fs.writeFile(path.join(broken, 'shell/upgrade-lock.yaml'), 'invalid lock');
const [bad] = JSON.parse((await execute('npm', ['pack', '--ignore-scripts', '--json', '--cache', path.join(temp, 'cache')], { cwd: broken, env })).stdout);
for (const global of [false, true]) {
await t.test(global ? 'global npm install' : 'local npm install', async () => {
const prefix = path.join(temp, global ? 'global' : 'local');
const linkedPrefix = prefix + '-link';
await fs.mkdir(prefix);
await fs.symlink(prefix, linkedPrefix);
const installed = path.join(prefix, global ? 'lib/node_modules/@whyour/qinglong' : 'node_modules/@whyour/qinglong');
const install = (file) => execute('npm', ['install', ...(global ? ['--global'] : []), '--prefix', linkedPrefix, '--force', '--offline', '--omit=dev', '--no-audit', '--no-fund', '--cache', path.join(temp, 'cache'), file], { cwd: temp, env, timeout: 60000 });
await install(path.join(legacy, old.filename));
const bytes = Buffer.from('QL_DATA_DIR=/external/data\r\nSECRET="配置 test"\r\n# no trailing newline');
const external = path.join(prefix, 'external.env');
await fs.writeFile(external, bytes);
await fs.symlink(external, path.join(installed, '.env'));
await assert.rejects(install(archive), (error) => {
assert.match(error.stderr, /must be a regular file/);
return true;
});
assert.equal(JSON.parse(await fs.readFile(path.join(installed, 'package.json'))).version, '2.22.0');
assert.equal(await fs.readlink(path.join(installed, '.env')), external);
assert.deepEqual(await fs.readFile(external), bytes);
await fs.rm(path.join(installed, '.env'));
await fs.writeFile(path.join(installed, '.env'), bytes, { mode: 0o644 });
await assert.rejects(install(path.join(broken, bad.filename)), (error) => {
assert.match(error.stderr, /does not match its build manifest/);
return true;
});
assert.equal(JSON.parse(await fs.readFile(path.join(installed, 'package.json'))).version, '2.22.0');
assert.equal(await fs.readFile(path.join(installed, 'old-package-marker'), 'utf8'), 'legacy');
assert.deepEqual(await fs.readFile(path.join(installed, '.env')), bytes);
await install(archive);
assert.deepEqual(await fs.readFile(path.join(installed, '.env')), bytes);
assert.equal((await fs.stat(path.join(installed, '.env'))).mode & 0o777, 0o600);
await assert.rejects(fs.access(path.join(installed, 'old-package-marker')), { code: 'ENOENT' });
// Same-version forced reinstall must also retain custom configuration.
await install(archive);
assert.deepEqual(await fs.readFile(path.join(installed, '.env')), bytes);
assert.ok(!(await fs.readdir(installed)).some((name) => name.startsWith('.upgrade-env-')));
});
}
});
test('environment recovery uses only the exact retired package and never overwrites existing configuration', async (t) => {
const temp = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'ql-npm-env-')));
t.after(() => fs.rm(temp, { recursive: true, force: true }));
const root = path.join(temp, 'node_modules/@whyour/qinglong');
await fs.mkdir(root, { recursive: true });
const suffix = createHash('sha1').update(root).digest('base64').replace(/[^a-zA-Z0-9]+/g, '').slice(0, 8);
const retired = path.join(path.dirname(root), `.qinglong-${suffix}`);
const destination = path.join(root, '.env');
const other = path.join(path.dirname(root), '.qinglong-unrelated');
await fs.mkdir(other);
await fs.writeFile(path.join(other, '.env'), 'unrelated');
restoreEnvironment(root);
await assert.rejects(fs.access(destination), { code: 'ENOENT' });
await fs.mkdir(retired);
await fs.writeFile(path.join(retired, 'package.json'), JSON.stringify({ name: 'another-package' }));
await fs.writeFile(path.join(retired, '.env'), 'private');
assert.throws(() => restoreEnvironment(root), /Unexpected retired npm package/);
await fs.writeFile(path.join(retired, 'package.json'), JSON.stringify({ name: '@whyour/qinglong' }));
await fs.rm(path.join(retired, '.env'));
restoreEnvironment(root);
await assert.rejects(fs.access(destination), { code: 'ENOENT' });
await fs.symlink(path.join(other, '.env'), path.join(retired, '.env'));
assert.throws(() => restoreEnvironment(root), /must be a regular file/);
await fs.rm(path.join(retired, '.env'));
await fs.mkdir(path.join(retired, '.env'));
assert.throws(() => restoreEnvironment(root), /must be a regular file/);
await fs.rm(path.join(retired, '.env'), { recursive: true });
await fs.writeFile(path.join(retired, '.env'), '');
restoreEnvironment(root);
assert.equal(await fs.readFile(destination, 'utf8'), '');
assert.equal((await fs.stat(destination)).mode & 0o777, 0o600);
await fs.writeFile(destination, 'new custom configuration');
await fs.writeFile(path.join(retired, '.env'), 'old configuration');
restoreEnvironment(root);
assert.equal(await fs.readFile(destination, 'utf8'), 'new custom configuration');
assert.ok(!(await fs.readdir(root)).some((name) => name.startsWith('.upgrade-env-')));
});
test('npm publication consumes the exact archive whose isolated installation passed', () => {
const yaml = require('js-yaml');
const sync = require('node:fs');
const workflow = yaml.load(sync.readFileSync('.github/workflows/build-docker-image.yml', 'utf8'));
const verify = workflow.jobs['verify-npm-package'];
assert.deepEqual(verify.needs, ['build-static', 'build-npm-migration-bundle']);
assert.ok(verify.steps.some((step) => step.with?.name === 'npm-migration-bundle-${{ github.sha }}'));
const migration = workflow.jobs['build-npm-migration-bundle'];
assert.equal(migration.needs, 'build-debian');
assert.ok(migration.steps.some((step) => /Dockerfile.upgrade-bundle/.test(step.run || '')));
assert.ok(!migration.steps.some((step) => /publish-upgrade-bundles/.test(step.run || '')));
assert.deepEqual(verify.strategy.matrix.node, ['20.18.1', '24']);
assert.match(verify.container, /node:.*bookworm/);
const tools = verify.steps.find((step) => step.name === 'Install isolated service test tools');
assert.match(tools.run, /npm install --global --prefix \/usr\/local.*pm2@6\.0\.14/);
const service = verify.steps.find((step) => step.name === 'Install and upgrade the exact npm archive');
assert.equal(service.env.QL_PANEL_TEST_PM2, '/usr/local/bin/pm2');
assert.ok(workflow.jobs.publish.needs.includes('verify-npm-package'));
const download = workflow.jobs.publish.steps.find((step) => step.uses?.startsWith('actions/download-artifact@'));
assert.equal(download.with.name, 'qinglong-npm-${{ github.sha }}');
const publication = workflow.jobs.publish.steps.find((step) => step.name === 'Publish npm package with OIDC');
assert.match(publication.run, /npm publish "\$archive" --ignore-scripts/);
assert.ok(!workflow.jobs.publish.steps.some((step) => /pnpm build/.test(step.run || '')));
});
for (const scenario of ['dirty-source', 'stale-commit', 'dirty-build', 'changed-lock', 'changed-output', 'missing-output', 'extra-output']) {
test(`native npm pack rejects ${scenario} before producing an archive`, async (t) => {
const { root, output, pack } = await packageFixture(t);
const manifestFile = path.join(root, 'static/build-info.json');
const manifest = JSON.parse(await fs.readFile(manifestFile, 'utf8'));
if (scenario === 'dirty-source') await fs.appendFile(path.join(root, 'shell/start.sh'), '\n# uncommitted change\n');
if (scenario === 'stale-commit') {
await fs.appendFile(path.join(root, 'shell/start.sh'), '\n# committed change after build\n');
await execute('git', ['add', '-A'], { cwd: root });
await execute('git', ['-c', 'user.name=Package Test', '-c', 'user.email=test@example.invalid', '-c', 'core.hooksPath=/dev/null', 'commit', '-qm', 'new source'], { cwd: root });
}
if (scenario === 'dirty-build') { manifest.dirty = true; await fs.writeFile(manifestFile, JSON.stringify(manifest)); }
if (scenario === 'changed-lock') {
await fs.appendFile(path.join(root, 'pnpm-lock.yaml'), '# changed lock\n');
await execute('git', ['add', '-A'], { cwd: root });
await execute('git', ['-c', 'user.name=Package Test', '-c', 'user.email=test@example.invalid', '-c', 'core.hooksPath=/dev/null', 'commit', '-qm', 'new lock'], { cwd: root });
manifest.sourceCommit = (await execute('git', ['rev-parse', 'HEAD'], { cwd: root })).stdout.trim();
await fs.writeFile(manifestFile, JSON.stringify(manifest));
}
if (scenario === 'changed-output') await fs.appendFile(path.join(root, 'static/build/app.js'), 'changed');
if (scenario === 'missing-output') await fs.rm(path.join(root, 'static/build/app.js'));
if (scenario === 'extra-output') await fs.writeFile(path.join(root, 'static/build/leftover.js'), 'old');
await assert.rejects(pack(), (error) => {
assert.match(error.stderr, /clean source|do not match|checksum mismatch|file set mismatch/i);
return true;
});
assert.deepEqual(await fs.readdir(output), []);
await assert.rejects(fs.access(path.join(root, 'shell/upgrade-lock.yaml')), { code: 'ENOENT' });
});
}
+2 -15
View File
@@ -36,23 +36,14 @@ for (const [scenario, expectedStatus, published] of [
fs.mkdirSync(bin);
fs.writeFileSync(
path.join(root, 'package.json'),
JSON.stringify({ name: '@fixture/unrelated-source', version: '0.0.0' }),
JSON.stringify({ name: '@fixture/qinglong', version: '2.22.0' }),
);
fs.mkdirSync(path.join(root, 'package'));
fs.mkdirSync(path.join(root, 'npm-panel'));
fs.writeFileSync(
path.join(root, 'package/package.json'),
JSON.stringify({ name: '@whyour/qinglong', version: '2.22.0' }),
);
const packed = spawnSync('tar', ['-czf', 'npm-panel/verified.tgz', 'package'], { cwd: root });
assert.equal(packed.status, 0, String(packed.stderr));
fs.writeFileSync(
path.join(bin, 'npm'),
`#!/usr/bin/env node
const fs = require('node:fs');
if (process.argv[2] === 'publish') { fs.writeFileSync('published', JSON.stringify(process.argv.slice(3))); process.exit(0); }
if (process.argv[2] === 'publish') { fs.writeFileSync('published', 'yes'); process.exit(0); }
if (process.argv[2] !== 'view') process.exit(99);
if (process.argv[3] !== '@whyour/qinglong@2.22.0') process.exit(98);
switch (process.env.QL_PUBLISH_SCENARIO) {
case 'existing': console.log(JSON.stringify('2.22.0')); break;
case 'unexpected-version': console.log(JSON.stringify('2.21.0')); break;
@@ -75,9 +66,5 @@ case 'network': console.log('upstream unavailable'); process.exit(1);
});
assert.equal(result.status, expectedStatus, result.stdout + result.stderr);
assert.equal(fs.existsSync(path.join(root, 'published')), published);
if (published)
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(root, 'published'), 'utf8')), [
'npm-panel/verified.tgz', '--ignore-scripts', '--access', 'public', '--registry=https://registry.npmjs.org',
]);
});
}
+3 -65
View File
@@ -7,67 +7,6 @@ const tar = require('tar');
const bundle = require('../../cli/src/internal/maintenance/platformBundle.cjs');
const artifacts = require('../../shell/upgrade.cjs');
test('dependency probes allow slow ARMv6 startup while preserving cancellation and normal Node execution', async () => {
const vm = require('node:vm');
const filename = require.resolve(
'../../cli/src/internal/maintenance/platformBundle.cjs',
);
const text = await fs.readFile(filename, 'utf8');
for (const [arch, armVersion, version, timeout] of [
['arm', 6, '20.18.1', 300000],
['arm', 6, '24.18.1', 300000],
['arm', 7, '24.18.1', 30000],
['x64', undefined, '24.18.1', 30000],
]) {
let launch;
const m = { exports: {} };
const controller = new AbortController();
const localRequire = (name) => {
if (name === 'node:child_process')
return {
execFile(program, args, options, callback) {
launch = { program, args, options };
callback(null, '', '');
},
};
return require(require.resolve(name, {
paths: [path.dirname(filename)],
}));
};
vm.runInNewContext(
text,
{
module: m,
exports: m.exports,
require: localRequire,
process: {
arch,
config: { variables: { arm_version: armVersion } },
versions: { node: version },
execPath: process.execPath,
env: { NODE_OPTIONS: '--jitless', NODE_PATH: '/outside' },
},
},
{ filename },
);
await m.exports.smokeDependencies('/staged/payload', controller.signal);
assert.equal(launch.program, process.execPath);
assert.equal(launch.args.at(-2), '-e');
assert.deepEqual(
Array.from(launch.args.slice(0, -2)),
arch === 'arm' && armVersion === 6 && version.startsWith('24.')
? ['--no-opt', '--no-sparkplug', '--no-maglev']
: [],
);
assert.ok(!launch.args.includes('--jitless'));
assert.equal(launch.options.cwd, '/staged/payload');
assert.equal(launch.options.timeout, timeout);
assert.equal(launch.options.signal, controller.signal);
assert.equal(launch.options.env.NODE_OPTIONS, '');
assert.equal(launch.options.env.NODE_PATH, '');
}
});
for (const failure of ['prestart', 'repair'])
test(`failed ${failure} preserves the installed dependencies and previous ready pointer`, async (t) => {
const f = await fixture(t);
@@ -456,7 +395,6 @@ test('native target compatibility distinguishes ARMv6/ARMv7 and libc and retains
bundle.assertCompatible(manifest, target, '20.20.2');
const glibcTarget = {
...target,
arch: 'armv7',
libc: 'glibc',
libcVersion: '2.31',
endianness: 'LE',
@@ -467,7 +405,7 @@ test('native target compatibility distinguishes ARMv6/ARMv7 and libc and retains
target: { ...glibcTarget, libcMinimum: '2.31' },
};
bundle.assertCompatible(glibcManifest, glibcTarget, '20.18.1');
assert.equal(bundle.targetKey(glibcTarget), 'linux-armv7-glibc');
assert.equal(bundle.targetKey(glibcTarget), 'linux-armv6-glibc');
assert.throws(
() =>
bundle.assertCompatible(glibcManifest, {
@@ -591,10 +529,10 @@ test('publishing requires every platform and validates all immutable archive has
);
}
const index = releaseIndex(f.directory, sourceCommit, repository);
assert.equal(Object.keys(index.packages).length, 12);
assert.equal(Object.keys(index.packages).length, 13);
const armv6 = path.join(
f.directory,
`qinglong-${sourceCommit}-linux-armv6-musl.tar.gz`,
`qinglong-${sourceCommit}-linux-armv6-glibc.tar.gz`,
);
const armv6Bytes = await fs.readFile(armv6);
await fs.rm(armv6);
+1 -2
View File
@@ -2,12 +2,11 @@ const test = require('node:test');
const assert = require('node:assert/strict');
const vm = require('node:vm');
const fs = require('node:fs');
const configRequire = require('node:module').createRequire(require('node:path').resolve('ecosystem.config.js'));
const source = fs.readFileSync('ecosystem.config.js', 'utf8');
const appSource = fs.readFileSync('back/app.ts', 'utf8');
const ts = require('typescript');
const compiled = ts.transpileModule(appSource.slice(0, appSource.indexOf('\nconst app = new Application();')) + '\nmodule.exports = Application;', {compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,esModuleInterop:true}}).outputText;
function config(env) {const module={exports:{}};vm.runInNewContext(source,{module,process:{env},require:configRequire});return module.exports.apps[0];}
function config(env) {const module={exports:{}};vm.runInNewContext(source,{module,process:{env}});return module.exports.apps[0];}
test('container APM policy keeps worker isolation and can restore all inherited probes', () => {
for (const primary of [undefined, 'true']) for (const workers of [undefined, 'true']) {
const env={QL_CONTAINER:'true',QL_PRIMARY_APM:primary,QL_WORKER_APM:workers};
+27 -126
View File
@@ -69,15 +69,6 @@ function fixture(t) {
const route = args[1];
if (route.endsWith('/git/ref/heads/master')) return state.head;
if (route.endsWith('/git/ref/heads/develop')) return state.developHead;
if (route.endsWith('/releases?per_page=100'))
return JSON.stringify([
Array.from(state.releases.keys(), (tag_name) => ({
tag_name,
prerelease: true,
draft: false,
...state.releaseMetadata?.[tag_name],
})),
]);
if (route.includes('/releases/tags/')) {
const tag = decodeURIComponent(route.split('/releases/tags/')[1]);
if (state.lookupFailure === tag)
@@ -127,23 +118,13 @@ function fixture(t) {
digest: `sha256:${sha256(bytes)}`,
});
}
if (tag === 'upgrade-develop' && state.corruptChannel)
release.get('latest.json').digest = 'sha256:wrong';
if (files.length > 1) {
if (state.corruptUploadedBatch)
release.get(path.basename(files[0])).digest = 'sha256:wrong';
if (state.changeHeadAfterUpload) state.head = 'b'.repeat(40);
if (state.changeDevelopHeadAfterUpload)
state.developHead = 'd'.repeat(40);
}
return '';
}
if (args[0] === 'release' && args[1] === 'delete') {
if (state.failDelete) throw new Error('Delete unavailable');
assert.ok(state.releases.has(args[2]));
state.releases.delete(args[2]);
return '';
}
if (args[0] === 'release' && args[1] === 'edit') return '';
throw new Error(`Unexpected gh call: ${JSON.stringify(args)}`);
};
@@ -162,10 +143,10 @@ test('publishes every platform and installer before advancing version and legacy
assert.equal(index.version, '2.24.0');
assert.equal(index.buildId, '100-1');
assert.equal(index.sourceCommit, f.options.commit);
assert.equal(Object.keys(index.packages).length, 12);
assert.equal(Object.keys(index.packages).length, 13);
assert.deepEqual(index, f.index('upgrade-v2.24.0'));
const tag = 'upgrade-build-v2.24.0-100-1';
assert.equal(f.state.releases.get(tag).size, 26);
assert.equal(f.state.releases.get(tag).size, 28);
for (const entry of [...Object.values(index.packages), index.installer]) {
const url = new URL(entry.url),
filename = path.basename(url.pathname);
@@ -249,7 +230,7 @@ for (const fault of ['missing', 'corrupt', 'wrong-version'])
const f = fixture(t);
const archive = path.join(
f.directory,
`qinglong-${f.options.commit}-linux-armv6-musl.tar.gz`,
`qinglong-${f.options.commit}-linux-armv6-glibc.tar.gz`,
);
if (fault === 'missing') fs.rmSync(archive);
else if (fault === 'corrupt') fs.appendFileSync(archive, 'corrupt');
@@ -307,7 +288,7 @@ test('an explicitly rerun earlier workflow may replace the same version and comm
test('non-master branches, invalid build IDs and API failures cannot overwrite published versions', (t) => {
const f = fixture(t);
for (const branch of ['debian-dev', 'v2.24.0', 'feature/test'])
for (const branch of ['develop', 'v2.24.0', 'feature/test'])
assert.throws(() => f.publish({ branch }), /Only master/);
for (const buildId of [undefined, '100', '100-1/other'])
assert.throws(() => f.publish({ buildId }), /Invalid bundle build ID/);
@@ -335,7 +316,7 @@ test('existing master clients can migrate from indexes without version/build met
assert.equal(f.index().buildId, '100-2');
});
test('automatic development publication shares musl/glibc packages without changing version or latest production assets', (t) => {
test('explicit development publication shares musl/glibc packages without changing version or latest production assets', (t) => {
const f = fixture(t);
f.publish();
const production = f.index();
@@ -344,17 +325,18 @@ test('automatic development publication shares musl/glibc packages without chang
f.publish({
branch: 'develop',
commit: f.state.developHead,
allowDevelopment: true,
}),
true,
);
const development = f.index('upgrade-develop');
assert.equal(development.channel, 'develop');
assert.equal(development.sourceCommit, f.state.developHead);
assert.equal(Object.keys(development.packages).length, 12);
assert.equal(Object.keys(development.packages).length, 13);
assert.ok(development.packages['linux-x64-musl']);
assert.ok(development.packages['linux-x64-glibc']);
assert.ok(development.packages['linux-armv6-musl']);
assert.equal(development.packages['linux-armv6-glibc'], undefined);
assert.ok(development.packages['linux-armv6-glibc']);
for (const entry of [
...Object.values(development.packages),
development.installer,
@@ -362,30 +344,44 @@ test('automatic development publication shares musl/glibc packages without chang
assert.ok(entry.url.includes('/upgrade-build-develop-v2.24.0-100-1/'));
assert.deepEqual(f.index(), production);
assert.deepEqual(f.index('upgrade-v2.24.0'), production);
assert.equal(f.state.releases.get('upgrade-build-v2.24.0-100-1').size, 26);
assert.equal(f.state.releases.get('upgrade-build-v2.24.0-100-1').size, 28);
assert.equal(
f.state.releases.get('upgrade-build-develop-v2.24.0-100-1').size,
26,
28,
);
});
test('development builds cannot publish obsolete development source', (t) => {
test('development builds require explicit boolean opt-in and cannot publish obsolete development source', (t) => {
const f = fixture(t);
for (const allowDevelopment of [false, undefined, 'true'])
assert.throws(
() => f.publish({ branch: 'develop', allowDevelopment }),
/explicitly requested/,
);
assert.throws(
() => f.publish({ branch: 'feature/test', allowDevelopment: true }),
/Only master/,
);
assert.equal(f.state.calls.length, 0);
f.write(f.state.developHead, 'development');
const obsolete = f.state.developHead;
f.state.developHead = 'd'.repeat(40);
assert.equal(f.publish({ branch: 'develop', commit: obsolete }), false);
assert.equal(
f.publish({ branch: 'develop', commit: obsolete, allowDevelopment: true }),
false,
);
assert.equal(f.state.releases.size, 0);
});
for (const fault of ['failBatchUpload', 'corruptUploadedBatch'])
test(`failed automatic development publication preserves both channels (${fault})`, (t) => {
test(`failed manual development publication preserves both channels (${fault})`, (t) => {
const f = fixture(t);
f.publish();
const production = f.index();
const options = {
branch: 'develop',
commit: f.state.developHead,
allowDevelopment: true,
};
f.write(options.commit, 'first development');
f.publish(options);
@@ -400,98 +396,3 @@ for (const fault of ['failBatchUpload', 'corruptUploadedBatch'])
assert.deepEqual(f.index(), production);
assert.deepEqual(f.index('upgrade-v2.24.0'), production);
});
for (const fault of [
null,
'failBatchUpload',
'corruptUploadedBatch',
'corruptChannel',
])
test(`development retention deletes old batches only after verified publication (${
fault || 'success'
})`, (t) => {
const f = fixture(t);
f.publish();
const production = f.index();
const options = { branch: 'develop', commit: f.state.developHead };
f.write(options.commit, 'development');
f.publish(options);
const oldTag = 'upgrade-build-develop-v2.24.0-100-1';
// Never prune production, channel aliases, legacy/unclassified releases,
// drafts or a non-prerelease even when its name resembles a dev build.
const protectedTags = [
'upgrade-86c37f426a5ba9945d6af1910a7885a26f77e4bc',
'v2.24.0',
'upgrade-build-develop-v2.24.0-10-1',
'upgrade-build-develop-v2.24.0-11-1',
];
for (const tag of protectedTags) f.state.releases.set(tag, new Map());
f.state.releaseMetadata = {
[protectedTags[2]]: { draft: true },
[protectedTags[3]]: { prerelease: false },
};
f.write(options.commit, 'next development');
if (fault) f.state[fault] = true;
if (fault)
assert.throws(
() => f.publish({ ...options, buildId: '101-1' }),
/Upload interrupted|verification failed/,
);
else {
assert.equal(f.publish({ ...options, buildId: '101-1' }), true);
assert.equal(f.index('upgrade-develop').buildId, '101-1');
const deletion = f.state.calls.findIndex(
(a) => a[0] === 'release' && a[1] === 'delete',
);
const aliasEdit = f.state.calls.findLastIndex(
(a) => a[0] === 'release' && a[1] === 'edit',
);
assert.ok(deletion > aliasEdit);
assert.ok(
f.state.calls
.slice(aliasEdit + 1, deletion)
.some(
(a) =>
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.equal(f.state.releases.has(oldTag), Boolean(fault));
for (const tag of protectedTags) assert.ok(f.state.releases.has(tag));
assert.deepEqual(f.index(), production);
assert.deepEqual(f.index('upgrade-v2.24.0'), production);
});
test('failed development cleanup is retryable without overwriting immutable assets', (t) => {
const f = fixture(t);
const options = { branch: 'develop', commit: f.state.developHead };
f.write(options.commit, 'development');
f.publish(options);
f.state.failDelete = true;
assert.throws(
() => f.publish({ ...options, buildId: '101-1' }),
/Delete unavailable/,
);
assert.equal(f.index('upgrade-develop').buildId, '101-1');
assert.ok(f.state.releases.has('upgrade-build-develop-v2.24.0-100-1'));
f.state.failDelete = false;
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.has('upgrade-build-develop-v2.24.0-101-1'));
});
test('a newer development commit during upload preserves the published channel and its old package', (t) => {
const f = fixture(t);
const options = { branch: 'develop', commit: f.state.developHead };
f.write(options.commit, 'development');
f.publish(options);
const before = f.index('upgrade-develop');
f.state.changeDevelopHeadAfterUpload = true;
assert.equal(f.publish({ ...options, buildId: '101-1' }), false);
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.calls.some((a) => a[0] === 'release' && a[1] === 'delete'),
);
});
+2 -44
View File
@@ -29,9 +29,7 @@ test('JS and Python script reports reach the same authenticated RPC with bounded
server.addService(Api.service, {
RecordNotifyLog: (call, callback) => {
received.push(call.request);
if (call.request.title === 'Concurrent cold startup')
setTimeout(() => callback(null, { code: 200 }), 2000);
else callback(null, { code: 200 });
callback(null, { code: 200 });
},
});
const credentials = grpc.ServerCredentials.createSsl(
@@ -93,46 +91,6 @@ n.send('Python failure','body')
);
assert.ok(received.every((x) => x.taskId === '9'));
assert.ok(!JSON.stringify(received).includes('private-password'));
// A busy host can spend longer than the former 3s timeout starting Node.
// All channels must still arrive, using one child and one TLS connection.
const coldStart = path.join(data, 'cold-start.cjs');
fs.writeFileSync(
coldStart,
'Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,3500);',
);
const concurrent = `import importlib.util,sys,types,os,threading,json,subprocess
sys.modules['requests']=types.SimpleNamespace()
spec=importlib.util.spec_from_file_location('notify',os.path.join(os.environ['QL_DIR'],'sample/notify.py'))
n=importlib.util.module_from_spec(spec);spec.loader.exec_module(n)
barrier=threading.Barrier(3)
def accepted(title,content):
barrier.wait(timeout=5);return True
def rejected(title,content):
barrier.wait(timeout=5);raise RuntimeError('private-password')
def no_result(title,content):
barrier.wait(timeout=5)
n.add_notify_function=lambda:[accepted,rejected,no_result]
original=subprocess.run
children=[]
def tracked(*args,**kwargs):
children.append(json.loads(kwargs['input']))
return original(*args,**kwargs)
n.subprocess.run=tracked
n.send('Concurrent cold startup','body')
assert len(children)==1,children
assert len(children[0])==3,children
`;
await run('python3', ['-c', concurrent], {
env: { ...env, NODE_OPTIONS: `--require=${coldStart}` },
timeout: 14000,
});
assert.deepEqual(
received
.filter((x) => x.title === 'Concurrent cold startup')
.map((x) => [x.notifyType, x.success])
.sort(),
[['accepted', true], ['no_result', false], ['rejected', false]],
);
// Missing certificates and an older/unavailable backend must not kill scripts.
await run(
process.execPath,
@@ -152,5 +110,5 @@ assert len(children[0])==3,children
],
{ env, timeout: 8000 },
);
assert.ok(Date.now() - started < 7000);
assert.ok(Date.now() - started < 5000);
});
-192
View File
@@ -1,192 +0,0 @@
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, []);
});
@@ -6,9 +6,7 @@ const path = require('node:path');
const { spawnSync } = require('node:child_process');
const startup = fs.readFileSync('shell/start-simplify.sh', 'utf8');
const runtimeLoader = fs.readFileSync('shell/share.sh', 'utf8')
.match(/^load_node_runtime_args\(\) \{[\s\S]*?^\}/m)[0];
const preparation = `${runtimeLoader}\ndir_root="$QL_DIR"\n` + startup.slice(
const preparation = startup.slice(
startup.indexOf('\nfix_config\n'),
startup.indexOf('log_with_style "INFO" "$(t \'🔄'),
);
-55
View File
@@ -6,61 +6,6 @@ const os = require('node:os');
const { execFileSync, spawnSync } = require('node:child_process');
const { writeManifest } = require('../helpers/upgrade-fixture.cjs');
test('both static publication steps supply a token as the HTTPS password and reject missing credentials', () => {
const workflow = require('js-yaml').load(
fs.readFileSync('.github/workflows/build-docker-image.yml', 'utf8'),
);
for (const name of [
'copy to static repo',
'publish verified upgrade download sizes',
]) {
const step = workflow.jobs['build-static'].steps.find(
(step) => step.name === name,
);
assert.equal(step.env.STATIC_TOKEN, '${{ secrets.API_TOKEN }}');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-static-auth-'));
try {
// Capture the real workflow command's argv, without contacting any remote.
for (const program of ['bash', 'node']) {
fs.writeFileSync(
path.join(tmp, program),
'#!/bin/sh\nprintf "%s\\n" "$@" > "$QA_ARGS"\n',
{ mode: 0o755 },
);
}
const argsFile = path.join(tmp, 'args');
const env = {
PATH: tmp,
QA_ARGS: argsFile,
STATIC_TOKEN: 'fixture-secret',
GITHUB_REPO: 'github.com/fixture/static',
GITHUB_BRANCH: 'develop',
GITHUB_REF_TYPE: 'branch',
GITHUB_SHA: 'a'.repeat(40),
};
const result = spawnSync('/bin/bash', ['-euc', step.run], {
env,
encoding: 'utf8',
});
assert.equal(result.status, 0, result.stderr);
const remote = fs.readFileSync(argsFile, 'utf8').split('\n')[1];
const url = new URL(remote);
assert.equal(url.username, 'x-access-token');
assert.equal(url.password, env.STATIC_TOKEN);
fs.unlinkSync(argsFile);
assert.notEqual(
spawnSync('/bin/bash', ['-euc', step.run], {
env: { ...env, STATIC_TOKEN: '' },
}).status,
0,
);
assert.equal(fs.existsSync(argsFile), false);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
}
});
test('static mirror receives complete orphan snapshots of each requested branch and reports rejection', (t) => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-static-mirror-'));
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
+69
View File
@@ -4,6 +4,75 @@ const load = require('../helpers/load-security-module.cjs');
const logger = { info() {}, error() {} };
function cronFixture(killTask) {
const events = [];
const { runCron } = load('back/shared/runCron.ts', {
'cross-spawn': {
spawn: () => {
events.push('spawn');
return {};
},
},
'./childProcess': {
observeChildProcess: () => ({ completed: Promise.resolve({ code: 0 }) }),
asError: (e) => e,
},
'./pLimit': {
runWithCronLimit: async (_, fn) => fn(),
removeQueuedCron: () => events.push('release'),
},
'../loaders/logger': logger,
'../config/util': { killTask },
'../data/cron': {
CrontabModel: {
findOne: async () => ({
pid: 100,
status: 0,
allow_multiple_instances: 0,
}),
update: async () => events.push('idle'),
},
CrontabStatus: { running: 0, idle: 1, queued: 3 },
},
'../data/runningInstance': {
RunningInstanceModel: { update: async () => events.push('stopped') },
InstanceStatus: { running: 0, stopped: 2 },
},
});
return { run: () => runCron('ignored', { id: '1' }), events };
}
test('single-instance replacement does not spawn or finalize when termination fails', async () => {
const f = cronFixture(async () => {
throw Error('EPERM');
});
await f.run();
assert.deepEqual(f.events, ['release']);
});
test('single-instance replacement waits for verified exit before spawning', async () => {
let finish, entered, verified;
const pending = new Promise((resolve) => {
entered = resolve;
});
const gate = new Promise((resolve) => {
finish = resolve;
});
const f = cronFixture(async (pid, wait) => {
assert.equal(pid, 100);
verified = wait;
entered();
await gate;
});
const running = f.run();
await pending;
assert.deepEqual(f.events, []);
finish();
await running;
assert.equal(verified, true);
assert.deepEqual(f.events, ['stopped', 'idle', 'spawn', 'release']);
});
function subscriptionFixture(killTask) {
const updates = [];
const Service = load('back/services/subscription.ts', {
+34 -53
View File
@@ -7,14 +7,6 @@ const yaml = require('js-yaml');
const workflow = yaml.load(
fs.readFileSync('.github/workflows/build-docker-image.yml', 'utf8'),
);
test('shared server runtime changes trigger the image and upgrade verification workflow', () => {
const paths = workflow.on.push.paths;
assert.ok(
paths.indexOf('cli/src/internal/maintenance/nodeRuntime.cjs') >
paths.indexOf('!cli/**'),
);
});
// These conditions use GitHub's boolean operators and format(), which have the
// same semantics for the boolean/string contexts exercised here.
const evaluate = (expression, github, inputs = {}) => {
@@ -33,14 +25,25 @@ const evaluate = (expression, github, inputs = {}) => {
);
};
test('both channels automatically build bundles, with static-only and Git-tag runs excluded', () => {
test('both bundle jobs require master or an explicit development dispatch', () => {
assert.equal(
workflow.on.workflow_dispatch.inputs.build_development_bundles.default,
false,
);
const cases = [
['push', 'refs/heads/develop', {}, true],
['workflow_dispatch', 'refs/heads/develop', {}, true],
['push', 'refs/heads/develop', {}, false],
['push', 'refs/heads/develop', { build_development_bundles: true }, false],
['workflow_dispatch', 'refs/heads/develop', {}, false],
[
'workflow_dispatch',
'refs/heads/develop',
{ static_sync_only: true },
{ build_development_bundles: true },
true,
],
[
'workflow_dispatch',
'refs/heads/develop',
{ build_development_bundles: true, static_sync_only: true },
false,
],
['push', 'refs/heads/master', {}, true],
@@ -52,7 +55,12 @@ test('both channels automatically build bundles, with static-only and Git-tag ru
false,
],
['push', 'refs/tags/v2.24.0', {}, false],
['workflow_dispatch', 'refs/tags/v2.24.0', {}, false],
[
'workflow_dispatch',
'refs/tags/v2.24.0',
{ build_development_bundles: true },
false,
],
];
for (const name of ['build-upgrade-bundles', 'publish-upgrade-bundles'])
for (const [event_name, ref, inputs, expected] of cases)
@@ -76,53 +84,26 @@ test('both channels automatically build bundles, with static-only and Git-tag ru
);
});
test('development image and bundle publication serialize separately from production and static-only runs', () => {
test('manual development releases serialize separately while normal develop CI remains independent', () => {
const group = (event_name, ref, inputs = {}, run_id = '100') =>
evaluate(workflow.concurrency.group, { event_name, ref, run_id }, inputs);
const master = group('push', 'refs/heads/master');
const development = group('push', 'refs/heads/develop');
const development = group('workflow_dispatch', 'refs/heads/develop', {
build_development_bundles: true,
});
assert.equal(
development,
group('workflow_dispatch', 'refs/heads/develop', {}, '101'),
group(
'workflow_dispatch',
'refs/heads/develop',
{ build_development_bundles: true },
'101',
),
);
assert.equal(master, group('push', 'refs/heads/master', {}, '101'));
assert.notEqual(master, development);
assert.notEqual(
development,
group('workflow_dispatch', 'refs/heads/develop', {
static_sync_only: true,
}),
);
assert.notEqual(
group('push', 'refs/tags/v2.24.0'),
group('push', 'refs/tags/v2.24.0', {}, '101'),
);
const push = group('push', 'refs/heads/develop');
assert.notEqual(push, development);
assert.notEqual(push, group('push', 'refs/heads/develop', {}, '101'));
assert.equal(workflow.concurrency['cancel-in-progress'], false);
});
test('bundle targets exactly match the source image platforms and publisher requirements', () => {
const targets =
workflow.jobs['build-upgrade-bundles'].strategy.matrix.include;
for (const [job, libc] of [
['build-alpine', 'musl'],
['build-debian', 'glibc'],
]) {
const platforms = workflow.jobs[job].steps
.find((step) => step.with?.platforms)
.with.platforms.split(',');
assert.deepEqual(
targets
.filter((t) => t.libc === libc)
.map((t) => t.platform)
.sort(),
platforms.sort(),
);
}
const { platforms } = require('../../scripts/publish-upgrade-bundles.cjs');
assert.deepEqual(
targets.map((t) => `linux-${t.name}`).sort(),
[...platforms].sort(),
);
assert.ok(platforms.includes('linux-armv6-musl'));
assert.ok(!platforms.includes('linux-armv6-glibc'));
});
-162
View File
@@ -1,162 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const { EventEmitter } = require('node:events');
const ts = require('typescript');
const load = require('../helpers/load-security-module.cjs');
const { SchedulerReadiness } = load('back/shared/schedulerReadiness.ts');
const { outputText } = ts.transpileModule(
fs.readFileSync('back/bootstrap/upgradeProbe.ts', 'utf8'),
{ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, esModuleInterop: true } },
);
// Run the real probe entry point, including its lifecycle and IPC reporting.
// Only external services and the clock are replaced; no test exports are needed.
async function runProbe({ readiness, arch = 'x64', armVersion, supervised = true }) {
let now = 0;
const events = [], waits = [], messages = [];
const build = { sourceCommit: 'a'.repeat(40) };
let finish;
const completed = new Promise((resolve) => { finish = resolve; });
const listener = { address: () => ({ port: 12345 }) };
const server = {
initialize: async () => { events.push('http'); return listener; },
shutdown: async () => { events.push('http-close'); },
};
const grpc = {
initialize: async () => { events.push('grpc'); return 12346; },
getServer: () => ({ forceShutdown: () => events.push('grpc-close') }),
};
class GrpcServerService {}
class HttpServerService {}
const modules = {
'reflect-metadata': {},
dns: { setDefaultResultOrder: () => {} },
'../config': {},
'../data': { sequelize: {
transaction: async (action) => action({}),
query: async () => [[{ value: 42 }]],
close: async () => { events.push('db-close'); },
} },
'../loaders/db': async () => { events.push('db'); },
typedi: { Container: { get: (service) => service === GrpcServerService ? grpc : server } },
'../services/grpc': { GrpcServerService },
'../services/http': { HttpServerService },
'../loaders/httpApplication': { createHttpApplication: async () => ({}) },
'../loaders/server': async () => {},
'../loaders/express': () => {},
'../loaders/app': {},
'../loaders/depInjector': () => {},
'../schedule/client': { readiness: {
configure: (restore) => readiness.configure(restore),
recover: async () => {
const ready = await readiness.recover();
if (ready) events.push('ready');
return ready;
},
} },
http: { get: (url, respond) => {
events.push('health');
const request = new EventEmitter();
request.setTimeout = () => {};
queueMicrotask(() => {
const response = new EventEmitter();
response.statusCode = 200;
respond(response);
response.emit('data', JSON.stringify({ code: 200, data: { status: 'ok', services: { grpc: true, http: true }, build } }));
response.emit('end');
});
return request;
} },
};
vm.runInNewContext(outputText, {
exports: {},
require: (name) => {
assert.ok(Object.hasOwn(modules, name), `Unexpected probe dependency: ${name}`);
return modules[name];
},
process: {
env: { QL_UPGRADE_PROBE: supervised ? '1' : '0' }, arch,
config: { variables: { arm_version: armVersion } }, pid: 123,
once: () => {}, memoryUsage: () => ({}),
send: (message) => { messages.push(message); events.push('report'); },
exit: finish,
},
Date: { now: () => now },
setTimeout: (callback, ms) => {
assert.ok(ms > 0 && ms <= 250);
waits.push(ms);
now += ms;
queueMicrotask(callback);
},
console: { error: () => {} },
}, { filename: 'upgradeProbe.js' });
const exitCode = await completed;
return { exitCode, events, waits, messages, elapsed: now, build };
}
test('upgrade probe survives cold scheduler recovery before checking HTTP and reporting readiness', async () => {
let attempts = 0, restored = false;
const readiness = new SchedulerReadiness(async () => {
if (++attempts < 3) throw new Error('Failed to connect before the deadline');
});
// The probe must configure and finish restoration before reporting readiness.
const configure = readiness.configure.bind(readiness);
readiness.configure = (restore) => configure(async () => { await restore(); restored = true; });
const result = await runProbe({ readiness });
assert.equal(result.exitCode, 0);
assert.equal(restored, true);
assert.equal(await readiness.check(), true);
assert.deepEqual(result.waits, [250, 250]);
assert.deepEqual(result.events, ['db', 'grpc', 'ready', 'http', 'health', 'report', 'http-close', 'grpc-close', 'db-close']);
assert.equal(result.messages.length, 1);
assert.equal(result.messages[0].ok, true);
assert.equal(result.messages[0].build.sourceCommit, result.build.sourceCommit);
});
for (const [arch, armVersion, timeout] of [['x64', undefined, 10000], ['arm', 7, 10000], ['arm', '6', 60000]]) {
test(`unavailable ${arch}/v${armVersion} scheduler fails at its startup budget and cleans up`, async () => {
let attempts = 0;
const result = await runProbe({ arch, armVersion, readiness: {
configure: () => {}, recover: async () => { attempts++; return false; },
} });
assert.equal(result.exitCode, 1);
assert.equal(result.elapsed, timeout);
assert.equal(attempts, timeout / 250);
assert.deepEqual(result.events, ['db', 'grpc', 'http-close', 'grpc-close', 'db-close', 'report']);
assert.equal(result.messages.length, 1);
assert.equal(result.messages[0].ok, false);
assert.match(result.messages[0].message, /Upgrade scheduler readiness failed/);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(attempts, timeout / 250, 'no retries after failure');
});
}
test('unexpected scheduler recovery errors fail prestart without retries or a success report', async () => {
let attempts = 0;
const result = await runProbe({ readiness: {
configure: () => {}, recover: async () => { attempts++; throw new Error('broken scheduler configuration'); },
} });
assert.equal(attempts, 1);
assert.equal(result.exitCode, 1);
assert.deepEqual(result.waits, []);
assert.deepEqual(result.events, ['db', 'grpc', 'http-close', 'grpc-close', 'db-close', 'report']);
assert.equal(result.messages.length, 1);
assert.equal(result.messages[0].ok, false);
assert.match(result.messages[0].message, /broken scheduler configuration/);
});
test('an already ready scheduler reaches health without waiting', async () => {
const result = await runProbe({ readiness: { configure: () => {}, recover: async () => true } });
assert.equal(result.exitCode, 0);
assert.deepEqual(result.waits, []);
assert.equal(result.messages[0].ok, true);
});
test('the upgrade probe still requires its isolated supervisor', async () => {
const result = await runProbe({ supervised: false });
assert.equal(result.exitCode, 1);
assert.deepEqual(result.events, ['report']);
assert.match(result.messages[0].message, /requires its isolated supervisor/);
});