mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-03 16:19:11 +08:00
Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2207098690 | ||
|
|
bd4775857a | ||
|
|
997bd35fc6 | ||
|
|
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
|
||||
@@ -384,7 +423,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
|
||||
|
||||
@@ -456,7 +495,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
|
||||
|
||||
@@ -499,7 +538,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.debian310
|
||||
push: true
|
||||
@@ -511,30 +550,78 @@ jobs:
|
||||
cache-from: type=registry,ref=whyour/qinglong:cache-debian-python3.10
|
||||
cache-to: type=registry,ref=whyour/qinglong:cache-debian-python3.10,mode=max
|
||||
|
||||
# One unmodified amd64 package for migration acceptance only. This does not
|
||||
# publish bundles or advance development/formal channel indexes.
|
||||
build-npm-migration-bundle:
|
||||
needs: build-debian
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- name: Export the official platform package from this run's image digest
|
||||
env:
|
||||
IMAGE_DIGEST: ${{ needs.build-debian.outputs.image-digest }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$IMAGE_DIGEST" =~ ^sha256:[a-f0-9]{64}$ ]]
|
||||
image="ghcr.io/${GITHUB_REPOSITORY}@${IMAGE_DIGEST}"
|
||||
docker buildx build --platform linux/amd64 --build-arg "BASE_IMAGE=$image" --file docker/Dockerfile.upgrade-bundle --output "type=local,dest=$RUNNER_TEMP/npm-migration-bundle" .
|
||||
- uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: npm-migration-bundle-${{ github.sha }}
|
||||
path: ${{ runner.temp }}/npm-migration-bundle/*
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
verify-npm-package:
|
||||
needs: [build-static, build-npm-migration-bundle]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: ['20.18.1', '24']
|
||||
container: node:${{ matrix.node }}-bookworm
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: qinglong-npm-${{ github.sha }}
|
||||
path: ${{ runner.temp }}/npm-panel/
|
||||
- uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: npm-migration-bundle-${{ github.sha }}
|
||||
path: ${{ runner.temp }}/npm-migration-bundle/
|
||||
- name: Install isolated service test tools
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends python3 openssl curl jq nginx procps
|
||||
# The published legacy launcher resets PATH to standard directories.
|
||||
# /usr/local is confined to this disposable job container; PM2_HOME
|
||||
# remains private to each verification run.
|
||||
npm install --global --prefix /usr/local --no-audit --no-fund pm2@6.0.14
|
||||
- name: Install and upgrade the exact npm archive
|
||||
env:
|
||||
QL_PANEL_PACKAGE_TEST_ISOLATED: '1'
|
||||
QL_PANEL_TEST_PM2: /usr/local/bin/pm2
|
||||
QL_PANEL_TEST_BUNDLE_DIR: ${{ runner.temp }}/npm-migration-bundle
|
||||
run: node scripts/verify-panel-package.cjs verify "$RUNNER_TEMP/npm-panel" "$RUNNER_TEMP/npm-panel-results"
|
||||
- uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: qinglong-npm-verification-${{ matrix.node }}-${{ github.sha }}
|
||||
path: ${{ runner.temp }}/npm-panel-results/
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
publish:
|
||||
if: ${{ github.repository == 'whyour/qinglong' && github.ref == 'refs/heads/master' }}
|
||||
needs: [build-alpine, build-debian, build-alpine310, build-debian310]
|
||||
needs: [build-alpine, build-debian, build-alpine310, build-debian310, 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 +629,110 @@ 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]
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 90
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { platform: linux/amd64, libc: musl, name: x64-musl }
|
||||
- { platform: linux/arm/v6, libc: musl, name: armv6-musl }
|
||||
- { platform: linux/arm/v7, libc: musl, name: armv7-musl }
|
||||
- { platform: linux/arm64, libc: musl, name: arm64-musl }
|
||||
- { platform: linux/ppc64le, libc: musl, name: ppc64-musl }
|
||||
- { platform: linux/s390x, libc: musl, name: s390x-musl }
|
||||
- { platform: linux/386, libc: musl, name: ia32-musl }
|
||||
- { platform: linux/amd64, libc: glibc, name: x64-glibc }
|
||||
- { platform: linux/arm64, libc: glibc, name: arm64-glibc }
|
||||
- { platform: linux/ppc64le, libc: glibc, name: ppc64-glibc }
|
||||
- { platform: linux/s390x, libc: glibc, name: s390x-glibc }
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
cache-image: false
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- name: Build and probe the immutable platform package
|
||||
env:
|
||||
ALPINE_DIGEST: ${{ needs.build-alpine.outputs.image-digest }}
|
||||
DEBIAN_DIGEST: ${{ needs.build-debian.outputs.image-digest }}
|
||||
BUNDLE_LIBC: ${{ matrix.libc }}
|
||||
BUNDLE_PLATFORM: ${{ matrix.platform }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
digest="$ALPINE_DIGEST"
|
||||
if [ "$BUNDLE_LIBC" = glibc ]; then digest="$DEBIAN_DIGEST"; fi
|
||||
test -n "$digest"
|
||||
image="ghcr.io/${GITHUB_REPOSITORY}@${digest}"
|
||||
docker buildx build --platform "$BUNDLE_PLATFORM" --build-arg "BASE_IMAGE=$image" --file docker/Dockerfile.upgrade-bundle --output type=local,dest=output/upgrade .
|
||||
- uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: upgrade-${{ matrix.name }}-${{ github.sha }}
|
||||
path: output/upgrade/*
|
||||
if-no-files-found: error
|
||||
|
||||
publish-upgrade-bundles:
|
||||
if: ${{ !inputs.static_sync_only && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/develop') }}
|
||||
needs: build-upgrade-bundles
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
concurrency:
|
||||
group: upgrade-channel-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: '8.3.1'
|
||||
- run: pnpm install --prod --frozen-lockfile
|
||||
- run: npm --prefix cli ci
|
||||
- uses: actions/download-artifact@v7
|
||||
with:
|
||||
pattern: upgrade-*-${{ github.sha }}
|
||||
path: output/upgrade
|
||||
merge-multiple: true
|
||||
- run: node scripts/build-bundle-installer.cjs output/upgrade/install-bundle.cjs
|
||||
- name: Publish complete packages before advancing the channel
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: node scripts/publish-upgrade-bundles.cjs output/upgrade "$GITHUB_SHA" "$GITHUB_REPOSITORY" "$GITHUB_REF_NAME"
|
||||
|
||||
@@ -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,7 +69,7 @@ jobs:
|
||||
- image: debian
|
||||
file: docker/Dockerfile.debian
|
||||
cache: debian
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
||||
platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/s390x
|
||||
- image: alpine-python3.10
|
||||
file: docker/Dockerfile.310
|
||||
cache: alpine-python3.10
|
||||
@@ -65,7 +77,7 @@ jobs:
|
||||
- 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 +133,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('配置更新失败'),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
+35
-12
@@ -20,6 +20,7 @@ import {
|
||||
getInstallCommand,
|
||||
getUninstallCommand,
|
||||
getGetCommand,
|
||||
isPythonSourceDependency,
|
||||
} from '../config/util';
|
||||
import dayjs from 'dayjs';
|
||||
import taskLimit from '../shared/pLimit';
|
||||
@@ -76,21 +77,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;
|
||||
}
|
||||
@@ -156,9 +161,15 @@ export default class DependenceService {
|
||||
},
|
||||
});
|
||||
const checks = await concurrentRun(
|
||||
docs.map((doc) => async () => {
|
||||
return (await this.isDependenceInstalled(doc)) ? undefined : doc.id;
|
||||
}),
|
||||
docs
|
||||
.filter(
|
||||
(doc) =>
|
||||
doc.type !== DependenceTypes.python3 ||
|
||||
!isPythonSourceDependency(doc.name),
|
||||
)
|
||||
.map((doc) => async () => {
|
||||
return (await this.isDependenceInstalled(doc)) ? undefined : doc.id;
|
||||
}),
|
||||
5,
|
||||
);
|
||||
const missingIds = (checks || []).filter(
|
||||
@@ -177,7 +188,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 +319,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 +404,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 +418,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+)/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()
|
||||
|
||||
Generated
+78
-4
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "@whyour/qinglong-cli",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@whyour/qinglong-cli",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"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.3",
|
||||
"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'),
|
||||
|
||||
@@ -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,575 @@
|
||||
// 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+)/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,394 @@
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const { spawn } = require('node:child_process');
|
||||
const crypto = require('node:crypto');
|
||||
const os = require('node:os');
|
||||
const { serverRuntimeArgs } = require('./nodeRuntime.cjs');
|
||||
|
||||
function probeLimits(target) {
|
||||
const small = target.arch === 'armv6';
|
||||
return { heapMb: small ? 128 : 192, timeoutMs: small ? 300000 : 60000 };
|
||||
}
|
||||
|
||||
function dependencyFailure(error) {
|
||||
const text = `${error.message || ''}\n${error.stack || ''}\n${
|
||||
error.stderr || ''
|
||||
}`;
|
||||
if (error.signal || /timed out|heap out of memory|ENOMEM/i.test(text))
|
||||
return false;
|
||||
if (
|
||||
/ERR_DLOPEN_FAILED/.test(`${error.code} ${text}`) &&
|
||||
/node_modules[/\\]/.test(text)
|
||||
)
|
||||
return true;
|
||||
const missing = /Cannot find (?:module|package) ['"]([^'"]+)['"]/.exec(
|
||||
text,
|
||||
)?.[1];
|
||||
const importer = /Require stack:\r?\n-\s+([^\r\n]+)/.exec(text)?.[1];
|
||||
const inDependencies = (filename) =>
|
||||
/(?:^|[/\\])node_modules[/\\]/.test(filename || '');
|
||||
// Node reports a missing package entry as an absolute path and missing
|
||||
// package-internal imports as relative paths. Neither is an app source error.
|
||||
const dependencyMissing =
|
||||
missing &&
|
||||
((!missing.startsWith('.') &&
|
||||
!path.isAbsolute(missing) &&
|
||||
!missing.startsWith('node:')) ||
|
||||
inDependencies(missing) ||
|
||||
inDependencies(importer));
|
||||
// Missing backend files, bad config and database errors must never trigger npm.
|
||||
return (
|
||||
(/MODULE_NOT_FOUND/.test(`${error.code} ${text}`) &&
|
||||
Boolean(dependencyMissing)) ||
|
||||
(/Could not locate the bindings file/.test(text) &&
|
||||
/node_modules[/\\]/.test(text))
|
||||
);
|
||||
}
|
||||
|
||||
async function prestartBackend(
|
||||
selected,
|
||||
{ target, signal, limits = probeLimits(target) },
|
||||
) {
|
||||
signal?.throwIfAborted();
|
||||
const workspace = await fs.mkdtemp(
|
||||
path.join(path.dirname(selected.source), 'prestart-'),
|
||||
);
|
||||
try {
|
||||
const data = path.join(workspace, 'data');
|
||||
for (const name of ['db', 'config', 'syslog', 'log', 'scripts', 'upload'])
|
||||
await fs.mkdir(path.join(data, name), { recursive: true });
|
||||
// Copy only backend code/metadata, never all frontend assets or node_modules.
|
||||
await fs.mkdir(path.join(workspace, 'static'));
|
||||
await fs.cp(
|
||||
path.join(selected.static, 'build'),
|
||||
path.join(workspace, 'static/build'),
|
||||
{ recursive: true },
|
||||
);
|
||||
await fs.copyFile(
|
||||
path.join(selected.static, 'build-info.json'),
|
||||
path.join(workspace, 'static/build-info.json'),
|
||||
);
|
||||
await fs.copyFile(
|
||||
path.join(selected.source, 'version.yaml'),
|
||||
path.join(workspace, 'version.yaml'),
|
||||
);
|
||||
await fs.symlink(
|
||||
path.join(selected.source, 'node_modules'),
|
||||
path.join(workspace, 'node_modules'),
|
||||
'dir',
|
||||
);
|
||||
await fs.writeFile(path.join(workspace, '.env'), '', { mode: 0o600 });
|
||||
const entry = path.join(
|
||||
workspace,
|
||||
'static/build/bootstrap/upgradeProbe.js',
|
||||
);
|
||||
await fs.access(entry);
|
||||
const expected = JSON.parse(
|
||||
await fs.readFile(path.join(selected.static, 'build-info.json')),
|
||||
);
|
||||
const backendSha256 = crypto
|
||||
.createHash('sha256')
|
||||
.update(await fs.readFile(path.join(selected.static, 'build/app.js')))
|
||||
.digest('hex');
|
||||
const version = (
|
||||
await fs.readFile(path.join(selected.source, 'version.yaml'), 'utf8')
|
||||
).match(/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+)/m)?.[1];
|
||||
// An allowlist prevents NODE_OPTIONS, startup hooks, real data paths and
|
||||
// package-manager settings from escaping into the disposable backend.
|
||||
const env = {
|
||||
PATH: process.env.PATH,
|
||||
LANG: process.env.LANG || 'C.UTF-8',
|
||||
HOME: workspace,
|
||||
TMPDIR: workspace,
|
||||
NODE_ENV: 'production',
|
||||
QL_DIR: workspace,
|
||||
QL_DATA_DIR: data,
|
||||
QL_UPGRADE_PROBE: '1',
|
||||
BACK_PORT: '0',
|
||||
GRPC_PORT: '0',
|
||||
BIND_HOST: '127.0.0.1',
|
||||
BIND_HOST_GRPC: '127.0.0.1',
|
||||
QlBaseUrl: '/',
|
||||
NODE_PATH: '',
|
||||
NODE_OPTIONS: '',
|
||||
UV_THREADPOOL_SIZE: '1',
|
||||
};
|
||||
return await new Promise((resolve, reject) => {
|
||||
let report,
|
||||
failure,
|
||||
output = '',
|
||||
finished = false,
|
||||
killTimer;
|
||||
// No PM2/cluster workers. OpenSSL key generation is sequential in this
|
||||
// one backend process. Keep normal V8 mode for Node 20's HTTP/WASM support.
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[...serverRuntimeArgs(), `--max-old-space-size=${limits.heapMb}`, entry],
|
||||
{
|
||||
cwd: workspace,
|
||||
env,
|
||||
detached: process.platform !== 'win32',
|
||||
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
||||
},
|
||||
);
|
||||
try {
|
||||
if (child.pid) os.setPriority(child.pid, 10);
|
||||
} catch {}
|
||||
const kill = (signalName) => {
|
||||
try {
|
||||
if (process.platform !== 'win32' && child.pid)
|
||||
process.kill(-child.pid, signalName);
|
||||
else child.kill(signalName);
|
||||
} catch (error) {
|
||||
if (error.code !== 'ESRCH') failure ||= error;
|
||||
}
|
||||
};
|
||||
const stop = (error) => {
|
||||
failure ||= error;
|
||||
kill('SIGTERM');
|
||||
killTimer ||= setTimeout(() => kill('SIGKILL'), 1500);
|
||||
};
|
||||
const abort = () =>
|
||||
stop(signal.reason || new Error('Upgrade prestart aborted'));
|
||||
const timer = setTimeout(
|
||||
() => stop(new Error('Upgrade prestart timed out')),
|
||||
limits.timeoutMs,
|
||||
);
|
||||
child.stdout.on('data', (chunk) => {
|
||||
output = (output + chunk).slice(-32768);
|
||||
});
|
||||
child.stderr.on('data', (chunk) => {
|
||||
output = (output + chunk).slice(-32768);
|
||||
});
|
||||
child.on('message', (message) => {
|
||||
if (message?.type === 'upgrade-probe') report = message;
|
||||
});
|
||||
const finish = (error) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
clearTimeout(timer);
|
||||
clearTimeout(killTimer);
|
||||
signal?.removeEventListener('abort', abort);
|
||||
if (error) reject(error);
|
||||
else resolve(report);
|
||||
};
|
||||
child.on('error', (error) => finish(error));
|
||||
// Descendants can retain our output pipes after their parent exits.
|
||||
// Reap the group on exit rather than waiting for those pipes to close.
|
||||
child.once('exit', () => kill('SIGKILL'));
|
||||
child.on('close', (code, exitSignal) => {
|
||||
// Reap descendants even when the backend exits unexpectedly.
|
||||
kill('SIGKILL');
|
||||
if (failure) return finish(failure);
|
||||
if (code !== 0 || !report?.ok)
|
||||
return finish(
|
||||
Object.assign(
|
||||
new Error(
|
||||
report?.message ||
|
||||
`Upgrade prestart exited: ${exitSignal || code}\n${output}`,
|
||||
),
|
||||
{
|
||||
code: report?.code,
|
||||
stack: report?.stack || output,
|
||||
signal: exitSignal,
|
||||
},
|
||||
),
|
||||
);
|
||||
if (
|
||||
report.build?.sourceCommit !== expected.sourceCommit ||
|
||||
report.build?.backendSha256 !== backendSha256 ||
|
||||
report.build?.version !== version
|
||||
)
|
||||
return finish(new Error('Upgrade prestart build identity mismatch'));
|
||||
finish();
|
||||
});
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
if (signal?.aborted) abort();
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(workspace, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function repairDependencies(source, { target, signal, env }) {
|
||||
const workspace = await fs.mkdtemp(
|
||||
path.join(path.dirname(source), 'repair-'),
|
||||
);
|
||||
try {
|
||||
const pkg = JSON.parse(
|
||||
await fs.readFile(path.join(source, 'package.json')),
|
||||
);
|
||||
if (pkg.packageManager !== 'pnpm@8.3.1')
|
||||
throw new Error('Unsupported frozen-lockfile package manager');
|
||||
// Install apart from the verified application: lifecycle scripts cannot
|
||||
// rewrite source/static files or use the old installation's node_modules.
|
||||
await fs.writeFile(
|
||||
path.join(workspace, 'package.json'),
|
||||
JSON.stringify({ ...pkg, scripts: {} }),
|
||||
);
|
||||
await fs.copyFile(
|
||||
path.join(source, 'pnpm-lock.yaml'),
|
||||
path.join(workspace, 'pnpm-lock.yaml'),
|
||||
);
|
||||
const small = target.arch === 'armv6';
|
||||
const childEnv = {
|
||||
PATH: env.PATH || process.env.PATH,
|
||||
HOME: workspace,
|
||||
TMPDIR: workspace,
|
||||
NODE_OPTIONS: `${small ? '--jitless ' : ''}--max-old-space-size=${
|
||||
small ? 256 : 384
|
||||
}`,
|
||||
NODE_PATH: '',
|
||||
CI: 'true',
|
||||
npm_config_jobs: '1',
|
||||
MAKEFLAGS: '-j1',
|
||||
...(small
|
||||
? {
|
||||
npm_config_build_from_source: 'true',
|
||||
npm_config_force_process_config: 'true',
|
||||
CFLAGS: '-march=armv6 -mfpu=vfp -mfloat-abi=hard',
|
||||
CXXFLAGS: '-march=armv6 -mfpu=vfp -mfloat-abi=hard',
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
// Preserve network routing without inheriting Node/preload/compiler settings.
|
||||
for (const name of [
|
||||
'HTTP_PROXY',
|
||||
'HTTPS_PROXY',
|
||||
'ALL_PROXY',
|
||||
'NO_PROXY',
|
||||
'http_proxy',
|
||||
'https_proxy',
|
||||
'all_proxy',
|
||||
'no_proxy',
|
||||
])
|
||||
if (typeof env[name] === 'string') childEnv[name] = env[name];
|
||||
// Reuse matching installed headers on small devices; never inherit a
|
||||
// caller's nodedir or accidentally compile against a different Node ABI.
|
||||
const prefix = path.resolve(path.dirname(process.execPath), '..');
|
||||
const header = await fs
|
||||
.readFile(path.join(prefix, 'include/node/node_version.h'), 'utf8')
|
||||
.catch(() => '');
|
||||
const headerVersion = ['MAJOR', 'MINOR', 'PATCH']
|
||||
.map(
|
||||
(part) =>
|
||||
header.match(
|
||||
new RegExp(`#define\\s+NODE_${part}_VERSION\\s+(\\d+)`),
|
||||
)?.[1],
|
||||
)
|
||||
.join('.');
|
||||
if (headerVersion === process.versions.node) {
|
||||
childEnv.npm_config_nodedir = prefix;
|
||||
childEnv.npm_config_force_process_config = 'true';
|
||||
}
|
||||
const run = (args) =>
|
||||
runRepair('pnpm', args, {
|
||||
cwd: workspace,
|
||||
env: childEnv,
|
||||
signal,
|
||||
timeoutMs: small ? 1800000 : 600000,
|
||||
});
|
||||
const version = await run(['--version']);
|
||||
if (version.trim() !== '8.3.1')
|
||||
throw new Error('Dependency repair requires existing pnpm 8.3.1');
|
||||
await run([
|
||||
'install',
|
||||
'--prod',
|
||||
'--frozen-lockfile',
|
||||
'--ignore-scripts',
|
||||
'--child-concurrency=1',
|
||||
'--network-concurrency=2',
|
||||
]);
|
||||
await run(['rebuild', 'sqlite3', '@whyour/sqlite3']);
|
||||
if (
|
||||
!(await fs.readFile(path.join(source, 'pnpm-lock.yaml'))).equals(
|
||||
await fs.readFile(path.join(workspace, 'pnpm-lock.yaml')),
|
||||
)
|
||||
)
|
||||
throw new Error('Dependency repair changed the frozen lockfile');
|
||||
await fs.rm(path.join(source, 'node_modules'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
await fs.rename(
|
||||
path.join(workspace, 'node_modules'),
|
||||
path.join(source, 'node_modules'),
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(workspace, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Bound output, wall time and the entire npm/node-gyp process group.
|
||||
async function runRepair(program, args, options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
options.signal?.throwIfAborted();
|
||||
let output = '',
|
||||
stdout = '',
|
||||
failure,
|
||||
killTimer;
|
||||
const child = spawn(program, args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
detached: process.platform !== 'win32',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
const kill = (sig) => {
|
||||
try {
|
||||
if (child.pid && process.platform !== 'win32')
|
||||
process.kill(-child.pid, sig);
|
||||
else child.kill(sig);
|
||||
} catch {}
|
||||
};
|
||||
const stop = (error) => {
|
||||
failure ||= error;
|
||||
kill('SIGTERM');
|
||||
killTimer ||= setTimeout(() => kill('SIGKILL'), 1500);
|
||||
};
|
||||
const abort = () =>
|
||||
stop(options.signal.reason || new Error('Dependency repair aborted'));
|
||||
const timer = setTimeout(
|
||||
() => stop(new Error('Dependency repair timed out')),
|
||||
options.timeoutMs,
|
||||
);
|
||||
const capture = (chunk) => {
|
||||
output = (output + chunk).slice(-32768);
|
||||
};
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout = (stdout + chunk).slice(-32768);
|
||||
capture(chunk);
|
||||
});
|
||||
child.stderr.on('data', capture);
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
clearTimeout(killTimer);
|
||||
options.signal?.removeEventListener('abort', abort);
|
||||
kill('SIGKILL');
|
||||
};
|
||||
child.on('error', (error) => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
});
|
||||
child.once('exit', () => kill('SIGKILL'));
|
||||
child.on('close', (code, signal) => {
|
||||
cleanup();
|
||||
if (failure || code !== 0)
|
||||
reject(
|
||||
failure ||
|
||||
new Error(
|
||||
`Dependency repair failed (${signal || code}): ${output}`,
|
||||
),
|
||||
);
|
||||
else resolve(stdout);
|
||||
});
|
||||
options.signal?.addEventListener('abort', abort, { once: true });
|
||||
if (options.signal?.aborted) abort();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
prestartBackend,
|
||||
dependencyFailure,
|
||||
repairDependencies,
|
||||
probeLimits,
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
@@ -12,6 +12,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',
|
||||
@@ -410,6 +416,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 +485,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' },
|
||||
|
||||
@@ -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状态再考虑重试。':
|
||||
@@ -169,8 +177,7 @@ export const english: Record<string, string> = {
|
||||
'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 配置无效,请运行 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'),
|
||||
|
||||
+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/latest.json`。`develop` 和 `debian-dev` 镜像都使用该索引,按实际 Node 架构和 musl/glibc 选择包,无需设置测试专用下载源。开发批次位于 `upgrade-build-develop-v<version>-<run-id>-<run-attempt>`,不会改写正式版本或 `upgrade-master`。同一开发渠道的镜像构建和包发布串行执行,与 master 独立。
|
||||
|
||||
开发包只保留最新一个已验证批次:所有平台资产和渠道索引的大小、SHA256 校验成功后,删除其他名称匹配的开发构建 prerelease;不删除正式包、渠道索引或无法分类的旧 release。构建或上传失败不会删除上一套可用包。旧批次被删除后,已选中旧索引但尚未下载完的客户端可能需要重新发起下载;准备失败仍保留旧服务和数据。
|
||||
|
||||
开发和正式包都必须在 11 个平台完成归档、解包后复验及依赖试运行,并且全部上传资产的大小和 SHA256 复验通过后,才更新各自索引。平台范围与现有 Alpine/Debian 镜像一致:ARMv6 和 ARMv7 仅提供 musl 包,Debian 不再发布 ARMv7 镜像或 glibc 平台包。
|
||||
|
||||
版本入口为 GitHub prerelease `upgrade-v<version>` 下的 `latest.json`;在线正式渠道保留 `upgrade-master/latest.json`,兼容现有客户端。二者指向同一套完整包,指定版本与 latest 不重复生成产物。索引记录渠道、版本、源码提交、构建批次、准确字节数、SHA256,以及 `upgrade-build-v<version>-<run-id>-<run-attempt>` 下的固定下载 URL。相同版本的镜像 tag 重新发布时,完整包也重新生成,版本和正式渠道索引同步覆盖到新批次;同一提交重跑也允许产生新内容。已选中旧索引的下载继续使用旧批次资产,避免覆盖文件导致哈希不匹配。master 的镜像构建和完整包发布整条流水线串行执行,避免镜像 tag 与完整包被并发重发交错覆盖;旧工作流明确重跑时也允许同版本、同提交重新发布。过期源码提交不能推进对应渠道或版本索引;平台包上传失败时不切换索引。
|
||||
|
||||
## 手动离线安装和升级
|
||||
|
||||
从 `upgrade-v<version>` 的说明或 `latest.json` 中取得当前批次的下载链接,下载匹配设备的 `.tar.gz` 和独立 `install-bundle.cjs`;两者都来自同一个 `upgrade-build-v<version>-<run-id>-<run-attempt>` release。独立安装器已内置解析依赖,在目标设备正常情况无需 npm、pnpm 或重新安装 SQLite。离线准备和预启动默认不联网;依赖不符合要求时直接报错。可对照同批次 release 的 `latest.json` 核对归档 SHA256。
|
||||
|
||||
已有安装(默认应用并重启):
|
||||
|
||||
```sh
|
||||
node install-bundle.cjs --root /ql --file /absolute/path/qinglong-<commit>-linux-arm64-musl.tar.gz
|
||||
```
|
||||
|
||||
只准备,稍后手动重启:
|
||||
|
||||
```sh
|
||||
node install-bundle.cjs --root /ql --file /absolute/path/package.tar.gz --download-only
|
||||
ql reload system
|
||||
```
|
||||
|
||||
仅在明确允许联网修复时添加 `--repair-deps`(安装和升级均支持),例如:
|
||||
|
||||
```sh
|
||||
node install-bundle.cjs --root /ql --file /absolute/path/package.tar.gz --download-only --repair-deps
|
||||
```
|
||||
|
||||
修复需要已有 pnpm 8.3.1 及必要编译工具。依赖在私有目录按 `--prod --frozen-lockfile` 重装,仅重建 SQLite;ARMv6 强制源码编译和 ARMv6 硬浮点指令,禁止下载 generic ARM 的 ARMv7 二进制。修复仅执行一次;不安装或替换 Node、不改锁文件、不改已校验源码和静态产物。应用前以原包校验清单和独立记录的修复依赖摘要复验。
|
||||
|
||||
修复继承调用环境中的标准 HTTP/HTTPS/ALL/NO_PROXY(含小写形式),其余 Node 预加载和编译参数仍隔离。修复安装或 SQLite 重建失败时,清理私有目录,保留当前服务、当前依赖及上一次就绪包;不会标记本次更新就绪。预启动或修复主进程退出时立即终止其进程组,防止持有输出管道的子进程继续写入已清理目录。
|
||||
|
||||
通过 Bash 或已选择的本地 CLI 执行上述重启时,也会把已准备的面板任务更新为完成或失败;启动成功必须同时匹配目标版本、提交和后端哈希,匹配失败则恢复旧文件和服务。
|
||||
|
||||
手动应用会以当前就绪指针为准建立新的应用任务,并清除之前的失败信息。因此,准备失败后应用保留的旧就绪包、此前任务已完成后再次应用,以及完整包安装中任务记录丢失或尚未建立时,仍会校验实际启动版本并同步面板状态。未支持完整包且没有任务记录的旧版保留原重载兼容路径。
|
||||
|
||||
重启中断后的停止新服务、恢复文件和启动旧服务不再使用已经取消的信号。若新服务无法停止,则保留备份,避免在仍运行的进程下覆盖文件;若文件已恢复但旧服务也无法启动,任务仍报告失败。Bash 和本地 CLI 启动后端时都移除离线包选择、修复许可和升级启动器 PID,避免后续在线更新误用旧离线包;用户配置及代理保持不变。回滚恢复应用文件和依赖,不把用户数据库恢复到历史快照;数据库迁移仍需保持向后兼容。
|
||||
|
||||
安装到空目录:
|
||||
|
||||
```sh
|
||||
node install-bundle.cjs --root /opt/qinglong --file /absolute/path/package.tar.gz --install
|
||||
QL_DIR=/opt/qinglong QL_DATA_DIR=/opt/qinglong/data bash /opt/qinglong/shell/start-simplify.sh
|
||||
```
|
||||
|
||||
`--install` 拒绝非空目录,不覆盖既有配置。该包安装青龙及其 Node 生产依赖;首次启动仍需已有 Python、Bash、OpenSSL、PM2、nginx 等相应运行环境,不会在线安装或替换它们。手动启动脚本会在启动工作进程前完成首次 gRPC 证书准备,避免慢设备生成密钥耗尽工作进程的就绪期限;已有证书继续复用。服务启动和系统自启按原手动安装流程配置。
|
||||
|
||||
ARMv6 小设备使用 `linux-armv6-musl.tar.gz`,要求已有兼容的 musl 系统及硬浮点 Node。ARMv6 glibc(例如 Raspbian/Raspberry Pi OS 32 位系统)不在平台包支持范围内,不能使用其他架构或 musl 包替代;安装器仍会检查实际架构和 libc,拒绝不匹配的包。
|
||||
|
||||
## npm 面板包
|
||||
|
||||
`@whyour/qinglong` 仍通过 npm 安装生产依赖;它包含运行脚本、gRPC proto、升级引擎和完整前后端构建,不包含用户数据、Node、开发 CLI 或生产 `node_modules`。`@whyour/qinglong-cli` 是单独的远程管理包。
|
||||
|
||||
npm 会排除根目录的 `pnpm-lock.yaml`。主包的原生 `npm pack/publish` 在 `prepack` 中核对当前 Git 提交、干净工作区、锁文件及完整构建文件的哈希,通过后才将锁文件复制为 `shell/upgrade-lock.yaml`,安装时由仅使用 Node 内置模块的 `postinstall` 校验 SHA256 后恢复标准路径;源代码安装和 Docker 的最小依赖构建阶段继续使用原有锁文件。归档使用精确运行文件白名单,避免打入本地生成的预加载配置、缓存、测试和验收输出。静态构建中的 source map 与 gzip 文件也必须完整保留,否则清单复验会失败。
|
||||
|
||||
`package.json#files` 是 npm 包内容的唯一项目规则,不再维护根 `.npmignore`:npm 在根包使用 `files` 时会停用该目录的 `.npmignore` 和 `.gitignore` 规则。打包测试在没有 `.npmignore` 的独立源码目录中注入私有配置、密钥占位文件、缓存及生成的预加载配置,执行真实 `npm pack` 和安装,检查运行文件完整、私有文件及其内容不进入归档。此规则只用于 npm 包,完整平台 tar.gz 仍由平台包构建器处理。
|
||||
|
||||
干净源码完成 `pnpm build:front`、`pnpm build:back`、`pnpm build:info` 后,运行 `node scripts/verify-panel-package.cjs pack /tmp/qinglong-npm` 生成并检查真实 npm 归档。CI 在一次性 Linux 容器中,以 Node 20.18.1 和 Node 24 安装同一个归档,使用真实生产依赖和已安装的 `qinglong`、`ql` 入口,验证启动、损坏下载保留旧服务、在线准备与重启、离线准备与 Bash 重启及账号、配置、环境变量、任务和脚本保留。测试还安装完整性摘要固定的已发布 `@whyour/qinglong@2.23.0`,在旧服务中建立真实数据,再用新 `.tgz` 执行 npm 覆盖安装,验证账号、配置、环境变量、任务和脚本保留。之后升级到同次 Debian 镜像不可变 digest 通过正式 `Dockerfile.upgrade-bundle` 导出的原始平台包,确认运行目录变为 pnpm 布局。该单平台验收包仅上传短期 CI artifact,不发布 Release,也不推进开发或正式更新索引。
|
||||
|
||||
Umi 4.4.4 的构建依赖仍使用 Node 24 已移除的 `http_parser`。前端源码构建使用 `.nvmrc` 中的 Node 22,CI 从该文件读取版本,Vercel 前后端项目均配置为 Node 22.x。`package.json` 不声明 `engines.node`,避免 Vercel 用开放的最低运行版本范围覆盖项目的构建版本;`pnpm build:front` 直接执行构建,不下载或切换 Node。面板运行时仍支持 Node 20.18.1 及以上,平台包的清单与安装器独立校验最低 Node 要求,npm 实装验收继续覆盖 Node 20/24。
|
||||
|
||||
服务集成测试只能在专用容器中运行:设置 `QL_PANEL_PACKAGE_TEST_ISOLATED=1`、指向隔离 PM2 的 `QL_PANEL_TEST_PM2` 和指向正式构建脚本输出目录的 `QL_PANEL_TEST_BUNDLE_DIR`,然后执行 `node scripts/verify-panel-package.cjs verify /tmp/qinglong-npm /tmp/qinglong-npm-results`。容器需已有 Bash、curl、OpenSSL、Python、jq、nginx 和 procps。旧正式包会重置 PATH,因此验收容器将 PM2 6.0.14 安装到 `/usr/local/bin/pm2`,并以此设置 `QL_PANEL_TEST_PM2`;隔离通过专属容器和独立 `PM2_HOME` 保证。测试创建独立的 npm prefix、HOME、PM2_HOME 和数据目录,结束后清理。
|
||||
|
||||
通过 npm 覆盖升级前,应先停止服务,执行新包安装,再启动服务。正常 npm 安装的 `postinstall` 会从当前路径对应的 npm 旧包临时目录中自动保留 `.env`,逐字节恢复为权限 `0600` 的普通文件,不需要手工备份和恢复;新目录中已有的 `.env` 不会被覆盖。只读取包名匹配的旧包,不扫描其他安装或备份目录;异常配置(如符号链接)会使安装失败,由 npm 回退旧包,避免静默丢失配置。此机制需要启用 npm 安装脚本,不能使用 `--ignore-scripts`。持久数据仍必须放在包目录以外的 `QL_DATA_DIR`。验收直接检查真实旧 npm 包覆盖安装后配置自动保留,并校验外置用户数据。在线和离线平台包升级仍由事务保留现有 `.env`。
|
||||
|
||||
master 发布任务等待上述测试通过,直接发布已验证的 `.tgz`,不重新构建或打包。develop 只生成测试归档与验收记录,不发布 npm。
|
||||
|
||||
## 平台兼容
|
||||
|
||||
- 最低 Node 为 20.18.1,Node-API 至少 6;安装器只检查,不替换 Node。
|
||||
- 以 Node 实际运行架构、ARM 指令集和 libc 类型/版本选择包;不依据 Docker 架构别名猜测。
|
||||
- glibc、musl 分包,并记录构建系统 libc 下限。系统库仍以目标设备加载测试作为最后检查。
|
||||
- ARMv6 保留独立包名。ARMv6 在目标镜像中从源码编译 SQLite,禁止复用 generic `arm` 的 ARMv7 预编译件;构建需通过加载和内存库读写,失败则不发布渠道。
|
||||
- ARMv6 的归档构建、在线更新工作进程及独立离线安装器使用 `--jitless`,避开 ARM1176 上 V8 的优化编译崩溃;保留现有 Node 二进制,Node 24 及以上的 ARMv6 面板进程通过共享 `nodeRuntime` 使用 `--no-opt --no-sparkplug --no-maglev`,其他架构和较低 Node 版本保持原启动参数。ARMv6 的 gRPC 工作进程首次启动及重建最多等待 2 分钟;升级后的整体健康检查最多等待 5 分钟,覆盖慢设备按顺序启动数据库、调度与 HTTP 工作进程的耗时;其他平台仍为 60 秒,超时或版本校验失败仍执行回退。依赖加载与后端预启动使用现有 Node 的正常模式,使 Node 20.18.1 的内置 HTTP/WebAssembly 组件保持可用。预启动只有一个后端进程,按顺序初始化数据库、gRPC、HTTP,不启动 PM2/cluster;ARMv6 V8 堆上限 128 MiB、期限 5 分钟,其他平台为 192 MiB/60 秒。堆上限不是 RSS 总内存上限,系统仍需为现有服务和 OpenSSL 留出内存。依赖修复的包管理器及编译使用单并发、低网络并发和有界时长;ARMv6 包管理器采用 `--jitless`、256 MiB 堆上限,并优先复用版本匹配的现有 Node 头文件与实际运行时编译配置;超时、中断会终止整个进程组并清理临时目录。
|
||||
- tar 中允许 pnpm 的包内符号链接;拒绝绝对路径、越界路径、包外链接、硬链接、设备条目和经符号链接写入的条目。重启前再次校验依赖,暂存失败保留上一次可用的就绪包。
|
||||
|
||||
自托管镜像可设置 `QL_UPGRADE_INDEX_URL` 指向兼容索引;要求 HTTPS,测试时仅放行 loopback HTTP。索引和所有平台归档应部署到同一可访问源,避免依赖额外的 raw 域名。
|
||||
@@ -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: '',
|
||||
|
||||
+49
-7
@@ -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,281 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const {
|
||||
sha256,
|
||||
} = require('../cli/src/internal/maintenance/platformBundle.cjs');
|
||||
const platforms = [
|
||||
'linux-x64-musl',
|
||||
'linux-armv6-musl',
|
||||
'linux-armv7-musl',
|
||||
'linux-arm64-musl',
|
||||
'linux-ppc64-musl',
|
||||
'linux-s390x-musl',
|
||||
'linux-ia32-musl',
|
||||
'linux-x64-glibc',
|
||||
'linux-arm64-glibc',
|
||||
'linux-ppc64-glibc',
|
||||
'linux-s390x-glibc',
|
||||
];
|
||||
function releaseIndex(directory, commit, repository, options = {}) {
|
||||
const { version, releaseTag = `upgrade-${commit}` } = options;
|
||||
if (
|
||||
!/^[a-f0-9]{40}$/.test(commit) ||
|
||||
!/^[\w.-]+\/[\w.-]+$/.test(repository) ||
|
||||
!/^[\w.+-]+$/.test(releaseTag)
|
||||
)
|
||||
throw new Error('Invalid release source');
|
||||
const packages = {};
|
||||
for (const target of platforms) {
|
||||
const filename = `qinglong-${commit}-${target}.tar.gz`;
|
||||
const record = JSON.parse(
|
||||
fs.readFileSync(path.join(directory, filename + '.json')),
|
||||
);
|
||||
const bytes = fs.readFileSync(path.join(directory, filename));
|
||||
if (
|
||||
record.format !== 2 ||
|
||||
record.sourceCommit !== commit ||
|
||||
(version !== undefined && record.version !== version) ||
|
||||
record.target !== target ||
|
||||
record.filename !== filename ||
|
||||
record.size !== bytes.length ||
|
||||
record.sha256 !== sha256(bytes)
|
||||
)
|
||||
throw new Error(`Unverified platform bundle: ${target}`);
|
||||
packages[target] = {
|
||||
size: record.size,
|
||||
sha256: record.sha256,
|
||||
url: `https://github.com/${repository}/releases/download/${encodeURIComponent(
|
||||
releaseTag,
|
||||
)}/${filename}`,
|
||||
};
|
||||
}
|
||||
return { format: 2, sourceCommit: commit, packages };
|
||||
}
|
||||
function publishBundles({
|
||||
directory,
|
||||
commit,
|
||||
repository,
|
||||
branch,
|
||||
version,
|
||||
buildId,
|
||||
gh = (...args) =>
|
||||
execFileSync('gh', args, {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}),
|
||||
}) {
|
||||
if (!['master', 'develop'].includes(branch))
|
||||
throw new Error('Only master or develop images publish bundles');
|
||||
if (!/^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.test(version || ''))
|
||||
throw new Error('Invalid bundle version');
|
||||
if (!/^\d+-\d+$/.test(buildId || ''))
|
||||
throw new Error('Invalid bundle build ID');
|
||||
// Rebuilding a version gets new download URLs, including when its commit is
|
||||
// unchanged. Only the version/channel pointer is overwritten, never bytes
|
||||
// that an in-flight download has already selected by hash.
|
||||
const development = branch === 'develop';
|
||||
const tag = `upgrade-build-${
|
||||
development ? 'develop-' : ''
|
||||
}v${version}-${buildId}`;
|
||||
const index = {
|
||||
...releaseIndex(directory, commit, repository, {
|
||||
version,
|
||||
releaseTag: tag,
|
||||
}),
|
||||
version,
|
||||
buildId,
|
||||
channel: branch,
|
||||
};
|
||||
const installer = fs.readFileSync(path.join(directory, 'install-bundle.cjs'));
|
||||
index.installer = {
|
||||
size: installer.length,
|
||||
sha256: sha256(installer),
|
||||
url: `https://github.com/${repository}/releases/download/${encodeURIComponent(
|
||||
tag,
|
||||
)}/install-bundle.cjs`,
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(directory, 'latest.json'),
|
||||
JSON.stringify(index) + '\n',
|
||||
);
|
||||
const lookup = (ref) => {
|
||||
try {
|
||||
return JSON.parse(
|
||||
gh(
|
||||
'api',
|
||||
`repos/${repository}/releases/tags/${encodeURIComponent(ref)}`,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (/\bHTTP 404\b/.test(String(error.stderr))) return null;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const notes =
|
||||
`Verified platform bundles for v${version}, source ${commit}, build ${buildId}. Existing Node runtime is retained.\n\n` +
|
||||
Object.entries(index.packages)
|
||||
.map(([target, entry]) => `- [${target}](${entry.url})`)
|
||||
.join('\n') +
|
||||
`\n- [Standalone offline installer](${index.installer.url})`;
|
||||
const ensureRelease = (ref, title) => {
|
||||
if (!lookup(ref))
|
||||
gh(
|
||||
'release',
|
||||
'create',
|
||||
ref,
|
||||
'--repo',
|
||||
repository,
|
||||
'--target',
|
||||
commit,
|
||||
'--prerelease',
|
||||
'--latest=false',
|
||||
'--title',
|
||||
title,
|
||||
'--notes',
|
||||
notes,
|
||||
);
|
||||
};
|
||||
const currentSource = () =>
|
||||
gh(
|
||||
'api',
|
||||
`repos/${repository}/git/ref/heads/${branch}`,
|
||||
'--jq',
|
||||
'.object.sha',
|
||||
).trim() === commit;
|
||||
if (!currentSource()) {
|
||||
console.log('Newer source exists; leaving version and channel unchanged');
|
||||
return false;
|
||||
}
|
||||
ensureRelease(tag, `Platform upgrade v${version} (${buildId})`);
|
||||
const assets = platforms
|
||||
.flatMap((target) => {
|
||||
const archive = path.join(
|
||||
directory,
|
||||
`qinglong-${commit}-${target}.tar.gz`,
|
||||
);
|
||||
return [archive, archive + '.json'];
|
||||
})
|
||||
.concat(
|
||||
path.join(directory, 'install-bundle.cjs'),
|
||||
path.join(directory, 'latest.json'),
|
||||
);
|
||||
const published = lookup(tag);
|
||||
const missing = [];
|
||||
const matches = (asset, file) => {
|
||||
const bytes = fs.readFileSync(file);
|
||||
return (
|
||||
asset?.size === bytes.length && asset.digest === `sha256:${sha256(bytes)}`
|
||||
);
|
||||
};
|
||||
for (const file of assets) {
|
||||
const previous = published.assets.find(
|
||||
(a) => a.name === path.basename(file),
|
||||
);
|
||||
if (!previous) missing.push(file);
|
||||
else if (!matches(previous, file))
|
||||
throw new Error(
|
||||
`Immutable release asset differs: ${path.basename(file)}`,
|
||||
);
|
||||
}
|
||||
if (missing.length)
|
||||
gh('release', 'upload', tag, ...missing, '--repo', repository);
|
||||
const uploaded = lookup(tag);
|
||||
if (
|
||||
!assets.every((file) =>
|
||||
matches(
|
||||
uploaded.assets.find((a) => a.name === path.basename(file)),
|
||||
file,
|
||||
),
|
||||
)
|
||||
)
|
||||
throw new Error('Published platform bundle verification failed');
|
||||
|
||||
// Each channel serializes its image builds and pointer writes as one release.
|
||||
// A rerun of an earlier workflow is allowed to replace the same
|
||||
// version/commit; an obsolete source commit must never advance either alias.
|
||||
const aliases = development
|
||||
? ['upgrade-develop']
|
||||
: [`upgrade-v${version}`, 'upgrade-master'];
|
||||
for (const ref of aliases) {
|
||||
if (!currentSource()) {
|
||||
console.log('Newer source exists; leaving version and channel unchanged');
|
||||
return false;
|
||||
}
|
||||
ensureRelease(
|
||||
ref,
|
||||
ref === `upgrade-${branch}`
|
||||
? `Upgrade channel ${branch}`
|
||||
: `Platform upgrade v${version}`,
|
||||
);
|
||||
gh(
|
||||
'release',
|
||||
'upload',
|
||||
ref,
|
||||
path.join(directory, 'latest.json'),
|
||||
'--repo',
|
||||
repository,
|
||||
'--clobber',
|
||||
);
|
||||
gh('release', 'edit', ref, '--repo', repository, '--notes', notes);
|
||||
if (
|
||||
!matches(
|
||||
lookup(ref)?.assets.find((a) => a.name === 'latest.json'),
|
||||
path.join(directory, 'latest.json'),
|
||||
)
|
||||
)
|
||||
throw new Error('Published channel index verification failed');
|
||||
}
|
||||
// Only prune development releases after the complete new batch and its
|
||||
// channel pointer are verified. Production versions and aliases are retained.
|
||||
if (development && currentSource()) {
|
||||
const pages = JSON.parse(
|
||||
gh(
|
||||
'api',
|
||||
`repos/${repository}/releases?per_page=100`,
|
||||
'--paginate',
|
||||
'--slurp',
|
||||
),
|
||||
);
|
||||
for (const release of pages.flat()) {
|
||||
if (
|
||||
release.tag_name !== tag &&
|
||||
/^upgrade-build-develop-v\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?-\d+-\d+$/.test(
|
||||
release.tag_name,
|
||||
) &&
|
||||
release.prerelease === true &&
|
||||
release.draft === false
|
||||
) {
|
||||
if (!currentSource()) break;
|
||||
gh(
|
||||
'release',
|
||||
'delete',
|
||||
release.tag_name,
|
||||
'--repo',
|
||||
repository,
|
||||
'--yes',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
module.exports = { releaseIndex, platforms, publishBundles };
|
||||
if (require.main === module) {
|
||||
const [directory, commit, repository, branch] = process.argv.slice(2);
|
||||
const version = require('../package.json').version;
|
||||
if (
|
||||
require('js-yaml').load(
|
||||
fs.readFileSync(path.join(__dirname, '../version.yaml'), 'utf8'),
|
||||
).version !== version
|
||||
)
|
||||
throw new Error('Package and image release versions differ');
|
||||
publishBundles({
|
||||
directory,
|
||||
commit,
|
||||
repository,
|
||||
branch,
|
||||
version,
|
||||
buildId: `${process.env.GITHUB_RUN_ID}-${process.env.GITHUB_RUN_ATTEMPT}`,
|
||||
});
|
||||
}
|
||||
@@ -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" \
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
#!/usr/bin/env node
|
||||
// Also published as a standalone, bundled installer for devices with Node only.
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { randomUUID } = require('node:crypto');
|
||||
const { promisify } = require('node:util');
|
||||
const execute = promisify(require('node:child_process').execFile);
|
||||
const { parseArgs } = require('node:util');
|
||||
const artifacts = require('../cli/src/internal/maintenance/upgradeArtifacts.cjs');
|
||||
const jobs = require('../cli/src/internal/maintenance/panelUpgrade.cjs');
|
||||
const {
|
||||
nodeRuntimeArgs,
|
||||
} = require('../cli/src/internal/maintenance/platformBundle.cjs');
|
||||
|
||||
async function runInstallerMain(argv = process.argv.slice(2)) {
|
||||
const flags = nodeRuntimeArgs();
|
||||
if (!flags.length || process.execArgv.includes('--jitless'))
|
||||
return installBundle(argv);
|
||||
const child = require('node:child_process').spawn(
|
||||
process.execPath,
|
||||
[...flags, __filename, ...argv],
|
||||
{ stdio: 'inherit', env: process.env },
|
||||
);
|
||||
const interrupt = () => child.kill('SIGINT');
|
||||
const terminate = () => child.kill('SIGTERM');
|
||||
process.once('SIGINT', interrupt);
|
||||
process.once('SIGTERM', terminate);
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
child.once('error', reject);
|
||||
child.once('exit', (code, signal) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`Offline installer exited: ${signal || code}`));
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
process.removeListener('SIGINT', interrupt);
|
||||
process.removeListener('SIGTERM', terminate);
|
||||
}
|
||||
}
|
||||
|
||||
async function installBundle(argv = process.argv.slice(2), env = process.env) {
|
||||
const { values } = parseArgs({
|
||||
args: argv,
|
||||
options: {
|
||||
root: { type: 'string' },
|
||||
file: { type: 'string' },
|
||||
'download-only': { type: 'boolean' },
|
||||
install: { type: 'boolean' },
|
||||
'repair-deps': { type: 'boolean' },
|
||||
},
|
||||
});
|
||||
if (!values.file || !(values.root || env.QL_DIR))
|
||||
throw new Error(
|
||||
'Usage: node install-bundle.cjs --root /path/to/qinglong --file /path/to/bundle.tar.gz [--download-only | --install] [--repair-deps]',
|
||||
);
|
||||
if (values.install && values['download-only'])
|
||||
throw new Error('Choose --install or --download-only');
|
||||
const root = path.resolve(values.root || env.QL_DIR);
|
||||
const existing = await fs.readdir(root).catch((error) => {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
return [];
|
||||
});
|
||||
if (values.install && existing.length)
|
||||
throw new Error('--install requires an empty installation directory');
|
||||
if (!values.install) await fs.access(path.join(root, 'shell/share.sh'));
|
||||
let environment = {
|
||||
...env,
|
||||
QL_DIR: root,
|
||||
QL_UPGRADE_FILE: path.resolve(values.file),
|
||||
};
|
||||
if (!values.install) {
|
||||
const captured = await execute(
|
||||
'bash',
|
||||
[
|
||||
'--noprofile',
|
||||
'--norc',
|
||||
'-c',
|
||||
'. "$1/shell/share.sh"\n. "$1/shell/env.sh"\nimport_config\nexport QlPort="${ql_port:-${QlPort:-5700}}" QlBaseUrl="${ql_base_url:-${QlBaseUrl:-/}}"\nenv -0',
|
||||
'ql-offline',
|
||||
root,
|
||||
],
|
||||
{ env: environment, maxBuffer: 1024 * 1024 },
|
||||
);
|
||||
const loaded = Object.fromEntries(
|
||||
captured.stdout.split('\0').flatMap((line) => {
|
||||
const match = /^([A-Za-z_][A-Za-z_0-9]*)=([\s\S]*)$/.exec(line);
|
||||
return match ? [[match[1], match[2]]] : [];
|
||||
}),
|
||||
);
|
||||
environment = {
|
||||
...environment,
|
||||
...loaded,
|
||||
QL_DIR: root,
|
||||
QL_UPGRADE_FILE: path.resolve(values.file),
|
||||
};
|
||||
}
|
||||
environment.QL_UPGRADE_REPAIR_DEPS = values['repair-deps'] ? '1' : '0';
|
||||
const freshWorkspace = values.install
|
||||
? await fs.mkdtemp(path.join(os.tmpdir(), 'ql-offline-install-'))
|
||||
: undefined;
|
||||
const tmp = freshWorkspace || path.join(root, '.tmp');
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort(new Error('离线升级已中断'));
|
||||
process.once('SIGINT', abort);
|
||||
process.once('SIGTERM', abort);
|
||||
try {
|
||||
const operation = () =>
|
||||
artifacts.withUpgradeLock(
|
||||
tmp,
|
||||
async () => {
|
||||
let state;
|
||||
const report = async (patch) => {
|
||||
if (patch.phase) console.log(patch.phase);
|
||||
if (state) {
|
||||
state = {
|
||||
...state,
|
||||
...patch,
|
||||
pid: process.pid,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
await jobs.saveState(tmp, state);
|
||||
}
|
||||
};
|
||||
if (!values.install) {
|
||||
const previous = await jobs.getStatus(tmp);
|
||||
if (['starting', 'running', 'restarting'].includes(previous.status))
|
||||
throw new Error('已有更新或重载正在执行。');
|
||||
state = {
|
||||
id: randomUUID(),
|
||||
action: values['download-only'] ? 'download' : 'apply',
|
||||
status: 'running',
|
||||
phase: 'verifying',
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
await report({});
|
||||
await fs.writeFile(
|
||||
path.join(tmp, 'panel-upgrade.log'),
|
||||
'Offline bundle verification\n',
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
const selected = await artifacts.stageUpgrade({
|
||||
root,
|
||||
tmp,
|
||||
env: environment,
|
||||
run: (program, args, capture) =>
|
||||
artifacts.runProcess(
|
||||
environment,
|
||||
program,
|
||||
args,
|
||||
capture,
|
||||
controller.signal,
|
||||
),
|
||||
signal: controller.signal,
|
||||
onProgress: report,
|
||||
install: async () => {
|
||||
throw new Error(
|
||||
'Offline bundles never install npm dependencies',
|
||||
);
|
||||
},
|
||||
});
|
||||
if (state)
|
||||
await report({
|
||||
status: 'ready',
|
||||
phase: 'ready',
|
||||
...(await jobs.stagedMetadata(selected)),
|
||||
});
|
||||
if (values['download-only']) {
|
||||
console.log('更新已就绪;执行 ql reload system 应用更新。');
|
||||
return selected;
|
||||
}
|
||||
if (values.install) {
|
||||
// Build beside the destination, then atomically replace only an empty root.
|
||||
await fs.mkdir(path.dirname(root), { recursive: true });
|
||||
const publication = await fs.mkdtemp(
|
||||
path.join(path.dirname(root), '.qinglong-install-'),
|
||||
);
|
||||
try {
|
||||
await fs.chmod(publication, 0o755);
|
||||
await fs.cp(selected.source, publication, {
|
||||
recursive: true,
|
||||
verbatimSymlinks: true,
|
||||
});
|
||||
await fs.cp(selected.static, path.join(publication, 'static'), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.copyFile(
|
||||
path.join(publication, '.env.example'),
|
||||
path.join(publication, '.env'),
|
||||
require('node:fs').constants.COPYFILE_EXCL,
|
||||
);
|
||||
await fs.chmod(path.join(publication, '.env'), 0o600);
|
||||
controller.signal.throwIfAborted();
|
||||
await fs.rename(publication, root);
|
||||
} finally {
|
||||
await fs.rm(publication, { recursive: true, force: true });
|
||||
}
|
||||
await fs.rm(path.dirname(selected.source), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
await fs.rm(
|
||||
path.join(
|
||||
tmp,
|
||||
`upgrade-ready-${artifacts.releaseBranch(environment)}.json`,
|
||||
),
|
||||
{ force: true },
|
||||
);
|
||||
console.log(
|
||||
`离线安装完成,保留现有 Node ${
|
||||
process.version
|
||||
}。启动服务:QL_DIR=${JSON.stringify(
|
||||
root,
|
||||
)} QL_DATA_DIR=${JSON.stringify(
|
||||
path.join(root, 'data'),
|
||||
)} bash ${JSON.stringify(
|
||||
path.join(root, 'shell/start-simplify.sh'),
|
||||
)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const result = await jobs.withPanelLifecycle(
|
||||
root,
|
||||
tmp,
|
||||
environment,
|
||||
async (hook, prepareSelection) => {
|
||||
const expected = await jobs.stagedMetadata(selected);
|
||||
const previous = await jobs
|
||||
.readRunningBuild(environment)
|
||||
.catch(() => undefined);
|
||||
const selection = await prepareSelection?.(selected);
|
||||
try {
|
||||
return await artifacts.reloadSystem({
|
||||
root,
|
||||
tmp,
|
||||
env: environment,
|
||||
data: environment.dir_data || path.join(root, 'data'),
|
||||
staticRoot: path.join(root, 'static'),
|
||||
config:
|
||||
environment.dir_config || path.join(root, 'data/config'),
|
||||
lifecycle: {
|
||||
signal: controller.signal,
|
||||
stop: async (recover) => {
|
||||
await report({
|
||||
status: 'restarting',
|
||||
phase: 'restarting',
|
||||
});
|
||||
await hook(
|
||||
'stop',
|
||||
[],
|
||||
recover ? undefined : controller.signal,
|
||||
);
|
||||
},
|
||||
start: async (recover) => {
|
||||
await hook(
|
||||
'start',
|
||||
[],
|
||||
recover ? undefined : controller.signal,
|
||||
);
|
||||
await artifacts.waitForHealth(
|
||||
environment,
|
||||
undefined,
|
||||
recover ? undefined : controller.signal,
|
||||
);
|
||||
if (!recover)
|
||||
await jobs.verifyRunningVersion(
|
||||
environment,
|
||||
expected,
|
||||
previous,
|
||||
);
|
||||
},
|
||||
},
|
||||
additionalReplacements: selection
|
||||
? [{ source: selection.source, target: selection.target }]
|
||||
: [],
|
||||
});
|
||||
} finally {
|
||||
if (selection)
|
||||
await fs.rm(selection.directory, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
controller.signal,
|
||||
);
|
||||
await report({ status: 'completed', phase: 'completed' });
|
||||
console.log('升级成功,已保留现有 Node 并运行更新后的版本。');
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (state) await report({ status: 'failed', error: error.message });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
environment,
|
||||
);
|
||||
return values.install
|
||||
? await operation()
|
||||
: await artifacts.withUpgradeLock(
|
||||
path.join(tmp, 'panel-job-launch'),
|
||||
operation,
|
||||
environment,
|
||||
);
|
||||
} finally {
|
||||
process.removeListener('SIGINT', abort);
|
||||
process.removeListener('SIGTERM', abort);
|
||||
if (freshWorkspace)
|
||||
await fs.rm(freshWorkspace, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { installBundle, runInstallerMain };
|
||||
if (require.main === module)
|
||||
runInstallerMain().catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createHash, randomUUID } = require('node:crypto');
|
||||
|
||||
function restorePackageLock(root = path.resolve(__dirname, '..')) {
|
||||
const copy = path.join(root, 'shell/upgrade-lock.yaml');
|
||||
const destination = path.join(root, 'pnpm-lock.yaml');
|
||||
if (!fs.existsSync(copy)) {
|
||||
// Source checkouts already have the canonical lock; no built artifacts yet.
|
||||
if (fs.existsSync(destination)) return;
|
||||
throw new Error('Missing npm package upgrade lockfile');
|
||||
}
|
||||
const bytes = fs.readFileSync(copy);
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(root, 'static/build-info.json'), 'utf8'),
|
||||
);
|
||||
if (
|
||||
manifest.dirty !== false ||
|
||||
manifest.lockfileSha256 !== createHash('sha256').update(bytes).digest('hex')
|
||||
)
|
||||
throw new Error('npm package lockfile does not match its build manifest');
|
||||
const temporary = path.join(root, `.upgrade-lock-${randomUUID()}`);
|
||||
try {
|
||||
fs.writeFileSync(temporary, bytes, { flag: 'wx', mode: 0o644 });
|
||||
fs.renameSync(temporary, destination);
|
||||
} finally {
|
||||
fs.rmSync(temporary, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function restoreEnvironment(root = path.resolve(__dirname, '..')) {
|
||||
// npm's Arborist keeps this exact retirement directory until lifecycle scripts
|
||||
// succeed, then deletes it. Never search other installations or sibling backups.
|
||||
const suffix = createHash('sha1').update(root).digest('base64')
|
||||
.replace(/[^a-zA-Z0-9]+/g, '').slice(0, 8);
|
||||
const retired = path.join(path.dirname(root), `.${path.basename(root)}-${suffix}`);
|
||||
const destination = path.join(root, '.env');
|
||||
const exists = (file) => {
|
||||
try { return fs.lstatSync(file); }
|
||||
catch (error) { if (error.code === 'ENOENT') return; throw error; }
|
||||
};
|
||||
if (exists(destination)) return;
|
||||
const directory = exists(retired);
|
||||
if (!directory) return;
|
||||
if (!directory.isDirectory()) throw new Error('Invalid npm retirement directory');
|
||||
const metadata = path.join(retired, 'package.json');
|
||||
if (!exists(metadata)?.isFile()) throw new Error('Invalid retired npm package');
|
||||
if (JSON.parse(fs.readFileSync(metadata, 'utf8')).name !== '@whyour/qinglong')
|
||||
throw new Error('Unexpected retired npm package');
|
||||
const source = path.join(retired, '.env');
|
||||
const stat = exists(source);
|
||||
if (!stat) return;
|
||||
if (!stat.isFile()) throw new Error('Retired npm .env must be a regular file');
|
||||
const descriptor = fs.openSync(source, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
||||
const temporary = path.join(root, `.upgrade-env-${randomUUID()}`);
|
||||
try {
|
||||
if (!fs.fstatSync(descriptor).isFile()) throw new Error('Invalid retired npm .env');
|
||||
fs.writeFileSync(temporary, fs.readFileSync(descriptor), { flag: 'wx', mode: 0o600 });
|
||||
// Linking publishes a complete file without replacing a concurrently created .env.
|
||||
try { fs.linkSync(temporary, destination); }
|
||||
catch (error) { if (error.code !== 'EEXIST') throw error; }
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
fs.rmSync(temporary, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function installPackage(root = path.resolve(__dirname, '..')) {
|
||||
restorePackageLock(root);
|
||||
restoreEnvironment(root);
|
||||
}
|
||||
|
||||
module.exports = { restorePackageLock, restoreEnvironment, installPackage };
|
||||
if (require.main === module) installPackage();
|
||||
@@ -108,4 +108,42 @@ declare -gA LANG_MESSAGES=(
|
||||
['暂不支持此系统部署 %s']='Unsupported system for deployment: %s'
|
||||
# update.sh
|
||||
['命令输入错误...\n']='Invalid command...\n'
|
||||
['%s 删除任务%s']='%s removed tasks%s'
|
||||
['%s 新增任务']='%s added tasks'
|
||||
['新增任务通知']='New task notification'
|
||||
['失效']='obsolete'
|
||||
['新']='new'
|
||||
['%s 命令使用方法:']='Usage: %s'
|
||||
['更新并重启青龙']='Update and restart QingLong'
|
||||
['运行自定义脚本']='Run custom scripts'
|
||||
['更新单个脚本文件']='Update a script'
|
||||
['更新单个仓库的脚本']='Update scripts from a repository'
|
||||
['删除旧日志']='Delete old logs'
|
||||
['启动tg-bot']='Start tg-bot'
|
||||
['检测青龙环境并修复']='Check and repair the QingLong environment'
|
||||
['查看青龙运行日志']='View QingLong service logs'
|
||||
['重置登录错误次数']='Reset failed login attempts'
|
||||
['禁用两步登录']='Disable two-factor login'
|
||||
['修改登录密码']='Change login password'
|
||||
['修改登录用户名']='Change login username'
|
||||
['禁用两步验证']='Disable two-factor authentication'
|
||||
['重置密码']='Reset password'
|
||||
['重置用户名']='Reset username'
|
||||
[', 任务状态更新失败(%s)']=', task status update failed (%s)'
|
||||
[', 状态更新失败(%s)']=', status update failed (%s)'
|
||||
['<未识别出活动名称>']='<Unrecognized task name>'
|
||||
['task命令运行本程序自动添加进crontab的脚本,需要输入脚本的绝对路径或去掉 “%s/” 目录后的相对路径(定时任务中请写作相对路径),用法为:']='task runs scripts registered in crontab. Use an absolute path or a path relative to %s/ (use relative paths in scheduled tasks). Usage:'
|
||||
['1.%s <file_name> # 依次执行,如果设置了随机延迟,将随机延迟一定秒数']='1.%s <file_name> # Run sequentially, with the configured random delay'
|
||||
['2.%s <file_name> now # 依次执行,无论是否设置了随机延迟,均立即运行,前台会输出日志,同时记录在日志文件中']='2.%s <file_name> now # Run immediately, displaying and saving logs'
|
||||
['3.%s <file_name> conc <环境变量名称> <账号编号,空格分隔>(可选的) # 并发执行,无论是否设置了随机延迟,均立即运行,前台不产生日志,直接记录在日志文件中,且可指定账号执行']='3.%s <file_name> conc <variable> [account-numbers...] # Run selected accounts concurrently, saving logs'
|
||||
['4.%s <file_name> desi <环境变量名称> <账号编号,空格分隔> # 指定账号执行,无论是否设置了随机延迟,均立即运行']='4.%s <file_name> desi <variable> <account-numbers...> # Run selected accounts immediately'
|
||||
['\n当前有以下脚本可以运行:']='\nAvailable scripts:'
|
||||
['\n暂无脚本可以执行']='\nNo scripts available'
|
||||
['🚀 1. 检测配置文件...']='🚀 1. Checking configuration...'
|
||||
['🔄 2. 启动 nginx...']='🔄 2. Starting nginx...'
|
||||
['⚙️ 3. 启动 pm2 服务...']='⚙️ 3. Starting pm2 services...'
|
||||
['🤖 4. 启动 bot...']='🤖 4. Starting bot...'
|
||||
['🛠️ 5. 执行自定义脚本...']='🛠️ 5. Running custom scripts...'
|
||||
['🎉 启动成功!']='🎉 Started successfully!'
|
||||
['\n## 执行结束... %s 耗时 %s 秒']='\n## Execution finished... %s elapsed %s seconds'
|
||||
)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
// Run in new processes: a healthy parent can outlive a broken native runtime.
|
||||
function probeNodeRuntime(executable = process.execPath) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-node-url-'));
|
||||
const source = `
|
||||
const assert = require('node:assert/strict');
|
||||
const { pathToFileURL, fileURLToPath } = require('node:url');
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
assert.equal(new URL('/任务?q=' + i, 'https://example.com').hostname, 'example.com');
|
||||
assert.equal(new URL('https://例子.测试').hostname, 'xn--fsqu00a.xn--0zwm56d');
|
||||
assert.equal(new URL('http://[::1]:5700').port, '5700');
|
||||
const filename = '/tmp/中文 path/' + i + '.cjs';
|
||||
assert.equal(fileURLToPath(pathToFileURL(filename)), filename);
|
||||
}
|
||||
console.log('QINGLONG_NODE_URL_OK');
|
||||
`;
|
||||
const file = path.join(directory, 'probe.cjs');
|
||||
fs.writeFileSync(file, source);
|
||||
try {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const args = process.execArgv.includes('--jitless') ? ['--jitless'] : [];
|
||||
args.push(...(i % 2 ? [file] : ['-e', source]));
|
||||
const child = spawnSync(executable, args, {
|
||||
encoding: 'utf8',
|
||||
// Multi-architecture CI runs this same probe through QEMU.
|
||||
timeout: 120000,
|
||||
maxBuffer: 65536,
|
||||
env: { ...process.env, NODE_OPTIONS: '' },
|
||||
});
|
||||
assert.equal(
|
||||
child.status,
|
||||
0,
|
||||
`Node URL runtime probe failed (${i % 2 ? 'file' : 'eval'}): signal=${
|
||||
child.signal
|
||||
}; status=${child.status}; ${child.error?.message || child.stderr}`,
|
||||
);
|
||||
assert.match(child.stdout, /QINGLONG_NODE_URL_OK/);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
node: process.version,
|
||||
architecture: process.arch,
|
||||
ada: process.versions.ada,
|
||||
processes: 4,
|
||||
urlIterationsPerProcess: 1000,
|
||||
nodeSha256: createHash('sha256')
|
||||
.update(fs.readFileSync(executable))
|
||||
.digest('hex'),
|
||||
};
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { probeNodeRuntime };
|
||||
if (require.main === module) console.log(JSON.stringify(probeNodeRuntime()));
|
||||
+38
-36
@@ -39,44 +39,44 @@ random_delay() {
|
||||
gen_array_scripts() {
|
||||
local dir_current=$(pwd)
|
||||
local i="-1"
|
||||
cd $dir_scripts
|
||||
for file in $(ls); do
|
||||
cd "$dir_scripts" || return $?
|
||||
for file in *; do
|
||||
if [[ -f $file ]] && [[ $file == *.js && $file != sendNotify.js ]]; then
|
||||
let i++
|
||||
array_scripts[i]=$(echo "$file" | perl -pe "s|$dir_scripts/||g")
|
||||
array_scripts_name[i]=$(grep "new Env" $file | awk -F "\(" '{print $2}' | awk -F "\)" '{print $1}' | sed 's:.*\('\''\|"\)\([^"'\'']*\)\('\''\|"\).*:\2:' | sed 's:"::g' | sed "s:'::g" | head -1)
|
||||
[[ -z ${array_scripts_name[i]} ]] && array_scripts_name[i]="<未识别出活动名称>"
|
||||
array_scripts[i]=$file
|
||||
array_scripts_name[i]=$(grep -- "new Env" "$file" | awk -F '[(]' '{print $2}' | awk -F '[)]' '{print $1}' | sed 's:.*\('\''\|"\)\([^"'\'']*\)\('\''\|"\).*:\2:' | sed 's:"::g' | sed "s:'::g" | head -1)
|
||||
[[ -z ${array_scripts_name[i]} ]] && array_scripts_name[i]="$(t '<未识别出活动名称>')"
|
||||
fi
|
||||
done
|
||||
cd $dir_current
|
||||
cd "$dir_current" || return $?
|
||||
}
|
||||
|
||||
## 使用说明
|
||||
usage() {
|
||||
gen_array_scripts
|
||||
echo -e "task命令运行本程序自动添加进crontab的脚本,需要输入脚本的绝对路径或去掉 “$dir_scripts/” 目录后的相对路径(定时任务中请写作相对路径),用法为:"
|
||||
echo -e "1.$cmd_task <file_name> # 依次执行,如果设置了随机延迟,将随机延迟一定秒数"
|
||||
echo -e "2.$cmd_task <file_name> now # 依次执行,无论是否设置了随机延迟,均立即运行,前台会输出日志,同时记录在日志文件中"
|
||||
echo -e "3.$cmd_task <file_name> conc <环境变量名称> <账号编号,空格分隔>(可选的) # 并发执行,无论是否设置了随机延迟,均立即运行,前台不产生日志,直接记录在日志文件中,且可指定账号执行"
|
||||
echo -e "4.$cmd_task <file_name> desi <环境变量名称> <账号编号,空格分隔> # 指定账号执行,无论是否设置了随机延迟,均立即运行"
|
||||
t 'task命令运行本程序自动添加进crontab的脚本,需要输入脚本的绝对路径或去掉 “%s/” 目录后的相对路径(定时任务中请写作相对路径),用法为:' "$dir_scripts"
|
||||
t '1.%s <file_name> # 依次执行,如果设置了随机延迟,将随机延迟一定秒数' "$cmd_task"
|
||||
t '2.%s <file_name> now # 依次执行,无论是否设置了随机延迟,均立即运行,前台会输出日志,同时记录在日志文件中' "$cmd_task"
|
||||
t '3.%s <file_name> conc <环境变量名称> <账号编号,空格分隔>(可选的) # 并发执行,无论是否设置了随机延迟,均立即运行,前台不产生日志,直接记录在日志文件中,且可指定账号执行' "$cmd_task"
|
||||
t '4.%s <file_name> desi <环境变量名称> <账号编号,空格分隔> # 指定账号执行,无论是否设置了随机延迟,均立即运行' "$cmd_task"
|
||||
if [[ ${#array_scripts[*]} -gt 0 ]]; then
|
||||
echo -e "\n当前有以下脚本可以运行:"
|
||||
t '\n当前有以下脚本可以运行:'
|
||||
for ((i = 0; i < ${#array_scripts[*]}; i++)); do
|
||||
echo -e "$(($i + 1)). ${array_scripts_name[i]}:${array_scripts[i]}"
|
||||
done
|
||||
else
|
||||
echo -e "\n暂无脚本可以执行"
|
||||
t '\n暂无脚本可以执行'
|
||||
fi
|
||||
}
|
||||
|
||||
## run nohup,$1:文件名,不含路径,带后缀
|
||||
run_nohup() {
|
||||
local file_name=$1
|
||||
nohup node $file_name &>$log_path &
|
||||
nohup node "$file_name" &>"$log_path" &
|
||||
}
|
||||
|
||||
env_str_to_array() {
|
||||
. $file_env
|
||||
. "$file_env"
|
||||
local IFS="&"
|
||||
read -ra array <<<"${!env_param}"
|
||||
array_length=${#array[@]}
|
||||
@@ -122,7 +122,7 @@ enter_script_workdir() {
|
||||
_target_dir="${dir_scripts}/${work_dir}"
|
||||
fi
|
||||
if [[ -d "${_target_dir}" ]]; then
|
||||
cd "${_target_dir}"
|
||||
cd "${_target_dir}" || return $?
|
||||
if [[ ${file_param} =~ "/" ]]; then
|
||||
local script_name="${file_param##*/}"
|
||||
if [[ "${use_dot_prefix}" == "true" ]]; then
|
||||
@@ -136,13 +136,13 @@ enter_script_workdir() {
|
||||
t '警告:工作目录不存在 %s' "${_target_dir}"
|
||||
fi
|
||||
|
||||
cd $dir_scripts
|
||||
cd "$dir_scripts" || return $?
|
||||
if [[ ${file_param} =~ "/" ]]; then
|
||||
local script_dir="${file_param%/*}"
|
||||
local script_name="${file_param##*/}"
|
||||
|
||||
if [[ -d ${script_dir} ]]; then
|
||||
cd ${script_dir}
|
||||
cd "$script_dir" || return $?
|
||||
if [[ "${use_dot_prefix}" == "true" ]]; then
|
||||
file_param="./${script_name}"
|
||||
else
|
||||
@@ -159,12 +159,12 @@ run_normal() {
|
||||
random_delay "$file_param"
|
||||
fi
|
||||
|
||||
enter_script_workdir
|
||||
enter_script_workdir || return $?
|
||||
|
||||
if [[ $isJsOrPythonFile == 'false' ]]; then
|
||||
clear_non_sh_env
|
||||
fi
|
||||
run_task_command $which_program $file_param "${script_params[@]}"
|
||||
run_task_command ${which_program:+"$which_program"} "$file_param" "${script_params[@]}"
|
||||
}
|
||||
|
||||
handle_env_split() {
|
||||
@@ -175,7 +175,7 @@ handle_env_split() {
|
||||
env_str_to_array
|
||||
local tempArr=$(echo $num_param | sed "s/-max/-${array_length}/g" | sed "s/max-/${array_length}-/g" | perl -pe "s|(\d+)(-\|~\|_)(\d+)|{\1..\3}|g")
|
||||
local runArr=($(eval echo $tempArr))
|
||||
array_run=($(awk -v RS=' ' '!a[$1]++' <<<${runArr[@]}))
|
||||
array_run=($(awk -v RS=' ' '!a[$1]++' <<<"${runArr[*]}"))
|
||||
}
|
||||
|
||||
## 并发执行时,设定的 RandomDelay 不会生效,即所有任务立即执行
|
||||
@@ -192,10 +192,10 @@ run_concurrent() {
|
||||
time=$(date "+$mtime_format")
|
||||
single_log_time=$(format_log_time "$mtime_format" "$time")
|
||||
|
||||
enter_script_workdir
|
||||
enter_script_workdir || return $?
|
||||
|
||||
local j=0
|
||||
for i in ${array_run[@]}; do
|
||||
for i in "${array_run[@]}"; do
|
||||
single_log_path="$dir_log/$log_dir/${single_log_time}_$((j + 1)).log"
|
||||
let j++
|
||||
|
||||
@@ -203,16 +203,16 @@ run_concurrent() {
|
||||
export "${env_param}=${array[$i - 1]}"
|
||||
clear_non_sh_env
|
||||
fi
|
||||
eval envParam="${env_param}" numParam="${i}" run_task_command $which_program $file_param "${script_params[@]}" &>$single_log_path &
|
||||
envParam="${env_param}" numParam="${i}" run_task_command ${which_program:+"$which_program"} "$file_param" "${script_params[@]}" &>"$single_log_path" &
|
||||
done
|
||||
|
||||
wait
|
||||
local k=0
|
||||
for i in ${array_run[@]}; do
|
||||
for i in "${array_run[@]}"; do
|
||||
single_log_path="$dir_log/$log_dir/${single_log_time}_$((k + 1)).log"
|
||||
let k++
|
||||
cat $single_log_path
|
||||
[[ -f $single_log_path ]] && rm -f $single_log_path
|
||||
cat "$single_log_path"
|
||||
[[ -f $single_log_path ]] && rm -f "$single_log_path"
|
||||
done
|
||||
}
|
||||
|
||||
@@ -229,7 +229,7 @@ run_designated() {
|
||||
|
||||
if [[ $isJsOrPythonFile == 'false' ]]; then
|
||||
local n=0
|
||||
for i in ${array_run[@]}; do
|
||||
for i in "${array_run[@]}"; do
|
||||
array_str[n]=${array[$i - 1]}
|
||||
let n++
|
||||
done
|
||||
@@ -241,9 +241,9 @@ run_designated() {
|
||||
clear_non_sh_env
|
||||
fi
|
||||
|
||||
enter_script_workdir
|
||||
enter_script_workdir || return $?
|
||||
|
||||
envParam="${env_param}" numParam="${num_param}" run_task_command $which_program $file_param "${script_params[@]}"
|
||||
envParam="${env_param}" numParam="${num_param}" run_task_command ${which_program:+"$which_program"} "$file_param" "${script_params[@]}"
|
||||
}
|
||||
|
||||
## 运行其他命令
|
||||
@@ -273,7 +273,7 @@ run_else() {
|
||||
if [[ -n "$script_for_dir" ]]; then
|
||||
local saved_file_param="$file_param"
|
||||
file_param="$script_for_dir"
|
||||
enter_script_workdir true
|
||||
enter_script_workdir true || return $?
|
||||
local adjusted_script="$file_param"
|
||||
file_param="$saved_file_param"
|
||||
|
||||
@@ -289,17 +289,17 @@ run_else() {
|
||||
set -- "${new_args[@]}"
|
||||
else
|
||||
# 没有找到脚本参数,只 cd 到 scripts 目录
|
||||
enter_script_workdir true
|
||||
enter_script_workdir true || return $?
|
||||
shift
|
||||
fi
|
||||
else
|
||||
# file_param 本身就是脚本,直接用 enter_script_workdir 处理
|
||||
enter_script_workdir true
|
||||
enter_script_workdir true || return $?
|
||||
shift
|
||||
fi
|
||||
|
||||
clear_non_sh_env
|
||||
run_task_command $which_program $file_param "$@"
|
||||
run_task_command ${which_program:+"$which_program"} "$file_param" "$@"
|
||||
}
|
||||
|
||||
check_file() {
|
||||
@@ -313,12 +313,14 @@ check_file() {
|
||||
export PREV_NODE_OPTIONS="${NODE_OPTIONS:=}"
|
||||
export PREV_PYTHONPATH="${PYTHONPATH:=}"
|
||||
if [[ $1 == *.js ]] || [[ $1 == *.ts ]] || [[ $1 == *.mjs ]]; then
|
||||
export NODE_OPTIONS="-r ${file_preload_js} ${NODE_OPTIONS}"
|
||||
local preload_path=${file_preload_js//\\/\\\\}
|
||||
preload_path=${preload_path//\"/\\\"}
|
||||
export NODE_OPTIONS="-r \"${preload_path}\" ${NODE_OPTIONS}"
|
||||
else
|
||||
export PYTHONPATH="${dir_preload}:${dir_config}:${PYTHONPATH}"
|
||||
fi
|
||||
else
|
||||
. $file_env
|
||||
. "$file_env"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ class GrpcClient {
|
||||
'updateEnvNames',
|
||||
'getEnvById',
|
||||
'systemNotify',
|
||||
'recordNotifyLog',
|
||||
'getCronDetail',
|
||||
'createCron',
|
||||
'updateCron',
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
|
||||
// A reporting failure must never terminate a script or change its send result.
|
||||
module.exports = async function reportNotificationAudit(payload) {
|
||||
let client;
|
||||
try {
|
||||
if (!process.env.QL_DIR) return;
|
||||
const grpc = require('@grpc/grpc-js');
|
||||
const loader = require('@grpc/proto-loader');
|
||||
const certDir = path.join(
|
||||
process.env.QL_DATA_DIR || path.join(process.env.QL_DIR, 'data'),
|
||||
'config/grpc',
|
||||
);
|
||||
const credentials = grpc.credentials.createSsl(
|
||||
fs.readFileSync(path.join(certDir, 'ca.crt')),
|
||||
fs.readFileSync(path.join(certDir, 'client.key')),
|
||||
fs.readFileSync(path.join(certDir, 'client.crt')),
|
||||
);
|
||||
const definition = loader.loadSync(
|
||||
path.join(process.env.QL_DIR, 'back/protos/api.proto'),
|
||||
{ keepCase: true, defaults: true },
|
||||
);
|
||||
const Api = grpc.loadPackageDefinition(definition).com.ql.api.Api;
|
||||
client = new Api(
|
||||
`localhost:${process.env.GRPC_PORT || '5500'}`,
|
||||
credentials,
|
||||
{ 'grpc.enable_http_proxy': 0 },
|
||||
);
|
||||
// Python sends all channel results together so they share startup and TLS.
|
||||
const records = Array.isArray(payload) ? payload : [payload];
|
||||
const deadline = new Date(Date.now() + 5000);
|
||||
await Promise.all(
|
||||
records.map(
|
||||
(record) =>
|
||||
new Promise((resolve) => {
|
||||
client.RecordNotifyLog(
|
||||
record,
|
||||
new grpc.Metadata(),
|
||||
{ deadline },
|
||||
() => resolve(),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
// Missing certificates, old panels and offline services are best effort.
|
||||
} finally {
|
||||
if (client) client.close();
|
||||
}
|
||||
};
|
||||
@@ -82,7 +82,7 @@ function run() {
|
||||
].filter(Boolean);
|
||||
|
||||
if (task_before) {
|
||||
console.log('执行前置命令\n');
|
||||
console.log(process.env.QL_LANG === 'en' ? 'Executing before command\n' : '执行前置命令\n');
|
||||
}
|
||||
|
||||
const res = execSync(commands.join(' && '), {
|
||||
@@ -122,7 +122,7 @@ function run() {
|
||||
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')) {
|
||||
@@ -131,7 +131,7 @@ function run() {
|
||||
// environment variable is too large
|
||||
}
|
||||
if (task_before) {
|
||||
console.log('执行前置命令结束\n');
|
||||
console.log(process.env.QL_LANG === 'en' ? 'Before command finished\n' : '执行前置命令结束\n');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ def run():
|
||||
if task_before:
|
||||
escaped_task_before = task_before.replace('"', '\\"').replace("$", "\\$")
|
||||
commands.append(f"eval '{escaped_task_before}'")
|
||||
print("执行前置命令\n")
|
||||
print("Executing before command\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令\n")
|
||||
|
||||
commands.append(f"echo -e '{split_str}'")
|
||||
|
||||
@@ -88,12 +88,12 @@ def run():
|
||||
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:
|
||||
@@ -101,11 +101,11 @@ def run():
|
||||
# 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
|
||||
|
||||
|
||||
+8
-2
@@ -1,4 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# BSD grep accepts \d as a digit; GNU grep treats it as a literal d.
|
||||
string=$(awk '$1 == "version:" { print $2; exit }' version.yaml) || exit 1
|
||||
if [[ ! $string =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[[:alnum:].-]+)?(\+[[:alnum:].-]+)?$ ]]; then
|
||||
printf 'Invalid release version: %s\n' "$string" >&2
|
||||
exit 1
|
||||
fi
|
||||
version="v$string"
|
||||
echo -e "开始发布"
|
||||
|
||||
echo -e "切换master分支"
|
||||
@@ -9,8 +17,6 @@ git push --set-upstream origin master -f
|
||||
echo -e "更新cdn文件"
|
||||
ts-node-transpile-only sample/tool.ts
|
||||
|
||||
string=$(cat version.yaml | grep "version" | egrep "[^ ]*" -o | egrep "\d\.*")
|
||||
version="v$string"
|
||||
echo -e "当前版本$version"
|
||||
|
||||
echo -e "删除已经存在的本地tag"
|
||||
|
||||
+21
-16
@@ -3,42 +3,47 @@
|
||||
days=$1
|
||||
|
||||
remove_js_log() {
|
||||
local log_full_path_list=$(find $dir_log -name "*.log")
|
||||
local diff_time
|
||||
for log in $log_full_path_list; do
|
||||
local log_date=$(echo $log | awk -F "/" '{print $NF}' | cut -c1-10)
|
||||
local log log_date log_timestamp diff_time log_path encoded_path result
|
||||
local now
|
||||
now=$(date +%s) || return $?
|
||||
while IFS= read -r -d '' log; do
|
||||
log_date=${log##*/}
|
||||
log_date=${log_date:0:10}
|
||||
if ! [[ $log_date =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
|
||||
if [[ $is_macos -eq 1 ]]; then
|
||||
log_date=$(stat -f %Sm -t "%Y-%m-%d" "$log")
|
||||
log_date=$(stat -f %Sm -t "%Y-%m-%d" "$log") || continue
|
||||
else
|
||||
log_date=$(stat -c %y "$log" | cut -d ' ' -f 1)
|
||||
fi
|
||||
fi
|
||||
if [[ $is_macos -eq 1 ]]; then
|
||||
diff_time=$(($(date +%s) - $(date -j -f "%Y-%m-%d" "$log_date" +%s)))
|
||||
# BSD date otherwise fills the omitted time from the current clock.
|
||||
log_timestamp=$(date -j -f "%Y-%m-%d %H:%M:%S" "$log_date 00:00:00" +%s) || continue
|
||||
else
|
||||
diff_time=$(($(date +%s) - $(date +%s -d "$log_date")))
|
||||
log_timestamp=$(date +%s -d "$log_date") || continue
|
||||
fi
|
||||
diff_time=$((now - log_timestamp))
|
||||
if [[ $diff_time -gt $((${days} * 86400)) ]]; then
|
||||
local log_path=$(echo "$log" | sed "s,${dir_log}/,,g")
|
||||
local result=$(find_cron_api "log_path=$log_path")
|
||||
log_path=${log#"$dir_log"/}
|
||||
encoded_path=$(printf '%s' "$log_path" | jq -sRr @uri) || continue
|
||||
result=$(find_cron_api "log_path=$encoded_path")
|
||||
t '查询文件 %s' "$log_path"
|
||||
if [[ -z $result ]]; then
|
||||
t '删除中~'
|
||||
rm -vf $log
|
||||
rm -vf -- "$log"
|
||||
else
|
||||
t '正在被 %s 使用,跳过~' "$result"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
done < <(find "$dir_log" -type f -name "*.log" -print0)
|
||||
}
|
||||
|
||||
remove_empty_dir() {
|
||||
cd $dir_log
|
||||
for dir in $(ls); do
|
||||
if [[ -d $dir ]] && [[ -z $(ls $dir) ]]; then
|
||||
rm -rf $dir
|
||||
fi
|
||||
local dir
|
||||
for dir in "$dir_log"/*/ "$dir_log"/.[!.]*/ "$dir_log"/..?*/; do
|
||||
[[ -d "$dir" ]] || continue
|
||||
# rmdir also protects a directory populated between discovery and removal.
|
||||
rmdir -- "$dir" 2>/dev/null || true
|
||||
done
|
||||
}
|
||||
|
||||
|
||||
+61
-44
@@ -77,16 +77,19 @@ load_ql_envs() {
|
||||
}
|
||||
|
||||
import_config() {
|
||||
[[ -f $file_config_user ]] && . $file_config_user
|
||||
[[ -f $dir_preload/lang_env.sh ]] && . $dir_preload/lang_env.sh
|
||||
[[ -f $file_config_user ]] && . "$file_config_user"
|
||||
[[ -f $dir_preload/lang_env.sh ]] && . "$dir_preload/lang_env.sh"
|
||||
|
||||
# 加载语言包(bash 4+ 支持 declare -A,不兼容时回退输出中文 key)
|
||||
# Language packs need global associative arrays (Bash 4.2+). Probe the
|
||||
# capability so Bash 3.2 and 4.0/4.1 retain the Chinese-key fallback.
|
||||
local lang=${QL_LANG:-zh}
|
||||
local lang_file="$dir_shell/lang/${lang}.sh"
|
||||
if [[ ${BASH_VERSINFO[0]} -ge 4 ]] && [[ -f $lang_file ]]; then
|
||||
. $lang_file
|
||||
elif [[ ${BASH_VERSINFO[0]} -ge 4 ]]; then
|
||||
. "$dir_shell/lang/zh.sh"
|
||||
if (declare -gA __ql_lang_probe) 2>/dev/null; then
|
||||
if [[ -f $lang_file ]]; then
|
||||
. "$lang_file"
|
||||
elif [[ -f "$dir_shell/lang/zh.sh" ]]; then
|
||||
. "$dir_shell/lang/zh.sh"
|
||||
fi
|
||||
fi
|
||||
|
||||
load_ql_envs
|
||||
@@ -132,7 +135,7 @@ unset_proxy() {
|
||||
make_dir() {
|
||||
local dir=$1
|
||||
if [[ ! -d $dir ]]; then
|
||||
mkdir -p $dir
|
||||
mkdir -p -- "$dir"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -176,57 +179,57 @@ define_cmd() {
|
||||
}
|
||||
|
||||
fix_config() {
|
||||
make_dir $dir_tmp
|
||||
make_dir $dir_static
|
||||
make_dir $dir_data
|
||||
make_dir $dir_config
|
||||
make_dir $dir_log
|
||||
make_dir $dir_db
|
||||
make_dir $dir_scripts
|
||||
make_dir $dir_list_tmp
|
||||
make_dir $dir_repo
|
||||
make_dir $dir_raw
|
||||
make_dir $dir_update_log
|
||||
make_dir $dir_dep
|
||||
make_dir "$dir_tmp"
|
||||
make_dir "$dir_static"
|
||||
make_dir "$dir_data"
|
||||
make_dir "$dir_config"
|
||||
make_dir "$dir_log"
|
||||
make_dir "$dir_db"
|
||||
make_dir "$dir_scripts"
|
||||
make_dir "$dir_list_tmp"
|
||||
make_dir "$dir_repo"
|
||||
make_dir "$dir_raw"
|
||||
make_dir "$dir_update_log"
|
||||
make_dir "$dir_dep"
|
||||
|
||||
if [[ ! -s $file_config_user ]]; then
|
||||
cp -f $file_config_sample $file_config_user
|
||||
cp -f "$file_config_sample" "$file_config_user"
|
||||
fi
|
||||
|
||||
if [[ ! -f $file_task_before ]]; then
|
||||
cp -f $file_task_sample $file_task_before
|
||||
cp -f "$file_task_sample" "$file_task_before"
|
||||
fi
|
||||
|
||||
if [[ ! -f $file_task_after ]]; then
|
||||
cp -f $file_task_sample $file_task_after
|
||||
cp -f "$file_task_sample" "$file_task_after"
|
||||
fi
|
||||
|
||||
if [[ ! -f $file_extra_shell ]]; then
|
||||
cp -f $file_extra_sample $file_extra_shell
|
||||
cp -f "$file_extra_sample" "$file_extra_shell"
|
||||
fi
|
||||
|
||||
if [[ ! -s $file_notify_py ]]; then
|
||||
cp -f $file_notify_py_sample $file_notify_py
|
||||
cp -f "$file_notify_py_sample" "$file_notify_py"
|
||||
fi
|
||||
|
||||
if [[ ! -s $file_notify_js ]]; then
|
||||
cp -f $file_notify_js_sample $file_notify_js
|
||||
cp -f "$file_notify_js_sample" "$file_notify_js"
|
||||
fi
|
||||
|
||||
if [[ ! -s $file_test_js ]]; then
|
||||
cp -f $file_test_js_sample $file_test_js
|
||||
cp -f "$file_test_js_sample" "$file_test_js"
|
||||
fi
|
||||
|
||||
if [[ ! -s $file_test_py ]]; then
|
||||
cp -f $file_test_py_sample $file_test_py
|
||||
cp -f "$file_test_py_sample" "$file_test_py"
|
||||
fi
|
||||
|
||||
if [[ ! -s $dep_notify_js ]]; then
|
||||
cp -f $file_notify_js_sample $dep_notify_js
|
||||
cp -f "$file_notify_js_sample" "$dep_notify_js"
|
||||
fi
|
||||
|
||||
if [[ ! -s $dep_notify_py ]]; then
|
||||
cp -f $file_notify_py_sample $dep_notify_py
|
||||
cp -f "$file_notify_py_sample" "$dep_notify_py"
|
||||
fi
|
||||
|
||||
}
|
||||
@@ -246,17 +249,17 @@ npm_install_2() {
|
||||
local dir_current=$(pwd)
|
||||
local dir_work=$1
|
||||
|
||||
cd $dir_work
|
||||
cd "$dir_work" || return $?
|
||||
t '安装 %s 依赖包...\n' "$dir_work"
|
||||
npm_install_sub
|
||||
cd $dir_current
|
||||
cd "$dir_current" || return $?
|
||||
}
|
||||
|
||||
diff_and_copy() {
|
||||
local copy_source=$1
|
||||
local copy_to=$2
|
||||
if [[ ! -s $copy_to ]] || [[ $(diff $copy_source $copy_to) ]]; then
|
||||
cp -f $copy_source $copy_to
|
||||
if [[ ! -s $copy_to ]] || [[ $(diff "$copy_source" "$copy_to") ]]; then
|
||||
cp -f "$copy_source" "$copy_to"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -265,12 +268,13 @@ git_clone_scripts() {
|
||||
local dir="$2"
|
||||
local branch="$3"
|
||||
local proxy="$4"
|
||||
[[ $branch ]] && local part_cmd="-b $branch "
|
||||
local branch_args=()
|
||||
[[ $branch ]] && branch_args=(-b "$branch")
|
||||
t '开始拉取仓库 %s 到 %s\n' "${uniq_path}" "$dir"
|
||||
|
||||
set_proxy "$proxy"
|
||||
|
||||
git clone -q --depth=1 $part_cmd $url $dir
|
||||
git clone -q --depth=1 "${branch_args[@]}" "$url" "$dir"
|
||||
exit_status=$?
|
||||
|
||||
unset_proxy
|
||||
@@ -283,15 +287,27 @@ random_range() {
|
||||
}
|
||||
|
||||
delete_pm2() {
|
||||
cd $dir_root
|
||||
cd "$dir_root" || return $?
|
||||
# Try to delete PM2 processes, but don't fail if PM2 is not available
|
||||
pm2 delete ecosystem.config.js 2>/dev/null || true
|
||||
# Also try to kill any directly spawned node processes
|
||||
pkill -f "node.*static/build/app.js" 2>/dev/null || true
|
||||
}
|
||||
|
||||
load_node_runtime_args() {
|
||||
ql_node_runtime_args=()
|
||||
local runtime_args
|
||||
local helper="$dir_root/cli/src/internal/maintenance/nodeRuntime.cjs"
|
||||
if [[ -f "$helper" ]]; then
|
||||
runtime_args=$(node "$helper") || return $?
|
||||
if [[ -n "$runtime_args" ]]; then
|
||||
read -r -a ql_node_runtime_args <<<"$runtime_args"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
reload_pm2() {
|
||||
cd $dir_root
|
||||
cd "$dir_root" || return $?
|
||||
restore_env_vars
|
||||
|
||||
# Try to start PM2, but handle failures gracefully
|
||||
@@ -301,6 +317,7 @@ reload_pm2() {
|
||||
local exit_code=$?
|
||||
t '警告: PM2 启动失败 (退出码: %s),可能是由于硬件不兼容' "$exit_code"
|
||||
t '正在尝试直接使用 Node.js 启动服务...'
|
||||
load_node_runtime_args || return $?
|
||||
|
||||
# Kill any existing node processes for qinglong
|
||||
pkill -f "node.*static/build/app.js" 2>/dev/null || true
|
||||
@@ -308,9 +325,9 @@ reload_pm2() {
|
||||
# Start node directly in the background. In containers, keep application
|
||||
# output on the container streams instead of duplicating Winston logs on disk.
|
||||
if [[ "$QL_CONTAINER" == "true" ]]; then
|
||||
nohup node static/build/app.js >/proc/1/fd/1 2>/proc/1/fd/2 &
|
||||
nohup node "${ql_node_runtime_args[@]}" static/build/app.js >/proc/1/fd/1 2>/proc/1/fd/2 &
|
||||
else
|
||||
nohup node static/build/app.js >$dir_log/qinglong.log 2>&1 &
|
||||
nohup node "${ql_node_runtime_args[@]}" static/build/app.js >"$dir_log/qinglong.log" 2>&1 &
|
||||
fi
|
||||
local node_pid=$!
|
||||
|
||||
@@ -391,14 +408,14 @@ handle_task_start() {
|
||||
if [[ $ID ]]; then
|
||||
local error=$(update_cron "\"$ID\"" "0" "$$" "$log_path" "$begin_timestamp")
|
||||
if [[ $error ]]; then
|
||||
error_message=", 任务状态更新失败(${error})"
|
||||
error_message=$(t ', 任务状态更新失败(%s)' "$error")
|
||||
fi
|
||||
fi
|
||||
t '## 开始执行... %s\n' "${begin_time}${error_message}"
|
||||
}
|
||||
|
||||
run_task_before() {
|
||||
. $file_task_before "$@"
|
||||
. "$file_task_before" "$@"
|
||||
|
||||
if [[ ${task_before:=} ]]; then
|
||||
t '执行前置命令\n'
|
||||
@@ -408,7 +425,7 @@ run_task_before() {
|
||||
}
|
||||
|
||||
run_task_after() {
|
||||
. $file_task_after "$@"
|
||||
. "$file_task_after" "$@"
|
||||
|
||||
if [[ ${task_after:=} ]]; then
|
||||
t '\n执行后置命令\n'
|
||||
@@ -433,7 +450,7 @@ handle_task_end() {
|
||||
if [[ $ID ]]; then
|
||||
local error=$(update_cron "\"$ID\"" "1" "$$" "$log_path" "$begin_timestamp" "$diff_time" "$exit_code")
|
||||
if [[ $error ]]; then
|
||||
error_message=", 状态更新失败(${error})"
|
||||
error_message=$(t ', 状态更新失败(%s)' "$error")
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user