Compare commits

...
Author SHA1 Message Date
whyour c9f1b2d4bb fix: preserve legacy SQLite addons loaded from build directories 2026-10-23 14:33:13 +08:00
whyour 5fdfd9077d fix: clean SQLite compiler artifacts before publishing repaired dependencies 2026-10-23 14:15:08 +08:00
whyour ce2e396b3e fix: remove legacy SQLite compiler artifacts from migration bundles 2026-10-23 14:04:42 +08:00
whyour 49cec13fcd test: classify subprocess crashes without exposing credentials 2026-10-23 13:50:37 +08:00
whyour 81de8f7094 fix: accept ARM1176-compatible ELF architecture attributes 2026-10-23 13:10:42 +08:00
whyour dce4f60c47 fix: restore locked build tools for the ARMv6 migration bridge 2026-10-23 12:16:28 +08:00
whyour 1623e89a71 fix: rebuild legacy migration SQLite for ARMv6 on the builder 2026-10-23 11:54:52 +08:00
whyour 1a49e81ffd fix: prevent second-hop migration from returning to a legacy backend 2026-10-23 11:08:20 +08:00
whyour 656b9419d6 test: budget startup for minimum-Node concurrent cache workers 2026-10-23 10:51:16 +08:00
whyour 8d2f67e263 Merge QEMU Node probe timeout fix into migration review 2026-10-23 10:40:27 +08:00
whyour 2a32f528f1 fix: initialize sample environment in migration candidate tests 2026-10-23 10:37:30 +08:00
whyour 384ce170c7 feat: bridge legacy panel upgrades in two verified steps 2026-10-23 10:33:00 +08:00
whyour cc001ecebd fix: allow emulated architectures time for Node URL probes 2026-10-23 01:53:28 +08:00
whyour 04c8a63518 Merge Alpine Node runtime fix into migration review branch 2026-10-23 01:49:18 +08:00
whyour 8976447b1e fix: pin Alpine amd64 Node runtime and verify URL recovery 2026-10-23 01:43:56 +08:00
whyour 0a586fbd1d fix: preserve newest-first execution instance ordering 2026-10-23 01:42:58 +08:00
whyour 15d54866a8 feat: migrate SQLite runtime and verify complete upgrade bundles
Use MikroORM's official Node SQLite dialect with bounded database isolation, preserve legacy schema and data for rollback, and require Node 22.17 or newer. Update Debian runtimes and retain ARMv6 musl compatibility without a SQLite addon.

Complete short archive writes, stop orphaned SQL workers after owner death, preserve buffered startup requests, and prestart the archived backend on every platform. Cover real package installation, migration, failure recovery, and legacy database compatibility.

Keep authentication storage failures distinct from expired sessions with a pinned Keyv runtime, propagated cache errors, and retryable HTTP 503 responses. Preserve genuine 401 failures and verify legacy cache bytes, initialization safety, and session recovery.
2026-10-22 13:47:26 +08:00
whyour 99a4d0eff8 fix: batch notification audits and bump CLI to 0.1.3 2026-10-22 01:25:29 +08:00
whyour b486989399 fix: validate ARMv6 upgrades and publish rolling development bundles 2026-10-21 01:43:44 +08:00
whyour 77fe551425 fix: simplify npm packaging and align frontend Node builds 2026-10-19 18:06:06 +08:00
whyour 2669f251ac fix: preserve npm environment during package replacement 2026-10-19 15:41:42 +08:00
whyour 3f02b04dff fix: ship verified npm panel packages and test production migrations 2026-10-19 14:17:53 +08:00
whyour e8b6a30bd9 feat: ship verified platform upgrades with channel bundle releases
Package production dependencies per architecture for online and offline upgrades.
Validate dependencies and isolated backend startup before readiness, repair once
when permitted, and preserve service/data through failed preparation or rollback.

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

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

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

Refs #3099
2026-10-18 01:08:36 +08:00
whyour cb5dfec2b5 fix: preserve cron semantics during node-cron adaptation
Preserve supported expression fields instead of stringifying them, and probe individual fields only when whole-expression validation fails. Convert unsupported syntax and weekday ranges ending in 7 while preserving legacy date/weekday rules and valid OR branches.

Add execution regressions for issue #3099 and 64 calendar boundary cases in UTC and Asia/Shanghai. Ensure timezone child tests actually run under the Node test runner.

Validation: 745 tests passed, 8 skipped; final targeted regressions passed; backend compilation passed. Differential calendar checks matched 142,360 execution timestamps.

Fixes #3099
2026-10-17 20:00:33 +08:00
whyour 286088a32c feat: stage online upgrades before verified restarts
Download updates asynchronously with persistent progress and validate matching source and build artifacts before an explicit restart. Keep upgrade workers alive across service restarts, verify the new runtime, and recover the browser with a one-time success notice.

Publish immutable archive sizes and checksums for accurate download progress. Align the update dialog, hide unavailable actions during transfers, and show the sidebar notice only for active, ready or failed jobs. Constrain long account names with ellipsis while preserving avatar and collapse controls.
2026-10-17 17:14:15 +08:00
whyour a1104ef47c fix: report page load timing after load handlers complete 2026-10-17 09:03:50 +08:00
whyour 1a152575cc fix: preserve active system log files when clearing logs 2026-10-17 07:42:10 +08:00
whyour ef348b445b fix: synchronize runtime locales and harden online updates
Translate UI, scheduler, validation, preload and maintenance messages dynamically, and propagate locale changes between workers. Harden update checks, downloads and terminal states, verify Debian pnpm, and ignore generated test artifacts.
2026-10-17 07:07:09 +08:00
whyour 8305825ffd Merge Python dependency and Shell regression fixes
fix: Python dependency status, GNU awk compatibility and dev regressions
2026-10-15 23:41:16 +08:00
249 changed files with 21272 additions and 3907 deletions
+216 -22
View File
@@ -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
@@ -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
@@ -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: |
@@ -511,30 +550,91 @@ 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
# Unmodified amd64 packages for glibc and musl migration acceptance. This does not
# publish bundles or advance development/formal channel indexes.
build-npm-migration-bundle:
needs: [build-debian, build-alpine]
strategy:
fail-fast: false
matrix:
libc: [glibc, musl]
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: ${{ matrix.libc == 'musl' && needs.build-alpine.outputs.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-${{ matrix.libc }}-${{ 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, build-alpine]
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
node: ['20.18.1', '22.17.0', '24', 'alpine']
container:
image: ${{ matrix.node == 'alpine' && format('ghcr.io/{0}@{1}', github.repository, needs.build-alpine.outputs.image-digest) || format('node:{0}-bookworm', matrix.node) }}
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-${{ matrix.node == 'alpine' && 'musl' || 'glibc' }}-${{ github.sha }}
path: ${{ runner.temp }}/npm-migration-bundle/
- name: Install isolated service test tools
run: |
if [ -f /etc/alpine-release ]; then
apk add --no-cache python3 openssl curl jq nginx procps make g++
else
apt-get update
apt-get install -y --no-install-recommends python3 openssl curl jq nginx procps
fi
# 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
if: matrix.node != '20.18.1'
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"
- name: Reject an unsupported runtime while preserving the old installation
if: matrix.node == '20.18.1'
run: node scripts/verify-panel-runtime-floor.cjs "$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 +642,112 @@ jobs:
registry-url: https://registry.npmjs.org
package-manager-cache: false
- uses: actions/download-artifact@v6
with:
name: qinglong-npm-${{ github.sha }}
path: npm-panel/
- name: Publish npm package with OIDC
shell: bash
run: |
set -euo pipefail
name=$(node -p 'require("./package.json").name')
version=$(node -p 'require("./package.json").version')
shopt -s nullglob
archives=(npm-panel/*.tgz)
if [ "${#archives[@]}" -ne 1 ]; then
echo '::error::Expected exactly one verified panel npm archive.'
exit 1
fi
archive="${archives[0]}"
metadata=$(tar -xOf "$archive" package/package.json)
name=$(jq -er '.name | select(. == "@whyour/qinglong")' <<< "$metadata")
version=$(jq -er '.version | select(type == "string")' <<< "$metadata")
if npm view "$name@$version" version --json --registry=https://registry.npmjs.org > "$RUNNER_TEMP/npm-version.json" 2> "$RUNNER_TEMP/npm-version-error.log"; then
jq -e --arg version "$version" '. == $version' "$RUNNER_TEMP/npm-version.json" > /dev/null
echo "::notice::$name@$version is already published; skipping npm publication for this image rebuild."
elif jq -e '.error.code == "E404"' "$RUNNER_TEMP/npm-version.json" > /dev/null; then
npm publish --access public --registry=https://registry.npmjs.org
npm publish "$archive" --ignore-scripts --access public --registry=https://registry.npmjs.org
else
echo '::error::Could not check the published npm version; refusing to publish.'
exit 1
fi
build-upgrade-bundles:
# Both channels publish packages from this run's immutable image digests.
if: ${{ !inputs.static_sync_only && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/develop') }}
needs: [build-alpine, build-debian]
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/arm/v7, libc: glibc, name: armv7-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}"
legacy=$(node -p "require('./docker/migration-bridge-images.json')['$BUNDLE_LIBC']")
docker buildx build --platform "$BUNDLE_PLATFORM" --build-arg "BASE_IMAGE=$image" --build-arg "LEGACY_IMAGE=$legacy" --file docker/Dockerfile.upgrade-bridge --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"
+2 -2
View File
@@ -31,7 +31,7 @@ jobs:
version: '8.3.1'
- uses: actions/setup-node@v6
with:
node-version: '20'
node-version: '22'
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build:front && pnpm build:back && pnpm build:info
@@ -120,7 +120,7 @@ jobs:
run: |
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")'
-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"}))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"); assert.equal(typeof require("@mikro-orm/sql").NodeSqliteDialect,"function"); const db=new (require("node:sqlite").DatabaseSync)(":memory:"); assert.equal(db.prepare("SELECT 42 AS value").get().value,42); db.close(); 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: |
@@ -0,0 +1,201 @@
name: SQLite migration candidate
on:
pull_request:
branches: [develop]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: sqlite-migration-candidate-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
SOURCE_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }}
jobs:
build-static:
if: ${{ (github.event_name == 'pull_request' && github.head_ref == 'codex/sqlite-runtime-migration' && github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/codex/sqlite-runtime-migration') }}
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v6
with:
ref: ${{ env.SOURCE_COMMIT }}
- uses: pnpm/action-setup@v6
with:
version: '8.3.1'
- uses: actions/setup-node@v6
with:
node-version: '22.17.0'
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: cp .env.example .env
- run: npm test
- run: npm run build:front && npm run build:back && npm run build:info
- uses: actions/upload-artifact@v6
with:
name: migration-static-${{ env.SOURCE_COMMIT }}
path: static/
include-hidden-files: true
if-no-files-found: error
retention-days: 7
test-node24:
needs: build-static
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
with:
ref: ${{ env.SOURCE_COMMIT }}
- uses: pnpm/action-setup@v6
with:
version: '8.3.1'
- uses: actions/setup-node@v6
with:
node-version: '24'
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: cp .env.example .env
- run: npm test
build-images:
needs: build-static
runs-on: ubuntu-22.04
timeout-minutes: 90
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- libc: musl
file: docker/Dockerfile
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
- libc: glibc
file: docker/Dockerfile.debian
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
steps:
- uses: actions/checkout@v6
with:
ref: ${{ env.SOURCE_COMMIT }}
- uses: actions/download-artifact@v6
with:
name: migration-static-${{ env.SOURCE_COMMIT }}
path: static/
- uses: docker/setup-qemu-action@v4
with:
cache-image: false
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ github.token }}
- uses: docker/build-push-action@v7
id: image
with:
context: .
file: ${{ matrix.file }}
platforms: ${{ matrix.platforms }}
build-args: |
QL_BRANCH=codex/sqlite-runtime-migration
SOURCE_COMMIT=${{ env.SOURCE_COMMIT }}
push: true
tags: ghcr.io/${{ github.repository }}:migration-candidate-${{ env.SOURCE_COMMIT }}-${{ matrix.libc }}
cache-from: type=registry,ref=whyour/qinglong:cache-${{ matrix.libc == 'musl' && 'alpine' || 'debian' }}
- name: Record the immutable candidate image
env:
IMAGE_DIGEST: ${{ steps.image.outputs.digest }}
BUNDLE_LIBC: ${{ matrix.libc }}
run: |
[[ "$IMAGE_DIGEST" =~ ^sha256:[a-f0-9]{64}$ ]]
printf '%s\n' "ghcr.io/$GITHUB_REPOSITORY@$IMAGE_DIGEST" > "$BUNDLE_LIBC-image.txt"
- uses: actions/upload-artifact@v6
with:
name: migration-${{ matrix.libc }}-image-${{ env.SOURCE_COMMIT }}
path: ${{ matrix.libc }}-image.txt
retention-days: 7
build-bundles:
needs: build-images
runs-on: ubuntu-22.04
timeout-minutes: 90
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/arm/v7, libc: glibc, name: armv7-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
with:
ref: ${{ env.SOURCE_COMMIT }}
- uses: actions/download-artifact@v6
with:
name: migration-${{ matrix.libc }}-image-${{ env.SOURCE_COMMIT }}
- uses: docker/setup-qemu-action@v4
with:
cache-image: false
- uses: docker/setup-buildx-action@v4
- name: Build and verify both online hops using the unchanged legacy client
env:
BUNDLE_PLATFORM: ${{ matrix.platform }}
BUNDLE_LIBC: ${{ matrix.libc }}
run: |
set -euo pipefail
image=$(cat "$BUNDLE_LIBC-image.txt")
legacy=$(node -p "require('./docker/migration-bridge-images.json')['$BUNDLE_LIBC']")
docker buildx build --platform "$BUNDLE_PLATFORM" --build-arg "BASE_IMAGE=$image" --build-arg "LEGACY_IMAGE=$legacy" --file docker/Dockerfile.upgrade-bridge --output type=local,dest=output/upgrade .
- uses: actions/upload-artifact@v6
with:
name: migration-bundles-${{ matrix.name }}-${{ env.SOURCE_COMMIT }}
path: output/upgrade/*
if-no-files-found: error
retention-days: 7
verify-index:
needs: [build-bundles, test-node24]
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
with:
ref: ${{ env.SOURCE_COMMIT }}
- 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 && npm --prefix cli ci
- uses: actions/download-artifact@v6
with:
pattern: migration-bundles-*-${{ env.SOURCE_COMMIT }}
path: output/upgrade/
merge-multiple: true
- run: node scripts/build-bundle-installer.cjs output/upgrade/install-bundle.cjs
- name: Validate every platform without publishing a channel
run: |
node -e "const fs=require('fs'),{releaseIndex}=require('./scripts/publish-upgrade-bundles.cjs'); const index=releaseIndex('output/upgrade',process.env.SOURCE_COMMIT,process.env.GITHUB_REPOSITORY,{migration:true}); fs.writeFileSync('output/upgrade/candidate-index.json',JSON.stringify(index));"
- uses: actions/upload-artifact@v6
with:
name: migration-candidate-index-${{ env.SOURCE_COMMIT }}
path: |
output/upgrade/candidate-index.json
output/upgrade/install-bundle.cjs
retention-days: 7
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
version: '8.3.1'
- uses: actions/setup-node@v6
with:
node-version: '20'
node-version: '24'
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: cp .env.example .env
+10
View File
@@ -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
View File
@@ -1,22 +0,0 @@
/.tmp/
/.github/
/.vscode/
/.history/
/back/**/*.ts
/back/**/*.json
/cli/
/data/
/src/
/static/**/*.js.map
/static/**/*.gz
/.editorconfig
/.gitignore
/.prettierignore
/.prettierrc
/.umirc.ts
/nodemon.json
/pnpm-lock.yaml
/tsconfig.back.json
/tsconfig.json
/typings.d.ts
/.env
+1
View File
@@ -0,0 +1 @@
22
+2
View File
@@ -1,5 +1,7 @@
# Node.js 原生部署与 Python venv
面板要求 Node.js >=22.17,推荐 Node.js 24,并要求该 Node 构建提供 `node:sqlite`。应用升级保留现有 Node;旧 Node 20 环境需先升级运行时。远程管理 CLI 的最低版本要求独立于面板。
设置 `QL_DIR` 为安装目录,`QL_DATA_DIR` 为以 `/data` 结尾的绝对持久化数据路径。
如需隔离 Python,请在启动进程的环境中显式设置 `PYTHON_VENV_DIR`(绝对路径或相对 `QL_DIR` 的路径),例如 `export PYTHON_VENV_DIR=/srv/qinglong/.venv`。未设置时保留现有 Python prefix 行为。
+2 -1
View File
@@ -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('配置更新失败'),
});
}
},
+21 -14
View File
@@ -5,10 +5,7 @@ import CronService from '../services/cron';
import CronViewService from '../services/cronView';
import { celebrate, Joi } from 'celebrate';
import { commonCronSchema } from '../validation/schedule';
import {
RunningInstanceModel,
InstanceStatus,
} from '../data/runningInstance';
import { RunningInstanceModel, InstanceStatus } from '../data/runningInstance';
import { t } from '../shared/i18n';
import cronClient from '../schedule/client';
@@ -19,8 +16,17 @@ export default (app: Router) => {
route.use(async (req, res, next) => {
// Keep stop/status callbacks available even when the scheduler is down.
if (['POST', 'PUT', 'DELETE'].includes(req.method) &&
['/', '/run', '/enable', '/disable', '/views/enable', '/views/disable'].includes(req.path)) {
if (
['POST', 'PUT', 'DELETE'].includes(req.method) &&
[
'/',
'/run',
'/enable',
'/disable',
'/views/enable',
'/views/disable',
].includes(req.path)
) {
try {
await cronClient.readiness.ensureReady();
} catch (error) {
@@ -497,13 +503,10 @@ export default (app: Router) => {
}),
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
try {
const instances = await RunningInstanceModel.findAll({
where: {
cron_id: req.params.id,
},
order: [['started_at', 'DESC']],
raw: true,
});
const instances = await RunningInstanceModel.find(
{ cron_id: req.params.id },
{ orderBy: [{ started_at: 'desc' }] },
);
return res.send({ code: 200, data: instances });
} catch (e) {
return next(e);
@@ -519,7 +522,11 @@ export default (app: Router) => {
instanceId: Joi.number().required(),
}),
}),
async (req: Request<{ id: number; instanceId: number }>, res: Response, next: NextFunction) => {
async (
req: Request<{ id: number; instanceId: number }>,
res: Response,
next: NextFunction,
) => {
try {
const cronService = Container.get(CronService);
const data = await cronService.stopInstance(req.params.instanceId);
+93 -157
View File
@@ -1,38 +1,32 @@
import { statistics } from '../data/statistics';
import { Query, QuerySpec, orderBy } from '../data/queries';
import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi';
import { fn, col, where, Op } from 'sequelize';
import { CrontabModel } from '../data/cron';
import { CrontabStatModel } from '../data/cronStats';
import {
RunningInstanceModel,
InstanceStatus,
} from '../data/runningInstance';
import { RunningInstanceModel, InstanceStatus } from '../data/runningInstance';
import dayjs from 'dayjs';
import os from 'os';
import { isEmpty } from 'lodash';
import { t, tf } from '../shared/i18n';
const route = Router();
export default (app: Router) => {
app.use('/dashboard', route);
route.post('/record', async (req: Request, res: Response) => {
try {
const { ref_id, code, elapsed } = req.body;
if (!ref_id) return res.send({ code: 400, message: 'ref_id required' });
const today = dayjs().format('YYYY-MM-DD');
const isSuccess = code === 0 ? 1 : 0;
const isFail = code !== 0 ? 1 : 0;
const elapsedMs = (Number(elapsed) || 0) * 1000;
const existing = await CrontabStatModel.findOne({
where: { ref_id: Number(ref_id), date: today },
});
const existing = await CrontabStatModel.findOne(
{ ref_id: Number(ref_id), date: today },
{},
);
if (existing) {
await CrontabStatModel.update(
await CrontabStatModel.nativeUpdate(
{ id: existing.id },
{
run_count: (existing.run_count || 0) + 1,
success_count: (existing.success_count || 0) + isSuccess,
@@ -40,10 +34,9 @@ export default (app: Router) => {
total_time: (existing.total_time || 0) + elapsedMs,
max_time: Math.max(existing.max_time || 0, elapsedMs),
},
{ where: { id: existing.id } },
);
} else {
await CrontabStatModel.create({
await CrontabStatModel.insert({
ref_id: Number(ref_id),
date: today,
run_count: 1,
@@ -53,41 +46,27 @@ export default (app: Router) => {
max_time: elapsedMs,
});
}
res.send({ code: 200 });
} catch (e) {
res.send({ code: 500 });
}
});
route.get(
'/overview',
async (req: Request, res: Response, next: NextFunction) => {
try {
const today = dayjs().format('YYYY-MM-DD');
const [total, enabled, disabled, stats] = await Promise.all([
CrontabModel.count(),
CrontabModel.count({ where: { isDisabled: 0 } }),
CrontabModel.count({ where: { isDisabled: 1 } }),
CrontabStatModel.findOne({
attributes: [
[fn('SUM', col('run_count')), 'total_runs'],
[fn('SUM', col('success_count')), 'total_success'],
[fn('SUM', col('fail_count')), 'total_fail'],
[fn('SUM', col('total_time')), 'total_time'],
],
where: { date: today },
raw: true,
}),
CrontabModel.count({}),
CrontabModel.count({ isDisabled: 0 }),
CrontabModel.count({ isDisabled: 1 }),
statistics.totals(today),
]);
const row = stats as any;
const totalRuns = Number(row?.total_runs) || 0;
const totalSuccess = Number(row?.total_success) || 0;
const totalFail = Number(row?.total_fail) || 0;
const totalTime = Number(row?.total_time) || 0;
res.send({
code: 200,
data: {
@@ -97,7 +76,10 @@ export default (app: Router) => {
todayRuns: totalRuns,
todaySuccess: totalSuccess,
todayFail: totalFail,
successRate: totalRuns > 0 ? ((totalSuccess / totalRuns) * 100).toFixed(1) : '0',
successRate:
totalRuns > 0
? ((totalSuccess / totalRuns) * 100).toFixed(1)
: '0',
avgTime: totalRuns > 0 ? Math.round(totalTime / totalRuns) : 0,
},
});
@@ -106,25 +88,25 @@ export default (app: Router) => {
}
},
);
for (const isSuccess of [false, true]) {
route.get(
isSuccess ? '/successes' : '/failures',
async (req: Request, res: Response, next: NextFunction) => {
try {
const countColumn = isSuccess ? 'success_count' : 'fail_count';
const rows = (await CrontabStatModel.findAll({
attributes: ['ref_id', [fn('SUM', col(countColumn)), 'result_count']],
where: { date: dayjs().format('YYYY-MM-DD'), [countColumn]: { [Op.gt]: 0 } },
group: ['ref_id'],
order: [[fn('SUM', col(countColumn)), 'DESC'], ['ref_id', 'ASC']],
raw: true,
})) as any[];
const crons = rows.length > 0 ? await CrontabModel.findAll({
attributes: ['id', 'name', 'command'],
where: { id: { [Op.in]: rows.map((row) => Number(row.ref_id)) } },
raw: true,
}) : [];
const rows = (await statistics.results(
dayjs().format('YYYY-MM-DD'),
countColumn,
)) as any[];
const crons =
rows.length > 0
? await CrontabModel.find(
{ id: { ['$in']: rows.map((row) => Number(row.ref_id)) } },
{
fields: ['id', 'name', 'command'],
},
)
: [];
const cronMap = new Map(crons.map((cron) => [cron.id, cron]));
res.send({
code: 200,
@@ -135,7 +117,9 @@ export default (app: Router) => {
id,
name: cron?.name || cron?.command || tf('任务#%s', id),
command: cron?.command || '',
[isSuccess ? 'successCount' : 'failCount']: Number(row.result_count),
[isSuccess ? 'successCount' : 'failCount']: Number(
row.result_count,
),
deleted: !cron,
};
}),
@@ -146,7 +130,6 @@ export default (app: Router) => {
},
);
}
route.get(
'/trend',
async (req: Request, res: Response, next: NextFunction) => {
@@ -156,22 +139,7 @@ export default (app: Router) => {
for (let i = days - 1; i >= 0; i--) {
dates.push(dayjs().subtract(i, 'day').format('YYYY-MM-DD'));
}
const rows = (await CrontabStatModel.findAll({
attributes: [
'date',
[fn('SUM', col('run_count')), 'total_runs'],
[fn('SUM', col('success_count')), 'total_success'],
[fn('SUM', col('fail_count')), 'total_fail'],
],
where: {
date: { [Op.in]: dates },
},
group: ['date'],
order: [['date', 'ASC']],
raw: true,
})) as any[];
const rows = (await statistics.trend(dates)) as any[];
const dataMap: Record<string, any> = {};
rows.forEach((r: any) => {
dataMap[r.date] = {
@@ -180,89 +148,52 @@ export default (app: Router) => {
fail: Number(r.total_fail) || 0,
};
});
const data = dates.map((d) => ({
date: dayjs(d).format('MM-DD'),
...(dataMap[d] || { total: 0, success: 0, fail: 0 }),
}));
res.send({ code: 200, data });
} catch (e) {
next(e);
}
},
);
route.get(
'/top-time',
async (req: Request, res: Response, next: NextFunction) => {
try {
const today = dayjs().format('YYYY-MM-DD');
const rows = (await CrontabStatModel.findAll({
attributes: [
'ref_id',
[fn('SUM', col('total_time')), 'total_time'],
[fn('SUM', col('run_count')), 'run_count'],
[fn('MAX', col('max_time')), 'max_time'],
],
where: { date: today, run_count: { [Op.gt]: 0 } },
group: ['ref_id'],
order: [[fn('SUM', col('total_time')), 'DESC']],
limit: 5,
raw: true,
})) as any[];
const rows = (await statistics.ranking(today, 'total_time')) as any[];
const ids = rows.map((r) => Number(r.ref_id));
const crons = await CrontabModel.findAll({
where: { id: { [Op.in]: ids } },
raw: true,
});
const crons = await CrontabModel.find({ id: { ['$in']: ids } }, {});
const nameMap: Record<number, string> = {};
crons.forEach((c: any) => { nameMap[c.id] = c.name || c.command; });
crons.forEach((c: any) => {
nameMap[c.id] = c.name || c.command;
});
const data = rows.map((r: any, i) => ({
rank: i + 1,
name: nameMap[Number(r.ref_id)] || tf('任务#%s', r.ref_id),
avgTime: Math.round(Number(r.total_time) / Number(r.run_count)),
maxTime: Number(r.max_time),
}));
res.send({ code: 200, data });
} catch (e) {
next(e);
}
},
);
route.get(
'/top-count',
async (req: Request, res: Response, next: NextFunction) => {
try {
const today = dayjs().format('YYYY-MM-DD');
const rows = (await CrontabStatModel.findAll({
attributes: [
'ref_id',
[fn('SUM', col('run_count')), 'run_count'],
[fn('SUM', col('total_time')), 'total_time'],
[fn('SUM', col('success_count')), 'success_count'],
],
where: { date: today, run_count: { [Op.gt]: 0 } },
group: ['ref_id'],
order: [[fn('SUM', col('run_count')), 'DESC']],
limit: 5,
raw: true,
})) as any[];
const rows = (await statistics.ranking(today, 'run_count')) as any[];
const ids = rows.map((r) => Number(r.ref_id));
const crons = await CrontabModel.findAll({
where: { id: { [Op.in]: ids } },
raw: true,
});
const crons = await CrontabModel.find({ id: { ['$in']: ids } }, {});
const nameMap: Record<number, string> = {};
crons.forEach((c: any) => { nameMap[c.id] = c.name || c.command; });
crons.forEach((c: any) => {
nameMap[c.id] = c.name || c.command;
});
const data = rows.map((r: any, i) => ({
rank: i + 1,
name: nameMap[Number(r.ref_id)] || tf('任务#%s', r.ref_id),
@@ -270,48 +201,42 @@ export default (app: Router) => {
avgTime: Math.round(Number(r.total_time) / Number(r.run_count)),
successRate:
Number(r.run_count) > 0
? ((Number(r.success_count) / Number(r.run_count)) * 100).toFixed(1)
? ((Number(r.success_count) / Number(r.run_count)) * 100).toFixed(
1,
)
: '0',
}));
res.send({ code: 200, data });
} catch (e) {
next(e);
}
},
);
route.get(
'/runtime',
async (req: Request, res: Response, next: NextFunction) => {
try {
const runningInstances = await RunningInstanceModel.findAll({
where: {
const runningInstances = await RunningInstanceModel.find(
{
status: InstanceStatus.running,
},
raw: true,
});
const queuedCrons = await CrontabModel.findAll({
where: {
{},
);
const queuedCrons = await CrontabModel.find(
{
status: 3, // queued
},
raw: true,
});
{},
);
// Fetch cron names for running instances
const cronIds = [
...new Set(runningInstances.map((i: any) => i.cron_id)),
];
const crons =
cronIds.length > 0
? await CrontabModel.findAll({
where: { id: cronIds },
raw: true,
})
? await CrontabModel.find({ id: cronIds }, {})
: [];
const cronMap = new Map(crons.map((c: any) => [c.id, c]));
const now = dayjs().unix();
const running = runningInstances.map((inst: any) => {
const cron = cronMap.get(inst.cron_id);
@@ -324,19 +249,18 @@ export default (app: Router) => {
logPath: inst.log_path,
};
});
const dayAgo = dayjs().subtract(24, 'hour').unix();
const idleTasks = await CrontabModel.findAll({
where: {
const idleTasks = await CrontabModel.find(
{
isDisabled: 0,
status: 1,
last_execution_time: { [Op.lt]: dayAgo },
last_execution_time: { ['$lt']: dayAgo },
},
order: [['last_execution_time', 'ASC']],
limit: 5,
raw: true,
});
{
orderBy: orderBy([['last_execution_time', 'ASC']]),
limit: 5,
},
);
res.send({
code: 200,
data: {
@@ -357,33 +281,45 @@ export default (app: Router) => {
}
},
);
route.get(
'/labels',
async (req: Request, res: Response, next: NextFunction) => {
try {
const today = dayjs().format('YYYY-MM-DD');
const [crons, stats] = (await Promise.all([
CrontabModel.findAll({ where: { isDisabled: 0 }, raw: true }),
CrontabStatModel.findAll({ where: { date: today }, raw: true }),
]));
const [crons, stats] = await Promise.all([
CrontabModel.find({ isDisabled: 0 }, {}),
CrontabStatModel.find({ date: today }, {}),
]);
const statMap: Record<number, any> = {};
stats.forEach((s: any) => { statMap[s.ref_id] = s; });
const labelMap: Record<string, { count: number; runs: number; success: number; totalTime: number }> = {};
stats.forEach((s: any) => {
statMap[s.ref_id] = s;
});
const labelMap: Record<
string,
{
count: number;
runs: number;
success: number;
totalTime: number;
}
> = {};
crons.forEach((c) => {
let rawLabels = c.labels;
if (typeof rawLabels === 'string') rawLabels = JSON.parse(rawLabels);
const labels: string[] = Array.isArray(rawLabels)
? [...new Set((rawLabels as string[]).filter((l: string) => !isEmpty(l)))]
? [
...new Set(
(rawLabels as string[]).filter((l: string) => !isEmpty(l)),
),
]
: [];
if (labels.length === 0) {
labels.push(t('未分类'));
}
const st = statMap[c.id!];
labels.forEach((label: string) => {
if (!labelMap[label]) labelMap[label] = { count: 0, runs: 0, success: 0, totalTime: 0 };
if (!labelMap[label])
labelMap[label] = { count: 0, runs: 0, success: 0, totalTime: 0 };
labelMap[label].count += 1;
if (st) {
labelMap[label].runs += Number(st.run_count) || 0;
@@ -392,24 +328,22 @@ export default (app: Router) => {
}
});
});
const data = Object.entries(labelMap)
.map(([label, v]) => ({
label,
count: v.count,
todayRuns: v.runs,
successRate: v.runs > 0 ? ((v.success / v.runs) * 100).toFixed(1) : '0',
successRate:
v.runs > 0 ? ((v.success / v.runs) * 100).toFixed(1) : '0',
avgTime: v.runs > 0 ? Math.round(v.totalTime / v.runs) : 0,
}))
.sort((a, b) => b.todayRuns - a.todayRuns);
res.send({ code: 200, data });
} catch (e) {
next(e);
}
},
);
route.get(
'/system',
async (req: Request, res: Response, next: NextFunction) => {
@@ -422,7 +356,9 @@ export default (app: Router) => {
uptime: Math.floor(process.uptime()),
memTotal: os.totalmem(),
memFree: os.freemem(),
memUsagePercent: ((1 - os.freemem() / os.totalmem()) * 100).toFixed(1),
memUsagePercent: ((1 - os.freemem() / os.totalmem()) * 100).toFixed(
1,
),
heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024),
heapTotal: Math.round(memUsage.heapTotal / 1024 / 1024),
loadAvg: os.loadavg().map((v) => Number(v.toFixed(2))),
+11
View File
@@ -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({
+13 -28
View File
@@ -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() {
+2 -5
View File
@@ -1,19 +1,16 @@
import { errStack } from '../shared/errors';
// Also terminate if the primary disappears without forwarding a signal.
const onParentDisconnect = () => process.exit(1);
process.once('disconnect', onParentDisconnect);
async function initializeDatabase() {
const { sequelize } = await import('../data');
const { database } = await import('../data');
try {
const { default: loadDatabase } = await import('../loaders/db');
await loadDatabase();
} finally {
await sequelize.close();
await database.close();
}
}
initializeDatabase()
.catch((error) => {
console.error(`[boot] Database initialization failed:\n${errStack(error)}`);
+141
View File
@@ -0,0 +1,141 @@
// 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 { database } = 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 database.transaction(async () => {
await database.query('CREATE TABLE upgrade_probe (value INTEGER)');
await database.query('INSERT INTO upgrade_probe VALUES (42)');
const rows = await database.query('SELECT value FROM upgrade_probe');
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 database.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);
});
+28
View File
@@ -0,0 +1,28 @@
import { EventEmitter } from 'events';
import { database } from '.';
import { DatabaseClient } from './databaseClient';
// Keep the existing Keyv serialization and namespace. Only its SQLite transport
// changes; old cached authentication records remain readable in either version.
export class CacheStore extends EventEmitter {
readonly opts = { dialect: 'sqlite' };
namespace?: string;
constructor(private client: () => DatabaseClient = () => database) {
super();
}
get(key: string): Promise<string | undefined> {
return this.client().request('cache', 'get', key);
}
async set(key: string, value: string) {
return this.client().request('cache', 'set', key, value);
}
async delete(key: string): Promise<boolean> {
return this.client().request('cache', 'delete', key);
}
async clear(): Promise<void> {
throw new Error('Clearing all authentication namespaces is not supported');
}
async disconnect(): Promise<void> {
/* The shared database client owns the worker. */
}
}
+3 -36
View File
@@ -1,5 +1,4 @@
import { sequelize } from '.';
import { DataTypes, Model, ModelDefined } from 'sequelize';
import { Repository, RecordRow } from './repository';
export class Crontab {
name?: string;
@@ -62,37 +61,5 @@ export enum CrontabStatus {
'queued' = 3,
}
export interface CronInstance extends Model<Crontab, Crontab>, Crontab {}
export const CrontabModel = sequelize.define<CronInstance>('Crontab', {
name: {
unique: 'compositeIndex',
type: DataTypes.STRING,
},
command: {
unique: 'compositeIndex',
type: DataTypes.STRING,
},
schedule: {
unique: 'compositeIndex',
type: DataTypes.STRING,
},
timestamp: DataTypes.STRING,
saved: DataTypes.BOOLEAN,
status: DataTypes.NUMBER,
isSystem: DataTypes.NUMBER,
pid: DataTypes.NUMBER,
isDisabled: DataTypes.NUMBER,
isPinned: DataTypes.NUMBER,
log_path: DataTypes.STRING,
queued_token: DataTypes.STRING,
labels: DataTypes.JSON,
last_running_time: DataTypes.NUMBER,
last_execution_time: DataTypes.NUMBER,
sub_id: { type: DataTypes.NUMBER, allowNull: true },
extra_schedules: DataTypes.JSON,
task_before: DataTypes.STRING,
task_after: DataTypes.STRING,
log_name: DataTypes.STRING,
allow_multiple_instances: DataTypes.NUMBER,
work_dir: DataTypes.STRING,
});
export type CronInstance = RecordRow<Crontab>;
export const CrontabModel = new Repository<Crontab>('Crontab');
+3 -48
View File
@@ -1,5 +1,4 @@
import { DataTypes, Model } from 'sequelize';
import { sequelize } from '.';
import { Repository, RecordRow } from './repository';
export class CrontabStat {
id?: number;
@@ -23,49 +22,5 @@ export class CrontabStat {
}
}
export interface CrontabStatInstance extends Model<CrontabStat, CrontabStat>, CrontabStat {}
export const CrontabStatModel = sequelize.define<CrontabStatInstance>(
'CrontabStat',
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
ref_id: {
type: DataTypes.NUMBER,
allowNull: false,
},
date: {
type: DataTypes.STRING,
allowNull: false,
},
run_count: {
type: DataTypes.NUMBER,
defaultValue: 0,
},
success_count: {
type: DataTypes.NUMBER,
defaultValue: 0,
},
fail_count: {
type: DataTypes.NUMBER,
defaultValue: 0,
},
total_time: {
type: DataTypes.NUMBER,
defaultValue: 0,
},
max_time: {
type: DataTypes.NUMBER,
defaultValue: 0,
},
},
{
indexes: [
{ unique: true, fields: ['ref_id', 'date'] },
{ fields: ['date'] },
],
},
);
export type CrontabStatInstance = RecordRow<CrontabStat>;
export const CrontabStatModel = new Repository<CrontabStat>('CrontabStat');
+3 -23
View File
@@ -1,5 +1,4 @@
import { sequelize } from '.';
import { DataTypes, Model } from 'sequelize';
import { Repository, RecordRow } from './repository';
export enum CronViewType {
'系统' = 1,
@@ -39,24 +38,5 @@ export class CrontabView {
}
}
export interface CronViewInstance
extends Model<CrontabView, CrontabView>,
CrontabView {}
export const CrontabViewModel = sequelize.define<CronViewInstance>(
'CrontabView',
{
name: {
unique: 'name',
type: DataTypes.STRING,
},
position: DataTypes.NUMBER,
isDisabled: DataTypes.NUMBER,
filters: DataTypes.JSON,
sorts: DataTypes.JSON,
filterRelation: {
type: DataTypes.STRING,
allowNull: true,
},
type: DataTypes.NUMBER,
},
);
export type CronViewInstance = RecordRow<CrontabView>;
export const CrontabViewModel = new Repository<CrontabView>('CrontabView');
+325
View File
@@ -0,0 +1,325 @@
import { AsyncLocalStorage } from 'async_hooks';
import { ChildProcess, fork } from 'child_process';
import { join } from 'path';
import { setTimeout, clearTimeout } from 'timers';
import { DatabaseError } from './databaseErrors';
type Scope = { active: boolean; child: boolean; token: number };
type Reply = {
id: number;
value?: any;
error?: { code: string; message: string };
};
type Limits = { timeoutMs?: number; queueLimit?: number };
// One worker per backend process; SQLite itself coordinates independent processes.
// A transaction owns this client's queue until its callback and commit finish.
export class DatabaseClient {
private worker?: ChildProcess;
private stopped: Promise<void> = Promise.resolve();
private failed?: Error;
private closed = false;
private nextId = 0;
private queued = 0;
private tail: Promise<void> = Promise.resolve();
private pending = new Map<
number,
{
resolve(value: any): void;
reject(error: Error): void;
timer: NodeJS.Timeout;
}
>();
private scope = new AsyncLocalStorage<Scope>();
private readonly timeoutMs: number;
private readonly queueLimit: number;
constructor(
public readonly storage: string,
private cacheStorage?: string,
limits: Limits = {},
) {
this.timeoutMs =
limits.timeoutMs ?? (process.arch === 'arm' ? 300000 : 60000);
this.queueLimit = limits.queueLimit ?? 256;
if (
!Number.isSafeInteger(this.timeoutMs) ||
this.timeoutMs < 1 ||
!Number.isSafeInteger(this.queueLimit) ||
this.queueLimit < 1
)
throw new Error('Invalid database worker limits');
}
private abort(error: Error) {
this.failed ||= error;
for (const request of this.pending.values()) {
clearTimeout(request.timer);
request.reject(this.failed);
}
this.pending.clear();
this.unref();
this.worker?.kill('SIGKILL');
}
private unref() {
this.worker?.unref();
this.worker?.channel?.unref();
}
private start() {
if (this.worker) return this.worker;
const [major, minor] = process.versions.node.split('.').map(Number);
if (major < 22 || (major === 22 && minor < 17))
throw new DatabaseError(
'Qinglong requires Node >=22.17; keep the previous installation until Node is upgraded',
'UNSUPPORTED_NODE',
);
// ts-node's CLI places its executable in execArgv, rather than using -r.
// Source workers register their own compiler; compiled workers need none.
const preloads = __filename.endsWith('.ts')
? ['--require', require.resolve('ts-node/register/transpile-only')]
: [];
const armv6 =
process.arch === 'arm' &&
Number(
(process.config.variables as { arm_version?: string | number })
.arm_version,
) === 6;
const worker = fork(require.resolve('./databaseProcess'), [], {
serialization: 'advanced',
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
execArgv: [
...preloads,
...(armv6 ? ['--jitless'] : []),
'--max-old-space-size=128',
],
env: {
...process.env,
NODE_OPTIONS: '',
...(__filename.endsWith('.ts')
? { TS_NODE_PROJECT: join(__dirname, 'tsconfig.worker.json') }
: {}),
},
});
this.stopped = new Promise((resolve) =>
worker.once('close', () => resolve()),
);
const onOwnerExit = () => worker.kill('SIGKILL');
process.once('exit', onOwnerExit);
worker.once('close', () => process.removeListener('exit', onOwnerExit));
this.worker = worker;
worker.on('message', (reply: Reply & { type?: string }) => {
if (reply.type === 'fatal' && reply.error) {
this.abort(new DatabaseError(reply.error.message, reply.error.code));
return;
}
const request = this.pending.get(reply.id);
if (!request) return;
this.pending.delete(reply.id);
clearTimeout(request.timer);
if (!this.pending.size) this.unref();
if (reply.error)
request.reject(
new DatabaseError(reply.error.message, reply.error.code),
);
else request.resolve(reply.value);
});
const fail = () =>
this.abort(
new DatabaseError(
'Database worker stopped; restart the service before retrying a write',
'DATABASE_WORKER_EXIT',
),
);
worker.on('error', fail);
worker.on('exit', () => {
if (!this.closed) fail();
});
worker.send({
storage: this.storage,
cacheStorage: this.cacheStorage,
ownerPid: process.pid,
});
this.unref();
return worker;
}
private send(
operation: string,
args: any[] = [],
token?: number,
): Promise<any> {
if (this.failed) return Promise.reject(this.failed);
if (this.closed)
return Promise.reject(
new DatabaseError('Database is closed', 'DATABASE_CLOSED'),
);
return new Promise((resolve, reject) => {
try {
const worker = this.start();
const id = ++this.nextId;
const timer = setTimeout(
() =>
this.abort(
new DatabaseError(
'Database worker timed out; writes are never automatically replayed',
'DATABASE_WORKER_TIMEOUT',
),
),
this.timeoutMs,
);
timer.unref();
this.pending.set(id, { resolve, reject, timer });
worker.ref();
worker.channel?.ref();
try {
worker.send({ id, operation, args, token }, (error) => {
if (error)
this.abort(
new DatabaseError(
'Database IPC disconnected',
'DATABASE_WORKER_EXIT',
),
);
});
} catch (error) {
this.pending.delete(id);
clearTimeout(timer);
if (!this.pending.size) this.unref();
reject(error);
}
} catch (error) {
reject(error);
}
});
}
private exclusive<T>(
operation: () => Promise<T>,
shutdown = false,
): Promise<T> {
if (!shutdown && this.queued >= this.queueLimit)
return Promise.reject(
new DatabaseError('Database request queue is full', 'DATABASE_BUSY'),
);
this.queued++;
const previous = this.tail;
let release!: () => void;
this.tail = new Promise<void>((resolve) => {
release = resolve;
});
return previous.then(operation).finally(() => {
this.queued--;
release();
});
}
request<T = any>(operation: string, ...args: any[]): Promise<T> {
const scope = this.scope.getStore();
if (scope) {
if (!scope.active || scope.child)
return Promise.reject(
new DatabaseError(
'Transaction scope is no longer available',
'TRANSACTION_SCOPE',
),
);
if (this.pending.size >= this.queueLimit)
return Promise.reject(
new DatabaseError('Database request queue is full', 'DATABASE_BUSY'),
);
return this.send(operation, args, scope.token);
}
return this.exclusive(() => this.send(operation, args));
}
async transaction<T>(operation: () => Promise<T>): Promise<T> {
const parent = this.scope.getStore();
if (parent && (!parent.active || parent.child))
throw new DatabaseError(
'Concurrent or expired nested transaction',
'TRANSACTION_SCOPE',
);
const run = async () => {
const token = ++this.nextId;
if (parent) parent.child = true;
const scope: Scope = { active: true, child: false, token };
let began = false;
let timer: NodeJS.Timeout | undefined;
try {
await this.send('begin', [], token);
began = true;
const deadline = new Promise<never>((_, reject) => {
timer = setTimeout(
() =>
reject(
new DatabaseError(
'Database transaction timed out',
'DATABASE_TRANSACTION_TIMEOUT',
),
),
this.timeoutMs,
);
});
const value = await Promise.race([
this.scope.run(scope, operation),
deadline,
]);
scope.active = false;
await this.send('commit', [], token);
return value;
} catch (error) {
scope.active = false;
if (began) {
try {
await this.send('rollback', [], token);
} catch {
this.abort(
new DatabaseError(
'Transaction rollback failed; restart the service',
'DATABASE_ROLLBACK_FAILED',
),
);
}
}
throw error;
} finally {
clearTimeout(timer);
if (parent) parent.child = false;
}
};
return parent ? run() : this.exclusive(run);
}
query<T = Record<string, unknown>>(
sql: string,
params: unknown[] = [],
): Promise<T[]> {
return this.request('query', sql, params);
}
get healthy() {
return !this.failed && !this.closed;
}
async close() {
if (this.scope.getStore())
throw new DatabaseError(
'Cannot close within a transaction',
'TRANSACTION_SCOPE',
);
await this.exclusive(async () => {
if (this.closed) return;
try {
if (this.worker && !this.failed) await this.send('close');
} finally {
this.closed = true;
this.worker?.ref();
this.worker?.kill('SIGKILL');
await this.stopped;
}
}, true);
}
}
+36
View File
@@ -0,0 +1,36 @@
// Error details (SQL, bindings, credentials) stay inside the database worker.
export class DatabaseError extends Error {
constructor(message: string, public readonly code: string) {
super(message);
this.name = 'DatabaseError';
}
}
export function databaseStartupFailure(value: unknown) {
const error = (value && typeof value === 'object' ? value : {}) as Error & { code?: string };
const missing = /Cannot find (?:module|package) ['"]([^'"]+)['"]/.exec(
error.message || '',
)?.[1];
const importer = /Require stack:\r?\n-\s+([^\r\n]+)/.exec(
error.message || '',
)?.[1];
const dependency =
/MODULE_NOT_FOUND/.test(error.code || '') &&
Boolean(
missing &&
((!missing.startsWith('.') &&
!missing.startsWith('/') &&
!missing.startsWith('node:')) ||
/node_modules[/\\]/.test(missing) ||
/node_modules[/\\]/.test(importer || '')),
);
return dependency
? {
code: 'MODULE_NOT_FOUND',
message: "Cannot find module '@mikro-orm/sql'",
}
: {
code: 'DATABASE_WORKER_EXIT',
message: 'Database process failed to start',
};
}
+61
View File
@@ -0,0 +1,61 @@
import { databaseStartupFailure } from './databaseErrors';
import { Worker } from 'worker_threads';
// IPC disconnect cannot run while DatabaseSync is executing SQL. Start this
// independent, allocation-light monitor before opening SQLite so a SIGKILLed
// backend cannot leave an orphan holding the old service's database write lock.
async function monitorOwner(ownerPid: number) {
if (!Number.isSafeInteger(ownerPid) || ownerPid < 1)
throw new Error('Invalid database owner');
const monitor = new Worker(
`
const {parentPort,workerData}=require('node:worker_threads');
process.on('uncaughtException',()=>process.kill(process.pid,'SIGKILL'));
const check=()=>{
if(process.ppid!==workerData.ownerPid)process.kill(process.pid,'SIGKILL');
};
check();
setInterval(check,500);
parentPort.postMessage('ready');
`,
{
eval: true,
workerData: { ownerPid },
// V8's process-wide --jitless remains active on ARMv6. Do not load the
// source worker's ts-node compiler into this tiny, plain-JavaScript monitor.
execArgv: [],
resourceLimits: {
maxOldGenerationSizeMb: 8,
maxYoungGenerationSizeMb: 2,
stackSizeMb: 1,
},
},
);
monitor.once('exit', () => process.exit(1));
await new Promise<void>((resolve, reject) => {
monitor.once('message', () => resolve());
monitor.once('error', reject);
});
monitor.unref();
}
// The bootstrap catches dependency import errors before the ORM module loads.
// The advanced IPC serializer preserves Date, Buffer and undefined values.
process.once('disconnect', () => process.exit(1));
process.once('message', async (settings) => {
const pending: unknown[] = [];
const buffer = (request: unknown) => pending.push(request);
// Keep a listener throughout asynchronous monitor startup. Node may flush
// several buffered IPC messages while delivering the initial settings.
process.on('message', buffer);
try {
await monitorOwner((settings as { ownerPid: number }).ownerPid);
require('./databaseWorker').startDatabaseWorker(settings);
process.removeListener('message', buffer);
for (const request of pending) process.emit('message', request, undefined);
} catch (error) {
process.send?.({ type: 'fatal', error: databaseStartupFailure(error) });
} finally {
process.removeListener('message', buffer);
}
});
+280
View File
@@ -0,0 +1,280 @@
import { mkdirSync } from 'fs';
import { dirname } from 'path';
import { EntitySchema, Type } from '@mikro-orm/core';
import { MikroORM, SqliteDriver, NodeSqliteDialect, raw } from '@mikro-orm/sql';
import { entityDefinitions } from './schema';
// Sequelize DATE readers expect TEXT. Numeric epoch dates would break rollback.
class LegacyDateType extends Type<Date, string> {
convertToDatabaseValue(value: Date) {
return value == null
? value
: value.toISOString().replace('T', ' ').replace('Z', ' +00:00');
}
convertToJSValue(value: string | Date) {
return value == null
? value
: value instanceof Date
? value
: new Date(value);
}
getColumnType() {
return 'DATETIME';
}
compareAsType() {
return 'string' as const;
}
get runtimeType() {
return 'Date';
}
}
const entities = Object.values(entityDefinitions).map(
(definition) =>
new EntitySchema({
...definition,
properties: Object.fromEntries(
Object.entries(definition.properties).map(([name, field]) => [
name,
{
...field,
type:
field.type === 'legacyDate' ? new LegacyDateType() : field.type,
},
]),
),
} as any),
);
let orm: Awaited<ReturnType<typeof MikroORM.init<SqliteDriver>>>;
let cache: any;
const transactions: number[] = [];
let ready: Promise<void>;
let workerData: { storage: string; cacheStorage?: string; ownerPid: number };
async function initialize() {
if (workerData.storage !== ':memory:')
mkdirSync(dirname(workerData.storage), { recursive: true });
orm = await MikroORM.init<SqliteDriver>({
driver: SqliteDriver,
driverOptions: new NodeSqliteDialect(workerData.storage),
dbName: workerData.storage,
entities,
debug: false,
allowGlobalContext: false,
discovery: { warnWhenNoEntities: false },
});
await orm.em.getConnection().execute('PRAGMA busy_timeout=10000');
}
function filter(name: keyof typeof entityDefinitions, value: any): any {
if (value === undefined) throw new Error('Undefined database predicate');
if (value == null || typeof value !== 'object' || value instanceof Date)
return value;
if (Array.isArray(value)) return value.map((part) => filter(name, part));
const properties = entityDefinitions[name].properties;
return Object.fromEntries(
Object.entries(value).map(([key, item]: [string, any]) => {
if (item === undefined) throw new Error('Undefined database predicate');
// LIKE searches JSON's serialized text, matching the pre-migration behavior.
if (
key in properties &&
(properties as any)[key].type === 'json' &&
item &&
typeof item === 'object' &&
'$like' in item
)
return [raw(`cast("${key}" as text)`), item];
return [key, filter(name, item)];
}),
);
}
function values(
name: keyof typeof entityDefinitions,
payload: Record<string, any>,
insert = false,
) {
const properties = entityDefinitions[name].properties;
const data = Object.fromEntries(
Object.entries(payload).filter(
([key, value]) => key in properties && value !== undefined,
),
);
for (const [key, value] of Object.entries(data)) {
if ((properties as any)[key].type === 'json' && value !== null)
data[key] = JSON.parse(JSON.stringify(value));
}
const now = new Date();
if (insert && !data.createdAt) data.createdAt = now;
data.updatedAt = now;
return data;
}
function plain(name: keyof typeof entityDefinitions, row: any) {
if (!row) return null;
return Object.fromEntries(
Object.keys(entityDefinitions[name].properties)
.filter((key) => row[key] !== undefined)
.map((key) => [key, row[key]]),
);
}
async function execute(
operation: string,
args: any[],
token?: number,
): Promise<any> {
await ready;
// Reject queued work after owner death; the monitor interrupts running SQL.
if (process.ppid !== workerData.ownerPid) process.exit(1);
const connection = orm.em.getConnection();
if (operation === 'begin') {
await connection.execute(
transactions.length ? `SAVEPOINT "ql_${token}"` : 'BEGIN IMMEDIATE',
);
transactions.push(token!);
return;
}
if (token !== transactions[transactions.length - 1])
throw new Error('Database transaction ownership mismatch');
if (operation === 'commit' || operation === 'rollback') {
if (!transactions.length) throw new Error('No active database transaction');
const nested = transactions.length > 1;
if (operation === 'rollback' && nested)
await connection.execute(`ROLLBACK TO SAVEPOINT "ql_${token}"`);
await connection.execute(
nested ? `RELEASE SAVEPOINT "ql_${token}"` : operation.toUpperCase(),
);
transactions.pop();
return;
}
if (operation === 'close') {
if (transactions.length)
throw new Error('Cannot close an active transaction');
cache?.close();
await orm.close();
return;
}
if (operation === 'query') return connection.execute(args[0], args[1]);
if (operation === 'cache') {
if (!workerData.cacheStorage)
throw new Error('No cache database configured');
if (!cache) {
const { DatabaseSync } = require('node:sqlite');
mkdirSync(dirname(workerData.cacheStorage), { recursive: true });
cache = new DatabaseSync(workerData.cacheStorage);
cache.exec(
'PRAGMA busy_timeout=10000;CREATE TABLE IF NOT EXISTS keyv(key VARCHAR(255) PRIMARY KEY,value TEXT)',
);
}
const [action, key, value] = args;
switch (action) {
case 'get':
return cache.prepare('SELECT value FROM keyv WHERE key=?').get(key)
?.value;
case 'set':
cache
.prepare(
'INSERT INTO keyv(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value',
)
.run(key, value);
return true;
case 'delete':
return (
cache.prepare('DELETE FROM keyv WHERE key=?').run(key).changes > 0
);
default:
throw new Error('Unsupported cache operation');
}
}
const [name, condition, options = {}] = args as [
keyof typeof entityDefinitions,
any,
any,
];
if (!(name in entityDefinitions)) throw new Error('Unknown entity');
const em = orm.em.fork();
const entityName = entities.find((entity) => entity.meta.className === name)!;
if (operation !== 'insert' && condition == null)
throw new Error('Missing database predicate');
const query = operation === 'insert' ? {} : filter(name, condition);
const readOptions = {
...options,
orderBy: options.orderBy?.map((order: Record<string, string>) =>
Object.fromEntries(
Object.entries(order).map(([field, direction]) => [
name === 'Env' && field === 'isPinned'
? raw('COALESCE("isPinned",0)')
: field,
direction,
]),
),
),
};
switch (operation) {
case 'find':
return (await em.find(entityName, query, readOptions)).map((row) =>
plain(name, row),
);
case 'findOne':
return plain(name, await em.findOne(entityName, query, readOptions));
case 'count':
return em.count(entityName, query);
case 'insert': {
const id = await em.insert(entityName, values(name, condition, true));
return plain(name, await em.findOneOrFail(entityName, { id }));
}
case 'nativeUpdate':
return em.nativeUpdate(entityName, query, values(name, options));
case 'nativeDelete':
return em.nativeDelete(entityName, query);
default:
throw new Error('Unsupported database operation');
}
}
export function startDatabaseWorker(settings: typeof workerData) {
workerData = settings;
ready = initialize();
void ready.catch(() => {});
let queue = Promise.resolve();
process.on('message', ({ id, operation, args, token }) => {
// Continue after a failed statement so the owner can still roll back.
queue = queue.then(async () => {
try {
process.send?.({
id,
value: await execute(operation, args, token),
});
} catch (error: any) {
const code =
error.name === 'UniqueConstraintViolationException'
? 'UNIQUE_CONSTRAINT'
: error.name === 'TableNotFoundException'
? 'DATABASE_SCHEMA_MISSING'
: (
{
5: 'SQLITE_BUSY',
6: 'SQLITE_LOCKED',
8: 'SQLITE_READONLY',
10: 'SQLITE_IOERR',
11: 'SQLITE_CORRUPT',
13: 'SQLITE_FULL',
14: 'SQLITE_CANTOPEN',
26: 'SQLITE_NOTADB',
} as Record<number, string>
)[Number(error.errcode) & 255] || 'DATABASE_QUERY_FAILED';
process.send?.({
id,
error: {
code,
message:
code === 'UNIQUE_CONSTRAINT'
? 'Resource already exists'
: `Database operation failed (${code})`,
},
});
}
});
});
}
+3 -16
View File
@@ -1,5 +1,4 @@
import { sequelize } from '.';
import { DataTypes, Model, ModelDefined } from 'sequelize';
import { Repository, RecordRow } from './repository';
export class Dependence {
timestamp?: string;
@@ -47,17 +46,5 @@ export enum versionDependenceCommandTypes {
'=',
}
export interface DependenceInstance
extends Model<Dependence, Dependence>,
Dependence {}
export const DependenceModel = sequelize.define<DependenceInstance>(
'Dependence',
{
name: DataTypes.STRING,
type: DataTypes.NUMBER,
timestamp: DataTypes.STRING,
status: DataTypes.NUMBER,
log: DataTypes.JSON,
remark: DataTypes.STRING,
},
);
export type DependenceInstance = RecordRow<Dependence>;
export const DependenceModel = new Repository<Dependence>('Dependence');
+3 -13
View File
@@ -1,5 +1,4 @@
import { DataTypes, Model } from 'sequelize';
import { sequelize } from '.';
import { Repository, RecordRow } from './repository';
export class Env {
value?: string;
@@ -38,14 +37,5 @@ export const initPosition = 4500000000000000;
export const stepPosition = 10000000000;
export const minPosition = 100;
export interface EnvInstance extends Model<Env, Env>, Env {}
export const EnvModel = sequelize.define<EnvInstance>('Env', {
value: { type: DataTypes.STRING, unique: 'compositeIndex' },
timestamp: DataTypes.STRING,
status: DataTypes.NUMBER,
position: DataTypes.NUMBER,
name: { type: DataTypes.STRING, unique: 'compositeIndex' },
remarks: DataTypes.STRING,
isPinned: DataTypes.NUMBER,
labels: DataTypes.JSON,
});
export type EnvInstance = RecordRow<Env>;
export const EnvModel = new Repository<Env>('Env');
+5 -18
View File
@@ -1,23 +1,10 @@
import { Sequelize, Transaction } from 'sequelize';
import config from '../config/index';
import { join } from 'path';
import { DatabaseClient } from './databaseClient';
export const sequelize = new Sequelize({
dialect: 'sqlite',
storage: join(config.dbPath, 'database.sqlite'),
logging: false,
retry: {
max: 10,
match: ['SQLITE_BUSY: database is locked'],
},
pool: {
max: 5,
min: 2,
idle: 30000,
acquire: 30000,
evict: 10000,
},
transactionType: Transaction.TYPES.IMMEDIATE,
});
export const database = new DatabaseClient(
join(config.dbPath, 'database.sqlite'),
join(config.dbPath, 'keyv.sqlite'),
);
export type ResponseType<T> = { code: number; data?: T; message?: string };
+11 -11
View File
@@ -1,5 +1,4 @@
import { sequelize } from '.';
import { DataTypes, Model, ModelDefined } from 'sequelize';
import { Repository, RecordRow } from './repository';
export class App {
name: string;
@@ -24,13 +23,14 @@ export interface AppToken {
expiration: number;
}
export type AppScope = 'envs' | 'crons' | 'configs' | 'scripts' | 'logs' | 'system' | 'dashboard';
export type AppScope =
| 'envs'
| 'crons'
| 'configs'
| 'scripts'
| 'logs'
| 'system'
| 'dashboard';
export interface AppInstance extends Model<App, App>, App {}
export const AppModel = sequelize.define<AppInstance>('App', {
name: { type: DataTypes.STRING, unique: 'name' },
scopes: DataTypes.JSON,
client_id: DataTypes.STRING,
client_secret: DataTypes.STRING,
tokens: DataTypes.JSON,
});
export type AppInstance = RecordRow<App>;
export const AppModel = new Repository<App>('App');
+25
View File
@@ -0,0 +1,25 @@
export type Query = Record<string, any>;
export type QuerySpec = {
where: Query;
orderBy?: Record<string, string>[];
offset?: number;
limit?: number;
};
export function orderBy(order: string[][]): Record<string, string>[] {
return order.map(([field, direction]) => {
if (!/^(asc|desc)$/i.test(direction))
throw new Error('Invalid sort direction');
return { [field]: direction.toLowerCase() };
});
}
export function textSearch(fields: string[], text: string): Query {
return {
$or: fields.flatMap((field) =>
[...new Set([text, encodeURI(text)])].map((value) => ({
[field]: { $like: `%${value}%` },
})),
),
};
}
+54
View File
@@ -0,0 +1,54 @@
import { database } from '.';
import { DatabaseClient } from './databaseClient';
export type RecordRow<T> = T & { id: number; createdAt: Date; updatedAt: Date };
export type Query = Record<string, any>;
export type ReadOptions = {
fields?: string[];
orderBy?: Record<string, string>[];
limit?: number;
offset?: number;
};
// A small IPC boundary with MikroORM's native query/operation names. No ORM
// entities or request identity maps escape the single database worker.
export class Repository<T> {
constructor(
private name: string,
private client: () => DatabaseClient = () => database,
) {}
find(where: Query = {}, options: ReadOptions = {}): Promise<RecordRow<T>[]> {
return this.client().request('find', this.name, where, options);
}
findOne(
where: Query,
options: ReadOptions = {},
): Promise<RecordRow<T> | null> {
return this.client().request('findOne', this.name, where, options);
}
count(where: Query = {}): Promise<number> {
return this.client().request('count', this.name, where);
}
insert(data: Partial<T>): Promise<RecordRow<T>> {
return this.client().request('insert', this.name, data);
}
nativeUpdate(where: Query, data: Partial<T>): Promise<number> {
return this.client().request('nativeUpdate', this.name, where, data);
}
nativeDelete(where: Query): Promise<number> {
return this.client().request('nativeDelete', this.name, where);
}
async insertMany(data: Partial<T>[]): Promise<RecordRow<T>[]> {
return this.client().transaction(async () => {
const rows = [];
for (const item of data) rows.push(await this.insert(item));
return rows;
});
}
async getOrCreate(where: Query): Promise<RecordRow<T>> {
return this.client().transaction(
async () =>
(await this.findOne(where)) || this.insert(where as Partial<T>),
);
}
}
+3 -38
View File
@@ -1,5 +1,4 @@
import { sequelize } from '.';
import { DataTypes, Model } from 'sequelize';
import { Repository, RecordRow } from './repository';
export enum InstanceStatus {
'running' = 0,
@@ -41,41 +40,7 @@ export class RunningInstance {
}
}
export interface RunningInstanceModel
extends Model<RunningInstanceAttributes, RunningInstanceAttributes>,
RunningInstanceAttributes {}
export const RunningInstanceModel = sequelize.define<RunningInstanceModel>(
export type RunningInstanceModel = RecordRow<RunningInstanceAttributes>;
export const RunningInstanceModel = new Repository<RunningInstanceAttributes>(
'RunningInstance',
{
cron_id: {
type: DataTypes.NUMBER,
allowNull: false,
},
pid: {
type: DataTypes.NUMBER,
allowNull: true,
},
log_path: {
type: DataTypes.STRING,
allowNull: true,
},
started_at: {
type: DataTypes.NUMBER,
allowNull: false,
},
finished_at: {
type: DataTypes.NUMBER,
allowNull: true,
},
status: {
type: DataTypes.NUMBER,
allowNull: false,
defaultValue: InstanceStatus.running,
},
exit_code: {
type: DataTypes.NUMBER,
allowNull: true,
},
},
);
+178
View File
@@ -0,0 +1,178 @@
// Persisted SQLite names and types are part of the rollback contract.
type Field = {
type: string;
fieldName: string;
nullable?: boolean;
primary?: boolean;
autoincrement?: boolean;
};
function entity<Name extends string>(
name: Name,
tableName: string,
groups: Record<string, string[]>,
required: string[] = [],
) {
const properties: Record<string, Field> = {
id: { type: 'number', fieldName: 'id', primary: true, autoincrement: true },
};
for (const [type, fields] of Object.entries(groups))
for (const field of fields)
properties[field] = {
type,
fieldName: field,
nullable: !required.includes(field),
};
for (const field of ['createdAt', 'updatedAt'])
properties[field] = {
type: 'legacyDate',
fieldName: field,
nullable: false,
};
return { name, tableName, properties };
}
export const entityDefinitions = {
App: entity(
'App',
'Apps',
{
string: ['name', 'client_id', 'client_secret'],
json: ['scopes', 'tokens'],
},
[],
),
Auth: entity('Auth', 'Auths', { string: ['ip', 'type'], json: ['info'] }, []),
CrontabStat: entity(
'CrontabStat',
'CrontabStats',
{
number: [
'ref_id',
'run_count',
'success_count',
'fail_count',
'total_time',
'max_time',
],
string: ['date'],
},
['ref_id', 'date'],
),
CrontabView: entity(
'CrontabView',
'CrontabViews',
{
string: ['name', 'filterRelation'],
number: ['position', 'isDisabled', 'type'],
json: ['filters', 'sorts'],
},
[],
),
Crontab: entity(
'Crontab',
'Crontabs',
{
string: [
'name',
'command',
'schedule',
'timestamp',
'log_path',
'queued_token',
'task_before',
'task_after',
'log_name',
'work_dir',
],
boolean: ['saved'],
number: [
'status',
'isSystem',
'pid',
'isDisabled',
'isPinned',
'last_running_time',
'last_execution_time',
'sub_id',
'allow_multiple_instances',
],
json: ['labels', 'extra_schedules'],
},
[],
),
Dependence: entity(
'Dependence',
'Dependences',
{
string: ['name', 'timestamp', 'remark'],
number: ['type', 'status'],
json: ['log'],
},
[],
),
Env: entity(
'Env',
'Envs',
{
string: ['value', 'timestamp', 'name', 'remarks'],
number: ['status', 'position', 'isPinned'],
json: ['labels'],
},
[],
),
RunningInstance: entity(
'RunningInstance',
'RunningInstances',
{
number: [
'cron_id',
'pid',
'started_at',
'finished_at',
'status',
'exit_code',
],
string: ['log_path'],
},
['cron_id', 'started_at', 'status'],
),
Subscription: entity(
'Subscription',
'Subscriptions',
{
string: [
'name',
'url',
'schedule',
'type',
'whitelist',
'blacklist',
'dependences',
'extensions',
'sub_before',
'sub_after',
'branch',
'pull_type',
'log_path',
'schedule_type',
'alias',
'proxy',
],
json: ['interval_schedule', 'pull_option'],
number: ['status', 'pid', 'is_disabled', 'autoAddCron', 'autoDelCron'],
},
[],
),
};
export const initialSchema = [
'CREATE TABLE IF NOT EXISTS `Apps` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` VARCHAR(255), `scopes` JSON, `client_id` VARCHAR(255), `client_secret` VARCHAR(255), `tokens` JSON, `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL, UNIQUE (`name`))',
'CREATE TABLE IF NOT EXISTS `Auths` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `ip` VARCHAR(255), `type` VARCHAR(255), `info` JSON, `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL)',
'CREATE TABLE IF NOT EXISTS `CrontabStats` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `ref_id` NUMBER NOT NULL, `date` VARCHAR(255) NOT NULL, `run_count` NUMBER DEFAULT 0, `success_count` NUMBER DEFAULT 0, `fail_count` NUMBER DEFAULT 0, `total_time` NUMBER DEFAULT 0, `max_time` NUMBER DEFAULT 0, `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL)',
'CREATE TABLE IF NOT EXISTS `CrontabViews` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` VARCHAR(255), `position` NUMBER, `isDisabled` NUMBER, `filters` JSON, `sorts` JSON, `filterRelation` VARCHAR(255), `type` NUMBER, `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL, UNIQUE (`name`))',
'CREATE TABLE IF NOT EXISTS `Crontabs` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` VARCHAR(255), `command` VARCHAR(255), `schedule` VARCHAR(255), `timestamp` VARCHAR(255), `saved` TINYINT(1), `status` NUMBER, `isSystem` NUMBER, `pid` NUMBER, `isDisabled` NUMBER, `isPinned` NUMBER, `log_path` VARCHAR(255), `queued_token` VARCHAR(255), `labels` JSON, `last_running_time` NUMBER, `last_execution_time` NUMBER, `sub_id` NUMBER, `extra_schedules` JSON, `task_before` VARCHAR(255), `task_after` VARCHAR(255), `log_name` VARCHAR(255), `allow_multiple_instances` NUMBER, `work_dir` VARCHAR(255), `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL, UNIQUE (`name`, `command`, `schedule`))',
'CREATE TABLE IF NOT EXISTS `Dependences` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` VARCHAR(255), `type` NUMBER, `timestamp` VARCHAR(255), `status` NUMBER, `log` JSON, `remark` VARCHAR(255), `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL)',
'CREATE TABLE IF NOT EXISTS `Envs` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `value` VARCHAR(255), `timestamp` VARCHAR(255), `status` NUMBER, `position` NUMBER, `name` VARCHAR(255), `remarks` VARCHAR(255), `isPinned` NUMBER, `labels` JSON, `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL, UNIQUE (`value`, `name`))',
'CREATE TABLE IF NOT EXISTS `RunningInstances` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `cron_id` NUMBER NOT NULL, `pid` NUMBER, `log_path` VARCHAR(255), `started_at` NUMBER NOT NULL, `finished_at` NUMBER, `status` NUMBER NOT NULL DEFAULT 0, `exit_code` NUMBER, `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL)',
'CREATE TABLE IF NOT EXISTS `Subscriptions` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` VARCHAR(255), `url` VARCHAR(255), `schedule` VARCHAR(255), `interval_schedule` JSON, `type` VARCHAR(255), `whitelist` VARCHAR(255), `blacklist` VARCHAR(255), `status` NUMBER, `dependences` VARCHAR(255), `extensions` VARCHAR(255), `sub_before` VARCHAR(255), `sub_after` VARCHAR(255), `branch` VARCHAR(255), `pull_type` VARCHAR(255), `pull_option` JSON, `pid` NUMBER, `is_disabled` NUMBER, `log_path` VARCHAR(255), `schedule_type` VARCHAR(255), `alias` VARCHAR(255), `proxy` VARCHAR(255), `autoAddCron` NUMBER, `autoDelCron` NUMBER, `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL, UNIQUE (`name`, `url`, `schedule`, `interval_schedule`), UNIQUE (`alias`))',
'CREATE INDEX IF NOT EXISTS `crontab_stats_date` ON `CrontabStats` (`date`)',
'CREATE UNIQUE INDEX IF NOT EXISTS `crontab_stats_ref_id_date` ON `CrontabStats` (`ref_id`, `date`)',
];
+45
View File
@@ -0,0 +1,45 @@
import { database } from '.';
// Aggregate SQL stays explicit and bound; public input is never used as an SQL
// identifier. These queries preserve the dashboard response names.
export const statistics = {
async totals(date: string) {
const rows = await database.query(
`SELECT SUM(run_count) AS total_runs,
SUM(success_count) AS total_success, SUM(fail_count) AS total_fail,
SUM(total_time) AS total_time FROM CrontabStats WHERE date=?`,
[date],
);
return rows[0];
},
results(date: string, column: 'success_count' | 'fail_count') {
if (!['success_count', 'fail_count'].includes(column))
throw new Error('Invalid statistic');
return database.query(
`SELECT ref_id,SUM(${column}) AS result_count FROM CrontabStats
WHERE date=? AND ${column}>0 GROUP BY ref_id ORDER BY result_count DESC,ref_id ASC`,
[date],
);
},
trend(dates: string[]) {
if (!dates.length) return Promise.resolve([]);
return database.query(
`SELECT date,SUM(run_count) AS total_runs,
SUM(success_count) AS total_success,SUM(fail_count) AS total_fail FROM CrontabStats
WHERE date IN (${dates
.map(() => '?')
.join(',')}) GROUP BY date ORDER BY date ASC`,
dates,
);
},
ranking(date: string, metric: 'total_time' | 'run_count') {
if (!['total_time', 'run_count'].includes(metric))
throw new Error('Invalid statistic');
return database.query(
`SELECT ref_id,SUM(total_time) AS total_time,SUM(run_count) AS run_count,
SUM(success_count) AS success_count,MAX(max_time) AS max_time FROM CrontabStats
WHERE date=? AND run_count>0 GROUP BY ref_id ORDER BY SUM(${metric}) DESC LIMIT 5`,
[date],
);
},
};
+3 -45
View File
@@ -1,5 +1,4 @@
import { sequelize } from '.';
import { DataTypes, Model, ModelDefined } from 'sequelize';
import { Repository, RecordRow } from './repository';
import { SimpleIntervalSchedule } from 'toad-scheduler';
type SimpleIntervalScheduleUnit = keyof SimpleIntervalSchedule;
@@ -70,46 +69,5 @@ export enum SubscriptionStatus {
'queued',
}
export interface SubscriptionInstance
extends Model<Subscription, Subscription>,
Subscription {}
export const SubscriptionModel = sequelize.define<SubscriptionInstance>(
'Subscription',
{
name: {
unique: 'compositeIndex',
type: DataTypes.STRING,
},
url: {
unique: 'compositeIndex',
type: DataTypes.STRING,
},
schedule: {
unique: 'compositeIndex',
type: DataTypes.STRING,
},
interval_schedule: {
unique: 'compositeIndex',
type: DataTypes.JSON,
},
type: DataTypes.STRING,
whitelist: DataTypes.STRING,
blacklist: DataTypes.STRING,
status: DataTypes.NUMBER,
dependences: DataTypes.STRING,
extensions: DataTypes.STRING,
sub_before: DataTypes.STRING,
sub_after: DataTypes.STRING,
branch: DataTypes.STRING,
pull_type: DataTypes.STRING,
pull_option: DataTypes.JSON,
pid: DataTypes.NUMBER,
is_disabled: DataTypes.NUMBER,
log_path: DataTypes.STRING,
schedule_type: DataTypes.STRING,
alias: { type: DataTypes.STRING, unique: 'alias' },
proxy: { type: DataTypes.STRING, allowNull: true },
autoAddCron: { type: DataTypes.NUMBER, allowNull: true },
autoDelCron: { type: DataTypes.NUMBER, allowNull: true },
},
);
export type SubscriptionInstance = RecordRow<Subscription>;
export const SubscriptionModel = new Repository<Subscription>('Subscription');
+3 -13
View File
@@ -1,5 +1,4 @@
import { sequelize } from '.';
import { DataTypes, Model, ModelDefined } from 'sequelize';
import { Repository, RecordRow } from './repository';
import { NotificationInfo } from './notify';
export class SystemInfo {
@@ -106,14 +105,5 @@ export type SystemModelInfo = SystemConfigInfo &
Partial<NotifyLogInfo> &
Partial<AuthInfo>;
export interface SystemInstance
extends Model<SystemInfo, SystemInfo>,
SystemInfo {}
export const SystemModel = sequelize.define<SystemInstance>('Auth', {
ip: DataTypes.STRING,
type: DataTypes.STRING,
info: {
type: DataTypes.JSON,
allowNull: true,
},
});
export type SystemInstance = RecordRow<SystemInfo>;
export const SystemModel = new Repository<SystemInfo>('Auth');
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"module": "node20",
"moduleResolution": "node16",
"rootDir": "..",
"outDir": "../../static/build"
},
"include": ["./databaseWorker.ts", "./schema.ts"],
"exclude": ["node_modules"]
}
+6 -22
View File
@@ -1,30 +1,14 @@
import Logger from './logger';
import { EnvModel } from '../data/env';
import { CrontabModel } from '../data/cron';
import { DependenceModel } from '../data/dependence';
import { AppModel } from '../data/open';
import { SystemModel } from '../data/system';
import { SubscriptionModel } from '../data/subscription';
import { CrontabViewModel } from '../data/cronView';
import { CrontabStatModel } from '../data/cronStats';
import { RunningInstanceModel } from '../data/runningInstance';
import { sequelize } from '../data';
import { database } from '../data';
import { initialSchema } from '../data/schema';
import { migrateSchema } from '../shared/schemaMigrations';
export default async () => {
try {
await CrontabModel.sync();
await DependenceModel.sync();
await AppModel.sync();
await SystemModel.sync();
await EnvModel.sync();
await SubscriptionModel.sync();
await CrontabViewModel.sync();
await CrontabStatModel.sync();
await RunningInstanceModel.sync();
await migrateSchema(sequelize);
await database.transaction(async () => {
for (const sql of initialSchema) await database.query(sql);
await migrateSchema(database);
});
Logger.info('[boot] DB loaded');
} catch (error) {
Logger.error('[boot] DB load failed', error);
+87 -101
View File
@@ -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';
@@ -16,7 +16,7 @@ import { t } from '../shared/i18n';
import { AppScope } from '../data/open';
import uniqueConstraintError from '../middlewares/uniqueConstraintError';
import protectedPathCase from '../middlewares/protectedPathCase';
import { withAuthAvailability } from '../middlewares/authAvailability';
function resolveTrustProxy(value = process.env.QL_TRUST_PROXY) {
const setting = value?.trim();
if (!setting) {
@@ -30,24 +30,20 @@ function resolveTrustProxy(value = process.env.QL_TRUST_PROXY) {
}
return setting;
}
export default ({ app }: { app: Application }) => {
// Security: Enable strict routing to prevent case-insensitive path bypass
app.set('case sensitive routing', true);
app.set('strict routing', true);
app.set('trust proxy', resolveTrustProxy());
app.use(cors());
// Security: Reject case variations under protected API namespaces before
// authentication checks can interpret the request differently from routing.
app.use(protectedPathCase);
// Rewrite URLs to strip baseUrl prefix if configured
// This allows the rest of the app to work without baseUrl awareness
if (config.baseUrl) {
app.use(rewrite(`${config.baseUrl}/*`, '/$1'));
}
app.get(`${config.api.prefix}/env.js`, serveEnv);
app.use(
`${config.api.prefix}/static`,
@@ -58,7 +54,6 @@ export default ({ app }: { app: Application }) => {
},
}),
);
const credentialPaths = ['/api', '/open'].flatMap((prefix) =>
['/user/login', '/user/init', '/user/two-factor/login'].map(
(route) => `${prefix}${route}`,
@@ -69,13 +64,10 @@ export default ({ app }: { app: Application }) => {
bodyParser.json({ limit: '16kb' }),
bodyParser.urlencoded({ limit: '16kb', extended: false }),
);
app.use(bodyParser.json({ limit: '50mb' }));
app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
const frontendPath = path.join(config.rootPath, 'static/dist');
app.use(express.static(frontendPath));
app.use(
expressjwt({
secret: config.jwt.secret,
@@ -84,7 +76,6 @@ export default ({ app }: { app: Application }) => {
path: [...config.apiWhiteList, /^(\/(?!api\/).*)$/i],
}),
);
app.use((req: Request, res, next) => {
if (!req.headers) {
req.platform = 'desktop';
@@ -94,91 +85,84 @@ export default ({ app }: { app: Application }) => {
}
return next();
});
app.use(async (req: Request, res, next) => {
const pathLower = req.path.toLowerCase();
if (!['/open/', '/api/'].some((x) => pathLower.startsWith(x))) {
return next();
}
const headerToken = getToken(req);
if (pathLower.startsWith('/open/')) {
const apps = await shareStore.getApps();
const doc = apps?.filter((x) =>
x.tokens?.find((y) => y.value === headerToken),
)?.[0];
if (doc && doc.tokens && doc.tokens.length > 0) {
const currentToken = doc.tokens.find((x) => x.value === headerToken);
const keyMatch = pathLower.match(/\/open\/([a-z]+)\/*/);
const key = keyMatch && keyMatch[1];
if (!doc.scopes.includes(key as AppScope)) {
const err = new UnauthorizedError('credentials_bad_scheme', {
message: t('暂无权限'),
});
return next(err);
}
if (
!currentToken ||
currentToken.expiration < Math.round(Date.now() / 1000)
) {
const err = new UnauthorizedError('invalid_token', {
message: t('Token 已失效'),
});
return next(err);
}
app.use(
withAuthAvailability(async (req: Request, res, next) => {
const pathLower = req.path.toLowerCase();
if (!['/open/', '/api/'].some((x) => pathLower.startsWith(x))) {
return next();
}
}
const originPath = `${req.baseUrl}${pathLower === '/' ? '' : pathLower}`;
if (
!headerToken &&
originPath &&
config.apiWhiteList.includes(originPath)
) {
return next();
}
const authInfo = await shareStore.getAuthInfo();
if (isValidToken(authInfo, headerToken, req.platform, config.jwt.secret)) {
return next();
}
const errorCode = headerToken ? 'invalid_token' : 'credentials_required';
const errorMessage = headerToken ? t('Token 已失效') : t('请先登录');
const err = new UnauthorizedError(errorCode, { message: errorMessage });
next(err);
});
app.use(async (req, res, next) => {
const pathLower = req.path.toLowerCase();
if (
![
'/api/user/init',
'/api/user/notification/init',
'/open/user/init',
'/open/user/notification/init',
].includes(pathLower)
) {
return next();
}
const authInfo = (await shareStore.getAuthInfo()) || ({} as AuthInfo);
let isInitialized = !isDefaultAuthInfo(authInfo);
if (isInitialized) {
return res.send({ code: 450, message: t('未知错误') });
} else {
return next();
}
});
const headerToken = getToken(req);
if (pathLower.startsWith('/open/')) {
const apps = await shareStore.getApps();
const doc = apps?.filter((x) =>
x.tokens?.find((y) => y.value === headerToken),
)?.[0];
if (doc && doc.tokens && doc.tokens.length > 0) {
const currentToken = doc.tokens.find((x) => x.value === headerToken);
const keyMatch = pathLower.match(/\/open\/([a-z]+)\/*/);
const key = keyMatch && keyMatch[1];
if (!doc.scopes.includes(key as AppScope)) {
const err = new UnauthorizedError('credentials_bad_scheme', {
message: t('暂无权限'),
});
return next(err);
}
if (
!currentToken ||
currentToken.expiration < Math.round(Date.now() / 1000)
) {
const err = new UnauthorizedError('invalid_token', {
message: t('Token 已失效'),
});
return next(err);
}
return next();
}
}
const originPath = `${req.baseUrl}${pathLower === '/' ? '' : pathLower}`;
if (
!headerToken &&
originPath &&
config.apiWhiteList.includes(originPath)
) {
return next();
}
const authInfo = await shareStore.getAuthInfo();
if (
isValidToken(authInfo, headerToken, req.platform, config.jwt.secret)
) {
return next();
}
const errorCode = headerToken ? 'invalid_token' : 'credentials_required';
const errorMessage = headerToken ? t('Token 已失效') : t('请先登录');
const err = new UnauthorizedError(errorCode, { message: errorMessage });
next(err);
}),
);
app.use(
withAuthAvailability(async (req, res, next) => {
const pathLower = req.path.toLowerCase();
if (
![
'/api/user/init',
'/api/user/notification/init',
'/open/user/init',
'/open/user/notification/init',
].includes(pathLower)
) {
return next();
}
const authInfo = (await shareStore.getAuthInfo()) || ({} as AuthInfo);
let isInitialized = !isDefaultAuthInfo(authInfo);
if (isInitialized) {
return res.send({ code: 450, message: t('未知错误') });
} else {
return next();
}
}),
);
app.use(rewrite('/open/*', '/api/$1'));
app.use(config.api.prefix, routes());
app.get('*', (req, res, next) => {
// Don't serve index.html for API routes
if (
@@ -196,13 +180,13 @@ export default ({ app }: { app: Application }) => {
}
});
});
app.use(errors());
app.use(validationErrors);
app.use(uniqueConstraintError);
app.use(
(
err: Error & { status: number },
err: Error & {
status: number;
},
req: Request,
res: Response,
next: NextFunction,
@@ -216,15 +200,16 @@ export default ({ app }: { app: Application }) => {
return next(err);
},
);
app.use(
(
err: Error & { errors: any[] },
err: Error & {
errors: any[];
},
req: Request,
res: Response,
next: NextFunction,
) => {
if (err.name.includes('Sequelize')) {
if (err.name === 'DatabaseError') {
return res
.status(500)
.send({
@@ -237,10 +222,11 @@ export default ({ app }: { app: Application }) => {
return next(err);
},
);
app.use(
(
err: Error & { status: number },
err: Error & {
status: number;
},
req: Request,
res: Response,
next: NextFunction,
+33
View File
@@ -0,0 +1,33 @@
import type express from 'express';
import config from '../config';
export async function createHttpApplication(): Promise<express.Application> {
// Only the HTTP worker needs an Express application and its middleware.
const [
{ default: createExpress },
{ default: helmet },
{ default: cors },
{ default: compression },
{ monitoringMiddleware },
] = await Promise.all([
import('express'),
import('helmet'),
import('cors'),
import('compression'),
import('../middlewares/monitoring'),
]);
const app = createExpress();
app.use((req, res, next) => {
if (req.query.t) delete req.query.t;
next();
});
app.use(
helmet({
contentSecurityPolicy: false,
}),
);
app.use(cors(config.cors));
app.use(compression());
app.use(monitoringMiddleware);
return app;
}
+65 -82
View File
@@ -1,3 +1,5 @@
import { textSearch } from '../data/queries';
import { Query, QuerySpec, orderBy } from '../data/queries';
import DependenceService from '../services/dependence';
import { exec } from 'child_process';
import { Container } from 'typedi';
@@ -5,7 +7,6 @@ import { Crontab, CrontabModel, CrontabStatus } from '../data/cron';
import CronService from '../services/cron';
import EnvService from '../services/env';
import { DependenceModel, DependenceStatus } from '../data/dependence';
import { Op } from 'sequelize';
import config from '../config';
import { CrontabViewModel, CronViewType } from '../data/cronView';
import { initPosition } from '../data/env';
@@ -20,7 +21,6 @@ import Logger from './logger';
import cronClient from '../schedule/client';
import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance';
import { setLang, systemLang } from '../shared/i18n';
export default async () => {
const cronService = Container.get(CronService);
const envService = Container.get(EnvService);
@@ -28,17 +28,14 @@ export default async () => {
const systemService = Container.get(SystemService);
const userService = Container.get(UserService);
const openService = Container.get(OpenService);
// 初始化增加系统配置
await openService.initializeSystemApp();
const [systemConfig] = await SystemModel.findOrCreate({
where: { type: AuthDataType.systemConfig },
const systemConfig = await SystemModel.getOrCreate({
type: AuthDataType.systemConfig,
});
await SystemModel.findOrCreate({
where: { type: AuthDataType.notification },
});
const [authConfig] = await SystemModel.findOrCreate({
where: { type: AuthDataType.authConfig },
await SystemModel.getOrCreate({ type: AuthDataType.notification });
const authConfig = await SystemModel.getOrCreate({
type: AuthDataType.authConfig,
});
if (!authConfig?.info || isDemoEnv()) {
let authInfo = {
@@ -54,36 +51,34 @@ export default async () => {
} catch (error) {
Logger.warn('Failed to read auth config file, using default credentials');
}
await SystemModel.upsert({
id: authConfig?.id,
info: authInfo,
type: AuthDataType.authConfig,
});
await SystemModel.nativeUpdate(
{ id: authConfig.id },
{
info: authInfo,
type: AuthDataType.authConfig,
},
);
}
const installDependencies = async () => {
const docs = await DependenceModel.findAll({
where: {},
order: [
['type', 'DESC'],
['createdAt', 'DESC'],
],
raw: true,
});
await DependenceModel.update(
const docs = await DependenceModel.find(
{},
{
orderBy: orderBy([
['type', 'DESC'],
['createdAt', 'DESC'],
]),
},
);
await DependenceModel.nativeUpdate(
{ id: docs.map((x) => x.id!) },
{ status: DependenceStatus.queued, log: [] },
{ where: { id: docs.map((x) => x.id!) } },
);
setTimeout(async () => {
await dependenceService.installDependenceOneByOne(docs);
const bootAfterLoader = await import('./bootAfter');
bootAfterLoader.default();
}, 5000);
};
// 初始化更新 linux/python/nodejs 镜像源配置
if (systemConfig.info?.pythonMirror) {
systemService.updatePythonMirror({
@@ -106,43 +101,40 @@ export default async () => {
nodeMirror: systemConfig.info?.nodeMirror,
});
}
// 初始化新增默认全部任务视图
CrontabViewModel.findAll({
where: { type: CronViewType.系统, name: '全部任务' },
raw: true,
}).then((docs) => {
if (docs.length === 0) {
CrontabViewModel.create({
name: '全部任务',
type: CronViewType.系统,
position: initPosition / 2,
});
}
});
CrontabViewModel.find({ type: CronViewType.系统, name: '全部任务' }, {}).then(
(docs) => {
if (docs.length === 0) {
CrontabViewModel.insert({
name: '全部任务',
type: CronViewType.系统,
position: initPosition / 2,
});
}
},
);
// 初始化更新所有任务状态为空闲
await CrontabModel.update({ status: CrontabStatus.idle }, { where: {} });
await CrontabModel.nativeUpdate({}, { status: CrontabStatus.idle });
// 清空所有运行中的实例记录(服务重启后进程已不存在)
await RunningInstanceModel.update(
await RunningInstanceModel.nativeUpdate(
{ status: InstanceStatus.running },
{
status: InstanceStatus.stopped,
finished_at: Math.floor(Date.now() / 1000),
// The previous process's exit code cannot be recovered after a restart.
},
{ where: { status: InstanceStatus.running } },
);
// 初始化时执行一次所有的 ql repo 任务
CrontabModel.findAll({
where: {
isDisabled: { [Op.ne]: 1 },
command: {
[Op.or]: [{ [Op.like]: `%ql repo%` }, { [Op.like]: `%ql raw%` }],
},
CrontabModel.find(
{
isDisabled: { ['$ne']: 1 },
$or: [
...textSearch(['command'], 'ql repo').$or,
...textSearch(['command'], 'ql raw').$or,
],
},
}).then((docs) => {
{},
).then((docs) => {
for (let i = 0; i < docs.length; i++) {
const doc = docs[i];
if (doc) {
@@ -150,87 +142,78 @@ export default async () => {
}
}
});
// 更新2.11.3以前的脚本路径
CrontabModel.findAll({
where: {
command: {
[Op.or]: [
{ [Op.like]: `%\/${config.rootPath}\/scripts\/%` },
{ [Op.like]: `%\/${config.rootPath}\/config\/%` },
{ [Op.like]: `%\/${config.rootPath}\/log\/%` },
{ [Op.like]: `%\/${config.rootPath}\/db\/%` },
],
},
CrontabModel.find(
{
$or: ['scripts', 'config', 'log', 'db'].flatMap(
(folder) =>
textSearch(['command'], `/${config.rootPath}/${folder}/`).$or,
),
},
}).then(async (docs) => {
{},
).then(async (docs) => {
for (let i = 0; i < docs.length; i++) {
const doc = docs[i];
if (doc) {
if (doc.command.includes(`${config.rootPath}/scripts/`)) {
await CrontabModel.update(
await CrontabModel.nativeUpdate(
{ id: doc.id },
{ command: doc.command.replace(`${config.rootPath}/scripts/`, '') },
{ where: { id: doc.id } },
);
}
if (doc.command.includes(`${config.rootPath}/log/`)) {
await CrontabModel.update(
await CrontabModel.nativeUpdate(
{ id: doc.id },
{
command: `${config.dataPath}/log/${doc.command.replace(
`${config.rootPath}/log/`,
'',
)}`,
},
{ where: { id: doc.id } },
);
}
if (doc.command.includes(`${config.rootPath}/config/`)) {
await CrontabModel.update(
await CrontabModel.nativeUpdate(
{ id: doc.id },
{
command: `${config.dataPath}/config/${doc.command.replace(
`${config.rootPath}/config/`,
'',
)}`,
},
{ where: { id: doc.id } },
);
}
if (doc.command.includes(`${config.rootPath}/db/`)) {
await CrontabModel.update(
await CrontabModel.nativeUpdate(
{ id: doc.id },
{
command: `${config.dataPath}/db/${doc.command.replace(
`${config.rootPath}/db/`,
'',
)}`,
},
{ where: { id: doc.id } },
);
}
}
}
});
// 初始化语言(必须在 autosave_crontab 之前)
const lang = systemConfig.info?.lang || systemLang();
setLang(lang);
// 确保 lang_env.sh 存在
try {
const langEnvExist = await fileExist(config.langEnvFile);
if (!langEnvExist) {
await writeFile(config.langEnvFile, `export QL_LANG='${lang}'\n`);
}
} catch { }
} catch {}
// 初始化保存一次ck和定时任务数据
cronClient.readiness.configure(async () => {
await cronService.autosave_crontab(true);
await systemService.restoreCronConcurrency();
});
await cronClient.readiness.recover();
await envService.set_envs();
const authInfo = await userService.getAuthInfo();
await shareStore.updateAuthInfo(authInfo);
await openService.refreshApps();
+2 -9
View File
@@ -9,20 +9,17 @@ import { join } from 'path';
import { t } from '../shared/i18n';
import Logger from './logger';
import { assertSubscriptionAlias } from '../shared/subscriptionPath';
export default async () => {
const systemService = Container.get(SystemService);
const scheduleService = Container.get(ScheduleService);
const subscriptionService = Container.get(SubscriptionService);
const sshKeyService = Container.get(SshKeyService);
// 生成内置token
let tokenCommand = `ts-node-transpile-only ${join(
config.rootPath,
'back/token.ts',
)}`;
const tokenFile = join(config.rootPath, 'static/build/token.js');
if (await fileExist(tokenFile)) {
tokenCommand = `node ${tokenFile}`;
}
@@ -41,7 +38,6 @@ export default async () => {
},
true,
);
// 运行删除日志任务
const data = await systemService.getSystemConfig();
if (data && data.info) {
@@ -61,24 +57,21 @@ export default async () => {
true,
);
}
systemService.updateTimezone(data.info);
// Apply global SSH key if configured
if (data.info.globalSshKey) {
await sshKeyService.addGlobalSSHKey(data.info.globalSshKey, 'global');
}
}
await subscriptionService.setSshConfig();
const subs = await subscriptionService.list();
for (const sub of subs) {
try {
const doc = sub.get({ plain: true });
const doc = sub;
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);
}
}
};
+18
View File
@@ -0,0 +1,18 @@
import type { RequestHandler } from 'express';
import { t } from '../shared/i18n';
// Express 4 does not forward rejected async middleware promises itself.
export function withAuthAvailability(handler: RequestHandler): RequestHandler {
return (req, res, next) => {
void Promise.resolve()
.then(() => handler(req, res, next))
.catch(() => {
res.setHeader('Retry-After', '1');
next(
Object.assign(new Error(t('认证服务暂时不可用,请稍后重试')), {
status: 503,
}),
);
});
};
}
+3 -4
View File
@@ -1,14 +1,13 @@
import { ErrorRequestHandler } from 'express';
import { UniqueConstraintError } from 'sequelize';
import { DatabaseError } from '../data/databaseErrors';
import { t } from '../shared/i18n';
// Handle the database constraint itself so concurrent writes are covered too.
const uniqueConstraintError: ErrorRequestHandler = (err, req, res, next) => {
if (!(err instanceof UniqueConstraintError)) return next(err);
if (!(err instanceof DatabaseError) || err.code !== 'UNIQUE_CONSTRAINT')
return next(err);
// Constraint details can contain environment values or application credentials.
res
.status(409)
.json({ code: 409, message: t('资源已存在,请检查重复的名称或值') });
};
export default uniqueConstraintError;
+18
View File
@@ -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;
+3 -3
View File
@@ -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,
);
+1 -39
View File
@@ -39,10 +39,7 @@ import {
RunCronsRequest,
} from '../protos/api';
import { NotificationInfo } from '../data/notify';
import { Model } from 'sequelize';
Container.set('logger', LoggerInstance);
export const getEnvs = async (
call: ServerUnaryCall<GetEnvsRequest, EnvsResponse>,
callback: sendUnaryData<EnvsResponse>,
@@ -62,7 +59,6 @@ export const getEnvs = async (
});
}
};
export const createEnv = async (
call: ServerUnaryCall<CreateEnvRequest, EnvsResponse>,
callback: sendUnaryData<EnvsResponse>,
@@ -75,7 +71,6 @@ export const createEnv = async (
callback(e);
}
};
export const updateEnv = async (
call: ServerUnaryCall<UpdateEnvRequest, EnvResponse>,
callback: sendUnaryData<EnvResponse>,
@@ -88,7 +83,6 @@ export const updateEnv = async (
message: 'id parameter is required',
});
}
const envService = Container.get(EnvService);
const data = await envService.update(
pick(call.request.env, ['id', 'name', 'value', 'remarks']) as EnvItem,
@@ -98,7 +92,6 @@ export const updateEnv = async (
callback(e);
}
};
export const deleteEnvs = async (
call: ServerUnaryCall<DeleteEnvsRequest, Response>,
callback: sendUnaryData<Response>,
@@ -110,7 +103,6 @@ export const deleteEnvs = async (
message: 'ids parameter is required',
});
}
const envService = Container.get(EnvService);
await envService.remove(call.request.ids);
callback(null, { code: 200 });
@@ -118,7 +110,6 @@ export const deleteEnvs = async (
callback(e);
}
};
export const moveEnv = async (
call: ServerUnaryCall<MoveEnvRequest, EnvResponse>,
callback: sendUnaryData<EnvResponse>,
@@ -131,7 +122,6 @@ export const moveEnv = async (
message: 'id parameter is required',
});
}
const envService = Container.get(EnvService);
const data = await envService.move(call.request.id, {
fromIndex: call.request.fromIndex,
@@ -142,7 +132,6 @@ export const moveEnv = async (
callback(e);
}
};
export const disableEnvs = async (
call: ServerUnaryCall<DisableEnvsRequest, Response>,
callback: sendUnaryData<Response>,
@@ -154,7 +143,6 @@ export const disableEnvs = async (
message: 'ids parameter is required',
});
}
const envService = Container.get(EnvService);
await envService.disabled(call.request.ids);
callback(null, { code: 200 });
@@ -162,7 +150,6 @@ export const disableEnvs = async (
callback(e);
}
};
export const enableEnvs = async (
call: ServerUnaryCall<EnableEnvsRequest, Response>,
callback: sendUnaryData<Response>,
@@ -174,7 +161,6 @@ export const enableEnvs = async (
message: 'ids parameter is required',
});
}
const envService = Container.get(EnvService);
await envService.enabled(call.request.ids);
callback(null, { code: 200 });
@@ -182,7 +168,6 @@ export const enableEnvs = async (
callback(e);
}
};
export const updateEnvNames = async (
call: ServerUnaryCall<UpdateEnvNamesRequest, Response>,
callback: sendUnaryData<Response>,
@@ -194,7 +179,6 @@ export const updateEnvNames = async (
message: 'ids parameter is required',
});
}
const envService = Container.get(EnvService);
await envService.updateNames({
ids: call.request.ids,
@@ -205,7 +189,6 @@ export const updateEnvNames = async (
callback(e);
}
};
export const getEnvById = async (
call: ServerUnaryCall<GetEnvByIdRequest, EnvResponse>,
callback: sendUnaryData<EnvResponse>,
@@ -218,7 +201,6 @@ export const getEnvById = async (
message: 'id parameter is required',
});
}
const envService = Container.get(EnvService);
const data = await envService.getDb({ id: call.request.id });
callback(null, {
@@ -229,7 +211,6 @@ export const getEnvById = async (
callback(e);
}
};
export const systemNotify = async (
call: ServerUnaryCall<SystemNotifyRequest, Response>,
callback: sendUnaryData<Response>,
@@ -247,7 +228,6 @@ export const systemNotify = async (
callback(e);
}
};
export const recordNotifyLog = async (
call: ServerUnaryCall<RecordNotifyLogRequest, Response>,
callback: sendUnaryData<Response>,
@@ -267,12 +247,9 @@ export const recordNotifyLog = async (
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;
return {
...cron,
labels: cron.labels ?? [],
@@ -283,7 +260,6 @@ const normalizeCronData = (data: CronItem | null): CronItem | undefined => {
task_after: cron.task_after ?? undefined,
};
};
export const getCronDetail = async (
call: ServerUnaryCall<CronDetailRequest, CronDetailResponse>,
callback: sendUnaryData<CronDetailResponse>,
@@ -305,7 +281,6 @@ export const getCronDetail = async (
callback(e);
}
};
export const createCron = async (
call: ServerUnaryCall<CreateCronRequest, CronResponse>,
callback: sendUnaryData<CronResponse>,
@@ -318,7 +293,6 @@ export const createCron = async (
callback(e);
}
};
export const updateCron = async (
call: ServerUnaryCall<UpdateCronRequest, CronResponse>,
callback: sendUnaryData<CronResponse>,
@@ -326,7 +300,6 @@ export const updateCron = async (
try {
const cronService = Container.get(CronService);
const { id, ...fields } = call.request;
const updateRequest = {
id,
...Object.entries(fields).reduce((acc: any, [key, value]) => {
@@ -336,14 +309,12 @@ export const updateCron = async (
return acc;
}, {}),
} as UpdateCronRequest;
const data = (await cronService.update(updateRequest)) as CronItem;
callback(null, { code: 200, data: normalizeCronData(data) });
} catch (e: any) {
callback(e);
}
};
export const deleteCrons = async (
call: ServerUnaryCall<DeleteCronsRequest, Response>,
callback: sendUnaryData<Response>,
@@ -356,7 +327,6 @@ export const deleteCrons = async (
callback(e);
}
};
export const getCrons = async (
call: ServerUnaryCall<GetCronsRequest, CronsResponse>,
callback: sendUnaryData<CronsResponse>,
@@ -384,7 +354,6 @@ export const getCrons = async (
});
}
};
export const getCronById = async (
call: ServerUnaryCall<GetCronByIdRequest, CronResponse>,
callback: sendUnaryData<CronResponse>,
@@ -397,7 +366,6 @@ export const getCronById = async (
message: 'id parameter is required',
});
}
const cronService = Container.get(CronService);
const data = (await cronService.getDb({ id: call.request.id })) as CronItem;
callback(null, { code: 200, data: normalizeCronData(data) });
@@ -409,7 +377,6 @@ export const getCronById = async (
});
}
};
export const enableCrons = async (
call: ServerUnaryCall<EnableCronsRequest, Response>,
callback: sendUnaryData<Response>,
@@ -421,7 +388,6 @@ export const enableCrons = async (
message: 'ids parameter is required',
});
}
const cronService = Container.get(CronService);
await cronService.enabled(call.request.ids);
callback(null, { code: 200 });
@@ -429,7 +395,6 @@ export const enableCrons = async (
callback(e);
}
};
export const disableCrons = async (
call: ServerUnaryCall<DisableCronsRequest, Response>,
callback: sendUnaryData<Response>,
@@ -441,7 +406,6 @@ export const disableCrons = async (
message: 'ids parameter is required',
});
}
const cronService = Container.get(CronService);
await cronService.disabled(call.request.ids);
callback(null, { code: 200 });
@@ -449,7 +413,6 @@ export const disableCrons = async (
callback(e);
}
};
export const runCrons = async (
call: ServerUnaryCall<RunCronsRequest, Response>,
callback: sendUnaryData<Response>,
@@ -461,7 +424,6 @@ export const runCrons = async (
message: 'ids parameter is required',
});
}
const cronService = Container.get(CronService);
await cronService.run(call.request.ids);
callback(null, { code: 200 });
+16 -15
View File
@@ -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);
}
}
+2 -1
View File
@@ -4,6 +4,7 @@ import path from 'path';
import { request } from 'undici';
import { HealthCheckRequest, HealthCheckResponse } from '../protos/health';
import config from '../config';
import { database } from '../data';
function formatError(error: unknown): string {
if (!(error instanceof Error)) {
@@ -54,7 +55,7 @@ const check = async (
switch (call.request.service) {
// Local scheduler liveness only: never call HTTP from this probe.
case 'scheduler':
return callback(null, { status: 1 });
return callback(null, { status: database.healthy ? 1 : 2 });
case 'cron': {
const healthUrl = `http://localhost:${config.port}${
config.baseUrl || ''
+239 -289
View File
File diff suppressed because it is too large Load Diff
+19 -31
View File
@@ -1,3 +1,4 @@
import { Query, QuerySpec, orderBy } from '../data/queries';
import { Service, Inject } from 'typedi';
import winston from 'winston';
import { CrontabView, CrontabViewModel } from '../data/cronView';
@@ -7,12 +8,12 @@ import {
minPosition,
stepPosition,
} from '../data/env';
import { FindOptions } from 'sequelize';
@Service()
export default class CronViewService {
constructor(@Inject('logger') private logger: winston.Logger) {}
constructor(
@Inject('logger')
private logger: winston.Logger,
) {}
public async create(payload: CrontabView): Promise<CrontabView> {
let position = initPosition;
const views = await this.list();
@@ -22,61 +23,51 @@ export default class CronViewService {
position = position / 2;
const tab = new CrontabView({ ...payload, position });
const doc = await this.insert(tab);
await this.checkPosition(tab.position!);
return doc;
}
public async insert(payload: CrontabView): Promise<CrontabView> {
return await CrontabViewModel.create(payload, { returning: true });
return await CrontabViewModel.insert(payload);
}
public async update(payload: CrontabView): Promise<CrontabView> {
const doc = await this.getDb({ id: payload.id });
const tab = new CrontabView({ ...doc, ...payload });
const newDoc = await this.updateDb(tab);
return newDoc;
}
public async updateDb(payload: CrontabView): Promise<CrontabView> {
await CrontabViewModel.update(payload, { where: { id: payload.id } });
await CrontabViewModel.nativeUpdate({ id: payload.id }, payload);
return await this.getDb({ id: payload.id });
}
public async remove(ids: number[]) {
await CrontabViewModel.destroy({ where: { id: ids } });
await CrontabViewModel.nativeDelete({ id: ids });
}
public async list(): Promise<CrontabView[]> {
try {
const result = await CrontabViewModel.findAll({
where: {},
order: [['position', 'DESC']],
});
const result = await CrontabViewModel.find(
{},
{
orderBy: orderBy([['position', 'DESC']]),
},
);
return result;
} catch (error) {
throw error;
}
}
public async getDb(
query: FindOptions<CrontabView>['where'],
): Promise<CrontabView> {
const doc: any = await CrontabViewModel.findOne({ where: { ...query } });
public async getDb(query: QuerySpec['where']): Promise<CrontabView> {
const doc: any = await CrontabViewModel.findOne({ ...query }, {});
if (!doc) {
throw new Error(`CronView ${JSON.stringify(query)} not found`);
}
return doc.get({ plain: true });
return doc;
}
public async disabled(ids: number[]) {
await CrontabViewModel.update({ isDisabled: 1 }, { where: { id: ids } });
await CrontabViewModel.nativeUpdate({ id: ids }, { isDisabled: 1 });
}
public async enabled(ids: number[]) {
await CrontabViewModel.update({ isDisabled: 0 }, { where: { id: ids } });
await CrontabViewModel.nativeUpdate({ id: ids }, { isDisabled: 0 });
}
private async checkPosition(position: number) {
const precisionPosition = parseFloat(position.toPrecision(16));
if (precisionPosition < minPosition || precisionPosition > maxPosition) {
@@ -88,11 +79,9 @@ export default class CronViewService {
}
}
}
private getPrecisionPosition(position: number): number {
return parseFloat(position.toPrecision(16));
}
public async move({
id,
fromIndex,
@@ -118,7 +107,6 @@ export default class CronViewService {
id,
position: this.getPrecisionPosition(targetPosition),
});
await this.checkPosition(targetPosition);
return newDoc;
}
+48 -93
View File
@@ -1,3 +1,5 @@
import { textSearch } from '../data/queries';
import { Query, QuerySpec, orderBy } from '../data/queries';
import { Service, Inject } from 'typedi';
import winston from 'winston';
import config from '../config';
@@ -10,7 +12,6 @@ import {
} from '../data/dependence';
import { spawn } from 'cross-spawn';
import SockService from './sock';
import { FindOptions, Op } from 'sequelize';
import {
fileExist,
getPid,
@@ -27,14 +28,13 @@ import taskLimit from '../shared/pLimit';
import { detectOS } from '../config/util';
import { LINUX_DEPENDENCE_COMMAND } from '../config/const';
import { t, tf } from '../shared/i18n';
@Service()
export default class DependenceService {
constructor(
@Inject('logger') private logger: winston.Logger,
@Inject('logger')
private logger: winston.Logger,
private sockService: SockService,
) {}
public async create(payloads: Dependence[]): Promise<Dependence[]> {
const tabs = payloads.map((x) => {
getInstallCommand(x.type, x.name);
@@ -45,14 +45,14 @@ export default class DependenceService {
this.installDependenceOneByOne(docs);
return docs;
}
public async insert(payloads: Dependence[]): Promise<Dependence[]> {
const docs = await DependenceModel.bulkCreate(payloads);
const docs = await DependenceModel.insertMany(payloads);
return docs;
}
public async update(
payload: Dependence & { id: string },
payload: Dependence & {
id: string;
},
): Promise<Dependence> {
const { id, ...other } = payload;
const doc = await this.getDb({ id });
@@ -66,14 +66,12 @@ export default class DependenceService {
this.installDependenceOneByOne([newDoc]);
return newDoc;
}
private async updateDb(payload: Dependence): Promise<Dependence> {
await DependenceModel.update(payload, { where: { id: payload.id } });
await DependenceModel.nativeUpdate({ id: payload.id }, payload);
return await this.getDb({ id: payload.id });
}
public async remove(ids: number[], force = false): Promise<Dependence[]> {
const docs = await DependenceModel.findAll({ where: { id: ids } });
const docs = await DependenceModel.find({ id: ids }, {});
for (const doc of docs) {
taskLimit.removeQueuedDependency(doc);
}
@@ -88,22 +86,18 @@ export default class DependenceService {
);
const unInstalledDeps = docs.filter((x) => !installedDeps.includes(x));
await this.removeDb(unInstalledDeps.map((x) => x.id!));
if (installedDeps.length) {
await DependenceModel.update(
await DependenceModel.nativeUpdate(
{ id: installedDeps.map((x) => x.id!) },
{ status: DependenceStatus.queued, log: [] },
{ where: { id: installedDeps.map((x) => x.id!) } },
);
this.installDependenceOneByOne(installedDeps, false, force);
}
return docs;
}
public async removeDb(ids: number[]) {
await DependenceModel.destroy({ where: { id: ids } });
await DependenceModel.nativeDelete({ id: ids });
}
public async dependencies(
{
searchValue,
@@ -130,13 +124,7 @@ export default class DependenceService {
condition.status = status.split(',').map(Number);
}
if (searchValue) {
const encodeText = encodeURI(searchValue);
condition.name = {
[Op.or]: [
{ [Op.like]: `%${searchValue}%` },
{ [Op.like]: `%${encodeText}%` },
],
};
Object.assign(condition, textSearch(['name'], searchValue));
}
try {
return await this.find(condition, sort);
@@ -144,7 +132,6 @@ export default class DependenceService {
throw error;
}
}
private async refreshInstalledStatuses(type?: DependenceTypes) {
const cacheDependenceTypes = [
DependenceTypes.nodejs,
@@ -153,13 +140,13 @@ export default class DependenceService {
if (type !== undefined && !cacheDependenceTypes.includes(type)) {
return;
}
const docs = await DependenceModel.findAll({
where: {
const docs = await DependenceModel.find(
{
status: DependenceStatus.installed,
type: type === undefined ? { [Op.in]: cacheDependenceTypes } : type,
type: type === undefined ? { ['$in']: cacheDependenceTypes } : type,
},
});
{},
);
const checks = await concurrentRun(
docs
.filter(
@@ -175,15 +162,13 @@ export default class DependenceService {
const missingIds = (checks || []).filter(
(id): id is number => id !== undefined,
);
if (missingIds.length) {
await DependenceModel.update(
await DependenceModel.nativeUpdate(
{ id: missingIds },
{ status: DependenceStatus.installFailed },
{ where: { id: missingIds } },
);
}
}
private async isDependenceInstalled(dependency: Dependence) {
let depName = dependency.name.trim();
const depVersionStr = versionDependenceCommandTypes[dependency.type];
@@ -201,7 +186,6 @@ export default class DependenceService {
depVersion = parsedVersion;
}
}
const depInfo = (
await promiseExecSuccess(getGetCommand(dependency.type, depName))
)
@@ -211,12 +195,10 @@ export default class DependenceService {
(dependency.type === DependenceTypes.nodejs &&
depInfo.split(' ')?.[0] === depName) ||
dependency.type === DependenceTypes.python3;
return Boolean(
depInfo && nameMatches && (!depVersion || depInfo.includes(depVersion)),
);
}
public installDependenceOneByOne(
docs: Dependence[],
isInstall: boolean = true,
@@ -225,32 +207,27 @@ export default class DependenceService {
docs.forEach((dep) => {
this.installOrUninstallDependency(dep, isInstall, force);
});
return taskLimit.waitDependencyQueueDone();
}
public async reInstall(ids: number[]): Promise<Dependence[]> {
await DependenceModel.update(
await DependenceModel.nativeUpdate(
{ id: ids },
{ status: DependenceStatus.queued, log: [] },
{ where: { id: ids } },
);
const docs = await DependenceModel.findAll({ where: { id: ids } });
const docs = await DependenceModel.find({ id: ids }, {});
for (const doc of docs) {
taskLimit.removeQueuedDependency(doc);
}
this.installDependenceOneByOne(docs, true, true);
return docs;
}
public async cancel(ids: number[]) {
const docs = await DependenceModel.findAll({ where: { id: ids } });
const docs = await DependenceModel.find({ id: ids }, {});
for (const doc of docs) {
taskLimit.removeQueuedDependency(doc);
let depInstallCommand = getInstallCommand(doc.type, doc.name);
let depUnInstallCommand = getUninstallCommand(doc.type, doc.name);
const isLinuxDependence = doc.type === DependenceTypes.linux;
if (isLinuxDependence) {
const osType = await detectOS();
if (!osType) {
@@ -268,44 +245,37 @@ export default class DependenceService {
pid && (await killTask(pid));
}
}
await DependenceModel.update(
await DependenceModel.nativeUpdate(
{ id: ids },
{ status: DependenceStatus.cancelled },
{ where: { id: ids } },
);
}
private async find(query: any, sort: any = []): Promise<Dependence[]> {
const docs = await DependenceModel.findAll({
where: { ...query },
order: [...sort, ['createdAt', 'DESC']],
});
const docs = await DependenceModel.find(
{ ...query },
{
orderBy: orderBy([...sort, ['createdAt', 'DESC']]),
},
);
return docs;
}
public async getDb(
query: FindOptions<Dependence>['where'],
): Promise<Dependence> {
const doc: any = await DependenceModel.findOne({ where: { ...query } });
public async getDb(query: QuerySpec['where']): Promise<Dependence> {
const doc: any = await DependenceModel.findOne({ ...query }, {});
if (!doc) {
throw new Error(`Dependency ${JSON.stringify(query)} not found`);
}
return doc.get({ plain: true });
return doc;
}
private async updateLog(ids: number[], log: string): Promise<void> {
taskLimit.updateDepLog(async () => {
const docs = await DependenceModel.findAll({ where: { id: ids } });
const docs = await DependenceModel.find({ id: ids }, {});
for (const doc of docs) {
const newLog = doc?.log ? [...doc.log, log] : [log];
await DependenceModel.update(
{ log: newLog },
{ where: { id: doc.id } },
);
await DependenceModel.nativeUpdate({ id: doc.id }, { log: newLog });
}
return null;
});
}
public installOrUninstallDependency(
dependency: Dependence,
isInstall: boolean = true,
@@ -322,14 +292,14 @@ export default class DependenceService {
if (!isInstall && force) {
return this.removeDb([dependency.id!]).then(() => null);
}
return DependenceModel.update(
return DependenceModel.nativeUpdate(
{ id: dependency.id },
{
status: isInstall
? DependenceStatus.installFailed
: DependenceStatus.removeFailed,
log: [String(error)],
},
{ where: { id: dependency.id } },
).then(() => null);
}
return new Promise(async (resolve) => {
@@ -350,9 +320,9 @@ export default class DependenceService {
taskLimit.removeQueuedDependency(dependency);
if (isLinuxDependence) {
if (!osType) {
await DependenceModel.update(
await DependenceModel.nativeUpdate(
{ id: depIds },
{ status: DependenceStatus.installFailed },
{ where: { id: depIds } },
);
const startTime = dayjs();
const message = tf(
@@ -375,12 +345,10 @@ export default class DependenceService {
}
linuxCommand = LINUX_DEPENDENCE_COMMAND[osType];
}
const status = isInstall
? DependenceStatus.installing
: DependenceStatus.removing;
await DependenceModel.update({ status }, { where: { id: depIds } });
await DependenceModel.nativeUpdate({ id: depIds }, { status });
let command = validatedCommand;
if (isLinuxDependence) {
command = isInstall
@@ -388,7 +356,6 @@ export default class DependenceService {
: `${linuxCommand.uninstall} ${depName.trim()}`;
}
const startTime = dayjs();
const message = tf(
'开始%s依赖 %s,开始时间 %s\n\n',
actionText,
@@ -402,7 +369,6 @@ export default class DependenceService {
status,
});
this.updateLog(depIds, message);
// 判断是否已经安装过依赖
// pip must resolve optional dependencies even if the base distribution
// is already present; its version alone cannot prove extras are ready.
@@ -416,7 +382,6 @@ export default class DependenceService {
if (isLinuxDependence) {
getCommand = `${linuxCommand.info} ${depName}`;
}
let depVersion = '';
if (!isPythonDependence && depName.includes(depVersionStr)) {
const symbolRegx = new RegExp(
@@ -431,7 +396,6 @@ export default class DependenceService {
const depInfo = (await promiseExecSuccess(getCommand))
.replace(/\s{2,}/, ' ')
.replace(/\s+$/, '');
if (
depInfo &&
((isNodeDependence && depInfo.split(' ')?.[0] === depName) ||
@@ -456,9 +420,9 @@ export default class DependenceService {
status: DependenceStatus.installed,
});
this.updateLog(depIds, _message);
await DependenceModel.update(
await DependenceModel.nativeUpdate(
{ id: depIds },
{ status: DependenceStatus.installed },
{ where: { id: depIds } },
);
return resolve(null);
}
@@ -472,7 +436,6 @@ export default class DependenceService {
const cp = spawn(`${proxyStr} ${command}`, {
shell: '/bin/bash',
});
cp.stdout.on('data', async (data) => {
this.sockService.sendMessage({
type: socketMessageType,
@@ -482,7 +445,6 @@ export default class DependenceService {
});
this.updateLog(depIds, data.toString());
});
cp.stderr.on('data', async (data) => {
this.sockService.sendMessage({
type: socketMessageType,
@@ -492,7 +454,6 @@ export default class DependenceService {
});
this.updateLog(depIds, data.toString());
});
cp.on('error', async (err) => {
this.sockService.sendMessage({
type: socketMessageType,
@@ -502,12 +463,10 @@ export default class DependenceService {
});
this.updateLog(depIds, JSON.stringify(err));
});
cp.on('exit', async (code) => {
const endTime = dayjs();
const isSucceed = code === 0;
const resultText = isSucceed ? t('成功') : t('失败');
const message =
'\n' +
tf(
@@ -531,24 +490,20 @@ export default class DependenceService {
status: exitStatus,
});
this.updateLog(depIds, message);
const docs = await DependenceModel.findAll({ where: { id: depIds } });
const docs = await DependenceModel.find({ id: depIds }, {});
const _docIds = docs
.filter((x) => x.status !== DependenceStatus.cancelled)
.map((x) => x.id!);
if (_docIds.length > 0) {
await DependenceModel.update(
await DependenceModel.nativeUpdate(
{ id: _docIds },
{ status: exitStatus },
{ where: { id: _docIds } },
);
}
// 如果删除依赖成功或者强制删除
if ((isSucceed || force) && !isInstall) {
this.removeDb(depIds);
}
resolve(null);
});
});
+41 -85
View File
@@ -1,5 +1,6 @@
import { textSearch } from '../data/queries';
import { Query, QuerySpec, orderBy } from '../data/queries';
import groupBy from 'lodash/groupBy';
import { FindOptions, Op } from 'sequelize';
import { Inject, Service } from 'typedi';
import winston from 'winston';
import config from '../config';
@@ -13,12 +14,13 @@ import {
stepPosition,
} from '../data/env';
import { writeFileWithLock } from '../shared/utils';
import { sequelize } from '../data';
import { database } from '../data';
@Service()
export default class EnvService {
constructor(@Inject('logger') private logger: winston.Logger) { }
constructor(
@Inject('logger')
private logger: winston.Logger,
) {}
public async create(payloads: Env[]): Promise<Env[]> {
const envs = await this.envs();
let position = initPosition;
@@ -39,16 +41,14 @@ export default class EnvService {
await this.checkPosition(tabs[tabs.length - 1].position!);
return docs;
}
public async insert(payloads: Env[]): Promise<Env[]> {
const result: Env[] = [];
for (const env of payloads) {
const doc = await EnvModel.create(env, { returning: true });
const doc = await EnvModel.insert(env);
result.push(doc);
}
return result;
}
public async update(payload: Env): Promise<Env> {
const doc = await this.getDb({ id: payload.id });
const tab = new Env({ ...doc, ...payload });
@@ -56,17 +56,14 @@ export default class EnvService {
await this.set_envs();
return newDoc;
}
private async updateDb(payload: Env): Promise<Env> {
await EnvModel.update({ ...payload }, { where: { id: payload.id } });
await EnvModel.nativeUpdate({ id: payload.id }, { ...payload });
return await this.getDb({ id: payload.id });
}
public async remove(ids: number[]) {
await EnvModel.destroy({ where: { id: ids } });
await EnvModel.nativeDelete({ id: ids });
await this.set_envs();
}
public async move(
id: number,
{
@@ -89,16 +86,13 @@ export default class EnvService {
? (envs[toIndex].position! + envs[toIndex - 1].position!) / 2
: (envs[toIndex].position! + envs[toIndex + 1].position!) / 2;
}
const newDoc = await this.update({
id,
position: this.getPrecisionPosition(targetPosition),
});
await this.checkPosition(targetPosition, envs[toIndex].position!);
return newDoc;
}
private async checkPosition(position: number, edge: number = 0) {
const precisionPosition = this.getPrecisionPosition(position);
if (
@@ -114,40 +108,20 @@ export default class EnvService {
}
}
}
private getPrecisionPosition(position: number): number {
return Math.trunc(parseFloat(position.toPrecision(16)));
}
public async envs(searchText: string = '', query: any = {}): Promise<Env[]> {
let condition = { ...query };
if (searchText) {
const encodeText = encodeURI(searchText);
const reg = {
[Op.or]: [
{ [Op.like]: `%${searchText}%` },
{ [Op.like]: `%${encodeText}%` },
],
};
condition = {
...condition,
[Op.or]: [
{
name: reg,
},
{
value: reg,
},
{
remarks: reg,
},
],
...textSearch(['name', 'value', 'remarks'], searchText),
};
}
try {
const result = await this.find(condition, [
[sequelize.literal('COALESCE(`isPinned`, 0)'), 'DESC'],
['isPinned', 'DESC'],
['position', 'DESC'],
['createdAt', 'ASC'],
]);
@@ -156,90 +130,73 @@ export default class EnvService {
throw error;
}
}
private async find(query: any, sort: any = []): Promise<Env[]> {
const docs = await EnvModel.findAll({
where: { ...query },
order: [...sort],
});
return docs.map((x) => x.get({ plain: true }));
const docs = await EnvModel.find(
{ ...query },
{
orderBy: orderBy([...sort]),
},
);
return docs.map((x) => x);
}
public async getDb(query: FindOptions<Env>['where']): Promise<Env> {
const doc: any = await EnvModel.findOne({ where: { ...query } });
public async getDb(query: QuerySpec['where']): Promise<Env> {
const doc: any = await EnvModel.findOne({ ...query }, {});
if (!doc) {
throw new Error(`Env ${JSON.stringify(query)} not found`);
}
return doc.get({ plain: true });
return doc;
}
public async disabled(ids: number[]) {
await EnvModel.update(
{ status: EnvStatus.disabled },
{ where: { id: ids } },
);
await EnvModel.nativeUpdate({ id: ids }, { status: EnvStatus.disabled });
await this.set_envs();
}
public async enabled(ids: number[]) {
await EnvModel.update({ status: EnvStatus.normal }, { where: { id: ids } });
await EnvModel.nativeUpdate({ id: ids }, { status: EnvStatus.normal });
await this.set_envs();
}
public async updateNames({ ids, name }: { ids: number[]; name: string }) {
await EnvModel.update({ name }, { where: { id: ids } });
await EnvModel.nativeUpdate({ id: ids }, { name });
await this.set_envs();
}
public async pin(ids: number[]) {
await EnvModel.update({ isPinned: 1 }, { where: { id: ids } });
await EnvModel.nativeUpdate({ id: ids }, { isPinned: 1 });
}
public async unPin(ids: number[]) {
await EnvModel.update({ isPinned: 0 }, { where: { id: ids } });
await EnvModel.nativeUpdate({ id: ids }, { isPinned: 0 });
}
public async addLabels(ids: number[], labels: string[]) {
await sequelize.transaction(async (transaction) => {
const docs = await EnvModel.findAll({
where: { id: ids },
transaction,
});
await database.transaction(async () => {
const docs = await EnvModel.find({ id: ids }, {});
for (const doc of docs) {
const env = doc.get({ plain: true });
await EnvModel.update(
const env = doc;
await EnvModel.nativeUpdate(
{ id: env.id },
{ labels: Array.from(new Set([...(env.labels || []), ...labels])) },
{ where: { id: env.id }, transaction },
);
}
});
return await this.find({ id: ids });
}
public async removeLabels(ids: number[], labels: string[]) {
await sequelize.transaction(async (transaction) => {
const docs = await EnvModel.findAll({
where: { id: ids },
transaction,
});
await database.transaction(async () => {
const docs = await EnvModel.find({ id: ids }, {});
for (const doc of docs) {
const env = doc.get({ plain: true });
await EnvModel.update(
const env = doc;
await EnvModel.nativeUpdate(
{ id: env.id },
{
labels: (env.labels || []).filter(
(label: string) => !labels.includes(label),
),
},
{ where: { id: env.id }, transaction },
);
}
});
return await this.find({ id: ids });
}
public async set_envs() {
const envs = await this.envs('', {
name: { [Op.not]: null },
name: { ['$ne']: null },
status: EnvStatus.normal,
});
const groups = groupBy(envs, 'name');
@@ -249,7 +206,6 @@ export default class EnvService {
for (const key in groups) {
if (Object.prototype.hasOwnProperty.call(groups, key)) {
const group = groups[key];
// 忽略不符合bash要求的环境变量名称
if (/^[a-zA-Z_][0-9a-zA-Z_]*$/.test(key)) {
let value = group
@@ -262,9 +218,9 @@ export default class EnvService {
.map((x) => x.value)
.join('&')
.replace(/\\/g, '\\\\')}`;
js_env_string += `process.env[${JSON.stringify(key)}]=${JSON.stringify(
group.map((x) => x.value).join('&'),
)};\n`;
js_env_string += `process.env[${JSON.stringify(
key,
)}]=${JSON.stringify(group.map((x) => x.value).join('&'))};\n`;
py_env_string += `os.environ['${key}']='''${_env_value.replace(
/\'/g,
"\\'",
+2 -2
View File
@@ -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);
+8 -3
View File
@@ -1,13 +1,17 @@
import { database } from '../data';
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;
grpc: boolean;
database: boolean;
};
metrics: {
uptime: number;
@@ -22,16 +26,16 @@ 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,
grpc: true,
database: database.healthy,
},
metrics: {
uptime: Math.floor((Date.now() - this.startTime) / 1000),
@@ -65,6 +69,7 @@ export class HealthService {
Logger.error('gRPC server check failed:', err);
}
if (!status.services.database) status.status = 'error';
return status;
}
}
+2 -1
View File
@@ -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;
}
}
+21 -49
View File
@@ -1,9 +1,9 @@
import { textSearch } from '../data/queries';
import { Service, Inject } from 'typedi';
import winston from 'winston';
import { createRandomString } from '../config/util';
import { App, AppModel } from '../data/open';
import { v4 as uuidV4 } from 'uuid';
import sequelize, { Op } from 'sequelize';
import { shareStore } from '../shared/store';
import { t } from '../shared/i18n';
import { lock } from 'proper-lockfile';
@@ -11,18 +11,19 @@ import path from 'path';
import os from 'os';
import { writeFile, rename, rm, stat, chmod } from 'fs/promises';
import config from '../config';
@Service()
export default class OpenService {
constructor(@Inject('logger') private logger: winston.Logger) {}
constructor(
@Inject('logger')
private logger: winston.Logger,
) {}
// Every database mutation and full authentication-cache publication shares
// this cross-process lock. Lock a separate marker, not token.json itself.
private async withAppsLock<T>(operation: () => Promise<T>): Promise<T> {
const target = path.join(config.configPath, '.apps-state');
const release = await lock(target, {
realpath: false,
stale: 10_000,
stale: 10000,
retries: { retries: 10, factor: 2, minTimeout: 100, maxTimeout: 3000 },
});
try {
@@ -31,11 +32,10 @@ export default class OpenService {
await release();
}
}
public async initializeSystemApp(): Promise<void> {
await this.withAppsLock(async () => {
const doc = await AppModel.findOne({ where: { name: 'system' } });
const app = doc?.get({ plain: true });
const doc = await AppModel.findOne({ name: 'system' }, {});
const app = doc;
if (!app) {
await this.insert({
name: 'system',
@@ -44,26 +44,23 @@ export default class OpenService {
client_secret: createRandomString(24, 24),
});
} else if (!app.scopes.includes('dashboard')) {
await AppModel.update(
await AppModel.nativeUpdate(
{ id: app.id },
{ scopes: [...app.scopes, 'dashboard'] },
{ where: { id: app.id } },
);
}
await shareStore.updateApps(await this.find({}));
});
}
public async refreshApps(): Promise<void> {
await this.withAppsLock(async () => {
await shareStore.updateApps(await this.find({}));
});
}
public async findApps(): Promise<App[] | null> {
const docs = await this.find({});
return docs;
}
public async create(payload: App): Promise<App> {
return this.withAppsLock(async () => {
const tab = { ...payload };
@@ -75,12 +72,10 @@ export default class OpenService {
return { ...doc, tokens: [] };
});
}
private async insert(payload: App): Promise<App> {
const doc = await AppModel.create(payload, { returning: true });
return doc.get({ plain: true });
const doc = await AppModel.insert(payload);
return doc;
}
public async update(payload: App): Promise<App> {
const newDoc = await this.updateDb({
name: payload.name,
@@ -89,32 +84,28 @@ export default class OpenService {
} as App);
return { ...newDoc, tokens: [] };
}
private async updateDb(payload: Partial<App>): Promise<App> {
return this.withAppsLock(async () => {
await AppModel.update(payload, { where: { id: payload.id } });
await AppModel.nativeUpdate({ id: payload.id }, payload);
const apps = await this.find({});
await shareStore.updateApps(apps);
return apps?.find((x) => x.id === payload.id) as App;
});
}
public async getDb(query: Record<string, any>): Promise<App> {
const doc = await AppModel.findOne({ where: query });
const doc = await AppModel.findOne(query, {});
if (!doc) {
throw new Error(`App ${JSON.stringify(query)} not found`);
}
return doc.get({ plain: true });
return doc;
}
public async remove(ids: number[]) {
return this.withAppsLock(async () => {
await AppModel.destroy({ where: { id: ids } });
await AppModel.nativeDelete({ id: ids });
const apps = await this.find({});
await shareStore.updateApps(apps);
});
}
public async resetSecret(id: number): Promise<App> {
const tab: Partial<App> = {
client_secret: createRandomString(24, 24),
@@ -130,7 +121,6 @@ export default class OpenService {
const newDoc = await this.updateDb(tab);
return newDoc;
}
public async list(
searchText: string = '',
sort: any = {},
@@ -138,18 +128,7 @@ export default class OpenService {
): Promise<App[]> {
let condition = { ...query };
if (searchText) {
const encodeText = encodeURI(searchText);
const reg = {
[Op.or]: [
{ [Op.like]: `%${searchText}%` },
{ [Op.like]: `%${encodeText}%` },
],
};
condition = {
...condition,
name: reg,
};
condition = { ...condition, ...textSearch(['name'], searchText) };
}
try {
const result = await this.find(condition);
@@ -160,19 +139,16 @@ export default class OpenService {
throw error;
}
}
private async find(query: Record<string, any>, sort?: any): Promise<App[]> {
const docs = await AppModel.findAll({ where: { ...query } });
return docs.map((x) => x.get({ plain: true }));
const docs = await AppModel.find({ ...query }, {});
return docs.map((x) => x);
}
public async authToken(credentials: {
client_id: string;
client_secret: string;
}): Promise<any> {
return this.withAppsLock(() => this.issueToken(credentials));
}
// Caller holds withAppsLock through database update and cache publication.
private async issueToken({
client_id,
@@ -183,7 +159,7 @@ export default class OpenService {
}): Promise<any> {
let token = uuidV4();
const expiration = Math.round(Date.now() / 1000) + 2592000; // 2592000 30天
const doc = await AppModel.findOne({ where: { client_id, client_secret } });
const doc = await AppModel.findOne({ client_id, client_secret }, {});
if (doc) {
const timestamp = Math.round(Date.now() / 1000);
const invalidTokens = (doc.tokens || []).filter(
@@ -199,10 +175,7 @@ export default class OpenService {
} else {
tokens = [...invalidTokens, { value: token, expiration }];
}
await AppModel.update(
{ tokens },
{ where: { client_id, client_secret } },
);
await AppModel.nativeUpdate({ client_id, client_secret }, { tokens });
const apps = await this.find({});
await shareStore.updateApps(apps);
return {
@@ -217,7 +190,6 @@ export default class OpenService {
return { code: 400, message: t('client_id 或 client_seret 有误') };
}
}
public async generateSystemToken(renew = false): Promise<{
value: string;
expiration: number;
+25 -41
View File
@@ -1,11 +1,10 @@
import fs from 'fs/promises';
import path from 'path';
import dayjs from 'dayjs';
import { Op } from 'sequelize';
import { Service } from 'typedi';
import config from '../config';
import { CrontabStatModel } from '../data/cronStats';
import { sequelize } from '../data';
import { database } from '../data';
import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance';
import { AuthDataType, SystemModel } from '../data/system';
import {
@@ -15,54 +14,50 @@ import {
normalizeRetentionPolicy,
RetentionPolicy,
} from '../shared/retention';
export interface StorageCleanupRequest extends RetentionPolicy {
dependenceCacheTypes?: DependenceCacheType[];
compactDatabase?: boolean;
}
function runningInstanceWhere(days: number) {
const cutoff = dayjs().subtract(days, 'day').unix();
return {
status: { [Op.ne]: InstanceStatus.running },
[Op.or]: [
{ finished_at: { [Op.lt]: cutoff } },
status: { ['$ne']: InstanceStatus.running },
['$or']: [
{ finished_at: { ['$lt']: cutoff } },
{
finished_at: { [Op.is]: null },
started_at: { [Op.lt]: cutoff },
finished_at: { ['$eq']: null },
started_at: { ['$lt']: cutoff },
},
],
};
}
function cronStatWhere(days: number) {
return {
date: { [Op.lt]: dayjs().subtract(days, 'day').format('YYYY-MM-DD') },
date: { ['$lt']: dayjs().subtract(days, 'day').format('YYYY-MM-DD') },
};
}
@Service()
export default class RetentionService {
public async updatePolicy(policy: Partial<RetentionPolicy>) {
const normalized = normalizeRetentionPolicy(policy);
const systemConfig = await SystemModel.findOne({
where: { type: AuthDataType.systemConfig },
});
const systemConfig = await SystemModel.findOne(
{ type: AuthDataType.systemConfig },
{},
);
if (!systemConfig) {
throw new Error('System config not found');
}
await SystemModel.update(
await SystemModel.nativeUpdate(
{ id: systemConfig.id },
{
info: {
...systemConfig.info,
...normalized,
},
},
{ where: { id: systemConfig.id } },
);
return normalized;
}
public async preview(request: StorageCleanupRequest) {
const policy = normalizeRetentionPolicy(request);
const dependenceCacheTypes = (request.dependenceCacheTypes || []).filter(
@@ -70,14 +65,12 @@ export default class RetentionService {
);
const [runningInstances, cronStats, dependenceCaches] = await Promise.all([
policy.runningInstanceRetentionDays > 0
? RunningInstanceModel.count({
where: runningInstanceWhere(policy.runningInstanceRetentionDays),
})
? RunningInstanceModel.count(
runningInstanceWhere(policy.runningInstanceRetentionDays),
)
: 0,
policy.cronStatRetentionDays > 0
? CrontabStatModel.count({
where: cronStatWhere(policy.cronStatRetentionDays),
})
? CrontabStatModel.count(cronStatWhere(policy.cronStatRetentionDays))
: 0,
Promise.all(
dependenceCacheTypes.map(async (type) => ({
@@ -88,7 +81,6 @@ export default class RetentionService {
})),
),
]);
return {
policy,
runningInstances,
@@ -101,29 +93,23 @@ export default class RetentionService {
compactDatabase: Boolean(request.compactDatabase),
};
}
public async cleanup(request: StorageCleanupRequest) {
const preview = await this.preview(request);
const deleted = await sequelize.transaction(async (transaction) => {
const deleted = await database.transaction(async () => {
const runningInstances =
preview.policy.runningInstanceRetentionDays > 0
? await RunningInstanceModel.destroy({
where: runningInstanceWhere(
preview.policy.runningInstanceRetentionDays,
),
transaction,
})
? await RunningInstanceModel.nativeDelete(
runningInstanceWhere(preview.policy.runningInstanceRetentionDays),
)
: 0;
const cronStats =
preview.policy.cronStatRetentionDays > 0
? await CrontabStatModel.destroy({
where: cronStatWhere(preview.policy.cronStatRetentionDays),
transaction,
})
? await CrontabStatModel.nativeDelete(
cronStatWhere(preview.policy.cronStatRetentionDays),
)
: 0;
return { runningInstances, cronStats };
});
const dependenceCaches = [];
for (const cache of preview.dependenceCaches) {
await fs.rm(path.join(config.dependenceCachePath, cache.type), {
@@ -132,14 +118,12 @@ export default class RetentionService {
});
dependenceCaches.push(cache);
}
if (
request.compactDatabase &&
(deleted.runningInstances || deleted.cronStats)
) {
await sequelize.query('VACUUM');
await database.query('VACUUM');
}
return {
deleted: {
...deleted,
+13 -12
View File
@@ -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
View File
@@ -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,
+70 -101
View File
@@ -1,3 +1,5 @@
import { textSearch } from '../data/queries';
import { Query, QuerySpec, orderBy } from '../data/queries';
import { Service, Inject } from 'typedi';
import winston from 'winston';
import config from '../config';
@@ -19,7 +21,6 @@ import {
rmPath,
} from '../config/util';
import fs from 'fs/promises';
import { FindOptions, Op } from 'sequelize';
import path from 'path';
import ScheduleService, { TaskCallbacks } from './schedule';
import { SimpleIntervalSchedule } from 'toad-scheduler';
@@ -42,17 +43,16 @@ import CrontabService from './cron';
import taskLimit from '../shared/pLimit';
import { logStreamManager } from '../shared/logStreamManager';
import { LogReadOptions, readLogChunk } from '../shared/logReader';
@Service()
export default class SubscriptionService {
constructor(
@Inject('logger') private logger: winston.Logger,
@Inject('logger')
private logger: winston.Logger,
private scheduleService: ScheduleService,
private sockService: SockService,
private sshKeyService: SshKeyService,
private crontabService: CrontabService,
) {}
public async list(
searchText?: string,
ids?: string,
@@ -60,37 +60,23 @@ export default class SubscriptionService {
let query = {};
const subIds = JSON.parse(ids || '[]');
if (searchText) {
const reg = {
[Op.or]: [
{ [Op.like]: `%${searchText}%` },
{ [Op.like]: `%${encodeURI(searchText)}%` },
],
};
query = {
[Op.or]: [
{
name: reg,
},
{
url: reg,
},
],
};
query = textSearch(['name', 'url'], searchText);
}
try {
const result = await SubscriptionModel.findAll({
where: { ...query, ...(ids ? { id: subIds } : undefined) },
order: [
['is_disabled', 'ASC'],
['createdAt', 'DESC'],
],
});
const result = await SubscriptionModel.find(
{ ...query, ...(ids ? { id: subIds } : undefined) },
{
orderBy: orderBy([
['is_disabled', 'ASC'],
['createdAt', 'DESC'],
]),
},
);
return result;
} catch (error) {
throw error;
}
}
public async handleTask(
doc: Subscription,
needCreate = true,
@@ -101,7 +87,6 @@ export default class SubscriptionService {
const { url } = formatUrl(doc);
doc.command = formatCommand(doc, url as string);
}
if (doc.schedule_type === 'crontab') {
this.scheduleService.cancelCronTask(doc as any);
needCreate &&
@@ -122,34 +107,34 @@ export default class SubscriptionService {
));
}
}
public async setSshConfig() {
const docs = await SubscriptionModel.findAll();
const docs = await SubscriptionModel.find({}, {});
await this.sshKeyService.setSshConfig(docs);
}
private taskCallbacks(doc: Subscription): TaskCallbacks {
return {
onBefore: async (startTime) => {
const logTime = startTime.format('YYYY-MM-DD-HH-mm-ss');
const logPath = `${doc.alias}/${logTime}.log`;
await SubscriptionModel.update(
await SubscriptionModel.nativeUpdate(
{ id: doc.id },
{
status: SubscriptionStatus.running,
log_path: logPath,
},
{ where: { id: doc.id } },
);
const absolutePath = await handleLogPath(
logPath as string,
tf('## 开始执行... %s\n', startTime.format('YYYY-MM-DD HH:mm:ss')),
);
// 执行sub_before
let beforeStr = '';
try {
if (doc.sub_before) {
await logStreamManager.write(absolutePath, `\n## ${t('执行before命令...')}\n\n`);
await logStreamManager.write(
absolutePath,
`\n## ${t('执行before命令...')}\n\n`,
);
beforeStr = await promiseExec(doc.sub_before);
}
} catch (error: any) {
@@ -161,11 +146,11 @@ export default class SubscriptionService {
}
},
onStart: async (cp: ChildProcessWithoutNullStreams, startTime) => {
await SubscriptionModel.update(
await SubscriptionModel.nativeUpdate(
{ id: doc.id },
{
pid: cp.pid,
},
{ where: { id: doc.id } },
);
},
onEnd: async (cp, endTime, diff) => {
@@ -173,7 +158,6 @@ export default class SubscriptionService {
try {
const sub = await this.getDb({ id: doc.id });
absolutePath = await handleLogPath(sub.log_path as string);
// 执行 sub_after
let afterStr = '';
try {
@@ -186,12 +170,12 @@ export default class SubscriptionService {
}
} catch (error: any) {
afterStr =
(error.stderr && error.stderr.toString()) || JSON.stringify(error);
(error.stderr && error.stderr.toString()) ||
JSON.stringify(error);
}
if (afterStr) {
await logStreamManager.write(absolutePath, `${afterStr}\n`);
}
await logStreamManager.write(
absolutePath,
'\n' +
@@ -206,10 +190,10 @@ export default class SubscriptionService {
try {
if (absolutePath) await logStreamManager.closeStream(absolutePath);
} finally {
await SubscriptionModel.update(
{ status: SubscriptionStatus.idle, pid: null } as any,
{ where: { id: doc.id } },
);
await SubscriptionModel.nativeUpdate({ id: doc.id }, {
status: SubscriptionStatus.idle,
pid: null,
} as any);
this.sockService.sendMessage({
type: 'runSubscriptionEnd',
message: t('订阅执行完成'),
@@ -230,22 +214,19 @@ export default class SubscriptionService {
},
};
}
public async create(payload: Subscription): Promise<Subscription> {
assertSubscriptionAlias(payload.alias);
return withSubscriptionMutation(async () => {
const tab = new Subscription(payload);
const doc = await this.insert(tab);
await this.handleTask(doc.get({ plain: true }));
await this.handleTask(doc);
await this.setSshConfig();
return doc;
});
}
public async insert(payload: Subscription): Promise<SubscriptionInstance> {
return await SubscriptionModel.create(payload, { returning: true });
return await SubscriptionModel.insert(payload);
}
public async update(payload: Subscription): Promise<Subscription> {
assertSubscriptionAlias(payload.alias);
return withSubscriptionMutation(async () => {
@@ -258,7 +239,6 @@ export default class SubscriptionService {
return newDoc;
});
}
private async removeSshConfigForSubscription(doc: Subscription) {
if (doc.type !== 'private-repo' || doc.pull_type !== 'ssh-key') return;
// Invalid historical rows must remain deletable without deriving unsafe paths.
@@ -269,12 +249,10 @@ export default class SubscriptionService {
}
await this.sshKeyService.removeSSHKey(doc.alias, '');
}
public async updateDb(payload: Subscription): Promise<Subscription> {
await SubscriptionModel.update(payload, { where: { id: payload.id } });
await SubscriptionModel.nativeUpdate({ id: payload.id }, payload);
return await this.getDb({ id: payload.id });
}
public async status({
ids,
status,
@@ -300,16 +278,16 @@ export default class SubscriptionService {
if (last_running_time > 0) {
options.last_running_time = last_running_time;
}
return await SubscriptionModel.update(
{ ...options },
{ where: { id: ids } },
);
return await SubscriptionModel.nativeUpdate({ id: ids }, { ...options });
}
public async remove(ids: number[], query: { force?: boolean }) {
public async remove(
ids: number[],
query: {
force?: boolean;
},
) {
return withSubscriptionMutation(async () => {
const docs = await SubscriptionModel.findAll({ where: { id: ids } });
const docs = await SubscriptionModel.find({ id: ids }, {});
const storagePaths: string[] = [];
const retainedPaths: string[] = [];
const collectPaths = (
@@ -321,7 +299,9 @@ export default class SubscriptionService {
// A URL accepted by one engine can produce an invalid name in another.
// Validate each candidate independently, retaining the safe candidate.
if (!Object.values(names).every(isSubscriptionStorageName)) continue;
const resolve = deleting ? resolveSubscriptionStoragePath : path.resolve;
const resolve = deleting
? resolveSubscriptionStoragePath
: path.resolve;
const scriptPath = resolve(config.scriptPath, names.script);
if (scriptPath) targets.push(scriptPath);
if (names.repo) {
@@ -329,7 +309,10 @@ export default class SubscriptionService {
if (repoPath) targets.push(repoPath);
}
if (names.raw) {
const rawPath = resolve(path.join(config.dataPath, 'raw'), names.raw);
const rawPath = resolve(
path.join(config.dataPath, 'raw'),
names.raw,
);
if (rawPath) targets.push(rawPath);
}
}
@@ -342,70 +325,65 @@ export default class SubscriptionService {
resolveSubscriptionStoragePath(config.repoPath, doc.alias);
collectPaths(doc, storagePaths, true);
}
const remaining = await SubscriptionModel.findAll({ where: {} });
const remaining = await SubscriptionModel.find({}, {});
for (const doc of remaining) {
if (!ids.includes(doc.id!)) collectPaths(doc, retainedPaths);
}
}
for (const doc of docs) {
await this.handleTask(doc.get({ plain: true }), false);
await this.handleTask(doc, false);
}
await SubscriptionModel.destroy({ where: { id: ids } });
await SubscriptionModel.nativeDelete({ id: ids });
for (const doc of docs) {
await this.removeSshConfigForSubscription(doc);
}
await this.setSshConfig();
if (query?.force === true) {
const crons = await CrontabModel.findAll({ where: { sub_id: ids } });
const crons = await CrontabModel.find({ sub_id: ids }, {});
if (crons?.length) {
await this.crontabService.remove(crons.map((x) => x.id!));
}
for (const storagePath of new Set(storagePaths)) {
// rm is recursive: an ancestor must also be retained if a surviving
// subscription uses a nested branch, and vice versa.
const shared = retainedPaths.some((retained) =>
retained === storagePath ||
retained.startsWith(`${storagePath}${path.sep}`) ||
storagePath.startsWith(`${retained}${path.sep}`),
const shared = retainedPaths.some(
(retained) =>
retained === storagePath ||
retained.startsWith(`${storagePath}${path.sep}`) ||
storagePath.startsWith(`${retained}${path.sep}`),
);
if (!shared) await rmPath(storagePath);
}
}
});
}
public async getDb(
query: FindOptions<Subscription>['where'],
): Promise<Subscription> {
const doc = await SubscriptionModel.findOne({ where: { ...query } });
public async getDb(query: QuerySpec['where']): Promise<Subscription> {
const doc = await SubscriptionModel.findOne({ ...query }, {});
if (!doc) {
throw new Error(`Subscription ${JSON.stringify(query)} not found`);
}
return doc.get({ plain: true });
return doc;
}
public async run(ids: number[]) {
await SubscriptionModel.update(
await SubscriptionModel.nativeUpdate(
{ id: ids },
{ status: SubscriptionStatus.queued },
{ where: { id: ids } },
);
ids.forEach((id) => {
this.runSingle(id);
});
}
public async stop(ids: number[]) {
const docs = await SubscriptionModel.findAll({ where: { id: ids } });
const docs = await SubscriptionModel.find({ id: ids }, {});
let failure: unknown;
for (const doc of docs) {
try {
if (doc.pid) {
await killTask(doc.pid, true);
}
await SubscriptionModel.update(
await SubscriptionModel.nativeUpdate(
{ id: doc.id, pid: doc.pid ?? null },
{ status: SubscriptionStatus.idle, pid: null } as any,
{ where: { id: doc.id, pid: doc.pid ?? null } },
);
} catch (error) {
this.logger.error(error);
@@ -414,15 +392,12 @@ export default class SubscriptionService {
}
if (failure) throw failure;
}
private async runSingle(subscriptionId: number) {
const subscription = await this.getDb({ id: subscriptionId });
if (subscription.status !== SubscriptionStatus.queued) {
return;
}
const command = formatCommand(subscription);
this.scheduleService.runTask(command, this.taskCallbacks(subscription), {
name: subscription.name,
schedule: subscription.schedule,
@@ -431,25 +406,22 @@ export default class SubscriptionService {
runOrigin: 'subscription',
});
}
public async disabled(ids: number[]) {
await SubscriptionModel.update({ is_disabled: 1 }, { where: { id: ids } });
const docs = await SubscriptionModel.findAll({ where: { id: ids } });
await SubscriptionModel.nativeUpdate({ id: ids }, { is_disabled: 1 });
const docs = await SubscriptionModel.find({ id: ids }, {});
await this.setSshConfig();
for (const doc of docs) {
await this.handleTask(doc.get({ plain: true }), false);
await this.handleTask(doc, false);
}
}
public async enabled(ids: number[]) {
await SubscriptionModel.update({ is_disabled: 0 }, { where: { id: ids } });
const docs = await SubscriptionModel.findAll({ where: { id: ids } });
await SubscriptionModel.nativeUpdate({ id: ids }, { is_disabled: 0 });
const docs = await SubscriptionModel.find({ id: ids }, {});
await this.setSshConfig();
for (const doc of docs) {
await this.handleTask(doc.get({ plain: true }));
await this.handleTask(doc);
}
}
public async log(id: number, options: LogReadOptions = {}) {
const doc = await this.getDb({ id });
if (!doc || !doc.log_path) {
@@ -461,17 +433,14 @@ export default class SubscriptionService {
truncated: false,
};
}
const absolutePath = await handleLogPath(doc.log_path as string);
return await readLogChunk(absolutePath, options);
}
public async logs(id: number) {
const doc = await this.getDb({ id });
if (!doc) {
return [];
}
if (doc.log_path) {
const relativeDir = path.dirname(`${doc.log_path}`);
const dir = path.dirname(resolveLogPath(config.logPath, doc.log_path));
+99 -117
View File
@@ -1,3 +1,4 @@
import { Query, QuerySpec, orderBy } from '../data/queries';
import { persistNotificationAudit } from '../shared/notificationAudit';
import { spawn } from 'cross-spawn';
import { execFile } from 'child_process';
@@ -5,7 +6,7 @@ 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';
@@ -46,18 +47,16 @@ import os from 'os';
import dayjs from 'dayjs';
import { t, setLang } from '../shared/i18n';
import { updateLinuxMirrorFile } from '../config/util';
@Service()
export default class SystemService {
@Inject((type) => NotificationService)
private notificationService!: NotificationService;
constructor(
@Inject('logger') private logger: winston.Logger,
@Inject('logger')
private logger: winston.Logger,
private scheduleService: ScheduleService,
private sockService: SockService,
) {}
public async getSystemConfig() {
const doc = await this.getDb({ type: AuthDataType.systemConfig });
return {
@@ -65,22 +64,19 @@ export default class SystemService {
info: { ...doc.info, timezone: doc.info?.timezone || 'Asia/Shanghai' },
};
}
private async updateAuthDb(payload: SystemInfo): Promise<SystemInfo> {
const { id, ...others } = payload;
await SystemModel.update(others, { where: { id } });
await SystemModel.nativeUpdate({ id }, others);
const doc = await this.getDb({ id });
return doc;
}
public async getDb(query: any): Promise<SystemInfo> {
const doc = await SystemModel.findOne({ where: query });
const doc = await SystemModel.findOne(query, {});
if (!doc) {
throw new Error(`System ${JSON.stringify(query)} not found`);
}
return doc.get({ plain: true });
return doc;
}
public async updateNotificationMode(notificationInfo: NotificationInfo) {
const code = Math.random().toString().slice(-6);
const isSuccess = await this.notificationService.testNotify(
@@ -98,7 +94,6 @@ export default class SystemService {
return { code: 400, message: t('通知发送失败,请检查参数') };
}
}
public async updateLogRemoveFrequency(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig();
const result = await this.updateAuthDb({
@@ -125,7 +120,6 @@ export default class SystemService {
}
return { code: 200, data: info };
}
public async updateCronConcurrency(info: SystemModelInfo) {
const limit = info.cronConcurrency ?? 0;
if (!Number.isInteger(limit) || limit < 0 || limit > 2147483647) {
@@ -142,7 +136,6 @@ export default class SystemService {
return { code: 200, data: info };
});
}
public async restoreCronConcurrency() {
await withSchedulerMutation(async () => {
const doc = await this.getSystemConfig();
@@ -151,7 +144,6 @@ export default class SystemService {
await cronClient.setConcurrency(limit);
});
}
public async updateDependenceProxy(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig();
await this.updateAuthDb({
@@ -170,7 +162,6 @@ export default class SystemService {
}
return { code: 200, data: info };
}
public async updateNodeMirror(info: SystemModelInfo, res?: Response) {
const oDoc = await this.getSystemConfig();
await this.updateAuthDb({
@@ -182,12 +173,13 @@ export default class SystemService {
cmd = `pnpm config set registry ${shellQuote(info.nodeMirror)}`;
}
let command = `cd && ${cmd}`;
const docs = await DependenceModel.findAll({
where: {
const docs = await DependenceModel.find(
{
type: DependenceTypes.nodejs,
status: DependenceStatus.installed,
},
});
{},
);
if (docs.length > 0) {
command += ` && pnpm i -g`;
}
@@ -219,7 +211,6 @@ export default class SystemService {
},
);
}
public async updatePythonMirror(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig();
await this.updateAuthDb({
@@ -233,7 +224,6 @@ export default class SystemService {
await promiseExec(cmd);
return { code: 200, data: info };
}
public async updateLinuxMirror(
info: SystemModelInfo,
res?: Response,
@@ -280,30 +270,25 @@ 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 {
code: 200,
data: {
@@ -319,11 +304,12 @@ export default class SystemService {
} catch (error: any) {
return {
code: 400,
message: error.message,
message: t('检查更新失败,请检查网络后重试'),
};
} finally {
clearTimeout(timeout);
}
}
private checkHasNewVersion(curVersion: string, lastVersion: string) {
const curArr = curVersion.split('.').map((x) => parseInt(x, 10));
const lastArr = lastVersion.split('.').map((x) => parseInt(x, 10));
@@ -342,47 +328,48 @@ export default class SystemService {
}
return false;
}
public async updateSystem() {
const cp = spawn('real_time=true ql update false', { shell: '/bin/bash' });
cp.stdout.on('data', (data) => {
this.sockService.sendMessage({
type: 'updateSystemVersion',
message: data.toString(),
});
});
cp.stderr.on('data', (data) => {
this.sockService.sendMessage({
type: 'updateSystemVersion',
message: data.toString(),
});
});
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 };
return this.startUpgradeJob('download');
}
private upgradeJobs() {
return require(path.join(
config.rootPath,
'cli/src/internal/maintenance/panelUpgrade.cjs',
));
}
public async getUpgradeStatus() {
return {
code: 200,
data: await this.upgradeJobs().getStatus(config.tmpPath),
};
}
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',
@@ -395,7 +382,6 @@ export default class SystemService {
});
return { code: 200 };
}
public async notify({
title,
content,
@@ -412,35 +398,38 @@ 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 {
return { code: 400, message: t('通知发送失败,请检查系统设置/通知配置') };
}
}
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,
});
const docs = await SystemModel.find(
{ type: AuthDataType.notifyLog },
{
orderBy: orderBy([['id', 'DESC']]),
limit: 200,
},
);
return docs.map((x) => ({ ...x.info, id: x.id }));
}
public async run(
{ command, logPath }: { command: string; logPath?: string },
{
command,
logPath,
}: {
command: string;
logPath?: string;
},
callback: TaskCallbacks,
) {
if (!command.startsWith(TASK_COMMAND)) {
@@ -457,17 +446,14 @@ export default class SystemService {
},
);
}
public async stop({ command, pid }: { command: string; pid: number }) {
if (!pid && !command) {
return { code: 400, message: t('参数错误') };
}
if (pid) {
await killTask(pid);
return { code: 200 };
}
if (!command.startsWith(TASK_COMMAND)) {
command = `${TASK_COMMAND} ${command}`;
}
@@ -479,7 +465,6 @@ export default class SystemService {
return { code: 400, message: t('任务未找到') };
}
}
public async exportData(res: Response, type?: string[]) {
try {
let dataDirs = ['db', 'upload'];
@@ -521,7 +506,6 @@ export default class SystemService {
return res.send({ code: 400, message: error.message });
}
}
public async importData() {
try {
await promiseExec(`rm -rf ${path.join(config.tmpPath, 'data')}`);
@@ -533,7 +517,6 @@ export default class SystemService {
return { code: 400, message: error.message };
}
}
public async getSystemLog(
res: Response,
query: {
@@ -553,7 +536,6 @@ export default class SystemService {
.reverse()
.filter((x) => x.title.endsWith('.log'))
.filter((x) => x.createTime >= startTime && x.createTime <= endTime);
const limit = Math.min(
Math.max(Number(query.limit) || 1024 * 1024, 1),
1024 * 1024,
@@ -561,7 +543,10 @@ export default class SystemService {
const total = logs.reduce((size, log) => size + (log.size || 0), 0);
let remaining = limit;
const selected: Array<
(typeof logs)[number] & { start: number; length: number }
(typeof logs)[number] & {
start: number;
length: number;
}
> = [];
for (let index = logs.length - 1; index >= 0 && remaining > 0; index--) {
const log = logs[index];
@@ -572,7 +557,6 @@ export default class SystemService {
remaining -= length;
}
}
const contentLength = selected.reduce((size, log) => size + log.length, 0);
res.set({
'Content-Length': contentLength,
@@ -584,7 +568,6 @@ export default class SystemService {
res.end();
return;
}
const currentLog = fileNames.shift();
if (currentLog) {
const currentFileStream = fs.createReadStream(
@@ -601,15 +584,23 @@ export default class SystemService {
}
})(res, selected);
}
public async deleteSystemLog() {
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();
}
}
}
public async updateTimezone(info: SystemModelInfo) {
if (!info.timezone) {
info.timezone = 'Asia/Shanghai';
@@ -626,7 +617,6 @@ export default class SystemService {
return { code: 400, message: t('设置时区失败') };
}
}
public async updateLanguage(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig();
const lang = info.lang || 'zh';
@@ -646,7 +636,6 @@ export default class SystemService {
setLang(lang);
return { code: 200, data: { lang } };
}
public async updatePanelTitle(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig();
const panelTitle = info.panelTitle?.trim() || '';
@@ -656,43 +645,36 @@ export default class SystemService {
});
return { code: 200, data: { panelTitle } };
}
public async updateGlobalSshKey(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig();
const result = await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info },
});
// Apply the global SSH key
const SshKeyService = require('./sshKey').default;
const Container = require('typedi').Container;
const sshKeyService = Container.get(SshKeyService);
if (info.globalSshKey) {
await sshKeyService.addGlobalSSHKey(info.globalSshKey, 'global');
} else {
await sshKeyService.removeGlobalSSHKey('global');
}
return { code: 200, data: result };
}
public async cleanDependence(type: 'node' | 'python3') {
if (!type || !['node', 'python3'].includes(type)) {
return { code: 400, message: t('参数错误') };
}
const finalPath = path.join(config.dependenceCachePath, type);
await fs.promises.rm(finalPath, { recursive: true, force: true });
await DependenceModel.update(
{ status: DependenceStatus.installFailed },
await DependenceModel.nativeUpdate(
{
where: {
type:
type === 'node' ? DependenceTypes.nodejs : DependenceTypes.python3,
status: DependenceStatus.installed,
},
type:
type === 'node' ? DependenceTypes.nodejs : DependenceTypes.python3,
status: DependenceStatus.installed,
},
{ status: DependenceStatus.installFailed },
);
return { code: 200 };
}
+22 -73
View File
@@ -34,27 +34,27 @@ import {
} from '../shared/password';
import { serializeAuthMutation } from '../shared/authMutation';
import { LoginThrottle } from '../shared/loginThrottle';
@Service()
export default class UserService {
private loginThrottle = new LoginThrottle();
@Inject((type) => NotificationService)
private notificationService!: NotificationService;
constructor(
@Inject('logger') private logger: winston.Logger,
@Inject('logger')
private logger: winston.Logger,
private scheduleService: ScheduleService,
private sockService: SockService,
) {}
@serializeAuthMutation
public async login(
payloads: { username: string; password: string },
payloads: {
username: string;
password: string;
},
req: Request,
): Promise<any> {
return this.authenticate(payloads, req);
}
private async authenticate(
payloads: {
username: string;
@@ -89,7 +89,6 @@ export default class UserService {
platform,
blockedIps = [],
} = content;
if (
ip &&
blockedIps.some((blockedIp) => normalizeClientIp(blockedIp) === ip)
@@ -107,7 +106,6 @@ export default class UserService {
this.getLoginLog();
return { code: 403, message: t('该 IP 已被列入黑名单') };
}
const waitTime = this.loginThrottle.retryAfter(ip, timestamp);
if (waitTime > 0) {
return {
@@ -116,10 +114,8 @@ export default class UserService {
data: waitTime,
};
}
const passwordMatches =
username === cUsername && (await verifyPassword(password, cPassword));
if (passwordMatches && twoFactorActivated && needTwoFactor) {
await this.updateAuthInfo(content, {
isTwoFactorChecking: true,
@@ -130,7 +126,6 @@ export default class UserService {
message: '',
};
}
if (passwordMatches) {
this.loginThrottle.reset(ip);
const data = createRandomString(50, 100);
@@ -139,7 +134,6 @@ export default class UserService {
expiresIn: config.jwt.expiresIn || expiration,
algorithm: 'HS384',
});
const tokenInfo: TokenInfo = {
value: token,
timestamp,
@@ -147,13 +141,11 @@ export default class UserService {
address,
platform: req.platform,
};
const updatedTokens = this.addTokenToList(
tokens,
req.platform,
tokenInfo,
);
await this.updateAuthInfo(content, {
password: isPasswordHash(cPassword)
? cPassword
@@ -173,7 +165,7 @@ export default class UserService {
t('你于') +
dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss') +
t('在') +
address +
t(address) +
' ' +
req.platform +
t('端') +
@@ -219,7 +211,7 @@ export default class UserService {
t('你于') +
dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss') +
t('在') +
address +
t(address) +
' ' +
req.platform +
t('端') +
@@ -252,16 +244,13 @@ export default class UserService {
}
}
}
@serializeAuthMutation
public async logout(platform: string, tokenValue: string): Promise<any> {
if (!platform || !tokenValue) {
this.logger.warn('Invalid logout parameters - empty platform or token');
return;
}
const authInfo = await this.getAuthInfo();
// Verify the token exists before attempting to remove it
const tokenExists = this.findTokenInList(
authInfo.tokens,
@@ -275,45 +264,36 @@ export default class UserService {
);
return;
}
const updatedTokens = this.removeTokenFromList(
authInfo.tokens,
platform,
tokenValue,
);
await this.updateAuthInfo(authInfo, {
token: authInfo.token === tokenValue ? '' : authInfo.token,
tokens: updatedTokens,
});
}
public async getLoginLog(): Promise<Array<SystemModelInfo | undefined>> {
const docs = await SystemModel.findAll({
where: { type: AuthDataType.loginLog },
});
const docs = await SystemModel.find({ type: AuthDataType.loginLog }, {});
if (docs && docs.length > 0) {
const result = docs.sort(
(a, b) => b.info!.timestamp! - a.info!.timestamp!,
);
if (result.length > 100) {
const ids = result.slice(100).map((x) => x.id!);
await SystemModel.destroy({
where: { id: ids },
});
await SystemModel.nativeDelete({ id: ids });
}
return result.map((x) => x.info);
}
return [];
}
public async getIpBlacklist(): Promise<string[]> {
const authInfo = await this.getAuthInfo();
return uniq((authInfo.blockedIps || []).map(normalizeClientIp)).filter(
Boolean,
);
}
@serializeAuthMutation
public async blockIp(ip: string): Promise<string[]> {
const authInfo = await this.getAuthInfo();
@@ -324,7 +304,6 @@ export default class UserService {
await this.updateAuthInfo(authInfo, { blockedIps });
return blockedIps;
}
@serializeAuthMutation
public async unblockIp(ip: string): Promise<string[]> {
const authInfo = await this.getAuthInfo();
@@ -335,12 +314,10 @@ export default class UserService {
await this.updateAuthInfo(authInfo, { blockedIps });
return blockedIps;
}
private async insertDb(payload: SystemInfo): Promise<SystemInfo> {
const doc = await SystemModel.create({ ...payload }, { returning: true });
const doc = await SystemModel.insert({ ...payload });
return doc;
}
@serializeAuthMutation
public async initializeUser({
username,
@@ -366,7 +343,6 @@ export default class UserService {
});
return { code: 200, message: t('更新成功') };
}
@serializeAuthMutation
public async updateUsernameAndPassword({
username,
@@ -389,14 +365,12 @@ export default class UserService {
});
return { code: 200, message: t('更新成功') };
}
@serializeAuthMutation
public async updateAvatar(avatar: string) {
const authInfo = await this.getAuthInfo();
await this.updateAuthInfo(authInfo, { avatar });
return { code: 200, data: avatar, message: t('更新成功') };
}
@serializeAuthMutation
public async initTwoFactor() {
const secret = authenticator.generateSecret();
@@ -408,7 +382,6 @@ export default class UserService {
await this.updateAuthInfo(authInfo, { twoFactorSecret: secret });
return { secret, url: otpauth };
}
@serializeAuthMutation
public async activeTwoFactor(code: string) {
const authInfo = await this.getAuthInfo();
@@ -427,14 +400,17 @@ export default class UserService {
}
return isValid;
}
@serializeAuthMutation
public async twoFactorLogin(
{
username,
password,
code,
}: { username: string; password: string; code: string },
}: {
username: string;
password: string;
code: string;
},
req: any,
) {
const authInfo = await this.getAuthInfo();
@@ -482,7 +458,6 @@ export default class UserService {
return { code: 430, message: t('验证失败') };
}
}
@serializeAuthMutation
public async deactivateTwoFactor() {
const authInfo = await this.getAuthInfo();
@@ -496,7 +471,6 @@ export default class UserService {
});
return true;
}
public async getAuthInfo() {
const authInfo = await shareStore.getAuthInfo();
if (authInfo) {
@@ -505,7 +479,6 @@ export default class UserService {
const doc = await this.getDb({ type: AuthDataType.authConfig });
return (doc.info || {}) as AuthInfo;
}
private async updateAuthInfo(authInfo: AuthInfo, info: Partial<AuthInfo>) {
const result = { ...authInfo, ...info };
await shareStore.updateAuthInfo(result);
@@ -517,34 +490,27 @@ export default class UserService {
this.sockService.getClients().forEach((conn) => conn.close('401'));
}
}
public async getNotificationMode(): Promise<NotificationInfo> {
const doc = await this.getDb({ type: AuthDataType.notification });
return (doc.info || {}) as NotificationInfo;
}
private async updateAuthDb(payload: SystemInfo): Promise<any> {
let doc = await SystemModel.findOne({ where: { type: payload.type } });
let doc = await SystemModel.findOne({ type: payload.type }, {});
if (doc) {
const updateResult = await SystemModel.update(payload, {
where: { id: doc.id },
returning: true,
});
doc = updateResult[1][0];
await SystemModel.nativeUpdate({ id: doc.id }, payload);
doc = await SystemModel.findOne({ id: doc.id });
} else {
doc = await SystemModel.create(payload, { returning: true });
doc = await SystemModel.insert(payload);
}
return doc;
}
public async getDb(query: any): Promise<SystemInfo> {
const doc = await SystemModel.findOne({ where: { ...query } });
const doc = await SystemModel.findOne({ ...query }, {});
if (!doc) {
throw new Error(`${JSON.stringify(query)} not found`);
}
return doc.get({ plain: true });
return doc;
}
public async updateNotificationMode(notificationInfo: NotificationInfo) {
const code = Math.random().toString().slice(-6);
const isSuccess = await this.notificationService.testNotify(
@@ -562,12 +528,10 @@ export default class UserService {
return { code: 400, message: t('通知发送失败,请检查参数') };
}
}
private normalizeTokens(
tokens: Record<string, string | TokenInfo[]>,
): Record<string, TokenInfo[]> {
const normalized: Record<string, TokenInfo[]> = {};
for (const [platform, value] of Object.entries(tokens)) {
if (typeof value === 'string') {
// Legacy format: convert string token to TokenInfo array
@@ -589,10 +553,8 @@ export default class UserService {
normalized[platform] = value || [];
}
}
return normalized;
}
private addTokenToList(
tokens: Record<string, string | TokenInfo[]>,
platform: string,
@@ -606,16 +568,12 @@ export default class UserService {
);
maxTokensPerPlatform = config.maxTokensPerPlatform;
}
const normalized = this.normalizeTokens(tokens);
if (!normalized[platform]) {
normalized[platform] = [];
}
// Add new token
normalized[platform].unshift(tokenInfo);
// Limit the number of active tokens per platform
if (normalized[platform].length > maxTokensPerPlatform) {
normalized[platform] = normalized[platform].slice(
@@ -623,40 +581,32 @@ export default class UserService {
maxTokensPerPlatform,
);
}
return normalized;
}
private removeTokenFromList(
tokens: Record<string, string | TokenInfo[]>,
platform: string,
tokenValue: string,
): Record<string, TokenInfo[]> {
const normalized = this.normalizeTokens(tokens);
if (normalized[platform]) {
normalized[platform] = normalized[platform].filter(
(t) => t.value !== tokenValue,
);
}
return normalized;
}
private findTokenInList(
tokens: Record<string, string | TokenInfo[]>,
platform: string,
tokenValue: string,
): TokenInfo | undefined {
const normalized = this.normalizeTokens(tokens);
if (normalized[platform]) {
return normalized[platform].find((t) => t.value === tokenValue);
}
return undefined;
}
@serializeAuthMutation
public async resetAuthInfo(info: Partial<AuthInfo>) {
const { retries, twoFactorActivated, password, username } = info;
@@ -673,7 +623,6 @@ export default class UserService {
},
(x) => !isNil(x),
);
if (password !== undefined) {
payload.password = await hashPassword(password);
}
+70 -11
View File
@@ -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 = {
@@ -78,8 +135,10 @@ export function parseCronSchedule(schedule: unknown): CronSchedule {
};
Object.freeze(patterns);
Object.freeze(parsed);
if (scheduleCache.size >= 512)
scheduleCache.delete(scheduleCache.keys().next().value);
if (scheduleCache.size >= 512) {
const oldest = scheduleCache.keys().next().value;
if (oldest !== undefined) scheduleCache.delete(oldest);
}
scheduleCache.set(cacheKey, parsed);
return parsed;
}
+3 -2
View File
@@ -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),
);
+226 -92
View File
@@ -1,112 +1,236 @@
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',
'认证服务暂时不可用,请稍后重试':
'Authentication service is temporarily unavailable; try again shortly.',
'升级任务启动失败,请检查日志后重试':
'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 +255,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 +277,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) */
+32
View File
@@ -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);
}
+14 -15
View File
@@ -1,17 +1,16 @@
import { Op } from 'sequelize';
import { Query, QuerySpec, orderBy } from '../data/queries';
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({
await SystemModel.insert({
type: AuthDataType.notifyLog,
info: {
timestamp: Date.now(),
@@ -27,19 +26,19 @@ export async function persistNotificationAudit(
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,
});
const oldest = await SystemModel.find(
{ type: AuthDataType.notifyLog },
{
fields: ['id'],
orderBy: orderBy([['id', 'DESC']]),
offset: 200,
limit: 1,
},
);
if (oldest[0]?.id) {
await SystemModel.destroy({
where: {
type: AuthDataType.notifyLog,
id: { [Op.lte]: oldest[0].id },
},
await SystemModel.nativeDelete({
type: AuthDataType.notifyLog,
id: { ['$lte']: oldest[0].id },
});
}
} catch (error) {
+32 -17
View File
@@ -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][任务队列] 空闲中...`));
});
}
@@ -130,13 +137,20 @@ class TaskLimit {
this.manualCronoLimit.concurrency = limit;
return;
}
await SystemModel.sync();
const doc = await SystemModel.findOne({
where: { type: AuthDataType.systemConfig },
});
if (doc?.info?.cronConcurrency) {
this.cronLimit.concurrency = doc.info.cronConcurrency;
this.manualCronoLimit.concurrency = doc.info.cronConcurrency;
try {
const doc = await SystemModel.findOne({
type: AuthDataType.systemConfig,
});
if (doc?.info?.cronConcurrency) {
this.cronLimit.concurrency = doc.info.cronConcurrency;
this.manualCronoLimit.concurrency = doc.info.cronConcurrency;
}
} catch (error) {
// Imports can precede the isolated initializer on a fresh installation.
// Use defaults until that initializer creates Auths; never create schema here.
if ((error as { code?: string }).code !== 'DATABASE_SCHEMA_MISSING')
throw error;
}
}
@@ -172,13 +186,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);
+21 -17
View File
@@ -1,3 +1,4 @@
import { t } from './i18n';
import { spawn } from 'cross-spawn';
import taskLimit from './pLimit';
import Logger from '../loaders/logger';
@@ -19,7 +20,7 @@ export function runCron(
// Check if the cron is already running and stop it (only if multiple instances are not allowed)
try {
const existingCron = await CrontabModel.findOne({
where: { id: Number(cron.id) },
id: Number(cron.id),
});
// A mutation may commit while this queued run is reading the database.
@@ -37,29 +38,31 @@ export function runCron(
existingCron.status === CrontabStatus.queued)
) {
Logger.info(
`[schedule][停止已运行任务] 任务ID: ${cron.id}, PID: ${existingCron.pid}`,
t('[schedule][停止已运行任务] 任务ID: %s, PID: %s'),
cron.id,
existingCron.pid,
);
await killTask(existingCron.pid, true);
// Mark old running instances as stopped
const stoppedAt = dayjs().unix();
await RunningInstanceModel.update(
{ status: InstanceStatus.stopped, finished_at: stoppedAt },
await RunningInstanceModel.nativeUpdate(
{
where: {
cron_id: Number(cron.id),
status: InstanceStatus.running,
},
cron_id: Number(cron.id),
status: InstanceStatus.running,
},
{ status: InstanceStatus.stopped, finished_at: stoppedAt },
);
// Update the status to idle after killing
await CrontabModel.update(
await CrontabModel.nativeUpdate(
{ id: Number(cron.id) },
{ status: CrontabStatus.idle, pid: undefined },
{ where: { id: Number(cron.id) } },
);
}
} catch (error) {
Logger.error(
`[schedule][检查已运行任务失败] 任务ID: ${cron.id}, 错误: ${error}`,
t('[schedule][检查已运行任务失败] 任务ID: %s, 错误: %s'),
cron.id,
error,
);
throw error;
}
@@ -67,17 +70,18 @@ export function runCron(
// In particular, invalidation can happen while stopping a previous run.
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 +90,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,
);
+41
View File
@@ -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,
});
+5 -15
View File
@@ -1,4 +1,4 @@
import { QueryTypes, Sequelize } from 'sequelize';
import { DatabaseClient } from '../data/databaseClient';
// Append new entries; IDs are persisted and must not be renumbered or reused.
const columns = [
@@ -27,25 +27,19 @@ const columns = [
{ table: 'Crontabs', column: 'queued_token', type: 'VARCHAR(255)' },
];
export async function migrateSchema(database: Sequelize): Promise<void> {
await database.transaction(async (transaction) => {
export async function migrateSchema(database: DatabaseClient): Promise<void> {
await database.transaction(async () => {
await database.query(
'CREATE TABLE IF NOT EXISTS "SchemaMigrations" ("id" TEXT PRIMARY KEY, "applied_at" TEXT NOT NULL)',
{ transaction },
);
const applied = await database.query<{ id: string }>(
'SELECT "id" FROM "SchemaMigrations"',
{ type: QueryTypes.SELECT, transaction },
);
const appliedIds = new Set(applied.map(({ id }) => id));
for (const { table, column, type } of columns) {
const id = `add-${table}-${column}`;
const fields = await database.query<{ name: string }>(
`PRAGMA table_info("${table}")`,
{
type: QueryTypes.SELECT,
transaction,
},
);
if (fields.length === 0)
throw new Error(`Migration table is missing: ${table}`);
@@ -53,16 +47,12 @@ export async function migrateSchema(database: Sequelize): Promise<void> {
// table/column/type come only from the static migration manifest above.
await database.query(
`ALTER TABLE "${table}" ADD COLUMN "${column}" ${type}`,
{ transaction },
);
}
if (!appliedIds.has(id)) {
await database.query(
'INSERT INTO "SchemaMigrations" ("id", "applied_at") VALUES (:id, :appliedAt)',
{
replacements: { id, appliedAt: new Date().toISOString() },
transaction,
},
'INSERT INTO "SchemaMigrations" ("id", "applied_at") VALUES (?, ?)',
[id, new Date().toISOString()],
);
}
}
+7 -5
View File
@@ -1,9 +1,7 @@
import { AuthInfo } from '../data/system';
import { App } from '../data/open';
import Keyv from 'keyv';
import KeyvSqlite from '@keyv/sqlite';
import config from '../config';
import path from 'path';
import { CacheStore } from '../data/cacheStore';
export enum EKeyv {
'apps' = 'apps',
@@ -17,8 +15,12 @@ export interface IKeyvStore {
lang: string;
}
const keyvSqlite = new KeyvSqlite(path.join(config.dbPath, 'keyv.sqlite'));
export const keyvStore = new Keyv<IKeyvStore>({ store: keyvSqlite });
const keyvSqlite = new CacheStore();
// A stopped database is unavailable, not a cache miss or an expired session.
export const keyvStore = new Keyv<IKeyvStore>({
store: keyvSqlite,
throwOnErrors: true,
});
export const shareStore = {
getAuthInfo() {
+17 -35
View File
@@ -1,21 +1,16 @@
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';
type TrustProxyValue = boolean | number | string;
let activeApp: Application | undefined;
function getEnvironmentSetting(): string {
return process.env.QL_TRUST_PROXY?.trim() || '';
}
function normalizeSetting(value?: string): string {
return value?.trim() || DEFAULT_TRUST_PROXY;
}
export function resolveTrustProxy(value?: string): TrustProxyValue {
const setting = normalizeSetting(value);
if (setting === 'true' || setting === 'false') {
@@ -26,34 +21,30 @@ export function resolveTrustProxy(value?: string): TrustProxyValue {
}
return setting;
}
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();
probe.set('trust proxy', resolveTrustProxy(setting));
return setting;
}
async function getStoredSetting(): Promise<string> {
const doc = await SystemModel.findOne({
where: { type: AuthDataType.systemConfig },
});
const info = (doc?.get('info') || {}) as Record<string, unknown>;
const doc = await SystemModel.findOne(
{ type: AuthDataType.systemConfig },
{},
);
const info = (doc?.info || {}) as Record<string, unknown>;
return typeof info.trustProxy === 'string' ? info.trustProxy : '';
}
export async function getTrustProxyConfig() {
const environmentSetting = getEnvironmentSetting();
const storedSetting = await getStoredSetting();
const trustProxy = normalizeSetting(environmentSetting || storedSetting);
return {
trustProxy,
source: environmentSetting
@@ -64,36 +55,31 @@ export async function getTrustProxyConfig() {
editable: !environmentSetting,
};
}
export async function initializeTrustProxy(app: Application) {
activeApp = app;
const { trustProxy } = await getTrustProxyConfig();
app.set('trust proxy', resolveTrustProxy(trustProxy));
}
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);
const doc = await SystemModel.findOne({
where: { type: AuthDataType.systemConfig },
});
const doc = await SystemModel.findOne(
{ type: AuthDataType.systemConfig },
{},
);
if (!doc) {
throw new Error('系统配置不存在');
throw new Error(t('系统配置不存在'));
}
const plain = doc.get({ plain: true });
await SystemModel.update(
const plain = doc;
await SystemModel.nativeUpdate(
{ id: plain.id },
{ info: { ...(plain.info || {}), trustProxy } as any },
{ where: { id: plain.id } },
);
activeApp?.set('trust proxy', resolveTrustProxy(trustProxy));
return getTrustProxyConfig();
}
function parseForwardedFor(value: string | string[] | undefined): string[] {
const values = Array.isArray(value) ? value : value ? [value] : [];
return values
@@ -101,7 +87,6 @@ function parseForwardedFor(value: string | string[] | undefined): string[] {
.map((item) => normalizeClientIp(item))
.filter(Boolean);
}
export async function diagnoseClientIp(req: Request) {
const remoteAddress = normalizeClientIp(req.socket.remoteAddress);
const forwardedFor = parseForwardedFor(req.headers['x-forwarded-for']);
@@ -111,7 +96,6 @@ export async function diagnoseClientIp(req: Request) {
const trust = req.app.get('trust proxy fn') as
| ((ip: string, hop: number) => boolean)
| undefined;
let selectedIndex = Math.max(hopsFromApp.length - 1, 0);
for (let index = 0; index < hopsFromApp.length - 1; index += 1) {
if (!trust?.(hopsFromApp[index], index)) {
@@ -119,7 +103,6 @@ export async function diagnoseClientIp(req: Request) {
break;
}
}
const hops = hopsFromApp.map((ip, index) => ({
ip,
hop: index,
@@ -130,7 +113,6 @@ export async function diagnoseClientIp(req: Request) {
? 'client'
: 'not_checked',
}));
return {
...(await getTrustProxyConfig()),
remoteAddress,
+1 -1
View File
@@ -23,5 +23,5 @@
"esModuleInterop": true
},
"include": ["./**/*"],
"exclude": ["node_modules"]
"exclude": ["node_modules", "./data/databaseWorker.ts"]
}
+23 -1
View File
@@ -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()
+78 -4
View File
@@ -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
View File
@@ -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": {
+19
View File
@@ -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'),
+8 -8
View File
@@ -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(' '));
+18 -2
View File
@@ -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,588 @@
// 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,
{ root = path.resolve(__dirname, '../../../..') } = {},
) {
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();
}
}
const pkg = JSON.parse(
await fs
.readFile(path.join(root, 'package.json'), 'utf8')
.catch((error) => {
if (error.code !== 'ENOENT') throw error;
return '{}';
}),
);
// Read the installed package on every poll: the worker's completion record
// survives replacement, while this marker disappears only with the new runtime.
return { ...state, migrationRequired: Boolean(pkg.qinglongMigrationBridge) };
}
// 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,731 @@
// One installed verifier handles release downloads and local offline archives.
const fs = require('node:fs/promises');
const { constants, createReadStream } = require('node:fs');
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();
const differences = [...new Set([...expected, ...names])].filter(
(name) =>
!Object.hasOwn(actual, name) ||
!Object.hasOwn(manifest.files, name) ||
JSON.stringify(actual[name]) !== JSON.stringify(manifest.files[name]),
);
if (differences.length)
throw new Error(
'Platform bundle files or dependency checksums do not match: ' +
JSON.stringify({
count: differences.length,
files: differences.slice(0, 5).map((name) => ({
name,
expected: manifest.files[name] || null,
actual: actual[name] || null,
})),
}),
);
if (
sha256(await fs.readFile(path.join(selected.source, 'pnpm-lock.yaml'))) !==
manifest.lockfileSha256
)
throw new Error('Platform bundle lockfile mismatch');
const pkg = JSON.parse(
await fs.readFile(path.join(selected.source, 'package.json'), 'utf8'),
);
await fs.access(
path.join(
selected.source,
pkg.dependencies?.['@mikro-orm/sql']
? 'node_modules/@mikro-orm/sql/package.json'
: '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'));
const pkg=r('./package.json'), modern=Boolean(pkg.dependencies?.['@mikro-orm/sql']);
if (modern) {
const [major,minor]=process.versions.node.split('.').map(Number);
if(major<22 || (major===22 && minor<17)) throw new Error('Bundle requires Node >=22.17; the existing Node is retained');
}
for (const name of [...(modern?['@mikro-orm/core','@mikro-orm/sql']:['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);
}
if (modern) {
const {DatabaseSync}=require('node:sqlite'), db=new DatabaseSync(':memory:');
try {
db.exec('CREATE TABLE verify_bundle(value INTEGER);INSERT INTO verify_bundle VALUES(42)');
if(db.prepare('SELECT value FROM verify_bundle').get().value!==42) throw new Error('SQLite readback failed');
} finally {db.close();}
} else {
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(),
directories = new Map([['qinglong', 0o755]]);
const header = (entry) => ({
type: entry.type,
size: entry.size,
mode: entry.mode,
linkpath: entry.linkpath,
});
let unpacked = 0,
invalid;
await tar.t({
file: archive,
strict: true,
maxReadSize: 256 * 1024,
onReadEntry(entry) {
try {
const name = entry.path.replace(/\/$/, '');
if (
!name ||
name.includes('\\') ||
/[\x00-\x1f\x7f]/.test(name) ||
name.split('/').length > 1024 ||
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}`);
if (!Number.isSafeInteger(entry.size) || entry.size < 0)
throw new Error('Invalid bundle archive entry size');
unpacked += entry.size;
if (unpacked > 2 * 1024 ** 3 || entries.size > 200000)
throw new Error('Bundle archive exceeds extraction limits');
entries.set(name, header(entry));
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');
}
} catch (error) {
invalid ||= error;
}
},
});
if (invalid) throw invalid;
for (const [name, entry] of entries) {
if (entry.type === 'Directory')
directories.set(name, ((entry.mode ?? 0o755) & 0o777) | 0o700);
let parent = path.posix.dirname(name);
while (parent !== '.') {
if (entries.has(parent) && entries.get(parent).type !== 'Directory')
throw new Error('Archive writes through a symlink or non-directory');
if (!directories.has(parent)) directories.set(parent, 0o755);
parent = path.posix.dirname(parent);
}
}
// The extraction root must be new and private. Create every parent before
// links, and keep directories private/searchable until all writes finish.
const directoryNames = [...directories.keys()].sort(
(a, b) => a.split('/').length - b.split('/').length,
);
for (const name of directoryNames) {
const filename = path.join(destination, name);
await fs.mkdir(filename, { mode: 0o700 });
await fs.chmod(filename, 0o700);
}
let writes = Promise.resolve(),
failure;
const active = new Set(),
seen = new Set();
const parser = tar.t({
cwd: destination,
strict: true,
noResume: true,
onReadEntry(entry) {
active.add(entry);
// A parser failure can destroy queued entries before their turn starts.
entry.on('error', () => {});
writes = writes
.then(async () => {
if (failure) return entry.resume();
const name = entry.path.replace(/\/$/, '');
if (
seen.has(name) ||
JSON.stringify(entries.get(name)) !== JSON.stringify(header(entry))
)
throw new Error('Archive changed during extraction');
seen.add(name);
const filename = path.join(destination, name);
if (entry.type === 'Directory') return entry.resume();
if (entry.type === 'SymbolicLink') {
await fs.symlink(entry.linkpath, filename);
return entry.resume();
}
const mode = (entry.mode ?? 0o644) & 0o777;
const handle = await fs.open(
filename,
constants.O_WRONLY |
constants.O_CREAT |
constants.O_EXCL |
constants.O_NOFOLLOW,
0o600,
);
try {
// tar's fs-minipass writer discards successful partial write/writev
// results. Node's writeFile loops until each chunk is fully written.
// One entry/chunk at a time bounds memory and open FDs on ARMv6.
for await (const chunk of entry) await handle.writeFile(chunk);
await handle.chmod(mode);
} finally {
await handle.close();
}
})
.catch((error) => {
failure ||= error;
entry.resume();
})
.finally(() => active.delete(entry));
},
});
const input = createReadStream(archive, { highWaterMark: 256 * 1024 });
try {
await new Promise((resolve, reject) => {
input.once('error', reject);
parser.once('error', reject);
parser.once('end', resolve);
input.pipe(parser);
});
} catch (error) {
failure ||= error;
for (const entry of active) entry.destroy(error);
} finally {
input.destroy();
}
await writes;
if (failure) throw failure;
if (seen.size !== entries.size)
throw new Error('Archive changed during extraction');
for (const name of directoryNames.reverse())
await fs.chmod(path.join(destination, name), directories.get(name));
}
async function stageBundle({
root,
tmp,
env,
run,
signal,
onProgress,
verifyStatic,
download,
probe = smokeDependencies,
prestart = prestartBackend,
repair = repairDependencies,
}) {
let local = env.QL_UPGRADE_FILE;
let pendingMigration = false,
requiresModern = false;
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.
pendingMigration = Boolean(current.qinglongMigrationBridge);
requiresModern =
pendingMigration || Boolean(current.dependencies?.['@mikro-orm/sql']);
}
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'));
if (pendingMigration && !Object.hasOwn(index, 'runtimePackages'))
throw new Error('更新索引尚未提供第二步运行包,请稍后重试');
requiresModern ||= Object.hasOwn(index, 'runtimePackages');
// Old format-2 clients read packages and receive an updater bridge. Once
// installed, this selector uses only the actual runtime packages. A broken
// runtime entry must never fall back to the bridge or a different platform.
const packages = Object.hasOwn(index, 'runtimePackages')
? index.runtimePackages
: index.packages;
const entry = 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 (manifest.migrationBridge)
throw new Error(
'This is a legacy upgrader bridge; download the runtime package instead',
);
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);
if (
requiresModern &&
!JSON.parse(
await fs.readFile(path.join(selected.source, 'package.json'), 'utf8'),
).dependencies?.['@mikro-orm/sql']
)
throw new Error(
'第二步更新包仍是旧数据库后端,已保留当前服务,请稍后重试',
);
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,
};
+58 -25
View File
@@ -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)
+418 -96
View File
@@ -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,422 @@
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 cleanSqliteBuildArtifacts(directory) {
const entries = await fs.readdir(directory, { withFileTypes: true }).catch((error) => {
if (error.code !== 'ENOENT') throw error;
return [];
});
if (!entries.length) return;
const pkg = JSON.parse(await fs.readFile(path.join(directory, 'package.json'), 'utf8'));
const runtimePath = path.posix.normalize(pkg.binary?.module_path || '.');
const copiedRuntime = runtimePath.startsWith('lib/binding/');
// Our legacy fork copies its runtime to lib/binding. Other sqlite3 versions
// load build/Release directly: retain that addon and remove only the compiler
// helper directory. Never traverse a build-directory symlink while cleaning.
for (const entry of entries) {
if (entry.name !== 'build' && !/^build-tmp-napi-v\d+$/.test(entry.name)) continue;
const build = path.join(directory, entry.name);
await fs.rm(
copiedRuntime || entry.isSymbolicLink() ? build : path.join(build, 'node_gyp_bins'),
{ 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',
]);
if (pkg.dependencies?.sqlite3) {
await run(['rebuild', 'sqlite3', '@whyour/sqlite3']);
await cleanSqliteBuildArtifacts(
path.join(workspace, 'node_modules/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,
cleanSqliteBuildArtifacts,
probeLimits,
};
+14 -4
View File
@@ -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(
+1
View File
@@ -416,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' },
+15 -8
View File
@@ -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.',
+183
View File
@@ -0,0 +1,183 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const os = require('node:os');
const vm = require('node:vm');
const { createRequire } = require('node:module');
const { createContext } = require('../../dist/internal/runtime/context');
const { replaceAndReload } = require('../../dist/internal/maintenance/upgrade');
const runtime = require('../../dist/internal/maintenance/nodeRuntime.cjs');
// Change only runtime selection; execute the compiled operator, actual child
// Node and process discovery, without pretending the host CPU is ARM.
async function operatorFor(arch, armVersion, node) {
const filename = require.resolve('../../dist/internal/maintenance/operator');
const localRequire = createRequire(filename);
const module = { exports: {} };
const flags = runtime.serverRuntimeArgs({
arch,
config: { variables: { arm_version: armVersion } },
versions: { node },
});
vm.runInNewContext(
await fs.readFile(filename, 'utf8'),
{
module,
exports: module.exports,
process,
setTimeout,
clearTimeout,
require(name) {
return name === './nodeRuntime.cjs'
? { ...runtime, serverRuntimeArgs: () => [...flags] }
: localRequire(name);
},
},
{ filename },
);
return { ...module.exports, flags };
}
for (const [arch, arm, node] of [
['arm', 6, '24.18.1'],
['arm', 6, '20.18.1'],
['arm', 6, '22.20.0'],
['arm', 7, '24.18.1'],
['arm64', undefined, '24.18.1'],
['x64', undefined, '24.18.1'],
])
test(`CLI direct startup and stop use compatible arguments: ${arch}/${arm}/${node}`, async (t) => {
const operator = await operatorFor(arch, arm, node);
const root = await fs.realpath(
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-runtime-start-')),
);
const context = createContext(
{ root },
{ PATH: process.platform === 'linux' ? '' : '/usr/bin:/bin', QL_LANG: 'en' },
);
const entry = path.join(context.paths.dir_static, 'build/app.js');
let pid;
t.after(async () => {
if (pid) {
try {
process.kill(pid, 'SIGKILL');
} catch {}
}
await fs.rm(root, { recursive: true, force: true });
});
await fs.mkdir(path.dirname(entry), { recursive: true });
await fs.writeFile(
entry,
`require('fs').writeFileSync(${JSON.stringify(
path.join(root, 'runtime.json'),
)}, JSON.stringify({args:process.execArgv,pid:process.pid}));setInterval(()=>{},1000);`,
);
const started = await operator.startPanel(context);
pid = started.pid;
const report = JSON.parse(
await fs.readFile(path.join(root, 'runtime.json')),
);
assert.equal(started.manager, 'node');
assert.deepEqual(report.args, operator.flags);
assert.equal(report.pid, pid);
await operator.stopPanel(context);
for (let n = 0; n < 100; n++) {
try {
process.kill(pid, 0);
} catch (e) {
if (e.code === 'ESRCH') {
pid = undefined;
break;
}
throw e;
}
await new Promise((resolve) => setTimeout(resolve, 20));
}
assert.equal(
pid,
undefined,
'the exact backend must stop even with Node flags',
);
});
test(
'ARMv6 CLI replacement stops flagged new backend before restoring old backend and data',
{
skip: process.platform !== 'linux',
timeout: 20000,
},
async (t) => {
const operator = await operatorFor('arm', 6, '24.18.1');
const root = await fs.realpath(
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-arm-rollback-')),
);
const context = createContext({ root }, { PATH: '', QL_LANG: 'en' });
const entry = path.join(context.paths.dir_static, 'build/app.js');
const incoming = path.join(root, 'incoming.js'),
journal = path.join(root, 'journal.jsonl');
const record = (version) =>
`const fs=require('fs');fs.appendFileSync(${JSON.stringify(
journal,
)},JSON.stringify({version:${JSON.stringify(
version,
)},pid:process.pid,args:process.execArgv})+'\\n');setInterval(()=>{},1000);`;
await fs.mkdir(path.dirname(entry), { recursive: true });
await fs.mkdir(context.data, { recursive: true });
await fs.writeFile(path.join(root, '.env'), 'KEEP=custom\n', {
mode: 0o600,
});
await fs.writeFile(path.join(context.data, 'keep'), 'old data');
await fs.writeFile(entry, record('old'));
await fs.writeFile(incoming, record('new'));
const pids = [];
t.after(async () => {
for (const pid of pids) {
try {
process.kill(pid, 'SIGKILL');
} catch {}
}
await fs.rm(root, { recursive: true, force: true });
});
pids.push((await operator.startPanel(context)).pid);
await assert.rejects(
replaceAndReload(context, [{ source: incoming, target: entry }], {
stop: (ctx) => operator.stopPanel(ctx),
start: async (ctx, recover) => {
const result = await operator.startPanel(ctx);
pids.push(result.pid);
if (!recover) throw new Error('injected new-version health failure');
},
}),
/injected new-version health failure/,
);
const launches = (await fs.readFile(journal, 'utf8'))
.trim()
.split('\n')
.map(JSON.parse);
assert.deepEqual(
launches.map((x) => x.version),
['old', 'new', 'old'],
);
for (const row of launches) assert.deepEqual(row.args, operator.flags);
for (const row of launches.slice(0, -1))
assert.throws(() => process.kill(row.pid, 0), { code: 'ESRCH' });
process.kill(launches.at(-1).pid, 0);
assert.equal(
await fs.readFile(path.join(root, '.env'), 'utf8'),
'KEEP=custom\n',
);
assert.equal((await fs.stat(path.join(root, '.env'))).mode & 0o777, 0o600);
assert.equal(
await fs.readFile(path.join(context.data, 'keep'), 'utf8'),
'old data',
);
assert.equal(await fs.readFile(entry, 'utf8'), record('old'));
assert.ok(
!(await fs.readdir(path.dirname(entry))).some((x) =>
x.includes('ql-backup'),
),
);
await operator.stopPanel(context);
},
);
+47 -3
View File
@@ -3,7 +3,46 @@ const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const 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 });
+11 -2
View File
@@ -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);
},
);
+36 -2
View File
@@ -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(),
[
+35
View File
@@ -0,0 +1,35 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
test('the standalone operational CLI verifier carries its archive parser without panel dependencies', (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-cli-bundle-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const filename = path.join(directory, 'platformBundle.cjs');
fs.copyFileSync(
path.resolve(
__dirname,
'../../dist/internal/maintenance/platformBundle.cjs',
),
filename,
);
const code = `
const Module=require('node:module'),load=Module._load;
Module._load=function(name,...args){
if(name!==process.argv[1]&&!name.startsWith('node:')&&!Module.builtinModules.includes(name))throw new Error('External dependency: '+name);
return load.call(this,name,...args);
};
const bundle=require(process.argv[1]);
if(typeof bundle.stageBundle!=='function')throw new Error('Missing verifier');
bundle.runtimeTarget().then(target=>console.log(bundle.targetKey(target))).catch(e=>{console.error(e);process.exitCode=1});
`;
const result = spawnSync(process.execPath, ['-e', code, filename], {
encoding: 'utf8',
env: { ...process.env, NODE_PATH: '' },
});
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, new RegExp(`^${process.platform}-`));
});
@@ -0,0 +1,99 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const {
manualUpgradeFixture,
} = require('../../../test/helpers/manual-upgrade-fixture.cjs');
const { createContext } = require('../../dist/internal/runtime/context');
const { reloadPanel } = require('../../dist/internal/maintenance/upgrade');
const operator = require('../../dist/internal/maintenance/operator');
const artifacts = require('../../dist/internal/maintenance/upgradeArtifacts.cjs');
for (const status of ['ready', 'failed', 'completed', 'absent'])
for (const wrongVersion of [false, true])
test(`CLI validates and records manual reload after ${status}; wrong version=${wrongVersion}`, async (t) => {
const f = await manualUpgradeFixture(t, wrongVersion);
await fs.writeFile(
path.join(f.root, 'package.json'),
'{"qinglongUpgradeFormat":2}',
);
const file = path.join(f.tmp, 'panel-upgrade.json');
if (status === 'absent') await fs.unlink(file);
else
await fs.writeFile(
file,
JSON.stringify({
...(await f.state()),
status,
error: 'old error',
version: '9.9.9',
sourceCommit: 'f'.repeat(40),
}),
);
t.mock.method(operator, 'stopPanel', async () => {});
t.mock.method(operator, 'startPanel', async () => ({
manager: 'fixture',
}));
t.mock.method(artifacts, 'waitForHealth', async () => {});
const operation = reloadPanel(
createContext({ root: f.root }, f.env),
'system',
);
if (wrongVersion)
await assert.rejects(operation, /启动版本与更新包不一致/);
else await operation;
const state = await f.state();
assert.equal(state.status, wrongVersion ? 'failed' : 'completed');
assert.equal(state.sourceCommit, f.expected.sourceCommit);
assert.equal(state.version, f.expected.version);
assert.notEqual(state.error, 'old error');
assert.equal(
await fs.readFile(path.join(f.root, 'static/build/app.js'), 'utf8'),
wrongVersion ? 'old backend' : 'new backend',
);
assert.equal(
await fs.readFile(path.join(f.root, 'data/keep'), 'utf8'),
'preserve data',
);
});
for (const wrongVersion of [false, true]) {
test(`operational CLI synchronizes prepared jobs and verifies startup before committing; wrong version=${wrongVersion}`, async (t) => {
const f = await manualUpgradeFixture(t, wrongVersion);
const context = createContext({ root: f.root }, f.env);
const timeouts = [];
t.mock.method(artifacts, 'waitForHealth', async (_env, timeout) => {
timeouts.push(timeout);
});
t.mock.method(operator, 'stopPanel', async () => {
assert.equal((await f.state()).status, 'restarting');
});
t.mock.method(operator, 'startPanel', async () => ({ manager: 'fixture' }));
const operation = reloadPanel(context, 'system');
if (wrongVersion) await assert.rejects(operation, /启动版本与更新包不一致/);
else await operation;
assert.ok(timeouts.length > 0);
assert.ok(
timeouts.every((timeout) => timeout === undefined),
'use the shared ARMv6/default startup deadline',
);
assert.equal(
(await f.state()).status,
wrongVersion ? 'failed' : 'completed',
);
assert.equal(
await fs.readFile(path.join(f.root, 'static/build/app.js'), 'utf8'),
wrongVersion ? 'old backend' : 'new backend',
);
assert.equal(
await fs.readFile(path.join(f.root, 'data/keep'), 'utf8'),
'preserve data',
);
if (wrongVersion)
assert.equal(
await fs.readFile(path.join(f.root, 'node_modules/old'), 'utf8'),
'old dependency',
);
});
}
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -23,7 +23,7 @@ test(
[
'--test',
'--test-reporter=tap',
path.resolve(__dirname, '../preload.test.cjs'),
path.resolve(__dirname, '../internal/preload.test.cjs'),
],
{
env,
+9 -1
View File
@@ -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'),
+18 -2
View File
@@ -1,6 +1,6 @@
# 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-alpine3.24 AS builder
ARG TARGETARCH
ENV NPM_CONFIG_PREFIX=/opt/node-global
@@ -20,10 +20,18 @@ RUN set -x \
&& npm_config_target_platform=linux \
npm_config_target_arch="${NODE_ARCH}" \
npm_config_target_libc=musl \
pnpm install --prod --frozen-lockfile
QL_BUILD_ONLY=1 pnpm install --prod --frozen-lockfile
# The amd64 APK runtime has crashed in native URL parsing on dev. Keep its
# URL implementation bundled with the pinned upstream musl Node binary.
FROM --platform=linux/amd64 node:24.18.1-alpine3.24@sha256:f70403e87646dc51b45295f4b8b70cdad0b63d2297c4c9899119b03f7af7a6b3 AS node-runtime-amd64
FROM python:3.11-alpine
ARG TARGETARCH
ARG TARGETVARIANT
COPY --from=node-runtime-amd64 /usr/local/bin/node /tmp/node-runtime-amd64
ARG QL_MAINTAINER="whyour"
LABEL maintainer="${QL_MAINTAINER}"
ARG QL_URL=https://github.com/${QL_MAINTAINER}/qinglong.git
@@ -62,6 +70,10 @@ RUN set -x \
netcat-openbsd \
unzip \
npm \
&& if [ "$TARGETARCH" = amd64 ]; then \
mv /tmp/node-runtime-amd64 /usr/local/bin/node; \
ln -sf /usr/local/bin/node /usr/bin/node; \
else rm /tmp/node-runtime-amd64; fi \
&& rm -rf /var/cache/apk/* \
&& apk update \
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
@@ -86,6 +98,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 \
+18 -2
View File
@@ -1,6 +1,6 @@
# 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-alpine3.24 AS builder
ARG TARGETARCH
ENV NPM_CONFIG_PREFIX=/opt/node-global
@@ -20,10 +20,18 @@ RUN set -x \
&& npm_config_target_platform=linux \
npm_config_target_arch="${NODE_ARCH}" \
npm_config_target_libc=musl \
pnpm install --prod --frozen-lockfile
QL_BUILD_ONLY=1 pnpm install --prod --frozen-lockfile
# The amd64 APK runtime has crashed in native URL parsing on dev. Keep its
# URL implementation bundled with the pinned upstream musl Node binary.
FROM --platform=linux/amd64 node:24.18.1-alpine3.24@sha256:f70403e87646dc51b45295f4b8b70cdad0b63d2297c4c9899119b03f7af7a6b3 AS node-runtime-amd64
FROM python:3.10-alpine
ARG TARGETARCH
ARG TARGETVARIANT
COPY --from=node-runtime-amd64 /usr/local/bin/node /tmp/node-runtime-amd64
ARG QL_MAINTAINER="whyour"
LABEL maintainer="${QL_MAINTAINER}"
ARG QL_URL=https://github.com/${QL_MAINTAINER}/qinglong.git
@@ -62,6 +70,10 @@ RUN set -x \
netcat-openbsd \
unzip \
npm \
&& if [ "$TARGETARCH" = amd64 ]; then \
mv /tmp/node-runtime-amd64 /usr/local/bin/node; \
ln -sf /usr/local/bin/node /usr/bin/node; \
else rm /tmp/node-runtime-amd64; fi \
&& rm -rf /var/cache/apk/* \
&& apk update \
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
@@ -86,6 +98,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 \

Some files were not shown because too many files have changed in this diff Show More