mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-04 00:42:01 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa150f051e |
@@ -9,9 +9,7 @@ on:
|
|||||||
- "!cli/**"
|
- "!cli/**"
|
||||||
- "cli/src/internal/maintenance/upgradeArtifacts.cjs"
|
- "cli/src/internal/maintenance/upgradeArtifacts.cjs"
|
||||||
- "cli/src/internal/maintenance/platformBundle.cjs"
|
- "cli/src/internal/maintenance/platformBundle.cjs"
|
||||||
- "cli/src/internal/maintenance/upgradePrestart.cjs"
|
|
||||||
- "cli/src/internal/maintenance/panelUpgrade.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"
|
||||||
@@ -37,14 +35,6 @@ 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 }}
|
||||||
@@ -80,7 +70,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version-file: ".nvmrc"
|
node-version: "20"
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
cache-dependency-path: pnpm-lock.yaml
|
cache-dependency-path: pnpm-lock.yaml
|
||||||
|
|
||||||
@@ -91,9 +81,6 @@ 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 }}
|
||||||
@@ -101,31 +88,19 @@ 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: |
|
||||||
: "${STATIC_TOKEN:?Missing static repository token}"
|
bash scripts/publish-static.sh "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_REF_TYPE"
|
||||||
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 }}
|
||||||
STATIC_TOKEN: ${{ secrets.API_TOKEN }}
|
run: node scripts/publish-upgrade-info.cjs "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_SHA"
|
||||||
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
|
||||||
@@ -247,7 +222,7 @@ jobs:
|
|||||||
version: "8.3.1"
|
version: "8.3.1"
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "20"
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
cache-dependency-path: pnpm-lock.yaml
|
cache-dependency-path: pnpm-lock.yaml
|
||||||
|
|
||||||
@@ -336,7 +311,7 @@ jobs:
|
|||||||
version: "8.3.1"
|
version: "8.3.1"
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "20"
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
cache-dependency-path: pnpm-lock.yaml
|
cache-dependency-path: pnpm-lock.yaml
|
||||||
|
|
||||||
@@ -394,7 +369,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/arm64,linux/ppc64le,linux/s390x
|
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
||||||
context: .
|
context: .
|
||||||
file: ./docker/Dockerfile.debian
|
file: ./docker/Dockerfile.debian
|
||||||
push: true
|
push: true
|
||||||
@@ -405,78 +380,175 @@ jobs:
|
|||||||
cache-from: type=registry,ref=whyour/qinglong:cache-debian
|
cache-from: type=registry,ref=whyour/qinglong:cache-debian
|
||||||
cache-to: type=registry,ref=whyour/qinglong:cache-debian,mode=max
|
cache-to: type=registry,ref=whyour/qinglong:cache-debian,mode=max
|
||||||
|
|
||||||
# One unmodified amd64 package for migration acceptance only. This does not
|
build-alpine310:
|
||||||
# publish bundles or advance development/formal channel indexes.
|
if: ${{ github.ref_name == 'master' }}
|
||||||
build-npm-migration-bundle:
|
needs: build-static
|
||||||
needs: build-debian
|
runs-on: ubuntu-22.04
|
||||||
runs-on: ubuntu-latest
|
permissions:
|
||||||
timeout-minutes: 20
|
packages: write
|
||||||
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
- uses: docker/setup-buildx-action@v4
|
- uses: actions/download-artifact@v7
|
||||||
- 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:
|
with:
|
||||||
name: npm-migration-bundle-${{ github.sha }}
|
name: qinglong-static-${{ github.sha }}
|
||||||
path: ${{ runner.temp }}/npm-migration-bundle/*
|
path: static/
|
||||||
if-no-files-found: error
|
- uses: pnpm/action-setup@v6
|
||||||
retention-days: 3
|
with:
|
||||||
|
version: "8.3.1"
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: "pnpm"
|
||||||
|
cache-dependency-path: pnpm-lock.yaml
|
||||||
|
|
||||||
verify-npm-package:
|
- name: Read version from version.yaml
|
||||||
needs: [build-static, build-npm-migration-bundle]
|
id: version
|
||||||
runs-on: ubuntu-latest
|
run: |
|
||||||
timeout-minutes: 30
|
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
|
||||||
strategy:
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
- name: Setup timezone
|
||||||
node: ['20.18.1', '24']
|
run: sudo timedatectl set-timezone Asia/Shanghai
|
||||||
container: node:${{ matrix.node }}-bookworm
|
|
||||||
|
- name: Login to DockerHub
|
||||||
|
uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Login to GHCR
|
||||||
|
uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v4
|
||||||
|
with:
|
||||||
|
cache-image: false
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
|
- name: Build and push (Alpine Python 3.10)
|
||||||
|
uses: docker/build-push-action@v7
|
||||||
|
with:
|
||||||
|
build-args: |
|
||||||
|
QL_MAINTAINER=${{ github.repository_owner }}
|
||||||
|
QL_BRANCH=${{ github.ref_name }}
|
||||||
|
SOURCE_COMMIT=${{ github.sha }}
|
||||||
|
network: host
|
||||||
|
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
|
||||||
|
context: .
|
||||||
|
file: ./docker/Dockerfile.310
|
||||||
|
push: true
|
||||||
|
provenance: mode=max
|
||||||
|
sbom: true
|
||||||
|
tags: |
|
||||||
|
whyour/qinglong:python3.10
|
||||||
|
whyour/qinglong:${{ steps.version.outputs.version }}-python3.10
|
||||||
|
cache-from: type=registry,ref=whyour/qinglong:cache-alpine-python3.10
|
||||||
|
cache-to: type=registry,ref=whyour/qinglong:cache-alpine-python3.10,mode=max
|
||||||
|
|
||||||
|
build-debian310:
|
||||||
|
if: ${{ github.ref_name == 'master' }}
|
||||||
|
needs: build-static
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
- uses: actions/download-artifact@v6
|
- uses: actions/download-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: qinglong-npm-${{ github.sha }}
|
name: qinglong-static-${{ github.sha }}
|
||||||
path: ${{ runner.temp }}/npm-panel/
|
path: static/
|
||||||
- uses: actions/download-artifact@v6
|
- uses: pnpm/action-setup@v6
|
||||||
with:
|
with:
|
||||||
name: npm-migration-bundle-${{ github.sha }}
|
version: "8.3.1"
|
||||||
path: ${{ runner.temp }}/npm-migration-bundle/
|
- uses: actions/setup-node@v6
|
||||||
- name: Install isolated service test tools
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: "pnpm"
|
||||||
|
cache-dependency-path: pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: Read version from version.yaml
|
||||||
|
id: version
|
||||||
run: |
|
run: |
|
||||||
apt-get update
|
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
|
||||||
apt-get install -y --no-install-recommends python3 openssl curl jq nginx procps
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
# The published legacy launcher resets PATH to standard directories.
|
|
||||||
# /usr/local is confined to this disposable job container; PM2_HOME
|
- name: Setup timezone
|
||||||
# remains private to each verification run.
|
run: sudo timedatectl set-timezone Asia/Shanghai
|
||||||
npm install --global --prefix /usr/local --no-audit --no-fund pm2@6.0.14
|
|
||||||
- name: Install and upgrade the exact npm archive
|
- name: Login to DockerHub
|
||||||
env:
|
uses: docker/login-action@v4
|
||||||
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:
|
with:
|
||||||
name: qinglong-npm-verification-${{ matrix.node }}-${{ github.sha }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
path: ${{ runner.temp }}/npm-panel-results/
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 14
|
- name: Login to GHCR
|
||||||
|
uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v4
|
||||||
|
with:
|
||||||
|
cache-image: false
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
|
- name: Build and push (Debian Python 3.10)
|
||||||
|
uses: docker/build-push-action@v7
|
||||||
|
with:
|
||||||
|
build-args: |
|
||||||
|
QL_MAINTAINER=${{ github.repository_owner }}
|
||||||
|
QL_BRANCH=${{ github.ref_name }}
|
||||||
|
SOURCE_COMMIT=${{ github.sha }}
|
||||||
|
network: host
|
||||||
|
# Keep s390x on Debian: npm can hang under QEMU with Alpine (nodejs/docker-node#1973).
|
||||||
|
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
||||||
|
context: .
|
||||||
|
file: ./docker/Dockerfile.debian310
|
||||||
|
push: true
|
||||||
|
provenance: mode=max
|
||||||
|
sbom: true
|
||||||
|
tags: |
|
||||||
|
whyour/qinglong:debian-python3.10
|
||||||
|
whyour/qinglong:${{ steps.version.outputs.version }}-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
|
||||||
|
|
||||||
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, verify-npm-package]
|
needs: [build-alpine, build-debian, build-alpine310, build-debian310]
|
||||||
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:
|
||||||
@@ -484,45 +556,26 @@ 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
|
||||||
shopt -s nullglob
|
name=$(node -p 'require("./package.json").name')
|
||||||
archives=(npm-panel/*.tgz)
|
version=$(node -p 'require("./package.json").version')
|
||||||
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 "./$archive" --ignore-scripts --access public --registry=https://registry.npmjs.org
|
npm publish --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:
|
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]
|
needs: [build-alpine, build-debian]
|
||||||
# Node 24 can SIGILL while archiving ARM64 musl under x64 QEMU.
|
runs-on: ubuntu-22.04
|
||||||
runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-22.04' }}
|
timeout-minutes: 45
|
||||||
timeout-minutes: 90
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -535,13 +588,13 @@ jobs:
|
|||||||
- { platform: linux/s390x, libc: musl, name: s390x-musl }
|
- { platform: linux/s390x, libc: musl, name: s390x-musl }
|
||||||
- { platform: linux/386, libc: musl, name: ia32-musl }
|
- { platform: linux/386, libc: musl, name: ia32-musl }
|
||||||
- { platform: linux/amd64, libc: glibc, name: x64-glibc }
|
- { platform: linux/amd64, libc: glibc, name: x64-glibc }
|
||||||
|
- { platform: linux/arm/v7, libc: glibc, name: armv7-glibc }
|
||||||
- { platform: linux/arm64, libc: glibc, name: arm64-glibc }
|
- { platform: linux/arm64, libc: glibc, name: arm64-glibc }
|
||||||
- { platform: linux/ppc64le, libc: glibc, name: ppc64-glibc }
|
- { platform: linux/ppc64le, libc: glibc, name: ppc64-glibc }
|
||||||
- { platform: linux/s390x, libc: glibc, name: s390x-glibc }
|
- { platform: linux/s390x, libc: glibc, name: s390x-glibc }
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
- uses: docker/setup-qemu-action@v4
|
- uses: docker/setup-qemu-action@v4
|
||||||
if: ${{ matrix.platform != 'linux/arm64' }}
|
|
||||||
with:
|
with:
|
||||||
cache-image: false
|
cache-image: false
|
||||||
- uses: docker/setup-buildx-action@v4
|
- uses: docker/setup-buildx-action@v4
|
||||||
@@ -556,8 +609,7 @@ jobs:
|
|||||||
digest="$ALPINE_DIGEST"
|
digest="$ALPINE_DIGEST"
|
||||||
if [ "$BUNDLE_LIBC" = glibc ]; then digest="$DEBIAN_DIGEST"; fi
|
if [ "$BUNDLE_LIBC" = glibc ]; then digest="$DEBIAN_DIGEST"; fi
|
||||||
test -n "$digest"
|
test -n "$digest"
|
||||||
image="ghcr.io/${GITHUB_REPOSITORY}@${digest}"
|
docker buildx build --platform "$BUNDLE_PLATFORM" --build-arg "BASE_IMAGE=ghcr.io/${GITHUB_REPOSITORY}@${digest}" --file docker/Dockerfile.upgrade-bundle --output type=local,dest=output/upgrade .
|
||||||
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
|
- uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: upgrade-${{ matrix.name }}-${{ github.sha }}
|
name: upgrade-${{ matrix.name }}-${{ github.sha }}
|
||||||
@@ -565,7 +617,6 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
publish-upgrade-bundles:
|
publish-upgrade-bundles:
|
||||||
if: ${{ !inputs.static_sync_only && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/develop') }}
|
|
||||||
needs: build-upgrade-bundles
|
needs: build-upgrade-bundles
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
@@ -577,7 +628,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '24'
|
node-version: '20'
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: pnpm/action-setup@v6
|
||||||
with:
|
with:
|
||||||
version: '8.3.1'
|
version: '8.3.1'
|
||||||
|
|||||||
@@ -6,11 +6,6 @@ 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:
|
||||||
@@ -46,13 +41,6 @@ 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]
|
||||||
@@ -69,7 +57,15 @@ jobs:
|
|||||||
- image: debian
|
- image: debian
|
||||||
file: docker/Dockerfile.debian
|
file: docker/Dockerfile.debian
|
||||||
cache: debian
|
cache: debian
|
||||||
platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/s390x
|
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
||||||
|
- image: alpine-python3.10
|
||||||
|
file: docker/Dockerfile.310
|
||||||
|
cache: alpine-python3.10
|
||||||
|
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
|
||||||
|
- image: debian-python3.10
|
||||||
|
file: docker/Dockerfile.debian310
|
||||||
|
cache: debian-python3.10
|
||||||
|
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
@@ -125,47 +121,16 @@ 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: Load ARMv6 image for SQLite and Node regression checks
|
- name: Export native Debian image for isolated dev validation
|
||||||
if: startsWith(matrix.image, 'alpine')
|
if: matrix.image == 'debian'
|
||||||
uses: docker/build-push-action@v7
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ${{ matrix.file }}
|
|
||||||
platforms: linux/arm/v6
|
|
||||||
build-args: |
|
|
||||||
QL_URL=${{ steps.source.outputs.url }}
|
|
||||||
QL_BRANCH=${{ steps.source.outputs.branch }}
|
|
||||||
SOURCE_COMMIT=${{ steps.source.outputs.sha }}
|
|
||||||
tags: qinglong-readiness:${{ matrix.image }}-armv6
|
|
||||||
load: true
|
|
||||||
push: false
|
|
||||||
provenance: false
|
|
||||||
sbom: false
|
|
||||||
- name: Verify ARMv6 SQLite on an ARM1176 CPU without networking
|
|
||||||
if: startsWith(matrix.image, 'alpine')
|
|
||||||
run: |
|
run: |
|
||||||
docker run --rm --network none --platform linux/arm/v6 \
|
docker save qinglong-readiness:debian | gzip -1 > release-readiness-debian-image.tar.gz
|
||||||
-e QEMU_CPU=arm1176 --entrypoint node \
|
sha256sum release-readiness-debian-image.tar.gz > release-readiness-debian-image.sha256
|
||||||
qinglong-readiness:${{ matrix.image }}-armv6 --jitless \
|
docker image inspect qinglong-readiness:debian > release-readiness-debian-image.json
|
||||||
-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
|
- uses: actions/upload-artifact@v6
|
||||||
if: matrix.image == 'debian' || matrix.image == 'alpine'
|
if: matrix.image == 'debian'
|
||||||
with:
|
with:
|
||||||
name: release-readiness-${{ matrix.image }}-image
|
name: release-readiness-debian-image
|
||||||
path: release-readiness-${{ matrix.image }}-image.*
|
path: release-readiness-debian-image.*
|
||||||
compression-level: 0
|
compression-level: 0
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ __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
@@ -0,0 +1,22 @@
|
|||||||
|
/.tmp/
|
||||||
|
/.github/
|
||||||
|
/.vscode/
|
||||||
|
/.history/
|
||||||
|
/back/**/*.ts
|
||||||
|
/back/**/*.json
|
||||||
|
/cli/
|
||||||
|
/data/
|
||||||
|
/src/
|
||||||
|
/static/**/*.js.map
|
||||||
|
/static/**/*.gz
|
||||||
|
/.editorconfig
|
||||||
|
/.gitignore
|
||||||
|
/.prettierignore
|
||||||
|
/.prettierrc
|
||||||
|
/.umirc.ts
|
||||||
|
/nodemon.json
|
||||||
|
/pnpm-lock.yaml
|
||||||
|
/tsconfig.back.json
|
||||||
|
/tsconfig.json
|
||||||
|
/typings.d.ts
|
||||||
|
/.env
|
||||||
+28
-11
@@ -41,18 +41,11 @@ 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, readyTimeout)
|
this.waitForWorkerReady(grpcWorker, 30000)
|
||||||
.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');
|
||||||
@@ -78,7 +71,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, readyTimeout)
|
this.waitForWorkerReady(newGrpcWorker, 30000)
|
||||||
.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
|
||||||
@@ -155,8 +148,32 @@ class Application {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async setupMiddlewares(): Promise<express.Application> {
|
private async setupMiddlewares(): Promise<express.Application> {
|
||||||
const { createHttpApplication } = await import('./loaders/httpApplication');
|
// Only the HTTP worker needs an Express application and its middleware.
|
||||||
return createHttpApplication();
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupMasterShutdown() {
|
private setupMasterShutdown() {
|
||||||
|
|||||||
@@ -1,139 +0,0 @@
|
|||||||
// 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);
|
|
||||||
});
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
const boundPort = await bindAsync(address, credentials);
|
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 boundPort;
|
return grpcPort;
|
||||||
} 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);
|
||||||
|
|||||||
@@ -212,9 +212,6 @@ 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',
|
||||||
|
|||||||
+28
-85
@@ -4,6 +4,7 @@ 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';
|
||||||
@@ -16,7 +17,7 @@ export function runCron(
|
|||||||
const execute = async () => {
|
const execute = async () => {
|
||||||
try {
|
try {
|
||||||
if (!isCurrent()) return;
|
if (!isCurrent()) return;
|
||||||
// A single-instance trigger must not interrupt work already in progress.
|
// Check if the cron is already running and stop it (only if multiple instances are not allowed)
|
||||||
try {
|
try {
|
||||||
const existingCron = await CrontabModel.findOne({
|
const existingCron = await CrontabModel.findOne({
|
||||||
where: { id: Number(cron.id) },
|
where: { id: Number(cron.id) },
|
||||||
@@ -29,93 +30,35 @@ export function runCron(
|
|||||||
const allowSingleInstances =
|
const allowSingleInstances =
|
||||||
existingCron?.allow_multiple_instances === 0;
|
existingCron?.allow_multiple_instances === 0;
|
||||||
|
|
||||||
if (allowSingleInstances) {
|
if (
|
||||||
// A queued manual run is an active reservation, even if its stored PID
|
allowSingleInstances &&
|
||||||
// belongs to a previous execution.
|
existingCron &&
|
||||||
if (
|
existingCron.pid &&
|
||||||
existingCron.status === CrontabStatus.queued &&
|
(existingCron.status === CrontabStatus.running ||
|
||||||
existingCron.pid
|
existingCron.status === CrontabStatus.queued)
|
||||||
) {
|
) {
|
||||||
Logger.info(
|
Logger.info(
|
||||||
'[schedule][Skip queued single-instance task] Task ID: %s, PID: %s',
|
t('[schedule][停止已运行任务] 任务ID: %s, PID: %s'),
|
||||||
cron.id,
|
cron.id,
|
||||||
existingCron.pid,
|
existingCron.pid,
|
||||||
);
|
);
|
||||||
return;
|
await killTask(existingCron.pid, true);
|
||||||
}
|
// Mark old running instances as stopped
|
||||||
if (
|
const stoppedAt = dayjs().unix();
|
||||||
existingCron.status === CrontabStatus.running &&
|
await RunningInstanceModel.update(
|
||||||
existingCron.pid
|
{ status: InstanceStatus.stopped, finished_at: stoppedAt },
|
||||||
) {
|
{
|
||||||
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;
|
);
|
||||||
const [recovered] = await CrontabModel.update(
|
// Update the status to idle after killing
|
||||||
{
|
await CrontabModel.update(
|
||||||
status: CrontabStatus.idle,
|
{ status: CrontabStatus.idle, pid: undefined },
|
||||||
pid: null,
|
{ where: { id: Number(cron.id) } },
|
||||||
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(
|
||||||
@@ -126,7 +69,7 @@ export function runCron(
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidation can happen during the asynchronous database check.
|
// In particular, invalidation can happen while stopping a previous run.
|
||||||
if (!isCurrent()) return;
|
if (!isCurrent()) return;
|
||||||
Logger.info(
|
Logger.info(
|
||||||
t('[schedule][开始执行任务] 参数 %s'),
|
t('[schedule][开始执行任务] 参数 %s'),
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const backend = read(path.resolve(__dirname, '../app.js'));
|
|||||||
const root = process.env.QL_DIR || path.resolve(__dirname, '../../..');
|
const root = process.env.QL_DIR || path.resolve(__dirname, '../../..');
|
||||||
const version = read(path.join(root, 'version.yaml'))
|
const version = read(path.join(root, 'version.yaml'))
|
||||||
?.toString()
|
?.toString()
|
||||||
.match(/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?)/m)?.[1];
|
.match(/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+)/m)?.[1];
|
||||||
|
|
||||||
export const runtimeBuild = Object.freeze({
|
export const runtimeBuild = Object.freeze({
|
||||||
instanceId: randomUUID(),
|
instanceId: randomUUID(),
|
||||||
|
|||||||
@@ -20,10 +20,8 @@ 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 = {
|
||||||
@@ -32,27 +30,7 @@ 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()
|
extra_schedules: Joi.array().optional().allow(null),
|
||||||
.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()
|
||||||
|
|||||||
Generated
+4
-78
@@ -1,13 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@whyour/qinglong-cli",
|
"name": "@whyour/qinglong-cli",
|
||||||
"version": "0.1.3",
|
"version": "0.1.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@whyour/qinglong-cli",
|
"name": "@whyour/qinglong-cli",
|
||||||
"version": "0.1.3",
|
"version": "0.1.2",
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"ql": "dist/npm/ql.js"
|
"ql": "dist/npm/ql.js"
|
||||||
},
|
},
|
||||||
@@ -15,12 +14,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",
|
||||||
@@ -464,19 +463,6 @@
|
|||||||
"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",
|
||||||
@@ -487,16 +473,6 @@
|
|||||||
"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",
|
||||||
@@ -549,46 +525,6 @@
|
|||||||
"@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",
|
||||||
@@ -609,16 +545,6 @@
|
|||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@whyour/qinglong-cli",
|
"name": "@whyour/qinglong-cli",
|
||||||
"version": "0.1.3",
|
"version": "0.1.2",
|
||||||
"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,8 +26,7 @@
|
|||||||
"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": {
|
||||||
|
|||||||
+2
-17
@@ -18,24 +18,9 @@ fs.copyFileSync(
|
|||||||
path.join(root, 'dist/internal/maintenance/upgradeArtifacts.cjs'),
|
path.join(root, 'dist/internal/maintenance/upgradeArtifacts.cjs'),
|
||||||
);
|
);
|
||||||
fs.copyFileSync(
|
fs.copyFileSync(
|
||||||
path.join(root, 'src/internal/maintenance/panelUpgrade.cjs'),
|
path.join(root, 'src/internal/maintenance/platformBundle.cjs'),
|
||||||
path.join(root, 'dist/internal/maintenance/panelUpgrade.cjs'),
|
path.join(root, 'dist/internal/maintenance/platformBundle.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'),
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
// Node 24's ARMv6 JS compiler tiers can trap on ARM1176. Keep WebAssembly
|
|
||||||
// enabled for fetch/undici; --jitless disables their HTTP parser as well.
|
|
||||||
const armv6ServerArgs = Object.freeze([
|
|
||||||
'--no-opt',
|
|
||||||
'--no-sparkplug',
|
|
||||||
'--no-maglev',
|
|
||||||
]);
|
|
||||||
function serverRuntimeArgs(runtime = process) {
|
|
||||||
return runtime.arch === 'arm' &&
|
|
||||||
Number(runtime.config.variables.arm_version) === 6 &&
|
|
||||||
Number(runtime.versions.node.split('.')[0]) >= 24
|
|
||||||
? [...armv6ServerArgs]
|
|
||||||
: [];
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { serverRuntimeArgs, armv6ServerArgs };
|
|
||||||
if (require.main === module)
|
|
||||||
process.stdout.write(serverRuntimeArgs().join(' '));
|
|
||||||
@@ -7,10 +7,6 @@ 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,
|
||||||
@@ -121,11 +117,7 @@ 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 (
|
if (match[3] !== entry) continue;
|
||||||
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 {
|
||||||
@@ -158,14 +150,6 @@ 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) {
|
||||||
@@ -187,7 +171,7 @@ export async function startPanel(
|
|||||||
0o600,
|
0o600,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
const child = spawn(process.execPath, [...serverRuntimeArgs(), entry], {
|
const child = spawn(process.execPath, [entry], {
|
||||||
...options,
|
...options,
|
||||||
detached: true,
|
detached: true,
|
||||||
stdio: ['ignore', log.fd, log.fd],
|
stdio: ['ignore', log.fd, log.fd],
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ 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');
|
||||||
@@ -61,9 +60,7 @@ async function withPanelLifecycle(root, tmp, env, operation, signal) {
|
|||||||
|
|
||||||
async function targetVersion(source) {
|
async function targetVersion(source) {
|
||||||
const text = await fs.readFile(path.join(source, 'version.yaml'), 'utf8');
|
const text = await fs.readFile(path.join(source, 'version.yaml'), 'utf8');
|
||||||
const version = text.match(
|
const version = text.match(/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+)/m)?.[1];
|
||||||
/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?)/m,
|
|
||||||
)?.[1];
|
|
||||||
if (!version) throw new Error('Invalid update version');
|
if (!version) throw new Error('Invalid update version');
|
||||||
return version;
|
return version;
|
||||||
}
|
}
|
||||||
@@ -203,93 +200,6 @@ 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');
|
||||||
@@ -310,14 +220,12 @@ async function startJob({ root, tmp, env = process.env }, action) {
|
|||||||
let selected;
|
let selected;
|
||||||
let metadata;
|
let metadata;
|
||||||
if (action === 'apply') {
|
if (action === 'apply') {
|
||||||
// Keep requests short on slow devices. The detached worker performs
|
// Fail before launching a process or stopping the working panel.
|
||||||
// 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')
|
||||||
@@ -482,7 +390,7 @@ async function runJob({ root, tmp, env = process.env }, id) {
|
|||||||
);
|
);
|
||||||
await artifacts.waitForHealth(
|
await artifacts.waitForHealth(
|
||||||
env,
|
env,
|
||||||
undefined,
|
60000,
|
||||||
recover ? undefined : controller.signal,
|
recover ? undefined : controller.signal,
|
||||||
);
|
);
|
||||||
if (!recover)
|
if (!recover)
|
||||||
@@ -523,28 +431,22 @@ async function runJob({ root, tmp, env = process.env }, id) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getStatus,
|
getStatus,
|
||||||
saveState: save,
|
|
||||||
startJob,
|
startJob,
|
||||||
runJob,
|
runJob,
|
||||||
withPanelLifecycle,
|
withPanelLifecycle,
|
||||||
readRunningBuild,
|
readRunningBuild,
|
||||||
verifyRunningVersion,
|
verifyRunningVersion,
|
||||||
stagedMetadata,
|
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(
|
const child = spawn(process.execPath, [__filename, 'worker', id], {
|
||||||
process.execPath,
|
env: { ...process.env, QL_UPGRADE_LAUNCHER_PID: String(process.pid) },
|
||||||
[...nodeRuntimeArgs(), __filename, 'worker', id],
|
detached: true,
|
||||||
{
|
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);
|
||||||
|
|||||||
@@ -6,19 +6,7 @@ const os = require('node:os');
|
|||||||
const { execFile } = require('node:child_process');
|
const { execFile } = require('node:child_process');
|
||||||
const { promisify } = require('node:util');
|
const { promisify } = require('node:util');
|
||||||
const tar = require('tar');
|
const tar = require('tar');
|
||||||
const { serverRuntimeArgs } = require('./nodeRuntime.cjs');
|
|
||||||
const {
|
|
||||||
prestartBackend,
|
|
||||||
dependencyFailure,
|
|
||||||
repairDependencies,
|
|
||||||
} = require('./upgradePrestart.cjs');
|
|
||||||
const execute = promisify(execFile);
|
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) =>
|
const sha256 = (bytes) =>
|
||||||
crypto.createHash('sha256').update(bytes).digest('hex');
|
crypto.createHash('sha256').update(bytes).digest('hex');
|
||||||
const inside = (root, filename) => {
|
const inside = (root, filename) => {
|
||||||
@@ -210,14 +198,10 @@ async function smokeDependencies(source, signal) {
|
|||||||
db.serialize(()=>{db.run('CREATE TABLE verify_bundle(value INTEGER)');db.run('INSERT INTO verify_bundle VALUES(42)');
|
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();});});
|
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
|
await execute(process.execPath, ['-e', probe], {
|
||||||
// WebAssembly, which --jitless disables; only archive workers need that flag.
|
|
||||||
await execute(process.execPath, [...serverRuntimeArgs(process), '-e', probe], {
|
|
||||||
cwd: source,
|
cwd: source,
|
||||||
env: { ...process.env, NODE_PATH: '', NODE_OPTIONS: '' },
|
env: { ...process.env, NODE_PATH: '', NODE_OPTIONS: '' },
|
||||||
// ARM1176 devices need the same startup allowance as the backend probe.
|
timeout: 30000,
|
||||||
// Keep this bounded and cancellable without forcing an unnecessary repair.
|
|
||||||
timeout: nodeRuntimeArgs().length ? 300000 : 30000,
|
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -293,8 +277,6 @@ async function stageBundle({
|
|||||||
verifyStatic,
|
verifyStatic,
|
||||||
download,
|
download,
|
||||||
probe = smokeDependencies,
|
probe = smokeDependencies,
|
||||||
prestart = prestartBackend,
|
|
||||||
repair = repairDependencies,
|
|
||||||
}) {
|
}) {
|
||||||
let local = env.QL_UPGRADE_FILE;
|
let local = env.QL_UPGRADE_FILE;
|
||||||
if (!local) {
|
if (!local) {
|
||||||
@@ -417,54 +399,7 @@ async function stageBundle({
|
|||||||
if (build.sourceCommit !== manifest.sourceCommit)
|
if (build.sourceCommit !== manifest.sourceCommit)
|
||||||
throw new Error('Bundle and build commit differ');
|
throw new Error('Bundle and build commit differ');
|
||||||
await report({ phase: 'dependencies', percent: null });
|
await report({ phase: 'dependencies', percent: null });
|
||||||
let dependencyFiles;
|
await probe(selected.source, signal);
|
||||||
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(
|
await fs.writeFile(
|
||||||
path.join(workspace, 'bundle.json'),
|
path.join(workspace, 'bundle.json'),
|
||||||
JSON.stringify(manifest),
|
JSON.stringify(manifest),
|
||||||
@@ -510,30 +445,6 @@ async function stageBundle({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
async function verifyStagedBundle(selected) {
|
||||||
const manifest = JSON.parse(
|
const manifest = JSON.parse(
|
||||||
await fs.readFile(
|
await fs.readFile(
|
||||||
@@ -541,33 +452,11 @@ async function verifyStagedBundle(selected) {
|
|||||||
'utf8',
|
'utf8',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const prestart = JSON.parse(
|
await verifyPayload(selected, manifest, await runtimeTarget());
|
||||||
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);
|
await smokeDependencies(selected.source);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
nodeRuntimeArgs,
|
|
||||||
atLeast,
|
atLeast,
|
||||||
runtimeTarget,
|
runtimeTarget,
|
||||||
targetKey,
|
targetKey,
|
||||||
|
|||||||
@@ -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, recover?: boolean): Promise<unknown>;
|
start(context: LocalContext): 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, true);
|
await lifecycle.start(context);
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
translate(
|
translate(
|
||||||
@@ -300,31 +300,14 @@ export async function reloadPanel(
|
|||||||
return await artifacts.withUpgradeLock(
|
return await artifacts.withUpgradeLock(
|
||||||
context.paths.dir_tmp!,
|
context.paths.dir_tmp!,
|
||||||
() =>
|
() =>
|
||||||
require('./panelUpgrade.cjs').withPreparedJob(
|
replaceAndReload(context, replacements, {
|
||||||
context.paths.dir_tmp!,
|
stop: stopPanel,
|
||||||
context.env,
|
start: async (ctx) => {
|
||||||
selected,
|
const service = await startPanel(ctx);
|
||||||
(job?: {
|
await artifacts.waitForHealth(ctx.env, 60000, operationSignal());
|
||||||
onStop(): Promise<void>;
|
return service;
|
||||||
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 {
|
||||||
|
|||||||
@@ -226,11 +226,7 @@ async function downloadWithProgress(
|
|||||||
'--connect-timeout',
|
'--connect-timeout',
|
||||||
'30',
|
'30',
|
||||||
'--max-time',
|
'--max-time',
|
||||||
String(
|
'600',
|
||||||
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',
|
||||||
@@ -495,12 +491,7 @@ async function stageUpgrade({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectedUpgrade(
|
async function selectedUpgrade(tmp, branch, env = process.env) {
|
||||||
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'),
|
||||||
);
|
);
|
||||||
@@ -524,12 +515,10 @@ async function selectedUpgrade(
|
|||||||
);
|
);
|
||||||
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 = metadataOnly
|
const manifest = await verifyUpgrade(selected.source, selected.static, env);
|
||||||
? 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)
|
if (ready.bundle)
|
||||||
await require('./platformBundle.cjs').verifyStagedBundle(selected);
|
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'));
|
||||||
@@ -606,7 +595,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(true);
|
if (attemptedStart) await lifecycle.stop();
|
||||||
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) {
|
||||||
@@ -675,16 +664,7 @@ async function reloadSystem({
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
function startupHealthTimeout(runtime = process) {
|
async function waitForHealth(env, timeout = 60000, signal) {
|
||||||
// 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}`;
|
||||||
@@ -830,18 +810,9 @@ 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(
|
||||||
lifecycleEnv,
|
env,
|
||||||
'bash',
|
'bash',
|
||||||
['--', snapshot, operation, ...args],
|
['--', snapshot, operation, ...args],
|
||||||
false,
|
false,
|
||||||
@@ -874,21 +845,16 @@ 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: async () => {
|
stop: () => hook('stop'),
|
||||||
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,
|
||||||
undefined,
|
60000,
|
||||||
recover ? undefined : controller.signal,
|
recover ? undefined : controller.signal,
|
||||||
);
|
);
|
||||||
await job?.onStart(recover);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
if (process.argv[2] === 'update') {
|
if (process.argv[2] === 'update') {
|
||||||
@@ -908,48 +874,15 @@ 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.');
|
||||||
const apply = async (preparedJob) => {
|
await reloadSystem({
|
||||||
job = preparedJob;
|
root,
|
||||||
await reloadSystem({
|
tmp,
|
||||||
root,
|
env,
|
||||||
tmp,
|
data: env.dir_data || path.join(root, 'data'),
|
||||||
env,
|
staticRoot: env.dir_static || path.join(root, 'static'),
|
||||||
data: env.dir_data || path.join(root, 'data'),
|
config: env.dir_config || path.join(root, 'data/config'),
|
||||||
staticRoot: env.dir_static || path.join(root, 'static'),
|
lifecycle,
|
||||||
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,
|
||||||
@@ -966,7 +899,6 @@ module.exports = {
|
|||||||
selectedUpgrade,
|
selectedUpgrade,
|
||||||
replaceAndReload,
|
replaceAndReload,
|
||||||
reloadSystem,
|
reloadSystem,
|
||||||
startupHealthTimeout,
|
|
||||||
waitForHealth,
|
waitForHealth,
|
||||||
runProcess,
|
runProcess,
|
||||||
withLifecycleHooks,
|
withLifecycleHooks,
|
||||||
|
|||||||
@@ -1,396 +0,0 @@
|
|||||||
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+(?:-[\w.-]+)?(?:\+[\w.-]+)?)/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,
|
|
||||||
};
|
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
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.
|
||||||
@@ -30,18 +27,11 @@ 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();
|
||||||
// Accept only the two launch shapes we own. Never treat eval, preload or
|
// Both the legacy fallback and the TS fallback launch one script without
|
||||||
// application arguments containing this path as a running backend.
|
// Node flags or application arguments. Reject other command shapes.
|
||||||
const scriptIndex =
|
if (args.length !== 2 || !/^node(?:\d+)?$/.test(path.basename(args[0]!)))
|
||||||
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[scriptIndex]!;
|
const script = args[1]!;
|
||||||
if (!script) continue;
|
if (!script) continue;
|
||||||
const absolute = path.isAbsolute(script)
|
const absolute = path.isAbsolute(script)
|
||||||
? script
|
? script
|
||||||
|
|||||||
@@ -1,183 +0,0 @@
|
|||||||
const test = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs/promises');
|
|
||||||
const path = require('node:path');
|
|
||||||
const os = require('node:os');
|
|
||||||
const vm = require('node:vm');
|
|
||||||
const { createRequire } = require('node:module');
|
|
||||||
const { createContext } = require('../../dist/internal/runtime/context');
|
|
||||||
const { replaceAndReload } = require('../../dist/internal/maintenance/upgrade');
|
|
||||||
const runtime = require('../../dist/internal/maintenance/nodeRuntime.cjs');
|
|
||||||
|
|
||||||
// Change only runtime selection; execute the compiled operator, actual child
|
|
||||||
// Node and process discovery, without pretending the host CPU is ARM.
|
|
||||||
async function operatorFor(arch, armVersion, node) {
|
|
||||||
const filename = require.resolve('../../dist/internal/maintenance/operator');
|
|
||||||
const localRequire = createRequire(filename);
|
|
||||||
const module = { exports: {} };
|
|
||||||
const flags = runtime.serverRuntimeArgs({
|
|
||||||
arch,
|
|
||||||
config: { variables: { arm_version: armVersion } },
|
|
||||||
versions: { node },
|
|
||||||
});
|
|
||||||
vm.runInNewContext(
|
|
||||||
await fs.readFile(filename, 'utf8'),
|
|
||||||
{
|
|
||||||
module,
|
|
||||||
exports: module.exports,
|
|
||||||
process,
|
|
||||||
setTimeout,
|
|
||||||
clearTimeout,
|
|
||||||
require(name) {
|
|
||||||
return name === './nodeRuntime.cjs'
|
|
||||||
? { ...runtime, serverRuntimeArgs: () => [...flags] }
|
|
||||||
: localRequire(name);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ filename },
|
|
||||||
);
|
|
||||||
return { ...module.exports, flags };
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [arch, arm, node] of [
|
|
||||||
['arm', 6, '24.18.1'],
|
|
||||||
['arm', 6, '20.18.1'],
|
|
||||||
['arm', 6, '22.20.0'],
|
|
||||||
['arm', 7, '24.18.1'],
|
|
||||||
['arm64', undefined, '24.18.1'],
|
|
||||||
['x64', undefined, '24.18.1'],
|
|
||||||
])
|
|
||||||
test(`CLI direct startup and stop use compatible arguments: ${arch}/${arm}/${node}`, async (t) => {
|
|
||||||
const operator = await operatorFor(arch, arm, node);
|
|
||||||
const root = await fs.realpath(
|
|
||||||
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-runtime-start-')),
|
|
||||||
);
|
|
||||||
const context = createContext(
|
|
||||||
{ root },
|
|
||||||
{ PATH: process.platform === 'linux' ? '' : '/usr/bin:/bin', QL_LANG: 'en' },
|
|
||||||
);
|
|
||||||
const entry = path.join(context.paths.dir_static, 'build/app.js');
|
|
||||||
let pid;
|
|
||||||
t.after(async () => {
|
|
||||||
if (pid) {
|
|
||||||
try {
|
|
||||||
process.kill(pid, 'SIGKILL');
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
await fs.rm(root, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
await fs.mkdir(path.dirname(entry), { recursive: true });
|
|
||||||
await fs.writeFile(
|
|
||||||
entry,
|
|
||||||
`require('fs').writeFileSync(${JSON.stringify(
|
|
||||||
path.join(root, 'runtime.json'),
|
|
||||||
)}, JSON.stringify({args:process.execArgv,pid:process.pid}));setInterval(()=>{},1000);`,
|
|
||||||
);
|
|
||||||
const started = await operator.startPanel(context);
|
|
||||||
pid = started.pid;
|
|
||||||
const report = JSON.parse(
|
|
||||||
await fs.readFile(path.join(root, 'runtime.json')),
|
|
||||||
);
|
|
||||||
assert.equal(started.manager, 'node');
|
|
||||||
assert.deepEqual(report.args, operator.flags);
|
|
||||||
assert.equal(report.pid, pid);
|
|
||||||
await operator.stopPanel(context);
|
|
||||||
for (let n = 0; n < 100; n++) {
|
|
||||||
try {
|
|
||||||
process.kill(pid, 0);
|
|
||||||
} catch (e) {
|
|
||||||
if (e.code === 'ESRCH') {
|
|
||||||
pid = undefined;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
||||||
}
|
|
||||||
assert.equal(
|
|
||||||
pid,
|
|
||||||
undefined,
|
|
||||||
'the exact backend must stop even with Node flags',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test(
|
|
||||||
'ARMv6 CLI replacement stops flagged new backend before restoring old backend and data',
|
|
||||||
{
|
|
||||||
skip: process.platform !== 'linux',
|
|
||||||
timeout: 20000,
|
|
||||||
},
|
|
||||||
async (t) => {
|
|
||||||
const operator = await operatorFor('arm', 6, '24.18.1');
|
|
||||||
const root = await fs.realpath(
|
|
||||||
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-arm-rollback-')),
|
|
||||||
);
|
|
||||||
const context = createContext({ root }, { PATH: '', QL_LANG: 'en' });
|
|
||||||
const entry = path.join(context.paths.dir_static, 'build/app.js');
|
|
||||||
const incoming = path.join(root, 'incoming.js'),
|
|
||||||
journal = path.join(root, 'journal.jsonl');
|
|
||||||
const record = (version) =>
|
|
||||||
`const fs=require('fs');fs.appendFileSync(${JSON.stringify(
|
|
||||||
journal,
|
|
||||||
)},JSON.stringify({version:${JSON.stringify(
|
|
||||||
version,
|
|
||||||
)},pid:process.pid,args:process.execArgv})+'\\n');setInterval(()=>{},1000);`;
|
|
||||||
await fs.mkdir(path.dirname(entry), { recursive: true });
|
|
||||||
await fs.mkdir(context.data, { recursive: true });
|
|
||||||
await fs.writeFile(path.join(root, '.env'), 'KEEP=custom\n', {
|
|
||||||
mode: 0o600,
|
|
||||||
});
|
|
||||||
await fs.writeFile(path.join(context.data, 'keep'), 'old data');
|
|
||||||
await fs.writeFile(entry, record('old'));
|
|
||||||
await fs.writeFile(incoming, record('new'));
|
|
||||||
const pids = [];
|
|
||||||
t.after(async () => {
|
|
||||||
for (const pid of pids) {
|
|
||||||
try {
|
|
||||||
process.kill(pid, 'SIGKILL');
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
await fs.rm(root, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
pids.push((await operator.startPanel(context)).pid);
|
|
||||||
await assert.rejects(
|
|
||||||
replaceAndReload(context, [{ source: incoming, target: entry }], {
|
|
||||||
stop: (ctx) => operator.stopPanel(ctx),
|
|
||||||
start: async (ctx, recover) => {
|
|
||||||
const result = await operator.startPanel(ctx);
|
|
||||||
pids.push(result.pid);
|
|
||||||
if (!recover) throw new Error('injected new-version health failure');
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
/injected new-version health failure/,
|
|
||||||
);
|
|
||||||
const launches = (await fs.readFile(journal, 'utf8'))
|
|
||||||
.trim()
|
|
||||||
.split('\n')
|
|
||||||
.map(JSON.parse);
|
|
||||||
assert.deepEqual(
|
|
||||||
launches.map((x) => x.version),
|
|
||||||
['old', 'new', 'old'],
|
|
||||||
);
|
|
||||||
for (const row of launches) assert.deepEqual(row.args, operator.flags);
|
|
||||||
for (const row of launches.slice(0, -1))
|
|
||||||
assert.throws(() => process.kill(row.pid, 0), { code: 'ESRCH' });
|
|
||||||
process.kill(launches.at(-1).pid, 0);
|
|
||||||
assert.equal(
|
|
||||||
await fs.readFile(path.join(root, '.env'), 'utf8'),
|
|
||||||
'KEEP=custom\n',
|
|
||||||
);
|
|
||||||
assert.equal((await fs.stat(path.join(root, '.env'))).mode & 0o777, 0o600);
|
|
||||||
assert.equal(
|
|
||||||
await fs.readFile(path.join(context.data, 'keep'), 'utf8'),
|
|
||||||
'old data',
|
|
||||||
);
|
|
||||||
assert.equal(await fs.readFile(entry, 'utf8'), record('old'));
|
|
||||||
assert.ok(
|
|
||||||
!(await fs.readdir(path.dirname(entry))).some((x) =>
|
|
||||||
x.includes('ql-backup'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await operator.stopPanel(context);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -3,46 +3,7 @@ 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 {
|
const { findDirectBackendPids } = require('../../dist/internal/runtime/backendProcesses');
|
||||||
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(
|
||||||
@@ -103,10 +64,7 @@ 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 {
|
const { stopPanel, startPanel } = require('../../dist/internal/maintenance/operator');
|
||||||
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;
|
||||||
@@ -265,9 +223,7 @@ 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 {
|
const { startPanel } = require('../../dist/internal/maintenance/operator');
|
||||||
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 });
|
||||||
|
|||||||
@@ -322,10 +322,6 @@ 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)
|
||||||
@@ -333,17 +329,13 @@ 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);require("node:fs").writeSync(3,"T");setInterval(()=>{},1000)';
|
'process.on("SIGINT",()=>{});process.on("SIGTERM",()=>{});console.log(process.pid);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,3]});setTimeout(()=>process.exit(0),100);`;
|
)}],{stdio:['ignore',1,2]});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());
|
||||||
@@ -353,7 +345,6 @@ 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);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -20,40 +20,6 @@ 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'),
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
const test = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const os = require('node:os');
|
|
||||||
const path = require('node:path');
|
|
||||||
const { 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}-`));
|
|
||||||
});
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
const test = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs/promises');
|
|
||||||
const path = require('node:path');
|
|
||||||
const {
|
|
||||||
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',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
+5
-28
@@ -1,9 +1,8 @@
|
|||||||
# 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:24.18.1-alpine3.24 AS builder
|
FROM --platform=$BUILDPLATFORM node:18-alpine3.18 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}
|
||||||
|
|
||||||
@@ -18,24 +17,12 @@ 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="${NODE_LIBC}" \
|
npm_config_target_libc=musl \
|
||||||
pnpm install --prod --frozen-lockfile --side-effects-cache=false
|
pnpm install --prod --frozen-lockfile
|
||||||
|
|
||||||
# Pin amd64 to upstream's musl runtime after native URL parser crashes with APK Node.
|
FROM python:3.11-alpine
|
||||||
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}"
|
||||||
@@ -57,8 +44,7 @@ EXPOSE 5700
|
|||||||
COPY --from=builder /opt/node-global/lib/node_modules/. /usr/local/lib/node_modules/
|
COPY --from=builder /opt/node-global/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 --mount=type=bind,from=node-runtime-amd64,source=/usr/local/bin/node,target=/tmp/node-runtime-amd64 \
|
RUN set -x \
|
||||||
set -x \
|
|
||||||
&& apk update -f \
|
&& apk update -f \
|
||||||
&& apk upgrade \
|
&& apk upgrade \
|
||||||
&& apk --no-cache add -f bash \
|
&& apk --no-cache add -f bash \
|
||||||
@@ -76,11 +62,6 @@ RUN --mount=type=bind,from=node-runtime-amd64,source=/usr/local/bin/node,target=
|
|||||||
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 \
|
||||||
@@ -105,10 +86,6 @@ 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 \
|
||||||
|
|||||||
+5
-28
@@ -1,9 +1,8 @@
|
|||||||
# 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:24.18.1-alpine3.24 AS builder
|
FROM --platform=$BUILDPLATFORM node:18-alpine3.18 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}
|
||||||
|
|
||||||
@@ -18,24 +17,12 @@ 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="${NODE_LIBC}" \
|
npm_config_target_libc=musl \
|
||||||
pnpm install --prod --frozen-lockfile --side-effects-cache=false
|
pnpm install --prod --frozen-lockfile
|
||||||
|
|
||||||
# Pin amd64 to upstream's musl runtime after native URL parser crashes with APK Node.
|
FROM python:3.10-alpine
|
||||||
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}"
|
||||||
@@ -57,8 +44,7 @@ EXPOSE 5700
|
|||||||
COPY --from=builder /opt/node-global/lib/node_modules/. /usr/local/lib/node_modules/
|
COPY --from=builder /opt/node-global/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 --mount=type=bind,from=node-runtime-amd64,source=/usr/local/bin/node,target=/tmp/node-runtime-amd64 \
|
RUN set -x \
|
||||||
set -x \
|
|
||||||
&& apk update -f \
|
&& apk update -f \
|
||||||
&& apk upgrade \
|
&& apk upgrade \
|
||||||
&& apk --no-cache add -f bash \
|
&& apk --no-cache add -f bash \
|
||||||
@@ -76,11 +62,6 @@ RUN --mount=type=bind,from=node-runtime-amd64,source=/usr/local/bin/node,target=
|
|||||||
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 \
|
||||||
@@ -105,10 +86,6 @@ COPY static/ /ql/static/
|
|||||||
COPY docker/verify-build.cjs docker/build-manifest.cjs /tmp/
|
COPY docker/verify-build.cjs docker/build-manifest.cjs /tmp/
|
||||||
COPY --from=builder /tmp/build/pnpm-lock.yaml /tmp/dependency-lock.yaml
|
COPY --from=builder /tmp/build/pnpm-lock.yaml /tmp/dependency-lock.yaml
|
||||||
RUN cd ${QL_DIR} && node /tmp/verify-build.cjs /tmp/dependency-lock.yaml
|
RUN cd ${QL_DIR} && node /tmp/verify-build.cjs /tmp/dependency-lock.yaml
|
||||||
RUN if [ "$TARGETARCH" = arm ] && [ "${TARGETVARIANT:-}" = v6 ]; then \
|
|
||||||
QEMU_CPU=arm1176 node --jitless ${QL_DIR}/shell/node-runtime-probe.cjs; \
|
|
||||||
else node ${QL_DIR}/shell/node-runtime-probe.cjs; fi
|
|
||||||
|
|
||||||
|
|
||||||
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
|
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
|
||||||
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
|
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
# Download and verify the target Node archive without running under QEMU.
|
# Node 20 Bookworm is the latest official Node image variant that covers the
|
||||||
FROM --platform=$BUILDPLATFORM debian:bookworm-slim AS nodebuilder
|
# full Debian build matrix, including arm/v7, ppc64le, and s390x.
|
||||||
ARG TARGETARCH
|
FROM node:20-bookworm-slim AS nodebuilder
|
||||||
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/
|
||||||
@@ -15,7 +11,6 @@ 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
|
||||||
@@ -76,7 +71,6 @@ RUN set -x && \
|
|||||||
git config --global user.name "qinglong" && \
|
git config --global user.name "qinglong" && \
|
||||||
git config --global http.postBuffer 524288000 && \
|
git config --global http.postBuffer 524288000 && \
|
||||||
npm install -g pnpm@8.3.1 pm2 ts-node typescript@5 && \
|
npm install -g pnpm@8.3.1 pm2 ts-node typescript@5 && \
|
||||||
test "$(node --version)" = "v24.18.1" && \
|
|
||||||
test "$(pnpm --version)" = "8.3.1" && \
|
test "$(pnpm --version)" = "8.3.1" && \
|
||||||
npm cache clean --force && \
|
npm cache clean --force && \
|
||||||
rm -rf /root/.cache && \
|
rm -rf /root/.cache && \
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
# Download and verify the target Node archive without running under QEMU.
|
# Node 20 Bookworm is the latest official Node image variant that covers the
|
||||||
FROM --platform=$BUILDPLATFORM debian:bookworm-slim AS nodebuilder
|
# full Debian build matrix, including arm/v7, ppc64le, and s390x.
|
||||||
ARG TARGETARCH
|
FROM node:20-bookworm-slim AS nodebuilder
|
||||||
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 s390x on the same immutable Python base as the other Debian targets.
|
# Keep the full release matrix, including s390x, on the same immutable base.
|
||||||
# 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/
|
||||||
@@ -17,7 +13,6 @@ 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
|
||||||
|
|
||||||
@@ -76,7 +71,6 @@ RUN set -x && \
|
|||||||
git config --global user.name "qinglong" && \
|
git config --global user.name "qinglong" && \
|
||||||
git config --global http.postBuffer 524288000 && \
|
git config --global http.postBuffer 524288000 && \
|
||||||
npm install -g pnpm@8.3.1 pm2 ts-node typescript@5 && \
|
npm install -g pnpm@8.3.1 pm2 ts-node typescript@5 && \
|
||||||
test "$(node --version)" = "v24.18.1" && \
|
|
||||||
npm cache clean --force && \
|
npm cache clean --force && \
|
||||||
rm -rf /root/.cache && \
|
rm -rf /root/.cache && \
|
||||||
rm -rf /root/.npm && \
|
rm -rf /root/.npm && \
|
||||||
|
|||||||
@@ -6,11 +6,18 @@ ENV QL_BUNDLE_BASE_IMAGE=${BASE_IMAGE}
|
|||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
ARG TARGETVARIANT
|
ARG TARGETVARIANT
|
||||||
USER root
|
USER root
|
||||||
RUN ulimit -c 0; \
|
# The generic "arm" sqlite release is ARMv7. ARMv6 must compile its own addon.
|
||||||
if [ "$TARGETARCH/$TARGETVARIANT" = arm/v6 ]; then \
|
RUN if [ "$TARGETARCH/$TARGETVARIANT" = arm/v6 ]; then \
|
||||||
export QEMU_CPU=arm1176; \
|
apk add --no-cache --virtual .ql-bundle-build build-base binutils python3 nodejs-dev && \
|
||||||
set -- --jitless; \
|
cd "$(readlink -f /ql/node_modules/sqlite3)" && \
|
||||||
fi; \
|
CFLAGS='-march=armv6 -mfpu=vfp -mfloat-abi=hard' \
|
||||||
node "$@" /ql/scripts/build-upgrade-bundle.cjs /ql /out
|
CXXFLAGS='-march=armv6 -mfpu=vfp -mfloat-abi=hard' \
|
||||||
|
npm_config_nodedir=/usr npm_config_build_from_source=true QEMU_CPU=arm1176 \
|
||||||
|
node -e 'require("node:child_process").execFileSync(process.execPath,[require.resolve("@mapbox/node-pre-gyp/bin/node-pre-gyp"),"rebuild","--napi_build_version=6"],{stdio:"inherit"})' && \
|
||||||
|
readelf -A lib/binding/napi-v6-linux-arm/node_sqlite3.node | grep -E 'Tag_CPU_arch: v6($|K)' && \
|
||||||
|
apk del .ql-bundle-build; \
|
||||||
|
fi
|
||||||
|
RUN if [ "$TARGETARCH/$TARGETVARIANT" = arm/v6 ]; then export QEMU_CPU=arm1176; fi; \
|
||||||
|
node /ql/scripts/build-upgrade-bundle.cjs /ql /out
|
||||||
FROM scratch
|
FROM scratch
|
||||||
COPY --from=bundle /out/ /
|
COPY --from=bundle /out/ /
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -4,93 +4,44 @@ CI 从同次构建的不可变镜像摘要导出完整 tar.gz,包含源码中
|
|||||||
|
|
||||||
## 在线升级
|
## 在线升级
|
||||||
|
|
||||||
`package.json` 中 `qinglongUpgradeFormat: 2` 表示使用完整包。旧版升级器仍可通过原归档路径完成首次迁移;完整包版本不会在平台包缺失、损坏或不兼容时退回设备端 npm 安装。仅当完整包已通过校验、依赖加载或后端预启动明确报告依赖错误时,在线升级会按同一冻结锁文件修复一次生产依赖,然后再次预启动;第二次失败即停止,保留正在运行的旧服务。
|
`package.json` 中 `qinglongUpgradeFormat: 2` 表示使用完整包。旧版升级器仍可通过原归档路径完成首次迁移;完整包版本不会在平台包缺失、损坏或不兼容时退回设备端 npm 安装。
|
||||||
|
|
||||||
面板下载任务与本地离线入口共用 `platformBundle.cjs`:平台匹配 → 归档安全检查 → 全文件/链接/权限和锁文件校验 → SQLite/Sequelize/cron-parser/gRPC/undici 加载及 SQLite 内存库读写 → 隔离后端预启动 → 发布就绪指针。预启动使用现有 Node、临时数据目录和 loopback 随机端口,运行真实数据库建表/迁移和事务读写、gRPC 服务,以及与正常启动共用的 HTTP 中间件、路由和 WebSocket 初始化,再通过 `/api/health` 确认双服务与目标版本、提交和后端摘要;不运行任务、订阅、通知、用户启动钩子和系统依赖安装。临时库不是用户数据库副本,因此实际应用后的健康检查和回退仍不可省略。点击重启时,接口只检查就绪记录并提交后台任务;完整文件与依赖复验由独立工作进程执行,复验通过后才停止旧服务,避免小设备的预检耗时超过 HTTP 请求期限。离线升级同步面板任务状态,并与在线任务互斥;中断准备时保留之前的可用就绪包。原有服务停止、文件备份、启动健康检查及失败恢复仍由共享升级引擎处理。
|
面板下载任务与本地离线入口共用 `platformBundle.cjs`:平台匹配 → 归档安全检查 → 全文件/链接/权限和锁文件校验 → SQLite/Sequelize/cron-parser/gRPC/undici 加载及 SQLite 内存库读写 → 发布就绪指针。重启前重新验证完整载荷。原有服务停止、文件备份、启动健康检查及失败恢复仍由共享升级引擎处理。
|
||||||
|
|
||||||
正式升级包跟随 master 同一次版本镜像构建;develop 的普通提交也自动构建开发包。两者都直接使用该次 Alpine/Debian 镜像的不可变 digest。Git tag 工作流和 `static_sync_only` 不生成完整包。需要重建当前开发包时,可手动运行同一工作流:
|
渠道索引为 GitHub prerelease `upgrade-develop` 或 `upgrade-master` 下的 `latest.json`。索引记录准确字节数、SHA256 和不可变 `upgrade-<commit>` release 资产 URL。12 个平台均构建及试运行成功才发布渠道;过期工作流不能覆盖更新提交的渠道。
|
||||||
|
|
||||||
```sh
|
|
||||||
gh workflow run build-docker-image.yml --ref develop
|
|
||||||
```
|
|
||||||
|
|
||||||
开发包只使用 `upgrade-develop` 这一个 prerelease。`develop` 和 `debian-dev` 镜像读取其中的 `latest.json`,按实际 Node 架构和 musl/glibc 选择包,无需设置测试专用下载源。新批次先以带构建 ID 的文件名上传并校验,再切换 `latest.json`;索引仍保留开发版本供客户端判断更新。开发渠道不会改写正式版本或 `upgrade-master`。同一开发渠道的镜像构建和包发布串行执行,与 master 独立。
|
|
||||||
|
|
||||||
开发包只保留最新一个已验证批次:所有平台资产和渠道索引的大小、SHA256 校验成功后,清理 `upgrade-develop` 中旧批次资产及旧命名的开发构建 prerelease;不删除正式包或无法分类的 release。构建或上传失败不会删除上一套可用包。旧资产被删除后,已选中旧索引但尚未下载完的客户端可能需要重新发起下载;准备失败仍保留旧服务和数据。
|
|
||||||
|
|
||||||
开发和正式包都必须在 11 个平台完成归档、解包后复验及依赖试运行,并且全部上传资产的大小和 SHA256 复验通过后,才更新各自索引。平台范围与现有 Alpine/Debian 镜像一致:ARMv6 和 ARMv7 仅提供 musl 包,Debian 不再发布 ARMv7 镜像或 glibc 平台包。
|
|
||||||
|
|
||||||
每个正式版本只使用一个 GitHub prerelease `upgrade-v<version>`,其中存放完整平台包、校验文件、安装器和 `latest.json`;在线正式渠道 `upgrade-master` 只保留指向最新版本的 `latest.json`,兼容现有客户端。不再创建 `upgrade-build-v<version>-<run-id>-<run-attempt>` 构建批次 release。索引记录渠道、版本、源码提交、构建批次、准确字节数、SHA256,以及该版本 release 中带构建 ID 的固定资产 URL。相同版本的镜像 tag 重新发布时,先上传并校验新资产,再更新版本索引和正式渠道索引;同一提交重跑也允许产生新内容。旧批次资产保留在同一版本 release,已选中旧索引的下载可继续完成,避免覆盖文件导致哈希不匹配。master 的镜像构建和完整包发布整条流水线串行执行,避免镜像 tag 与完整包被并发重发交错覆盖;旧工作流明确重跑时也允许同版本、同提交重新发布。过期源码提交不能推进对应渠道或版本索引;平台包上传失败时不切换索引。
|
|
||||||
|
|
||||||
## 手动离线安装和升级
|
## 手动离线安装和升级
|
||||||
|
|
||||||
从 `upgrade-v<version>` 的说明或 `latest.json` 中取得当前批次的下载链接,下载匹配设备的 `.tar.gz` 和带构建 ID 的独立安装器;两者都来自该版本 release。独立安装器已内置解析依赖,在目标设备正常情况无需 npm、pnpm 或重新安装 SQLite。离线准备和预启动默认不联网;依赖不符合要求时直接报错。可对照同一 release 的 `latest.json` 核对归档 SHA256。
|
从同一个 `upgrade-<commit>` release 下载匹配设备的 `.tar.gz` 和独立 `install-bundle.cjs`。独立安装器已内置解析依赖,在目标设备无需 npm、pnpm 或重新安装 SQLite。可对照同 release 的 `latest.json` 核对归档 SHA256。
|
||||||
|
|
||||||
已有安装(默认应用并重启):
|
已有安装(默认应用并重启):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
node install-bundle-<build-id>.cjs --root /ql --file /absolute/path/qinglong-<commit>-linux-arm64-musl-<build-id>.tar.gz
|
node install-bundle.cjs --root /ql --file /absolute/path/qinglong-<commit>-linux-arm64-musl.tar.gz
|
||||||
```
|
```
|
||||||
|
|
||||||
只准备,稍后手动重启:
|
只准备,稍后手动重启:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
node install-bundle-<build-id>.cjs --root /ql --file /absolute/path/package.tar.gz --download-only
|
node install-bundle.cjs --root /ql --file /absolute/path/package.tar.gz --download-only
|
||||||
ql reload system
|
ql reload system
|
||||||
```
|
```
|
||||||
|
|
||||||
仅在明确允许联网修复时添加 `--repair-deps`(安装和升级均支持),例如:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
node install-bundle-<build-id>.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
|
```sh
|
||||||
node install-bundle-<build-id>.cjs --root /opt/qinglong --file /absolute/path/package.tar.gz --install
|
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
|
QL_DIR=/opt/qinglong bash /opt/qinglong/shell/start-simplify.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
`--install` 拒绝非空目录,不覆盖既有配置。该包安装青龙及其 Node 生产依赖;首次启动仍需已有 Python、Bash、OpenSSL、PM2、nginx 等相应运行环境,不会在线安装或替换它们。手动启动脚本会在启动工作进程前完成首次 gRPC 证书准备,避免慢设备生成密钥耗尽工作进程的就绪期限;已有证书继续复用。服务启动和系统自启按原手动安装流程配置。
|
`--install` 拒绝非空目录,不覆盖既有配置。该包安装青龙及其 Node 生产依赖;首次启动仍需已有 Python、Bash、PM2、nginx 等相应运行环境,不会在线安装或替换它们。服务启动和系统自启按原手动安装流程配置。
|
||||||
|
|
||||||
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 为 20.18.1,Node-API 至少 6;安装器只检查,不替换 Node。
|
||||||
- 以 Node 实际运行架构、ARM 指令集和 libc 类型/版本选择包;不依据 Docker 架构别名猜测。
|
- 以 Node 实际运行架构、ARM 指令集和 libc 类型/版本选择包;不依据 Docker 架构别名猜测。
|
||||||
- glibc、musl 分包,并记录构建系统 libc 下限。系统库仍以目标设备加载测试作为最后检查。
|
- glibc、musl 分包,并记录构建系统 libc 下限。系统库仍以目标设备加载测试作为最后检查。
|
||||||
- ARMv6 保留独立包名。ARMv6 在目标镜像中从源码编译 SQLite,禁止复用 generic `arm` 的 ARMv7 预编译件;构建需通过加载和内存库读写,失败则不发布渠道。
|
- ARMv6 与 ARMv7 使用不同包名。ARMv6 在目标镜像中从源码编译 SQLite,禁止复用 generic `arm` 的 ARMv7 预编译件;构建需通过加载和内存库读写,失败则不发布渠道。
|
||||||
- ARMv6 的归档构建、在线更新工作进程及独立离线安装器使用 `--jitless`,避开 ARM1176 上 V8 的优化编译崩溃;保留现有 Node 二进制,Node 24 及以上的 ARMv6 面板进程通过共享 `nodeRuntime` 使用 `--no-opt --no-sparkplug --no-maglev`,其他架构和较低 Node 版本保持原启动参数。ARMv6 的 gRPC 工作进程首次启动及重建最多等待 2 分钟;升级后的整体健康检查最多等待 5 分钟,覆盖慢设备按顺序启动数据库、调度与 HTTP 工作进程的耗时;其他平台仍为 60 秒,超时或版本校验失败仍执行回退。依赖加载与后端预启动使用现有 Node 的正常模式,使 Node 20.18.1 的内置 HTTP/WebAssembly 组件保持可用。预启动只有一个后端进程,按顺序初始化数据库、gRPC、HTTP,不启动 PM2/cluster;ARMv6 V8 堆上限 128 MiB、期限 5 分钟,其他平台为 192 MiB/60 秒。堆上限不是 RSS 总内存上限,系统仍需为现有服务和 OpenSSL 留出内存。依赖修复的包管理器及编译使用单并发、低网络并发和有界时长;ARMv6 包管理器采用 `--jitless`、256 MiB 堆上限,并优先复用版本匹配的现有 Node 头文件与实际运行时编译配置;超时、中断会终止整个进程组并清理临时目录。
|
|
||||||
- tar 中允许 pnpm 的包内符号链接;拒绝绝对路径、越界路径、包外链接、硬链接、设备条目和经符号链接写入的条目。重启前再次校验依赖,暂存失败保留上一次可用的就绪包。
|
- tar 中允许 pnpm 的包内符号链接;拒绝绝对路径、越界路径、包外链接、硬链接、设备条目和经符号链接写入的条目。重启前再次校验依赖,暂存失败保留上一次可用的就绪包。
|
||||||
|
|
||||||
自托管镜像可设置 `QL_UPGRADE_INDEX_URL` 指向兼容索引;要求 HTTPS,测试时仅放行 loopback HTTP。索引和所有平台归档应部署到同一可访问源,避免依赖额外的 raw 域名。
|
自托管镜像可设置 `QL_UPGRADE_INDEX_URL` 指向兼容索引;要求 HTTPS,测试时仅放行 loopback HTTP。索引和所有平台归档应部署到同一可访问源,避免依赖额外的 raw 域名。
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
# 青龙 2.24.0-dev.0 开发候选
|
|
||||||
|
|
||||||
这是 `develop` 渠道的开发构建,上一正式版本为 2.23.0。此版本尚未正式发布。
|
|
||||||
|
|
||||||
## 本轮变化
|
|
||||||
|
|
||||||
- Alpine 与 Debian 镜像统一使用 Node.js 24;SQLite 依赖升级至 1.1.4。
|
|
||||||
- Alpine 镜像保留 arm/v6 和 arm/v7,Debian 镜像暂不提供 arm/v7。
|
|
||||||
- ARM64 升级包改用原生 runner 构建,避免在 x64 QEMU 下归档时发生异常。
|
|
||||||
|
|
||||||
升级前请备份完整持久化数据,并在目标设备验证脚本、依赖和通知。正式发布说明将在候选验收后整理。
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# 青龙 2.24.0
|
|
||||||
|
|
||||||
## 新增功能
|
|
||||||
|
|
||||||
- Node.js 原生部署支持 Python venv 隔离环境。Python 依赖可使用包名、Git URL,以及位于脚本或订阅仓库中的 requirements 文件和 pyproject.toml;拉取订阅不会自动安装仓库声明的依赖。
|
|
||||||
- 新增通知审计日志,便于查看通知发送结果和失败原因。
|
|
||||||
- 提供经过平台校验的升级包,支持在线暂存、离线安装及失败回退。升级过程保留账号、配置、环境变量、任务和脚本等数据。
|
|
||||||
|
|
||||||
## 修复与优化
|
|
||||||
|
|
||||||
- 默认 Alpine 与 Debian 镜像统一使用 Node.js 24;SQLite 依赖升级至 1.1.4。ARM64 升级包使用原生构建环境。
|
|
||||||
- 修复额外调度规则校验、单实例任务运行期间的新触发排队、订阅同步、Python 依赖安装与脚本发现等问题。
|
|
||||||
- 修复系统日志清理时活跃日志文件被移除、页面加载时序和升级流程中的异常情况,并补齐相关中英文提示。
|
|
||||||
- 独立远程 CLI 更新至 0.1.3,最低 Node.js 版本为 22.12.0。
|
|
||||||
|
|
||||||
## 镜像与升级提醒
|
|
||||||
|
|
||||||
- `2.24.0` 的 Alpine 镜像支持 amd64、arm/v6、arm/v7、arm64、ppc64le、s390x 和 386;`2.24.0-debian` 支持 amd64、arm64、ppc64le 和 s390x。Debian 暂不提供 arm/v7。
|
|
||||||
- 2.23.0 是 Python 3.10 兼容镜像的最后一个版本;2.24.0 不再更新该变体。需要 Python 3.10 的用户请先评估迁移方案。
|
|
||||||
- 升级前停止任务并备份完整持久化数据目录,记录原镜像摘要和启动配置。升级后检查脚本、依赖、订阅与通知;如需回退,请同时恢复与旧版本匹配的数据备份。
|
|
||||||
- 主 npm 包 `@whyour/qinglong` 与独立远程 CLI 包 `@whyour/qinglong-cli` 均提供 `ql` 命令。在已有面板的主机上安装远程 CLI 时,使用独立 npm prefix 或 `npm exec --package=@whyour/qinglong-cli -- ql ...`,避免覆盖面板内置入口。
|
|
||||||
|
|
||||||
本版本以 2.23.0 为上一正式版本。开发候选的源码、npm 包与 11 个发布镜像架构已完成隔离验收;正式产物发布后仍以各目标渠道的实际构建结果为准。
|
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
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: [
|
||||||
@@ -20,7 +18,6 @@ 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: '',
|
||||||
|
|||||||
+4
-42
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@whyour/qinglong",
|
"name": "@whyour/qinglong",
|
||||||
"packageManager": "pnpm@8.3.1",
|
"packageManager": "pnpm@8.3.1",
|
||||||
"version": "2.24.0",
|
"version": "2.23.0",
|
||||||
"description": "Timed task management platform supporting Python3, JavaScript, Shell, Typescript",
|
"description": "Timed task management platform supporting Python3, JavaScript, Shell, Typescript",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -12,42 +12,6 @@
|
|||||||
"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",
|
||||||
@@ -64,9 +28,7 @@
|
|||||||
"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}'",
|
||||||
"prepack": "node scripts/prepare-npm-package.cjs",
|
"postinstall": "max setup 2>/dev/null || true",
|
||||||
"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": {
|
||||||
@@ -111,7 +73,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"sqlite3": "npm:@whyour/sqlite3@1.1.4",
|
"sqlite3": "npm:@whyour/sqlite3@1.1.2",
|
||||||
"@codemirror/state": "6.5.4",
|
"@codemirror/state": "6.5.4",
|
||||||
"@codemirror/view": "6.39.16",
|
"@codemirror/view": "6.39.16",
|
||||||
"proxy-addr": "2.0.8",
|
"proxy-addr": "2.0.8",
|
||||||
@@ -159,7 +121,7 @@
|
|||||||
"request-ip": "3.3.0",
|
"request-ip": "3.3.0",
|
||||||
"sequelize": "6.37.8",
|
"sequelize": "6.37.8",
|
||||||
"sockjs": "^0.3.24",
|
"sockjs": "^0.3.24",
|
||||||
"sqlite3": "npm:@whyour/sqlite3@1.1.4",
|
"sqlite3": "npm:@whyour/sqlite3@1.1.2",
|
||||||
"toad-scheduler": "^3.0.1",
|
"toad-scheduler": "^3.0.1",
|
||||||
"typedi": "^0.10.0",
|
"typedi": "^0.10.0",
|
||||||
"undici": "^7.9.0",
|
"undici": "^7.9.0",
|
||||||
|
|||||||
Generated
+10
-10
@@ -1,7 +1,7 @@
|
|||||||
lockfileVersion: '6.0'
|
lockfileVersion: '6.0'
|
||||||
|
|
||||||
overrides:
|
overrides:
|
||||||
sqlite3: npm:@whyour/sqlite3@1.1.4
|
sqlite3: npm:@whyour/sqlite3@1.1.2
|
||||||
'@codemirror/state': 6.5.4
|
'@codemirror/state': 6.5.4
|
||||||
'@codemirror/view': 6.39.16
|
'@codemirror/view': 6.39.16
|
||||||
proxy-addr: 2.0.8
|
proxy-addr: 2.0.8
|
||||||
@@ -120,13 +120,13 @@ dependencies:
|
|||||||
version: 3.3.0
|
version: 3.3.0
|
||||||
sequelize:
|
sequelize:
|
||||||
specifier: 6.37.8
|
specifier: 6.37.8
|
||||||
version: 6.37.8(@whyour/sqlite3@1.1.4)
|
version: 6.37.8(@whyour/sqlite3@1.1.2)
|
||||||
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.4
|
specifier: npm:@whyour/sqlite3@1.1.2
|
||||||
version: /@whyour/sqlite3@1.1.4
|
version: /@whyour/sqlite3@1.1.2
|
||||||
tar:
|
tar:
|
||||||
specifier: 7.5.20
|
specifier: 7.5.20
|
||||||
version: 7.5.20
|
version: 7.5.20
|
||||||
@@ -3193,7 +3193,7 @@ packages:
|
|||||||
resolution: {integrity: sha512-Ngs9jhElXN7efS9WvvCC/p6rXMbihna8eNLVBc421Zf+VcFd+pR4DOcS6yA9V22EKtAy4DEj7LtvEEIm0bb80A==}
|
resolution: {integrity: sha512-Ngs9jhElXN7efS9WvvCC/p6rXMbihna8eNLVBc421Zf+VcFd+pR4DOcS6yA9V22EKtAy4DEj7LtvEEIm0bb80A==}
|
||||||
engines: {node: '>= 18'}
|
engines: {node: '>= 18'}
|
||||||
dependencies:
|
dependencies:
|
||||||
sqlite3: /@whyour/sqlite3@1.1.4
|
sqlite3: /@whyour/sqlite3@1.1.2
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- encoding
|
- encoding
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -5611,15 +5611,14 @@ packages:
|
|||||||
- supports-color
|
- supports-color
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/@whyour/sqlite3@1.1.4:
|
/@whyour/sqlite3@1.1.2:
|
||||||
resolution: {integrity: sha512-MlWzqOdpZ0KPuBx/FYRrHZ8p3S0eCbaf2d8duDzWmfko/aky25Xoss67qDjFgbRjnwj9z0i0KNdNtQI02dnWKA==}
|
resolution: {integrity: sha512-OY24/OJMqLHIGolo4GCFW4lrJKAoOB+ZqERXb7u7DKZQz7QR3f0GALE99BZWQBSZEVVZ/StVeAb2fCkMfTtuDQ==}
|
||||||
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:
|
||||||
@@ -7602,6 +7601,7 @@ packages:
|
|||||||
resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==}
|
resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==}
|
||||||
engines: {node: '>=0.10'}
|
engines: {node: '>=0.10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
dev: true
|
||||||
|
|
||||||
/detect-libc@2.1.2:
|
/detect-libc@2.1.2:
|
||||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
@@ -13903,7 +13903,7 @@ packages:
|
|||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/sequelize@6.37.8(@whyour/sqlite3@1.1.4):
|
/sequelize@6.37.8(@whyour/sqlite3@1.1.2):
|
||||||
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.4
|
sqlite3: /@whyour/sqlite3@1.1.2
|
||||||
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
|
||||||
|
|||||||
+6
-18
@@ -1313,40 +1313,30 @@ 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 or not results:
|
if not ql_dir:
|
||||||
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=10)
|
capture_output=True, timeout=3)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Audit availability never changes notification delivery.
|
pass # Audit availability never changes notification delivery.
|
||||||
|
|
||||||
|
|
||||||
def audited_notification(mode, title, content, results=None):
|
def audited_notification(mode, title, content):
|
||||||
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__} 推送失败")
|
||||||
if results is None:
|
report_notification(mode.__name__, title, content, result is True)
|
||||||
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):
|
||||||
@@ -1372,14 +1362,12 @@ def send(title: str, content: str, ignore_default_config: bool = False, **kwargs
|
|||||||
content += "\n\n" + one() if hitokoto not in [False, "false"] else ""
|
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, results), name=mode.__name__)
|
threading.Thread(target=audited_notification, args=(mode, title, content), 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():
|
||||||
|
|||||||
@@ -6,19 +6,6 @@ const {
|
|||||||
verifyUpgrade,
|
verifyUpgrade,
|
||||||
} = require('../cli/src/internal/maintenance/upgradeArtifacts.cjs');
|
} = 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) {
|
async function buildBundle(root, output) {
|
||||||
root = path.resolve(root);
|
root = path.resolve(root);
|
||||||
output = path.resolve(output);
|
output = path.resolve(output);
|
||||||
@@ -59,8 +46,6 @@ async function buildBundle(root, output) {
|
|||||||
recursive: true,
|
recursive: true,
|
||||||
verbatimSymlinks: true,
|
verbatimSymlinks: true,
|
||||||
});
|
});
|
||||||
await normalizeModes(source);
|
|
||||||
await normalizeModes(staticRoot);
|
|
||||||
const manifest = {
|
const manifest = {
|
||||||
format: 2,
|
format: 2,
|
||||||
sourceCommit: build.sourceCommit,
|
sourceCommit: build.sourceCommit,
|
||||||
@@ -84,7 +69,6 @@ async function buildBundle(root, output) {
|
|||||||
path.join(payload, 'bundle.json'),
|
path.join(payload, 'bundle.json'),
|
||||||
JSON.stringify(manifest),
|
JSON.stringify(manifest),
|
||||||
);
|
);
|
||||||
console.log('Bundle inventory verified; writing archive');
|
|
||||||
const filename = `qinglong-${build.sourceCommit}-${bundle.targetKey(
|
const filename = `qinglong-${build.sourceCommit}-${bundle.targetKey(
|
||||||
target,
|
target,
|
||||||
)}.tar.gz`;
|
)}.tar.gz`;
|
||||||
@@ -98,24 +82,10 @@ async function buildBundle(root, output) {
|
|||||||
},
|
},
|
||||||
['qinglong'],
|
['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 bytes = await fs.readFile(path.join(output, filename));
|
||||||
const record = {
|
const record = {
|
||||||
format: 2,
|
format: 2,
|
||||||
sourceCommit: build.sourceCommit,
|
sourceCommit: build.sourceCommit,
|
||||||
version: manifest.version,
|
|
||||||
target: bundle.targetKey(target),
|
target: bundle.targetKey(target),
|
||||||
filename,
|
filename,
|
||||||
baseImage: process.env.QL_BUNDLE_BASE_IMAGE,
|
baseImage: process.env.QL_BUNDLE_BASE_IMAGE,
|
||||||
@@ -132,7 +102,7 @@ async function buildBundle(root, output) {
|
|||||||
await fs.rm(workspace, { recursive: true, force: true });
|
await fs.rm(workspace, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
module.exports = { buildBundle, normalizeModes };
|
module.exports = { buildBundle };
|
||||||
if (require.main === module)
|
if (require.main === module)
|
||||||
buildBundle(process.argv[2] || '/ql', process.argv[3] || '/out').catch(
|
buildBundle(process.argv[2] || '/ql', process.argv[3] || '/out').catch(
|
||||||
(e) => {
|
(e) => {
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
// npm-packlist always excludes the root pnpm lockfile, even with a files entry.
|
|
||||||
// Carry a checked copy under a runtime filename and restore it after installation.
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const { execFileSync } = require('node:child_process');
|
|
||||||
const root = path.resolve(__dirname, '..');
|
|
||||||
const copy = path.join(root, 'shell/upgrade-lock.yaml');
|
|
||||||
if (process.argv.includes('--clean')) {
|
|
||||||
fs.rmSync(copy, { force: true });
|
|
||||||
} else {
|
|
||||||
if (execFileSync('git', ['-C', root, 'status', '--porcelain', '--untracked-files=all'], { encoding: 'utf8' }).trim())
|
|
||||||
throw new Error('Pack only from a clean source checkout');
|
|
||||||
// The native npm pack/publish hooks must enforce the same checks as CI:
|
|
||||||
// current commit, clean build, lockfile and every output file's checksum.
|
|
||||||
// Capture stdout so npm pack --json remains machine-readable.
|
|
||||||
execFileSync(process.execPath, [path.join(root, 'docker/verify-build.cjs')], { cwd: root });
|
|
||||||
const bytes = fs.readFileSync(path.join(root, 'pnpm-lock.yaml'));
|
|
||||||
fs.rmSync(copy, { force: true });
|
|
||||||
fs.writeFileSync(copy, bytes, { flag: 'wx', mode: 0o644 });
|
|
||||||
}
|
|
||||||
@@ -13,18 +13,13 @@ const platforms = [
|
|||||||
'linux-s390x-musl',
|
'linux-s390x-musl',
|
||||||
'linux-ia32-musl',
|
'linux-ia32-musl',
|
||||||
'linux-x64-glibc',
|
'linux-x64-glibc',
|
||||||
|
'linux-armv7-glibc',
|
||||||
'linux-arm64-glibc',
|
'linux-arm64-glibc',
|
||||||
'linux-ppc64-glibc',
|
'linux-ppc64-glibc',
|
||||||
'linux-s390x-glibc',
|
'linux-s390x-glibc',
|
||||||
];
|
];
|
||||||
function releaseIndex(directory, commit, repository, options = {}) {
|
function releaseIndex(directory, commit, repository) {
|
||||||
const { version, releaseTag = `upgrade-${commit}`, assetBuildId } = options;
|
if (!/^[a-f0-9]{40}$/.test(commit) || !/^[\w.-]+\/[\w.-]+$/.test(repository))
|
||||||
if (
|
|
||||||
!/^[a-f0-9]{40}$/.test(commit) ||
|
|
||||||
!/^[\w.-]+\/[\w.-]+$/.test(repository) ||
|
|
||||||
!/^[\w.+-]+$/.test(releaseTag) ||
|
|
||||||
(assetBuildId && !/^\d+-\d+$/.test(assetBuildId))
|
|
||||||
)
|
|
||||||
throw new Error('Invalid release source');
|
throw new Error('Invalid release source');
|
||||||
const packages = {};
|
const packages = {};
|
||||||
for (const target of platforms) {
|
for (const target of platforms) {
|
||||||
@@ -36,7 +31,6 @@ function releaseIndex(directory, commit, repository, options = {}) {
|
|||||||
if (
|
if (
|
||||||
record.format !== 2 ||
|
record.format !== 2 ||
|
||||||
record.sourceCommit !== commit ||
|
record.sourceCommit !== commit ||
|
||||||
(version !== undefined && record.version !== version) ||
|
|
||||||
record.target !== target ||
|
record.target !== target ||
|
||||||
record.filename !== filename ||
|
record.filename !== filename ||
|
||||||
record.size !== bytes.length ||
|
record.size !== bytes.length ||
|
||||||
@@ -46,293 +40,113 @@ function releaseIndex(directory, commit, repository, options = {}) {
|
|||||||
packages[target] = {
|
packages[target] = {
|
||||||
size: record.size,
|
size: record.size,
|
||||||
sha256: record.sha256,
|
sha256: record.sha256,
|
||||||
url: `https://github.com/${repository}/releases/download/${encodeURIComponent(
|
url: `https://github.com/${repository}/releases/download/upgrade-${commit}/${filename}`,
|
||||||
releaseTag,
|
|
||||||
)}/${
|
|
||||||
assetBuildId
|
|
||||||
? filename.replace(/\.tar\.gz$/, `-${assetBuildId}.tar.gz`)
|
|
||||||
: filename
|
|
||||||
}`,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { format: 2, sourceCommit: commit, packages };
|
return { format: 2, sourceCommit: commit, packages };
|
||||||
}
|
}
|
||||||
function stageReleaseAssets(directory, commit, buildId, channel) {
|
module.exports = { releaseIndex, platforms };
|
||||||
const staged = fs.mkdtempSync(path.join(directory, `.upgrade-${channel}-`));
|
if (require.main === module) {
|
||||||
const assets = [];
|
const [directory, commit, repository, branch] = process.argv.slice(2);
|
||||||
try {
|
const index = releaseIndex(directory, commit, repository);
|
||||||
for (const target of platforms) {
|
|
||||||
const filename = `qinglong-${commit}-${target}.tar.gz`;
|
|
||||||
const archiveName = filename.replace(/\.tar\.gz$/, `-${buildId}.tar.gz`);
|
|
||||||
const archive = path.join(staged, archiveName);
|
|
||||||
fs.linkSync(path.join(directory, filename), archive);
|
|
||||||
const record = JSON.parse(
|
|
||||||
fs.readFileSync(path.join(directory, filename + '.json')),
|
|
||||||
);
|
|
||||||
fs.writeFileSync(
|
|
||||||
archive + '.json',
|
|
||||||
JSON.stringify({
|
|
||||||
...record,
|
|
||||||
filename: archiveName,
|
|
||||||
}) + '\n',
|
|
||||||
);
|
|
||||||
assets.push(archive, archive + '.json');
|
|
||||||
}
|
|
||||||
const installer = path.join(staged, `install-bundle-${buildId}.cjs`);
|
|
||||||
fs.linkSync(path.join(directory, 'install-bundle.cjs'), installer);
|
|
||||||
assets.push(installer);
|
|
||||||
return { staged, assets };
|
|
||||||
} catch (error) {
|
|
||||||
fs.rmSync(staged, { recursive: true, force: true });
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function stageDevelopmentAssets(directory, commit, buildId) {
|
|
||||||
return stageReleaseAssets(directory, commit, buildId, 'develop');
|
|
||||||
}
|
|
||||||
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 = development ? 'upgrade-develop' : `upgrade-v${version}`;
|
|
||||||
const index = {
|
|
||||||
...releaseIndex(directory, commit, repository, {
|
|
||||||
version,
|
|
||||||
releaseTag: tag,
|
|
||||||
assetBuildId: buildId,
|
|
||||||
}),
|
|
||||||
version,
|
|
||||||
buildId,
|
|
||||||
channel: branch,
|
|
||||||
};
|
|
||||||
const installer = fs.readFileSync(path.join(directory, 'install-bundle.cjs'));
|
const installer = fs.readFileSync(path.join(directory, 'install-bundle.cjs'));
|
||||||
index.installer = {
|
index.installer = {
|
||||||
size: installer.length,
|
size: installer.length,
|
||||||
sha256: sha256(installer),
|
sha256: sha256(installer),
|
||||||
url: `https://github.com/${repository}/releases/download/${encodeURIComponent(
|
url: `https://github.com/${repository}/releases/download/upgrade-${commit}/install-bundle.cjs`,
|
||||||
tag,
|
|
||||||
)}/install-bundle-${buildId}.cjs`,
|
|
||||||
};
|
};
|
||||||
|
if (!['develop', 'master'].includes(branch))
|
||||||
|
throw new Error('Invalid release channel');
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(directory, 'latest.json'),
|
path.join(directory, 'latest.json'),
|
||||||
JSON.stringify(index) + '\n',
|
JSON.stringify(index) + '\n',
|
||||||
);
|
);
|
||||||
const lookup = (ref) => {
|
const gh = (...args) =>
|
||||||
try {
|
execFileSync('gh', args, {
|
||||||
return JSON.parse(
|
encoding: 'utf8',
|
||||||
gh(
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
'api',
|
});
|
||||||
`repos/${repository}/releases/tags/${encodeURIComponent(ref)}`,
|
const tag = `upgrade-${commit}`;
|
||||||
),
|
try {
|
||||||
|
gh('release', 'view', tag, '--repo', repository);
|
||||||
|
} catch {
|
||||||
|
gh(
|
||||||
|
'release',
|
||||||
|
'create',
|
||||||
|
tag,
|
||||||
|
'--repo',
|
||||||
|
repository,
|
||||||
|
'--target',
|
||||||
|
commit,
|
||||||
|
'--prerelease',
|
||||||
|
'--title',
|
||||||
|
`Platform upgrade ${commit.slice(0, 12)}`,
|
||||||
|
'--notes',
|
||||||
|
'Verified platform bundles with production dependencies. Existing Node runtime is retained.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const assets = fs
|
||||||
|
.readdirSync(directory)
|
||||||
|
.filter((n) => /\.(?:tar\.gz|cjs|json)$/.test(n) && n !== 'latest.json')
|
||||||
|
.map((n) => path.join(directory, n));
|
||||||
|
const published = JSON.parse(
|
||||||
|
gh('api', `repos/${repository}/releases/tags/${tag}`),
|
||||||
|
);
|
||||||
|
const missing = [];
|
||||||
|
for (const file of [...assets, path.join(directory, 'latest.json')]) {
|
||||||
|
const bytes = fs.readFileSync(file);
|
||||||
|
const previous = published.assets.find(
|
||||||
|
(a) => a.name === path.basename(file),
|
||||||
|
);
|
||||||
|
if (!previous) missing.push(file);
|
||||||
|
else if (
|
||||||
|
previous.size !== bytes.length ||
|
||||||
|
previous.digest !== `sha256:${sha256(bytes)}`
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
`Immutable release asset differs: ${path.basename(file)}`,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
}
|
||||||
if (/\bHTTP 404\b/.test(String(error.stderr))) return null;
|
if (missing.length)
|
||||||
throw error;
|
gh('release', 'upload', tag, ...missing, '--repo', repository);
|
||||||
}
|
// An older, slower run may publish immutable assets, but must not regress a channel.
|
||||||
};
|
if (
|
||||||
const notes =
|
|
||||||
`${
|
|
||||||
development ? 'Development' : '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(
|
gh(
|
||||||
'api',
|
'api',
|
||||||
`repos/${repository}/git/ref/heads/${branch}`,
|
`repos/${repository}/git/ref/heads/${branch}`,
|
||||||
'--jq',
|
'--jq',
|
||||||
'.object.sha',
|
'.object.sha',
|
||||||
).trim() === commit;
|
).trim() !== commit
|
||||||
if (!currentSource()) {
|
) {
|
||||||
console.log('Newer source exists; leaving version and channel unchanged');
|
console.log('Newer source exists; leaving channel unchanged');
|
||||||
return false;
|
} else {
|
||||||
}
|
const channel = `upgrade-${branch}`;
|
||||||
const staged = development
|
try {
|
||||||
? stageDevelopmentAssets(directory, commit, buildId)
|
gh('release', 'view', channel, '--repo', repository);
|
||||||
: stageReleaseAssets(directory, commit, buildId, `v${version}`);
|
} catch {
|
||||||
try {
|
|
||||||
ensureRelease(
|
|
||||||
tag,
|
|
||||||
development ? 'Upgrade channel develop' : `Platform upgrade v${version}`,
|
|
||||||
);
|
|
||||||
const assets = staged.assets;
|
|
||||||
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'] : [tag, '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(
|
gh(
|
||||||
'release',
|
'release',
|
||||||
'upload',
|
'create',
|
||||||
ref,
|
channel,
|
||||||
path.join(directory, 'latest.json'),
|
|
||||||
'--repo',
|
'--repo',
|
||||||
repository,
|
repository,
|
||||||
'--clobber',
|
'--target',
|
||||||
|
commit,
|
||||||
|
'--prerelease',
|
||||||
|
'--title',
|
||||||
|
`Upgrade channel ${branch}`,
|
||||||
|
'--notes',
|
||||||
|
'The latest.json asset selects immutable, fully verified platform bundles.',
|
||||||
);
|
);
|
||||||
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
|
gh(
|
||||||
// channel pointer are verified. Production versions and aliases are retained.
|
'release',
|
||||||
if (development && currentSource()) {
|
'upload',
|
||||||
const currentAssets = new Set(assets.map((file) => path.basename(file)));
|
channel,
|
||||||
for (const asset of lookup(tag).assets) {
|
path.join(directory, 'latest.json'),
|
||||||
if (
|
'--repo',
|
||||||
!currentAssets.has(asset.name) &&
|
repository,
|
||||||
(/^qinglong-[a-f0-9]{40}-linux-[\w-]+-\d+-\d+\.tar\.gz(?:\.json)?$/.test(
|
'--clobber',
|
||||||
asset.name,
|
);
|
||||||
) ||
|
|
||||||
/^install-bundle-\d+-\d+\.cjs$/.test(asset.name))
|
|
||||||
) {
|
|
||||||
if (!currentSource()) break;
|
|
||||||
gh(
|
|
||||||
'api',
|
|
||||||
'-X',
|
|
||||||
'DELETE',
|
|
||||||
`repos/${repository}/releases/assets/${asset.id}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const pages = JSON.parse(
|
|
||||||
gh(
|
|
||||||
'api',
|
|
||||||
`repos/${repository}/releases?per_page=100`,
|
|
||||||
'--paginate',
|
|
||||||
'--slurp',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
for (const release of pages.flat()) {
|
|
||||||
if (
|
|
||||||
release.tag_name !== tag &&
|
|
||||||
/^upgrade-build-develop-(?:v\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?-)?\d+-\d+$/.test(
|
|
||||||
release.tag_name,
|
|
||||||
) &&
|
|
||||||
release.prerelease === true &&
|
|
||||||
release.draft === false
|
|
||||||
) {
|
|
||||||
if (!currentSource()) break;
|
|
||||||
gh(
|
|
||||||
'release',
|
|
||||||
'delete',
|
|
||||||
release.tag_name,
|
|
||||||
'--repo',
|
|
||||||
repository,
|
|
||||||
'--yes',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
} finally {
|
|
||||||
fs.rmSync(staged.staged, { recursive: true, force: 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}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
"""Check serialized Docker layers, including files hidden by later deletions."""
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
import tarfile
|
|
||||||
|
|
||||||
archive, architecture = sys.argv[1:]
|
|
||||||
assert architecture in {"amd64", "arm"}
|
|
||||||
nodes = []
|
|
||||||
with tarfile.open(archive) as image:
|
|
||||||
manifests = json.load(image.extractfile("manifest.json"))
|
|
||||||
assert len(manifests) == 1, "Expected one platform image"
|
|
||||||
manifest = manifests[0]
|
|
||||||
config = json.load(image.extractfile(manifest["Config"]))
|
|
||||||
assert config["architecture"] == architecture
|
|
||||||
for name in manifest["Layers"]:
|
|
||||||
with tarfile.open(fileobj=image.extractfile(name), mode="r|*") as layer:
|
|
||||||
for member in layer:
|
|
||||||
filename = member.name.removeprefix("./")
|
|
||||||
assert not (
|
|
||||||
filename == "tmp/node-runtime-amd64" and member.size > 0
|
|
||||||
), "Temporary amd64 Node remains in an immutable layer"
|
|
||||||
if filename not in {"usr/local/bin/node", "usr/bin/node"}:
|
|
||||||
continue
|
|
||||||
if not member.isfile():
|
|
||||||
continue
|
|
||||||
with layer.extractfile(member) as binary:
|
|
||||||
header = binary.read(20)
|
|
||||||
assert header[:4] == b"\x7fELF", "Expected a native Node binary"
|
|
||||||
digest = hashlib.sha256(header)
|
|
||||||
while chunk := binary.read(1024 * 1024):
|
|
||||||
digest.update(chunk)
|
|
||||||
endian = "little" if header[5] == 1 else "big"
|
|
||||||
machine = int.from_bytes(header[18:20], endian)
|
|
||||||
assert machine == {"amd64": 62, "arm": 40}[architecture], (
|
|
||||||
"Wrong Node architecture in an image layer"
|
|
||||||
)
|
|
||||||
nodes.append({"path": filename, "size": member.size, "sha256": digest.hexdigest()})
|
|
||||||
assert len(nodes) == 1, "Expected exactly one retained Node binary across all layers"
|
|
||||||
assert nodes[0]["path"] == (
|
|
||||||
"usr/local/bin/node" if architecture == "amd64" else "usr/bin/node"
|
|
||||||
)
|
|
||||||
print(json.dumps({"pass": True, "architecture": architecture, "layers": len(manifest["Layers"]), "nodes": nodes}))
|
|
||||||
@@ -1,377 +0,0 @@
|
|||||||
// Run service tests only inside a disposable Linux container or CI job.
|
|
||||||
// Unlike source-tree tests, these load the published entrypoints and dependencies.
|
|
||||||
const fs = require('node:fs/promises');
|
|
||||||
const sync = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const os = require('node:os');
|
|
||||||
const http = require('node:http');
|
|
||||||
const net = require('node:net');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const { createHash, randomUUID } = require('node:crypto');
|
|
||||||
const { createRequire } = require('node:module');
|
|
||||||
const { execFile } = require('node:child_process');
|
|
||||||
const { promisify } = require('node:util');
|
|
||||||
const { setTimeout: delay } = require('node:timers/promises');
|
|
||||||
const execute = promisify(execFile);
|
|
||||||
const repository = path.resolve(__dirname, '..');
|
|
||||||
const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
||||||
const read = async (file) => JSON.parse(await fs.readFile(file, 'utf8'));
|
|
||||||
const run = (program, args, options = {}) => execute(program, args, {
|
|
||||||
encoding: 'utf8', maxBuffer: 8 * 1024 * 1024, timeout: 120000,
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
const requiredFiles = [
|
|
||||||
'.env.example', 'back/protos/api.proto', 'ecosystem.config.js', 'version.yaml',
|
|
||||||
'shell/upgrade.cjs', 'shell/install-bundle.cjs', 'shell/install-package.cjs',
|
|
||||||
'shell/upgrade-lock.yaml', 'shell/node-runtime-probe.cjs', 'shell/start.sh', 'shell/start-simplify.sh',
|
|
||||||
'shell/task.sh', 'shell/preload/client.js', 'shell/preload/client.py',
|
|
||||||
'shell/preload/sitecustomize.js', 'shell/preload/sitecustomize.py',
|
|
||||||
'shell/preload/esm-loader.mjs', 'shell/preload/notificationAudit.js',
|
|
||||||
'sample/config.sample.sh', 'sample/auth.sample.json',
|
|
||||||
'static/build-info.json', 'static/build/app.js', 'static/build/token.js',
|
|
||||||
'static/build/bootstrap/upgradeProbe.js',
|
|
||||||
'static/dist/index.html',
|
|
||||||
...['upgradeArtifacts', 'platformBundle', 'upgradePrestart', 'panelUpgrade', 'nodeRuntime']
|
|
||||||
.map((name) => `cli/src/internal/maintenance/${name}.cjs`),
|
|
||||||
];
|
|
||||||
|
|
||||||
function checkPackedFiles(files) {
|
|
||||||
const names = new Set(files.map((file) => file.path));
|
|
||||||
for (const name of requiredFiles) assert.ok(names.has(name), `Missing npm runtime: ${name}`);
|
|
||||||
for (const name of names) {
|
|
||||||
assert.ok(/^(?:package\.json$|LICENSE$|README[^/]*\.md$|\.env\.example$|ecosystem\.config\.js$|version\.yaml$|back\/protos\/api\.proto$|cli\/(?:README[^/]*\.md$|src\/internal\/maintenance\/[^/]+\.cjs$)|sample\/|shell\/|static\/)/.test(name), `Unexpected npm file: ${name}`);
|
|
||||||
assert.ok(!/^shell\/(?:pub\.sh$|preload\/(?:env\.|notify\.|__ql_notify__\.|lang_env\.sh|.*-notify\.json))/.test(name), `Generated or release-only runtime file: ${name}`);
|
|
||||||
assert.ok(!/(?:^|\/)__pycache__\/|\.pyc$|(?:^|\/)\.DS_Store$/.test(name), name);
|
|
||||||
assert.notEqual(name, 'sample/tool.ts');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function packPanel(output) {
|
|
||||||
const git = (...args) => run('git', args, { cwd: repository });
|
|
||||||
assert.equal((await git('status', '--porcelain', '--untracked-files=all')).stdout.trim(), '', 'Pack only from a clean source checkout');
|
|
||||||
await run(process.execPath, [path.join(repository, 'docker/verify-build.cjs')], { cwd: repository });
|
|
||||||
await fs.mkdir(output, { recursive: true });
|
|
||||||
const cache = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-npm-pack-cache-'));
|
|
||||||
try {
|
|
||||||
const result = await run('npm', ['pack', '--json', '--cache', cache, '--pack-destination', output], { cwd: repository });
|
|
||||||
const [manifest] = JSON.parse(result.stdout);
|
|
||||||
checkPackedFiles(manifest.files);
|
|
||||||
assert.equal(manifest.name, '@whyour/qinglong');
|
|
||||||
const build = await read(path.join(repository, 'static/build-info.json'));
|
|
||||||
const names = new Set(manifest.files.map((file) => file.path));
|
|
||||||
for (const name of Object.keys(build.files)) assert.ok(names.has(`static/${name}`), `npm filtered a verified build artifact: ${name}`);
|
|
||||||
const receipt = {
|
|
||||||
filename: manifest.filename, sha256: hash(await fs.readFile(path.join(output, manifest.filename))),
|
|
||||||
sourceCommit: build.sourceCommit, size: manifest.size, fileCount: manifest.files.length,
|
|
||||||
};
|
|
||||||
await fs.writeFile(path.join(output, 'package-receipt.json'), JSON.stringify(receipt, null, 2));
|
|
||||||
console.log(JSON.stringify(receipt));
|
|
||||||
return receipt;
|
|
||||||
} finally {
|
|
||||||
await fs.rm(cache, { recursive: true, force: true });
|
|
||||||
// Also recover an interrupted npm pack whose postpack hook did not execute.
|
|
||||||
await fs.rm(path.join(repository, 'shell/upgrade-lock.yaml'), { force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function unusedPort() {
|
|
||||||
const server = net.createServer();
|
|
||||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
||||||
const port = server.address().port;
|
|
||||||
await new Promise((resolve) => server.close(resolve));
|
|
||||||
return port;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Consume the unmodified output of Dockerfile.upgrade-bundle / buildBundle.
|
|
||||||
async function platformFixture(root, directory, expectedCommit) {
|
|
||||||
assert.ok(directory, 'Set QL_PANEL_TEST_BUNDLE_DIR to the official platform bundle output');
|
|
||||||
const names = (await fs.readdir(directory)).filter((name) => name.endsWith('.tar.gz'));
|
|
||||||
assert.equal(names.length, 1, 'Expected one official platform package');
|
|
||||||
const archive = path.resolve(directory, names[0]);
|
|
||||||
const record = await read(archive + '.json');
|
|
||||||
const bytes = await fs.readFile(archive);
|
|
||||||
assert.equal(record.format, 2);
|
|
||||||
assert.equal(record.sourceCommit, expectedCommit);
|
|
||||||
assert.equal(record.filename, names[0]);
|
|
||||||
assert.equal(record.sha256, hash(bytes));
|
|
||||||
assert.equal(record.size, bytes.length);
|
|
||||||
assert.match(record.baseImage, /^ghcr\.io\/[^@]+@sha256:[a-f0-9]{64}$/);
|
|
||||||
const local = createRequire(path.join(root, 'package.json'));
|
|
||||||
const bundle = local('./cli/src/internal/maintenance/platformBundle.cjs');
|
|
||||||
assert.equal(record.target, bundle.targetKey(await bundle.runtimeTarget()));
|
|
||||||
return { ...record, archive };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function legacyArchive(temp, env) {
|
|
||||||
const directory = path.join(temp, 'legacy');
|
|
||||||
await fs.mkdir(directory);
|
|
||||||
const result = await run('npm', ['pack', '@whyour/qinglong@2.23.0', '--ignore-scripts', '--json', '--registry=https://registry.npmjs.org', '--pack-destination', directory, '--cache', path.join(temp, 'cache')], { cwd: temp, env, timeout: 300000 });
|
|
||||||
const [record] = JSON.parse(result.stdout);
|
|
||||||
assert.equal(record.name, '@whyour/qinglong');
|
|
||||||
assert.equal(record.version, '2.23.0');
|
|
||||||
assert.equal(record.filename, 'whyour-qinglong-2.23.0.tgz');
|
|
||||||
const archive = path.join(directory, record.filename);
|
|
||||||
const integrity = 'sha512-' + createHash('sha512').update(await fs.readFile(archive)).digest('base64');
|
|
||||||
assert.equal(integrity, 'sha512-x6B9s1/SRKkHe7jRQtb3LzMluJ3O3PAAssrr/5jaFMMl7DZvABSEiqkkZMu0r1BNtiCF72uj8OqXN9pCnF2WVw==', 'Published legacy package integrity changed');
|
|
||||||
return { archive, integrity, version: record.version };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function verifyPanel(input, output) {
|
|
||||||
assert.equal(process.platform, 'linux', 'Service tests require Linux');
|
|
||||||
assert.equal(process.env.QL_PANEL_PACKAGE_TEST_ISOLATED, '1', 'Run only inside a disposable Linux container: QL_PANEL_PACKAGE_TEST_ISOLATED=1');
|
|
||||||
const stat = await fs.stat(input);
|
|
||||||
if (stat.isDirectory()) {
|
|
||||||
const names = (await fs.readdir(input)).filter((name) => name.endsWith('.tgz'));
|
|
||||||
assert.equal(names.length, 1, 'Expected one exact npm archive');
|
|
||||||
input = path.join(input, names[0]);
|
|
||||||
}
|
|
||||||
const expected = await read(path.join(path.dirname(input), 'package-receipt.json'));
|
|
||||||
assert.equal(path.basename(input), expected.filename);
|
|
||||||
assert.equal(hash(await fs.readFile(input)), expected.sha256, 'npm archive differs from its build receipt');
|
|
||||||
assert.equal((await fs.stat(input)).size, expected.size);
|
|
||||||
assert.match(expected.sourceCommit, /^[a-f0-9]{40}$/);
|
|
||||||
if (process.env.GITHUB_SHA) assert.equal(expected.sourceCommit, process.env.GITHUB_SHA);
|
|
||||||
const nodeSha = hash(await fs.readFile(process.execPath));
|
|
||||||
const pm2 = process.env.QL_PANEL_TEST_PM2;
|
|
||||||
assert.ok(pm2 && path.isAbsolute(pm2), 'Set QL_PANEL_TEST_PM2 to an isolated PM2 executable');
|
|
||||||
const temp = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'ql-npm-install-')));
|
|
||||||
const home = path.join(temp, 'home'), prefix = path.join(temp, 'prefix'), data = path.join(temp, 'data');
|
|
||||||
await fs.mkdir(home);
|
|
||||||
await fs.mkdir(data);
|
|
||||||
const port = await unusedPort();
|
|
||||||
let grpcPort = await unusedPort();
|
|
||||||
while (grpcPort === port) grpcPort = await unusedPort();
|
|
||||||
const env = { ...process.env, HOME: home, PM2_HOME: path.join(temp, '.pm2'),
|
|
||||||
QL_DATA_DIR: data, QL_BRANCH: 'develop', NODE_ENV: 'production', QL_CONTAINER: 'false',
|
|
||||||
BACK_PORT: String(port), GRPC_PORT: String(grpcPort), QlPort: String(port), QlGrpcPort: String(grpcPort),
|
|
||||||
BIND_HOST: '127.0.0.1', BIND_HOST_GRPC: '127.0.0.1',
|
|
||||||
NODE_PATH: '', NODE_OPTIONS: '',
|
|
||||||
PATH: [path.join(prefix, 'bin'), path.dirname(pm2), process.env.PATH].join(path.delimiter),
|
|
||||||
};
|
|
||||||
for (const key of ['QL_CLI_ROOT', 'QL_DIR', 'QL_UPGRADE_FILE', 'QL_UPGRADE_REPAIR_DEPS', 'QL_UPGRADE_LAUNCHER_PID', 'QL_UPGRADE_INDEX_URL']) delete env[key];
|
|
||||||
let root, fixtureServer, token;
|
|
||||||
const api = async (route, method = 'GET', body, anonymous = false) => {
|
|
||||||
const response = await fetch(`http://127.0.0.1:${port}/api/${route}`, {
|
|
||||||
method, headers: { 'Content-Type': 'application/json', ...(!anonymous && token ? { Authorization: `Bearer ${token}` } : {}) },
|
|
||||||
body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(5000),
|
|
||||||
});
|
|
||||||
const result = await response.json();
|
|
||||||
assert.equal(result.code, 200, `${method} ${route}: ${result.message}`);
|
|
||||||
return result.data;
|
|
||||||
};
|
|
||||||
const healthy = async () => {
|
|
||||||
const deadline = Date.now() + 120000;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
try { const value = await api('health', 'GET', undefined, true); if (value.status === 'ok') return value; } catch {}
|
|
||||||
await delay(300);
|
|
||||||
}
|
|
||||||
throw new Error('Installed npm backend did not become healthy');
|
|
||||||
};
|
|
||||||
const job = async (id, status) => {
|
|
||||||
const deadline = Date.now() + 240000;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
try {
|
|
||||||
const value = await api('system/update-status');
|
|
||||||
assert.equal(value.id, id);
|
|
||||||
if (value.status === status) return value;
|
|
||||||
if (value.status === 'failed' && status !== 'failed') throw new Error(value.error);
|
|
||||||
} catch (error) {
|
|
||||||
if (!/fetch failed|ECONNRESET|ECONNREFUSED|aborted|timeout/i.test(error.message)) throw error;
|
|
||||||
}
|
|
||||||
await delay(250);
|
|
||||||
}
|
|
||||||
throw new Error(`Installed npm update never reached ${status}`);
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
const install = (archive) => run('npm', ['install', '--global', '--prefix', prefix, '--force', '--omit=dev', '--no-audit', '--no-fund', '--cache', path.join(temp, 'cache'), archive], { cwd: temp, env, timeout: 600000 });
|
|
||||||
const start = () => run(path.join(prefix, 'bin/qinglong'), ['reload'], { cwd: temp, env, timeout: 180000 });
|
|
||||||
const stop = () => run(pm2, ['delete', 'ecosystem.config.js'], { cwd: root, env });
|
|
||||||
console.log(`Installing actual npm archive with production lifecycle scripts on ${process.version}`);
|
|
||||||
await install(input);
|
|
||||||
const globalRoot = (await run('npm', ['root', '--global', '--prefix', prefix], { cwd: temp, env })).stdout.trim();
|
|
||||||
root = path.join(globalRoot, '@whyour/qinglong');
|
|
||||||
env.QL_DIR = root;
|
|
||||||
const runtimeProbes = [];
|
|
||||||
const checkRuntime = async () => {
|
|
||||||
const result = await run(process.execPath, [path.join(root, 'shell/node-runtime-probe.cjs')], { cwd: temp, env, timeout: 540000 });
|
|
||||||
runtimeProbes.push(JSON.parse(result.stdout.trim()));
|
|
||||||
};
|
|
||||||
await checkRuntime();
|
|
||||||
// Use a subprocess: cached candidate modules must not mask missing files
|
|
||||||
// after old-package installation and the real npm overwrite below.
|
|
||||||
await run(process.execPath, ['-e', `const root=process.argv[1],p=require('node:path');const m=require(root+'/package.json');for(const target of Object.values(m.bin))require('node:fs').accessSync(p.join(root,target));for(const name of ['platformBundle','upgradePrestart','panelUpgrade'])require(root+'/cli/src/internal/maintenance/'+name+'.cjs');require(root+'/shell/upgrade.cjs').verifyUpgrade(root,p.join(root,'static')).catch(e=>{console.error(e);process.exitCode=1});`, root], { env, cwd: temp });
|
|
||||||
env.QL_DATA_DIR = path.join(temp, 'fresh/data');
|
|
||||||
await fs.mkdir(env.QL_DATA_DIR, { recursive: true });
|
|
||||||
console.log('Starting the freshly installed npm package');
|
|
||||||
await start();
|
|
||||||
const fresh = await healthy();
|
|
||||||
assert.equal(fresh.build.sourceCommit, expected.sourceCommit);
|
|
||||||
assert.equal(fresh.services.grpc, true);
|
|
||||||
assert.match(await fetch(`http://127.0.0.1:${port}/`).then((r) => r.text()), /<html/i);
|
|
||||||
await stop();
|
|
||||||
await fs.rm(path.join(temp, 'fresh'), { recursive: true, force: true });
|
|
||||||
env.QL_DATA_DIR = data;
|
|
||||||
console.log('Installing the integrity-pinned published legacy npm package');
|
|
||||||
const legacy = await legacyArchive(temp, env);
|
|
||||||
await install(legacy.archive);
|
|
||||||
assert.equal((await read(path.join(root, 'package.json'))).version, legacy.version);
|
|
||||||
await assert.rejects(fs.access(path.join(root, 'cli/src/internal/maintenance/platformBundle.cjs')), { code: 'ENOENT' });
|
|
||||||
const legacyBackendSha = hash(await fs.readFile(path.join(root, 'static/build/app.js')));
|
|
||||||
await start();
|
|
||||||
await healthy();
|
|
||||||
const credentials = { username: 'npm_package_verification', password: `Test_${randomUUID()}!` };
|
|
||||||
await api('user/init', 'PUT', credentials, true);
|
|
||||||
token = (await api('user/login', 'POST', credentials, true)).token;
|
|
||||||
const nonce = randomUUID();
|
|
||||||
await api('envs', 'POST', [{ name: 'NPM_PACKAGE_PRESERVE', value: nonce }]);
|
|
||||||
const script = path.join(data, 'scripts/npm-package-check.js');
|
|
||||||
await fs.writeFile(script, `console.log(${JSON.stringify(nonce)});\n`);
|
|
||||||
const cron = await api('crons', 'POST', { name: 'npm package retained task', command: `node ${script}`, schedule: '0 0 1 1 *' });
|
|
||||||
const userConfig = path.join(data, 'config/config.sh');
|
|
||||||
await fs.appendFile(userConfig, `\n# retained npm package setting ${nonce}\n`);
|
|
||||||
const userConfigSha = hash(await fs.readFile(userConfig));
|
|
||||||
const preservedData = async () => {
|
|
||||||
assert.equal((await api('user')).username, credentials.username);
|
|
||||||
assert.ok((await api('envs')).some((x) => x.name === 'NPM_PACKAGE_PRESERVE' && x.value === nonce));
|
|
||||||
assert.equal((await api(`crons/${cron.id}`)).command, cron.command);
|
|
||||||
assert.equal(hash(await fs.readFile(userConfig)), userConfigSha);
|
|
||||||
assert.equal(await fs.readFile(script, 'utf8'), `console.log(${JSON.stringify(nonce)});\n`);
|
|
||||||
};
|
|
||||||
await preservedData();
|
|
||||||
console.log('Stopping legacy service and verifying npm automatically preserves its .env during overwrite');
|
|
||||||
await stop();
|
|
||||||
await fs.appendFile(path.join(root, '.env'), `\n# retained custom npm environment ${nonce}\n`);
|
|
||||||
const savedEnv = await fs.readFile(path.join(root, '.env'));
|
|
||||||
await install(input);
|
|
||||||
assert.deepEqual(await fs.readFile(path.join(root, '.env')), savedEnv);
|
|
||||||
assert.equal((await fs.stat(path.join(root, '.env'))).mode & 0o777, 0o600);
|
|
||||||
const configSha = hash(savedEnv);
|
|
||||||
const local = createRequire(path.join(root, 'package.json'));
|
|
||||||
const artifacts = local('./shell/upgrade.cjs');
|
|
||||||
const build = await artifacts.verifyUpgrade(root, path.join(root, 'static'));
|
|
||||||
assert.equal(build.sourceCommit, expected.sourceCommit);
|
|
||||||
assert.notEqual(hash(await fs.readFile(path.join(root, 'static/build/app.js'))), legacyBackendSha);
|
|
||||||
assert.equal(hash(await fs.readFile(path.join(root, 'pnpm-lock.yaml'))), build.lockfileSha256);
|
|
||||||
const fixture = await platformFixture(root, process.env.QL_PANEL_TEST_BUNDLE_DIR, expected.sourceCommit);
|
|
||||||
let badChecksum = true, requests = 0;
|
|
||||||
fixtureServer = http.createServer((request, response) => {
|
|
||||||
requests++;
|
|
||||||
if (request.url === '/latest.json') {
|
|
||||||
response.setHeader('Content-Type', 'application/json');
|
|
||||||
response.end(JSON.stringify({ format: 2, sourceCommit: fixture.sourceCommit, packages: { [fixture.target]: { size: fixture.size, sha256: badChecksum ? '0'.repeat(64) : fixture.sha256, url: `http://127.0.0.1:${fixtureServer.address().port}/bundle.tar.gz` } } }));
|
|
||||||
} else if (request.url === '/bundle.tar.gz') {
|
|
||||||
response.setHeader('Content-Length', fixture.size);
|
|
||||||
sync.createReadStream(fixture.archive).pipe(response);
|
|
||||||
} else { response.statusCode = 404; response.end(); }
|
|
||||||
});
|
|
||||||
await new Promise((resolve) => fixtureServer.listen(0, '127.0.0.1', resolve));
|
|
||||||
env.QL_UPGRADE_INDEX_URL = `http://127.0.0.1:${fixtureServer.address().port}/latest.json`;
|
|
||||||
console.log('Starting the overwritten npm package against legacy user data');
|
|
||||||
await start();
|
|
||||||
const original = await healthy();
|
|
||||||
assert.equal(original.build.sourceCommit, build.sourceCommit);
|
|
||||||
assert.equal(original.services.grpc, true);
|
|
||||||
token = (await api('user/login', 'POST', credentials, true)).token;
|
|
||||||
const preserved = async () => {
|
|
||||||
await preservedData();
|
|
||||||
assert.equal(hash(await fs.readFile(path.join(root, '.env'))), configSha);
|
|
||||||
};
|
|
||||||
await preserved();
|
|
||||||
console.log('Testing a rejected download through the installed panel API');
|
|
||||||
const rejected = await api('system/update', 'PUT');
|
|
||||||
assert.match((await job(rejected.id, 'failed')).error, /checksum|校验/i);
|
|
||||||
assert.equal((await healthy()).build.instanceId, original.build.instanceId);
|
|
||||||
await preserved();
|
|
||||||
badChecksum = false;
|
|
||||||
console.log('Testing installed panel download, prestart and explicit application');
|
|
||||||
const accepted = await api('system/update', 'PUT');
|
|
||||||
const ready = await job(accepted.id, 'ready');
|
|
||||||
assert.equal((await healthy()).build.instanceId, original.build.instanceId);
|
|
||||||
const pointer = await read(path.join(root, '.tmp/upgrade-ready-develop.json'));
|
|
||||||
const receipt = await read(path.join(root, '.tmp', pointer.directory, 'prestart.json'));
|
|
||||||
assert.ok(receipt.completedAt <= ready.updatedAt, 'Readiness was recorded before backend prestart');
|
|
||||||
const applying = await api('update/system', 'PUT');
|
|
||||||
await job(applying.id, 'completed');
|
|
||||||
const afterOnline = await healthy();
|
|
||||||
await checkRuntime();
|
|
||||||
assert.notEqual(afterOnline.build.instanceId, original.build.instanceId);
|
|
||||||
assert.equal(afterOnline.build.sourceCommit, fixture.sourceCommit);
|
|
||||||
await fs.access(path.join(root, 'node_modules/.pnpm'));
|
|
||||||
assert.match(await fs.realpath(path.join(root, 'node_modules/sqlite3')), /[/\\]node_modules[/\\]\.pnpm[/\\]/);
|
|
||||||
await fs.access(path.join(root, 'scripts/build-upgrade-bundle.cjs'));
|
|
||||||
await artifacts.verifyUpgrade(root, path.join(root, 'static'));
|
|
||||||
assert.match(await fetch(`http://127.0.0.1:${port}/`).then((r) => r.text()), /<html/i);
|
|
||||||
await preserved();
|
|
||||||
console.log('Testing offline installer and Bash reload from the installed npm package');
|
|
||||||
const requestsBeforeOffline = requests;
|
|
||||||
await run(process.execPath, [path.join(root, 'shell/install-bundle.cjs'), '--root', root, '--file', fixture.archive, '--download-only'], { cwd: temp, env, timeout: 240000 });
|
|
||||||
assert.equal(requests, requestsBeforeOffline, 'Offline preparation contacted the index');
|
|
||||||
assert.equal((await healthy()).build.instanceId, afterOnline.build.instanceId);
|
|
||||||
await run(path.join(prefix, 'bin/ql'), ['reload', 'system'], { cwd: temp, env, timeout: 240000 });
|
|
||||||
const afterOffline = await healthy();
|
|
||||||
await checkRuntime();
|
|
||||||
assert.notEqual(afterOffline.build.instanceId, afterOnline.build.instanceId);
|
|
||||||
assert.equal(afterOffline.build.sourceCommit, fixture.sourceCommit);
|
|
||||||
await fs.access(path.join(root, 'node_modules/.pnpm'));
|
|
||||||
assert.equal(hash(await fs.readFile(process.execPath)), nodeSha);
|
|
||||||
assert.equal((await api('system/update-status')).status, 'completed');
|
|
||||||
await preserved();
|
|
||||||
await api('crons/run', 'PUT', [cron.id]);
|
|
||||||
let taskExecuted = false;
|
|
||||||
for (let i = 0; i < 100; i++) {
|
|
||||||
const log = String(await api(`crons/${cron.id}/log`));
|
|
||||||
if (log.includes(nonce) && /完成|执行结束|finished|completed/i.test(log)) { taskExecuted = true; break; }
|
|
||||||
await delay(300);
|
|
||||||
}
|
|
||||||
assert.ok(taskExecuted, 'Retained task did not execute after npm package upgrades');
|
|
||||||
const result = {
|
|
||||||
node: process.version, platform: fixture.target,
|
|
||||||
archiveSha256: hash(await fs.readFile(input)), sourceCommit: build.sourceCommit,
|
|
||||||
installedProductionDependencies: true, restoredLockVerified: true,
|
|
||||||
legacyNpmVersion: legacy.version, legacyNpmIntegrity: legacy.integrity,
|
|
||||||
legacyNpmOverwriteInstalled: true, legacyExternalDataPreserved: true,
|
|
||||||
customRootEnvironmentPreservedAutomatically: true,
|
|
||||||
officialPlatformBundle: { baseImage: fixture.baseImage, sourceCommit: fixture.sourceCommit, sha256: fixture.sha256 },
|
|
||||||
migratedFromNpmToOfficialPnpmLayout: true,
|
|
||||||
existingNodePreserved: true, nativeUrlRuntimeProbes: runtimeProbes,
|
|
||||||
frontendAndBackendStarted: true, checksumFailureKeptOldService: true,
|
|
||||||
prestartBeforeReady: true, onlineActualRestart: true,
|
|
||||||
offlinePreparationDidNotContactIndex: true, offlineBashActualRestart: true,
|
|
||||||
accountEnvironmentConfigurationAndScriptsPreserved: true, retainedTaskExecuted: true,
|
|
||||||
initialBuild: original.build, afterOnline: afterOnline.build, afterOffline: afterOffline.build,
|
|
||||||
};
|
|
||||||
if (output) {
|
|
||||||
await fs.mkdir(output, { recursive: true });
|
|
||||||
await fs.writeFile(path.join(output, `verification-${process.version}.json`), JSON.stringify(result, null, 2));
|
|
||||||
}
|
|
||||||
console.log(JSON.stringify(result, null, 2));
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
if (root) {
|
|
||||||
for (const file of ['qinglong-error.log', 'qinglong-out.log']) {
|
|
||||||
const text = await fs.readFile(path.join(env.PM2_HOME, 'logs', file), 'utf8').catch(() => '');
|
|
||||||
if (text) console.error(text.slice(-6000));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
if (fixtureServer) {
|
|
||||||
fixtureServer.closeAllConnections();
|
|
||||||
await new Promise((resolve) => fixtureServer.close(resolve));
|
|
||||||
}
|
|
||||||
await run(pm2, ['kill'], { env, cwd: temp }).catch(() => {});
|
|
||||||
await run('nginx', ['-s', 'quit'], { env, cwd: temp }).catch(() => {});
|
|
||||||
await fs.rm(temp, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { checkPackedFiles, packPanel, verifyPanel };
|
|
||||||
if (require.main === module) {
|
|
||||||
const [action, input, output] = process.argv.slice(2);
|
|
||||||
const operation = action === 'pack' && input
|
|
||||||
? packPanel(path.resolve(input))
|
|
||||||
: action === 'verify' && input
|
|
||||||
? verifyPanel(path.resolve(input), output && path.resolve(output))
|
|
||||||
: Promise.reject(new Error('Usage: node scripts/verify-panel-package.cjs pack OUTPUT | verify ARCHIVE_OR_DIRECTORY [RESULT_DIRECTORY]'));
|
|
||||||
operation.catch((error) => { console.error(error); process.exitCode = 1; });
|
|
||||||
}
|
|
||||||
@@ -47,19 +47,7 @@ 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
|
||||||
# Ubuntu 26.04's default timeout is from uutils. We require GNU's process-group
|
command -v timeout >/dev/null || { echo 'GNU timeout is required' >&2; exit 2; }
|
||||||
# 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
|
||||||
@@ -74,7 +62,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_command" --signal=KILL "${budget}s" "$@"
|
timeout --signal=KILL "${budget}s" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
notify_static_mirror() {
|
notify_static_mirror() {
|
||||||
@@ -168,7 +156,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_command" --signal=KILL "${budget}s" git ls-remote --refs \
|
if ! output=$(timeout --signal=KILL "${budget}s" git ls-remote --refs \
|
||||||
"${urls[$index]}" "${refs[@]}" | LC_ALL=C sort); then
|
"${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
|
||||||
|
|||||||
+107
-234
@@ -3,42 +3,11 @@
|
|||||||
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 { randomUUID } = require('node:crypto');
|
|
||||||
const { promisify } = require('node:util');
|
const { promisify } = require('node:util');
|
||||||
const execute = promisify(require('node:child_process').execFile);
|
const execute = promisify(require('node:child_process').execFile);
|
||||||
const { parseArgs } = require('node:util');
|
const { parseArgs } = require('node:util');
|
||||||
const artifacts = require('../cli/src/internal/maintenance/upgradeArtifacts.cjs');
|
const artifacts = require('../cli/src/internal/maintenance/upgradeArtifacts.cjs');
|
||||||
const jobs = require('../cli/src/internal/maintenance/panelUpgrade.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) {
|
async function installBundle(argv = process.argv.slice(2), env = process.env) {
|
||||||
const { values } = parseArgs({
|
const { values } = parseArgs({
|
||||||
@@ -48,12 +17,11 @@ async function installBundle(argv = process.argv.slice(2), env = process.env) {
|
|||||||
file: { type: 'string' },
|
file: { type: 'string' },
|
||||||
'download-only': { type: 'boolean' },
|
'download-only': { type: 'boolean' },
|
||||||
install: { type: 'boolean' },
|
install: { type: 'boolean' },
|
||||||
'repair-deps': { type: 'boolean' },
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!values.file || !(values.root || env.QL_DIR))
|
if (!values.file || !(values.root || env.QL_DIR))
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Usage: node install-bundle.cjs --root /path/to/qinglong --file /path/to/bundle.tar.gz [--download-only | --install] [--repair-deps]',
|
'Usage: node install-bundle.cjs --root /path/to/qinglong --file /path/to/bundle.tar.gz [--download-only | --install]',
|
||||||
);
|
);
|
||||||
if (values.install && values['download-only'])
|
if (values.install && values['download-only'])
|
||||||
throw new Error('Choose --install or --download-only');
|
throw new Error('Choose --install or --download-only');
|
||||||
@@ -96,225 +64,130 @@ async function installBundle(argv = process.argv.slice(2), env = process.env) {
|
|||||||
QL_UPGRADE_FILE: path.resolve(values.file),
|
QL_UPGRADE_FILE: path.resolve(values.file),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
environment.QL_UPGRADE_REPAIR_DEPS = values['repair-deps'] ? '1' : '0';
|
|
||||||
const freshWorkspace = values.install
|
const freshWorkspace = values.install
|
||||||
? await fs.mkdtemp(path.join(os.tmpdir(), 'ql-offline-install-'))
|
? await fs.mkdtemp(path.join(os.tmpdir(), 'ql-offline-install-'))
|
||||||
: undefined;
|
: undefined;
|
||||||
const tmp = freshWorkspace || path.join(root, '.tmp');
|
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 {
|
try {
|
||||||
const operation = () =>
|
return await artifacts.withUpgradeLock(
|
||||||
artifacts.withUpgradeLock(
|
tmp,
|
||||||
tmp,
|
async () => {
|
||||||
async () => {
|
const selected = await artifacts.stageUpgrade({
|
||||||
let state;
|
root,
|
||||||
const report = async (patch) => {
|
tmp,
|
||||||
if (patch.phase) console.log(patch.phase);
|
env: environment,
|
||||||
if (state) {
|
run: (program, args, capture) =>
|
||||||
state = {
|
artifacts.runProcess(environment, program, args, capture),
|
||||||
...state,
|
onProgress: async (p) => console.log(p.phase),
|
||||||
...patch,
|
install: async () => {
|
||||||
pid: process.pid,
|
throw new Error('Offline bundles never install npm dependencies');
|
||||||
updatedAt: Date.now(),
|
},
|
||||||
};
|
});
|
||||||
await jobs.saveState(tmp, state);
|
if (values['download-only']) {
|
||||||
}
|
console.log('更新已就绪;执行 ql reload system 应用更新。');
|
||||||
};
|
return selected;
|
||||||
if (!values.install) {
|
}
|
||||||
const previous = await jobs.getStatus(tmp);
|
if (values.install) {
|
||||||
if (['starting', 'running', 'restarting'].includes(previous.status))
|
// Build beside the destination, then atomically replace only an empty root.
|
||||||
throw new Error('已有更新或重载正在执行。');
|
await fs.mkdir(path.dirname(root), { recursive: true });
|
||||||
state = {
|
const publication = await fs.mkdtemp(
|
||||||
id: randomUUID(),
|
path.join(path.dirname(root), '.qinglong-install-'),
|
||||||
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 {
|
try {
|
||||||
const selected = await artifacts.stageUpgrade({
|
await fs.cp(selected.source, publication, {
|
||||||
root,
|
recursive: true,
|
||||||
tmp,
|
verbatimSymlinks: true,
|
||||||
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 fs.cp(selected.static, path.join(publication, 'static'), {
|
||||||
await report({
|
recursive: true,
|
||||||
status: 'ready',
|
});
|
||||||
phase: 'ready',
|
await fs.copyFile(
|
||||||
...(await jobs.stagedMetadata(selected)),
|
path.join(publication, '.env.example'),
|
||||||
});
|
path.join(publication, '.env'),
|
||||||
if (values['download-only']) {
|
require('node:fs').constants.COPYFILE_EXCL,
|
||||||
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' });
|
await fs.chmod(path.join(publication, '.env'), 0o600);
|
||||||
console.log('升级成功,已保留现有 Node 并运行更新后的版本。');
|
await fs.rename(publication, root);
|
||||||
return result;
|
} finally {
|
||||||
} catch (error) {
|
await fs.rm(publication, { recursive: true, force: true });
|
||||||
if (state) await report({ status: 'failed', error: error.message });
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
},
|
await fs.rm(path.dirname(selected.source), {
|
||||||
environment,
|
recursive: true,
|
||||||
);
|
force: true,
|
||||||
return values.install
|
});
|
||||||
? await operation()
|
await fs.rm(
|
||||||
: await artifacts.withUpgradeLock(
|
path.join(
|
||||||
path.join(tmp, 'panel-job-launch'),
|
tmp,
|
||||||
operation,
|
`upgrade-ready-${artifacts.releaseBranch(environment)}.json`,
|
||||||
|
),
|
||||||
|
{ force: true },
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`离线安装完成,保留现有 Node ${
|
||||||
|
process.version
|
||||||
|
}。启动服务:QL_DIR=${JSON.stringify(root)} bash ${JSON.stringify(
|
||||||
|
path.join(root, 'shell/start-simplify.sh'),
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return jobs.withPanelLifecycle(
|
||||||
|
root,
|
||||||
|
tmp,
|
||||||
environment,
|
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: {
|
||||||
|
stop: () => hook('stop'),
|
||||||
|
start: async (recover) => {
|
||||||
|
await hook('start');
|
||||||
|
await artifacts.waitForHealth(environment);
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
environment,
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
process.removeListener('SIGINT', abort);
|
|
||||||
process.removeListener('SIGTERM', abort);
|
|
||||||
if (freshWorkspace)
|
if (freshWorkspace)
|
||||||
await fs.rm(freshWorkspace, { recursive: true, force: true });
|
await fs.rm(freshWorkspace, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { installBundle, runInstallerMain };
|
module.exports = { installBundle };
|
||||||
if (require.main === module)
|
if (require.main === module)
|
||||||
runInstallerMain().catch((error) => {
|
installBundle().catch((error) => {
|
||||||
console.error(error.message);
|
console.error(error.message);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const { createHash, randomUUID } = require('node:crypto');
|
|
||||||
|
|
||||||
function restorePackageLock(root = path.resolve(__dirname, '..')) {
|
|
||||||
const copy = path.join(root, 'shell/upgrade-lock.yaml');
|
|
||||||
const destination = path.join(root, 'pnpm-lock.yaml');
|
|
||||||
if (!fs.existsSync(copy)) {
|
|
||||||
// Source checkouts already have the canonical lock; no built artifacts yet.
|
|
||||||
if (fs.existsSync(destination)) return;
|
|
||||||
throw new Error('Missing npm package upgrade lockfile');
|
|
||||||
}
|
|
||||||
const bytes = fs.readFileSync(copy);
|
|
||||||
const manifest = JSON.parse(
|
|
||||||
fs.readFileSync(path.join(root, 'static/build-info.json'), 'utf8'),
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
manifest.dirty !== false ||
|
|
||||||
manifest.lockfileSha256 !== createHash('sha256').update(bytes).digest('hex')
|
|
||||||
)
|
|
||||||
throw new Error('npm package lockfile does not match its build manifest');
|
|
||||||
const temporary = path.join(root, `.upgrade-lock-${randomUUID()}`);
|
|
||||||
try {
|
|
||||||
fs.writeFileSync(temporary, bytes, { flag: 'wx', mode: 0o644 });
|
|
||||||
fs.renameSync(temporary, destination);
|
|
||||||
} finally {
|
|
||||||
fs.rmSync(temporary, { force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function restoreEnvironment(root = path.resolve(__dirname, '..')) {
|
|
||||||
// npm's Arborist keeps this exact retirement directory until lifecycle scripts
|
|
||||||
// succeed, then deletes it. Never search other installations or sibling backups.
|
|
||||||
const suffix = createHash('sha1').update(root).digest('base64')
|
|
||||||
.replace(/[^a-zA-Z0-9]+/g, '').slice(0, 8);
|
|
||||||
const retired = path.join(path.dirname(root), `.${path.basename(root)}-${suffix}`);
|
|
||||||
const destination = path.join(root, '.env');
|
|
||||||
const exists = (file) => {
|
|
||||||
try { return fs.lstatSync(file); }
|
|
||||||
catch (error) { if (error.code === 'ENOENT') return; throw error; }
|
|
||||||
};
|
|
||||||
if (exists(destination)) return;
|
|
||||||
const directory = exists(retired);
|
|
||||||
if (!directory) return;
|
|
||||||
if (!directory.isDirectory()) throw new Error('Invalid npm retirement directory');
|
|
||||||
const metadata = path.join(retired, 'package.json');
|
|
||||||
if (!exists(metadata)?.isFile()) throw new Error('Invalid retired npm package');
|
|
||||||
if (JSON.parse(fs.readFileSync(metadata, 'utf8')).name !== '@whyour/qinglong')
|
|
||||||
throw new Error('Unexpected retired npm package');
|
|
||||||
const source = path.join(retired, '.env');
|
|
||||||
const stat = exists(source);
|
|
||||||
if (!stat) return;
|
|
||||||
if (!stat.isFile()) throw new Error('Retired npm .env must be a regular file');
|
|
||||||
const descriptor = fs.openSync(source, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
||||||
const temporary = path.join(root, `.upgrade-env-${randomUUID()}`);
|
|
||||||
try {
|
|
||||||
if (!fs.fstatSync(descriptor).isFile()) throw new Error('Invalid retired npm .env');
|
|
||||||
fs.writeFileSync(temporary, fs.readFileSync(descriptor), { flag: 'wx', mode: 0o600 });
|
|
||||||
// Linking publishes a complete file without replacing a concurrently created .env.
|
|
||||||
try { fs.linkSync(temporary, destination); }
|
|
||||||
catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
||||||
} finally {
|
|
||||||
fs.closeSync(descriptor);
|
|
||||||
fs.rmSync(temporary, { force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function installPackage(root = path.resolve(__dirname, '..')) {
|
|
||||||
restorePackageLock(root);
|
|
||||||
restoreEnvironment(root);
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { restorePackageLock, restoreEnvironment, installPackage };
|
|
||||||
if (require.main === module) installPackage();
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const os = require('node:os');
|
|
||||||
const path = require('node:path');
|
|
||||||
const { createHash } = require('node:crypto');
|
|
||||||
const { spawnSync } = require('node:child_process');
|
|
||||||
|
|
||||||
// Run in new processes: a healthy parent can outlive a broken native runtime.
|
|
||||||
function probeNodeRuntime(executable = process.execPath) {
|
|
||||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-node-url-'));
|
|
||||||
const source = `
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const { pathToFileURL, fileURLToPath } = require('node:url');
|
|
||||||
for (let i = 0; i < 1000; i++) {
|
|
||||||
assert.equal(new URL('/任务?q=' + i, 'https://example.com').hostname, 'example.com');
|
|
||||||
assert.equal(new URL('https://例子.测试').hostname, 'xn--fsqu00a.xn--0zwm56d');
|
|
||||||
assert.equal(new URL('http://[::1]:5700').port, '5700');
|
|
||||||
const filename = '/tmp/中文 path/' + i + '.cjs';
|
|
||||||
assert.equal(fileURLToPath(pathToFileURL(filename)), filename);
|
|
||||||
}
|
|
||||||
console.log('QINGLONG_NODE_URL_OK');
|
|
||||||
`;
|
|
||||||
const file = path.join(directory, 'probe.cjs');
|
|
||||||
fs.writeFileSync(file, source);
|
|
||||||
try {
|
|
||||||
for (let i = 0; i < 4; i++) {
|
|
||||||
const args = process.execArgv.includes('--jitless') ? ['--jitless'] : [];
|
|
||||||
args.push(...(i % 2 ? [file] : ['-e', source]));
|
|
||||||
const child = spawnSync(executable, args, {
|
|
||||||
encoding: 'utf8',
|
|
||||||
// Multi-architecture CI runs this same probe through QEMU.
|
|
||||||
timeout: 120000,
|
|
||||||
maxBuffer: 65536,
|
|
||||||
env: { ...process.env, NODE_OPTIONS: '' },
|
|
||||||
});
|
|
||||||
assert.equal(
|
|
||||||
child.status,
|
|
||||||
0,
|
|
||||||
`Node URL runtime probe failed (${i % 2 ? 'file' : 'eval'}): signal=${
|
|
||||||
child.signal
|
|
||||||
}; status=${child.status}; ${child.error?.message || child.stderr}`,
|
|
||||||
);
|
|
||||||
assert.match(child.stdout, /QINGLONG_NODE_URL_OK/);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
node: process.version,
|
|
||||||
architecture: process.arch,
|
|
||||||
ada: process.versions.ada,
|
|
||||||
processes: 4,
|
|
||||||
urlIterationsPerProcess: 1000,
|
|
||||||
nodeSha256: createHash('sha256')
|
|
||||||
.update(fs.readFileSync(executable))
|
|
||||||
.digest('hex'),
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
fs.rmSync(directory, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { probeNodeRuntime };
|
|
||||||
if (require.main === module) console.log(JSON.stringify(probeNodeRuntime()));
|
|
||||||
@@ -27,22 +27,14 @@ module.exports = async function reportNotificationAudit(payload) {
|
|||||||
credentials,
|
credentials,
|
||||||
{ 'grpc.enable_http_proxy': 0 },
|
{ 'grpc.enable_http_proxy': 0 },
|
||||||
);
|
);
|
||||||
// Python sends all channel results together so they share startup and TLS.
|
await new Promise((resolve) => {
|
||||||
const records = Array.isArray(payload) ? payload : [payload];
|
client.RecordNotifyLog(
|
||||||
const deadline = new Date(Date.now() + 5000);
|
payload,
|
||||||
await Promise.all(
|
new grpc.Metadata(),
|
||||||
records.map(
|
{ deadline: new Date(Date.now() + 1500) },
|
||||||
(record) =>
|
() => resolve(),
|
||||||
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 {
|
||||||
|
|||||||
+2
-15
@@ -294,18 +294,6 @@ 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
|
||||||
@@ -317,7 +305,6 @@ 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
|
||||||
@@ -325,9 +312,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 "${ql_node_runtime_args[@]}" static/build/app.js >/proc/1/fd/1 2>/proc/1/fd/2 &
|
nohup node static/build/app.js >/proc/1/fd/1 2>/proc/1/fd/2 &
|
||||||
else
|
else
|
||||||
nohup node "${ql_node_runtime_args[@]}" static/build/app.js >"$dir_log/qinglong.log" 2>&1 &
|
nohup node static/build/app.js >"$dir_log/qinglong.log" 2>&1 &
|
||||||
fi
|
fi
|
||||||
local node_pid=$!
|
local node_pid=$!
|
||||||
|
|
||||||
|
|||||||
+1
-13
@@ -52,19 +52,7 @@ make_dir /etc/nginx/conf.d
|
|||||||
make_dir /run/nginx
|
make_dir /run/nginx
|
||||||
fix_config
|
fix_config
|
||||||
|
|
||||||
# Slow devices may need more than the worker's readiness window to generate
|
pm2 l &>/dev/null
|
||||||
# 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
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ 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, {
|
import useUpgrade, { activeStatuses, phaseLabels } from '@/hooks/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({
|
||||||
@@ -24,15 +21,16 @@ export default function UpgradeNotice({
|
|||||||
if (!showNotice && !open) return null;
|
if (!showNotice && !open) return null;
|
||||||
const text = !connected
|
const text = !connected
|
||||||
? intl.get('连接已断开,正在重新连接')
|
? intl.get('连接已断开,正在重新连接')
|
||||||
: intl.get(upgradeStatusLabel(job));
|
: job.status === 'failed'
|
||||||
|
? 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 && !open && (
|
{showNotice && (
|
||||||
<Tooltip title={label} placement="right">
|
<Tooltip title={label} placement="right">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -25,26 +25,11 @@ 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 = {
|
||||||
|
|||||||
@@ -346,11 +346,6 @@ 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 {
|
||||||
|
|||||||
@@ -734,8 +734,6 @@
|
|||||||
"从开始至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",
|
||||||
@@ -746,7 +744,6 @@
|
|||||||
"更新已就绪,重启后应用新版本": "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",
|
||||||
|
|||||||
@@ -734,8 +734,6 @@
|
|||||||
"从开始至load总耗时": "从开始至 load 总耗时: {duration} ms",
|
"从开始至load总耗时": "从开始至 load 总耗时: {duration} ms",
|
||||||
"正在解压更新包": "正在解压更新包",
|
"正在解压更新包": "正在解压更新包",
|
||||||
"正在校验更新包": "正在校验更新包",
|
"正在校验更新包": "正在校验更新包",
|
||||||
"正在预启动验证": "正在预启动验证",
|
|
||||||
"正在修复运行依赖": "正在修复运行依赖",
|
|
||||||
"正在准备依赖": "正在准备依赖",
|
"正在准备依赖": "正在准备依赖",
|
||||||
"更新已就绪": "更新已就绪",
|
"更新已就绪": "更新已就绪",
|
||||||
"正在重启并更新": "正在重启并更新",
|
"正在重启并更新": "正在重启并更新",
|
||||||
@@ -746,7 +744,6 @@
|
|||||||
"更新已就绪,重启后应用新版本": "更新已就绪,重启后应用新版本",
|
"更新已就绪,重启后应用新版本": "更新已就绪,重启后应用新版本",
|
||||||
"更新在后台进行,可以继续使用面板": "更新在后台进行,可以继续使用面板",
|
"更新在后台进行,可以继续使用面板": "更新在后台进行,可以继续使用面板",
|
||||||
"下载构建产物": "下载构建产物",
|
"下载构建产物": "下载构建产物",
|
||||||
"下载更新包": "下载更新包",
|
|
||||||
"下载源码": "下载源码",
|
"下载源码": "下载源码",
|
||||||
"正在获取下载大小": "正在获取下载大小",
|
"正在获取下载大小": "正在获取下载大小",
|
||||||
"查看更新日志": "查看更新日志",
|
"查看更新日志": "查看更新日志",
|
||||||
|
|||||||
@@ -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,
|
||||||
upgradeStatusLabel,
|
phaseLabels,
|
||||||
reloadKey,
|
reloadKey,
|
||||||
upgradeStore,
|
upgradeStore,
|
||||||
} from '@/hooks/useUpgrade';
|
} from '@/hooks/useUpgrade';
|
||||||
@@ -100,7 +100,14 @@ const CheckUpdate = ({ systemInfo }: any) => {
|
|||||||
onOk: () => reloadSystem(true),
|
onOk: () => reloadSystem(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
const label = intl.get(upgradeStatusLabel(job));
|
const label =
|
||||||
|
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>
|
||||||
@@ -160,9 +167,7 @@ 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-bundle'
|
job.artifact === 'qinglong-static'
|
||||||
? '下载更新包'
|
|
||||||
: job.artifact === 'qinglong-static'
|
|
||||||
? '下载构建产物'
|
? '下载构建产物'
|
||||||
: '下载源码',
|
: '下载源码',
|
||||||
)}
|
)}
|
||||||
|
|||||||
+22
-24
@@ -1,29 +1,27 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import browserType from './index';
|
||||||
|
|
||||||
export const useCtx = () => {
|
export const useCtx = () => {
|
||||||
// Match ProLayout's xs/sm breakpoint, including narrow desktop windows.
|
const [width, setWidth] = useState('100%');
|
||||||
const [isPhone, setIsPhone] = useState(
|
const [marginLeft, setMarginLeft] = useState(0);
|
||||||
() =>
|
const [marginTop, setMarginTop] = useState(-48);
|
||||||
typeof window !== 'undefined' &&
|
const [isPhone, setIsPhone] = useState(false);
|
||||||
window.matchMedia('(max-width: 767.99px)').matches,
|
const { platform } = useMemo(() => browserType(), []);
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const media = window.matchMedia('(max-width: 767.99px)');
|
if (platform === 'mobile' && document.body.offsetWidth < 768) {
|
||||||
const update = () => {
|
setWidth('auto');
|
||||||
setIsPhone(media.matches);
|
setMarginLeft(0);
|
||||||
document.body.setAttribute(
|
setMarginTop(0);
|
||||||
'data-mode',
|
setIsPhone(true);
|
||||||
media.matches ? 'phone' : 'desktop',
|
document.body.setAttribute('data-mode', 'phone');
|
||||||
);
|
} else {
|
||||||
};
|
setWidth('100%');
|
||||||
update();
|
setMarginLeft(0);
|
||||||
if (typeof media.addEventListener === 'function') {
|
setMarginTop(-48);
|
||||||
media.addEventListener('change', update);
|
setIsPhone(false);
|
||||||
return () => media.removeEventListener('change', update);
|
document.body.setAttribute('data-mode', 'desktop');
|
||||||
}
|
}
|
||||||
media.addListener(update);
|
|
||||||
return () => media.removeListener(update);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -33,9 +31,9 @@ export const useCtx = () => {
|
|||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
zIndex: 20,
|
zIndex: 20,
|
||||||
marginTop: isPhone ? 0 : -48,
|
marginTop,
|
||||||
width: isPhone ? 'auto' : '100%',
|
width,
|
||||||
marginLeft: 0,
|
marginLeft,
|
||||||
} as any,
|
} as any,
|
||||||
isPhone,
|
isPhone,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
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',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -51,6 +51,8 @@ test('release images receive the same-run artifact and verify it before use', ()
|
|||||||
for (const name of [
|
for (const name of [
|
||||||
'build-alpine',
|
'build-alpine',
|
||||||
'build-debian',
|
'build-debian',
|
||||||
|
'build-alpine310',
|
||||||
|
'build-debian310',
|
||||||
]) {
|
]) {
|
||||||
const job = workflow.jobs[name];
|
const job = workflow.jobs[name];
|
||||||
assert.equal(job.needs, 'build-static');
|
assert.equal(job.needs, 'build-static');
|
||||||
|
|||||||
@@ -43,127 +43,3 @@ 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);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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, fullResponse = false) {
|
function responseFor(schema, value) {
|
||||||
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, fullResponse = false) {
|
|||||||
validationErrors(error, {}, res, (err) => {
|
validationErrors(error, {}, res, (err) => {
|
||||||
throw err;
|
throw err;
|
||||||
});
|
});
|
||||||
return fullResponse ? response : response.validation.body.message;
|
return 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,100 +75,6 @@ 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 = [
|
||||||
'请先初始化',
|
'请先初始化',
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
const test = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs/promises');
|
|
||||||
const path = require('node:path');
|
|
||||||
const { 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')),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -50,30 +50,6 @@ 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');
|
||||||
@@ -273,6 +249,8 @@ test('Gitee workflows verify both mirrors and do not gate image publication', ()
|
|||||||
for (const name of [
|
for (const name of [
|
||||||
'build-alpine',
|
'build-alpine',
|
||||||
'build-debian',
|
'build-debian',
|
||||||
|
'build-alpine310',
|
||||||
|
'build-debian310',
|
||||||
]) {
|
]) {
|
||||||
assert.equal(workflow.jobs[name].needs, 'build-static');
|
assert.equal(workflow.jobs[name].needs, 'build-static');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
const test = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const os = require('node:os');
|
|
||||||
const path = require('node:path');
|
|
||||||
const { probeNodeRuntime } = require('../../shell/node-runtime-probe.cjs');
|
|
||||||
|
|
||||||
test('the retained Node handles URL, IDNA and file paths in fresh eval and file processes', () => {
|
|
||||||
const result = probeNodeRuntime();
|
|
||||||
assert.equal(result.ok, true);
|
|
||||||
assert.equal(result.processes, 4);
|
|
||||||
assert.match(result.nodeSha256, /^[0-9a-f]{64}$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a native child crash fails the runtime probe instead of accepting its healthy parent', (t) => {
|
|
||||||
if (process.platform === 'win32') return t.skip('POSIX crash fixture');
|
|
||||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-node-crash-'));
|
|
||||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
|
||||||
const executable = path.join(directory, 'node');
|
|
||||||
fs.writeFileSync(executable, '#!/bin/sh\nkill -SEGV $$\n', { mode: 0o700 });
|
|
||||||
assert.throws(
|
|
||||||
() => probeNodeRuntime(executable),
|
|
||||||
/Node URL runtime probe failed.*SIGSEGV/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('emulated Node probes have a bounded two-minute budget and reject a timeout', (t) => {
|
|
||||||
const childProcess = require('node:child_process');
|
|
||||||
const modulePath = require.resolve('../../shell/node-runtime-probe.cjs');
|
|
||||||
let directory;
|
|
||||||
const makeDirectory = fs.mkdtempSync;
|
|
||||||
t.mock.method(fs, 'mkdtempSync', (...args) => {
|
|
||||||
directory = makeDirectory(...args);
|
|
||||||
return directory;
|
|
||||||
});
|
|
||||||
t.mock.method(childProcess, 'spawnSync', (_program, args, options) => {
|
|
||||||
assert.equal(options.timeout, 120000);
|
|
||||||
assert.equal(options.env.NODE_OPTIONS, '');
|
|
||||||
return { status: null, signal: 'SIGTERM', error: Object.assign(new Error('probe timed out'), { code: 'ETIMEDOUT' }) };
|
|
||||||
});
|
|
||||||
delete require.cache[modulePath];
|
|
||||||
t.after(() => { delete require.cache[modulePath]; });
|
|
||||||
const probe = require(modulePath).probeNodeRuntime;
|
|
||||||
assert.throws(() => probe(), /Node URL runtime probe failed.*SIGTERM.*probe timed out/);
|
|
||||||
assert.ok(directory);
|
|
||||||
assert.equal(fs.existsSync(directory), false, 'Timed-out probes must remove their temporary source files');
|
|
||||||
});
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
const test = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const {
|
|
||||||
serverRuntimeArgs,
|
|
||||||
} = require('../../cli/src/internal/maintenance/nodeRuntime.cjs');
|
|
||||||
|
|
||||||
test('server compatibility flags target ARMv6 Node 24 while retaining WebAssembly and the existing Node', () => {
|
|
||||||
for (const [arch, armVersion, version, enabled] of [
|
|
||||||
['arm', '6', '24.18.1', true],
|
|
||||||
['arm', 6, '24.18.1', true],
|
|
||||||
['arm', 6, '20.18.1', false],
|
|
||||||
['arm', 6, '22.20.0', false],
|
|
||||||
['arm', 7, '24.18.1', false],
|
|
||||||
['arm64', undefined, '24.18.1', false],
|
|
||||||
['x64', undefined, '24.18.1', false],
|
|
||||||
]) {
|
|
||||||
const flags = serverRuntimeArgs({
|
|
||||||
arch,
|
|
||||||
config: { variables: { arm_version: armVersion } },
|
|
||||||
versions: { node: version },
|
|
||||||
});
|
|
||||||
assert.deepEqual(
|
|
||||||
flags,
|
|
||||||
enabled ? ['--no-opt', '--no-sparkplug', '--no-maglev'] : [],
|
|
||||||
);
|
|
||||||
assert.ok(!flags.includes('--jitless'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the normal PM2 application uses the same server arguments as prestart', () => {
|
|
||||||
const app = require('../../ecosystem.config.js').apps.find(
|
|
||||||
(x) => x.name === 'qinglong',
|
|
||||||
);
|
|
||||||
const flags = serverRuntimeArgs();
|
|
||||||
assert.deepEqual(app.node_args || [], flags);
|
|
||||||
if (!flags.length) assert.equal(app.node_args, undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ARMv6 server flags retain real HTTP, fetch and undici parsing', () => {
|
|
||||||
const { spawnSync } = require('node:child_process');
|
|
||||||
const flags = serverRuntimeArgs({
|
|
||||||
arch: 'arm',
|
|
||||||
config: { variables: { arm_version: 6 } },
|
|
||||||
versions: { node: '24.18.1' },
|
|
||||||
});
|
|
||||||
const result = spawnSync(
|
|
||||||
process.execPath,
|
|
||||||
[
|
|
||||||
...flags,
|
|
||||||
'-e',
|
|
||||||
`
|
|
||||||
const http=require('http'),assert=require('assert/strict');
|
|
||||||
assert.equal(typeof WebAssembly,'object');
|
|
||||||
const server=http.createServer((req,res)=>res.end('parser-ok'));
|
|
||||||
server.listen(0,'127.0.0.1',async()=>{
|
|
||||||
try {
|
|
||||||
const url='http://127.0.0.1:'+server.address().port;
|
|
||||||
assert.equal(await (await fetch(url)).text(),'parser-ok');
|
|
||||||
assert.equal(await (await require('undici').request(url)).body.text(),'parser-ok');
|
|
||||||
} catch(e){console.error(e);process.exitCode=1;}
|
|
||||||
finally{server.closeAllConnections();server.close();}
|
|
||||||
});
|
|
||||||
`,
|
|
||||||
],
|
|
||||||
{
|
|
||||||
encoding: 'utf8',
|
|
||||||
timeout: 10000,
|
|
||||||
env: { ...process.env, NODE_OPTIONS: '' },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
assert.equal(result.status, 0, result.stderr || result.error?.message);
|
|
||||||
});
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
const test = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs/promises');
|
|
||||||
const path = require('node:path');
|
|
||||||
const os = require('node:os');
|
|
||||||
const { createHash } = require('node:crypto');
|
|
||||||
const { execFile } = require('node:child_process');
|
|
||||||
const { promisify } = require('node:util');
|
|
||||||
const execute = promisify(execFile);
|
|
||||||
const { checkPackedFiles } = require('../../scripts/verify-panel-package.cjs');
|
|
||||||
const { restorePackageLock } = require('../../shell/install-package.cjs');
|
|
||||||
const { restoreEnvironment } = require('../../shell/install-package.cjs');
|
|
||||||
|
|
||||||
async function packageFixture(t) {
|
|
||||||
const temp = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'ql-npm-contract-')));
|
|
||||||
t.after(() => fs.rm(temp, { recursive: true, force: true }));
|
|
||||||
const root = path.join(temp, 'source'), output = path.join(temp, 'archives');
|
|
||||||
await fs.mkdir(root);
|
|
||||||
await fs.mkdir(output);
|
|
||||||
const copy = async (name) => {
|
|
||||||
await fs.mkdir(path.dirname(path.join(root, name)), { recursive: true });
|
|
||||||
await fs.copyFile(path.resolve(name), path.join(root, name));
|
|
||||||
};
|
|
||||||
for (const name of ['.gitignore', 'docker/verify-build.cjs', 'docker/build-manifest.cjs', '.env.example', 'ecosystem.config.js', 'version.yaml', 'README.md', 'LICENSE', 'scripts/prepare-npm-package.cjs', 'back/protos/api.proto']) await copy(name);
|
|
||||||
for (const directory of ['shell', 'sample', 'cli/src/internal/maintenance']) {
|
|
||||||
await fs.cp(directory, path.join(root, directory), {
|
|
||||||
recursive: true,
|
|
||||||
filter: (source) => !/[/\\](?:__pycache__|node_modules)(?:[/\\]|$)|[/\\]preload[/\\](?:env\.|notify\.|__ql_notify__\.|lang_env\.sh)/.test(source),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const metadata = JSON.parse(await fs.readFile('package.json', 'utf8'));
|
|
||||||
// The structural test stays offline; full production installation and service
|
|
||||||
// upgrades run against the unmodified package in verify-panel-package.cjs.
|
|
||||||
metadata.dependencies = {};
|
|
||||||
metadata.devDependencies = {};
|
|
||||||
await fs.writeFile(path.join(root, 'package.json'), JSON.stringify(metadata));
|
|
||||||
const lock = 'lockfileVersion: 6.0\n';
|
|
||||||
await fs.writeFile(path.join(root, 'pnpm-lock.yaml'), lock);
|
|
||||||
const files = {};
|
|
||||||
for (const name of ['build/app.js', 'build/token.js', 'build/bootstrap/upgradeProbe.js', 'dist/index.html', 'build/app.js.map', 'dist/app.js.gz']) {
|
|
||||||
const bytes = name.endsWith('.html') ? '<html>pack fixture</html>' : '{}';
|
|
||||||
await fs.mkdir(path.dirname(path.join(root, 'static', name)), { recursive: true });
|
|
||||||
await fs.writeFile(path.join(root, 'static', name), bytes);
|
|
||||||
files[name] = createHash('sha256').update(bytes).digest('hex');
|
|
||||||
}
|
|
||||||
for (const name of ['.env', '.env.local', '.env.production', '.aws/credentials', '.ssh/id_rsa', '.vscode/settings.json', '.github/workflows/private.yml', '.tmp/private.json', '.DS_Store', 'output/private.json', 'data/config/auth.json', 'back/private.json', 'back/private.ts', 'sample/tool.ts', 'sample/private.json', 'sample/__pycache__/notify.pyc', 'shell/preload/env.js', 'shell/preload/env.py', 'shell/preload/notify.js', 'shell/preload/__ql_notify__.js', 'shell/preload/lang_env.sh', 'shell/preload/task-notify.json', 'shell/preload/__pycache__/env.pyc', 'cli/dist/npm/ql.js', 'cli/src/internal/private.cjs']) {
|
|
||||||
await fs.mkdir(path.dirname(path.join(root, name)), { recursive: true });
|
|
||||||
await fs.writeFile(path.join(root, name), 'DO_NOT_PUBLISH');
|
|
||||||
}
|
|
||||||
await fs.writeFile(path.join(root, '.npmrc'), '# DO_NOT_PUBLISH\n');
|
|
||||||
const env = { ...process.env, HOME: temp, NODE_ENV: 'production' };
|
|
||||||
await execute('git', ['init', '-q'], { cwd: root, env });
|
|
||||||
await execute('git', ['add', '-A'], { cwd: root, env });
|
|
||||||
await execute('git', ['-c', 'user.name=Package Test', '-c', 'user.email=test@example.invalid', '-c', 'core.hooksPath=/dev/null', 'commit', '-qm', 'fixture'], { cwd: root, env });
|
|
||||||
await fs.writeFile(path.join(root, 'static/build-info.json'), JSON.stringify({
|
|
||||||
version: 1, dirty: false, sourceCommit: (await execute('git', ['rev-parse', 'HEAD'], { cwd: root })).stdout.trim(), files,
|
|
||||||
lockfileSha256: createHash('sha256').update(lock).digest('hex'),
|
|
||||||
}));
|
|
||||||
const pack = () => execute('npm', ['pack', '--json', '--cache', path.join(temp, 'cache'), '--pack-destination', output], { cwd: root, env, timeout: 60000, maxBuffer: 4 * 1024 * 1024 });
|
|
||||||
return { root, output, temp, env, lock, files, pack };
|
|
||||||
}
|
|
||||||
|
|
||||||
test('real npm pack excludes local state and its installed lifecycle restores verified runtime files', async (t) => {
|
|
||||||
const { root, output, temp, env, lock, files, pack } = await packageFixture(t);
|
|
||||||
// The whitelist must work by itself, even for artifacts ignored by Git.
|
|
||||||
await assert.rejects(fs.access(path.join(root, '.npmignore')), { code: 'ENOENT' });
|
|
||||||
const packed = await pack();
|
|
||||||
const [archive] = JSON.parse(packed.stdout);
|
|
||||||
checkPackedFiles(archive.files);
|
|
||||||
const names = new Set(archive.files.map((file) => file.path));
|
|
||||||
assert.ok(names.has('shell/upgrade-lock.yaml'));
|
|
||||||
for (const name of Object.keys(files)) assert.ok(names.has('static/' + name));
|
|
||||||
await assert.rejects(fs.access(path.join(root, 'shell/upgrade-lock.yaml')), { code: 'ENOENT' });
|
|
||||||
const prefix = path.join(temp, 'installed');
|
|
||||||
await execute('npm', ['install', '--offline', '--omit=dev', '--no-audit', '--no-fund', '--cache', path.join(temp, 'cache'), '--prefix', prefix, path.join(output, archive.filename)], { cwd: temp, env, timeout: 60000 });
|
|
||||||
const installed = path.join(prefix, 'node_modules/@whyour/qinglong');
|
|
||||||
for (const { path: name } of archive.files)
|
|
||||||
assert.equal((await fs.readFile(path.join(installed, name))).includes(Buffer.from('DO_NOT_PUBLISH')), false, `Private fixture content in npm package: ${name}`);
|
|
||||||
await assert.rejects(fs.access(path.join(installed, '.env')), { code: 'ENOENT' });
|
|
||||||
assert.equal(await fs.readFile(path.join(installed, 'pnpm-lock.yaml'), 'utf8'), lock);
|
|
||||||
assert.equal(typeof require(path.join(installed, 'shell/upgrade.cjs')).stageUpgrade, 'function');
|
|
||||||
assert.ok((await fs.stat(path.join(installed, 'shell/update.sh'))).mode & 0o111);
|
|
||||||
for (const [name, file] of Object.entries(JSON.parse(await fs.readFile(path.join(installed, 'package.json'), 'utf8')).bin)) {
|
|
||||||
await fs.access(path.join(prefix, 'node_modules/.bin', name));
|
|
||||||
assert.ok((await fs.stat(path.join(installed, file))).mode & 0o111, file);
|
|
||||||
}
|
|
||||||
await fs.writeFile(path.join(installed, 'shell/upgrade-lock.yaml'), 'tampered');
|
|
||||||
assert.throws(() => restorePackageLock(installed), /does not match/);
|
|
||||||
assert.equal(await fs.readFile(path.join(installed, 'pnpm-lock.yaml'), 'utf8'), lock);
|
|
||||||
assert.ok(!(await fs.readdir(installed)).some((name) => name.startsWith('.upgrade-lock-')));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('postinstall still supports minimal Docker dependency stages before shell files are copied', async (t) => {
|
|
||||||
const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-npm-builder-'));
|
|
||||||
t.after(() => fs.rm(temp, { recursive: true, force: true }));
|
|
||||||
const metadata = JSON.parse(await fs.readFile('package.json', 'utf8'));
|
|
||||||
await fs.writeFile(path.join(temp, 'package.json'), JSON.stringify({ scripts: { postinstall: metadata.scripts.postinstall } }));
|
|
||||||
await fs.writeFile(path.join(temp, 'pnpm-lock.yaml'), 'source lock');
|
|
||||||
await execute('npm', ['run', 'postinstall', '--cache', path.join(temp, 'cache')], { cwd: temp, timeout: 30000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a missing npm runtime lockfile fails instead of claiming an installed package is ready', async (t) => {
|
|
||||||
const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-npm-lock-missing-'));
|
|
||||||
t.after(() => fs.rm(temp, { recursive: true, force: true }));
|
|
||||||
assert.throws(() => restorePackageLock(temp), /Missing npm package upgrade lockfile/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('real npm overwrites preserve .env automatically and failed installs restore the old package', async (t) => {
|
|
||||||
const { root, output, temp, env, pack } = await packageFixture(t);
|
|
||||||
const [packed] = JSON.parse((await pack()).stdout);
|
|
||||||
const archive = path.join(output, packed.filename);
|
|
||||||
const legacy = path.join(temp, 'legacy');
|
|
||||||
await fs.mkdir(legacy);
|
|
||||||
await fs.writeFile(path.join(legacy, 'package.json'), JSON.stringify({ name: '@whyour/qinglong', version: '2.22.0' }));
|
|
||||||
await fs.writeFile(path.join(legacy, 'old-package-marker'), 'legacy');
|
|
||||||
const [old] = JSON.parse((await execute('npm', ['pack', '--ignore-scripts', '--json', '--cache', path.join(temp, 'cache')], { cwd: legacy, env })).stdout);
|
|
||||||
const broken = path.join(temp, 'broken');
|
|
||||||
await fs.mkdir(broken);
|
|
||||||
await fs.cp(root, broken, { recursive: true });
|
|
||||||
await fs.writeFile(path.join(broken, 'shell/upgrade-lock.yaml'), 'invalid lock');
|
|
||||||
const [bad] = JSON.parse((await execute('npm', ['pack', '--ignore-scripts', '--json', '--cache', path.join(temp, 'cache')], { cwd: broken, env })).stdout);
|
|
||||||
|
|
||||||
for (const global of [false, true]) {
|
|
||||||
await t.test(global ? 'global npm install' : 'local npm install', async () => {
|
|
||||||
const prefix = path.join(temp, global ? 'global' : 'local');
|
|
||||||
const linkedPrefix = prefix + '-link';
|
|
||||||
await fs.mkdir(prefix);
|
|
||||||
await fs.symlink(prefix, linkedPrefix);
|
|
||||||
const installed = path.join(prefix, global ? 'lib/node_modules/@whyour/qinglong' : 'node_modules/@whyour/qinglong');
|
|
||||||
const install = (file) => execute('npm', ['install', ...(global ? ['--global'] : []), '--prefix', linkedPrefix, '--force', '--offline', '--omit=dev', '--no-audit', '--no-fund', '--cache', path.join(temp, 'cache'), file], { cwd: temp, env, timeout: 60000 });
|
|
||||||
await install(path.join(legacy, old.filename));
|
|
||||||
const bytes = Buffer.from('QL_DATA_DIR=/external/data\r\nSECRET="配置 test"\r\n# no trailing newline');
|
|
||||||
const external = path.join(prefix, 'external.env');
|
|
||||||
await fs.writeFile(external, bytes);
|
|
||||||
await fs.symlink(external, path.join(installed, '.env'));
|
|
||||||
await assert.rejects(install(archive), (error) => {
|
|
||||||
assert.match(error.stderr, /must be a regular file/);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
assert.equal(JSON.parse(await fs.readFile(path.join(installed, 'package.json'))).version, '2.22.0');
|
|
||||||
assert.equal(await fs.readlink(path.join(installed, '.env')), external);
|
|
||||||
assert.deepEqual(await fs.readFile(external), bytes);
|
|
||||||
await fs.rm(path.join(installed, '.env'));
|
|
||||||
await fs.writeFile(path.join(installed, '.env'), bytes, { mode: 0o644 });
|
|
||||||
await assert.rejects(install(path.join(broken, bad.filename)), (error) => {
|
|
||||||
assert.match(error.stderr, /does not match its build manifest/);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
assert.equal(JSON.parse(await fs.readFile(path.join(installed, 'package.json'))).version, '2.22.0');
|
|
||||||
assert.equal(await fs.readFile(path.join(installed, 'old-package-marker'), 'utf8'), 'legacy');
|
|
||||||
assert.deepEqual(await fs.readFile(path.join(installed, '.env')), bytes);
|
|
||||||
await install(archive);
|
|
||||||
assert.deepEqual(await fs.readFile(path.join(installed, '.env')), bytes);
|
|
||||||
assert.equal((await fs.stat(path.join(installed, '.env'))).mode & 0o777, 0o600);
|
|
||||||
await assert.rejects(fs.access(path.join(installed, 'old-package-marker')), { code: 'ENOENT' });
|
|
||||||
// Same-version forced reinstall must also retain custom configuration.
|
|
||||||
await install(archive);
|
|
||||||
assert.deepEqual(await fs.readFile(path.join(installed, '.env')), bytes);
|
|
||||||
assert.ok(!(await fs.readdir(installed)).some((name) => name.startsWith('.upgrade-env-')));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('environment recovery uses only the exact retired package and never overwrites existing configuration', async (t) => {
|
|
||||||
const temp = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'ql-npm-env-')));
|
|
||||||
t.after(() => fs.rm(temp, { recursive: true, force: true }));
|
|
||||||
const root = path.join(temp, 'node_modules/@whyour/qinglong');
|
|
||||||
await fs.mkdir(root, { recursive: true });
|
|
||||||
const suffix = createHash('sha1').update(root).digest('base64').replace(/[^a-zA-Z0-9]+/g, '').slice(0, 8);
|
|
||||||
const retired = path.join(path.dirname(root), `.qinglong-${suffix}`);
|
|
||||||
const destination = path.join(root, '.env');
|
|
||||||
const other = path.join(path.dirname(root), '.qinglong-unrelated');
|
|
||||||
await fs.mkdir(other);
|
|
||||||
await fs.writeFile(path.join(other, '.env'), 'unrelated');
|
|
||||||
restoreEnvironment(root);
|
|
||||||
await assert.rejects(fs.access(destination), { code: 'ENOENT' });
|
|
||||||
await fs.mkdir(retired);
|
|
||||||
await fs.writeFile(path.join(retired, 'package.json'), JSON.stringify({ name: 'another-package' }));
|
|
||||||
await fs.writeFile(path.join(retired, '.env'), 'private');
|
|
||||||
assert.throws(() => restoreEnvironment(root), /Unexpected retired npm package/);
|
|
||||||
await fs.writeFile(path.join(retired, 'package.json'), JSON.stringify({ name: '@whyour/qinglong' }));
|
|
||||||
await fs.rm(path.join(retired, '.env'));
|
|
||||||
restoreEnvironment(root);
|
|
||||||
await assert.rejects(fs.access(destination), { code: 'ENOENT' });
|
|
||||||
await fs.symlink(path.join(other, '.env'), path.join(retired, '.env'));
|
|
||||||
assert.throws(() => restoreEnvironment(root), /must be a regular file/);
|
|
||||||
await fs.rm(path.join(retired, '.env'));
|
|
||||||
await fs.mkdir(path.join(retired, '.env'));
|
|
||||||
assert.throws(() => restoreEnvironment(root), /must be a regular file/);
|
|
||||||
await fs.rm(path.join(retired, '.env'), { recursive: true });
|
|
||||||
await fs.writeFile(path.join(retired, '.env'), '');
|
|
||||||
restoreEnvironment(root);
|
|
||||||
assert.equal(await fs.readFile(destination, 'utf8'), '');
|
|
||||||
assert.equal((await fs.stat(destination)).mode & 0o777, 0o600);
|
|
||||||
await fs.writeFile(destination, 'new custom configuration');
|
|
||||||
await fs.writeFile(path.join(retired, '.env'), 'old configuration');
|
|
||||||
restoreEnvironment(root);
|
|
||||||
assert.equal(await fs.readFile(destination, 'utf8'), 'new custom configuration');
|
|
||||||
assert.ok(!(await fs.readdir(root)).some((name) => name.startsWith('.upgrade-env-')));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('npm publication consumes the exact archive whose isolated installation passed', () => {
|
|
||||||
const yaml = require('js-yaml');
|
|
||||||
const sync = require('node:fs');
|
|
||||||
const workflow = yaml.load(sync.readFileSync('.github/workflows/build-docker-image.yml', 'utf8'));
|
|
||||||
const verify = workflow.jobs['verify-npm-package'];
|
|
||||||
assert.deepEqual(verify.needs, ['build-static', 'build-npm-migration-bundle']);
|
|
||||||
assert.ok(verify.steps.some((step) => step.with?.name === 'npm-migration-bundle-${{ github.sha }}'));
|
|
||||||
const migration = workflow.jobs['build-npm-migration-bundle'];
|
|
||||||
assert.equal(migration.needs, 'build-debian');
|
|
||||||
assert.ok(migration.steps.some((step) => /Dockerfile.upgrade-bundle/.test(step.run || '')));
|
|
||||||
assert.ok(!migration.steps.some((step) => /publish-upgrade-bundles/.test(step.run || '')));
|
|
||||||
assert.deepEqual(verify.strategy.matrix.node, ['20.18.1', '24']);
|
|
||||||
assert.match(verify.container, /node:.*bookworm/);
|
|
||||||
const tools = verify.steps.find((step) => step.name === 'Install isolated service test tools');
|
|
||||||
assert.match(tools.run, /npm install --global --prefix \/usr\/local.*pm2@6\.0\.14/);
|
|
||||||
const service = verify.steps.find((step) => step.name === 'Install and upgrade the exact npm archive');
|
|
||||||
assert.equal(service.env.QL_PANEL_TEST_PM2, '/usr/local/bin/pm2');
|
|
||||||
assert.ok(workflow.jobs.publish.needs.includes('verify-npm-package'));
|
|
||||||
const download = workflow.jobs.publish.steps.find((step) => step.uses?.startsWith('actions/download-artifact@'));
|
|
||||||
assert.equal(download.with.name, 'qinglong-npm-${{ github.sha }}');
|
|
||||||
const publication = workflow.jobs.publish.steps.find((step) => step.name === 'Publish npm package with OIDC');
|
|
||||||
assert.match(publication.run, /npm publish "\.\/\$archive" --ignore-scripts/);
|
|
||||||
assert.ok(!workflow.jobs.publish.steps.some((step) => /pnpm build/.test(step.run || '')));
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const scenario of ['dirty-source', 'stale-commit', 'dirty-build', 'changed-lock', 'changed-output', 'missing-output', 'extra-output']) {
|
|
||||||
test(`native npm pack rejects ${scenario} before producing an archive`, async (t) => {
|
|
||||||
const { root, output, pack } = await packageFixture(t);
|
|
||||||
const manifestFile = path.join(root, 'static/build-info.json');
|
|
||||||
const manifest = JSON.parse(await fs.readFile(manifestFile, 'utf8'));
|
|
||||||
if (scenario === 'dirty-source') await fs.appendFile(path.join(root, 'shell/start.sh'), '\n# uncommitted change\n');
|
|
||||||
if (scenario === 'stale-commit') {
|
|
||||||
await fs.appendFile(path.join(root, 'shell/start.sh'), '\n# committed change after build\n');
|
|
||||||
await execute('git', ['add', '-A'], { cwd: root });
|
|
||||||
await execute('git', ['-c', 'user.name=Package Test', '-c', 'user.email=test@example.invalid', '-c', 'core.hooksPath=/dev/null', 'commit', '-qm', 'new source'], { cwd: root });
|
|
||||||
}
|
|
||||||
if (scenario === 'dirty-build') { manifest.dirty = true; await fs.writeFile(manifestFile, JSON.stringify(manifest)); }
|
|
||||||
if (scenario === 'changed-lock') {
|
|
||||||
await fs.appendFile(path.join(root, 'pnpm-lock.yaml'), '# changed lock\n');
|
|
||||||
await execute('git', ['add', '-A'], { cwd: root });
|
|
||||||
await execute('git', ['-c', 'user.name=Package Test', '-c', 'user.email=test@example.invalid', '-c', 'core.hooksPath=/dev/null', 'commit', '-qm', 'new lock'], { cwd: root });
|
|
||||||
manifest.sourceCommit = (await execute('git', ['rev-parse', 'HEAD'], { cwd: root })).stdout.trim();
|
|
||||||
await fs.writeFile(manifestFile, JSON.stringify(manifest));
|
|
||||||
}
|
|
||||||
if (scenario === 'changed-output') await fs.appendFile(path.join(root, 'static/build/app.js'), 'changed');
|
|
||||||
if (scenario === 'missing-output') await fs.rm(path.join(root, 'static/build/app.js'));
|
|
||||||
if (scenario === 'extra-output') await fs.writeFile(path.join(root, 'static/build/leftover.js'), 'old');
|
|
||||||
await assert.rejects(pack(), (error) => {
|
|
||||||
assert.match(error.stderr, /clean source|do not match|checksum mismatch|file set mismatch/i);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
assert.deepEqual(await fs.readdir(output), []);
|
|
||||||
await assert.rejects(fs.access(path.join(root, 'shell/upgrade-lock.yaml')), { code: 'ENOENT' });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -12,10 +12,12 @@ const publication = workflow.jobs.publish.steps.find(
|
|||||||
(step) => step.name === 'Publish npm package with OIDC',
|
(step) => step.name === 'Publish npm package with OIDC',
|
||||||
).run;
|
).run;
|
||||||
|
|
||||||
test('npm publication waits for every supported release image', () => {
|
test('npm publication waits for every release image, including Python 3.10', () => {
|
||||||
for (const name of [
|
for (const name of [
|
||||||
'build-alpine',
|
'build-alpine',
|
||||||
'build-debian',
|
'build-debian',
|
||||||
|
'build-alpine310',
|
||||||
|
'build-debian310',
|
||||||
])
|
])
|
||||||
assert.ok(workflow.jobs.publish.needs.includes(name), name);
|
assert.ok(workflow.jobs.publish.needs.includes(name), name);
|
||||||
});
|
});
|
||||||
@@ -34,23 +36,14 @@ 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/unrelated-source', version: '0.0.0' }),
|
JSON.stringify({ name: '@fixture/qinglong', version: '2.22.0' }),
|
||||||
);
|
);
|
||||||
fs.mkdirSync(path.join(root, 'package'));
|
|
||||||
fs.mkdirSync(path.join(root, 'npm-panel'));
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(root, 'package/package.json'),
|
|
||||||
JSON.stringify({ name: '@whyour/qinglong', version: '2.22.0' }),
|
|
||||||
);
|
|
||||||
const packed = spawnSync('tar', ['-czf', 'npm-panel/verified.tgz', 'package'], { cwd: root });
|
|
||||||
assert.equal(packed.status, 0, String(packed.stderr));
|
|
||||||
fs.writeFileSync(
|
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', JSON.stringify(process.argv.slice(3))); process.exit(0); }
|
if (process.argv[2] === 'publish') { fs.writeFileSync('published', 'yes'); process.exit(0); }
|
||||||
if (process.argv[2] !== 'view') process.exit(99);
|
if (process.argv[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;
|
||||||
@@ -73,9 +66,5 @@ 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',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
const test = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs/promises');
|
|
||||||
const path = require('node:path');
|
|
||||||
const {
|
|
||||||
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-')),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -19,7 +19,7 @@ const {
|
|||||||
sourceCommit,
|
sourceCommit,
|
||||||
} = require('../helpers/upgrade-fixture.cjs');
|
} = require('../helpers/upgrade-fixture.cjs');
|
||||||
|
|
||||||
async function fixture(t, targetVersion = '2.24.0') {
|
async function fixture(t) {
|
||||||
const base = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-panel-job-'));
|
const base = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-panel-job-'));
|
||||||
const root = path.join(base, 'panel'),
|
const root = path.join(base, 'panel'),
|
||||||
tmp = path.join(root, '.tmp');
|
tmp = path.join(root, '.tmp');
|
||||||
@@ -30,10 +30,7 @@ async function fixture(t, targetVersion = '2.24.0') {
|
|||||||
await fs.writeFile(name, contents);
|
await fs.writeFile(name, contents);
|
||||||
};
|
};
|
||||||
for (const dir of [root, source]) {
|
for (const dir of [root, source]) {
|
||||||
await write(
|
await write(path.join(dir, 'package.json'), '{"version":"2.24.0"}');
|
||||||
path.join(dir, 'package.json'),
|
|
||||||
JSON.stringify({ version: targetVersion }),
|
|
||||||
);
|
|
||||||
await write(path.join(dir, 'pnpm-lock.yaml'), 'lock');
|
await write(path.join(dir, 'pnpm-lock.yaml'), 'lock');
|
||||||
await write(path.join(dir, 'sample/config.sample.sh'), '# config');
|
await write(path.join(dir, 'sample/config.sample.sh'), '# config');
|
||||||
}
|
}
|
||||||
@@ -42,7 +39,7 @@ async function fixture(t, targetVersion = '2.24.0') {
|
|||||||
'installed dependency',
|
'installed dependency',
|
||||||
);
|
);
|
||||||
await write(path.join(root, 'version.yaml'), 'version: 2.23.0');
|
await write(path.join(root, 'version.yaml'), 'version: 2.23.0');
|
||||||
await write(path.join(source, 'version.yaml'), `version: ${targetVersion}`);
|
await write(path.join(source, 'version.yaml'), 'version: 2.24.0');
|
||||||
await write(path.join(staticRoot, 'build/app.js'), 'new backend');
|
await write(path.join(staticRoot, 'build/app.js'), 'new backend');
|
||||||
writeManifest(source, staticRoot);
|
writeManifest(source, staticRoot);
|
||||||
for (const repo of ['qinglong', 'qinglong-static'])
|
for (const repo of ['qinglong', 'qinglong-static'])
|
||||||
@@ -84,7 +81,7 @@ fs.writeFileSync(args[args.indexOf('--output') + 1], buffer);
|
|||||||
);
|
);
|
||||||
await fs.chmod(path.join(base, 'bin/curl'), 0o700);
|
await fs.chmod(path.join(base, 'bin/curl'), 0o700);
|
||||||
const runtime = {
|
const runtime = {
|
||||||
version: targetVersion,
|
version: '2.24.0',
|
||||||
sourceCommit,
|
sourceCommit,
|
||||||
instanceId: 'after-restart',
|
instanceId: 'after-restart',
|
||||||
parentPid: 100002,
|
parentPid: 100002,
|
||||||
@@ -189,22 +186,7 @@ test('detached download survives its initiating request and is idempotent; apply
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('prerelease version is retained through download and restart verification', async (t) => {
|
test('invalid staged payload is rejected before spawning a restart', async (t) => {
|
||||||
const f = await fixture(t, '2.24.0-dev.0');
|
|
||||||
await startJob(f, 'download');
|
|
||||||
const ready = await finished(f.tmp);
|
|
||||||
assert.equal(ready.status, 'ready', ready.log);
|
|
||||||
assert.equal(ready.version, '2.24.0-dev.0');
|
|
||||||
await startJob(f, 'apply');
|
|
||||||
const complete = await finished(f.tmp);
|
|
||||||
assert.equal(complete.status, 'completed', complete.log);
|
|
||||||
assert.equal(
|
|
||||||
await fs.readFile(path.join(f.root, 'version.yaml'), 'utf8'),
|
|
||||||
'version: 2.24.0-dev.0',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('invalid staged payload fails asynchronously before stopping the panel', async (t) => {
|
|
||||||
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');
|
||||||
@@ -215,16 +197,9 @@ test('invalid staged payload fails asynchronously before stopping the panel', as
|
|||||||
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',
|
||||||
);
|
);
|
||||||
const queued = await startJob(f, 'apply');
|
await assert.rejects(startJob(f, 'apply'), /checksum|损坏/);
|
||||||
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(
|
assert.equal((await getStatus(f.tmp)).status, 'failed');
|
||||||
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) => {
|
||||||
@@ -492,15 +467,3 @@ 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);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -6,278 +6,6 @@ const os = require('node:os');
|
|||||||
const tar = require('tar');
|
const tar = require('tar');
|
||||||
const bundle = require('../../cli/src/internal/maintenance/platformBundle.cjs');
|
const bundle = require('../../cli/src/internal/maintenance/platformBundle.cjs');
|
||||||
const artifacts = require('../../shell/upgrade.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 {
|
const {
|
||||||
writeManifest,
|
writeManifest,
|
||||||
sourceCommit,
|
sourceCommit,
|
||||||
@@ -320,19 +48,6 @@ async function fixture(t) {
|
|||||||
);
|
);
|
||||||
await fs.mkdir(path.join(staticRoot, 'build'), { recursive: true });
|
await fs.mkdir(path.join(staticRoot, 'build'), { recursive: true });
|
||||||
await fs.writeFile(path.join(staticRoot, 'build/app.js'), 'backend');
|
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);
|
writeManifest(source, staticRoot);
|
||||||
const target = await bundle.runtimeTarget();
|
const target = await bundle.runtimeTarget();
|
||||||
const manifest = {
|
const manifest = {
|
||||||
@@ -454,36 +169,6 @@ test('native target compatibility distinguishes ARMv6/ARMv7 and libc and retains
|
|||||||
target: { ...target, libcMinimum: '1.2.4' },
|
target: { ...target, libcMinimum: '1.2.4' },
|
||||||
};
|
};
|
||||||
bundle.assertCompatible(manifest, target, '20.20.2');
|
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(
|
assert.throws(
|
||||||
() => bundle.assertCompatible(manifest, { ...target, arch: 'armv7' }),
|
() => bundle.assertCompatible(manifest, { ...target, arch: 'armv7' }),
|
||||||
/platform mismatch/,
|
/platform mismatch/,
|
||||||
@@ -591,20 +276,7 @@ test('publishing requires every platform and validates all immutable archive has
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const index = releaseIndex(f.directory, sourceCommit, repository);
|
const index = releaseIndex(f.directory, sourceCommit, repository);
|
||||||
assert.equal(Object.keys(index.packages).length, 11);
|
assert.equal(Object.keys(index.packages).length, 12);
|
||||||
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(
|
await fs.appendFile(
|
||||||
path.join(f.directory, `qinglong-${sourceCommit}-${platforms[0]}.tar.gz`),
|
path.join(f.directory, `qinglong-${sourceCommit}-${platforms[0]}.tar.gz`),
|
||||||
'corrupt',
|
'corrupt',
|
||||||
@@ -679,200 +351,3 @@ test('fresh offline installation needs no package manager, keeps Node and publis
|
|||||||
'keep',
|
'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,12 +2,11 @@ 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},require:configRequire});return module.exports.apps[0];}
|
function config(env) {const module={exports:{}};vm.runInNewContext(source,{module,process:{env}});return module.exports.apps[0];}
|
||||||
test('container APM policy keeps worker isolation and can restore all inherited probes', () => {
|
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};
|
||||||
|
|||||||
@@ -1,595 +0,0 @@
|
|||||||
const test = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const os = require('node:os');
|
|
||||||
const path = require('node:path');
|
|
||||||
const {
|
|
||||||
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') {
|
|
||||||
if (args[1] === '-X' && args[2] === 'DELETE') {
|
|
||||||
if (state.failDelete) throw new Error('Delete unavailable');
|
|
||||||
const id = Number(args[3].split('/').at(-1));
|
|
||||||
for (const release of state.releases.values())
|
|
||||||
for (const asset of release.values())
|
|
||||||
if (asset.id === id) {
|
|
||||||
release.delete(asset.name);
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return notFound();
|
|
||||||
}
|
|
||||||
const route = args[1];
|
|
||||||
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 (
|
|
||||||
files.some((file) => path.basename(file) === 'latest.json') &&
|
|
||||||
((tag === 'upgrade-develop' && state.corruptChannel) ||
|
|
||||||
tag === state.corruptIndexTag)
|
|
||||||
)
|
|
||||||
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-v2.24.0';
|
|
||||||
assert.equal(f.state.releases.get(tag).size, 24);
|
|
||||||
assert.equal(f.state.releases.get('upgrade-master').size, 1);
|
|
||||||
assert.ok(!f.state.releases.has('upgrade-build-v2.24.0-100-1'));
|
|
||||||
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}/`));
|
|
||||||
assert.ok(filename.includes('-100-1.'));
|
|
||||||
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, tag, '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-v2.24.0'), ([name, a]) => [
|
|
||||||
name,
|
|
||||||
a.bytes.toString('base64'),
|
|
||||||
]).filter(([name]) => name !== 'latest.json'),
|
|
||||||
);
|
|
||||||
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-v2.24.0'), ([name, a]) => [
|
|
||||||
name,
|
|
||||||
a.bytes.toString('base64'),
|
|
||||||
]).filter(([name]) => oldAssets.has(name)),
|
|
||||||
),
|
|
||||||
oldAssets,
|
|
||||||
);
|
|
||||||
assert.equal(f.state.releases.get('upgrade-v2.24.0').size, 47);
|
|
||||||
});
|
|
||||||
|
|
||||||
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-v2.24.0' &&
|
|
||||||
!a.includes('--clobber'),
|
|
||||||
).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');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a new version keeps the previous version release while master advances', (t) => {
|
|
||||||
const f = fixture(t);
|
|
||||||
f.publish();
|
|
||||||
const previous = f.index('upgrade-v2.24.0');
|
|
||||||
const commit = 'b'.repeat(40);
|
|
||||||
f.state.head = commit;
|
|
||||||
f.write(commit, 'next version', '2.25.0');
|
|
||||||
assert.equal(
|
|
||||||
f.publish({ commit, version: '2.25.0', buildId: '101-1' }),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
assert.deepEqual(f.index('upgrade-v2.24.0'), previous);
|
|
||||||
assert.equal(f.state.releases.get('upgrade-v2.24.0').size, 24);
|
|
||||||
assert.equal(f.state.releases.get('upgrade-v2.25.0').size, 24);
|
|
||||||
assert.equal(f.state.releases.get('upgrade-master').size, 1);
|
|
||||||
assert.equal(f.index().version, '2.25.0');
|
|
||||||
assert.ok(!f.state.releases.has('upgrade-build-v2.25.0-101-1'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a failed version index verification leaves master on the previous version', (t) => {
|
|
||||||
const f = fixture(t);
|
|
||||||
f.publish();
|
|
||||||
const previous = f.index();
|
|
||||||
const commit = 'b'.repeat(40);
|
|
||||||
f.state.head = commit;
|
|
||||||
f.write(commit, 'next version', '2.25.0');
|
|
||||||
f.state.corruptIndexTag = 'upgrade-v2.25.0';
|
|
||||||
assert.throws(
|
|
||||||
() => f.publish({ commit, version: '2.25.0', buildId: '101-1' }),
|
|
||||||
/Published channel index verification failed/,
|
|
||||||
);
|
|
||||||
assert.deepEqual(f.index(), previous);
|
|
||||||
assert.deepEqual(f.index('upgrade-v2.24.0'), previous);
|
|
||||||
});
|
|
||||||
|
|
||||||
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.equal(f.state.releases.get('upgrade-v2.24.0').size, 24);
|
|
||||||
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 keeps its prerelease version separate from production assets', (t) => {
|
|
||||||
const f = fixture(t);
|
|
||||||
f.publish();
|
|
||||||
const production = f.index();
|
|
||||||
const developmentVersion = '2.24.0-dev.0';
|
|
||||||
f.write(f.state.developHead, 'development', developmentVersion);
|
|
||||||
assert.equal(
|
|
||||||
f.publish({
|
|
||||||
branch: 'develop',
|
|
||||||
commit: f.state.developHead,
|
|
||||||
version: developmentVersion,
|
|
||||||
}),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
const development = f.index('upgrade-develop');
|
|
||||||
assert.equal(development.channel, 'develop');
|
|
||||||
assert.equal(development.version, developmentVersion);
|
|
||||||
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-develop/'));
|
|
||||||
for (const entry of Object.values(development.packages))
|
|
||||||
assert.ok(entry.url.endsWith('-100-1.tar.gz'));
|
|
||||||
assert.ok(development.installer.url.endsWith('/install-bundle-100-1.cjs'));
|
|
||||||
assert.ok(
|
|
||||||
f.state.calls.some(
|
|
||||||
(args) =>
|
|
||||||
args[0] === 'release' &&
|
|
||||||
args[1] === 'create' &&
|
|
||||||
args[2] === 'upgrade-develop' &&
|
|
||||||
args[args.indexOf('--title') + 1] === 'Upgrade channel develop',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
assert.deepEqual(f.index(), production);
|
|
||||||
assert.deepEqual(f.index('upgrade-v2.24.0'), production);
|
|
||||||
assert.equal(f.state.releases.get('upgrade-v2.24.0').size, 24);
|
|
||||||
assert.equal(f.state.releases.get('upgrade-develop').size, 24);
|
|
||||||
assert.ok(!f.state.releases.has('upgrade-build-develop-100-1'));
|
|
||||||
const archiveName = path.basename(development.packages['linux-x64-musl'].url);
|
|
||||||
const sidecar = JSON.parse(
|
|
||||||
f.state.releases.get('upgrade-develop').get(`${archiveName}.json`).bytes,
|
|
||||||
);
|
|
||||||
assert.equal(sidecar.filename, archiveName);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('development builds cannot publish obsolete development source', (t) => {
|
|
||||||
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-100-1';
|
|
||||||
const legacyTag = 'upgrade-build-develop-v2.24.0-99-1';
|
|
||||||
f.state.releases.set(oldTag, new Map());
|
|
||||||
f.state.releases.set(legacyTag, new Map());
|
|
||||||
const oldAsset = Array.from(
|
|
||||||
f.state.releases.get('upgrade-develop').keys(),
|
|
||||||
).find((name) => name.endsWith('-100-1.tar.gz'));
|
|
||||||
assert.ok(oldAsset);
|
|
||||||
// Never prune production, channel aliases, legacy/unclassified releases,
|
|
||||||
// drafts or a non-prerelease even when its name resembles a dev build.
|
|
||||||
const protectedTags = [
|
|
||||||
'upgrade-86c37f426a5ba9945d6af1910a7885a26f77e4bc',
|
|
||||||
'v2.24.0',
|
|
||||||
'upgrade-build-develop-10-1',
|
|
||||||
'upgrade-build-develop-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] === 'api' && a[1] === '-X' && a[2] === '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-develop'));
|
|
||||||
assert.ok(!f.state.releases.has('upgrade-build-develop-101-1'));
|
|
||||||
}
|
|
||||||
assert.equal(f.state.releases.has(oldTag), Boolean(fault));
|
|
||||||
assert.equal(f.state.releases.has(legacyTag), Boolean(fault));
|
|
||||||
assert.equal(
|
|
||||||
f.state.releases.get('upgrade-develop').has(oldAsset),
|
|
||||||
Boolean(fault),
|
|
||||||
);
|
|
||||||
for (const tag of protectedTags) assert.ok(f.state.releases.has(tag));
|
|
||||||
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);
|
|
||||||
const oldAsset = Array.from(
|
|
||||||
f.state.releases.get('upgrade-develop').keys(),
|
|
||||||
).find((name) => name.endsWith('-100-1.tar.gz'));
|
|
||||||
assert.ok(oldAsset);
|
|
||||||
f.state.failDelete = true;
|
|
||||||
assert.throws(
|
|
||||||
() => f.publish({ ...options, buildId: '101-1' }),
|
|
||||||
/Delete unavailable/,
|
|
||||||
);
|
|
||||||
assert.equal(f.index('upgrade-develop').buildId, '101-1');
|
|
||||||
assert.ok(f.state.releases.get('upgrade-develop').has(oldAsset));
|
|
||||||
f.state.failDelete = false;
|
|
||||||
f.publish({ ...options, buildId: '101-1' });
|
|
||||||
assert.ok(!f.state.releases.get('upgrade-develop').has(oldAsset));
|
|
||||||
assert.equal(f.state.releases.get('upgrade-develop').size, 24);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a newer development commit during upload preserves the published channel and its old package', (t) => {
|
|
||||||
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');
|
|
||||||
const oldAsset = Array.from(
|
|
||||||
f.state.releases.get('upgrade-develop').keys(),
|
|
||||||
).find((name) => name.endsWith('-100-1.tar.gz'));
|
|
||||||
assert.ok(oldAsset);
|
|
||||||
f.state.changeDevelopHeadAfterUpload = true;
|
|
||||||
assert.equal(f.publish({ ...options, buildId: '101-1' }), false);
|
|
||||||
assert.deepEqual(f.index('upgrade-develop'), before);
|
|
||||||
assert.ok(f.state.releases.get('upgrade-develop').has(oldAsset));
|
|
||||||
assert.ok(
|
|
||||||
!f.state.calls.some((a) => a[0] === 'release' && a[1] === 'delete'),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -23,9 +23,6 @@ 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() {} },
|
||||||
@@ -40,33 +37,6 @@ 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,
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ test('a running process retains its own build identity when installed files chan
|
|||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-runtime-build-'));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-runtime-build-'));
|
||||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||||
fs.mkdirSync(path.join(root, 'static/build'), { recursive: true });
|
fs.mkdirSync(path.join(root, 'static/build'), { recursive: true });
|
||||||
fs.writeFileSync(path.join(root, 'version.yaml'), 'version: 2.24.0-dev.0');
|
fs.writeFileSync(path.join(root, 'version.yaml'), 'version: 2.24.0');
|
||||||
fs.writeFileSync(path.join(root, 'static/build/app.js'), 'old backend');
|
fs.writeFileSync(path.join(root, 'static/build/app.js'), 'old backend');
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(root, 'static/build-info.json'),
|
path.join(root, 'static/build-info.json'),
|
||||||
@@ -45,7 +45,7 @@ test('a running process retains its own build identity when installed files chan
|
|||||||
JSON.stringify({ sourceCommit: 'b'.repeat(40) }),
|
JSON.stringify({ sourceCommit: 'b'.repeat(40) }),
|
||||||
);
|
);
|
||||||
fs.writeFileSync(path.join(root, 'version.yaml'), 'version: 2.25.0');
|
fs.writeFileSync(path.join(root, 'version.yaml'), 'version: 2.25.0');
|
||||||
assert.equal(running.version, '2.24.0-dev.0');
|
assert.equal(running.version, '2.24.0');
|
||||||
assert.equal(running.sourceCommit, 'a'.repeat(40));
|
assert.equal(running.sourceCommit, 'a'.repeat(40));
|
||||||
assert.equal(
|
assert.equal(
|
||||||
running.backendSha256,
|
running.backendSha256,
|
||||||
|
|||||||
@@ -29,9 +29,7 @@ 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);
|
||||||
if (call.request.title === 'Concurrent cold startup')
|
callback(null, { code: 200 });
|
||||||
setTimeout(() => callback(null, { code: 200 }), 2000);
|
|
||||||
else callback(null, { code: 200 });
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const credentials = grpc.ServerCredentials.createSsl(
|
const credentials = grpc.ServerCredentials.createSsl(
|
||||||
@@ -93,46 +91,6 @@ 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,
|
||||||
@@ -152,5 +110,5 @@ assert len(children[0])==3,children
|
|||||||
],
|
],
|
||||||
{ env, timeout: 8000 },
|
{ env, timeout: 8000 },
|
||||||
);
|
);
|
||||||
assert.ok(Date.now() - started < 7000);
|
assert.ok(Date.now() - started < 5000);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,192 +0,0 @@
|
|||||||
const test = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const { spawnSync } = require('node:child_process');
|
|
||||||
const load = require('../helpers/load-security-module.cjs');
|
|
||||||
|
|
||||||
function fixture(row, { claim = true, onSpawn = () => {} } = {}) {
|
|
||||||
const spawned = [],
|
|
||||||
cronWrites = [],
|
|
||||||
instanceWrites = [],
|
|
||||||
instanceReads = [];
|
|
||||||
const { runCron } = load('back/shared/runCron.ts', {
|
|
||||||
'./i18n': { t: (s) => s },
|
|
||||||
'cross-spawn': {
|
|
||||||
spawn: (cmd) => {
|
|
||||||
spawned.push(cmd);
|
|
||||||
onSpawn(row);
|
|
||||||
return { pid: process.pid };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'./pLimit': {
|
|
||||||
runWithCronLimit: (_cron, execute) => execute(),
|
|
||||||
removeQueuedCron() {},
|
|
||||||
},
|
|
||||||
'../loaders/logger': { info() {}, error() {} },
|
|
||||||
'../data/cron': {
|
|
||||||
CrontabModel: {
|
|
||||||
findOne: async () => row,
|
|
||||||
update: async (...args) => {
|
|
||||||
cronWrites.push(args);
|
|
||||||
if (!claim) return [0];
|
|
||||||
Object.assign(row, args[0]);
|
|
||||||
return [1];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
CrontabStatus: { running: 0, idle: 1, queued: 3 },
|
|
||||||
},
|
|
||||||
'../data/runningInstance': {
|
|
||||||
RunningInstanceModel: {
|
|
||||||
findAll: async (options) => {
|
|
||||||
instanceReads.push(options);
|
|
||||||
return [{ id: 7 }];
|
|
||||||
},
|
|
||||||
update: async (...args) => instanceWrites.push(args),
|
|
||||||
},
|
|
||||||
InstanceStatus: { running: 0, stopped: 2 },
|
|
||||||
},
|
|
||||||
dayjs: () => ({ unix: () => 123 }),
|
|
||||||
'./childProcess': {
|
|
||||||
observeChildProcess: () => ({ completed: Promise.resolve({ code: 0 }) }),
|
|
||||||
asError: (e) => e,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
run: () => runCron('task example.py', { id: '1' }),
|
|
||||||
spawned,
|
|
||||||
cronWrites,
|
|
||||||
instanceWrites,
|
|
||||||
instanceReads,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function exitedPid() {
|
|
||||||
const child = spawnSync(process.execPath, ['-e', '']);
|
|
||||||
assert.equal(child.status, 0);
|
|
||||||
assert.throws(() => process.kill(child.pid, 0), { code: 'ESRCH' });
|
|
||||||
return child.pid;
|
|
||||||
}
|
|
||||||
|
|
||||||
test('running single-instance task stays untouched while its process is alive', async () => {
|
|
||||||
const f = fixture({
|
|
||||||
allow_multiple_instances: 0,
|
|
||||||
pid: process.pid,
|
|
||||||
status: 0,
|
|
||||||
});
|
|
||||||
await f.run();
|
|
||||||
assert.deepEqual(f.spawned, []);
|
|
||||||
assert.deepEqual(f.cronWrites, []);
|
|
||||||
assert.deepEqual(f.instanceWrites, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('queued single-instance task keeps its reservation even with an exited PID', async () => {
|
|
||||||
const f = fixture({
|
|
||||||
allow_multiple_instances: 0,
|
|
||||||
pid: exitedPid(),
|
|
||||||
status: 3,
|
|
||||||
});
|
|
||||||
await f.run();
|
|
||||||
assert.deepEqual(f.spawned, []);
|
|
||||||
assert.deepEqual(f.cronWrites, []);
|
|
||||||
assert.deepEqual(f.instanceWrites, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('exited running task is recovered and the next trigger sees the new active run', async () => {
|
|
||||||
const oldPid = exitedPid();
|
|
||||||
const f = fixture(
|
|
||||||
{
|
|
||||||
allow_multiple_instances: 0,
|
|
||||||
pid: oldPid,
|
|
||||||
status: 0,
|
|
||||||
log_path: 'old.log',
|
|
||||||
queued_token: 'old-token',
|
|
||||||
last_execution_time: 10,
|
|
||||||
isDisabled: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSpawn: (row) => {
|
|
||||||
row.status = 0;
|
|
||||||
row.pid = process.pid;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await f.run();
|
|
||||||
await f.run();
|
|
||||||
assert.equal(f.spawned.length, 1);
|
|
||||||
assert.deepEqual(f.instanceReads, [
|
|
||||||
{
|
|
||||||
attributes: ['id'],
|
|
||||||
where: { cron_id: 1, pid: oldPid, status: 0 },
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
assert.deepEqual(f.cronWrites, [
|
|
||||||
[
|
|
||||||
{ status: 1, pid: null, queued_token: null },
|
|
||||||
{
|
|
||||||
where: {
|
|
||||||
id: 1,
|
|
||||||
pid: oldPid,
|
|
||||||
status: 0,
|
|
||||||
log_path: 'old.log',
|
|
||||||
queued_token: 'old-token',
|
|
||||||
last_execution_time: 10,
|
|
||||||
isDisabled: 0,
|
|
||||||
allow_multiple_instances: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
assert.deepEqual(f.instanceWrites, [
|
|
||||||
[{ status: 2, finished_at: 123 }, { where: { id: [7], status: 0 } }],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a lost stale-state claim does not spawn or change running instances', async () => {
|
|
||||||
const f = fixture(
|
|
||||||
{ allow_multiple_instances: 0, pid: exitedPid(), status: 0 },
|
|
||||||
{ claim: false },
|
|
||||||
);
|
|
||||||
await f.run();
|
|
||||||
assert.equal(f.cronWrites.length, 1);
|
|
||||||
assert.deepEqual(f.spawned, []);
|
|
||||||
assert.deepEqual(f.instanceWrites, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('EPERM means an existing process must still block a new run', async () => {
|
|
||||||
const pid = exitedPid();
|
|
||||||
const originalKill = process.kill;
|
|
||||||
process.kill = (target, signal) => {
|
|
||||||
if (target === pid && signal === 0)
|
|
||||||
throw Object.assign(new Error('denied'), { code: 'EPERM' });
|
|
||||||
return originalKill(target, signal);
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
const f = fixture({ allow_multiple_instances: 0, pid, status: 0 });
|
|
||||||
await f.run();
|
|
||||||
assert.deepEqual(f.spawned, []);
|
|
||||||
assert.deepEqual(f.cronWrites, []);
|
|
||||||
} finally {
|
|
||||||
process.kill = originalKill;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('single-instance still starts idle tasks with a stale stored PID', async () => {
|
|
||||||
const f = fixture({
|
|
||||||
allow_multiple_instances: 0,
|
|
||||||
pid: exitedPid(),
|
|
||||||
status: 1,
|
|
||||||
});
|
|
||||||
await f.run();
|
|
||||||
assert.equal(f.spawned.length, 1);
|
|
||||||
assert.deepEqual(f.cronWrites, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('multi-instance keeps its existing concurrent execution behavior', async () => {
|
|
||||||
const f = fixture({
|
|
||||||
allow_multiple_instances: 1,
|
|
||||||
pid: process.pid,
|
|
||||||
status: 0,
|
|
||||||
});
|
|
||||||
await f.run();
|
|
||||||
assert.equal(f.spawned.length, 1);
|
|
||||||
assert.deepEqual(f.cronWrites, []);
|
|
||||||
});
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
const test = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const os = require('node:os');
|
|
||||||
const path = require('node:path');
|
|
||||||
const { 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/);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -393,10 +393,7 @@ 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',
|
||||||
// Bash SECONDS has one-second resolution. Two ticks guarantee a real
|
QL_MIRROR_RETRY_AFTER_SECONDS: '1',
|
||||||
// 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,
|
||||||
},
|
},
|
||||||
@@ -406,14 +403,6 @@ 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);
|
||||||
|
|||||||
@@ -6,61 +6,6 @@ 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 }));
|
||||||
@@ -225,6 +170,8 @@ test('static backfill uses existing snapshots and skips all source, build and im
|
|||||||
'verify-gitee-mirrors',
|
'verify-gitee-mirrors',
|
||||||
'build-alpine',
|
'build-alpine',
|
||||||
'build-debian',
|
'build-debian',
|
||||||
|
'build-alpine310',
|
||||||
|
'build-debian310',
|
||||||
]) {
|
]) {
|
||||||
assert.equal(workflow.jobs[name].needs, 'build-static');
|
assert.equal(workflow.jobs[name].needs, 'build-static');
|
||||||
assert.doesNotMatch(workflow.jobs[name].if || '', /always\(/);
|
assert.doesNotMatch(workflow.jobs[name].if || '', /always\(/);
|
||||||
|
|||||||
@@ -4,6 +4,75 @@ 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', {
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
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'));
|
|
||||||
});
|
|
||||||
@@ -283,54 +283,3 @@ 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);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -286,46 +286,3 @@ 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');
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,354 +0,0 @@
|
|||||||
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 [version, exitCode] of [
|
|
||||||
['2.23.0', 0],
|
|
||||||
['2.23.0', 1],
|
|
||||||
['2.24.0-dev.0', 0],
|
|
||||||
])
|
|
||||||
test(`prestart ${version} exit ${exitCode} reaps children retaining output pipes before removing its workspace`, async (t) => {
|
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-probe-exit-'));
|
|
||||||
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: ${version}`);
|
|
||||||
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:${JSON.stringify(version)},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']);
|
|
||||||
});
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
const test = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const vm = require('node:vm');
|
|
||||||
const { EventEmitter } = require('node:events');
|
|
||||||
const ts = require('typescript');
|
|
||||||
const load = require('../helpers/load-security-module.cjs');
|
|
||||||
const { SchedulerReadiness } = load('back/shared/schedulerReadiness.ts');
|
|
||||||
const { outputText } = ts.transpileModule(
|
|
||||||
fs.readFileSync('back/bootstrap/upgradeProbe.ts', 'utf8'),
|
|
||||||
{ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, esModuleInterop: true } },
|
|
||||||
);
|
|
||||||
|
|
||||||
// Run the real probe entry point, including its lifecycle and IPC reporting.
|
|
||||||
// Only external services and the clock are replaced; no test exports are needed.
|
|
||||||
async function runProbe({ readiness, arch = 'x64', armVersion, supervised = true }) {
|
|
||||||
let now = 0;
|
|
||||||
const events = [], waits = [], messages = [];
|
|
||||||
const build = { sourceCommit: 'a'.repeat(40) };
|
|
||||||
let finish;
|
|
||||||
const completed = new Promise((resolve) => { finish = resolve; });
|
|
||||||
const listener = { address: () => ({ port: 12345 }) };
|
|
||||||
const server = {
|
|
||||||
initialize: async () => { events.push('http'); return listener; },
|
|
||||||
shutdown: async () => { events.push('http-close'); },
|
|
||||||
};
|
|
||||||
const grpc = {
|
|
||||||
initialize: async () => { events.push('grpc'); return 12346; },
|
|
||||||
getServer: () => ({ forceShutdown: () => events.push('grpc-close') }),
|
|
||||||
};
|
|
||||||
class GrpcServerService {}
|
|
||||||
class HttpServerService {}
|
|
||||||
const modules = {
|
|
||||||
'reflect-metadata': {},
|
|
||||||
dns: { setDefaultResultOrder: () => {} },
|
|
||||||
'../config': {},
|
|
||||||
'../data': { sequelize: {
|
|
||||||
transaction: async (action) => action({}),
|
|
||||||
query: async () => [[{ value: 42 }]],
|
|
||||||
close: async () => { events.push('db-close'); },
|
|
||||||
} },
|
|
||||||
'../loaders/db': async () => { events.push('db'); },
|
|
||||||
typedi: { Container: { get: (service) => service === GrpcServerService ? grpc : server } },
|
|
||||||
'../services/grpc': { GrpcServerService },
|
|
||||||
'../services/http': { HttpServerService },
|
|
||||||
'../loaders/httpApplication': { createHttpApplication: async () => ({}) },
|
|
||||||
'../loaders/server': async () => {},
|
|
||||||
'../loaders/express': () => {},
|
|
||||||
'../loaders/app': {},
|
|
||||||
'../loaders/depInjector': () => {},
|
|
||||||
'../schedule/client': { readiness: {
|
|
||||||
configure: (restore) => readiness.configure(restore),
|
|
||||||
recover: async () => {
|
|
||||||
const ready = await readiness.recover();
|
|
||||||
if (ready) events.push('ready');
|
|
||||||
return ready;
|
|
||||||
},
|
|
||||||
} },
|
|
||||||
http: { get: (url, respond) => {
|
|
||||||
events.push('health');
|
|
||||||
const request = new EventEmitter();
|
|
||||||
request.setTimeout = () => {};
|
|
||||||
queueMicrotask(() => {
|
|
||||||
const response = new EventEmitter();
|
|
||||||
response.statusCode = 200;
|
|
||||||
respond(response);
|
|
||||||
response.emit('data', JSON.stringify({ code: 200, data: { status: 'ok', services: { grpc: true, http: true }, build } }));
|
|
||||||
response.emit('end');
|
|
||||||
});
|
|
||||||
return request;
|
|
||||||
} },
|
|
||||||
};
|
|
||||||
vm.runInNewContext(outputText, {
|
|
||||||
exports: {},
|
|
||||||
require: (name) => {
|
|
||||||
assert.ok(Object.hasOwn(modules, name), `Unexpected probe dependency: ${name}`);
|
|
||||||
return modules[name];
|
|
||||||
},
|
|
||||||
process: {
|
|
||||||
env: { QL_UPGRADE_PROBE: supervised ? '1' : '0' }, arch,
|
|
||||||
config: { variables: { arm_version: armVersion } }, pid: 123,
|
|
||||||
once: () => {}, memoryUsage: () => ({}),
|
|
||||||
send: (message) => { messages.push(message); events.push('report'); },
|
|
||||||
exit: finish,
|
|
||||||
},
|
|
||||||
Date: { now: () => now },
|
|
||||||
setTimeout: (callback, ms) => {
|
|
||||||
assert.ok(ms > 0 && ms <= 250);
|
|
||||||
waits.push(ms);
|
|
||||||
now += ms;
|
|
||||||
queueMicrotask(callback);
|
|
||||||
},
|
|
||||||
console: { error: () => {} },
|
|
||||||
}, { filename: 'upgradeProbe.js' });
|
|
||||||
const exitCode = await completed;
|
|
||||||
return { exitCode, events, waits, messages, elapsed: now, build };
|
|
||||||
}
|
|
||||||
|
|
||||||
test('upgrade probe survives cold scheduler recovery before checking HTTP and reporting readiness', async () => {
|
|
||||||
let attempts = 0, restored = false;
|
|
||||||
const readiness = new SchedulerReadiness(async () => {
|
|
||||||
if (++attempts < 3) throw new Error('Failed to connect before the deadline');
|
|
||||||
});
|
|
||||||
// The probe must configure and finish restoration before reporting readiness.
|
|
||||||
const configure = readiness.configure.bind(readiness);
|
|
||||||
readiness.configure = (restore) => configure(async () => { await restore(); restored = true; });
|
|
||||||
const result = await runProbe({ readiness });
|
|
||||||
assert.equal(result.exitCode, 0);
|
|
||||||
assert.equal(restored, true);
|
|
||||||
assert.equal(await readiness.check(), true);
|
|
||||||
assert.deepEqual(result.waits, [250, 250]);
|
|
||||||
assert.deepEqual(result.events, ['db', 'grpc', 'ready', 'http', 'health', 'report', 'http-close', 'grpc-close', 'db-close']);
|
|
||||||
assert.equal(result.messages.length, 1);
|
|
||||||
assert.equal(result.messages[0].ok, true);
|
|
||||||
assert.equal(result.messages[0].build.sourceCommit, result.build.sourceCommit);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const [arch, armVersion, timeout] of [['x64', undefined, 10000], ['arm', 7, 10000], ['arm', '6', 60000]]) {
|
|
||||||
test(`unavailable ${arch}/v${armVersion} scheduler fails at its startup budget and cleans up`, async () => {
|
|
||||||
let attempts = 0;
|
|
||||||
const result = await runProbe({ arch, armVersion, readiness: {
|
|
||||||
configure: () => {}, recover: async () => { attempts++; return false; },
|
|
||||||
} });
|
|
||||||
assert.equal(result.exitCode, 1);
|
|
||||||
assert.equal(result.elapsed, timeout);
|
|
||||||
assert.equal(attempts, timeout / 250);
|
|
||||||
assert.deepEqual(result.events, ['db', 'grpc', 'http-close', 'grpc-close', 'db-close', 'report']);
|
|
||||||
assert.equal(result.messages.length, 1);
|
|
||||||
assert.equal(result.messages[0].ok, false);
|
|
||||||
assert.match(result.messages[0].message, /Upgrade scheduler readiness failed/);
|
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
|
||||||
assert.equal(attempts, timeout / 250, 'no retries after failure');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test('unexpected scheduler recovery errors fail prestart without retries or a success report', async () => {
|
|
||||||
let attempts = 0;
|
|
||||||
const result = await runProbe({ readiness: {
|
|
||||||
configure: () => {}, recover: async () => { attempts++; throw new Error('broken scheduler configuration'); },
|
|
||||||
} });
|
|
||||||
assert.equal(attempts, 1);
|
|
||||||
assert.equal(result.exitCode, 1);
|
|
||||||
assert.deepEqual(result.waits, []);
|
|
||||||
assert.deepEqual(result.events, ['db', 'grpc', 'http-close', 'grpc-close', 'db-close', 'report']);
|
|
||||||
assert.equal(result.messages.length, 1);
|
|
||||||
assert.equal(result.messages[0].ok, false);
|
|
||||||
assert.match(result.messages[0].message, /broken scheduler configuration/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('an already ready scheduler reaches health without waiting', async () => {
|
|
||||||
const result = await runProbe({ readiness: { configure: () => {}, recover: async () => true } });
|
|
||||||
assert.equal(result.exitCode, 0);
|
|
||||||
assert.deepEqual(result.waits, []);
|
|
||||||
assert.equal(result.messages[0].ok, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the upgrade probe still requires its isolated supervisor', async () => {
|
|
||||||
const result = await runProbe({ supervised: false });
|
|
||||||
assert.equal(result.exitCode, 1);
|
|
||||||
assert.deepEqual(result.events, ['report']);
|
|
||||||
assert.match(result.messages[0].message, /requires its isolated supervisor/);
|
|
||||||
});
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
@@ -335,15 +335,11 @@ 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.equal(p.nodes('button').length, 0);
|
|
||||||
assert.equal(p.nodes(p.Modal)[0].props.open, true);
|
|
||||||
f.store.setJob({ status: 'ready', phase: 'completed' });
|
|
||||||
p.render();
|
|
||||||
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.match(p.nodes('button')[0].props.className, /collapsed/);
|
||||||
assert.equal(p.nodes('button')[0].props['aria-label'], '下载完成,等待重启');
|
assert.equal(p.nodes(p.Modal)[0].props.open, true);
|
||||||
|
f.store.setJob({ status: 'ready', phase: 'ready' });
|
||||||
|
p.render();
|
||||||
|
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 () => {
|
||||||
@@ -437,32 +433,3 @@ 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?.('升级成功')),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
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 };
|
|
||||||
+14
-11
@@ -1,12 +1,15 @@
|
|||||||
version: 2.24.0
|
version: 2.23.0
|
||||||
changeLogLink: https://github.com/whyour/qinglong/blob/v2.24.0/docs/releases/2.24.0.md
|
changeLogLink: https://github.com/whyour/qinglong/blob/master/docs/releases/2.23.0.md
|
||||||
publishTime: "2026-10-29 02:14:58"
|
publishTime: "2026-10-11 12:27:02"
|
||||||
changeLog: |
|
changeLog: |
|
||||||
1. Alpine 与 Debian 默认镜像统一使用 Node.js 24,SQLite 依赖升级至 1.1.4
|
1. 新增脚本历史版本查看、对比与恢复,支持 CLI 和 OpenAPI;恢复前检查当前内容哈希,避免覆盖并发修改
|
||||||
2. 新增 Node.js 原生部署的 Python venv 隔离支持,以及 Python Git URL、requirements、pyproject 依赖源
|
2. 普通任务与订阅调度迁移到 node-cron,保留旧规则兼容及日期/星期 OR 语义,补充宏和夏令时说明
|
||||||
3. 新增通知审计日志,完善通知失败记录与配置提示
|
3. 修复已失效排队任务仍被执行的问题,完善任务进程超时、停止和 Shell 环境传递
|
||||||
4. 升级包按平台构建并校验;支持在线暂存、离线安装、失败回退及保留数据迁移
|
4. 修复系统令牌主动续期与认证缓存并发更新;Shell API 遇到 401 时刷新令牌并重试一次
|
||||||
5. 修复任务调度、额外规则、依赖安装、脚本发现和订阅同步等问题
|
5. 降低任务启动开销,JS/Python 通知依赖改为首次调用时加载
|
||||||
6. 独立远程 CLI 更新至 0.1.3,最低 Node.js 版本为 22.12.0
|
6. 加固环境变量、订阅参数、文件访问和 SSH 配置输入,改善资源冲突与 CLI 错误反馈
|
||||||
7. Alpine 保留 arm/v6 和 arm/v7;Debian 暂不提供 arm/v7,Python 3.10 兼容镜像停止更新
|
7. 修复页面升级、ql update 与静态资源版本同步,完善离线备份和升级产物校验
|
||||||
8. 升级前备份完整数据,并在目标设备验证脚本、依赖、订阅和通知
|
8. 修复 Debian 依赖安装、通知与调度回归,补齐前端中英文翻译
|
||||||
|
9. 最小修补 HTTP 压缩、上传、gRPC、代理 IP、WebSocket 和 JSON 查询相关依赖漏洞
|
||||||
|
10. 远程 CLI 更新至 0.1.2,新增脚本历史命令,最低 Node.js 版本仍为 22.12
|
||||||
|
11. 本次为 Python 3.10 兼容镜像的最后一个版本,发布后停止维护;升级前备份完整数据并验证脚本、依赖和通知
|
||||||
|
|||||||
Reference in New Issue
Block a user