Compare commits

...
Author SHA1 Message Date
whyour 2207098690 build: install Debian Node archives on build platform 2026-10-25 13:12:57 +08:00
whyour bd4775857a fix: use s390x-capable Python bases for Debian Node stages 2026-10-25 12:56:57 +08:00
whyour 997bd35fc6 build: unify container Node 24 and drop Debian armv7 2026-10-25 12:46:33 +08:00
whyour b92621bdb8 build: upgrade sqlite3 to 1.1.4 (#3108) 2026-10-25 01:52:58 +08:00
da9c6a6bbb fix: skip new scheduled triggers while a single-instance task is active (#3101)
* fix: skip active single-instance scheduled executions

* fix: recover stale single-instance scheduled tasks

* test: remove obsolete single-instance replacement assertions

---------

Co-authored-by: Patrick Gu <username@users.noreply.github.com>
Co-authored-by: whyour <imwhyour@gmail.com>
2026-10-24 23:11:36 +08:00
whyour d895fbba36 build: use one bundle validation path for all architectures (#3106) 2026-10-24 19:59:41 +08:00
whyour 6862ad4f6c build: use sqlite3 1.1.3 ARM musl prebuilds (#3105) 2026-10-24 17:40:47 +08:00
whyour b8110c52eb fix: isolate Alpine Node runtime and bound URL probes (#3104)
Pin Alpine amd64 to the verified upstream Node runtime without retaining temporary binaries in image layers. Bound fresh-process URL probes and verify serialized amd64/ARMv6 layers in CI. Keep existing Sequelize, SQLite and Debian runtime behavior unchanged.
2026-10-23 23:34:24 +08:00
whyour 99a4d0eff8 fix: batch notification audits and bump CLI to 0.1.3 2026-10-22 01:25:29 +08:00
whyour b486989399 fix: validate ARMv6 upgrades and publish rolling development bundles 2026-10-21 01:43:44 +08:00
whyour 77fe551425 fix: simplify npm packaging and align frontend Node builds 2026-10-19 18:06:06 +08:00
whyour 2669f251ac fix: preserve npm environment during package replacement 2026-10-19 15:41:42 +08:00
whyour 3f02b04dff fix: ship verified npm panel packages and test production migrations 2026-10-19 14:17:53 +08:00
whyour e8b6a30bd9 feat: ship verified platform upgrades with channel bundle releases
Package production dependencies per architecture for online and offline upgrades.
Validate dependencies and isolated backend startup before readiness, repair once
when permitted, and preserve service/data through failed preparation or rollback.

Publish release bundles with master images and expose an opt-in develop build
sharing the development index across Alpine and Debian images.
2026-10-18 22:19:49 +08:00
whyour a36a77086a fix: validate extra schedules and localize validation errors
Reject invalid extra_schedules with HTTP 400 before create or update reaches the task service. Preserve omitted, null and empty extras, supported cron syntax, and additional metadata.

Localize missing rules, invalid string and array types, and non-object extra entries through the existing per-request translation middleware. Cover POST/PUT rejection without mutations, dynamic English/Chinese switching, and stable error paths.

Validation: 9 related regression tests passed on current develop; backend build passed. The API validation fix also passed full regression and isolated develop/debian-dev acceptance.

Refs #3099
2026-10-18 01:08:36 +08:00
89 changed files with 7874 additions and 352 deletions
+202 -29
View File
@@ -8,6 +8,10 @@ on:
# CLI is verified separately; its shared Bash upgrade engine ships in images. # CLI is verified separately; its shared Bash upgrade engine ships in images.
- "!cli/**" - "!cli/**"
- "cli/src/internal/maintenance/upgradeArtifacts.cjs" - "cli/src/internal/maintenance/upgradeArtifacts.cjs"
- "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" - "!.github/workflows/cli-package.yml"
branches: branches:
- "master" - "master"
@@ -33,6 +37,14 @@ on:
permissions: permissions:
contents: read contents: read
# 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.
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) }}
cancel-in-progress: false
jobs: jobs:
validate: validate:
if: ${{ !inputs.static_sync_only }} if: ${{ !inputs.static_sync_only }}
@@ -68,7 +80,7 @@ jobs:
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
node-version: "20" node-version-file: ".nvmrc"
cache: "pnpm" cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml cache-dependency-path: pnpm-lock.yaml
@@ -79,6 +91,9 @@ jobs:
pnpm build:back pnpm build:back
pnpm build:info 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 - uses: actions/upload-artifact@v6
with: with:
name: qinglong-static-${{ github.sha }} name: qinglong-static-${{ github.sha }}
@@ -86,19 +101,31 @@ jobs:
if-no-files-found: error if-no-files-found: error
include-hidden-files: true 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 - name: copy to static repo
env: env:
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
GITHUB_BRANCH: ${{ github.ref_name }} GITHUB_BRANCH: ${{ github.ref_name }}
GITHUB_REF_TYPE: ${{ github.ref_type }} GITHUB_REF_TYPE: ${{ github.ref_type }}
STATIC_TOKEN: ${{ secrets.API_TOKEN }}
run: | run: |
bash scripts/publish-static.sh "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_REF_TYPE" : "${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"
- name: publish verified upgrade download sizes - name: publish verified upgrade download sizes
env: env:
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
GITHUB_BRANCH: ${{ github.ref_name }} GITHUB_BRANCH: ${{ github.ref_name }}
run: node scripts/publish-upgrade-info.cjs "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_SHA" 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"
static_gitlab: static_gitlab:
needs: build-static needs: build-static
@@ -203,6 +230,8 @@ jobs:
build-alpine: build-alpine:
if: ${{ !startsWith(github.ref, 'refs/tags/') }} if: ${{ !startsWith(github.ref, 'refs/tags/') }}
needs: build-static needs: build-static
outputs:
image-digest: ${{ steps.image.outputs.digest }}
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
permissions: permissions:
packages: write packages: write
@@ -218,7 +247,7 @@ jobs:
version: "8.3.1" version: "8.3.1"
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
node-version: "20" node-version: "24"
cache: "pnpm" cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml cache-dependency-path: pnpm-lock.yaml
@@ -268,6 +297,7 @@ jobs:
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v4
- name: Build and push (Alpine) - name: Build and push (Alpine)
id: image
uses: docker/build-push-action@v7 uses: docker/build-push-action@v7
with: with:
build-args: | build-args: |
@@ -289,6 +319,8 @@ jobs:
build-debian: build-debian:
if: ${{ !startsWith(github.ref, 'refs/tags/') }} if: ${{ !startsWith(github.ref, 'refs/tags/') }}
needs: build-static needs: build-static
outputs:
image-digest: ${{ steps.image.outputs.digest }}
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
permissions: permissions:
packages: write packages: write
@@ -304,7 +336,7 @@ jobs:
version: "8.3.1" version: "8.3.1"
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
node-version: "20" node-version: "24"
cache: "pnpm" cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml cache-dependency-path: pnpm-lock.yaml
@@ -353,6 +385,7 @@ jobs:
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v4
- name: Build and push (Debian) - name: Build and push (Debian)
id: image
uses: docker/build-push-action@v7 uses: docker/build-push-action@v7
with: with:
build-args: | build-args: |
@@ -361,7 +394,7 @@ jobs:
SOURCE_COMMIT=${{ github.sha }} SOURCE_COMMIT=${{ github.sha }}
network: host network: host
# Keep s390x on Debian: npm can hang under QEMU with Alpine (nodejs/docker-node#1973). # Keep s390x on Debian: npm can hang under QEMU with Alpine (nodejs/docker-node#1973).
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/s390x
context: . context: .
file: ./docker/Dockerfile.debian file: ./docker/Dockerfile.debian
push: true push: true
@@ -390,7 +423,7 @@ jobs:
version: "8.3.1" version: "8.3.1"
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
node-version: "20" node-version: "24"
cache: "pnpm" cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml cache-dependency-path: pnpm-lock.yaml
@@ -462,7 +495,7 @@ jobs:
version: "8.3.1" version: "8.3.1"
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
node-version: "20" node-version: "24"
cache: "pnpm" cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml cache-dependency-path: pnpm-lock.yaml
@@ -505,7 +538,7 @@ jobs:
SOURCE_COMMIT=${{ github.sha }} SOURCE_COMMIT=${{ github.sha }}
network: host network: host
# Keep s390x on Debian: npm can hang under QEMU with Alpine (nodejs/docker-node#1973). # Keep s390x on Debian: npm can hang under QEMU with Alpine (nodejs/docker-node#1973).
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/s390x
context: . context: .
file: ./docker/Dockerfile.debian310 file: ./docker/Dockerfile.debian310
push: true push: true
@@ -517,30 +550,78 @@ jobs:
cache-from: type=registry,ref=whyour/qinglong:cache-debian-python3.10 cache-from: type=registry,ref=whyour/qinglong:cache-debian-python3.10
cache-to: type=registry,ref=whyour/qinglong:cache-debian-python3.10,mode=max 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: publish:
if: ${{ github.repository == 'whyour/qinglong' && github.ref == 'refs/heads/master' }} if: ${{ github.repository == 'whyour/qinglong' && github.ref == 'refs/heads/master' }}
needs: [build-alpine, build-debian, build-alpine310, build-debian310] needs: [build-alpine, build-debian, build-alpine310, build-debian310, verify-npm-package]
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
id-token: write id-token: write
steps: 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 - name: Set up Node for trusted publishing
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
@@ -548,18 +629,110 @@ jobs:
registry-url: https://registry.npmjs.org registry-url: https://registry.npmjs.org
package-manager-cache: false package-manager-cache: false
- uses: actions/download-artifact@v6
with:
name: qinglong-npm-${{ github.sha }}
path: npm-panel/
- name: Publish npm package with OIDC - name: Publish npm package with OIDC
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
name=$(node -p 'require("./package.json").name') shopt -s nullglob
version=$(node -p 'require("./package.json").version') 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")
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 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 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." 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 elif jq -e '.error.code == "E404"' "$RUNNER_TEMP/npm-version.json" > /dev/null; then
npm publish --access public --registry=https://registry.npmjs.org npm publish "$archive" --ignore-scripts --access public --registry=https://registry.npmjs.org
else else
echo '::error::Could not check the published npm version; refusing to publish.' echo '::error::Could not check the published npm version; refusing to publish.'
exit 1 exit 1
fi 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') }}
needs: [build-alpine, build-debian]
runs-on: ubuntu-22.04
timeout-minutes: 90
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- { platform: linux/amd64, libc: musl, name: x64-musl }
- { platform: linux/arm/v6, libc: musl, name: armv6-musl }
- { platform: linux/arm/v7, libc: musl, name: armv7-musl }
- { platform: linux/arm64, libc: musl, name: arm64-musl }
- { platform: linux/ppc64le, libc: musl, name: ppc64-musl }
- { 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/arm64, libc: glibc, name: arm64-glibc }
- { platform: linux/ppc64le, libc: glibc, name: ppc64-glibc }
- { platform: linux/s390x, libc: glibc, name: s390x-glibc }
steps:
- uses: actions/checkout@v6
- uses: docker/setup-qemu-action@v4
with:
cache-image: false
- uses: docker/setup-buildx-action@v4
- name: Build and probe the immutable platform package
env:
ALPINE_DIGEST: ${{ needs.build-alpine.outputs.image-digest }}
DEBIAN_DIGEST: ${{ needs.build-debian.outputs.image-digest }}
BUNDLE_LIBC: ${{ matrix.libc }}
BUNDLE_PLATFORM: ${{ matrix.platform }}
run: |
set -euo pipefail
digest="$ALPINE_DIGEST"
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 .
- uses: actions/upload-artifact@v6
with:
name: upgrade-${{ matrix.name }}-${{ github.sha }}
path: output/upgrade/*
if-no-files-found: error
publish-upgrade-bundles:
if: ${{ !inputs.static_sync_only && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/develop') }}
needs: build-upgrade-bundles
runs-on: ubuntu-latest
permissions:
contents: write
concurrency:
group: upgrade-channel-${{ github.ref_name }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '24'
- uses: pnpm/action-setup@v6
with:
version: '8.3.1'
- run: pnpm install --prod --frozen-lockfile
- run: npm --prefix cli ci
- uses: actions/download-artifact@v7
with:
pattern: upgrade-*-${{ github.sha }}
path: output/upgrade
merge-multiple: true
- run: node scripts/build-bundle-installer.cjs output/upgrade/install-bundle.cjs
- name: Publish complete packages before advancing the channel
env:
GH_TOKEN: ${{ github.token }}
run: node scripts/publish-upgrade-bundles.cjs output/upgrade "$GITHUB_SHA" "$GITHUB_REPOSITORY" "$GITHUB_REF_NAME"
+55 -12
View File
@@ -6,6 +6,11 @@ on:
- '.github/workflows/release-readiness.yml' - '.github/workflows/release-readiness.yml'
- 'package.json' - 'package.json'
- 'pnpm-lock.yaml' - 'pnpm-lock.yaml'
- 'docker/Dockerfile*'
- 'shell/node-runtime-probe.cjs'
- 'scripts/verify-panel-package.cjs'
- 'scripts/verify-image-node-layers.py'
- 'test/back/node-runtime-probe.test.cjs'
workflow_dispatch: workflow_dispatch:
permissions: permissions:
@@ -41,6 +46,13 @@ jobs:
path: static/ path: static/
include-hidden-files: true include-hidden-files: true
if-no-files-found: error if-no-files-found: error
- name: Pack the verified npm archive for isolated runtime validation
run: node scripts/verify-panel-package.cjs pack "$RUNNER_TEMP/npm-panel"
- uses: actions/upload-artifact@v6
with:
name: release-readiness-npm-panel
path: ${{ runner.temp }}/npm-panel/
if-no-files-found: error
images: images:
needs: [regression, static] needs: [regression, static]
@@ -57,7 +69,7 @@ jobs:
- image: debian - image: debian
file: docker/Dockerfile.debian file: docker/Dockerfile.debian
cache: debian cache: debian
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/s390x
- image: alpine-python3.10 - image: alpine-python3.10
file: docker/Dockerfile.310 file: docker/Dockerfile.310
cache: alpine-python3.10 cache: alpine-python3.10
@@ -65,7 +77,7 @@ jobs:
- image: debian-python3.10 - image: debian-python3.10
file: docker/Dockerfile.debian310 file: docker/Dockerfile.debian310
cache: debian-python3.10 cache: debian-python3.10
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/s390x
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
with: with:
@@ -121,16 +133,47 @@ jobs:
docker run --rm --network none --entrypoint node \ docker run --rm --network none --entrypoint node \
qinglong-readiness:${{ matrix.image }} \ qinglong-readiness:${{ matrix.image }} \
-e 'const assert=require("node:assert/strict"); const {createRequire}=require("node:module"); for(const [name,want] of Object.entries({"@grpc/grpc-js":"1.14.5",compression:"1.8.2",multer:"2.4.0",sequelize:"6.37.8"}))assert.equal(require(name+"/package.json").version,want); const expressRequire=createRequire(require.resolve("express")); assert.equal(expressRequire("proxy-addr/package.json").version,"2.0.8"); const sockRequire=createRequire(require.resolve("sockjs")); assert.equal(sockRequire("websocket-driver/package.json").version,"0.7.5"); require("sqlite3"); require("nodemailer"); require("undici"); console.log("Minimal production patches and runtime imports verified")' -e 'const assert=require("node:assert/strict"); const {createRequire}=require("node:module"); for(const [name,want] of Object.entries({"@grpc/grpc-js":"1.14.5",compression:"1.8.2",multer:"2.4.0",sequelize:"6.37.8"}))assert.equal(require(name+"/package.json").version,want); const expressRequire=createRequire(require.resolve("express")); assert.equal(expressRequire("proxy-addr/package.json").version,"2.0.8"); const sockRequire=createRequire(require.resolve("sockjs")); assert.equal(sockRequire("websocket-driver/package.json").version,"0.7.5"); require("sqlite3"); require("nodemailer"); require("undici"); console.log("Minimal production patches and runtime imports verified")'
- name: Export native Debian image for isolated dev validation - name: Load ARMv6 image for SQLite and Node regression checks
if: matrix.image == 'debian' if: startsWith(matrix.image, 'alpine')
run: | uses: docker/build-push-action@v7
docker save qinglong-readiness:debian | gzip -1 > release-readiness-debian-image.tar.gz
sha256sum release-readiness-debian-image.tar.gz > release-readiness-debian-image.sha256
docker image inspect qinglong-readiness:debian > release-readiness-debian-image.json
- uses: actions/upload-artifact@v6
if: matrix.image == 'debian'
with: with:
name: release-readiness-debian-image context: .
path: release-readiness-debian-image.* file: ${{ matrix.file }}
platforms: linux/arm/v6
build-args: |
QL_URL=${{ steps.source.outputs.url }}
QL_BRANCH=${{ steps.source.outputs.branch }}
SOURCE_COMMIT=${{ steps.source.outputs.sha }}
tags: qinglong-readiness:${{ matrix.image }}-armv6
load: true
push: false
provenance: false
sbom: false
- name: Verify ARMv6 SQLite on an ARM1176 CPU without networking
if: startsWith(matrix.image, 'alpine')
run: |
docker run --rm --network none --platform linux/arm/v6 \
-e QEMU_CPU=arm1176 --entrypoint node \
qinglong-readiness:${{ matrix.image }}-armv6 --jitless \
-e 'const assert=require("node:assert/strict"); const sqlite3=require("sqlite3"); const db=new sqlite3.Database(":memory:"); db.get("SELECT 42 AS n",(error,row)=>{assert.ifError(error); assert.equal(row.n,42); db.close()});'
- name: Check Node binaries in every serialized image layer
if: startsWith(matrix.image, 'alpine')
run: |
docker save qinglong-readiness:${{ matrix.image }} -o "$RUNNER_TEMP/node-layers-amd64.tar"
docker save qinglong-readiness:${{ matrix.image }}-armv6 -o "$RUNNER_TEMP/node-layers-arm.tar"
python3 scripts/verify-image-node-layers.py "$RUNNER_TEMP/node-layers-amd64.tar" amd64
python3 scripts/verify-image-node-layers.py "$RUNNER_TEMP/node-layers-arm.tar" arm
rm "$RUNNER_TEMP/node-layers-amd64.tar" "$RUNNER_TEMP/node-layers-arm.tar"
- name: Export native image for isolated dev validation
if: matrix.image == 'debian' || matrix.image == 'alpine'
run: |
docker save qinglong-readiness:${{ matrix.image }} | gzip -1 > release-readiness-${{ matrix.image }}-image.tar.gz
sha256sum release-readiness-${{ matrix.image }}-image.tar.gz > release-readiness-${{ matrix.image }}-image.sha256
docker image inspect qinglong-readiness:${{ matrix.image }} > release-readiness-${{ matrix.image }}-image.json
- uses: actions/upload-artifact@v6
if: matrix.image == 'debian' || matrix.image == 'alpine'
with:
name: release-readiness-${{ matrix.image }}-image
path: release-readiness-${{ matrix.image }}-image.*
compression-level: 0 compression-level: 0
if-no-files-found: error if-no-files-found: error
+1
View File
@@ -29,6 +29,7 @@ __pycache__
/shell/preload/*-notify.json /shell/preload/*-notify.json
/shell/preload/__ql_notify__.* /shell/preload/__ql_notify__.*
/shell/preload/lang_env.sh /shell/preload/lang_env.sh
/shell/upgrade-lock.yaml
.deepseek/ .deepseek/
.claude/ .claude/
-22
View File
@@ -1,22 +0,0 @@
/.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
@@ -0,0 +1 @@
22
+11 -28
View File
@@ -41,11 +41,18 @@ class Application {
} }
private startMasterProcess() { private startMasterProcess() {
const readyTimeout =
process.arch === 'arm' &&
Number(
(process.config.variables as { arm_version?: string | number }).arm_version,
) === 6
? 120000
: 30000;
// Fork gRPC worker first and wait for it to be ready // Fork gRPC worker first and wait for it to be ready
const grpcWorker = this.forkWorker('grpc'); const grpcWorker = this.forkWorker('grpc');
// Wait for gRPC worker to signal it's ready before starting HTTP worker // Wait for gRPC worker to signal it's ready before starting HTTP worker
this.waitForWorkerReady(grpcWorker, 30000) this.waitForWorkerReady(grpcWorker, readyTimeout)
.then(() => { .then(() => {
Logger.info('[boot] gRPC worker is ready, starting HTTP worker'); Logger.info('[boot] gRPC worker is ready, starting HTTP worker');
this.httpWorker = this.forkWorker('http'); this.httpWorker = this.forkWorker('http');
@@ -71,7 +78,7 @@ class Application {
Logger.warn('Unable to notify HTTP worker of scheduler exit'); Logger.warn('Unable to notify HTTP worker of scheduler exit');
} }
const newGrpcWorker = this.forkWorker('grpc'); const newGrpcWorker = this.forkWorker('grpc');
this.waitForWorkerReady(newGrpcWorker, 30000) this.waitForWorkerReady(newGrpcWorker, readyTimeout)
.then(() => { .then(() => {
Logger.info('gRPC worker restarted and ready'); Logger.info('gRPC worker restarted and ready');
// Re-register cron jobs by notifying the HTTP worker // Re-register cron jobs by notifying the HTTP worker
@@ -148,32 +155,8 @@ class Application {
} }
private async setupMiddlewares(): Promise<express.Application> { private async setupMiddlewares(): Promise<express.Application> {
// Only the HTTP worker needs an Express application and its middleware. const { createHttpApplication } = await import('./loaders/httpApplication');
const [ return createHttpApplication();
{ default: createExpress },
{ default: helmet },
{ default: cors },
{ default: compression },
{ monitoringMiddleware },
] = await Promise.all([
import('express'),
import('helmet'),
import('cors'),
import('compression'),
import('./middlewares/monitoring'),
]);
const app = createExpress();
app.use((req, res, next) => {
if (req.query.t) delete req.query.t;
next();
});
app.use(helmet({
contentSecurityPolicy: false,
}));
app.use(cors(config.cors));
app.use(compression());
app.use(monitoringMiddleware);
return app;
} }
private setupMasterShutdown() { private setupMasterShutdown() {
+139
View File
@@ -0,0 +1,139 @@
// A single, disposable backend process. Resolve startup dependencies, but never
// run appLoader: its initData/initTask hooks install dependencies and start user work.
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');
dns.setDefaultResultOrder('ipv4first');
const { default: config } = await import('../config');
const { sequelize } = await import('../data');
const { default: loadDatabase } = await import('../loaders/db');
const { Container } = await import('typedi');
const { GrpcServerService } = await import('../services/grpc');
const { HttpServerService } = await import('../services/http');
const { createHttpApplication } = await import('../loaders/httpApplication');
const { default: loadServer } = await import('../loaders/server');
const { default: loadExpress } = await import('../loaders/express');
await import('../loaders/app'); // Resolve normal startup hooks without running them.
const { default: inject } = await import('../loaders/depInjector');
const grpc = Container.get(GrpcServerService);
const server = Container.get(HttpServerService);
try {
await loadDatabase();
// Exercise an actual on-disk transaction as well as schema initialization.
await sequelize.transaction(async (transaction) => {
await sequelize.query('CREATE TABLE upgrade_probe (value INTEGER)', {
transaction,
});
await sequelize.query('INSERT INTO upgrade_probe VALUES (42)', {
transaction,
});
const [rows] = await sequelize.query('SELECT value FROM upgrade_probe', {
transaction,
});
if ((rows[0] as { value: number })?.value !== 42)
throw new Error('Upgrade database readback failed');
});
inject();
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,
);
const app = await createHttpApplication();
loadExpress({ app });
const listener = await server.initialize(app, 0);
await loadServer({ server: listener });
const address = listener.address();
if (!address || typeof address === 'string')
throw new Error('No probe HTTP port');
const health = await new Promise<any>((resolve, reject) => {
const request = http.get(
`http://127.0.0.1:${address.port}/api/health`,
(response) => {
let body = '';
response.on('data', (chunk) => {
body += chunk;
});
response.on('error', reject);
response.on('end', () => {
try {
const result = JSON.parse(body);
if (
response.statusCode !== 200 ||
result.code !== 200 ||
result.data?.status !== 'ok' ||
!result.data.services?.grpc ||
!result.data.services?.http
)
throw new Error('Upgrade HTTP/gRPC health check failed');
resolve(result.data);
} catch (error) {
reject(error);
}
});
},
);
request.setTimeout(10000, () =>
request.destroy(new Error('Upgrade health request timed out')),
);
request.on('error', reject);
});
process.send!({
type: 'upgrade-probe',
ok: true,
build: health.build,
memory: process.memoryUsage(),
pid: process.pid,
});
} finally {
await server.shutdown();
grpc.getServer().forceShutdown();
await sequelize.close();
}
}
process.once('disconnect', () => process.exit(1));
probeUpgrade()
.then(() => process.exit(0))
.catch((error) => {
process.send?.({
type: 'upgrade-probe',
ok: false,
code: error.code,
message: error.message,
stack: error.stack,
});
console.error(error);
process.exit(1);
});
+33
View File
@@ -0,0 +1,33 @@
import type express from 'express';
import config from '../config';
export async function createHttpApplication(): Promise<express.Application> {
// Only the HTTP worker needs an Express application and its middleware.
const [
{ default: createExpress },
{ default: helmet },
{ default: cors },
{ default: compression },
{ monitoringMiddleware },
] = await Promise.all([
import('express'),
import('helmet'),
import('cors'),
import('compression'),
import('../middlewares/monitoring'),
]);
const app = createExpress();
app.use((req, res, next) => {
if (req.query.t) delete req.query.t;
next();
});
app.use(
helmet({
contentSecurityPolicy: false,
}),
);
app.use(cors(config.cors));
app.use(compression());
app.use(monitoringMiddleware);
return app;
}
+2 -2
View File
@@ -50,13 +50,13 @@ export class GrpcServerService {
for (const host of hostsToTry) { for (const host of hostsToTry) {
try { try {
const address = this.formatGrpcAddress(host, grpcPort); const address = this.formatGrpcAddress(host, grpcPort);
await bindAsync(address, credentials); const boundPort = await bindAsync(address, credentials);
Logger.debug(`[boot] gRPC service started successfully on ${address}`); Logger.debug(`[boot] gRPC service started successfully on ${address}`);
metricsService.record('grpc_service_start', 1, { metricsService.record('grpc_service_start', 1, {
port: grpcPort.toString(), port: grpcPort.toString(),
host host
}); });
return grpcPort; return boundPort;
} catch (err) { } catch (err) {
lastError = err as Error; lastError = err as Error;
Logger.warn(`Failed to bind gRPC on ${host}:${grpcPort}, trying next...`, err); Logger.warn(`Failed to bind gRPC on ${host}:${grpcPort}, trying next...`, err);
+3
View File
@@ -212,6 +212,9 @@ const messages: Record<string, Record<string, string>> = {
日志设置为忽略: 'Log set to ignore', 日志设置为忽略: 'Log set to ignore',
定时规则不能为空: 'Schedule rule cannot be empty', 定时规则不能为空: 'Schedule rule cannot be empty',
无效的定时规则: 'Invalid schedule rule', 无效的定时规则: 'Invalid schedule rule',
定时规则必须是字符串: 'Schedule rule must be a string',
额外定时规则必须是对象: 'Extra schedule must be an object',
额外定时规则必须是数组: 'Extra schedules must be an array',
'日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符': '日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符':
'Log name can only contain Chinese characters, letters, numbers, underscores, hyphens, dots, and path separators', 'Log name can only contain Chinese characters, letters, numbers, underscores, hyphens, dots, and path separators',
日志名称不能超过100个字符: 'Log name cannot exceed 100 characters', 日志名称不能超过100个字符: 'Log name cannot exceed 100 characters',
+85 -28
View File
@@ -4,7 +4,6 @@ import taskLimit from './pLimit';
import Logger from '../loaders/logger'; import Logger from '../loaders/logger';
import { ICron } from '../protos/cron'; import { ICron } from '../protos/cron';
import { CrontabModel, CrontabStatus } from '../data/cron'; import { CrontabModel, CrontabStatus } from '../data/cron';
import { killTask } from '../config/util';
import { RunningInstanceModel, InstanceStatus } from '../data/runningInstance'; import { RunningInstanceModel, InstanceStatus } from '../data/runningInstance';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { observeChildProcess, asError } from './childProcess'; import { observeChildProcess, asError } from './childProcess';
@@ -17,7 +16,7 @@ export function runCron(
const execute = async () => { const execute = async () => {
try { try {
if (!isCurrent()) return; if (!isCurrent()) return;
// Check if the cron is already running and stop it (only if multiple instances are not allowed) // A single-instance trigger must not interrupt work already in progress.
try { try {
const existingCron = await CrontabModel.findOne({ const existingCron = await CrontabModel.findOne({
where: { id: Number(cron.id) }, where: { id: Number(cron.id) },
@@ -30,35 +29,93 @@ export function runCron(
const allowSingleInstances = const allowSingleInstances =
existingCron?.allow_multiple_instances === 0; existingCron?.allow_multiple_instances === 0;
if ( if (allowSingleInstances) {
allowSingleInstances && // A queued manual run is an active reservation, even if its stored PID
existingCron && // belongs to a previous execution.
existingCron.pid && if (
(existingCron.status === CrontabStatus.running || existingCron.status === CrontabStatus.queued &&
existingCron.status === CrontabStatus.queued) existingCron.pid
) { ) {
Logger.info( Logger.info(
t('[schedule][停止已运行任务] 任务ID: %s, PID: %s'), '[schedule][Skip queued single-instance task] Task ID: %s, PID: %s',
cron.id, cron.id,
existingCron.pid, existingCron.pid,
); );
await killTask(existingCron.pid, true); return;
// Mark old running instances as stopped }
const stoppedAt = dayjs().unix(); if (
await RunningInstanceModel.update( existingCron.status === CrontabStatus.running &&
{ status: InstanceStatus.stopped, finished_at: stoppedAt }, existingCron.pid
{ ) {
let missing = false;
try {
process.kill(existingCron.pid, 0);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ESRCH') missing = true;
else if (code !== 'EPERM') throw error;
}
if (!missing) {
Logger.info(
'[schedule][Skip active single-instance task] Task ID: %s, PID: %s',
cron.id,
existingCron.pid,
);
return;
}
// Capture old instance IDs before releasing the stale cron state so
// a concurrent replacement cannot have its instance marked stopped.
const staleInstances = await RunningInstanceModel.findAll({
attributes: ['id'],
where: { where: {
cron_id: Number(cron.id), cron_id: Number(cron.id),
pid: existingCron.pid,
status: InstanceStatus.running, status: InstanceStatus.running,
}, },
}, });
); if (!isCurrent()) return;
// Update the status to idle after killing const [recovered] = await CrontabModel.update(
await CrontabModel.update( {
{ status: CrontabStatus.idle, pid: undefined }, status: CrontabStatus.idle,
{ where: { id: Number(cron.id) } }, pid: null,
); queued_token: null,
} as any,
{
where: {
id: Number(cron.id),
pid: existingCron.pid,
status: CrontabStatus.running,
...(existingCron.log_path !== undefined
? { log_path: existingCron.log_path }
: {}),
...(existingCron.queued_token !== undefined
? { queued_token: existingCron.queued_token }
: {}),
...(existingCron.last_execution_time !== undefined
? { last_execution_time: existingCron.last_execution_time }
: {}),
...(existingCron.isDisabled !== undefined
? { isDisabled: existingCron.isDisabled }
: {}),
allow_multiple_instances: 0,
},
},
);
if (recovered !== 1) return;
const staleIds = staleInstances.map((instance) => instance.id!);
if (staleIds.length) {
await RunningInstanceModel.update(
{ status: InstanceStatus.stopped, finished_at: dayjs().unix() },
{ where: { id: staleIds, status: InstanceStatus.running } },
);
}
Logger.info(
'[schedule][Recovered stale single-instance task] Task ID: %s, PID: %s',
cron.id,
existingCron.pid,
);
}
} }
} catch (error) { } catch (error) {
Logger.error( Logger.error(
@@ -69,7 +126,7 @@ export function runCron(
throw error; throw error;
} }
// In particular, invalidation can happen while stopping a previous run. // Invalidation can happen during the asynchronous database check.
if (!isCurrent()) return; if (!isCurrent()) return;
Logger.info( Logger.info(
t('[schedule][开始执行任务] 参数 %s'), t('[schedule][开始执行任务] 参数 %s'),
+23 -1
View File
@@ -20,8 +20,10 @@ export const scheduleSchema = Joi.string()
.required() .required()
.custom(validateSchedule) .custom(validateSchedule)
.messages({ .messages({
'any.required': '定时规则不能为空',
'any.invalid': '无效的定时规则', 'any.invalid': '无效的定时规则',
'string.empty': '定时规则不能为空', 'string.empty': '定时规则不能为空',
'string.base': '定时规则必须是字符串',
}); });
export const commonCronSchema = { export const commonCronSchema = {
@@ -30,7 +32,27 @@ export const commonCronSchema = {
schedule: scheduleSchema, schedule: scheduleSchema,
labels: Joi.array().optional().allow(null), labels: Joi.array().optional().allow(null),
sub_id: Joi.number().optional().allow(null), sub_id: Joi.number().optional().allow(null),
extra_schedules: Joi.array().optional().allow(null), extra_schedules: Joi.array()
.items(
Joi.object({
schedule: Joi.string()
.required()
.custom((value, helpers) =>
isValidCronSchedule(value) ? value : helpers.error('any.invalid'),
)
.messages({
'any.required': '定时规则不能为空',
'any.invalid': '无效的定时规则',
'string.empty': '定时规则不能为空',
'string.base': '定时规则必须是字符串',
}),
})
.unknown(true)
.messages({ 'object.base': '额外定时规则必须是对象' }),
)
.optional()
.allow(null)
.messages({ 'array.base': '额外定时规则必须是数组' }),
task_before: Joi.string().optional().allow('').allow(null), task_before: Joi.string().optional().allow('').allow(null),
task_after: Joi.string().optional().allow('').allow(null), task_after: Joi.string().optional().allow('').allow(null),
log_name: Joi.string() log_name: Joi.string()
+78 -4
View File
@@ -1,12 +1,13 @@
{ {
"name": "@whyour/qinglong-cli", "name": "@whyour/qinglong-cli",
"version": "0.1.2", "version": "0.1.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@whyour/qinglong-cli", "name": "@whyour/qinglong-cli",
"version": "0.1.2", "version": "0.1.3",
"license": "Apache-2.0",
"bin": { "bin": {
"ql": "dist/npm/ql.js" "ql": "dist/npm/ql.js"
}, },
@@ -14,12 +15,12 @@
"@types/node": "22.19.0", "@types/node": "22.19.0",
"commander": "15.0.0", "commander": "15.0.0",
"esbuild": "0.28.2", "esbuild": "0.28.2",
"tar": "7.5.20",
"typescript": "5.2.2" "typescript": "5.2.2"
}, },
"engines": { "engines": {
"node": ">=22.12.0" "node": ">=22.12.0"
}, }
"license": "Apache-2.0"
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2", "version": "0.28.2",
@@ -463,6 +464,19 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"minipass": "^7.0.4"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.19.0", "version": "22.19.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.0.tgz",
@@ -473,6 +487,16 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
}
},
"node_modules/commander": { "node_modules/commander": {
"version": "15.0.0", "version": "15.0.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
@@ -525,6 +549,46 @@
"@esbuild/win32-x64": "0.28.2" "@esbuild/win32-x64": "0.28.2"
} }
}, },
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/minizlib": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"minipass": "^7.1.2"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/tar": {
"version": "7.5.20",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz",
"integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
"chownr": "^3.0.0",
"minipass": "^7.1.2",
"minizlib": "^3.1.0",
"yallist": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.2.2", "version": "5.2.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",
@@ -545,6 +609,16 @@
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
},
"node_modules/yallist": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
}
} }
} }
} }
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@whyour/qinglong-cli", "name": "@whyour/qinglong-cli",
"version": "0.1.2", "version": "0.1.3",
"description": "Authenticated remote management CLI for QingLong 2.x", "description": "Authenticated remote management CLI for QingLong 2.x",
"type": "commonjs", "type": "commonjs",
"bin": { "bin": {
@@ -26,7 +26,8 @@
"commander": "15.0.0", "commander": "15.0.0",
"esbuild": "0.28.2", "esbuild": "0.28.2",
"typescript": "5.2.2", "typescript": "5.2.2",
"@types/node": "22.19.0" "@types/node": "22.19.0",
"tar": "7.5.20"
}, },
"license": "Apache-2.0", "license": "Apache-2.0",
"repository": { "repository": {
+19
View File
@@ -17,6 +17,25 @@ fs.copyFileSync(
path.join(root, 'src/internal/maintenance/upgradeArtifacts.cjs'), path.join(root, 'src/internal/maintenance/upgradeArtifacts.cjs'),
path.join(root, 'dist/internal/maintenance/upgradeArtifacts.cjs'), path.join(root, 'dist/internal/maintenance/upgradeArtifacts.cjs'),
); );
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')],
outfile: path.join(root, 'dist/internal/maintenance/platformBundle.cjs'),
bundle: true,
platform: 'node',
format: 'cjs',
target: 'node20',
minify: true,
legalComments: 'inline',
});
require('esbuild').buildSync({ require('esbuild').buildSync({
entryPoints: [path.join(root, 'src/shared/cli/commander.ts')], entryPoints: [path.join(root, 'src/shared/cli/commander.ts')],
outfile: path.join(root, 'dist/shared/cli/commander.js'), outfile: path.join(root, 'dist/shared/cli/commander.js'),
@@ -0,0 +1,18 @@
// 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(' '));
+18 -2
View File
@@ -7,6 +7,10 @@ import type { LocalContext } from '../runtime/context';
import { atomicWrite } from '../runtime/files'; import { atomicWrite } from '../runtime/files';
import { runProcess, checkedProcess } from '../runtime/process'; import { runProcess, checkedProcess } from '../runtime/process';
import { findDirectBackendPids } from '../runtime/backendProcesses'; import { findDirectBackendPids } from '../runtime/backendProcesses';
const { serverRuntimeArgs, armv6ServerArgs } = require('./nodeRuntime.cjs') as {
serverRuntimeArgs(): string[];
armv6ServerArgs: readonly string[];
};
export async function repairConfiguration( export async function repairConfiguration(
context: LocalContext, context: LocalContext,
@@ -117,7 +121,11 @@ async function stopDirectBackend(context: LocalContext): Promise<void> {
for (const line of result.stdout.split('\n')) { for (const line of result.stdout.split('\n')) {
const match = line.trim().match(/^(\d+)\s+(\S+)\s+(.*)$/); const match = line.trim().match(/^(\d+)\s+(\S+)\s+(.*)$/);
if (!match || !/^node(?:\d+)?$/.test(path.basename(match[2]!))) continue; if (!match || !/^node(?:\d+)?$/.test(path.basename(match[2]!))) continue;
if (match[3] !== entry) continue; if (
match[3] !== entry &&
match[3] !== `${armv6ServerArgs.join(' ')} ${entry}`
)
continue;
const pid = Number(match[1]); const pid = Number(match[1]);
if (pid === process.pid) continue; if (pid === process.pid) continue;
try { try {
@@ -150,6 +158,14 @@ export async function startPanel(
GRPC_PORT: context.env.QlGrpcPort || '5500', GRPC_PORT: context.env.QlGrpcPort || '5500',
}, },
}; };
// Archive selection and launcher ownership apply only to the upgrade worker.
// Retaining them in PM2 would pin the next online update to an offline file.
for (const name of [
'QL_UPGRADE_FILE',
'QL_UPGRADE_REPAIR_DEPS',
'QL_UPGRADE_LAUNCHER_PID',
])
delete options.env[name as keyof typeof options.env];
if (await executableAvailable('pm2', context.env)) { if (await executableAvailable('pm2', context.env)) {
const flushed = await runProcess('pm2', ['flush'], options); const flushed = await runProcess('pm2', ['flush'], options);
if (flushed.code === 0) { if (flushed.code === 0) {
@@ -171,7 +187,7 @@ export async function startPanel(
0o600, 0o600,
); );
try { try {
const child = spawn(process.execPath, [entry], { const child = spawn(process.execPath, [...serverRuntimeArgs(), entry], {
...options, ...options,
detached: true, detached: true,
stdio: ['ignore', log.fd, log.fd], stdio: ['ignore', log.fd, log.fd],
+112 -8
View File
@@ -6,6 +6,7 @@ const { spawn } = require('node:child_process');
const http = require('node:http'); const http = require('node:http');
const { setTimeout: delay } = require('node:timers/promises'); const { setTimeout: delay } = require('node:timers/promises');
const artifacts = require('./upgradeArtifacts.cjs'); const artifacts = require('./upgradeArtifacts.cjs');
const { nodeRuntimeArgs } = require('./platformBundle.cjs');
const active = new Set(['starting', 'running', 'restarting']); const active = new Set(['starting', 'running', 'restarting']);
const stateFile = (tmp) => path.join(tmp, 'panel-upgrade.json'); const stateFile = (tmp) => path.join(tmp, 'panel-upgrade.json');
@@ -200,6 +201,93 @@ async function getStatus(tmp) {
return state; return state;
} }
// Manual restarts accept the currently prepared package, including one retained
// after a failed preparation. Legacy installs without jobs keep their old path;
// complete-bundle installs always verify and record the actual running build.
async function withPreparedJob(tmp, env, selected, operation) {
const previous = await getStatus(tmp);
if (active.has(previous.status)) throw new Error('已有更新或重载正在执行。');
const hasRecord = await fs
.access(stateFile(tmp))
.then(() => true)
.catch((error) => {
if (error.code !== 'ENOENT') throw error;
return false;
});
if (!hasRecord) {
const pkg = env.QL_DIR
? JSON.parse(
await fs
.readFile(path.join(env.QL_DIR, 'package.json'), 'utf8')
.catch((error) => {
if (error.code !== 'ENOENT') throw error;
return '{}';
}),
)
: {};
if (pkg.qinglongUpgradeFormat !== 2) return operation();
}
return artifacts.withUpgradeLock(
path.join(tmp, 'panel-job-launch'),
async () => {
const current = await getStatus(tmp);
if (active.has(current.status))
throw new Error('已有更新或重载正在执行。');
const prepared = await artifacts.selectedUpgrade(
tmp,
artifacts.releaseBranch(env),
env,
{ metadataOnly: true },
);
if (
prepared.source !== selected.source ||
prepared.static !== selected.static
)
throw new Error('暂存更新已变化,请重新下载更新。');
const metadata = await stagedMetadata(selected);
const previousBuild = await readRunningBuild(env).catch(() => undefined);
const { log, error, ...record } = current;
let state = {
...record,
...metadata,
id: randomUUID(),
action: 'apply',
status: 'running',
phase: 'verifying',
startedAt: Date.now(),
pid: process.pid,
};
const report = async (patch) => {
state = { ...state, ...patch, updatedAt: Date.now() };
await save(tmp, state);
};
await fs.writeFile(
logFile(tmp),
'Manual application of prepared update\n',
{
mode: 0o600,
},
);
await report({});
try {
const result = await operation({
onStop: () => report({ status: 'restarting', phase: 'restarting' }),
onStart: (recover) =>
recover
? undefined
: verifyRunningVersion(env, state, previousBuild),
});
await report({ status: 'completed', phase: 'completed' });
return result;
} catch (error) {
await report({ status: 'failed', error: error.message });
throw error;
}
},
env,
);
}
async function startJob({ root, tmp, env = process.env }, action) { async function startJob({ root, tmp, env = process.env }, action) {
if (!['download', 'apply'].includes(action)) if (!['download', 'apply'].includes(action))
throw new Error('Invalid upgrade action'); throw new Error('Invalid upgrade action');
@@ -220,12 +308,14 @@ async function startJob({ root, tmp, env = process.env }, action) {
let selected; let selected;
let metadata; let metadata;
if (action === 'apply') { if (action === 'apply') {
// Fail before launching a process or stopping the working panel. // Keep requests short on slow devices. The detached worker performs
// complete file/dependency verification before it can stop the panel.
try { try {
selected = await artifacts.selectedUpgrade( selected = await artifacts.selectedUpgrade(
tmp, tmp,
artifacts.releaseBranch(env), artifacts.releaseBranch(env),
env, env,
{ metadataOnly: true },
); );
metadata = await stagedMetadata(selected); metadata = await stagedMetadata(selected);
if (previous.status === 'ready') if (previous.status === 'ready')
@@ -390,7 +480,7 @@ async function runJob({ root, tmp, env = process.env }, id) {
); );
await artifacts.waitForHealth( await artifacts.waitForHealth(
env, env,
60000, undefined,
recover ? undefined : controller.signal, recover ? undefined : controller.signal,
); );
if (!recover) if (!recover)
@@ -429,16 +519,30 @@ async function runJob({ root, tmp, env = process.env }, id) {
} }
} }
module.exports = { getStatus, startJob, runJob }; module.exports = {
getStatus,
saveState: save,
startJob,
runJob,
withPanelLifecycle,
readRunningBuild,
verifyRunningVersion,
stagedMetadata,
withPreparedJob,
};
async function launchWorker(id) { async function launchWorker(id) {
// detached creates a session but keeps the original parent. PM2 kills the // detached creates a session but keeps the original parent. PM2 kills the
// backend's entire descendant tree, so an intermediate launcher must exit // backend's entire descendant tree, so an intermediate launcher must exit
// before the worker is allowed to stop that backend. // before the worker is allowed to stop that backend.
const child = spawn(process.execPath, [__filename, 'worker', id], { const child = spawn(
env: { ...process.env, QL_UPGRADE_LAUNCHER_PID: String(process.pid) }, process.execPath,
detached: true, [...nodeRuntimeArgs(), __filename, 'worker', id],
stdio: 'inherit', {
}); env: { ...process.env, QL_UPGRADE_LAUNCHER_PID: String(process.pid) },
detached: true,
stdio: 'inherit',
},
);
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
child.once('spawn', resolve); child.once('spawn', resolve);
child.once('error', reject); child.once('error', reject);
@@ -0,0 +1,582 @@
// One installed verifier handles release downloads and local offline archives.
const fs = require('node:fs/promises');
const path = require('node:path');
const crypto = require('node:crypto');
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,
repairDependencies,
} = require('./upgradePrestart.cjs');
const execute = promisify(execFile);
// Heavy archive parsing can hit unsupported V8 JIT paths on ARM1176. Keep
// upgrade workers interpreted without changing the installed Node or server.
const nodeRuntimeArgs = (runtime = process) =>
runtime.arch === 'arm' && Number(runtime.config.variables.arm_version) === 6
? ['--jitless']
: [];
const sha256 = (bytes) =>
crypto.createHash('sha256').update(bytes).digest('hex');
const inside = (root, filename) => {
const relative = path.relative(root, filename);
return (
relative !== '..' &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative)
);
};
const versionParts = (v) => String(v).replace(/^v/, '').split('.').map(Number);
function atLeast(actual, minimum) {
const a = versionParts(actual),
b = versionParts(minimum);
return (
b.every((n) => Number.isFinite(n)) &&
a.every((n) => Number.isFinite(n)) &&
(a[0] > b[0] ||
(a[0] === b[0] &&
(a[1] > b[1] || (a[1] === b[1] && (a[2] || 0) >= (b[2] || 0)))))
);
}
async function runtimeTarget() {
let libc, libcVersion;
if (process.platform === 'linux') {
const header = process.report.getReport().header;
if (header.glibcVersionRuntime) {
libc = 'glibc';
libcVersion = header.glibcVersionRuntime;
} else {
let text = '';
try {
text = (await execute('ldd', ['--version'])).stdout;
} catch (error) {
text = `${error.stdout || ''}\n${error.stderr || ''}`;
}
const match = text.match(/musl[\s\S]*?Version\s+(\d+\.\d+\.\d+)/i);
if (!match)
throw new Error(
'Cannot identify Linux libc; refusing a native dependency bundle',
);
libc = 'musl';
libcVersion = match[1];
}
}
const armVersion =
process.arch === 'arm'
? Number(process.config.variables.arm_version)
: undefined;
if (process.arch === 'arm' && ![6, 7, 8].includes(armVersion))
throw new Error('Cannot identify ARM instruction set');
const arch =
process.arch === 'arm' ? `armv${armVersion >= 7 ? 7 : 6}` : process.arch;
const armFloatAbi =
process.arch === 'arm' ? process.config.variables.arm_float_abi : undefined;
if (
process.arch === 'arm' &&
!['hard', 'soft', 'softfp'].includes(armFloatAbi)
)
throw new Error('Cannot identify ARM floating-point ABI');
return {
platform: process.platform,
arch,
libc,
libcVersion,
endianness: os.endianness(),
armFloatAbi,
};
}
function targetKey(target) {
return [target.platform, target.arch, target.libc].filter(Boolean).join('-');
}
function assertCompatible(manifest, target, node = process.versions.node) {
if (
manifest.format !== 2 ||
!/^[a-f0-9]{40}$/.test(manifest.sourceCommit) ||
!/^[a-f0-9]{64}$/.test(manifest.lockfileSha256) ||
!manifest.files ||
typeof manifest.files !== 'object' ||
Array.isArray(manifest.files) ||
!Number.isSafeInteger(manifest.nodeApi) ||
manifest.nodeApi < 6
)
throw new Error('Invalid platform bundle manifest');
if (targetKey(manifest.target || {}) !== targetKey(target))
throw new Error(`Bundle platform mismatch: need ${targetKey(target)}`);
if (
!atLeast(node, manifest.nodeMinimum) ||
Number(process.versions.napi) < manifest.nodeApi
)
throw new Error(
`Bundle requires Node >= ${manifest.nodeMinimum} and Node-API ${manifest.nodeApi}; the existing Node is retained`,
);
if (
manifest.target.endianness !== target.endianness ||
manifest.target.armFloatAbi !== target.armFloatAbi
)
throw new Error(
'Bundle native byte order or ARM floating-point ABI mismatch',
);
if (target.libc && !atLeast(target.libcVersion, manifest.target.libcMinimum))
throw new Error(
`Bundle requires ${target.libc} >= ${manifest.target.libcMinimum}`,
);
}
async function inventory(roots) {
const files = {};
async function visit(directory, prefix) {
for (const name of (await fs.readdir(directory)).sort()) {
const filename = path.join(directory, name),
relative = `${prefix}/${name}`;
const stat = await fs.lstat(filename);
if (stat.isDirectory()) await visit(filename, relative);
else if (stat.isFile())
files[relative] = {
type: 'file',
mode: stat.mode & 0o777,
sha256: sha256(await fs.readFile(filename)),
};
else if (stat.isSymbolicLink()) {
const link = await fs.readlink(filename);
const boundary = roots[prefix.split('/')[0]];
if (
path.isAbsolute(link) ||
!inside(boundary, path.resolve(path.dirname(filename), link)) ||
!inside(await fs.realpath(boundary), await fs.realpath(filename))
)
throw new Error(`Bundle link escapes its payload: ${relative}`);
files[relative] = { type: 'link', link };
} else throw new Error(`Unsupported bundle entry: ${relative}`);
}
}
for (const [name, root] of Object.entries(roots)) await visit(root, name);
return files;
}
async function verifyPayload(selected, manifest, target) {
assertCompatible(manifest, target);
for (const name of ['data', '.tmp', '.git', '.env', 'static']) {
if (
await fs.lstat(path.join(selected.source, name)).catch((error) => {
if (error.code !== 'ENOENT') throw error;
return null;
})
)
throw new Error(`Reserved installation path in bundle: ${name}`);
}
const actual = await inventory({
source: selected.source,
static: selected.static,
});
const names = Object.keys(actual).sort(),
expected = Object.keys(manifest.files).sort();
if (
JSON.stringify(names) !== JSON.stringify(expected) ||
names.some(
(name) =>
JSON.stringify(actual[name]) !== JSON.stringify(manifest.files[name]),
)
)
throw new Error(
'Platform bundle files or dependency checksums do not match',
);
if (
sha256(await fs.readFile(path.join(selected.source, 'pnpm-lock.yaml'))) !==
manifest.lockfileSha256
)
throw new Error('Platform bundle lockfile mismatch');
await fs.access(
path.join(selected.source, 'node_modules/sqlite3/package.json'),
);
}
async function smokeDependencies(source, signal) {
// This probe is installed verifier code, never a verifier supplied by an archive.
const probe = `
const path = require('node:path'),fs = require('node:fs');
const root = fs.realpathSync(process.cwd());
const r = require('node:module').createRequire(path.join(root,'package.json'));
for (const name of ['sqlite3','sequelize','cron-parser','@grpc/grpc-js','undici']) {
if (!fs.realpathSync(r.resolve(name)).startsWith(root + path.sep)) throw new Error('Dependency outside staged payload: '+name);
r(name);
}
const sqlite=r('sqlite3'), db=new sqlite.Database(':memory:');
db.serialize(()=>{db.run('CREATE TABLE verify_bundle(value INTEGER)');db.run('INSERT INTO verify_bundle VALUES(42)');
db.get('SELECT value FROM verify_bundle',(err,row)=>{if(err||row.value!==42){console.error(err||'SQLite readback failed');process.exitCode=1;}db.close();});});
`;
// 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], {
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,
signal,
});
}
async function extractArchive(archive, destination) {
const entries = new Map(),
links = new Set();
let unpacked = 0,
invalid;
await tar.t({
file: archive,
strict: true,
onReadEntry(entry) {
try {
const name = entry.path.replace(/\/$/, '');
if (
!name ||
name.includes('\\') ||
/[\x00-\x1f\x7f]/.test(name) ||
name.split('/').some((p) => !p || p === '.' || p === '..') ||
!(name === 'qinglong' || name.startsWith('qinglong/')) ||
entries.has(name) ||
!['File', 'OldFile', 'Directory', 'SymbolicLink'].includes(entry.type)
)
throw new Error(`Unsafe bundle archive entry: ${name}`);
unpacked += entry.size;
if (unpacked > 2 * 1024 ** 3 || entries.size > 200000)
throw new Error('Bundle archive exceeds extraction limits');
entries.set(name, entry.type);
if (entry.type === 'SymbolicLink') {
if (
path.posix.isAbsolute(entry.linkpath) ||
entry.linkpath.includes('\\') ||
!inside(
'/qinglong',
path.posix.resolve('/', path.posix.dirname(name), entry.linkpath),
)
)
throw new Error('Unsafe bundle symlink');
links.add(name);
}
} catch (error) {
invalid ||= error;
}
},
});
if (invalid) throw invalid;
for (const name of entries.keys()) {
let parent = path.posix.dirname(name);
while (parent !== '.') {
if (links.has(parent))
throw new Error('Archive writes through a symlink');
parent = path.posix.dirname(parent);
}
}
await tar.x({
file: archive,
cwd: destination,
strict: true,
preservePaths: false,
noChown: true,
umask: 0,
});
}
async function stageBundle({
root,
tmp,
env,
run,
signal,
onProgress,
verifyStatic,
download,
probe = smokeDependencies,
prestart = prestartBackend,
repair = repairDependencies,
}) {
let local = env.QL_UPGRADE_FILE;
if (!local) {
const current = JSON.parse(
await fs
.readFile(path.join(root, 'package.json'), 'utf8')
.catch((error) => {
if (error.code !== 'ENOENT') throw error;
return '{}';
}),
);
if (current.qinglongUpgradeFormat !== 2) return null; // One-time compatibility path for older releases.
}
const target = await runtimeTarget();
const branch = ['develop', 'debian-dev'].includes(env.QL_BRANCH)
? 'develop'
: 'master';
const workspace = await fs.mkdtemp(path.join(tmp, 'upgrade-'));
const report = onProgress || (async () => {});
try {
const archive = path.join(workspace, 'bundle.tar.gz');
let expectedCommit;
if (local) {
if (!path.isAbsolute(local))
throw new Error('Offline bundle path must be absolute');
// Snapshot the local input so replacing it cannot race validation/extraction.
await fs.copyFile(local, archive);
} else {
const indexUrl =
env.QL_UPGRADE_INDEX_URL ||
`https://github.com/whyour/qinglong/releases/download/upgrade-${branch}/latest.json`;
const checkUrl = (url) => {
const parsed = new URL(url);
if (
parsed.protocol !== 'https:' &&
!(
parsed.protocol === 'http:' &&
['127.0.0.1', '[::1]'].includes(parsed.hostname)
)
)
throw new Error('Upgrade URLs require HTTPS');
};
checkUrl(indexUrl);
const indexFile = path.join(workspace, 'index.json');
await run('curl', [
'--fail',
'--silent',
'--show-error',
'--location',
'--proto',
new URL(indexUrl).protocol === 'http:' ? '=http,https' : '=https',
'--proto-redir',
'=https',
'--header',
'Cache-Control: no-cache',
'--connect-timeout',
'10',
'--max-time',
'30',
'--max-filesize',
'1048576',
'--output',
indexFile,
indexUrl,
]);
const index = JSON.parse(await fs.readFile(indexFile, 'utf8'));
const entry = index.packages?.[targetKey(target)];
if (
index.format !== 2 ||
!/^[a-f0-9]{40}$/.test(index.sourceCommit) ||
!entry ||
!Number.isSafeInteger(entry.size) ||
entry.size <= 0 ||
entry.size > 512 * 1024 ** 2 ||
!/^[a-f0-9]{64}$/.test(entry.sha256)
)
throw new Error(`No verified update bundle for ${targetKey(target)}`);
checkUrl(entry.url);
expectedCommit = index.sourceCommit;
await report({
phase: 'downloading',
artifact: 'qinglong-bundle',
downloadedBytes: 0,
totalBytes: entry.size,
percent: 0,
});
await download(
run,
archive,
entry.url,
'qinglong-bundle',
report,
true,
entry,
);
}
if ((await fs.stat(archive)).size > 512 * 1024 ** 2)
throw new Error('Bundle is too large');
await report({
phase: 'extracting',
artifact: 'qinglong-bundle',
percent: null,
});
await extractArchive(archive, workspace);
const incoming = path.join(workspace, 'qinglong');
const manifest = JSON.parse(
await fs.readFile(path.join(incoming, 'bundle.json'), 'utf8'),
);
if (expectedCommit && manifest.sourceCommit !== expectedCommit)
throw new Error('Update index and bundle commit differ');
const selected = {
source: path.join(workspace, `qinglong-${branch}`),
static: path.join(workspace, `qinglong-static-${branch}`),
};
await fs.rename(path.join(incoming, 'source'), selected.source);
await fs.rename(path.join(incoming, 'static'), selected.static);
await report({ phase: 'verifying', percent: null });
await verifyPayload(selected, manifest, target);
const build = await verifyStatic(selected.source, selected.static, env);
if (build.sourceCommit !== manifest.sourceCommit)
throw new Error('Bundle and build commit differ');
await report({ phase: 'dependencies', percent: null });
let dependencyFiles;
const testCandidate = async () => {
await probe(selected.source, signal);
await report({ phase: 'prestarting', percent: null });
return prestart(selected, { target, signal });
};
try {
await testCandidate();
} catch (error) {
signal?.throwIfAborted();
if (!dependencyFailure(error)) throw error;
if (local && env.QL_UPGRADE_REPAIR_DEPS !== '1')
throw new Error(
`Offline dependency check failed; no network access attempted. Use --repair-deps to allow one repair. ${error.message}`,
);
await report({ phase: 'repairing', percent: null });
await repair(selected.source, { target, signal, env });
const repaired = await inventory({
source: selected.source,
static: selected.static,
});
dependencyFiles = Object.fromEntries(
Object.entries(repaired).filter(([name]) =>
name.startsWith('source/node_modules/'),
),
);
// A repair may change only dependencies, never bless changed application code.
await verifyPayload(
selected,
manifestWithDependencies(manifest, dependencyFiles),
target,
);
await verifyStatic(selected.source, selected.static, env);
await testCandidate(); // Exactly one repair; this failure propagates.
}
await fs.writeFile(
path.join(workspace, 'prestart.json'),
JSON.stringify({
format: 1,
sourceCommit: manifest.sourceCommit,
manifestSha256: sha256(Buffer.from(JSON.stringify(manifest))),
node: process.version,
execPath: process.execPath,
completedAt: Date.now(),
...(dependencyFiles ? { dependencyFiles } : {}),
}),
{ flag: 'wx', mode: 0o600 },
);
await fs.writeFile(
path.join(workspace, 'bundle.json'),
JSON.stringify(manifest),
{ flag: 'wx', mode: 0o600 },
);
await fs.writeFile(
path.join(workspace, 'ready.json'),
JSON.stringify({
...selected,
root: path.resolve(root),
sourceCommit: manifest.sourceCommit,
dependenciesChanged: true,
bundle: true,
}),
{ flag: 'wx', mode: 0o600 },
);
const pointer = path.join(workspace, 'pointer.json');
await fs.writeFile(
pointer,
JSON.stringify({ directory: path.basename(workspace) }),
{ flag: 'wx', mode: 0o600 },
);
signal?.throwIfAborted();
const readyPointer = path.join(tmp, `upgrade-ready-${branch}.json`);
const previous = await fs
.readFile(readyPointer, 'utf8')
.catch(() => 'null');
await fs.rename(pointer, readyPointer);
try {
const old = JSON.parse(previous)?.directory;
if (
/^upgrade-[A-Za-z0-9_-]+$/.test(old) &&
old !== path.basename(workspace)
)
await fs.rm(path.join(tmp, old), { recursive: true, force: true });
} catch {
/* Committed readiness must survive cleanup failure. */
}
return selected;
} catch (error) {
await fs.rm(workspace, { recursive: true, force: true });
throw error;
}
}
function manifestWithDependencies(manifest, dependencyFiles) {
if (!dependencyFiles) return manifest;
if (
typeof dependencyFiles !== 'object' ||
Array.isArray(dependencyFiles) ||
!Object.keys(dependencyFiles).length ||
Object.keys(dependencyFiles).some(
(name) => !name.startsWith('source/node_modules/'),
)
)
throw new Error('Invalid repaired dependency inventory');
return {
...manifest,
files: {
...Object.fromEntries(
Object.entries(manifest.files).filter(
([name]) => !name.startsWith('source/node_modules/'),
),
),
...dependencyFiles,
},
};
}
async function verifyStagedBundle(selected) {
const manifest = JSON.parse(
await fs.readFile(
path.join(path.dirname(selected.source), 'bundle.json'),
'utf8',
),
);
const prestart = JSON.parse(
await fs.readFile(
path.join(path.dirname(selected.source), 'prestart.json'),
'utf8',
),
);
if (
prestart.format !== 1 ||
prestart.sourceCommit !== manifest.sourceCommit ||
prestart.manifestSha256 !== sha256(Buffer.from(JSON.stringify(manifest))) ||
prestart.node !== process.version ||
prestart.execPath !== process.execPath ||
!Number.isSafeInteger(prestart.completedAt)
)
throw new Error(
'Upgrade prestart receipt is missing or stale; prepare the update again',
);
await verifyPayload(
selected,
manifestWithDependencies(manifest, prestart.dependencyFiles),
await runtimeTarget(),
);
await smokeDependencies(selected.source);
}
module.exports = {
nodeRuntimeArgs,
atLeast,
runtimeTarget,
targetKey,
assertCompatible,
inventory,
verifyPayload,
smokeDependencies,
extractArchive,
stageBundle,
verifyStagedBundle,
sha256,
};
+27 -10
View File
@@ -107,7 +107,7 @@ async function selectedUpgrade(
export interface ReloadLifecycle { export interface ReloadLifecycle {
stop(context: LocalContext): Promise<void>; stop(context: LocalContext): Promise<void>;
start(context: LocalContext): Promise<unknown>; start(context: LocalContext, recover?: boolean): Promise<unknown>;
} }
// All old entries stay on the same filesystem until service start succeeds. // All old entries stay on the same filesystem until service start succeeds.
@@ -202,7 +202,7 @@ export async function replaceAndReload(
if (item.existed) await fs.rename(item.backup, item.target); if (item.existed) await fs.rename(item.backup, item.target);
} }
try { try {
await lifecycle.start(context); await lifecycle.start(context, true);
} catch { } catch {
throw new Error( throw new Error(
translate( translate(
@@ -300,14 +300,31 @@ export async function reloadPanel(
return await artifacts.withUpgradeLock( return await artifacts.withUpgradeLock(
context.paths.dir_tmp!, context.paths.dir_tmp!,
() => () =>
replaceAndReload(context, replacements, { require('./panelUpgrade.cjs').withPreparedJob(
stop: stopPanel, context.paths.dir_tmp!,
start: async (ctx) => { context.env,
const service = await startPanel(ctx); selected,
await artifacts.waitForHealth(ctx.env, 60000, operationSignal()); (job?: {
return service; onStop(): Promise<void>;
}, onStart(recover?: boolean): Promise<void> | undefined;
}), }) =>
replaceAndReload(context, replacements, {
stop: async (ctx) => {
await job?.onStop();
await stopPanel(ctx);
},
start: async (ctx, recover) => {
const service = await startPanel(ctx);
await artifacts.waitForHealth(
ctx.env,
undefined,
operationSignal(),
);
await job?.onStart(recover);
return service;
},
}),
),
context.env, context.env,
); );
} finally { } finally {
+110 -18
View File
@@ -218,13 +218,19 @@ async function downloadWithProgress(
'--location', '--location',
...(quiet ? ['--silent', '--show-error'] : []), ...(quiet ? ['--silent', '--show-error'] : []),
'--proto', '--proto',
'=https', /^http:\/\/(?:127\.0\.0\.1|\[::1\])(?::\d+)?\//.test(url)
? '=http,https'
: '=https',
'--proto-redir', '--proto-redir',
'=https', '=https',
'--connect-timeout', '--connect-timeout',
'30', '30',
'--max-time', '--max-time',
'600', String(
artifact === 'qinglong-bundle' && expected?.size
? Math.max(600, Math.min(7200, Math.ceil(expected.size / 8192) + 120))
: 600,
),
'--speed-limit', '--speed-limit',
'1024', '1024',
'--speed-time', '--speed-time',
@@ -260,6 +266,26 @@ async function stageUpgrade({
signal, signal,
onProgress, onProgress,
}) { }) {
const currentPackage = JSON.parse(
await fs
.readFile(path.join(root, 'package.json'), 'utf8')
.catch((error) => {
if (error.code !== 'ENOENT') throw error;
return '{}';
}),
);
if (env.QL_UPGRADE_FILE || currentPackage.qinglongUpgradeFormat === 2) {
return require('./platformBundle.cjs').stageBundle({
root,
tmp,
env,
run,
signal,
onProgress,
verifyStatic: verifyUpgrade,
download: downloadWithProgress,
});
}
const report = onProgress || (async () => {}); const report = onProgress || (async () => {});
const workspace = await fs.mkdtemp(path.join(tmp, 'upgrade-')); const workspace = await fs.mkdtemp(path.join(tmp, 'upgrade-'));
const branch = releaseBranch(env); const branch = releaseBranch(env);
@@ -469,7 +495,12 @@ async function stageUpgrade({
} }
} }
async function selectedUpgrade(tmp, branch, env = process.env) { async function selectedUpgrade(
tmp,
branch,
env = process.env,
{ metadataOnly = false } = {},
) {
const pointer = JSON.parse( const pointer = JSON.parse(
await fs.readFile(path.join(tmp, `upgrade-ready-${branch}.json`), 'utf8'), await fs.readFile(path.join(tmp, `upgrade-ready-${branch}.json`), 'utf8'),
); );
@@ -493,9 +524,13 @@ async function selectedUpgrade(tmp, branch, env = process.env) {
); );
if (ready.source !== selected.source || ready.static !== selected.static) if (ready.source !== selected.source || ready.static !== selected.static)
throw new Error(upgradeMessage('暂存升级就绪记录不匹配。', env)); throw new Error(upgradeMessage('暂存升级就绪记录不匹配。', env));
const manifest = await verifyUpgrade(selected.source, selected.static, env); const manifest = metadataOnly
? await manifestAt(selected.static, env)
: await verifyUpgrade(selected.source, selected.static, env);
if (ready.sourceCommit !== manifest.sourceCommit) if (ready.sourceCommit !== manifest.sourceCommit)
throw new Error(upgradeMessage('暂存升级就绪记录不匹配。', env)); throw new Error(upgradeMessage('暂存升级就绪记录不匹配。', env));
if (ready.bundle && !metadataOnly)
await require('./platformBundle.cjs').verifyStagedBundle(selected);
if (ready.dependenciesChanged) if (ready.dependenciesChanged)
await fs.access(path.join(selected.source, 'node_modules')); await fs.access(path.join(selected.source, 'node_modules'));
else { else {
@@ -571,7 +606,7 @@ async function replaceAndReload(replacements, lifecycle, env = process.env) {
} catch (error) { } catch (error) {
// Do not modify files under a still-running failed startup. Keep backups // Do not modify files under a still-running failed startup. Keep backups
// for recovery if stopping the new process itself fails. // for recovery if stopping the new process itself fails.
if (attemptedStart) await lifecycle.stop(); if (attemptedStart) await lifecycle.stop(true);
for (const item of changed.reverse()) { for (const item of changed.reverse()) {
await fs.rm(item.target, { recursive: true, force: true }); await fs.rm(item.target, { recursive: true, force: true });
if (item.existed) { if (item.existed) {
@@ -640,7 +675,16 @@ async function reloadSystem({
.catch(() => {}); .catch(() => {});
} }
async function waitForHealth(env, timeout = 60000, signal) { function startupHealthTimeout(runtime = process) {
// ARMv6 starts the database, scheduler and HTTP workers sequentially. Give
// slow devices time to become healthy before deciding to restore a backup.
return runtime.arch === 'arm' &&
Number(runtime.config.variables.arm_version) === 6
? 300000
: 60000;
}
async function waitForHealth(env, timeout = startupHealthTimeout(), signal) {
const port = Number(env.QlPort || 5700); const port = Number(env.QlPort || 5700);
let base = env.QlBaseUrl || ''; let base = env.QlBaseUrl || '';
if (base && !base.startsWith('/')) base = `/${base}`; if (base && !base.startsWith('/')) base = `/${base}`;
@@ -786,9 +830,18 @@ esac
mode: 0o600, mode: 0o600,
}, },
); );
// Offline selection and worker ownership belong to this operation only.
// Passing them to PM2 would pin future online updates to the old local file.
const lifecycleEnv = { ...env };
for (const name of [
'QL_UPGRADE_FILE',
'QL_UPGRADE_REPAIR_DEPS',
'QL_UPGRADE_LAUNCHER_PID',
])
delete lifecycleEnv[name];
const hook = (operation, args = [], signal) => const hook = (operation, args = [], signal) =>
runProcess( runProcess(
env, lifecycleEnv,
'bash', 'bash',
['--', snapshot, operation, ...args], ['--', snapshot, operation, ...args],
false, false,
@@ -821,16 +874,21 @@ async function main() {
tmp, tmp,
() => () =>
withLifecycleHooks(root, tmp, env, async (hook) => { withLifecycleHooks(root, tmp, env, async (hook) => {
let job;
const lifecycle = { const lifecycle = {
signal: controller.signal, signal: controller.signal,
stop: () => hook('stop'), stop: async () => {
await job?.onStop();
await hook('stop');
},
start: async (recover) => { start: async (recover) => {
await hook('start', [], recover ? undefined : controller.signal); await hook('start', [], recover ? undefined : controller.signal);
await waitForHealth( await waitForHealth(
env, env,
60000, undefined,
recover ? undefined : controller.signal, recover ? undefined : controller.signal,
); );
await job?.onStart(recover);
}, },
}; };
if (process.argv[2] === 'update') { if (process.argv[2] === 'update') {
@@ -850,15 +908,48 @@ async function main() {
if (process.argv[4] !== 'true') return; if (process.argv[4] !== 'true') return;
} else if (process.argv[2] !== 'reload') } else if (process.argv[2] !== 'reload')
throw new Error('Unknown upgrade command.'); throw new Error('Unknown upgrade command.');
await reloadSystem({ const apply = async (preparedJob) => {
root, job = preparedJob;
tmp, await reloadSystem({
env, root,
data: env.dir_data || path.join(root, 'data'), tmp,
staticRoot: env.dir_static || path.join(root, 'static'), env,
config: env.dir_config || path.join(root, 'data/config'), data: env.dir_data || path.join(root, 'data'),
lifecycle, staticRoot: env.dir_static || path.join(root, 'static'),
}); config: env.dir_config || path.join(root, 'data/config'),
lifecycle,
});
};
// Legacy reloads can carry only this self-contained Bash verifier.
// Complete-bundle installs also track manual applies without a job file.
const hasPanelJob = await fs
.access(path.join(tmp, 'panel-upgrade.json'))
.then(() => true)
.catch((error) => {
if (error.code !== 'ENOENT') throw error;
return false;
});
const packageInfo = hasPanelJob
? {}
: JSON.parse(
await fs
.readFile(path.join(root, 'package.json'), 'utf8')
.catch((error) => {
if (error.code !== 'ENOENT') throw error;
return '{}';
}),
);
if (hasPanelJob || packageInfo.qinglongUpgradeFormat === 2) {
const selected = await selectedUpgrade(tmp, releaseBranch(env), env, {
metadataOnly: true,
});
await require('./panelUpgrade.cjs').withPreparedJob(
tmp,
env,
selected,
apply,
);
} else await apply();
console.log(upgradeMessage('更新及启动检查成功...', env)); console.log(upgradeMessage('更新及启动检查成功...', env));
}), }),
env, env,
@@ -875,6 +966,7 @@ module.exports = {
selectedUpgrade, selectedUpgrade,
replaceAndReload, replaceAndReload,
reloadSystem, reloadSystem,
startupHealthTimeout,
waitForHealth, waitForHealth,
runProcess, runProcess,
withLifecycleHooks, withLifecycleHooks,
@@ -0,0 +1,394 @@
const fs = require('node:fs/promises');
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';
return { heapMb: small ? 128 : 192, timeoutMs: small ? 300000 : 60000 };
}
function dependencyFailure(error) {
const text = `${error.message || ''}\n${error.stack || ''}\n${
error.stderr || ''
}`;
if (error.signal || /timed out|heap out of memory|ENOMEM/i.test(text))
return false;
if (
/ERR_DLOPEN_FAILED/.test(`${error.code} ${text}`) &&
/node_modules[/\\]/.test(text)
)
return true;
const missing = /Cannot find (?:module|package) ['"]([^'"]+)['"]/.exec(
text,
)?.[1];
const importer = /Require stack:\r?\n-\s+([^\r\n]+)/.exec(text)?.[1];
const inDependencies = (filename) =>
/(?:^|[/\\])node_modules[/\\]/.test(filename || '');
// Node reports a missing package entry as an absolute path and missing
// package-internal imports as relative paths. Neither is an app source error.
const dependencyMissing =
missing &&
((!missing.startsWith('.') &&
!path.isAbsolute(missing) &&
!missing.startsWith('node:')) ||
inDependencies(missing) ||
inDependencies(importer));
// Missing backend files, bad config and database errors must never trigger npm.
return (
(/MODULE_NOT_FOUND/.test(`${error.code} ${text}`) &&
Boolean(dependencyMissing)) ||
(/Could not locate the bindings file/.test(text) &&
/node_modules[/\\]/.test(text))
);
}
async function prestartBackend(
selected,
{ target, signal, limits = probeLimits(target) },
) {
signal?.throwIfAborted();
const workspace = await fs.mkdtemp(
path.join(path.dirname(selected.source), 'prestart-'),
);
try {
const data = path.join(workspace, 'data');
for (const name of ['db', 'config', 'syslog', 'log', 'scripts', 'upload'])
await fs.mkdir(path.join(data, name), { recursive: true });
// Copy only backend code/metadata, never all frontend assets or node_modules.
await fs.mkdir(path.join(workspace, 'static'));
await fs.cp(
path.join(selected.static, 'build'),
path.join(workspace, 'static/build'),
{ recursive: true },
);
await fs.copyFile(
path.join(selected.static, 'build-info.json'),
path.join(workspace, 'static/build-info.json'),
);
await fs.copyFile(
path.join(selected.source, 'version.yaml'),
path.join(workspace, 'version.yaml'),
);
await fs.symlink(
path.join(selected.source, 'node_modules'),
path.join(workspace, 'node_modules'),
'dir',
);
await fs.writeFile(path.join(workspace, '.env'), '', { mode: 0o600 });
const entry = path.join(
workspace,
'static/build/bootstrap/upgradeProbe.js',
);
await fs.access(entry);
const expected = JSON.parse(
await fs.readFile(path.join(selected.static, 'build-info.json')),
);
const backendSha256 = crypto
.createHash('sha256')
.update(await fs.readFile(path.join(selected.static, 'build/app.js')))
.digest('hex');
const version = (
await fs.readFile(path.join(selected.source, 'version.yaml'), 'utf8')
).match(/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+)/m)?.[1];
// An allowlist prevents NODE_OPTIONS, startup hooks, real data paths and
// package-manager settings from escaping into the disposable backend.
const env = {
PATH: process.env.PATH,
LANG: process.env.LANG || 'C.UTF-8',
HOME: workspace,
TMPDIR: workspace,
NODE_ENV: 'production',
QL_DIR: workspace,
QL_DATA_DIR: data,
QL_UPGRADE_PROBE: '1',
BACK_PORT: '0',
GRPC_PORT: '0',
BIND_HOST: '127.0.0.1',
BIND_HOST_GRPC: '127.0.0.1',
QlBaseUrl: '/',
NODE_PATH: '',
NODE_OPTIONS: '',
UV_THREADPOOL_SIZE: '1',
};
return await new Promise((resolve, reject) => {
let report,
failure,
output = '',
finished = false,
killTimer;
// No PM2/cluster workers. OpenSSL key generation is sequential in this
// 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],
{
cwd: workspace,
env,
detached: process.platform !== 'win32',
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
},
);
try {
if (child.pid) os.setPriority(child.pid, 10);
} catch {}
const kill = (signalName) => {
try {
if (process.platform !== 'win32' && child.pid)
process.kill(-child.pid, signalName);
else child.kill(signalName);
} catch (error) {
if (error.code !== 'ESRCH') failure ||= error;
}
};
const stop = (error) => {
failure ||= error;
kill('SIGTERM');
killTimer ||= setTimeout(() => kill('SIGKILL'), 1500);
};
const abort = () =>
stop(signal.reason || new Error('Upgrade prestart aborted'));
const timer = setTimeout(
() => stop(new Error('Upgrade prestart timed out')),
limits.timeoutMs,
);
child.stdout.on('data', (chunk) => {
output = (output + chunk).slice(-32768);
});
child.stderr.on('data', (chunk) => {
output = (output + chunk).slice(-32768);
});
child.on('message', (message) => {
if (message?.type === 'upgrade-probe') report = message;
});
const finish = (error) => {
if (finished) return;
finished = true;
clearTimeout(timer);
clearTimeout(killTimer);
signal?.removeEventListener('abort', abort);
if (error) reject(error);
else resolve(report);
};
child.on('error', (error) => finish(error));
// Descendants can retain our output pipes after their parent exits.
// Reap the group on exit rather than waiting for those pipes to close.
child.once('exit', () => kill('SIGKILL'));
child.on('close', (code, exitSignal) => {
// Reap descendants even when the backend exits unexpectedly.
kill('SIGKILL');
if (failure) return finish(failure);
if (code !== 0 || !report?.ok)
return finish(
Object.assign(
new Error(
report?.message ||
`Upgrade prestart exited: ${exitSignal || code}\n${output}`,
),
{
code: report?.code,
stack: report?.stack || output,
signal: exitSignal,
},
),
);
if (
report.build?.sourceCommit !== expected.sourceCommit ||
report.build?.backendSha256 !== backendSha256 ||
report.build?.version !== version
)
return finish(new Error('Upgrade prestart build identity mismatch'));
finish();
});
signal?.addEventListener('abort', abort, { once: true });
if (signal?.aborted) abort();
});
} finally {
await fs.rm(workspace, { recursive: true, force: true });
}
}
async function repairDependencies(source, { target, signal, env }) {
const workspace = await fs.mkdtemp(
path.join(path.dirname(source), 'repair-'),
);
try {
const pkg = JSON.parse(
await fs.readFile(path.join(source, 'package.json')),
);
if (pkg.packageManager !== 'pnpm@8.3.1')
throw new Error('Unsupported frozen-lockfile package manager');
// Install apart from the verified application: lifecycle scripts cannot
// rewrite source/static files or use the old installation's node_modules.
await fs.writeFile(
path.join(workspace, 'package.json'),
JSON.stringify({ ...pkg, scripts: {} }),
);
await fs.copyFile(
path.join(source, 'pnpm-lock.yaml'),
path.join(workspace, 'pnpm-lock.yaml'),
);
const small = target.arch === 'armv6';
const childEnv = {
PATH: env.PATH || process.env.PATH,
HOME: workspace,
TMPDIR: workspace,
NODE_OPTIONS: `${small ? '--jitless ' : ''}--max-old-space-size=${
small ? 256 : 384
}`,
NODE_PATH: '',
CI: 'true',
npm_config_jobs: '1',
MAKEFLAGS: '-j1',
...(small
? {
npm_config_build_from_source: 'true',
npm_config_force_process_config: 'true',
CFLAGS: '-march=armv6 -mfpu=vfp -mfloat-abi=hard',
CXXFLAGS: '-march=armv6 -mfpu=vfp -mfloat-abi=hard',
}
: {}),
};
// Preserve network routing without inheriting Node/preload/compiler settings.
for (const name of [
'HTTP_PROXY',
'HTTPS_PROXY',
'ALL_PROXY',
'NO_PROXY',
'http_proxy',
'https_proxy',
'all_proxy',
'no_proxy',
])
if (typeof env[name] === 'string') childEnv[name] = env[name];
// Reuse matching installed headers on small devices; never inherit a
// caller's nodedir or accidentally compile against a different Node ABI.
const prefix = path.resolve(path.dirname(process.execPath), '..');
const header = await fs
.readFile(path.join(prefix, 'include/node/node_version.h'), 'utf8')
.catch(() => '');
const headerVersion = ['MAJOR', 'MINOR', 'PATCH']
.map(
(part) =>
header.match(
new RegExp(`#define\\s+NODE_${part}_VERSION\\s+(\\d+)`),
)?.[1],
)
.join('.');
if (headerVersion === process.versions.node) {
childEnv.npm_config_nodedir = prefix;
childEnv.npm_config_force_process_config = 'true';
}
const run = (args) =>
runRepair('pnpm', args, {
cwd: workspace,
env: childEnv,
signal,
timeoutMs: small ? 1800000 : 600000,
});
const version = await run(['--version']);
if (version.trim() !== '8.3.1')
throw new Error('Dependency repair requires existing pnpm 8.3.1');
await run([
'install',
'--prod',
'--frozen-lockfile',
'--ignore-scripts',
'--child-concurrency=1',
'--network-concurrency=2',
]);
await run(['rebuild', 'sqlite3', '@whyour/sqlite3']);
if (
!(await fs.readFile(path.join(source, 'pnpm-lock.yaml'))).equals(
await fs.readFile(path.join(workspace, 'pnpm-lock.yaml')),
)
)
throw new Error('Dependency repair changed the frozen lockfile');
await fs.rm(path.join(source, 'node_modules'), {
recursive: true,
force: true,
});
await fs.rename(
path.join(workspace, 'node_modules'),
path.join(source, 'node_modules'),
);
} finally {
await fs.rm(workspace, { recursive: true, force: true });
}
}
// Bound output, wall time and the entire npm/node-gyp process group.
async function runRepair(program, args, options) {
return new Promise((resolve, reject) => {
options.signal?.throwIfAborted();
let output = '',
stdout = '',
failure,
killTimer;
const child = spawn(program, args, {
cwd: options.cwd,
env: options.env,
detached: process.platform !== 'win32',
stdio: ['ignore', 'pipe', 'pipe'],
});
const kill = (sig) => {
try {
if (child.pid && process.platform !== 'win32')
process.kill(-child.pid, sig);
else child.kill(sig);
} catch {}
};
const stop = (error) => {
failure ||= error;
kill('SIGTERM');
killTimer ||= setTimeout(() => kill('SIGKILL'), 1500);
};
const abort = () =>
stop(options.signal.reason || new Error('Dependency repair aborted'));
const timer = setTimeout(
() => stop(new Error('Dependency repair timed out')),
options.timeoutMs,
);
const capture = (chunk) => {
output = (output + chunk).slice(-32768);
};
child.stdout.on('data', (chunk) => {
stdout = (stdout + chunk).slice(-32768);
capture(chunk);
});
child.stderr.on('data', capture);
const cleanup = () => {
clearTimeout(timer);
clearTimeout(killTimer);
options.signal?.removeEventListener('abort', abort);
kill('SIGKILL');
};
child.on('error', (error) => {
cleanup();
reject(error);
});
child.once('exit', () => kill('SIGKILL'));
child.on('close', (code, signal) => {
cleanup();
if (failure || code !== 0)
reject(
failure ||
new Error(
`Dependency repair failed (${signal || code}): ${output}`,
),
);
else resolve(stdout);
});
options.signal?.addEventListener('abort', abort, { once: true });
if (options.signal?.aborted) abort();
});
}
module.exports = {
prestartBackend,
dependencyFailure,
repairDependencies,
probeLimits,
};
+14 -4
View File
@@ -1,5 +1,8 @@
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; 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 // 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. // each process's cwd, not from the operator's cwd or a broad ps substring match.
@@ -27,11 +30,18 @@ export async function findDirectBackendPids(
); );
const args = command.split('\0'); const args = command.split('\0');
if (args.at(-1) === '') args.pop(); if (args.at(-1) === '') args.pop();
// Both the legacy fallback and the TS fallback launch one script without // Accept only the two launch shapes we own. Never treat eval, preload or
// Node flags or application arguments. Reject other command shapes. // application arguments containing this path as a running backend.
if (args.length !== 2 || !/^node(?:\d+)?$/.test(path.basename(args[0]!))) 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]!)))
continue; continue;
const script = args[1]!; const script = args[scriptIndex]!;
if (!script) continue; if (!script) continue;
const absolute = path.isAbsolute(script) const absolute = path.isAbsolute(script)
? script ? script
+183
View File
@@ -0,0 +1,183 @@
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);
},
);
+47 -3
View File
@@ -3,7 +3,46 @@ const assert = require('node:assert/strict');
const fs = require('node:fs/promises'); const fs = require('node:fs/promises');
const path = require('node:path'); const path = require('node:path');
const os = require('node:os'); const os = require('node:os');
const { findDirectBackendPids } = require('../../dist/internal/runtime/backendProcesses'); 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);
});
test('Linux backend discovery scopes legacy relative and absolute commands to one installation', async (t) => { test('Linux backend discovery scopes legacy relative and absolute commands to one installation', async (t) => {
const root = await fs.realpath( const root = await fs.realpath(
@@ -64,7 +103,10 @@ test(
const { spawn } = require('node:child_process'); const { spawn } = require('node:child_process');
const { once } = require('node:events'); const { once } = require('node:events');
const { createContext } = require('../../dist/internal/runtime/context'); 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 root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-live-proc-'));
const children = []; const children = [];
let fallbackPid; let fallbackPid;
@@ -223,7 +265,9 @@ for (const language of ['zh', 'en', 'unsupported']) {
); );
t.after(() => fs.rm(root, { recursive: true, force: true })); t.after(() => fs.rm(root, { recursive: true, force: true }));
const { createContext } = require('../../dist/internal/runtime/context'); 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 context = createContext({ root }, { PATH: '', QL_LANG: language });
const entry = path.join(context.paths.dir_static, 'build/app.js'); const entry = path.join(context.paths.dir_static, 'build/app.js');
await fs.mkdir(path.dirname(entry), { recursive: true }); await fs.mkdir(path.dirname(entry), { recursive: true });
+11 -2
View File
@@ -322,6 +322,10 @@ test(
async (t) => { async (t) => {
if (process.platform === 'win32') return t.skip('POSIX process groups'); if (process.platform === 'win32') return t.skip('POSIX process groups');
const { runProcess } = require('../../dist/internal/runtime/process'); const { runProcess } = require('../../dist/internal/runtime/process');
const marker = path.join(sandbox(t), 'timeout');
const controller = new AbortController();
const guard = setTimeout(() => controller.abort(), 4000);
t.after(() => clearTimeout(guard));
let descendant; let descendant;
t.after(() => { t.after(() => {
if (descendant) if (descendant)
@@ -329,13 +333,17 @@ test(
process.kill(descendant, 'SIGKILL'); process.kill(descendant, 'SIGKILL');
} catch {} } catch {}
}); });
// Start the deadline only after the descendant installed its handlers.
// Cold Node startup under parallel load need not fit in 350 milliseconds.
const grandchild = const grandchild =
'process.on("SIGINT",()=>{});process.on("SIGTERM",()=>{});console.log(process.pid);setInterval(()=>{},1000)'; 'process.on("SIGINT",()=>{});process.on("SIGTERM",()=>{});console.log(process.pid);require("node:fs").writeSync(3,"T");setInterval(()=>{},1000)';
const parent = `require('node:child_process').spawn(process.execPath,['-e',${JSON.stringify( const parent = `require('node:child_process').spawn(process.execPath,['-e',${JSON.stringify(
grandchild, grandchild,
)}],{stdio:['ignore',1,2]});setTimeout(()=>process.exit(0),100);`; )}],{stdio:['ignore',1,2,3]});setTimeout(()=>process.exit(0),100);`;
const result = await runProcess(process.execPath, ['-e', parent], { const result = await runProcess(process.execPath, ['-e', parent], {
timeoutMs: 350, timeoutMs: 350,
timeoutControl: { marker },
signal: controller.signal,
graceMs: 100, graceMs: 100,
output: (chunk) => { output: (chunk) => {
const pid = Number(chunk.toString().trim()); const pid = Number(chunk.toString().trim());
@@ -345,6 +353,7 @@ test(
assert.equal(result.code, 124); assert.equal(result.code, 124);
assert.equal(result.timedOut, true); assert.equal(result.timedOut, true);
assert.ok(descendant); assert.ok(descendant);
assert.equal(fs.existsSync(marker), true);
}, },
); );
+34
View File
@@ -20,6 +20,40 @@ const {
sourceCommit, sourceCommit,
} = require('../../../test/helpers/upgrade-fixture.cjs'); } = require('../../../test/helpers/upgrade-fixture.cjs');
test('CLI service startup drops operation-only upgrade variables and preserves user configuration', async (t) => {
const ctx = await fixture(t);
ctx.env.CALLS = path.join(ctx.root, 'runtime.jsonl');
ctx.env.QL_UPGRADE_FILE = '/old/offline.tar.gz';
ctx.env.QL_UPGRADE_REPAIR_DEPS = '1';
ctx.env.QL_UPGRADE_LAUNCHER_PID = '12345';
ctx.env.QA_USER_SETTING = 'preserved';
ctx.env.HTTPS_PROXY = 'http://127.0.0.1:7890';
await stub(
ctx,
'pm2',
`require('node:fs').appendFileSync(process.env.CALLS,JSON.stringify({
file:process.env.QL_UPGRADE_FILE??null,repair:process.env.QL_UPGRADE_REPAIR_DEPS??null,
launcher:process.env.QL_UPGRADE_LAUNCHER_PID??null,user:process.env.QA_USER_SETTING,
proxy:process.env.HTTPS_PROXY,
})+'\\n');`,
);
await startPanel(ctx);
for (const runtime of (await fs.readFile(ctx.env.CALLS, 'utf8'))
.trim()
.split('\n')
.map(JSON.parse))
assert.deepEqual(runtime, {
file: null,
repair: null,
launcher: null,
user: 'preserved',
proxy: 'http://127.0.0.1:7890',
});
assert.equal(ctx.env.QL_UPGRADE_FILE, '/old/offline.tar.gz');
assert.equal(ctx.env.QL_UPGRADE_REPAIR_DEPS, '1');
assert.equal(ctx.env.QL_UPGRADE_LAUNCHER_PID, '12345');
});
async function fixture(t) { async function fixture(t) {
t.mock.method( t.mock.method(
require('../../dist/internal/maintenance/upgradeArtifacts.cjs'), require('../../dist/internal/maintenance/upgradeArtifacts.cjs'),
+35
View File
@@ -0,0 +1,35 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
test('the standalone operational CLI verifier carries its archive parser without panel dependencies', (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-cli-bundle-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const filename = path.join(directory, 'platformBundle.cjs');
fs.copyFileSync(
path.resolve(
__dirname,
'../../dist/internal/maintenance/platformBundle.cjs',
),
filename,
);
const code = `
const Module=require('node:module'),load=Module._load;
Module._load=function(name,...args){
if(name!==process.argv[1]&&!name.startsWith('node:')&&!Module.builtinModules.includes(name))throw new Error('External dependency: '+name);
return load.call(this,name,...args);
};
const bundle=require(process.argv[1]);
if(typeof bundle.stageBundle!=='function')throw new Error('Missing verifier');
bundle.runtimeTarget().then(target=>console.log(bundle.targetKey(target))).catch(e=>{console.error(e);process.exitCode=1});
`;
const result = spawnSync(process.execPath, ['-e', code, filename], {
encoding: 'utf8',
env: { ...process.env, NODE_PATH: '' },
});
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, new RegExp(`^${process.platform}-`));
});
@@ -0,0 +1,99 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const {
manualUpgradeFixture,
} = require('../../../test/helpers/manual-upgrade-fixture.cjs');
const { createContext } = require('../../dist/internal/runtime/context');
const { reloadPanel } = require('../../dist/internal/maintenance/upgrade');
const operator = require('../../dist/internal/maintenance/operator');
const artifacts = require('../../dist/internal/maintenance/upgradeArtifacts.cjs');
for (const status of ['ready', 'failed', 'completed', 'absent'])
for (const wrongVersion of [false, true])
test(`CLI validates and records manual reload after ${status}; wrong version=${wrongVersion}`, async (t) => {
const f = await manualUpgradeFixture(t, wrongVersion);
await fs.writeFile(
path.join(f.root, 'package.json'),
'{"qinglongUpgradeFormat":2}',
);
const file = path.join(f.tmp, 'panel-upgrade.json');
if (status === 'absent') await fs.unlink(file);
else
await fs.writeFile(
file,
JSON.stringify({
...(await f.state()),
status,
error: 'old error',
version: '9.9.9',
sourceCommit: 'f'.repeat(40),
}),
);
t.mock.method(operator, 'stopPanel', async () => {});
t.mock.method(operator, 'startPanel', async () => ({
manager: 'fixture',
}));
t.mock.method(artifacts, 'waitForHealth', async () => {});
const operation = reloadPanel(
createContext({ root: f.root }, f.env),
'system',
);
if (wrongVersion)
await assert.rejects(operation, /启动版本与更新包不一致/);
else await operation;
const state = await f.state();
assert.equal(state.status, wrongVersion ? 'failed' : 'completed');
assert.equal(state.sourceCommit, f.expected.sourceCommit);
assert.equal(state.version, f.expected.version);
assert.notEqual(state.error, 'old error');
assert.equal(
await fs.readFile(path.join(f.root, 'static/build/app.js'), 'utf8'),
wrongVersion ? 'old backend' : 'new backend',
);
assert.equal(
await fs.readFile(path.join(f.root, 'data/keep'), 'utf8'),
'preserve data',
);
});
for (const wrongVersion of [false, true]) {
test(`operational CLI synchronizes prepared jobs and verifies startup before committing; wrong version=${wrongVersion}`, async (t) => {
const f = await manualUpgradeFixture(t, wrongVersion);
const context = createContext({ root: f.root }, f.env);
const timeouts = [];
t.mock.method(artifacts, 'waitForHealth', async (_env, timeout) => {
timeouts.push(timeout);
});
t.mock.method(operator, 'stopPanel', async () => {
assert.equal((await f.state()).status, 'restarting');
});
t.mock.method(operator, 'startPanel', async () => ({ manager: 'fixture' }));
const operation = reloadPanel(context, 'system');
if (wrongVersion) await assert.rejects(operation, /启动版本与更新包不一致/);
else await operation;
assert.ok(timeouts.length > 0);
assert.ok(
timeouts.every((timeout) => timeout === undefined),
'use the shared ARMv6/default startup deadline',
);
assert.equal(
(await f.state()).status,
wrongVersion ? 'failed' : 'completed',
);
assert.equal(
await fs.readFile(path.join(f.root, 'static/build/app.js'), 'utf8'),
wrongVersion ? 'old backend' : 'new backend',
);
assert.equal(
await fs.readFile(path.join(f.root, 'data/keep'), 'utf8'),
'preserve data',
);
if (wrongVersion)
assert.equal(
await fs.readFile(path.join(f.root, 'node_modules/old'), 'utf8'),
'old dependency',
);
});
}
+28 -5
View File
@@ -1,8 +1,9 @@
# Run Node package installation natively on the builder. Node/npm can spin at # Run Node package installation natively on the builder. Node/npm can spin at
# 100% CPU when Alpine s390x is emulated through QEMU. # 100% CPU when Alpine s390x is emulated through QEMU.
FROM --platform=$BUILDPLATFORM node:18-alpine3.18 AS builder FROM --platform=$BUILDPLATFORM node:24.18.1-alpine3.24 AS builder
ARG TARGETARCH ARG TARGETARCH
ARG TARGETVARIANT
ENV NPM_CONFIG_PREFIX=/opt/node-global ENV NPM_CONFIG_PREFIX=/opt/node-global
ENV PATH=/opt/node-global/bin:${PATH} ENV PATH=/opt/node-global/bin:${PATH}
@@ -17,12 +18,24 @@ RUN set -x \
ppc64le) NODE_ARCH=ppc64 ;; \ ppc64le) NODE_ARCH=ppc64 ;; \
*) NODE_ARCH="${TARGETARCH}" ;; \ *) NODE_ARCH="${TARGETARCH}" ;; \
esac \ esac \
&& case "${TARGETARCH}/${TARGETVARIANT:-}" in \
arm/v6) NODE_LIBC=musl-armv6 ;; \
arm/v7) NODE_LIBC=musl-armv7 ;; \
arm/*) echo "Unsupported ARM variant: ${TARGETVARIANT:-unknown}" >&2; exit 1 ;; \
*) NODE_LIBC=musl ;; \
esac \
&& npm_config_target_platform=linux \ && npm_config_target_platform=linux \
npm_config_target_arch="${NODE_ARCH}" \ npm_config_target_arch="${NODE_ARCH}" \
npm_config_target_libc=musl \ npm_config_target_libc="${NODE_LIBC}" \
pnpm install --prod --frozen-lockfile pnpm install --prod --frozen-lockfile --side-effects-cache=false
FROM python:3.11-alpine # Pin amd64 to upstream's musl runtime after native URL parser crashes with APK Node.
FROM --platform=linux/amd64 node:24.18.1-alpine3.24@sha256:f70403e87646dc51b45295f4b8b70cdad0b63d2297c4c9899119b03f7af7a6b3 AS node-runtime-amd64
FROM python:3.11-alpine3.24
ARG TARGETARCH
ARG TARGETVARIANT
ARG QL_MAINTAINER="whyour" ARG QL_MAINTAINER="whyour"
LABEL maintainer="${QL_MAINTAINER}" LABEL maintainer="${QL_MAINTAINER}"
@@ -44,7 +57,8 @@ EXPOSE 5700
COPY --from=builder /opt/node-global/lib/node_modules/. /usr/local/lib/node_modules/ COPY --from=builder /opt/node-global/lib/node_modules/. /usr/local/lib/node_modules/
COPY --from=builder /opt/node-global/bin/. /usr/local/bin/ COPY --from=builder /opt/node-global/bin/. /usr/local/bin/
RUN set -x \ RUN --mount=type=bind,from=node-runtime-amd64,source=/usr/local/bin/node,target=/tmp/node-runtime-amd64 \
set -x \
&& apk update -f \ && apk update -f \
&& apk upgrade \ && apk upgrade \
&& apk --no-cache add -f bash \ && apk --no-cache add -f bash \
@@ -62,6 +76,11 @@ RUN set -x \
netcat-openbsd \ netcat-openbsd \
unzip \ unzip \
npm \ npm \
&& if [ "$TARGETARCH" = amd64 ]; then \
cp /tmp/node-runtime-amd64 /usr/local/bin/node; \
ln -sf /usr/local/bin/node /usr/bin/node; \
fi \
&& node --version | grep -E '^v24\.' \
&& rm -rf /var/cache/apk/* \ && rm -rf /var/cache/apk/* \
&& apk update \ && apk update \
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ && ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
@@ -86,6 +105,10 @@ COPY static/ /ql/static/
COPY docker/verify-build.cjs docker/build-manifest.cjs /tmp/ COPY docker/verify-build.cjs docker/build-manifest.cjs /tmp/
COPY --from=builder /tmp/build/pnpm-lock.yaml /tmp/dependency-lock.yaml COPY --from=builder /tmp/build/pnpm-lock.yaml /tmp/dependency-lock.yaml
RUN cd ${QL_DIR} && node /tmp/verify-build.cjs /tmp/dependency-lock.yaml RUN cd ${QL_DIR} && node /tmp/verify-build.cjs /tmp/dependency-lock.yaml
RUN if [ "$TARGETARCH" = arm ] && [ "${TARGETVARIANT:-}" = v6 ]; then \
QEMU_CPU=arm1176 node --jitless ${QL_DIR}/shell/node-runtime-probe.cjs; \
else node ${QL_DIR}/shell/node-runtime-probe.cjs; fi
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \ ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \ PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
+28 -5
View File
@@ -1,8 +1,9 @@
# Run Node package installation natively on the builder. Node/npm can spin at # Run Node package installation natively on the builder. Node/npm can spin at
# 100% CPU when Alpine s390x is emulated through QEMU. # 100% CPU when Alpine s390x is emulated through QEMU.
FROM --platform=$BUILDPLATFORM node:18-alpine3.18 AS builder FROM --platform=$BUILDPLATFORM node:24.18.1-alpine3.24 AS builder
ARG TARGETARCH ARG TARGETARCH
ARG TARGETVARIANT
ENV NPM_CONFIG_PREFIX=/opt/node-global ENV NPM_CONFIG_PREFIX=/opt/node-global
ENV PATH=/opt/node-global/bin:${PATH} ENV PATH=/opt/node-global/bin:${PATH}
@@ -17,12 +18,24 @@ RUN set -x \
ppc64le) NODE_ARCH=ppc64 ;; \ ppc64le) NODE_ARCH=ppc64 ;; \
*) NODE_ARCH="${TARGETARCH}" ;; \ *) NODE_ARCH="${TARGETARCH}" ;; \
esac \ esac \
&& case "${TARGETARCH}/${TARGETVARIANT:-}" in \
arm/v6) NODE_LIBC=musl-armv6 ;; \
arm/v7) NODE_LIBC=musl-armv7 ;; \
arm/*) echo "Unsupported ARM variant: ${TARGETVARIANT:-unknown}" >&2; exit 1 ;; \
*) NODE_LIBC=musl ;; \
esac \
&& npm_config_target_platform=linux \ && npm_config_target_platform=linux \
npm_config_target_arch="${NODE_ARCH}" \ npm_config_target_arch="${NODE_ARCH}" \
npm_config_target_libc=musl \ npm_config_target_libc="${NODE_LIBC}" \
pnpm install --prod --frozen-lockfile pnpm install --prod --frozen-lockfile --side-effects-cache=false
FROM python:3.10-alpine # Pin amd64 to upstream's musl runtime after native URL parser crashes with APK Node.
FROM --platform=linux/amd64 node:24.18.1-alpine3.24@sha256:f70403e87646dc51b45295f4b8b70cdad0b63d2297c4c9899119b03f7af7a6b3 AS node-runtime-amd64
FROM python:3.10-alpine3.24
ARG TARGETARCH
ARG TARGETVARIANT
ARG QL_MAINTAINER="whyour" ARG QL_MAINTAINER="whyour"
LABEL maintainer="${QL_MAINTAINER}" LABEL maintainer="${QL_MAINTAINER}"
@@ -44,7 +57,8 @@ EXPOSE 5700
COPY --from=builder /opt/node-global/lib/node_modules/. /usr/local/lib/node_modules/ COPY --from=builder /opt/node-global/lib/node_modules/. /usr/local/lib/node_modules/
COPY --from=builder /opt/node-global/bin/. /usr/local/bin/ COPY --from=builder /opt/node-global/bin/. /usr/local/bin/
RUN set -x \ RUN --mount=type=bind,from=node-runtime-amd64,source=/usr/local/bin/node,target=/tmp/node-runtime-amd64 \
set -x \
&& apk update -f \ && apk update -f \
&& apk upgrade \ && apk upgrade \
&& apk --no-cache add -f bash \ && apk --no-cache add -f bash \
@@ -62,6 +76,11 @@ RUN set -x \
netcat-openbsd \ netcat-openbsd \
unzip \ unzip \
npm \ npm \
&& if [ "$TARGETARCH" = amd64 ]; then \
cp /tmp/node-runtime-amd64 /usr/local/bin/node; \
ln -sf /usr/local/bin/node /usr/bin/node; \
fi \
&& node --version | grep -E '^v24\.' \
&& rm -rf /var/cache/apk/* \ && rm -rf /var/cache/apk/* \
&& apk update \ && apk update \
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ && ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
@@ -86,6 +105,10 @@ COPY static/ /ql/static/
COPY docker/verify-build.cjs docker/build-manifest.cjs /tmp/ COPY docker/verify-build.cjs docker/build-manifest.cjs /tmp/
COPY --from=builder /tmp/build/pnpm-lock.yaml /tmp/dependency-lock.yaml COPY --from=builder /tmp/build/pnpm-lock.yaml /tmp/dependency-lock.yaml
RUN cd ${QL_DIR} && node /tmp/verify-build.cjs /tmp/dependency-lock.yaml RUN cd ${QL_DIR} && node /tmp/verify-build.cjs /tmp/dependency-lock.yaml
RUN if [ "$TARGETARCH" = arm ] && [ "${TARGETVARIANT:-}" = v6 ]; then \
QEMU_CPU=arm1176 node --jitless ${QL_DIR}/shell/node-runtime-probe.cjs; \
else node ${QL_DIR}/shell/node-runtime-probe.cjs; fi
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \ ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \ PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
+9 -3
View File
@@ -1,6 +1,10 @@
# Node 20 Bookworm is the latest official Node image variant that covers the # Download and verify the target Node archive without running under QEMU.
# full Debian build matrix, including arm/v7, ppc64le, and s390x. FROM --platform=$BUILDPLATFORM debian:bookworm-slim AS nodebuilder
FROM node:20-bookworm-slim AS nodebuilder ARG TARGETARCH
COPY docker/install-node-debian.sh /tmp/install-node-debian.sh
RUN apt-get update && \
apt-get install --no-install-recommends -y ca-certificates curl xz-utils && \
sh /tmp/install-node-debian.sh "${TARGETARCH}"
FROM python:3.11.14-slim-bookworm AS builder FROM python:3.11.14-slim-bookworm AS builder
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/ COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
@@ -11,6 +15,7 @@ RUN set -x && \
apt-get update && \ apt-get update && \
apt-get install --no-install-recommends -y libatomic1 && \ apt-get install --no-install-recommends -y libatomic1 && \
npm i -g pnpm@8.3.1 && \ npm i -g pnpm@8.3.1 && \
test "$(node --version)" = "v24.18.1" && \
test "$(pnpm --version)" = "8.3.1" && \ test "$(pnpm --version)" = "8.3.1" && \
cd /tmp/build && \ cd /tmp/build && \
pnpm install --prod --frozen-lockfile pnpm install --prod --frozen-lockfile
@@ -71,6 +76,7 @@ RUN set -x && \
git config --global user.name "qinglong" && \ git config --global user.name "qinglong" && \
git config --global http.postBuffer 524288000 && \ git config --global http.postBuffer 524288000 && \
npm install -g pnpm@8.3.1 pm2 ts-node typescript@5 && \ npm install -g pnpm@8.3.1 pm2 ts-node typescript@5 && \
test "$(node --version)" = "v24.18.1" && \
test "$(pnpm --version)" = "8.3.1" && \ test "$(pnpm --version)" = "8.3.1" && \
npm cache clean --force && \ npm cache clean --force && \
rm -rf /root/.cache && \ rm -rf /root/.cache && \
+10 -4
View File
@@ -1,8 +1,12 @@
# Node 20 Bookworm is the latest official Node image variant that covers the # Download and verify the target Node archive without running under QEMU.
# full Debian build matrix, including arm/v7, ppc64le, and s390x. FROM --platform=$BUILDPLATFORM debian:bookworm-slim AS nodebuilder
FROM node:20-bookworm-slim AS nodebuilder ARG TARGETARCH
COPY docker/install-node-debian.sh /tmp/install-node-debian.sh
RUN apt-get update && \
apt-get install --no-install-recommends -y ca-certificates curl xz-utils && \
sh /tmp/install-node-debian.sh "${TARGETARCH}"
# Keep the full release matrix, including s390x, on the same immutable base. # Keep s390x on the same immutable Python base as the other Debian targets.
# The floating 3.10-slim-bookworm tag no longer includes s390x. # The floating 3.10-slim-bookworm tag no longer includes s390x.
FROM python:3.10.19-slim-bookworm@sha256:23f63358922e79a794f71be8f3723c84e5ccca9638af3f74456dc73d6184499e AS builder FROM python:3.10.19-slim-bookworm@sha256:23f63358922e79a794f71be8f3723c84e5ccca9638af3f74456dc73d6184499e AS builder
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/ COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
@@ -13,6 +17,7 @@ RUN set -x && \
apt-get update && \ apt-get update && \
apt-get install --no-install-recommends -y libatomic1 && \ apt-get install --no-install-recommends -y libatomic1 && \
npm i -g pnpm@8.3.1 && \ npm i -g pnpm@8.3.1 && \
test "$(node --version)" = "v24.18.1" && \
cd /tmp/build && \ cd /tmp/build && \
pnpm install --prod --frozen-lockfile pnpm install --prod --frozen-lockfile
@@ -71,6 +76,7 @@ RUN set -x && \
git config --global user.name "qinglong" && \ git config --global user.name "qinglong" && \
git config --global http.postBuffer 524288000 && \ git config --global http.postBuffer 524288000 && \
npm install -g pnpm@8.3.1 pm2 ts-node typescript@5 && \ npm install -g pnpm@8.3.1 pm2 ts-node typescript@5 && \
test "$(node --version)" = "v24.18.1" && \
npm cache clean --force && \ npm cache clean --force && \
rm -rf /root/.cache && \ rm -rf /root/.cache && \
rm -rf /root/.npm && \ rm -rf /root/.npm && \
+16
View File
@@ -0,0 +1,16 @@
# The image digest is supplied by the same workflow run, never a rolling tag.
ARG BASE_IMAGE
FROM ${BASE_IMAGE} AS bundle
ARG BASE_IMAGE
ENV QL_BUNDLE_BASE_IMAGE=${BASE_IMAGE}
ARG TARGETARCH
ARG TARGETVARIANT
USER root
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
FROM scratch
COPY --from=bundle /out/ /
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
set -eu
# The official Node 24 Bookworm image does not cover s390x. Use the same
# verified upstream release archive on every supported Debian architecture.
version=24.18.1
case "${1:?Missing Docker TARGETARCH}" in
amd64) arch=x64; checksum=d6c664df3f3f61458e8c277585571328522d705166723a7c7823a9253a4d15a0 ;;
arm64) arch=arm64; checksum=7201e3a09dc825bac57867c81913e2b8f0ef87d04cb9082af4cda82f6ff3d88c ;;
ppc64le) arch=ppc64le; checksum=c47812c13862be7f6e71194c434c4d78a99096e72efb9c31a2627437e056d669 ;;
s390x) arch=s390x; checksum=acf57bd726f35afc2ba8475194e5a8bd532c00e1a3b2436295b2fbdde199d0e9 ;;
*) echo "Node ${version} has no supported Debian binary for TARGETARCH=$1" >&2; exit 1 ;;
esac
archive="node-v${version}-linux-${arch}.tar.xz"
curl --fail --location --silent --show-error --retry 3 \
"https://nodejs.org/dist/v${version}/${archive}" -o "/tmp/${archive}"
printf '%s %s\n' "${checksum}" "/tmp/${archive}" | sha256sum --check --status
tar -xJf "/tmp/${archive}" -C /usr/local --strip-components=1 --no-same-owner
rm "/tmp/${archive}"
# The target binary is executed and version-checked in the target builder/runtime stages.
test -x /usr/local/bin/node
@@ -0,0 +1,96 @@
# 平台完整升级包
CI 从同次构建的不可变镜像摘要导出完整 tar.gz,包含源码中的运行脚本和模板、前后端编译产物、锁文件和已经安装的生产 `node_modules`。不包含 Node 可执行文件、用户数据、`.env`、Git 历史或 npm 缓存。保留现有 Node 和用户脚本依赖。
## 在线升级
`package.json` 中 `qinglongUpgradeFormat: 2` 表示使用完整包。旧版升级器仍可通过原归档路径完成首次迁移;完整包版本不会在平台包缺失、损坏或不兼容时退回设备端 npm 安装。仅当完整包已通过校验、依赖加载或后端预启动明确报告依赖错误时,在线升级会按同一冻结锁文件修复一次生产依赖,然后再次预启动;第二次失败即停止,保留正在运行的旧服务。
面板下载任务与本地离线入口共用 `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` 不生成完整包。需要重建当前开发包时,可手动运行同一工作流:
```sh
gh workflow run build-docker-image.yml --ref develop
```
开发包更新共用的 `upgrade-develop/latest.json`。`develop` 和 `debian-dev` 镜像都使用该索引,按实际 Node 架构和 musl/glibc 选择包,无需设置测试专用下载源。开发批次位于 `upgrade-build-develop-v<version>-<run-id>-<run-attempt>`,不会改写正式版本或 `upgrade-master`。同一开发渠道的镜像构建和包发布串行执行,与 master 独立。
开发包只保留最新一个已验证批次:所有平台资产和渠道索引的大小、SHA256 校验成功后,删除其他名称匹配的开发构建 prerelease;不删除正式包、渠道索引或无法分类的旧 release。构建或上传失败不会删除上一套可用包。旧批次被删除后,已选中旧索引但尚未下载完的客户端可能需要重新发起下载;准备失败仍保留旧服务和数据。
开发和正式包都必须在 11 个平台完成归档、解包后复验及依赖试运行,并且全部上传资产的大小和 SHA256 复验通过后,才更新各自索引。平台范围与现有 Alpine/Debian 镜像一致:ARMv6 和 ARMv7 仅提供 musl 包,Debian 不再发布 ARMv7 镜像或 glibc 平台包。
版本入口为 GitHub prerelease `upgrade-v<version>` 下的 `latest.json`;在线正式渠道保留 `upgrade-master/latest.json`,兼容现有客户端。二者指向同一套完整包,指定版本与 latest 不重复生成产物。索引记录渠道、版本、源码提交、构建批次、准确字节数、SHA256,以及 `upgrade-build-v<version>-<run-id>-<run-attempt>` 下的固定下载 URL。相同版本的镜像 tag 重新发布时,完整包也重新生成,版本和正式渠道索引同步覆盖到新批次;同一提交重跑也允许产生新内容。已选中旧索引的下载继续使用旧批次资产,避免覆盖文件导致哈希不匹配。master 的镜像构建和完整包发布整条流水线串行执行,避免镜像 tag 与完整包被并发重发交错覆盖;旧工作流明确重跑时也允许同版本、同提交重新发布。过期源码提交不能推进对应渠道或版本索引;平台包上传失败时不切换索引。
## 手动离线安装和升级
从 `upgrade-v<version>` 的说明或 `latest.json` 中取得当前批次的下载链接,下载匹配设备的 `.tar.gz` 和独立 `install-bundle.cjs`;两者都来自同一个 `upgrade-build-v<version>-<run-id>-<run-attempt>` release。独立安装器已内置解析依赖,在目标设备正常情况无需 npm、pnpm 或重新安装 SQLite。离线准备和预启动默认不联网;依赖不符合要求时直接报错。可对照同批次 release 的 `latest.json` 核对归档 SHA256。
已有安装(默认应用并重启):
```sh
node install-bundle.cjs --root /ql --file /absolute/path/qinglong-<commit>-linux-arm64-musl.tar.gz
```
只准备,稍后手动重启:
```sh
node install-bundle.cjs --root /ql --file /absolute/path/package.tar.gz --download-only
ql reload system
```
仅在明确允许联网修复时添加 `--repair-deps`(安装和升级均支持),例如:
```sh
node install-bundle.cjs --root /ql --file /absolute/path/package.tar.gz --download-only --repair-deps
```
修复需要已有 pnpm 8.3.1 及必要编译工具。依赖在私有目录按 `--prod --frozen-lockfile` 重装,仅重建 SQLite;ARMv6 强制源码编译和 ARMv6 硬浮点指令,禁止下载 generic ARM 的 ARMv7 二进制。修复仅执行一次;不安装或替换 Node、不改锁文件、不改已校验源码和静态产物。应用前以原包校验清单和独立记录的修复依赖摘要复验。
修复继承调用环境中的标准 HTTP/HTTPS/ALL/NO_PROXY(含小写形式),其余 Node 预加载和编译参数仍隔离。修复安装或 SQLite 重建失败时,清理私有目录,保留当前服务、当前依赖及上一次就绪包;不会标记本次更新就绪。预启动或修复主进程退出时立即终止其进程组,防止持有输出管道的子进程继续写入已清理目录。
通过 Bash 或已选择的本地 CLI 执行上述重启时,也会把已准备的面板任务更新为完成或失败;启动成功必须同时匹配目标版本、提交和后端哈希,匹配失败则恢复旧文件和服务。
手动应用会以当前就绪指针为准建立新的应用任务,并清除之前的失败信息。因此,准备失败后应用保留的旧就绪包、此前任务已完成后再次应用,以及完整包安装中任务记录丢失或尚未建立时,仍会校验实际启动版本并同步面板状态。未支持完整包且没有任务记录的旧版保留原重载兼容路径。
重启中断后的停止新服务、恢复文件和启动旧服务不再使用已经取消的信号。若新服务无法停止,则保留备份,避免在仍运行的进程下覆盖文件;若文件已恢复但旧服务也无法启动,任务仍报告失败。Bash 和本地 CLI 启动后端时都移除离线包选择、修复许可和升级启动器 PID,避免后续在线更新误用旧离线包;用户配置及代理保持不变。回滚恢复应用文件和依赖,不把用户数据库恢复到历史快照;数据库迁移仍需保持向后兼容。
安装到空目录:
```sh
node install-bundle.cjs --root /opt/qinglong --file /absolute/path/package.tar.gz --install
QL_DIR=/opt/qinglong QL_DATA_DIR=/opt/qinglong/data bash /opt/qinglong/shell/start-simplify.sh
```
`--install` 拒绝非空目录,不覆盖既有配置。该包安装青龙及其 Node 生产依赖;首次启动仍需已有 Python、Bash、OpenSSL、PM2、nginx 等相应运行环境,不会在线安装或替换它们。手动启动脚本会在启动工作进程前完成首次 gRPC 证书准备,避免慢设备生成密钥耗尽工作进程的就绪期限;已有证书继续复用。服务启动和系统自启按原手动安装流程配置。
ARMv6 小设备使用 `linux-armv6-musl.tar.gz`,要求已有兼容的 musl 系统及硬浮点 Node。ARMv6 glibc(例如 Raspbian/Raspberry Pi OS 32 位系统)不在平台包支持范围内,不能使用其他架构或 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。
## 平台兼容
- 最低 Node 为 20.18.1,Node-API 至少 6;安装器只检查,不替换 Node。
- 以 Node 实际运行架构、ARM 指令集和 libc 类型/版本选择包;不依据 Docker 架构别名猜测。
- glibc、musl 分包,并记录构建系统 libc 下限。系统库仍以目标设备加载测试作为最后检查。
- ARMv6 保留独立包名。ARMv6 在目标镜像中从源码编译 SQLite,禁止复用 generic `arm` 的 ARMv7 预编译件;构建需通过加载和内存库读写,失败则不发布渠道。
- ARMv6 的归档构建、在线更新工作进程及独立离线安装器使用 `--jitless`,避开 ARM1176 上 V8 的优化编译崩溃;保留现有 Node 二进制,Node 24 及以上的 ARMv6 面板进程通过共享 `nodeRuntime` 使用 `--no-opt --no-sparkplug --no-maglev`,其他架构和较低 Node 版本保持原启动参数。ARMv6 的 gRPC 工作进程首次启动及重建最多等待 2 分钟;升级后的整体健康检查最多等待 5 分钟,覆盖慢设备按顺序启动数据库、调度与 HTTP 工作进程的耗时;其他平台仍为 60 秒,超时或版本校验失败仍执行回退。依赖加载与后端预启动使用现有 Node 的正常模式,使 Node 20.18.1 的内置 HTTP/WebAssembly 组件保持可用。预启动只有一个后端进程,按顺序初始化数据库、gRPC、HTTP,不启动 PM2/cluster;ARMv6 V8 堆上限 128 MiB、期限 5 分钟,其他平台为 192 MiB/60 秒。堆上限不是 RSS 总内存上限,系统仍需为现有服务和 OpenSSL 留出内存。依赖修复的包管理器及编译使用单并发、低网络并发和有界时长;ARMv6 包管理器采用 `--jitless`、256 MiB 堆上限,并优先复用版本匹配的现有 Node 头文件与实际运行时编译配置;超时、中断会终止整个进程组并清理临时目录。
- tar 中允许 pnpm 的包内符号链接;拒绝绝对路径、越界路径、包外链接、硬链接、设备条目和经符号链接写入的条目。重启前再次校验依赖,暂存失败保留上一次可用的就绪包。
自托管镜像可设置 `QL_UPGRADE_INDEX_URL` 指向兼容索引;要求 HTTPS,测试时仅放行 loopback HTTP。索引和所有平台归档应部署到同一可访问源,避免依赖额外的 raw 域名。
+3
View File
@@ -1,4 +1,6 @@
const isContainer = process.env.QL_CONTAINER === 'true'; const isContainer = process.env.QL_CONTAINER === 'true';
const runtimeArgs =
require('./cli/src/internal/maintenance/nodeRuntime.cjs').serverRuntimeArgs();
module.exports = { module.exports = {
apps: [ apps: [
@@ -18,6 +20,7 @@ module.exports = {
// visible through `docker logs` before Winston is initialized. // visible through `docker logs` before Winston is initialized.
error_file: isContainer ? '/proc/1/fd/2' : undefined, error_file: isContainer ? '/proc/1/fd/2' : undefined,
script: 'static/build/app.js', script: 'static/build/app.js',
...(runtimeArgs.length ? { node_args: runtimeArgs } : {}),
env: { env: {
http_proxy: '', http_proxy: '',
https_proxy: '', https_proxy: '',
+45 -5
View File
@@ -12,6 +12,42 @@
"bugs": { "bugs": {
"url": "https://github.com/whyour/qinglong/issues" "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": { "scripts": {
"build:cli": "node cli/scripts/build.cjs", "build:cli": "node cli/scripts/build.cjs",
"check:cli": "npm --prefix cli run check", "check:cli": "npm --prefix cli run check",
@@ -28,7 +64,9 @@
"panel": "npm run build:back && node static/build/app.js", "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", "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}'", "prettier": "prettier --write '**/*.{js,jsx,tsx,ts,less,md,json}'",
"postinstall": "max setup 2>/dev/null || true", "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)",
"start:simple": "bash shell/start-simplify.sh" "start:simple": "bash shell/start-simplify.sh"
}, },
"gitHooks": { "gitHooks": {
@@ -73,7 +111,7 @@
} }
}, },
"overrides": { "overrides": {
"sqlite3": "npm:@whyour/sqlite3@1.1.2", "sqlite3": "npm:@whyour/sqlite3@1.1.4",
"@codemirror/state": "6.5.4", "@codemirror/state": "6.5.4",
"@codemirror/view": "6.39.16", "@codemirror/view": "6.39.16",
"proxy-addr": "2.0.8", "proxy-addr": "2.0.8",
@@ -121,13 +159,14 @@
"request-ip": "3.3.0", "request-ip": "3.3.0",
"sequelize": "6.37.8", "sequelize": "6.37.8",
"sockjs": "^0.3.24", "sockjs": "^0.3.24",
"sqlite3": "npm:@whyour/sqlite3@1.1.2", "sqlite3": "npm:@whyour/sqlite3@1.1.4",
"toad-scheduler": "^3.0.1", "toad-scheduler": "^3.0.1",
"typedi": "^0.10.0", "typedi": "^0.10.0",
"undici": "^7.9.0", "undici": "^7.9.0",
"uuid": "^11.0.3", "uuid": "^11.0.3",
"winston": "^3.17.0", "winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0" "winston-daily-rotate-file": "^5.0.0",
"tar": "7.5.20"
}, },
"devDependencies": { "devDependencies": {
"@ant-design/icons": "^5.0.1", "@ant-design/icons": "^5.0.1",
@@ -201,5 +240,6 @@
"typescript": "5.2.2", "typescript": "5.2.2",
"vh-check": "^2.0.5", "vh-check": "^2.0.5",
"virtualizedtableforantd4": "1.3.0" "virtualizedtableforantd4": "1.3.0"
} },
"qinglongUpgradeFormat": 2
} }
+13 -15
View File
@@ -1,7 +1,7 @@
lockfileVersion: '6.0' lockfileVersion: '6.0'
overrides: overrides:
sqlite3: npm:@whyour/sqlite3@1.1.2 sqlite3: npm:@whyour/sqlite3@1.1.4
'@codemirror/state': 6.5.4 '@codemirror/state': 6.5.4
'@codemirror/view': 6.39.16 '@codemirror/view': 6.39.16
proxy-addr: 2.0.8 proxy-addr: 2.0.8
@@ -120,13 +120,16 @@ dependencies:
version: 3.3.0 version: 3.3.0
sequelize: sequelize:
specifier: 6.37.8 specifier: 6.37.8
version: 6.37.8(@whyour/sqlite3@1.1.2) version: 6.37.8(@whyour/sqlite3@1.1.4)
sockjs: sockjs:
specifier: ^0.3.24 specifier: ^0.3.24
version: 0.3.24 version: 0.3.24
sqlite3: sqlite3:
specifier: npm:@whyour/sqlite3@1.1.2 specifier: npm:@whyour/sqlite3@1.1.4
version: /@whyour/sqlite3@1.1.2 version: /@whyour/sqlite3@1.1.4
tar:
specifier: 7.5.20
version: 7.5.20
toad-scheduler: toad-scheduler:
specifier: ^3.0.1 specifier: ^3.0.1
version: 3.0.1 version: 3.0.1
@@ -3053,7 +3056,6 @@ packages:
dependencies: dependencies:
minipass: 7.1.3 minipass: 7.1.3
dev: false dev: false
optional: true
/@istanbuljs/load-nyc-config@1.1.0: /@istanbuljs/load-nyc-config@1.1.0:
resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==}
@@ -3191,7 +3193,7 @@ packages:
resolution: {integrity: sha512-Ngs9jhElXN7efS9WvvCC/p6rXMbihna8eNLVBc421Zf+VcFd+pR4DOcS6yA9V22EKtAy4DEj7LtvEEIm0bb80A==} resolution: {integrity: sha512-Ngs9jhElXN7efS9WvvCC/p6rXMbihna8eNLVBc421Zf+VcFd+pR4DOcS6yA9V22EKtAy4DEj7LtvEEIm0bb80A==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
dependencies: dependencies:
sqlite3: /@whyour/sqlite3@1.1.2 sqlite3: /@whyour/sqlite3@1.1.4
transitivePeerDependencies: transitivePeerDependencies:
- encoding - encoding
- supports-color - supports-color
@@ -5609,14 +5611,15 @@ packages:
- supports-color - supports-color
dev: true dev: true
/@whyour/sqlite3@1.1.2: /@whyour/sqlite3@1.1.4:
resolution: {integrity: sha512-OY24/OJMqLHIGolo4GCFW4lrJKAoOB+ZqERXb7u7DKZQz7QR3f0GALE99BZWQBSZEVVZ/StVeAb2fCkMfTtuDQ==} resolution: {integrity: sha512-MlWzqOdpZ0KPuBx/FYRrHZ8p3S0eCbaf2d8duDzWmfko/aky25Xoss67qDjFgbRjnwj9z0i0KNdNtQI02dnWKA==}
requiresBuild: true requiresBuild: true
peerDependenciesMeta: peerDependenciesMeta:
node-gyp: node-gyp:
optional: true optional: true
dependencies: dependencies:
'@mapbox/node-pre-gyp': 1.0.11 '@mapbox/node-pre-gyp': 1.0.11
detect-libc: 1.0.3
node-addon-api: 4.3.0 node-addon-api: 4.3.0
tar: 6.2.1 tar: 6.2.1
optionalDependencies: optionalDependencies:
@@ -6623,7 +6626,6 @@ packages:
engines: {node: '>=18'} engines: {node: '>=18'}
requiresBuild: true requiresBuild: true
dev: false dev: false
optional: true
/ci-info@3.9.0: /ci-info@3.9.0:
resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==}
@@ -7600,7 +7602,6 @@ packages:
resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==}
engines: {node: '>=0.10'} engines: {node: '>=0.10'}
hasBin: true hasBin: true
dev: true
/detect-libc@2.1.2: /detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
@@ -10701,7 +10702,6 @@ packages:
dependencies: dependencies:
minipass: 7.1.3 minipass: 7.1.3
dev: false dev: false
optional: true
/mkdirp@0.5.6: /mkdirp@0.5.6:
resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
@@ -13903,7 +13903,7 @@ packages:
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
dev: false dev: false
/sequelize@6.37.8(@whyour/sqlite3@1.1.2): /sequelize@6.37.8(@whyour/sqlite3@1.1.4):
resolution: {integrity: sha512-HJ0IQFqcTsTiqbEgiuioYFMSD00TP6Cz7zoTti+zVVBwVe9fEhev9cH6WnM3XU31+ABS356durAb99ZuOthnKw==} resolution: {integrity: sha512-HJ0IQFqcTsTiqbEgiuioYFMSD00TP6Cz7zoTti+zVVBwVe9fEhev9cH6WnM3XU31+ABS356durAb99ZuOthnKw==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
peerDependencies: peerDependencies:
@@ -13948,7 +13948,7 @@ packages:
retry-as-promised: 7.0.4 retry-as-promised: 7.0.4
semver: 7.7.4 semver: 7.7.4
sequelize-pool: 7.1.0 sequelize-pool: 7.1.0
sqlite3: /@whyour/sqlite3@1.1.2 sqlite3: /@whyour/sqlite3@1.1.4
toposort-class: 1.0.1 toposort-class: 1.0.1
uuid: 8.3.2 uuid: 8.3.2
validator: 13.12.0 validator: 13.12.0
@@ -14706,7 +14706,6 @@ packages:
minizlib: 3.1.0 minizlib: 3.1.0
yallist: 5.0.0 yallist: 5.0.0
dev: false dev: false
optional: true
/terser@5.36.0: /terser@5.36.0:
resolution: {integrity: sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w==} resolution: {integrity: sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w==}
@@ -15727,7 +15726,6 @@ packages:
engines: {node: '>=18'} engines: {node: '>=18'}
requiresBuild: true requiresBuild: true
dev: false dev: false
optional: true
/yaml@1.10.2: /yaml@1.10.2:
resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==}
+18 -6
View File
@@ -1313,30 +1313,40 @@ def add_notify_function():
def report_notification(channel, title, content, success): 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") ql_dir = os.getenv("QL_DIR")
if not ql_dir: if not ql_dir or not results:
return 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, "success": success, "notifyType": channel,
"scriptName": os.path.basename(__import__("sys").argv[0]), "taskId": os.getenv("ID", "")} "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'); code = """const fs=require('node:fs'),path=require('node:path');
const report=require(path.join(process.env.QL_DIR,'shell/preload/notificationAudit.js')); const report=require(path.join(process.env.QL_DIR,'shell/preload/notificationAudit.js'));
report(JSON.parse(fs.readFileSync(0,'utf8'))).catch(()=>{});""" report(JSON.parse(fs.readFileSync(0,'utf8'))).catch(()=>{});"""
try: 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, subprocess.run(["node", "-e", code], input=json.dumps(payload), text=True,
capture_output=True, timeout=3) capture_output=True, timeout=10)
except Exception: except Exception:
pass # Audit availability never changes notification delivery. pass # Audit availability never changes notification delivery.
def audited_notification(mode, title, content): def audited_notification(mode, title, content, results=None):
result = None result = None
try: try:
result = mode(title, content) result = mode(title, content)
except Exception: except Exception:
result = False result = False
print(f"{mode.__name__} 推送失败") print(f"{mode.__name__} 推送失败")
report_notification(mode.__name__, title, content, result is True) if results is None:
report_notification(mode.__name__, title, content, result is True)
else:
results.append((mode.__name__, result is True))
def send(title: str, content: str, ignore_default_config: bool = False, **kwargs): def send(title: str, content: str, ignore_default_config: bool = False, **kwargs):
@@ -1362,12 +1372,14 @@ def send(title: str, content: str, ignore_default_config: bool = False, **kwargs
content += "\n\n" + one() if hitokoto not in [False, "false"] else "" content += "\n\n" + one() if hitokoto not in [False, "false"] else ""
notify_function = add_notify_function() notify_function = add_notify_function()
results = []
ts = [ ts = [
threading.Thread(target=audited_notification, args=(mode, title, content), name=mode.__name__) threading.Thread(target=audited_notification, args=(mode, title, content, results), name=mode.__name__)
for mode in notify_function for mode in notify_function
] ]
[t.start() for t in ts] [t.start() for t in ts]
[t.join() for t in ts] [t.join() for t in ts]
report_notifications(results, title, content)
def main(): def main():
+22
View File
@@ -0,0 +1,22 @@
const path = require('node:path');
require('../cli/node_modules/esbuild').buildSync({
entryPoints: [path.resolve(__dirname, '../shell/install-bundle.cjs')],
outfile: path.resolve(process.argv[2] || 'output/upgrade/install-bundle.cjs'),
bundle: true,
platform: 'node',
format: 'cjs',
target: 'node20',
minify: true,
legalComments: 'inline',
});
const { spawnSync } = require('node:child_process');
const result = spawnSync(
process.execPath,
[path.resolve(process.argv[2] || 'output/upgrade/install-bundle.cjs')],
{ encoding: 'utf8' },
);
if (
result.status !== 1 ||
!result.stderr.includes('Usage: node install-bundle.cjs')
)
throw new Error('Standalone offline installer did not start correctly');
+142
View File
@@ -0,0 +1,142 @@
const fs = require('node:fs/promises');
const path = require('node:path');
const tar = require('tar');
const bundle = require('../cli/src/internal/maintenance/platformBundle.cjs');
const {
verifyUpgrade,
} = require('../cli/src/internal/maintenance/upgradeArtifacts.cjs');
async function normalizeModes(directory) {
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
const filename = path.join(directory, entry.name);
if (entry.isDirectory()) {
await fs.chmod(filename, 0o755);
await normalizeModes(filename);
} else if (entry.isFile()) {
const stat = await fs.stat(filename);
await fs.chmod(filename, 0o644 | (stat.mode & 0o111));
}
}
}
async function buildBundle(root, output) {
root = path.resolve(root);
output = path.resolve(output);
const target = await bundle.runtimeTarget();
if (process.platform !== 'linux')
throw new Error('Release bundles must be built and probed on Linux');
const build = await verifyUpgrade(root, path.join(root, 'static'));
await bundle.smokeDependencies(root);
await fs.mkdir(output, { recursive: true });
const workspace = await fs.mkdtemp(path.join(output, '.bundle-'));
try {
const payload = path.join(workspace, 'qinglong');
const source = path.join(payload, 'source'),
staticRoot = path.join(payload, 'static');
await fs.mkdir(source, { recursive: true });
const excluded = new Set([
'.git',
'.tmp',
'.env',
'data',
'static',
'output',
'node_modules',
]);
for (const name of await fs.readdir(root)) {
if (excluded.has(name)) continue;
await fs.cp(path.join(root, name), path.join(source, name), {
recursive: true,
verbatimSymlinks: true,
});
}
await fs.cp(
path.join(root, 'node_modules'),
path.join(source, 'node_modules'),
{ recursive: true, verbatimSymlinks: true },
);
await fs.cp(path.join(root, 'static'), staticRoot, {
recursive: true,
verbatimSymlinks: true,
});
await normalizeModes(source);
await normalizeModes(staticRoot);
const manifest = {
format: 2,
sourceCommit: build.sourceCommit,
version: JSON.parse(
await fs.readFile(path.join(root, 'package.json'), 'utf8'),
).version,
lockfileSha256: build.lockfileSha256,
target: {
platform: target.platform,
arch: target.arch,
libc: target.libc,
libcMinimum: target.libcVersion,
endianness: target.endianness,
armFloatAbi: target.armFloatAbi,
},
nodeMinimum: '20.18.1',
nodeApi: 6,
files: await bundle.inventory({ source, static: staticRoot }),
};
await fs.writeFile(
path.join(payload, 'bundle.json'),
JSON.stringify(manifest),
);
console.log('Bundle inventory verified; writing archive');
const filename = `qinglong-${build.sourceCommit}-${bundle.targetKey(
target,
)}.tar.gz`;
await tar.c(
{
gzip: true,
portable: true,
noMtime: true,
cwd: workspace,
file: path.join(output, filename),
},
['qinglong'],
);
// Verify the bytes users receive, not just the pre-archive build directory.
const roundtrip = path.join(workspace, 'roundtrip');
await fs.mkdir(roundtrip);
console.log('Verifying archived paths, permissions and checksums');
await bundle.extractArchive(path.join(output, filename), roundtrip);
const extracted = {
source: path.join(roundtrip, 'qinglong/source'),
static: path.join(roundtrip, 'qinglong/static'),
};
await bundle.verifyPayload(extracted, manifest, target);
await verifyUpgrade(extracted.source, extracted.static);
await bundle.smokeDependencies(extracted.source);
console.log('Archived production dependencies and SQLite verified');
const bytes = await fs.readFile(path.join(output, filename));
const record = {
format: 2,
sourceCommit: build.sourceCommit,
version: manifest.version,
target: bundle.targetKey(target),
filename,
baseImage: process.env.QL_BUNDLE_BASE_IMAGE,
size: bytes.length,
sha256: bundle.sha256(bytes),
};
await fs.writeFile(
path.join(output, filename + '.json'),
JSON.stringify(record) + '\n',
);
console.log(JSON.stringify(record));
return record;
} finally {
await fs.rm(workspace, { recursive: true, force: true });
}
}
module.exports = { buildBundle, normalizeModes };
if (require.main === module)
buildBundle(process.argv[2] || '/ql', process.argv[3] || '/out').catch(
(e) => {
console.error(e);
process.exitCode = 1;
},
);
+20
View File
@@ -0,0 +1,20 @@
// 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 });
}
+281
View File
@@ -0,0 +1,281 @@
const fs = require('node:fs');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const {
sha256,
} = require('../cli/src/internal/maintenance/platformBundle.cjs');
const platforms = [
'linux-x64-musl',
'linux-armv6-musl',
'linux-armv7-musl',
'linux-arm64-musl',
'linux-ppc64-musl',
'linux-s390x-musl',
'linux-ia32-musl',
'linux-x64-glibc',
'linux-arm64-glibc',
'linux-ppc64-glibc',
'linux-s390x-glibc',
];
function releaseIndex(directory, commit, repository, options = {}) {
const { version, releaseTag = `upgrade-${commit}` } = options;
if (
!/^[a-f0-9]{40}$/.test(commit) ||
!/^[\w.-]+\/[\w.-]+$/.test(repository) ||
!/^[\w.+-]+$/.test(releaseTag)
)
throw new Error('Invalid release source');
const packages = {};
for (const target of platforms) {
const filename = `qinglong-${commit}-${target}.tar.gz`;
const record = JSON.parse(
fs.readFileSync(path.join(directory, filename + '.json')),
);
const bytes = fs.readFileSync(path.join(directory, filename));
if (
record.format !== 2 ||
record.sourceCommit !== commit ||
(version !== undefined && record.version !== version) ||
record.target !== target ||
record.filename !== filename ||
record.size !== bytes.length ||
record.sha256 !== sha256(bytes)
)
throw new Error(`Unverified platform bundle: ${target}`);
packages[target] = {
size: record.size,
sha256: record.sha256,
url: `https://github.com/${repository}/releases/download/${encodeURIComponent(
releaseTag,
)}/${filename}`,
};
}
return { format: 2, sourceCommit: commit, packages };
}
function publishBundles({
directory,
commit,
repository,
branch,
version,
buildId,
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 (!/^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.test(version || ''))
throw new Error('Invalid bundle version');
if (!/^\d+-\d+$/.test(buildId || ''))
throw new Error('Invalid bundle build ID');
// Rebuilding a version gets new download URLs, including when its commit is
// unchanged. Only the version/channel pointer is overwritten, never bytes
// that an in-flight download has already selected by hash.
const development = branch === 'develop';
const tag = `upgrade-build-${
development ? 'develop-' : ''
}v${version}-${buildId}`;
const index = {
...releaseIndex(directory, commit, repository, {
version,
releaseTag: tag,
}),
version,
buildId,
channel: branch,
};
const installer = fs.readFileSync(path.join(directory, 'install-bundle.cjs'));
index.installer = {
size: installer.length,
sha256: sha256(installer),
url: `https://github.com/${repository}/releases/download/${encodeURIComponent(
tag,
)}/install-bundle.cjs`,
};
fs.writeFileSync(
path.join(directory, 'latest.json'),
JSON.stringify(index) + '\n',
);
const lookup = (ref) => {
try {
return JSON.parse(
gh(
'api',
`repos/${repository}/releases/tags/${encodeURIComponent(ref)}`,
),
);
} catch (error) {
if (/\bHTTP 404\b/.test(String(error.stderr))) return null;
throw error;
}
};
const notes =
`Verified platform bundles for v${version}, source ${commit}, build ${buildId}. Existing Node runtime is retained.\n\n` +
Object.entries(index.packages)
.map(([target, entry]) => `- [${target}](${entry.url})`)
.join('\n') +
`\n- [Standalone offline installer](${index.installer.url})`;
const ensureRelease = (ref, title) => {
if (!lookup(ref))
gh(
'release',
'create',
ref,
'--repo',
repository,
'--target',
commit,
'--prerelease',
'--latest=false',
'--title',
title,
'--notes',
notes,
);
};
const currentSource = () =>
gh(
'api',
`repos/${repository}/git/ref/heads/${branch}`,
'--jq',
'.object.sha',
).trim() === commit;
if (!currentSource()) {
console.log('Newer source exists; leaving version and channel unchanged');
return false;
}
ensureRelease(tag, `Platform upgrade v${version} (${buildId})`);
const assets = platforms
.flatMap((target) => {
const archive = path.join(
directory,
`qinglong-${commit}-${target}.tar.gz`,
);
return [archive, archive + '.json'];
})
.concat(
path.join(directory, 'install-bundle.cjs'),
path.join(directory, 'latest.json'),
);
const published = lookup(tag);
const missing = [];
const matches = (asset, file) => {
const bytes = fs.readFileSync(file);
return (
asset?.size === bytes.length && asset.digest === `sha256:${sha256(bytes)}`
);
};
for (const file of assets) {
const previous = published.assets.find(
(a) => a.name === path.basename(file),
);
if (!previous) missing.push(file);
else if (!matches(previous, file))
throw new Error(
`Immutable release asset differs: ${path.basename(file)}`,
);
}
if (missing.length)
gh('release', 'upload', tag, ...missing, '--repo', repository);
const uploaded = lookup(tag);
if (
!assets.every((file) =>
matches(
uploaded.assets.find((a) => a.name === path.basename(file)),
file,
),
)
)
throw new Error('Published platform bundle verification failed');
// Each channel serializes its image builds and pointer writes as one release.
// A rerun of an earlier workflow is allowed to replace the same
// version/commit; an obsolete source commit must never advance either alias.
const aliases = development
? ['upgrade-develop']
: [`upgrade-v${version}`, 'upgrade-master'];
for (const ref of aliases) {
if (!currentSource()) {
console.log('Newer source exists; leaving version and channel unchanged');
return false;
}
ensureRelease(
ref,
ref === `upgrade-${branch}`
? `Upgrade channel ${branch}`
: `Platform upgrade v${version}`,
);
gh(
'release',
'upload',
ref,
path.join(directory, 'latest.json'),
'--repo',
repository,
'--clobber',
);
gh('release', 'edit', ref, '--repo', repository, '--notes', notes);
if (
!matches(
lookup(ref)?.assets.find((a) => a.name === 'latest.json'),
path.join(directory, 'latest.json'),
)
)
throw new Error('Published channel index verification failed');
}
// Only prune development releases after the complete new batch and its
// channel pointer are verified. Production versions and aliases are retained.
if (development && currentSource()) {
const 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;
}
module.exports = { releaseIndex, platforms, publishBundles };
if (require.main === module) {
const [directory, commit, repository, branch] = process.argv.slice(2);
const version = require('../package.json').version;
if (
require('js-yaml').load(
fs.readFileSync(path.join(__dirname, '../version.yaml'), 'utf8'),
).version !== version
)
throw new Error('Package and image release versions differ');
publishBundles({
directory,
commit,
repository,
branch,
version,
buildId: `${process.env.GITHUB_RUN_ID}-${process.env.GITHUB_RUN_ATTEMPT}`,
});
}
+44
View File
@@ -0,0 +1,44 @@
"""Check serialized Docker layers, including files hidden by later deletions."""
import hashlib
import json
import sys
import tarfile
archive, architecture = sys.argv[1:]
assert architecture in {"amd64", "arm"}
nodes = []
with tarfile.open(archive) as image:
manifests = json.load(image.extractfile("manifest.json"))
assert len(manifests) == 1, "Expected one platform image"
manifest = manifests[0]
config = json.load(image.extractfile(manifest["Config"]))
assert config["architecture"] == architecture
for name in manifest["Layers"]:
with tarfile.open(fileobj=image.extractfile(name), mode="r|*") as layer:
for member in layer:
filename = member.name.removeprefix("./")
assert not (
filename == "tmp/node-runtime-amd64" and member.size > 0
), "Temporary amd64 Node remains in an immutable layer"
if filename not in {"usr/local/bin/node", "usr/bin/node"}:
continue
if not member.isfile():
continue
with layer.extractfile(member) as binary:
header = binary.read(20)
assert header[:4] == b"\x7fELF", "Expected a native Node binary"
digest = hashlib.sha256(header)
while chunk := binary.read(1024 * 1024):
digest.update(chunk)
endian = "little" if header[5] == 1 else "big"
machine = int.from_bytes(header[18:20], endian)
assert machine == {"amd64": 62, "arm": 40}[architecture], (
"Wrong Node architecture in an image layer"
)
nodes.append({"path": filename, "size": member.size, "sha256": digest.hexdigest()})
assert len(nodes) == 1, "Expected exactly one retained Node binary across all layers"
assert nodes[0]["path"] == (
"usr/local/bin/node" if architecture == "amd64" else "usr/bin/node"
)
print(json.dumps({"pass": True, "architecture": architecture, "layers": len(manifest["Layers"]), "nodes": nodes}))
+377
View File
@@ -0,0 +1,377 @@
// 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; });
}
+15 -3
View File
@@ -47,7 +47,19 @@ fi
for requested_ref in "${refs[@]}"; do for requested_ref in "${refs[@]}"; do
git check-ref-format "$requested_ref" git check-ref-format "$requested_ref"
done done
command -v timeout >/dev/null || { echo 'GNU timeout is required' >&2; exit 2; } # 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
if [[ -n "$notify_repo" ]]; then if [[ -n "$notify_repo" ]]; then
if [[ ! "$notify_repo" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; 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 echo 'QL_MIRROR_NOTIFY_REPO must be a GitHub owner/repository' >&2
@@ -62,7 +74,7 @@ notification_command() {
local budget=$((notification_deadline - SECONDS)) local budget=$((notification_deadline - SECONDS))
if (( budget <= 0 )); then return 1; fi if (( budget <= 0 )); then return 1; fi
if (( budget > 10 )); then budget=10; fi if (( budget > 10 )); then budget=10; fi
timeout --signal=KILL "${budget}s" "$@" "$timeout_command" --signal=KILL "${budget}s" "$@"
} }
notify_static_mirror() { notify_static_mirror() {
@@ -156,7 +168,7 @@ while (( SECONDS < wait_seconds )); do
fi fi
budget=$query_timeout budget=$query_timeout
if (( budget > remaining )); then budget=$remaining; fi if (( budget > remaining )); then budget=$remaining; fi
if ! output=$(timeout --signal=KILL "${budget}s" git ls-remote --refs \ if ! output=$("$timeout_command" --signal=KILL "${budget}s" git ls-remote --refs \
"${urls[$index]}" "${refs[@]}" | LC_ALL=C sort); then "${urls[$index]}" "${refs[@]}" | LC_ALL=C sort); then
echo "::warning::Could not read ${labels[$index]} refs on attempt $attempt; retrying" echo "::warning::Could not read ${labels[$index]} refs on attempt $attempt; retrying"
ready=false ready=false
+320
View File
@@ -0,0 +1,320 @@
#!/usr/bin/env node
// Also published as a standalone, bundled installer for devices with Node only.
const fs = require('node:fs/promises');
const path = require('node:path');
const os = require('node:os');
const { randomUUID } = require('node:crypto');
const { promisify } = require('node:util');
const execute = promisify(require('node:child_process').execFile);
const { parseArgs } = require('node:util');
const artifacts = require('../cli/src/internal/maintenance/upgradeArtifacts.cjs');
const jobs = require('../cli/src/internal/maintenance/panelUpgrade.cjs');
const {
nodeRuntimeArgs,
} = require('../cli/src/internal/maintenance/platformBundle.cjs');
async function runInstallerMain(argv = process.argv.slice(2)) {
const flags = nodeRuntimeArgs();
if (!flags.length || process.execArgv.includes('--jitless'))
return installBundle(argv);
const child = require('node:child_process').spawn(
process.execPath,
[...flags, __filename, ...argv],
{ stdio: 'inherit', env: process.env },
);
const interrupt = () => child.kill('SIGINT');
const terminate = () => child.kill('SIGTERM');
process.once('SIGINT', interrupt);
process.once('SIGTERM', terminate);
try {
await new Promise((resolve, reject) => {
child.once('error', reject);
child.once('exit', (code, signal) => {
if (code === 0) resolve();
else reject(new Error(`Offline installer exited: ${signal || code}`));
});
});
} finally {
process.removeListener('SIGINT', interrupt);
process.removeListener('SIGTERM', terminate);
}
}
async function installBundle(argv = process.argv.slice(2), env = process.env) {
const { values } = parseArgs({
args: argv,
options: {
root: { type: 'string' },
file: { type: 'string' },
'download-only': { type: 'boolean' },
install: { type: 'boolean' },
'repair-deps': { type: 'boolean' },
},
});
if (!values.file || !(values.root || env.QL_DIR))
throw new Error(
'Usage: node install-bundle.cjs --root /path/to/qinglong --file /path/to/bundle.tar.gz [--download-only | --install] [--repair-deps]',
);
if (values.install && values['download-only'])
throw new Error('Choose --install or --download-only');
const root = path.resolve(values.root || env.QL_DIR);
const existing = await fs.readdir(root).catch((error) => {
if (error.code !== 'ENOENT') throw error;
return [];
});
if (values.install && existing.length)
throw new Error('--install requires an empty installation directory');
if (!values.install) await fs.access(path.join(root, 'shell/share.sh'));
let environment = {
...env,
QL_DIR: root,
QL_UPGRADE_FILE: path.resolve(values.file),
};
if (!values.install) {
const captured = await execute(
'bash',
[
'--noprofile',
'--norc',
'-c',
'. "$1/shell/share.sh"\n. "$1/shell/env.sh"\nimport_config\nexport QlPort="${ql_port:-${QlPort:-5700}}" QlBaseUrl="${ql_base_url:-${QlBaseUrl:-/}}"\nenv -0',
'ql-offline',
root,
],
{ env: environment, maxBuffer: 1024 * 1024 },
);
const loaded = Object.fromEntries(
captured.stdout.split('\0').flatMap((line) => {
const match = /^([A-Za-z_][A-Za-z_0-9]*)=([\s\S]*)$/.exec(line);
return match ? [[match[1], match[2]]] : [];
}),
);
environment = {
...environment,
...loaded,
QL_DIR: root,
QL_UPGRADE_FILE: path.resolve(values.file),
};
}
environment.QL_UPGRADE_REPAIR_DEPS = values['repair-deps'] ? '1' : '0';
const freshWorkspace = values.install
? await fs.mkdtemp(path.join(os.tmpdir(), 'ql-offline-install-'))
: undefined;
const tmp = freshWorkspace || path.join(root, '.tmp');
const controller = new AbortController();
const abort = () => controller.abort(new Error('离线升级已中断'));
process.once('SIGINT', abort);
process.once('SIGTERM', abort);
try {
const operation = () =>
artifacts.withUpgradeLock(
tmp,
async () => {
let state;
const report = async (patch) => {
if (patch.phase) console.log(patch.phase);
if (state) {
state = {
...state,
...patch,
pid: process.pid,
updatedAt: Date.now(),
};
await jobs.saveState(tmp, state);
}
};
if (!values.install) {
const previous = await jobs.getStatus(tmp);
if (['starting', 'running', 'restarting'].includes(previous.status))
throw new Error('已有更新或重载正在执行。');
state = {
id: randomUUID(),
action: values['download-only'] ? 'download' : 'apply',
status: 'running',
phase: 'verifying',
startedAt: Date.now(),
};
await report({});
await fs.writeFile(
path.join(tmp, 'panel-upgrade.log'),
'Offline bundle verification\n',
{ mode: 0o600 },
);
}
try {
const selected = await artifacts.stageUpgrade({
root,
tmp,
env: environment,
run: (program, args, capture) =>
artifacts.runProcess(
environment,
program,
args,
capture,
controller.signal,
),
signal: controller.signal,
onProgress: report,
install: async () => {
throw new Error(
'Offline bundles never install npm dependencies',
);
},
});
if (state)
await report({
status: 'ready',
phase: 'ready',
...(await jobs.stagedMetadata(selected)),
});
if (values['download-only']) {
console.log('更新已就绪;执行 ql reload system 应用更新。');
return selected;
}
if (values.install) {
// Build beside the destination, then atomically replace only an empty root.
await fs.mkdir(path.dirname(root), { recursive: true });
const publication = await fs.mkdtemp(
path.join(path.dirname(root), '.qinglong-install-'),
);
try {
await fs.chmod(publication, 0o755);
await fs.cp(selected.source, publication, {
recursive: true,
verbatimSymlinks: true,
});
await fs.cp(selected.static, path.join(publication, 'static'), {
recursive: true,
});
await fs.copyFile(
path.join(publication, '.env.example'),
path.join(publication, '.env'),
require('node:fs').constants.COPYFILE_EXCL,
);
await fs.chmod(path.join(publication, '.env'), 0o600);
controller.signal.throwIfAborted();
await fs.rename(publication, root);
} finally {
await fs.rm(publication, { recursive: true, force: true });
}
await fs.rm(path.dirname(selected.source), {
recursive: true,
force: true,
});
await fs.rm(
path.join(
tmp,
`upgrade-ready-${artifacts.releaseBranch(environment)}.json`,
),
{ force: true },
);
console.log(
`离线安装完成,保留现有 Node ${
process.version
}。启动服务:QL_DIR=${JSON.stringify(
root,
)} QL_DATA_DIR=${JSON.stringify(
path.join(root, 'data'),
)} bash ${JSON.stringify(
path.join(root, 'shell/start-simplify.sh'),
)}`,
);
return;
}
const result = await jobs.withPanelLifecycle(
root,
tmp,
environment,
async (hook, prepareSelection) => {
const expected = await jobs.stagedMetadata(selected);
const previous = await jobs
.readRunningBuild(environment)
.catch(() => undefined);
const selection = await prepareSelection?.(selected);
try {
return await artifacts.reloadSystem({
root,
tmp,
env: environment,
data: environment.dir_data || path.join(root, 'data'),
staticRoot: path.join(root, 'static'),
config:
environment.dir_config || path.join(root, 'data/config'),
lifecycle: {
signal: controller.signal,
stop: async (recover) => {
await report({
status: 'restarting',
phase: 'restarting',
});
await hook(
'stop',
[],
recover ? undefined : controller.signal,
);
},
start: async (recover) => {
await hook(
'start',
[],
recover ? undefined : controller.signal,
);
await artifacts.waitForHealth(
environment,
undefined,
recover ? undefined : controller.signal,
);
if (!recover)
await jobs.verifyRunningVersion(
environment,
expected,
previous,
);
},
},
additionalReplacements: selection
? [{ source: selection.source, target: selection.target }]
: [],
});
} finally {
if (selection)
await fs.rm(selection.directory, {
recursive: true,
force: true,
});
}
},
controller.signal,
);
await report({ status: 'completed', phase: 'completed' });
console.log('升级成功,已保留现有 Node 并运行更新后的版本。');
return result;
} catch (error) {
if (state) await report({ status: 'failed', error: error.message });
throw error;
}
},
environment,
);
return values.install
? await operation()
: await artifacts.withUpgradeLock(
path.join(tmp, 'panel-job-launch'),
operation,
environment,
);
} finally {
process.removeListener('SIGINT', abort);
process.removeListener('SIGTERM', abort);
if (freshWorkspace)
await fs.rm(freshWorkspace, { recursive: true, force: true });
}
}
module.exports = { installBundle, runInstallerMain };
if (require.main === module)
runInstallerMain().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
+74
View File
@@ -0,0 +1,74 @@
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
@@ -0,0 +1,62 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { createHash } = require('node:crypto');
const { spawnSync } = require('node:child_process');
// Run in new processes: a healthy parent can outlive a broken native runtime.
function probeNodeRuntime(executable = process.execPath) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-node-url-'));
const source = `
const assert = require('node:assert/strict');
const { pathToFileURL, fileURLToPath } = require('node:url');
for (let i = 0; i < 1000; i++) {
assert.equal(new URL('/任务?q=' + i, 'https://example.com').hostname, 'example.com');
assert.equal(new URL('https://例子.测试').hostname, 'xn--fsqu00a.xn--0zwm56d');
assert.equal(new URL('http://[::1]:5700').port, '5700');
const filename = '/tmp/中文 path/' + i + '.cjs';
assert.equal(fileURLToPath(pathToFileURL(filename)), filename);
}
console.log('QINGLONG_NODE_URL_OK');
`;
const file = path.join(directory, 'probe.cjs');
fs.writeFileSync(file, source);
try {
for (let i = 0; i < 4; i++) {
const args = process.execArgv.includes('--jitless') ? ['--jitless'] : [];
args.push(...(i % 2 ? [file] : ['-e', source]));
const child = spawnSync(executable, args, {
encoding: 'utf8',
// Multi-architecture CI runs this same probe through QEMU.
timeout: 120000,
maxBuffer: 65536,
env: { ...process.env, NODE_OPTIONS: '' },
});
assert.equal(
child.status,
0,
`Node URL runtime probe failed (${i % 2 ? 'file' : 'eval'}): signal=${
child.signal
}; status=${child.status}; ${child.error?.message || child.stderr}`,
);
assert.match(child.stdout, /QINGLONG_NODE_URL_OK/);
}
return {
ok: true,
node: process.version,
architecture: process.arch,
ada: process.versions.ada,
processes: 4,
urlIterationsPerProcess: 1000,
nodeSha256: createHash('sha256')
.update(fs.readFileSync(executable))
.digest('hex'),
};
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
}
module.exports = { probeNodeRuntime };
if (require.main === module) console.log(JSON.stringify(probeNodeRuntime()));
+16 -8
View File
@@ -27,14 +27,22 @@ module.exports = async function reportNotificationAudit(payload) {
credentials, credentials,
{ 'grpc.enable_http_proxy': 0 }, { 'grpc.enable_http_proxy': 0 },
); );
await new Promise((resolve) => { // Python sends all channel results together so they share startup and TLS.
client.RecordNotifyLog( const records = Array.isArray(payload) ? payload : [payload];
payload, const deadline = new Date(Date.now() + 5000);
new grpc.Metadata(), await Promise.all(
{ deadline: new Date(Date.now() + 1500) }, records.map(
() => resolve(), (record) =>
); new Promise((resolve) => {
}); client.RecordNotifyLog(
record,
new grpc.Metadata(),
{ deadline },
() => resolve(),
);
}),
),
);
} catch (_) { } catch (_) {
// Missing certificates, old panels and offline services are best effort. // Missing certificates, old panels and offline services are best effort.
} finally { } finally {
+15 -2
View File
@@ -294,6 +294,18 @@ delete_pm2() {
pkill -f "node.*static/build/app.js" 2>/dev/null || true 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() { reload_pm2() {
cd "$dir_root" || return $? cd "$dir_root" || return $?
restore_env_vars restore_env_vars
@@ -305,6 +317,7 @@ reload_pm2() {
local exit_code=$? local exit_code=$?
t '警告: PM2 启动失败 (退出码: %s),可能是由于硬件不兼容' "$exit_code" t '警告: PM2 启动失败 (退出码: %s),可能是由于硬件不兼容' "$exit_code"
t '正在尝试直接使用 Node.js 启动服务...' t '正在尝试直接使用 Node.js 启动服务...'
load_node_runtime_args || return $?
# Kill any existing node processes for qinglong # Kill any existing node processes for qinglong
pkill -f "node.*static/build/app.js" 2>/dev/null || true pkill -f "node.*static/build/app.js" 2>/dev/null || true
@@ -312,9 +325,9 @@ reload_pm2() {
# Start node directly in the background. In containers, keep application # Start node directly in the background. In containers, keep application
# output on the container streams instead of duplicating Winston logs on disk. # output on the container streams instead of duplicating Winston logs on disk.
if [[ "$QL_CONTAINER" == "true" ]]; then if [[ "$QL_CONTAINER" == "true" ]]; then
nohup node static/build/app.js >/proc/1/fd/1 2>/proc/1/fd/2 & nohup node "${ql_node_runtime_args[@]}" static/build/app.js >/proc/1/fd/1 2>/proc/1/fd/2 &
else else
nohup node static/build/app.js >"$dir_log/qinglong.log" 2>&1 & nohup node "${ql_node_runtime_args[@]}" static/build/app.js >"$dir_log/qinglong.log" 2>&1 &
fi fi
local node_pid=$! local node_pid=$!
+13 -1
View File
@@ -52,7 +52,19 @@ make_dir /etc/nginx/conf.d
make_dir /run/nginx make_dir /run/nginx
fix_config fix_config
pm2 l &>/dev/null # 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'
require(require('node:path').join(process.env.QL_DIR, 'static/build/config/grpcCerts.js'))
.initGrpcCerts()
.catch((error) => {
console.error(error);
process.exitCode = 1;
});
QL_PREPARE_GRPC_CERTS
pm2 l &>/dev/null || true
log_with_style "INFO" "$(t '🔄 2. 启动 nginx...')" log_with_style "INFO" "$(t '🔄 2. 启动 nginx...')"
nginx -s reload 2>/dev/null || nginx -c /etc/nginx/nginx.conf nginx -s reload 2>/dev/null || nginx -c /etc/nginx/nginx.conf
+7 -5
View File
@@ -2,7 +2,10 @@ import { CloudDownloadOutlined } from '@ant-design/icons';
import { Modal, Tooltip } from 'antd'; import { Modal, Tooltip } from 'antd';
import { useState } from 'react'; import { useState } from 'react';
import intl from 'react-intl-universal'; import intl from 'react-intl-universal';
import useUpgrade, { activeStatuses, phaseLabels } from '@/hooks/useUpgrade'; import useUpgrade, {
activeStatuses,
upgradeStatusLabel,
} from '@/hooks/useUpgrade';
import CheckUpdate from '@/pages/setting/checkUpdate'; import CheckUpdate from '@/pages/setting/checkUpdate';
export default function UpgradeNotice({ export default function UpgradeNotice({
@@ -21,16 +24,15 @@ export default function UpgradeNotice({
if (!showNotice && !open) return null; if (!showNotice && !open) return null;
const text = !connected const text = !connected
? intl.get('连接已断开,正在重新连接') ? intl.get('连接已断开,正在重新连接')
: job.status === 'failed' : intl.get(upgradeStatusLabel(job));
? intl.get('更新失败')
: intl.get(phaseLabels[job.phase || ''] || '检查更新');
const label = const label =
activeStatuses.includes(job.status) &&
job.phase === 'downloading' && job.percent != null job.phase === 'downloading' && job.percent != null
? `${text} ${job.percent}%` ? `${text} ${job.percent}%`
: text; : text;
return ( return (
<> <>
{showNotice && ( {showNotice && !open && (
<Tooltip title={label} placement="right"> <Tooltip title={label} placement="right">
<button <button
type="button" type="button"
+15
View File
@@ -25,11 +25,26 @@ export const phaseLabels: Record<string, string> = {
extracting: '正在解压更新包', extracting: '正在解压更新包',
verifying: '正在校验更新包', verifying: '正在校验更新包',
dependencies: '正在准备依赖', dependencies: '正在准备依赖',
prestarting: '正在预启动验证',
repairing: '正在修复运行依赖',
ready: '更新已就绪', ready: '更新已就绪',
restarting: '正在重启并更新', restarting: '正在重启并更新',
completed: '更新完成', completed: '更新完成',
}; };
// Status is authoritative; old progress fields must not claim completion.
export function upgradeStatusLabel(job: UpgradeJob): string {
if (job.status === 'ready') return '下载完成,等待重启';
if (job.status === 'completed') return '升级成功';
if (job.status === 'failed') return '更新失败';
if (activeStatuses.includes(job.status)) {
return !['ready', 'completed'].includes(job.phase || '')
? phaseLabels[job.phase || ''] || '正在准备更新'
: '正在准备更新';
}
return '检查更新';
}
// The sidebar and settings share one poller and one mutation state. // The sidebar and settings share one poller and one mutation state.
export function createUpgradeStore() { export function createUpgradeStore() {
let snapshot = { let snapshot = {
+5
View File
@@ -346,6 +346,11 @@ select:-webkit-autofill:focus {
.ant-pro-global-header { .ant-pro-global-header {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.03); box-shadow: 0 4px 8px rgba(0, 0, 0, 0.03);
.ant-pro-global-header-collapsed-button {
min-width: 0;
max-width: 100%;
}
} }
.ant-menu-item.ant-pro-sider-collapsed-button { .ant-menu-item.ant-pro-sider-collapsed-button {
+3
View File
@@ -734,6 +734,8 @@
"从开始至load总耗时": "Total load time: {duration} ms", "从开始至load总耗时": "Total load time: {duration} ms",
"正在解压更新包": "Extracting update", "正在解压更新包": "Extracting update",
"正在校验更新包": "Verifying update", "正在校验更新包": "Verifying update",
"正在预启动验证": "Verifying backend startup",
"正在修复运行依赖": "Repairing runtime dependencies",
"正在准备依赖": "Preparing dependencies", "正在准备依赖": "Preparing dependencies",
"更新已就绪": "Update ready", "更新已就绪": "Update ready",
"正在重启并更新": "Restarting and updating", "正在重启并更新": "Restarting and updating",
@@ -744,6 +746,7 @@
"更新已就绪,重启后应用新版本": "Update ready. Restart to apply the new version.", "更新已就绪,重启后应用新版本": "Update ready. Restart to apply the new version.",
"更新在后台进行,可以继续使用面板": "The update runs in the background. You can keep using the panel.", "更新在后台进行,可以继续使用面板": "The update runs in the background. You can keep using the panel.",
"下载构建产物": "Downloading application files", "下载构建产物": "Downloading application files",
"下载更新包": "Downloading update package",
"下载源码": "Downloading source files", "下载源码": "Downloading source files",
"正在获取下载大小": "Total size unavailable", "正在获取下载大小": "Total size unavailable",
"查看更新日志": "View update log", "查看更新日志": "View update log",
+3
View File
@@ -734,6 +734,8 @@
"从开始至load总耗时": "从开始至 load 总耗时: {duration} ms", "从开始至load总耗时": "从开始至 load 总耗时: {duration} ms",
"正在解压更新包": "正在解压更新包", "正在解压更新包": "正在解压更新包",
"正在校验更新包": "正在校验更新包", "正在校验更新包": "正在校验更新包",
"正在预启动验证": "正在预启动验证",
"正在修复运行依赖": "正在修复运行依赖",
"正在准备依赖": "正在准备依赖", "正在准备依赖": "正在准备依赖",
"更新已就绪": "更新已就绪", "更新已就绪": "更新已就绪",
"正在重启并更新": "正在重启并更新", "正在重启并更新": "正在重启并更新",
@@ -744,6 +746,7 @@
"更新已就绪,重启后应用新版本": "更新已就绪,重启后应用新版本", "更新已就绪,重启后应用新版本": "更新已就绪,重启后应用新版本",
"更新在后台进行,可以继续使用面板": "更新在后台进行,可以继续使用面板", "更新在后台进行,可以继续使用面板": "更新在后台进行,可以继续使用面板",
"下载构建产物": "下载构建产物", "下载构建产物": "下载构建产物",
"下载更新包": "下载更新包",
"下载源码": "下载源码", "下载源码": "下载源码",
"正在获取下载大小": "正在获取下载大小", "正在获取下载大小": "正在获取下载大小",
"查看更新日志": "查看更新日志", "查看更新日志": "查看更新日志",
+5 -10
View File
@@ -5,7 +5,7 @@ import { Button, Modal, Progress, Space, Typography, message } from 'antd';
import { useState } from 'react'; import { useState } from 'react';
import useUpgrade, { import useUpgrade, {
activeStatuses, activeStatuses,
phaseLabels, upgradeStatusLabel,
reloadKey, reloadKey,
upgradeStore, upgradeStore,
} from '@/hooks/useUpgrade'; } from '@/hooks/useUpgrade';
@@ -100,14 +100,7 @@ const CheckUpdate = ({ systemInfo }: any) => {
onOk: () => reloadSystem(true), onOk: () => reloadSystem(true),
}); });
const label = const label = intl.get(upgradeStatusLabel(job));
job.status === 'failed'
? intl.get('更新失败')
: ready
? intl.get('下载完成,等待重启')
: completed
? intl.get('升级成功')
: intl.get(phaseLabels[job.phase || ''] || '正在准备更新');
const hasJob = job.status !== 'idle' && job.status !== 'loading'; const hasJob = job.status !== 'idle' && job.status !== 'loading';
const actions = !active && ( const actions = !active && (
<Space wrap> <Space wrap>
@@ -167,7 +160,9 @@ const CheckUpdate = ({ systemInfo }: any) => {
<div className="upgrade-progress-meta"> <div className="upgrade-progress-meta">
<Typography.Text type="secondary"> <Typography.Text type="secondary">
{intl.get( {intl.get(
job.artifact === 'qinglong-static' job.artifact === 'qinglong-bundle'
? '下载更新包'
: job.artifact === 'qinglong-static'
? '下载构建产物' ? '下载构建产物'
: '下载源码', : '下载源码',
)} )}
+24 -22
View File
@@ -1,27 +1,29 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect } from 'react';
import browserType from './index';
export const useCtx = () => { export const useCtx = () => {
const [width, setWidth] = useState('100%'); // Match ProLayout's xs/sm breakpoint, including narrow desktop windows.
const [marginLeft, setMarginLeft] = useState(0); const [isPhone, setIsPhone] = useState(
const [marginTop, setMarginTop] = useState(-48); () =>
const [isPhone, setIsPhone] = useState(false); typeof window !== 'undefined' &&
const { platform } = useMemo(() => browserType(), []); window.matchMedia('(max-width: 767.99px)').matches,
);
useEffect(() => { useEffect(() => {
if (platform === 'mobile' && document.body.offsetWidth < 768) { const media = window.matchMedia('(max-width: 767.99px)');
setWidth('auto'); const update = () => {
setMarginLeft(0); setIsPhone(media.matches);
setMarginTop(0); document.body.setAttribute(
setIsPhone(true); 'data-mode',
document.body.setAttribute('data-mode', 'phone'); media.matches ? 'phone' : 'desktop',
} else { );
setWidth('100%'); };
setMarginLeft(0); update();
setMarginTop(-48); if (typeof media.addEventListener === 'function') {
setIsPhone(false); media.addEventListener('change', update);
document.body.setAttribute('data-mode', 'desktop'); return () => media.removeEventListener('change', update);
} }
media.addListener(update);
return () => media.removeListener(update);
}, []); }, []);
return { return {
@@ -31,9 +33,9 @@ export const useCtx = () => {
top: 0, top: 0,
left: 0, left: 0,
zIndex: 20, zIndex: 20,
marginTop, marginTop: isPhone ? 0 : -48,
width, width: isPhone ? 'auto' : '100%',
marginLeft, marginLeft: 0,
} as any, } as any,
isPhone, isPhone,
}; };
+101
View File
@@ -0,0 +1,101 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const { EventEmitter } = require('node:events');
const ts = require('typescript');
const source = ts.createSourceFile(
'app.ts',
fs.readFileSync('back/app.ts', 'utf8'),
ts.ScriptTarget.Latest,
true,
);
const application = source.statements.find(
(node) => ts.isClassDeclaration(node) && node.name.text === 'Application',
);
const method = application.members.find(
(node) => node.name?.getText(source) === 'startMasterProcess',
);
const compiled = ts.transpileModule(
`class Application { ${method.getText(
source,
)} }\nmodule.exports = Application;`,
{
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
},
},
).outputText;
for (const [arch, armVersion, timeout] of [
['arm', 6, 120000],
['arm', 7, 30000],
['arm64', undefined, 30000],
]) {
test(`${arch} v${
armVersion ?? ''
} waits for scheduler readiness before HTTP and job recovery`, async () => {
const cluster = new EventEmitter();
const module = { exports: {} };
const logger = { info() {}, warn() {}, error() {} };
new Function(
'module',
'cluster',
'Logger',
'errStack',
'process',
compiled,
)(module, cluster, logger, String, {
arch,
config: { variables: { arm_version: armVersion } },
exit() {
assert.fail('unexpected application exit');
},
});
const app = new module.exports();
app.workerMetadataMap = new Map();
const forks = [],
waits = [],
notifications = [];
let ready;
app.forkWorker = (serviceType) => {
const worker = {
id: forks.length + 1,
process: { pid: 100 + forks.length },
send: (message) => notifications.push(message),
};
forks.push({ serviceType, worker });
app.workerMetadataMap.set(worker.id, { serviceType });
return worker;
};
app.waitForWorkerReady = (worker, milliseconds) => {
waits.push({ worker, milliseconds });
return new Promise((resolve) => {
ready = resolve;
});
};
app.setupMasterShutdown = () => {};
app.startMasterProcess();
assert.deepEqual(
forks.map((fork) => fork.serviceType),
['grpc'],
);
assert.equal(waits[0].milliseconds, timeout);
ready();
await Promise.resolve();
assert.deepEqual(
forks.map((fork) => fork.serviceType),
['grpc', 'http'],
);
cluster.emit('exit', forks[0].worker, 1);
assert.equal(waits[1].milliseconds, timeout);
assert.deepEqual(notifications, ['scheduler-unavailable']);
ready();
await Promise.resolve();
assert.deepEqual(notifications, [
'scheduler-unavailable',
'reregister-crons',
]);
});
}
+124
View File
@@ -43,3 +43,127 @@ test('cron validation identifies invalid fields', () => {
['allow_multiple_instances'], ['allow_multiple_instances'],
); );
}); });
test('extra schedules preserve optional values, compatible syntax and metadata', () => {
for (const extra_schedules of [
undefined,
null,
[],
[{ schedule: '0 6/6 * * *', remarks: 'legacy metadata' }],
[{ schedule: '0 0 * JAN MON#2' }, { schedule: '@hourly' }],
]) {
const result = schema.validate({
command: 'task valid.js',
schedule: '01 7 * * *',
extra_schedules,
});
assert.equal(result.error, undefined, JSON.stringify(extra_schedules));
assert.deepEqual(result.value.extra_schedules, extra_schedules);
}
for (const schedule of ['@boot', '@once 1792245000']) {
assert.equal(
schema.validate({ command: 'task valid.js', schedule }).error,
undefined,
);
}
});
test('create and update reject invalid extra schedules before calling the service', async (t) => {
const express = require('express');
const { errors } = require('celebrate');
const load = require('../helpers/load-security-module.cjs');
const mutations = [];
const service = {
async create(value) {
mutations.push(value);
return { id: 1, ...value };
},
async update(value) {
mutations.push(value);
return value;
},
};
const route = load('back/api/cron.ts', {
typedi: {
Container: {
get: (key) => (key === 'logger' ? { error() {} } : service),
},
},
'../services/cron': class {},
'../services/cronView': class {},
'../validation/schedule': { commonCronSchema },
'../data/runningInstance': {},
'../shared/i18n': { t: (value) => value },
'../schedule/client': { readiness: { async ensureReady() {} } },
});
const app = express();
app.use(express.json());
route.default(app);
app.use(errors());
app.use((error, _req, res, _next) =>
res.status(error.status || 500).json({ message: error.message }),
);
const server = app.listen(0, '127.0.0.1');
await new Promise((resolve, reject) => {
server.once('listening', resolve);
server.once('error', reject);
});
t.after(() => new Promise((resolve) => server.close(resolve)));
for (const method of ['POST', 'PUT']) {
const payload = {
command: 'task valid.js',
schedule: '01 7 * * *',
...(method === 'PUT' ? { id: 1 } : {}),
};
const request = (body) =>
fetch(`http://127.0.0.1:${server.address().port}/crons`, {
method,
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
for (const extra_schedules of [
[{ schedule: '60 * * * *' }],
[{ schedule: '0 0 31 2,4 *' }],
[{ schedule: '' }],
[{ schedule: 12 }],
[{ schedule: '@boot' }],
[{ schedule: '@once 1792245000' }],
[{}],
[null],
['0 * * * *'],
[{ schedule: '0 * * * *' }, { schedule: 'invalid' }],
]) {
const before = mutations.length;
const response = await request({ ...payload, extra_schedules });
assert.equal(
response.status,
400,
`${method}: ${JSON.stringify(extra_schedules)}`,
);
const body = await response.json();
assert.ok(
body.validation.body.keys.some((key) =>
key.startsWith('extra_schedules.'),
),
);
assert.equal(
mutations.length,
before,
'invalid input must not mutate tasks',
);
}
assert.equal(
(await request({ ...payload, schedule: '60 * * * *' })).status,
400,
);
const extra_schedules = [{ schedule: '0 6/6 * * *', remarks: 'keep' }];
const response = await request({ ...payload, extra_schedules });
assert.equal(response.status, 200);
assert.deepEqual(
(await response.json()).data.extra_schedules,
extra_schedules,
);
}
assert.equal(mutations.length, 2);
});
+96 -2
View File
@@ -22,7 +22,7 @@ const {
} = require('../../back/validation/schedule'); } = require('../../back/validation/schedule');
const upgrade = require('../../cli/src/internal/maintenance/upgradeArtifacts.cjs'); const upgrade = require('../../cli/src/internal/maintenance/upgradeArtifacts.cjs');
function responseFor(schema, value) { function responseFor(schema, value, fullResponse = false) {
const validation = schema.validate(value).error; const validation = schema.validate(value).error;
assert.ok(validation); assert.ok(validation);
const error = new CelebrateError('Validation failed', { celebrated: true }); const error = new CelebrateError('Validation failed', { celebrated: true });
@@ -41,7 +41,7 @@ function responseFor(schema, value) {
validationErrors(error, {}, res, (err) => { validationErrors(error, {}, res, (err) => {
throw err; throw err;
}); });
return response.validation.body.message; return fullResponse ? response : response.validation.body.message;
} }
test('compiled validation schemas follow live language changes without altering field paths', () => { test('compiled validation schemas follow live language changes without altering field paths', () => {
@@ -75,6 +75,100 @@ test('compiled validation schemas follow live language changes without altering
setLang('zh'); setLang('zh');
}); });
test('cron structure errors switch languages while retaining status and field paths', (t) => {
const schema = Joi.object(commonCronSchema);
const empty = ['定时规则不能为空', 'Schedule rule cannot be empty'];
const string = ['定时规则必须是字符串', 'Schedule rule must be a string'];
const object = ['额外定时规则必须是对象', 'Extra schedule must be an object'];
const array = ['额外定时规则必须是数组', 'Extra schedules must be an array'];
const invalid = ['无效的定时规则', 'Invalid schedule rule'];
const cases = [
[{ schedule: undefined }, 'schedule', empty],
[{ schedule: '' }, 'schedule', empty],
[{ schedule: 12 }, 'schedule', string],
[{ schedule: null }, 'schedule', string],
[{ extra_schedules: [{}] }, 'extra_schedules.0.schedule', empty],
[
{ extra_schedules: [{ schedule: '' }] },
'extra_schedules.0.schedule',
empty,
],
[
{ extra_schedules: [{ schedule: 12 }] },
'extra_schedules.0.schedule',
string,
],
[
{ extra_schedules: [{ schedule: null }] },
'extra_schedules.0.schedule',
string,
],
[
{ extra_schedules: [{ schedule: 'invalid' }] },
'extra_schedules.0.schedule',
invalid,
],
[
{ extra_schedules: [{ schedule: '0 * * * *' }, {}] },
'extra_schedules.1.schedule',
empty,
],
...[null, '0 * * * *', 12, []].map((item) => [
{ extra_schedules: [item] },
'extra_schedules.0',
object,
]),
...['0 * * * *', {}, 12].map((value) => [
{ extra_schedules: value },
'extra_schedules',
array,
]),
];
t.after(() => setLang('zh', false));
for (const lang of ['en', 'zh', 'en']) {
setLang(lang, false);
for (const [value, field, messages] of cases) {
const response = responseFor(
schema,
{
command: 'task valid.js',
schedule: '0 * * * *',
...value,
},
true,
);
assert.equal(response.statusCode, 400);
assert.equal(
response.validation.body.message,
messages[lang === 'zh' ? 0 : 1],
`${lang}: ${field}`,
);
assert.deepEqual(response.validation.body.keys, [field]);
}
const multiple = responseFor(
schema.prefs({ abortEarly: false }),
{
command: 'task valid.js',
schedule: '0 * * * *',
extra_schedules: [{}, { schedule: 12 }, null],
},
true,
);
assert.equal(multiple.statusCode, 400);
assert.equal(
multiple.validation.body.message,
[empty, string, object]
.map((messages) => messages[lang === 'zh' ? 0 : 1])
.join('. '),
);
assert.deepEqual(multiple.validation.body.keys, [
'extra_schedules.0.schedule',
'extra_schedules.1.schedule',
'extra_schedules.2',
]);
}
});
test('history, upload, initialization and proxy messages resolve dynamically and preserve user text', () => { test('history, upload, initialization and proxy messages resolve dynamically and preserve user text', () => {
const keys = [ const keys = [
'请先初始化', '请先初始化',
+157
View File
@@ -0,0 +1,157 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const { execFile } = require('node:child_process');
const { promisify } = require('node:util');
const execute = promisify(execFile);
const {
manualUpgradeFixture,
} = require('../helpers/manual-upgrade-fixture.cjs');
const jobs = require('../../cli/src/internal/maintenance/panelUpgrade.cjs');
for (const previousStatus of ['ready', 'failed', 'completed', 'idle', 'absent'])
for (const wrongVersion of [false, true])
test(`manual application records the selected package after ${previousStatus}; wrong version=${wrongVersion}`, async (t) => {
const f = await manualUpgradeFixture(t, wrongVersion);
await fs.writeFile(
path.join(f.root, 'package.json'),
'{"qinglongUpgradeFormat":2}',
);
const file = path.join(f.tmp, 'panel-upgrade.json');
if (previousStatus === 'absent') await fs.unlink(file);
else
await fs.writeFile(
file,
JSON.stringify({
...(await f.state()),
status: previousStatus,
error: 'previous preparation failed',
version: '9.9.9',
sourceCommit: 'f'.repeat(40),
backendSha256: 'f'.repeat(64),
stageDirectory: 'upgrade-other',
}),
);
const operation = execute(
process.execPath,
[path.resolve('shell/upgrade.cjs'), 'reload'],
{ env: f.env, timeout: 15000 },
);
if (wrongVersion)
await assert.rejects(operation, /启动版本与更新包不一致/);
else await operation;
const state = await f.state();
assert.equal(state.status, wrongVersion ? 'failed' : 'completed');
assert.equal(state.sourceCommit, f.expected.sourceCommit);
assert.equal(state.stageDirectory, f.expected.stageDirectory);
assert.equal(state.version, f.expected.version);
assert.notEqual(state.id, 'offline-prepared');
assert.equal(state.action, 'apply');
assert.notEqual(state.error, 'previous preparation failed');
if (!wrongVersion) assert.equal(state.error, undefined);
assert.equal(
await fs.readFile(path.join(f.root, 'static/build/app.js'), 'utf8'),
wrongVersion ? 'old backend' : 'new backend',
);
assert.equal(
await fs.readFile(path.join(f.root, 'data/keep'), 'utf8'),
'preserve data',
);
});
test('manual application rejects a selection that no longer matches the ready pointer', async (t) => {
const f = await manualUpgradeFixture(t);
let called = false;
await assert.rejects(
jobs.withPreparedJob(
f.tmp,
f.env,
{ ...f.selected, source: f.selected.source + '-stale' },
async () => {
called = true;
},
),
/暂存更新已变化/,
);
assert.equal(called, false);
assert.equal((await f.state()).status, 'ready');
});
test('legacy manual reload without a job or bundle capability stays self-contained', async (t) => {
const f = await manualUpgradeFixture(t);
await fs.unlink(path.join(f.tmp, 'panel-upgrade.json'));
await fs.unlink(path.join(f.tmp, 'upgrade-ready-develop.json'));
const result = await jobs.withPreparedJob(
f.tmp,
f.env,
f.selected,
async (job) => {
assert.equal(job, undefined);
return 'legacy';
},
);
assert.equal(result, 'legacy');
await assert.rejects(fs.access(path.join(f.tmp, 'panel-upgrade.json')), {
code: 'ENOENT',
});
});
for (const wrongVersion of [false, true]) {
test(`Bash reload consumes offline readiness and records the actual result; wrong version=${wrongVersion}`, async (t) => {
const f = await manualUpgradeFixture(t, wrongVersion);
const operation = execute(
process.execPath,
[path.resolve('shell/upgrade.cjs'), 'reload'],
{ env: f.env, timeout: 15000 },
);
if (wrongVersion) await assert.rejects(operation, /启动版本与更新包不一致/);
else await operation;
const state = await f.state();
assert.equal(state.status, wrongVersion ? 'failed' : 'completed');
assert.equal(state.action, 'apply');
assert.notEqual(state.id, 'offline-prepared');
assert.equal(
await fs.readFile(path.join(f.root, 'static/build/app.js'), 'utf8'),
wrongVersion ? 'old backend' : 'new backend',
);
assert.equal(
await fs.readFile(path.join(f.root, '.env'), 'utf8'),
'preserve configuration',
);
assert.equal(
await fs.readFile(path.join(f.root, 'data/keep'), 'utf8'),
'preserve data',
);
if (wrongVersion)
assert.equal(
await fs.readFile(path.join(f.root, 'node_modules/old'), 'utf8'),
'old dependency',
);
else
await assert.rejects(
fs.access(path.join(f.tmp, 'upgrade-ready-develop.json')),
);
});
}
test('manual application holds the launch lock and does not allow a competing panel job', async (t) => {
const f = await manualUpgradeFixture(t);
await assert.rejects(
jobs.withPreparedJob(f.tmp, f.env, f.selected, async (job) => {
assert.equal((await f.state()).status, 'running');
await assert.rejects(
jobs.startJob({ root: f.root, tmp: f.tmp, env: f.env }, 'apply'),
/已有更新/,
);
await job.onStop();
assert.equal((await f.state()).status, 'restarting');
throw new Error('cancel manual operation');
}),
/cancel manual operation/,
);
assert.equal((await f.state()).status, 'failed');
await assert.rejects(
fs.access(path.join(f.tmp, 'panel-job-launch/upgrade.lock')),
);
});
+24
View File
@@ -50,6 +50,30 @@ test('mirror verification reads matching refs without changing either repository
assert.equal(git('-C', source, 'status', '--porcelain'), ''); 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) => { test('mirror polling follows a source that advances while waiting', async (t) => {
const { source, mirror } = fixture(t); const { source, mirror } = fixture(t);
git('-C', source, 'push', mirror, 'develop'); git('-C', source, 'push', mirror, 'develop');
+47
View File
@@ -0,0 +1,47 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { probeNodeRuntime } = require('../../shell/node-runtime-probe.cjs');
test('the retained Node handles URL, IDNA and file paths in fresh eval and file processes', () => {
const result = probeNodeRuntime();
assert.equal(result.ok, true);
assert.equal(result.processes, 4);
assert.match(result.nodeSha256, /^[0-9a-f]{64}$/);
});
test('a native child crash fails the runtime probe instead of accepting its healthy parent', (t) => {
if (process.platform === 'win32') return t.skip('POSIX crash fixture');
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-node-crash-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const executable = path.join(directory, 'node');
fs.writeFileSync(executable, '#!/bin/sh\nkill -SEGV $$\n', { mode: 0o700 });
assert.throws(
() => probeNodeRuntime(executable),
/Node URL runtime probe failed.*SIGSEGV/,
);
});
test('emulated Node probes have a bounded two-minute budget and reject a timeout', (t) => {
const childProcess = require('node:child_process');
const modulePath = require.resolve('../../shell/node-runtime-probe.cjs');
let directory;
const makeDirectory = fs.mkdtempSync;
t.mock.method(fs, 'mkdtempSync', (...args) => {
directory = makeDirectory(...args);
return directory;
});
t.mock.method(childProcess, 'spawnSync', (_program, args, options) => {
assert.equal(options.timeout, 120000);
assert.equal(options.env.NODE_OPTIONS, '');
return { status: null, signal: 'SIGTERM', error: Object.assign(new Error('probe timed out'), { code: 'ETIMEDOUT' }) };
});
delete require.cache[modulePath];
t.after(() => { delete require.cache[modulePath]; });
const probe = require(modulePath).probeNodeRuntime;
assert.throws(() => probe(), /Node URL runtime probe failed.*SIGTERM.*probe timed out/);
assert.ok(directory);
assert.equal(fs.existsSync(directory), false, 'Timed-out probes must remove their temporary source files');
});
+72
View File
@@ -0,0 +1,72 @@
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
@@ -0,0 +1,256 @@
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' });
});
}
+15 -2
View File
@@ -36,14 +36,23 @@ for (const [scenario, expectedStatus, published] of [
fs.mkdirSync(bin); fs.mkdirSync(bin);
fs.writeFileSync( fs.writeFileSync(
path.join(root, 'package.json'), path.join(root, 'package.json'),
JSON.stringify({ name: '@fixture/qinglong', version: '2.22.0' }), JSON.stringify({ name: '@fixture/unrelated-source', version: '0.0.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( fs.writeFileSync(
path.join(bin, 'npm'), path.join(bin, 'npm'),
`#!/usr/bin/env node `#!/usr/bin/env node
const fs = require('node:fs'); const fs = require('node:fs');
if (process.argv[2] === 'publish') { fs.writeFileSync('published', 'yes'); process.exit(0); } if (process.argv[2] === 'publish') { fs.writeFileSync('published', JSON.stringify(process.argv.slice(3))); process.exit(0); }
if (process.argv[2] !== 'view') process.exit(99); 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) { switch (process.env.QL_PUBLISH_SCENARIO) {
case 'existing': console.log(JSON.stringify('2.22.0')); break; case 'existing': console.log(JSON.stringify('2.22.0')); break;
case 'unexpected-version': console.log(JSON.stringify('2.21.0')); break; case 'unexpected-version': console.log(JSON.stringify('2.21.0')); break;
@@ -66,5 +75,9 @@ case 'network': console.log('upstream unavailable'); process.exit(1);
}); });
assert.equal(result.status, expectedStatus, result.stdout + result.stderr); assert.equal(result.status, expectedStatus, result.stdout + result.stderr);
assert.equal(fs.existsSync(path.join(root, 'published')), published); 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',
]);
}); });
} }
@@ -0,0 +1,91 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const {
manualUpgradeFixture,
} = require('../helpers/manual-upgrade-fixture.cjs');
const artifacts = require('../../cli/src/internal/maintenance/upgradeArtifacts.cjs');
const { installBundle } = require('../../shell/install-bundle.cjs');
for (const scenario of ['cancel-new-start', 'both-starts-fail'])
test(`offline application restores files and records failure: ${scenario}`, async (t) => {
const f = await manualUpgradeFixture(t);
t.mock.method(artifacts, 'stageUpgrade', async () => f.selected);
const journal = path.join(f.root, 'lifecycle.jsonl');
await fs.writeFile(
path.join(f.root, 'lifecycle.cjs'),
`
const fs=require('node:fs'),path=require('node:path');
const root=process.env.QL_DIR,op=process.argv[2];
const version=fs.readFileSync(path.join(root,'static/build/app.js'),'utf8');
fs.appendFileSync(${JSON.stringify(
journal,
)},JSON.stringify({op,version})+'\\n');
if(op==='start') {
if(${JSON.stringify(scenario)}==='both-starts-fail') process.exit(9);
if(version==='new backend') {
process.kill(Number(process.env.TEST_INSTALLER_PID),'SIGTERM');
setTimeout(()=>{},10000);
}
}
`,
);
await fs.writeFile(
path.join(f.root, 'shell/share.sh'),
`
import_config(){ :; }
delete_pm2(){ "$TEST_NODE" "$QL_DIR/lifecycle.cjs" stop; }
reload_pm2(){ "$TEST_NODE" "$QL_DIR/lifecycle.cjs" start; }
`,
);
await assert.rejects(
installBundle(
['--root', f.root, '--file', path.join(f.root, 'unused.tar.gz')],
{
...f.env,
TEST_NODE: process.execPath,
TEST_INSTALLER_PID: String(process.pid),
},
),
scenario === 'cancel-new-start' ? /离线升级已中断/ : /failed/,
);
const calls = (await fs.readFile(journal, 'utf8'))
.trim()
.split('\n')
.map(JSON.parse);
assert.deepEqual(calls, [
{ op: 'stop', version: 'old backend' },
{ op: 'start', version: 'new backend' },
{ op: 'stop', version: 'new backend' },
{ op: 'start', version: 'old backend' },
]);
assert.equal((await f.state()).status, 'failed');
assert.equal(
await fs.readFile(path.join(f.root, 'static/build/app.js'), 'utf8'),
'old backend',
);
assert.equal(
await fs.readFile(path.join(f.root, 'node_modules/old'), 'utf8'),
'old dependency',
);
assert.equal(
await fs.readFile(path.join(f.root, '.env'), 'utf8'),
'preserve configuration',
);
assert.equal(
await fs.readFile(path.join(f.root, 'data/keep'), 'utf8'),
'preserve data',
);
await fs.access(path.join(f.tmp, 'upgrade-ready-develop.json'));
await assert.rejects(fs.access(path.join(f.tmp, 'upgrade.lock')), {
code: 'ENOENT',
});
await assert.rejects(
fs.access(path.join(f.tmp, 'panel-job-launch/upgrade.lock')),
{ code: 'ENOENT' },
);
assert.ok(
!(await fs.readdir(f.root)).some((n) => n.includes('.ql-backup-')),
);
});
+22 -3
View File
@@ -186,7 +186,7 @@ test('detached download survives its initiating request and is idempotent; apply
); );
}); });
test('invalid staged payload is rejected before spawning a restart', async (t) => { test('invalid staged payload fails asynchronously before stopping the panel', async (t) => {
const f = await fixture(t); const f = await fixture(t);
await startJob(f, 'download'); await startJob(f, 'download');
assert.equal((await finished(f.tmp)).status, 'ready'); assert.equal((await finished(f.tmp)).status, 'ready');
@@ -197,9 +197,16 @@ test('invalid staged payload is rejected before spawning a restart', async (t) =
path.join(f.tmp, pointer.directory, 'qinglong-static-master/build/app.js'), path.join(f.tmp, pointer.directory, 'qinglong-static-master/build/app.js'),
'corrupted', 'corrupted',
); );
await assert.rejects(startJob(f, 'apply'), /checksum|损坏/); const queued = await startJob(f, 'apply');
assert.equal(queued.status, 'starting');
const failed = await finished(f.tmp);
assert.equal(failed.status, 'failed');
assert.match(failed.error, /checksum|损坏/);
assert.equal(syncFs.existsSync(path.join(f.root, 'stops')), false); assert.equal(syncFs.existsSync(path.join(f.root, 'stops')), false);
assert.equal((await getStatus(f.tmp)).status, 'failed'); assert.equal(
await fs.readFile(path.join(f.root, 'version.yaml'), 'utf8'),
'version: 2.23.0',
);
}); });
test('failed download persists its failure without stopping the panel and can be retried', async (t) => { test('failed download persists its failure without stopping the panel and can be retried', async (t) => {
@@ -467,3 +474,15 @@ for (const knownSize of [true, false])
assert.equal(reports.at(-1).downloadedBytes, 100); assert.equal(reports.at(-1).downloadedBytes, 100);
assert.equal(reports.at(-1).percent, knownSize ? 100 : null); assert.equal(reports.at(-1).percent, knownSize ? 100 : null);
}); });
test('asynchronous apply still rejects unsafe stage pointers before dispatch', async (t) => {
const f = await fixture(t);
await startJob(f, 'download');
assert.equal((await finished(f.tmp)).status, 'ready');
await fs.writeFile(
path.join(f.tmp, 'upgrade-ready-master.json'),
JSON.stringify({ directory: '../outside' }),
);
await assert.rejects(startJob(f, 'apply'), /指针|pointer/);
assert.equal(syncFs.existsSync(path.join(f.root, 'stops')), false);
});
+878
View File
@@ -0,0 +1,878 @@
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 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);
const pointer = path.join(f.tmp, 'upgrade-ready-develop.json');
await fs.writeFile(pointer, 'previous-ready');
await fs.mkdir(path.join(f.root, 'node_modules'));
await fs.writeFile(
path.join(f.root, 'node_modules/keep'),
'old dependency',
);
let repairs = 0;
await assert.rejects(
bundle.stageBundle({
root: f.root,
tmp: f.tmp,
env: {
QL_BRANCH: 'develop',
QL_UPGRADE_FILE: f.archive,
QL_UPGRADE_REPAIR_DEPS: '1',
},
verifyStatic: artifacts.verifyUpgrade,
probe: async () => {},
prestart: async () => {
if (failure === 'prestart') throw new Error('SQLITE_CANTOPEN');
throw Object.assign(new Error("Cannot find module 'decimal.js'"), {
code: 'MODULE_NOT_FOUND',
});
},
repair: async (source) => {
repairs++;
await fs.writeFile(
path.join(source, 'node_modules/partial-repair'),
'partial',
);
throw new Error('registry unavailable');
},
}),
failure === 'prestart' ? /SQLITE_CANTOPEN/ : /registry unavailable/,
);
assert.equal(repairs, failure === 'prestart' ? 0 : 1);
assert.equal(await fs.readFile(pointer, 'utf8'), 'previous-ready');
assert.equal(
await fs.readFile(path.join(f.root, 'node_modules/keep'), 'utf8'),
'old dependency',
);
assert.deepEqual(await fs.readdir(f.tmp), ['upgrade-ready-develop.json']);
});
test('ready is published only after prestart; dependency failure repairs once and keeps application hashes', async (t) => {
const f = await fixture(t);
const pointer = path.join(f.tmp, 'upgrade-ready-develop.json');
await fs.writeFile(pointer, 'previous-ready');
let probes = 0,
repairs = 0,
starts = 0;
const options = {
root: f.root,
tmp: f.tmp,
env: {
QL_BRANCH: 'develop',
QL_UPGRADE_FILE: f.archive,
QL_UPGRADE_REPAIR_DEPS: '1',
},
verifyStatic: artifacts.verifyUpgrade,
probe: async () => {
if (++probes === 1)
throw Object.assign(
new Error(
'dlopen node_modules/sqlite3/node.node: invalid ELF header',
),
{ code: 'ERR_DLOPEN_FAILED' },
);
},
repair: async (source) => {
repairs++;
await fs.writeFile(
path.join(source, 'node_modules/sqlite3/repaired'),
'rebuilt for this device',
);
},
prestart: async () => {
starts++;
assert.equal(await fs.readFile(pointer, 'utf8'), 'previous-ready');
},
};
const selected = await bundle.stageBundle(options);
assert.equal(repairs, 1);
assert.equal(probes, 2);
assert.equal(starts, 1);
const receipt = JSON.parse(
await fs.readFile(
path.join(path.dirname(selected.source), 'prestart.json'),
),
);
assert.ok(receipt.completedAt);
assert.ok(
receipt.dependencyFiles[
'source/node_modules/.pnpm/sqlite/node_modules/sqlite3/repaired'
],
);
await fs.writeFile(
path.join(selected.source, 'version.yaml'),
'version: 9.9.9',
);
await assert.rejects(bundle.verifyStagedBundle(selected), /checksums/);
});
test('offline dependency failure never repairs by default; a failed retry keeps old readiness', async (t) => {
const f = await fixture(t),
pointer = path.join(f.tmp, 'upgrade-ready-develop.json');
await fs.writeFile(pointer, 'previous-ready');
let repairs = 0,
starts = 0;
const options = {
root: f.root,
tmp: f.tmp,
env: { QL_BRANCH: 'develop', QL_UPGRADE_FILE: f.archive },
verifyStatic: artifacts.verifyUpgrade,
probe: async () => {},
prestart: async () => {
starts++;
throw Object.assign(new Error("Cannot find module 'decimal.js'"), {
code: 'MODULE_NOT_FOUND',
});
},
repair: async () => {
repairs++;
},
run: () => assert.fail('offline must not access network'),
};
await assert.rejects(
bundle.stageBundle(options),
/no network access attempted/,
);
assert.equal(repairs, 0);
await assert.rejects(
bundle.stageBundle({
...options,
env: { ...options.env, QL_UPGRADE_REPAIR_DEPS: '1' },
}),
/Cannot find module/,
);
assert.equal(repairs, 1);
assert.equal(starts, 3);
assert.equal(await fs.readFile(pointer, 'utf8'), 'previous-ready');
assert.deepEqual(await fs.readdir(f.tmp), ['upgrade-ready-develop.json']);
});
test('dependency repair cannot change verified source or static files', async (t) => {
const f = await fixture(t);
let starts = 0;
await assert.rejects(
bundle.stageBundle({
root: f.root,
tmp: f.tmp,
env: {
QL_BRANCH: 'develop',
QL_UPGRADE_FILE: f.archive,
QL_UPGRADE_REPAIR_DEPS: '1',
},
verifyStatic: artifacts.verifyUpgrade,
probe: async () => {
throw Object.assign(new Error("Cannot find module 'decimal.js'"), {
code: 'MODULE_NOT_FOUND',
});
},
repair: async (source) =>
fs.writeFile(path.join(source, 'version.yaml'), 'version: 9.9.9'),
prestart: async () => {
starts++;
},
}),
/checksums/,
);
assert.equal(starts, 0);
assert.deepEqual(await fs.readdir(f.tmp), []);
});
test('ARMv6 offline entry re-executes with an interpreted Node while preserving CLI errors', async (t) => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-armv6-entry-'));
t.after(() => fs.rm(directory, { recursive: true, force: true }));
const preload = path.join(directory, 'armv6.cjs');
const observed = path.join(directory, 'child.json');
await fs.writeFile(
preload,
`if(process.execArgv.includes('--jitless'))require('node:fs').writeFileSync(${JSON.stringify(
observed,
)},JSON.stringify({args:process.execArgv,execPath:process.execPath}));else{Object.defineProperty(process,'arch',{value:'arm'});Object.defineProperty(process,'config',{value:{variables:{arm_version:6}}});}`,
);
const { execFile } = require('node:child_process');
const result = await new Promise((resolve) =>
execFile(
process.execPath,
[path.resolve(__dirname, '../../shell/install-bundle.cjs')],
{
timeout: 10000,
env: {
...process.env,
NODE_OPTIONS: '--require ' + JSON.stringify(preload),
},
},
(error, stdout, stderr) => resolve({ error, stdout, stderr }),
),
);
assert.equal(result.error.code, 1);
const child = JSON.parse(await fs.readFile(observed));
assert.ok(child.args.includes('--jitless'));
assert.equal(child.execPath, process.execPath);
assert.match(result.stderr, /Usage: node install-bundle.cjs/);
});
const {
writeManifest,
sourceCommit,
} = require('../helpers/upgrade-fixture.cjs');
const {
releaseIndex,
platforms,
} = require('../../scripts/publish-upgrade-bundles.cjs');
async function fixture(t) {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-bundle-test-'));
t.after(() => fs.rm(directory, { recursive: true, force: true }));
const root = path.join(directory, 'installed'),
tmp = path.join(root, '.tmp');
const payload = path.join(directory, 'qinglong');
const source = path.join(payload, 'source'),
staticRoot = path.join(payload, 'static');
await fs.mkdir(tmp, { recursive: true });
await fs.mkdir(
path.join(source, 'node_modules/.pnpm/sqlite/node_modules/sqlite3'),
{ recursive: true },
);
await fs.symlink(
'.pnpm/sqlite/node_modules/sqlite3',
path.join(source, 'node_modules/sqlite3'),
);
await fs.writeFile(
path.join(source, 'node_modules/sqlite3/package.json'),
'{"name":"sqlite3"}',
);
await fs.mkdir(path.join(source, 'sample'));
await fs.writeFile(path.join(source, 'sample/config.sample.sh'), '# config');
await fs.writeFile(
path.join(source, 'package.json'),
'{"version":"2.24.0","qinglongUpgradeFormat":2}',
);
await fs.writeFile(
path.join(root, 'package.json'),
'{"qinglongUpgradeFormat":2}',
);
await fs.mkdir(path.join(staticRoot, 'build'), { recursive: true });
await fs.writeFile(path.join(staticRoot, 'build/app.js'), 'backend');
await fs.writeFile(path.join(source, 'version.yaml'), 'version: 2.24.0\n');
await fs.mkdir(path.join(staticRoot, 'build/bootstrap'));
await fs.writeFile(
path.join(staticRoot, 'build/bootstrap/upgradeProbe.js'),
`
const fs=require('node:fs'),path=require('node:path'),crypto=require('node:crypto');
if(process.env.NODE_OPTIONS || process.env.NODE_PATH || process.env.QL_CLI_ROOT)throw new Error('Leaked host environment');
const info=JSON.parse(fs.readFileSync(path.join(__dirname,'../../build-info.json')));
process.send({type:'upgrade-probe',ok:true,build:{version:'2.24.0',sourceCommit:info.sourceCommit,
backendSha256:crypto.createHash('sha256').update(fs.readFileSync(path.join(__dirname,'../app.js'))).digest('hex')}});
process.disconnect();
`,
);
writeManifest(source, staticRoot);
const target = await bundle.runtimeTarget();
const manifest = {
format: 2,
sourceCommit,
lockfileSha256: bundle.sha256(
await fs.readFile(path.join(source, 'pnpm-lock.yaml')),
),
target: { ...target, libcMinimum: target.libcVersion },
nodeMinimum: '20.18.1',
nodeApi: 6,
files: await bundle.inventory({ source, static: staticRoot }),
};
const archive = path.join(directory, 'package.tar.gz');
const pack = async () => {
await fs.writeFile(
path.join(payload, 'bundle.json'),
JSON.stringify(manifest),
);
await tar.c(
{
file: archive,
cwd: directory,
gzip: true,
portable: true,
noMtime: true,
},
['qinglong'],
);
};
await pack();
return {
directory,
root,
tmp,
source,
staticRoot,
target,
manifest,
archive,
pack,
};
}
test('online and offline stage the same complete payload, preserve pnpm links and never install dependencies', async (t) => {
const f = await fixture(t),
bytes = await fs.readFile(f.archive),
progress = [];
const entry = {
size: bytes.length,
sha256: bundle.sha256(bytes),
url: 'https://example.invalid/immutable.tar.gz',
};
let calls = 0,
probes = 0;
const run = async (program, args) => {
assert.equal(program, 'curl');
calls++;
const output = args[args.indexOf('--output') + 1];
if (args.at(-1).endsWith('latest.json'))
await fs.writeFile(
output,
JSON.stringify({
format: 2,
sourceCommit,
packages: { [bundle.targetKey(f.target)]: entry },
}),
);
else {
await fs.writeFile(output, bytes);
await fs.writeFile(
args[args.indexOf('--dump-header') + 1],
'HTTP/2 200 OK\r\n\r\n',
);
}
};
const options = {
root: f.root,
tmp: f.tmp,
env: { QL_BRANCH: 'develop' },
run,
verifyStatic: artifacts.verifyUpgrade,
download: artifacts.downloadWithProgress,
probe: async () => {
probes++;
},
onProgress: async (p) => progress.push(p),
};
const online = await bundle.stageBundle(options);
const first = await bundle.inventory(online);
assert.equal(calls, 2);
assert.equal(probes, 1);
assert.ok(progress.some((p) => p.percent === 100));
assert.ok(progress.some((p) => p.percent === 0));
const offline = await bundle.stageBundle({
...options,
env: { QL_BRANCH: 'develop', QL_UPGRADE_FILE: f.archive },
run: () => assert.fail('offline network access'),
});
assert.equal(probes, 2);
assert.deepEqual(await bundle.inventory(offline), first);
assert.equal(
await fs.readlink(path.join(offline.source, 'node_modules/sqlite3')),
'.pnpm/sqlite/node_modules/sqlite3',
);
await assert.rejects(fs.access(online.source));
});
test('native target compatibility distinguishes ARMv6/ARMv7 and libc and retains the existing Node requirement', async (t) => {
const f = await fixture(t),
target = {
platform: 'linux',
arch: 'armv6',
libc: 'musl',
libcVersion: '1.2.5',
};
const manifest = {
...f.manifest,
target: { ...target, libcMinimum: '1.2.4' },
};
bundle.assertCompatible(manifest, target, '20.20.2');
const glibcTarget = {
...target,
arch: 'armv7',
libc: 'glibc',
libcVersion: '2.31',
endianness: 'LE',
armFloatAbi: 'hard',
};
const glibcManifest = {
...manifest,
target: { ...glibcTarget, libcMinimum: '2.31' },
};
bundle.assertCompatible(glibcManifest, glibcTarget, '20.18.1');
assert.equal(bundle.targetKey(glibcTarget), 'linux-armv7-glibc');
assert.throws(
() =>
bundle.assertCompatible(glibcManifest, {
...glibcTarget,
libcVersion: '2.30',
}),
/requires glibc/,
);
assert.throws(
() =>
bundle.assertCompatible(glibcManifest, {
...glibcTarget,
armFloatAbi: 'soft',
}),
/floating-point ABI mismatch/,
);
assert.throws(
() => bundle.assertCompatible(manifest, { ...target, arch: 'armv7' }),
/platform mismatch/,
);
assert.throws(
() => bundle.assertCompatible(manifest, { ...target, libc: 'glibc' }),
/platform mismatch/,
);
assert.throws(
() => bundle.assertCompatible(manifest, target, '18.20.0'),
/requires Node/,
);
assert.throws(
() =>
bundle.assertCompatible(manifest, { ...target, libcVersion: '1.1.24' }),
/requires musl/,
);
});
test('dependency tampering and payload link escapes are rejected', async (t) => {
const f = await fixture(t),
selected = { source: f.source, static: f.staticRoot };
await bundle.verifyPayload(selected, f.manifest, f.target);
await fs.writeFile(
path.join(f.source, 'node_modules/sqlite3/package.json'),
'{"tampered":true}',
);
await assert.rejects(
bundle.verifyPayload(selected, f.manifest, f.target),
/checksums/,
);
await fs.symlink('/etc/passwd', path.join(f.source, 'outside'));
await assert.rejects(bundle.inventory(selected), /escapes/);
});
test('archive traversal, absolute links and writes through internal symlinks are rejected before extraction', async (t) => {
const f = await fixture(t),
dest = path.join(f.directory, 'extract');
await fs.mkdir(dest);
await fs.symlink('/etc', path.join(f.directory, 'qinglong/bad'));
await tar.c({ cwd: f.directory, file: f.archive, gzip: true }, [
'qinglong/bad',
]);
await assert.rejects(bundle.extractArchive(f.archive, dest), /symlink/);
await tar.c(
{ cwd: f.directory, file: f.archive, gzip: true, prefix: '../outside' },
['qinglong/source/package.json'],
);
await assert.rejects(bundle.extractArchive(f.archive, dest), /Unsafe/);
await fs.mkdir(path.join(f.directory, 'qinglong/real'));
await fs.writeFile(path.join(f.directory, 'qinglong/real/file'), 'value');
await fs.symlink('real', path.join(f.directory, 'qinglong/link'));
await tar.c({ cwd: f.directory, file: f.archive, gzip: true }, [
'qinglong/link',
'qinglong/link/file',
]);
await assert.rejects(
bundle.extractArchive(f.archive, dest),
/through a symlink/,
);
assert.deepEqual(await fs.readdir(dest), []);
});
test('an incomplete or corrupt platform download cannot replace an existing ready pointer', async (t) => {
const f = await fixture(t),
pointer = path.join(f.tmp, 'upgrade-ready-develop.json');
await fs.writeFile(pointer, 'previous-ready');
await assert.rejects(
bundle.stageBundle({
root: f.root,
tmp: f.tmp,
env: { QL_BRANCH: 'develop', QL_UPGRADE_FILE: f.archive },
verifyStatic: artifacts.verifyUpgrade,
probe: async () => {
throw new Error('native addon failed');
},
}),
/native addon failed/,
);
assert.equal(await fs.readFile(pointer, 'utf8'), 'previous-ready');
assert.equal(
(await fs.readdir(f.tmp)).filter((n) => n.startsWith('upgrade-')).length,
1,
);
});
test('publishing requires every platform and validates all immutable archive hashes', async (t) => {
const f = await fixture(t),
repository = 'whyour/qinglong';
assert.throws(() => releaseIndex(f.directory, sourceCommit, repository));
for (const target of platforms) {
const filename = `qinglong-${sourceCommit}-${target}.tar.gz`,
bytes = Buffer.from(target);
await fs.writeFile(path.join(f.directory, filename), bytes);
await fs.writeFile(
path.join(f.directory, filename + '.json'),
JSON.stringify({
format: 2,
sourceCommit,
target,
filename,
size: bytes.length,
sha256: bundle.sha256(bytes),
}),
);
}
const index = releaseIndex(f.directory, sourceCommit, repository);
assert.equal(Object.keys(index.packages).length, 11);
assert.ok(index.packages['linux-armv7-musl']);
assert.equal(index.packages['linux-armv7-glibc'], undefined);
const armv6 = path.join(
f.directory,
`qinglong-${sourceCommit}-linux-armv6-musl.tar.gz`,
);
const armv6Bytes = await fs.readFile(armv6);
await fs.rm(armv6);
assert.throws(
() => releaseIndex(f.directory, sourceCommit, repository),
/ENOENT/,
);
await fs.writeFile(armv6, armv6Bytes);
await fs.appendFile(
path.join(f.directory, `qinglong-${sourceCommit}-${platforms[0]}.tar.gz`),
'corrupt',
);
assert.throws(
() => releaseIndex(f.directory, sourceCommit, repository),
/Unverified/,
);
});
test('fresh offline installation needs no package manager, keeps Node and publishes a complete directory', async (t) => {
const f = await fixture(t);
await fs.writeFile(path.join(f.source, '.env.example'), 'QlPort=5700\n');
await fs.writeFile(path.join(f.source, 'version.yaml'), 'version: 2.24.0\n');
await fs.writeFile(
path.join(f.source, 'node_modules/sqlite3/index.js'),
`exports.Database=class {serialize(fn){fn()}run(){}get(sql,cb){cb(null,{value:42})}close(){}};`,
);
for (const name of ['sequelize', 'cron-parser', '@grpc/grpc-js', 'undici']) {
const dir = path.join(f.source, 'node_modules', name);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, 'index.js'), 'module.exports={};');
}
f.manifest.files = await bundle.inventory({
source: f.source,
static: f.staticRoot,
});
await f.pack();
const destination = path.join(f.directory, 'fresh'),
node = process.execPath;
await require('../../shell/install-bundle.cjs').installBundle(
['--root', destination, '--file', f.archive, '--install'],
{ ...process.env, PATH: '', QL_BRANCH: 'develop' },
);
assert.equal(process.execPath, node);
assert.equal(
await fs.readFile(path.join(destination, '.env'), 'utf8'),
'QlPort=5700\n',
);
assert.equal(
(await fs.stat(path.join(destination, '.env'))).mode & 0o777,
0o600,
);
assert.equal(
await fs.readFile(path.join(destination, 'static/build/app.js'), 'utf8'),
'backend',
);
assert.ok(
(
await fs.stat(path.join(destination, 'node_modules/sqlite3/package.json'))
).isFile(),
);
assert.equal(
(await fs.readdir(f.directory)).filter((n) =>
n.startsWith('.qinglong-install-'),
).length,
0,
);
await fs.writeFile(path.join(destination, 'keep-user-data'), 'keep');
await assert.rejects(
require('../../shell/install-bundle.cjs').installBundle([
'--root',
destination,
'--file',
f.archive,
'--install',
]),
/empty installation directory/,
);
assert.equal(
await fs.readFile(path.join(destination, 'keep-user-data'), 'utf8'),
'keep',
);
});
test('offline staging replaces stale panel readiness and refuses an active online job', async (t) => {
const f = await fixture(t);
await fs.writeFile(path.join(f.source, 'version.yaml'), 'version: 2.24.0\n');
await fs.writeFile(
path.join(f.source, 'node_modules/sqlite3/index.js'),
'exports.Database=class {serialize(fn){fn()}run(){}get(sql,cb){cb(null,{value:42})}close(){}};',
);
for (const name of ['sequelize', 'cron-parser', '@grpc/grpc-js', 'undici']) {
const dir = path.join(f.source, 'node_modules', name);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, 'index.js'), 'module.exports={};');
}
f.manifest.files = await bundle.inventory({
source: f.source,
static: f.staticRoot,
});
await f.pack();
await fs.mkdir(path.join(f.root, 'shell'));
await fs.writeFile(
path.join(f.root, 'shell/share.sh'),
'import_config() { :; }\n',
);
await fs.writeFile(
path.join(f.root, 'shell/env.sh'),
'export QL_BRANCH=develop\n',
);
const stateFile = path.join(f.tmp, 'panel-upgrade.json');
await fs.writeFile(
stateFile,
JSON.stringify({
id: 'previous-online-job',
status: 'ready',
phase: 'ready',
}),
);
const install = require('../../shell/install-bundle.cjs').installBundle;
await install(['--root', f.root, '--file', f.archive, '--download-only']);
const state = JSON.parse(await fs.readFile(stateFile, 'utf8'));
assert.equal(state.status, 'ready');
assert.equal(state.sourceCommit, sourceCommit);
assert.notEqual(state.id, 'previous-online-job');
assert.equal(state.version, '2.24.0');
const pointer = await fs.readFile(
path.join(f.tmp, 'upgrade-ready-develop.json'),
'utf8',
);
await fs.writeFile(
stateFile,
JSON.stringify({
id: 'live-online-job',
status: 'running',
pid: process.pid,
startedAt: Date.now(),
}),
);
await assert.rejects(
install(['--root', f.root, '--file', f.archive, '--download-only']),
/已有更新/,
);
assert.equal(
JSON.parse(await fs.readFile(stateFile, 'utf8')).id,
'live-online-job',
);
assert.equal(
await fs.readFile(path.join(f.tmp, 'upgrade-ready-develop.json'), 'utf8'),
pointer,
);
});
test('interrupting offline staging records failure, preserves prior readiness and releases both locks', async (t) => {
const f = await fixture(t);
const { spawn } = require('node:child_process');
await fs.writeFile(path.join(f.source, 'version.yaml'), 'version: 2.24.0\n');
await fs.writeFile(
path.join(f.source, 'node_modules/sqlite3/index.js'),
'exports.Database=class {serialize(fn){fn()}run(){}get(sql,cb){setTimeout(()=>cb(null,{value:42}),10000)}close(){}};',
);
for (const name of ['sequelize', 'cron-parser', '@grpc/grpc-js', 'undici']) {
const dir = path.join(f.source, 'node_modules', name);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, 'index.js'), 'module.exports={};');
}
f.manifest.files = await bundle.inventory({
source: f.source,
static: f.staticRoot,
});
await f.pack();
await fs.mkdir(path.join(f.root, 'shell'));
await fs.writeFile(
path.join(f.root, 'shell/share.sh'),
'import_config() { :; }\n',
);
await fs.writeFile(
path.join(f.root, 'shell/env.sh'),
'export QL_BRANCH=develop\n',
);
const pointer = path.join(f.tmp, 'upgrade-ready-develop.json');
await fs.writeFile(pointer, 'previous-good-readiness');
const child = spawn(
process.execPath,
[
path.resolve(__dirname, '../../shell/install-bundle.cjs'),
'--root',
f.root,
'--file',
f.archive,
'--download-only',
],
{ stdio: 'ignore' },
);
t.after(() => {
if (child.exitCode === null) child.kill('SIGKILL');
});
const exited = new Promise((resolve, reject) => {
child.once('exit', resolve);
child.once('error', reject);
});
let seen = false;
for (let i = 0; i < 200; i++) {
const state = await fs
.readFile(path.join(f.tmp, 'panel-upgrade.json'), 'utf8')
.then(JSON.parse)
.catch(() => ({}));
if (state.phase === 'dependencies') {
seen = true;
break;
}
await new Promise((r) => setTimeout(r, 20));
}
assert.ok(seen);
child.kill('SIGTERM');
assert.equal(await exited, 1);
assert.equal(await fs.readFile(pointer, 'utf8'), 'previous-good-readiness');
assert.equal(
JSON.parse(
await fs.readFile(path.join(f.tmp, 'panel-upgrade.json'), 'utf8'),
).status,
'failed',
);
await assert.rejects(fs.access(path.join(f.tmp, 'upgrade.lock')));
await assert.rejects(
fs.access(path.join(f.tmp, 'panel-job-launch/upgrade.lock')),
);
});
test('portable release archives preserve the permission manifest after normalizing image scripts', async (t) => {
const f = await fixture(t);
const script = path.join(f.source, 'sample/config.sample.sh');
await fs.chmod(script, 0o777);
const { normalizeModes } = require('../../scripts/build-upgrade-bundle.cjs');
await normalizeModes(f.source);
await normalizeModes(f.staticRoot);
assert.equal((await fs.stat(script)).mode & 0o777, 0o755);
f.manifest.files = await bundle.inventory({
source: f.source,
static: f.staticRoot,
});
await f.pack();
const extracted = path.join(f.directory, 'roundtrip');
await fs.mkdir(extracted);
await bundle.extractArchive(f.archive, extracted);
const selected = {
source: path.join(extracted, 'qinglong/source'),
static: path.join(extracted, 'qinglong/static'),
};
await bundle.verifyPayload(selected, f.manifest, f.target);
await fs.chmod(path.join(selected.source, 'sample/config.sample.sh'), 0o777);
await assert.rejects(
bundle.verifyPayload(selected, f.manifest, f.target),
/checksums/,
);
});
test('full dependency bundles get a bounded slow-link download budget instead of the old ten-minute limit', async (t) => {
const f = await fixture(t);
let observed;
await assert.rejects(
artifacts.downloadWithProgress(
async (program, args) => {
assert.equal(program, 'curl');
observed = Number(args[args.indexOf('--max-time') + 1]);
assert.ok(observed > 600 && observed <= 7200);
assert.equal(args[args.indexOf('--speed-limit') + 1], '1024');
throw new Error('stop before transfer');
},
path.join(f.directory, 'slow.tar.gz'),
'https://example.invalid/package.tar.gz',
'qinglong-bundle',
async () => {},
true,
{ size: 24 * 1024 * 1024, sha256: 'a'.repeat(64) },
),
/stop before transfer/,
);
assert.ok(observed);
});
+2 -1
View File
@@ -2,11 +2,12 @@ const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const vm = require('node:vm'); const vm = require('node:vm');
const fs = require('node:fs'); 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 source = fs.readFileSync('ecosystem.config.js', 'utf8');
const appSource = fs.readFileSync('back/app.ts', 'utf8'); const appSource = fs.readFileSync('back/app.ts', 'utf8');
const ts = require('typescript'); 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; 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}});return module.exports.apps[0];} function config(env) {const module={exports:{}};vm.runInNewContext(source,{module,process:{env},require:configRequire});return module.exports.apps[0];}
test('container APM policy keeps worker isolation and can restore all inherited probes', () => { 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']) { 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}; const env={QL_CONTAINER:'true',QL_PRIMARY_APM:primary,QL_WORKER_APM:workers};
+501
View File
@@ -0,0 +1,501 @@
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 {
sha256,
} = require('../../cli/src/internal/maintenance/platformBundle.cjs');
const {
platforms,
publishBundles,
} = require('../../scripts/publish-upgrade-bundles.cjs');
function fixture(t) {
const directory = fs.mkdtempSync(
path.join(os.tmpdir(), 'ql-publish-bundles-'),
);
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const state = {
releases: new Map(),
head: 'a'.repeat(40),
developHead: 'c'.repeat(40),
calls: [],
nextId: 1,
};
const options = {
directory,
commit: state.head,
repository: 'whyour/qinglong',
branch: 'master',
version: '2.24.0',
buildId: '100-1',
};
const write = (
commit = options.commit,
content = 'first',
version = options.version,
) => {
for (const target of platforms) {
const filename = `qinglong-${commit}-${target}.tar.gz`;
const bytes = Buffer.from(`${version}/${content}/${target}`);
fs.writeFileSync(path.join(directory, filename), bytes);
fs.writeFileSync(
path.join(directory, filename + '.json'),
JSON.stringify({
format: 2,
sourceCommit: commit,
version,
target,
filename,
size: bytes.length,
sha256: sha256(bytes),
}),
);
}
fs.writeFileSync(
path.join(directory, 'install-bundle.cjs'),
`// ${content} installer`,
);
};
const notFound = () => {
throw Object.assign(new Error('Not found'), {
stderr: 'gh: Not Found (HTTP 404)',
});
};
const gh = (...args) => {
state.calls.push(args);
if (args[0] === 'api') {
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)
throw Object.assign(new Error('Unavailable'), { stderr: 'HTTP 503' });
const release = state.releases.get(tag);
if (!release) return notFound();
return JSON.stringify({
assets: Array.from(release.values(), (a) => ({
name: a.name,
id: a.id,
size: a.bytes.length,
digest: a.digest,
})),
});
}
if (route.includes('/releases/assets/')) {
const id = Number(route.split('/').at(-1));
for (const release of state.releases.values())
for (const asset of release.values())
if (asset.id === id) return asset.bytes.toString();
return notFound();
}
}
if (args[0] === 'release' && args[1] === 'create') {
assert.ok(!state.releases.has(args[2]));
state.releases.set(args[2], new Map());
return '';
}
if (args[0] === 'release' && args[1] === 'upload') {
const tag = args[2],
release = state.releases.get(tag);
assert.ok(release);
const files = args.slice(3, args.indexOf('--repo'));
for (let i = 0; i < files.length; i++) {
if (state.failBatchUpload && files.length > 1 && i === 4)
throw new Error('Upload interrupted');
const name = path.basename(files[i]),
bytes = fs.readFileSync(files[i]);
assert.ok(
!release.has(name) || args.includes('--clobber'),
'Batch assets must not be overwritten',
);
release.set(name, {
name,
id: state.nextId++,
bytes: Buffer.from(bytes),
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)}`);
};
const publish = (overrides = {}) =>
publishBundles({ ...options, gh, ...overrides });
const index = (tag = 'upgrade-master') =>
JSON.parse(state.releases.get(tag).get('latest.json').bytes);
write();
return { directory, state, options, write, publish, index };
}
test('publishes every platform and installer before advancing version and legacy master indexes', (t) => {
const f = fixture(t);
assert.equal(f.publish(), true);
const index = f.index();
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, 11);
assert.ok(index.packages['linux-armv7-musl']);
assert.equal(index.packages['linux-armv7-glibc'], undefined);
assert.deepEqual(index, f.index('upgrade-v2.24.0'));
const tag = 'upgrade-build-v2.24.0-100-1';
assert.equal(f.state.releases.get(tag).size, 24);
for (const entry of [...Object.values(index.packages), index.installer]) {
const url = new URL(entry.url),
filename = path.basename(url.pathname);
assert.ok(url.pathname.includes(`/releases/download/${tag}/`));
const bytes = f.state.releases.get(tag).get(filename).bytes;
assert.equal(bytes.length, entry.size);
assert.equal(sha256(bytes), entry.sha256);
}
const writes = f.state.calls.filter(
(a) => a[0] === 'release' && a[1] === 'upload',
);
assert.deepEqual(
writes.map((a) => a[2]),
[tag, 'upgrade-v2.24.0', 'upgrade-master'],
);
assert.ok(writes.slice(1).every((a) => a.includes('--clobber')));
});
for (const sameCommit of [false, true])
test(`same-version replacement updates both indexes and retains in-flight downloads (${
sameCommit ? 'same commit rerun' : 'new commit'
})`, (t) => {
const f = fixture(t);
f.publish();
const before = f.index();
const oldAssets = new Map(
Array.from(
f.state.releases.get('upgrade-build-v2.24.0-100-1'),
([name, a]) => [name, a.bytes.toString('base64')],
),
);
const commit = sameCommit ? f.options.commit : 'b'.repeat(40);
f.state.head = commit;
f.write(commit, 'replacement');
assert.equal(f.publish({ commit, buildId: '100-2' }), true);
const after = f.index();
assert.equal(after.version, before.version);
assert.equal(after.buildId, '100-2');
assert.equal(after.sourceCommit, commit);
assert.deepEqual(after, f.index('upgrade-v2.24.0'));
for (const target of platforms) {
assert.notEqual(after.packages[target].url, before.packages[target].url);
assert.notEqual(
after.packages[target].sha256,
before.packages[target].sha256,
);
}
assert.notEqual(after.installer.url, before.installer.url);
assert.notEqual(after.installer.sha256, before.installer.sha256);
assert.deepEqual(
new Map(
Array.from(
f.state.releases.get('upgrade-build-v2.24.0-100-1'),
([name, a]) => [name, a.bytes.toString('base64')],
),
),
oldAssets,
);
});
test('retrying a publication is idempotent, but changed bytes require a new build ID', (t) => {
const f = fixture(t);
f.publish();
const count = () =>
f.state.calls.filter(
(a) =>
a[0] === 'release' &&
a[1] === 'upload' &&
a[2] === 'upgrade-build-v2.24.0-100-1',
).length;
assert.equal(count(), 1);
f.publish();
assert.equal(count(), 1);
f.write(f.options.commit, 'changed within the same attempt');
assert.throws(() => f.publish(), /Immutable release asset differs/);
assert.equal(f.index().buildId, '100-1');
});
for (const fault of ['missing', 'corrupt', 'wrong-version'])
test(`incomplete, corrupt or wrong-version packages never publish (${fault})`, (t) => {
const f = fixture(t);
const archive = path.join(
f.directory,
`qinglong-${f.options.commit}-linux-armv6-musl.tar.gz`,
);
if (fault === 'missing') fs.rmSync(archive);
else if (fault === 'corrupt') fs.appendFileSync(archive, 'corrupt');
else {
const record = JSON.parse(fs.readFileSync(archive + '.json'));
record.version = '2.23.0';
fs.writeFileSync(archive + '.json', JSON.stringify(record));
}
assert.throws(() => f.publish(), /ENOENT|Unverified/);
assert.equal(f.state.calls.length, 0);
});
for (const fault of ['failBatchUpload', 'corruptUploadedBatch'])
test(`failed batch publication keeps both existing indexes (${fault})`, (t) => {
const f = fixture(t);
f.publish();
const previous = f.index();
f.write(f.options.commit, 'next');
f.state[fault] = true;
assert.throws(
() => f.publish({ buildId: '100-2' }),
/Upload interrupted|Published platform bundle verification failed/,
);
assert.deepEqual(f.index(), previous);
assert.deepEqual(f.index('upgrade-v2.24.0'), previous);
});
test('stale commits do not publish; a source change during upload cannot advance indexes', (t) => {
const f = fixture(t);
f.publish();
const before = f.index();
f.state.head = 'b'.repeat(40);
assert.equal(f.publish({ buildId: '101-1' }), false);
assert.ok(!f.state.releases.has('upgrade-build-v2.24.0-101-1'));
f.state.head = f.options.commit;
f.state.changeHeadAfterUpload = true;
assert.equal(f.publish({ buildId: '102-1' }), false);
assert.deepEqual(f.index(), before);
assert.deepEqual(f.index('upgrade-v2.24.0'), before);
});
test('an explicitly rerun earlier workflow may replace the same version and commit', (t) => {
const f = fixture(t);
f.publish({ buildId: '101-1' });
const before = f.index();
f.write(f.options.commit, 'rebuild from an earlier run');
assert.equal(f.publish({ buildId: '100-2' }), true);
assert.equal(f.index().buildId, '100-2');
assert.deepEqual(f.index(), f.index('upgrade-v2.24.0'));
assert.notEqual(
f.index().packages[platforms[0]].sha256,
before.packages[platforms[0]].sha256,
);
});
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'])
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/);
assert.throws(
() => f.publish({ version: '../bad' }),
/Invalid bundle version/,
);
assert.equal(f.state.calls.length, 0);
f.publish();
const before = f.index();
f.state.lookupFailure = 'upgrade-v2.24.0';
assert.throws(() => f.publish({ buildId: '100-2' }), /Unavailable/);
assert.deepEqual(f.index(), before);
});
test('existing master clients can migrate from indexes without version/build metadata', (t) => {
const f = fixture(t);
f.publish();
const asset = f.state.releases.get('upgrade-master').get('latest.json');
const legacy = f.index();
delete legacy.version;
delete legacy.buildId;
asset.bytes = Buffer.from(JSON.stringify(legacy));
assert.equal(f.publish({ buildId: '100-2' }), true);
assert.equal(f.index().buildId, '100-2');
});
test('automatic development publication shares musl/glibc packages without changing version or latest production assets', (t) => {
const f = fixture(t);
f.publish();
const production = f.index();
f.write(f.state.developHead, 'development');
assert.equal(
f.publish({
branch: 'develop',
commit: f.state.developHead,
}),
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, 11);
assert.ok(development.packages['linux-x64-musl']);
assert.ok(development.packages['linux-x64-glibc']);
assert.ok(development.packages['linux-armv6-musl']);
assert.equal(development.packages['linux-armv6-glibc'], undefined);
assert.ok(development.packages['linux-armv7-musl']);
assert.equal(development.packages['linux-armv7-glibc'], undefined);
for (const entry of [
...Object.values(development.packages),
development.installer,
])
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, 24);
assert.equal(
f.state.releases.get('upgrade-build-develop-v2.24.0-100-1').size,
24,
);
});
test('development builds cannot publish obsolete development source', (t) => {
const f = fixture(t);
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.state.releases.size, 0);
});
for (const fault of ['failBatchUpload', 'corruptUploadedBatch'])
test(`failed automatic 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,
};
f.write(options.commit, 'first development');
f.publish(options);
const previous = f.index('upgrade-develop');
f.write(options.commit, 'next development');
f.state[fault] = true;
assert.throws(
() => f.publish({ ...options, buildId: '100-2' }),
/Upload interrupted|Published platform bundle verification failed/,
);
assert.deepEqual(f.index('upgrade-develop'), previous);
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'),
);
});
+30
View File
@@ -23,6 +23,9 @@ function fixture() {
let invalidations = 0; let invalidations = 0;
const module = { exports: {} }; const module = { exports: {} };
const localRequire = createRequire(path.resolve('back/app.ts')); const localRequire = createRequire(path.resolve('back/app.ts'));
const middlewareRequire = createRequire(
path.resolve('back/loaders/httpApplication.ts'),
);
const mocks = { const mocks = {
'./config': { cors: { origin: ['http://localhost'] } }, './config': { cors: { origin: ['http://localhost'] } },
'./loaders/logger': { error() {}, warn() {}, info() {} }, './loaders/logger': { error() {}, warn() {}, info() {} },
@@ -37,6 +40,33 @@ function fixture() {
new Function('require', 'module', 'exports', 'process', compiled)( new Function('require', 'module', 'exports', 'process', compiled)(
(name) => { (name) => {
required.push(name); required.push(name);
if (name === './loaders/httpApplication') {
const helper = { exports: {} };
const code = ts.transpileModule(
fs.readFileSync('back/loaders/httpApplication.ts', 'utf8'),
{
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
esModuleInterop: true,
},
},
).outputText;
new Function('require', 'module', 'exports', code)(
(dependency) => {
required.push(dependency);
const alias = dependency.startsWith('../')
? './' + dependency.slice(3)
: dependency;
return Object.hasOwn(mocks, alias)
? mocks[alias]
: middlewareRequire(dependency);
},
helper,
helper.exports,
);
return helper.exports;
}
return Object.hasOwn(mocks, name) ? mocks[name] : localRequire(name); return Object.hasOwn(mocks, name) ? mocks[name] : localRequire(name);
}, },
module, module,
+44 -2
View File
@@ -29,7 +29,9 @@ test('JS and Python script reports reach the same authenticated RPC with bounded
server.addService(Api.service, { server.addService(Api.service, {
RecordNotifyLog: (call, callback) => { RecordNotifyLog: (call, callback) => {
received.push(call.request); received.push(call.request);
callback(null, { code: 200 }); if (call.request.title === 'Concurrent cold startup')
setTimeout(() => callback(null, { code: 200 }), 2000);
else callback(null, { code: 200 });
}, },
}); });
const credentials = grpc.ServerCredentials.createSsl( const credentials = grpc.ServerCredentials.createSsl(
@@ -91,6 +93,46 @@ n.send('Python failure','body')
); );
assert.ok(received.every((x) => x.taskId === '9')); assert.ok(received.every((x) => x.taskId === '9'));
assert.ok(!JSON.stringify(received).includes('private-password')); 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. // Missing certificates and an older/unavailable backend must not kill scripts.
await run( await run(
process.execPath, process.execPath,
@@ -110,5 +152,5 @@ n.send('Python failure','body')
], ],
{ env, timeout: 8000 }, { env, timeout: 8000 },
); );
assert.ok(Date.now() - started < 5000); assert.ok(Date.now() - started < 7000);
}); });
+192
View File
@@ -0,0 +1,192 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { spawnSync } = require('node:child_process');
const load = require('../helpers/load-security-module.cjs');
function fixture(row, { claim = true, onSpawn = () => {} } = {}) {
const spawned = [],
cronWrites = [],
instanceWrites = [],
instanceReads = [];
const { runCron } = load('back/shared/runCron.ts', {
'./i18n': { t: (s) => s },
'cross-spawn': {
spawn: (cmd) => {
spawned.push(cmd);
onSpawn(row);
return { pid: process.pid };
},
},
'./pLimit': {
runWithCronLimit: (_cron, execute) => execute(),
removeQueuedCron() {},
},
'../loaders/logger': { info() {}, error() {} },
'../data/cron': {
CrontabModel: {
findOne: async () => row,
update: async (...args) => {
cronWrites.push(args);
if (!claim) return [0];
Object.assign(row, args[0]);
return [1];
},
},
CrontabStatus: { running: 0, idle: 1, queued: 3 },
},
'../data/runningInstance': {
RunningInstanceModel: {
findAll: async (options) => {
instanceReads.push(options);
return [{ id: 7 }];
},
update: async (...args) => instanceWrites.push(args),
},
InstanceStatus: { running: 0, stopped: 2 },
},
dayjs: () => ({ unix: () => 123 }),
'./childProcess': {
observeChildProcess: () => ({ completed: Promise.resolve({ code: 0 }) }),
asError: (e) => e,
},
});
return {
run: () => runCron('task example.py', { id: '1' }),
spawned,
cronWrites,
instanceWrites,
instanceReads,
};
}
function exitedPid() {
const child = spawnSync(process.execPath, ['-e', '']);
assert.equal(child.status, 0);
assert.throws(() => process.kill(child.pid, 0), { code: 'ESRCH' });
return child.pid;
}
test('running single-instance task stays untouched while its process is alive', async () => {
const f = fixture({
allow_multiple_instances: 0,
pid: process.pid,
status: 0,
});
await f.run();
assert.deepEqual(f.spawned, []);
assert.deepEqual(f.cronWrites, []);
assert.deepEqual(f.instanceWrites, []);
});
test('queued single-instance task keeps its reservation even with an exited PID', async () => {
const f = fixture({
allow_multiple_instances: 0,
pid: exitedPid(),
status: 3,
});
await f.run();
assert.deepEqual(f.spawned, []);
assert.deepEqual(f.cronWrites, []);
assert.deepEqual(f.instanceWrites, []);
});
test('exited running task is recovered and the next trigger sees the new active run', async () => {
const oldPid = exitedPid();
const f = fixture(
{
allow_multiple_instances: 0,
pid: oldPid,
status: 0,
log_path: 'old.log',
queued_token: 'old-token',
last_execution_time: 10,
isDisabled: 0,
},
{
onSpawn: (row) => {
row.status = 0;
row.pid = process.pid;
},
},
);
await f.run();
await f.run();
assert.equal(f.spawned.length, 1);
assert.deepEqual(f.instanceReads, [
{
attributes: ['id'],
where: { cron_id: 1, pid: oldPid, status: 0 },
},
]);
assert.deepEqual(f.cronWrites, [
[
{ status: 1, pid: null, queued_token: null },
{
where: {
id: 1,
pid: oldPid,
status: 0,
log_path: 'old.log',
queued_token: 'old-token',
last_execution_time: 10,
isDisabled: 0,
allow_multiple_instances: 0,
},
},
],
]);
assert.deepEqual(f.instanceWrites, [
[{ status: 2, finished_at: 123 }, { where: { id: [7], status: 0 } }],
]);
});
test('a lost stale-state claim does not spawn or change running instances', async () => {
const f = fixture(
{ allow_multiple_instances: 0, pid: exitedPid(), status: 0 },
{ claim: false },
);
await f.run();
assert.equal(f.cronWrites.length, 1);
assert.deepEqual(f.spawned, []);
assert.deepEqual(f.instanceWrites, []);
});
test('EPERM means an existing process must still block a new run', async () => {
const pid = exitedPid();
const originalKill = process.kill;
process.kill = (target, signal) => {
if (target === pid && signal === 0)
throw Object.assign(new Error('denied'), { code: 'EPERM' });
return originalKill(target, signal);
};
try {
const f = fixture({ allow_multiple_instances: 0, pid, status: 0 });
await f.run();
assert.deepEqual(f.spawned, []);
assert.deepEqual(f.cronWrites, []);
} finally {
process.kill = originalKill;
}
});
test('single-instance still starts idle tasks with a stale stored PID', async () => {
const f = fixture({
allow_multiple_instances: 0,
pid: exitedPid(),
status: 1,
});
await f.run();
assert.equal(f.spawned.length, 1);
assert.deepEqual(f.cronWrites, []);
});
test('multi-instance keeps its existing concurrent execution behavior', async () => {
const f = fixture({
allow_multiple_instances: 1,
pid: process.pid,
status: 0,
});
await f.run();
assert.equal(f.spawned.length, 1);
assert.deepEqual(f.cronWrites, []);
});
@@ -0,0 +1,55 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const startup = fs.readFileSync('shell/start-simplify.sh', 'utf8');
const 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(
startup.indexOf('\nfix_config\n'),
startup.indexOf('log_with_style "INFO" "$(t \'🔄'),
);
for (const fails of [false, true]) {
test(`manual startup ${
fails
? 'stops on certificate failure'
: 'awaits certificates before starting supervision'
}`, (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-start-certs-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const module = path.join(root, 'static/build/config/grpcCerts.js');
fs.mkdirSync(path.dirname(module), { recursive: true });
fs.writeFileSync(
module,
`exports.initGrpcCerts = () => new Promise((resolve, reject) => setTimeout(() => {
if (process.env.QA_CERT_FAILURE === '1') return reject(new Error('certificate preparation failed'));
require('node:fs').writeFileSync(require('node:path').join(process.env.QL_DIR, 'certs-ready'), 'ready');
resolve();
}, 50));`,
);
const result = spawnSync(
'/bin/bash',
[
'-c',
`set -e
fix_config(){ :; }
pm2(){ test -f "$QL_DIR/certs-ready"; touch "$QL_DIR/supervisor-started"; }
${preparation}`,
],
{
encoding: 'utf8',
env: {
...process.env,
QL_DIR: root,
QA_CERT_FAILURE: fails ? '1' : '0',
},
},
);
assert.equal(result.status, fails ? 1 : 0, result.stderr);
assert.equal(fs.existsSync(path.join(root, 'supervisor-started')), !fails);
if (fails) assert.match(result.stderr, /certificate preparation failed/);
});
}
+12 -1
View File
@@ -393,7 +393,10 @@ done
QL_MIRROR_NOTIFY_REPO: 'fixture/source', QL_MIRROR_NOTIFY_REPO: 'fixture/source',
QL_MIRROR_NOTIFY_ON_START: 'false', QL_MIRROR_NOTIFY_ON_START: 'false',
QL_MIRROR_INCLUDE_VERSION_BRANCH: 'false', QL_MIRROR_INCLUDE_VERSION_BRANCH: 'false',
QL_MIRROR_RETRY_AFTER_SECONDS: '1', // Bash SECONDS has one-second resolution. Two ticks guarantee a real
// wait across a second boundary; a single tick may take only milliseconds.
QL_MIRROR_RETRY_AFTER_SECONDS: '2',
QL_MIRROR_WAIT_SECONDS: '8',
QL_TEST_COUNT: countFile, QL_TEST_COUNT: countFile,
QL_TEST_MIRROR: mirror, QL_TEST_MIRROR: mirror,
}, },
@@ -403,6 +406,14 @@ done
Date.now() - started >= 900, Date.now() - started >= 900,
'notification should wait for the sync interval', 'notification should wait for the sync interval',
); );
const retry = result.stdout.indexOf('Retrying the GitHub notification');
assert.ok(retry >= 0, result.stdout);
const elapsed = [
...result.stdout
.slice(0, retry)
.matchAll(/Waiting for mirror \(attempt \d+, (\d+)\/8s\)/g),
].at(-1);
assert.ok(elapsed && Number(elapsed[1]) >= 2, result.stdout);
assert.equal(fs.readFileSync(countFile, 'utf8'), 'notification\n'); assert.equal(fs.readFileSync(countFile, 'utf8'), 'notification\n');
assert.equal(git('-C', source, 'rev-list', '--count', triggerRef), '1'); assert.equal(git('-C', source, 'rev-list', '--count', triggerRef), '1');
assert.equal(git('-C', source, 'rev-parse', 'develop'), snapshot); assert.equal(git('-C', source, 'rev-parse', 'develop'), snapshot);
+55
View File
@@ -6,6 +6,61 @@ const os = require('node:os');
const { execFileSync, spawnSync } = require('node:child_process'); const { execFileSync, spawnSync } = require('node:child_process');
const { writeManifest } = require('../helpers/upgrade-fixture.cjs'); 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) => { 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-')); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-static-mirror-'));
t.after(() => fs.rmSync(tmp, { recursive: true, force: true })); t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
-69
View File
@@ -4,75 +4,6 @@ const load = require('../helpers/load-security-module.cjs');
const logger = { info() {}, error() {} }; const logger = { info() {}, error() {} };
function cronFixture(killTask) {
const events = [];
const { runCron } = load('back/shared/runCron.ts', {
'cross-spawn': {
spawn: () => {
events.push('spawn');
return {};
},
},
'./childProcess': {
observeChildProcess: () => ({ completed: Promise.resolve({ code: 0 }) }),
asError: (e) => e,
},
'./pLimit': {
runWithCronLimit: async (_, fn) => fn(),
removeQueuedCron: () => events.push('release'),
},
'../loaders/logger': logger,
'../config/util': { killTask },
'../data/cron': {
CrontabModel: {
findOne: async () => ({
pid: 100,
status: 0,
allow_multiple_instances: 0,
}),
update: async () => events.push('idle'),
},
CrontabStatus: { running: 0, idle: 1, queued: 3 },
},
'../data/runningInstance': {
RunningInstanceModel: { update: async () => events.push('stopped') },
InstanceStatus: { running: 0, stopped: 2 },
},
});
return { run: () => runCron('ignored', { id: '1' }), events };
}
test('single-instance replacement does not spawn or finalize when termination fails', async () => {
const f = cronFixture(async () => {
throw Error('EPERM');
});
await f.run();
assert.deepEqual(f.events, ['release']);
});
test('single-instance replacement waits for verified exit before spawning', async () => {
let finish, entered, verified;
const pending = new Promise((resolve) => {
entered = resolve;
});
const gate = new Promise((resolve) => {
finish = resolve;
});
const f = cronFixture(async (pid, wait) => {
assert.equal(pid, 100);
verified = wait;
entered();
await gate;
});
const running = f.run();
await pending;
assert.deepEqual(f.events, []);
finish();
await running;
assert.equal(verified, true);
assert.deepEqual(f.events, ['stopped', 'idle', 'spawn', 'release']);
});
function subscriptionFixture(killTask) { function subscriptionFixture(killTask) {
const updates = []; const updates = [];
const Service = load('back/services/subscription.ts', { const Service = load('back/services/subscription.ts', {
+128
View File
@@ -0,0 +1,128 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
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 = {}) => {
const run = (code) =>
vm.runInNewContext(code, {
github: { run_id: '100', run_attempt: '1', ...github },
inputs,
format: (template, ...values) =>
template.replace(/\{(\d+)\}/g, (_, i) => values[i]),
});
const exact = expression.match(/^\$\{\{([\s\S]*?)\}\}$/);
return exact
? run(exact[1])
: expression.replace(/\$\{\{([\s\S]*?)\}\}/g, (_, code) =>
String(run(code)),
);
};
test('both channels automatically build bundles, with static-only and Git-tag runs excluded', () => {
const cases = [
['push', 'refs/heads/develop', {}, true],
['workflow_dispatch', 'refs/heads/develop', {}, true],
[
'workflow_dispatch',
'refs/heads/develop',
{ static_sync_only: true },
false,
],
['push', 'refs/heads/master', {}, true],
['workflow_dispatch', 'refs/heads/master', {}, true],
[
'workflow_dispatch',
'refs/heads/master',
{ static_sync_only: true },
false,
],
['push', 'refs/tags/v2.24.0', {}, false],
['workflow_dispatch', 'refs/tags/v2.24.0', {}, false],
];
for (const name of ['build-upgrade-bundles', 'publish-upgrade-bundles'])
for (const [event_name, ref, inputs, expected] of cases)
assert.equal(
Boolean(evaluate(workflow.jobs[name].if, { event_name, ref }, inputs)),
expected,
`${name}: ${event_name} ${ref} ${JSON.stringify(inputs)}`,
);
assert.deepEqual(workflow.jobs['build-upgrade-bundles'].needs, [
'build-alpine',
'build-debian',
]);
assert.ok(
workflow.jobs['build-upgrade-bundles'].steps.some(
(step) =>
step.env?.ALPINE_DIGEST ===
'${{ needs.build-alpine.outputs.image-digest }}' &&
step.env?.DEBIAN_DIGEST ===
'${{ needs.build-debian.outputs.image-digest }}',
),
);
});
test('development image and bundle publication serialize separately from production and static-only runs', () => {
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');
assert.equal(
development,
group('workflow_dispatch', 'refs/heads/develop', {}, '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'),
);
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'));
});
+51
View File
@@ -283,3 +283,54 @@ test('unhealthy, malformed and oversized health responses fail within the deadli
await assert.rejects(waitForHealth({ QlPort: port }, 20), /health check|后端启动检查失败/); await assert.rejects(waitForHealth({ QlPort: port }, 20), /health check|后端启动检查失败/);
} }
}); });
test('ARMv6 startup can become healthy after the standard one-minute deadline', async (t) => {
const { EventEmitter } = require('node:events');
const descriptors = ['arch', 'config'].map((key) => [
key,
Object.getOwnPropertyDescriptor(process, key),
]);
t.after(() => {
for (const [key, descriptor] of descriptors)
Object.defineProperty(process, key, descriptor);
});
Object.defineProperty(process, 'arch', { value: 'arm', configurable: true });
Object.defineProperty(process, 'config', {
value: { variables: { arm_version: 6 } },
configurable: true,
});
let now = 0;
let probes = 0;
t.mock.method(Date, 'now', () => now);
t.mock.method(http, 'get', (_options, callback) => {
const request = new EventEmitter();
request.destroy = (error) => request.emit('error', error);
queueMicrotask(() => {
const response = new EventEmitter();
response.statusCode = ++probes === 1 ? 503 : 200;
callback(response);
now = probes * 70000;
response.emit(
'data',
JSON.stringify({
code: response.statusCode,
data: { status: probes > 1 ? 'ok' : 'starting' },
}),
);
response.emit('end');
});
return request;
});
await waitForHealth({});
assert.equal(probes, 2);
assert.equal(now, 140000);
const { startupHealthTimeout } = require('../../shell/upgrade.cjs');
assert.equal(
startupHealthTimeout({
arch: 'arm',
config: { variables: { arm_version: 7 } },
}),
60000,
);
assert.equal(startupHealthTimeout({ arch: 'arm64' }), 60000);
});
+43
View File
@@ -286,3 +286,46 @@ npm_install_2() { printf '%s' "$1" > "$QL_DIR/installed"; exit_status=0; }
); );
assert.deepEqual(await fs.readdir(f.tmp), []); assert.deepEqual(await fs.readdir(f.tmp), []);
}); });
test('offline selection and worker ownership do not leak into the restarted backend', async (t) => {
const f = await fixture(t);
await fs.writeFile(
path.join(f.root, 'shell/share.sh'),
`${await fs.readFile('shell/share.sh', 'utf8')}
pm2() { if [ "$1" = startOrGracefulReload ]; then node "$QL_DIR/report-runtime.cjs"; fi; }
`,
);
await fs.writeFile(
path.join(f.root, 'report-runtime.cjs'),
`
require('node:fs').writeFileSync(process.env.QL_DIR + '/runtime.json', JSON.stringify({
file: process.env.QL_UPGRADE_FILE ?? null,
repair: process.env.QL_UPGRADE_REPAIR_DEPS ?? null,
launcher: process.env.QL_UPGRADE_LAUNCHER_PID ?? null,
custom: process.env.QA_USER_SETTING,
branch: process.env.QL_BRANCH,
}));
`,
);
const env = Object.freeze({
...f.env,
QL_UPGRADE_FILE: path.join(f.root, 'offline.tar.gz'),
QL_UPGRADE_REPAIR_DEPS: '1',
QL_UPGRADE_LAUNCHER_PID: '12345',
QA_USER_SETTING: 'preserved',
QL_BRANCH: 'develop',
});
await withLifecycleHooks(f.root, f.tmp, env, (hook) => hook('start'));
assert.deepEqual(
JSON.parse(await fs.readFile(path.join(f.root, 'runtime.json'))),
{
file: null,
repair: null,
launcher: null,
custom: 'preserved',
branch: 'develop',
},
);
assert.equal(env.QL_UPGRADE_FILE, path.join(f.root, 'offline.tar.gz'));
assert.equal(env.QL_UPGRADE_REPAIR_DEPS, '1');
});
+350
View File
@@ -0,0 +1,350 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const {
prestartBackend,
dependencyFailure,
probeLimits,
repairDependencies,
} = require('../../cli/src/internal/maintenance/upgradePrestart.cjs');
for (const exitCode of [0, 1])
test(`prestart exit ${exitCode} reaps children retaining output pipes before removing its workspace`, async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-probe-exit-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const source = path.join(root, 'source'),
statics = path.join(root, 'static'),
survivor = path.join(root, 'survivor');
await fs.mkdir(path.join(source, 'node_modules'), { recursive: true });
await fs.mkdir(path.join(statics, 'build/bootstrap'), { recursive: true });
await fs.writeFile(path.join(source, 'version.yaml'), 'version: 2.23.0');
await fs.writeFile(path.join(statics, 'build/app.js'), 'backend');
await fs.writeFile(
path.join(statics, 'build-info.json'),
JSON.stringify({ sourceCommit: 'a'.repeat(40) }),
);
await fs.writeFile(
path.join(statics, 'build/bootstrap/upgradeProbe.js'),
`
const fs=require('node:fs'),crypto=require('node:crypto');
require('node:child_process').spawn(process.execPath,['-e',${JSON.stringify(
`setTimeout(()=>require('node:fs').writeFileSync(${JSON.stringify(
survivor,
)},'escaped'),600);`,
)}],{stdio:'inherit'});
process.send({type:'upgrade-probe',ok:${
exitCode === 0
},message:'simulated prestart failure',build:{sourceCommit:'a'.repeat(40),version:'2.23.0',backendSha256:crypto.createHash('sha256').update('backend').digest('hex')}},()=>process.exit(${exitCode}));
`,
);
const operation = prestartBackend(
{ source, static: statics },
{ target: { arch: 'armv6' }, limits: { heapMb: 128, timeoutMs: 3000 } },
);
if (exitCode) await assert.rejects(operation, /simulated prestart failure/);
else await operation;
assert.deepEqual((await fs.readdir(root)).sort(), ['source', 'static']);
await new Promise((r) => setTimeout(r, 700));
await assert.rejects(fs.access(survivor), { code: 'ENOENT' });
});
for (const phase of ['install', 'rebuild'])
test(`dependency repair ${phase} failure preserves original dependencies, cleans workspace and uses configured proxy`, async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-repair-failure-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const source = path.join(root, 'source'),
bin = path.join(root, 'bin');
await fs.mkdir(path.join(source, 'node_modules'), { recursive: true });
await fs.mkdir(bin);
await fs.writeFile(
path.join(source, 'package.json'),
JSON.stringify({ packageManager: 'pnpm@8.3.1' }),
);
await fs.writeFile(path.join(source, 'pnpm-lock.yaml'), 'frozen lock');
await fs.writeFile(path.join(source, 'node_modules/old'), 'preserved');
const proxy = path.join(root, 'network.json');
await fs.writeFile(
path.join(bin, 'pnpm'),
`#!${process.execPath}
const fs=require('node:fs');
if(process.argv[2]==='--version') {console.log('8.3.1');process.exit(0);}
fs.writeFileSync(${JSON.stringify(
proxy,
)},JSON.stringify({https:process.env.HTTPS_PROXY,no:process.env.no_proxy,options:process.env.NODE_OPTIONS}));
fs.mkdirSync('node_modules',{recursive:true});fs.writeFileSync('node_modules/partial','partial');
if(process.argv[2]===${JSON.stringify(
phase,
)}) {console.error('simulated repair failure');process.exit(12);}
`,
{ mode: 0o755 },
);
await assert.rejects(
repairDependencies(source, {
target: { arch: 'armv6' },
env: {
PATH: bin,
HTTPS_PROXY: 'http://127.0.0.1:7890',
no_proxy: 'localhost,127.0.0.1',
NODE_OPTIONS: '--require untrusted',
},
}),
/simulated repair failure/,
);
assert.equal(
await fs.readFile(path.join(source, 'node_modules/old'), 'utf8'),
'preserved',
);
assert.deepEqual(await fs.readdir(path.join(source, 'node_modules')), [
'old',
]);
assert.deepEqual((await fs.readdir(root)).sort(), [
'bin',
'network.json',
'source',
]);
const network = JSON.parse(await fs.readFile(proxy));
assert.equal(network.https, 'http://127.0.0.1:7890');
assert.equal(network.no, 'localhost,127.0.0.1');
assert.doesNotMatch(network.options, /untrusted/);
});
test('cancelled dependency repair kills descendants and retains the original tree', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-repair-cancel-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const source = path.join(root, 'source'),
bin = path.join(root, 'bin'),
started = path.join(root, 'started'),
survivor = path.join(root, 'survivor');
await fs.mkdir(path.join(source, 'node_modules'), { recursive: true });
await fs.mkdir(bin);
await fs.writeFile(
path.join(source, 'package.json'),
JSON.stringify({ packageManager: 'pnpm@8.3.1' }),
);
await fs.writeFile(path.join(source, 'pnpm-lock.yaml'), 'frozen lock');
await fs.writeFile(path.join(source, 'node_modules/old'), 'preserved');
await fs.writeFile(
path.join(bin, 'pnpm'),
`#!${process.execPath}
if(process.argv[2]==='--version'){console.log('8.3.1');process.exit(0);}
require('node:child_process').spawn(process.execPath,['-e',${JSON.stringify(
`process.on('SIGTERM',()=>{});require('node:fs').writeFileSync(${JSON.stringify(
started,
)},'started');setTimeout(()=>require('node:fs').writeFileSync(${JSON.stringify(
survivor,
)},'escaped'),1400);`,
)}],{stdio:'inherit'});
setInterval(()=>{},1000);
`,
{ mode: 0o755 },
);
const controller = new AbortController();
const operation = repairDependencies(source, {
target: { arch: 'armv6' },
env: { PATH: bin },
signal: controller.signal,
});
const rejection = assert.rejects(operation, /cancel repair/);
let seen = false;
for (let i = 0; i < 150; i++) {
if (await fs.stat(started).catch(() => null)) {
seen = true;
break;
}
await new Promise((r) => setTimeout(r, 20));
}
controller.abort(new Error('cancel repair'));
await rejection;
assert.ok(seen);
assert.equal(
await fs.readFile(path.join(source, 'node_modules/old'), 'utf8'),
'preserved',
);
assert.deepEqual((await fs.readdir(root)).sort(), [
'bin',
'source',
'started',
]);
await new Promise((r) => setTimeout(r, 1500));
await assert.rejects(fs.access(survivor), { code: 'ENOENT' });
});
test('real Node resolution failures distinguish broken dependency entries and internal imports from backend source', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-missing-entry-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const directory = path.join(root, 'node_modules/broken-entry');
await fs.mkdir(directory, { recursive: true });
await fs.writeFile(
path.join(directory, 'package.json'),
JSON.stringify({ main: 'missing.js' }),
);
const load = require('node:module').createRequire(
path.join(root, 'package.json'),
);
assert.throws(
() => load('broken-entry'),
(error) => dependencyFailure(error),
);
await fs.writeFile(
path.join(directory, 'missing.js'),
"require('./missing-internal-file')",
);
assert.throws(
() => load('broken-entry'),
(error) => dependencyFailure(error),
);
await fs.writeFile(
path.join(root, 'api.js'),
"require('./missing-api-route')",
);
assert.throws(
() => load('./api.js'),
(error) => !dependencyFailure(error),
);
});
test('ARMv6 repair isolates configuration, uses frozen install, one compiler job and interpreted Node', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-repair-armv6-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const source = path.join(root, 'source'),
bin = path.join(root, 'bin');
await fs.mkdir(source);
await fs.mkdir(bin);
await fs.writeFile(
path.join(source, 'package.json'),
JSON.stringify({ packageManager: 'pnpm@8.3.1' }),
);
await fs.writeFile(path.join(source, 'pnpm-lock.yaml'), 'frozen lock');
await fs.writeFile(
path.join(bin, 'pnpm'),
`#!${process.execPath}
const fs=require('node:fs');
if(process.argv[2]==='--version') {console.error('harmless runtime warning'); console.log('8.3.1');}
else if(process.argv[2]==='install') {
if(!process.argv.includes('--frozen-lockfile')||!process.argv.includes('--prod')) throw Error('not frozen production install');
fs.mkdirSync('node_modules');fs.writeFileSync('node_modules/flags.json',JSON.stringify({
args:process.argv,options:process.env.NODE_OPTIONS,source:process.env.npm_config_build_from_source,
processConfig:process.env.npm_config_force_process_config,jobs:process.env.npm_config_jobs,make:process.env.MAKEFLAGS
}));
} else if(process.argv.slice(2).join(' ')!=='rebuild sqlite3 @whyour/sqlite3') throw Error('unexpected command');
`,
{ mode: 0o755 },
);
await repairDependencies(source, {
target: { arch: 'armv6' },
env: {
PATH: bin,
NODE_OPTIONS: '--require untrusted',
npm_config_nodedir: '/wrong-node',
},
});
const flags = JSON.parse(
await fs.readFile(path.join(source, 'node_modules/flags.json')),
);
assert.match(flags.options, /--jitless/);
assert.match(flags.options, /max-old-space-size=256/);
assert.equal(flags.source, 'true');
assert.equal(flags.processConfig, 'true');
assert.equal(flags.jobs, '1');
assert.equal(flags.make, '-j1');
assert.deepEqual((await fs.readdir(root)).sort(), ['bin', 'source']);
});
test('repair classification excludes application, platform, config, database, OOM and timeouts', () => {
for (const error of [
{ code: 'MODULE_NOT_FOUND', message: "Cannot find module './api/missing'" },
{ message: 'Bundle platform mismatch' },
{ message: 'SQLITE_CANTOPEN' },
{ message: 'Upgrade prestart timed out' },
{
code: 'ERR_DLOPEN_FAILED',
message: 'node_modules/sqlite3/node.node',
signal: 'SIGKILL',
},
{ message: 'JavaScript heap out of memory' },
])
assert.equal(dependencyFailure(error), false, JSON.stringify(error));
assert.equal(
dependencyFailure({
code: 'MODULE_NOT_FOUND',
message: "Cannot find module 'decimal.js'",
}),
true,
);
assert.equal(
dependencyFailure({
code: 'ERR_DLOPEN_FAILED',
message: 'dlopen /tmp/node_modules/sqlite3/node.node: invalid ELF header',
}),
true,
);
assert.equal(
dependencyFailure({
message:
'Could not locate the bindings file /tmp/node_modules/sqlite3/bindings',
}),
true,
);
assert.deepEqual(probeLimits({ arch: 'armv6' }), {
heapMb: 128,
timeoutMs: 300000,
});
});
test('prestart deadline kills its process group and removes temporary data', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-prestart-timeout-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const source = path.join(root, 'source'),
statics = path.join(root, 'static');
await fs.mkdir(path.join(source, 'node_modules'), { recursive: true });
await fs.writeFile(path.join(source, 'version.yaml'), 'version: 2.23.0');
await fs.mkdir(path.join(statics, 'build/bootstrap'), { recursive: true });
await fs.writeFile(path.join(statics, 'build/app.js'), 'backend');
await fs.writeFile(
path.join(statics, 'build-info.json'),
JSON.stringify({ sourceCommit: 'a'.repeat(40) }),
);
const survivor = path.join(root, 'survivor');
await fs.writeFile(
path.join(statics, 'build/bootstrap/upgradeProbe.js'),
`
require('node:child_process').spawn(process.execPath,['-e',${JSON.stringify(
`setTimeout(()=>require('node:fs').writeFileSync(${JSON.stringify(
survivor,
)},'escaped'),2200)`,
)}],{stdio:'ignore'});
setInterval(()=>{},1000);
`,
);
await assert.rejects(
prestartBackend(
{ source, static: statics },
{
target: { arch: 'armv6' },
limits: { heapMb: 128, timeoutMs: 1000 },
},
),
/timed out/,
);
assert.deepEqual((await fs.readdir(root)).sort(), ['source', 'static']);
await new Promise((resolve) => setTimeout(resolve, 1600));
await assert.rejects(fs.access(survivor), { code: 'ENOENT' });
});
test('repair refuses an unsupported package manager before invoking a network command', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-repair-lock-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const source = path.join(root, 'source');
await fs.mkdir(source);
await fs.writeFile(path.join(source, 'package.json'), '{}');
await assert.rejects(
repairDependencies(source, {
target: { arch: 'armv6' },
env: { PATH: '' },
}),
/Unsupported frozen-lockfile/,
);
assert.deepEqual(await fs.readdir(root), ['source']);
});
+162
View File
@@ -0,0 +1,162 @@
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/);
});
+106
View File
@@ -0,0 +1,106 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ts = require('typescript');
function layout(width, legacy = false) {
const values = [],
listeners = new Set(),
attributes = new Map(),
effects = [];
let cursor = 0,
mounted = false;
const media = { matches: width < 768 };
const add = (fn) => listeners.add(fn),
remove = (fn) => listeners.delete(fn);
Object.assign(
media,
legacy
? { addListener: add, removeListener: remove }
: {
addEventListener: (_, fn) => add(fn),
removeEventListener: (_, fn) => remove(fn),
},
);
const module = { exports: {} };
const source = ts.transpileModule(
fs.readFileSync(path.join(__dirname, '../../src/utils/hooks.ts'), 'utf8'),
{
compilerOptions: {
target: ts.ScriptTarget.ES2020,
module: ts.ModuleKind.CommonJS,
},
},
).outputText;
new Function('require', 'module', 'exports', 'window', 'document', source)(
(name) => {
assert.equal(name, 'react');
return {
useState: (initial) => {
const index = cursor++;
if (!(index in values))
values[index] = typeof initial === 'function' ? initial() : initial;
return [
values[index],
(value) => {
values[index] = value;
},
];
},
useEffect: (fn) => {
if (!mounted) effects.push(fn);
},
};
},
module,
module.exports,
{ matchMedia: () => media, navigator: { userAgent: 'Desktop browser' } },
{ body: { setAttribute: (key, value) => attributes.set(key, value) } },
);
const render = () => {
cursor = 0;
return module.exports.useCtx();
};
const initial = render();
const cleanup = effects.map((fn) => fn());
mounted = true;
return {
initial,
attributes,
listeners,
resize: (width) => {
media.matches = width < 768;
listeners.forEach((fn) => fn());
return render();
},
unmount: () => cleanup.forEach((fn) => fn?.()),
};
}
test('narrow desktop windows retain the top navigation and user controls, and follow viewport changes', () => {
const view = layout(499);
assert.equal(view.initial.isPhone, true);
assert.equal(view.initial.headerStyle.marginTop, 0);
assert.equal(view.attributes.get('data-mode'), 'phone');
assert.equal(view.listeners.size, 1);
const desktop = view.resize(1024);
assert.equal(desktop.isPhone, false);
assert.equal(desktop.headerStyle.marginTop, -48);
assert.equal(view.attributes.get('data-mode'), 'desktop');
const narrow = view.resize(767.5);
assert.equal(narrow.isPhone, true);
assert.equal(narrow.headerStyle.marginTop, 0);
assert.equal(view.attributes.get('data-mode'), 'phone');
assert.equal(view.resize(768).isPhone, false);
view.unmount();
assert.equal(view.listeners.size, 0);
});
test('older media query listeners are removed when the layout unmounts', () => {
const view = layout(1024, true);
assert.equal(view.resize(600).isPhone, true);
assert.equal(view.attributes.get('data-mode'), 'phone');
view.unmount();
assert.equal(view.listeners.size, 0);
});
+36 -3
View File
@@ -335,11 +335,15 @@ test('the persistent yellow sidebar entry shows live progress and remains access
assert.match(p.nodes('button')[0].props['aria-label'], /62%/); assert.match(p.nodes('button')[0].props['aria-label'], /62%/);
p.nodes('button')[0].props.onClick(); p.nodes('button')[0].props.onClick();
p.render({ collapsed: true }); p.render({ collapsed: true });
assert.match(p.nodes('button')[0].props.className, /collapsed/); assert.equal(p.nodes('button').length, 0);
assert.equal(p.nodes(p.Modal)[0].props.open, true); assert.equal(p.nodes(p.Modal)[0].props.open, true);
f.store.setJob({ status: 'ready', phase: 'ready' }); f.store.setJob({ status: 'ready', phase: 'completed' });
p.render(); p.render();
assert.equal(p.nodes('button')[0].props['aria-label'], '更新已就绪'); assert.equal(p.nodes('button').length, 0);
p.nodes(p.Modal)[0].props.onCancel();
p.render({ collapsed: true });
assert.match(p.nodes('button')[0].props.className, /collapsed/);
assert.equal(p.nodes('button')[0].props['aria-label'], '下载完成,等待重启');
}); });
test('completed update refreshes once and reminds once after reconnecting the refreshed page', async () => { test('completed update refreshes once and reminds once after reconnecting the refreshed page', async () => {
@@ -433,3 +437,32 @@ test('update modal uses plain aligned content and explains restart state', () =>
), ),
); );
}); });
test('sidebar and dialog use the same authoritative status even when old phase fields disagree', () => {
const f = fixture(),
notice = f.component('src/components/UpgradeNotice.tsx'),
panel = f.component('src/pages/setting/checkUpdate.tsx');
f.store.setJob({ status: 'ready', phase: 'completed' });
notice.render();
panel.render();
assert.equal(
notice.nodes('button')[0].props['aria-label'],
f.hooks.upgradeStatusLabel(f.store.getSnapshot().job),
);
assert.ok(
panel
.nodes('Text')
.some((node) => node.props.children.includes?.('下载完成,等待重启')),
);
f.store.setJob({ status: 'ready', phase: 'downloading', percent: 62 });
notice.render();
assert.equal(notice.nodes('button')[0].props['aria-label'], '下载完成,等待重启');
f.store.setJob({ status: 'completed', phase: 'ready' });
assert.equal(notice.render(), null);
panel.render();
assert.ok(
panel
.nodes('Text')
.some((node) => node.props.children.includes?.('升级成功')),
);
});
+134
View File
@@ -0,0 +1,134 @@
const fs = require('node:fs/promises');
const path = require('node:path');
const os = require('node:os');
const http = require('node:http');
const { createHash } = require('node:crypto');
const { writeManifest, sourceCommit } = require('./upgrade-fixture.cjs');
async function manualUpgradeFixture(t, wrongVersion = false) {
const root = await fs.realpath(
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-manual-job-')),
);
const tmp = path.join(root, '.tmp');
const workspace = path.join(tmp, 'upgrade-manual');
const selected = {
source: path.join(workspace, 'qinglong-develop'),
static: path.join(workspace, 'qinglong-static-develop'),
};
for (const dir of [
path.join(root, 'static/build'),
path.join(root, 'shell'),
path.join(root, 'data/config'),
path.join(root, 'node_modules'),
path.join(selected.source, 'sample'),
path.join(selected.source, 'node_modules'),
path.join(selected.static, 'build'),
])
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(root, 'static/build/app.js'), 'old backend');
await fs.writeFile(path.join(root, '.env'), 'preserve configuration');
await fs.writeFile(path.join(root, 'data/keep'), 'preserve data');
await fs.writeFile(path.join(root, 'node_modules/old'), 'old dependency');
await fs.writeFile(
path.join(root, 'shell/share.sh'),
'import_config(){ :; }\ndelete_pm2(){ :; }\nreload_pm2(){ :; }\n',
);
await fs.writeFile(path.join(root, 'shell/env.sh'), '');
await fs.writeFile(
path.join(selected.source, 'sample/config.sample.sh'),
'# sample',
);
await fs.writeFile(
path.join(selected.source, 'package.json'),
'{"version":"2.24.0"}',
);
await fs.writeFile(
path.join(selected.source, 'version.yaml'),
'version: 2.24.0\n',
);
await fs.writeFile(path.join(selected.static, 'build/app.js'), 'new backend');
writeManifest(selected.source, selected.static);
const manifest = JSON.parse(
await fs.readFile(path.join(selected.static, 'build-info.json')),
);
const expected = {
version: '2.24.0',
sourceCommit,
backendSha256: manifest.files['build/app.js'],
stageDirectory: 'upgrade-manual',
};
await fs.writeFile(
path.join(workspace, 'ready.json'),
JSON.stringify({
...selected,
root,
sourceCommit,
dependenciesChanged: true,
}),
);
await fs.writeFile(
path.join(tmp, 'upgrade-ready-develop.json'),
'{"directory":"upgrade-manual"}',
);
await fs.writeFile(
path.join(tmp, 'panel-upgrade.json'),
JSON.stringify({
id: 'offline-prepared',
action: 'download',
status: 'ready',
phase: 'ready',
...expected,
}),
);
const server = http.createServer(async (_request, response) => {
const content = await fs
.readFile(path.join(root, 'static/build/app.js'), 'utf8')
.catch(() => 'stopped');
const updated = content === 'new backend';
response.setHeader('Content-Type', 'application/json');
response.end(
JSON.stringify({
code: 200,
data: {
status: 'ok',
services: { http: true, grpc: true },
build: {
version: updated ? '2.24.0' : '2.23.0',
sourceCommit:
updated && !wrongVersion ? sourceCommit : 'b'.repeat(40),
backendSha256: createHash('sha256').update(content).digest('hex'),
instanceId: updated ? 'new-instance' : 'old-instance',
parentPid: updated ? 222 : 111,
},
},
}),
);
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
t.after(async () => {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
await fs.rm(root, { recursive: true, force: true });
});
const env = {
...process.env,
QL_DIR: root,
QL_BRANCH: 'develop',
dir_tmp: tmp,
dir_static: path.join(root, 'static'),
dir_data: path.join(root, 'data'),
dir_config: path.join(root, 'data/config'),
QlPort: String(server.address().port),
QlBaseUrl: '/',
};
return {
root,
tmp,
selected,
env,
expected,
state: async () =>
JSON.parse(await fs.readFile(path.join(tmp, 'panel-upgrade.json'))),
};
}
module.exports = { manualUpgradeFixture };