mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-04 00:42:01 +08:00
Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d69c3340c | ||
|
|
a6c4acbfd2 | ||
|
|
a5f22f8d44 | ||
|
|
f2c2f5e4f8 | ||
|
|
2efc5e4503 | ||
|
|
b92621bdb8 | ||
|
|
da9c6a6bbb | ||
|
|
d895fbba36 | ||
|
|
6862ad4f6c | ||
|
|
b8110c52eb | ||
|
|
99a4d0eff8 | ||
|
|
b486989399 | ||
|
|
77fe551425 | ||
|
|
2669f251ac | ||
|
|
3f02b04dff | ||
|
|
e8b6a30bd9 | ||
|
|
a36a77086a | ||
|
|
cb5dfec2b5 | ||
|
|
286088a32c | ||
|
|
a1104ef47c | ||
|
|
1a152575cc | ||
|
|
ef348b445b | ||
|
|
8305825ffd | ||
|
|
e555fcbb0b | ||
|
|
83e8f6bccd | ||
|
|
5801f735bc | ||
|
|
53e1129a12 | ||
|
|
13c0a4d21b | ||
|
|
126fe8eb67 | ||
|
|
73c41eed22 | ||
|
|
40149b6f17 | ||
|
|
6532775a25 | ||
|
|
6469792546 | ||
|
|
e435f6e886 | ||
|
|
a0c09046c6 | ||
|
|
5a9e53c771 | ||
|
|
7334566d3c | ||
|
|
1503f07c75 | ||
|
|
0d33d2321a | ||
|
|
79964f149c | ||
|
|
bcb2471768 |
@@ -8,6 +8,10 @@ on:
|
||||
# CLI is verified separately; its shared Bash upgrade engine ships in images.
|
||||
- "!cli/**"
|
||||
- "cli/src/internal/maintenance/upgradeArtifacts.cjs"
|
||||
- "cli/src/internal/maintenance/platformBundle.cjs"
|
||||
- "cli/src/internal/maintenance/upgradePrestart.cjs"
|
||||
- "cli/src/internal/maintenance/panelUpgrade.cjs"
|
||||
- "cli/src/internal/maintenance/nodeRuntime.cjs"
|
||||
- "!.github/workflows/cli-package.yml"
|
||||
branches:
|
||||
- "master"
|
||||
@@ -33,6 +37,14 @@ on:
|
||||
permissions:
|
||||
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:
|
||||
validate:
|
||||
if: ${{ !inputs.static_sync_only }}
|
||||
@@ -68,7 +80,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version-file: ".nvmrc"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
@@ -79,6 +91,9 @@ jobs:
|
||||
pnpm build:back
|
||||
pnpm build:info
|
||||
|
||||
- name: Pack the npm panel archive from the verified build
|
||||
run: node scripts/verify-panel-package.cjs pack "$RUNNER_TEMP/npm-panel"
|
||||
|
||||
- uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: qinglong-static-${{ github.sha }}
|
||||
@@ -86,13 +101,31 @@ jobs:
|
||||
if-no-files-found: error
|
||||
include-hidden-files: true
|
||||
|
||||
- uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: qinglong-npm-${{ github.sha }}
|
||||
path: ${{ runner.temp }}/npm-panel/
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
- name: copy to static repo
|
||||
env:
|
||||
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
|
||||
GITHUB_BRANCH: ${{ github.ref_name }}
|
||||
GITHUB_REF_TYPE: ${{ github.ref_type }}
|
||||
STATIC_TOKEN: ${{ secrets.API_TOKEN }}
|
||||
run: |
|
||||
bash scripts/publish-static.sh "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_REF_TYPE"
|
||||
: "${STATIC_TOKEN:?Missing static repository token}"
|
||||
bash scripts/publish-static.sh "https://x-access-token:${STATIC_TOKEN}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_REF_TYPE"
|
||||
|
||||
- name: publish verified upgrade download sizes
|
||||
env:
|
||||
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
|
||||
GITHUB_BRANCH: ${{ github.ref_name }}
|
||||
STATIC_TOKEN: ${{ secrets.API_TOKEN }}
|
||||
run: |
|
||||
: "${STATIC_TOKEN:?Missing static repository token}"
|
||||
node scripts/publish-upgrade-info.cjs "https://x-access-token:${STATIC_TOKEN}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_SHA"
|
||||
|
||||
static_gitlab:
|
||||
needs: build-static
|
||||
@@ -197,6 +230,8 @@ jobs:
|
||||
build-alpine:
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
needs: build-static
|
||||
outputs:
|
||||
image-digest: ${{ steps.image.outputs.digest }}
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
packages: write
|
||||
@@ -212,7 +247,7 @@ jobs:
|
||||
version: "8.3.1"
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "24"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
@@ -262,6 +297,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build and push (Alpine)
|
||||
id: image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
build-args: |
|
||||
@@ -283,6 +319,8 @@ jobs:
|
||||
build-debian:
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
needs: build-static
|
||||
outputs:
|
||||
image-digest: ${{ steps.image.outputs.digest }}
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
packages: write
|
||||
@@ -298,7 +336,7 @@ jobs:
|
||||
version: "8.3.1"
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "24"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
@@ -347,6 +385,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build and push (Debian)
|
||||
id: image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
build-args: |
|
||||
@@ -355,7 +394,7 @@ jobs:
|
||||
SOURCE_COMMIT=${{ github.sha }}
|
||||
network: host
|
||||
# Keep s390x on Debian: npm can hang under QEMU with Alpine (nodejs/docker-node#1973).
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
||||
platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/s390x
|
||||
context: .
|
||||
file: ./docker/Dockerfile.debian
|
||||
push: true
|
||||
@@ -366,175 +405,78 @@ jobs:
|
||||
cache-from: type=registry,ref=whyour/qinglong:cache-debian
|
||||
cache-to: type=registry,ref=whyour/qinglong:cache-debian,mode=max
|
||||
|
||||
build-alpine310:
|
||||
if: ${{ github.ref_name == 'master' }}
|
||||
needs: build-static
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
# One unmodified amd64 package for migration acceptance only. This does not
|
||||
# publish bundles or advance development/formal channel indexes.
|
||||
build-npm-migration-bundle:
|
||||
needs: build-debian
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: qinglong-static-${{ github.sha }}
|
||||
path: static/
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: "8.3.1"
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- name: Read version from version.yaml
|
||||
id: version
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- name: Export the official platform package from this run's image digest
|
||||
env:
|
||||
IMAGE_DIGEST: ${{ needs.build-debian.outputs.image-digest }}
|
||||
run: |
|
||||
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup timezone
|
||||
run: sudo timedatectl set-timezone Asia/Shanghai
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v4
|
||||
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:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
name: npm-migration-bundle-${{ github.sha }}
|
||||
path: ${{ runner.temp }}/npm-migration-bundle/*
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
- 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
|
||||
verify-npm-package:
|
||||
needs: [build-static, build-npm-migration-bundle]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: ['20.18.1', '24']
|
||||
container: node:${{ matrix.node }}-bookworm
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/download-artifact@v7
|
||||
- uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: qinglong-static-${{ github.sha }}
|
||||
path: static/
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: qinglong-npm-${{ github.sha }}
|
||||
path: ${{ runner.temp }}/npm-panel/
|
||||
- uses: actions/download-artifact@v6
|
||||
with:
|
||||
version: "8.3.1"
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- name: Read version from version.yaml
|
||||
id: version
|
||||
name: npm-migration-bundle-${{ github.sha }}
|
||||
path: ${{ runner.temp }}/npm-migration-bundle/
|
||||
- name: Install isolated service test tools
|
||||
run: |
|
||||
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup timezone
|
||||
run: sudo timedatectl set-timezone Asia/Shanghai
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v4
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends python3 openssl curl jq nginx procps
|
||||
# The published legacy launcher resets PATH to standard directories.
|
||||
# /usr/local is confined to this disposable job container; PM2_HOME
|
||||
# remains private to each verification run.
|
||||
npm install --global --prefix /usr/local --no-audit --no-fund pm2@6.0.14
|
||||
- name: Install and upgrade the exact npm archive
|
||||
env:
|
||||
QL_PANEL_PACKAGE_TEST_ISOLATED: '1'
|
||||
QL_PANEL_TEST_PM2: /usr/local/bin/pm2
|
||||
QL_PANEL_TEST_BUNDLE_DIR: ${{ runner.temp }}/npm-migration-bundle
|
||||
run: node scripts/verify-panel-package.cjs verify "$RUNNER_TEMP/npm-panel" "$RUNNER_TEMP/npm-panel-results"
|
||||
- uses: actions/upload-artifact@v6
|
||||
with:
|
||||
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 (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
|
||||
name: qinglong-npm-verification-${{ matrix.node }}-${{ github.sha }}
|
||||
path: ${{ runner.temp }}/npm-panel-results/
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
publish:
|
||||
if: ${{ github.repository == 'whyour/qinglong' && github.ref == 'refs/heads/master' }}
|
||||
needs: [build-alpine, build-debian, build-alpine310, build-debian310]
|
||||
needs: [build-alpine, build-debian, verify-npm-package]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: "8.3.1"
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: build front and back
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build:front
|
||||
pnpm build:back
|
||||
|
||||
- name: Set up Node for trusted publishing
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -542,18 +484,112 @@ jobs:
|
||||
registry-url: https://registry.npmjs.org
|
||||
package-manager-cache: false
|
||||
|
||||
- uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: qinglong-npm-${{ github.sha }}
|
||||
path: npm-panel/
|
||||
|
||||
- name: Publish npm package with OIDC
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
name=$(node -p 'require("./package.json").name')
|
||||
version=$(node -p 'require("./package.json").version')
|
||||
shopt -s nullglob
|
||||
archives=(npm-panel/*.tgz)
|
||||
if [ "${#archives[@]}" -ne 1 ]; then
|
||||
echo '::error::Expected exactly one verified panel npm archive.'
|
||||
exit 1
|
||||
fi
|
||||
archive="${archives[0]}"
|
||||
metadata=$(tar -xOf "$archive" package/package.json)
|
||||
name=$(jq -er '.name | select(. == "@whyour/qinglong")' <<< "$metadata")
|
||||
version=$(jq -er '.version | select(type == "string")' <<< "$metadata")
|
||||
if npm view "$name@$version" version --json --registry=https://registry.npmjs.org > "$RUNNER_TEMP/npm-version.json" 2> "$RUNNER_TEMP/npm-version-error.log"; then
|
||||
jq -e --arg version "$version" '. == $version' "$RUNNER_TEMP/npm-version.json" > /dev/null
|
||||
echo "::notice::$name@$version is already published; skipping npm publication for this image rebuild."
|
||||
elif jq -e '.error.code == "E404"' "$RUNNER_TEMP/npm-version.json" > /dev/null; then
|
||||
npm publish --access public --registry=https://registry.npmjs.org
|
||||
npm publish "./$archive" --ignore-scripts --access public --registry=https://registry.npmjs.org
|
||||
else
|
||||
echo '::error::Could not check the published npm version; refusing to publish.'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build-upgrade-bundles:
|
||||
# Both channels publish packages from this run's immutable image digests.
|
||||
if: ${{ !inputs.static_sync_only && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/develop') }}
|
||||
needs: [build-alpine, build-debian]
|
||||
# Node 24 can SIGILL while archiving ARM64 musl under x64 QEMU.
|
||||
runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-22.04' }}
|
||||
timeout-minutes: 90
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { platform: linux/amd64, libc: musl, name: x64-musl }
|
||||
- { platform: linux/arm/v6, libc: musl, name: armv6-musl }
|
||||
- { platform: linux/arm/v7, libc: musl, name: armv7-musl }
|
||||
- { platform: linux/arm64, libc: musl, name: arm64-musl }
|
||||
- { platform: linux/ppc64le, libc: musl, name: ppc64-musl }
|
||||
- { platform: linux/s390x, libc: musl, name: s390x-musl }
|
||||
- { platform: linux/386, libc: musl, name: ia32-musl }
|
||||
- { platform: linux/amd64, libc: glibc, name: x64-glibc }
|
||||
- { platform: linux/arm64, libc: glibc, name: arm64-glibc }
|
||||
- { platform: linux/ppc64le, libc: glibc, name: ppc64-glibc }
|
||||
- { platform: linux/s390x, libc: glibc, name: s390x-glibc }
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: docker/setup-qemu-action@v4
|
||||
if: ${{ matrix.platform != 'linux/arm64' }}
|
||||
with:
|
||||
cache-image: false
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- name: Build and probe the immutable platform package
|
||||
env:
|
||||
ALPINE_DIGEST: ${{ needs.build-alpine.outputs.image-digest }}
|
||||
DEBIAN_DIGEST: ${{ needs.build-debian.outputs.image-digest }}
|
||||
BUNDLE_LIBC: ${{ matrix.libc }}
|
||||
BUNDLE_PLATFORM: ${{ matrix.platform }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
digest="$ALPINE_DIGEST"
|
||||
if [ "$BUNDLE_LIBC" = glibc ]; then digest="$DEBIAN_DIGEST"; fi
|
||||
test -n "$digest"
|
||||
image="ghcr.io/${GITHUB_REPOSITORY}@${digest}"
|
||||
docker buildx build --platform "$BUNDLE_PLATFORM" --build-arg "BASE_IMAGE=$image" --file docker/Dockerfile.upgrade-bundle --output type=local,dest=output/upgrade .
|
||||
- uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: upgrade-${{ matrix.name }}-${{ github.sha }}
|
||||
path: output/upgrade/*
|
||||
if-no-files-found: error
|
||||
|
||||
publish-upgrade-bundles:
|
||||
if: ${{ !inputs.static_sync_only && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/develop') }}
|
||||
needs: build-upgrade-bundles
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
concurrency:
|
||||
group: upgrade-channel-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: '8.3.1'
|
||||
- run: pnpm install --prod --frozen-lockfile
|
||||
- run: npm --prefix cli ci
|
||||
- uses: actions/download-artifact@v7
|
||||
with:
|
||||
pattern: upgrade-*-${{ github.sha }}
|
||||
path: output/upgrade
|
||||
merge-multiple: true
|
||||
- run: node scripts/build-bundle-installer.cjs output/upgrade/install-bundle.cjs
|
||||
- name: Publish complete packages before advancing the channel
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: node scripts/publish-upgrade-bundles.cjs output/upgrade "$GITHUB_SHA" "$GITHUB_REPOSITORY" "$GITHUB_REF_NAME"
|
||||
|
||||
@@ -25,7 +25,7 @@ on:
|
||||
type: boolean
|
||||
default: false
|
||||
prerelease_version:
|
||||
description: Develop only, e.g. 0.1.2-beta.0 (alpha/beta/rc)
|
||||
description: Develop only, e.g. 0.1.5-beta.0 (alpha/beta/rc)
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
@@ -93,8 +93,26 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
platform-smoke:
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-latest, ubuntu-24.04-arm]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: cli/package-lock.json
|
||||
- run: npm ci --prefix cli --no-audit --no-fund
|
||||
- run: npm run build:cli
|
||||
- run: node --test cli/test/remote/platformSmoke.test.cjs
|
||||
|
||||
publish:
|
||||
needs: package
|
||||
needs: [package, platform-smoke]
|
||||
if: >-
|
||||
github.repository == 'whyour/qinglong' &&
|
||||
((github.ref == 'refs/heads/master' &&
|
||||
|
||||
@@ -6,6 +6,11 @@ on:
|
||||
- '.github/workflows/release-readiness.yml'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'docker/Dockerfile*'
|
||||
- 'shell/node-runtime-probe.cjs'
|
||||
- 'scripts/verify-panel-package.cjs'
|
||||
- 'scripts/verify-image-node-layers.py'
|
||||
- 'test/back/node-runtime-probe.test.cjs'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -41,6 +46,13 @@ jobs:
|
||||
path: static/
|
||||
include-hidden-files: true
|
||||
if-no-files-found: error
|
||||
- name: Pack the verified npm archive for isolated runtime validation
|
||||
run: node scripts/verify-panel-package.cjs pack "$RUNNER_TEMP/npm-panel"
|
||||
- uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: release-readiness-npm-panel
|
||||
path: ${{ runner.temp }}/npm-panel/
|
||||
if-no-files-found: error
|
||||
|
||||
images:
|
||||
needs: [regression, static]
|
||||
@@ -57,15 +69,7 @@ jobs:
|
||||
- image: debian
|
||||
file: docker/Dockerfile.debian
|
||||
cache: debian
|
||||
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
|
||||
platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/s390x
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
@@ -121,16 +125,47 @@ jobs:
|
||||
docker run --rm --network none --entrypoint node \
|
||||
qinglong-readiness:${{ matrix.image }} \
|
||||
-e 'const assert=require("node:assert/strict"); const {createRequire}=require("node:module"); for(const [name,want] of Object.entries({"@grpc/grpc-js":"1.14.5",compression:"1.8.2",multer:"2.4.0",sequelize:"6.37.8"}))assert.equal(require(name+"/package.json").version,want); const expressRequire=createRequire(require.resolve("express")); assert.equal(expressRequire("proxy-addr/package.json").version,"2.0.8"); const sockRequire=createRequire(require.resolve("sockjs")); assert.equal(sockRequire("websocket-driver/package.json").version,"0.7.5"); require("sqlite3"); require("nodemailer"); require("undici"); console.log("Minimal production patches and runtime imports verified")'
|
||||
- name: Export native Debian image for isolated dev validation
|
||||
if: matrix.image == 'debian'
|
||||
run: |
|
||||
docker save qinglong-readiness:debian | gzip -1 > release-readiness-debian-image.tar.gz
|
||||
sha256sum release-readiness-debian-image.tar.gz > release-readiness-debian-image.sha256
|
||||
docker image inspect qinglong-readiness:debian > release-readiness-debian-image.json
|
||||
- uses: actions/upload-artifact@v6
|
||||
if: matrix.image == 'debian'
|
||||
- name: Load ARMv6 image for SQLite and Node regression checks
|
||||
if: startsWith(matrix.image, 'alpine')
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
name: release-readiness-debian-image
|
||||
path: release-readiness-debian-image.*
|
||||
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: |
|
||||
docker run --rm --network none --platform linux/arm/v6 \
|
||||
-e QEMU_CPU=arm1176 --entrypoint node \
|
||||
qinglong-readiness:${{ matrix.image }}-armv6 --jitless \
|
||||
-e 'const assert=require("node:assert/strict"); const sqlite3=require("sqlite3"); const db=new sqlite3.Database(":memory:"); db.get("SELECT 42 AS n",(error,row)=>{assert.ifError(error); assert.equal(row.n,42); db.close()});'
|
||||
- name: Check Node binaries in every serialized image layer
|
||||
if: startsWith(matrix.image, 'alpine')
|
||||
run: |
|
||||
docker save qinglong-readiness:${{ matrix.image }} -o "$RUNNER_TEMP/node-layers-amd64.tar"
|
||||
docker save qinglong-readiness:${{ matrix.image }}-armv6 -o "$RUNNER_TEMP/node-layers-arm.tar"
|
||||
python3 scripts/verify-image-node-layers.py "$RUNNER_TEMP/node-layers-amd64.tar" amd64
|
||||
python3 scripts/verify-image-node-layers.py "$RUNNER_TEMP/node-layers-arm.tar" arm
|
||||
rm "$RUNNER_TEMP/node-layers-amd64.tar" "$RUNNER_TEMP/node-layers-arm.tar"
|
||||
- name: Export native image for isolated dev validation
|
||||
if: matrix.image == 'debian' || matrix.image == 'alpine'
|
||||
run: |
|
||||
docker save qinglong-readiness:${{ matrix.image }} | gzip -1 > release-readiness-${{ matrix.image }}-image.tar.gz
|
||||
sha256sum release-readiness-${{ matrix.image }}-image.tar.gz > release-readiness-${{ matrix.image }}-image.sha256
|
||||
docker image inspect qinglong-readiness:${{ matrix.image }} > release-readiness-${{ matrix.image }}-image.json
|
||||
- uses: actions/upload-artifact@v6
|
||||
if: matrix.image == 'debian' || matrix.image == 'alpine'
|
||||
with:
|
||||
name: release-readiness-${{ matrix.image }}-image
|
||||
path: release-readiness-${{ matrix.image }}-image.*
|
||||
compression-level: 0
|
||||
if-no-files-found: error
|
||||
|
||||
+10
@@ -29,6 +29,7 @@ __pycache__
|
||||
/shell/preload/*-notify.json
|
||||
/shell/preload/__ql_notify__.*
|
||||
/shell/preload/lang_env.sh
|
||||
/shell/upgrade-lock.yaml
|
||||
|
||||
.deepseek/
|
||||
.claude/
|
||||
@@ -38,3 +39,12 @@ __pycache__
|
||||
|
||||
# Local security audit artifacts
|
||||
/audit/
|
||||
|
||||
# Local browser tests, screenshots, reports, and temporary test output
|
||||
/.playwright-cli/
|
||||
/output/
|
||||
/playwright-report/
|
||||
/test-results/
|
||||
/blob-report/
|
||||
/coverage/
|
||||
/.nyc_output/
|
||||
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
/.tmp/
|
||||
/.github/
|
||||
/.vscode/
|
||||
/.history/
|
||||
/back/**/*.ts
|
||||
/back/**/*.json
|
||||
/cli/
|
||||
/data/
|
||||
/src/
|
||||
/static/**/*.js.map
|
||||
/static/**/*.gz
|
||||
/.editorconfig
|
||||
/.gitignore
|
||||
/.prettierignore
|
||||
/.prettierrc
|
||||
/.umirc.ts
|
||||
/nodemon.json
|
||||
/pnpm-lock.yaml
|
||||
/tsconfig.back.json
|
||||
/tsconfig.json
|
||||
/typings.d.ts
|
||||
/.env
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextFunction, Request, Response, Router } from 'express';
|
||||
import { celebrate, Joi } from 'celebrate';
|
||||
import { t } from '../shared/i18n';
|
||||
import {
|
||||
diagnoseClientIp,
|
||||
getTrustProxyConfig,
|
||||
@@ -36,7 +37,7 @@ export default (app: Router) => {
|
||||
} catch (error) {
|
||||
res.send({
|
||||
code: 400,
|
||||
message: error instanceof Error ? error.message : '配置更新失败',
|
||||
message: error instanceof Error ? error.message : t('配置更新失败'),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
+68
-6
@@ -11,12 +11,11 @@ export default (app: Router) => {
|
||||
route.get(
|
||||
'/',
|
||||
celebrate({
|
||||
query:
|
||||
Joi.object({
|
||||
searchValue: Joi.string().optional().allow(''),
|
||||
type: Joi.string().optional().allow(''),
|
||||
status: Joi.string().optional().allow(''),
|
||||
}).unknown(true),
|
||||
query: Joi.object({
|
||||
searchValue: Joi.string().optional().allow(''),
|
||||
type: Joi.string().optional().allow(''),
|
||||
status: Joi.string().optional().allow(''),
|
||||
}).unknown(true),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
@@ -31,6 +30,69 @@ export default (app: Router) => {
|
||||
},
|
||||
);
|
||||
|
||||
route.post(
|
||||
'/refresh',
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
type: Joi.string().valid('nodejs', 'python3').required(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const dependenceService = Container.get(DependenceService);
|
||||
const { job, accepted } = dependenceService.startRefresh(req.body.type);
|
||||
if (!accepted) {
|
||||
return res.status(409).send({
|
||||
code: 409,
|
||||
message: 'Another dependency refresh is already running',
|
||||
});
|
||||
}
|
||||
return res.send({ code: 200, data: job });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/refresh/active',
|
||||
async (_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const dependenceService = Container.get(DependenceService);
|
||||
res.set('Cache-Control', 'no-store');
|
||||
return res.send({
|
||||
code: 200,
|
||||
data: dependenceService.getActiveRefreshJob() || null,
|
||||
});
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/refresh/:id',
|
||||
celebrate({
|
||||
params: Joi.object({ id: Joi.string().uuid().required() }),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const dependenceService = Container.get(DependenceService);
|
||||
const job = dependenceService.getRefreshJob(req.params.id);
|
||||
if (!job) {
|
||||
return res.status(404).send({
|
||||
code: 404,
|
||||
message: 'Dependency refresh job not found',
|
||||
});
|
||||
}
|
||||
res.set('Cache-Control', 'no-store');
|
||||
return res.send({ code: 200, data: job });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.post(
|
||||
'/',
|
||||
celebrate({
|
||||
|
||||
@@ -205,6 +205,17 @@ export default (app: Router) => {
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/update-status',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
res.send(await Container.get(SystemService).getUpgradeStatus());
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.put(
|
||||
'/reload',
|
||||
celebrate({
|
||||
@@ -383,6 +394,19 @@ export default (app: Router) => {
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/notify-log',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const systemService = Container.get(SystemService);
|
||||
const data = await systemService.getNotifyLog();
|
||||
res.send({ code: 200, data });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.delete(
|
||||
'/log',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
|
||||
+13
-28
@@ -5,6 +5,7 @@ import { Container } from 'typedi';
|
||||
import config from './config';
|
||||
import Logger from './loaders/logger';
|
||||
import { errStack } from './shared/errors';
|
||||
import { initializeLanguageSync } from './shared/languageSync';
|
||||
import { type GrpcServerService } from './services/grpc';
|
||||
import { type HttpServerService } from './services/http';
|
||||
|
||||
@@ -27,6 +28,7 @@ class Application {
|
||||
if (cluster.isPrimary) {
|
||||
await this.initializeDatabase();
|
||||
}
|
||||
await initializeLanguageSync();
|
||||
if (cluster.isPrimary) {
|
||||
this.startMasterProcess();
|
||||
} else {
|
||||
@@ -39,11 +41,18 @@ class Application {
|
||||
}
|
||||
|
||||
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
|
||||
const grpcWorker = this.forkWorker('grpc');
|
||||
|
||||
// Wait for gRPC worker to signal it's ready before starting HTTP worker
|
||||
this.waitForWorkerReady(grpcWorker, 30000)
|
||||
this.waitForWorkerReady(grpcWorker, readyTimeout)
|
||||
.then(() => {
|
||||
Logger.info('[boot] gRPC worker is ready, starting HTTP worker');
|
||||
this.httpWorker = this.forkWorker('http');
|
||||
@@ -69,7 +78,7 @@ class Application {
|
||||
Logger.warn('Unable to notify HTTP worker of scheduler exit');
|
||||
}
|
||||
const newGrpcWorker = this.forkWorker('grpc');
|
||||
this.waitForWorkerReady(newGrpcWorker, 30000)
|
||||
this.waitForWorkerReady(newGrpcWorker, readyTimeout)
|
||||
.then(() => {
|
||||
Logger.info('gRPC worker restarted and ready');
|
||||
// Re-register cron jobs by notifying the HTTP worker
|
||||
@@ -146,32 +155,8 @@ class Application {
|
||||
}
|
||||
|
||||
private async setupMiddlewares(): 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;
|
||||
const { createHttpApplication } = await import('./loaders/httpApplication');
|
||||
return createHttpApplication();
|
||||
}
|
||||
|
||||
private setupMasterShutdown() {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
// A single, disposable backend process. Resolve startup dependencies, but never
|
||||
// run appLoader: its initData/initTask hooks install dependencies and start user work.
|
||||
import 'reflect-metadata';
|
||||
import dns from 'dns';
|
||||
import http from 'http';
|
||||
|
||||
// The first scheduler connection can outlive its short RPC deadline. Let the
|
||||
// normal single-flight recovery finish within the supervised startup budget.
|
||||
async function waitForUpgradeScheduler(
|
||||
readiness: { recover(): Promise<boolean> },
|
||||
timeoutMs: number,
|
||||
retryMs = 250,
|
||||
) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
do {
|
||||
if (await readiness.recover()) return;
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) break;
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, Math.min(retryMs, remaining)),
|
||||
);
|
||||
} while (Date.now() < deadline);
|
||||
throw new Error('Upgrade scheduler readiness failed');
|
||||
}
|
||||
|
||||
async function probeUpgrade() {
|
||||
if (!process.send || process.env.QL_UPGRADE_PROBE !== '1')
|
||||
throw new Error('Upgrade probe requires its isolated supervisor');
|
||||
dns.setDefaultResultOrder('ipv4first');
|
||||
const { default: config } = await import('../config');
|
||||
const { sequelize } = await import('../data');
|
||||
const { default: loadDatabase } = await import('../loaders/db');
|
||||
const { Container } = await import('typedi');
|
||||
const { GrpcServerService } = await import('../services/grpc');
|
||||
const { HttpServerService } = await import('../services/http');
|
||||
const { createHttpApplication } = await import('../loaders/httpApplication');
|
||||
const { default: loadServer } = await import('../loaders/server');
|
||||
const { default: loadExpress } = await import('../loaders/express');
|
||||
await import('../loaders/app'); // Resolve normal startup hooks without running them.
|
||||
const { default: inject } = await import('../loaders/depInjector');
|
||||
const grpc = Container.get(GrpcServerService);
|
||||
const server = Container.get(HttpServerService);
|
||||
try {
|
||||
await loadDatabase();
|
||||
// Exercise an actual on-disk transaction as well as schema initialization.
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await sequelize.query('CREATE TABLE upgrade_probe (value INTEGER)', {
|
||||
transaction,
|
||||
});
|
||||
await sequelize.query('INSERT INTO upgrade_probe VALUES (42)', {
|
||||
transaction,
|
||||
});
|
||||
const [rows] = await sequelize.query('SELECT value FROM upgrade_probe', {
|
||||
transaction,
|
||||
});
|
||||
if ((rows[0] as { value: number })?.value !== 42)
|
||||
throw new Error('Upgrade database readback failed');
|
||||
});
|
||||
inject();
|
||||
config.grpcPort = await grpc.initialize();
|
||||
const { default: cronClient } = await import('../schedule/client');
|
||||
cronClient.readiness.configure(async () => {});
|
||||
await waitForUpgradeScheduler(
|
||||
cronClient.readiness,
|
||||
process.arch === 'arm' &&
|
||||
Number(
|
||||
(process.config.variables as { arm_version?: string | number })
|
||||
.arm_version,
|
||||
) === 6
|
||||
? 60000
|
||||
: 10000,
|
||||
);
|
||||
const app = await createHttpApplication();
|
||||
loadExpress({ app });
|
||||
const listener = await server.initialize(app, 0);
|
||||
await loadServer({ server: listener });
|
||||
const address = listener.address();
|
||||
if (!address || typeof address === 'string')
|
||||
throw new Error('No probe HTTP port');
|
||||
const health = await new Promise<any>((resolve, reject) => {
|
||||
const request = http.get(
|
||||
`http://127.0.0.1:${address.port}/api/health`,
|
||||
(response) => {
|
||||
let body = '';
|
||||
response.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
response.on('error', reject);
|
||||
response.on('end', () => {
|
||||
try {
|
||||
const result = JSON.parse(body);
|
||||
if (
|
||||
response.statusCode !== 200 ||
|
||||
result.code !== 200 ||
|
||||
result.data?.status !== 'ok' ||
|
||||
!result.data.services?.grpc ||
|
||||
!result.data.services?.http
|
||||
)
|
||||
throw new Error('Upgrade HTTP/gRPC health check failed');
|
||||
resolve(result.data);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
request.setTimeout(10000, () =>
|
||||
request.destroy(new Error('Upgrade health request timed out')),
|
||||
);
|
||||
request.on('error', reject);
|
||||
});
|
||||
process.send!({
|
||||
type: 'upgrade-probe',
|
||||
ok: true,
|
||||
build: health.build,
|
||||
memory: process.memoryUsage(),
|
||||
pid: process.pid,
|
||||
});
|
||||
} finally {
|
||||
await server.shutdown();
|
||||
grpc.getServer().forceShutdown();
|
||||
await sequelize.close();
|
||||
}
|
||||
}
|
||||
|
||||
process.once('disconnect', () => process.exit(1));
|
||||
probeUpgrade()
|
||||
.then(() => process.exit(0))
|
||||
.catch((error) => {
|
||||
process.send?.({
|
||||
type: 'upgrade-probe',
|
||||
ok: false,
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
+38
-11
@@ -716,17 +716,20 @@ function isPyprojectToml(name: string): boolean {
|
||||
return name.endsWith('pyproject.toml');
|
||||
}
|
||||
|
||||
export function isPythonSourceDependency(name: string): boolean {
|
||||
const source = name.trim();
|
||||
return (
|
||||
isGitHubUrl(source) || isRequirementsFile(source) || isPyprojectToml(source)
|
||||
);
|
||||
}
|
||||
|
||||
export function getGetCommand(type: DependenceTypes, name: string): string {
|
||||
const trimmedName = name.trim();
|
||||
|
||||
// For Python dependencies installed from GitHub or requirements files,
|
||||
// we can't reliably check if they're installed, so skip the check
|
||||
if (type === DependenceTypes.python3) {
|
||||
if (
|
||||
isGitHubUrl(trimmedName) ||
|
||||
isRequirementsFile(trimmedName) ||
|
||||
isPyprojectToml(trimmedName)
|
||||
) {
|
||||
if (isPythonSourceDependency(trimmedName)) {
|
||||
// Return a command that will always indicate not installed
|
||||
// This ensures GitHub URLs and requirements files are always installed
|
||||
return 'echo ""';
|
||||
@@ -736,10 +739,30 @@ export function getGetCommand(type: DependenceTypes, name: string): string {
|
||||
const baseCommands = {
|
||||
[DependenceTypes.nodejs]: `pnpm ls -g | grep "${trimmedName}" | head -1`,
|
||||
[DependenceTypes.python3]: `${getPythonExecutable()} -c ${shellQuote(
|
||||
`from importlib.metadata import version\nimport importlib.util as u\nname=${JSON.stringify(
|
||||
trimmedName,
|
||||
)}\ntry:\n print(version(name))\nexcept Exception:\n print(name if u.find_spec(name) else '')`,
|
||||
)}`,
|
||||
`from importlib.metadata import version, PackageNotFoundError
|
||||
import importlib.util
|
||||
import sys
|
||||
try:
|
||||
try:
|
||||
from packaging.requirements import Requirement
|
||||
except ImportError:
|
||||
from pip._vendor.packaging.requirements import Requirement
|
||||
requirement = Requirement(sys.argv[1])
|
||||
if requirement.marker and not requirement.marker.evaluate():
|
||||
print('not applicable')
|
||||
else:
|
||||
try:
|
||||
installed = version(requirement.name)
|
||||
except PackageNotFoundError:
|
||||
if not requirement.specifier and not requirement.extras and not requirement.url:
|
||||
if importlib.util.find_spec(requirement.name):
|
||||
print(requirement.name)
|
||||
else:
|
||||
if requirement.specifier.contains(installed, prereleases=True):
|
||||
print(installed)
|
||||
except Exception:
|
||||
pass`,
|
||||
)} ${shellQuote(trimmedName)}`,
|
||||
[DependenceTypes.linux]:
|
||||
getOsTypeSync() === 'Alpine'
|
||||
? `apk info -es ${trimmedName}`
|
||||
@@ -824,9 +847,13 @@ export function getUninstallCommand(
|
||||
'Remove installed Python distributions separately by package name.',
|
||||
)}`;
|
||||
}
|
||||
const packageName =
|
||||
name.match(
|
||||
/^([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]+\])?(?=\s*(?:[<>=!~;@]|$))/,
|
||||
)?.[1] || name;
|
||||
return `${getPythonPip()} uninstall --disable-pip-version-check${
|
||||
PYTHON_VENV_DIR ? '' : ' --root-user-action=ignore'
|
||||
} -y -- ${shellQuote(name)}`;
|
||||
} -y -- ${shellQuote(packageName)}`;
|
||||
}
|
||||
const baseCommands = {
|
||||
[DependenceTypes.nodejs]: 'pnpm remove -g',
|
||||
@@ -917,7 +944,7 @@ async function _updateLinuxMirror(
|
||||
throw new Error('Unsupported OS type for updating mirrors.');
|
||||
const target = mirror || defaults[osType];
|
||||
normalizeMirror(target);
|
||||
const S = isInContainer() ? 'sudo ' : '';
|
||||
const S = osType !== 'Alpine' && isInContainer() ? 'sudo ' : '';
|
||||
const update =
|
||||
osType === 'Alpine' ? 'apk update' : `${S}apt-get update --error-on=any`;
|
||||
let file =
|
||||
|
||||
@@ -28,8 +28,12 @@ export enum AuthDataType {
|
||||
'removeLogFrequency' = 'removeLogFrequency',
|
||||
'systemConfig' = 'systemConfig',
|
||||
'authConfig' = 'authConfig',
|
||||
'notifyLog' = 'notifyLog',
|
||||
}
|
||||
|
||||
export const NotifyStatus = LoginStatus;
|
||||
export type NotifyStatus = LoginStatus;
|
||||
|
||||
export interface SystemConfigInfo {
|
||||
lang?: string;
|
||||
panelTitle?: string;
|
||||
@@ -53,6 +57,17 @@ export interface LoginLogInfo {
|
||||
status?: LoginStatus;
|
||||
}
|
||||
|
||||
export interface NotifyLogInfo {
|
||||
timestamp?: number;
|
||||
title?: string;
|
||||
content?: string;
|
||||
status?: NotifyStatus;
|
||||
notifyType?: string;
|
||||
source?: 'system' | 'script';
|
||||
scriptName?: string;
|
||||
taskId?: string;
|
||||
}
|
||||
|
||||
export interface TokenInfo {
|
||||
value: string;
|
||||
timestamp: number;
|
||||
@@ -88,6 +103,7 @@ export interface AuthInfo {
|
||||
export type SystemModelInfo = SystemConfigInfo &
|
||||
Partial<NotificationInfo> &
|
||||
LoginLogInfo &
|
||||
Partial<NotifyLogInfo> &
|
||||
Partial<AuthInfo>;
|
||||
|
||||
export interface SystemInstance
|
||||
|
||||
@@ -6,7 +6,7 @@ import config from '../config';
|
||||
import { UnauthorizedError, expressjwt } from 'express-jwt';
|
||||
import { getPlatform, getToken } from '../config/util';
|
||||
import rewrite from 'express-urlrewrite';
|
||||
import { errors } from 'celebrate';
|
||||
import validationErrors from '../middlewares/validationErrors';
|
||||
import { serveEnv } from '../config/serverEnv';
|
||||
import { shareStore } from '../shared/store';
|
||||
import { isValidToken, isDefaultAuthInfo } from '../shared/auth';
|
||||
@@ -197,7 +197,7 @@ export default ({ app }: { app: Application }) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.use(errors());
|
||||
app.use(validationErrors);
|
||||
app.use(uniqueConstraintError);
|
||||
|
||||
app.use(
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type express from 'express';
|
||||
import config from '../config';
|
||||
|
||||
export async function createHttpApplication(): Promise<express.Application> {
|
||||
// Only the HTTP worker needs an Express application and its middleware.
|
||||
const [
|
||||
{ default: createExpress },
|
||||
{ default: helmet },
|
||||
{ default: cors },
|
||||
{ default: compression },
|
||||
{ monitoringMiddleware },
|
||||
] = await Promise.all([
|
||||
import('express'),
|
||||
import('helmet'),
|
||||
import('cors'),
|
||||
import('compression'),
|
||||
import('../middlewares/monitoring'),
|
||||
]);
|
||||
const app = createExpress();
|
||||
app.use((req, res, next) => {
|
||||
if (req.query.t) delete req.query.t;
|
||||
next();
|
||||
});
|
||||
app.use(
|
||||
helmet({
|
||||
contentSecurityPolicy: false,
|
||||
}),
|
||||
);
|
||||
app.use(cors(config.cors));
|
||||
app.use(compression());
|
||||
app.use(monitoringMiddleware);
|
||||
return app;
|
||||
}
|
||||
@@ -63,7 +63,7 @@ export default async () => {
|
||||
}
|
||||
|
||||
systemService.updateTimezone(data.info);
|
||||
|
||||
|
||||
// Apply global SSH key if configured
|
||||
if (data.info.globalSshKey) {
|
||||
await sshKeyService.addGlobalSSHKey(data.info.globalSshKey, 'global');
|
||||
@@ -78,7 +78,7 @@ export default async () => {
|
||||
assertSubscriptionAlias(doc.alias);
|
||||
await subscriptionService.handleTask(doc, !sub.is_disabled);
|
||||
} catch (error) {
|
||||
Logger.warn('跳过订阅 %s 的无效定时配置: %s', sub.id, error);
|
||||
Logger.warn(t('跳过订阅 %s 的无效定时配置: %s'), sub.id, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { errors, isCelebrateError } from 'celebrate';
|
||||
import { ErrorRequestHandler } from 'express';
|
||||
import { t } from '../shared/i18n';
|
||||
|
||||
const respond = errors();
|
||||
|
||||
// Schemas are compiled once at startup; resolve their messages per request so
|
||||
// changing the system language also changes errors from those existing schemas.
|
||||
const validationErrors: ErrorRequestHandler = (error, req, res, next) => {
|
||||
if (isCelebrateError(error)) {
|
||||
for (const detail of error.details.values()) {
|
||||
detail.message = detail.details.map((item) => t(item.message)).join('. ');
|
||||
}
|
||||
}
|
||||
return respond(error, req, res, next);
|
||||
};
|
||||
|
||||
export default validationErrors;
|
||||
@@ -260,6 +260,15 @@ message SystemNotifyRequest {
|
||||
optional NotificationInfo notificationInfo = 3;
|
||||
}
|
||||
|
||||
message RecordNotifyLogRequest {
|
||||
string title = 1;
|
||||
string content = 2;
|
||||
bool success = 3;
|
||||
string notifyType = 4;
|
||||
string scriptName = 5;
|
||||
string taskId = 6;
|
||||
}
|
||||
|
||||
service Api {
|
||||
rpc GetEnvs(GetEnvsRequest) returns (EnvsResponse) {}
|
||||
rpc CreateEnv(CreateEnvRequest) returns (EnvsResponse) {}
|
||||
@@ -271,6 +280,7 @@ service Api {
|
||||
rpc UpdateEnvNames(UpdateEnvNamesRequest) returns (Response) {}
|
||||
rpc GetEnvById(GetEnvByIdRequest) returns (EnvResponse) {}
|
||||
rpc SystemNotify(SystemNotifyRequest) returns (Response) {}
|
||||
rpc RecordNotifyLog(RecordNotifyLogRequest) returns (Response) {}
|
||||
rpc GetCronDetail(CronDetailRequest) returns (CronDetailResponse) {}
|
||||
rpc CreateCron(CreateCronRequest) returns (CronResponse) {}
|
||||
rpc UpdateCron(UpdateCronRequest) returns (CronResponse) {}
|
||||
|
||||
+175
-1
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-ts_proto v2.6.1
|
||||
// protoc v3.17.3
|
||||
// protoc v6.31.1
|
||||
// source: back/protos/api.proto
|
||||
|
||||
/* eslint-disable */
|
||||
@@ -417,6 +417,15 @@ export interface SystemNotifyRequest {
|
||||
notificationInfo?: NotificationInfo | undefined;
|
||||
}
|
||||
|
||||
export interface RecordNotifyLogRequest {
|
||||
title: string;
|
||||
content: string;
|
||||
success: boolean;
|
||||
notifyType: string;
|
||||
scriptName: string;
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
function createBaseEnvItem(): EnvItem {
|
||||
return {
|
||||
id: undefined,
|
||||
@@ -4296,6 +4305,146 @@ export const SystemNotifyRequest: MessageFns<SystemNotifyRequest> = {
|
||||
},
|
||||
};
|
||||
|
||||
function createBaseRecordNotifyLogRequest(): RecordNotifyLogRequest {
|
||||
return { title: "", content: "", success: false, notifyType: "", scriptName: "", taskId: "" };
|
||||
}
|
||||
|
||||
export const RecordNotifyLogRequest: MessageFns<RecordNotifyLogRequest> = {
|
||||
encode(message: RecordNotifyLogRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||
if (message.title !== "") {
|
||||
writer.uint32(10).string(message.title);
|
||||
}
|
||||
if (message.content !== "") {
|
||||
writer.uint32(18).string(message.content);
|
||||
}
|
||||
if (message.success !== false) {
|
||||
writer.uint32(24).bool(message.success);
|
||||
}
|
||||
if (message.notifyType !== "") {
|
||||
writer.uint32(34).string(message.notifyType);
|
||||
}
|
||||
if (message.scriptName !== "") {
|
||||
writer.uint32(42).string(message.scriptName);
|
||||
}
|
||||
if (message.taskId !== "") {
|
||||
writer.uint32(50).string(message.taskId);
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): RecordNotifyLogRequest {
|
||||
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||
let end = length === undefined ? reader.len : reader.pos + length;
|
||||
const message = createBaseRecordNotifyLogRequest();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
case 1: {
|
||||
if (tag !== 10) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.title = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 2: {
|
||||
if (tag !== 18) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.content = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 3: {
|
||||
if (tag !== 24) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.success = reader.bool();
|
||||
continue;
|
||||
}
|
||||
case 4: {
|
||||
if (tag !== 34) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.notifyType = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 5: {
|
||||
if (tag !== 42) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.scriptName = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 6: {
|
||||
if (tag !== 50) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.taskId = reader.string();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
}
|
||||
reader.skip(tag & 7);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
|
||||
fromJSON(object: any): RecordNotifyLogRequest {
|
||||
return {
|
||||
title: isSet(object.title) ? globalThis.String(object.title) : "",
|
||||
content: isSet(object.content) ? globalThis.String(object.content) : "",
|
||||
success: isSet(object.success) ? globalThis.Boolean(object.success) : false,
|
||||
notifyType: isSet(object.notifyType) ? globalThis.String(object.notifyType) : "",
|
||||
scriptName: isSet(object.scriptName) ? globalThis.String(object.scriptName) : "",
|
||||
taskId: isSet(object.taskId) ? globalThis.String(object.taskId) : "",
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: RecordNotifyLogRequest): unknown {
|
||||
const obj: any = {};
|
||||
if (message.title !== "") {
|
||||
obj.title = message.title;
|
||||
}
|
||||
if (message.content !== "") {
|
||||
obj.content = message.content;
|
||||
}
|
||||
if (message.success !== false) {
|
||||
obj.success = message.success;
|
||||
}
|
||||
if (message.notifyType !== "") {
|
||||
obj.notifyType = message.notifyType;
|
||||
}
|
||||
if (message.scriptName !== "") {
|
||||
obj.scriptName = message.scriptName;
|
||||
}
|
||||
if (message.taskId !== "") {
|
||||
obj.taskId = message.taskId;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<RecordNotifyLogRequest>, I>>(base?: I): RecordNotifyLogRequest {
|
||||
return RecordNotifyLogRequest.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
fromPartial<I extends Exact<DeepPartial<RecordNotifyLogRequest>, I>>(object: I): RecordNotifyLogRequest {
|
||||
const message = createBaseRecordNotifyLogRequest();
|
||||
message.title = object.title ?? "";
|
||||
message.content = object.content ?? "";
|
||||
message.success = object.success ?? false;
|
||||
message.notifyType = object.notifyType ?? "";
|
||||
message.scriptName = object.scriptName ?? "";
|
||||
message.taskId = object.taskId ?? "";
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
export type ApiService = typeof ApiService;
|
||||
export const ApiService = {
|
||||
getEnvs: {
|
||||
@@ -4388,6 +4537,15 @@ export const ApiService = {
|
||||
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
|
||||
responseDeserialize: (value: Buffer) => Response.decode(value),
|
||||
},
|
||||
recordNotifyLog: {
|
||||
path: "/com.ql.api.Api/RecordNotifyLog",
|
||||
requestStream: false,
|
||||
responseStream: false,
|
||||
requestSerialize: (value: RecordNotifyLogRequest) => Buffer.from(RecordNotifyLogRequest.encode(value).finish()),
|
||||
requestDeserialize: (value: Buffer) => RecordNotifyLogRequest.decode(value),
|
||||
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
|
||||
responseDeserialize: (value: Buffer) => Response.decode(value),
|
||||
},
|
||||
getCronDetail: {
|
||||
path: "/com.ql.api.Api/GetCronDetail",
|
||||
requestStream: false,
|
||||
@@ -4482,6 +4640,7 @@ export interface ApiServer extends UntypedServiceImplementation {
|
||||
updateEnvNames: handleUnaryCall<UpdateEnvNamesRequest, Response>;
|
||||
getEnvById: handleUnaryCall<GetEnvByIdRequest, EnvResponse>;
|
||||
systemNotify: handleUnaryCall<SystemNotifyRequest, Response>;
|
||||
recordNotifyLog: handleUnaryCall<RecordNotifyLogRequest, Response>;
|
||||
getCronDetail: handleUnaryCall<CronDetailRequest, CronDetailResponse>;
|
||||
createCron: handleUnaryCall<CreateCronRequest, CronResponse>;
|
||||
updateCron: handleUnaryCall<UpdateCronRequest, CronResponse>;
|
||||
@@ -4644,6 +4803,21 @@ export interface ApiClient extends Client {
|
||||
options: Partial<CallOptions>,
|
||||
callback: (error: ServiceError | null, response: Response) => void,
|
||||
): ClientUnaryCall;
|
||||
recordNotifyLog(
|
||||
request: RecordNotifyLogRequest,
|
||||
callback: (error: ServiceError | null, response: Response) => void,
|
||||
): ClientUnaryCall;
|
||||
recordNotifyLog(
|
||||
request: RecordNotifyLogRequest,
|
||||
metadata: Metadata,
|
||||
callback: (error: ServiceError | null, response: Response) => void,
|
||||
): ClientUnaryCall;
|
||||
recordNotifyLog(
|
||||
request: RecordNotifyLogRequest,
|
||||
metadata: Metadata,
|
||||
options: Partial<CallOptions>,
|
||||
callback: (error: ServiceError | null, response: Response) => void,
|
||||
): ClientUnaryCall;
|
||||
getCronDetail(
|
||||
request: CronDetailRequest,
|
||||
callback: (error: ServiceError | null, response: CronDetailResponse) => void,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { isValidCronSchedule } from '../shared/cronSchedule';
|
||||
import { scheduleStacks } from './data';
|
||||
import { runCron } from '../shared/runCron';
|
||||
import Logger from '../loaders/logger';
|
||||
import { tf } from '../shared/i18n';
|
||||
import { t, tf } from '../shared/i18n';
|
||||
|
||||
// Validate the entire batch before replacing any existing jobs.
|
||||
const isValidCronField = (cron: string): boolean => {
|
||||
@@ -67,7 +67,7 @@ const addCron = (
|
||||
createCronJob(
|
||||
schedule,
|
||||
async () => {
|
||||
Logger.info('[schedule][准备运行任务] 命令: %s', item.command);
|
||||
Logger.info(t('[schedule][准备运行任务] 命令: %s'), item.command);
|
||||
await runCron(
|
||||
item.command,
|
||||
item,
|
||||
@@ -104,7 +104,7 @@ const addCron = (
|
||||
scheduleStacks.set(id, jobs);
|
||||
jobs.forEach((job) => job.start());
|
||||
Logger.info(
|
||||
'[schedule][创建定时任务] 任务ID: %s, 规则数: %s',
|
||||
t('[schedule][创建定时任务] 任务ID: %s, 规则数: %s'),
|
||||
id,
|
||||
jobs.length,
|
||||
);
|
||||
|
||||
+25
-2
@@ -16,6 +16,7 @@ import {
|
||||
MoveEnvRequest,
|
||||
Response,
|
||||
SystemNotifyRequest,
|
||||
RecordNotifyLogRequest,
|
||||
UpdateEnvNamesRequest,
|
||||
UpdateEnvRequest,
|
||||
} from '../protos/api';
|
||||
@@ -238,7 +239,8 @@ export const systemNotify = async (
|
||||
const data = await systemService.notify({
|
||||
title: call.request.title,
|
||||
content: call.request.content,
|
||||
notificationInfo: call.request.notificationInfo as unknown as NotificationInfo,
|
||||
notificationInfo: call.request
|
||||
.notificationInfo as unknown as NotificationInfo,
|
||||
});
|
||||
callback(null, data);
|
||||
} catch (e: any) {
|
||||
@@ -246,10 +248,31 @@ export const systemNotify = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const recordNotifyLog = async (
|
||||
call: ServerUnaryCall<RecordNotifyLogRequest, Response>,
|
||||
callback: sendUnaryData<Response>,
|
||||
) => {
|
||||
try {
|
||||
await Container.get(SystemService).recordNotifyLog({
|
||||
title: call.request.title,
|
||||
content: call.request.content,
|
||||
status: call.request.success ? 0 : 1,
|
||||
notifyType: call.request.notifyType,
|
||||
source: 'script',
|
||||
scriptName: call.request.scriptName,
|
||||
taskId: call.request.taskId,
|
||||
});
|
||||
callback(null, { code: 200 });
|
||||
} catch (error: any) {
|
||||
callback(error);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeCronData = (data: CronItem | null): CronItem | undefined => {
|
||||
if (!data) return undefined;
|
||||
// create() returns a Sequelize instance; spreading it omits attribute getters.
|
||||
const cron = data instanceof Model ? (data.get({ plain: true }) as CronItem) : data;
|
||||
const cron =
|
||||
data instanceof Model ? (data.get({ plain: true }) as CronItem) : data;
|
||||
return {
|
||||
...cron,
|
||||
labels: cron.labels ?? [],
|
||||
|
||||
+16
-15
@@ -1,3 +1,4 @@
|
||||
import { t } from '../shared/i18n';
|
||||
import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js';
|
||||
import { DeleteCronRequest, DeleteCronResponse } from '../protos/cron';
|
||||
import { scheduleStacks } from './data';
|
||||
@@ -9,24 +10,24 @@ const delCron = (
|
||||
) => {
|
||||
for (const id of call.request.ids) {
|
||||
if (scheduleStacks.has(id)) {
|
||||
Logger.info(
|
||||
'[schedule][取消定时任务] 任务ID: %s',
|
||||
id,
|
||||
);
|
||||
Logger.info(t('[schedule][取消定时任务] 任务ID: %s'), id);
|
||||
// 防御性过滤历史调度栈中的空任务,
|
||||
// 否则对 null 调 cancel() 会让整个取消流程抛出 UNKNOWN 错误,
|
||||
// 进而导致 HTTP 端的 remove() 跳过 setCrontab(),造成 crontab.list 残留。
|
||||
scheduleStacks.get(id)?.filter((x) => x != null).forEach((x) => {
|
||||
try {
|
||||
x.cancel();
|
||||
} catch (error: any) {
|
||||
Logger.warn(
|
||||
'[schedule][取消任务失败] 任务ID: %s, 错误: %s',
|
||||
id,
|
||||
error?.message || error,
|
||||
);
|
||||
}
|
||||
});
|
||||
scheduleStacks
|
||||
.get(id)
|
||||
?.filter((x) => x != null)
|
||||
.forEach((x) => {
|
||||
try {
|
||||
x.cancel();
|
||||
} catch (error: any) {
|
||||
Logger.warn(
|
||||
t('[schedule][取消任务失败] 任务ID: %s, 错误: %s'),
|
||||
id,
|
||||
error?.message || error,
|
||||
);
|
||||
}
|
||||
});
|
||||
scheduleStacks.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -641,7 +641,7 @@ export default class CronService {
|
||||
stopped.add(pid);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'[panel][停止任务失败] PID: %s, 错误: %s',
|
||||
t('[panel][停止任务失败] PID: %s, 错误: %s'),
|
||||
pid,
|
||||
asError(error).message
|
||||
);
|
||||
@@ -695,7 +695,7 @@ export default class CronService {
|
||||
await killTask(instance.pid);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[panel][停止实例失败] 实例ID: ${instanceId}, PID: ${instance.pid}, 错误: ${error}`,
|
||||
t("[panel][停止实例失败] 实例ID: %s, PID: %s, 错误: %s"), instanceId, instance.pid, error,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -791,20 +791,20 @@ export default class CronService {
|
||||
const result = await completed;
|
||||
if (result.error) {
|
||||
this.logger.error(
|
||||
'[panel][执行任务失败] 任务ID: %s, 错误: %s',
|
||||
t('[panel][执行任务失败] 任务ID: %s, 错误: %s'),
|
||||
id,
|
||||
result.error.message
|
||||
);
|
||||
}
|
||||
this.logger.info(
|
||||
'[panel][执行任务结束] 任务ID: %s, 退出码: %j',
|
||||
t('[panel][执行任务结束] 任务ID: %s, 退出码: %j'),
|
||||
id,
|
||||
result.code
|
||||
);
|
||||
return { ...cron, pid: cp.pid, ...result } as any;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'[panel][创建任务失败] 任务ID: %s, 错误: %s',
|
||||
t('[panel][创建任务失败] 任务ID: %s, 错误: %s'),
|
||||
cronId,
|
||||
asError(error).message
|
||||
);
|
||||
@@ -813,7 +813,7 @@ export default class CronService {
|
||||
if (absolutePath) await logStreamManager.closeStream(absolutePath);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'[panel][关闭任务日志失败] %s',
|
||||
t('[panel][关闭任务日志失败] %s'),
|
||||
asError(error).message
|
||||
);
|
||||
}
|
||||
@@ -845,7 +845,7 @@ export default class CronService {
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'[panel][清理任务状态失败] %s',
|
||||
t('[panel][清理任务状态失败] %s'),
|
||||
asError(error).message
|
||||
);
|
||||
}
|
||||
|
||||
+102
-22
@@ -20,15 +20,27 @@ import {
|
||||
getInstallCommand,
|
||||
getUninstallCommand,
|
||||
getGetCommand,
|
||||
isPythonSourceDependency,
|
||||
} from '../config/util';
|
||||
import dayjs from 'dayjs';
|
||||
import taskLimit from '../shared/pLimit';
|
||||
import { detectOS } from '../config/util';
|
||||
import { LINUX_DEPENDENCE_COMMAND } from '../config/const';
|
||||
import { t, tf } from '../shared/i18n';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
type RefreshType = 'nodejs' | 'python3';
|
||||
type RefreshJob = {
|
||||
id: string;
|
||||
type: RefreshType;
|
||||
state: 'running' | 'completed' | 'failed';
|
||||
};
|
||||
|
||||
@Service()
|
||||
export default class DependenceService {
|
||||
private activeRefreshJob?: RefreshJob;
|
||||
private refreshJobs = new Map<string, RefreshJob>();
|
||||
|
||||
constructor(
|
||||
@Inject('logger') private logger: winston.Logger,
|
||||
private sockService: SockService,
|
||||
@@ -76,21 +88,25 @@ export default class DependenceService {
|
||||
for (const doc of docs) {
|
||||
taskLimit.removeQueuedDependency(doc);
|
||||
}
|
||||
const unInstalledDeps = docs.filter(
|
||||
(x) => x.status !== DependenceStatus.installed,
|
||||
);
|
||||
const installedDeps = docs.filter(
|
||||
(x) => x.status === DependenceStatus.installed,
|
||||
(x) =>
|
||||
x.status === DependenceStatus.installed ||
|
||||
// Older list refreshes marked unprobeable sources as failed. They may
|
||||
// still have installed distributions (including partial pip installs).
|
||||
(x.status === DependenceStatus.installFailed &&
|
||||
x.type === DependenceTypes.python3 &&
|
||||
isPythonSourceDependency(x.name)),
|
||||
);
|
||||
const unInstalledDeps = docs.filter((x) => !installedDeps.includes(x));
|
||||
await this.removeDb(unInstalledDeps.map((x) => x.id!));
|
||||
|
||||
if (installedDeps.length) {
|
||||
await DependenceModel.update(
|
||||
{ status: DependenceStatus.queued, log: [] },
|
||||
{ where: { id: ids } },
|
||||
{ where: { id: installedDeps.map((x) => x.id!) } },
|
||||
);
|
||||
|
||||
this.installDependenceOneByOne(docs, false, force);
|
||||
this.installDependenceOneByOne(installedDeps, false, force);
|
||||
}
|
||||
return docs;
|
||||
}
|
||||
@@ -113,11 +129,6 @@ export default class DependenceService {
|
||||
query: any = {},
|
||||
): Promise<Dependence[]> {
|
||||
let condition = query;
|
||||
const dependenceType =
|
||||
type && DependenceTypes[type] !== undefined
|
||||
? DependenceTypes[type]
|
||||
: undefined;
|
||||
await this.refreshInstalledStatuses(dependenceType);
|
||||
if (type && DependenceTypes[type] !== undefined) {
|
||||
condition.type = DependenceTypes[type];
|
||||
}
|
||||
@@ -140,6 +151,52 @@ export default class DependenceService {
|
||||
}
|
||||
}
|
||||
|
||||
public startRefresh(type: RefreshType): {
|
||||
job: RefreshJob;
|
||||
accepted: boolean;
|
||||
} {
|
||||
if (this.activeRefreshJob) {
|
||||
return {
|
||||
job: this.activeRefreshJob,
|
||||
accepted: this.activeRefreshJob.type === type,
|
||||
};
|
||||
}
|
||||
|
||||
const job: RefreshJob = { id: randomUUID(), type, state: 'running' };
|
||||
this.activeRefreshJob = job;
|
||||
this.refreshJobs.set(job.id, job);
|
||||
while (this.refreshJobs.size > 20) {
|
||||
const oldestId = this.refreshJobs.keys().next().value;
|
||||
this.refreshJobs.delete(oldestId);
|
||||
}
|
||||
|
||||
void this.refreshInstalledStatuses(DependenceTypes[type])
|
||||
.then(() => {
|
||||
job.state = 'completed';
|
||||
})
|
||||
.catch((error) => {
|
||||
this.logger.error('Dependency refresh failed: %o', error);
|
||||
job.state = 'failed';
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.activeRefreshJob === job) {
|
||||
this.activeRefreshJob = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
return { job, accepted: true };
|
||||
}
|
||||
|
||||
public getRefreshJob(id: string): RefreshJob | undefined {
|
||||
return this.refreshJobs.get(id);
|
||||
}
|
||||
|
||||
public getActiveRefreshJob(): RefreshJob | undefined {
|
||||
return this.activeRefreshJob?.state === 'running'
|
||||
? this.activeRefreshJob
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private async refreshInstalledStatuses(type?: DependenceTypes) {
|
||||
const cacheDependenceTypes = [
|
||||
DependenceTypes.nodejs,
|
||||
@@ -155,20 +212,31 @@ export default class DependenceService {
|
||||
type: type === undefined ? { [Op.in]: cacheDependenceTypes } : type,
|
||||
},
|
||||
});
|
||||
const checkableDocs = docs.filter(
|
||||
(doc) =>
|
||||
doc.type !== DependenceTypes.python3 ||
|
||||
!isPythonSourceDependency(doc.name),
|
||||
);
|
||||
const checks = await concurrentRun(
|
||||
docs.map((doc) => async () => {
|
||||
return (await this.isDependenceInstalled(doc)) ? undefined : doc.id;
|
||||
}),
|
||||
checkableDocs.map((doc) => async () => this.isDependenceInstalled(doc)),
|
||||
5,
|
||||
);
|
||||
const missingIds = (checks || []).filter(
|
||||
(id): id is number => id !== undefined,
|
||||
);
|
||||
|
||||
if (missingIds.length) {
|
||||
for (const [index, installed] of (checks || []).entries()) {
|
||||
if (installed) continue;
|
||||
const doc = checkableDocs[index];
|
||||
await DependenceModel.update(
|
||||
{ status: DependenceStatus.installFailed },
|
||||
{ where: { id: missingIds } },
|
||||
{
|
||||
where: {
|
||||
id: doc.id,
|
||||
status: DependenceStatus.installed,
|
||||
name: doc.name,
|
||||
type: doc.type,
|
||||
updatedAt: (doc as typeof doc & { updatedAt: Date }).updatedAt,
|
||||
// Sequelize's automatic timestamp is absent from Dependence's attribute type.
|
||||
} as any,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -177,7 +245,10 @@ export default class DependenceService {
|
||||
let depName = dependency.name.trim();
|
||||
const depVersionStr = versionDependenceCommandTypes[dependency.type];
|
||||
let depVersion = '';
|
||||
if (depName.includes(depVersionStr)) {
|
||||
if (
|
||||
dependency.type !== DependenceTypes.python3 &&
|
||||
depName.includes(depVersionStr)
|
||||
) {
|
||||
const symbolRegx = new RegExp(
|
||||
`(.*)${depVersionStr}([0-9\\.\\-\\+a-zA-Z]*)`,
|
||||
);
|
||||
@@ -305,6 +376,9 @@ export default class DependenceService {
|
||||
: getUninstallCommand(dependency.type, dependency.name.trim());
|
||||
} catch (error) {
|
||||
taskLimit.removeQueuedDependency(dependency);
|
||||
if (!isInstall && force) {
|
||||
return this.removeDb([dependency.id!]).then(() => null);
|
||||
}
|
||||
return DependenceModel.update(
|
||||
{
|
||||
status: isInstall
|
||||
@@ -387,7 +461,13 @@ export default class DependenceService {
|
||||
this.updateLog(depIds, message);
|
||||
|
||||
// 判断是否已经安装过依赖
|
||||
if (isInstall && !force) {
|
||||
// pip must resolve optional dependencies even if the base distribution
|
||||
// is already present; its version alone cannot prove extras are ready.
|
||||
if (
|
||||
isInstall &&
|
||||
!force &&
|
||||
!(isPythonDependence && depName.includes('['))
|
||||
) {
|
||||
let getCommand = getGetCommand(dependency.type, depName);
|
||||
const depVersionStr = versionDependenceCommandTypes[dependency.type];
|
||||
if (isLinuxDependence) {
|
||||
@@ -395,7 +475,7 @@ export default class DependenceService {
|
||||
}
|
||||
|
||||
let depVersion = '';
|
||||
if (depName.includes(depVersionStr)) {
|
||||
if (!isPythonDependence && depName.includes(depVersionStr)) {
|
||||
const symbolRegx = new RegExp(
|
||||
`(.*)${depVersionStr}([0-9\\.\\-\\+a-zA-Z]*)`,
|
||||
);
|
||||
|
||||
@@ -50,13 +50,13 @@ export class GrpcServerService {
|
||||
for (const host of hostsToTry) {
|
||||
try {
|
||||
const address = this.formatGrpcAddress(host, grpcPort);
|
||||
await bindAsync(address, credentials);
|
||||
const boundPort = await bindAsync(address, credentials);
|
||||
Logger.debug(`[boot] gRPC service started successfully on ${address}`);
|
||||
metricsService.record('grpc_service_start', 1, {
|
||||
port: grpcPort.toString(),
|
||||
host
|
||||
});
|
||||
return grpcPort;
|
||||
return boundPort;
|
||||
} catch (err) {
|
||||
lastError = err as Error;
|
||||
Logger.warn(`Failed to bind gRPC on ${host}:${grpcPort}, trying next...`, err);
|
||||
|
||||
@@ -2,8 +2,10 @@ import { Service } from 'typedi';
|
||||
import Logger from '../loaders/logger';
|
||||
import cronClient from '../schedule/client';
|
||||
import { HttpServerService } from './http';
|
||||
import { runtimeBuild } from '../shared/runtimeBuild';
|
||||
|
||||
interface HealthStatus {
|
||||
build: typeof runtimeBuild;
|
||||
status: 'ok' | 'error';
|
||||
services: {
|
||||
http: boolean;
|
||||
@@ -22,12 +24,11 @@ interface HealthStatus {
|
||||
export class HealthService {
|
||||
private startTime = Date.now();
|
||||
|
||||
constructor(
|
||||
private httpServerService: HttpServerService,
|
||||
) {}
|
||||
constructor(private httpServerService: HttpServerService) {}
|
||||
|
||||
async check(): Promise<HealthStatus> {
|
||||
const status: HealthStatus = {
|
||||
build: runtimeBuild,
|
||||
status: 'ok',
|
||||
services: {
|
||||
http: true,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from '../shared/i18n';
|
||||
import { performance } from 'perf_hooks';
|
||||
import Logger from '../loaders/logger';
|
||||
|
||||
@@ -126,7 +127,7 @@ export class MetricsService {
|
||||
timestamp: Date.now(),
|
||||
metrics: this.getMetrics(),
|
||||
};
|
||||
Logger.info('性能指标报告:', report);
|
||||
Logger.info(t('性能指标报告:'), report);
|
||||
return report;
|
||||
}
|
||||
}
|
||||
|
||||
+41
-25
@@ -1,3 +1,4 @@
|
||||
import { persistNotificationAudit } from '../shared/notificationAudit';
|
||||
import crypto from 'crypto';
|
||||
import nodemailer from 'nodemailer';
|
||||
import { Inject, Service } from 'typedi';
|
||||
@@ -55,27 +56,33 @@ export default class NotificationService {
|
||||
content: string,
|
||||
notificationInfo?: NotificationInfo,
|
||||
): Promise<boolean | undefined> {
|
||||
let { type, ...rest } = await this.userService.getNotificationMode();
|
||||
if (notificationInfo?.type) {
|
||||
type = notificationInfo?.type;
|
||||
}
|
||||
if (type) {
|
||||
this.title = title;
|
||||
this.content = content;
|
||||
let params = rest;
|
||||
if (notificationInfo) {
|
||||
const { type: _, ...others } = notificationInfo;
|
||||
params = { ...rest, ...others };
|
||||
}
|
||||
this.params = params;
|
||||
const notificationModeAction = this.modeMap.get(type);
|
||||
try {
|
||||
return await notificationModeAction?.call(this);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
let type: string | undefined;
|
||||
let success = false;
|
||||
try {
|
||||
const saved = await this.userService.getNotificationMode();
|
||||
type = notificationInfo?.type || saved.type;
|
||||
if (type) {
|
||||
this.title = title;
|
||||
this.content = content;
|
||||
const { type: _, ...overrides } = notificationInfo || {};
|
||||
const { type: _saved, ...rest } = saved;
|
||||
this.params = { ...rest, ...overrides };
|
||||
try {
|
||||
success = !!(await this.modeMap.get(type)?.call(this));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
return success;
|
||||
} finally {
|
||||
await persistNotificationAudit({
|
||||
title,
|
||||
content,
|
||||
notifyType: type,
|
||||
source: 'system',
|
||||
status: success ? 0 : 1,
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public async testNotify(
|
||||
@@ -84,14 +91,23 @@ export default class NotificationService {
|
||||
content: string,
|
||||
) {
|
||||
const { type, ...rest } = info;
|
||||
if (type) {
|
||||
if (!type) return true;
|
||||
let success = false;
|
||||
try {
|
||||
this.title = title;
|
||||
this.content = content;
|
||||
this.params = rest;
|
||||
const notificationModeAction = this.modeMap.get(type);
|
||||
return await notificationModeAction?.call(this);
|
||||
success = !!(await this.modeMap.get(type)?.call(this));
|
||||
return success;
|
||||
} finally {
|
||||
await persistNotificationAudit({
|
||||
title,
|
||||
content,
|
||||
notifyType: type,
|
||||
source: 'system',
|
||||
status: success ? 0 : 1,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private parseMailRecipients(value?: string) {
|
||||
@@ -921,7 +937,8 @@ export default class NotificationService {
|
||||
private async openiLink() {
|
||||
const { openiLinkAppToken, openiLinkHubUrl, openiLinkContextToken } =
|
||||
this.params;
|
||||
const baseUrl = openiLinkHubUrl?.replace(/\/$/, '') || 'https://hub.openilink.com';
|
||||
const baseUrl =
|
||||
openiLinkHubUrl?.replace(/\/$/, '') || 'https://hub.openilink.com';
|
||||
const url = `${baseUrl}/bot/v1/message/send`;
|
||||
const body: Record<string, string> = {
|
||||
type: 'text',
|
||||
@@ -974,5 +991,4 @@ export default class NotificationService {
|
||||
throw new Error(error.response ? error.response.body : error);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+13
-12
@@ -1,3 +1,4 @@
|
||||
import { t } from '../shared/i18n';
|
||||
import { Service, Inject } from 'typedi';
|
||||
import winston from 'winston';
|
||||
import { createCronJob, CronJob } from '../shared/cronScheduler';
|
||||
@@ -83,7 +84,7 @@ export default class ScheduleService {
|
||||
let cp: ChildProcessWithoutNullStreams | undefined;
|
||||
let result: ProcessResult = { code: null, signal: null };
|
||||
try {
|
||||
this.logger.info('[panel][开始执行任务] 任务ID: %s', others.id);
|
||||
this.logger.info(t('[panel][开始执行任务] 任务ID: %s'), others.id);
|
||||
await callbacks.onBefore?.(startTime);
|
||||
cp = spawn(command, { shell: '/bin/bash' });
|
||||
const child = cp;
|
||||
@@ -103,7 +104,7 @@ export default class ScheduleService {
|
||||
|
||||
if (result.error) {
|
||||
this.logger.error(
|
||||
'[panel][执行任务失败] 任务ID: %s, 错误: %s',
|
||||
t('[panel][执行任务失败] 任务ID: %s, 错误: %s'),
|
||||
others.id,
|
||||
result.error.message,
|
||||
);
|
||||
@@ -111,7 +112,7 @@ export default class ScheduleService {
|
||||
await callbacks.onError?.(result.error.message);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'[panel][任务错误回调失败] %s',
|
||||
t('[panel][任务错误回调失败] %s'),
|
||||
asError(error).message,
|
||||
);
|
||||
}
|
||||
@@ -127,12 +128,12 @@ export default class ScheduleService {
|
||||
} catch (error) {
|
||||
result.error ??= asError(error);
|
||||
this.logger.error(
|
||||
'[panel][任务结束回调失败] %s',
|
||||
t('[panel][任务结束回调失败] %s'),
|
||||
asError(error).message,
|
||||
);
|
||||
}
|
||||
this.logger.info(
|
||||
'[panel][执行任务结束] 任务ID: %s, 退出码: %j',
|
||||
t('[panel][执行任务结束] 任务ID: %s, 退出码: %j'),
|
||||
others.id,
|
||||
result.code,
|
||||
);
|
||||
@@ -141,7 +142,7 @@ export default class ScheduleService {
|
||||
).catch((error) => {
|
||||
// Queue/setup failures must not become unhandled rejections in detached callers.
|
||||
rejectStart(asError(error));
|
||||
this.logger.error('[panel][任务队列失败] %s', asError(error).message);
|
||||
this.logger.error(t('[panel][任务队列失败] %s'), asError(error).message);
|
||||
return { ...others, code: null, signal: null, error: asError(error) };
|
||||
});
|
||||
|
||||
@@ -156,7 +157,7 @@ export default class ScheduleService {
|
||||
) {
|
||||
const _id = this.formatId(id);
|
||||
this.logger.info(
|
||||
'[panel][创建cron任务] 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s',
|
||||
t('[panel][创建cron任务] 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s'),
|
||||
_id,
|
||||
schedule,
|
||||
name,
|
||||
@@ -183,7 +184,7 @@ export default class ScheduleService {
|
||||
} catch (error) {
|
||||
// A persisted invalid subscription must not become an unhandled startup rejection.
|
||||
this.logger.warn(
|
||||
'[panel][跳过无效定时任务] 任务ID: %s, cron: %s, 错误: %s',
|
||||
t('[panel][跳过无效定时任务] 任务ID: %s, cron: %s, 错误: %s'),
|
||||
_id,
|
||||
schedule,
|
||||
asError(error).message,
|
||||
@@ -204,7 +205,7 @@ export default class ScheduleService {
|
||||
|
||||
async cancelCronTask({ id = 0, name }: ScheduleTaskType) {
|
||||
const _id = this.formatId(id);
|
||||
this.logger.info('[panel][取消定时任务] 任务名: %s', name);
|
||||
this.logger.info(t('[panel][取消定时任务] 任务名: %s'), name);
|
||||
if (this.scheduleStacks.has(_id)) {
|
||||
this.scheduleStacks.get(_id)?.cancel();
|
||||
this.scheduleStacks.delete(_id);
|
||||
@@ -219,7 +220,7 @@ export default class ScheduleService {
|
||||
) {
|
||||
const _id = this.formatId(id);
|
||||
this.logger.info(
|
||||
'[panel][创建interval任务] 任务ID: %s, 任务名: %s, 执行命令: %s',
|
||||
t('[panel][创建interval任务] 任务ID: %s, 任务名: %s, 执行命令: %s'),
|
||||
_id,
|
||||
name,
|
||||
command,
|
||||
@@ -236,7 +237,7 @@ export default class ScheduleService {
|
||||
},
|
||||
(err) => {
|
||||
this.logger.error(
|
||||
'[panel][执行任务失败] 命令: %s, 错误信息: %j',
|
||||
t('[panel][执行任务失败] 命令: %s, 错误信息: %j'),
|
||||
command,
|
||||
err,
|
||||
);
|
||||
@@ -264,7 +265,7 @@ export default class ScheduleService {
|
||||
async cancelIntervalTask({ id = 0, name }: ScheduleTaskType) {
|
||||
const _id = this.formatId(id);
|
||||
this.logger.info(
|
||||
'[panel][取消interval任务] 任务ID: %s, 任务名: %s',
|
||||
t('[panel][取消interval任务] 任务ID: %s, 任务名: %s'),
|
||||
_id,
|
||||
name,
|
||||
);
|
||||
|
||||
+21
-10
@@ -1,3 +1,4 @@
|
||||
import { t } from '../shared/i18n';
|
||||
import { Service, Inject } from 'typedi';
|
||||
import winston from 'winston';
|
||||
import fs from 'fs/promises';
|
||||
@@ -61,10 +62,10 @@ export default class SshKeyService {
|
||||
const filePath = resolveSubscriptionPath(this.sshPath, alias);
|
||||
try {
|
||||
await rmPath(filePath);
|
||||
} catch { }
|
||||
} catch {}
|
||||
await writeFileWithLock(filePath, `${key}${os.EOL}`, { mode: '400' });
|
||||
} catch (error) {
|
||||
this.logger.error('生成私钥文件失败', error);
|
||||
this.logger.error(t('生成私钥文件失败'), error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +74,7 @@ export default class SshKeyService {
|
||||
const filePath = resolveSubscriptionPath(this.sshPath, alias);
|
||||
await rmPath(filePath);
|
||||
} catch (error) {
|
||||
this.logger.error('删除私钥文件失败', error);
|
||||
this.logger.error(t('删除私钥文件失败'), error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,9 +83,13 @@ export default class SshKeyService {
|
||||
host: string,
|
||||
proxy?: string,
|
||||
) {
|
||||
if (!/^[a-zA-Z0-9.-]+$/.test(host) ||
|
||||
(proxy && !/^[a-zA-Z0-9.:[\]-]+$/.test(proxy))) {
|
||||
throw Object.assign(new Error('Invalid SSH host or proxy'), { status: 400 });
|
||||
if (
|
||||
!/^[a-zA-Z0-9.-]+$/.test(host) ||
|
||||
(proxy && !/^[a-zA-Z0-9.:[\]-]+$/.test(proxy))
|
||||
) {
|
||||
throw Object.assign(new Error('Invalid SSH host or proxy'), {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
if (host === 'github.com') {
|
||||
host = `ssh.github.com\n Port 443\n HostkeyAlgorithms +ssh-rsa`;
|
||||
@@ -92,7 +97,10 @@ export default class SshKeyService {
|
||||
const proxyStr = proxy
|
||||
? ` ProxyCommand nc -v -x ${proxy} %h %p 2>/dev/null\n`
|
||||
: '';
|
||||
const config = `Host ${alias}\n Hostname ${host}\n IdentityFile ${resolveSubscriptionPath(this.sshPath, alias)}\n StrictHostKeyChecking no\n${proxyStr}`;
|
||||
const config = `Host ${alias}\n Hostname ${host}\n IdentityFile ${resolveSubscriptionPath(
|
||||
this.sshPath,
|
||||
alias,
|
||||
)}\n StrictHostKeyChecking no\n${proxyStr}`;
|
||||
await writeFileWithLock(
|
||||
`${resolveSubscriptionPath(this.sshPath, alias, '.config')}`,
|
||||
config,
|
||||
@@ -108,7 +116,7 @@ export default class SshKeyService {
|
||||
const filePath = resolveSubscriptionPath(this.sshPath, alias, '.config');
|
||||
await rmPath(filePath);
|
||||
} catch (error) {
|
||||
this.logger.error(`删除ssh配置文件${alias}失败`, error);
|
||||
this.logger.error(t('删除ssh配置文件%s失败'), alias, error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,7 +155,7 @@ export default class SshKeyService {
|
||||
);
|
||||
await this.generateSingleSshConfig(alias, host, doc.proxy);
|
||||
} catch (error) {
|
||||
this.logger.warn('跳过订阅 %s 的无效 SSH 配置: %s', doc.id, error);
|
||||
this.logger.warn(t('跳过订阅 %s 的无效 SSH 配置: %s'), doc.id, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,7 +175,10 @@ export default class SshKeyService {
|
||||
|
||||
private async generateGlobalSshConfig(alias: string) {
|
||||
// Create a config that matches all hosts, making this key globally available
|
||||
const config = `Host *\n IdentityFile ${resolveSubscriptionPath(this.sshPath, alias)}\n StrictHostKeyChecking no\n`;
|
||||
const config = `Host *\n IdentityFile ${resolveSubscriptionPath(
|
||||
this.sshPath,
|
||||
alias,
|
||||
)}\n StrictHostKeyChecking no\n`;
|
||||
await writeFileWithLock(
|
||||
`${resolveSubscriptionPath(this.sshPath, alias, '.config')}`,
|
||||
config,
|
||||
|
||||
+123
-64
@@ -1,10 +1,11 @@
|
||||
import { persistNotificationAudit } from '../shared/notificationAudit';
|
||||
import { spawn } from 'cross-spawn';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { shellQuote } from '../shared/shellQuote';
|
||||
import { Response } from 'express';
|
||||
import fs from 'fs';
|
||||
import { Agent, request } from 'undici';
|
||||
import { request } from 'undici';
|
||||
import path from 'path';
|
||||
import { Inject, Service } from 'typedi';
|
||||
import winston from 'winston';
|
||||
@@ -32,6 +33,8 @@ import {
|
||||
SystemInstance,
|
||||
SystemModel,
|
||||
SystemModelInfo,
|
||||
NotifyStatus,
|
||||
NotifyLogInfo,
|
||||
} from '../data/system';
|
||||
import taskLimit from '../shared/pLimit';
|
||||
import cronClient from '../schedule/client';
|
||||
@@ -53,7 +56,7 @@ export default class SystemService {
|
||||
@Inject('logger') private logger: winston.Logger,
|
||||
private scheduleService: ScheduleService,
|
||||
private sockService: SockService,
|
||||
) { }
|
||||
) {}
|
||||
|
||||
public async getSystemConfig() {
|
||||
const doc = await this.getDb({ type: AuthDataType.systemConfig });
|
||||
@@ -158,7 +161,9 @@ export default class SystemService {
|
||||
if (info.dependenceProxy) {
|
||||
await fs.promises.writeFile(
|
||||
config.dependenceProxyFile,
|
||||
`export http_proxy=${shellQuote(info.dependenceProxy)}\nexport https_proxy=${shellQuote(info.dependenceProxy)}`,
|
||||
`export http_proxy=${shellQuote(
|
||||
info.dependenceProxy,
|
||||
)}\nexport https_proxy=${shellQuote(info.dependenceProxy)}`,
|
||||
);
|
||||
} else {
|
||||
await fs.promises.rm(config.dependenceProxyFile);
|
||||
@@ -277,26 +282,24 @@ export default class SystemService {
|
||||
}
|
||||
|
||||
public async checkUpdate() {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 15000);
|
||||
try {
|
||||
const currentVersionContent = await parseVersion(config.versionFile);
|
||||
|
||||
let lastVersionContent;
|
||||
try {
|
||||
const { body } = await request(
|
||||
`${config.lastVersionFile}?t=${Date.now()}`,
|
||||
{
|
||||
dispatcher: new Agent({
|
||||
keepAliveTimeout: 30000,
|
||||
keepAliveMaxTimeout: 30000,
|
||||
}),
|
||||
},
|
||||
);
|
||||
const text = await body.text();
|
||||
lastVersionContent = parseContentVersion(text);
|
||||
} catch (error) { }
|
||||
|
||||
if (!lastVersionContent) {
|
||||
lastVersionContent = currentVersionContent;
|
||||
const { body, statusCode } = await request(
|
||||
`${config.lastVersionFile}?t=${Date.now()}`,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
const text = await body.text();
|
||||
if (statusCode !== 200) throw new Error('Version request failed');
|
||||
const lastVersionContent = parseContentVersion(text);
|
||||
if (
|
||||
!lastVersionContent ||
|
||||
typeof lastVersionContent.version !== 'string' ||
|
||||
!/^\d+\.\d+\.\d+$/.test(lastVersionContent.version)
|
||||
) {
|
||||
throw new Error('Invalid version response');
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -314,8 +317,10 @@ export default class SystemService {
|
||||
} catch (error: any) {
|
||||
return {
|
||||
code: 400,
|
||||
message: error.message,
|
||||
message: t('检查更新失败,请检查网络后重试'),
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,45 +344,51 @@ export default class SystemService {
|
||||
}
|
||||
|
||||
public async updateSystem() {
|
||||
const cp = spawn('real_time=true ql update false', { shell: '/bin/bash' });
|
||||
return this.startUpgradeJob('download');
|
||||
}
|
||||
|
||||
cp.stdout.on('data', (data) => {
|
||||
this.sockService.sendMessage({
|
||||
type: 'updateSystemVersion',
|
||||
message: data.toString(),
|
||||
});
|
||||
});
|
||||
private upgradeJobs() {
|
||||
return require(path.join(
|
||||
config.rootPath,
|
||||
'cli/src/internal/maintenance/panelUpgrade.cjs',
|
||||
));
|
||||
}
|
||||
|
||||
cp.stderr.on('data', (data) => {
|
||||
this.sockService.sendMessage({
|
||||
type: 'updateSystemVersion',
|
||||
message: data.toString(),
|
||||
});
|
||||
});
|
||||
public async getUpgradeStatus() {
|
||||
return {
|
||||
code: 200,
|
||||
data: await this.upgradeJobs().getStatus(config.tmpPath),
|
||||
};
|
||||
}
|
||||
|
||||
cp.on('error', (err) => {
|
||||
this.sockService.sendMessage({
|
||||
type: 'updateSystemVersion',
|
||||
message: JSON.stringify(err),
|
||||
status: 'failed',
|
||||
});
|
||||
});
|
||||
|
||||
cp.on('exit', (code) => {
|
||||
this.sockService.sendMessage({
|
||||
type: 'updateSystemVersion',
|
||||
message: '',
|
||||
status: code === 0 ? 'success' : 'failed',
|
||||
});
|
||||
});
|
||||
|
||||
return { code: 200 };
|
||||
private async startUpgradeJob(action: 'download' | 'apply') {
|
||||
try {
|
||||
const data = await this.upgradeJobs().startJob(
|
||||
{
|
||||
root: config.rootPath,
|
||||
tmp: config.tmpPath,
|
||||
env: {
|
||||
...process.env,
|
||||
dir_data: config.dataPath,
|
||||
dir_config: config.configPath,
|
||||
QlPort: String(config.port),
|
||||
QlBaseUrl: config.baseUrl,
|
||||
},
|
||||
},
|
||||
action,
|
||||
);
|
||||
return { code: 200, data };
|
||||
} catch (error: any) {
|
||||
this.logger.error('[upgrade] %s', error.message);
|
||||
return { code: 400, message: t('升级任务启动失败,请检查日志后重试') };
|
||||
}
|
||||
}
|
||||
|
||||
public async reloadSystem(target?: 'system' | 'data') {
|
||||
if (target && !['system', 'data'].includes(target)) {
|
||||
return { code: 400, message: t('参数错误') };
|
||||
}
|
||||
if (target === 'system') return this.startUpgradeJob('apply');
|
||||
const cmd = `real_time=true ql reload ${shellQuote(target || '')}`;
|
||||
const cp = spawn(cmd, {
|
||||
shell: '/bin/bash',
|
||||
@@ -407,11 +418,13 @@ export default class SystemService {
|
||||
if (notificationInfo && typeString) {
|
||||
notificationInfo.type = typeString;
|
||||
}
|
||||
|
||||
const isSuccess = await this.notificationService.notify(
|
||||
title,
|
||||
content,
|
||||
notificationInfo,
|
||||
);
|
||||
|
||||
if (isSuccess) {
|
||||
return { code: 200, message: t('通知发送成功') };
|
||||
} else {
|
||||
@@ -419,16 +432,36 @@ export default class SystemService {
|
||||
}
|
||||
}
|
||||
|
||||
public async run({ command, logPath }: { command: string; logPath?: string }, callback: TaskCallbacks) {
|
||||
public async recordNotifyLog(info: NotifyLogInfo): Promise<void> {
|
||||
await persistNotificationAudit(info);
|
||||
}
|
||||
|
||||
public async getNotifyLog(): Promise<Array<NotifyLogInfo>> {
|
||||
const docs = await SystemModel.findAll({
|
||||
where: { type: AuthDataType.notifyLog },
|
||||
order: [['id', 'DESC']],
|
||||
limit: 200,
|
||||
});
|
||||
return docs.map((x) => ({ ...x.info, id: x.id }));
|
||||
}
|
||||
|
||||
public async run(
|
||||
{ command, logPath }: { command: string; logPath?: string },
|
||||
callback: TaskCallbacks,
|
||||
) {
|
||||
if (!command.startsWith(TASK_COMMAND)) {
|
||||
command = `${TASK_COMMAND} ${command}`;
|
||||
}
|
||||
const logPathPrefix = logPath ? `real_log_path=${logPath}` : ''
|
||||
this.scheduleService.runTask(`${logPathPrefix} real_time=true ${command}`, callback, {
|
||||
command,
|
||||
id: command.replace(/ /g, '-'),
|
||||
runOrigin: 'system',
|
||||
});
|
||||
const logPathPrefix = logPath ? `real_log_path=${logPath}` : '';
|
||||
this.scheduleService.runTask(
|
||||
`${logPathPrefix} real_time=true ${command}`,
|
||||
callback,
|
||||
{
|
||||
command,
|
||||
id: command.replace(/ /g, '-'),
|
||||
runOrigin: 'system',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public async stop({ command, pid }: { command: string; pid: number }) {
|
||||
@@ -459,8 +492,20 @@ export default class SystemService {
|
||||
if (type && type.length) {
|
||||
dataDirs = dataDirs.concat(type.filter((x) => x !== 'base'));
|
||||
}
|
||||
const allowed = new Set(['db', 'upload', 'config', 'scripts', 'log', 'deps',
|
||||
'syslog', 'dep_cache', 'raw', 'repo', 'ssh.d', 'script-history']);
|
||||
const allowed = new Set([
|
||||
'db',
|
||||
'upload',
|
||||
'config',
|
||||
'scripts',
|
||||
'log',
|
||||
'deps',
|
||||
'syslog',
|
||||
'dep_cache',
|
||||
'raw',
|
||||
'repo',
|
||||
'ssh.d',
|
||||
'script-history',
|
||||
]);
|
||||
if (dataDirs.some((dir) => !allowed.has(dir))) {
|
||||
return res.status(400).send({ code: 400, message: t('参数错误') });
|
||||
}
|
||||
@@ -470,9 +515,13 @@ export default class SystemService {
|
||||
dataDirs = dataDirs.filter((dir) => dir !== 'script-history');
|
||||
}
|
||||
const dataPaths = [...new Set(dataDirs)].map((dir) => `data/${dir}`);
|
||||
await promisify(execFile)('tar', ['-zcf', config.dataTgzFile, '--', ...dataPaths], {
|
||||
cwd: path.dirname(config.dataPath),
|
||||
});
|
||||
await promisify(execFile)(
|
||||
'tar',
|
||||
['-zcf', config.dataTgzFile, '--', ...dataPaths],
|
||||
{
|
||||
cwd: path.dirname(config.dataPath),
|
||||
},
|
||||
);
|
||||
res.download(config.dataTgzFile);
|
||||
} catch (error: any) {
|
||||
return res.send({ code: 400, message: error.message });
|
||||
@@ -563,7 +612,17 @@ export default class SystemService {
|
||||
const result = await readDirs(config.systemLogPath, config.systemLogPath);
|
||||
const logs = result.reverse().filter((x) => x.title.endsWith('.log'));
|
||||
for (const log of logs) {
|
||||
await rmPath(path.join(config.systemLogPath, log.title));
|
||||
// All workers keep append handles open; unlinking strands future writes
|
||||
// in a deleted inode until rotation. Preserve the file while clearing it.
|
||||
const file = await fs.promises.open(
|
||||
path.join(config.systemLogPath, log.title),
|
||||
fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW,
|
||||
);
|
||||
try {
|
||||
await file.truncate(0);
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -173,7 +173,7 @@ export default class UserService {
|
||||
t('你于') +
|
||||
dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss') +
|
||||
t('在') +
|
||||
address +
|
||||
t(address) +
|
||||
' ' +
|
||||
req.platform +
|
||||
t('端') +
|
||||
@@ -219,7 +219,7 @@ export default class UserService {
|
||||
t('你于') +
|
||||
dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss') +
|
||||
t('在') +
|
||||
address +
|
||||
t(address) +
|
||||
' ' +
|
||||
req.platform +
|
||||
t('端') +
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface CronSchedule {
|
||||
// Validation and registration repeatedly see identical rules in large snapshots.
|
||||
// Cache only immutable syntax, never dates or running tasks, and bound user input.
|
||||
const scheduleCache = new Map<string, CronSchedule>();
|
||||
const daysInMonth = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
|
||||
// Canonicalize legacy shorthand, aliases, names and numeric-start steps before
|
||||
// node-cron sees them. Extend legacy aliases explicitly; reject H and bare /N.
|
||||
@@ -58,17 +59,73 @@ export function parseCronSchedule(schedule: unknown): CronSchedule {
|
||||
const expression = CronExpressionParser.parse(parts.join(' '));
|
||||
if (!expression.hasNext())
|
||||
throw new Error('Cron schedule has no next execution');
|
||||
const normalized = expression.stringify(true).replace(/\?/g, '*').split(' ');
|
||||
const dayCount = expression.fields.dayOfMonth.values.length;
|
||||
let normalized = parts.map((part) => part.replace(/\?/g, '*'));
|
||||
// cron-parser includes Sunday in stepped ranges ending in 7 even when the
|
||||
// step does not land on 7. node-cron accepts those ranges with different days.
|
||||
if (/(^|,)(?:\d+|[a-z]{3})-0*7\/\d+(?=,|$)/i.test(normalized[5])) {
|
||||
normalized[5] = expression.fields.dayOfWeek.values.join(',');
|
||||
}
|
||||
// Most rules are already supported; only inspect individual fields on failure.
|
||||
if (!validate(normalized.join(' '))) {
|
||||
const fields = [
|
||||
expression.fields.second,
|
||||
expression.fields.minute,
|
||||
expression.fields.hour,
|
||||
expression.fields.dayOfMonth,
|
||||
expression.fields.month,
|
||||
expression.fields.dayOfWeek,
|
||||
];
|
||||
normalized = normalized.map((value, index) => {
|
||||
// Preserve supported lists, ranges and names. Expand only fields node-cron
|
||||
// cannot consume, such as numeric/name-start steps; never compress them.
|
||||
const probe = ['0', '0', '0', '*', '*', '*'];
|
||||
probe[index] = value;
|
||||
if (validate(probe.join(' '))) return value;
|
||||
const values = fields[index].values;
|
||||
if (index === 5 && expression.fields.dayOfWeek.nthDay) {
|
||||
// The legacy normalizer treated *#N / ?#N as an unrestricted weekday.
|
||||
if (values.length === 8) return '*';
|
||||
// # applies to a single weekday; normalize its numeric spelling only.
|
||||
return values.length === 1
|
||||
? `${values[0]}#${expression.fields.dayOfWeek.nthDay}`
|
||||
: value;
|
||||
}
|
||||
return values.join(',');
|
||||
});
|
||||
}
|
||||
const months = expression.fields.month.values;
|
||||
// cron-parser 4 clipped dates outside a single selected month's maximum
|
||||
// before deciding whether the day field covered the whole month.
|
||||
const dayCount = expression.fields.dayOfMonth.values.filter(
|
||||
(day) =>
|
||||
months.length !== 1 ||
|
||||
typeof day !== 'number' ||
|
||||
day <= daysInMonth[months[0] - 1],
|
||||
).length;
|
||||
const weekCount = expression.fields.dayOfWeek.values.length;
|
||||
// Preserve the legacy count-based wildcard rules, including repeated Sunday
|
||||
// and L tokens: an ignored day field must not remain an AND constraint.
|
||||
const weekdayPattern = [
|
||||
...normalized.slice(0, 3),
|
||||
'*',
|
||||
...normalized.slice(4),
|
||||
].join(' ');
|
||||
const patterns =
|
||||
dayCount < 31 && weekCount < 8
|
||||
? [
|
||||
[...normalized.slice(0, 5), '*'].join(' '),
|
||||
[...normalized.slice(0, 3), '*', ...normalized.slice(4)].join(' '),
|
||||
]
|
||||
: [normalized.join(' ')];
|
||||
if (!patterns.every((pattern) => validate(pattern))) {
|
||||
weekCount === 8
|
||||
? [[...normalized.slice(0, 5), '*'].join(' ')]
|
||||
: dayCount < 31
|
||||
? [[...normalized.slice(0, 5), '*'].join(' '), weekdayPattern]
|
||||
: [weekdayPattern];
|
||||
const valid = patterns.map((pattern) => validate(pattern));
|
||||
if (patterns.length === 2 && !valid[0] && valid[1]) {
|
||||
// A date branch can be impossible for all chosen months while the weekday
|
||||
// branch still runs. Drop it only if its syntax is valid with any month.
|
||||
if (validate([...normalized.slice(0, 4), '*', '*'].join(' '))) {
|
||||
patterns.shift();
|
||||
valid.shift();
|
||||
}
|
||||
}
|
||||
if (!valid.every(Boolean)) {
|
||||
throw new Error('Unsupported cron schedule');
|
||||
}
|
||||
const parsed = {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from './i18n';
|
||||
import { createTask, ScheduledTask } from 'node-cron';
|
||||
import { parseCronSchedule } from './cronSchedule';
|
||||
|
||||
@@ -36,7 +37,7 @@ export function createCronJob(
|
||||
if (cancelled || !started || !accepts(index, date)) return;
|
||||
if (missed) {
|
||||
options.logger.warn(
|
||||
'[schedule][补执行迟到任务] 任务: %s, 计划时间: %s',
|
||||
t('[schedule][补执行迟到任务] 任务: %s, 计划时间: %s'),
|
||||
options.name,
|
||||
date.toISOString(),
|
||||
);
|
||||
@@ -45,7 +46,7 @@ export function createCronJob(
|
||||
await callback(date);
|
||||
} catch (error) {
|
||||
options.logger.error(
|
||||
'[schedule][定时回调失败] 任务: %s, 错误: %s',
|
||||
t('[schedule][定时回调失败] 任务: %s, 错误: %s'),
|
||||
options.name,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
|
||||
+224
-92
@@ -1,112 +1,234 @@
|
||||
import { shareStore } from './store';
|
||||
|
||||
const messages: Record<string, Record<string, string>> = {
|
||||
zh: {},
|
||||
en: {
|
||||
'资源已存在,请检查重复的名称或值': 'Resource already exists; check for duplicate names or values',
|
||||
'暂无权限': 'Access denied',
|
||||
'参数错误': 'Invalid parameter',
|
||||
'参数不正确': 'Invalid parameter',
|
||||
'文件无法访问': 'File not accessible',
|
||||
'文件不存在': 'File not found',
|
||||
'路径不存在': 'Path does not exist',
|
||||
'路径不正确': 'Invalid path',
|
||||
'升级任务启动失败,请检查日志后重试':
|
||||
'Failed to start the update. Check the logs and try again.',
|
||||
'[schedule][停止已运行任务] 任务ID: %s, PID: %s':
|
||||
'[schedule][Stopping running task] Task ID: %s, PID: %s',
|
||||
|
||||
'接口已下线,请使用 /configs/detail 接口':
|
||||
'This endpoint has been retired. Use /configs/detail instead.',
|
||||
'接口已下线,请使用 /logs/detail 接口':
|
||||
'This endpoint has been retired. Use /logs/detail instead.',
|
||||
'接口已下线,请使用 /scripts/detail 接口':
|
||||
'This endpoint has been retired. Use /scripts/detail instead.',
|
||||
'仅支持 PNG、JPEG、GIF、WebP、AVIF 图片':
|
||||
'Only PNG, JPEG, GIF, WebP and AVIF images are supported',
|
||||
请先初始化: 'Please initialize the panel first',
|
||||
请先关闭两步验证: 'Please disable two-factor authentication first',
|
||||
'失败次数过多,请稍后重试':
|
||||
'Too many failed attempts. Please try again later.',
|
||||
配置更新失败: 'Failed to update configuration',
|
||||
'trust proxy 配置格式无效': 'Invalid trust proxy configuration',
|
||||
'代理层数不能超过 20': 'Proxy hop count cannot exceed 20',
|
||||
'环境变量 QL_TRUST_PROXY 已生效,系统设置不可覆盖':
|
||||
'QL_TRUST_PROXY is active and cannot be overridden by system settings',
|
||||
系统配置不存在: 'System configuration does not exist',
|
||||
'历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本':
|
||||
'Version history only supports UTF-8 text scripts up to 1 MiB',
|
||||
脚本不存在: 'Script does not exist',
|
||||
请选择脚本文件: 'Please select a script file',
|
||||
'历史版本读取失败,请检查历史文件':
|
||||
'Failed to read version history. Check the history file.',
|
||||
历史版本不存在或已清理:
|
||||
'This history version does not exist or has been removed',
|
||||
'文件已变化,请刷新历史版本后重试':
|
||||
'The file has changed. Refresh version history and try again.',
|
||||
'历史版本容量不足,无法安全保存':
|
||||
'Version history storage is full. The file cannot be saved safely.',
|
||||
'跳过订阅 %s 的无效定时配置: %s':
|
||||
'Skipping invalid schedule for subscription %s: %s',
|
||||
'[schedule][准备运行任务] 命令: %s':
|
||||
'[schedule][Preparing task] Command: %s',
|
||||
'[schedule][创建定时任务] 任务ID: %s, 规则数: %s':
|
||||
'[schedule][Creating scheduled task] Task ID: %s, schedules: %s',
|
||||
'[schedule][取消定时任务] 任务ID: %s':
|
||||
'[schedule][Cancelling scheduled task] Task ID: %s',
|
||||
'[schedule][取消任务失败] 任务ID: %s, 错误: %s':
|
||||
'[schedule][Failed to cancel task] Task ID: %s, error: %s',
|
||||
'[panel][停止任务失败] PID: %s, 错误: %s':
|
||||
'[panel][Failed to stop task] PID: %s, error: %s',
|
||||
'[panel][停止实例失败] 实例ID: %s, PID: %s, 错误: %s':
|
||||
'[panel][Failed to stop instance] Instance ID: %s, PID: %s, error: %s',
|
||||
'[panel][执行任务失败] 任务ID: %s, 错误: %s':
|
||||
'[panel][Task failed] Task ID: %s, error: %s',
|
||||
'[panel][执行任务结束] 任务ID: %s, 退出码: %j':
|
||||
'[panel][Task finished] Task ID: %s, exit code: %j',
|
||||
'[panel][创建任务失败] 任务ID: %s, 错误: %s':
|
||||
'[panel][Failed to create task] Task ID: %s, error: %s',
|
||||
'[panel][关闭任务日志失败] %s': '[panel][Failed to close task log] %s',
|
||||
'[panel][清理任务状态失败] %s': '[panel][Failed to clear task status] %s',
|
||||
'性能指标报告:': 'Performance metrics:',
|
||||
'[panel][开始执行任务] 任务ID: %s': '[panel][Starting task] Task ID: %s',
|
||||
'[panel][任务错误回调失败] %s': '[panel][Task error callback failed] %s',
|
||||
'[panel][任务结束回调失败] %s':
|
||||
'[panel][Task completion callback failed] %s',
|
||||
'[panel][任务队列失败] %s': '[panel][Task queue failed] %s',
|
||||
'[panel][创建cron任务] 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s':
|
||||
'[panel][Creating cron task] Task ID: %s, cron: %s, name: %s, command: %s',
|
||||
'[panel][跳过无效定时任务] 任务ID: %s, cron: %s, 错误: %s':
|
||||
'[panel][Skipping invalid schedule] Task ID: %s, cron: %s, error: %s',
|
||||
'[panel][取消定时任务] 任务名: %s':
|
||||
'[panel][Cancelling scheduled task] Name: %s',
|
||||
'[panel][创建interval任务] 任务ID: %s, 任务名: %s, 执行命令: %s':
|
||||
'[panel][Creating interval task] Task ID: %s, name: %s, command: %s',
|
||||
'[panel][执行任务失败] 命令: %s, 错误信息: %j':
|
||||
'[panel][Task failed] Command: %s, error: %j',
|
||||
'[panel][取消interval任务] 任务ID: %s, 任务名: %s':
|
||||
'[panel][Cancelling interval task] Task ID: %s, name: %s',
|
||||
生成私钥文件失败: 'Failed to generate private key file',
|
||||
删除私钥文件失败: 'Failed to delete private key file',
|
||||
'删除ssh配置文件%s失败': 'Failed to delete SSH configuration file %s',
|
||||
'跳过订阅 %s 的无效 SSH 配置: %s':
|
||||
'Skipping invalid SSH configuration for subscription %s: %s',
|
||||
'[schedule][补执行迟到任务] 任务: %s, 计划时间: %s':
|
||||
'[schedule][Running late task] Task: %s, scheduled time: %s',
|
||||
'[schedule][定时回调失败] 任务: %s, 错误: %s':
|
||||
'[schedule][Scheduled callback failed] Task: %s, error: %s',
|
||||
'[schedule][任务队列] 空闲中...': '[schedule][Task queue] Idle...',
|
||||
'[schedule][任务加入队列] 运行中任务数: %s, 等待中任务数: %s':
|
||||
'[schedule][Task queued] Running: %s, waiting: %s',
|
||||
'[schedule][开始处理任务] 运行中任务数: %s, 等待中任务数: %s':
|
||||
'[schedule][Processing task] Running: %s, waiting: %s',
|
||||
'[schedule][任务处理成功] 参数 %s':
|
||||
'[schedule][Task succeeded] Arguments: %s',
|
||||
'[schedule][任务处理错误] 参数 %s': '[schedule][Task failed] Arguments: %s',
|
||||
'[schedule][任务处理结束] 运行中任务数: %s, 等待中任务数: %s':
|
||||
'[schedule][Task finished] Running: %s, waiting: %s',
|
||||
'[schedule][任务重复运行] 通知失败 %s':
|
||||
'[schedule][Duplicate task] Notification failed: %s',
|
||||
'[schedule][任务重复运行] 参数 %s':
|
||||
'[schedule][Duplicate task] Arguments: %s',
|
||||
'[schedule][停止已运行任务] 任务ID: %s':
|
||||
'[schedule][Stopping running task] Task ID: %s',
|
||||
'[schedule][检查已运行任务失败] 任务ID: %s, 错误: %s':
|
||||
'[schedule][Failed to check running task] Task ID: %s, error: %s',
|
||||
'[schedule][开始执行任务] 参数 %s':
|
||||
'[schedule][Starting task] Arguments: %s',
|
||||
'[schedule][任务标准错误] 命令: %s, 信息: %s':
|
||||
'[schedule][Task stderr] Command: %s, message: %s',
|
||||
'[schedule][执行任务失败] 命令: %s, 错误: %s':
|
||||
'[schedule][Task failed] Command: %s, error: %s',
|
||||
'[schedule][执行任务结束] 任务ID: %s, 退出码: %j':
|
||||
'[schedule][Task finished] Task ID: %s, exit code: %j',
|
||||
'[schedule][创建任务失败] 命令: %s, 错误: %s':
|
||||
'[schedule][Failed to create task] Command: %s, error: %s',
|
||||
|
||||
'检查更新失败,请检查网络后重试':
|
||||
'Failed to check for updates. Check your network and try again.',
|
||||
'资源已存在,请检查重复的名称或值':
|
||||
'Resource already exists; check for duplicate names or values',
|
||||
暂无权限: 'Access denied',
|
||||
参数错误: 'Invalid parameter',
|
||||
参数不正确: 'Invalid parameter',
|
||||
文件无法访问: 'File not accessible',
|
||||
文件不存在: 'File not found',
|
||||
路径不存在: 'Path does not exist',
|
||||
路径不正确: 'Invalid path',
|
||||
'通知发送失败,请检查参数': 'Notification failed, check parameters',
|
||||
'通知发送失败,请检查系统设置/通知配置': 'Notification failed, check system settings',
|
||||
'通知发送成功': 'Notification sent successfully',
|
||||
'设置时区失败': 'Failed to set timezone',
|
||||
'任务未找到': 'Task not found',
|
||||
'密码不能设置为admin': 'Password cannot be admin',
|
||||
'更新成功': 'Update successful',
|
||||
'未知错误': 'Unknown error',
|
||||
'验证失败': 'Verification failed',
|
||||
'实例不存在或已停止': 'Instance does not exist or stopped',
|
||||
'实例已停止': 'Instance stopped',
|
||||
'确认停止实例': 'Confirm to stop instance',
|
||||
'确认停止运行实例': 'Confirm to stop running instance',
|
||||
'确认停止': 'Confirm to stop',
|
||||
'确认停止定时任务': 'Confirm to stop scheduled task',
|
||||
'确认删除': 'Confirm to delete',
|
||||
'确认删除定时任务': 'Confirm to delete scheduled task',
|
||||
'确认删除选中的定时任务吗': 'Confirm to delete selected tasks?',
|
||||
'确认运行': 'Confirm to run',
|
||||
'确认运行定时任务': 'Confirm to run scheduled task',
|
||||
'确认保存': 'Confirm to save',
|
||||
'确认保存文件': 'Confirm to save file',
|
||||
'确认重启': 'Confirm restart',
|
||||
'确认启用': 'Confirm to enable',
|
||||
'确认禁用': 'Confirm to disable',
|
||||
'确认': 'Confirm',
|
||||
'删除成功': 'Deleted successfully',
|
||||
'操作成功': 'Operation successful',
|
||||
'参数不完整': 'Incomplete parameters',
|
||||
'默认路径不支持删除': 'Default path cannot be deleted',
|
||||
'必须在日志目录下': 'Must be within log directory',
|
||||
'通知发送失败,请检查系统设置/通知配置':
|
||||
'Notification failed, check system settings',
|
||||
通知发送成功: 'Notification sent successfully',
|
||||
设置时区失败: 'Failed to set timezone',
|
||||
任务未找到: 'Task not found',
|
||||
密码不能设置为admin: 'Password cannot be admin',
|
||||
更新成功: 'Update successful',
|
||||
未知错误: 'Unknown error',
|
||||
验证失败: 'Verification failed',
|
||||
实例不存在或已停止: 'Instance does not exist or stopped',
|
||||
实例已停止: 'Instance stopped',
|
||||
确认停止实例: 'Confirm to stop instance',
|
||||
确认停止运行实例: 'Confirm to stop running instance',
|
||||
确认停止: 'Confirm to stop',
|
||||
确认停止定时任务: 'Confirm to stop scheduled task',
|
||||
确认删除: 'Confirm to delete',
|
||||
确认删除定时任务: 'Confirm to delete scheduled task',
|
||||
确认删除选中的定时任务吗: 'Confirm to delete selected tasks?',
|
||||
确认运行: 'Confirm to run',
|
||||
确认运行定时任务: 'Confirm to run scheduled task',
|
||||
确认保存: 'Confirm to save',
|
||||
确认保存文件: 'Confirm to save file',
|
||||
确认重启: 'Confirm restart',
|
||||
确认启用: 'Confirm to enable',
|
||||
确认禁用: 'Confirm to disable',
|
||||
确认: 'Confirm',
|
||||
删除成功: 'Deleted successfully',
|
||||
操作成功: 'Operation successful',
|
||||
参数不完整: 'Incomplete parameters',
|
||||
默认路径不支持删除: 'Default path cannot be deleted',
|
||||
必须在日志目录下: 'Must be within log directory',
|
||||
'备份数据上传成功,确认覆盖数据': 'Backup uploaded, confirm overwrite',
|
||||
'如果恢复失败,可进入容器执行': 'If restore fails, run in container:',
|
||||
'系统将在': 'System will',
|
||||
'秒后自动刷新': 'refresh in seconds',
|
||||
系统将在: 'System will',
|
||||
秒后自动刷新: 'refresh in seconds',
|
||||
'生成数据中...': 'Generating data...',
|
||||
'每条数据 name 或者 value 字段不能为空,参考导出文件格式': 'Each entry must have name and value, see export format',
|
||||
'不支持当前依赖类型': 'Unsupported dependency type',
|
||||
'依赖已存在': 'Dependency already exists',
|
||||
'依赖不存在': 'Dependency does not exist',
|
||||
'该脚本正在运行中': 'Script is running',
|
||||
'该脚本未在运行中': 'Script is not running',
|
||||
'文件内容为空': 'File content is empty',
|
||||
'文件名不能为空': 'File name cannot be empty',
|
||||
'标签不能为空': 'Label cannot be empty',
|
||||
'名称不能为空': 'Name cannot be empty',
|
||||
'名称不能为保留关键字': 'Name cannot be reserved keyword',
|
||||
'名称已存在': 'Name already exists',
|
||||
'密码错误': 'Incorrect password',
|
||||
'用户不存在': 'User does not exist',
|
||||
'请输入用户名和密码': 'Please enter username and password',
|
||||
'无权访问': 'Access denied',
|
||||
'登录成功': 'Login successful',
|
||||
'退出成功': 'Logout successful',
|
||||
'每条数据 name 或者 value 字段不能为空,参考导出文件格式':
|
||||
'Each entry must have name and value, see export format',
|
||||
不支持当前依赖类型: 'Unsupported dependency type',
|
||||
依赖已存在: 'Dependency already exists',
|
||||
依赖不存在: 'Dependency does not exist',
|
||||
该脚本正在运行中: 'Script is running',
|
||||
该脚本未在运行中: 'Script is not running',
|
||||
文件内容为空: 'File content is empty',
|
||||
文件名不能为空: 'File name cannot be empty',
|
||||
标签不能为空: 'Label cannot be empty',
|
||||
名称不能为空: 'Name cannot be empty',
|
||||
名称不能为保留关键字: 'Name cannot be reserved keyword',
|
||||
名称已存在: 'Name already exists',
|
||||
密码错误: 'Incorrect password',
|
||||
用户不存在: 'User does not exist',
|
||||
请输入用户名和密码: 'Please enter username and password',
|
||||
无权访问: 'Access denied',
|
||||
登录成功: 'Login successful',
|
||||
退出成功: 'Logout successful',
|
||||
'Token 已失效': 'Token expired',
|
||||
'Token 无效': 'Invalid token',
|
||||
'两步骤验证已开启': '2FA enabled',
|
||||
'两步骤验证已关闭': '2FA disabled',
|
||||
'验证码错误': 'Invalid verification code',
|
||||
'验证码已过期': 'Verification code expired',
|
||||
'请先开启两步骤验证': 'Please enable 2FA first',
|
||||
'两步骤验证密钥不能为空': '2FA secret cannot be empty',
|
||||
'用户已存在': 'User already exists',
|
||||
'用户名不能为admin': 'Username cannot be admin',
|
||||
'不能删除自己': 'Cannot delete yourself',
|
||||
'不能禁用自己': 'Cannot disable yourself',
|
||||
'文件路径无效': 'Invalid file path',
|
||||
'保存成功': 'Saved successfully',
|
||||
两步骤验证已开启: '2FA enabled',
|
||||
两步骤验证已关闭: '2FA disabled',
|
||||
验证码错误: 'Invalid verification code',
|
||||
验证码已过期: 'Verification code expired',
|
||||
请先开启两步骤验证: 'Please enable 2FA first',
|
||||
两步骤验证密钥不能为空: '2FA secret cannot be empty',
|
||||
用户已存在: 'User already exists',
|
||||
用户名不能为admin: 'Username cannot be admin',
|
||||
不能删除自己: 'Cannot delete yourself',
|
||||
不能禁用自己: 'Cannot disable yourself',
|
||||
文件路径无效: 'Invalid file path',
|
||||
保存成功: 'Saved successfully',
|
||||
'client_id 或 client_seret 有误': 'Invalid client_id or client_secret',
|
||||
'订阅执行完成': 'Subscription completed',
|
||||
'wxPusher 服务的 TopicIds 和 Uids 至少配置一个才行': 'wxPusher requires at least one of TopicIds or Uids',
|
||||
订阅执行完成: 'Subscription completed',
|
||||
'wxPusher 服务的 TopicIds 和 Uids 至少配置一个才行':
|
||||
'wxPusher requires at least one of TopicIds or Uids',
|
||||
'wxPusher SPT 不能为空': 'wxPusher SPT cannot be empty',
|
||||
'wxPusher SPT 最多支持 10 个': 'wxPusher SPT supports at most 10 tokens',
|
||||
'Url 或者 Body 中必须包含 $title': 'Url or Body must contain $title',
|
||||
'绝对路径必须在日志目录内或使用 /dev/null':
|
||||
'Absolute path must be within log directory or use /dev/null',
|
||||
'请先登录': 'Please login first',
|
||||
请先登录: 'Please login first',
|
||||
'运行中...': 'Running...',
|
||||
'日志不存在...': 'Log does not exist...',
|
||||
'未分类': 'Uncategorized',
|
||||
'任务重复运行': 'Duplicate task execution',
|
||||
'日志设置为忽略': 'Log set to ignore',
|
||||
'定时规则不能为空': 'Schedule rule cannot be empty',
|
||||
'无效的定时规则': 'Invalid schedule rule',
|
||||
未分类: 'Uncategorized',
|
||||
任务重复运行: 'Duplicate task execution',
|
||||
日志设置为忽略: 'Log set to ignore',
|
||||
定时规则不能为空: 'Schedule rule cannot be empty',
|
||||
无效的定时规则: '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',
|
||||
'日志名称不能超过100个字符': 'Log name cannot exceed 100 characters',
|
||||
'错误的用户名密码,请重试': 'Incorrect username or password, please try again',
|
||||
日志名称不能超过100个字符: 'Log name cannot exceed 100 characters',
|
||||
'错误的用户名密码,请重试':
|
||||
'Incorrect username or password, please try again',
|
||||
'该 IP 已被列入黑名单': 'This IP address has been blocked',
|
||||
'已加入 IP 黑名单': 'IP address added to the blacklist',
|
||||
'已移出 IP 黑名单': 'IP address removed from the blacklist',
|
||||
'青龙快讯': 'QingLong',
|
||||
'登录通知': 'Login Notification',
|
||||
'你于': 'You at ',
|
||||
'在': ' in ',
|
||||
端: '',
|
||||
青龙快讯: 'QingLong',
|
||||
登录通知: 'Login Notification',
|
||||
你于: 'You at ',
|
||||
在: ' in ',
|
||||
端: ' client',
|
||||
内网IP: 'Private IP',
|
||||
登录失败: 'login failed',
|
||||
',ip地址': ', IP: ',
|
||||
'任务#%s': 'Task#%s',
|
||||
@@ -131,12 +253,13 @@ const messages: Record<string, Record<string, string>> = {
|
||||
青龙: 'QingLong',
|
||||
'【蛟龙】测试通知 https://t.me/jiao_long':
|
||||
'[JiaoLong] Test notification https://t.me/jiao_long',
|
||||
'生成token': 'Generate token',
|
||||
'删除日志': 'Delete logs',
|
||||
生成token: 'Generate token',
|
||||
删除日志: 'Delete logs',
|
||||
'## 开始执行... %s\n': '## Start executing... %s\n',
|
||||
'执行before命令...': 'Execute before command...',
|
||||
'执行after命令...': 'Execute after command...',
|
||||
'## 执行结束... %s 耗时 %s 秒': '## Execution finished... %s elapsed %s seconds',
|
||||
'## 执行结束... %s 耗时 %s 秒':
|
||||
'## Execution finished... %s elapsed %s seconds',
|
||||
'调度器注册失败,任务创建已回滚':
|
||||
'Scheduler registration failed, task creation rolled back',
|
||||
'调度器注册失败,任务更新已回滚':
|
||||
@@ -152,9 +275,18 @@ const messages: Record<string, Record<string, string>> = {
|
||||
|
||||
let currentLang: string = 'zh';
|
||||
|
||||
export function setLang(lang: string) {
|
||||
export function setLang(lang: string, persist = true) {
|
||||
currentLang = lang || 'zh';
|
||||
shareStore.setLang(currentLang);
|
||||
if (!persist) return;
|
||||
// Translation is also used by standalone scheduler and CLI helpers. Load
|
||||
// persistent storage only when changing the language, not when reading it.
|
||||
const { shareStore } = require('./store') as typeof import('./store');
|
||||
const language = currentLang;
|
||||
return shareStore.setLang(language).then(() => {
|
||||
if (currentLang === language && process.connected && process.send) {
|
||||
process.send({ type: 'language-changed', lang: language }, () => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 系统默认语言:Intl 检测 → 'zh'(仅返回 zh/en) */
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import cluster from 'cluster';
|
||||
import { setLang, systemLang } from './i18n';
|
||||
|
||||
function languageMessage(
|
||||
message: unknown,
|
||||
): message is { type: string; lang: string } {
|
||||
const value = message as { type?: string; lang?: string } | null;
|
||||
return (
|
||||
value?.type === 'language-changed' && ['zh', 'en'].includes(value.lang!)
|
||||
);
|
||||
}
|
||||
|
||||
// Each worker owns a translation cache. Relay saved settings to live workers;
|
||||
// a replacement worker restores the persisted language before serving requests.
|
||||
export async function initializeLanguageSync() {
|
||||
if (cluster.isPrimary) {
|
||||
cluster.on('message', (_worker, message) => {
|
||||
if (!languageMessage(message)) return;
|
||||
setLang(message.lang, false);
|
||||
for (const worker of Object.values(cluster.workers || {})) {
|
||||
if (worker?.isConnected()) worker.send(message, () => {});
|
||||
}
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
process.on('message', (message) => {
|
||||
if (languageMessage(message)) setLang(message.lang, false);
|
||||
});
|
||||
}
|
||||
const { shareStore } = await import('./store');
|
||||
setLang((await shareStore.getLang()) || systemLang(), false);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Op } from 'sequelize';
|
||||
import {
|
||||
SystemModel,
|
||||
AuthDataType,
|
||||
NotifyStatus,
|
||||
NotifyLogInfo,
|
||||
} from '../data/system';
|
||||
|
||||
// Both backend sends and script reports use the same bounded, best-effort sink.
|
||||
export async function persistNotificationAudit(
|
||||
info: NotifyLogInfo,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await SystemModel.create({
|
||||
type: AuthDataType.notifyLog,
|
||||
info: {
|
||||
timestamp: Date.now(),
|
||||
title: String(info.title || '').slice(0, 512),
|
||||
content: String(info.content || '').slice(0, 8192),
|
||||
status:
|
||||
info.status === NotifyStatus.success
|
||||
? NotifyStatus.success
|
||||
: NotifyStatus.fail,
|
||||
notifyType: String(info.notifyType || '').slice(0, 64),
|
||||
source: info.source === 'script' ? 'script' : 'system',
|
||||
scriptName: String(info.scriptName || '').slice(0, 512),
|
||||
taskId: /^\d+$/.test(info.taskId || '') ? info.taskId : undefined,
|
||||
},
|
||||
});
|
||||
const oldest = await SystemModel.findAll({
|
||||
where: { type: AuthDataType.notifyLog },
|
||||
attributes: ['id'],
|
||||
order: [['id', 'DESC']],
|
||||
offset: 200,
|
||||
limit: 1,
|
||||
});
|
||||
if (oldest[0]?.id) {
|
||||
await SystemModel.destroy({
|
||||
where: {
|
||||
type: AuthDataType.notifyLog,
|
||||
id: { [Op.lte]: oldest[0].id },
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to persist notification audit log');
|
||||
}
|
||||
}
|
||||
+18
-10
@@ -79,29 +79,36 @@ class TaskLimit {
|
||||
private handleEvents() {
|
||||
this.cronLimit.on('add', () => {
|
||||
Logger.info(
|
||||
`[schedule][任务加入队列] 运行中任务数: ${this.cronLimitActiveCount}, 等待中任务数: ${this.cronLimitPendingCount}`,
|
||||
t('[schedule][任务加入队列] 运行中任务数: %s, 等待中任务数: %s'),
|
||||
this.cronLimitActiveCount,
|
||||
this.cronLimitPendingCount,
|
||||
);
|
||||
});
|
||||
this.cronLimit.on('active', () => {
|
||||
Logger.info(
|
||||
`[schedule][开始处理任务] 运行中任务数: ${
|
||||
this.cronLimitActiveCount + 1
|
||||
}, 等待中任务数: ${this.cronLimitPendingCount}`,
|
||||
t('[schedule][开始处理任务] 运行中任务数: %s, 等待中任务数: %s'),
|
||||
this.cronLimitActiveCount + 1,
|
||||
this.cronLimitPendingCount,
|
||||
);
|
||||
});
|
||||
this.cronLimit.on('completed', (param) => {
|
||||
Logger.info(`[schedule][任务处理成功] 参数 ${JSON.stringify(param)}`);
|
||||
Logger.info(t('[schedule][任务处理成功] 参数 %s'), JSON.stringify(param));
|
||||
});
|
||||
this.cronLimit.on('error', (error) => {
|
||||
Logger.error(`[schedule][任务处理错误] 参数 ${JSON.stringify(error)}`);
|
||||
Logger.error(
|
||||
t('[schedule][任务处理错误] 参数 %s'),
|
||||
JSON.stringify(error),
|
||||
);
|
||||
});
|
||||
this.cronLimit.on('next', () => {
|
||||
Logger.info(
|
||||
`[schedule][任务处理结束] 运行中任务数: ${this.cronLimitActiveCount}, 等待中任务数: ${this.cronLimitPendingCount}`,
|
||||
t('[schedule][任务处理结束] 运行中任务数: %s, 等待中任务数: %s'),
|
||||
this.cronLimitActiveCount,
|
||||
this.cronLimitPendingCount,
|
||||
);
|
||||
});
|
||||
this.cronLimit.on('idle', () => {
|
||||
Logger.info(`[schedule][任务队列] 空闲中...`);
|
||||
Logger.info(t(`[schedule][任务队列] 空闲中...`));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -172,13 +179,14 @@ class TaskLimit {
|
||||
(err, res) => {
|
||||
if (err) {
|
||||
Logger.error(
|
||||
`[schedule][任务重复运行] 通知失败 ${JSON.stringify(err)}`,
|
||||
t('[schedule][任务重复运行] 通知失败 %s'),
|
||||
JSON.stringify(err),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
Logger.warn(`[schedule][任务重复运行] 参数 ${JSON.stringify(cron)}`);
|
||||
Logger.warn(t('[schedule][任务重复运行] 参数 %s'), JSON.stringify(cron));
|
||||
return;
|
||||
}
|
||||
this.queuedCrons.set(cron.id, result);
|
||||
|
||||
+96
-33
@@ -1,9 +1,9 @@
|
||||
import { t } from './i18n';
|
||||
import { spawn } from 'cross-spawn';
|
||||
import taskLimit from './pLimit';
|
||||
import Logger from '../loaders/logger';
|
||||
import { ICron } from '../protos/cron';
|
||||
import { CrontabModel, CrontabStatus } from '../data/cron';
|
||||
import { killTask } from '../config/util';
|
||||
import { RunningInstanceModel, InstanceStatus } from '../data/runningInstance';
|
||||
import dayjs from 'dayjs';
|
||||
import { observeChildProcess, asError } from './childProcess';
|
||||
@@ -16,7 +16,7 @@ export function runCron(
|
||||
const execute = async () => {
|
||||
try {
|
||||
if (!isCurrent()) return;
|
||||
// Check if the cron is already running and stop it (only if multiple instances are not allowed)
|
||||
// A single-instance trigger must not interrupt work already in progress.
|
||||
try {
|
||||
const existingCron = await CrontabModel.findOne({
|
||||
where: { id: Number(cron.id) },
|
||||
@@ -29,55 +29,118 @@ export function runCron(
|
||||
const allowSingleInstances =
|
||||
existingCron?.allow_multiple_instances === 0;
|
||||
|
||||
if (
|
||||
allowSingleInstances &&
|
||||
existingCron &&
|
||||
existingCron.pid &&
|
||||
(existingCron.status === CrontabStatus.running ||
|
||||
existingCron.status === CrontabStatus.queued)
|
||||
) {
|
||||
Logger.info(
|
||||
`[schedule][停止已运行任务] 任务ID: ${cron.id}, PID: ${existingCron.pid}`,
|
||||
);
|
||||
await killTask(existingCron.pid, true);
|
||||
// Mark old running instances as stopped
|
||||
const stoppedAt = dayjs().unix();
|
||||
await RunningInstanceModel.update(
|
||||
{ status: InstanceStatus.stopped, finished_at: stoppedAt },
|
||||
{
|
||||
if (allowSingleInstances) {
|
||||
// A queued manual run is an active reservation, even if its stored PID
|
||||
// belongs to a previous execution.
|
||||
if (
|
||||
existingCron.status === CrontabStatus.queued &&
|
||||
existingCron.pid
|
||||
) {
|
||||
Logger.info(
|
||||
'[schedule][Skip queued single-instance task] Task ID: %s, PID: %s',
|
||||
cron.id,
|
||||
existingCron.pid,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
existingCron.status === CrontabStatus.running &&
|
||||
existingCron.pid
|
||||
) {
|
||||
let missing = false;
|
||||
try {
|
||||
process.kill(existingCron.pid, 0);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'ESRCH') missing = true;
|
||||
else if (code !== 'EPERM') throw error;
|
||||
}
|
||||
if (!missing) {
|
||||
Logger.info(
|
||||
'[schedule][Skip active single-instance task] Task ID: %s, PID: %s',
|
||||
cron.id,
|
||||
existingCron.pid,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture old instance IDs before releasing the stale cron state so
|
||||
// a concurrent replacement cannot have its instance marked stopped.
|
||||
const staleInstances = await RunningInstanceModel.findAll({
|
||||
attributes: ['id'],
|
||||
where: {
|
||||
cron_id: Number(cron.id),
|
||||
pid: existingCron.pid,
|
||||
status: InstanceStatus.running,
|
||||
},
|
||||
},
|
||||
);
|
||||
// Update the status to idle after killing
|
||||
await CrontabModel.update(
|
||||
{ status: CrontabStatus.idle, pid: undefined },
|
||||
{ where: { id: Number(cron.id) } },
|
||||
);
|
||||
});
|
||||
if (!isCurrent()) return;
|
||||
const [recovered] = await CrontabModel.update(
|
||||
{
|
||||
status: CrontabStatus.idle,
|
||||
pid: null,
|
||||
queued_token: null,
|
||||
} as any,
|
||||
{
|
||||
where: {
|
||||
id: Number(cron.id),
|
||||
pid: existingCron.pid,
|
||||
status: CrontabStatus.running,
|
||||
...(existingCron.log_path !== undefined
|
||||
? { log_path: existingCron.log_path }
|
||||
: {}),
|
||||
...(existingCron.queued_token !== undefined
|
||||
? { queued_token: existingCron.queued_token }
|
||||
: {}),
|
||||
...(existingCron.last_execution_time !== undefined
|
||||
? { last_execution_time: existingCron.last_execution_time }
|
||||
: {}),
|
||||
...(existingCron.isDisabled !== undefined
|
||||
? { isDisabled: existingCron.isDisabled }
|
||||
: {}),
|
||||
allow_multiple_instances: 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (recovered !== 1) return;
|
||||
const staleIds = staleInstances.map((instance) => instance.id!);
|
||||
if (staleIds.length) {
|
||||
await RunningInstanceModel.update(
|
||||
{ status: InstanceStatus.stopped, finished_at: dayjs().unix() },
|
||||
{ where: { id: staleIds, status: InstanceStatus.running } },
|
||||
);
|
||||
}
|
||||
Logger.info(
|
||||
'[schedule][Recovered stale single-instance task] Task ID: %s, PID: %s',
|
||||
cron.id,
|
||||
existingCron.pid,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
`[schedule][检查已运行任务失败] 任务ID: ${cron.id}, 错误: ${error}`,
|
||||
t('[schedule][检查已运行任务失败] 任务ID: %s, 错误: %s'),
|
||||
cron.id,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// In particular, invalidation can happen while stopping a previous run.
|
||||
// Invalidation can happen during the asynchronous database check.
|
||||
if (!isCurrent()) return;
|
||||
Logger.info(
|
||||
`[schedule][开始执行任务] 参数 ${JSON.stringify({
|
||||
t('[schedule][开始执行任务] 参数 %s'),
|
||||
JSON.stringify({
|
||||
...cron,
|
||||
command: cmd,
|
||||
})}`,
|
||||
}),
|
||||
);
|
||||
const cp = spawn(cmd, { shell: '/bin/bash' });
|
||||
|
||||
const { completed } = observeChildProcess(cp, {
|
||||
onStderr: async (message) => {
|
||||
Logger.info(
|
||||
'[schedule][任务标准错误] 命令: %s, 信息: %s',
|
||||
t('[schedule][任务标准错误] 命令: %s, 信息: %s'),
|
||||
cmd,
|
||||
message,
|
||||
);
|
||||
@@ -86,20 +149,20 @@ export function runCron(
|
||||
const result = await completed;
|
||||
if (result.error) {
|
||||
Logger.error(
|
||||
'[schedule][执行任务失败] 命令: %s, 错误: %s',
|
||||
t('[schedule][执行任务失败] 命令: %s, 错误: %s'),
|
||||
cmd,
|
||||
result.error.message,
|
||||
);
|
||||
}
|
||||
Logger.info(
|
||||
'[schedule][执行任务结束] 任务ID: %s, 退出码: %j',
|
||||
t('[schedule][执行任务结束] 任务ID: %s, 退出码: %j'),
|
||||
cron.id,
|
||||
result.code,
|
||||
);
|
||||
return { ...cron, command: cmd, pid: cp.pid, ...result } as any;
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
'[schedule][创建任务失败] 命令: %s, 错误: %s',
|
||||
t('[schedule][创建任务失败] 命令: %s, 错误: %s'),
|
||||
cmd,
|
||||
asError(error).message,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
|
||||
function read(filename: string): Buffer | undefined {
|
||||
try {
|
||||
return readFileSync(filename);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function manifest(): { sourceCommit?: string } {
|
||||
try {
|
||||
const value = JSON.parse(
|
||||
read(path.resolve(__dirname, '../../build-info.json'))?.toString() ||
|
||||
'{}',
|
||||
);
|
||||
return value && typeof value === 'object' ? value : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Capture before serving requests. Reading the installed files on each request
|
||||
// would let an old process claim the identity of a newly copied build.
|
||||
const backend = read(path.resolve(__dirname, '../app.js'));
|
||||
const root = process.env.QL_DIR || path.resolve(__dirname, '../../..');
|
||||
const version = read(path.join(root, 'version.yaml'))
|
||||
?.toString()
|
||||
.match(/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?)/m)?.[1];
|
||||
|
||||
export const runtimeBuild = Object.freeze({
|
||||
instanceId: randomUUID(),
|
||||
parentPid: process.ppid,
|
||||
version,
|
||||
sourceCommit: manifest().sourceCommit,
|
||||
backendSha256: backend
|
||||
? createHash('sha256').update(backend).digest('hex')
|
||||
: undefined,
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import express, { Application, Request } from 'express';
|
||||
import { AuthDataType, SystemModel } from '../data/system';
|
||||
import { normalizeClientIp } from './clientIp';
|
||||
import { t } from './i18n';
|
||||
|
||||
const DEFAULT_TRUST_PROXY = 'loopback';
|
||||
|
||||
@@ -30,10 +31,10 @@ export function resolveTrustProxy(value?: string): TrustProxyValue {
|
||||
function validateTrustProxy(value: string): string {
|
||||
const setting = normalizeSetting(value);
|
||||
if (setting.length > 500 || /[\r\n]/.test(setting)) {
|
||||
throw new Error('trust proxy 配置格式无效');
|
||||
throw new Error(t('trust proxy 配置格式无效'));
|
||||
}
|
||||
if (/^\d+$/.test(setting) && Number(setting) > 20) {
|
||||
throw new Error('代理层数不能超过 20');
|
||||
throw new Error(t('代理层数不能超过 20'));
|
||||
}
|
||||
|
||||
const probe = express();
|
||||
@@ -73,7 +74,7 @@ export async function initializeTrustProxy(app: Application) {
|
||||
|
||||
export async function updateTrustProxy(value: string) {
|
||||
if (getEnvironmentSetting()) {
|
||||
throw new Error('环境变量 QL_TRUST_PROXY 已生效,系统设置不可覆盖');
|
||||
throw new Error(t('环境变量 QL_TRUST_PROXY 已生效,系统设置不可覆盖'));
|
||||
}
|
||||
|
||||
const trustProxy = validateTrustProxy(value);
|
||||
@@ -81,7 +82,7 @@ export async function updateTrustProxy(value: string) {
|
||||
where: { type: AuthDataType.systemConfig },
|
||||
});
|
||||
if (!doc) {
|
||||
throw new Error('系统配置不存在');
|
||||
throw new Error(t('系统配置不存在'));
|
||||
}
|
||||
|
||||
const plain = doc.get({ plain: true });
|
||||
|
||||
@@ -20,8 +20,10 @@ export const scheduleSchema = Joi.string()
|
||||
.required()
|
||||
.custom(validateSchedule)
|
||||
.messages({
|
||||
'any.required': '定时规则不能为空',
|
||||
'any.invalid': '无效的定时规则',
|
||||
'string.empty': '定时规则不能为空',
|
||||
'string.base': '定时规则必须是字符串',
|
||||
});
|
||||
|
||||
export const commonCronSchema = {
|
||||
@@ -30,7 +32,27 @@ export const commonCronSchema = {
|
||||
schedule: scheduleSchema,
|
||||
labels: Joi.array().optional().allow(null),
|
||||
sub_id: Joi.number().optional().allow(null),
|
||||
extra_schedules: Joi.array().optional().allow(null),
|
||||
extra_schedules: Joi.array()
|
||||
.items(
|
||||
Joi.object({
|
||||
schedule: Joi.string()
|
||||
.required()
|
||||
.custom((value, helpers) =>
|
||||
isValidCronSchedule(value) ? value : helpers.error('any.invalid'),
|
||||
)
|
||||
.messages({
|
||||
'any.required': '定时规则不能为空',
|
||||
'any.invalid': '无效的定时规则',
|
||||
'string.empty': '定时规则不能为空',
|
||||
'string.base': '定时规则必须是字符串',
|
||||
}),
|
||||
})
|
||||
.unknown(true)
|
||||
.messages({ 'object.base': '额外定时规则必须是对象' }),
|
||||
)
|
||||
.optional()
|
||||
.allow(null)
|
||||
.messages({ 'array.base': '额外定时规则必须是数组' }),
|
||||
task_before: Joi.string().optional().allow('').allow(null),
|
||||
task_after: Joi.string().optional().allow('').allow(null),
|
||||
log_name: Joi.string()
|
||||
|
||||
+4
-4
@@ -76,7 +76,7 @@ ql auth status --scope crons --json
|
||||
# For automation, inject QL_CLIENT_ID and QL_CLIENT_SECRET before the same login command
|
||||
```
|
||||
|
||||
Credentials/token are stored in `~/.config/qinglong/cli.json`, a plaintext file owned by the current user with mode 0600. `QL_CLI_CONFIG` selects another file. Login validates application credentials, not every resource permission.
|
||||
Credentials/token are stored in `~/.config/qinglong/cli.json` as plaintext. The CLI requests mode `0600` when creating the file and rejects symlinks and non-regular files when reading it. Windows, WSL mounts, and some network or device filesystems do not reliably report POSIX permissions, so reads do not depend on mode bits or UID. Keep the config in a location accessible only to your user and protect it with the filesystem's actual permissions or ACLs; use the direct-token environment variables below if that cannot be guaranteed. `QL_CLI_CONFIG` selects another file. Login validates application credentials, not every resource permission.
|
||||
|
||||
### Direct access token
|
||||
|
||||
@@ -128,11 +128,11 @@ npm run test:cli
|
||||
node cli/scripts/verify-package.cjs
|
||||
```
|
||||
|
||||
Tests cover requests, token refresh, output, errors, no automatic retries, permissions and isolated installation. Package verification installs the archive offline, verifies the sole ql executable and rejects local commands.
|
||||
Tests cover requests, token refresh, output, errors, no automatic retries, config file type and isolated installation. Package verification installs the archive offline, verifies the sole ql executable and rejects local commands.
|
||||
|
||||
The CLI package workflow checks, builds and tests Node 22.12/24 on relevant PRs, develop/master pushes and manual runs. Node 24 uploads the archive verified by offline installation as `qinglong-cli-<commit>`. After both matrix jobs succeed, master pushes publish that exact archive to npm as latest, using GitHub Actions OIDC trusted publishing without an NPM_TOKEN secret. Manual runs publish only when run on master with publish enabled; PRs, develop and forks never publish. Configure the npm Trusted Publisher for repository `whyour/qinglong`, workflow `cli-package.yml`, with `npm publish` allowed. The panel package `@whyour/qinglong` separately trusts `build-docker-image.yml`. Publishing uses Node 24 with `id-token: write` granted only to the publish job. New packages need an initial publication before configuring their trusted publisher.
|
||||
The CLI package workflow checks, builds and tests Node 22.12/24 on relevant PRs, develop/master pushes and manual runs, and verifies saved login and command reads on real Windows and Linux ARM64 filesystems. Node 24 uploads the archive verified by offline installation as `qinglong-cli-<commit>`. After all validation jobs succeed, master pushes publish that exact archive to npm as `latest`; a manual run on master with `publish` enabled can also publish a stable release. Develop pushes only verify. A manual run on develop with `publish` enabled and `prerelease_version` set (for example `0.1.5-beta.0`) publishes under the matching `alpha`, `beta` or `rc` tag. PRs and forks never publish. CLI-only changes do not trigger the main image build, except for the few internal CLI upgrade files used by the image. Configure the npm Trusted Publisher for repository `whyour/qinglong`, workflow `cli-package.yml`, with `npm publish` allowed. The panel package `@whyour/qinglong` separately trusts `build-docker-image.yml`. The publish job uses Node 24 and GitHub Actions OIDC without an `NPM_TOKEN` secret, with `id-token: write` granted only to that job. New packages need an initial publication before configuring their trusted publisher.
|
||||
|
||||
The CLI has an independent stable version in cli/package.json and cli/package-lock.json. Before releasing changes, run `npm version patch --prefix cli --no-git-tag-version` (or minor/major) and commit both files. Existing versions are skipped with a notice; registry failures stop publication. Only stable X.Y.Z versions are published by this workflow. Publication does not rebuild the verified archive or run package lifecycle scripts.
|
||||
The CLI stable version is maintained independently in cli/package.json and cli/package-lock.json. Before releasing changes, run `npm version patch --prefix cli --no-git-tag-version` (or minor/major) and commit both files. A develop prerelease version is set only in the workflow run and is not written back to the repository. An existing stable version is skipped with a notice; a duplicate prerelease version fails, as does a registry lookup error. The publish job does not rebuild the verified archive or run package lifecycle scripts.
|
||||
|
||||
## Panel-internal tools
|
||||
|
||||
|
||||
+4
-4
@@ -76,7 +76,7 @@ ql auth status --scope crons --json
|
||||
# 自动化环境事先注入 QL_CLIENT_ID 和 QL_CLIENT_SECRET,再运行同一 login 命令
|
||||
```
|
||||
|
||||
凭据和 token 保存到 `~/.config/qinglong/cli.json`,为当前用户所有的 0600 明文文件。`QL_CLI_CONFIG` 可选择其他配置文件。登录只验证应用凭据,不意味着具备所有资源权限。
|
||||
凭据和 token 保存到 `~/.config/qinglong/cli.json` 明文文件。CLI 创建文件时会请求 `0600` 权限,并在读取时拒绝符号链接和非普通文件;由于 Windows、WSL 挂载盘及部分网络或设备文件系统不能可靠提供 POSIX 权限位,读取时不以权限位或 UID 为准。请将配置保存在仅当前用户可访问的位置,并按实际文件系统的权限或 ACL 保护它;无法保证时可使用下述环境令牌方式。`QL_CLI_CONFIG` 可选择其他配置文件。登录只验证应用凭据,不意味着具备所有资源权限。
|
||||
|
||||
### 直接使用访问令牌
|
||||
|
||||
@@ -128,11 +128,11 @@ npm run test:cli
|
||||
node cli/scripts/verify-package.cjs
|
||||
```
|
||||
|
||||
测试覆盖请求格式、认证刷新、输出、错误、禁止自动重试、权限和独立安装。打包验证会离线安装 tgz,并确认唯一入口是 `ql`,本机命令不可调用。
|
||||
测试覆盖请求格式、认证刷新、输出、错误、禁止自动重试、配置文件类型和独立安装。打包验证会离线安装 tgz,并确认唯一入口是 `ql`,本机命令不可调用。
|
||||
|
||||
CLI package 工作流在相关 PR、develop/master 推送和手动触发时执行 Node 22.12/24 类型检查、构建及测试。Node 24 上传通过离线安装验证的 `qinglong-cli-<commit>` artifact。两个矩阵任务成功后,master 推送会将这份已验证的 tgz 发布到 npm 的 latest 标签,通过 GitHub Actions OIDC 可信发布,无需 `NPM_TOKEN` Secret。手动运行需选择 master 并勾选 publish;PR、develop 和 fork 不发布。npm 的 Trusted Publisher 需绑定仓库 `whyour/qinglong` 和工作流 `cli-package.yml`,允许 `npm publish`;面板包 `@whyour/qinglong` 单独绑定 `build-docker-image.yml`。发布 job 使用 Node 24,并仅在该 job 授予 `id-token: write`。新包需先完成首次发布,再配置对应包的可信发布关系。
|
||||
CLI package 工作流在相关 PR、develop/master 推送和手动触发时执行 Node 22.12/24 类型检查、构建及测试,并在 Windows 与 Linux ARM64 上用真实文件系统验证登录和命令读取。Node 24 上传通过离线安装验证的 `qinglong-cli-<commit>` artifact。全部验证成功后,master 推送会将这份 tgz 发布到 npm 的 `latest` 标签;手动在 master 勾选 `publish` 也可发布稳定版。develop 推送只验证;手动在 develop 勾选 `publish` 并填写 `prerelease_version`(例如 `0.1.5-beta.0`)可发布到对应的 `alpha`、`beta` 或 `rc` 标签。PR 和 fork 不发布。CLI 专属改动不会触发主镜像构建,少数被镜像使用的 CLI 内部升级文件除外。npm 的 Trusted Publisher 需绑定仓库 `whyour/qinglong` 和工作流 `cli-package.yml`,允许 `npm publish`;面板包 `@whyour/qinglong` 单独绑定 `build-docker-image.yml`。发布 job 使用 Node 24 和 GitHub Actions OIDC,无需 `NPM_TOKEN` Secret,仅该 job 授予 `id-token: write`。新包需先完成首次发布,再配置对应包的可信发布关系。
|
||||
|
||||
CLI 版本独立维护在 cli/package.json 和 cli/package-lock.json。发布改动前执行 `npm version patch --prefix cli --no-git-tag-version`(也可用 minor/major),提交这两个文件。已发布的版本会提示并跳过;registry 查询失败则停止发布。该流程仅发布 X.Y.Z 稳定版本,不重新构建产物或执行包生命周期脚本。
|
||||
CLI 稳定版版本号独立维护在 cli/package.json 和 cli/package-lock.json。发布改动前执行 `npm version patch --prefix cli --no-git-tag-version`(也可用 minor/major),提交这两个文件。develop 预发布版版本号仅在工作流运行时设置,不写回仓库。已发布的稳定版版本号会提示并跳过;重复的预发布版版本号会报错,registry 查询失败也会停止发布。发布 job 不重新构建产物或执行包生命周期脚本。
|
||||
|
||||
## 面板内部工具
|
||||
|
||||
|
||||
Generated
+78
-4
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "@whyour/qinglong-cli",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@whyour/qinglong-cli",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.4",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"ql": "dist/npm/ql.js"
|
||||
},
|
||||
@@ -14,12 +15,12 @@
|
||||
"@types/node": "22.19.0",
|
||||
"commander": "15.0.0",
|
||||
"esbuild": "0.28.2",
|
||||
"tar": "7.5.20",
|
||||
"typescript": "5.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"license": "Apache-2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.2",
|
||||
@@ -463,6 +464,19 @@
|
||||
"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": {
|
||||
"version": "22.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.0.tgz",
|
||||
@@ -473,6 +487,16 @@
|
||||
"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": {
|
||||
"version": "15.0.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
|
||||
@@ -525,6 +549,46 @@
|
||||
"@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": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",
|
||||
@@ -545,6 +609,16 @@
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@whyour/qinglong-cli",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.4",
|
||||
"description": "Authenticated remote management CLI for QingLong 2.x",
|
||||
"type": "commonjs",
|
||||
"bin": {
|
||||
@@ -26,7 +26,8 @@
|
||||
"commander": "15.0.0",
|
||||
"esbuild": "0.28.2",
|
||||
"typescript": "5.2.2",
|
||||
"@types/node": "22.19.0"
|
||||
"@types/node": "22.19.0",
|
||||
"tar": "7.5.20"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -17,6 +17,25 @@ fs.copyFileSync(
|
||||
path.join(root, 'src/internal/maintenance/upgradeArtifacts.cjs'),
|
||||
path.join(root, 'dist/internal/maintenance/upgradeArtifacts.cjs'),
|
||||
);
|
||||
fs.copyFileSync(
|
||||
path.join(root, 'src/internal/maintenance/panelUpgrade.cjs'),
|
||||
path.join(root, 'dist/internal/maintenance/panelUpgrade.cjs'),
|
||||
);
|
||||
fs.copyFileSync(
|
||||
path.join(root, 'src/internal/maintenance/nodeRuntime.cjs'),
|
||||
path.join(root, 'dist/internal/maintenance/nodeRuntime.cjs'),
|
||||
);
|
||||
// The operational CLI may live outside the panel and has no panel node_modules.
|
||||
require('esbuild').buildSync({
|
||||
entryPoints: [path.join(root, 'src/internal/maintenance/platformBundle.cjs')],
|
||||
outfile: path.join(root, 'dist/internal/maintenance/platformBundle.cjs'),
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
target: 'node20',
|
||||
minify: true,
|
||||
legalComments: 'inline',
|
||||
});
|
||||
require('esbuild').buildSync({
|
||||
entryPoints: [path.join(root, 'src/shared/cli/commander.ts')],
|
||||
outfile: path.join(root, 'dist/shared/cli/commander.js'),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# OpenAPI 全量参考 / Complete OpenAPI reference
|
||||
|
||||
CLI 对应本版本后端的 146 条有效路由。通过 `ql api routes --json` 查看方法、路径、对应命令、位置参数、请求体与上传字段;CLI 测试与 back/api 路由逐项核对。GET configs/:file、scripts/:file、logs/:file 已返回 410,改用 detail 命令,不再暴露旧入口。旧版面板可能没有新路由,以服务端响应为准。
|
||||
CLI 对应本版本后端的 151 条有效路由。通过 `ql api routes --json` 查看方法、路径、对应命令、位置参数、请求体与上传字段;CLI 测试与 back/api 路由逐项核对。GET configs/:file、scripts/:file、logs/:file 已返回 410,改用 detail 命令,不再暴露旧入口。旧版面板可能没有新路由,以服务端响应为准。
|
||||
|
||||
The catalogue covers 146 active routes in the current backend. `ql api routes --json` lists methods, paths, commands, positional parameters, bodies and upload fields. A route-coverage test detects drift. Three retired GET :file routes return 410; use detail-based commands. Older panel versions may not implement newer routes.
|
||||
The catalogue covers 151 active routes in the current backend. `ql api routes --json` lists methods, paths, commands, positional parameters, bodies and upload fields. A route-coverage test detects drift. Three retired GET :file routes return 410; use detail-based commands. Older panel versions may not implement newer routes.
|
||||
|
||||
## 认证与权限 / Authentication and permissions
|
||||
|
||||
@@ -53,12 +53,19 @@ ql config save --data '{"name":"example.sh","content":"# example"}' --json
|
||||
ql config get --query '{"path":"example.sh"}' --json
|
||||
ql log download --data '{"filename":"example.log","path":"example"}' --output ./example.log --json
|
||||
ql dependency create --data '[{"name":"example-package","type":0}]' --json
|
||||
ql dependency refresh --data '{"type":"python3"}' --json
|
||||
ql dependency refresh-active --json
|
||||
ql dependency refresh-status <JOB_UUID> --json
|
||||
ql system data-export --data '{}' --output ./panel.tgz --json
|
||||
ql system data-import --file ./panel.tgz --json
|
||||
ql api request PUT /open/crons/run --data '[12,13]' --json
|
||||
ql api request GET /open/crons --query '{"page":1,"size":100,"searchValue":"demo"}' --json
|
||||
```
|
||||
|
||||
依赖 refresh 的 type 为 nodejs 或 python3,立即返回后台任务 id 与 state;用 refresh-status 查询该 UUID,直到 completed 或 failed。refresh-active 返回运行中的任务或 null,普通 list 不触发检查。
|
||||
|
||||
Dependency refresh accepts type nodejs or python3 and immediately returns a background job id and state. Poll refresh-status with that UUID until completed or failed. refresh-active returns the running job or null; listing dependencies does not trigger checks.
|
||||
|
||||
任务 create/update 需要 command/schedule;name、labels、sub_id、extra_schedules、task_before/after、log_name、allow_multiple_instances、work_dir 等字段可通过 --data 提交。订阅 create 需要 type/url/alias/schedule_type,update 需要 id/type/url/alias;interval_schedule、pull_option、dependences、extensions、sub_before/after、proxy、autoAddCron/autoDelCron 等用 --data。private-repo 凭据仅通过受保护文件或 stdin 提供。环境变量与依赖 create 接受对象数组,应用 scopes 为字符串数组。其余准确字段/枚举由当前后端 Joi schema 校验:见 [back/api](https://github.com/whyour/qinglong/tree/develop/back/api) 和 [定时规则 schema](https://github.com/whyour/qinglong/blob/develop/back/validation/schedule.ts)。
|
||||
|
||||
Task creation/update needs command/schedule; additional fields use --data. Subscription creation needs type/url/alias/schedule_type; updates need id/type/url/alias. Interval schedules, private repository credentials, filters, hooks and booleans use --data. Env/dependency creation accepts arrays; application scopes is a string array. The server validates field types/enums; the linked backend schemas are authoritative.
|
||||
@@ -151,6 +158,9 @@ System/user operations below execute remotely through the panel API. They do not
|
||||
| `ql log delete ` | DELETE | `logs` | --data, --query |
|
||||
| `ql log download ` | POST | `logs/download` | --data, --output, --query |
|
||||
| `ql dependency list ` | GET | `dependencies` | --query |
|
||||
| `ql dependency refresh ` | POST | `dependencies/refresh` | --data, --query |
|
||||
| `ql dependency refresh-active ` | GET | `dependencies/refresh/active` | --query |
|
||||
| `ql dependency refresh-status <id>` | GET | `dependencies/refresh/:id` | --query |
|
||||
| `ql dependency create ` | POST | `dependencies` | --data, --query |
|
||||
| `ql dependency update <id>` | PUT | `dependencies` | --data, --query |
|
||||
| `ql dependency delete <id...>` | DELETE | `dependencies` | --query |
|
||||
|
||||
@@ -82,7 +82,7 @@ export const nodePreloadSource: string = [
|
||||
'{ if [ -n "$inline" ]; then eval "$inline"; fi; } &&',
|
||||
"printf '%s\\\\n' '__sitecustomize__' &&",
|
||||
'node -e \'require("fs").writeFileSync(process.argv[1], JSON.stringify(process.env))\' "$output"`;',
|
||||
" if (task_before) console.log('执行前置命令\\n');",
|
||||
" if (task_before) console.log(process.env.QL_LANG === 'en' ? 'Executing before command\\n' : '执行前置命令\\n');",
|
||||
' const hookArgs = JSON.parse(process.env.QL_CLI_PRELOAD_ARGS || JSON.stringify([fileName]));',
|
||||
" const res = execFileSync('/bin/bash', ['-c', commands, 'ql-before', file_task_before, tempFile, task_before || '', ...hookArgs], {",
|
||||
" encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024,",
|
||||
@@ -115,7 +115,7 @@ export const nodePreloadSource: string = [
|
||||
' console.log(output);',
|
||||
' }',
|
||||
' if (task_before) {',
|
||||
" console.log('执行前置命令结束\\n');",
|
||||
" console.log(process.env.QL_LANG === 'en' ? 'Before command finished\\n' : '执行前置命令结束\\n');",
|
||||
' }',
|
||||
' } catch (error) {',
|
||||
" if (!error.message.includes('spawnSync /bin/bash E2BIG')) {",
|
||||
@@ -124,7 +124,7 @@ export const nodePreloadSource: string = [
|
||||
' // environment variable is too large',
|
||||
' }',
|
||||
' if (task_before) {',
|
||||
" console.log('执行前置命令结束\\n');",
|
||||
" console.log(process.env.QL_LANG === 'en' ? 'Before command finished\\n' : '执行前置命令结束\\n');",
|
||||
' }',
|
||||
' }',
|
||||
'',
|
||||
@@ -214,7 +214,7 @@ export const pythonPreloadSource: string = [
|
||||
' ',
|
||||
' task_before = os.getenv("task_before", "")',
|
||||
' if task_before:',
|
||||
' print("执行前置命令\\n")',
|
||||
' print("Executing before command\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令\\n")',
|
||||
' command = """file="$1"; output="$2"; inline="$3"; shift 3',
|
||||
'source "$file" "$@" &&',
|
||||
'{ if [ -n "$inline" ]; then eval "$inline"; fi; } &&',
|
||||
@@ -244,12 +244,12 @@ export const pythonPreloadSource: string = [
|
||||
' if len(output) > 0:',
|
||||
' print(output)',
|
||||
' if task_before:',
|
||||
' print("执行前置命令结束\\n")',
|
||||
' print("Before command finished\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\\n")',
|
||||
'',
|
||||
' except subprocess.CalledProcessError as error:',
|
||||
' print(f"\\ue926 run task before error: {error}")',
|
||||
' if task_before:',
|
||||
' print("执行前置命令结束\\n")',
|
||||
' print("Before command finished\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\\n")',
|
||||
' except OSError as error:',
|
||||
' error_message = str(error)',
|
||||
' if "Argument list too long" not in error_message:',
|
||||
@@ -257,11 +257,11 @@ export const pythonPreloadSource: string = [
|
||||
' # else:',
|
||||
' # environment variable is too large',
|
||||
' if task_before:',
|
||||
' print("执行前置命令结束\\n")',
|
||||
' print("Before command finished\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\\n")',
|
||||
' except Exception as error:',
|
||||
' print(f"\\ue926 run task before error: {error}")',
|
||||
' if task_before:',
|
||||
' print("执行前置命令结束\\n")',
|
||||
' print("Before command finished\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\\n")',
|
||||
'',
|
||||
' import task_before',
|
||||
'',
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Node 24's ARMv6 JS compiler tiers can trap on ARM1176. Keep WebAssembly
|
||||
// enabled for fetch/undici; --jitless disables their HTTP parser as well.
|
||||
const armv6ServerArgs = Object.freeze([
|
||||
'--no-opt',
|
||||
'--no-sparkplug',
|
||||
'--no-maglev',
|
||||
]);
|
||||
function serverRuntimeArgs(runtime = process) {
|
||||
return runtime.arch === 'arm' &&
|
||||
Number(runtime.config.variables.arm_version) === 6 &&
|
||||
Number(runtime.versions.node.split('.')[0]) >= 24
|
||||
? [...armv6ServerArgs]
|
||||
: [];
|
||||
}
|
||||
|
||||
module.exports = { serverRuntimeArgs, armv6ServerArgs };
|
||||
if (require.main === module)
|
||||
process.stdout.write(serverRuntimeArgs().join(' '));
|
||||
@@ -7,6 +7,10 @@ import type { LocalContext } from '../runtime/context';
|
||||
import { atomicWrite } from '../runtime/files';
|
||||
import { runProcess, checkedProcess } from '../runtime/process';
|
||||
import { findDirectBackendPids } from '../runtime/backendProcesses';
|
||||
const { serverRuntimeArgs, armv6ServerArgs } = require('./nodeRuntime.cjs') as {
|
||||
serverRuntimeArgs(): string[];
|
||||
armv6ServerArgs: readonly string[];
|
||||
};
|
||||
|
||||
export async function repairConfiguration(
|
||||
context: LocalContext,
|
||||
@@ -117,7 +121,11 @@ async function stopDirectBackend(context: LocalContext): Promise<void> {
|
||||
for (const line of result.stdout.split('\n')) {
|
||||
const match = line.trim().match(/^(\d+)\s+(\S+)\s+(.*)$/);
|
||||
if (!match || !/^node(?:\d+)?$/.test(path.basename(match[2]!))) continue;
|
||||
if (match[3] !== entry) continue;
|
||||
if (
|
||||
match[3] !== entry &&
|
||||
match[3] !== `${armv6ServerArgs.join(' ')} ${entry}`
|
||||
)
|
||||
continue;
|
||||
const pid = Number(match[1]);
|
||||
if (pid === process.pid) continue;
|
||||
try {
|
||||
@@ -150,6 +158,14 @@ export async function startPanel(
|
||||
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)) {
|
||||
const flushed = await runProcess('pm2', ['flush'], options);
|
||||
if (flushed.code === 0) {
|
||||
@@ -171,7 +187,7 @@ export async function startPanel(
|
||||
0o600,
|
||||
);
|
||||
try {
|
||||
const child = spawn(process.execPath, [entry], {
|
||||
const child = spawn(process.execPath, [...serverRuntimeArgs(), entry], {
|
||||
...options,
|
||||
detached: true,
|
||||
stdio: ['ignore', log.fd, log.fd],
|
||||
|
||||
@@ -0,0 +1,577 @@
|
||||
// Persistent panel jobs run independently of HTTP requests and browser sessions.
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const { randomUUID } = require('node:crypto');
|
||||
const { spawn } = require('node:child_process');
|
||||
const http = require('node:http');
|
||||
const { setTimeout: delay } = require('node:timers/promises');
|
||||
const artifacts = require('./upgradeArtifacts.cjs');
|
||||
const { nodeRuntimeArgs } = require('./platformBundle.cjs');
|
||||
|
||||
const active = new Set(['starting', 'running', 'restarting']);
|
||||
const stateFile = (tmp) => path.join(tmp, 'panel-upgrade.json');
|
||||
const logFile = (tmp) => path.join(tmp, 'panel-upgrade.log');
|
||||
const launches = new Map();
|
||||
|
||||
async function withPanelLifecycle(root, tmp, env, operation, signal) {
|
||||
if (!env.QL_CLI_ROOT)
|
||||
return artifacts.withLifecycleHooks(root, tmp, env, operation);
|
||||
if (!path.isAbsolute(env.QL_CLI_ROOT))
|
||||
throw new Error('QL_CLI_ROOT must be absolute');
|
||||
const internal = path.join(env.QL_CLI_ROOT, 'dist/internal');
|
||||
// Keep the selected installation's operators loaded across file replacement.
|
||||
const { createContext, sourceEnvironment } = require(path.join(
|
||||
internal,
|
||||
'runtime/context.js',
|
||||
));
|
||||
const { cancellableOperation } = require(path.join(
|
||||
internal,
|
||||
'runtime/cancellation.js',
|
||||
));
|
||||
const operator = require(path.join(internal, 'maintenance/operator.js'));
|
||||
const { prepareUpgradeSelection } = require(path.join(
|
||||
internal,
|
||||
'maintenance/upgradeSelection.js',
|
||||
));
|
||||
const context = createContext(
|
||||
{ root, 'data-dir': env.dir_data || env.QL_DATA_DIR },
|
||||
env,
|
||||
);
|
||||
context.env = await sourceEnvironment(
|
||||
context.env,
|
||||
[
|
||||
context.paths.file_config_user,
|
||||
path.join(context.paths.dir_preload, 'lang_env.sh'),
|
||||
],
|
||||
[],
|
||||
{ signal },
|
||||
);
|
||||
const hook = (name, args = [], signal) =>
|
||||
cancellableOperation(signal, () => {
|
||||
if (name === 'install')
|
||||
return operator.installPanelDependencies(context, args[0]);
|
||||
if (name === 'stop') return operator.stopPanel(context);
|
||||
if (name === 'start') return operator.startPanel(context);
|
||||
throw new Error('Invalid lifecycle operation');
|
||||
});
|
||||
return operation(hook, (selected) =>
|
||||
prepareUpgradeSelection(context, selected.source, selected.static),
|
||||
);
|
||||
}
|
||||
|
||||
async function targetVersion(source) {
|
||||
const text = await fs.readFile(path.join(source, 'version.yaml'), 'utf8');
|
||||
const version = text.match(
|
||||
/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?)/m,
|
||||
)?.[1];
|
||||
if (!version) throw new Error('Invalid update version');
|
||||
return version;
|
||||
}
|
||||
|
||||
async function readRunningBuild(env) {
|
||||
let base = env.QlBaseUrl || '';
|
||||
if (base && !base.startsWith('/')) base = `/${base}`;
|
||||
base = base.replace(/\/$/, '');
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => request.destroy(new Error('Version check timed out')),
|
||||
5000,
|
||||
);
|
||||
const request = http.get(
|
||||
{
|
||||
hostname: '127.0.0.1',
|
||||
port: Number(env.QlPort || 5700),
|
||||
path: `${base}/api/health`,
|
||||
},
|
||||
(response) => {
|
||||
let body = '';
|
||||
response.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
if (body.length > 65536)
|
||||
request.destroy(new Error('Invalid version response'));
|
||||
});
|
||||
response.on('error', reject);
|
||||
response.on('end', () => {
|
||||
try {
|
||||
const result = JSON.parse(body);
|
||||
if (
|
||||
response.statusCode !== 200 ||
|
||||
result.code !== 200 ||
|
||||
result.data?.status !== 'ok'
|
||||
)
|
||||
throw new Error('后端启动检查失败。');
|
||||
resolve(result.data.build);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
request.on('error', reject);
|
||||
request.on('close', () => clearTimeout(timer));
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyRunningVersion(env, expected, previousBuild) {
|
||||
const build = await readRunningBuild(env);
|
||||
if (
|
||||
!build ||
|
||||
!build.instanceId ||
|
||||
build.instanceId === previousBuild?.instanceId ||
|
||||
(previousBuild?.parentPid !== undefined &&
|
||||
build.parentPid === previousBuild.parentPid) ||
|
||||
build.version !== expected.version ||
|
||||
build.sourceCommit !== expected.sourceCommit ||
|
||||
build.backendSha256 !== expected.backendSha256
|
||||
)
|
||||
throw new Error('启动版本与更新包不一致');
|
||||
}
|
||||
|
||||
async function stagedMetadata(selected) {
|
||||
const manifest = JSON.parse(
|
||||
await fs.readFile(path.join(selected.static, 'build-info.json'), 'utf8'),
|
||||
);
|
||||
return {
|
||||
version: await targetVersion(selected.source),
|
||||
sourceCommit: manifest.sourceCommit,
|
||||
backendSha256: manifest.files['build/app.js'],
|
||||
stageDirectory: path.basename(path.dirname(selected.source)),
|
||||
};
|
||||
}
|
||||
|
||||
function assertSelected(expected, actual) {
|
||||
for (const key of [
|
||||
'sourceCommit',
|
||||
'backendSha256',
|
||||
'stageDirectory',
|
||||
'version',
|
||||
]) {
|
||||
if (expected[key] !== actual[key])
|
||||
throw new Error('暂存更新已变化,请重新下载更新。');
|
||||
}
|
||||
}
|
||||
|
||||
async function save(tmp, state) {
|
||||
const temporary = `${stateFile(tmp)}.${randomUUID()}`;
|
||||
await fs.writeFile(temporary, JSON.stringify(state), { mode: 0o600 });
|
||||
await fs.rename(temporary, stateFile(tmp));
|
||||
}
|
||||
|
||||
async function readState(tmp) {
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(stateFile(tmp), 'utf8'));
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return { status: 'idle' };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function getStatus(tmp) {
|
||||
const state = await readState(tmp);
|
||||
if (active.has(state.status)) {
|
||||
let alive = false;
|
||||
if (Number.isSafeInteger(state.pid) && state.pid > 0) {
|
||||
try {
|
||||
process.kill(state.pid, 0);
|
||||
alive = true;
|
||||
} catch (error) {
|
||||
alive = error.code === 'EPERM';
|
||||
}
|
||||
} else alive = Date.now() - state.startedAt < 10000;
|
||||
if (!alive) {
|
||||
state.status = 'failed';
|
||||
state.error = '升级任务意外中断,请重试';
|
||||
}
|
||||
}
|
||||
// Bound reads even when an installer produces a very large log.
|
||||
const handle = await fs.open(logFile(tmp), 'r').catch(() => undefined);
|
||||
if (handle) {
|
||||
try {
|
||||
const { size } = await handle.stat();
|
||||
const buffer = Buffer.alloc(Math.min(size, 32768));
|
||||
const { bytesRead } = await handle.read(
|
||||
buffer,
|
||||
0,
|
||||
buffer.length,
|
||||
Math.max(0, size - buffer.length),
|
||||
);
|
||||
state.log = buffer.subarray(0, bytesRead).toString();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
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) {
|
||||
if (!['download', 'apply'].includes(action))
|
||||
throw new Error('Invalid upgrade action');
|
||||
await fs.mkdir(tmp, { recursive: true });
|
||||
const preceding = launches.get(tmp) || Promise.resolve();
|
||||
const launching = preceding
|
||||
.catch(() => {})
|
||||
.then(() =>
|
||||
artifacts.withUpgradeLock(
|
||||
path.join(tmp, 'panel-job-launch'),
|
||||
async () => {
|
||||
const previous = await getStatus(tmp);
|
||||
if (active.has(previous.status)) {
|
||||
if (previous.action !== action)
|
||||
throw new Error('已有更新或重载正在执行。');
|
||||
return previous;
|
||||
}
|
||||
let selected;
|
||||
let metadata;
|
||||
if (action === 'apply') {
|
||||
// Keep requests short on slow devices. The detached worker performs
|
||||
// complete file/dependency verification before it can stop the panel.
|
||||
try {
|
||||
selected = await artifacts.selectedUpgrade(
|
||||
tmp,
|
||||
artifacts.releaseBranch(env),
|
||||
env,
|
||||
{ metadataOnly: true },
|
||||
);
|
||||
metadata = await stagedMetadata(selected);
|
||||
if (previous.status === 'ready')
|
||||
assertSelected(previous, metadata);
|
||||
} catch (error) {
|
||||
const { log, ...record } = previous;
|
||||
await save(tmp, {
|
||||
...record,
|
||||
status: 'failed',
|
||||
phase: 'verifying',
|
||||
error: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
} else if (previous.status === 'ready') return previous;
|
||||
const state = {
|
||||
id: randomUUID(),
|
||||
action,
|
||||
status: 'starting',
|
||||
phase: action === 'apply' ? 'verifying' : 'downloading',
|
||||
startedAt: Date.now(),
|
||||
...metadata,
|
||||
};
|
||||
await save(tmp, state);
|
||||
const log = await fs.open(logFile(tmp), 'w', 0o600);
|
||||
try {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[__filename, 'launch', state.id],
|
||||
{
|
||||
env: { ...env, QL_DIR: root, dir_tmp: tmp },
|
||||
detached: true,
|
||||
stdio: ['ignore', log.fd, log.fd],
|
||||
},
|
||||
);
|
||||
await new Promise((resolve, reject) => {
|
||||
child.once('exit', (code, signal) => {
|
||||
if (code === 0) resolve();
|
||||
else
|
||||
reject(
|
||||
new Error(`Upgrade launcher exited (${signal || code})`),
|
||||
);
|
||||
});
|
||||
child.once('error', reject);
|
||||
});
|
||||
child.unref();
|
||||
} catch (error) {
|
||||
await save(tmp, {
|
||||
...state,
|
||||
status: 'failed',
|
||||
error: error.message,
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
await log.close();
|
||||
}
|
||||
return state;
|
||||
},
|
||||
env,
|
||||
),
|
||||
);
|
||||
launches.set(tmp, launching);
|
||||
try {
|
||||
return await launching;
|
||||
} finally {
|
||||
if (launches.get(tmp) === launching) launches.delete(tmp);
|
||||
}
|
||||
}
|
||||
|
||||
async function runJob({ root, tmp, env = process.env }, id) {
|
||||
let state = await readState(tmp);
|
||||
if (state.id !== id || state.status !== 'starting')
|
||||
throw new Error('Stale upgrade job');
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort(new Error('升级任务意外中断,请重试'));
|
||||
process.once('SIGTERM', abort);
|
||||
process.once('SIGINT', abort);
|
||||
// Serialize progress writes so an older tick cannot overwrite ready/completed.
|
||||
let writes = Promise.resolve();
|
||||
const report = (patch) => {
|
||||
state = { ...state, ...patch, pid: process.pid, updatedAt: Date.now() };
|
||||
const snapshot = { ...state };
|
||||
writes = writes.then(() => save(tmp, snapshot));
|
||||
return writes;
|
||||
};
|
||||
try {
|
||||
await report({ status: 'running' });
|
||||
let terminal;
|
||||
await artifacts.withUpgradeLock(
|
||||
tmp,
|
||||
() =>
|
||||
withPanelLifecycle(
|
||||
root,
|
||||
tmp,
|
||||
env,
|
||||
async (hook, prepareSelection) => {
|
||||
const run = (program, args, capture) =>
|
||||
artifacts.runProcess(
|
||||
env,
|
||||
program,
|
||||
args,
|
||||
capture,
|
||||
controller.signal,
|
||||
);
|
||||
if (state.action === 'download') {
|
||||
const selected = await artifacts.stageUpgrade({
|
||||
root,
|
||||
tmp,
|
||||
env,
|
||||
mirror: 'github',
|
||||
fallbackMirror: 'gitee',
|
||||
run,
|
||||
signal: controller.signal,
|
||||
onProgress: report,
|
||||
install: (source) =>
|
||||
hook('install', [source], controller.signal),
|
||||
});
|
||||
terminal = {
|
||||
status: 'ready',
|
||||
phase: 'ready',
|
||||
...(await stagedMetadata(selected)),
|
||||
};
|
||||
} else {
|
||||
const selected = await artifacts.selectedUpgrade(
|
||||
tmp,
|
||||
artifacts.releaseBranch(env),
|
||||
env,
|
||||
);
|
||||
assertSelected(state, await stagedMetadata(selected));
|
||||
const previousBuild = await readRunningBuild(env).catch(
|
||||
() => undefined,
|
||||
);
|
||||
const selection = await prepareSelection?.(selected);
|
||||
try {
|
||||
await artifacts.reloadSystem({
|
||||
root,
|
||||
tmp,
|
||||
env,
|
||||
data:
|
||||
env.dir_data || env.QL_DATA_DIR || path.join(root, 'data'),
|
||||
staticRoot: env.dir_static || path.join(root, 'static'),
|
||||
config:
|
||||
env.dir_config ||
|
||||
path.join(
|
||||
env.QL_DATA_DIR || path.join(root, 'data'),
|
||||
'config',
|
||||
),
|
||||
lifecycle: {
|
||||
signal: controller.signal,
|
||||
stop: async () => {
|
||||
await report({
|
||||
status: 'restarting',
|
||||
phase: 'restarting',
|
||||
});
|
||||
await hook('stop');
|
||||
},
|
||||
start: async (recover) => {
|
||||
await hook(
|
||||
'start',
|
||||
[],
|
||||
recover ? undefined : controller.signal,
|
||||
);
|
||||
await artifacts.waitForHealth(
|
||||
env,
|
||||
undefined,
|
||||
recover ? undefined : controller.signal,
|
||||
);
|
||||
if (!recover)
|
||||
await verifyRunningVersion(env, state, previousBuild);
|
||||
},
|
||||
},
|
||||
additionalReplacements: selection
|
||||
? [{ source: selection.source, target: selection.target }]
|
||||
: [],
|
||||
});
|
||||
} finally {
|
||||
if (selection)
|
||||
await fs.rm(selection.directory, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
terminal = { status: 'completed', phase: 'completed' };
|
||||
}
|
||||
},
|
||||
controller.signal,
|
||||
),
|
||||
env,
|
||||
);
|
||||
await report(terminal);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
// A failed write must not prevent recording the final failure.
|
||||
await writes.catch(() => {});
|
||||
writes = Promise.resolve();
|
||||
await report({ status: 'failed', error: error.message });
|
||||
} finally {
|
||||
process.removeListener('SIGTERM', abort);
|
||||
process.removeListener('SIGINT', abort);
|
||||
await writes;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getStatus,
|
||||
saveState: save,
|
||||
startJob,
|
||||
runJob,
|
||||
withPanelLifecycle,
|
||||
readRunningBuild,
|
||||
verifyRunningVersion,
|
||||
stagedMetadata,
|
||||
withPreparedJob,
|
||||
};
|
||||
async function launchWorker(id) {
|
||||
// detached creates a session but keeps the original parent. PM2 kills the
|
||||
// backend's entire descendant tree, so an intermediate launcher must exit
|
||||
// before the worker is allowed to stop that backend.
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[...nodeRuntimeArgs(), __filename, 'worker', id],
|
||||
{
|
||||
env: { ...process.env, QL_UPGRADE_LAUNCHER_PID: String(process.pid) },
|
||||
detached: true,
|
||||
stdio: 'inherit',
|
||||
},
|
||||
);
|
||||
await new Promise((resolve, reject) => {
|
||||
child.once('spawn', resolve);
|
||||
child.once('error', reject);
|
||||
});
|
||||
child.unref();
|
||||
}
|
||||
|
||||
async function runOrphanedWorker(id) {
|
||||
const launcher = Number(process.env.QL_UPGRADE_LAUNCHER_PID);
|
||||
const deadline = Date.now() + 10000;
|
||||
while (process.platform !== 'win32' && process.ppid === launcher) {
|
||||
if (Date.now() >= deadline)
|
||||
throw new Error('Upgrade launcher did not exit');
|
||||
await delay(10);
|
||||
}
|
||||
const root = process.env.QL_DIR;
|
||||
await runJob(
|
||||
{ root, tmp: process.env.dir_tmp || path.join(root, '.tmp') },
|
||||
id,
|
||||
);
|
||||
}
|
||||
|
||||
if (require.main === module && ['launch', 'worker'].includes(process.argv[2])) {
|
||||
const operation =
|
||||
process.argv[2] === 'launch' ? launchWorker : runOrphanedWorker;
|
||||
operation(process.argv[3]).catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
// One installed verifier handles release downloads and local offline archives.
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const os = require('node:os');
|
||||
const { execFile } = require('node:child_process');
|
||||
const { promisify } = require('node:util');
|
||||
const tar = require('tar');
|
||||
const { serverRuntimeArgs } = require('./nodeRuntime.cjs');
|
||||
const {
|
||||
prestartBackend,
|
||||
dependencyFailure,
|
||||
repairDependencies,
|
||||
} = require('./upgradePrestart.cjs');
|
||||
const execute = promisify(execFile);
|
||||
// Heavy archive parsing can hit unsupported V8 JIT paths on ARM1176. Keep
|
||||
// upgrade workers interpreted without changing the installed Node or server.
|
||||
const nodeRuntimeArgs = (runtime = process) =>
|
||||
runtime.arch === 'arm' && Number(runtime.config.variables.arm_version) === 6
|
||||
? ['--jitless']
|
||||
: [];
|
||||
const sha256 = (bytes) =>
|
||||
crypto.createHash('sha256').update(bytes).digest('hex');
|
||||
const inside = (root, filename) => {
|
||||
const relative = path.relative(root, filename);
|
||||
return (
|
||||
relative !== '..' &&
|
||||
!relative.startsWith(`..${path.sep}`) &&
|
||||
!path.isAbsolute(relative)
|
||||
);
|
||||
};
|
||||
const versionParts = (v) => String(v).replace(/^v/, '').split('.').map(Number);
|
||||
function atLeast(actual, minimum) {
|
||||
const a = versionParts(actual),
|
||||
b = versionParts(minimum);
|
||||
return (
|
||||
b.every((n) => Number.isFinite(n)) &&
|
||||
a.every((n) => Number.isFinite(n)) &&
|
||||
(a[0] > b[0] ||
|
||||
(a[0] === b[0] &&
|
||||
(a[1] > b[1] || (a[1] === b[1] && (a[2] || 0) >= (b[2] || 0)))))
|
||||
);
|
||||
}
|
||||
|
||||
async function runtimeTarget() {
|
||||
let libc, libcVersion;
|
||||
if (process.platform === 'linux') {
|
||||
const header = process.report.getReport().header;
|
||||
if (header.glibcVersionRuntime) {
|
||||
libc = 'glibc';
|
||||
libcVersion = header.glibcVersionRuntime;
|
||||
} else {
|
||||
let text = '';
|
||||
try {
|
||||
text = (await execute('ldd', ['--version'])).stdout;
|
||||
} catch (error) {
|
||||
text = `${error.stdout || ''}\n${error.stderr || ''}`;
|
||||
}
|
||||
const match = text.match(/musl[\s\S]*?Version\s+(\d+\.\d+\.\d+)/i);
|
||||
if (!match)
|
||||
throw new Error(
|
||||
'Cannot identify Linux libc; refusing a native dependency bundle',
|
||||
);
|
||||
libc = 'musl';
|
||||
libcVersion = match[1];
|
||||
}
|
||||
}
|
||||
const armVersion =
|
||||
process.arch === 'arm'
|
||||
? Number(process.config.variables.arm_version)
|
||||
: undefined;
|
||||
if (process.arch === 'arm' && ![6, 7, 8].includes(armVersion))
|
||||
throw new Error('Cannot identify ARM instruction set');
|
||||
const arch =
|
||||
process.arch === 'arm' ? `armv${armVersion >= 7 ? 7 : 6}` : process.arch;
|
||||
const armFloatAbi =
|
||||
process.arch === 'arm' ? process.config.variables.arm_float_abi : undefined;
|
||||
if (
|
||||
process.arch === 'arm' &&
|
||||
!['hard', 'soft', 'softfp'].includes(armFloatAbi)
|
||||
)
|
||||
throw new Error('Cannot identify ARM floating-point ABI');
|
||||
return {
|
||||
platform: process.platform,
|
||||
arch,
|
||||
libc,
|
||||
libcVersion,
|
||||
endianness: os.endianness(),
|
||||
armFloatAbi,
|
||||
};
|
||||
}
|
||||
|
||||
function targetKey(target) {
|
||||
return [target.platform, target.arch, target.libc].filter(Boolean).join('-');
|
||||
}
|
||||
|
||||
function assertCompatible(manifest, target, node = process.versions.node) {
|
||||
if (
|
||||
manifest.format !== 2 ||
|
||||
!/^[a-f0-9]{40}$/.test(manifest.sourceCommit) ||
|
||||
!/^[a-f0-9]{64}$/.test(manifest.lockfileSha256) ||
|
||||
!manifest.files ||
|
||||
typeof manifest.files !== 'object' ||
|
||||
Array.isArray(manifest.files) ||
|
||||
!Number.isSafeInteger(manifest.nodeApi) ||
|
||||
manifest.nodeApi < 6
|
||||
)
|
||||
throw new Error('Invalid platform bundle manifest');
|
||||
if (targetKey(manifest.target || {}) !== targetKey(target))
|
||||
throw new Error(`Bundle platform mismatch: need ${targetKey(target)}`);
|
||||
if (
|
||||
!atLeast(node, manifest.nodeMinimum) ||
|
||||
Number(process.versions.napi) < manifest.nodeApi
|
||||
)
|
||||
throw new Error(
|
||||
`Bundle requires Node >= ${manifest.nodeMinimum} and Node-API ${manifest.nodeApi}; the existing Node is retained`,
|
||||
);
|
||||
if (
|
||||
manifest.target.endianness !== target.endianness ||
|
||||
manifest.target.armFloatAbi !== target.armFloatAbi
|
||||
)
|
||||
throw new Error(
|
||||
'Bundle native byte order or ARM floating-point ABI mismatch',
|
||||
);
|
||||
if (target.libc && !atLeast(target.libcVersion, manifest.target.libcMinimum))
|
||||
throw new Error(
|
||||
`Bundle requires ${target.libc} >= ${manifest.target.libcMinimum}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function inventory(roots) {
|
||||
const files = {};
|
||||
async function visit(directory, prefix) {
|
||||
for (const name of (await fs.readdir(directory)).sort()) {
|
||||
const filename = path.join(directory, name),
|
||||
relative = `${prefix}/${name}`;
|
||||
const stat = await fs.lstat(filename);
|
||||
if (stat.isDirectory()) await visit(filename, relative);
|
||||
else if (stat.isFile())
|
||||
files[relative] = {
|
||||
type: 'file',
|
||||
mode: stat.mode & 0o777,
|
||||
sha256: sha256(await fs.readFile(filename)),
|
||||
};
|
||||
else if (stat.isSymbolicLink()) {
|
||||
const link = await fs.readlink(filename);
|
||||
const boundary = roots[prefix.split('/')[0]];
|
||||
if (
|
||||
path.isAbsolute(link) ||
|
||||
!inside(boundary, path.resolve(path.dirname(filename), link)) ||
|
||||
!inside(await fs.realpath(boundary), await fs.realpath(filename))
|
||||
)
|
||||
throw new Error(`Bundle link escapes its payload: ${relative}`);
|
||||
files[relative] = { type: 'link', link };
|
||||
} else throw new Error(`Unsupported bundle entry: ${relative}`);
|
||||
}
|
||||
}
|
||||
for (const [name, root] of Object.entries(roots)) await visit(root, name);
|
||||
return files;
|
||||
}
|
||||
|
||||
async function verifyPayload(selected, manifest, target) {
|
||||
assertCompatible(manifest, target);
|
||||
for (const name of ['data', '.tmp', '.git', '.env', 'static']) {
|
||||
if (
|
||||
await fs.lstat(path.join(selected.source, name)).catch((error) => {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
return null;
|
||||
})
|
||||
)
|
||||
throw new Error(`Reserved installation path in bundle: ${name}`);
|
||||
}
|
||||
const actual = await inventory({
|
||||
source: selected.source,
|
||||
static: selected.static,
|
||||
});
|
||||
const names = Object.keys(actual).sort(),
|
||||
expected = Object.keys(manifest.files).sort();
|
||||
if (
|
||||
JSON.stringify(names) !== JSON.stringify(expected) ||
|
||||
names.some(
|
||||
(name) =>
|
||||
JSON.stringify(actual[name]) !== JSON.stringify(manifest.files[name]),
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
'Platform bundle files or dependency checksums do not match',
|
||||
);
|
||||
if (
|
||||
sha256(await fs.readFile(path.join(selected.source, 'pnpm-lock.yaml'))) !==
|
||||
manifest.lockfileSha256
|
||||
)
|
||||
throw new Error('Platform bundle lockfile mismatch');
|
||||
await fs.access(
|
||||
path.join(selected.source, 'node_modules/sqlite3/package.json'),
|
||||
);
|
||||
}
|
||||
|
||||
async function smokeDependencies(source, signal) {
|
||||
// This probe is installed verifier code, never a verifier supplied by an archive.
|
||||
const probe = `
|
||||
const path = require('node:path'),fs = require('node:fs');
|
||||
const root = fs.realpathSync(process.cwd());
|
||||
const r = require('node:module').createRequire(path.join(root,'package.json'));
|
||||
for (const name of ['sqlite3','sequelize','cron-parser','@grpc/grpc-js','undici']) {
|
||||
if (!fs.realpathSync(r.resolve(name)).startsWith(root + path.sep)) throw new Error('Dependency outside staged payload: '+name);
|
||||
r(name);
|
||||
}
|
||||
const sqlite=r('sqlite3'), db=new sqlite.Database(':memory:');
|
||||
db.serialize(()=>{db.run('CREATE TABLE verify_bundle(value INTEGER)');db.run('INSERT INTO verify_bundle VALUES(42)');
|
||||
db.get('SELECT value FROM verify_bundle',(err,row)=>{if(err||row.value!==42){console.error(err||'SQLite readback failed');process.exitCode=1;}db.close();});});
|
||||
`;
|
||||
// Match the server's normal runtime. Node 20.18's built-in HTTP parser needs
|
||||
// WebAssembly, which --jitless disables; only archive workers need that flag.
|
||||
await execute(process.execPath, [...serverRuntimeArgs(process), '-e', probe], {
|
||||
cwd: source,
|
||||
env: { ...process.env, NODE_PATH: '', NODE_OPTIONS: '' },
|
||||
// ARM1176 devices need the same startup allowance as the backend probe.
|
||||
// Keep this bounded and cancellable without forcing an unnecessary repair.
|
||||
timeout: nodeRuntimeArgs().length ? 300000 : 30000,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
async function extractArchive(archive, destination) {
|
||||
const entries = new Map(),
|
||||
links = new Set();
|
||||
let unpacked = 0,
|
||||
invalid;
|
||||
await tar.t({
|
||||
file: archive,
|
||||
strict: true,
|
||||
onReadEntry(entry) {
|
||||
try {
|
||||
const name = entry.path.replace(/\/$/, '');
|
||||
if (
|
||||
!name ||
|
||||
name.includes('\\') ||
|
||||
/[\x00-\x1f\x7f]/.test(name) ||
|
||||
name.split('/').some((p) => !p || p === '.' || p === '..') ||
|
||||
!(name === 'qinglong' || name.startsWith('qinglong/')) ||
|
||||
entries.has(name) ||
|
||||
!['File', 'OldFile', 'Directory', 'SymbolicLink'].includes(entry.type)
|
||||
)
|
||||
throw new Error(`Unsafe bundle archive entry: ${name}`);
|
||||
unpacked += entry.size;
|
||||
if (unpacked > 2 * 1024 ** 3 || entries.size > 200000)
|
||||
throw new Error('Bundle archive exceeds extraction limits');
|
||||
entries.set(name, entry.type);
|
||||
if (entry.type === 'SymbolicLink') {
|
||||
if (
|
||||
path.posix.isAbsolute(entry.linkpath) ||
|
||||
entry.linkpath.includes('\\') ||
|
||||
!inside(
|
||||
'/qinglong',
|
||||
path.posix.resolve('/', path.posix.dirname(name), entry.linkpath),
|
||||
)
|
||||
)
|
||||
throw new Error('Unsafe bundle symlink');
|
||||
links.add(name);
|
||||
}
|
||||
} catch (error) {
|
||||
invalid ||= error;
|
||||
}
|
||||
},
|
||||
});
|
||||
if (invalid) throw invalid;
|
||||
for (const name of entries.keys()) {
|
||||
let parent = path.posix.dirname(name);
|
||||
while (parent !== '.') {
|
||||
if (links.has(parent))
|
||||
throw new Error('Archive writes through a symlink');
|
||||
parent = path.posix.dirname(parent);
|
||||
}
|
||||
}
|
||||
await tar.x({
|
||||
file: archive,
|
||||
cwd: destination,
|
||||
strict: true,
|
||||
preservePaths: false,
|
||||
noChown: true,
|
||||
umask: 0,
|
||||
});
|
||||
}
|
||||
|
||||
async function stageBundle({
|
||||
root,
|
||||
tmp,
|
||||
env,
|
||||
run,
|
||||
signal,
|
||||
onProgress,
|
||||
verifyStatic,
|
||||
download,
|
||||
probe = smokeDependencies,
|
||||
prestart = prestartBackend,
|
||||
repair = repairDependencies,
|
||||
}) {
|
||||
let local = env.QL_UPGRADE_FILE;
|
||||
if (!local) {
|
||||
const current = JSON.parse(
|
||||
await fs
|
||||
.readFile(path.join(root, 'package.json'), 'utf8')
|
||||
.catch((error) => {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
return '{}';
|
||||
}),
|
||||
);
|
||||
if (current.qinglongUpgradeFormat !== 2) return null; // One-time compatibility path for older releases.
|
||||
}
|
||||
const target = await runtimeTarget();
|
||||
const branch = ['develop', 'debian-dev'].includes(env.QL_BRANCH)
|
||||
? 'develop'
|
||||
: 'master';
|
||||
const workspace = await fs.mkdtemp(path.join(tmp, 'upgrade-'));
|
||||
const report = onProgress || (async () => {});
|
||||
try {
|
||||
const archive = path.join(workspace, 'bundle.tar.gz');
|
||||
let expectedCommit;
|
||||
if (local) {
|
||||
if (!path.isAbsolute(local))
|
||||
throw new Error('Offline bundle path must be absolute');
|
||||
// Snapshot the local input so replacing it cannot race validation/extraction.
|
||||
await fs.copyFile(local, archive);
|
||||
} else {
|
||||
const indexUrl =
|
||||
env.QL_UPGRADE_INDEX_URL ||
|
||||
`https://github.com/whyour/qinglong/releases/download/upgrade-${branch}/latest.json`;
|
||||
const checkUrl = (url) => {
|
||||
const parsed = new URL(url);
|
||||
if (
|
||||
parsed.protocol !== 'https:' &&
|
||||
!(
|
||||
parsed.protocol === 'http:' &&
|
||||
['127.0.0.1', '[::1]'].includes(parsed.hostname)
|
||||
)
|
||||
)
|
||||
throw new Error('Upgrade URLs require HTTPS');
|
||||
};
|
||||
checkUrl(indexUrl);
|
||||
const indexFile = path.join(workspace, 'index.json');
|
||||
await run('curl', [
|
||||
'--fail',
|
||||
'--silent',
|
||||
'--show-error',
|
||||
'--location',
|
||||
'--proto',
|
||||
new URL(indexUrl).protocol === 'http:' ? '=http,https' : '=https',
|
||||
'--proto-redir',
|
||||
'=https',
|
||||
'--header',
|
||||
'Cache-Control: no-cache',
|
||||
'--connect-timeout',
|
||||
'10',
|
||||
'--max-time',
|
||||
'30',
|
||||
'--max-filesize',
|
||||
'1048576',
|
||||
'--output',
|
||||
indexFile,
|
||||
indexUrl,
|
||||
]);
|
||||
const index = JSON.parse(await fs.readFile(indexFile, 'utf8'));
|
||||
const entry = index.packages?.[targetKey(target)];
|
||||
if (
|
||||
index.format !== 2 ||
|
||||
!/^[a-f0-9]{40}$/.test(index.sourceCommit) ||
|
||||
!entry ||
|
||||
!Number.isSafeInteger(entry.size) ||
|
||||
entry.size <= 0 ||
|
||||
entry.size > 512 * 1024 ** 2 ||
|
||||
!/^[a-f0-9]{64}$/.test(entry.sha256)
|
||||
)
|
||||
throw new Error(`No verified update bundle for ${targetKey(target)}`);
|
||||
checkUrl(entry.url);
|
||||
expectedCommit = index.sourceCommit;
|
||||
await report({
|
||||
phase: 'downloading',
|
||||
artifact: 'qinglong-bundle',
|
||||
downloadedBytes: 0,
|
||||
totalBytes: entry.size,
|
||||
percent: 0,
|
||||
});
|
||||
await download(
|
||||
run,
|
||||
archive,
|
||||
entry.url,
|
||||
'qinglong-bundle',
|
||||
report,
|
||||
true,
|
||||
entry,
|
||||
);
|
||||
}
|
||||
if ((await fs.stat(archive)).size > 512 * 1024 ** 2)
|
||||
throw new Error('Bundle is too large');
|
||||
await report({
|
||||
phase: 'extracting',
|
||||
artifact: 'qinglong-bundle',
|
||||
percent: null,
|
||||
});
|
||||
await extractArchive(archive, workspace);
|
||||
const incoming = path.join(workspace, 'qinglong');
|
||||
const manifest = JSON.parse(
|
||||
await fs.readFile(path.join(incoming, 'bundle.json'), 'utf8'),
|
||||
);
|
||||
if (expectedCommit && manifest.sourceCommit !== expectedCommit)
|
||||
throw new Error('Update index and bundle commit differ');
|
||||
const selected = {
|
||||
source: path.join(workspace, `qinglong-${branch}`),
|
||||
static: path.join(workspace, `qinglong-static-${branch}`),
|
||||
};
|
||||
await fs.rename(path.join(incoming, 'source'), selected.source);
|
||||
await fs.rename(path.join(incoming, 'static'), selected.static);
|
||||
await report({ phase: 'verifying', percent: null });
|
||||
await verifyPayload(selected, manifest, target);
|
||||
const build = await verifyStatic(selected.source, selected.static, env);
|
||||
if (build.sourceCommit !== manifest.sourceCommit)
|
||||
throw new Error('Bundle and build commit differ');
|
||||
await report({ phase: 'dependencies', percent: null });
|
||||
let dependencyFiles;
|
||||
const testCandidate = async () => {
|
||||
await probe(selected.source, signal);
|
||||
await report({ phase: 'prestarting', percent: null });
|
||||
return prestart(selected, { target, signal });
|
||||
};
|
||||
try {
|
||||
await testCandidate();
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
if (!dependencyFailure(error)) throw error;
|
||||
if (local && env.QL_UPGRADE_REPAIR_DEPS !== '1')
|
||||
throw new Error(
|
||||
`Offline dependency check failed; no network access attempted. Use --repair-deps to allow one repair. ${error.message}`,
|
||||
);
|
||||
await report({ phase: 'repairing', percent: null });
|
||||
await repair(selected.source, { target, signal, env });
|
||||
const repaired = await inventory({
|
||||
source: selected.source,
|
||||
static: selected.static,
|
||||
});
|
||||
dependencyFiles = Object.fromEntries(
|
||||
Object.entries(repaired).filter(([name]) =>
|
||||
name.startsWith('source/node_modules/'),
|
||||
),
|
||||
);
|
||||
// A repair may change only dependencies, never bless changed application code.
|
||||
await verifyPayload(
|
||||
selected,
|
||||
manifestWithDependencies(manifest, dependencyFiles),
|
||||
target,
|
||||
);
|
||||
await verifyStatic(selected.source, selected.static, env);
|
||||
await testCandidate(); // Exactly one repair; this failure propagates.
|
||||
}
|
||||
await fs.writeFile(
|
||||
path.join(workspace, 'prestart.json'),
|
||||
JSON.stringify({
|
||||
format: 1,
|
||||
sourceCommit: manifest.sourceCommit,
|
||||
manifestSha256: sha256(Buffer.from(JSON.stringify(manifest))),
|
||||
node: process.version,
|
||||
execPath: process.execPath,
|
||||
completedAt: Date.now(),
|
||||
...(dependencyFiles ? { dependencyFiles } : {}),
|
||||
}),
|
||||
{ flag: 'wx', mode: 0o600 },
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(workspace, 'bundle.json'),
|
||||
JSON.stringify(manifest),
|
||||
{ flag: 'wx', mode: 0o600 },
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(workspace, 'ready.json'),
|
||||
JSON.stringify({
|
||||
...selected,
|
||||
root: path.resolve(root),
|
||||
sourceCommit: manifest.sourceCommit,
|
||||
dependenciesChanged: true,
|
||||
bundle: true,
|
||||
}),
|
||||
{ flag: 'wx', mode: 0o600 },
|
||||
);
|
||||
const pointer = path.join(workspace, 'pointer.json');
|
||||
await fs.writeFile(
|
||||
pointer,
|
||||
JSON.stringify({ directory: path.basename(workspace) }),
|
||||
{ flag: 'wx', mode: 0o600 },
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
const readyPointer = path.join(tmp, `upgrade-ready-${branch}.json`);
|
||||
const previous = await fs
|
||||
.readFile(readyPointer, 'utf8')
|
||||
.catch(() => 'null');
|
||||
await fs.rename(pointer, readyPointer);
|
||||
try {
|
||||
const old = JSON.parse(previous)?.directory;
|
||||
if (
|
||||
/^upgrade-[A-Za-z0-9_-]+$/.test(old) &&
|
||||
old !== path.basename(workspace)
|
||||
)
|
||||
await fs.rm(path.join(tmp, old), { recursive: true, force: true });
|
||||
} catch {
|
||||
/* Committed readiness must survive cleanup failure. */
|
||||
}
|
||||
return selected;
|
||||
} catch (error) {
|
||||
await fs.rm(workspace, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function manifestWithDependencies(manifest, dependencyFiles) {
|
||||
if (!dependencyFiles) return manifest;
|
||||
if (
|
||||
typeof dependencyFiles !== 'object' ||
|
||||
Array.isArray(dependencyFiles) ||
|
||||
!Object.keys(dependencyFiles).length ||
|
||||
Object.keys(dependencyFiles).some(
|
||||
(name) => !name.startsWith('source/node_modules/'),
|
||||
)
|
||||
)
|
||||
throw new Error('Invalid repaired dependency inventory');
|
||||
return {
|
||||
...manifest,
|
||||
files: {
|
||||
...Object.fromEntries(
|
||||
Object.entries(manifest.files).filter(
|
||||
([name]) => !name.startsWith('source/node_modules/'),
|
||||
),
|
||||
),
|
||||
...dependencyFiles,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function verifyStagedBundle(selected) {
|
||||
const manifest = JSON.parse(
|
||||
await fs.readFile(
|
||||
path.join(path.dirname(selected.source), 'bundle.json'),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
const prestart = JSON.parse(
|
||||
await fs.readFile(
|
||||
path.join(path.dirname(selected.source), 'prestart.json'),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
if (
|
||||
prestart.format !== 1 ||
|
||||
prestart.sourceCommit !== manifest.sourceCommit ||
|
||||
prestart.manifestSha256 !== sha256(Buffer.from(JSON.stringify(manifest))) ||
|
||||
prestart.node !== process.version ||
|
||||
prestart.execPath !== process.execPath ||
|
||||
!Number.isSafeInteger(prestart.completedAt)
|
||||
)
|
||||
throw new Error(
|
||||
'Upgrade prestart receipt is missing or stale; prepare the update again',
|
||||
);
|
||||
await verifyPayload(
|
||||
selected,
|
||||
manifestWithDependencies(manifest, prestart.dependencyFiles),
|
||||
await runtimeTarget(),
|
||||
);
|
||||
await smokeDependencies(selected.source);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
nodeRuntimeArgs,
|
||||
atLeast,
|
||||
runtimeTarget,
|
||||
targetKey,
|
||||
assertCompatible,
|
||||
inventory,
|
||||
verifyPayload,
|
||||
smokeDependencies,
|
||||
extractArchive,
|
||||
stageBundle,
|
||||
verifyStagedBundle,
|
||||
sha256,
|
||||
};
|
||||
@@ -11,13 +11,21 @@ import { installPanelDependencies, startPanel, stopPanel } from './operator';
|
||||
// The Bash image updater and local CLI must enforce the same build provenance.
|
||||
const artifacts = require('./upgradeArtifacts.cjs') as {
|
||||
releaseBranch(env: NodeJS.ProcessEnv): string;
|
||||
verifyUpgrade(source: string, staticRoot: string): Promise<unknown>;
|
||||
verifyUpgrade(
|
||||
source: string,
|
||||
staticRoot: string,
|
||||
env?: NodeJS.ProcessEnv,
|
||||
): Promise<unknown>;
|
||||
waitForHealth(
|
||||
env: NodeJS.ProcessEnv,
|
||||
timeout?: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void>;
|
||||
withUpgradeLock<T>(tmp: string, operation: () => Promise<T>): Promise<T>;
|
||||
withUpgradeLock<T>(
|
||||
tmp: string,
|
||||
operation: () => Promise<T>,
|
||||
env?: NodeJS.ProcessEnv,
|
||||
): Promise<T>;
|
||||
stageUpgrade(options: {
|
||||
root: string;
|
||||
tmp: string;
|
||||
@@ -30,6 +38,7 @@ const artifacts = require('./upgradeArtifacts.cjs') as {
|
||||
selectedUpgrade(
|
||||
tmp: string,
|
||||
branch: string,
|
||||
env?: NodeJS.ProcessEnv,
|
||||
): Promise<{ source: string; static: string }>;
|
||||
};
|
||||
|
||||
@@ -69,17 +78,20 @@ export async function stageUpgrade(
|
||||
context: LocalContext,
|
||||
mirror: 'github' | 'gitee',
|
||||
): Promise<{ source: string; static: string }> {
|
||||
return artifacts.withUpgradeLock(context.paths.dir_tmp!, () =>
|
||||
artifacts.stageUpgrade({
|
||||
root: context.root,
|
||||
tmp: context.paths.dir_tmp!,
|
||||
env: context.env,
|
||||
mirror,
|
||||
signal: operationSignal(),
|
||||
run: (program, args, capture) =>
|
||||
checkedProcess(program, args, { env: context.env, capture }),
|
||||
install: (source) => installPanelDependencies(context, source),
|
||||
}),
|
||||
return artifacts.withUpgradeLock(
|
||||
context.paths.dir_tmp!,
|
||||
() =>
|
||||
artifacts.stageUpgrade({
|
||||
root: context.root,
|
||||
tmp: context.paths.dir_tmp!,
|
||||
env: context.env,
|
||||
mirror,
|
||||
signal: operationSignal(),
|
||||
run: (program, args, capture) =>
|
||||
checkedProcess(program, args, { env: context.env, capture }),
|
||||
install: (source) => installPanelDependencies(context, source),
|
||||
}),
|
||||
context.env,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -89,12 +101,13 @@ async function selectedUpgrade(
|
||||
return artifacts.selectedUpgrade(
|
||||
context.paths.dir_tmp!,
|
||||
releaseBranch(context),
|
||||
context.env,
|
||||
);
|
||||
}
|
||||
|
||||
export interface ReloadLifecycle {
|
||||
stop(context: LocalContext): Promise<void>;
|
||||
start(context: LocalContext): Promise<unknown>;
|
||||
start(context: LocalContext, recover?: boolean): Promise<unknown>;
|
||||
}
|
||||
|
||||
// All old entries stay on the same filesystem until service start succeeds.
|
||||
@@ -189,7 +202,7 @@ export async function replaceAndReload(
|
||||
if (item.existed) await fs.rename(item.backup, item.target);
|
||||
}
|
||||
try {
|
||||
await lifecycle.start(context);
|
||||
await lifecycle.start(context, true);
|
||||
} catch {
|
||||
throw new Error(
|
||||
translate(
|
||||
@@ -259,7 +272,7 @@ export async function reloadPanel(
|
||||
const staticRoot = selected.static;
|
||||
await validateTree(source, context.env);
|
||||
await validateTree(staticRoot, context.env);
|
||||
await artifacts.verifyUpgrade(source, staticRoot);
|
||||
await artifacts.verifyUpgrade(source, staticRoot, context.env);
|
||||
const entries = await fs.readdir(source);
|
||||
const reserved = new Set(['data', '.tmp', 'static', '.git', '.env']);
|
||||
const relativeData = path.relative(context.root, context.data);
|
||||
@@ -284,15 +297,35 @@ export async function reloadPanel(
|
||||
if (selection)
|
||||
replacements.push({ source: selection.source, target: selection.target });
|
||||
try {
|
||||
return await artifacts.withUpgradeLock(context.paths.dir_tmp!, () =>
|
||||
replaceAndReload(context, replacements, {
|
||||
stop: stopPanel,
|
||||
start: async (ctx) => {
|
||||
const service = await startPanel(ctx);
|
||||
await artifacts.waitForHealth(ctx.env, 60000, operationSignal());
|
||||
return service;
|
||||
},
|
||||
}),
|
||||
return await artifacts.withUpgradeLock(
|
||||
context.paths.dir_tmp!,
|
||||
() =>
|
||||
require('./panelUpgrade.cjs').withPreparedJob(
|
||||
context.paths.dir_tmp!,
|
||||
context.env,
|
||||
selected,
|
||||
(job?: {
|
||||
onStop(): Promise<void>;
|
||||
onStart(recover?: boolean): Promise<void> | undefined;
|
||||
}) =>
|
||||
replaceAndReload(context, replacements, {
|
||||
stop: async (ctx) => {
|
||||
await job?.onStop();
|
||||
await stopPanel(ctx);
|
||||
},
|
||||
start: async (ctx, recover) => {
|
||||
const service = await startPanel(ctx);
|
||||
await artifacts.waitForHealth(
|
||||
ctx.env,
|
||||
undefined,
|
||||
operationSignal(),
|
||||
);
|
||||
await job?.onStart(recover);
|
||||
return service;
|
||||
},
|
||||
}),
|
||||
),
|
||||
context.env,
|
||||
);
|
||||
} finally {
|
||||
if (selection)
|
||||
|
||||
@@ -7,11 +7,41 @@ const { spawn } = require('node:child_process');
|
||||
const http = require('node:http');
|
||||
const { setTimeout: delay } = require('node:timers/promises');
|
||||
|
||||
const upgradeEnglish = {
|
||||
'更新包下载及校验成功...': 'Update package downloaded and verified...',
|
||||
'更新及启动检查成功...':
|
||||
'Update and startup checks completed successfully...',
|
||||
'更新失败: %s': 'Update failed: %s',
|
||||
'构建清单无效。': 'Invalid or dirty build manifest',
|
||||
'缺少构建产物: %s': 'Missing build output: %s',
|
||||
'源码与构建依赖不一致。': 'Source and build lockfiles differ',
|
||||
'构建包含非法文件: %s': 'Unsupported build entry: %s',
|
||||
'构建文件缺失、损坏或混入旧文件。': 'Build checksum mismatch',
|
||||
'已有更新或重载正在执行。': 'Another upgrade is running',
|
||||
'GitHub 下载失败,切换 Gitee...': 'GitHub download failed; retrying Gitee',
|
||||
'升级归档路径无效。': 'Invalid archive paths',
|
||||
'升级归档包含符号链接。': 'Archive contains symlinks',
|
||||
'暂存升级指针无效。': 'Invalid staged upgrade pointer',
|
||||
'暂存目录无效。': 'Invalid staging directory',
|
||||
'暂存升级就绪记录不匹配。': 'Invalid upgrade readiness',
|
||||
'运行依赖已变化,请重新下载更新。':
|
||||
'Installed dependencies changed; download the update again',
|
||||
'保留备份: %s': 'Retained backup: %s',
|
||||
'后端启动检查失败。': 'Backend failed health check',
|
||||
'保留生命周期快照: %s': 'Retained lifecycle snapshot: %s',
|
||||
};
|
||||
|
||||
function upgradeMessage(key, env = process.env, ...values) {
|
||||
const template = env.QL_LANG === 'en' ? upgradeEnglish[key] ?? key : key;
|
||||
let index = 0;
|
||||
return template.replace(/%s/g, () => String(values[index++] ?? ''));
|
||||
}
|
||||
|
||||
const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
||||
const releaseBranch = (env) =>
|
||||
['develop', 'debian-dev'].includes(env.QL_BRANCH) ? 'develop' : 'master';
|
||||
|
||||
async function manifestAt(staticRoot) {
|
||||
async function manifestAt(staticRoot, env = process.env) {
|
||||
const manifest = JSON.parse(
|
||||
await fs.readFile(path.join(staticRoot, 'build-info.json'), 'utf8'),
|
||||
);
|
||||
@@ -24,22 +54,20 @@ async function manifestAt(staticRoot) {
|
||||
typeof manifest.files !== 'object' ||
|
||||
Array.isArray(manifest.files)
|
||||
)
|
||||
throw new Error('Invalid or dirty build manifest / 构建清单无效。');
|
||||
throw new Error(upgradeMessage('构建清单无效。', env));
|
||||
for (const required of ['build/app.js', 'dist/index.html'])
|
||||
if (!Object.hasOwn(manifest.files, required))
|
||||
throw new Error(`Missing build output / 缺少构建产物: ${required}`);
|
||||
throw new Error(upgradeMessage('缺少构建产物: %s', env, required));
|
||||
return manifest;
|
||||
}
|
||||
|
||||
async function verifyUpgrade(source, staticRoot) {
|
||||
const manifest = await manifestAt(staticRoot);
|
||||
async function verifyUpgrade(source, staticRoot, env = process.env) {
|
||||
const manifest = await manifestAt(staticRoot, env);
|
||||
if (
|
||||
hash(await fs.readFile(path.join(source, 'pnpm-lock.yaml'))) !==
|
||||
manifest.lockfileSha256
|
||||
)
|
||||
throw new Error(
|
||||
'Source and build lockfiles differ / 源码与构建依赖不一致。',
|
||||
);
|
||||
throw new Error(upgradeMessage('源码与构建依赖不一致。', env));
|
||||
await fs.access(path.join(source, 'package.json'));
|
||||
await fs.access(path.join(source, 'sample/config.sample.sh'));
|
||||
const files = {};
|
||||
@@ -50,8 +78,7 @@ async function verifyUpgrade(source, staticRoot) {
|
||||
const filename = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) await visit(filename, `${name}/`);
|
||||
else if (entry.isFile()) files[name] = hash(await fs.readFile(filename));
|
||||
else
|
||||
throw new Error(`Unsupported build entry / 构建包含非法文件: ${name}`);
|
||||
else throw new Error(upgradeMessage('构建包含非法文件: %s', env, name));
|
||||
}
|
||||
}
|
||||
await visit(staticRoot);
|
||||
@@ -61,13 +88,11 @@ async function verifyUpgrade(source, staticRoot) {
|
||||
JSON.stringify(actual) !== JSON.stringify(expected) ||
|
||||
actual.some((name) => files[name] !== manifest.files[name])
|
||||
)
|
||||
throw new Error(
|
||||
'Build checksum mismatch / 构建文件缺失、损坏或混入旧文件。',
|
||||
);
|
||||
throw new Error(upgradeMessage('构建文件缺失、损坏或混入旧文件。', env));
|
||||
return manifest;
|
||||
}
|
||||
|
||||
async function withUpgradeLock(tmp, operation) {
|
||||
async function withUpgradeLock(tmp, operation, env = process.env) {
|
||||
await fs.mkdir(tmp, { recursive: true });
|
||||
const lock = path.join(tmp, 'upgrade.lock');
|
||||
const token = randomUUID();
|
||||
@@ -123,9 +148,7 @@ async function withUpgradeLock(tmp, operation) {
|
||||
}
|
||||
}
|
||||
if (!stale)
|
||||
throw new Error(
|
||||
'Another upgrade is running / 已有更新或重载正在执行。',
|
||||
);
|
||||
throw new Error(upgradeMessage('已有更新或重载正在执行。', env));
|
||||
// Removing the observed unique marker is a compare-and-delete. A
|
||||
// contender cannot unlink the marker belonging to a replacement lock.
|
||||
// Legacy pid files are supported; new owners never reuse that filename.
|
||||
@@ -150,31 +173,223 @@ async function withUpgradeLock(tmp, operation) {
|
||||
}
|
||||
}
|
||||
|
||||
async function stageUpgrade({ root, tmp, env, mirror, run, install, signal }) {
|
||||
async function downloadWithProgress(
|
||||
run,
|
||||
archive,
|
||||
url,
|
||||
artifact,
|
||||
onProgress,
|
||||
quiet = true,
|
||||
expected,
|
||||
) {
|
||||
const headers = `${archive}.headers`;
|
||||
await fs.rm(archive, { force: true });
|
||||
await fs.rm(headers, { force: true });
|
||||
let sampling = Promise.resolve();
|
||||
const sample = async (finished = false) => {
|
||||
const bytes = (await fs.stat(archive).catch(() => undefined))?.size || 0;
|
||||
const response = (await fs.readFile(headers, 'utf8').catch(() => ''))
|
||||
.split(/(?=HTTP\/\S+ \d{3})/)
|
||||
.at(-1);
|
||||
const length = response?.match(/^content-length:\s*(\d+)\s*$/im);
|
||||
const total =
|
||||
expected?.size ||
|
||||
(length && Number(length[1]) > 0 ? Number(length[1]) : null);
|
||||
await onProgress({
|
||||
phase: 'downloading',
|
||||
artifact,
|
||||
downloadedBytes: bytes,
|
||||
totalBytes: total,
|
||||
percent: total
|
||||
? finished
|
||||
? 100
|
||||
: Math.min(99, Math.floor((bytes * 100) / total))
|
||||
: null,
|
||||
});
|
||||
};
|
||||
const timer = setInterval(() => {
|
||||
sampling = sampling.then(() => sample());
|
||||
// Observe failures immediately; propagate after the transfer has closed.
|
||||
sampling.catch(() => {});
|
||||
}, 1000);
|
||||
try {
|
||||
await run('curl', [
|
||||
'--fail',
|
||||
'--location',
|
||||
...(quiet ? ['--silent', '--show-error'] : []),
|
||||
'--proto',
|
||||
/^http:\/\/(?:127\.0\.0\.1|\[::1\])(?::\d+)?\//.test(url)
|
||||
? '=http,https'
|
||||
: '=https',
|
||||
'--proto-redir',
|
||||
'=https',
|
||||
'--connect-timeout',
|
||||
'30',
|
||||
'--max-time',
|
||||
String(
|
||||
artifact === 'qinglong-bundle' && expected?.size
|
||||
? Math.max(600, Math.min(7200, Math.ceil(expected.size / 8192) + 120))
|
||||
: 600,
|
||||
),
|
||||
'--speed-limit',
|
||||
'1024',
|
||||
'--speed-time',
|
||||
'30',
|
||||
'--dump-header',
|
||||
headers,
|
||||
'--output',
|
||||
archive,
|
||||
url,
|
||||
]);
|
||||
clearInterval(timer);
|
||||
await sampling;
|
||||
if (expected) {
|
||||
const bytes = await fs.readFile(archive);
|
||||
if (bytes.length !== expected.size || hash(bytes) !== expected.sha256)
|
||||
throw new Error('Upgrade archive size or checksum mismatch');
|
||||
}
|
||||
await sample(true);
|
||||
} finally {
|
||||
clearInterval(timer);
|
||||
await sampling;
|
||||
}
|
||||
}
|
||||
|
||||
async function stageUpgrade({
|
||||
root,
|
||||
tmp,
|
||||
env,
|
||||
mirror,
|
||||
fallbackMirror,
|
||||
run,
|
||||
install,
|
||||
signal,
|
||||
onProgress,
|
||||
}) {
|
||||
const currentPackage = JSON.parse(
|
||||
await fs
|
||||
.readFile(path.join(root, 'package.json'), 'utf8')
|
||||
.catch((error) => {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
return '{}';
|
||||
}),
|
||||
);
|
||||
if (env.QL_UPGRADE_FILE || currentPackage.qinglongUpgradeFormat === 2) {
|
||||
return require('./platformBundle.cjs').stageBundle({
|
||||
root,
|
||||
tmp,
|
||||
env,
|
||||
run,
|
||||
signal,
|
||||
onProgress,
|
||||
verifyStatic: verifyUpgrade,
|
||||
download: downloadWithProgress,
|
||||
});
|
||||
}
|
||||
const report = onProgress || (async () => {});
|
||||
const workspace = await fs.mkdtemp(path.join(tmp, 'upgrade-'));
|
||||
const branch = releaseBranch(env);
|
||||
try {
|
||||
// Metadata is optional for older releases and unavailable mirrors. Never
|
||||
// invent a percentage when neither metadata nor HTTP supplies a total.
|
||||
let info;
|
||||
if (onProgress && mirror === 'github') {
|
||||
try {
|
||||
const readJson = async (url) => {
|
||||
const filename = path.join(workspace, 'metadata.json');
|
||||
await run('curl', [
|
||||
'--fail',
|
||||
'--silent',
|
||||
'--show-error',
|
||||
'--location',
|
||||
'--proto',
|
||||
'=https',
|
||||
'--proto-redir',
|
||||
'=https',
|
||||
'--connect-timeout',
|
||||
'5',
|
||||
'--max-time',
|
||||
'10',
|
||||
'--max-filesize',
|
||||
'1048576',
|
||||
'--dump-header',
|
||||
`${filename}.headers`,
|
||||
'--output',
|
||||
filename,
|
||||
url,
|
||||
]);
|
||||
return JSON.parse(await fs.readFile(filename, 'utf8'));
|
||||
};
|
||||
const base = 'https://raw.githubusercontent.com/whyour/qinglong-static';
|
||||
const current = await readJson(
|
||||
`${base}/${branch}/build-info.json?t=${Date.now()}`,
|
||||
);
|
||||
if (!/^[a-f0-9]{40}$/.test(current.sourceCommit))
|
||||
throw new Error('Invalid commit');
|
||||
const candidate = await readJson(
|
||||
`${base}/upgrade-info/${current.sourceCommit}.json`,
|
||||
);
|
||||
if (
|
||||
candidate.version !== 1 ||
|
||||
candidate.sourceCommit !== current.sourceCommit ||
|
||||
!/^[a-f0-9]{40}$/.test(candidate.staticCommit) ||
|
||||
!['qinglong-static', 'qinglong'].every((repo) => {
|
||||
const entry = candidate.archives?.[repo];
|
||||
return (
|
||||
Number.isSafeInteger(entry?.size) &&
|
||||
entry.size > 0 &&
|
||||
/^[a-f0-9]{64}$/.test(entry.sha256)
|
||||
);
|
||||
})
|
||||
)
|
||||
throw new Error('Invalid archive metadata');
|
||||
info = candidate;
|
||||
} catch {
|
||||
signal?.throwIfAborted();
|
||||
}
|
||||
}
|
||||
async function extract(repository, ref) {
|
||||
const archive = path.join(workspace, `${repository}.zip`);
|
||||
const url =
|
||||
mirror === 'github'
|
||||
? `https://github.com/whyour/${repository}/archive/${ref}.zip`
|
||||
? `https://codeload.github.com/whyour/${repository}/zip/${ref}`
|
||||
: `https://gitee.com/whyour/${repository}/repository/archive/${ref}.zip`;
|
||||
await run('curl', [
|
||||
'--fail',
|
||||
'--location',
|
||||
'--proto',
|
||||
'=https',
|
||||
'--proto-redir',
|
||||
'=https',
|
||||
'--connect-timeout',
|
||||
'30',
|
||||
'--max-time',
|
||||
'600',
|
||||
'--output',
|
||||
archive,
|
||||
url,
|
||||
]);
|
||||
const download = async (target, expected) => {
|
||||
await report({
|
||||
phase: 'downloading',
|
||||
artifact: repository,
|
||||
downloadedBytes: 0,
|
||||
totalBytes: expected?.size || null,
|
||||
percent: expected ? 0 : null,
|
||||
});
|
||||
await downloadWithProgress(
|
||||
run,
|
||||
archive,
|
||||
target,
|
||||
repository,
|
||||
report,
|
||||
Boolean(onProgress),
|
||||
expected,
|
||||
);
|
||||
};
|
||||
try {
|
||||
await download(
|
||||
url,
|
||||
mirror === 'github' ? info?.archives[repository] : undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
if (mirror !== 'github' || fallbackMirror !== 'gitee') throw error;
|
||||
console.warn(upgradeMessage('GitHub 下载失败,切换 Gitee...', env));
|
||||
await download(
|
||||
`https://gitee.com/whyour/${repository}/repository/archive/${ref}.zip`,
|
||||
);
|
||||
}
|
||||
await report({
|
||||
phase: 'extracting',
|
||||
artifact: repository,
|
||||
percent: null,
|
||||
});
|
||||
const listing = await run('unzip', ['-Z1', archive], true);
|
||||
const expected = `${repository}-${ref}`;
|
||||
const names = listing.stdout.split('\n').filter(Boolean);
|
||||
@@ -189,21 +404,30 @@ async function stageUpgrade({ root, tmp, env, mirror, run, install, signal }) {
|
||||
name.split('/')[0] !== expected,
|
||||
)
|
||||
)
|
||||
throw new Error('Invalid archive paths / 升级归档路径无效。');
|
||||
throw new Error(upgradeMessage('升级归档路径无效。', env));
|
||||
const modes = await run('unzip', ['-Z', '-l', archive], true);
|
||||
if (modes.stdout.split('\n').some((line) => /^l[rwx-]{9}\s/.test(line)))
|
||||
throw new Error('Archive contains symlinks / 升级归档包含符号链接。');
|
||||
throw new Error(upgradeMessage('升级归档包含符号链接。', env));
|
||||
await run('unzip', ['-oq', archive, '-d', workspace]);
|
||||
return path.join(workspace, expected);
|
||||
}
|
||||
// Static branches are mutable snapshots. Their manifest pins the exact
|
||||
// source revision, avoiding a race between two independent branch reads.
|
||||
const staticRoot = await extract('qinglong-static', branch);
|
||||
const manifest = await manifestAt(staticRoot);
|
||||
const downloadedStatic = await extract(
|
||||
'qinglong-static',
|
||||
info?.staticCommit || branch,
|
||||
);
|
||||
const staticRoot = path.join(workspace, `qinglong-static-${branch}`);
|
||||
if (downloadedStatic !== staticRoot)
|
||||
await fs.rename(downloadedStatic, staticRoot);
|
||||
const manifest = await manifestAt(staticRoot, env);
|
||||
if (info && manifest.sourceCommit !== info.sourceCommit)
|
||||
throw new Error(upgradeMessage('构建清单无效。', env));
|
||||
const downloadedSource = await extract('qinglong', manifest.sourceCommit);
|
||||
const source = path.join(workspace, `qinglong-${branch}`);
|
||||
await fs.rename(downloadedSource, source);
|
||||
await verifyUpgrade(source, staticRoot);
|
||||
await report({ phase: 'verifying', percent: null });
|
||||
await verifyUpgrade(source, staticRoot, env);
|
||||
let dependenciesChanged = false;
|
||||
for (const name of ['package.json', 'pnpm-lock.yaml']) {
|
||||
const current = await fs
|
||||
@@ -220,9 +444,10 @@ async function stageUpgrade({ root, tmp, env, mirror, run, install, signal }) {
|
||||
)
|
||||
dependenciesChanged = true;
|
||||
if (dependenciesChanged) {
|
||||
await report({ phase: 'dependencies', percent: null });
|
||||
await install(source);
|
||||
await fs.access(path.join(source, 'node_modules'));
|
||||
await verifyUpgrade(source, staticRoot);
|
||||
await verifyUpgrade(source, staticRoot, env);
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const selected = { source, static: staticRoot };
|
||||
@@ -230,6 +455,7 @@ async function stageUpgrade({ root, tmp, env, mirror, run, install, signal }) {
|
||||
path.join(workspace, 'ready.json'),
|
||||
JSON.stringify({
|
||||
...selected,
|
||||
root: path.resolve(root),
|
||||
sourceCommit: manifest.sourceCommit,
|
||||
dependenciesChanged,
|
||||
}),
|
||||
@@ -269,7 +495,12 @@ async function stageUpgrade({ root, tmp, env, mirror, run, install, signal }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function selectedUpgrade(tmp, branch) {
|
||||
async function selectedUpgrade(
|
||||
tmp,
|
||||
branch,
|
||||
env = process.env,
|
||||
{ metadataOnly = false } = {},
|
||||
) {
|
||||
const pointer = JSON.parse(
|
||||
await fs.readFile(path.join(tmp, `upgrade-ready-${branch}.json`), 'utf8'),
|
||||
);
|
||||
@@ -277,7 +508,7 @@ async function selectedUpgrade(tmp, branch) {
|
||||
typeof pointer.directory !== 'string' ||
|
||||
!/^upgrade-[A-Za-z0-9_-]+$/.test(pointer.directory)
|
||||
)
|
||||
throw new Error('Invalid staged upgrade pointer / 暂存升级指针无效。');
|
||||
throw new Error(upgradeMessage('暂存升级指针无效。', env));
|
||||
const workspace = path.join(tmp, pointer.directory);
|
||||
const selected = {
|
||||
source: path.join(workspace, `qinglong-${branch}`),
|
||||
@@ -286,22 +517,48 @@ async function selectedUpgrade(tmp, branch) {
|
||||
for (const directory of [workspace, selected.source, selected.static]) {
|
||||
const stat = await fs.lstat(directory);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink())
|
||||
throw new Error('Invalid staging directory / 暂存目录无效。');
|
||||
throw new Error(upgradeMessage('暂存目录无效。', env));
|
||||
}
|
||||
const ready = JSON.parse(
|
||||
await fs.readFile(path.join(workspace, 'ready.json'), 'utf8'),
|
||||
);
|
||||
if (ready.source !== selected.source || ready.static !== selected.static)
|
||||
throw new Error('Invalid upgrade readiness / 暂存升级就绪记录不匹配。');
|
||||
const manifest = await verifyUpgrade(selected.source, selected.static);
|
||||
throw new Error(upgradeMessage('暂存升级就绪记录不匹配。', env));
|
||||
const manifest = metadataOnly
|
||||
? await manifestAt(selected.static, env)
|
||||
: await verifyUpgrade(selected.source, selected.static, env);
|
||||
if (ready.sourceCommit !== manifest.sourceCommit)
|
||||
throw new Error('Invalid upgrade readiness / 暂存升级就绪记录不匹配。');
|
||||
throw new Error(upgradeMessage('暂存升级就绪记录不匹配。', env));
|
||||
if (ready.bundle && !metadataOnly)
|
||||
await require('./platformBundle.cjs').verifyStagedBundle(selected);
|
||||
if (ready.dependenciesChanged)
|
||||
await fs.access(path.join(selected.source, 'node_modules'));
|
||||
else {
|
||||
const root = ready.root || env.QL_DIR;
|
||||
if (
|
||||
!root ||
|
||||
!(
|
||||
await fs.stat(path.join(root, 'node_modules')).catch(() => undefined)
|
||||
)?.isDirectory()
|
||||
)
|
||||
throw new Error(upgradeMessage('运行依赖已变化,请重新下载更新。', env));
|
||||
for (const name of ['package.json', 'pnpm-lock.yaml']) {
|
||||
const current = await fs
|
||||
.readFile(path.join(root, name))
|
||||
.catch(() => undefined);
|
||||
if (
|
||||
!current ||
|
||||
!(await fs.readFile(path.join(selected.source, name))).equals(current)
|
||||
)
|
||||
throw new Error(
|
||||
upgradeMessage('运行依赖已变化,请重新下载更新。', env),
|
||||
);
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
async function replaceAndReload(replacements, lifecycle) {
|
||||
async function replaceAndReload(replacements, lifecycle, env = process.env) {
|
||||
const changed = [];
|
||||
let attemptedStart = false;
|
||||
lifecycle.signal?.throwIfAborted();
|
||||
@@ -349,7 +606,7 @@ async function replaceAndReload(replacements, lifecycle) {
|
||||
} catch (error) {
|
||||
// Do not modify files under a still-running failed startup. Keep backups
|
||||
// for recovery if stopping the new process itself fails.
|
||||
if (attemptedStart) await lifecycle.stop();
|
||||
if (attemptedStart) await lifecycle.stop(true);
|
||||
for (const item of changed.reverse()) {
|
||||
await fs.rm(item.target, { recursive: true, force: true });
|
||||
if (item.existed) {
|
||||
@@ -373,7 +630,7 @@ async function replaceAndReload(replacements, lifecycle) {
|
||||
await fs
|
||||
.rm(item.backup, { recursive: true, force: true })
|
||||
.catch(() =>
|
||||
console.error(`Retained backup / 保留备份: ${item.backup}`),
|
||||
console.error(upgradeMessage('保留备份: %s', env, item.backup)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -385,8 +642,9 @@ async function reloadSystem({
|
||||
config,
|
||||
env,
|
||||
lifecycle,
|
||||
additionalReplacements = [],
|
||||
}) {
|
||||
const selected = await selectedUpgrade(tmp, releaseBranch(env));
|
||||
const selected = await selectedUpgrade(tmp, releaseBranch(env), env);
|
||||
const reserved = new Set(['data', '.tmp', 'static', '.git', '.env']);
|
||||
const relative = path.relative(root, data);
|
||||
if (relative && !relative.startsWith('..') && !path.isAbsolute(relative))
|
||||
@@ -404,7 +662,8 @@ async function reloadSystem({
|
||||
target: path.join(config, 'config.sample.sh'),
|
||||
},
|
||||
);
|
||||
await replaceAndReload(replacements, lifecycle);
|
||||
replacements.push(...additionalReplacements);
|
||||
await replaceAndReload(replacements, lifecycle, env);
|
||||
// Cleanup after a committed installation must not report a failed update.
|
||||
await fs
|
||||
.rm(path.join(tmp, `upgrade-ready-${releaseBranch(env)}.json`), {
|
||||
@@ -416,7 +675,16 @@ async function reloadSystem({
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
async function waitForHealth(env, timeout = 60000, signal) {
|
||||
function startupHealthTimeout(runtime = process) {
|
||||
// ARMv6 starts the database, scheduler and HTTP workers sequentially. Give
|
||||
// slow devices time to become healthy before deciding to restore a backup.
|
||||
return runtime.arch === 'arm' &&
|
||||
Number(runtime.config.variables.arm_version) === 6
|
||||
? 300000
|
||||
: 60000;
|
||||
}
|
||||
|
||||
async function waitForHealth(env, timeout = startupHealthTimeout(), signal) {
|
||||
const port = Number(env.QlPort || 5700);
|
||||
let base = env.QlBaseUrl || '';
|
||||
if (base && !base.startsWith('/')) base = `/${base}`;
|
||||
@@ -472,7 +740,7 @@ async function waitForHealth(env, timeout = 60000, signal) {
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error('Backend failed health check / 后端启动检查失败。');
|
||||
throw new Error(upgradeMessage('后端启动检查失败。', env));
|
||||
}
|
||||
|
||||
function runProcess(env, program, args, capture = false, signal) {
|
||||
@@ -562,9 +830,18 @@ esac
|
||||
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) =>
|
||||
runProcess(
|
||||
env,
|
||||
lifecycleEnv,
|
||||
'bash',
|
||||
['--', snapshot, operation, ...args],
|
||||
false,
|
||||
@@ -575,9 +852,7 @@ esac
|
||||
await fs
|
||||
.rm(directory, { recursive: true, force: true })
|
||||
.catch(() =>
|
||||
console.error(
|
||||
`Retained lifecycle snapshot / 保留生命周期快照: ${directory}`,
|
||||
),
|
||||
console.error(upgradeMessage('保留生命周期快照: %s', env, directory)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -595,49 +870,95 @@ async function main() {
|
||||
);
|
||||
const run = (program, args, capture) =>
|
||||
runProcess(env, program, args, capture, controller.signal);
|
||||
await withUpgradeLock(tmp, () =>
|
||||
withLifecycleHooks(root, tmp, env, async (hook) => {
|
||||
const lifecycle = {
|
||||
signal: controller.signal,
|
||||
stop: () => hook('stop'),
|
||||
start: async (recover) => {
|
||||
await hook('start', [], recover ? undefined : controller.signal);
|
||||
await waitForHealth(
|
||||
env,
|
||||
60000,
|
||||
recover ? undefined : controller.signal,
|
||||
);
|
||||
},
|
||||
};
|
||||
if (process.argv[2] === 'update') {
|
||||
await stageUpgrade({
|
||||
root,
|
||||
tmp,
|
||||
env,
|
||||
mirror: process.argv[3],
|
||||
run,
|
||||
await withUpgradeLock(
|
||||
tmp,
|
||||
() =>
|
||||
withLifecycleHooks(root, tmp, env, async (hook) => {
|
||||
let job;
|
||||
const lifecycle = {
|
||||
signal: controller.signal,
|
||||
install: (source) => hook('install', [source], controller.signal),
|
||||
});
|
||||
console.log('更新包下载及校验成功...');
|
||||
if (process.argv[4] !== 'true') return;
|
||||
} else if (process.argv[2] !== 'reload')
|
||||
throw new Error('Unknown upgrade command.');
|
||||
await reloadSystem({
|
||||
root,
|
||||
tmp,
|
||||
env,
|
||||
data: env.dir_data || path.join(root, 'data'),
|
||||
staticRoot: env.dir_static || path.join(root, 'static'),
|
||||
config: env.dir_config || path.join(root, 'data/config'),
|
||||
lifecycle,
|
||||
});
|
||||
console.log('更新及启动检查成功...');
|
||||
}),
|
||||
stop: async () => {
|
||||
await job?.onStop();
|
||||
await hook('stop');
|
||||
},
|
||||
start: async (recover) => {
|
||||
await hook('start', [], recover ? undefined : controller.signal);
|
||||
await waitForHealth(
|
||||
env,
|
||||
undefined,
|
||||
recover ? undefined : controller.signal,
|
||||
);
|
||||
await job?.onStart(recover);
|
||||
},
|
||||
};
|
||||
if (process.argv[2] === 'update') {
|
||||
await stageUpgrade({
|
||||
root,
|
||||
tmp,
|
||||
env,
|
||||
mirror: process.argv[3],
|
||||
// The Bash updater chooses its source automatically. An explicitly
|
||||
// selected CLI mirror does not opt into this network-only failover.
|
||||
fallbackMirror: 'gitee',
|
||||
run,
|
||||
signal: controller.signal,
|
||||
install: (source) => hook('install', [source], controller.signal),
|
||||
});
|
||||
console.log(upgradeMessage('更新包下载及校验成功...', env));
|
||||
if (process.argv[4] !== 'true') return;
|
||||
} else if (process.argv[2] !== 'reload')
|
||||
throw new Error('Unknown upgrade command.');
|
||||
const apply = async (preparedJob) => {
|
||||
job = preparedJob;
|
||||
await reloadSystem({
|
||||
root,
|
||||
tmp,
|
||||
env,
|
||||
data: env.dir_data || path.join(root, 'data'),
|
||||
staticRoot: env.dir_static || path.join(root, 'static'),
|
||||
config: env.dir_config || path.join(root, 'data/config'),
|
||||
lifecycle,
|
||||
});
|
||||
};
|
||||
// Legacy reloads can carry only this self-contained Bash verifier.
|
||||
// Complete-bundle installs also track manual applies without a job file.
|
||||
const hasPanelJob = await fs
|
||||
.access(path.join(tmp, 'panel-upgrade.json'))
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
return false;
|
||||
});
|
||||
const packageInfo = hasPanelJob
|
||||
? {}
|
||||
: JSON.parse(
|
||||
await fs
|
||||
.readFile(path.join(root, 'package.json'), 'utf8')
|
||||
.catch((error) => {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
return '{}';
|
||||
}),
|
||||
);
|
||||
if (hasPanelJob || packageInfo.qinglongUpgradeFormat === 2) {
|
||||
const selected = await selectedUpgrade(tmp, releaseBranch(env), env, {
|
||||
metadataOnly: true,
|
||||
});
|
||||
await require('./panelUpgrade.cjs').withPreparedJob(
|
||||
tmp,
|
||||
env,
|
||||
selected,
|
||||
apply,
|
||||
);
|
||||
} else await apply();
|
||||
console.log(upgradeMessage('更新及启动检查成功...', env));
|
||||
}),
|
||||
env,
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
downloadWithProgress,
|
||||
upgradeMessage,
|
||||
releaseBranch,
|
||||
verifyUpgrade,
|
||||
withUpgradeLock,
|
||||
@@ -645,6 +966,7 @@ module.exports = {
|
||||
selectedUpgrade,
|
||||
replaceAndReload,
|
||||
reloadSystem,
|
||||
startupHealthTimeout,
|
||||
waitForHealth,
|
||||
runProcess,
|
||||
withLifecycleHooks,
|
||||
@@ -652,6 +974,6 @@ module.exports = {
|
||||
};
|
||||
if (require.main === module)
|
||||
main().catch((error) => {
|
||||
console.error(`更新失败: ${error.message}`);
|
||||
console.error(upgradeMessage('更新失败: %s', process.env, error.message));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
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,5 +1,8 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
const { armv6ServerArgs } = require('../maintenance/nodeRuntime.cjs') as {
|
||||
armv6ServerArgs: readonly string[];
|
||||
};
|
||||
|
||||
// Linux preserves argv boundaries in cmdline. Resolve relative entrypoints from
|
||||
// each process's cwd, not from the operator's cwd or a broad ps substring match.
|
||||
@@ -27,11 +30,18 @@ export async function findDirectBackendPids(
|
||||
);
|
||||
const args = command.split('\0');
|
||||
if (args.at(-1) === '') args.pop();
|
||||
// Both the legacy fallback and the TS fallback launch one script without
|
||||
// Node flags or application arguments. Reject other command shapes.
|
||||
if (args.length !== 2 || !/^node(?:\d+)?$/.test(path.basename(args[0]!)))
|
||||
// Accept only the two launch shapes we own. Never treat eval, preload or
|
||||
// application arguments containing this path as a running backend.
|
||||
const scriptIndex =
|
||||
args.length === 2
|
||||
? 1
|
||||
: args.length === armv6ServerArgs.length + 2 &&
|
||||
armv6ServerArgs.every((flag, i) => args[i + 1] === flag)
|
||||
? args.length - 1
|
||||
: -1;
|
||||
if (scriptIndex < 0 || !/^node(?:\d+)?$/.test(path.basename(args[0]!)))
|
||||
continue;
|
||||
const script = args[1]!;
|
||||
const script = args[scriptIndex]!;
|
||||
if (!script) continue;
|
||||
const absolute = path.isAbsolute(script)
|
||||
? script
|
||||
|
||||
@@ -214,8 +214,13 @@ async function reconcile(
|
||||
if (added || removed) {
|
||||
try {
|
||||
await api.call('system/notify', 'PUT', {
|
||||
title: `${namespace} 订阅同步`,
|
||||
content: `新增 ${added} 个任务,删除 ${removed} 个任务`,
|
||||
title: translate(context.env, '%s 订阅同步', namespace),
|
||||
content: translate(
|
||||
context.env,
|
||||
'新增 %s 个任务,删除 %s 个任务',
|
||||
added,
|
||||
removed,
|
||||
),
|
||||
});
|
||||
} catch {
|
||||
operationOutput(
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface OpenOperation {
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
path: string;
|
||||
params?: string[];
|
||||
uuidParams?: string[];
|
||||
body?: 'ids' | 'json' | 'object-id';
|
||||
upload?: string;
|
||||
download?: boolean;
|
||||
@@ -12,6 +13,12 @@ export interface OpenOperation {
|
||||
anonymous?: boolean;
|
||||
}
|
||||
export const openOperations: readonly OpenOperation[] = [
|
||||
{
|
||||
name: 'system notification-logs',
|
||||
method: 'GET',
|
||||
path: 'system/notify-log',
|
||||
},
|
||||
|
||||
{ name: 'task view-list', method: 'GET', path: 'crons/views' },
|
||||
{
|
||||
name: 'task view-create',
|
||||
@@ -328,6 +335,24 @@ export const openOperations: readonly OpenOperation[] = [
|
||||
download: true,
|
||||
},
|
||||
{ name: 'dependency list', method: 'GET', path: 'dependencies' },
|
||||
{
|
||||
name: 'dependency refresh',
|
||||
method: 'POST',
|
||||
path: 'dependencies/refresh',
|
||||
body: 'json',
|
||||
},
|
||||
{
|
||||
name: 'dependency refresh-active',
|
||||
method: 'GET',
|
||||
path: 'dependencies/refresh/active',
|
||||
},
|
||||
{
|
||||
name: 'dependency refresh-status',
|
||||
method: 'GET',
|
||||
path: 'dependencies/refresh/:id',
|
||||
params: ['id'],
|
||||
uuidParams: ['id'],
|
||||
},
|
||||
{
|
||||
name: 'dependency create',
|
||||
method: 'POST',
|
||||
@@ -410,6 +435,7 @@ export const openOperations: readonly OpenOperation[] = [
|
||||
body: 'json',
|
||||
},
|
||||
{ name: 'system update-check', method: 'PUT', path: 'system/update-check' },
|
||||
{ name: 'system update-status', method: 'GET', path: 'system/update-status' },
|
||||
{ name: 'system update', method: 'PUT', path: 'system/update' },
|
||||
{ name: 'system reload', method: 'PUT', path: 'system/reload', body: 'json' },
|
||||
{ name: 'system notify', method: 'PUT', path: 'system/notify', body: 'json' },
|
||||
@@ -478,7 +504,12 @@ export const openOperations: readonly OpenOperation[] = [
|
||||
path: 'system/config/dependence-clean',
|
||||
body: 'json',
|
||||
},
|
||||
{ name: 'dashboard record', method: 'POST', path: 'dashboard/record', body: 'json' },
|
||||
{
|
||||
name: 'dashboard record',
|
||||
method: 'POST',
|
||||
path: 'dashboard/record',
|
||||
body: 'json',
|
||||
},
|
||||
{ name: 'dashboard overview', method: 'GET', path: 'dashboard/overview' },
|
||||
{ name: 'dashboard trend', method: 'GET', path: 'dashboard/trend' },
|
||||
{ name: 'dashboard top-time', method: 'GET', path: 'dashboard/top-time' },
|
||||
|
||||
@@ -17,20 +17,28 @@ export function configPath(): string {
|
||||
export function readConfig(): StoredConfig {
|
||||
let fd: number | undefined;
|
||||
try {
|
||||
fd = fs.openSync(
|
||||
configPath(),
|
||||
fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW,
|
||||
);
|
||||
const file = configPath();
|
||||
const pathStat = fs.lstatSync(file);
|
||||
if (!pathStat.isFile())
|
||||
fail(
|
||||
translate(
|
||||
process.env,
|
||||
'CLI 配置必须是普通文件,且不能是符号链接。',
|
||||
),
|
||||
);
|
||||
// Not every platform or filesystem implements POSIX mode bits or O_NOFOLLOW.
|
||||
const noFollow = fs.constants.O_NOFOLLOW ?? 0;
|
||||
fd = fs.openSync(file, fs.constants.O_RDONLY | noFollow);
|
||||
const stat = fs.fstatSync(fd);
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.mode & 0o077 ||
|
||||
(process.getuid && stat.uid !== process.getuid())
|
||||
pathStat.dev !== stat.dev ||
|
||||
pathStat.ino !== stat.ino
|
||||
) {
|
||||
fail(
|
||||
translate(
|
||||
process.env,
|
||||
'CLI 配置必须是当前用户拥有的私有普通文件(0600)。',
|
||||
'CLI 配置必须是普通文件,且不能是符号链接。',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,12 +49,19 @@ async function input(value: string | boolean | undefined): Promise<unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
const UUID_PATTERN =
|
||||
'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}';
|
||||
|
||||
function route(method: string, path: string): OpenOperation {
|
||||
const match = openOperations.find(
|
||||
(op) =>
|
||||
op.method === method &&
|
||||
new RegExp(
|
||||
'^' + op.path.replace(/:[A-Za-z]+/g, '[1-9][0-9]*') + '$',
|
||||
'^' +
|
||||
op.path.replace(/:([A-Za-z]+)/g, (_match, name) =>
|
||||
op.uuidParams?.includes(name) ? UUID_PATTERN : '[1-9][0-9]*',
|
||||
) +
|
||||
'$',
|
||||
).test(path),
|
||||
);
|
||||
if (!match)
|
||||
@@ -63,7 +70,8 @@ function route(method: string, path: string): OpenOperation {
|
||||
'Method/path is not a supported OpenAPI route.',
|
||||
);
|
||||
for (const [index, part] of match.path.split('/').entries())
|
||||
if (part.startsWith(':')) integer(path.split('/')[index]!);
|
||||
if (part.startsWith(':') && !match.uuidParams?.includes(part.slice(1)))
|
||||
integer(path.split('/')[index]!);
|
||||
return match;
|
||||
}
|
||||
|
||||
@@ -107,7 +115,11 @@ export async function openCommand(
|
||||
if (!generic) {
|
||||
endpoint = op.path;
|
||||
for (const [index, name] of (op.params ?? []).entries()) {
|
||||
const value = integer(command.positionals[index]!);
|
||||
const positional = command.positionals[index]!;
|
||||
const isUuid = op.uuidParams?.includes(name);
|
||||
if (isUuid && !new RegExp(`^${UUID_PATTERN}$`).test(positional))
|
||||
usage('任务 ID 必须为 UUID。', 'Job ID must be a UUID.');
|
||||
const value = isUuid ? positional : integer(positional);
|
||||
endpoint = endpoint.replace(`:${name}`, String(value));
|
||||
if (op.body === 'object-id') {
|
||||
if (body !== undefined && !isRecord(body))
|
||||
|
||||
+17
-10
@@ -1,8 +1,13 @@
|
||||
// Migrated from shell/lang/en.sh; the original remains for differential evaluation.
|
||||
export const english: Record<string, string> = {
|
||||
'需要双因素验证,请调用 user two-factor-login。': 'Two-factor authentication required; use user two-factor-login.',
|
||||
'QL_URL 和 QL_ACCESS_TOKEN 必须同时提供。': 'Provide QL_URL and QL_ACCESS_TOKEN together.',
|
||||
'接口返回 JSON,未保存下载文件。': 'The endpoint returned JSON; no download was saved.',
|
||||
'%s 订阅同步': '%s subscription sync',
|
||||
'新增 %s 个任务,删除 %s 个任务': 'Added %s tasks, removed %s tasks',
|
||||
'需要双因素验证,请调用 user two-factor-login。':
|
||||
'Two-factor authentication required; use user two-factor-login.',
|
||||
'QL_URL 和 QL_ACCESS_TOKEN 必须同时提供。':
|
||||
'Provide QL_URL and QL_ACCESS_TOKEN together.',
|
||||
'接口返回 JSON,未保存下载文件。':
|
||||
'The endpoint returned JSON; no download was saved.',
|
||||
'已选择的 CLI 安装路径必须为绝对路径。':
|
||||
'The selected CLI installation path must be absolute.',
|
||||
'保留 CLI 入口仅支持已确认的 2.x 升级载荷。':
|
||||
@@ -152,9 +157,12 @@ export const english: Record<string, string> = {
|
||||
'应用凭据和 %s 权限': 'application credentials and %s permission',
|
||||
未知: 'unknown',
|
||||
'请检查%s。': 'Check %s.',
|
||||
'资源冲突,请检查重复的名称或值。': 'Resource conflict; check for duplicate names or values.',
|
||||
'服务端错误,请检查服务状态和日志。': 'Server error; check service status and logs.',
|
||||
'请求未成功,请检查请求参数和服务日志。': 'Request unsuccessful; check request parameters and service logs.',
|
||||
'资源冲突,请检查重复的名称或值。':
|
||||
'Resource conflict; check for duplicate names or values.',
|
||||
'服务端错误,请检查服务状态和日志。':
|
||||
'Server error; check service status and logs.',
|
||||
'请求未成功,请检查请求参数和服务日志。':
|
||||
'Request unsuccessful; check request parameters and service logs.',
|
||||
'API 请求失败(%s %s)。%s%s': 'API request failed (%s %s). %s%s',
|
||||
|
||||
' 执行结果可能未知,请先检查%s状态再考虑重试。':
|
||||
@@ -167,10 +175,9 @@ export const english: Record<string, string> = {
|
||||
'Request failed or returned invalid JSON. Check the instance URL, network and TLS certificate.',
|
||||
'API 请求被拒绝(code %s),请检查%s。':
|
||||
'API request rejected (code %s). Check %s.',
|
||||
'CLI 配置必须是当前用户拥有的私有普通文件(0600)。':
|
||||
'CLI config must be an owned private regular file (0600).',
|
||||
'CLI 配置无效,请运行 ql login。':
|
||||
'Invalid CLI config. Run ql login.',
|
||||
'CLI 配置必须是普通文件,且不能是符号链接。':
|
||||
'CLI config must be a regular file, not a symbolic link.',
|
||||
'CLI 配置无效,请运行 ql login。': 'Invalid CLI config. Run ql login.',
|
||||
'尚未登录,请运行 ql login。': 'Not logged in. Run ql login.',
|
||||
'无法读取 CLI 配置,请检查文件权限并运行 ql login。':
|
||||
'Cannot read CLI config. Check file permissions and run ql login.',
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const vm = require('node:vm');
|
||||
const { createRequire } = require('node:module');
|
||||
const { createContext } = require('../../dist/internal/runtime/context');
|
||||
const { replaceAndReload } = require('../../dist/internal/maintenance/upgrade');
|
||||
const runtime = require('../../dist/internal/maintenance/nodeRuntime.cjs');
|
||||
|
||||
// Change only runtime selection; execute the compiled operator, actual child
|
||||
// Node and process discovery, without pretending the host CPU is ARM.
|
||||
async function operatorFor(arch, armVersion, node) {
|
||||
const filename = require.resolve('../../dist/internal/maintenance/operator');
|
||||
const localRequire = createRequire(filename);
|
||||
const module = { exports: {} };
|
||||
const flags = runtime.serverRuntimeArgs({
|
||||
arch,
|
||||
config: { variables: { arm_version: armVersion } },
|
||||
versions: { node },
|
||||
});
|
||||
vm.runInNewContext(
|
||||
await fs.readFile(filename, 'utf8'),
|
||||
{
|
||||
module,
|
||||
exports: module.exports,
|
||||
process,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
require(name) {
|
||||
return name === './nodeRuntime.cjs'
|
||||
? { ...runtime, serverRuntimeArgs: () => [...flags] }
|
||||
: localRequire(name);
|
||||
},
|
||||
},
|
||||
{ filename },
|
||||
);
|
||||
return { ...module.exports, flags };
|
||||
}
|
||||
|
||||
for (const [arch, arm, node] of [
|
||||
['arm', 6, '24.18.1'],
|
||||
['arm', 6, '20.18.1'],
|
||||
['arm', 6, '22.20.0'],
|
||||
['arm', 7, '24.18.1'],
|
||||
['arm64', undefined, '24.18.1'],
|
||||
['x64', undefined, '24.18.1'],
|
||||
])
|
||||
test(`CLI direct startup and stop use compatible arguments: ${arch}/${arm}/${node}`, async (t) => {
|
||||
const operator = await operatorFor(arch, arm, node);
|
||||
const root = await fs.realpath(
|
||||
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-runtime-start-')),
|
||||
);
|
||||
const context = createContext(
|
||||
{ root },
|
||||
{ PATH: process.platform === 'linux' ? '' : '/usr/bin:/bin', QL_LANG: 'en' },
|
||||
);
|
||||
const entry = path.join(context.paths.dir_static, 'build/app.js');
|
||||
let pid;
|
||||
t.after(async () => {
|
||||
if (pid) {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch {}
|
||||
}
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
await fs.mkdir(path.dirname(entry), { recursive: true });
|
||||
await fs.writeFile(
|
||||
entry,
|
||||
`require('fs').writeFileSync(${JSON.stringify(
|
||||
path.join(root, 'runtime.json'),
|
||||
)}, JSON.stringify({args:process.execArgv,pid:process.pid}));setInterval(()=>{},1000);`,
|
||||
);
|
||||
const started = await operator.startPanel(context);
|
||||
pid = started.pid;
|
||||
const report = JSON.parse(
|
||||
await fs.readFile(path.join(root, 'runtime.json')),
|
||||
);
|
||||
assert.equal(started.manager, 'node');
|
||||
assert.deepEqual(report.args, operator.flags);
|
||||
assert.equal(report.pid, pid);
|
||||
await operator.stopPanel(context);
|
||||
for (let n = 0; n < 100; n++) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
} catch (e) {
|
||||
if (e.code === 'ESRCH') {
|
||||
pid = undefined;
|
||||
break;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
assert.equal(
|
||||
pid,
|
||||
undefined,
|
||||
'the exact backend must stop even with Node flags',
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'ARMv6 CLI replacement stops flagged new backend before restoring old backend and data',
|
||||
{
|
||||
skip: process.platform !== 'linux',
|
||||
timeout: 20000,
|
||||
},
|
||||
async (t) => {
|
||||
const operator = await operatorFor('arm', 6, '24.18.1');
|
||||
const root = await fs.realpath(
|
||||
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-arm-rollback-')),
|
||||
);
|
||||
const context = createContext({ root }, { PATH: '', QL_LANG: 'en' });
|
||||
const entry = path.join(context.paths.dir_static, 'build/app.js');
|
||||
const incoming = path.join(root, 'incoming.js'),
|
||||
journal = path.join(root, 'journal.jsonl');
|
||||
const record = (version) =>
|
||||
`const fs=require('fs');fs.appendFileSync(${JSON.stringify(
|
||||
journal,
|
||||
)},JSON.stringify({version:${JSON.stringify(
|
||||
version,
|
||||
)},pid:process.pid,args:process.execArgv})+'\\n');setInterval(()=>{},1000);`;
|
||||
await fs.mkdir(path.dirname(entry), { recursive: true });
|
||||
await fs.mkdir(context.data, { recursive: true });
|
||||
await fs.writeFile(path.join(root, '.env'), 'KEEP=custom\n', {
|
||||
mode: 0o600,
|
||||
});
|
||||
await fs.writeFile(path.join(context.data, 'keep'), 'old data');
|
||||
await fs.writeFile(entry, record('old'));
|
||||
await fs.writeFile(incoming, record('new'));
|
||||
const pids = [];
|
||||
t.after(async () => {
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch {}
|
||||
}
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
pids.push((await operator.startPanel(context)).pid);
|
||||
await assert.rejects(
|
||||
replaceAndReload(context, [{ source: incoming, target: entry }], {
|
||||
stop: (ctx) => operator.stopPanel(ctx),
|
||||
start: async (ctx, recover) => {
|
||||
const result = await operator.startPanel(ctx);
|
||||
pids.push(result.pid);
|
||||
if (!recover) throw new Error('injected new-version health failure');
|
||||
},
|
||||
}),
|
||||
/injected new-version health failure/,
|
||||
);
|
||||
const launches = (await fs.readFile(journal, 'utf8'))
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map(JSON.parse);
|
||||
assert.deepEqual(
|
||||
launches.map((x) => x.version),
|
||||
['old', 'new', 'old'],
|
||||
);
|
||||
for (const row of launches) assert.deepEqual(row.args, operator.flags);
|
||||
for (const row of launches.slice(0, -1))
|
||||
assert.throws(() => process.kill(row.pid, 0), { code: 'ESRCH' });
|
||||
process.kill(launches.at(-1).pid, 0);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(root, '.env'), 'utf8'),
|
||||
'KEEP=custom\n',
|
||||
);
|
||||
assert.equal((await fs.stat(path.join(root, '.env'))).mode & 0o777, 0o600);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(context.data, 'keep'), 'utf8'),
|
||||
'old data',
|
||||
);
|
||||
assert.equal(await fs.readFile(entry, 'utf8'), record('old'));
|
||||
assert.ok(
|
||||
!(await fs.readdir(path.dirname(entry))).some((x) =>
|
||||
x.includes('ql-backup'),
|
||||
),
|
||||
);
|
||||
await operator.stopPanel(context);
|
||||
},
|
||||
);
|
||||
@@ -3,7 +3,46 @@ const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { findDirectBackendPids } = require('../../dist/internal/runtime/backendProcesses');
|
||||
const {
|
||||
findDirectBackendPids,
|
||||
} = require('../../dist/internal/runtime/backendProcesses');
|
||||
|
||||
test('backend discovery accepts only owned compatibility flags and the exact installation entry', async (t) => {
|
||||
const root = await fs.realpath(
|
||||
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-flagged-proc-')),
|
||||
);
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const proc = path.join(root, 'proc'),
|
||||
panel = path.join(root, 'panel with spaces');
|
||||
const other = path.join(root, 'other/static/build/app.js');
|
||||
const entry = path.join(panel, 'static/build/app.js');
|
||||
for (const file of [entry, other]) {
|
||||
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||
await fs.writeFile(file, '');
|
||||
}
|
||||
const flags = ['--no-opt', '--no-sparkplug', '--no-maglev'];
|
||||
const cases = [
|
||||
[['/usr/bin/node', ...flags, entry], true],
|
||||
[['node24', ...flags, 'static/build/app.js'], true],
|
||||
[['node', ...flags, other], false],
|
||||
[['node', '-e', entry], false],
|
||||
[['node', '-r', entry], false],
|
||||
[['node', ...flags, entry, 'argument'], false],
|
||||
[['node', '--no-opt', entry], false],
|
||||
[['bash', ...flags, entry], false],
|
||||
[['node', ...flags, '-e', entry], false],
|
||||
];
|
||||
const expected = [];
|
||||
for (const [i, [args, matches]] of cases.entries()) {
|
||||
const pid = 910001 + i,
|
||||
directory = path.join(proc, String(pid));
|
||||
await fs.mkdir(directory, { recursive: true });
|
||||
await fs.writeFile(path.join(directory, 'cmdline'), args.join('\0') + '\0');
|
||||
await fs.symlink(panel, path.join(directory, 'cwd'));
|
||||
if (matches) expected.push(pid);
|
||||
}
|
||||
assert.deepEqual((await findDirectBackendPids(entry, proc)).sort(), expected);
|
||||
});
|
||||
|
||||
test('Linux backend discovery scopes legacy relative and absolute commands to one installation', async (t) => {
|
||||
const root = await fs.realpath(
|
||||
@@ -64,7 +103,10 @@ test(
|
||||
const { spawn } = require('node:child_process');
|
||||
const { once } = require('node:events');
|
||||
const { createContext } = require('../../dist/internal/runtime/context');
|
||||
const { stopPanel, startPanel } = require('../../dist/internal/maintenance/operator');
|
||||
const {
|
||||
stopPanel,
|
||||
startPanel,
|
||||
} = require('../../dist/internal/maintenance/operator');
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-live-proc-'));
|
||||
const children = [];
|
||||
let fallbackPid;
|
||||
@@ -223,7 +265,9 @@ for (const language of ['zh', 'en', 'unsupported']) {
|
||||
);
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const { createContext } = require('../../dist/internal/runtime/context');
|
||||
const { startPanel } = require('../../dist/internal/maintenance/operator');
|
||||
const {
|
||||
startPanel,
|
||||
} = require('../../dist/internal/maintenance/operator');
|
||||
const context = createContext({ root }, { PATH: '', QL_LANG: language });
|
||||
const entry = path.join(context.paths.dir_static, 'build/app.js');
|
||||
await fs.mkdir(path.dirname(entry), { recursive: true });
|
||||
|
||||
@@ -322,6 +322,10 @@ test(
|
||||
async (t) => {
|
||||
if (process.platform === 'win32') return t.skip('POSIX process groups');
|
||||
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;
|
||||
t.after(() => {
|
||||
if (descendant)
|
||||
@@ -329,13 +333,17 @@ test(
|
||||
process.kill(descendant, 'SIGKILL');
|
||||
} 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 =
|
||||
'process.on("SIGINT",()=>{});process.on("SIGTERM",()=>{});console.log(process.pid);setInterval(()=>{},1000)';
|
||||
'process.on("SIGINT",()=>{});process.on("SIGTERM",()=>{});console.log(process.pid);require("node:fs").writeSync(3,"T");setInterval(()=>{},1000)';
|
||||
const parent = `require('node:child_process').spawn(process.execPath,['-e',${JSON.stringify(
|
||||
grandchild,
|
||||
)}],{stdio:['ignore',1,2]});setTimeout(()=>process.exit(0),100);`;
|
||||
)}],{stdio:['ignore',1,2,3]});setTimeout(()=>process.exit(0),100);`;
|
||||
const result = await runProcess(process.execPath, ['-e', parent], {
|
||||
timeoutMs: 350,
|
||||
timeoutControl: { marker },
|
||||
signal: controller.signal,
|
||||
graceMs: 100,
|
||||
output: (chunk) => {
|
||||
const pid = Number(chunk.toString().trim());
|
||||
@@ -345,6 +353,7 @@ test(
|
||||
assert.equal(result.code, 124);
|
||||
assert.equal(result.timedOut, true);
|
||||
assert.ok(descendant);
|
||||
assert.equal(fs.existsSync(marker), true);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -20,6 +20,40 @@ const {
|
||||
sourceCommit,
|
||||
} = 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) {
|
||||
t.mock.method(
|
||||
require('../../dist/internal/maintenance/upgradeArtifacts.cjs'),
|
||||
@@ -276,7 +310,7 @@ test('upgrade staging extracts both archives before publishing readiness and rej
|
||||
'utf8',
|
||||
),
|
||||
),
|
||||
{ ...staged, sourceCommit, dependenciesChanged: false },
|
||||
{ ...staged, root: ctx.root, sourceCommit, dependenciesChanged: false },
|
||||
);
|
||||
await fs.symlink(
|
||||
'../../outside',
|
||||
@@ -287,7 +321,7 @@ test('upgrade staging extracts both archives before publishing readiness and rej
|
||||
['-qry', path.join(archives, 'qinglong.zip'), `qinglong-${sourceCommit}`],
|
||||
{ cwd: payloads },
|
||||
);
|
||||
await assert.rejects(stageUpgrade(ctx, 'github'), /symlinks/);
|
||||
await assert.rejects(stageUpgrade(ctx, 'github'), /symlinks|符号链接/);
|
||||
assert.deepEqual(
|
||||
(await fs.readdir(ctx.paths.dir_tmp)).sort(),
|
||||
[
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
test('the standalone operational CLI verifier carries its archive parser without panel dependencies', (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-cli-bundle-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const filename = path.join(directory, 'platformBundle.cjs');
|
||||
fs.copyFileSync(
|
||||
path.resolve(
|
||||
__dirname,
|
||||
'../../dist/internal/maintenance/platformBundle.cjs',
|
||||
),
|
||||
filename,
|
||||
);
|
||||
const code = `
|
||||
const Module=require('node:module'),load=Module._load;
|
||||
Module._load=function(name,...args){
|
||||
if(name!==process.argv[1]&&!name.startsWith('node:')&&!Module.builtinModules.includes(name))throw new Error('External dependency: '+name);
|
||||
return load.call(this,name,...args);
|
||||
};
|
||||
const bundle=require(process.argv[1]);
|
||||
if(typeof bundle.stageBundle!=='function')throw new Error('Missing verifier');
|
||||
bundle.runtimeTarget().then(target=>console.log(bundle.targetKey(target))).catch(e=>{console.error(e);process.exitCode=1});
|
||||
`;
|
||||
const result = spawnSync(process.execPath, ['-e', code, filename], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, NODE_PATH: '' },
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, new RegExp(`^${process.platform}-`));
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const {
|
||||
manualUpgradeFixture,
|
||||
} = require('../../../test/helpers/manual-upgrade-fixture.cjs');
|
||||
const { createContext } = require('../../dist/internal/runtime/context');
|
||||
const { reloadPanel } = require('../../dist/internal/maintenance/upgrade');
|
||||
const operator = require('../../dist/internal/maintenance/operator');
|
||||
const artifacts = require('../../dist/internal/maintenance/upgradeArtifacts.cjs');
|
||||
|
||||
for (const status of ['ready', 'failed', 'completed', 'absent'])
|
||||
for (const wrongVersion of [false, true])
|
||||
test(`CLI validates and records manual reload after ${status}; wrong version=${wrongVersion}`, async (t) => {
|
||||
const f = await manualUpgradeFixture(t, wrongVersion);
|
||||
await fs.writeFile(
|
||||
path.join(f.root, 'package.json'),
|
||||
'{"qinglongUpgradeFormat":2}',
|
||||
);
|
||||
const file = path.join(f.tmp, 'panel-upgrade.json');
|
||||
if (status === 'absent') await fs.unlink(file);
|
||||
else
|
||||
await fs.writeFile(
|
||||
file,
|
||||
JSON.stringify({
|
||||
...(await f.state()),
|
||||
status,
|
||||
error: 'old error',
|
||||
version: '9.9.9',
|
||||
sourceCommit: 'f'.repeat(40),
|
||||
}),
|
||||
);
|
||||
t.mock.method(operator, 'stopPanel', async () => {});
|
||||
t.mock.method(operator, 'startPanel', async () => ({
|
||||
manager: 'fixture',
|
||||
}));
|
||||
t.mock.method(artifacts, 'waitForHealth', async () => {});
|
||||
const operation = reloadPanel(
|
||||
createContext({ root: f.root }, f.env),
|
||||
'system',
|
||||
);
|
||||
if (wrongVersion)
|
||||
await assert.rejects(operation, /启动版本与更新包不一致/);
|
||||
else await operation;
|
||||
const state = await f.state();
|
||||
assert.equal(state.status, wrongVersion ? 'failed' : 'completed');
|
||||
assert.equal(state.sourceCommit, f.expected.sourceCommit);
|
||||
assert.equal(state.version, f.expected.version);
|
||||
assert.notEqual(state.error, 'old error');
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'static/build/app.js'), 'utf8'),
|
||||
wrongVersion ? 'old backend' : 'new backend',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'data/keep'), 'utf8'),
|
||||
'preserve data',
|
||||
);
|
||||
});
|
||||
|
||||
for (const wrongVersion of [false, true]) {
|
||||
test(`operational CLI synchronizes prepared jobs and verifies startup before committing; wrong version=${wrongVersion}`, async (t) => {
|
||||
const f = await manualUpgradeFixture(t, wrongVersion);
|
||||
const context = createContext({ root: f.root }, f.env);
|
||||
const timeouts = [];
|
||||
t.mock.method(artifacts, 'waitForHealth', async (_env, timeout) => {
|
||||
timeouts.push(timeout);
|
||||
});
|
||||
t.mock.method(operator, 'stopPanel', async () => {
|
||||
assert.equal((await f.state()).status, 'restarting');
|
||||
});
|
||||
t.mock.method(operator, 'startPanel', async () => ({ manager: 'fixture' }));
|
||||
const operation = reloadPanel(context, 'system');
|
||||
if (wrongVersion) await assert.rejects(operation, /启动版本与更新包不一致/);
|
||||
else await operation;
|
||||
assert.ok(timeouts.length > 0);
|
||||
assert.ok(
|
||||
timeouts.every((timeout) => timeout === undefined),
|
||||
'use the shared ARMv6/default startup deadline',
|
||||
);
|
||||
assert.equal(
|
||||
(await f.state()).status,
|
||||
wrongVersion ? 'failed' : 'completed',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'static/build/app.js'), 'utf8'),
|
||||
wrongVersion ? 'old backend' : 'new backend',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'data/keep'), 'utf8'),
|
||||
'preserve data',
|
||||
);
|
||||
if (wrongVersion)
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'node_modules/old'), 'utf8'),
|
||||
'old dependency',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -23,7 +23,7 @@ test(
|
||||
assert.notEqual(before.dev, (await fs.stat(root)).dev);
|
||||
const context = createContext(
|
||||
{ root, 'data-dir': data },
|
||||
{ PATH: '/nonexistent' },
|
||||
{ PATH: '/nonexistent', QL_LANG: 'en' },
|
||||
);
|
||||
t.after(async () => {
|
||||
await stopPanel(context);
|
||||
|
||||
@@ -23,7 +23,7 @@ test(
|
||||
[
|
||||
'--test',
|
||||
'--test-reporter=tap',
|
||||
path.resolve(__dirname, '../preload.test.cjs'),
|
||||
path.resolve(__dirname, '../internal/preload.test.cjs'),
|
||||
],
|
||||
{
|
||||
env,
|
||||
|
||||
@@ -43,6 +43,7 @@ test(
|
||||
context.paths.dir_tmp,
|
||||
);
|
||||
installations.push(directory);
|
||||
await fs.mkdir(path.join(directory, 'node_modules'), { recursive: true });
|
||||
},
|
||||
);
|
||||
const controller = new AbortController();
|
||||
@@ -70,9 +71,16 @@ test(
|
||||
),
|
||||
);
|
||||
assert.equal(pointer.directory, path.basename(workspace));
|
||||
const manifest = JSON.parse(
|
||||
await fs.readFile(path.join(staged.static, 'build-info.json'), 'utf8'),
|
||||
);
|
||||
assert.deepEqual(
|
||||
JSON.parse(await fs.readFile(path.join(workspace, 'ready.json'), 'utf8')),
|
||||
staged,
|
||||
{
|
||||
...staged,
|
||||
sourceCommit: manifest.sourceCommit,
|
||||
dependenciesChanged: true,
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(root, 'package.json'), 'utf8'),
|
||||
|
||||
@@ -296,11 +296,13 @@ test('malformed successful responses are rejected, including expired authenticat
|
||||
failure(await f.invoke(['login', '--url', f.url, '--json']), 1);
|
||||
});
|
||||
|
||||
test('config permissions, malformed content and symlinks fail closed', async (t) => {
|
||||
test('config content and symlinks are checked independently of permission bits', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.login();
|
||||
fs.chmodSync(f.file, 0o644);
|
||||
failure(await f.invoke(['task', 'list', '--json']), 1);
|
||||
const sharedMode = await f.invoke(['task', 'list', '--json']);
|
||||
assert.equal(sharedMode.code, 0, sharedMode.err);
|
||||
assert.equal(JSON.parse(sharedMode.out).data.total, 1);
|
||||
fs.chmodSync(f.file, 0o600);
|
||||
fs.writeFileSync(f.file, '{broken');
|
||||
failure(await f.invoke(['task', 'list', '--json']), 1);
|
||||
@@ -312,12 +314,73 @@ test('config permissions, malformed content and symlinks fail closed', async (t)
|
||||
const target = path.join(f.dir, 'target');
|
||||
fs.renameSync(f.file, target);
|
||||
fs.symlinkSync(target, f.file);
|
||||
failure(await f.invoke(['task', 'list', '--json']), 1);
|
||||
const linked = await f.invoke(['task', 'list', '--json'], { QL_LANG: 'en' });
|
||||
failure(linked, 1);
|
||||
assert.match(JSON.parse(linked.err).message, /regular file, not a symbolic link/);
|
||||
await f.login();
|
||||
assert.equal(fs.lstatSync(f.file).isSymbolicLink(), false);
|
||||
assert.equal(JSON.parse(fs.readFileSync(target)).clientId, 123);
|
||||
});
|
||||
|
||||
test('Windows accepts a saved config despite synthetic 0666 mode bits', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.login();
|
||||
const preload = path.join(f.dir, 'windows-mode.cjs');
|
||||
fs.writeFileSync(
|
||||
preload,
|
||||
`Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
const fs = require('node:fs');
|
||||
const originalFstatSync = fs.fstatSync;
|
||||
const originalOpenSync = fs.openSync;
|
||||
fs.openSync = function (file, flags, ...args) {
|
||||
return originalOpenSync(file, typeof flags === 'number'
|
||||
? flags & ~fs.constants.O_NOFOLLOW : flags, ...args);
|
||||
};
|
||||
fs.fstatSync = function (...args) {
|
||||
const stat = originalFstatSync(...args);
|
||||
stat.mode = (stat.mode & ~0o777) | 0o666;
|
||||
return stat;
|
||||
};\n`,
|
||||
);
|
||||
const env = { NODE_OPTIONS: `--require=${preload}` };
|
||||
const status = await f.invoke(['auth', 'status', '--json'], env);
|
||||
assert.equal(status.code, 0, status.err);
|
||||
assert.equal(JSON.parse(status.out).data.scopeChecked, 'crons');
|
||||
const list = await f.invoke(['task', 'list', '--json'], env);
|
||||
assert.equal(list.code, 0, list.err);
|
||||
assert.equal(JSON.parse(list.out).data.total, 1);
|
||||
const target = path.join(f.dir, 'target');
|
||||
fs.renameSync(f.file, target);
|
||||
fs.symlinkSync(target, f.file);
|
||||
failure(await f.invoke(['task', 'list', '--json'], env), 1);
|
||||
});
|
||||
|
||||
test('Linux ARM accepts a saved config with synthetic mode and owner', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.login();
|
||||
const preload = path.join(f.dir, 'arm-mode.cjs');
|
||||
fs.writeFileSync(
|
||||
preload,
|
||||
`Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
Object.defineProperty(process, 'arch', { value: 'arm' });
|
||||
const fs = require('node:fs');
|
||||
const originalFstatSync = fs.fstatSync;
|
||||
fs.fstatSync = function (...args) {
|
||||
const stat = originalFstatSync(...args);
|
||||
stat.mode = (stat.mode & ~0o777) | 0o666;
|
||||
stat.uid = 999999;
|
||||
return stat;
|
||||
};\n`,
|
||||
);
|
||||
const env = { NODE_OPTIONS: `--require=${preload}` };
|
||||
const status = await f.invoke(['auth', 'status', '--json'], env);
|
||||
assert.equal(status.code, 0, status.err);
|
||||
assert.equal(JSON.parse(status.out).data.scopeChecked, 'crons');
|
||||
const list = await f.invoke(['task', 'list', '--json'], env);
|
||||
assert.equal(list.code, 0, list.err);
|
||||
assert.equal(JSON.parse(list.out).data.total, 1);
|
||||
});
|
||||
|
||||
test('standalone compiled artifact and symlink run without repo runtime dependencies', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const dist = path.join(f.dir, 'standalone');
|
||||
|
||||
@@ -83,6 +83,27 @@ test('script history commands send exact query parameters and restore payloads',
|
||||
assert.equal(f.requests.length, count, 'missing restore payload must not issue a request');
|
||||
});
|
||||
|
||||
test('dependency refresh commands send the dependency type and address background jobs', async t => {
|
||||
const f = await fixture(t);
|
||||
success(await f.run(['dependency', 'refresh', '--data', '{"type":"python3"}']));
|
||||
assert.equal(f.requests[0].method, 'POST');
|
||||
assert.equal(f.requests[0].url, '/panel/open/dependencies/refresh');
|
||||
assert.deepEqual(JSON.parse(f.requests[0].body), { type: 'python3' });
|
||||
const id = '32ec8b22-2c48-4bcf-85d4-b394198417ba';
|
||||
success(await f.run(['dependency', 'refresh-status', id]));
|
||||
assert.equal(f.requests[1].method, 'GET');
|
||||
assert.equal(f.requests[1].url, `/panel/open/dependencies/refresh/${id}`);
|
||||
success(await f.run(['dependency', 'refresh-active']));
|
||||
assert.equal(f.requests[2].method, 'GET');
|
||||
assert.equal(f.requests[2].url, '/panel/open/dependencies/refresh/active');
|
||||
success(await f.run(['api', 'request', 'GET', `/open/dependencies/refresh/${id}`]));
|
||||
assert.equal(f.requests[3].url, `/panel/open/dependencies/refresh/${id}`);
|
||||
assert.equal((await f.run(['dependency', 'refresh-status', '7'])).code, 2);
|
||||
assert.equal((await f.run(['api', 'request', 'GET', '/open/dependencies/refresh/7'])).code, 2);
|
||||
assert.equal((await f.run(['dependency', 'refresh'])).code, 2);
|
||||
assert.equal(f.requests.length, 4, 'invalid arguments must not issue a request');
|
||||
});
|
||||
|
||||
test('OpenAPI catalogue covers every active registered backend route; retired 410 routes are explicit', async () => {
|
||||
const root = path.resolve(__dirname, '../../../back/api');
|
||||
const expected = [];
|
||||
@@ -132,7 +153,8 @@ test('all added named operations reach their registered method/path, including u
|
||||
const file = path.join(f.root, 'fixture.json'); await fs.writeFile(file, '{}');
|
||||
for (const op of openOperations.filter(op => !op.existing)) {
|
||||
const args = op.name.split(' ');
|
||||
for (const _ of op.params ?? []) args.push('7');
|
||||
const parameter = name => op.uuidParams?.includes(name) ? '32ec8b22-2c48-4bcf-85d4-b394198417ba' : '7';
|
||||
for (const name of op.params ?? []) args.push(parameter(name));
|
||||
if (op.body === 'ids') args.push('7', '8');
|
||||
else if (op.body) {
|
||||
const data = op.name.startsWith('task ') ? { command: 'echo fixture', schedule: '0 0 * * *' }
|
||||
@@ -144,7 +166,7 @@ test('all added named operations reach their registered method/path, including u
|
||||
success(await f.run(args));
|
||||
const last = f.requests.at(-1);
|
||||
assert.equal(last.method, op.method, op.name);
|
||||
assert.equal(last.url, '/panel/open/' + op.path.replace(/:[A-Za-z]+/g, '7'), op.name);
|
||||
assert.equal(last.url, '/panel/open/' + op.path.replace(/:([A-Za-z]+)/g, (_match, name) => parameter(name)), op.name);
|
||||
assert.equal(last.headers.authorization, op.anonymous ? undefined : 'Bearer fixture-token', op.name);
|
||||
if (op.upload) assert.ok(last.body.includes(`name="${op.upload}"; filename="fixture.json"`), op.name);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
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 http = require('node:http');
|
||||
const { spawn } = require('node:child_process');
|
||||
|
||||
const entry = path.resolve(__dirname, '../../dist/npm/ql.js');
|
||||
|
||||
test('saved login works on the host filesystem', async (t) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-platform-smoke-'));
|
||||
const config = path.join(dir, 'cli.json');
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url, 'http://127.0.0.1');
|
||||
res.setHeader('content-type', 'application/json');
|
||||
if (url.pathname === '/panel/open/auth/token') {
|
||||
assert.equal(url.searchParams.get('client_id'), 'smoke-id');
|
||||
assert.equal(url.searchParams.get('client_secret'), 'smoke-secret');
|
||||
res.end(JSON.stringify({
|
||||
code: 200,
|
||||
data: {
|
||||
token: 'smoke-token',
|
||||
expiration: Math.floor(Date.now() / 1000) + 3600,
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
assert.equal(req.headers.authorization, 'Bearer smoke-token');
|
||||
assert.equal(url.pathname, '/panel/open/crons');
|
||||
res.end(JSON.stringify({ code: 200, data: { data: [], total: 0 } }));
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
t.after(async () => {
|
||||
server.closeAllConnections();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
QL_CLI_CONFIG: config,
|
||||
QL_CLIENT_ID: 'smoke-id',
|
||||
QL_CLIENT_SECRET: 'smoke-secret',
|
||||
QL_URL: '',
|
||||
QL_ACCESS_TOKEN: '',
|
||||
};
|
||||
const invoke = (args) => new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [entry, ...args], {
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let out = '';
|
||||
let err = '';
|
||||
child.stdout.on('data', (data) => (out += data));
|
||||
child.stderr.on('data', (data) => (err += data));
|
||||
child.once('error', reject);
|
||||
child.once('close', (code) => resolve({ code, out, err }));
|
||||
});
|
||||
const url = `http://127.0.0.1:${server.address().port}/panel`;
|
||||
const login = await invoke(['login', '--url', url, '--json']);
|
||||
assert.equal(login.code, 0, login.err);
|
||||
assert.equal(JSON.parse(login.out).data.authenticated, true);
|
||||
assert.equal(fs.lstatSync(config).isFile(), true);
|
||||
const status = await invoke(['auth', 'status', '--json']);
|
||||
assert.equal(status.code, 0, status.err);
|
||||
assert.equal(JSON.parse(status.out).data.authenticated, true);
|
||||
const list = await invoke(['task', 'list', '--json']);
|
||||
assert.equal(list.code, 0, list.err);
|
||||
assert.equal(JSON.parse(list.out).data.total, 0);
|
||||
});
|
||||
+28
-5
@@ -1,8 +1,9 @@
|
||||
# Run Node package installation natively on the builder. Node/npm can spin at
|
||||
# 100% CPU when Alpine s390x is emulated through QEMU.
|
||||
FROM --platform=$BUILDPLATFORM node:18-alpine3.18 AS builder
|
||||
FROM --platform=$BUILDPLATFORM node:24.18.1-alpine3.24 AS builder
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
ENV NPM_CONFIG_PREFIX=/opt/node-global
|
||||
ENV PATH=/opt/node-global/bin:${PATH}
|
||||
|
||||
@@ -17,12 +18,24 @@ RUN set -x \
|
||||
ppc64le) NODE_ARCH=ppc64 ;; \
|
||||
*) NODE_ARCH="${TARGETARCH}" ;; \
|
||||
esac \
|
||||
&& case "${TARGETARCH}/${TARGETVARIANT:-}" in \
|
||||
arm/v6) NODE_LIBC=musl-armv6 ;; \
|
||||
arm/v7) NODE_LIBC=musl-armv7 ;; \
|
||||
arm/*) echo "Unsupported ARM variant: ${TARGETVARIANT:-unknown}" >&2; exit 1 ;; \
|
||||
*) NODE_LIBC=musl ;; \
|
||||
esac \
|
||||
&& npm_config_target_platform=linux \
|
||||
npm_config_target_arch="${NODE_ARCH}" \
|
||||
npm_config_target_libc=musl \
|
||||
pnpm install --prod --frozen-lockfile
|
||||
npm_config_target_libc="${NODE_LIBC}" \
|
||||
pnpm install --prod --frozen-lockfile --side-effects-cache=false
|
||||
|
||||
FROM python:3.11-alpine
|
||||
# Pin amd64 to upstream's musl runtime after native URL parser crashes with APK Node.
|
||||
FROM --platform=linux/amd64 node:24.18.1-alpine3.24@sha256:f70403e87646dc51b45295f4b8b70cdad0b63d2297c4c9899119b03f7af7a6b3 AS node-runtime-amd64
|
||||
|
||||
FROM python:3.11-alpine3.24
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
|
||||
ARG QL_MAINTAINER="whyour"
|
||||
LABEL maintainer="${QL_MAINTAINER}"
|
||||
@@ -44,7 +57,8 @@ EXPOSE 5700
|
||||
COPY --from=builder /opt/node-global/lib/node_modules/. /usr/local/lib/node_modules/
|
||||
COPY --from=builder /opt/node-global/bin/. /usr/local/bin/
|
||||
|
||||
RUN set -x \
|
||||
RUN --mount=type=bind,from=node-runtime-amd64,source=/usr/local/bin/node,target=/tmp/node-runtime-amd64 \
|
||||
set -x \
|
||||
&& apk update -f \
|
||||
&& apk upgrade \
|
||||
&& apk --no-cache add -f bash \
|
||||
@@ -62,6 +76,11 @@ RUN set -x \
|
||||
netcat-openbsd \
|
||||
unzip \
|
||||
npm \
|
||||
&& if [ "$TARGETARCH" = amd64 ]; then \
|
||||
cp /tmp/node-runtime-amd64 /usr/local/bin/node; \
|
||||
ln -sf /usr/local/bin/node /usr/bin/node; \
|
||||
fi \
|
||||
&& node --version | grep -E '^v24\.' \
|
||||
&& rm -rf /var/cache/apk/* \
|
||||
&& apk update \
|
||||
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||
@@ -86,6 +105,10 @@ COPY static/ /ql/static/
|
||||
COPY docker/verify-build.cjs docker/build-manifest.cjs /tmp/
|
||||
COPY --from=builder /tmp/build/pnpm-lock.yaml /tmp/dependency-lock.yaml
|
||||
RUN cd ${QL_DIR} && node /tmp/verify-build.cjs /tmp/dependency-lock.yaml
|
||||
RUN if [ "$TARGETARCH" = arm ] && [ "${TARGETVARIANT:-}" = v6 ]; then \
|
||||
QEMU_CPU=arm1176 node --jitless ${QL_DIR}/shell/node-runtime-probe.cjs; \
|
||||
else node ${QL_DIR}/shell/node-runtime-probe.cjs; fi
|
||||
|
||||
|
||||
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
|
||||
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
|
||||
|
||||
+28
-5
@@ -1,8 +1,9 @@
|
||||
# Run Node package installation natively on the builder. Node/npm can spin at
|
||||
# 100% CPU when Alpine s390x is emulated through QEMU.
|
||||
FROM --platform=$BUILDPLATFORM node:18-alpine3.18 AS builder
|
||||
FROM --platform=$BUILDPLATFORM node:24.18.1-alpine3.24 AS builder
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
ENV NPM_CONFIG_PREFIX=/opt/node-global
|
||||
ENV PATH=/opt/node-global/bin:${PATH}
|
||||
|
||||
@@ -17,12 +18,24 @@ RUN set -x \
|
||||
ppc64le) NODE_ARCH=ppc64 ;; \
|
||||
*) NODE_ARCH="${TARGETARCH}" ;; \
|
||||
esac \
|
||||
&& case "${TARGETARCH}/${TARGETVARIANT:-}" in \
|
||||
arm/v6) NODE_LIBC=musl-armv6 ;; \
|
||||
arm/v7) NODE_LIBC=musl-armv7 ;; \
|
||||
arm/*) echo "Unsupported ARM variant: ${TARGETVARIANT:-unknown}" >&2; exit 1 ;; \
|
||||
*) NODE_LIBC=musl ;; \
|
||||
esac \
|
||||
&& npm_config_target_platform=linux \
|
||||
npm_config_target_arch="${NODE_ARCH}" \
|
||||
npm_config_target_libc=musl \
|
||||
pnpm install --prod --frozen-lockfile
|
||||
npm_config_target_libc="${NODE_LIBC}" \
|
||||
pnpm install --prod --frozen-lockfile --side-effects-cache=false
|
||||
|
||||
FROM python:3.10-alpine
|
||||
# Pin amd64 to upstream's musl runtime after native URL parser crashes with APK Node.
|
||||
FROM --platform=linux/amd64 node:24.18.1-alpine3.24@sha256:f70403e87646dc51b45295f4b8b70cdad0b63d2297c4c9899119b03f7af7a6b3 AS node-runtime-amd64
|
||||
|
||||
FROM python:3.10-alpine3.24
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
|
||||
ARG QL_MAINTAINER="whyour"
|
||||
LABEL maintainer="${QL_MAINTAINER}"
|
||||
@@ -44,7 +57,8 @@ EXPOSE 5700
|
||||
COPY --from=builder /opt/node-global/lib/node_modules/. /usr/local/lib/node_modules/
|
||||
COPY --from=builder /opt/node-global/bin/. /usr/local/bin/
|
||||
|
||||
RUN set -x \
|
||||
RUN --mount=type=bind,from=node-runtime-amd64,source=/usr/local/bin/node,target=/tmp/node-runtime-amd64 \
|
||||
set -x \
|
||||
&& apk update -f \
|
||||
&& apk upgrade \
|
||||
&& apk --no-cache add -f bash \
|
||||
@@ -62,6 +76,11 @@ RUN set -x \
|
||||
netcat-openbsd \
|
||||
unzip \
|
||||
npm \
|
||||
&& if [ "$TARGETARCH" = amd64 ]; then \
|
||||
cp /tmp/node-runtime-amd64 /usr/local/bin/node; \
|
||||
ln -sf /usr/local/bin/node /usr/bin/node; \
|
||||
fi \
|
||||
&& node --version | grep -E '^v24\.' \
|
||||
&& rm -rf /var/cache/apk/* \
|
||||
&& apk update \
|
||||
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||
@@ -86,6 +105,10 @@ COPY static/ /ql/static/
|
||||
COPY docker/verify-build.cjs docker/build-manifest.cjs /tmp/
|
||||
COPY --from=builder /tmp/build/pnpm-lock.yaml /tmp/dependency-lock.yaml
|
||||
RUN cd ${QL_DIR} && node /tmp/verify-build.cjs /tmp/dependency-lock.yaml
|
||||
RUN if [ "$TARGETARCH" = arm ] && [ "${TARGETVARIANT:-}" = v6 ]; then \
|
||||
QEMU_CPU=arm1176 node --jitless ${QL_DIR}/shell/node-runtime-probe.cjs; \
|
||||
else node ${QL_DIR}/shell/node-runtime-probe.cjs; fi
|
||||
|
||||
|
||||
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
|
||||
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
# Node 20 Bookworm is the latest official Node image variant that covers the
|
||||
# full Debian build matrix, including arm/v7, ppc64le, and s390x.
|
||||
FROM node:20-bookworm-slim AS nodebuilder
|
||||
# Download and verify the target Node archive without running under QEMU.
|
||||
FROM --platform=$BUILDPLATFORM debian:bookworm-slim AS nodebuilder
|
||||
ARG TARGETARCH
|
||||
COPY docker/install-node-debian.sh /tmp/install-node-debian.sh
|
||||
RUN apt-get update && \
|
||||
apt-get install --no-install-recommends -y ca-certificates curl xz-utils && \
|
||||
sh /tmp/install-node-debian.sh "${TARGETARCH}"
|
||||
|
||||
FROM python:3.11.14-slim-bookworm AS builder
|
||||
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
|
||||
@@ -11,6 +15,8 @@ RUN set -x && \
|
||||
apt-get update && \
|
||||
apt-get install --no-install-recommends -y libatomic1 && \
|
||||
npm i -g pnpm@8.3.1 && \
|
||||
test "$(node --version)" = "v24.18.1" && \
|
||||
test "$(pnpm --version)" = "8.3.1" && \
|
||||
cd /tmp/build && \
|
||||
pnpm install --prod --frozen-lockfile
|
||||
|
||||
@@ -70,6 +76,8 @@ RUN set -x && \
|
||||
git config --global user.name "qinglong" && \
|
||||
git config --global http.postBuffer 524288000 && \
|
||||
npm install -g pnpm@8.3.1 pm2 ts-node typescript@5 && \
|
||||
test "$(node --version)" = "v24.18.1" && \
|
||||
test "$(pnpm --version)" = "8.3.1" && \
|
||||
npm cache clean --force && \
|
||||
rm -rf /root/.cache && \
|
||||
rm -rf /root/.npm && \
|
||||
@@ -114,7 +122,8 @@ USER root
|
||||
|
||||
RUN ln -sf ${QL_DIR}/shell/task.sh /usr/local/bin/task \
|
||||
&& ln -sf ${QL_DIR}/shell/update.sh /usr/local/bin/ql \
|
||||
&& chmod +x /usr/local/bin/task /usr/local/bin/ql
|
||||
&& chmod +x /usr/local/bin/task /usr/local/bin/ql \
|
||||
&& test "$(pnpm --version)" = "8.3.1"
|
||||
|
||||
WORKDIR ${QL_DIR}
|
||||
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
# Node 20 Bookworm is the latest official Node image variant that covers the
|
||||
# full Debian build matrix, including arm/v7, ppc64le, and s390x.
|
||||
FROM node:20-bookworm-slim AS nodebuilder
|
||||
# Download and verify the target Node archive without running under QEMU.
|
||||
FROM --platform=$BUILDPLATFORM debian:bookworm-slim AS nodebuilder
|
||||
ARG TARGETARCH
|
||||
COPY docker/install-node-debian.sh /tmp/install-node-debian.sh
|
||||
RUN apt-get update && \
|
||||
apt-get install --no-install-recommends -y ca-certificates curl xz-utils && \
|
||||
sh /tmp/install-node-debian.sh "${TARGETARCH}"
|
||||
|
||||
# Keep the full release matrix, including s390x, on the same immutable base.
|
||||
# Keep s390x on the same immutable Python base as the other Debian targets.
|
||||
# The floating 3.10-slim-bookworm tag no longer includes s390x.
|
||||
FROM python:3.10.19-slim-bookworm@sha256:23f63358922e79a794f71be8f3723c84e5ccca9638af3f74456dc73d6184499e AS builder
|
||||
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
|
||||
@@ -13,6 +17,7 @@ RUN set -x && \
|
||||
apt-get update && \
|
||||
apt-get install --no-install-recommends -y libatomic1 && \
|
||||
npm i -g pnpm@8.3.1 && \
|
||||
test "$(node --version)" = "v24.18.1" && \
|
||||
cd /tmp/build && \
|
||||
pnpm install --prod --frozen-lockfile
|
||||
|
||||
@@ -71,6 +76,7 @@ RUN set -x && \
|
||||
git config --global user.name "qinglong" && \
|
||||
git config --global http.postBuffer 524288000 && \
|
||||
npm install -g pnpm@8.3.1 pm2 ts-node typescript@5 && \
|
||||
test "$(node --version)" = "v24.18.1" && \
|
||||
npm cache clean --force && \
|
||||
rm -rf /root/.cache && \
|
||||
rm -rf /root/.npm && \
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# The image digest is supplied by the same workflow run, never a rolling tag.
|
||||
ARG BASE_IMAGE
|
||||
FROM ${BASE_IMAGE} AS bundle
|
||||
ARG BASE_IMAGE
|
||||
ENV QL_BUNDLE_BASE_IMAGE=${BASE_IMAGE}
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
USER root
|
||||
RUN ulimit -c 0; \
|
||||
if [ "$TARGETARCH/$TARGETVARIANT" = arm/v6 ]; then \
|
||||
export QEMU_CPU=arm1176; \
|
||||
set -- --jitless; \
|
||||
fi; \
|
||||
node "$@" /ql/scripts/build-upgrade-bundle.cjs /ql /out
|
||||
FROM scratch
|
||||
COPY --from=bundle /out/ /
|
||||
@@ -119,12 +119,12 @@ reload_pm2
|
||||
|
||||
if [[ $AutoStartBot == true ]]; then
|
||||
log_with_style "INFO" "🤖 3. 启动 bot..."
|
||||
nohup ql bot >$dir_log/bot.log 2>&1 &
|
||||
nohup ql bot >"$dir_log/bot.log" 2>&1 &
|
||||
fi
|
||||
|
||||
if [[ $EnableExtraShell == true ]]; then
|
||||
log_with_style "INFO" "🛠️ 4. 执行自定义脚本..."
|
||||
nohup ql extra >$dir_log/extra.log 2>&1 &
|
||||
nohup ql extra >"$dir_log/extra.log" 2>&1 &
|
||||
fi
|
||||
|
||||
log_with_style "SUCCESS" "🎉 容器启动成功!"
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# The official Node 24 Bookworm image does not cover s390x. Use the same
|
||||
# verified upstream release archive on every supported Debian architecture.
|
||||
version=24.18.1
|
||||
case "${1:?Missing Docker TARGETARCH}" in
|
||||
amd64) arch=x64; checksum=d6c664df3f3f61458e8c277585571328522d705166723a7c7823a9253a4d15a0 ;;
|
||||
arm64) arch=arm64; checksum=7201e3a09dc825bac57867c81913e2b8f0ef87d04cb9082af4cda82f6ff3d88c ;;
|
||||
ppc64le) arch=ppc64le; checksum=c47812c13862be7f6e71194c434c4d78a99096e72efb9c31a2627437e056d669 ;;
|
||||
s390x) arch=s390x; checksum=acf57bd726f35afc2ba8475194e5a8bd532c00e1a3b2436295b2fbdde199d0e9 ;;
|
||||
*) echo "Node ${version} has no supported Debian binary for TARGETARCH=$1" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
archive="node-v${version}-linux-${arch}.tar.xz"
|
||||
curl --fail --location --silent --show-error --retry 3 \
|
||||
"https://nodejs.org/dist/v${version}/${archive}" -o "/tmp/${archive}"
|
||||
printf '%s %s\n' "${checksum}" "/tmp/${archive}" | sha256sum --check --status
|
||||
tar -xJf "/tmp/${archive}" -C /usr/local --strip-components=1 --no-same-owner
|
||||
rm "/tmp/${archive}"
|
||||
# The target binary is executed and version-checked in the target builder/runtime stages.
|
||||
test -x /usr/local/bin/node
|
||||
@@ -10,6 +10,10 @@ Node 调度路径使用固定版本 `node-cron@4.6.0`,普通任务(含额外
|
||||
- 日期与星期同时指定时保留 OR 语义,不采用 node-cron 默认的 AND。两个原生任务分别处理日期和星期,并按计划时间过滤交集,避免重复执行;保留旧解析器的月份全日期范围及全局 nth-week 限制。
|
||||
- 原生任务先暂停创建并验证,再替换旧任务。构造失败时销毁已准备的实例并保留旧快照。
|
||||
- 进程内迟到的触发通过 `execution:missed` 加入既有执行队列,记录补执行日志。并发和单任务积压上限仍由青龙队列控制;这不提供进程退出期间的持久化补跑或分布式 exactly-once 保证。
|
||||
- 保留 node-cron 已支持的列表、范围和名称,不调用 cron-parser 的 `stringify()` 重写整条表达式。先校验整条表达式,通过则跳过逐字段探测;失败时才检查并展开不支持的字段(如数字/名称起点步长)。保留原有 `?` 和 `#` 兼容处理,避免有效的 `6,12,18` 被压缩为不兼容的 `6/6`;日期/星期拆分后的最终校验仍然保留。
|
||||
- 星期步长范围以 `7` 结尾时,先按 cron-parser 的解析值展开。例如 `2-7/4` 在现有解析器中包含星期日,而 node-cron 原样执行会遗漏星期日;仅通过语法校验不足以保证执行含义一致。
|
||||
- 单月规则先剔除超出该月最大日期的值再计算日期数量,保留旧版日期/星期的通配计数语义。被判为通配的字段不再作为另一侧的 AND 约束,避免 `2-31 4 MON` 和包含 `L`、重复星期日的边界规则漏执行。
|
||||
- 日期与星期按 OR 拆分后,若日期分支在所有指定月份中都不可达,而星期分支有效,则仅保留星期分支;移除前仍检查日期分支在任意月份下语法有效。`31 2,4 MON` 因而可以执行,单独的 `31 2,4 *` 仍拒绝。
|
||||
- 标准化结果使用上限 512 条的只读缓存,仅缓存语法,不缓存运行实例或下次执行时间。避免大批相同表达式重复解析导致恢复 RPC 超时。
|
||||
- 回调异常记录日志,后续调度继续;取消时调用原生 `destroy()` 释放计时器和注册表。
|
||||
- 持久化无效任务在恢复时跳过并记录日志,无效订阅不会产生未处理的启动拒绝。API、导入和执行使用同一套表达式校验。
|
||||
@@ -31,6 +35,7 @@ Node 调度路径使用固定版本 `node-cron@4.6.0`,普通任务(含额外
|
||||
|
||||
- `test/fixtures/legacy-cron.json` 是迁移前使用 node-schedule 2.1.1 / cron-parser 4.9.0 保存的快照:269 条语法判定,18 组日历序列,每组 10 次执行。
|
||||
- 真正的 node-cron 实例配合虚拟时钟验证日期/星期 OR、交集去重、闰年、L/#、8 秒迟到补执行、取消释放、回调拒绝和夏令时。
|
||||
- `test/fixtures/cron-calendar-boundaries.json` 保存 64 组规则与起点,以独立 cron-parser 4.9.0 / luxon 3.7.2 或公历计算产生预期结果,每组在 UTC、Asia/Shanghai 下各核对 4 次实际执行,包括 2100 年不闰和长周期第五个星期日的边界。时区子测试清理继承的 `NODE_TEST_CONTEXT` 并检查子测试输出,防止嵌套测试入口被跳过而出现假通过。
|
||||
- 注册构造失败回滚、无效订阅隔离、空表达式立即运行,以及现有调度恢复/503/子进程测试覆盖接入流程。
|
||||
- 隔离的正式 Debian 镜像使用实际编译产物验证 10,000 条有效任务和 2 条错误任务的恢复、300 个同时到期的 Python 脚本、并发上限 16 及 HTTP 健康检查。具体结果见本次测试报告;这是生产镜像隔离验证,不是线上长期运行证明。
|
||||
|
||||
@@ -47,3 +52,11 @@ Node 调度路径使用固定版本 `node-cron@4.6.0`,普通任务(含额外
|
||||
完整回归 267 通过、3 项原有跳过;Node 20 的 10 项修复回归及 Alpine 镜像的 24 项兼容/宏测试通过。
|
||||
|
||||
额外使用 9,700 个不同表达式和 300 个同时到期任务验证恢复(另有 2 个无效任务),不再局限于共享两个表达式。300/300 脚本完成,无重复或遗漏,最大并发 16,270 个退出码 0、30 个预设退出码 3 均落库。恢复后健康检查 1,650/1,650 成功、任务列表 33/33 成功;启动恢复阶段另有 260 次健康检查失败,整批脚本耗时 60.27 秒。较大快照恢复仍需等待,不承诺启动阶段始终可用或任意规模都能在时限内恢复。
|
||||
|
||||
### 深度兼容性审计(2026-10-17)
|
||||
|
||||
保持 cron-parser 5.4.0 和 node-cron 4.6.0,扫描 19,811 个表达式,覆盖六个字段的数字、前导零、列表、范围、起点步长、名称、L/#/? 及日期/星期组合。发现并修复星期步长末尾 7、单月日期裁剪、通配计数边界和不可达日期 OR 分支问题;47 个初始失败样例已保存为回归数据。
|
||||
|
||||
UTC 和 Asia/Shanghai 各比较 17,795 条规则的连续 4 次实际触发,共核对 142,360 个触发时刻,修复后无偏差、无构造失败。每个时区另外有 71 个通配 `#` 组合沿用已明确保留的旧适配规则,不与原解析器直接对照;22 个原版拒绝的日期输入仍拒绝,11 个参考迭代器触及搜索上限的长周期规则改用独立公历预期验证。
|
||||
|
||||
持久化回归覆盖 64 组规则与起点、两个时区,共 128 组日历用例;还保留 H、裸 `/N`、非标准 L 写法及不可达规则的拒绝行为。完整回归 745 通过、8 跳过,后端编译通过。本次为确定性语法与日历测试,不涉及生产环境压力或长期稳定性验证。
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# 通知日志
|
||||
|
||||
通知日志记录系统统一通知入口(包括登录通知、配置测试和系统通知 API),以及任务使用内置 `notify.js`、`notify.py` 或 `QLAPI.notify` 发出的通知。脚本通知按实际配置的渠道各记录一条结果,未配置渠道和跳过的标题不记账。
|
||||
|
||||
日志包含时间、标题、正文、渠道、结果和来源;脚本来源另包含文件名与任务 ID。成功表示通知渠道返回成功或接受请求,不表示最终收件人已经收到。多目标渠道只在所有目标成功时记为成功。
|
||||
|
||||
所有入口共用数据库中的最近 200 条日志。标题最多 512 字符、正文最多 8192 字符;上报字段不包含通知密码、令牌或渠道配置。日志页受系统设置的管理员权限保护。
|
||||
|
||||
脚本通过现有的本机 mTLS gRPC 服务上报,独立客户端最多等待 1.5 秒。旧面板、服务不可用或数据库写入失败不会改变通知发送结果。Python 使用 Node 上报子进程,并限制在 3 秒内。
|
||||
|
||||
自行保存的旧版通知库需要更新为面板内置版本才能上报。脚本自行调用 HTTP、SMTP 或第三方 SDK 时不经过这些入口,无法自动纳入通知日志。
|
||||
@@ -0,0 +1,96 @@
|
||||
# 平台完整升级包
|
||||
|
||||
CI 从同次构建的不可变镜像摘要导出完整 tar.gz,包含源码中的运行脚本和模板、前后端编译产物、锁文件和已经安装的生产 `node_modules`。不包含 Node 可执行文件、用户数据、`.env`、Git 历史或 npm 缓存。保留现有 Node 和用户脚本依赖。
|
||||
|
||||
## 在线升级
|
||||
|
||||
`package.json` 中 `qinglongUpgradeFormat: 2` 表示使用完整包。旧版升级器仍可通过原归档路径完成首次迁移;完整包版本不会在平台包缺失、损坏或不兼容时退回设备端 npm 安装。仅当完整包已通过校验、依赖加载或后端预启动明确报告依赖错误时,在线升级会按同一冻结锁文件修复一次生产依赖,然后再次预启动;第二次失败即停止,保留正在运行的旧服务。
|
||||
|
||||
面板下载任务与本地离线入口共用 `platformBundle.cjs`:平台匹配 → 归档安全检查 → 全文件/链接/权限和锁文件校验 → SQLite/Sequelize/cron-parser/gRPC/undici 加载及 SQLite 内存库读写 → 隔离后端预启动 → 发布就绪指针。预启动使用现有 Node、临时数据目录和 loopback 随机端口,运行真实数据库建表/迁移和事务读写、gRPC 服务,以及与正常启动共用的 HTTP 中间件、路由和 WebSocket 初始化,再通过 `/api/health` 确认双服务与目标版本、提交和后端摘要;不运行任务、订阅、通知、用户启动钩子和系统依赖安装。临时库不是用户数据库副本,因此实际应用后的健康检查和回退仍不可省略。点击重启时,接口只检查就绪记录并提交后台任务;完整文件与依赖复验由独立工作进程执行,复验通过后才停止旧服务,避免小设备的预检耗时超过 HTTP 请求期限。离线升级同步面板任务状态,并与在线任务互斥;中断准备时保留之前的可用就绪包。原有服务停止、文件备份、启动健康检查及失败恢复仍由共享升级引擎处理。
|
||||
|
||||
正式升级包跟随 master 同一次版本镜像构建;develop 的普通提交也自动构建开发包。两者都直接使用该次 Alpine/Debian 镜像的不可变 digest。Git tag 工作流和 `static_sync_only` 不生成完整包。需要重建当前开发包时,可手动运行同一工作流:
|
||||
|
||||
```sh
|
||||
gh workflow run build-docker-image.yml --ref develop
|
||||
```
|
||||
|
||||
开发包只使用 `upgrade-develop` 这一个 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。
|
||||
|
||||
已有安装(默认应用并重启):
|
||||
|
||||
```sh
|
||||
node install-bundle-<build-id>.cjs --root /ql --file /absolute/path/qinglong-<commit>-linux-arm64-musl-<build-id>.tar.gz
|
||||
```
|
||||
|
||||
只准备,稍后手动重启:
|
||||
|
||||
```sh
|
||||
node install-bundle-<build-id>.cjs --root /ql --file /absolute/path/package.tar.gz --download-only
|
||||
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
|
||||
node install-bundle-<build-id>.cjs --root /opt/qinglong --file /absolute/path/package.tar.gz --install
|
||||
QL_DIR=/opt/qinglong QL_DATA_DIR=/opt/qinglong/data bash /opt/qinglong/shell/start-simplify.sh
|
||||
```
|
||||
|
||||
`--install` 拒绝非空目录,不覆盖既有配置。该包安装青龙及其 Node 生产依赖;首次启动仍需已有 Python、Bash、OpenSSL、PM2、nginx 等相应运行环境,不会在线安装或替换它们。手动启动脚本会在启动工作进程前完成首次 gRPC 证书准备,避免慢设备生成密钥耗尽工作进程的就绪期限;已有证书继续复用。服务启动和系统自启按原手动安装流程配置。
|
||||
|
||||
ARMv6 小设备使用 `linux-armv6-musl.tar.gz`,要求已有兼容的 musl 系统及硬浮点 Node。ARMv6 glibc(例如 Raspbian/Raspberry Pi OS 32 位系统)不在平台包支持范围内,不能使用其他架构或 musl 包替代;安装器仍会检查实际架构和 libc,拒绝不匹配的包。
|
||||
|
||||
## npm 面板包
|
||||
|
||||
`@whyour/qinglong` 仍通过 npm 安装生产依赖;它包含运行脚本、gRPC proto、升级引擎和完整前后端构建,不包含用户数据、Node、开发 CLI 或生产 `node_modules`。`@whyour/qinglong-cli` 是单独的远程管理包。
|
||||
|
||||
npm 会排除根目录的 `pnpm-lock.yaml`。主包的原生 `npm pack/publish` 在 `prepack` 中核对当前 Git 提交、干净工作区、锁文件及完整构建文件的哈希,通过后才将锁文件复制为 `shell/upgrade-lock.yaml`,安装时由仅使用 Node 内置模块的 `postinstall` 校验 SHA256 后恢复标准路径;源代码安装和 Docker 的最小依赖构建阶段继续使用原有锁文件。归档使用精确运行文件白名单,避免打入本地生成的预加载配置、缓存、测试和验收输出。静态构建中的 source map 与 gzip 文件也必须完整保留,否则清单复验会失败。
|
||||
|
||||
`package.json#files` 是 npm 包内容的唯一项目规则,不再维护根 `.npmignore`:npm 在根包使用 `files` 时会停用该目录的 `.npmignore` 和 `.gitignore` 规则。打包测试在没有 `.npmignore` 的独立源码目录中注入私有配置、密钥占位文件、缓存及生成的预加载配置,执行真实 `npm pack` 和安装,检查运行文件完整、私有文件及其内容不进入归档。此规则只用于 npm 包,完整平台 tar.gz 仍由平台包构建器处理。
|
||||
|
||||
干净源码完成 `pnpm build:front`、`pnpm build:back`、`pnpm build:info` 后,运行 `node scripts/verify-panel-package.cjs pack /tmp/qinglong-npm` 生成并检查真实 npm 归档。CI 在一次性 Linux 容器中,以 Node 20.18.1 和 Node 24 安装同一个归档,使用真实生产依赖和已安装的 `qinglong`、`ql` 入口,验证启动、损坏下载保留旧服务、在线准备与重启、离线准备与 Bash 重启及账号、配置、环境变量、任务和脚本保留。测试还安装完整性摘要固定的已发布 `@whyour/qinglong@2.23.0`,在旧服务中建立真实数据,再用新 `.tgz` 执行 npm 覆盖安装,验证账号、配置、环境变量、任务和脚本保留。之后升级到同次 Debian 镜像不可变 digest 通过正式 `Dockerfile.upgrade-bundle` 导出的原始平台包,确认运行目录变为 pnpm 布局。该单平台验收包仅上传短期 CI artifact,不发布 Release,也不推进开发或正式更新索引。
|
||||
|
||||
Umi 4.4.4 的构建依赖仍使用 Node 24 已移除的 `http_parser`。前端源码构建使用 `.nvmrc` 中的 Node 22,CI 从该文件读取版本,Vercel 前后端项目均配置为 Node 22.x。`package.json` 不声明 `engines.node`,避免 Vercel 用开放的最低运行版本范围覆盖项目的构建版本;`pnpm build:front` 直接执行构建,不下载或切换 Node。面板运行时仍支持 Node 20.18.1 及以上,平台包的清单与安装器独立校验最低 Node 要求,npm 实装验收继续覆盖 Node 20/24。
|
||||
|
||||
服务集成测试只能在专用容器中运行:设置 `QL_PANEL_PACKAGE_TEST_ISOLATED=1`、指向隔离 PM2 的 `QL_PANEL_TEST_PM2` 和指向正式构建脚本输出目录的 `QL_PANEL_TEST_BUNDLE_DIR`,然后执行 `node scripts/verify-panel-package.cjs verify /tmp/qinglong-npm /tmp/qinglong-npm-results`。容器需已有 Bash、curl、OpenSSL、Python、jq、nginx 和 procps。旧正式包会重置 PATH,因此验收容器将 PM2 6.0.14 安装到 `/usr/local/bin/pm2`,并以此设置 `QL_PANEL_TEST_PM2`;隔离通过专属容器和独立 `PM2_HOME` 保证。测试创建独立的 npm prefix、HOME、PM2_HOME 和数据目录,结束后清理。
|
||||
|
||||
通过 npm 覆盖升级前,应先停止服务,执行新包安装,再启动服务。正常 npm 安装的 `postinstall` 会从当前路径对应的 npm 旧包临时目录中自动保留 `.env`,逐字节恢复为权限 `0600` 的普通文件,不需要手工备份和恢复;新目录中已有的 `.env` 不会被覆盖。只读取包名匹配的旧包,不扫描其他安装或备份目录;异常配置(如符号链接)会使安装失败,由 npm 回退旧包,避免静默丢失配置。此机制需要启用 npm 安装脚本,不能使用 `--ignore-scripts`。持久数据仍必须放在包目录以外的 `QL_DATA_DIR`。验收直接检查真实旧 npm 包覆盖安装后配置自动保留,并校验外置用户数据。在线和离线平台包升级仍由事务保留现有 `.env`。
|
||||
|
||||
master 发布任务等待上述测试通过,直接发布已验证的 `.tgz`,不重新构建或打包。develop 只生成测试归档与验收记录,不发布 npm。
|
||||
|
||||
## 平台兼容
|
||||
|
||||
- 最低 Node 为 20.18.1,Node-API 至少 6;安装器只检查,不替换 Node。
|
||||
- 以 Node 实际运行架构、ARM 指令集和 libc 类型/版本选择包;不依据 Docker 架构别名猜测。
|
||||
- glibc、musl 分包,并记录构建系统 libc 下限。系统库仍以目标设备加载测试作为最后检查。
|
||||
- ARMv6 保留独立包名。ARMv6 在目标镜像中从源码编译 SQLite,禁止复用 generic `arm` 的 ARMv7 预编译件;构建需通过加载和内存库读写,失败则不发布渠道。
|
||||
- ARMv6 的归档构建、在线更新工作进程及独立离线安装器使用 `--jitless`,避开 ARM1176 上 V8 的优化编译崩溃;保留现有 Node 二进制,Node 24 及以上的 ARMv6 面板进程通过共享 `nodeRuntime` 使用 `--no-opt --no-sparkplug --no-maglev`,其他架构和较低 Node 版本保持原启动参数。ARMv6 的 gRPC 工作进程首次启动及重建最多等待 2 分钟;升级后的整体健康检查最多等待 5 分钟,覆盖慢设备按顺序启动数据库、调度与 HTTP 工作进程的耗时;其他平台仍为 60 秒,超时或版本校验失败仍执行回退。依赖加载与后端预启动使用现有 Node 的正常模式,使 Node 20.18.1 的内置 HTTP/WebAssembly 组件保持可用。预启动只有一个后端进程,按顺序初始化数据库、gRPC、HTTP,不启动 PM2/cluster;ARMv6 V8 堆上限 128 MiB、期限 5 分钟,其他平台为 192 MiB/60 秒。堆上限不是 RSS 总内存上限,系统仍需为现有服务和 OpenSSL 留出内存。依赖修复的包管理器及编译使用单并发、低网络并发和有界时长;ARMv6 包管理器采用 `--jitless`、256 MiB 堆上限,并优先复用版本匹配的现有 Node 头文件与实际运行时编译配置;超时、中断会终止整个进程组并清理临时目录。
|
||||
- tar 中允许 pnpm 的包内符号链接;拒绝绝对路径、越界路径、包外链接、硬链接、设备条目和经符号链接写入的条目。重启前再次校验依赖,暂存失败保留上一次可用的就绪包。
|
||||
|
||||
自托管镜像可设置 `QL_UPGRADE_INDEX_URL` 指向兼容索引;要求 HTTPS,测试时仅放行 loopback HTTP。索引和所有平台归档应部署到同一可访问源,避免依赖额外的 raw 域名。
|
||||
@@ -0,0 +1,11 @@
|
||||
# 青龙 2.24.0-dev.0 开发候选
|
||||
|
||||
这是 `develop` 渠道的开发构建,上一正式版本为 2.23.0。此版本尚未正式发布。
|
||||
|
||||
## 本轮变化
|
||||
|
||||
- Alpine 与 Debian 镜像统一使用 Node.js 24;SQLite 依赖升级至 1.1.4。
|
||||
- Alpine 镜像保留 arm/v6 和 arm/v7,Debian 镜像暂不提供 arm/v7。
|
||||
- ARM64 升级包改用原生 runner 构建,避免在 x64 QEMU 下归档时发生异常。
|
||||
|
||||
升级前请备份完整持久化数据,并在目标设备验证脚本、依赖和通知。正式发布说明将在候选验收后整理。
|
||||
@@ -0,0 +1,23 @@
|
||||
# 青龙 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,4 +1,6 @@
|
||||
const isContainer = process.env.QL_CONTAINER === 'true';
|
||||
const runtimeArgs =
|
||||
require('./cli/src/internal/maintenance/nodeRuntime.cjs').serverRuntimeArgs();
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
@@ -18,6 +20,7 @@ module.exports = {
|
||||
// visible through `docker logs` before Winston is initialized.
|
||||
error_file: isContainer ? '/proc/1/fd/2' : undefined,
|
||||
script: 'static/build/app.js',
|
||||
...(runtimeArgs.length ? { node_args: runtimeArgs } : {}),
|
||||
env: {
|
||||
http_proxy: '',
|
||||
https_proxy: '',
|
||||
|
||||
+50
-8
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@whyour/qinglong",
|
||||
"packageManager": "pnpm@8.3.1",
|
||||
"version": "2.23.0",
|
||||
"version": "2.24.0",
|
||||
"description": "Timed task management platform supporting Python3, JavaScript, Shell, Typescript",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -12,6 +12,42 @@
|
||||
"bugs": {
|
||||
"url": "https://github.com/whyour/qinglong/issues"
|
||||
},
|
||||
"files": [
|
||||
".env.example",
|
||||
"back/protos/api.proto",
|
||||
"cli/src/internal/maintenance/*.cjs",
|
||||
"ecosystem.config.js",
|
||||
"README*.md",
|
||||
"sample/*.sample.*",
|
||||
"sample/notify.js",
|
||||
"sample/notify.py",
|
||||
"sample/ql_sample.js",
|
||||
"sample/ql_sample.py",
|
||||
"shell/*.cjs",
|
||||
"shell/api.sh",
|
||||
"shell/bot.sh",
|
||||
"shell/check.sh",
|
||||
"shell/env.sh",
|
||||
"shell/lang/*.sh",
|
||||
"shell/node_path_cache.sh",
|
||||
"shell/otask.sh",
|
||||
"shell/preload/client.js",
|
||||
"shell/preload/client.py",
|
||||
"shell/preload/esm-loader.mjs",
|
||||
"shell/preload/notificationAudit.js",
|
||||
"shell/preload/sitecustomize.js",
|
||||
"shell/preload/sitecustomize.py",
|
||||
"shell/rmlog.sh",
|
||||
"shell/share.sh",
|
||||
"shell/start-simplify.sh",
|
||||
"shell/start.sh",
|
||||
"shell/task.sh",
|
||||
"shell/task-timeout.sh",
|
||||
"shell/update.sh",
|
||||
"shell/upgrade-lock.yaml",
|
||||
"static/",
|
||||
"version.yaml"
|
||||
],
|
||||
"scripts": {
|
||||
"build:cli": "node cli/scripts/build.cjs",
|
||||
"check:cli": "npm --prefix cli run check",
|
||||
@@ -28,7 +64,9 @@
|
||||
"panel": "npm run build:back && node static/build/app.js",
|
||||
"gen:proto": "protoc --experimental_allow_proto3_optional --plugin=./node_modules/.bin/protoc-gen-ts_proto ./back/protos/*.proto --ts_proto_out=./ --ts_proto_opt=outputServices=grpc-js,env=node,esModuleInterop=true,snakeToCamel=false",
|
||||
"prettier": "prettier --write '**/*.{js,jsx,tsx,ts,less,md,json}'",
|
||||
"postinstall": "max setup 2>/dev/null || true",
|
||||
"prepack": "node scripts/prepare-npm-package.cjs",
|
||||
"postpack": "node scripts/prepare-npm-package.cjs --clean",
|
||||
"postinstall": "node -e \"const fs = require('fs'); if (fs.existsSync('shell/install-package.cjs')) require('./shell/install-package.cjs').installPackage(); else fs.accessSync('pnpm-lock.yaml')\" && (max setup 2>/dev/null || true)",
|
||||
"start:simple": "bash shell/start-simplify.sh"
|
||||
},
|
||||
"gitHooks": {
|
||||
@@ -73,11 +111,13 @@
|
||||
}
|
||||
},
|
||||
"overrides": {
|
||||
"sqlite3": "npm:@whyour/sqlite3@1.1.2",
|
||||
"sqlite3": "npm:@whyour/sqlite3@1.1.4",
|
||||
"@codemirror/state": "6.5.4",
|
||||
"@codemirror/view": "6.39.16",
|
||||
"proxy-addr": "2.0.8",
|
||||
"websocket-driver": "0.7.5"
|
||||
"websocket-driver": "0.7.5",
|
||||
"brace-expansion@1": "1.1.21",
|
||||
"brace-expansion@2": "2.1.7"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -119,13 +159,14 @@
|
||||
"request-ip": "3.3.0",
|
||||
"sequelize": "6.37.8",
|
||||
"sockjs": "^0.3.24",
|
||||
"sqlite3": "npm:@whyour/sqlite3@1.1.2",
|
||||
"sqlite3": "npm:@whyour/sqlite3@1.1.4",
|
||||
"toad-scheduler": "^3.0.1",
|
||||
"typedi": "^0.10.0",
|
||||
"undici": "^7.9.0",
|
||||
"uuid": "^11.0.3",
|
||||
"winston": "^3.17.0",
|
||||
"winston-daily-rotate-file": "^5.0.0"
|
||||
"winston-daily-rotate-file": "^5.0.0",
|
||||
"tar": "7.5.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ant-design/icons": "^5.0.1",
|
||||
@@ -172,7 +213,7 @@
|
||||
"concurrently": "^7.0.0",
|
||||
"file-saver": "2.0.2",
|
||||
"lint-staged": "^13.0.3",
|
||||
"moment": "2.30.1",
|
||||
"moment": "2.31.0",
|
||||
"monaco-editor": "0.33.0",
|
||||
"nodemon": "^3.0.1",
|
||||
"prettier": "^2.5.1",
|
||||
@@ -199,5 +240,6 @@
|
||||
"typescript": "5.2.2",
|
||||
"vh-check": "^2.0.5",
|
||||
"virtualizedtableforantd4": "1.3.0"
|
||||
}
|
||||
},
|
||||
"qinglongUpgradeFormat": 2
|
||||
}
|
||||
|
||||
Generated
+27
-38
@@ -1,11 +1,13 @@
|
||||
lockfileVersion: '6.0'
|
||||
|
||||
overrides:
|
||||
sqlite3: npm:@whyour/sqlite3@1.1.2
|
||||
sqlite3: npm:@whyour/sqlite3@1.1.4
|
||||
'@codemirror/state': 6.5.4
|
||||
'@codemirror/view': 6.39.16
|
||||
proxy-addr: 2.0.8
|
||||
websocket-driver: 0.7.5
|
||||
brace-expansion@1: 1.1.21
|
||||
brace-expansion@2: 2.1.7
|
||||
|
||||
dependencies:
|
||||
'@ant-design/plots':
|
||||
@@ -118,13 +120,16 @@ dependencies:
|
||||
version: 3.3.0
|
||||
sequelize:
|
||||
specifier: 6.37.8
|
||||
version: 6.37.8(@whyour/sqlite3@1.1.2)
|
||||
version: 6.37.8(@whyour/sqlite3@1.1.4)
|
||||
sockjs:
|
||||
specifier: ^0.3.24
|
||||
version: 0.3.24
|
||||
sqlite3:
|
||||
specifier: npm:@whyour/sqlite3@1.1.2
|
||||
version: /@whyour/sqlite3@1.1.2
|
||||
specifier: npm:@whyour/sqlite3@1.1.4
|
||||
version: /@whyour/sqlite3@1.1.4
|
||||
tar:
|
||||
specifier: 7.5.20
|
||||
version: 7.5.20
|
||||
toad-scheduler:
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.1
|
||||
@@ -278,8 +283,8 @@ devDependencies:
|
||||
specifier: ^13.0.3
|
||||
version: 13.3.0
|
||||
moment:
|
||||
specifier: 2.30.1
|
||||
version: 2.30.1
|
||||
specifier: 2.31.0
|
||||
version: 2.31.0
|
||||
monaco-editor:
|
||||
specifier: 0.33.0
|
||||
version: 0.33.0
|
||||
@@ -876,7 +881,7 @@ packages:
|
||||
'@babel/runtime': 7.26.0
|
||||
antd: 4.24.16(react-dom@18.3.1)(react@18.3.1)
|
||||
classnames: 2.5.1
|
||||
moment: 2.30.1
|
||||
moment: 2.31.0
|
||||
rc-util: 5.43.0(react-dom@18.3.1)(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
@@ -3051,7 +3056,6 @@ packages:
|
||||
dependencies:
|
||||
minipass: 7.1.3
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@istanbuljs/load-nyc-config@1.1.0:
|
||||
resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==}
|
||||
@@ -3189,7 +3193,7 @@ packages:
|
||||
resolution: {integrity: sha512-Ngs9jhElXN7efS9WvvCC/p6rXMbihna8eNLVBc421Zf+VcFd+pR4DOcS6yA9V22EKtAy4DEj7LtvEEIm0bb80A==}
|
||||
engines: {node: '>= 18'}
|
||||
dependencies:
|
||||
sqlite3: /@whyour/sqlite3@1.1.2
|
||||
sqlite3: /@whyour/sqlite3@1.1.4
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- supports-color
|
||||
@@ -5369,7 +5373,7 @@ packages:
|
||||
fast-deep-equal: 3.1.3
|
||||
intl: 1.2.5
|
||||
lodash: 4.17.21
|
||||
moment: 2.30.1
|
||||
moment: 2.31.0
|
||||
qiankun: 2.10.16
|
||||
react-intl: 3.12.1(react@18.3.1)
|
||||
react-redux: 8.1.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1)(react@18.3.1)(redux@4.2.1)
|
||||
@@ -5607,14 +5611,15 @@ packages:
|
||||
- supports-color
|
||||
dev: true
|
||||
|
||||
/@whyour/sqlite3@1.1.2:
|
||||
resolution: {integrity: sha512-OY24/OJMqLHIGolo4GCFW4lrJKAoOB+ZqERXb7u7DKZQz7QR3f0GALE99BZWQBSZEVVZ/StVeAb2fCkMfTtuDQ==}
|
||||
/@whyour/sqlite3@1.1.4:
|
||||
resolution: {integrity: sha512-MlWzqOdpZ0KPuBx/FYRrHZ8p3S0eCbaf2d8duDzWmfko/aky25Xoss67qDjFgbRjnwj9z0i0KNdNtQI02dnWKA==}
|
||||
requiresBuild: true
|
||||
peerDependenciesMeta:
|
||||
node-gyp:
|
||||
optional: true
|
||||
dependencies:
|
||||
'@mapbox/node-pre-gyp': 1.0.11
|
||||
detect-libc: 1.0.3
|
||||
node-addon-api: 4.3.0
|
||||
tar: 6.2.1
|
||||
optionalDependencies:
|
||||
@@ -5866,7 +5871,7 @@ packages:
|
||||
classnames: 2.5.1
|
||||
copy-to-clipboard: 3.3.3
|
||||
lodash: 4.17.21
|
||||
moment: 2.30.1
|
||||
moment: 2.31.0
|
||||
rc-cascader: 3.7.3(react-dom@18.3.1)(react@18.3.1)
|
||||
rc-checkbox: 3.0.1(react-dom@18.3.1)(react@18.3.1)
|
||||
rc-collapse: 3.4.2(react-dom@18.3.1)(react@18.3.1)
|
||||
@@ -6357,18 +6362,6 @@ packages:
|
||||
big-integer: 1.6.52
|
||||
dev: true
|
||||
|
||||
/brace-expansion@1.1.12:
|
||||
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
concat-map: 0.0.1
|
||||
|
||||
/brace-expansion@2.0.2:
|
||||
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
dev: true
|
||||
|
||||
/braces@3.0.3:
|
||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -6633,7 +6626,6 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/ci-info@3.9.0:
|
||||
resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==}
|
||||
@@ -7610,7 +7602,6 @@ packages:
|
||||
resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==}
|
||||
engines: {node: '>=0.10'}
|
||||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/detect-libc@2.1.2:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
@@ -8662,7 +8653,7 @@ packages:
|
||||
/file-stream-rotator@0.6.1:
|
||||
resolution: {integrity: sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==}
|
||||
dependencies:
|
||||
moment: 2.30.1
|
||||
moment: 2.31.0
|
||||
dev: false
|
||||
|
||||
/fill-range@7.1.1:
|
||||
@@ -10711,7 +10702,6 @@ packages:
|
||||
dependencies:
|
||||
minipass: 7.1.3
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/mkdirp@0.5.6:
|
||||
resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
|
||||
@@ -10733,11 +10723,12 @@ packages:
|
||||
/moment-timezone@0.5.46:
|
||||
resolution: {integrity: sha512-ZXm9b36esbe7OmdABqIWJuBBiLLwAjrN7CE+7sYdCCx82Nabt1wHDj8TVseS59QIlfFPbOoiBPm6ca9BioG4hw==}
|
||||
dependencies:
|
||||
moment: 2.30.1
|
||||
moment: 2.31.0
|
||||
dev: false
|
||||
|
||||
/moment@2.30.1:
|
||||
resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==}
|
||||
/moment@2.31.0:
|
||||
resolution:
|
||||
integrity: sha512-0acOTfMiWOheYS4eoWb80yYMb/JLvVv9SHbs2PehaDzfUG0Bw855SKyk0IKTnPGa5+U2bmi3W68l1+sGLX/pvw==
|
||||
|
||||
/monaco-editor@0.33.0:
|
||||
resolution: {integrity: sha512-VcRWPSLIUEgQJQIE0pVT8FcGBIgFoxz7jtqctE+IiCxWugD0DwgyQBcZBhdSrdMC84eumoqMZsGl2GTreOzwqw==}
|
||||
@@ -12724,7 +12715,7 @@ packages:
|
||||
classnames: 2.5.1
|
||||
date-fns: 2.30.0
|
||||
dayjs: 1.11.13
|
||||
moment: 2.30.1
|
||||
moment: 2.31.0
|
||||
rc-trigger: 5.3.4(react-dom@18.3.1)(react@18.3.1)
|
||||
rc-util: 5.43.0(react-dom@18.3.1)(react@18.3.1)
|
||||
react: 18.3.1
|
||||
@@ -13912,7 +13903,7 @@ packages:
|
||||
engines: {node: '>= 10.0.0'}
|
||||
dev: false
|
||||
|
||||
/sequelize@6.37.8(@whyour/sqlite3@1.1.2):
|
||||
/sequelize@6.37.8(@whyour/sqlite3@1.1.4):
|
||||
resolution: {integrity: sha512-HJ0IQFqcTsTiqbEgiuioYFMSD00TP6Cz7zoTti+zVVBwVe9fEhev9cH6WnM3XU31+ABS356durAb99ZuOthnKw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
@@ -13951,13 +13942,13 @@ packages:
|
||||
dottie: 2.0.6
|
||||
inflection: 1.13.4
|
||||
lodash: 4.17.21
|
||||
moment: 2.30.1
|
||||
moment: 2.31.0
|
||||
moment-timezone: 0.5.46
|
||||
pg-connection-string: 2.7.0
|
||||
retry-as-promised: 7.0.4
|
||||
semver: 7.7.4
|
||||
sequelize-pool: 7.1.0
|
||||
sqlite3: /@whyour/sqlite3@1.1.2
|
||||
sqlite3: /@whyour/sqlite3@1.1.4
|
||||
toposort-class: 1.0.1
|
||||
uuid: 8.3.2
|
||||
validator: 13.12.0
|
||||
@@ -14715,7 +14706,6 @@ packages:
|
||||
minizlib: 3.1.0
|
||||
yallist: 5.0.0
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/terser@5.36.0:
|
||||
resolution: {integrity: sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w==}
|
||||
@@ -15736,7 +15726,6 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/yaml@1.10.2:
|
||||
resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==}
|
||||
|
||||
+308
-135
File diff suppressed because it is too large
Load Diff
+174
-27
@@ -10,6 +10,8 @@ import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
import smtplib
|
||||
import subprocess
|
||||
from typing import Optional
|
||||
from email.mime.text import MIMEText
|
||||
from email.header import Header
|
||||
from email.utils import formataddr
|
||||
@@ -161,10 +163,11 @@ for k in push_config:
|
||||
push_config[k] = v
|
||||
|
||||
|
||||
def bark(title: str, content: str) -> None:
|
||||
def bark(title: str, content: str) -> Optional[bool]:
|
||||
"""
|
||||
使用 bark 推送消息。
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("BARK_PUSH"):
|
||||
return
|
||||
print("bark 服务启动")
|
||||
@@ -200,22 +203,28 @@ def bark(title: str, content: str) -> None:
|
||||
).json()
|
||||
|
||||
if response["code"] == 200:
|
||||
audit_results.append(True)
|
||||
print("bark 推送成功!")
|
||||
else:
|
||||
audit_results.append(False)
|
||||
print("bark 推送失败!")
|
||||
return all(audit_results) if audit_results else None
|
||||
|
||||
|
||||
def console(title: str, content: str) -> None:
|
||||
def console(title: str, content: str) -> Optional[bool]:
|
||||
"""
|
||||
使用 控制台 推送消息。
|
||||
"""
|
||||
audit_results = []
|
||||
print(f"{title}\n\n{content}")
|
||||
return True
|
||||
|
||||
|
||||
def dingding_bot(title: str, content: str) -> None:
|
||||
def dingding_bot(title: str, content: str) -> Optional[bool]:
|
||||
"""
|
||||
使用 钉钉机器人 推送消息。
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("DD_BOT_SECRET") or not push_config.get("DD_BOT_TOKEN"):
|
||||
return
|
||||
print("钉钉机器人 服务启动")
|
||||
@@ -236,15 +245,19 @@ def dingding_bot(title: str, content: str) -> None:
|
||||
).json()
|
||||
|
||||
if not response["errcode"]:
|
||||
audit_results.append(True)
|
||||
print("钉钉机器人 推送成功!")
|
||||
else:
|
||||
audit_results.append(False)
|
||||
print("钉钉机器人 推送失败!")
|
||||
return all(audit_results) if audit_results else None
|
||||
|
||||
|
||||
def feishu_bot(title: str, content: str) -> None:
|
||||
def feishu_bot(title: str, content: str) -> Optional[bool]:
|
||||
"""
|
||||
使用 飞书机器人 推送消息。
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("FSKEY"):
|
||||
return
|
||||
print("飞书 服务启动")
|
||||
@@ -268,15 +281,19 @@ def feishu_bot(title: str, content: str) -> None:
|
||||
response = requests.post(url, data=json.dumps(data)).json()
|
||||
|
||||
if response.get("StatusCode") == 0 or response.get("code") == 0:
|
||||
audit_results.append(True)
|
||||
print("飞书 推送成功!")
|
||||
else:
|
||||
audit_results.append(False)
|
||||
print("飞书 推送失败!错误信息如下:\n", response)
|
||||
return all(audit_results) if audit_results else None
|
||||
|
||||
|
||||
def go_cqhttp(title: str, content: str) -> None:
|
||||
def go_cqhttp(title: str, content: str) -> Optional[bool]:
|
||||
"""
|
||||
使用 go_cqhttp 推送消息。
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("GOBOT_URL") or not push_config.get("GOBOT_QQ"):
|
||||
return
|
||||
print("go-cqhttp 服务启动")
|
||||
@@ -285,15 +302,19 @@ def go_cqhttp(title: str, content: str) -> None:
|
||||
response = requests.get(url).json()
|
||||
|
||||
if response["status"] == "ok":
|
||||
audit_results.append(True)
|
||||
print("go-cqhttp 推送成功!")
|
||||
else:
|
||||
audit_results.append(False)
|
||||
print("go-cqhttp 推送失败!")
|
||||
return all(audit_results) if audit_results else None
|
||||
|
||||
|
||||
def gotify(title: str, content: str) -> None:
|
||||
def gotify(title: str, content: str) -> Optional[bool]:
|
||||
"""
|
||||
使用 gotify 推送消息。
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("GOTIFY_URL") or not push_config.get("GOTIFY_TOKEN"):
|
||||
return
|
||||
print("gotify 服务启动")
|
||||
@@ -307,15 +328,19 @@ def gotify(title: str, content: str) -> None:
|
||||
response = requests.post(url, data=data).json()
|
||||
|
||||
if response.get("id"):
|
||||
audit_results.append(True)
|
||||
print("gotify 推送成功!")
|
||||
else:
|
||||
audit_results.append(False)
|
||||
print("gotify 推送失败!")
|
||||
return all(audit_results) if audit_results else None
|
||||
|
||||
|
||||
def iGot(title: str, content: str) -> None:
|
||||
def iGot(title: str, content: str) -> Optional[bool]:
|
||||
"""
|
||||
使用 iGot 推送消息。
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("IGOT_PUSH_KEY"):
|
||||
return
|
||||
print("iGot 服务启动")
|
||||
@@ -326,15 +351,19 @@ def iGot(title: str, content: str) -> None:
|
||||
response = requests.post(url, data=data, headers=headers).json()
|
||||
|
||||
if response["ret"] == 0:
|
||||
audit_results.append(True)
|
||||
print("iGot 推送成功!")
|
||||
else:
|
||||
audit_results.append(False)
|
||||
print(f'iGot 推送失败!{response["errMsg"]}')
|
||||
return all(audit_results) if audit_results else None
|
||||
|
||||
|
||||
def serverJ(title: str, content: str) -> None:
|
||||
def serverJ(title: str, content: str) -> Optional[bool]:
|
||||
"""
|
||||
通过 serverJ 推送消息。
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("PUSH_KEY"):
|
||||
return
|
||||
print("serverJ 服务启动")
|
||||
@@ -351,15 +380,19 @@ def serverJ(title: str, content: str) -> None:
|
||||
response = requests.post(url, data=data).json()
|
||||
|
||||
if response.get("errno") == 0 or response.get("code") == 0:
|
||||
audit_results.append(True)
|
||||
print("serverJ 推送成功!")
|
||||
else:
|
||||
audit_results.append(False)
|
||||
print(f'serverJ 推送失败!错误码:{response["message"]}')
|
||||
return all(audit_results) if audit_results else None
|
||||
|
||||
|
||||
def pushdeer(title: str, content: str) -> None:
|
||||
def pushdeer(title: str, content: str) -> Optional[bool]:
|
||||
"""
|
||||
通过PushDeer 推送消息
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("DEER_KEY"):
|
||||
return
|
||||
print("PushDeer 服务启动")
|
||||
@@ -376,15 +409,19 @@ def pushdeer(title: str, content: str) -> None:
|
||||
response = requests.post(url, data=data).json()
|
||||
|
||||
if len(response.get("content").get("result")) > 0:
|
||||
audit_results.append(True)
|
||||
print("PushDeer 推送成功!")
|
||||
else:
|
||||
audit_results.append(False)
|
||||
print("PushDeer 推送失败!错误信息:", response)
|
||||
return all(audit_results) if audit_results else None
|
||||
|
||||
|
||||
def chat(title: str, content: str) -> None:
|
||||
def chat(title: str, content: str) -> Optional[bool]:
|
||||
"""
|
||||
通过Chat 推送消息
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("CHAT_URL") or not push_config.get("CHAT_TOKEN"):
|
||||
return
|
||||
print("chat 服务启动")
|
||||
@@ -393,15 +430,19 @@ def chat(title: str, content: str) -> None:
|
||||
response = requests.post(url, data=data)
|
||||
|
||||
if response.status_code == 200:
|
||||
audit_results.append(True)
|
||||
print("Chat 推送成功!")
|
||||
else:
|
||||
audit_results.append(False)
|
||||
print("Chat 推送失败!错误信息:", response)
|
||||
return all(audit_results) if audit_results else None
|
||||
|
||||
|
||||
def pushplus_bot(title: str, content: str) -> None:
|
||||
def pushplus_bot(title: str, content: str) -> Optional[bool]:
|
||||
"""
|
||||
通过 pushplus 推送消息。
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("PUSH_PLUS_TOKEN"):
|
||||
return
|
||||
print("PUSHPLUS 服务启动")
|
||||
@@ -424,7 +465,9 @@ def pushplus_bot(title: str, content: str) -> None:
|
||||
|
||||
code = response["code"]
|
||||
if code == 200:
|
||||
audit_results.append(True)
|
||||
print("PUSHPLUS 推送请求成功,可根据流水号查询推送结果:" + response["data"])
|
||||
audit_results.append(True)
|
||||
print(
|
||||
"注意:请求成功并不代表推送成功,如未收到消息,请到pushplus官网使用流水号查询推送最终结果"
|
||||
)
|
||||
@@ -437,16 +480,20 @@ def pushplus_bot(title: str, content: str) -> None:
|
||||
response = requests.post(url=url_old, data=body, headers=headers).json()
|
||||
|
||||
if response["code"] == 200:
|
||||
audit_results.append(True)
|
||||
print("PUSHPLUS(hxtrip) 推送成功!")
|
||||
|
||||
else:
|
||||
audit_results.append(False)
|
||||
print("PUSHPLUS 推送失败!")
|
||||
return all(audit_results) if audit_results else None
|
||||
|
||||
|
||||
def weplus_bot(title: str, content: str) -> None:
|
||||
def weplus_bot(title: str, content: str) -> Optional[bool]:
|
||||
"""
|
||||
通过 微加机器人 推送消息。
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("WE_PLUS_BOT_TOKEN"):
|
||||
return
|
||||
print("微加机器人 服务启动")
|
||||
@@ -469,15 +516,19 @@ def weplus_bot(title: str, content: str) -> None:
|
||||
response = requests.post(url=url, data=body, headers=headers).json()
|
||||
|
||||
if response["code"] == 200:
|
||||
audit_results.append(True)
|
||||
print("微加机器人 推送成功!")
|
||||
else:
|
||||
audit_results.append(False)
|
||||
print("微加机器人 推送失败!")
|
||||
return all(audit_results) if audit_results else None
|
||||
|
||||
|
||||
def qmsg_bot(title: str, content: str) -> None:
|
||||
def qmsg_bot(title: str, content: str) -> Optional[bool]:
|
||||
"""
|
||||
使用 qmsg 推送消息。
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("QMSG_KEY") or not push_config.get("QMSG_TYPE"):
|
||||
return
|
||||
print("qmsg 服务启动")
|
||||
@@ -487,15 +538,19 @@ def qmsg_bot(title: str, content: str) -> None:
|
||||
response = requests.post(url=url, params=payload).json()
|
||||
|
||||
if response["code"] == 0:
|
||||
audit_results.append(True)
|
||||
print("qmsg 推送成功!")
|
||||
else:
|
||||
audit_results.append(False)
|
||||
print(f'qmsg 推送失败!{response["reason"]}')
|
||||
return all(audit_results) if audit_results else None
|
||||
|
||||
|
||||
def wecom_app(title: str, content: str) -> None:
|
||||
def wecom_app(title: str, content: str) -> Optional[bool]:
|
||||
"""
|
||||
通过 企业微信 APP 推送消息。
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("QYWX_AM"):
|
||||
return
|
||||
QYWX_AM_AY = re.split(",", push_config.get("QYWX_AM"))
|
||||
@@ -521,9 +576,12 @@ def wecom_app(title: str, content: str) -> None:
|
||||
response = wx.send_mpnews(title, content, media_id, touser)
|
||||
|
||||
if response == "ok":
|
||||
audit_results.append(True)
|
||||
print("企业微信推送成功!")
|
||||
else:
|
||||
audit_results.append(False)
|
||||
print("企业微信推送失败!错误信息如下:\n", response)
|
||||
return all(audit_results) if audit_results else None
|
||||
|
||||
|
||||
class WeCom:
|
||||
@@ -588,10 +646,11 @@ class WeCom:
|
||||
return respone["errmsg"]
|
||||
|
||||
|
||||
def wecom_bot(title: str, content: str) -> None:
|
||||
def wecom_bot(title: str, content: str) -> Optional[bool]:
|
||||
"""
|
||||
通过 企业微信机器人 推送消息。
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("QYWX_KEY"):
|
||||
return
|
||||
print("企业微信机器人服务启动")
|
||||
@@ -608,15 +667,19 @@ def wecom_bot(title: str, content: str) -> None:
|
||||
).json()
|
||||
|
||||
if response["errcode"] == 0:
|
||||
audit_results.append(True)
|
||||
print("企业微信机器人推送成功!")
|
||||
else:
|
||||
audit_results.append(False)
|
||||
print("企业微信机器人推送失败!")
|
||||
return all(audit_results) if audit_results else None
|
||||
|
||||
|
||||
def telegram_bot(title: str, content: str) -> None:
|
||||
def telegram_bot(title: str, content: str) -> Optional[bool]:
|
||||
"""
|
||||
使用 telegram 机器人 推送消息。
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("TG_BOT_TOKEN") or not push_config.get("TG_USER_ID"):
|
||||
return
|
||||
print("tg 服务启动")
|
||||
@@ -652,15 +715,19 @@ def telegram_bot(title: str, content: str) -> None:
|
||||
).json()
|
||||
|
||||
if response["ok"]:
|
||||
audit_results.append(True)
|
||||
print("tg 推送成功!")
|
||||
else:
|
||||
audit_results.append(False)
|
||||
print("tg 推送失败!")
|
||||
return all(audit_results) if audit_results else None
|
||||
|
||||
|
||||
def aibotk(title: str, content: str) -> None:
|
||||
def aibotk(title: str, content: str) -> Optional[bool]:
|
||||
"""
|
||||
使用 智能微秘书 推送消息。
|
||||
"""
|
||||
audit_results = []
|
||||
if (
|
||||
not push_config.get("AIBOTK_KEY")
|
||||
or not push_config.get("AIBOTK_TYPE")
|
||||
@@ -688,15 +755,19 @@ def aibotk(title: str, content: str) -> None:
|
||||
response = requests.post(url=url, data=body, headers=headers).json()
|
||||
print(response)
|
||||
if response["code"] == 0:
|
||||
audit_results.append(True)
|
||||
print("智能微秘书 推送成功!")
|
||||
else:
|
||||
audit_results.append(False)
|
||||
print(f'智能微秘书 推送失败!{response["error"]}')
|
||||
return all(audit_results) if audit_results else None
|
||||
|
||||
|
||||
def smtp(title: str, content: str) -> None:
|
||||
def smtp(title: str, content: str) -> Optional[bool]:
|
||||
"""
|
||||
使用 SMTP 邮件 推送消息。
|
||||
"""
|
||||
audit_results = []
|
||||
if (
|
||||
not push_config.get("SMTP_SERVER")
|
||||
or not push_config.get("SMTP_SSL")
|
||||
@@ -736,15 +807,19 @@ def smtp(title: str, content: str) -> None:
|
||||
message.as_bytes(),
|
||||
)
|
||||
smtp_server.close()
|
||||
audit_results.append(True)
|
||||
print("SMTP 邮件 推送成功!")
|
||||
except Exception as e:
|
||||
audit_results.append(False)
|
||||
print(f"SMTP 邮件 推送失败!{e}")
|
||||
return all(audit_results) if audit_results else None
|
||||
|
||||
|
||||
def pushme(title: str, content: str) -> None:
|
||||
def pushme(title: str, content: str) -> Optional[bool]:
|
||||
"""
|
||||
使用 PushMe 推送消息。
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("PUSHME_KEY"):
|
||||
return
|
||||
print("PushMe 服务启动")
|
||||
@@ -764,15 +839,19 @@ def pushme(title: str, content: str) -> None:
|
||||
response = requests.post(url, data=data)
|
||||
|
||||
if response.status_code == 200 and response.text == "success":
|
||||
audit_results.append(True)
|
||||
print("PushMe 推送成功!")
|
||||
else:
|
||||
audit_results.append(False)
|
||||
print(f"PushMe 推送失败!{response.status_code} {response.text}")
|
||||
return all(audit_results) if audit_results else None
|
||||
|
||||
|
||||
def chronocat(title: str, content: str) -> None:
|
||||
def chronocat(title: str, content: str) -> Optional[bool]:
|
||||
"""
|
||||
使用 CHRONOCAT 推送消息。
|
||||
"""
|
||||
audit_results = []
|
||||
if (
|
||||
not push_config.get("CHRONOCAT_URL")
|
||||
or not push_config.get("CHRONOCAT_QQ")
|
||||
@@ -807,21 +886,27 @@ def chronocat(title: str, content: str) -> None:
|
||||
response = requests.post(url, headers=headers, data=json.dumps(data))
|
||||
if response.status_code == 200:
|
||||
if chat_type == 1:
|
||||
audit_results.append(True)
|
||||
print(f"QQ个人消息:{ids}推送成功!")
|
||||
else:
|
||||
audit_results.append(True)
|
||||
print(f"QQ群消息:{ids}推送成功!")
|
||||
else:
|
||||
if chat_type == 1:
|
||||
audit_results.append(False)
|
||||
print(f"QQ个人消息:{ids}推送失败!")
|
||||
else:
|
||||
audit_results.append(False)
|
||||
print(f"QQ群消息:{ids}推送失败!")
|
||||
return all(audit_results) if audit_results else None
|
||||
|
||||
|
||||
def ntfy(title: str, content: str) -> None:
|
||||
def ntfy(title: str, content: str) -> Optional[bool]:
|
||||
"""
|
||||
通过 Ntfy 推送消息
|
||||
"""
|
||||
|
||||
audit_results = []
|
||||
def encode_rfc2047(text: str) -> str:
|
||||
"""将文本编码为符合 RFC 2047 标准的格式"""
|
||||
encoded_bytes = base64.b64encode(text.encode("utf-8"))
|
||||
@@ -853,12 +938,15 @@ def ntfy(title: str, content: str) -> None:
|
||||
url = push_config.get("NTFY_URL") + "/" + push_config.get("NTFY_TOPIC")
|
||||
response = requests.post(url, data=data, headers=headers)
|
||||
if response.status_code == 200: # 使用 response.status_code 进行检查
|
||||
audit_results.append(True)
|
||||
print("Ntfy 推送成功!")
|
||||
else:
|
||||
audit_results.append(False)
|
||||
print("Ntfy 推送失败!错误信息:", response.text)
|
||||
return all(audit_results) if audit_results else None
|
||||
|
||||
|
||||
def wxpusher_bot(title: str, content: str) -> None:
|
||||
def wxpusher_bot(title: str, content: str) -> Optional[bool]:
|
||||
"""
|
||||
通过 wxpusher 推送消息。
|
||||
支持的环境变量:
|
||||
@@ -866,6 +954,7 @@ def wxpusher_bot(title: str, content: str) -> None:
|
||||
- WXPUSHER_TOPIC_IDS: 主题ID, 多个用英文分号;分隔
|
||||
- WXPUSHER_UIDS: 用户ID, 多个用英文分号;分隔
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("WXPUSHER_APP_TOKEN"):
|
||||
return
|
||||
|
||||
@@ -909,17 +998,21 @@ def wxpusher_bot(title: str, content: str) -> None:
|
||||
response = requests.post(url=url, json=data, headers=headers).json()
|
||||
|
||||
if response.get("code") == 1000:
|
||||
audit_results.append(True)
|
||||
print("wxpusher 推送成功!")
|
||||
else:
|
||||
audit_results.append(False)
|
||||
print(f"wxpusher 推送失败!错误信息:{response.get('msg')}")
|
||||
return all(audit_results) if audit_results else None
|
||||
|
||||
|
||||
def wxpusher_spt(title: str, content: str) -> None:
|
||||
def wxpusher_spt(title: str, content: str) -> Optional[bool]:
|
||||
"""
|
||||
通过 wxpusher 极简推送(SPT)推送消息。
|
||||
支持的环境变量:
|
||||
- WXPUSHER_SPT_LIST: SPT, 多个用英文逗号,分隔, 最多10个
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("WXPUSHER_SPT_LIST"):
|
||||
return
|
||||
|
||||
@@ -956,17 +1049,21 @@ def wxpusher_spt(title: str, content: str) -> None:
|
||||
response = requests.post(url=url, json=data, headers=headers).json()
|
||||
|
||||
if response.get("code") == 1000:
|
||||
audit_results.append(True)
|
||||
print("wxpusher SPT 推送成功!")
|
||||
else:
|
||||
audit_results.append(False)
|
||||
print(f"wxpusher SPT 推送失败!错误信息:{response.get('msg')}")
|
||||
return all(audit_results) if audit_results else None
|
||||
|
||||
|
||||
|
||||
def wpush(title: str, content: str) -> None:
|
||||
def wpush(title: str, content: str) -> Optional[bool]:
|
||||
"""
|
||||
通过 WPUSH 推送消息。
|
||||
官方文档: https://wpush.cn/docs
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("WPUSH_APIKEY"):
|
||||
return
|
||||
|
||||
@@ -986,12 +1083,15 @@ def wpush(title: str, content: str) -> None:
|
||||
response = requests.post(url=url, json=data, headers=headers, timeout=15).json()
|
||||
|
||||
if response.get("code") == 0:
|
||||
audit_results.append(True)
|
||||
print("WPUSH 推送成功!")
|
||||
else:
|
||||
audit_results.append(False)
|
||||
print(f'WPUSH 推送失败!错误信息:{response.get("message") or response}')
|
||||
return all(audit_results) if audit_results else None
|
||||
|
||||
|
||||
def openilink(title: str, content: str) -> None:
|
||||
def openilink(title: str, content: str) -> Optional[bool]:
|
||||
"""
|
||||
通过 OpeniLink 推送消息。
|
||||
支持的环境变量:
|
||||
@@ -999,6 +1099,7 @@ def openilink(title: str, content: str) -> None:
|
||||
- OPENILINK_HUB_URL: OpeniLink Hub 地址,默认为 https://hub.openilink.com
|
||||
- OPENILINK_CONTEXT_TOKEN: 消息会话上下文 token,可从消息事件中获取
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("OPENILINK_APP_TOKEN"):
|
||||
return
|
||||
|
||||
@@ -1023,9 +1124,12 @@ def openilink(title: str, content: str) -> None:
|
||||
response = requests.post(url=url, json=data, headers=headers).json()
|
||||
|
||||
if response.get("ok"):
|
||||
audit_results.append(True)
|
||||
print("OpeniLink 推送成功!")
|
||||
else:
|
||||
audit_results.append(False)
|
||||
print(f'OpeniLink 推送失败!错误信息:{response.get("error")}')
|
||||
return all(audit_results) if audit_results else None
|
||||
|
||||
|
||||
def parse_headers(headers):
|
||||
@@ -1079,10 +1183,11 @@ def parse_body(body, content_type, value_format_fn=None):
|
||||
return parsed
|
||||
|
||||
|
||||
def custom_notify(title: str, content: str) -> None:
|
||||
def custom_notify(title: str, content: str) -> Optional[bool]:
|
||||
"""
|
||||
通过 自定义通知 推送消息。
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("WEBHOOK_URL") or not push_config.get("WEBHOOK_METHOD"):
|
||||
return
|
||||
|
||||
@@ -1114,9 +1219,12 @@ def custom_notify(title: str, content: str) -> None:
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
audit_results.append(True)
|
||||
print("自定义通知推送成功!")
|
||||
else:
|
||||
audit_results.append(False)
|
||||
print(f"自定义通知推送失败!{response.status_code} {response.text}")
|
||||
return all(audit_results) if audit_results else None
|
||||
|
||||
|
||||
def one() -> str:
|
||||
@@ -1204,6 +1312,43 @@ def add_notify_function():
|
||||
return notify_function
|
||||
|
||||
|
||||
def report_notification(channel, title, content, success):
|
||||
report_notifications([(channel, success)], title, content)
|
||||
|
||||
|
||||
def report_notifications(results, title, content):
|
||||
ql_dir = os.getenv("QL_DIR")
|
||||
if not ql_dir or not results:
|
||||
return
|
||||
payload = [{"title": str(title or "")[:512], "content": str(content or "")[:8192],
|
||||
"success": success, "notifyType": channel,
|
||||
"scriptName": os.path.basename(__import__("sys").argv[0]), "taskId": os.getenv("ID", "")}
|
||||
for channel, success in results]
|
||||
code = """const fs=require('node:fs'),path=require('node:path');
|
||||
const report=require(path.join(process.env.QL_DIR,'shell/preload/notificationAudit.js'));
|
||||
report(JSON.parse(fs.readFileSync(0,'utf8'))).catch(()=>{});"""
|
||||
try:
|
||||
# One Node startup/TLS connection per send, with room for cold startup
|
||||
# before the reporter's bounded RPC deadline on busy Alpine hosts.
|
||||
subprocess.run(["node", "-e", code], input=json.dumps(payload), text=True,
|
||||
capture_output=True, timeout=10)
|
||||
except Exception:
|
||||
pass # Audit availability never changes notification delivery.
|
||||
|
||||
|
||||
def audited_notification(mode, title, content, results=None):
|
||||
result = None
|
||||
try:
|
||||
result = mode(title, content)
|
||||
except Exception:
|
||||
result = False
|
||||
print(f"{mode.__name__} 推送失败")
|
||||
if results is None:
|
||||
report_notification(mode.__name__, title, content, result is True)
|
||||
else:
|
||||
results.append((mode.__name__, result is True))
|
||||
|
||||
|
||||
def send(title: str, content: str, ignore_default_config: bool = False, **kwargs):
|
||||
if kwargs:
|
||||
global push_config
|
||||
@@ -1227,12 +1372,14 @@ def send(title: str, content: str, ignore_default_config: bool = False, **kwargs
|
||||
content += "\n\n" + one() if hitokoto not in [False, "false"] else ""
|
||||
|
||||
notify_function = add_notify_function()
|
||||
results = []
|
||||
ts = [
|
||||
threading.Thread(target=mode, args=(title, content), name=mode.__name__)
|
||||
threading.Thread(target=audited_notification, args=(mode, title, content, results), name=mode.__name__)
|
||||
for mode in notify_function
|
||||
]
|
||||
[t.start() for t in ts]
|
||||
[t.join() for t in ts]
|
||||
report_notifications(results, title, content)
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
const path = require('node:path');
|
||||
require('../cli/node_modules/esbuild').buildSync({
|
||||
entryPoints: [path.resolve(__dirname, '../shell/install-bundle.cjs')],
|
||||
outfile: path.resolve(process.argv[2] || 'output/upgrade/install-bundle.cjs'),
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
target: 'node20',
|
||||
minify: true,
|
||||
legalComments: 'inline',
|
||||
});
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[path.resolve(process.argv[2] || 'output/upgrade/install-bundle.cjs')],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
if (
|
||||
result.status !== 1 ||
|
||||
!result.stderr.includes('Usage: node install-bundle.cjs')
|
||||
)
|
||||
throw new Error('Standalone offline installer did not start correctly');
|
||||
@@ -0,0 +1,142 @@
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const tar = require('tar');
|
||||
const bundle = require('../cli/src/internal/maintenance/platformBundle.cjs');
|
||||
const {
|
||||
verifyUpgrade,
|
||||
} = require('../cli/src/internal/maintenance/upgradeArtifacts.cjs');
|
||||
|
||||
async function normalizeModes(directory) {
|
||||
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
|
||||
const filename = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await fs.chmod(filename, 0o755);
|
||||
await normalizeModes(filename);
|
||||
} else if (entry.isFile()) {
|
||||
const stat = await fs.stat(filename);
|
||||
await fs.chmod(filename, 0o644 | (stat.mode & 0o111));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function buildBundle(root, output) {
|
||||
root = path.resolve(root);
|
||||
output = path.resolve(output);
|
||||
const target = await bundle.runtimeTarget();
|
||||
if (process.platform !== 'linux')
|
||||
throw new Error('Release bundles must be built and probed on Linux');
|
||||
const build = await verifyUpgrade(root, path.join(root, 'static'));
|
||||
await bundle.smokeDependencies(root);
|
||||
await fs.mkdir(output, { recursive: true });
|
||||
const workspace = await fs.mkdtemp(path.join(output, '.bundle-'));
|
||||
try {
|
||||
const payload = path.join(workspace, 'qinglong');
|
||||
const source = path.join(payload, 'source'),
|
||||
staticRoot = path.join(payload, 'static');
|
||||
await fs.mkdir(source, { recursive: true });
|
||||
const excluded = new Set([
|
||||
'.git',
|
||||
'.tmp',
|
||||
'.env',
|
||||
'data',
|
||||
'static',
|
||||
'output',
|
||||
'node_modules',
|
||||
]);
|
||||
for (const name of await fs.readdir(root)) {
|
||||
if (excluded.has(name)) continue;
|
||||
await fs.cp(path.join(root, name), path.join(source, name), {
|
||||
recursive: true,
|
||||
verbatimSymlinks: true,
|
||||
});
|
||||
}
|
||||
await fs.cp(
|
||||
path.join(root, 'node_modules'),
|
||||
path.join(source, 'node_modules'),
|
||||
{ recursive: true, verbatimSymlinks: true },
|
||||
);
|
||||
await fs.cp(path.join(root, 'static'), staticRoot, {
|
||||
recursive: true,
|
||||
verbatimSymlinks: true,
|
||||
});
|
||||
await normalizeModes(source);
|
||||
await normalizeModes(staticRoot);
|
||||
const manifest = {
|
||||
format: 2,
|
||||
sourceCommit: build.sourceCommit,
|
||||
version: JSON.parse(
|
||||
await fs.readFile(path.join(root, 'package.json'), 'utf8'),
|
||||
).version,
|
||||
lockfileSha256: build.lockfileSha256,
|
||||
target: {
|
||||
platform: target.platform,
|
||||
arch: target.arch,
|
||||
libc: target.libc,
|
||||
libcMinimum: target.libcVersion,
|
||||
endianness: target.endianness,
|
||||
armFloatAbi: target.armFloatAbi,
|
||||
},
|
||||
nodeMinimum: '20.18.1',
|
||||
nodeApi: 6,
|
||||
files: await bundle.inventory({ source, static: staticRoot }),
|
||||
};
|
||||
await fs.writeFile(
|
||||
path.join(payload, 'bundle.json'),
|
||||
JSON.stringify(manifest),
|
||||
);
|
||||
console.log('Bundle inventory verified; writing archive');
|
||||
const filename = `qinglong-${build.sourceCommit}-${bundle.targetKey(
|
||||
target,
|
||||
)}.tar.gz`;
|
||||
await tar.c(
|
||||
{
|
||||
gzip: true,
|
||||
portable: true,
|
||||
noMtime: true,
|
||||
cwd: workspace,
|
||||
file: path.join(output, filename),
|
||||
},
|
||||
['qinglong'],
|
||||
);
|
||||
// Verify the bytes users receive, not just the pre-archive build directory.
|
||||
const roundtrip = path.join(workspace, 'roundtrip');
|
||||
await fs.mkdir(roundtrip);
|
||||
console.log('Verifying archived paths, permissions and checksums');
|
||||
await bundle.extractArchive(path.join(output, filename), roundtrip);
|
||||
const extracted = {
|
||||
source: path.join(roundtrip, 'qinglong/source'),
|
||||
static: path.join(roundtrip, 'qinglong/static'),
|
||||
};
|
||||
await bundle.verifyPayload(extracted, manifest, target);
|
||||
await verifyUpgrade(extracted.source, extracted.static);
|
||||
await bundle.smokeDependencies(extracted.source);
|
||||
console.log('Archived production dependencies and SQLite verified');
|
||||
const bytes = await fs.readFile(path.join(output, filename));
|
||||
const record = {
|
||||
format: 2,
|
||||
sourceCommit: build.sourceCommit,
|
||||
version: manifest.version,
|
||||
target: bundle.targetKey(target),
|
||||
filename,
|
||||
baseImage: process.env.QL_BUNDLE_BASE_IMAGE,
|
||||
size: bytes.length,
|
||||
sha256: bundle.sha256(bytes),
|
||||
};
|
||||
await fs.writeFile(
|
||||
path.join(output, filename + '.json'),
|
||||
JSON.stringify(record) + '\n',
|
||||
);
|
||||
console.log(JSON.stringify(record));
|
||||
return record;
|
||||
} finally {
|
||||
await fs.rm(workspace, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
module.exports = { buildBundle, normalizeModes };
|
||||
if (require.main === module)
|
||||
buildBundle(process.argv[2] || '/ql', process.argv[3] || '/out').catch(
|
||||
(e) => {
|
||||
console.error(e);
|
||||
process.exitCode = 1;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,20 @@
|
||||
// npm-packlist always excludes the root pnpm lockfile, even with a files entry.
|
||||
// Carry a checked copy under a runtime filename and restore it after installation.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const copy = path.join(root, 'shell/upgrade-lock.yaml');
|
||||
if (process.argv.includes('--clean')) {
|
||||
fs.rmSync(copy, { force: true });
|
||||
} else {
|
||||
if (execFileSync('git', ['-C', root, 'status', '--porcelain', '--untracked-files=all'], { encoding: 'utf8' }).trim())
|
||||
throw new Error('Pack only from a clean source checkout');
|
||||
// The native npm pack/publish hooks must enforce the same checks as CI:
|
||||
// current commit, clean build, lockfile and every output file's checksum.
|
||||
// Capture stdout so npm pack --json remains machine-readable.
|
||||
execFileSync(process.execPath, [path.join(root, 'docker/verify-build.cjs')], { cwd: root });
|
||||
const bytes = fs.readFileSync(path.join(root, 'pnpm-lock.yaml'));
|
||||
fs.rmSync(copy, { force: true });
|
||||
fs.writeFileSync(copy, bytes, { flag: 'wx', mode: 0o644 });
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const {
|
||||
sha256,
|
||||
} = require('../cli/src/internal/maintenance/platformBundle.cjs');
|
||||
const platforms = [
|
||||
'linux-x64-musl',
|
||||
'linux-armv6-musl',
|
||||
'linux-armv7-musl',
|
||||
'linux-arm64-musl',
|
||||
'linux-ppc64-musl',
|
||||
'linux-s390x-musl',
|
||||
'linux-ia32-musl',
|
||||
'linux-x64-glibc',
|
||||
'linux-arm64-glibc',
|
||||
'linux-ppc64-glibc',
|
||||
'linux-s390x-glibc',
|
||||
];
|
||||
function releaseIndex(directory, commit, repository, options = {}) {
|
||||
const { version, releaseTag = `upgrade-${commit}`, assetBuildId } = options;
|
||||
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');
|
||||
const packages = {};
|
||||
for (const target of platforms) {
|
||||
const filename = `qinglong-${commit}-${target}.tar.gz`;
|
||||
const record = JSON.parse(
|
||||
fs.readFileSync(path.join(directory, filename + '.json')),
|
||||
);
|
||||
const bytes = fs.readFileSync(path.join(directory, filename));
|
||||
if (
|
||||
record.format !== 2 ||
|
||||
record.sourceCommit !== commit ||
|
||||
(version !== undefined && record.version !== version) ||
|
||||
record.target !== target ||
|
||||
record.filename !== filename ||
|
||||
record.size !== bytes.length ||
|
||||
record.sha256 !== sha256(bytes)
|
||||
)
|
||||
throw new Error(`Unverified platform bundle: ${target}`);
|
||||
packages[target] = {
|
||||
size: record.size,
|
||||
sha256: record.sha256,
|
||||
url: `https://github.com/${repository}/releases/download/${encodeURIComponent(
|
||||
releaseTag,
|
||||
)}/${
|
||||
assetBuildId
|
||||
? filename.replace(/\.tar\.gz$/, `-${assetBuildId}.tar.gz`)
|
||||
: filename
|
||||
}`,
|
||||
};
|
||||
}
|
||||
return { format: 2, sourceCommit: commit, packages };
|
||||
}
|
||||
function stageReleaseAssets(directory, commit, buildId, channel) {
|
||||
const staged = fs.mkdtempSync(path.join(directory, `.upgrade-${channel}-`));
|
||||
const assets = [];
|
||||
try {
|
||||
for (const target of platforms) {
|
||||
const filename = `qinglong-${commit}-${target}.tar.gz`;
|
||||
const archiveName = filename.replace(/\.tar\.gz$/, `-${buildId}.tar.gz`);
|
||||
const archive = path.join(staged, archiveName);
|
||||
fs.linkSync(path.join(directory, filename), archive);
|
||||
const record = JSON.parse(
|
||||
fs.readFileSync(path.join(directory, filename + '.json')),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
archive + '.json',
|
||||
JSON.stringify({
|
||||
...record,
|
||||
filename: archiveName,
|
||||
}) + '\n',
|
||||
);
|
||||
assets.push(archive, archive + '.json');
|
||||
}
|
||||
const installer = path.join(staged, `install-bundle-${buildId}.cjs`);
|
||||
fs.linkSync(path.join(directory, 'install-bundle.cjs'), installer);
|
||||
assets.push(installer);
|
||||
return { staged, assets };
|
||||
} catch (error) {
|
||||
fs.rmSync(staged, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function 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'));
|
||||
index.installer = {
|
||||
size: installer.length,
|
||||
sha256: sha256(installer),
|
||||
url: `https://github.com/${repository}/releases/download/${encodeURIComponent(
|
||||
tag,
|
||||
)}/install-bundle-${buildId}.cjs`,
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(directory, 'latest.json'),
|
||||
JSON.stringify(index) + '\n',
|
||||
);
|
||||
const lookup = (ref) => {
|
||||
try {
|
||||
return JSON.parse(
|
||||
gh(
|
||||
'api',
|
||||
`repos/${repository}/releases/tags/${encodeURIComponent(ref)}`,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (/\bHTTP 404\b/.test(String(error.stderr))) return null;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const notes =
|
||||
`${
|
||||
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(
|
||||
'api',
|
||||
`repos/${repository}/git/ref/heads/${branch}`,
|
||||
'--jq',
|
||||
'.object.sha',
|
||||
).trim() === commit;
|
||||
if (!currentSource()) {
|
||||
console.log('Newer source exists; leaving version and channel unchanged');
|
||||
return false;
|
||||
}
|
||||
const staged = development
|
||||
? stageDevelopmentAssets(directory, commit, buildId)
|
||||
: stageReleaseAssets(directory, commit, buildId, `v${version}`);
|
||||
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(
|
||||
'release',
|
||||
'upload',
|
||||
ref,
|
||||
path.join(directory, 'latest.json'),
|
||||
'--repo',
|
||||
repository,
|
||||
'--clobber',
|
||||
);
|
||||
gh('release', 'edit', ref, '--repo', repository, '--notes', notes);
|
||||
if (
|
||||
!matches(
|
||||
lookup(ref)?.assets.find((a) => a.name === 'latest.json'),
|
||||
path.join(directory, 'latest.json'),
|
||||
)
|
||||
)
|
||||
throw new Error('Published channel index verification failed');
|
||||
}
|
||||
// Only prune development releases after the complete new batch and its
|
||||
// channel pointer are verified. Production versions and aliases are retained.
|
||||
if (development && currentSource()) {
|
||||
const currentAssets = new Set(assets.map((file) => path.basename(file)));
|
||||
for (const asset of lookup(tag).assets) {
|
||||
if (
|
||||
!currentAssets.has(asset.name) &&
|
||||
(/^qinglong-[a-f0-9]{40}-linux-[\w-]+-\d+-\d+\.tar\.gz(?:\.json)?$/.test(
|
||||
asset.name,
|
||||
) ||
|
||||
/^install-bundle-\d+-\d+\.cjs$/.test(asset.name))
|
||||
) {
|
||||
if (!currentSource()) break;
|
||||
gh(
|
||||
'api',
|
||||
'-X',
|
||||
'DELETE',
|
||||
`repos/${repository}/releases/assets/${asset.id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const pages = JSON.parse(
|
||||
gh(
|
||||
'api',
|
||||
`repos/${repository}/releases?per_page=100`,
|
||||
'--paginate',
|
||||
'--slurp',
|
||||
),
|
||||
);
|
||||
for (const release of pages.flat()) {
|
||||
if (
|
||||
release.tag_name !== tag &&
|
||||
/^upgrade-build-develop-(?:v\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?-)?\d+-\d+$/.test(
|
||||
release.tag_name,
|
||||
) &&
|
||||
release.prerelease === true &&
|
||||
release.draft === false
|
||||
) {
|
||||
if (!currentSource()) break;
|
||||
gh(
|
||||
'release',
|
||||
'delete',
|
||||
release.tag_name,
|
||||
'--repo',
|
||||
repository,
|
||||
'--yes',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
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}`,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// Publish archive sizes separately: adding them to the archive itself would
|
||||
// change its bytes. Immutable commit URLs make both size and checksum stable.
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
function publishUpgradeInfo(
|
||||
destination,
|
||||
ref,
|
||||
sourceCommit,
|
||||
execute = execFileSync,
|
||||
) {
|
||||
const run = (program, args) =>
|
||||
execute(program, args, {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
if (!/^[a-f0-9]{40}$/.test(sourceCommit))
|
||||
throw new Error('Invalid source commit');
|
||||
run('git', ['check-ref-format', `refs/heads/${ref}`]);
|
||||
const staticCommit = run('git', [
|
||||
'ls-remote',
|
||||
destination,
|
||||
`refs/heads/${ref}`,
|
||||
])
|
||||
.trim()
|
||||
.split(/\s/)[0];
|
||||
if (!/^[a-f0-9]{40}$/.test(staticCommit))
|
||||
throw new Error('Missing static commit');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-upgrade-info-'));
|
||||
try {
|
||||
const archives = {};
|
||||
for (const [repository, commit] of [
|
||||
['qinglong-static', staticCommit],
|
||||
['qinglong', sourceCommit],
|
||||
]) {
|
||||
const filename = path.join(tmp, `${repository}.zip`);
|
||||
run('curl', [
|
||||
'--fail',
|
||||
'--silent',
|
||||
'--show-error',
|
||||
'--location',
|
||||
'--proto',
|
||||
'=https',
|
||||
'--proto-redir',
|
||||
'=https',
|
||||
'--connect-timeout',
|
||||
'30',
|
||||
'--max-time',
|
||||
'600',
|
||||
'--output',
|
||||
filename,
|
||||
`https://codeload.github.com/whyour/${repository}/zip/${commit}`,
|
||||
]);
|
||||
run('unzip', ['-tq', filename]);
|
||||
if (repository === 'qinglong-static') {
|
||||
const manifest = JSON.parse(
|
||||
run('unzip', [
|
||||
'-p',
|
||||
filename,
|
||||
`${repository}-${commit}/build-info.json`,
|
||||
]),
|
||||
);
|
||||
if (manifest.sourceCommit !== sourceCommit || manifest.dirty !== false)
|
||||
throw new Error('Static source commit does not match this build');
|
||||
}
|
||||
const bytes = fs.readFileSync(filename);
|
||||
archives[repository] = {
|
||||
size: bytes.length,
|
||||
sha256: createHash('sha256').update(bytes).digest('hex'),
|
||||
};
|
||||
}
|
||||
const info = { version: 1, sourceCommit, staticCommit, archives };
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const checkout = path.join(tmp, `metadata-${attempt}`);
|
||||
const exists = run('git', [
|
||||
'ls-remote',
|
||||
destination,
|
||||
'refs/heads/upgrade-info',
|
||||
]).trim();
|
||||
if (exists)
|
||||
run('git', [
|
||||
'clone',
|
||||
'--quiet',
|
||||
'--depth',
|
||||
'1',
|
||||
'--branch',
|
||||
'upgrade-info',
|
||||
'--single-branch',
|
||||
destination,
|
||||
checkout,
|
||||
]);
|
||||
else run('git', ['init', '--quiet', '-b', 'upgrade-info', checkout]);
|
||||
const git = (...args) => run('git', ['-C', checkout, ...args]);
|
||||
git('config', 'user.name', 'github-actions[bot]');
|
||||
git(
|
||||
'config',
|
||||
'user.email',
|
||||
'github-actions[bot]@users.noreply.github.com',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(checkout, `${sourceCommit}.json`),
|
||||
JSON.stringify(info) + '\n',
|
||||
);
|
||||
git('add', `${sourceCommit}.json`);
|
||||
git(
|
||||
'commit',
|
||||
'--quiet',
|
||||
'--allow-empty',
|
||||
'-m',
|
||||
`Archive metadata for ${sourceCommit}`,
|
||||
);
|
||||
try {
|
||||
git('push', '--quiet', destination, 'HEAD:refs/heads/upgrade-info');
|
||||
return info;
|
||||
} catch (error) {
|
||||
if (attempt === 2) throw error;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { publishUpgradeInfo };
|
||||
if (require.main === module) {
|
||||
try {
|
||||
publishUpgradeInfo(...process.argv.slice(2));
|
||||
} catch {
|
||||
// Git errors may include a credential-bearing remote URL.
|
||||
console.error('Failed to publish verified upgrade archive metadata');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Check serialized Docker layers, including files hidden by later deletions."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import tarfile
|
||||
|
||||
archive, architecture = sys.argv[1:]
|
||||
assert architecture in {"amd64", "arm"}
|
||||
nodes = []
|
||||
with tarfile.open(archive) as image:
|
||||
manifests = json.load(image.extractfile("manifest.json"))
|
||||
assert len(manifests) == 1, "Expected one platform image"
|
||||
manifest = manifests[0]
|
||||
config = json.load(image.extractfile(manifest["Config"]))
|
||||
assert config["architecture"] == architecture
|
||||
for name in manifest["Layers"]:
|
||||
with tarfile.open(fileobj=image.extractfile(name), mode="r|*") as layer:
|
||||
for member in layer:
|
||||
filename = member.name.removeprefix("./")
|
||||
assert not (
|
||||
filename == "tmp/node-runtime-amd64" and member.size > 0
|
||||
), "Temporary amd64 Node remains in an immutable layer"
|
||||
if filename not in {"usr/local/bin/node", "usr/bin/node"}:
|
||||
continue
|
||||
if not member.isfile():
|
||||
continue
|
||||
with layer.extractfile(member) as binary:
|
||||
header = binary.read(20)
|
||||
assert header[:4] == b"\x7fELF", "Expected a native Node binary"
|
||||
digest = hashlib.sha256(header)
|
||||
while chunk := binary.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
endian = "little" if header[5] == 1 else "big"
|
||||
machine = int.from_bytes(header[18:20], endian)
|
||||
assert machine == {"amd64": 62, "arm": 40}[architecture], (
|
||||
"Wrong Node architecture in an image layer"
|
||||
)
|
||||
nodes.append({"path": filename, "size": member.size, "sha256": digest.hexdigest()})
|
||||
assert len(nodes) == 1, "Expected exactly one retained Node binary across all layers"
|
||||
assert nodes[0]["path"] == (
|
||||
"usr/local/bin/node" if architecture == "amd64" else "usr/bin/node"
|
||||
)
|
||||
print(json.dumps({"pass": True, "architecture": architecture, "layers": len(manifest["Layers"]), "nodes": nodes}))
|
||||
@@ -0,0 +1,377 @@
|
||||
// Run service tests only inside a disposable Linux container or CI job.
|
||||
// Unlike source-tree tests, these load the published entrypoints and dependencies.
|
||||
const fs = require('node:fs/promises');
|
||||
const sync = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const http = require('node:http');
|
||||
const net = require('node:net');
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHash, randomUUID } = require('node:crypto');
|
||||
const { createRequire } = require('node:module');
|
||||
const { execFile } = require('node:child_process');
|
||||
const { promisify } = require('node:util');
|
||||
const { setTimeout: delay } = require('node:timers/promises');
|
||||
const execute = promisify(execFile);
|
||||
const repository = path.resolve(__dirname, '..');
|
||||
const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
||||
const read = async (file) => JSON.parse(await fs.readFile(file, 'utf8'));
|
||||
const run = (program, args, options = {}) => execute(program, args, {
|
||||
encoding: 'utf8', maxBuffer: 8 * 1024 * 1024, timeout: 120000,
|
||||
...options,
|
||||
});
|
||||
const requiredFiles = [
|
||||
'.env.example', 'back/protos/api.proto', 'ecosystem.config.js', 'version.yaml',
|
||||
'shell/upgrade.cjs', 'shell/install-bundle.cjs', 'shell/install-package.cjs',
|
||||
'shell/upgrade-lock.yaml', 'shell/node-runtime-probe.cjs', 'shell/start.sh', 'shell/start-simplify.sh',
|
||||
'shell/task.sh', 'shell/preload/client.js', 'shell/preload/client.py',
|
||||
'shell/preload/sitecustomize.js', 'shell/preload/sitecustomize.py',
|
||||
'shell/preload/esm-loader.mjs', 'shell/preload/notificationAudit.js',
|
||||
'sample/config.sample.sh', 'sample/auth.sample.json',
|
||||
'static/build-info.json', 'static/build/app.js', 'static/build/token.js',
|
||||
'static/build/bootstrap/upgradeProbe.js',
|
||||
'static/dist/index.html',
|
||||
...['upgradeArtifacts', 'platformBundle', 'upgradePrestart', 'panelUpgrade', 'nodeRuntime']
|
||||
.map((name) => `cli/src/internal/maintenance/${name}.cjs`),
|
||||
];
|
||||
|
||||
function checkPackedFiles(files) {
|
||||
const names = new Set(files.map((file) => file.path));
|
||||
for (const name of requiredFiles) assert.ok(names.has(name), `Missing npm runtime: ${name}`);
|
||||
for (const name of names) {
|
||||
assert.ok(/^(?:package\.json$|LICENSE$|README[^/]*\.md$|\.env\.example$|ecosystem\.config\.js$|version\.yaml$|back\/protos\/api\.proto$|cli\/(?:README[^/]*\.md$|src\/internal\/maintenance\/[^/]+\.cjs$)|sample\/|shell\/|static\/)/.test(name), `Unexpected npm file: ${name}`);
|
||||
assert.ok(!/^shell\/(?:pub\.sh$|preload\/(?:env\.|notify\.|__ql_notify__\.|lang_env\.sh|.*-notify\.json))/.test(name), `Generated or release-only runtime file: ${name}`);
|
||||
assert.ok(!/(?:^|\/)__pycache__\/|\.pyc$|(?:^|\/)\.DS_Store$/.test(name), name);
|
||||
assert.notEqual(name, 'sample/tool.ts');
|
||||
}
|
||||
}
|
||||
|
||||
async function packPanel(output) {
|
||||
const git = (...args) => run('git', args, { cwd: repository });
|
||||
assert.equal((await git('status', '--porcelain', '--untracked-files=all')).stdout.trim(), '', 'Pack only from a clean source checkout');
|
||||
await run(process.execPath, [path.join(repository, 'docker/verify-build.cjs')], { cwd: repository });
|
||||
await fs.mkdir(output, { recursive: true });
|
||||
const cache = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-npm-pack-cache-'));
|
||||
try {
|
||||
const result = await run('npm', ['pack', '--json', '--cache', cache, '--pack-destination', output], { cwd: repository });
|
||||
const [manifest] = JSON.parse(result.stdout);
|
||||
checkPackedFiles(manifest.files);
|
||||
assert.equal(manifest.name, '@whyour/qinglong');
|
||||
const build = await read(path.join(repository, 'static/build-info.json'));
|
||||
const names = new Set(manifest.files.map((file) => file.path));
|
||||
for (const name of Object.keys(build.files)) assert.ok(names.has(`static/${name}`), `npm filtered a verified build artifact: ${name}`);
|
||||
const receipt = {
|
||||
filename: manifest.filename, sha256: hash(await fs.readFile(path.join(output, manifest.filename))),
|
||||
sourceCommit: build.sourceCommit, size: manifest.size, fileCount: manifest.files.length,
|
||||
};
|
||||
await fs.writeFile(path.join(output, 'package-receipt.json'), JSON.stringify(receipt, null, 2));
|
||||
console.log(JSON.stringify(receipt));
|
||||
return receipt;
|
||||
} finally {
|
||||
await fs.rm(cache, { recursive: true, force: true });
|
||||
// Also recover an interrupted npm pack whose postpack hook did not execute.
|
||||
await fs.rm(path.join(repository, 'shell/upgrade-lock.yaml'), { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function unusedPort() {
|
||||
const server = net.createServer();
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const port = server.address().port;
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
return port;
|
||||
}
|
||||
|
||||
// Consume the unmodified output of Dockerfile.upgrade-bundle / buildBundle.
|
||||
async function platformFixture(root, directory, expectedCommit) {
|
||||
assert.ok(directory, 'Set QL_PANEL_TEST_BUNDLE_DIR to the official platform bundle output');
|
||||
const names = (await fs.readdir(directory)).filter((name) => name.endsWith('.tar.gz'));
|
||||
assert.equal(names.length, 1, 'Expected one official platform package');
|
||||
const archive = path.resolve(directory, names[0]);
|
||||
const record = await read(archive + '.json');
|
||||
const bytes = await fs.readFile(archive);
|
||||
assert.equal(record.format, 2);
|
||||
assert.equal(record.sourceCommit, expectedCommit);
|
||||
assert.equal(record.filename, names[0]);
|
||||
assert.equal(record.sha256, hash(bytes));
|
||||
assert.equal(record.size, bytes.length);
|
||||
assert.match(record.baseImage, /^ghcr\.io\/[^@]+@sha256:[a-f0-9]{64}$/);
|
||||
const local = createRequire(path.join(root, 'package.json'));
|
||||
const bundle = local('./cli/src/internal/maintenance/platformBundle.cjs');
|
||||
assert.equal(record.target, bundle.targetKey(await bundle.runtimeTarget()));
|
||||
return { ...record, archive };
|
||||
}
|
||||
|
||||
async function legacyArchive(temp, env) {
|
||||
const directory = path.join(temp, 'legacy');
|
||||
await fs.mkdir(directory);
|
||||
const result = await run('npm', ['pack', '@whyour/qinglong@2.23.0', '--ignore-scripts', '--json', '--registry=https://registry.npmjs.org', '--pack-destination', directory, '--cache', path.join(temp, 'cache')], { cwd: temp, env, timeout: 300000 });
|
||||
const [record] = JSON.parse(result.stdout);
|
||||
assert.equal(record.name, '@whyour/qinglong');
|
||||
assert.equal(record.version, '2.23.0');
|
||||
assert.equal(record.filename, 'whyour-qinglong-2.23.0.tgz');
|
||||
const archive = path.join(directory, record.filename);
|
||||
const integrity = 'sha512-' + createHash('sha512').update(await fs.readFile(archive)).digest('base64');
|
||||
assert.equal(integrity, 'sha512-x6B9s1/SRKkHe7jRQtb3LzMluJ3O3PAAssrr/5jaFMMl7DZvABSEiqkkZMu0r1BNtiCF72uj8OqXN9pCnF2WVw==', 'Published legacy package integrity changed');
|
||||
return { archive, integrity, version: record.version };
|
||||
}
|
||||
|
||||
async function verifyPanel(input, output) {
|
||||
assert.equal(process.platform, 'linux', 'Service tests require Linux');
|
||||
assert.equal(process.env.QL_PANEL_PACKAGE_TEST_ISOLATED, '1', 'Run only inside a disposable Linux container: QL_PANEL_PACKAGE_TEST_ISOLATED=1');
|
||||
const stat = await fs.stat(input);
|
||||
if (stat.isDirectory()) {
|
||||
const names = (await fs.readdir(input)).filter((name) => name.endsWith('.tgz'));
|
||||
assert.equal(names.length, 1, 'Expected one exact npm archive');
|
||||
input = path.join(input, names[0]);
|
||||
}
|
||||
const expected = await read(path.join(path.dirname(input), 'package-receipt.json'));
|
||||
assert.equal(path.basename(input), expected.filename);
|
||||
assert.equal(hash(await fs.readFile(input)), expected.sha256, 'npm archive differs from its build receipt');
|
||||
assert.equal((await fs.stat(input)).size, expected.size);
|
||||
assert.match(expected.sourceCommit, /^[a-f0-9]{40}$/);
|
||||
if (process.env.GITHUB_SHA) assert.equal(expected.sourceCommit, process.env.GITHUB_SHA);
|
||||
const nodeSha = hash(await fs.readFile(process.execPath));
|
||||
const pm2 = process.env.QL_PANEL_TEST_PM2;
|
||||
assert.ok(pm2 && path.isAbsolute(pm2), 'Set QL_PANEL_TEST_PM2 to an isolated PM2 executable');
|
||||
const temp = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'ql-npm-install-')));
|
||||
const home = path.join(temp, 'home'), prefix = path.join(temp, 'prefix'), data = path.join(temp, 'data');
|
||||
await fs.mkdir(home);
|
||||
await fs.mkdir(data);
|
||||
const port = await unusedPort();
|
||||
let grpcPort = await unusedPort();
|
||||
while (grpcPort === port) grpcPort = await unusedPort();
|
||||
const env = { ...process.env, HOME: home, PM2_HOME: path.join(temp, '.pm2'),
|
||||
QL_DATA_DIR: data, QL_BRANCH: 'develop', NODE_ENV: 'production', QL_CONTAINER: 'false',
|
||||
BACK_PORT: String(port), GRPC_PORT: String(grpcPort), QlPort: String(port), QlGrpcPort: String(grpcPort),
|
||||
BIND_HOST: '127.0.0.1', BIND_HOST_GRPC: '127.0.0.1',
|
||||
NODE_PATH: '', NODE_OPTIONS: '',
|
||||
PATH: [path.join(prefix, 'bin'), path.dirname(pm2), process.env.PATH].join(path.delimiter),
|
||||
};
|
||||
for (const key of ['QL_CLI_ROOT', 'QL_DIR', 'QL_UPGRADE_FILE', 'QL_UPGRADE_REPAIR_DEPS', 'QL_UPGRADE_LAUNCHER_PID', 'QL_UPGRADE_INDEX_URL']) delete env[key];
|
||||
let root, fixtureServer, token;
|
||||
const api = async (route, method = 'GET', body, anonymous = false) => {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/${route}`, {
|
||||
method, headers: { 'Content-Type': 'application/json', ...(!anonymous && token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||
body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
const result = await response.json();
|
||||
assert.equal(result.code, 200, `${method} ${route}: ${result.message}`);
|
||||
return result.data;
|
||||
};
|
||||
const healthy = async () => {
|
||||
const deadline = Date.now() + 120000;
|
||||
while (Date.now() < deadline) {
|
||||
try { const value = await api('health', 'GET', undefined, true); if (value.status === 'ok') return value; } catch {}
|
||||
await delay(300);
|
||||
}
|
||||
throw new Error('Installed npm backend did not become healthy');
|
||||
};
|
||||
const job = async (id, status) => {
|
||||
const deadline = Date.now() + 240000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const value = await api('system/update-status');
|
||||
assert.equal(value.id, id);
|
||||
if (value.status === status) return value;
|
||||
if (value.status === 'failed' && status !== 'failed') throw new Error(value.error);
|
||||
} catch (error) {
|
||||
if (!/fetch failed|ECONNRESET|ECONNREFUSED|aborted|timeout/i.test(error.message)) throw error;
|
||||
}
|
||||
await delay(250);
|
||||
}
|
||||
throw new Error(`Installed npm update never reached ${status}`);
|
||||
};
|
||||
try {
|
||||
const install = (archive) => run('npm', ['install', '--global', '--prefix', prefix, '--force', '--omit=dev', '--no-audit', '--no-fund', '--cache', path.join(temp, 'cache'), archive], { cwd: temp, env, timeout: 600000 });
|
||||
const start = () => run(path.join(prefix, 'bin/qinglong'), ['reload'], { cwd: temp, env, timeout: 180000 });
|
||||
const stop = () => run(pm2, ['delete', 'ecosystem.config.js'], { cwd: root, env });
|
||||
console.log(`Installing actual npm archive with production lifecycle scripts on ${process.version}`);
|
||||
await install(input);
|
||||
const globalRoot = (await run('npm', ['root', '--global', '--prefix', prefix], { cwd: temp, env })).stdout.trim();
|
||||
root = path.join(globalRoot, '@whyour/qinglong');
|
||||
env.QL_DIR = root;
|
||||
const runtimeProbes = [];
|
||||
const checkRuntime = async () => {
|
||||
const result = await run(process.execPath, [path.join(root, 'shell/node-runtime-probe.cjs')], { cwd: temp, env, timeout: 540000 });
|
||||
runtimeProbes.push(JSON.parse(result.stdout.trim()));
|
||||
};
|
||||
await checkRuntime();
|
||||
// Use a subprocess: cached candidate modules must not mask missing files
|
||||
// after old-package installation and the real npm overwrite below.
|
||||
await run(process.execPath, ['-e', `const root=process.argv[1],p=require('node:path');const m=require(root+'/package.json');for(const target of Object.values(m.bin))require('node:fs').accessSync(p.join(root,target));for(const name of ['platformBundle','upgradePrestart','panelUpgrade'])require(root+'/cli/src/internal/maintenance/'+name+'.cjs');require(root+'/shell/upgrade.cjs').verifyUpgrade(root,p.join(root,'static')).catch(e=>{console.error(e);process.exitCode=1});`, root], { env, cwd: temp });
|
||||
env.QL_DATA_DIR = path.join(temp, 'fresh/data');
|
||||
await fs.mkdir(env.QL_DATA_DIR, { recursive: true });
|
||||
console.log('Starting the freshly installed npm package');
|
||||
await start();
|
||||
const fresh = await healthy();
|
||||
assert.equal(fresh.build.sourceCommit, expected.sourceCommit);
|
||||
assert.equal(fresh.services.grpc, true);
|
||||
assert.match(await fetch(`http://127.0.0.1:${port}/`).then((r) => r.text()), /<html/i);
|
||||
await stop();
|
||||
await fs.rm(path.join(temp, 'fresh'), { recursive: true, force: true });
|
||||
env.QL_DATA_DIR = data;
|
||||
console.log('Installing the integrity-pinned published legacy npm package');
|
||||
const legacy = await legacyArchive(temp, env);
|
||||
await install(legacy.archive);
|
||||
assert.equal((await read(path.join(root, 'package.json'))).version, legacy.version);
|
||||
await assert.rejects(fs.access(path.join(root, 'cli/src/internal/maintenance/platformBundle.cjs')), { code: 'ENOENT' });
|
||||
const legacyBackendSha = hash(await fs.readFile(path.join(root, 'static/build/app.js')));
|
||||
await start();
|
||||
await healthy();
|
||||
const credentials = { username: 'npm_package_verification', password: `Test_${randomUUID()}!` };
|
||||
await api('user/init', 'PUT', credentials, true);
|
||||
token = (await api('user/login', 'POST', credentials, true)).token;
|
||||
const nonce = randomUUID();
|
||||
await api('envs', 'POST', [{ name: 'NPM_PACKAGE_PRESERVE', value: nonce }]);
|
||||
const script = path.join(data, 'scripts/npm-package-check.js');
|
||||
await fs.writeFile(script, `console.log(${JSON.stringify(nonce)});\n`);
|
||||
const cron = await api('crons', 'POST', { name: 'npm package retained task', command: `node ${script}`, schedule: '0 0 1 1 *' });
|
||||
const userConfig = path.join(data, 'config/config.sh');
|
||||
await fs.appendFile(userConfig, `\n# retained npm package setting ${nonce}\n`);
|
||||
const userConfigSha = hash(await fs.readFile(userConfig));
|
||||
const preservedData = async () => {
|
||||
assert.equal((await api('user')).username, credentials.username);
|
||||
assert.ok((await api('envs')).some((x) => x.name === 'NPM_PACKAGE_PRESERVE' && x.value === nonce));
|
||||
assert.equal((await api(`crons/${cron.id}`)).command, cron.command);
|
||||
assert.equal(hash(await fs.readFile(userConfig)), userConfigSha);
|
||||
assert.equal(await fs.readFile(script, 'utf8'), `console.log(${JSON.stringify(nonce)});\n`);
|
||||
};
|
||||
await preservedData();
|
||||
console.log('Stopping legacy service and verifying npm automatically preserves its .env during overwrite');
|
||||
await stop();
|
||||
await fs.appendFile(path.join(root, '.env'), `\n# retained custom npm environment ${nonce}\n`);
|
||||
const savedEnv = await fs.readFile(path.join(root, '.env'));
|
||||
await install(input);
|
||||
assert.deepEqual(await fs.readFile(path.join(root, '.env')), savedEnv);
|
||||
assert.equal((await fs.stat(path.join(root, '.env'))).mode & 0o777, 0o600);
|
||||
const configSha = hash(savedEnv);
|
||||
const local = createRequire(path.join(root, 'package.json'));
|
||||
const artifacts = local('./shell/upgrade.cjs');
|
||||
const build = await artifacts.verifyUpgrade(root, path.join(root, 'static'));
|
||||
assert.equal(build.sourceCommit, expected.sourceCommit);
|
||||
assert.notEqual(hash(await fs.readFile(path.join(root, 'static/build/app.js'))), legacyBackendSha);
|
||||
assert.equal(hash(await fs.readFile(path.join(root, 'pnpm-lock.yaml'))), build.lockfileSha256);
|
||||
const fixture = await platformFixture(root, process.env.QL_PANEL_TEST_BUNDLE_DIR, expected.sourceCommit);
|
||||
let badChecksum = true, requests = 0;
|
||||
fixtureServer = http.createServer((request, response) => {
|
||||
requests++;
|
||||
if (request.url === '/latest.json') {
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(JSON.stringify({ format: 2, sourceCommit: fixture.sourceCommit, packages: { [fixture.target]: { size: fixture.size, sha256: badChecksum ? '0'.repeat(64) : fixture.sha256, url: `http://127.0.0.1:${fixtureServer.address().port}/bundle.tar.gz` } } }));
|
||||
} else if (request.url === '/bundle.tar.gz') {
|
||||
response.setHeader('Content-Length', fixture.size);
|
||||
sync.createReadStream(fixture.archive).pipe(response);
|
||||
} else { response.statusCode = 404; response.end(); }
|
||||
});
|
||||
await new Promise((resolve) => fixtureServer.listen(0, '127.0.0.1', resolve));
|
||||
env.QL_UPGRADE_INDEX_URL = `http://127.0.0.1:${fixtureServer.address().port}/latest.json`;
|
||||
console.log('Starting the overwritten npm package against legacy user data');
|
||||
await start();
|
||||
const original = await healthy();
|
||||
assert.equal(original.build.sourceCommit, build.sourceCommit);
|
||||
assert.equal(original.services.grpc, true);
|
||||
token = (await api('user/login', 'POST', credentials, true)).token;
|
||||
const preserved = async () => {
|
||||
await preservedData();
|
||||
assert.equal(hash(await fs.readFile(path.join(root, '.env'))), configSha);
|
||||
};
|
||||
await preserved();
|
||||
console.log('Testing a rejected download through the installed panel API');
|
||||
const rejected = await api('system/update', 'PUT');
|
||||
assert.match((await job(rejected.id, 'failed')).error, /checksum|校验/i);
|
||||
assert.equal((await healthy()).build.instanceId, original.build.instanceId);
|
||||
await preserved();
|
||||
badChecksum = false;
|
||||
console.log('Testing installed panel download, prestart and explicit application');
|
||||
const accepted = await api('system/update', 'PUT');
|
||||
const ready = await job(accepted.id, 'ready');
|
||||
assert.equal((await healthy()).build.instanceId, original.build.instanceId);
|
||||
const pointer = await read(path.join(root, '.tmp/upgrade-ready-develop.json'));
|
||||
const receipt = await read(path.join(root, '.tmp', pointer.directory, 'prestart.json'));
|
||||
assert.ok(receipt.completedAt <= ready.updatedAt, 'Readiness was recorded before backend prestart');
|
||||
const applying = await api('update/system', 'PUT');
|
||||
await job(applying.id, 'completed');
|
||||
const afterOnline = await healthy();
|
||||
await checkRuntime();
|
||||
assert.notEqual(afterOnline.build.instanceId, original.build.instanceId);
|
||||
assert.equal(afterOnline.build.sourceCommit, fixture.sourceCommit);
|
||||
await fs.access(path.join(root, 'node_modules/.pnpm'));
|
||||
assert.match(await fs.realpath(path.join(root, 'node_modules/sqlite3')), /[/\\]node_modules[/\\]\.pnpm[/\\]/);
|
||||
await fs.access(path.join(root, 'scripts/build-upgrade-bundle.cjs'));
|
||||
await artifacts.verifyUpgrade(root, path.join(root, 'static'));
|
||||
assert.match(await fetch(`http://127.0.0.1:${port}/`).then((r) => r.text()), /<html/i);
|
||||
await preserved();
|
||||
console.log('Testing offline installer and Bash reload from the installed npm package');
|
||||
const requestsBeforeOffline = requests;
|
||||
await run(process.execPath, [path.join(root, 'shell/install-bundle.cjs'), '--root', root, '--file', fixture.archive, '--download-only'], { cwd: temp, env, timeout: 240000 });
|
||||
assert.equal(requests, requestsBeforeOffline, 'Offline preparation contacted the index');
|
||||
assert.equal((await healthy()).build.instanceId, afterOnline.build.instanceId);
|
||||
await run(path.join(prefix, 'bin/ql'), ['reload', 'system'], { cwd: temp, env, timeout: 240000 });
|
||||
const afterOffline = await healthy();
|
||||
await checkRuntime();
|
||||
assert.notEqual(afterOffline.build.instanceId, afterOnline.build.instanceId);
|
||||
assert.equal(afterOffline.build.sourceCommit, fixture.sourceCommit);
|
||||
await fs.access(path.join(root, 'node_modules/.pnpm'));
|
||||
assert.equal(hash(await fs.readFile(process.execPath)), nodeSha);
|
||||
assert.equal((await api('system/update-status')).status, 'completed');
|
||||
await preserved();
|
||||
await api('crons/run', 'PUT', [cron.id]);
|
||||
let taskExecuted = false;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const log = String(await api(`crons/${cron.id}/log`));
|
||||
if (log.includes(nonce) && /完成|执行结束|finished|completed/i.test(log)) { taskExecuted = true; break; }
|
||||
await delay(300);
|
||||
}
|
||||
assert.ok(taskExecuted, 'Retained task did not execute after npm package upgrades');
|
||||
const result = {
|
||||
node: process.version, platform: fixture.target,
|
||||
archiveSha256: hash(await fs.readFile(input)), sourceCommit: build.sourceCommit,
|
||||
installedProductionDependencies: true, restoredLockVerified: true,
|
||||
legacyNpmVersion: legacy.version, legacyNpmIntegrity: legacy.integrity,
|
||||
legacyNpmOverwriteInstalled: true, legacyExternalDataPreserved: true,
|
||||
customRootEnvironmentPreservedAutomatically: true,
|
||||
officialPlatformBundle: { baseImage: fixture.baseImage, sourceCommit: fixture.sourceCommit, sha256: fixture.sha256 },
|
||||
migratedFromNpmToOfficialPnpmLayout: true,
|
||||
existingNodePreserved: true, nativeUrlRuntimeProbes: runtimeProbes,
|
||||
frontendAndBackendStarted: true, checksumFailureKeptOldService: true,
|
||||
prestartBeforeReady: true, onlineActualRestart: true,
|
||||
offlinePreparationDidNotContactIndex: true, offlineBashActualRestart: true,
|
||||
accountEnvironmentConfigurationAndScriptsPreserved: true, retainedTaskExecuted: true,
|
||||
initialBuild: original.build, afterOnline: afterOnline.build, afterOffline: afterOffline.build,
|
||||
};
|
||||
if (output) {
|
||||
await fs.mkdir(output, { recursive: true });
|
||||
await fs.writeFile(path.join(output, `verification-${process.version}.json`), JSON.stringify(result, null, 2));
|
||||
}
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (root) {
|
||||
for (const file of ['qinglong-error.log', 'qinglong-out.log']) {
|
||||
const text = await fs.readFile(path.join(env.PM2_HOME, 'logs', file), 'utf8').catch(() => '');
|
||||
if (text) console.error(text.slice(-6000));
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (fixtureServer) {
|
||||
fixtureServer.closeAllConnections();
|
||||
await new Promise((resolve) => fixtureServer.close(resolve));
|
||||
}
|
||||
await run(pm2, ['kill'], { env, cwd: temp }).catch(() => {});
|
||||
await run('nginx', ['-s', 'quit'], { env, cwd: temp }).catch(() => {});
|
||||
await fs.rm(temp, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { checkPackedFiles, packPanel, verifyPanel };
|
||||
if (require.main === module) {
|
||||
const [action, input, output] = process.argv.slice(2);
|
||||
const operation = action === 'pack' && input
|
||||
? packPanel(path.resolve(input))
|
||||
: action === 'verify' && input
|
||||
? verifyPanel(path.resolve(input), output && path.resolve(output))
|
||||
: Promise.reject(new Error('Usage: node scripts/verify-panel-package.cjs pack OUTPUT | verify ARCHIVE_OR_DIRECTORY [RESULT_DIRECTORY]'));
|
||||
operation.catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
}
|
||||
@@ -47,7 +47,19 @@ fi
|
||||
for requested_ref in "${refs[@]}"; do
|
||||
git check-ref-format "$requested_ref"
|
||||
done
|
||||
command -v timeout >/dev/null || { echo 'GNU timeout is required' >&2; exit 2; }
|
||||
# Ubuntu 26.04's default timeout is from uutils. We require GNU's process-group
|
||||
# termination so git transport children cannot keep the query pipes open.
|
||||
timeout_command=''
|
||||
for candidate in timeout gnutimeout gtimeout; do
|
||||
if version=$("$candidate" --version 2>/dev/null) && [[ "$version" == *'(GNU coreutils)'* ]]; then
|
||||
timeout_command=$candidate
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ -z "$timeout_command" ]]; then
|
||||
echo 'GNU timeout is required (timeout, gnutimeout or gtimeout)' >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ -n "$notify_repo" ]]; then
|
||||
if [[ ! "$notify_repo" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then
|
||||
echo 'QL_MIRROR_NOTIFY_REPO must be a GitHub owner/repository' >&2
|
||||
@@ -62,7 +74,7 @@ notification_command() {
|
||||
local budget=$((notification_deadline - SECONDS))
|
||||
if (( budget <= 0 )); then return 1; fi
|
||||
if (( budget > 10 )); then budget=10; fi
|
||||
timeout --signal=KILL "${budget}s" "$@"
|
||||
"$timeout_command" --signal=KILL "${budget}s" "$@"
|
||||
}
|
||||
|
||||
notify_static_mirror() {
|
||||
@@ -156,7 +168,7 @@ while (( SECONDS < wait_seconds )); do
|
||||
fi
|
||||
budget=$query_timeout
|
||||
if (( budget > remaining )); then budget=$remaining; fi
|
||||
if ! output=$(timeout --signal=KILL "${budget}s" git ls-remote --refs \
|
||||
if ! output=$("$timeout_command" --signal=KILL "${budget}s" git ls-remote --refs \
|
||||
"${urls[$index]}" "${refs[@]}" | LC_ALL=C sort); then
|
||||
echo "::warning::Could not read ${labels[$index]} refs on attempt $attempt; retrying"
|
||||
ready=false
|
||||
|
||||
+4
-4
@@ -1,12 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
create_token() {
|
||||
local token_command="ts-node-transpile-only ${dir_root}/back/token.ts"
|
||||
local token_file="${dir_root}/static/build/token.js"
|
||||
if [[ -f $token_file ]]; then
|
||||
token_command="node ${token_file}"
|
||||
__ql_token__=$(node "$token_file")
|
||||
else
|
||||
__ql_token__=$(ts-node-transpile-only "${dir_root}/back/token.ts")
|
||||
fi
|
||||
__ql_token__=$(eval "$token_command")
|
||||
}
|
||||
|
||||
get_token() {
|
||||
@@ -236,7 +236,7 @@ find_cron_api() {
|
||||
|
||||
update_auth_config() {
|
||||
local body="$1"
|
||||
local tip="$2"
|
||||
local tip=$(t "$2")
|
||||
local currentTimeStamp=$(date +%s)
|
||||
local api=$(ql_api_request \
|
||||
"http://localhost:${ql_port}/open/system/auth/reset?t=$currentTimeStamp" \
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user