Compare commits

..
256 changed files with 44770 additions and 26545 deletions
-3
View File
@@ -21,6 +21,3 @@ JWT_EXPIRES_IN=
QINIU_AK=
QINIU_SK=
QINIU_SCOPE=
# Python 虚拟环境路径,留空则使用系统 Python
# PYTHON_VENV_DIR=./.venv
+124 -220
View File
@@ -8,10 +8,6 @@ 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"
@@ -37,19 +33,15 @@ 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 }}
uses: ./.github/workflows/validate.yml
runtime-tools:
if: ${{ !inputs.static_sync_only }}
uses: ./.github/workflows/runtime-tools.yml
code_gitlab:
if: ${{ !inputs.static_sync_only }}
runs-on: ubuntu-latest
@@ -70,17 +62,17 @@ jobs:
build-static:
if: ${{ !inputs.static_sync_only }}
needs: validate
needs: [validate, runtime-tools]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
version: "10.34.6"
- uses: actions/setup-node@v6
with:
node-version-file: ".nvmrc"
node-version: "22"
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
@@ -89,10 +81,12 @@ jobs:
pnpm install --frozen-lockfile
pnpm build:front
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/download-artifact@v7
with:
name: qinglong-runtime-tools-${{ github.sha }}
path: static/
- run: pnpm build:info
- uses: actions/upload-artifact@v6
with:
@@ -101,31 +95,20 @@ 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: |
: "${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"
bash scripts/publish-static.sh "https://${{ secrets.API_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"
npm-package:
if: ${{ !inputs.static_sync_only }}
needs: build-static
uses: ./.github/workflows/npm-package.yml
with:
static-artifact: qinglong-static-${{ github.sha }}
static_gitlab:
needs: build-static
@@ -230,8 +213,6 @@ 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
@@ -244,10 +225,10 @@ jobs:
path: static/
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
version: "10.34.6"
- uses: actions/setup-node@v6
with:
node-version: "24"
node-version: "22"
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
@@ -297,7 +278,6 @@ jobs:
uses: docker/setup-buildx-action@v4
- name: Build and push (Alpine)
id: image
uses: docker/build-push-action@v7
with:
build-args: |
@@ -319,8 +299,6 @@ 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
@@ -333,10 +311,10 @@ jobs:
path: static/
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
version: "10.34.6"
- uses: actions/setup-node@v6
with:
node-version: "24"
node-version: "22"
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
@@ -385,7 +363,6 @@ jobs:
uses: docker/setup-buildx-action@v4
- name: Build and push (Debian)
id: image
uses: docker/build-push-action@v7
with:
build-args: |
@@ -394,7 +371,7 @@ jobs:
SOURCE_COMMIT=${{ github.sha }}
network: host
# Keep s390x on Debian: npm can hang under QEMU with Alpine (nodejs/docker-node#1973).
platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/s390x
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
context: .
file: ./docker/Dockerfile.debian
push: true
@@ -420,10 +397,10 @@ jobs:
path: static/
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
version: "10.34.6"
- uses: actions/setup-node@v6
with:
node-version: "24"
node-version: "22"
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
@@ -492,10 +469,10 @@ jobs:
path: static/
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
version: "10.34.6"
- uses: actions/setup-node@v6
with:
node-version: "24"
node-version: "22"
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
@@ -538,7 +515,7 @@ jobs:
SOURCE_COMMIT=${{ github.sha }}
network: host
# Keep s390x on Debian: npm can hang under QEMU with Alpine (nodejs/docker-node#1973).
platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/s390x
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
context: .
file: ./docker/Dockerfile.debian310
push: true
@@ -550,191 +527,118 @@ jobs:
cache-from: type=registry,ref=whyour/qinglong:cache-debian-python3.10
cache-to: type=registry,ref=whyour/qinglong:cache-debian-python3.10,mode=max
# One unmodified amd64 package for migration acceptance only. This does not
# publish bundles or advance development/formal channel indexes.
build-npm-migration-bundle:
needs: build-debian
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
- uses: docker/setup-buildx-action@v4
- name: Export the official platform package from this run's image digest
env:
IMAGE_DIGEST: ${{ needs.build-debian.outputs.image-digest }}
run: |
set -euo pipefail
[[ "$IMAGE_DIGEST" =~ ^sha256:[a-f0-9]{64}$ ]]
image="ghcr.io/${GITHUB_REPOSITORY}@${IMAGE_DIGEST}"
docker buildx build --platform linux/amd64 --build-arg "BASE_IMAGE=$image" --file docker/Dockerfile.upgrade-bundle --output "type=local,dest=$RUNNER_TEMP/npm-migration-bundle" .
- uses: actions/upload-artifact@v6
with:
name: npm-migration-bundle-${{ github.sha }}
path: ${{ runner.temp }}/npm-migration-bundle/*
if-no-files-found: error
retention-days: 3
verify-npm-package:
needs: [build-static, build-npm-migration-bundle]
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
node: ['20.18.1', '24']
container: node:${{ matrix.node }}-bookworm
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v6
with:
name: qinglong-npm-${{ github.sha }}
path: ${{ runner.temp }}/npm-panel/
- uses: actions/download-artifact@v6
with:
name: npm-migration-bundle-${{ github.sha }}
path: ${{ runner.temp }}/npm-migration-bundle/
- name: Install isolated service test tools
run: |
apt-get update
apt-get install -y --no-install-recommends python3 openssl curl jq nginx procps
# The published legacy launcher resets PATH to standard directories.
# /usr/local is confined to this disposable job container; PM2_HOME
# remains private to each verification run.
npm install --global --prefix /usr/local --no-audit --no-fund pm2@6.0.14
- name: Install and upgrade the exact npm archive
env:
QL_PANEL_PACKAGE_TEST_ISOLATED: '1'
QL_PANEL_TEST_PM2: /usr/local/bin/pm2
QL_PANEL_TEST_BUNDLE_DIR: ${{ runner.temp }}/npm-migration-bundle
run: node scripts/verify-panel-package.cjs verify "$RUNNER_TEMP/npm-panel" "$RUNNER_TEMP/npm-panel-results"
- uses: actions/upload-artifact@v6
with:
name: qinglong-npm-verification-${{ matrix.node }}-${{ github.sha }}
path: ${{ runner.temp }}/npm-panel-results/
if-no-files-found: error
retention-days: 14
publish:
if: ${{ github.repository == 'whyour/qinglong' && github.ref == 'refs/heads/master' }}
needs: [build-alpine, build-debian, build-alpine310, build-debian310, verify-npm-package]
needs: [build-alpine, build-debian, build-alpine310, build-debian310, npm-package]
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
id-token: write
env:
SOURCE_SHA: ${{ github.sha }}
VERIFIED_TARBALL_SHA256: ${{ needs.npm-package.outputs.tarball-sha256 }}
VERIFIED_PACKAGE_VERSION: ${{ needs.npm-package.outputs.package-version }}
steps:
- name: Set up Node for trusted publishing
uses: actions/setup-node@v6
- uses: actions/checkout@v6
with:
node-version: '24'
registry-url: https://registry.npmjs.org
ref: ${{ github.sha }}
- uses: actions/setup-node@v6
with:
node-version: '22.23.3'
package-manager-cache: false
- uses: actions/download-artifact@v6
- uses: actions/download-artifact@v7
with:
name: qinglong-npm-${{ github.sha }}
path: npm-panel/
- name: Publish npm package with OIDC
name: ${{ needs.npm-package.outputs.artifact-name }}
path: ${{ runner.temp }}/main-npm-publish/
- uses: actions/download-artifact@v7
with:
name: qinglong-runtime-tools-${{ github.sha }}
path: ${{ runner.temp }}/npm-publisher-runtime-tools/
- name: Verify the gate-approved archive and its source receipts
shell: bash
run: |
set -euo pipefail
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
node <<'NODE'
const fs = require('node:fs'), path = require('node:path'), crypto = require('node:crypto'), assert = require('node:assert/strict');
const { spawnSync } = require('node:child_process');
const hash = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex');
const fileHash = (file) => hash(fs.readFileSync(file));
const directory = path.join(process.env.RUNNER_TEMP, 'main-npm-publish');
const summary = JSON.parse(fs.readFileSync(path.join(directory, 'package-build-summary.json')));
const pkg = JSON.parse(fs.readFileSync('package.json'));
assert.match(process.env.VERIFIED_TARBALL_SHA256 || '', /^[a-f0-9]{64}$/);
assert.equal(summary.sourceCommit, process.env.SOURCE_SHA); assert.equal(summary.previewMetadata, false);
assert.deepEqual(summary.sourceMetadataChanges, []); assert(summary.helperProvenance.length > 0);
assert(summary.helperProvenance.every((item) => item.matchesGitSource));
assert.equal(summary.package, pkg.name); assert.equal(summary.packageVersion, pkg.version);
assert.equal(summary.packageVersion, process.env.VERIFIED_PACKAGE_VERSION);
assert.equal(summary.productionManifestSHA256, fileHash('package.json')); assert.equal(summary.productionLockSHA256, fileHash('pnpm-lock.yaml'));
const source = spawnSync('git', ['archive', process.env.SOURCE_SHA], { maxBuffer: 128 * 1024 * 1024 });
assert.equal(source.status, 0); assert.equal(hash(source.stdout), summary.sourceArchiveSHA256);
assert.equal(summary.runtimeToolsIncluded, true); assert.equal(summary.runtimeTools.sourceCommit, process.env.SOURCE_SHA);
assert.equal(summary.runtimeTools.builder.platform, 'linux'); assert.equal(summary.runtimeTools.builder.releaseBuild, true);
assert.equal(path.basename(summary.tarball.filename), summary.tarball.filename); assert(summary.tarball.filename.endsWith('.tgz'));
assert.deepEqual(fs.readdirSync(directory).filter((name) => name.endsWith('.tgz')), [summary.tarball.filename]);
const tarball = path.join(directory, summary.tarball.filename);
assert.equal(summary.tarball.sha256, process.env.VERIFIED_TARBALL_SHA256);
assert.equal(fileHash(tarball), process.env.VERIFIED_TARBALL_SHA256); assert.equal(fs.statSync(tarball).size, summary.tarball.bytes);
const tar = (args) => {
const result = spawnSync('tar', args, { maxBuffer: 128 * 1024 * 1024 });
assert.equal(result.status, 0, 'Cannot read the approved archive'); return result.stdout;
};
const entries = tar(['-tzf', tarball]).toString().split('\n').filter(Boolean);
assert.equal(entries.length, new Set(entries).size);
for (const name of entries) assert(name.startsWith('package/') && !name.split('/').includes('..') && !name.endsWith('.node') && !name.split('/').some((part) => ['.npmrc', '.git', '.pnpm', '_cacache'].includes(part)), 'Forbidden archive item ' + name);
const read = (relative) => tar(['-xOf', tarball, 'package/' + relative]);
const receipt = JSON.parse(read('qinglong-npm-dependency-proof.json'));
const { tarball: ignoredTarball, ...producerReceipt } = summary; assert.deepEqual(receipt, producerReceipt);
assert.deepEqual(entries.map((name) => name.slice('package/'.length)).sort(), receipt.publishedFilePaths);
const published = JSON.parse(read('package.json')); assert.equal(published.name, pkg.name); assert.equal(published.version, pkg.version);
assert.equal(hash(read('package.json')), receipt.publishedRootManifestSHA256);
const buildInfo = read('static/build-info.json'), build = JSON.parse(buildInfo);
assert.equal(hash(buildInfo), summary.ciBuildInfoSHA256); assert.equal(build.sourceCommit, process.env.SOURCE_SHA); assert.equal(build.dirty, false);
assert.equal(build.lockfileSha256, summary.productionLockSHA256);
assert.deepEqual(entries.filter((name) => name.startsWith('package/static/')).map((name) => name.slice('package/static/'.length)).sort(), [...Object.keys(build.files), 'build-info.json'].sort());
const toolsRoot = path.join(process.env.RUNNER_TEMP, 'npm-publisher-runtime-tools');
for (const [name, expected] of [['runtime-tools.tgz', summary.runtimeTools.archiveSHA256], ['runtime-tools-proof.json', summary.runtimeTools.proofSHA256]]) {
assert.equal(fileHash(path.join(toolsRoot, name)), expected); assert.equal(hash(read('static/' + name)), expected); assert.equal(build.files[name], expected);
}
const tools = JSON.parse(fs.readFileSync(path.join(toolsRoot, 'runtime-tools-proof.json')));
assert.equal(tools.sourceCommit, process.env.SOURCE_SHA); assert.equal(tools.archive.sha256, summary.runtimeTools.archiveSHA256);
assert.equal(tools.builder.platform, 'linux'); assert.equal(tools.builder.releaseBuild, true);
assert.equal(tools.sourceAttestation.verified, true); assert.equal(tools.sourceAttestation.head, process.env.SOURCE_SHA); assert(tools.inputs.length > 0);
for (const input of tools.inputs) {
assert(input.path && !path.isAbsolute(input.path) && !input.path.split(/[\\/]/).includes('..'));
assert.equal(fileHash(input.path), input.sha256, 'Tool source input changed: ' + input.path);
}
fs.appendFileSync(process.env.GITHUB_ENV, 'QINGLONG_PUBLISH_TARBALL=' + tarball + '\n');
console.log(JSON.stringify({ sourceCommit: summary.sourceCommit, packageVersion: summary.packageVersion, tarballSHA256: summary.tarball.sha256, cleanSource: true, runtimeToolsIncluded: true }));
NODE
- name: Install the same fixed npm CLI for trusted publishing
run: |
node scripts/install-runtime-tools.cjs \
--archive "$RUNNER_TEMP/npm-publisher-runtime-tools/runtime-tools.tgz" \
--proof "$RUNNER_TEMP/npm-publisher-runtime-tools/runtime-tools-proof.json" \
--prefix "$RUNNER_TEMP/npm-publisher-tools"
- name: Publish the verified npm archive with OIDC
shell: bash
run: |
set -euo pipefail
npm_cli="$RUNNER_TEMP/npm-publisher-tools/lib/node_modules/npm/bin/npm-cli.js"
export NPM_CONFIG_USERCONFIG="$RUNNER_TEMP/npm-publish-public.npmrc"
export NPM_CONFIG_GLOBALCONFIG=/dev/null
export NPM_CONFIG_CACHE="$RUNNER_TEMP/npm-publish-cache"
printf 'registry=https://registry.npmjs.org/\n' > "$NPM_CONFIG_USERCONFIG"
test "$(node "$npm_cli" --version)" = '12.2.0'
name=$(node -p 'require("./package.json").name')
version=$(node -p 'require("./package.json").version')
if node "$npm_cli" 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 "$archive" --ignore-scripts --access public --registry=https://registry.npmjs.org
node "$npm_cli" publish "$QINGLONG_PUBLISH_TARBALL" --ignore-scripts --access public --registry=https://registry.npmjs.org
else
echo '::error::Could not check the published npm version; refusing to publish.'
exit 1
fi
build-upgrade-bundles:
# Both channels publish packages from this run's immutable image digests.
if: ${{ !inputs.static_sync_only && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/develop') }}
needs: [build-alpine, build-debian]
# Node 24 can SIGILL while archiving ARM64 musl under x64 QEMU.
runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-22.04' }}
timeout-minutes: 90
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- { platform: linux/amd64, libc: musl, name: x64-musl }
- { platform: linux/arm/v6, libc: musl, name: armv6-musl }
- { platform: linux/arm/v7, libc: musl, name: armv7-musl }
- { platform: linux/arm64, libc: musl, name: arm64-musl }
- { platform: linux/ppc64le, libc: musl, name: ppc64-musl }
- { platform: linux/s390x, libc: musl, name: s390x-musl }
- { platform: linux/386, libc: musl, name: ia32-musl }
- { platform: linux/amd64, libc: glibc, name: x64-glibc }
- { platform: linux/arm64, libc: glibc, name: arm64-glibc }
- { platform: linux/ppc64le, libc: glibc, name: ppc64-glibc }
- { platform: linux/s390x, libc: glibc, name: s390x-glibc }
steps:
- uses: actions/checkout@v6
- uses: docker/setup-qemu-action@v4
if: ${{ matrix.platform != 'linux/arm64' }}
with:
cache-image: false
- uses: docker/setup-buildx-action@v4
- name: Build and probe the immutable platform package
env:
ALPINE_DIGEST: ${{ needs.build-alpine.outputs.image-digest }}
DEBIAN_DIGEST: ${{ needs.build-debian.outputs.image-digest }}
BUNDLE_LIBC: ${{ matrix.libc }}
BUNDLE_PLATFORM: ${{ matrix.platform }}
run: |
set -euo pipefail
digest="$ALPINE_DIGEST"
if [ "$BUNDLE_LIBC" = glibc ]; then digest="$DEBIAN_DIGEST"; fi
test -n "$digest"
image="ghcr.io/${GITHUB_REPOSITORY}@${digest}"
docker buildx build --platform "$BUNDLE_PLATFORM" --build-arg "BASE_IMAGE=$image" --file docker/Dockerfile.upgrade-bundle --output type=local,dest=output/upgrade .
- uses: actions/upload-artifact@v6
with:
name: upgrade-${{ matrix.name }}-${{ github.sha }}
path: output/upgrade/*
if-no-files-found: error
publish-upgrade-bundles:
if: ${{ !inputs.static_sync_only && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/develop') }}
needs: build-upgrade-bundles
runs-on: ubuntu-latest
permissions:
contents: write
concurrency:
group: upgrade-channel-${{ github.ref_name }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '24'
- uses: pnpm/action-setup@v6
with:
version: '8.3.1'
- run: pnpm install --prod --frozen-lockfile
- run: npm --prefix cli ci
- uses: actions/download-artifact@v7
with:
pattern: upgrade-*-${{ github.sha }}
path: output/upgrade
merge-multiple: true
- run: node scripts/build-bundle-installer.cjs output/upgrade/install-bundle.cjs
- name: Publish complete packages before advancing the channel
env:
GH_TOKEN: ${{ github.token }}
run: node scripts/publish-upgrade-bundles.cjs output/upgrade "$GITHUB_SHA" "$GITHUB_REPOSITORY" "$GITHUB_REF_NAME"
+312
View File
@@ -0,0 +1,312 @@
name: Verify main npm archive
on:
workflow_call:
inputs:
static-artifact:
description: Complete static artifact including the same-source runtime tools
required: true
type: string
outputs:
artifact-name:
description: Main npm archive and producer summary, after every consumer passes
value: ${{ jobs.verified.outputs.artifact-name }}
tarball-sha256:
description: SHA256 of the exact archive consumed by all six cases
value: ${{ jobs.verified.outputs.tarball-sha256 }}
package-version:
description: Source package version verified against the archive
value: ${{ jobs.verified.outputs.package-version }}
permissions:
contents: read
env:
SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
jobs:
archive:
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
artifact-name: ${{ steps.archive.outputs.artifact-name }}
tarball-sha256: ${{ steps.archive.outputs.tarball-sha256 }}
package-version: ${{ steps.archive.outputs.package-version }}
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- uses: actions/setup-node@v6
with:
node-version: '22.23.3'
package-manager-cache: false
- uses: actions/download-artifact@v7
with:
name: ${{ inputs.static-artifact }}
path: static/
- name: Verify tool artifact source before executing the producer tools
shell: bash
run: |
set -euo pipefail
node <<'NODE'
const fs = require('node:fs'), crypto = require('node:crypto'), assert = require('node:assert/strict');
const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
const build = JSON.parse(fs.readFileSync('static/build-info.json'));
const tools = JSON.parse(fs.readFileSync('static/runtime-tools-proof.json'));
assert.equal(build.sourceCommit, process.env.SOURCE_SHA); assert.equal(build.dirty, false);
assert.equal(build.lockfileSha256, hash('pnpm-lock.yaml'));
assert.equal(tools.sourceCommit, process.env.SOURCE_SHA);
assert.equal(tools.builder.platform, 'linux'); assert.equal(tools.builder.releaseBuild, true);
assert.equal(tools.sourceAttestation.verified, true); assert.equal(tools.sourceAttestation.head, process.env.SOURCE_SHA);
for (const name of ['runtime-tools.tgz', 'runtime-tools-proof.json']) assert.equal(hash(`static/${name}`), build.files[name]);
assert.equal(hash('static/runtime-tools.tgz'), tools.archive.sha256);
for (const input of tools.inputs) {
assert(input.path && !input.path.startsWith('/') && !input.path.split(/[\\/]/).includes('..'));
assert.equal(hash(input.path), input.sha256, `Runtime source input changed: ${input.path}`);
}
NODE
- name: Install fixed producer tools into an isolated prefix
run: |
node scripts/install-runtime-tools.cjs \
--archive "$GITHUB_WORKSPACE/static/runtime-tools.tgz" \
--proof "$GITHUB_WORKSPACE/static/runtime-tools-proof.json" \
--prefix "$RUNNER_TEMP/npm-producer-tools"
- name: Export the single frozen production graph
run: |
node scripts/build-npm-package.cjs \
--repo "$GITHUB_WORKSPACE" \
--source-ref "$SOURCE_SHA" \
--artifact-dir "$GITHUB_WORKSPACE/static" \
--pnpm-cli "$RUNNER_TEMP/npm-producer-tools/lib/node_modules/qinglong-runtime-tools/node_modules/pnpm/bin/pnpm.cjs" \
--npm-cli "$RUNNER_TEMP/npm-producer-tools/lib/node_modules/npm/bin/npm-cli.js" \
--output "$RUNNER_TEMP/qinglong-npm-export"
- name: Verify and retain the exact consumer archive
id: archive
shell: bash
run: |
set -euo pipefail
node <<'NODE'
const fs = require('node:fs'), path = require('node:path'), crypto = require('node:crypto'), assert = require('node:assert/strict');
const root = path.join(process.env.RUNNER_TEMP, 'qinglong-npm-export');
const summary = JSON.parse(fs.readFileSync(path.join(root, 'package-build-summary.json')));
const pkg = JSON.parse(fs.readFileSync('package.json'));
const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
assert.equal(summary.sourceCommit, process.env.SOURCE_SHA); assert.equal(summary.previewMetadata, false);
assert.deepEqual(summary.sourceMetadataChanges, []); assert(summary.helperProvenance.every((item) => item.matchesGitSource));
assert.equal(summary.package, pkg.name); assert.equal(summary.packageVersion, pkg.version);
assert.equal(summary.productionManifestSHA256, hash('package.json')); assert.equal(summary.productionLockSHA256, hash('pnpm-lock.yaml'));
assert.equal(summary.runtimeToolsIncluded, true); assert.equal(summary.runtimeTools.sourceCommit, process.env.SOURCE_SHA);
assert.equal(summary.runtimeTools.builder.releaseBuild, true);
assert.equal(path.basename(summary.tarball.filename), summary.tarball.filename);
const tarball = path.join(root, 'artifacts', summary.tarball.filename);
assert.equal(hash(tarball), summary.tarball.sha256); assert.equal(fs.statSync(tarball).size, summary.tarball.bytes);
const delivery = path.join(process.env.RUNNER_TEMP, 'main-npm-delivery'); fs.mkdirSync(delivery);
fs.copyFileSync(tarball, path.join(delivery, summary.tarball.filename));
fs.copyFileSync(path.join(root, 'package-build-summary.json'), path.join(delivery, 'package-build-summary.json'));
fs.appendFileSync(process.env.GITHUB_OUTPUT, `artifact-name=qinglong-main-npm-${process.env.SOURCE_SHA}\ntarball-sha256=${summary.tarball.sha256}\npackage-version=${pkg.version}\n`);
NODE
- uses: actions/upload-artifact@v6
with:
name: ${{ steps.archive.outputs.artifact-name }}
path: ${{ runner.temp }}/main-npm-delivery/
compression-level: 0
if-no-files-found: error
- name: Preserve producer diagnostics
if: always()
uses: actions/upload-artifact@v6
with:
name: qinglong-main-npm-producer-${{ github.event.pull_request.head.sha || github.sha }}
path: ${{ runner.temp }}/qinglong-npm-export/logs/
if-no-files-found: ignore
consumer:
needs: archive
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
client:
- {npm: '10', mode: local}
- {npm: '12', mode: local}
- {npm: '12', mode: global}
sqlite: [prebuilt, source]
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- uses: actions/setup-node@v6
with:
node-version: '22.23.3'
package-manager-cache: false
- uses: actions/download-artifact@v7
with:
name: ${{ needs.archive.outputs.artifact-name }}
path: ${{ runner.temp }}/main-npm-input/
- uses: actions/download-artifact@v7
with:
name: qinglong-runtime-tools-${{ github.event.pull_request.head.sha || github.sha }}
path: ${{ runner.temp }}/runtime-tools-input/
- name: Verify archive and client tools against the producer source
env:
VERIFIED_TARBALL_SHA256: ${{ needs.archive.outputs.tarball-sha256 }}
shell: bash
run: |
set -euo pipefail
node <<'NODE'
const fs = require('node:fs'), path = require('node:path'), crypto = require('node:crypto'), assert = require('node:assert/strict');
const directory = path.join(process.env.RUNNER_TEMP, 'main-npm-input');
const summary = JSON.parse(fs.readFileSync(path.join(directory, 'package-build-summary.json')));
const pkg = JSON.parse(fs.readFileSync('package.json'));
const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
assert.equal(summary.sourceCommit, process.env.SOURCE_SHA); assert.equal(summary.previewMetadata, false);
assert.equal(summary.package, pkg.name); assert.equal(summary.packageVersion, pkg.version);
assert.equal(summary.productionManifestSHA256, hash('package.json')); assert.equal(summary.productionLockSHA256, hash('pnpm-lock.yaml'));
assert.equal(summary.runtimeToolsIncluded, true); assert.equal(summary.runtimeTools.sourceCommit, process.env.SOURCE_SHA);
assert.equal(summary.tarball.sha256, process.env.VERIFIED_TARBALL_SHA256);
assert.equal(path.basename(summary.tarball.filename), summary.tarball.filename);
assert.deepEqual(fs.readdirSync(directory).filter((name) => name.endsWith('.tgz')), [summary.tarball.filename]);
assert.equal(hash(path.join(directory, summary.tarball.filename)), process.env.VERIFIED_TARBALL_SHA256);
const tools = path.join(process.env.RUNNER_TEMP, 'runtime-tools-input');
assert.equal(hash(path.join(tools, 'runtime-tools.tgz')), summary.runtimeTools.archiveSHA256);
assert.equal(hash(path.join(tools, 'runtime-tools-proof.json')), summary.runtimeTools.proofSHA256);
NODE
- name: Install native compiler prerequisites
if: matrix.sqlite == 'source'
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends build-essential python3
- name: Select the isolated npm client
env:
CLIENT_NPM: ${{ matrix.client.npm }}
shell: bash
run: |
set -euo pipefail
npm10_cli="$(node -p "require('node:path').join(require('node:path').dirname(process.execPath), '../lib/node_modules/npm/bin/npm-cli.js')")"
if [[ "$(node "$npm10_cli" --version)" != 10.* ]]; then
echo '::error::The pinned Node22 client must supply npm10.'
exit 1
fi
node scripts/install-runtime-tools.cjs \
--archive "$RUNNER_TEMP/runtime-tools-input/runtime-tools.tgz" \
--proof "$RUNNER_TEMP/runtime-tools-input/runtime-tools-proof.json" \
--prefix "$RUNNER_TEMP/npm-consumer-tools"
if [[ "$CLIENT_NPM" == 10 ]]; then
npm_cli="$npm10_cli"
else
npm_cli="$RUNNER_TEMP/npm-consumer-tools/lib/node_modules/npm/bin/npm-cli.js"
fi
echo "QINGLONG_CONSUMER_NPM_CLI=$npm_cli" >> "$GITHUB_ENV"
- name: Consume the same archive and verify the selected SQLite installation
env:
CONSUMER_MODE: ${{ matrix.client.mode }}
SQLITE_INSTALL: ${{ matrix.sqlite }}
shell: bash
run: |
set -euo pipefail
extra_args=()
if [[ "$CONSUMER_MODE" == global ]]; then extra_args+=(--global); fi
if [[ "$SQLITE_INSTALL" == source ]]; then extra_args+=(--source-build); else extra_args+=(--require-prebuilt); fi
tarball="$(node -e 'const fs=require("node:fs"),p=require("node:path"); const d=process.argv[1]; const s=JSON.parse(fs.readFileSync(p.join(d,"package-build-summary.json"))); process.stdout.write(p.join(d,s.tarball.filename))' "$RUNNER_TEMP/main-npm-input")"
node scripts/verify-npm-package.cjs \
--tarball "$tarball" \
--build-summary "$RUNNER_TEMP/main-npm-input/package-build-summary.json" \
--npm-cli "$QINGLONG_CONSUMER_NPM_CLI" \
--output "$RUNNER_TEMP/qinglong-npm-consumer" \
"${extra_args[@]}"
- name: Preserve actual consumer evidence
if: always()
uses: actions/upload-artifact@v6
with:
name: qinglong-main-npm-consumer-${{ matrix.client.npm }}-${{ matrix.client.mode }}-${{ matrix.sqlite }}-${{ github.event.pull_request.head.sha || github.sha }}
path: |
${{ runner.temp }}/qinglong-npm-consumer/*.log
${{ runner.temp }}/qinglong-npm-consumer/npm-consumer-validation.json
if-no-files-found: ignore
source-fallback:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- uses: actions/setup-node@v6
with:
node-version: '22.23.3'
package-manager-cache: false
- uses: actions/download-artifact@v7
with:
name: ${{ inputs.static-artifact }}
path: static/
- name: Install native compiler prerequisites
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends build-essential python3
- name: Prepare source using an older manager and the same verified static tools
env:
npm_config_build_from_source: 'true'
shell: bash
run: |
set -euo pipefail
mkdir "$RUNNER_TEMP/source-tool-fallback"
git archive "$SOURCE_SHA" | tar -x -C "$RUNNER_TEMP/source-tool-fallback"
npm10_cli="$(node -p "require('node:path').join(require('node:path').dirname(process.execPath), '../lib/node_modules/npm/bin/npm-cli.js')")"
printf 'registry=https://registry.npmjs.org/\n' > "$RUNNER_TEMP/legacy-pnpm-public.npmrc"
export NPM_CONFIG_USERCONFIG="$RUNNER_TEMP/legacy-pnpm-public.npmrc"
export npm_config_userconfig="$NPM_CONFIG_USERCONFIG"
export NPM_CONFIG_GLOBALCONFIG=/dev/null npm_config_globalconfig=/dev/null
export NPM_CONFIG_CACHE="$RUNNER_TEMP/legacy-pnpm-cache"
export PNPM_HOME="$RUNNER_TEMP/legacy-pnpm-home"
export XDG_CACHE_HOME="$RUNNER_TEMP/legacy-pnpm-xdg-cache" XDG_STATE_HOME="$RUNNER_TEMP/legacy-pnpm-xdg-state"
node "$npm10_cli" install --global pnpm@8.3.1 \
--prefix "$RUNNER_TEMP/legacy-pnpm" --ignore-scripts --no-audit --no-fund \
--userconfig "$RUNNER_TEMP/legacy-pnpm-public.npmrc" --globalconfig /dev/null \
--cache "$RUNNER_TEMP/legacy-pnpm-cache" --registry=https://registry.npmjs.org \
2>&1 | tee "$RUNNER_TEMP/legacy-pnpm-install.log"
legacy_cli="$RUNNER_TEMP/legacy-pnpm/lib/node_modules/pnpm/bin/pnpm.cjs"
node "$legacy_cli" --version | tee "$RUNNER_TEMP/legacy-pnpm-version.log"
test "$(cat "$RUNNER_TEMP/legacy-pnpm-version.log")" = '8.3.1'
node -e 'const fs=require("node:fs"),os=require("node:os"); fs.writeFileSync(process.argv[1],JSON.stringify(fs.readdirSync(os.tmpdir()).filter(n=>n.startsWith("qinglong-source-tools-")).sort()))' "$RUNNER_TEMP/source-tool-prefixes-before.json"
node scripts/install-panel-dependencies.cjs \
--root "$RUNNER_TEMP/source-tool-fallback" \
--pnpm-cli "$legacy_cli" \
--static-dir "$GITHUB_WORKSPACE/static" \
--source-commit "$SOURCE_SHA" \
2>&1 | tee "$RUNNER_TEMP/source-tool-fallback.log"
grep -q 'SOLINK_MODULE' "$RUNNER_TEMP/source-tool-fallback.log"
grep -q 'node-gyp@12.4.0' "$RUNNER_TEMP/source-tool-fallback.log"
grep -q '"prefixScope":"fresh temporary only"' "$RUNNER_TEMP/source-tool-fallback.log"
node <<'NODE'
const fs = require('node:fs'), path = require('node:path'), os = require('node:os'), crypto = require('node:crypto'), assert = require('node:assert/strict');
const { createRequire } = require('node:module');
const root = path.join(process.env.RUNNER_TEMP, 'source-tool-fallback');
const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
assert.equal(hash(path.join(root, 'pnpm-lock.yaml')), hash('pnpm-lock.yaml'));
const before = JSON.parse(fs.readFileSync(path.join(process.env.RUNNER_TEMP, 'source-tool-prefixes-before.json')));
assert.deepEqual(fs.readdirSync(os.tmpdir()).filter((name) => name.startsWith('qinglong-source-tools-')).sort(), before);
const requireSource = createRequire(path.join(root, 'package.json')); requireSource('sqlite3');
const binding = Object.keys(require.cache).find((file) => file.endsWith('.node'));
assert(binding); assert.equal(fs.readFileSync(binding).subarray(0, 4).toString('hex'), '7f454c46');
fs.writeFileSync(path.join(process.env.RUNNER_TEMP, 'source-tool-native-proof.json'), JSON.stringify({ sourceCommit: process.env.SOURCE_SHA, productionLockSHA256: hash('pnpm-lock.yaml'), legacyManagerVersion: '8.3.1', legacyManagerCliSHA256: hash(path.join(process.env.RUNNER_TEMP, 'legacy-pnpm/lib/node_modules/pnpm/bin/pnpm.cjs')), binding: path.relative(root, binding), bindingSHA256: hash(binding), temporaryPrefixRemoved: true }, null, 2));
NODE
node "$RUNNER_TEMP/source-tool-fallback/scripts/verify-production-dependencies.cjs" \
2>&1 | tee "$RUNNER_TEMP/source-tool-fallback-protocols.log"
- name: Preserve source migration evidence
if: always()
uses: actions/upload-artifact@v6
with:
name: qinglong-source-tool-fallback-${{ github.event.pull_request.head.sha || github.sha }}
path: |
${{ runner.temp }}/legacy-pnpm-install.log
${{ runner.temp }}/legacy-pnpm-version.log
${{ runner.temp }}/source-tool-fallback.log
${{ runner.temp }}/source-tool-fallback-protocols.log
${{ runner.temp }}/source-tool-native-proof.json
if-no-files-found: ignore
verified:
needs: [archive, consumer, source-fallback]
runs-on: ubuntu-latest
outputs:
artifact-name: ${{ needs.archive.outputs.artifact-name }}
tarball-sha256: ${{ needs.archive.outputs.tarball-sha256 }}
package-version: ${{ needs.archive.outputs.package-version }}
steps:
- run: echo 'The exact main npm archive passed six Linux consumers and the older-manager source migration.'
+72 -57
View File
@@ -6,11 +6,6 @@ on:
- '.github/workflows/release-readiness.yml'
- 'package.json'
- 'pnpm-lock.yaml'
- 'docker/Dockerfile*'
- 'shell/node-runtime-probe.cjs'
- 'scripts/verify-panel-package.cjs'
- 'scripts/verify-image-node-layers.py'
- 'test/back/node-runtime-probe.test.cjs'
workflow_dispatch:
permissions:
@@ -24,7 +19,11 @@ jobs:
regression:
uses: ./.github/workflows/validate.yml
runtime-tools:
uses: ./.github/workflows/runtime-tools.yml
static:
needs: runtime-tools
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
@@ -33,29 +32,61 @@ jobs:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- uses: pnpm/action-setup@v6
with:
version: '8.3.1'
version: '10.34.6'
- 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
- run: pnpm audit --prod --audit-level high
- run: pnpm build:front && pnpm build:back
- uses: actions/download-artifact@v7
with:
name: qinglong-runtime-tools-${{ github.event.pull_request.head.sha || github.sha }}
path: static/
- run: pnpm build:info
- uses: actions/upload-artifact@v6
with:
name: release-readiness-static
path: static/
include-hidden-files: true
if-no-files-found: error
- name: Pack the verified npm archive for isolated runtime validation
run: node scripts/verify-panel-package.cjs pack "$RUNNER_TEMP/npm-panel"
- uses: actions/upload-artifact@v6
sqlite-install:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
with:
name: release-readiness-npm-panel
path: ${{ runner.temp }}/npm-panel/
if-no-files-found: error
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- uses: pnpm/action-setup@v6
with:
version: '10.34.6'
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: pnpm
- run: pnpm install --prod --frozen-lockfile
- name: Verify precompiled SQLite and production protocols
run: node scripts/verify-production-dependencies.cjs
- name: Rebuild SQLite from source
env:
npm_config_build_from_source: 'true'
PYTHON: /usr/bin/python3
run: |
export npm_config_node_gyp="$(node -p 'require("node:module").createRequire(require.resolve("sqlite3/package.json")).resolve("node-gyp/bin/node-gyp.js")')"
pnpm rebuild @whyour/sqlite3
- name: Verify source-built SQLite and production protocols
run: node scripts/verify-production-dependencies.cjs
npm-package:
needs: static
uses: ./.github/workflows/npm-package.yml
with:
static-artifact: release-readiness-static
images:
needs: [regression, static]
needs: [regression, static, sqlite-install]
runs-on: ubuntu-22.04
timeout-minutes: 60
strategy:
@@ -69,7 +100,7 @@ jobs:
- image: debian
file: docker/Dockerfile.debian
cache: debian
platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/s390x
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
- image: alpine-python3.10
file: docker/Dockerfile.310
cache: alpine-python3.10
@@ -77,7 +108,7 @@ jobs:
- image: debian-python3.10
file: docker/Dockerfile.debian310
cache: debian-python3.10
platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/s390x
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
steps:
- uses: actions/checkout@v6
with:
@@ -132,48 +163,32 @@ 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")'
- name: Load ARMv6 image for SQLite and Node regression checks
if: startsWith(matrix.image, 'alpine')
uses: docker/build-push-action@v7
with:
context: .
file: ${{ matrix.file }}
platforms: linux/arm/v6
build-args: |
QL_URL=${{ steps.source.outputs.url }}
QL_BRANCH=${{ steps.source.outputs.branch }}
SOURCE_COMMIT=${{ steps.source.outputs.sha }}
tags: qinglong-readiness:${{ matrix.image }}-armv6
load: true
push: false
provenance: false
sbom: false
- name: Verify ARMv6 SQLite on an ARM1176 CPU without networking
if: startsWith(matrix.image, 'alpine')
--eval 'console.log(JSON.stringify({node: process.version, arch: process.arch, platform: process.platform}))'
docker run --rm --network none --entrypoint python3 \
qinglong-readiness:${{ matrix.image }} --version
docker run --rm --network none --entrypoint node \
qinglong-readiness:${{ matrix.image }} \
scripts/verify-system-runtime.cjs
docker run --rm --network none --entrypoint node \
qinglong-readiness:${{ matrix.image }} \
scripts/verify-production-dependencies.cjs
- name: Archive verified native Debian image for isolated dev validation
if: matrix.image == 'debian'
shell: bash
run: |
docker run --rm --network none --platform linux/arm/v6 \
-e QEMU_CPU=arm1176 --entrypoint node \
qinglong-readiness:${{ matrix.image }}-armv6 --jitless \
-e 'const assert=require("node:assert/strict"); const sqlite3=require("sqlite3"); const db=new sqlite3.Database(":memory:"); db.get("SELECT 42 AS n",(error,row)=>{assert.ifError(error); assert.equal(row.n,42); db.close()});'
- name: Check Node binaries in every serialized image layer
if: startsWith(matrix.image, 'alpine')
run: |
docker save qinglong-readiness:${{ matrix.image }} -o "$RUNNER_TEMP/node-layers-amd64.tar"
docker save qinglong-readiness:${{ matrix.image }}-armv6 -o "$RUNNER_TEMP/node-layers-arm.tar"
python3 scripts/verify-image-node-layers.py "$RUNNER_TEMP/node-layers-amd64.tar" amd64
python3 scripts/verify-image-node-layers.py "$RUNNER_TEMP/node-layers-arm.tar" arm
rm "$RUNNER_TEMP/node-layers-amd64.tar" "$RUNNER_TEMP/node-layers-arm.tar"
- name: Export native image for isolated dev validation
if: matrix.image == 'debian' || matrix.image == 'alpine'
run: |
docker save qinglong-readiness:${{ matrix.image }} | gzip -1 > release-readiness-${{ matrix.image }}-image.tar.gz
sha256sum release-readiness-${{ matrix.image }}-image.tar.gz > release-readiness-${{ matrix.image }}-image.sha256
docker image inspect qinglong-readiness:${{ matrix.image }} > release-readiness-${{ matrix.image }}-image.json
docker image inspect qinglong-readiness:debian \
--format '{"id":{{json .Id}},"revision":{{json (index .Config.Labels "org.opencontainers.image.revision")}},"architecture":{{json .Architecture}},"os":{{json .Os}}}' \
> release-readiness-debian-image.json
docker save qinglong-readiness:debian | gzip > release-readiness-debian-image.tar.gz
sha256sum release-readiness-debian-image.tar.gz > release-readiness-debian-image.sha256
- uses: actions/upload-artifact@v6
if: matrix.image == 'debian' || matrix.image == 'alpine'
if: matrix.image == 'debian'
with:
name: release-readiness-${{ matrix.image }}-image
path: release-readiness-${{ matrix.image }}-image.*
name: release-readiness-debian-image
path: |
release-readiness-debian-image.tar.gz
release-readiness-debian-image.sha256
release-readiness-debian-image.json
compression-level: 0
retention-days: 3
if-no-files-found: error
+40
View File
@@ -0,0 +1,40 @@
name: Build verified runtime tools
on:
workflow_call:
workflow_dispatch:
permissions:
contents: read
jobs:
tools:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- uses: actions/setup-node@v6
with:
node-version: '22.23.3'
package-manager-cache: false
- name: Build the pinned universal Linux tool archive
env:
QL_TOOLS_SOURCE_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }}
run: bash scripts/build-runtime-tools.sh "$RUNNER_TEMP/qinglong-runtime-tools"
- uses: actions/upload-artifact@v6
with:
name: qinglong-runtime-tools-${{ github.event.pull_request.head.sha || github.sha }}
path: |
${{ runner.temp }}/qinglong-runtime-tools/runtime-tools.tgz
${{ runner.temp }}/qinglong-runtime-tools/runtime-tools-proof.json
compression-level: 0
if-no-files-found: error
- name: Preserve build diagnostics
if: always()
uses: actions/upload-artifact@v6
with:
name: qinglong-runtime-tools-diagnostics-${{ github.event.pull_request.head.sha || github.sha }}
path: ${{ runner.temp }}/qinglong-runtime-tools/logs/
if-no-files-found: ignore
+8 -3
View File
@@ -11,21 +11,26 @@ permissions:
jobs:
regression:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: ['22', '24']
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: '8.3.1'
version: '10.34.6'
- uses: actions/setup-node@v6
with:
node-version: '20'
node-version: ${{ matrix.node }}
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: cp .env.example .env
- run: pnpm test
- run: pnpm build:back
- run: pnpm build:front
- run: pnpm benchmark:execution -- --runs=10 --output=execution-baseline.json
- uses: actions/upload-artifact@v6
with:
name: execution-baseline
name: execution-baseline-node-${{ matrix.node }}
path: execution-baseline.json
-10
View File
@@ -29,7 +29,6 @@ __pycache__
/shell/preload/*-notify.json
/shell/preload/__ql_notify__.*
/shell/preload/lang_env.sh
/shell/upgrade-lock.yaml
.deepseek/
.claude/
@@ -39,12 +38,3 @@ __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
@@ -0,0 +1,22 @@
/.tmp/
/.github/
/.vscode/
/.history/
/back/**/*.ts
/back/**/*.json
/cli/
/data/
/src/
/static/**/*.js.map
/static/**/*.gz
/.editorconfig
/.gitignore
/.prettierignore
/.prettierrc
/.umirc.ts
/nodemon.json
/pnpm-lock.yaml
/tsconfig.back.json
/tsconfig.json
/typings.d.ts
/.env
+2 -1
View File
@@ -1 +1,2 @@
strict-peer-dependencies=false
strict-peer-dependencies=false
strict-dep-builds=true
-1
View File
@@ -1 +0,0 @@
22
-10
View File
@@ -1,10 +0,0 @@
# Node.js 原生部署与 Python venv
设置 `QL_DIR` 为安装目录,`QL_DATA_DIR` 为以 `/data` 结尾的绝对持久化数据路径。
如需隔离 Python,请在启动进程的环境中显式设置 `PYTHON_VENV_DIR`(绝对路径或相对 `QL_DIR` 的路径),例如 `export PYTHON_VENV_DIR=/srv/qinglong/.venv`。未设置时保留现有 Python prefix 行为。
- `qinglong`(`shell/start.sh`)安装系统依赖和 Node 工具,并安装 Python requests,然后调用公共启动脚本。
- `npm run start:simple`(`shell/start-simplify.sh`)使用已准备的 Node、pm2、nginx、jq 等依赖,不安装系统包、全局 npm 包或 requests;显式指定的 venv 不存在时仍会创建。请先在 venv 中安装 requests 和任务所需依赖。
- 两种启动方式均保留已有 `.env`;使用 venv 时清除继承的 `PYTHONPATH`/`PYTHONUSERBASE`,依赖查询、安装和卸载使用该 venv 的 Python。
该模式需具备现有启动流程所要求的 nginx 配置和 pm2 服务权限。生产环境设置变量应通过 systemd 的 `Environment` 或相应进程配置完成。已有系统 Python 依赖不自动迁移至 venv,需在 venv 中重新安装。
+10 -4
View File
@@ -62,10 +62,14 @@ docker run -d \
### npm
The npm version supports `debian/ubuntu/alpine` systems and requires `node/npm/python3/pip3/pnpm` to be installed.
The npm deployment supports `debian/ubuntu/alpine` with Node.js 22.x >=22.22.2 or 24.x >=24.15.0, npm, Python 3 and pip. The 2.23.0 archive includes frozen production dependencies and verified runtime tools; a separate pnpm installation is unnecessary. After this version is published, use the command below. With npm 12, approve this exact package lifecycle in your project's `package.json` first:
```json
{"allowScripts":{"@whyour/qinglong@2.23.0":true}}
```
```bash
npm i @whyour/qinglong
npm i @whyour/qinglong@2.23.0
```
## Deployment
@@ -105,13 +109,15 @@ The package includes a [remote management skill](cli/skills/qinglong-cli/SKILL.m
## Development
Use Node.js 22.x starting at 22.22.2, or Node.js 24.x starting at 24.15.0.
```bash
git clone https://github.com/whyour/qinglong.git
cd qinglong
cp .env.example .env
# Recommended use pnpm https://pnpm.io/zh/installation
npm install -g pnpm@8.3.1
pnpm install
npm install -g pnpm@10.34.6
pnpm install --frozen-lockfile
pnpm start
```
+10 -4
View File
@@ -64,10 +64,14 @@ docker run -d \
### npm
npm 版本支持 `debian/ubuntu/alpine` 系统,需要自行安装 `node/npm/python3/pip3/pnpm`
npm 版本支持 `debian/ubuntu/alpine` 系统,需要自行安装 Node.js 22.x >=22.22.2 或 24.x >=24.15.0,以及 npm、Python 3 和 pip。2.23.0 归档携带冻结生产依赖和受控工具,不需要单独安装 pnpm。该版本发布后使用以下命令;npm 12 的项目安装需要在当前项目 `package.json` 中精确批准安装脚本:
```json
{"allowScripts":{"@whyour/qinglong@2.23.0":true}}
```
```bash
npm i @whyour/qinglong
npm i @whyour/qinglong@2.23.0
```
## 部署
@@ -107,13 +111,15 @@ npm CLI 用于远程管理。面板内置 `ql` 同样使用这个命令名,已
## 开发
使用 Node.js 22.22.2 及以上的 22.x,或 Node.js 24.x >=24.15.0。
```bash
git clone https://github.com/whyour/qinglong.git
cd qinglong
cp .env.example .env
# 推荐使用 pnpm https://pnpm.io/zh/installation
npm install -g pnpm@8.3.1
pnpm install
npm install -g pnpm@10.34.6
pnpm install --frozen-lockfile
pnpm start
```
+1 -2
View File
@@ -1,6 +1,5 @@
import { NextFunction, Request, Response, Router } from 'express';
import { celebrate, Joi } from 'celebrate';
import { t } from '../shared/i18n';
import {
diagnoseClientIp,
getTrustProxyConfig,
@@ -37,7 +36,7 @@ export default (app: Router) => {
} catch (error) {
res.send({
code: 400,
message: error instanceof Error ? error.message : t('配置更新失败'),
message: error instanceof Error ? error.message : '配置更新失败',
});
}
},
-24
View File
@@ -205,17 +205,6 @@ 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({
@@ -394,19 +383,6 @@ export default (app: Router) => {
},
);
route.get(
'/notify-log',
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const data = await systemService.getNotifyLog();
res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.delete(
'/log',
async (req: Request, res: Response, next: NextFunction) => {
+28 -13
View File
@@ -5,7 +5,6 @@ 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';
@@ -28,7 +27,6 @@ class Application {
if (cluster.isPrimary) {
await this.initializeDatabase();
}
await initializeLanguageSync();
if (cluster.isPrimary) {
this.startMasterProcess();
} else {
@@ -41,18 +39,11 @@ 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, readyTimeout)
this.waitForWorkerReady(grpcWorker, 30000)
.then(() => {
Logger.info('[boot] gRPC worker is ready, starting HTTP worker');
this.httpWorker = this.forkWorker('http');
@@ -78,7 +69,7 @@ class Application {
Logger.warn('Unable to notify HTTP worker of scheduler exit');
}
const newGrpcWorker = this.forkWorker('grpc');
this.waitForWorkerReady(newGrpcWorker, readyTimeout)
this.waitForWorkerReady(newGrpcWorker, 30000)
.then(() => {
Logger.info('gRPC worker restarted and ready');
// Re-register cron jobs by notifying the HTTP worker
@@ -155,8 +146,32 @@ class Application {
}
private async setupMiddlewares(): Promise<express.Application> {
const { createHttpApplication } = await import('./loaders/httpApplication');
return createHttpApplication();
// 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;
}
private setupMasterShutdown() {
-139
View File
@@ -1,139 +0,0 @@
// A single, disposable backend process. Resolve startup dependencies, but never
// run appLoader: its initData/initTask hooks install dependencies and start user work.
import 'reflect-metadata';
import dns from 'dns';
import http from 'http';
// The first scheduler connection can outlive its short RPC deadline. Let the
// normal single-flight recovery finish within the supervised startup budget.
async function waitForUpgradeScheduler(
readiness: { recover(): Promise<boolean> },
timeoutMs: number,
retryMs = 250,
) {
const deadline = Date.now() + timeoutMs;
do {
if (await readiness.recover()) return;
const remaining = deadline - Date.now();
if (remaining <= 0) break;
await new Promise((resolve) =>
setTimeout(resolve, Math.min(retryMs, remaining)),
);
} while (Date.now() < deadline);
throw new Error('Upgrade scheduler readiness failed');
}
async function probeUpgrade() {
if (!process.send || process.env.QL_UPGRADE_PROBE !== '1')
throw new Error('Upgrade probe requires its isolated supervisor');
dns.setDefaultResultOrder('ipv4first');
const { default: config } = await import('../config');
const { sequelize } = await import('../data');
const { default: loadDatabase } = await import('../loaders/db');
const { Container } = await import('typedi');
const { GrpcServerService } = await import('../services/grpc');
const { HttpServerService } = await import('../services/http');
const { createHttpApplication } = await import('../loaders/httpApplication');
const { default: loadServer } = await import('../loaders/server');
const { default: loadExpress } = await import('../loaders/express');
await import('../loaders/app'); // Resolve normal startup hooks without running them.
const { default: inject } = await import('../loaders/depInjector');
const grpc = Container.get(GrpcServerService);
const server = Container.get(HttpServerService);
try {
await loadDatabase();
// Exercise an actual on-disk transaction as well as schema initialization.
await sequelize.transaction(async (transaction) => {
await sequelize.query('CREATE TABLE upgrade_probe (value INTEGER)', {
transaction,
});
await sequelize.query('INSERT INTO upgrade_probe VALUES (42)', {
transaction,
});
const [rows] = await sequelize.query('SELECT value FROM upgrade_probe', {
transaction,
});
if ((rows[0] as { value: number })?.value !== 42)
throw new Error('Upgrade database readback failed');
});
inject();
config.grpcPort = await grpc.initialize();
const { default: cronClient } = await import('../schedule/client');
cronClient.readiness.configure(async () => {});
await waitForUpgradeScheduler(
cronClient.readiness,
process.arch === 'arm' &&
Number(
(process.config.variables as { arm_version?: string | number })
.arm_version,
) === 6
? 60000
: 10000,
);
const app = await createHttpApplication();
loadExpress({ app });
const listener = await server.initialize(app, 0);
await loadServer({ server: listener });
const address = listener.address();
if (!address || typeof address === 'string')
throw new Error('No probe HTTP port');
const health = await new Promise<any>((resolve, reject) => {
const request = http.get(
`http://127.0.0.1:${address.port}/api/health`,
(response) => {
let body = '';
response.on('data', (chunk) => {
body += chunk;
});
response.on('error', reject);
response.on('end', () => {
try {
const result = JSON.parse(body);
if (
response.statusCode !== 200 ||
result.code !== 200 ||
result.data?.status !== 'ok' ||
!result.data.services?.grpc ||
!result.data.services?.http
)
throw new Error('Upgrade HTTP/gRPC health check failed');
resolve(result.data);
} catch (error) {
reject(error);
}
});
},
);
request.setTimeout(10000, () =>
request.destroy(new Error('Upgrade health request timed out')),
);
request.on('error', reject);
});
process.send!({
type: 'upgrade-probe',
ok: true,
build: health.build,
memory: process.memoryUsage(),
pid: process.pid,
});
} finally {
await server.shutdown();
grpc.getServer().forceShutdown();
await sequelize.close();
}
}
process.once('disconnect', () => process.exit(1));
probeUpgrade()
.then(() => process.exit(0))
.catch((error) => {
process.send?.({
type: 'upgrade-probe',
ok: false,
code: error.code,
message: error.message,
stack: error.stack,
});
console.error(error);
process.exit(1);
});
-1
View File
@@ -27,7 +27,6 @@ export const SAMPLE_FILES = [
];
export const PYTHON_INSTALL_DIR = process.env.PYTHON_HOME;
export const PYTHON_VENV_DIR = process.env.PYTHON_VENV_DIR;
export const NotificationModeStringMap = {
0: 'gotify',
+38 -162
View File
@@ -1,25 +1,19 @@
import * as fs from 'fs/promises';
import { statSync } from 'fs';
import * as path from 'path';
import { exec, execSync } from 'child_process';
import psTreeFun from 'ps-tree';
import { promisify } from 'util';
import { load } from 'js-yaml';
import config from './index';
import { PYTHON_INSTALL_DIR, PYTHON_VENV_DIR, TASK_COMMAND } from './const';
import { PYTHON_INSTALL_DIR, TASK_COMMAND } from './const';
import Logger from '../loaders/logger';
import { writeFileWithLock } from '../shared/utils';
import { DependenceTypes } from '../data/dependence';
import { FormData } from 'undici';
import os from 'os';
import { maybeSudo, isInContainer } from './container';
import { shellQuote } from '../shared/shellQuote';
import { resolveFileAccess } from '../shared/fileAccess';
import {
mirrorWriteCommand,
normalizeMirror,
rewriteMirrorSources,
} from '../shared/linuxMirror';
import { mirrorWriteCommand, normalizeMirror, rewriteMirrorSources } from '../shared/linuxMirror';
export * from './share';
@@ -677,9 +671,7 @@ export async function setSystemTimezone(timezone: string): Promise<boolean> {
throw new Error('Invalid timezone');
}
await promiseExec(
maybeSudo(`ln -sf /usr/share/zoneinfo/${timezone} /etc/localtime`),
);
await promiseExec(maybeSudo(`ln -sf /usr/share/zoneinfo/${timezone} /etc/localtime`));
await promiseExec(`echo "${timezone}" | ${maybeSudo('tee /etc/timezone')}`);
return true;
@@ -689,177 +681,61 @@ export async function setSystemTimezone(timezone: string): Promise<boolean> {
}
}
// Helper function to check if a name is a GitHub URL
function getPythonExecutable(): string {
return PYTHON_VENV_DIR
? shellQuote(path.resolve(config.rootPath, PYTHON_VENV_DIR, 'bin/python3'))
: 'python3';
}
function getPythonPip(): string {
return PYTHON_VENV_DIR ? `${getPythonExecutable()} -m pip` : 'pip3';
}
function isGitHubUrl(name: string): boolean {
// Support git+https://, git+http://, https://, and http:// URLs
// This covers GitHub URLs and other git-compatible repositories
return !!name.match(/^(git\+https?:\/\/|https?:\/\/)/i);
}
// Helper function to check if a name is a requirements file
function isRequirementsFile(name: string): boolean {
return !!name.match(/requirements.*\.(txt|in)$/i);
}
// Helper function to check if a name is a pyproject.toml file
function isPyprojectToml(name: string): boolean {
return name.endsWith('pyproject.toml');
}
export function isPythonSourceDependency(name: string): boolean {
const source = name.trim();
return (
isGitHubUrl(source) || isRequirementsFile(source) || isPyprojectToml(source)
);
}
export function getGetCommand(type: DependenceTypes, name: string): string {
const trimmedName = name.trim();
// For Python dependencies installed from GitHub or requirements files,
// we can't reliably check if they're installed, so skip the check
if (type === DependenceTypes.python3) {
if (isPythonSourceDependency(trimmedName)) {
// Return a command that will always indicate not installed
// This ensures GitHub URLs and requirements files are always installed
return 'echo ""';
}
}
const baseCommands = {
[DependenceTypes.nodejs]: `pnpm ls -g | grep "${trimmedName}" | head -1`,
[DependenceTypes.python3]: `${getPythonExecutable()} -c ${shellQuote(
`from importlib.metadata import version, PackageNotFoundError
import importlib.util
import sys
[DependenceTypes.nodejs]: `pnpm ls -g | grep "${name}" | head -1`,
[DependenceTypes.python3]: `
python3 -c "exec('''
name='${name}'
try:
try:
from packaging.requirements import Requirement
except ImportError:
from pip._vendor.packaging.requirements import Requirement
requirement = Requirement(sys.argv[1])
if requirement.marker and not requirement.marker.evaluate():
print('not applicable')
else:
try:
installed = version(requirement.name)
except PackageNotFoundError:
if not requirement.specifier and not requirement.extras and not requirement.url:
if importlib.util.find_spec(requirement.name):
print(requirement.name)
else:
if requirement.specifier.contains(installed, prereleases=True):
print(installed)
except Exception:
pass`,
)} ${shellQuote(trimmedName)}`,
[DependenceTypes.linux]:
getOsTypeSync() === 'Alpine'
? `apk info -es ${trimmedName}`
: maybeSudo(`dpkg-query -s ${trimmedName}`),
from importlib.metadata import version
print(version(name))
except:
import importlib.util as u
import importlib.metadata as m
spec=u.find_spec(name)
print(name if spec else '')
''')"`,
[DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
? `apk info -es ${name}`
: maybeSudo(`dpkg-query -s ${name}`),
};
return baseCommands[type];
}
export function getInstallCommand(type: DependenceTypes, name: string): string {
if (type === DependenceTypes.python3) {
const prefix =
PYTHON_INSTALL_DIR && !PYTHON_VENV_DIR
? ` --prefix=${shellQuote(PYTHON_INSTALL_DIR)}`
: '';
const pip = `${getPythonPip()} install --disable-pip-version-check${
PYTHON_VENV_DIR ? '' : ' --root-user-action=ignore'
}${prefix}`;
name = name.trim();
if (isRequirementsFile(name) || isPyprojectToml(name)) {
const file = [config.scriptPath, config.repoPath]
.filter(Boolean)
.map((root) => resolveFileAccess(root, [name]))
.find((file) => {
if (!file) return false;
try {
return statSync(file).isFile();
} catch {
return false;
}
});
if (!file)
throw new Error(
'Python dependency file must be inside scripts or repo',
);
return isRequirementsFile(name)
? `${pip} -r ${shellQuote(file)}`
: `${pip} ${shellQuote(path.dirname(file))}`;
}
return `${pip} -- ${shellQuote(name)}`;
}
const baseCommands = {
[DependenceTypes.nodejs]: 'pnpm add -g',
[DependenceTypes.linux]:
getOsTypeSync() === 'Alpine'
? 'apk add --no-check-certificate'
: maybeSudo('apt-get install -y'),
[DependenceTypes.python3]:
'pip3 install --disable-pip-version-check --root-user-action=ignore',
[DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
? 'apk add --no-check-certificate'
: maybeSudo('apt-get install -y'),
};
return `${baseCommands[type]} ${name.trim()}`;
let command = baseCommands[type];
if (type === DependenceTypes.python3 && PYTHON_INSTALL_DIR) {
command = `${command} --prefix=${PYTHON_INSTALL_DIR}`;
}
return `${command} ${name.trim()}`;
}
export function getUninstallCommand(
type: DependenceTypes,
name: string,
): string {
if (type === DependenceTypes.python3) {
name = name.trim();
if (isRequirementsFile(name)) {
const file = [config.scriptPath, config.repoPath]
.filter(Boolean)
.map((root) => resolveFileAccess(root, [name]))
.find((file) => {
if (!file) return false;
try {
return statSync(file).isFile();
} catch {
return false;
}
});
if (!file)
throw new Error(
'Python dependency file must be inside scripts or repo',
);
return `${getPythonPip()} uninstall --disable-pip-version-check${
PYTHON_VENV_DIR ? '' : ' --root-user-action=ignore'
} -y -r ${shellQuote(file)}`;
}
if (isPyprojectToml(name) || isGitHubUrl(name)) {
// A source URL/project can install several distributions and has no safe
// inverse. Removing its panel entry leaves installed packages intact.
return `printf '%s\\n' ${shellQuote(
'Remove installed Python distributions separately by package name.',
)}`;
}
const packageName =
name.match(
/^([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]+\])?(?=\s*(?:[<>=!~;@]|$))/,
)?.[1] || name;
return `${getPythonPip()} uninstall --disable-pip-version-check${
PYTHON_VENV_DIR ? '' : ' --root-user-action=ignore'
} -y -- ${shellQuote(packageName)}`;
}
const baseCommands = {
[DependenceTypes.nodejs]: 'pnpm remove -g',
[DependenceTypes.linux]:
getOsTypeSync() === 'Alpine' ? 'apk del' : maybeSudo('apt-get remove -y'),
[DependenceTypes.python3]:
'pip3 uninstall --disable-pip-version-check --root-user-action=ignore -y',
[DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
? 'apk del'
: maybeSudo('apt-get remove -y'),
};
return `${baseCommands[type]} ${name.trim()}`;
}
@@ -944,7 +820,7 @@ async function _updateLinuxMirror(
throw new Error('Unsupported OS type for updating mirrors.');
const target = mirror || defaults[osType];
normalizeMirror(target);
const S = osType !== 'Alpine' && isInContainer() ? 'sudo ' : '';
const S = isInContainer() ? 'sudo ' : '';
const update =
osType === 'Alpine' ? 'apk update' : `${S}apt-get update --error-on=any`;
let file =
-16
View File
@@ -28,12 +28,8 @@ export enum AuthDataType {
'removeLogFrequency' = 'removeLogFrequency',
'systemConfig' = 'systemConfig',
'authConfig' = 'authConfig',
'notifyLog' = 'notifyLog',
}
export const NotifyStatus = LoginStatus;
export type NotifyStatus = LoginStatus;
export interface SystemConfigInfo {
lang?: string;
panelTitle?: string;
@@ -57,17 +53,6 @@ export interface LoginLogInfo {
status?: LoginStatus;
}
export interface NotifyLogInfo {
timestamp?: number;
title?: string;
content?: string;
status?: NotifyStatus;
notifyType?: string;
source?: 'system' | 'script';
scriptName?: string;
taskId?: string;
}
export interface TokenInfo {
value: string;
timestamp: number;
@@ -103,7 +88,6 @@ export interface AuthInfo {
export type SystemModelInfo = SystemConfigInfo &
Partial<NotificationInfo> &
LoginLogInfo &
Partial<NotifyLogInfo> &
Partial<AuthInfo>;
export interface SystemInstance
+2 -2
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 validationErrors from '../middlewares/validationErrors';
import { errors } from 'celebrate';
import { serveEnv } from '../config/serverEnv';
import { shareStore } from '../shared/store';
import { isValidToken, isDefaultAuthInfo } from '../shared/auth';
@@ -197,7 +197,7 @@ export default ({ app }: { app: Application }) => {
});
});
app.use(validationErrors);
app.use(errors());
app.use(uniqueConstraintError);
app.use(
-33
View File
@@ -1,33 +0,0 @@
import type express from 'express';
import config from '../config';
export async function createHttpApplication(): Promise<express.Application> {
// Only the HTTP worker needs an Express application and its middleware.
const [
{ default: createExpress },
{ default: helmet },
{ default: cors },
{ default: compression },
{ monitoringMiddleware },
] = await Promise.all([
import('express'),
import('helmet'),
import('cors'),
import('compression'),
import('../middlewares/monitoring'),
]);
const app = createExpress();
app.use((req, res, next) => {
if (req.query.t) delete req.query.t;
next();
});
app.use(
helmet({
contentSecurityPolicy: false,
}),
);
app.use(cors(config.cors));
app.use(compression());
app.use(monitoringMiddleware);
return app;
}
+2 -2
View File
@@ -63,7 +63,7 @@ export default async () => {
}
systemService.updateTimezone(data.info);
// Apply global SSH key if configured
if (data.info.globalSshKey) {
await sshKeyService.addGlobalSSHKey(data.info.globalSshKey, 'global');
@@ -78,7 +78,7 @@ export default async () => {
assertSubscriptionAlias(doc.alias);
await subscriptionService.handleTask(doc, !sub.is_disabled);
} catch (error) {
Logger.warn(t('跳过订阅 %s 的无效定时配置: %s'), sub.id, error);
Logger.warn('跳过订阅 %s 的无效定时配置: %s', sub.id, error);
}
}
};
-18
View File
@@ -1,18 +0,0 @@
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;
-10
View File
@@ -260,15 +260,6 @@ message SystemNotifyRequest {
optional NotificationInfo notificationInfo = 3;
}
message RecordNotifyLogRequest {
string title = 1;
string content = 2;
bool success = 3;
string notifyType = 4;
string scriptName = 5;
string taskId = 6;
}
service Api {
rpc GetEnvs(GetEnvsRequest) returns (EnvsResponse) {}
rpc CreateEnv(CreateEnvRequest) returns (EnvsResponse) {}
@@ -280,7 +271,6 @@ service Api {
rpc UpdateEnvNames(UpdateEnvNamesRequest) returns (Response) {}
rpc GetEnvById(GetEnvByIdRequest) returns (EnvResponse) {}
rpc SystemNotify(SystemNotifyRequest) returns (Response) {}
rpc RecordNotifyLog(RecordNotifyLogRequest) returns (Response) {}
rpc GetCronDetail(CronDetailRequest) returns (CronDetailResponse) {}
rpc CreateCron(CreateCronRequest) returns (CronResponse) {}
rpc UpdateCron(UpdateCronRequest) returns (CronResponse) {}
+1 -175
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions:
// protoc-gen-ts_proto v2.6.1
// protoc v6.31.1
// protoc v3.17.3
// source: back/protos/api.proto
/* eslint-disable */
@@ -417,15 +417,6 @@ export interface SystemNotifyRequest {
notificationInfo?: NotificationInfo | undefined;
}
export interface RecordNotifyLogRequest {
title: string;
content: string;
success: boolean;
notifyType: string;
scriptName: string;
taskId: string;
}
function createBaseEnvItem(): EnvItem {
return {
id: undefined,
@@ -4305,146 +4296,6 @@ export const SystemNotifyRequest: MessageFns<SystemNotifyRequest> = {
},
};
function createBaseRecordNotifyLogRequest(): RecordNotifyLogRequest {
return { title: "", content: "", success: false, notifyType: "", scriptName: "", taskId: "" };
}
export const RecordNotifyLogRequest: MessageFns<RecordNotifyLogRequest> = {
encode(message: RecordNotifyLogRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.title !== "") {
writer.uint32(10).string(message.title);
}
if (message.content !== "") {
writer.uint32(18).string(message.content);
}
if (message.success !== false) {
writer.uint32(24).bool(message.success);
}
if (message.notifyType !== "") {
writer.uint32(34).string(message.notifyType);
}
if (message.scriptName !== "") {
writer.uint32(42).string(message.scriptName);
}
if (message.taskId !== "") {
writer.uint32(50).string(message.taskId);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): RecordNotifyLogRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseRecordNotifyLogRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.title = reader.string();
continue;
}
case 2: {
if (tag !== 18) {
break;
}
message.content = reader.string();
continue;
}
case 3: {
if (tag !== 24) {
break;
}
message.success = reader.bool();
continue;
}
case 4: {
if (tag !== 34) {
break;
}
message.notifyType = reader.string();
continue;
}
case 5: {
if (tag !== 42) {
break;
}
message.scriptName = reader.string();
continue;
}
case 6: {
if (tag !== 50) {
break;
}
message.taskId = reader.string();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): RecordNotifyLogRequest {
return {
title: isSet(object.title) ? globalThis.String(object.title) : "",
content: isSet(object.content) ? globalThis.String(object.content) : "",
success: isSet(object.success) ? globalThis.Boolean(object.success) : false,
notifyType: isSet(object.notifyType) ? globalThis.String(object.notifyType) : "",
scriptName: isSet(object.scriptName) ? globalThis.String(object.scriptName) : "",
taskId: isSet(object.taskId) ? globalThis.String(object.taskId) : "",
};
},
toJSON(message: RecordNotifyLogRequest): unknown {
const obj: any = {};
if (message.title !== "") {
obj.title = message.title;
}
if (message.content !== "") {
obj.content = message.content;
}
if (message.success !== false) {
obj.success = message.success;
}
if (message.notifyType !== "") {
obj.notifyType = message.notifyType;
}
if (message.scriptName !== "") {
obj.scriptName = message.scriptName;
}
if (message.taskId !== "") {
obj.taskId = message.taskId;
}
return obj;
},
create<I extends Exact<DeepPartial<RecordNotifyLogRequest>, I>>(base?: I): RecordNotifyLogRequest {
return RecordNotifyLogRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<RecordNotifyLogRequest>, I>>(object: I): RecordNotifyLogRequest {
const message = createBaseRecordNotifyLogRequest();
message.title = object.title ?? "";
message.content = object.content ?? "";
message.success = object.success ?? false;
message.notifyType = object.notifyType ?? "";
message.scriptName = object.scriptName ?? "";
message.taskId = object.taskId ?? "";
return message;
},
};
export type ApiService = typeof ApiService;
export const ApiService = {
getEnvs: {
@@ -4537,15 +4388,6 @@ export const ApiService = {
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
responseDeserialize: (value: Buffer) => Response.decode(value),
},
recordNotifyLog: {
path: "/com.ql.api.Api/RecordNotifyLog",
requestStream: false,
responseStream: false,
requestSerialize: (value: RecordNotifyLogRequest) => Buffer.from(RecordNotifyLogRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => RecordNotifyLogRequest.decode(value),
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
responseDeserialize: (value: Buffer) => Response.decode(value),
},
getCronDetail: {
path: "/com.ql.api.Api/GetCronDetail",
requestStream: false,
@@ -4640,7 +4482,6 @@ export interface ApiServer extends UntypedServiceImplementation {
updateEnvNames: handleUnaryCall<UpdateEnvNamesRequest, Response>;
getEnvById: handleUnaryCall<GetEnvByIdRequest, EnvResponse>;
systemNotify: handleUnaryCall<SystemNotifyRequest, Response>;
recordNotifyLog: handleUnaryCall<RecordNotifyLogRequest, Response>;
getCronDetail: handleUnaryCall<CronDetailRequest, CronDetailResponse>;
createCron: handleUnaryCall<CreateCronRequest, CronResponse>;
updateCron: handleUnaryCall<UpdateCronRequest, CronResponse>;
@@ -4803,21 +4644,6 @@ export interface ApiClient extends Client {
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
recordNotifyLog(
request: RecordNotifyLogRequest,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
recordNotifyLog(
request: RecordNotifyLogRequest,
metadata: Metadata,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
recordNotifyLog(
request: RecordNotifyLogRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
getCronDetail(
request: CronDetailRequest,
callback: (error: ServiceError | null, response: CronDetailResponse) => void,
+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 { t, tf } from '../shared/i18n';
import { 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(t('[schedule][准备运行任务] 命令: %s'), item.command);
Logger.info('[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(
t('[schedule][创建定时任务] 任务ID: %s, 规则数: %s'),
'[schedule][创建定时任务] 任务ID: %s, 规则数: %s',
id,
jobs.length,
);
+2 -25
View File
@@ -16,7 +16,6 @@ import {
MoveEnvRequest,
Response,
SystemNotifyRequest,
RecordNotifyLogRequest,
UpdateEnvNamesRequest,
UpdateEnvRequest,
} from '../protos/api';
@@ -239,8 +238,7 @@ export const systemNotify = async (
const data = await systemService.notify({
title: call.request.title,
content: call.request.content,
notificationInfo: call.request
.notificationInfo as unknown as NotificationInfo,
notificationInfo: call.request.notificationInfo as unknown as NotificationInfo,
});
callback(null, data);
} catch (e: any) {
@@ -248,31 +246,10 @@ export const systemNotify = async (
}
};
export const recordNotifyLog = async (
call: ServerUnaryCall<RecordNotifyLogRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
await Container.get(SystemService).recordNotifyLog({
title: call.request.title,
content: call.request.content,
status: call.request.success ? 0 : 1,
notifyType: call.request.notifyType,
source: 'script',
scriptName: call.request.scriptName,
taskId: call.request.taskId,
});
callback(null, { code: 200 });
} catch (error: any) {
callback(error);
}
};
const normalizeCronData = (data: CronItem | null): CronItem | undefined => {
if (!data) return undefined;
// create() returns a Sequelize instance; spreading it omits attribute getters.
const cron =
data instanceof Model ? (data.get({ plain: true }) as CronItem) : data;
const cron = data instanceof Model ? (data.get({ plain: true }) as CronItem) : data;
return {
...cron,
labels: cron.labels ?? [],
+15 -16
View File
@@ -1,4 +1,3 @@
import { t } from '../shared/i18n';
import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js';
import { DeleteCronRequest, DeleteCronResponse } from '../protos/cron';
import { scheduleStacks } from './data';
@@ -10,24 +9,24 @@ const delCron = (
) => {
for (const id of call.request.ids) {
if (scheduleStacks.has(id)) {
Logger.info(t('[schedule][取消定时任务] 任务ID: %s'), id);
Logger.info(
'[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(
t('[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(
'[schedule][取消任务失败] 任务ID: %s, 错误: %s',
id,
error?.message || error,
);
}
});
scheduleStacks.delete(id);
}
}
+7 -7
View File
@@ -641,7 +641,7 @@ export default class CronService {
stopped.add(pid);
} catch (error) {
this.logger.error(
t('[panel][停止任务失败] PID: %s, 错误: %s'),
'[panel][停止任务失败] PID: %s, 错误: %s',
pid,
asError(error).message
);
@@ -695,7 +695,7 @@ export default class CronService {
await killTask(instance.pid);
} catch (error) {
this.logger.error(
t("[panel][停止实例失败] 实例ID: %s, PID: %s, 错误: %s"), instanceId, instance.pid, error,
`[panel][停止实例失败] 实例ID: ${instanceId}, PID: ${instance.pid}, 错误: ${error}`,
);
}
}
@@ -791,20 +791,20 @@ export default class CronService {
const result = await completed;
if (result.error) {
this.logger.error(
t('[panel][执行任务失败] 任务ID: %s, 错误: %s'),
'[panel][执行任务失败] 任务ID: %s, 错误: %s',
id,
result.error.message
);
}
this.logger.info(
t('[panel][执行任务结束] 任务ID: %s, 退出码: %j'),
'[panel][执行任务结束] 任务ID: %s, 退出码: %j',
id,
result.code
);
return { ...cron, pid: cp.pid, ...result } as any;
} catch (error) {
this.logger.error(
t('[panel][创建任务失败] 任务ID: %s, 错误: %s'),
'[panel][创建任务失败] 任务ID: %s, 错误: %s',
cronId,
asError(error).message
);
@@ -813,7 +813,7 @@ export default class CronService {
if (absolutePath) await logStreamManager.closeStream(absolutePath);
} catch (error) {
this.logger.error(
t('[panel][关闭任务日志失败] %s'),
'[panel][关闭任务日志失败] %s',
asError(error).message
);
}
@@ -845,7 +845,7 @@ export default class CronService {
);
} catch (error) {
this.logger.error(
t('[panel][清理任务状态失败] %s'),
'[panel][清理任务状态失败] %s',
asError(error).message
);
}
+24 -69
View File
@@ -20,7 +20,6 @@ import {
getInstallCommand,
getUninstallCommand,
getGetCommand,
isPythonSourceDependency,
} from '../config/util';
import dayjs from 'dayjs';
import taskLimit from '../shared/pLimit';
@@ -33,11 +32,10 @@ export default class DependenceService {
constructor(
@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);
const tab = new Dependence({ ...x, status: DependenceStatus.queued });
return tab;
});
@@ -61,7 +59,6 @@ export default class DependenceService {
...other,
status: DependenceStatus.queued,
});
getInstallCommand(tab.type, tab.name);
const newDoc = await this.updateDb(tab);
this.installDependenceOneByOne([newDoc]);
return newDoc;
@@ -77,25 +74,21 @@ export default class DependenceService {
for (const doc of docs) {
taskLimit.removeQueuedDependency(doc);
}
const unInstalledDeps = docs.filter(
(x) => x.status !== DependenceStatus.installed,
);
const installedDeps = docs.filter(
(x) =>
x.status === DependenceStatus.installed ||
// Older list refreshes marked unprobeable sources as failed. They may
// still have installed distributions (including partial pip installs).
(x.status === DependenceStatus.installFailed &&
x.type === DependenceTypes.python3 &&
isPythonSourceDependency(x.name)),
(x) => x.status === DependenceStatus.installed,
);
const unInstalledDeps = docs.filter((x) => !installedDeps.includes(x));
await this.removeDb(unInstalledDeps.map((x) => x.id!));
if (installedDeps.length) {
await DependenceModel.update(
{ status: DependenceStatus.queued, log: [] },
{ where: { id: installedDeps.map((x) => x.id!) } },
{ where: { id: ids } },
);
this.installDependenceOneByOne(installedDeps, false, force);
this.installDependenceOneByOne(docs, false, force);
}
return docs;
}
@@ -161,15 +154,9 @@ export default class DependenceService {
},
});
const checks = await concurrentRun(
docs
.filter(
(doc) =>
doc.type !== DependenceTypes.python3 ||
!isPythonSourceDependency(doc.name),
)
.map((doc) => async () => {
return (await this.isDependenceInstalled(doc)) ? undefined : doc.id;
}),
docs.map((doc) => async () => {
return (await this.isDependenceInstalled(doc)) ? undefined : doc.id;
}),
5,
);
const missingIds = (checks || []).filter(
@@ -188,10 +175,7 @@ export default class DependenceService {
let depName = dependency.name.trim();
const depVersionStr = versionDependenceCommandTypes[dependency.type];
let depVersion = '';
if (
dependency.type !== DependenceTypes.python3 &&
depName.includes(depVersionStr)
) {
if (depName.includes(depVersionStr)) {
const symbolRegx = new RegExp(
`(.*)${depVersionStr}([0-9\\.\\-\\+a-zA-Z]*)`,
);
@@ -312,26 +296,6 @@ export default class DependenceService {
force: boolean = false,
) {
return taskLimit.runDependeny(dependency, () => {
let validatedCommand: string;
try {
validatedCommand = isInstall
? getInstallCommand(dependency.type, dependency.name.trim())
: getUninstallCommand(dependency.type, dependency.name.trim());
} catch (error) {
taskLimit.removeQueuedDependency(dependency);
if (!isInstall && force) {
return this.removeDb([dependency.id!]).then(() => null);
}
return DependenceModel.update(
{
status: isInstall
? DependenceStatus.installFailed
: DependenceStatus.removeFailed,
log: [String(error)],
},
{ where: { id: dependency.id } },
).then(() => null);
}
return new Promise(async (resolve) => {
if (taskLimit.firstDependencyId !== dependency.id) {
return resolve(null);
@@ -381,7 +345,9 @@ export default class DependenceService {
: DependenceStatus.removing;
await DependenceModel.update({ status }, { where: { id: depIds } });
let command = validatedCommand;
let command = isInstall
? getInstallCommand(dependency.type, depName)
: getUninstallCommand(dependency.type, depName);
if (isLinuxDependence) {
command = isInstall
? `${linuxCommand.install} ${depName.trim()}`
@@ -404,13 +370,7 @@ export default class DependenceService {
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.
if (
isInstall &&
!force &&
!(isPythonDependence && depName.includes('['))
) {
if (isInstall && !force) {
let getCommand = getGetCommand(dependency.type, depName);
const depVersionStr = versionDependenceCommandTypes[dependency.type];
if (isLinuxDependence) {
@@ -418,7 +378,7 @@ export default class DependenceService {
}
let depVersion = '';
if (!isPythonDependence && depName.includes(depVersionStr)) {
if (depName.includes(depVersionStr)) {
const symbolRegx = new RegExp(
`(.*)${depVersionStr}([0-9\\.\\-\\+a-zA-Z]*)`,
);
@@ -510,20 +470,15 @@ export default class DependenceService {
const message =
'\n' +
tf(
'依赖%s%s,结束时间 %s,耗时 %s 秒',
actionText,
resultText,
endTime.format('YYYY-MM-DD HH:mm:ss'),
String(endTime.diff(startTime, 'second')),
);
tf('依赖%s%s,结束时间 %s,耗时 %s 秒',
actionText,
resultText,
endTime.format('YYYY-MM-DD HH:mm:ss'),
String(endTime.diff(startTime, 'second')),
);
const exitStatus = isSucceed
? isInstall
? DependenceStatus.installed
: DependenceStatus.removed
: isInstall
? DependenceStatus.installFailed
: DependenceStatus.removeFailed;
? (isInstall ? DependenceStatus.installed : DependenceStatus.removed)
: (isInstall ? DependenceStatus.installFailed : DependenceStatus.removeFailed);
this.sockService.sendMessage({
type: socketMessageType,
message,
+2 -2
View File
@@ -50,13 +50,13 @@ export class GrpcServerService {
for (const host of hostsToTry) {
try {
const address = this.formatGrpcAddress(host, grpcPort);
const boundPort = await bindAsync(address, credentials);
await bindAsync(address, credentials);
Logger.debug(`[boot] gRPC service started successfully on ${address}`);
metricsService.record('grpc_service_start', 1, {
port: grpcPort.toString(),
host
});
return boundPort;
return grpcPort;
} catch (err) {
lastError = err as Error;
Logger.warn(`Failed to bind gRPC on ${host}:${grpcPort}, trying next...`, err);
+3 -4
View File
@@ -2,10 +2,8 @@ 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;
@@ -24,11 +22,12 @@ interface HealthStatus {
export class HealthService {
private startTime = Date.now();
constructor(private httpServerService: HttpServerService) {}
constructor(
private httpServerService: HttpServerService,
) {}
async check(): Promise<HealthStatus> {
const status: HealthStatus = {
build: runtimeBuild,
status: 'ok',
services: {
http: true,
+1 -2
View File
@@ -1,4 +1,3 @@
import { t } from '../shared/i18n';
import { performance } from 'perf_hooks';
import Logger from '../loaders/logger';
@@ -127,7 +126,7 @@ export class MetricsService {
timestamp: Date.now(),
metrics: this.getMetrics(),
};
Logger.info(t('性能指标报告:'), report);
Logger.info('性能指标报告:', report);
return report;
}
}
+26 -42
View File
@@ -1,4 +1,3 @@
import { persistNotificationAudit } from '../shared/notificationAudit';
import crypto from 'crypto';
import nodemailer from 'nodemailer';
import { Inject, Service } from 'typedi';
@@ -56,33 +55,27 @@ export default class NotificationService {
content: string,
notificationInfo?: NotificationInfo,
): Promise<boolean | undefined> {
let type: string | undefined;
let success = false;
try {
const saved = await this.userService.getNotificationMode();
type = notificationInfo?.type || saved.type;
if (type) {
this.title = title;
this.content = content;
const { type: _, ...overrides } = notificationInfo || {};
const { type: _saved, ...rest } = saved;
this.params = { ...rest, ...overrides };
try {
success = !!(await this.modeMap.get(type)?.call(this));
} catch (error) {
console.error(error);
}
}
return success;
} finally {
await persistNotificationAudit({
title,
content,
notifyType: type,
source: 'system',
status: success ? 0 : 1,
});
let { type, ...rest } = await this.userService.getNotificationMode();
if (notificationInfo?.type) {
type = notificationInfo?.type;
}
if (type) {
this.title = title;
this.content = content;
let params = rest;
if (notificationInfo) {
const { type: _, ...others } = notificationInfo;
params = { ...rest, ...others };
}
this.params = params;
const notificationModeAction = this.modeMap.get(type);
try {
return await notificationModeAction?.call(this);
} catch (error: any) {
console.error(error);
}
}
return false;
}
public async testNotify(
@@ -91,23 +84,14 @@ export default class NotificationService {
content: string,
) {
const { type, ...rest } = info;
if (!type) return true;
let success = false;
try {
if (type) {
this.title = title;
this.content = content;
this.params = rest;
success = !!(await this.modeMap.get(type)?.call(this));
return success;
} finally {
await persistNotificationAudit({
title,
content,
notifyType: type,
source: 'system',
status: success ? 0 : 1,
});
const notificationModeAction = this.modeMap.get(type);
return await notificationModeAction?.call(this);
}
return true;
}
private parseMailRecipients(value?: string) {
@@ -937,8 +921,7 @@ export default class NotificationService {
private async openiLink() {
const { openiLinkAppToken, openiLinkHubUrl, openiLinkContextToken } =
this.params;
const baseUrl =
openiLinkHubUrl?.replace(/\/$/, '') || 'https://hub.openilink.com';
const baseUrl = openiLinkHubUrl?.replace(/\/$/, '') || 'https://hub.openilink.com';
const url = `${baseUrl}/bot/v1/message/send`;
const body: Record<string, string> = {
type: 'text',
@@ -991,4 +974,5 @@ export default class NotificationService {
throw new Error(error.response ? error.response.body : error);
}
}
}
+12 -13
View File
@@ -1,4 +1,3 @@
import { t } from '../shared/i18n';
import { Service, Inject } from 'typedi';
import winston from 'winston';
import { createCronJob, CronJob } from '../shared/cronScheduler';
@@ -84,7 +83,7 @@ export default class ScheduleService {
let cp: ChildProcessWithoutNullStreams | undefined;
let result: ProcessResult = { code: null, signal: null };
try {
this.logger.info(t('[panel][开始执行任务] 任务ID: %s'), others.id);
this.logger.info('[panel][开始执行任务] 任务ID: %s', others.id);
await callbacks.onBefore?.(startTime);
cp = spawn(command, { shell: '/bin/bash' });
const child = cp;
@@ -104,7 +103,7 @@ export default class ScheduleService {
if (result.error) {
this.logger.error(
t('[panel][执行任务失败] 任务ID: %s, 错误: %s'),
'[panel][执行任务失败] 任务ID: %s, 错误: %s',
others.id,
result.error.message,
);
@@ -112,7 +111,7 @@ export default class ScheduleService {
await callbacks.onError?.(result.error.message);
} catch (error) {
this.logger.error(
t('[panel][任务错误回调失败] %s'),
'[panel][任务错误回调失败] %s',
asError(error).message,
);
}
@@ -128,12 +127,12 @@ export default class ScheduleService {
} catch (error) {
result.error ??= asError(error);
this.logger.error(
t('[panel][任务结束回调失败] %s'),
'[panel][任务结束回调失败] %s',
asError(error).message,
);
}
this.logger.info(
t('[panel][执行任务结束] 任务ID: %s, 退出码: %j'),
'[panel][执行任务结束] 任务ID: %s, 退出码: %j',
others.id,
result.code,
);
@@ -142,7 +141,7 @@ export default class ScheduleService {
).catch((error) => {
// Queue/setup failures must not become unhandled rejections in detached callers.
rejectStart(asError(error));
this.logger.error(t('[panel][任务队列失败] %s'), asError(error).message);
this.logger.error('[panel][任务队列失败] %s', asError(error).message);
return { ...others, code: null, signal: null, error: asError(error) };
});
@@ -157,7 +156,7 @@ export default class ScheduleService {
) {
const _id = this.formatId(id);
this.logger.info(
t('[panel][创建cron任务] 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s'),
'[panel][创建cron任务] 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s',
_id,
schedule,
name,
@@ -184,7 +183,7 @@ export default class ScheduleService {
} catch (error) {
// A persisted invalid subscription must not become an unhandled startup rejection.
this.logger.warn(
t('[panel][跳过无效定时任务] 任务ID: %s, cron: %s, 错误: %s'),
'[panel][跳过无效定时任务] 任务ID: %s, cron: %s, 错误: %s',
_id,
schedule,
asError(error).message,
@@ -205,7 +204,7 @@ export default class ScheduleService {
async cancelCronTask({ id = 0, name }: ScheduleTaskType) {
const _id = this.formatId(id);
this.logger.info(t('[panel][取消定时任务] 任务名: %s'), name);
this.logger.info('[panel][取消定时任务] 任务名: %s', name);
if (this.scheduleStacks.has(_id)) {
this.scheduleStacks.get(_id)?.cancel();
this.scheduleStacks.delete(_id);
@@ -220,7 +219,7 @@ export default class ScheduleService {
) {
const _id = this.formatId(id);
this.logger.info(
t('[panel][创建interval任务] 任务ID: %s, 任务名: %s, 执行命令: %s'),
'[panel][创建interval任务] 任务ID: %s, 任务名: %s, 执行命令: %s',
_id,
name,
command,
@@ -237,7 +236,7 @@ export default class ScheduleService {
},
(err) => {
this.logger.error(
t('[panel][执行任务失败] 命令: %s, 错误信息: %j'),
'[panel][执行任务失败] 命令: %s, 错误信息: %j',
command,
err,
);
@@ -265,7 +264,7 @@ export default class ScheduleService {
async cancelIntervalTask({ id = 0, name }: ScheduleTaskType) {
const _id = this.formatId(id);
this.logger.info(
t('[panel][取消interval任务] 任务ID: %s, 任务名: %s'),
'[panel][取消interval任务] 任务ID: %s, 任务名: %s',
_id,
name,
);
+10 -21
View File
@@ -1,4 +1,3 @@
import { t } from '../shared/i18n';
import { Service, Inject } from 'typedi';
import winston from 'winston';
import fs from 'fs/promises';
@@ -62,10 +61,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(t('生成私钥文件失败'), error);
this.logger.error('生成私钥文件失败', error);
}
}
@@ -74,7 +73,7 @@ export default class SshKeyService {
const filePath = resolveSubscriptionPath(this.sshPath, alias);
await rmPath(filePath);
} catch (error) {
this.logger.error(t('删除私钥文件失败'), error);
this.logger.error('删除私钥文件失败', error);
}
}
@@ -83,13 +82,9 @@ 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`;
@@ -97,10 +92,7 @@ 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,
@@ -116,7 +108,7 @@ export default class SshKeyService {
const filePath = resolveSubscriptionPath(this.sshPath, alias, '.config');
await rmPath(filePath);
} catch (error) {
this.logger.error(t('删除ssh配置文件%s失败'), alias, error);
this.logger.error(`删除ssh配置文件${alias}失败`, error);
}
}
@@ -155,7 +147,7 @@ export default class SshKeyService {
);
await this.generateSingleSshConfig(alias, host, doc.proxy);
} catch (error) {
this.logger.warn(t('跳过订阅 %s 的无效 SSH 配置: %s'), doc.id, error);
this.logger.warn('跳过订阅 %s 的无效 SSH 配置: %s', doc.id, error);
}
}
}
@@ -175,10 +167,7 @@ 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,
+64 -123
View File
@@ -1,11 +1,10 @@
import { persistNotificationAudit } from '../shared/notificationAudit';
import { spawn } from 'cross-spawn';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { shellQuote } from '../shared/shellQuote';
import { Response } from 'express';
import fs from 'fs';
import { request } from 'undici';
import { Agent, request } from 'undici';
import path from 'path';
import { Inject, Service } from 'typedi';
import winston from 'winston';
@@ -33,8 +32,6 @@ import {
SystemInstance,
SystemModel,
SystemModelInfo,
NotifyStatus,
NotifyLogInfo,
} from '../data/system';
import taskLimit from '../shared/pLimit';
import cronClient from '../schedule/client';
@@ -56,7 +53,7 @@ export default class SystemService {
@Inject('logger') private logger: winston.Logger,
private scheduleService: ScheduleService,
private sockService: SockService,
) {}
) { }
public async getSystemConfig() {
const doc = await this.getDb({ type: AuthDataType.systemConfig });
@@ -161,9 +158,7 @@ export default class SystemService {
if (info.dependenceProxy) {
await fs.promises.writeFile(
config.dependenceProxyFile,
`export http_proxy=${shellQuote(
info.dependenceProxy,
)}\nexport https_proxy=${shellQuote(info.dependenceProxy)}`,
`export http_proxy=${shellQuote(info.dependenceProxy)}\nexport https_proxy=${shellQuote(info.dependenceProxy)}`,
);
} else {
await fs.promises.rm(config.dependenceProxyFile);
@@ -282,24 +277,26 @@ export default class SystemService {
}
public async checkUpdate() {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
try {
const currentVersionContent = await parseVersion(config.versionFile);
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');
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;
}
return {
@@ -317,10 +314,8 @@ export default class SystemService {
} catch (error: any) {
return {
code: 400,
message: t('检查更新失败,请检查网络后重试'),
message: error.message,
};
} finally {
clearTimeout(timeout);
}
}
@@ -344,51 +339,45 @@ export default class SystemService {
}
public async updateSystem() {
return this.startUpgradeJob('download');
}
const cp = spawn('real_time=true ql update false', { shell: '/bin/bash' });
private upgradeJobs() {
return require(path.join(
config.rootPath,
'cli/src/internal/maintenance/panelUpgrade.cjs',
));
}
cp.stdout.on('data', (data) => {
this.sockService.sendMessage({
type: 'updateSystemVersion',
message: data.toString(),
});
});
public async getUpgradeStatus() {
return {
code: 200,
data: await this.upgradeJobs().getStatus(config.tmpPath),
};
}
cp.stderr.on('data', (data) => {
this.sockService.sendMessage({
type: 'updateSystemVersion',
message: data.toString(),
});
});
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('升级任务启动失败,请检查日志后重试') };
}
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 };
}
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',
@@ -418,13 +407,11 @@ 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 {
@@ -432,36 +419,16 @@ export default class SystemService {
}
}
public async recordNotifyLog(info: NotifyLogInfo): Promise<void> {
await persistNotificationAudit(info);
}
public async getNotifyLog(): Promise<Array<NotifyLogInfo>> {
const docs = await SystemModel.findAll({
where: { type: AuthDataType.notifyLog },
order: [['id', 'DESC']],
limit: 200,
});
return docs.map((x) => ({ ...x.info, id: x.id }));
}
public async run(
{ command, logPath }: { command: string; logPath?: string },
callback: TaskCallbacks,
) {
public async run({ command, logPath }: { command: string; logPath?: string }, callback: TaskCallbacks) {
if (!command.startsWith(TASK_COMMAND)) {
command = `${TASK_COMMAND} ${command}`;
}
const logPathPrefix = logPath ? `real_log_path=${logPath}` : '';
this.scheduleService.runTask(
`${logPathPrefix} real_time=true ${command}`,
callback,
{
command,
id: command.replace(/ /g, '-'),
runOrigin: 'system',
},
);
const logPathPrefix = logPath ? `real_log_path=${logPath}` : ''
this.scheduleService.runTask(`${logPathPrefix} real_time=true ${command}`, callback, {
command,
id: command.replace(/ /g, '-'),
runOrigin: 'system',
});
}
public async stop({ command, pid }: { command: string; pid: number }) {
@@ -492,20 +459,8 @@ export default class SystemService {
if (type && type.length) {
dataDirs = dataDirs.concat(type.filter((x) => x !== 'base'));
}
const allowed = new Set([
'db',
'upload',
'config',
'scripts',
'log',
'deps',
'syslog',
'dep_cache',
'raw',
'repo',
'ssh.d',
'script-history',
]);
const allowed = new Set(['db', 'upload', 'config', 'scripts', 'log', 'deps',
'syslog', 'dep_cache', 'raw', 'repo', 'ssh.d', 'script-history']);
if (dataDirs.some((dir) => !allowed.has(dir))) {
return res.status(400).send({ code: 400, message: t('参数错误') });
}
@@ -515,13 +470,9 @@ export default class SystemService {
dataDirs = dataDirs.filter((dir) => dir !== 'script-history');
}
const dataPaths = [...new Set(dataDirs)].map((dir) => `data/${dir}`);
await promisify(execFile)(
'tar',
['-zcf', config.dataTgzFile, '--', ...dataPaths],
{
cwd: path.dirname(config.dataPath),
},
);
await promisify(execFile)('tar', ['-zcf', config.dataTgzFile, '--', ...dataPaths], {
cwd: path.dirname(config.dataPath),
});
res.download(config.dataTgzFile);
} catch (error: any) {
return res.send({ code: 400, message: error.message });
@@ -612,17 +563,7 @@ export default class SystemService {
const result = await readDirs(config.systemLogPath, config.systemLogPath);
const logs = result.reverse().filter((x) => x.title.endsWith('.log'));
for (const log of logs) {
// 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();
}
await rmPath(path.join(config.systemLogPath, log.title));
}
}
+2 -2
View File
@@ -173,7 +173,7 @@ export default class UserService {
t('你于') +
dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss') +
t('在') +
t(address) +
address +
' ' +
req.platform +
t('端') +
@@ -219,7 +219,7 @@ export default class UserService {
t('你于') +
dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss') +
t('在') +
t(address) +
address +
' ' +
req.platform +
t('端') +
+9 -66
View File
@@ -22,7 +22,6 @@ 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.
@@ -59,73 +58,17 @@ export function parseCronSchedule(schedule: unknown): CronSchedule {
const expression = CronExpressionParser.parse(parts.join(' '));
if (!expression.hasNext())
throw new Error('Cron schedule has no next execution');
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 normalized = expression.stringify(true).replace(/\?/g, '*').split(' ');
const dayCount = expression.fields.dayOfMonth.values.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 =
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)) {
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))) {
throw new Error('Unsupported cron schedule');
}
const parsed = {
+2 -3
View File
@@ -1,4 +1,3 @@
import { t } from './i18n';
import { createTask, ScheduledTask } from 'node-cron';
import { parseCronSchedule } from './cronSchedule';
@@ -37,7 +36,7 @@ export function createCronJob(
if (cancelled || !started || !accepts(index, date)) return;
if (missed) {
options.logger.warn(
t('[schedule][补执行迟到任务] 任务: %s, 计划时间: %s'),
'[schedule][补执行迟到任务] 任务: %s, 计划时间: %s',
options.name,
date.toISOString(),
);
@@ -46,7 +45,7 @@ export function createCronJob(
await callback(date);
} catch (error) {
options.logger.error(
t('[schedule][定时回调失败] 任务: %s, 错误: %s'),
'[schedule][定时回调失败] 任务: %s, 错误: %s',
options.name,
error instanceof Error ? error.message : String(error),
);
+92 -224
View File
@@ -1,234 +1,112 @@
import { shareStore } from './store';
const messages: Record<string, Record<string, string>> = {
zh: {},
en: {
'升级任务启动失败,请检查日志后重试':
'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',
'资源已存在,请检查重复的名称或值': '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',
定时规则必须是字符串: 'Schedule rule must be a string',
额外定时规则必须是对象: 'Extra schedule must be an object',
额外定时规则必须是数组: 'Extra schedules must be an array',
'未分类': 'Uncategorized',
'任务重复运行': 'Duplicate task execution',
'日志设置为忽略': 'Log set to ignore',
'定时规则不能为空': 'Schedule rule cannot be empty',
'无效的定时规则': 'Invalid schedule rule',
'日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符':
'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 ',
端: ' client',
内网IP: 'Private IP',
'青龙快讯': 'QingLong',
'登录通知': 'Login Notification',
'你于': 'You at ',
'在': ' in ',
端: '',
登录失败: 'login failed',
',ip地址': ', IP: ',
'任务#%s': 'Task#%s',
@@ -253,13 +131,12 @@ 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',
'调度器注册失败,任务更新已回滚':
@@ -275,18 +152,9 @@ const messages: Record<string, Record<string, string>> = {
let currentLang: string = 'zh';
export function setLang(lang: string, persist = true) {
export function setLang(lang: string) {
currentLang = lang || 'zh';
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 }, () => {});
}
});
shareStore.setLang(currentLang);
}
/** 系统默认语言:Intl 检测 → 'zh'(仅返回 zh/en) */
-32
View File
@@ -1,32 +0,0 @@
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);
}
-48
View File
@@ -1,48 +0,0 @@
import { Op } from 'sequelize';
import {
SystemModel,
AuthDataType,
NotifyStatus,
NotifyLogInfo,
} from '../data/system';
// Both backend sends and script reports use the same bounded, best-effort sink.
export async function persistNotificationAudit(
info: NotifyLogInfo,
): Promise<void> {
try {
await SystemModel.create({
type: AuthDataType.notifyLog,
info: {
timestamp: Date.now(),
title: String(info.title || '').slice(0, 512),
content: String(info.content || '').slice(0, 8192),
status:
info.status === NotifyStatus.success
? NotifyStatus.success
: NotifyStatus.fail,
notifyType: String(info.notifyType || '').slice(0, 64),
source: info.source === 'script' ? 'script' : 'system',
scriptName: String(info.scriptName || '').slice(0, 512),
taskId: /^\d+$/.test(info.taskId || '') ? info.taskId : undefined,
},
});
const oldest = await SystemModel.findAll({
where: { type: AuthDataType.notifyLog },
attributes: ['id'],
order: [['id', 'DESC']],
offset: 200,
limit: 1,
});
if (oldest[0]?.id) {
await SystemModel.destroy({
where: {
type: AuthDataType.notifyLog,
id: { [Op.lte]: oldest[0].id },
},
});
}
} catch (error) {
console.error('Failed to persist notification audit log');
}
}
+10 -18
View File
@@ -79,36 +79,29 @@ class TaskLimit {
private handleEvents() {
this.cronLimit.on('add', () => {
Logger.info(
t('[schedule][任务加入队列] 运行中任务数: %s, 等待中任务数: %s'),
this.cronLimitActiveCount,
this.cronLimitPendingCount,
`[schedule][任务加入队列] 运行中任务数: ${this.cronLimitActiveCount}, 等待中任务数: ${this.cronLimitPendingCount}`,
);
});
this.cronLimit.on('active', () => {
Logger.info(
t('[schedule][开始处理任务] 运行中任务数: %s, 等待中任务数: %s'),
this.cronLimitActiveCount + 1,
this.cronLimitPendingCount,
`[schedule][开始处理任务] 运行中任务数: ${
this.cronLimitActiveCount + 1
}, 等待中任务数: ${this.cronLimitPendingCount}`,
);
});
this.cronLimit.on('completed', (param) => {
Logger.info(t('[schedule][任务处理成功] 参数 %s'), JSON.stringify(param));
Logger.info(`[schedule][任务处理成功] 参数 ${JSON.stringify(param)}`);
});
this.cronLimit.on('error', (error) => {
Logger.error(
t('[schedule][任务处理错误] 参数 %s'),
JSON.stringify(error),
);
Logger.error(`[schedule][任务处理错误] 参数 ${JSON.stringify(error)}`);
});
this.cronLimit.on('next', () => {
Logger.info(
t('[schedule][任务处理结束] 运行中任务数: %s, 等待中任务数: %s'),
this.cronLimitActiveCount,
this.cronLimitPendingCount,
`[schedule][任务处理结束] 运行中任务数: ${this.cronLimitActiveCount}, 等待中任务数: ${this.cronLimitPendingCount}`,
);
});
this.cronLimit.on('idle', () => {
Logger.info(t(`[schedule][任务队列] 空闲中...`));
Logger.info(`[schedule][任务队列] 空闲中...`);
});
}
@@ -179,14 +172,13 @@ class TaskLimit {
(err, res) => {
if (err) {
Logger.error(
t('[schedule][任务重复运行] 通知失败 %s'),
JSON.stringify(err),
`[schedule][任务重复运行] 通知失败 ${JSON.stringify(err)}`,
);
}
},
);
}
Logger.warn(t('[schedule][任务重复运行] 参数 %s'), JSON.stringify(cron));
Logger.warn(`[schedule][任务重复运行] 参数 ${JSON.stringify(cron)}`);
return;
}
this.queuedCrons.set(cron.id, result);
+33 -96
View File
@@ -1,9 +1,9 @@
import { t } from './i18n';
import { spawn } from 'cross-spawn';
import taskLimit from './pLimit';
import Logger from '../loaders/logger';
import { ICron } from '../protos/cron';
import { CrontabModel, CrontabStatus } from '../data/cron';
import { killTask } from '../config/util';
import { RunningInstanceModel, InstanceStatus } from '../data/runningInstance';
import dayjs from 'dayjs';
import { observeChildProcess, asError } from './childProcess';
@@ -16,7 +16,7 @@ export function runCron(
const execute = async () => {
try {
if (!isCurrent()) return;
// A single-instance trigger must not interrupt work already in progress.
// Check if the cron is already running and stop it (only if multiple instances are not allowed)
try {
const existingCron = await CrontabModel.findOne({
where: { id: Number(cron.id) },
@@ -29,118 +29,55 @@ export function runCron(
const allowSingleInstances =
existingCron?.allow_multiple_instances === 0;
if (allowSingleInstances) {
// A queued manual run is an active reservation, even if its stored PID
// belongs to a previous execution.
if (
existingCron.status === CrontabStatus.queued &&
existingCron.pid
) {
Logger.info(
'[schedule][Skip queued single-instance task] Task ID: %s, PID: %s',
cron.id,
existingCron.pid,
);
return;
}
if (
existingCron.status === CrontabStatus.running &&
existingCron.pid
) {
let missing = false;
try {
process.kill(existingCron.pid, 0);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ESRCH') missing = true;
else if (code !== 'EPERM') throw error;
}
if (!missing) {
Logger.info(
'[schedule][Skip active single-instance task] Task ID: %s, PID: %s',
cron.id,
existingCron.pid,
);
return;
}
// Capture old instance IDs before releasing the stale cron state so
// a concurrent replacement cannot have its instance marked stopped.
const staleInstances = await RunningInstanceModel.findAll({
attributes: ['id'],
if (
allowSingleInstances &&
existingCron &&
existingCron.pid &&
(existingCron.status === CrontabStatus.running ||
existingCron.status === CrontabStatus.queued)
) {
Logger.info(
`[schedule][停止已运行任务] 任务ID: ${cron.id}, PID: ${existingCron.pid}`,
);
await killTask(existingCron.pid, true);
// Mark old running instances as stopped
const stoppedAt = dayjs().unix();
await RunningInstanceModel.update(
{ status: InstanceStatus.stopped, finished_at: stoppedAt },
{
where: {
cron_id: Number(cron.id),
pid: existingCron.pid,
status: InstanceStatus.running,
},
});
if (!isCurrent()) return;
const [recovered] = await CrontabModel.update(
{
status: CrontabStatus.idle,
pid: null,
queued_token: null,
} as any,
{
where: {
id: Number(cron.id),
pid: existingCron.pid,
status: CrontabStatus.running,
...(existingCron.log_path !== undefined
? { log_path: existingCron.log_path }
: {}),
...(existingCron.queued_token !== undefined
? { queued_token: existingCron.queued_token }
: {}),
...(existingCron.last_execution_time !== undefined
? { last_execution_time: existingCron.last_execution_time }
: {}),
...(existingCron.isDisabled !== undefined
? { isDisabled: existingCron.isDisabled }
: {}),
allow_multiple_instances: 0,
},
},
);
if (recovered !== 1) return;
const staleIds = staleInstances.map((instance) => instance.id!);
if (staleIds.length) {
await RunningInstanceModel.update(
{ status: InstanceStatus.stopped, finished_at: dayjs().unix() },
{ where: { id: staleIds, status: InstanceStatus.running } },
);
}
Logger.info(
'[schedule][Recovered stale single-instance task] Task ID: %s, PID: %s',
cron.id,
existingCron.pid,
);
}
},
);
// Update the status to idle after killing
await CrontabModel.update(
{ status: CrontabStatus.idle, pid: undefined },
{ where: { id: Number(cron.id) } },
);
}
} catch (error) {
Logger.error(
t('[schedule][检查已运行任务失败] 任务ID: %s, 错误: %s'),
cron.id,
error,
`[schedule][检查已运行任务失败] 任务ID: ${cron.id}, 错误: ${error}`,
);
throw error;
}
// Invalidation can happen during the asynchronous database check.
// In particular, invalidation can happen while stopping a previous run.
if (!isCurrent()) return;
Logger.info(
t('[schedule][开始执行任务] 参数 %s'),
JSON.stringify({
`[schedule][开始执行任务] 参数 ${JSON.stringify({
...cron,
command: cmd,
}),
})}`,
);
const cp = spawn(cmd, { shell: '/bin/bash' });
const { completed } = observeChildProcess(cp, {
onStderr: async (message) => {
Logger.info(
t('[schedule][任务标准错误] 命令: %s, 信息: %s'),
'[schedule][任务标准错误] 命令: %s, 信息: %s',
cmd,
message,
);
@@ -149,20 +86,20 @@ export function runCron(
const result = await completed;
if (result.error) {
Logger.error(
t('[schedule][执行任务失败] 命令: %s, 错误: %s'),
'[schedule][执行任务失败] 命令: %s, 错误: %s',
cmd,
result.error.message,
);
}
Logger.info(
t('[schedule][执行任务结束] 任务ID: %s, 退出码: %j'),
'[schedule][执行任务结束] 任务ID: %s, 退出码: %j',
cron.id,
result.code,
);
return { ...cron, command: cmd, pid: cp.pid, ...result } as any;
} catch (error) {
Logger.error(
t('[schedule][创建任务失败] 命令: %s, 错误: %s'),
'[schedule][创建任务失败] 命令: %s, 错误: %s',
cmd,
asError(error).message,
);
-41
View File
@@ -1,41 +0,0 @@
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,
});
+4 -5
View File
@@ -1,7 +1,6 @@
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';
@@ -31,10 +30,10 @@ export function resolveTrustProxy(value?: string): TrustProxyValue {
function validateTrustProxy(value: string): string {
const setting = normalizeSetting(value);
if (setting.length > 500 || /[\r\n]/.test(setting)) {
throw new Error(t('trust proxy 配置格式无效'));
throw new Error('trust proxy 配置格式无效');
}
if (/^\d+$/.test(setting) && Number(setting) > 20) {
throw new Error(t('代理层数不能超过 20'));
throw new Error('代理层数不能超过 20');
}
const probe = express();
@@ -74,7 +73,7 @@ export async function initializeTrustProxy(app: Application) {
export async function updateTrustProxy(value: string) {
if (getEnvironmentSetting()) {
throw new Error(t('环境变量 QL_TRUST_PROXY 已生效,系统设置不可覆盖'));
throw new Error('环境变量 QL_TRUST_PROXY 已生效,系统设置不可覆盖');
}
const trustProxy = validateTrustProxy(value);
@@ -82,7 +81,7 @@ export async function updateTrustProxy(value: string) {
where: { type: AuthDataType.systemConfig },
});
if (!doc) {
throw new Error(t('系统配置不存在'));
throw new Error('系统配置不存在');
}
const plain = doc.get({ plain: true });
+1 -23
View File
@@ -20,10 +20,8 @@ export const scheduleSchema = Joi.string()
.required()
.custom(validateSchedule)
.messages({
'any.required': '定时规则不能为空',
'any.invalid': '无效的定时规则',
'string.empty': '定时规则不能为空',
'string.base': '定时规则必须是字符串',
});
export const commonCronSchema = {
@@ -32,27 +30,7 @@ export const commonCronSchema = {
schedule: scheduleSchema,
labels: Joi.array().optional().allow(null),
sub_id: Joi.number().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': '额外定时规则必须是数组' }),
extra_schedules: Joi.array().optional().allow(null),
task_before: Joi.string().optional().allow('').allow(null),
task_after: Joi.string().optional().allow('').allow(null),
log_name: Joi.string()
+16 -4
View File
@@ -5,7 +5,7 @@
The npm and panel-internal entries both use the name `ql`, but have separate Commander command trees. The npm entry only calls remote APIs; the internal entry only runs local tools. Verify the absolute executable path and `--help` before use. Installing the npm package does not migrate the built-in Shell commands.
`@whyour/qinglong-cli` is a standalone npm client for the panel's open API. It registers only `ql`, for the currently supported OpenAPI resources. Local execution, repo/raw workers, reload/update/account recovery belong to the panel's internal tools and are excluded from npm. Development publishing is outside both command sets.
`@whyour/qinglong-cli` is a standalone npm client for the panel's open API. It registers only `ql`, for the currently supported OpenAPI resources. Local execution, repo/raw workers, reload/update/account recovery belong to the panel's internal tools and are excluded from npm. Remote CLI commands do not manage development publishing.
## Installation and commands
@@ -43,7 +43,7 @@ Use command `--help` and `QL_LANG=en` for English. Command names and JSON fields
## Complete OpenAPI management
The CLI also supports task/subscription CRUD, application and secret management, environment variables, configuration, scripts, logs, dependencies, system, dashboard and user APIs. `ql api routes --json` lists all 143 active routes; three retired file-reading endpoints are excluded. CI compares the catalogue against backend routes. See the [complete bilingual reference](skills/qinglong-cli/references/openapi.md) for every command and payload.
The CLI also supports task/subscription CRUD, application and secret management, environment variables, configuration, scripts, logs, dependencies, system, dashboard and user APIs. `ql api routes --json` lists all 146 active routes; three retired file-reading endpoints are excluded. CI compares the catalogue against backend routes. See the [complete bilingual reference](skills/qinglong-cli/references/openapi.md) for every command and payload.
```sh
ql task create --name demo --command 'task demo.js' --schedule '0 0 * * *' --json
@@ -53,8 +53,20 @@ ql env create --data @envs.json --json
ql api request PUT /open/crons/run --data '[12,13]' --json
```
CLI 0.1.2 adds script history listing, detail and confirmed restore commands, requiring the history APIs in QingLong 2.23.0. Restore uses the current hash from the detail response; on 409, read and review the current state again.
```sh
ql script history-list --query '{"filename":"demo.js","path":""}' --json
ql script history-detail --query '{"filename":"demo.js","path":"","id":"VERSION_UUID"}' --json
ql script history-restore --data '{"filename":"demo.js","path":"","id":"VERSION_UUID","expectedHash":"CURRENT_SHA256"}' --json
```
Replace `VERSION_UUID` and `CURRENT_SHA256` with values returned by the list and detail commands.
Use --data JSON/@file/- for bodies, --query for query objects, --file for uploads and --output for downloads. New commands support --timeout seconds. Existing command contracts remain; raw API requests expose all fields and batch operations. Downloads do not overwrite files. App secrets require --show-secrets; other raw resources may contain sensitive data.
`--timeout` only limits waiting for the HTTP response; it does not stop server-side tasks. A failed write may already have taken effect, so inspect the resource before retrying.
Remote ql system commands call the target panel API. Local reload/reset tools remain excluded from npm.
## Authentication
@@ -130,9 +142,9 @@ node cli/scripts/verify-package.cjs
Tests cover requests, token refresh, output, errors, no automatic retries, permissions and isolated installation. Package verification installs the archive offline, verifies the sole ql executable and rejects local commands.
The CLI package workflow checks, builds and tests Node 22.12/24 on relevant PRs, develop/master pushes and manual runs. Node 24 uploads the archive verified by offline installation as `qinglong-cli-<commit>`. After both matrix jobs succeed, master pushes publish that exact archive to npm as latest, using GitHub Actions OIDC trusted publishing without an NPM_TOKEN secret. Manual runs publish only when run on master with publish enabled; PRs, develop and forks never publish. Configure the npm Trusted Publisher for repository `whyour/qinglong`, workflow `cli-package.yml`, with `npm publish` allowed. The panel package `@whyour/qinglong` separately trusts `build-docker-image.yml`. Publishing uses Node 24 with `id-token: write` granted only to the publish job. New packages need an initial publication before configuring their trusted publisher.
The CLI package workflow checks, builds and tests Node 22.12/24 on relevant PRs, develop/master pushes and manual runs. Node 24 uploads the archive verified by offline installation as `qinglong-cli-<commit>`. After both matrix jobs succeed, master pushes publish that exact archive to npm as latest, using GitHub Actions OIDC trusted publishing without an NPM_TOKEN secret. A manual release requires publish on master, or publish plus an explicit `X.Y.Z-alpha.N`, `X.Y.Z-beta.N` or `X.Y.Z-rc.N` version on develop. Develop prereleases set the same requested version before both Node verification jobs and publish to alpha/beta/rc without changing latest. Ordinary PRs, develop pushes, validation-only manual runs and forks do not publish. Releases and validation use separate concurrency groups; new validation does not cancel an active release. Configure the npm Trusted Publisher for repository `whyour/qinglong`, workflow `cli-package.yml`, with `npm publish` allowed. The panel package `@whyour/qinglong` separately trusts `build-docker-image.yml`. Publishing uses Node 24 with `id-token: write` granted only to the publish job. New packages need an initial publication before configuring their trusted publisher.
The CLI has an independent stable version in cli/package.json and cli/package-lock.json. Before releasing changes, run `npm version patch --prefix cli --no-git-tag-version` (or minor/major) and commit both files. Existing versions are skipped with a notice; registry failures stop publication. Only stable X.Y.Z versions are published by this workflow. Publication does not rebuild the verified archive or run package lifecycle scripts.
The CLI has an independent stable version in cli/package.json and cli/package-lock.json. Before releasing changes, run `npm version patch --prefix cli --no-git-tag-version` (or minor/major) and commit both files. Existing stable versions are skipped with a notice; an existing prerelease is an error and requires a new version. Registry failures stop publication. Manual develop prereleases only update the version in that verification workspace and do not replace stable source metadata maintenance. Publication does not rebuild the verified archive or run package lifecycle scripts.
## Panel-internal tools
+16 -4
View File
@@ -5,7 +5,7 @@
npm 与面板内部入口都叫 `ql`,但使用独立的 Commander 命令树:npm 入口只调用远程 API,内部入口只运行本机工具。使用前确认可执行文件的绝对路径和 `--help`;安装 npm 包不会迁移内置 Shell 命令。
`@whyour/qinglong-cli` 是独立 npm 包,覆盖当前 develop 的有效 OpenAPI,只注册一个 `ql` 命令。它不包含脚本执行器或本机运维实现;`task exec`、`repo/raw`、`reload/update/reset*` 等属于面板内部工具,不随 npm 包分发。开发发布也不属于 CLI 范围。
`@whyour/qinglong-cli` 是独立 npm 包,覆盖当前 develop 的有效 OpenAPI,只注册一个 `ql` 命令。它不包含脚本执行器或本机运维实现;`task exec`、`repo/raw`、`reload/update/reset*` 等属于面板内部工具,不随 npm 包分发。远程 CLI 命令不提供开发发布操作。
## 安装与使用
@@ -43,7 +43,7 @@ ql auth logout --json
## 全量 OpenAPI 管理
现在还支持任务/订阅创建、修改、删除,应用管理与密钥重置,以及环境变量、配置、脚本、日志、依赖、系统、仪表盘和用户管理。`ql api routes --json` 列出全部 143 条有效路由;3 条已下线文件读取接口不包含在内。新增命令在 [完整双语参考](skills/qinglong-cli/references/openapi.md) 中逐项列出,路由覆盖由 CI 与后端代码核对。
现在还支持任务/订阅创建、修改、删除,应用管理与密钥重置,以及环境变量、配置、脚本、日志、依赖、系统、仪表盘和用户管理。`ql api routes --json` 列出全部 146 条有效路由;3 条已下线文件读取接口不包含在内。新增命令在 [完整双语参考](skills/qinglong-cli/references/openapi.md) 中逐项列出,路由覆盖由 CI 与后端代码核对。
```sh
ql task create --name demo --command 'task demo.js' --schedule '0 0 * * *' --json
@@ -53,8 +53,20 @@ ql env create --data @envs.json --json
ql api request PUT /open/crons/run --data '[12,13]' --json
```
CLI 0.1.2 增加脚本历史列表、详情和确认恢复命令,需要青龙 2.23.0 的历史 API。恢复使用详情返回的当前哈希,409 后应重新读取并确认。
```sh
ql script history-list --query '{"filename":"demo.js","path":""}' --json
ql script history-detail --query '{"filename":"demo.js","path":"","id":"VERSION_UUID"}' --json
ql script history-restore --data '{"filename":"demo.js","path":"","id":"VERSION_UUID","expectedHash":"CURRENT_SHA256"}' --json
```
将 `VERSION_UUID` 和 `CURRENT_SHA256` 替换为列表和详情实际返回值。
请求体使用 --data JSON/@file/-,查询使用 --query,上传 --file,下载 --output。新命令支持 --timeout 秒数;旧命令行为保留,完整参数可用 api request。下载不覆盖现有文件,应用密钥默认隐藏,明确加 --show-secrets 才输出。新增资源通常保留原始返回字段,注意环境、配置和会话信息可能敏感。
`--timeout` 只限制等待 HTTP 响应,不停止服务器上的任务;失败的写入可能已经生效,应先查询状态。
远程 `ql system ...` 调用面板 API;本机 reload/reset 等仍不在 npm 包中。不要将远程 API 覆盖理解为本机运维重新混入包。
## 认证
@@ -130,9 +142,9 @@ node cli/scripts/verify-package.cjs
测试覆盖请求格式、认证刷新、输出、错误、禁止自动重试、权限和独立安装。打包验证会离线安装 tgz,并确认唯一入口是 `ql`,本机命令不可调用。
CLI package 工作流在相关 PR、develop/master 推送和手动触发时执行 Node 22.12/24 类型检查、构建及测试。Node 24 上传通过离线安装验证的 `qinglong-cli-<commit>` artifact。两个矩阵任务成功后,master 推送会将这份已验证的 tgz 发布到 npm 的 latest 标签,通过 GitHub Actions OIDC 可信发布,无需 `NPM_TOKEN` Secret。手动运行需选择 master 并勾选 publish;PR、develop 和 fork 不发布。npm 的 Trusted Publisher 需绑定仓库 `whyour/qinglong` 和工作流 `cli-package.yml`,允许 `npm publish`;面板包 `@whyour/qinglong` 单独绑定 `build-docker-image.yml`。发布 job 使用 Node 24,并仅在该 job 授予 `id-token: write`。新包需先完成首次发布,再配置对应包的可信发布关系。
CLI package 工作流在相关 PR、develop/master 推送和手动触发时执行 Node 22.12/24 类型检查、构建及测试。Node 24 上传通过离线安装验证的 `qinglong-cli-<commit>` artifact。两个矩阵任务成功后,master 推送会将这份已验证的 tgz 发布到 npm 的 latest 标签,通过 GitHub Actions OIDC 可信发布,无需 `NPM_TOKEN` Secret。手动发布在 master 勾选 publish,或在 develop 勾选 publish 并填写 `X.Y.Z-alpha.N`、`X.Y.Z-beta.N` 或 `X.Y.Z-rc.N` 版本。develop 预发布在两个 Node 矩阵中先设置相同版本并验证,发布对应 alpha/beta/rc 标签,不更新 latest。普通 PR、develop 推送、仅验证的手动运行和 fork 不发布。发布与验证使用独立并发组,正在运行的发布不会被新验证取消。npm 的 Trusted Publisher 需绑定仓库 `whyour/qinglong` 和工作流 `cli-package.yml`,允许 `npm publish`;面板包 `@whyour/qinglong` 单独绑定 `build-docker-image.yml`。发布 job 使用 Node 24,并仅在该 job 授予 `id-token: write`。新包需先完成首次发布,再配置对应包的可信发布关系。
CLI 版本独立维护在 cli/package.json 和 cli/package-lock.json。发布改动前执行 `npm version patch --prefix cli --no-git-tag-version`(也可用 minor/major),提交这两个文件。已发布的版本会提示并跳过;registry 查询失败则停止发布。该流程仅发布 X.Y.Z 稳定版本,不重新构建产物或执行包生命周期脚本。
CLI 版本独立维护在 cli/package.json 和 cli/package-lock.json。发布改动前执行 `npm version patch --prefix cli --no-git-tag-version`(也可用 minor/major),提交这两个文件。已存在的稳定版本提示并跳过;预发布版本已存在则失败,需选择新版本。registry 查询失败停止发布。手动 develop 预发布只临时修改本轮验证工作区的版本,不代替稳定版本元数据维护。发布不重新构建已验证归档,也不执行包生命周期脚本。
## 面板内部工具
+4 -78
View File
@@ -1,13 +1,12 @@
{
"name": "@whyour/qinglong-cli",
"version": "0.1.3",
"version": "0.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@whyour/qinglong-cli",
"version": "0.1.3",
"license": "Apache-2.0",
"version": "0.1.2",
"bin": {
"ql": "dist/npm/ql.js"
},
@@ -15,12 +14,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",
@@ -464,19 +463,6 @@
"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",
@@ -487,16 +473,6 @@
"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",
@@ -549,46 +525,6 @@
"@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",
@@ -609,16 +545,6 @@
"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"
}
}
}
}
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@whyour/qinglong-cli",
"version": "0.1.3",
"version": "0.1.2",
"description": "Authenticated remote management CLI for QingLong 2.x",
"type": "commonjs",
"bin": {
@@ -26,8 +26,7 @@
"commander": "15.0.0",
"esbuild": "0.28.2",
"typescript": "5.2.2",
"@types/node": "22.19.0",
"tar": "7.5.20"
"@types/node": "22.19.0"
},
"license": "Apache-2.0",
"repository": {
-19
View File
@@ -17,25 +17,6 @@ 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(process.env.QL_LANG === 'en' ? 'Executing before command\\n' : '执行前置命令\\n');",
" if (task_before) console.log('执行前置命令\\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(process.env.QL_LANG === 'en' ? 'Before command finished\\n' : '执行前置命令结束\\n');",
" console.log('执行前置命令结束\\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(process.env.QL_LANG === 'en' ? 'Before command finished\\n' : '执行前置命令结束\\n');",
" console.log('执行前置命令结束\\n');",
' }',
' }',
'',
@@ -214,7 +214,7 @@ export const pythonPreloadSource: string = [
' ',
' task_before = os.getenv("task_before", "")',
' if task_before:',
' print("Executing before command\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令\\n")',
' print("执行前置命令\\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("Before command finished\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\\n")',
' print("执行前置命令结束\\n")',
'',
' except subprocess.CalledProcessError as error:',
' print(f"\\ue926 run task before error: {error}")',
' if task_before:',
' print("Before command finished\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\\n")',
' print("执行前置命令结束\\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("Before command finished\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\\n")',
' print("执行前置命令结束\\n")',
' except Exception as error:',
' print(f"\\ue926 run task before error: {error}")',
' if task_before:',
' print("Before command finished\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\\n")',
' print("执行前置命令结束\\n")',
'',
' import task_before',
'',
+6 -6
View File
@@ -8,6 +8,7 @@ import { repairConfiguration, startPanel } from './operator';
import { operatingSystem } from './bot';
import { fail } from '../../shared/errors';
import { registerHostServices } from '../runtime/hostServices';
import { installRuntimeTools } from './tools';
export function bootstrapPackages(
os: string,
@@ -92,10 +93,13 @@ export function runtimeEnvironment(
.filter(Boolean)
.join(path.delimiter),
NODE_PATH: [
context.env.NODE_PATH,
'/usr/local/bin',
'/usr/local/lib/node_modules',
path.join(node, 'global/5/node_modules'),
].join(path.delimiter),
]
.filter(Boolean)
.join(path.delimiter),
PYTHONPATH: [
python,
path.join(python, `lib/python${pythonVersion}`),
@@ -176,11 +180,7 @@ export async function bootstrapPanel(
root ? args : [packages.program, ...args],
{ cwd: context.root, env: context.env },
);
await checkedProcess(
'npm',
['install', '-g', 'pnpm@8.3.1', 'pm2', 'ts-node', 'typescript@5'],
{ cwd: context.root, env: context.env },
);
await installRuntimeTools(context);
}
const python = await checkedProcess(
'python3',
+2 -6
View File
@@ -4,7 +4,7 @@ import path from 'node:path';
import http from 'node:http';
import type { LocalContext } from '../runtime/context';
import { atomicWrite } from '../runtime/files';
import { checkedProcess } from '../runtime/process';
import { installRuntimeTools } from './tools';
import {
repairConfiguration,
installPanelDependencies,
@@ -152,11 +152,7 @@ export async function diagnosticLog(file: string): Promise<{
}
export async function checkAndRepair(context: LocalContext): Promise<unknown> {
await checkedProcess(
'npm',
['i', '-g', 'pnpm@8.3.1', 'pm2', 'ts-node', 'typescript@5'],
{ cwd: context.root, env: context.env },
);
await installRuntimeTools(context);
const restored = await repairConfiguration(context);
await installPanelDependencies(context);
const copied: string[] = [];
@@ -1,18 +0,0 @@
// Node 24's ARMv6 JS compiler tiers can trap on ARM1176. Keep WebAssembly
// enabled for fetch/undici; --jitless disables their HTTP parser as well.
const armv6ServerArgs = Object.freeze([
'--no-opt',
'--no-sparkplug',
'--no-maglev',
]);
function serverRuntimeArgs(runtime = process) {
return runtime.arch === 'arm' &&
Number(runtime.config.variables.arm_version) === 6 &&
Number(runtime.versions.node.split('.')[0]) >= 24
? [...armv6ServerArgs]
: [];
}
module.exports = { serverRuntimeArgs, armv6ServerArgs };
if (require.main === module)
process.stdout.write(serverRuntimeArgs().join(' '));
+12 -24
View File
@@ -7,10 +7,6 @@ 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,
@@ -75,14 +71,18 @@ export async function executableAvailable(
export async function installPanelDependencies(
context: LocalContext,
cwd = context.root,
staticRoot?: string,
sourceCommit?: string,
): Promise<void> {
const termux = context.env.is_termux === '1';
const pnpm = !termux && (await executableAvailable('pnpm', context.env));
await checkedProcess(
pnpm ? 'pnpm' : 'npm',
pnpm
? ['install', '--loglevel', 'error', '--production']
: ['install', '--production', ...(termux ? ['--no-bin-links'] : [])],
process.execPath,
[
path.join(context.root, 'scripts/install-panel-dependencies.cjs'),
'--root',
cwd,
...(staticRoot ? ['--static-dir', staticRoot] : []),
...(sourceCommit ? ['--source-commit', sourceCommit] : []),
],
{ cwd, env: context.env },
);
}
@@ -121,11 +121,7 @@ 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 &&
match[3] !== `${armv6ServerArgs.join(' ')} ${entry}`
)
continue;
if (match[3] !== entry) continue;
const pid = Number(match[1]);
if (pid === process.pid) continue;
try {
@@ -158,14 +154,6 @@ 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) {
@@ -187,7 +175,7 @@ export async function startPanel(
0o600,
);
try {
const child = spawn(process.execPath, [...serverRuntimeArgs(), entry], {
const child = spawn(process.execPath, [entry], {
...options,
detached: true,
stdio: ['ignore', log.fd, log.fd],
@@ -1,577 +0,0 @@
// Persistent panel jobs run independently of HTTP requests and browser sessions.
const fs = require('node:fs/promises');
const path = require('node:path');
const { randomUUID } = require('node:crypto');
const { spawn } = require('node:child_process');
const http = require('node:http');
const { setTimeout: delay } = require('node:timers/promises');
const artifacts = require('./upgradeArtifacts.cjs');
const { nodeRuntimeArgs } = require('./platformBundle.cjs');
const active = new Set(['starting', 'running', 'restarting']);
const stateFile = (tmp) => path.join(tmp, 'panel-upgrade.json');
const logFile = (tmp) => path.join(tmp, 'panel-upgrade.log');
const launches = new Map();
async function withPanelLifecycle(root, tmp, env, operation, signal) {
if (!env.QL_CLI_ROOT)
return artifacts.withLifecycleHooks(root, tmp, env, operation);
if (!path.isAbsolute(env.QL_CLI_ROOT))
throw new Error('QL_CLI_ROOT must be absolute');
const internal = path.join(env.QL_CLI_ROOT, 'dist/internal');
// Keep the selected installation's operators loaded across file replacement.
const { createContext, sourceEnvironment } = require(path.join(
internal,
'runtime/context.js',
));
const { cancellableOperation } = require(path.join(
internal,
'runtime/cancellation.js',
));
const operator = require(path.join(internal, 'maintenance/operator.js'));
const { prepareUpgradeSelection } = require(path.join(
internal,
'maintenance/upgradeSelection.js',
));
const context = createContext(
{ root, 'data-dir': env.dir_data || env.QL_DATA_DIR },
env,
);
context.env = await sourceEnvironment(
context.env,
[
context.paths.file_config_user,
path.join(context.paths.dir_preload, 'lang_env.sh'),
],
[],
{ signal },
);
const hook = (name, args = [], signal) =>
cancellableOperation(signal, () => {
if (name === 'install')
return operator.installPanelDependencies(context, args[0]);
if (name === 'stop') return operator.stopPanel(context);
if (name === 'start') return operator.startPanel(context);
throw new Error('Invalid lifecycle operation');
});
return operation(hook, (selected) =>
prepareUpgradeSelection(context, selected.source, selected.static),
);
}
async function targetVersion(source) {
const text = await fs.readFile(path.join(source, 'version.yaml'), 'utf8');
const version = text.match(
/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?)/m,
)?.[1];
if (!version) throw new Error('Invalid update version');
return version;
}
async function readRunningBuild(env) {
let base = env.QlBaseUrl || '';
if (base && !base.startsWith('/')) base = `/${base}`;
base = base.replace(/\/$/, '');
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => request.destroy(new Error('Version check timed out')),
5000,
);
const request = http.get(
{
hostname: '127.0.0.1',
port: Number(env.QlPort || 5700),
path: `${base}/api/health`,
},
(response) => {
let body = '';
response.on('data', (chunk) => {
body += chunk;
if (body.length > 65536)
request.destroy(new Error('Invalid version response'));
});
response.on('error', reject);
response.on('end', () => {
try {
const result = JSON.parse(body);
if (
response.statusCode !== 200 ||
result.code !== 200 ||
result.data?.status !== 'ok'
)
throw new Error('后端启动检查失败。');
resolve(result.data.build);
} catch (error) {
reject(error);
}
});
},
);
request.on('error', reject);
request.on('close', () => clearTimeout(timer));
});
}
async function verifyRunningVersion(env, expected, previousBuild) {
const build = await readRunningBuild(env);
if (
!build ||
!build.instanceId ||
build.instanceId === previousBuild?.instanceId ||
(previousBuild?.parentPid !== undefined &&
build.parentPid === previousBuild.parentPid) ||
build.version !== expected.version ||
build.sourceCommit !== expected.sourceCommit ||
build.backendSha256 !== expected.backendSha256
)
throw new Error('启动版本与更新包不一致');
}
async function stagedMetadata(selected) {
const manifest = JSON.parse(
await fs.readFile(path.join(selected.static, 'build-info.json'), 'utf8'),
);
return {
version: await targetVersion(selected.source),
sourceCommit: manifest.sourceCommit,
backendSha256: manifest.files['build/app.js'],
stageDirectory: path.basename(path.dirname(selected.source)),
};
}
function assertSelected(expected, actual) {
for (const key of [
'sourceCommit',
'backendSha256',
'stageDirectory',
'version',
]) {
if (expected[key] !== actual[key])
throw new Error('暂存更新已变化,请重新下载更新。');
}
}
async function save(tmp, state) {
const temporary = `${stateFile(tmp)}.${randomUUID()}`;
await fs.writeFile(temporary, JSON.stringify(state), { mode: 0o600 });
await fs.rename(temporary, stateFile(tmp));
}
async function readState(tmp) {
try {
return JSON.parse(await fs.readFile(stateFile(tmp), 'utf8'));
} catch (error) {
if (error.code === 'ENOENT') return { status: 'idle' };
throw error;
}
}
async function getStatus(tmp) {
const state = await readState(tmp);
if (active.has(state.status)) {
let alive = false;
if (Number.isSafeInteger(state.pid) && state.pid > 0) {
try {
process.kill(state.pid, 0);
alive = true;
} catch (error) {
alive = error.code === 'EPERM';
}
} else alive = Date.now() - state.startedAt < 10000;
if (!alive) {
state.status = 'failed';
state.error = '升级任务意外中断,请重试';
}
}
// Bound reads even when an installer produces a very large log.
const handle = await fs.open(logFile(tmp), 'r').catch(() => undefined);
if (handle) {
try {
const { size } = await handle.stat();
const buffer = Buffer.alloc(Math.min(size, 32768));
const { bytesRead } = await handle.read(
buffer,
0,
buffer.length,
Math.max(0, size - buffer.length),
);
state.log = buffer.subarray(0, bytesRead).toString();
} finally {
await handle.close();
}
}
return state;
}
// Manual restarts accept the currently prepared package, including one retained
// after a failed preparation. Legacy installs without jobs keep their old path;
// complete-bundle installs always verify and record the actual running build.
async function withPreparedJob(tmp, env, selected, operation) {
const previous = await getStatus(tmp);
if (active.has(previous.status)) throw new Error('已有更新或重载正在执行。');
const hasRecord = await fs
.access(stateFile(tmp))
.then(() => true)
.catch((error) => {
if (error.code !== 'ENOENT') throw error;
return false;
});
if (!hasRecord) {
const pkg = env.QL_DIR
? JSON.parse(
await fs
.readFile(path.join(env.QL_DIR, 'package.json'), 'utf8')
.catch((error) => {
if (error.code !== 'ENOENT') throw error;
return '{}';
}),
)
: {};
if (pkg.qinglongUpgradeFormat !== 2) return operation();
}
return artifacts.withUpgradeLock(
path.join(tmp, 'panel-job-launch'),
async () => {
const current = await getStatus(tmp);
if (active.has(current.status))
throw new Error('已有更新或重载正在执行。');
const prepared = await artifacts.selectedUpgrade(
tmp,
artifacts.releaseBranch(env),
env,
{ metadataOnly: true },
);
if (
prepared.source !== selected.source ||
prepared.static !== selected.static
)
throw new Error('暂存更新已变化,请重新下载更新。');
const metadata = await stagedMetadata(selected);
const previousBuild = await readRunningBuild(env).catch(() => undefined);
const { log, error, ...record } = current;
let state = {
...record,
...metadata,
id: randomUUID(),
action: 'apply',
status: 'running',
phase: 'verifying',
startedAt: Date.now(),
pid: process.pid,
};
const report = async (patch) => {
state = { ...state, ...patch, updatedAt: Date.now() };
await save(tmp, state);
};
await fs.writeFile(
logFile(tmp),
'Manual application of prepared update\n',
{
mode: 0o600,
},
);
await report({});
try {
const result = await operation({
onStop: () => report({ status: 'restarting', phase: 'restarting' }),
onStart: (recover) =>
recover
? undefined
: verifyRunningVersion(env, state, previousBuild),
});
await report({ status: 'completed', phase: 'completed' });
return result;
} catch (error) {
await report({ status: 'failed', error: error.message });
throw error;
}
},
env,
);
}
async function startJob({ root, tmp, env = process.env }, action) {
if (!['download', 'apply'].includes(action))
throw new Error('Invalid upgrade action');
await fs.mkdir(tmp, { recursive: true });
const preceding = launches.get(tmp) || Promise.resolve();
const launching = preceding
.catch(() => {})
.then(() =>
artifacts.withUpgradeLock(
path.join(tmp, 'panel-job-launch'),
async () => {
const previous = await getStatus(tmp);
if (active.has(previous.status)) {
if (previous.action !== action)
throw new Error('已有更新或重载正在执行。');
return previous;
}
let selected;
let metadata;
if (action === 'apply') {
// Keep requests short on slow devices. The detached worker performs
// complete file/dependency verification before it can stop the panel.
try {
selected = await artifacts.selectedUpgrade(
tmp,
artifacts.releaseBranch(env),
env,
{ metadataOnly: true },
);
metadata = await stagedMetadata(selected);
if (previous.status === 'ready')
assertSelected(previous, metadata);
} catch (error) {
const { log, ...record } = previous;
await save(tmp, {
...record,
status: 'failed',
phase: 'verifying',
error: error.message,
});
throw error;
}
} else if (previous.status === 'ready') return previous;
const state = {
id: randomUUID(),
action,
status: 'starting',
phase: action === 'apply' ? 'verifying' : 'downloading',
startedAt: Date.now(),
...metadata,
};
await save(tmp, state);
const log = await fs.open(logFile(tmp), 'w', 0o600);
try {
const child = spawn(
process.execPath,
[__filename, 'launch', state.id],
{
env: { ...env, QL_DIR: root, dir_tmp: tmp },
detached: true,
stdio: ['ignore', log.fd, log.fd],
},
);
await new Promise((resolve, reject) => {
child.once('exit', (code, signal) => {
if (code === 0) resolve();
else
reject(
new Error(`Upgrade launcher exited (${signal || code})`),
);
});
child.once('error', reject);
});
child.unref();
} catch (error) {
await save(tmp, {
...state,
status: 'failed',
error: error.message,
});
throw error;
} finally {
await log.close();
}
return state;
},
env,
),
);
launches.set(tmp, launching);
try {
return await launching;
} finally {
if (launches.get(tmp) === launching) launches.delete(tmp);
}
}
async function runJob({ root, tmp, env = process.env }, id) {
let state = await readState(tmp);
if (state.id !== id || state.status !== 'starting')
throw new Error('Stale upgrade job');
const controller = new AbortController();
const abort = () => controller.abort(new Error('升级任务意外中断,请重试'));
process.once('SIGTERM', abort);
process.once('SIGINT', abort);
// Serialize progress writes so an older tick cannot overwrite ready/completed.
let writes = Promise.resolve();
const report = (patch) => {
state = { ...state, ...patch, pid: process.pid, updatedAt: Date.now() };
const snapshot = { ...state };
writes = writes.then(() => save(tmp, snapshot));
return writes;
};
try {
await report({ status: 'running' });
let terminal;
await artifacts.withUpgradeLock(
tmp,
() =>
withPanelLifecycle(
root,
tmp,
env,
async (hook, prepareSelection) => {
const run = (program, args, capture) =>
artifacts.runProcess(
env,
program,
args,
capture,
controller.signal,
);
if (state.action === 'download') {
const selected = await artifacts.stageUpgrade({
root,
tmp,
env,
mirror: 'github',
fallbackMirror: 'gitee',
run,
signal: controller.signal,
onProgress: report,
install: (source) =>
hook('install', [source], controller.signal),
});
terminal = {
status: 'ready',
phase: 'ready',
...(await stagedMetadata(selected)),
};
} else {
const selected = await artifacts.selectedUpgrade(
tmp,
artifacts.releaseBranch(env),
env,
);
assertSelected(state, await stagedMetadata(selected));
const previousBuild = await readRunningBuild(env).catch(
() => undefined,
);
const selection = await prepareSelection?.(selected);
try {
await artifacts.reloadSystem({
root,
tmp,
env,
data:
env.dir_data || env.QL_DATA_DIR || path.join(root, 'data'),
staticRoot: env.dir_static || path.join(root, 'static'),
config:
env.dir_config ||
path.join(
env.QL_DATA_DIR || path.join(root, 'data'),
'config',
),
lifecycle: {
signal: controller.signal,
stop: async () => {
await report({
status: 'restarting',
phase: 'restarting',
});
await hook('stop');
},
start: async (recover) => {
await hook(
'start',
[],
recover ? undefined : controller.signal,
);
await artifacts.waitForHealth(
env,
undefined,
recover ? undefined : controller.signal,
);
if (!recover)
await verifyRunningVersion(env, state, previousBuild);
},
},
additionalReplacements: selection
? [{ source: selection.source, target: selection.target }]
: [],
});
} finally {
if (selection)
await fs.rm(selection.directory, {
recursive: true,
force: true,
});
}
terminal = { status: 'completed', phase: 'completed' };
}
},
controller.signal,
),
env,
);
await report(terminal);
} catch (error) {
console.error(error);
// A failed write must not prevent recording the final failure.
await writes.catch(() => {});
writes = Promise.resolve();
await report({ status: 'failed', error: error.message });
} finally {
process.removeListener('SIGTERM', abort);
process.removeListener('SIGINT', abort);
await writes;
}
}
module.exports = {
getStatus,
saveState: save,
startJob,
runJob,
withPanelLifecycle,
readRunningBuild,
verifyRunningVersion,
stagedMetadata,
withPreparedJob,
};
async function launchWorker(id) {
// detached creates a session but keeps the original parent. PM2 kills the
// backend's entire descendant tree, so an intermediate launcher must exit
// before the worker is allowed to stop that backend.
const child = spawn(
process.execPath,
[...nodeRuntimeArgs(), __filename, 'worker', id],
{
env: { ...process.env, QL_UPGRADE_LAUNCHER_PID: String(process.pid) },
detached: true,
stdio: 'inherit',
},
);
await new Promise((resolve, reject) => {
child.once('spawn', resolve);
child.once('error', reject);
});
child.unref();
}
async function runOrphanedWorker(id) {
const launcher = Number(process.env.QL_UPGRADE_LAUNCHER_PID);
const deadline = Date.now() + 10000;
while (process.platform !== 'win32' && process.ppid === launcher) {
if (Date.now() >= deadline)
throw new Error('Upgrade launcher did not exit');
await delay(10);
}
const root = process.env.QL_DIR;
await runJob(
{ root, tmp: process.env.dir_tmp || path.join(root, '.tmp') },
id,
);
}
if (require.main === module && ['launch', 'worker'].includes(process.argv[2])) {
const operation =
process.argv[2] === 'launch' ? launchWorker : runOrphanedWorker;
operation(process.argv[3]).catch((error) => {
console.error(error);
process.exitCode = 1;
});
}
@@ -1,582 +0,0 @@
// One installed verifier handles release downloads and local offline archives.
const fs = require('node:fs/promises');
const path = require('node:path');
const crypto = require('node:crypto');
const os = require('node:os');
const { execFile } = require('node:child_process');
const { promisify } = require('node:util');
const tar = require('tar');
const { serverRuntimeArgs } = require('./nodeRuntime.cjs');
const {
prestartBackend,
dependencyFailure,
repairDependencies,
} = require('./upgradePrestart.cjs');
const execute = promisify(execFile);
// Heavy archive parsing can hit unsupported V8 JIT paths on ARM1176. Keep
// upgrade workers interpreted without changing the installed Node or server.
const nodeRuntimeArgs = (runtime = process) =>
runtime.arch === 'arm' && Number(runtime.config.variables.arm_version) === 6
? ['--jitless']
: [];
const sha256 = (bytes) =>
crypto.createHash('sha256').update(bytes).digest('hex');
const inside = (root, filename) => {
const relative = path.relative(root, filename);
return (
relative !== '..' &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative)
);
};
const versionParts = (v) => String(v).replace(/^v/, '').split('.').map(Number);
function atLeast(actual, minimum) {
const a = versionParts(actual),
b = versionParts(minimum);
return (
b.every((n) => Number.isFinite(n)) &&
a.every((n) => Number.isFinite(n)) &&
(a[0] > b[0] ||
(a[0] === b[0] &&
(a[1] > b[1] || (a[1] === b[1] && (a[2] || 0) >= (b[2] || 0)))))
);
}
async function runtimeTarget() {
let libc, libcVersion;
if (process.platform === 'linux') {
const header = process.report.getReport().header;
if (header.glibcVersionRuntime) {
libc = 'glibc';
libcVersion = header.glibcVersionRuntime;
} else {
let text = '';
try {
text = (await execute('ldd', ['--version'])).stdout;
} catch (error) {
text = `${error.stdout || ''}\n${error.stderr || ''}`;
}
const match = text.match(/musl[\s\S]*?Version\s+(\d+\.\d+\.\d+)/i);
if (!match)
throw new Error(
'Cannot identify Linux libc; refusing a native dependency bundle',
);
libc = 'musl';
libcVersion = match[1];
}
}
const armVersion =
process.arch === 'arm'
? Number(process.config.variables.arm_version)
: undefined;
if (process.arch === 'arm' && ![6, 7, 8].includes(armVersion))
throw new Error('Cannot identify ARM instruction set');
const arch =
process.arch === 'arm' ? `armv${armVersion >= 7 ? 7 : 6}` : process.arch;
const armFloatAbi =
process.arch === 'arm' ? process.config.variables.arm_float_abi : undefined;
if (
process.arch === 'arm' &&
!['hard', 'soft', 'softfp'].includes(armFloatAbi)
)
throw new Error('Cannot identify ARM floating-point ABI');
return {
platform: process.platform,
arch,
libc,
libcVersion,
endianness: os.endianness(),
armFloatAbi,
};
}
function targetKey(target) {
return [target.platform, target.arch, target.libc].filter(Boolean).join('-');
}
function assertCompatible(manifest, target, node = process.versions.node) {
if (
manifest.format !== 2 ||
!/^[a-f0-9]{40}$/.test(manifest.sourceCommit) ||
!/^[a-f0-9]{64}$/.test(manifest.lockfileSha256) ||
!manifest.files ||
typeof manifest.files !== 'object' ||
Array.isArray(manifest.files) ||
!Number.isSafeInteger(manifest.nodeApi) ||
manifest.nodeApi < 6
)
throw new Error('Invalid platform bundle manifest');
if (targetKey(manifest.target || {}) !== targetKey(target))
throw new Error(`Bundle platform mismatch: need ${targetKey(target)}`);
if (
!atLeast(node, manifest.nodeMinimum) ||
Number(process.versions.napi) < manifest.nodeApi
)
throw new Error(
`Bundle requires Node >= ${manifest.nodeMinimum} and Node-API ${manifest.nodeApi}; the existing Node is retained`,
);
if (
manifest.target.endianness !== target.endianness ||
manifest.target.armFloatAbi !== target.armFloatAbi
)
throw new Error(
'Bundle native byte order or ARM floating-point ABI mismatch',
);
if (target.libc && !atLeast(target.libcVersion, manifest.target.libcMinimum))
throw new Error(
`Bundle requires ${target.libc} >= ${manifest.target.libcMinimum}`,
);
}
async function inventory(roots) {
const files = {};
async function visit(directory, prefix) {
for (const name of (await fs.readdir(directory)).sort()) {
const filename = path.join(directory, name),
relative = `${prefix}/${name}`;
const stat = await fs.lstat(filename);
if (stat.isDirectory()) await visit(filename, relative);
else if (stat.isFile())
files[relative] = {
type: 'file',
mode: stat.mode & 0o777,
sha256: sha256(await fs.readFile(filename)),
};
else if (stat.isSymbolicLink()) {
const link = await fs.readlink(filename);
const boundary = roots[prefix.split('/')[0]];
if (
path.isAbsolute(link) ||
!inside(boundary, path.resolve(path.dirname(filename), link)) ||
!inside(await fs.realpath(boundary), await fs.realpath(filename))
)
throw new Error(`Bundle link escapes its payload: ${relative}`);
files[relative] = { type: 'link', link };
} else throw new Error(`Unsupported bundle entry: ${relative}`);
}
}
for (const [name, root] of Object.entries(roots)) await visit(root, name);
return files;
}
async function verifyPayload(selected, manifest, target) {
assertCompatible(manifest, target);
for (const name of ['data', '.tmp', '.git', '.env', 'static']) {
if (
await fs.lstat(path.join(selected.source, name)).catch((error) => {
if (error.code !== 'ENOENT') throw error;
return null;
})
)
throw new Error(`Reserved installation path in bundle: ${name}`);
}
const actual = await inventory({
source: selected.source,
static: selected.static,
});
const names = Object.keys(actual).sort(),
expected = Object.keys(manifest.files).sort();
if (
JSON.stringify(names) !== JSON.stringify(expected) ||
names.some(
(name) =>
JSON.stringify(actual[name]) !== JSON.stringify(manifest.files[name]),
)
)
throw new Error(
'Platform bundle files or dependency checksums do not match',
);
if (
sha256(await fs.readFile(path.join(selected.source, 'pnpm-lock.yaml'))) !==
manifest.lockfileSha256
)
throw new Error('Platform bundle lockfile mismatch');
await fs.access(
path.join(selected.source, 'node_modules/sqlite3/package.json'),
);
}
async function smokeDependencies(source, signal) {
// This probe is installed verifier code, never a verifier supplied by an archive.
const probe = `
const path = require('node:path'),fs = require('node:fs');
const root = fs.realpathSync(process.cwd());
const r = require('node:module').createRequire(path.join(root,'package.json'));
for (const name of ['sqlite3','sequelize','cron-parser','@grpc/grpc-js','undici']) {
if (!fs.realpathSync(r.resolve(name)).startsWith(root + path.sep)) throw new Error('Dependency outside staged payload: '+name);
r(name);
}
const sqlite=r('sqlite3'), db=new sqlite.Database(':memory:');
db.serialize(()=>{db.run('CREATE TABLE verify_bundle(value INTEGER)');db.run('INSERT INTO verify_bundle VALUES(42)');
db.get('SELECT value FROM verify_bundle',(err,row)=>{if(err||row.value!==42){console.error(err||'SQLite readback failed');process.exitCode=1;}db.close();});});
`;
// Match the server's normal runtime. Node 20.18's built-in HTTP parser needs
// WebAssembly, which --jitless disables; only archive workers need that flag.
await execute(process.execPath, [...serverRuntimeArgs(process), '-e', probe], {
cwd: source,
env: { ...process.env, NODE_PATH: '', NODE_OPTIONS: '' },
// ARM1176 devices need the same startup allowance as the backend probe.
// Keep this bounded and cancellable without forcing an unnecessary repair.
timeout: nodeRuntimeArgs().length ? 300000 : 30000,
signal,
});
}
async function extractArchive(archive, destination) {
const entries = new Map(),
links = new Set();
let unpacked = 0,
invalid;
await tar.t({
file: archive,
strict: true,
onReadEntry(entry) {
try {
const name = entry.path.replace(/\/$/, '');
if (
!name ||
name.includes('\\') ||
/[\x00-\x1f\x7f]/.test(name) ||
name.split('/').some((p) => !p || p === '.' || p === '..') ||
!(name === 'qinglong' || name.startsWith('qinglong/')) ||
entries.has(name) ||
!['File', 'OldFile', 'Directory', 'SymbolicLink'].includes(entry.type)
)
throw new Error(`Unsafe bundle archive entry: ${name}`);
unpacked += entry.size;
if (unpacked > 2 * 1024 ** 3 || entries.size > 200000)
throw new Error('Bundle archive exceeds extraction limits');
entries.set(name, entry.type);
if (entry.type === 'SymbolicLink') {
if (
path.posix.isAbsolute(entry.linkpath) ||
entry.linkpath.includes('\\') ||
!inside(
'/qinglong',
path.posix.resolve('/', path.posix.dirname(name), entry.linkpath),
)
)
throw new Error('Unsafe bundle symlink');
links.add(name);
}
} catch (error) {
invalid ||= error;
}
},
});
if (invalid) throw invalid;
for (const name of entries.keys()) {
let parent = path.posix.dirname(name);
while (parent !== '.') {
if (links.has(parent))
throw new Error('Archive writes through a symlink');
parent = path.posix.dirname(parent);
}
}
await tar.x({
file: archive,
cwd: destination,
strict: true,
preservePaths: false,
noChown: true,
umask: 0,
});
}
async function stageBundle({
root,
tmp,
env,
run,
signal,
onProgress,
verifyStatic,
download,
probe = smokeDependencies,
prestart = prestartBackend,
repair = repairDependencies,
}) {
let local = env.QL_UPGRADE_FILE;
if (!local) {
const current = JSON.parse(
await fs
.readFile(path.join(root, 'package.json'), 'utf8')
.catch((error) => {
if (error.code !== 'ENOENT') throw error;
return '{}';
}),
);
if (current.qinglongUpgradeFormat !== 2) return null; // One-time compatibility path for older releases.
}
const target = await runtimeTarget();
const branch = ['develop', 'debian-dev'].includes(env.QL_BRANCH)
? 'develop'
: 'master';
const workspace = await fs.mkdtemp(path.join(tmp, 'upgrade-'));
const report = onProgress || (async () => {});
try {
const archive = path.join(workspace, 'bundle.tar.gz');
let expectedCommit;
if (local) {
if (!path.isAbsolute(local))
throw new Error('Offline bundle path must be absolute');
// Snapshot the local input so replacing it cannot race validation/extraction.
await fs.copyFile(local, archive);
} else {
const indexUrl =
env.QL_UPGRADE_INDEX_URL ||
`https://github.com/whyour/qinglong/releases/download/upgrade-${branch}/latest.json`;
const checkUrl = (url) => {
const parsed = new URL(url);
if (
parsed.protocol !== 'https:' &&
!(
parsed.protocol === 'http:' &&
['127.0.0.1', '[::1]'].includes(parsed.hostname)
)
)
throw new Error('Upgrade URLs require HTTPS');
};
checkUrl(indexUrl);
const indexFile = path.join(workspace, 'index.json');
await run('curl', [
'--fail',
'--silent',
'--show-error',
'--location',
'--proto',
new URL(indexUrl).protocol === 'http:' ? '=http,https' : '=https',
'--proto-redir',
'=https',
'--header',
'Cache-Control: no-cache',
'--connect-timeout',
'10',
'--max-time',
'30',
'--max-filesize',
'1048576',
'--output',
indexFile,
indexUrl,
]);
const index = JSON.parse(await fs.readFile(indexFile, 'utf8'));
const entry = index.packages?.[targetKey(target)];
if (
index.format !== 2 ||
!/^[a-f0-9]{40}$/.test(index.sourceCommit) ||
!entry ||
!Number.isSafeInteger(entry.size) ||
entry.size <= 0 ||
entry.size > 512 * 1024 ** 2 ||
!/^[a-f0-9]{64}$/.test(entry.sha256)
)
throw new Error(`No verified update bundle for ${targetKey(target)}`);
checkUrl(entry.url);
expectedCommit = index.sourceCommit;
await report({
phase: 'downloading',
artifact: 'qinglong-bundle',
downloadedBytes: 0,
totalBytes: entry.size,
percent: 0,
});
await download(
run,
archive,
entry.url,
'qinglong-bundle',
report,
true,
entry,
);
}
if ((await fs.stat(archive)).size > 512 * 1024 ** 2)
throw new Error('Bundle is too large');
await report({
phase: 'extracting',
artifact: 'qinglong-bundle',
percent: null,
});
await extractArchive(archive, workspace);
const incoming = path.join(workspace, 'qinglong');
const manifest = JSON.parse(
await fs.readFile(path.join(incoming, 'bundle.json'), 'utf8'),
);
if (expectedCommit && manifest.sourceCommit !== expectedCommit)
throw new Error('Update index and bundle commit differ');
const selected = {
source: path.join(workspace, `qinglong-${branch}`),
static: path.join(workspace, `qinglong-static-${branch}`),
};
await fs.rename(path.join(incoming, 'source'), selected.source);
await fs.rename(path.join(incoming, 'static'), selected.static);
await report({ phase: 'verifying', percent: null });
await verifyPayload(selected, manifest, target);
const build = await verifyStatic(selected.source, selected.static, env);
if (build.sourceCommit !== manifest.sourceCommit)
throw new Error('Bundle and build commit differ');
await report({ phase: 'dependencies', percent: null });
let dependencyFiles;
const testCandidate = async () => {
await probe(selected.source, signal);
await report({ phase: 'prestarting', percent: null });
return prestart(selected, { target, signal });
};
try {
await testCandidate();
} catch (error) {
signal?.throwIfAborted();
if (!dependencyFailure(error)) throw error;
if (local && env.QL_UPGRADE_REPAIR_DEPS !== '1')
throw new Error(
`Offline dependency check failed; no network access attempted. Use --repair-deps to allow one repair. ${error.message}`,
);
await report({ phase: 'repairing', percent: null });
await repair(selected.source, { target, signal, env });
const repaired = await inventory({
source: selected.source,
static: selected.static,
});
dependencyFiles = Object.fromEntries(
Object.entries(repaired).filter(([name]) =>
name.startsWith('source/node_modules/'),
),
);
// A repair may change only dependencies, never bless changed application code.
await verifyPayload(
selected,
manifestWithDependencies(manifest, dependencyFiles),
target,
);
await verifyStatic(selected.source, selected.static, env);
await testCandidate(); // Exactly one repair; this failure propagates.
}
await fs.writeFile(
path.join(workspace, 'prestart.json'),
JSON.stringify({
format: 1,
sourceCommit: manifest.sourceCommit,
manifestSha256: sha256(Buffer.from(JSON.stringify(manifest))),
node: process.version,
execPath: process.execPath,
completedAt: Date.now(),
...(dependencyFiles ? { dependencyFiles } : {}),
}),
{ flag: 'wx', mode: 0o600 },
);
await fs.writeFile(
path.join(workspace, 'bundle.json'),
JSON.stringify(manifest),
{ flag: 'wx', mode: 0o600 },
);
await fs.writeFile(
path.join(workspace, 'ready.json'),
JSON.stringify({
...selected,
root: path.resolve(root),
sourceCommit: manifest.sourceCommit,
dependenciesChanged: true,
bundle: true,
}),
{ flag: 'wx', mode: 0o600 },
);
const pointer = path.join(workspace, 'pointer.json');
await fs.writeFile(
pointer,
JSON.stringify({ directory: path.basename(workspace) }),
{ flag: 'wx', mode: 0o600 },
);
signal?.throwIfAborted();
const readyPointer = path.join(tmp, `upgrade-ready-${branch}.json`);
const previous = await fs
.readFile(readyPointer, 'utf8')
.catch(() => 'null');
await fs.rename(pointer, readyPointer);
try {
const old = JSON.parse(previous)?.directory;
if (
/^upgrade-[A-Za-z0-9_-]+$/.test(old) &&
old !== path.basename(workspace)
)
await fs.rm(path.join(tmp, old), { recursive: true, force: true });
} catch {
/* Committed readiness must survive cleanup failure. */
}
return selected;
} catch (error) {
await fs.rm(workspace, { recursive: true, force: true });
throw error;
}
}
function manifestWithDependencies(manifest, dependencyFiles) {
if (!dependencyFiles) return manifest;
if (
typeof dependencyFiles !== 'object' ||
Array.isArray(dependencyFiles) ||
!Object.keys(dependencyFiles).length ||
Object.keys(dependencyFiles).some(
(name) => !name.startsWith('source/node_modules/'),
)
)
throw new Error('Invalid repaired dependency inventory');
return {
...manifest,
files: {
...Object.fromEntries(
Object.entries(manifest.files).filter(
([name]) => !name.startsWith('source/node_modules/'),
),
),
...dependencyFiles,
},
};
}
async function verifyStagedBundle(selected) {
const manifest = JSON.parse(
await fs.readFile(
path.join(path.dirname(selected.source), 'bundle.json'),
'utf8',
),
);
const prestart = JSON.parse(
await fs.readFile(
path.join(path.dirname(selected.source), 'prestart.json'),
'utf8',
),
);
if (
prestart.format !== 1 ||
prestart.sourceCommit !== manifest.sourceCommit ||
prestart.manifestSha256 !== sha256(Buffer.from(JSON.stringify(manifest))) ||
prestart.node !== process.version ||
prestart.execPath !== process.execPath ||
!Number.isSafeInteger(prestart.completedAt)
)
throw new Error(
'Upgrade prestart receipt is missing or stale; prepare the update again',
);
await verifyPayload(
selected,
manifestWithDependencies(manifest, prestart.dependencyFiles),
await runtimeTarget(),
);
await smokeDependencies(selected.source);
}
module.exports = {
nodeRuntimeArgs,
atLeast,
runtimeTarget,
targetKey,
assertCompatible,
inventory,
verifyPayload,
smokeDependencies,
extractArchive,
stageBundle,
verifyStagedBundle,
sha256,
};
+38
View File
@@ -0,0 +1,38 @@
import path from 'node:path';
import type { LocalContext } from '../runtime/context';
import { checkedProcess } from '../runtime/process';
// Preserve the installation's configured global prefix, without resolving new
// registry packages or replacing the user's persisted script dependencies.
export async function installRuntimeTools(context: LocalContext): Promise<void> {
const options = { cwd: context.root, env: context.env };
const result = await checkedProcess('npm', ['prefix', '--global'], {
...options,
capture: true,
});
const prefix = result.stdout.trim();
if (!path.isAbsolute(prefix) || prefix.includes('\n'))
throw new Error('npm returned an invalid global prefix.');
await checkedProcess(
process.execPath,
[
path.join(context.root, 'scripts/install-runtime-tools.cjs'),
'--archive',
path.join(context.root, 'static/runtime-tools.tgz'),
'--proof',
path.join(context.root, 'static/runtime-tools-proof.json'),
'--prefix',
prefix,
],
options,
);
context.env.PATH = [path.join(prefix, 'bin'), context.env.PATH]
.filter(Boolean)
.join(path.delimiter);
context.env.NODE_PATH = [
path.join(prefix, 'lib/node_modules'),
context.env.NODE_PATH,
]
.filter(Boolean)
.join(path.delimiter);
}
+27 -59
View File
@@ -11,21 +11,13 @@ 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,
env?: NodeJS.ProcessEnv,
): Promise<unknown>;
verifyUpgrade(source: string, staticRoot: string): Promise<unknown>;
waitForHealth(
env: NodeJS.ProcessEnv,
timeout?: number,
signal?: AbortSignal,
): Promise<void>;
withUpgradeLock<T>(
tmp: string,
operation: () => Promise<T>,
env?: NodeJS.ProcessEnv,
): Promise<T>;
withUpgradeLock<T>(tmp: string, operation: () => Promise<T>): Promise<T>;
stageUpgrade(options: {
root: string;
tmp: string;
@@ -33,12 +25,11 @@ const artifacts = require('./upgradeArtifacts.cjs') as {
mirror: 'github' | 'gitee';
signal?: AbortSignal;
run(program: string, args: string[], capture?: boolean): Promise<unknown>;
install(source: string): Promise<void>;
install(source: string, staticRoot: string, sourceCommit: string): Promise<void>;
}): Promise<{ source: string; static: string }>;
selectedUpgrade(
tmp: string,
branch: string,
env?: NodeJS.ProcessEnv,
): Promise<{ source: string; static: string }>;
};
@@ -78,20 +69,18 @@ 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),
}),
context.env,
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, staticRoot, sourceCommit) =>
installPanelDependencies(context, source, staticRoot, sourceCommit),
}),
);
}
@@ -101,13 +90,12 @@ 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, recover?: boolean): Promise<unknown>;
start(context: LocalContext): Promise<unknown>;
}
// All old entries stay on the same filesystem until service start succeeds.
@@ -202,7 +190,7 @@ export async function replaceAndReload(
if (item.existed) await fs.rename(item.backup, item.target);
}
try {
await lifecycle.start(context, true);
await lifecycle.start(context);
} catch {
throw new Error(
translate(
@@ -272,7 +260,7 @@ export async function reloadPanel(
const staticRoot = selected.static;
await validateTree(source, context.env);
await validateTree(staticRoot, context.env);
await artifacts.verifyUpgrade(source, staticRoot, context.env);
await artifacts.verifyUpgrade(source, staticRoot);
const entries = await fs.readdir(source);
const reserved = new Set(['data', '.tmp', 'static', '.git', '.env']);
const relativeData = path.relative(context.root, context.data);
@@ -297,35 +285,15 @@ export async function reloadPanel(
if (selection)
replacements.push({ source: selection.source, target: selection.target });
try {
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,
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;
},
}),
);
} finally {
if (selection)
+104 -419
View File
@@ -7,41 +7,11 @@ 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, env = process.env) {
async function manifestAt(staticRoot) {
const manifest = JSON.parse(
await fs.readFile(path.join(staticRoot, 'build-info.json'), 'utf8'),
);
@@ -54,20 +24,22 @@ async function manifestAt(staticRoot, env = process.env) {
typeof manifest.files !== 'object' ||
Array.isArray(manifest.files)
)
throw new Error(upgradeMessage('构建清单无效。', env));
throw new Error('Invalid or dirty build manifest / 构建清单无效。');
for (const required of ['build/app.js', 'dist/index.html'])
if (!Object.hasOwn(manifest.files, required))
throw new Error(upgradeMessage('缺少构建产物: %s', env, required));
throw new Error(`Missing build output / 缺少构建产物: ${required}`);
return manifest;
}
async function verifyUpgrade(source, staticRoot, env = process.env) {
const manifest = await manifestAt(staticRoot, env);
async function verifyUpgrade(source, staticRoot) {
const manifest = await manifestAt(staticRoot);
if (
hash(await fs.readFile(path.join(source, 'pnpm-lock.yaml'))) !==
manifest.lockfileSha256
)
throw new Error(upgradeMessage('源码与构建依赖不一致。', env));
throw new Error(
'Source and build lockfiles differ / 源码与构建依赖不一致。',
);
await fs.access(path.join(source, 'package.json'));
await fs.access(path.join(source, 'sample/config.sample.sh'));
const files = {};
@@ -78,7 +50,8 @@ async function verifyUpgrade(source, staticRoot, env = process.env) {
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(upgradeMessage('构建包含非法文件: %s', env, name));
else
throw new Error(`Unsupported build entry / 构建包含非法文件: ${name}`);
}
}
await visit(staticRoot);
@@ -88,11 +61,13 @@ async function verifyUpgrade(source, staticRoot, env = process.env) {
JSON.stringify(actual) !== JSON.stringify(expected) ||
actual.some((name) => files[name] !== manifest.files[name])
)
throw new Error(upgradeMessage('构建文件缺失、损坏或混入旧文件。', env));
throw new Error(
'Build checksum mismatch / 构建文件缺失、损坏或混入旧文件。',
);
return manifest;
}
async function withUpgradeLock(tmp, operation, env = process.env) {
async function withUpgradeLock(tmp, operation) {
await fs.mkdir(tmp, { recursive: true });
const lock = path.join(tmp, 'upgrade.lock');
const token = randomUUID();
@@ -148,7 +123,9 @@ async function withUpgradeLock(tmp, operation, env = process.env) {
}
}
if (!stale)
throw new Error(upgradeMessage('已有更新或重载正在执行。', env));
throw new Error(
'Another upgrade is running / 已有更新或重载正在执行。',
);
// 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.
@@ -173,223 +150,31 @@ async function withUpgradeLock(tmp, operation, env = process.env) {
}
}
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 () => {});
async function stageUpgrade({ root, tmp, env, mirror, run, install, signal }) {
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://codeload.github.com/whyour/${repository}/zip/${ref}`
? `https://github.com/whyour/${repository}/archive/${ref}.zip`
: `https://gitee.com/whyour/${repository}/repository/archive/${ref}.zip`;
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,
});
await run('curl', [
'--fail',
'--location',
'--proto',
'=https',
'--proto-redir',
'=https',
'--connect-timeout',
'30',
'--max-time',
'600',
'--output',
archive,
url,
]);
const listing = await run('unzip', ['-Z1', archive], true);
const expected = `${repository}-${ref}`;
const names = listing.stdout.split('\n').filter(Boolean);
@@ -404,30 +189,21 @@ async function stageUpgrade({
name.split('/')[0] !== expected,
)
)
throw new Error(upgradeMessage('升级归档路径无效。', env));
throw new Error('Invalid archive paths / 升级归档路径无效。');
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(upgradeMessage('升级归档包含符号链接。', env));
throw new Error('Archive contains symlinks / 升级归档包含符号链接。');
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 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 staticRoot = await extract('qinglong-static', branch);
const manifest = await manifestAt(staticRoot);
const downloadedSource = await extract('qinglong', manifest.sourceCommit);
const source = path.join(workspace, `qinglong-${branch}`);
await fs.rename(downloadedSource, source);
await report({ phase: 'verifying', percent: null });
await verifyUpgrade(source, staticRoot, env);
await verifyUpgrade(source, staticRoot);
let dependenciesChanged = false;
for (const name of ['package.json', 'pnpm-lock.yaml']) {
const current = await fs
@@ -444,10 +220,9 @@ async function stageUpgrade({
)
dependenciesChanged = true;
if (dependenciesChanged) {
await report({ phase: 'dependencies', percent: null });
await install(source);
await install(source, staticRoot, manifest.sourceCommit);
await fs.access(path.join(source, 'node_modules'));
await verifyUpgrade(source, staticRoot, env);
await verifyUpgrade(source, staticRoot);
}
signal?.throwIfAborted();
const selected = { source, static: staticRoot };
@@ -455,7 +230,6 @@ async function stageUpgrade({
path.join(workspace, 'ready.json'),
JSON.stringify({
...selected,
root: path.resolve(root),
sourceCommit: manifest.sourceCommit,
dependenciesChanged,
}),
@@ -495,12 +269,7 @@ async function stageUpgrade({
}
}
async function selectedUpgrade(
tmp,
branch,
env = process.env,
{ metadataOnly = false } = {},
) {
async function selectedUpgrade(tmp, branch) {
const pointer = JSON.parse(
await fs.readFile(path.join(tmp, `upgrade-ready-${branch}.json`), 'utf8'),
);
@@ -508,7 +277,7 @@ async function selectedUpgrade(
typeof pointer.directory !== 'string' ||
!/^upgrade-[A-Za-z0-9_-]+$/.test(pointer.directory)
)
throw new Error(upgradeMessage('暂存升级指针无效。', env));
throw new Error('Invalid staged upgrade pointer / 暂存升级指针无效。');
const workspace = path.join(tmp, pointer.directory);
const selected = {
source: path.join(workspace, `qinglong-${branch}`),
@@ -517,48 +286,22 @@ async function selectedUpgrade(
for (const directory of [workspace, selected.source, selected.static]) {
const stat = await fs.lstat(directory);
if (!stat.isDirectory() || stat.isSymbolicLink())
throw new Error(upgradeMessage('暂存目录无效。', env));
throw new Error('Invalid staging directory / 暂存目录无效。');
}
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(upgradeMessage('暂存升级就绪记录不匹配。', env));
const manifest = metadataOnly
? await manifestAt(selected.static, env)
: await verifyUpgrade(selected.source, selected.static, env);
throw new Error('Invalid upgrade readiness / 暂存升级就绪记录不匹配。');
const manifest = await verifyUpgrade(selected.source, selected.static);
if (ready.sourceCommit !== manifest.sourceCommit)
throw new Error(upgradeMessage('暂存升级就绪记录不匹配。', env));
if (ready.bundle && !metadataOnly)
await require('./platformBundle.cjs').verifyStagedBundle(selected);
throw new Error('Invalid upgrade readiness / 暂存升级就绪记录不匹配。');
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, env = process.env) {
async function replaceAndReload(replacements, lifecycle) {
const changed = [];
let attemptedStart = false;
lifecycle.signal?.throwIfAborted();
@@ -606,7 +349,7 @@ async function replaceAndReload(replacements, lifecycle, env = process.env) {
} 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(true);
if (attemptedStart) await lifecycle.stop();
for (const item of changed.reverse()) {
await fs.rm(item.target, { recursive: true, force: true });
if (item.existed) {
@@ -630,7 +373,7 @@ async function replaceAndReload(replacements, lifecycle, env = process.env) {
await fs
.rm(item.backup, { recursive: true, force: true })
.catch(() =>
console.error(upgradeMessage('保留备份: %s', env, item.backup)),
console.error(`Retained backup / 保留备份: ${item.backup}`),
);
}
@@ -642,9 +385,8 @@ async function reloadSystem({
config,
env,
lifecycle,
additionalReplacements = [],
}) {
const selected = await selectedUpgrade(tmp, releaseBranch(env), env);
const selected = await selectedUpgrade(tmp, releaseBranch(env));
const reserved = new Set(['data', '.tmp', 'static', '.git', '.env']);
const relative = path.relative(root, data);
if (relative && !relative.startsWith('..') && !path.isAbsolute(relative))
@@ -662,8 +404,7 @@ async function reloadSystem({
target: path.join(config, 'config.sample.sh'),
},
);
replacements.push(...additionalReplacements);
await replaceAndReload(replacements, lifecycle, env);
await replaceAndReload(replacements, lifecycle);
// Cleanup after a committed installation must not report a failed update.
await fs
.rm(path.join(tmp, `upgrade-ready-${releaseBranch(env)}.json`), {
@@ -675,16 +416,7 @@ async function reloadSystem({
.catch(() => {});
}
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) {
async function waitForHealth(env, timeout = 60000, signal) {
const port = Number(env.QlPort || 5700);
let base = env.QlBaseUrl || '';
if (base && !base.startsWith('/')) base = `/${base}`;
@@ -740,7 +472,7 @@ async function waitForHealth(env, timeout = startupHealthTimeout(), signal) {
}
}
}
throw new Error(upgradeMessage('后端启动检查失败。', env));
throw new Error('Backend failed health check / 后端启动检查失败。');
}
function runProcess(env, program, args, capture = false, signal) {
@@ -821,7 +553,13 @@ async function withLifecycleHooks(root, tmp, env, operation) {
case "$1" in
stop) delete_pm2 ;;
start) reload_pm2 ;;
install) npm_install_2 "$2"; exit "$exit_status" ;;
install)
exit_status=0
npm_install_2 "$2" "$3" "$4"
hook_install_status=$?
[[ "$hook_install_status" -eq 0 ]] || exit "$hook_install_status"
exit "$exit_status"
;;
*) printf '%s\\n' 'Unknown lifecycle operation' >&2; exit 2 ;;
esac
`,
@@ -830,18 +568,9 @@ 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(
lifecycleEnv,
env,
'bash',
['--', snapshot, operation, ...args],
false,
@@ -852,7 +581,9 @@ esac
await fs
.rm(directory, { recursive: true, force: true })
.catch(() =>
console.error(upgradeMessage('保留生命周期快照: %s', env, directory)),
console.error(
`Retained lifecycle snapshot / 保留生命周期快照: ${directory}`,
),
);
}
}
@@ -870,95 +601,50 @@ async function main() {
);
const run = (program, args, capture) =>
runProcess(env, program, args, capture, controller.signal);
await withUpgradeLock(
tmp,
() =>
withLifecycleHooks(root, tmp, env, async (hook) => {
let job;
const lifecycle = {
signal: controller.signal,
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,
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,
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,
60000,
recover ? undefined : controller.signal,
);
} else await apply();
console.log(upgradeMessage('更新及启动检查成功...', env));
}),
env,
},
};
if (process.argv[2] === 'update') {
await stageUpgrade({
root,
tmp,
env,
mirror: process.argv[3],
run,
signal: controller.signal,
install: (source, staticRoot, sourceCommit) =>
hook('install', [source, staticRoot, sourceCommit], 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('更新及启动检查成功...');
}),
);
}
module.exports = {
downloadWithProgress,
upgradeMessage,
releaseBranch,
verifyUpgrade,
withUpgradeLock,
@@ -966,7 +652,6 @@ module.exports = {
selectedUpgrade,
replaceAndReload,
reloadSystem,
startupHealthTimeout,
waitForHealth,
runProcess,
withLifecycleHooks,
@@ -974,6 +659,6 @@ module.exports = {
};
if (require.main === module)
main().catch((error) => {
console.error(upgradeMessage('更新失败: %s', process.env, error.message));
console.error(`更新失败: ${error.message}`);
process.exitCode = 1;
});
@@ -1,396 +0,0 @@
const fs = require('node:fs/promises');
const path = require('node:path');
const { spawn } = require('node:child_process');
const crypto = require('node:crypto');
const os = require('node:os');
const { serverRuntimeArgs } = require('./nodeRuntime.cjs');
function probeLimits(target) {
const small = target.arch === 'armv6';
return { heapMb: small ? 128 : 192, timeoutMs: small ? 300000 : 60000 };
}
function dependencyFailure(error) {
const text = `${error.message || ''}\n${error.stack || ''}\n${
error.stderr || ''
}`;
if (error.signal || /timed out|heap out of memory|ENOMEM/i.test(text))
return false;
if (
/ERR_DLOPEN_FAILED/.test(`${error.code} ${text}`) &&
/node_modules[/\\]/.test(text)
)
return true;
const missing = /Cannot find (?:module|package) ['"]([^'"]+)['"]/.exec(
text,
)?.[1];
const importer = /Require stack:\r?\n-\s+([^\r\n]+)/.exec(text)?.[1];
const inDependencies = (filename) =>
/(?:^|[/\\])node_modules[/\\]/.test(filename || '');
// Node reports a missing package entry as an absolute path and missing
// package-internal imports as relative paths. Neither is an app source error.
const dependencyMissing =
missing &&
((!missing.startsWith('.') &&
!path.isAbsolute(missing) &&
!missing.startsWith('node:')) ||
inDependencies(missing) ||
inDependencies(importer));
// Missing backend files, bad config and database errors must never trigger npm.
return (
(/MODULE_NOT_FOUND/.test(`${error.code} ${text}`) &&
Boolean(dependencyMissing)) ||
(/Could not locate the bindings file/.test(text) &&
/node_modules[/\\]/.test(text))
);
}
async function prestartBackend(
selected,
{ target, signal, limits = probeLimits(target) },
) {
signal?.throwIfAborted();
const workspace = await fs.mkdtemp(
path.join(path.dirname(selected.source), 'prestart-'),
);
try {
const data = path.join(workspace, 'data');
for (const name of ['db', 'config', 'syslog', 'log', 'scripts', 'upload'])
await fs.mkdir(path.join(data, name), { recursive: true });
// Copy only backend code/metadata, never all frontend assets or node_modules.
await fs.mkdir(path.join(workspace, 'static'));
await fs.cp(
path.join(selected.static, 'build'),
path.join(workspace, 'static/build'),
{ recursive: true },
);
await fs.copyFile(
path.join(selected.static, 'build-info.json'),
path.join(workspace, 'static/build-info.json'),
);
await fs.copyFile(
path.join(selected.source, 'version.yaml'),
path.join(workspace, 'version.yaml'),
);
await fs.symlink(
path.join(selected.source, 'node_modules'),
path.join(workspace, 'node_modules'),
'dir',
);
await fs.writeFile(path.join(workspace, '.env'), '', { mode: 0o600 });
const entry = path.join(
workspace,
'static/build/bootstrap/upgradeProbe.js',
);
await fs.access(entry);
const expected = JSON.parse(
await fs.readFile(path.join(selected.static, 'build-info.json')),
);
const backendSha256 = crypto
.createHash('sha256')
.update(await fs.readFile(path.join(selected.static, 'build/app.js')))
.digest('hex');
const version = (
await fs.readFile(path.join(selected.source, 'version.yaml'), 'utf8')
).match(
/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?)/m,
)?.[1];
// An allowlist prevents NODE_OPTIONS, startup hooks, real data paths and
// package-manager settings from escaping into the disposable backend.
const env = {
PATH: process.env.PATH,
LANG: process.env.LANG || 'C.UTF-8',
HOME: workspace,
TMPDIR: workspace,
NODE_ENV: 'production',
QL_DIR: workspace,
QL_DATA_DIR: data,
QL_UPGRADE_PROBE: '1',
BACK_PORT: '0',
GRPC_PORT: '0',
BIND_HOST: '127.0.0.1',
BIND_HOST_GRPC: '127.0.0.1',
QlBaseUrl: '/',
NODE_PATH: '',
NODE_OPTIONS: '',
UV_THREADPOOL_SIZE: '1',
};
return await new Promise((resolve, reject) => {
let report,
failure,
output = '',
finished = false,
killTimer;
// No PM2/cluster workers. OpenSSL key generation is sequential in this
// one backend process. Keep normal V8 mode for Node 20's HTTP/WASM support.
const child = spawn(
process.execPath,
[...serverRuntimeArgs(), `--max-old-space-size=${limits.heapMb}`, entry],
{
cwd: workspace,
env,
detached: process.platform !== 'win32',
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
},
);
try {
if (child.pid) os.setPriority(child.pid, 10);
} catch {}
const kill = (signalName) => {
try {
if (process.platform !== 'win32' && child.pid)
process.kill(-child.pid, signalName);
else child.kill(signalName);
} catch (error) {
if (error.code !== 'ESRCH') failure ||= error;
}
};
const stop = (error) => {
failure ||= error;
kill('SIGTERM');
killTimer ||= setTimeout(() => kill('SIGKILL'), 1500);
};
const abort = () =>
stop(signal.reason || new Error('Upgrade prestart aborted'));
const timer = setTimeout(
() => stop(new Error('Upgrade prestart timed out')),
limits.timeoutMs,
);
child.stdout.on('data', (chunk) => {
output = (output + chunk).slice(-32768);
});
child.stderr.on('data', (chunk) => {
output = (output + chunk).slice(-32768);
});
child.on('message', (message) => {
if (message?.type === 'upgrade-probe') report = message;
});
const finish = (error) => {
if (finished) return;
finished = true;
clearTimeout(timer);
clearTimeout(killTimer);
signal?.removeEventListener('abort', abort);
if (error) reject(error);
else resolve(report);
};
child.on('error', (error) => finish(error));
// Descendants can retain our output pipes after their parent exits.
// Reap the group on exit rather than waiting for those pipes to close.
child.once('exit', () => kill('SIGKILL'));
child.on('close', (code, exitSignal) => {
// Reap descendants even when the backend exits unexpectedly.
kill('SIGKILL');
if (failure) return finish(failure);
if (code !== 0 || !report?.ok)
return finish(
Object.assign(
new Error(
report?.message ||
`Upgrade prestart exited: ${exitSignal || code}\n${output}`,
),
{
code: report?.code,
stack: report?.stack || output,
signal: exitSignal,
},
),
);
if (
report.build?.sourceCommit !== expected.sourceCommit ||
report.build?.backendSha256 !== backendSha256 ||
report.build?.version !== version
)
return finish(new Error('Upgrade prestart build identity mismatch'));
finish();
});
signal?.addEventListener('abort', abort, { once: true });
if (signal?.aborted) abort();
});
} finally {
await fs.rm(workspace, { recursive: true, force: true });
}
}
async function repairDependencies(source, { target, signal, env }) {
const workspace = await fs.mkdtemp(
path.join(path.dirname(source), 'repair-'),
);
try {
const pkg = JSON.parse(
await fs.readFile(path.join(source, 'package.json')),
);
if (pkg.packageManager !== 'pnpm@8.3.1')
throw new Error('Unsupported frozen-lockfile package manager');
// Install apart from the verified application: lifecycle scripts cannot
// rewrite source/static files or use the old installation's node_modules.
await fs.writeFile(
path.join(workspace, 'package.json'),
JSON.stringify({ ...pkg, scripts: {} }),
);
await fs.copyFile(
path.join(source, 'pnpm-lock.yaml'),
path.join(workspace, 'pnpm-lock.yaml'),
);
const small = target.arch === 'armv6';
const childEnv = {
PATH: env.PATH || process.env.PATH,
HOME: workspace,
TMPDIR: workspace,
NODE_OPTIONS: `${small ? '--jitless ' : ''}--max-old-space-size=${
small ? 256 : 384
}`,
NODE_PATH: '',
CI: 'true',
npm_config_jobs: '1',
MAKEFLAGS: '-j1',
...(small
? {
npm_config_build_from_source: 'true',
npm_config_force_process_config: 'true',
CFLAGS: '-march=armv6 -mfpu=vfp -mfloat-abi=hard',
CXXFLAGS: '-march=armv6 -mfpu=vfp -mfloat-abi=hard',
}
: {}),
};
// Preserve network routing without inheriting Node/preload/compiler settings.
for (const name of [
'HTTP_PROXY',
'HTTPS_PROXY',
'ALL_PROXY',
'NO_PROXY',
'http_proxy',
'https_proxy',
'all_proxy',
'no_proxy',
])
if (typeof env[name] === 'string') childEnv[name] = env[name];
// Reuse matching installed headers on small devices; never inherit a
// caller's nodedir or accidentally compile against a different Node ABI.
const prefix = path.resolve(path.dirname(process.execPath), '..');
const header = await fs
.readFile(path.join(prefix, 'include/node/node_version.h'), 'utf8')
.catch(() => '');
const headerVersion = ['MAJOR', 'MINOR', 'PATCH']
.map(
(part) =>
header.match(
new RegExp(`#define\\s+NODE_${part}_VERSION\\s+(\\d+)`),
)?.[1],
)
.join('.');
if (headerVersion === process.versions.node) {
childEnv.npm_config_nodedir = prefix;
childEnv.npm_config_force_process_config = 'true';
}
const run = (args) =>
runRepair('pnpm', args, {
cwd: workspace,
env: childEnv,
signal,
timeoutMs: small ? 1800000 : 600000,
});
const version = await run(['--version']);
if (version.trim() !== '8.3.1')
throw new Error('Dependency repair requires existing pnpm 8.3.1');
await run([
'install',
'--prod',
'--frozen-lockfile',
'--ignore-scripts',
'--child-concurrency=1',
'--network-concurrency=2',
]);
await run(['rebuild', 'sqlite3', '@whyour/sqlite3']);
if (
!(await fs.readFile(path.join(source, 'pnpm-lock.yaml'))).equals(
await fs.readFile(path.join(workspace, 'pnpm-lock.yaml')),
)
)
throw new Error('Dependency repair changed the frozen lockfile');
await fs.rm(path.join(source, 'node_modules'), {
recursive: true,
force: true,
});
await fs.rename(
path.join(workspace, 'node_modules'),
path.join(source, 'node_modules'),
);
} finally {
await fs.rm(workspace, { recursive: true, force: true });
}
}
// Bound output, wall time and the entire npm/node-gyp process group.
async function runRepair(program, args, options) {
return new Promise((resolve, reject) => {
options.signal?.throwIfAborted();
let output = '',
stdout = '',
failure,
killTimer;
const child = spawn(program, args, {
cwd: options.cwd,
env: options.env,
detached: process.platform !== 'win32',
stdio: ['ignore', 'pipe', 'pipe'],
});
const kill = (sig) => {
try {
if (child.pid && process.platform !== 'win32')
process.kill(-child.pid, sig);
else child.kill(sig);
} catch {}
};
const stop = (error) => {
failure ||= error;
kill('SIGTERM');
killTimer ||= setTimeout(() => kill('SIGKILL'), 1500);
};
const abort = () =>
stop(options.signal.reason || new Error('Dependency repair aborted'));
const timer = setTimeout(
() => stop(new Error('Dependency repair timed out')),
options.timeoutMs,
);
const capture = (chunk) => {
output = (output + chunk).slice(-32768);
};
child.stdout.on('data', (chunk) => {
stdout = (stdout + chunk).slice(-32768);
capture(chunk);
});
child.stderr.on('data', capture);
const cleanup = () => {
clearTimeout(timer);
clearTimeout(killTimer);
options.signal?.removeEventListener('abort', abort);
kill('SIGKILL');
};
child.on('error', (error) => {
cleanup();
reject(error);
});
child.once('exit', () => kill('SIGKILL'));
child.on('close', (code, signal) => {
cleanup();
if (failure || code !== 0)
reject(
failure ||
new Error(
`Dependency repair failed (${signal || code}): ${output}`,
),
);
else resolve(stdout);
});
options.signal?.addEventListener('abort', abort, { once: true });
if (options.signal?.aborted) abort();
});
}
module.exports = {
prestartBackend,
dependencyFailure,
repairDependencies,
probeLimits,
};
+4 -14
View File
@@ -1,8 +1,5 @@
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.
@@ -30,18 +27,11 @@ export async function findDirectBackendPids(
);
const args = command.split('\0');
if (args.at(-1) === '') args.pop();
// 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]!)))
// 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]!)))
continue;
const script = args[scriptIndex]!;
const script = args[1]!;
if (!script) continue;
const absolute = path.isAbsolute(script)
? script
@@ -214,13 +214,8 @@ async function reconcile(
if (added || removed) {
try {
await api.call('system/notify', 'PUT', {
title: translate(context.env, '%s 订阅同步', namespace),
content: translate(
context.env,
'新增 %s 个任务,删除 %s 个任务',
added,
removed,
),
title: `${namespace} 订阅同步`,
content: `新增 ${added} 个任务,删除 ${removed} 个任务`,
});
} catch {
operationOutput(
+1 -13
View File
@@ -12,12 +12,6 @@ export interface OpenOperation {
anonymous?: boolean;
}
export const openOperations: readonly OpenOperation[] = [
{
name: 'system notification-logs',
method: 'GET',
path: 'system/notify-log',
},
{ name: 'task view-list', method: 'GET', path: 'crons/views' },
{
name: 'task view-create',
@@ -416,7 +410,6 @@ 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' },
@@ -485,12 +478,7 @@ export const openOperations: readonly OpenOperation[] = [
path: 'system/config/dependence-clean',
body: 'json',
},
{
name: 'dashboard record',
method: 'POST',
path: 'dashboard/record',
body: 'json',
},
{ name: 'dashboard record', method: 'POST', path: 'dashboard/record', body: 'json' },
{ name: 'dashboard overview', method: 'GET', path: 'dashboard/overview' },
{ name: 'dashboard trend', method: 'GET', path: 'dashboard/trend' },
{ name: 'dashboard top-time', method: 'GET', path: 'dashboard/top-time' },
+8 -15
View File
@@ -1,13 +1,8 @@
// Migrated from shell/lang/en.sh; the original remains for differential evaluation.
export const english: Record<string, string> = {
'%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.',
'需要双因素验证,请调用 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 升级载荷。':
@@ -157,12 +152,9 @@ 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状态再考虑重试。':
@@ -177,7 +169,8 @@ 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
@@ -1,183 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const os = require('node:os');
const vm = require('node:vm');
const { createRequire } = require('node:module');
const { createContext } = require('../../dist/internal/runtime/context');
const { replaceAndReload } = require('../../dist/internal/maintenance/upgrade');
const runtime = require('../../dist/internal/maintenance/nodeRuntime.cjs');
// Change only runtime selection; execute the compiled operator, actual child
// Node and process discovery, without pretending the host CPU is ARM.
async function operatorFor(arch, armVersion, node) {
const filename = require.resolve('../../dist/internal/maintenance/operator');
const localRequire = createRequire(filename);
const module = { exports: {} };
const flags = runtime.serverRuntimeArgs({
arch,
config: { variables: { arm_version: armVersion } },
versions: { node },
});
vm.runInNewContext(
await fs.readFile(filename, 'utf8'),
{
module,
exports: module.exports,
process,
setTimeout,
clearTimeout,
require(name) {
return name === './nodeRuntime.cjs'
? { ...runtime, serverRuntimeArgs: () => [...flags] }
: localRequire(name);
},
},
{ filename },
);
return { ...module.exports, flags };
}
for (const [arch, arm, node] of [
['arm', 6, '24.18.1'],
['arm', 6, '20.18.1'],
['arm', 6, '22.20.0'],
['arm', 7, '24.18.1'],
['arm64', undefined, '24.18.1'],
['x64', undefined, '24.18.1'],
])
test(`CLI direct startup and stop use compatible arguments: ${arch}/${arm}/${node}`, async (t) => {
const operator = await operatorFor(arch, arm, node);
const root = await fs.realpath(
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-runtime-start-')),
);
const context = createContext(
{ root },
{ PATH: process.platform === 'linux' ? '' : '/usr/bin:/bin', QL_LANG: 'en' },
);
const entry = path.join(context.paths.dir_static, 'build/app.js');
let pid;
t.after(async () => {
if (pid) {
try {
process.kill(pid, 'SIGKILL');
} catch {}
}
await fs.rm(root, { recursive: true, force: true });
});
await fs.mkdir(path.dirname(entry), { recursive: true });
await fs.writeFile(
entry,
`require('fs').writeFileSync(${JSON.stringify(
path.join(root, 'runtime.json'),
)}, JSON.stringify({args:process.execArgv,pid:process.pid}));setInterval(()=>{},1000);`,
);
const started = await operator.startPanel(context);
pid = started.pid;
const report = JSON.parse(
await fs.readFile(path.join(root, 'runtime.json')),
);
assert.equal(started.manager, 'node');
assert.deepEqual(report.args, operator.flags);
assert.equal(report.pid, pid);
await operator.stopPanel(context);
for (let n = 0; n < 100; n++) {
try {
process.kill(pid, 0);
} catch (e) {
if (e.code === 'ESRCH') {
pid = undefined;
break;
}
throw e;
}
await new Promise((resolve) => setTimeout(resolve, 20));
}
assert.equal(
pid,
undefined,
'the exact backend must stop even with Node flags',
);
});
test(
'ARMv6 CLI replacement stops flagged new backend before restoring old backend and data',
{
skip: process.platform !== 'linux',
timeout: 20000,
},
async (t) => {
const operator = await operatorFor('arm', 6, '24.18.1');
const root = await fs.realpath(
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-arm-rollback-')),
);
const context = createContext({ root }, { PATH: '', QL_LANG: 'en' });
const entry = path.join(context.paths.dir_static, 'build/app.js');
const incoming = path.join(root, 'incoming.js'),
journal = path.join(root, 'journal.jsonl');
const record = (version) =>
`const fs=require('fs');fs.appendFileSync(${JSON.stringify(
journal,
)},JSON.stringify({version:${JSON.stringify(
version,
)},pid:process.pid,args:process.execArgv})+'\\n');setInterval(()=>{},1000);`;
await fs.mkdir(path.dirname(entry), { recursive: true });
await fs.mkdir(context.data, { recursive: true });
await fs.writeFile(path.join(root, '.env'), 'KEEP=custom\n', {
mode: 0o600,
});
await fs.writeFile(path.join(context.data, 'keep'), 'old data');
await fs.writeFile(entry, record('old'));
await fs.writeFile(incoming, record('new'));
const pids = [];
t.after(async () => {
for (const pid of pids) {
try {
process.kill(pid, 'SIGKILL');
} catch {}
}
await fs.rm(root, { recursive: true, force: true });
});
pids.push((await operator.startPanel(context)).pid);
await assert.rejects(
replaceAndReload(context, [{ source: incoming, target: entry }], {
stop: (ctx) => operator.stopPanel(ctx),
start: async (ctx, recover) => {
const result = await operator.startPanel(ctx);
pids.push(result.pid);
if (!recover) throw new Error('injected new-version health failure');
},
}),
/injected new-version health failure/,
);
const launches = (await fs.readFile(journal, 'utf8'))
.trim()
.split('\n')
.map(JSON.parse);
assert.deepEqual(
launches.map((x) => x.version),
['old', 'new', 'old'],
);
for (const row of launches) assert.deepEqual(row.args, operator.flags);
for (const row of launches.slice(0, -1))
assert.throws(() => process.kill(row.pid, 0), { code: 'ESRCH' });
process.kill(launches.at(-1).pid, 0);
assert.equal(
await fs.readFile(path.join(root, '.env'), 'utf8'),
'KEEP=custom\n',
);
assert.equal((await fs.stat(path.join(root, '.env'))).mode & 0o777, 0o600);
assert.equal(
await fs.readFile(path.join(context.data, 'keep'), 'utf8'),
'old data',
);
assert.equal(await fs.readFile(entry, 'utf8'), record('old'));
assert.ok(
!(await fs.readdir(path.dirname(entry))).some((x) =>
x.includes('ql-backup'),
),
);
await operator.stopPanel(context);
},
);
+3 -47
View File
@@ -3,46 +3,7 @@ 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');
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);
});
const { findDirectBackendPids } = require('../../dist/internal/runtime/backendProcesses');
test('Linux backend discovery scopes legacy relative and absolute commands to one installation', async (t) => {
const root = await fs.realpath(
@@ -103,10 +64,7 @@ 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;
@@ -265,9 +223,7 @@ 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 });
+76 -6
View File
@@ -46,6 +46,7 @@ async function fixture(t) {
const fs=require('node:fs'),p=require('node:path');
const program=p.basename(process.argv[1]),args=process.argv.slice(2);
fs.appendFileSync(process.env.CALLS,JSON.stringify({program,args,python:process.env.PYTHON_HOME,node:process.env.PNPM_HOME})+'\\n');
if(program==='npm' && args[0]==='prefix') process.stdout.write(p.join(p.dirname(process.env.CALLS),'global-prefix')+'\\n');
if(program==='python3') process.stdout.write('3.12\\n');
if(program==='nginx' && args.includes('-s')) process.exit(1);
`;
@@ -64,6 +65,11 @@ async function fixture(t) {
`#!${process.execPath}\n${program}`,
{ mode: 0o755 },
);
await fs.mkdir(path.join(root, 'scripts'));
await fs.writeFile(
path.join(root, 'scripts/install-runtime-tools.cjs'),
program,
);
const hooks = [];
const registrations = [];
const system = {
@@ -82,7 +88,7 @@ async function fixture(t) {
}
test('bootstrap installs prerequisites, uses the configured data directory and starts services in order', async (t) => {
const { ctx, system, hooks, registrations } = await fixture(t);
const { ctx, system, hooks, base, registrations } = await fixture(t);
await fs.writeFile(path.join(ctx.root, '.env'), 'KEEP=true');
const result = await bootstrapPanel(ctx, false, system);
assert.equal(result.mode, 'install');
@@ -107,6 +113,7 @@ test('bootstrap installs prerequisites, uses the configured data directory and s
process.getuid() === 0 ? 'apt-get' : 'sudo',
process.getuid() === 0 ? 'apt-get' : 'sudo',
'npm',
'install-runtime-tools.cjs',
'python3',
'pip3',
'pm2',
@@ -118,14 +125,23 @@ test('bootstrap installs prerequisites, uses the configured data directory and s
'pm2',
],
);
assert.deepEqual(calls[4].args, [
assert.deepEqual(calls[2].args, ['prefix', '--global']);
assert.deepEqual(calls[3].args, [
'--archive',
path.join(ctx.root, 'static/runtime-tools.tgz'),
'--proof',
path.join(ctx.root, 'static/runtime-tools-proof.json'),
'--prefix',
path.join(base, 'global-prefix'),
]);
assert.deepEqual(calls[5].args, [
'install',
'--prefix',
path.join(ctx.data, 'dep_cache/python3'),
'requests',
]);
assert.deepEqual(calls[6].args, ['-c', system.nginxConfig, '-s', 'reload']);
assert.deepEqual(calls[7].args, ['-c', system.nginxConfig]);
assert.deepEqual(calls[7].args, ['-c', system.nginxConfig, '-s', 'reload']);
assert.deepEqual(calls[8].args, ['-c', system.nginxConfig]);
assert.deepEqual(
calls.slice(-4).map((c) => c.args),
[
@@ -137,7 +153,7 @@ test('bootstrap installs prerequisites, uses the configured data directory and s
);
assert.ok(
calls
.slice(4)
.slice(5)
.every(
(c) =>
c.python === path.join(ctx.data, 'dep_cache/python3') &&
@@ -240,7 +256,9 @@ for (const language of ['zh', 'en', 'unsupported']) {
return true;
},
);
const { runtimeEnvironment } = require('../../dist/internal/maintenance/bootstrap');
const {
runtimeEnvironment,
} = require('../../dist/internal/maintenance/bootstrap');
assert.throws(
() => runtimeEnvironment(ctx, 'bad'),
(error) => {
@@ -255,3 +273,55 @@ for (const language of ['zh', 'en', 'unsupported']) {
assert.deepEqual(await fs.readdir(root), []);
});
}
test('failed reviewed runtime installation stops before service registration and startup', async (t) => {
const { ctx, system, hooks, registrations } = await fixture(t);
await fs.writeFile(path.join(ctx.root, '.env'), 'KEEP=true');
await fs.writeFile(
path.join(ctx.root, 'scripts/install-runtime-tools.cjs'),
'process.exit(19);',
);
await assert.rejects(bootstrapPanel(ctx, false, system), /exit 19|退出码 19/);
assert.deepEqual(registrations, []);
assert.deepEqual(hooks, []);
assert.equal(
await fs.readFile(path.join(ctx.root, '.env'), 'utf8'),
'KEEP=true',
);
const calls = (await fs.readFile(ctx.env.CALLS, 'utf8'))
.trim()
.split('\n')
.map(JSON.parse);
assert.ok(
calls.every(
(call) => !['python3', 'pip3', 'nginx', 'pm2'].includes(call.program),
),
);
});
test('runtime environment preserves the controlled tools prefix and persistent global dependencies', async (t) => {
const { ctx, base } = await fixture(t);
const {
runtimeEnvironment,
} = require('../../dist/internal/maintenance/bootstrap');
const toolsModules = path.join(base, 'global-prefix/lib/node_modules');
const customModules = path.join(base, 'operator-modules');
const env = runtimeEnvironment(
{
...ctx,
env: {
...ctx.env,
NODE_PATH: [toolsModules, customModules].join(path.delimiter),
},
},
'3.12',
);
const modules = env.NODE_PATH.split(path.delimiter);
assert.deepEqual(modules.slice(0, 2), [toolsModules, customModules]);
assert.ok(modules.includes('/usr/local/lib/node_modules'));
assert.ok(
modules.includes(
path.join(ctx.data, 'dep_cache/node/global/5/node_modules'),
),
);
});
+2 -11
View File
@@ -322,10 +322,6 @@ 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)
@@ -333,17 +329,13 @@ 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);require("node:fs").writeSync(3,"T");setInterval(()=>{},1000)';
'process.on("SIGINT",()=>{});process.on("SIGTERM",()=>{});console.log(process.pid);setInterval(()=>{},1000)';
const parent = `require('node:child_process').spawn(process.execPath,['-e',${JSON.stringify(
grandchild,
)}],{stdio:['ignore',1,2,3]});setTimeout(()=>process.exit(0),100);`;
)}],{stdio:['ignore',1,2]});setTimeout(()=>process.exit(0),100);`;
const result = await runProcess(process.execPath, ['-e', parent], {
timeoutMs: 350,
timeoutControl: { marker },
signal: controller.signal,
graceMs: 100,
output: (chunk) => {
const pid = Number(chunk.toString().trim());
@@ -353,7 +345,6 @@ test(
assert.equal(result.code, 124);
assert.equal(result.timedOut, true);
assert.ok(descendant);
assert.equal(fs.existsSync(marker), true);
},
);
+229 -59
View File
@@ -20,40 +20,6 @@ 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'),
@@ -93,6 +59,24 @@ async function stub(context, program, body) {
context.env.PATH = `${bin}:/usr/bin:/bin`;
}
async function stubRuntimeTools(context, record) {
const prefix = path.join(context.root, 'global-prefix');
await stub(
context,
'npm',
`${record}
if(process.argv[2]==='prefix') process.stdout.write(${JSON.stringify(
prefix,
)}+'\\n');`,
);
await fs.mkdir(path.join(context.root, 'scripts'), { recursive: true });
await fs.writeFile(
path.join(context.root, 'scripts/install-runtime-tools.cjs'),
record,
);
return prefix;
}
test('configuration repair preserves custom content and intentionally empty hooks', async (t) => {
const ctx = await fixture(t);
await fs.mkdir(ctx.paths.dir_config, { recursive: true });
@@ -109,29 +93,51 @@ test('configuration repair preserves custom content and intentionally empty hook
assert.deepEqual(await repairConfiguration(ctx), [ctx.paths.file_notify_py]);
});
test('dependency installation selects pnpm or Termux npm argv without shell interpolation', async (t) => {
async function stubPanelDependencies(context, body) {
await fs.mkdir(path.join(context.root, 'scripts'), { recursive: true });
await fs.writeFile(
path.join(context.root, 'scripts/install-panel-dependencies.cjs'),
body,
);
}
test('dependency installation uses the installed helper and preserves root, static and commit arguments', async (t) => {
const ctx = await fixture(t);
const output = path.join(ctx.root, 'calls.jsonl');
ctx.env.CALLS = output;
ctx.env.CALLS = path.join(ctx.root, 'dependencies.jsonl');
const record =
'require("node:fs").appendFileSync(process.env.CALLS, JSON.stringify({args:process.argv.slice(2),cwd:process.cwd()})+"\\n");';
await stub(ctx, 'pnpm', record);
await stub(ctx, 'npm', record);
'require("node:fs").appendFileSync(process.env.CALLS,JSON.stringify({args:process.argv.slice(2),cwd:process.cwd(),termux:process.env.is_termux||""})+"\\n");';
await stubPanelDependencies(ctx, record);
await installPanelDependencies(ctx);
ctx.env.is_termux = '1';
await installPanelDependencies(ctx);
const calls = (await fs.readFile(output, 'utf8'))
const source = path.join(ctx.root, 'source ;$(touch SHOULD_NOT_RUN)');
const staticRoot = path.join(ctx.root, 'static ;$(touch SHOULD_NOT_RUN)');
await fs.mkdir(source);
await installPanelDependencies(ctx, source, staticRoot, sourceCommit);
const calls = (await fs.readFile(ctx.env.CALLS, 'utf8'))
.trim()
.split('\n')
.map(JSON.parse);
assert.deepEqual(
calls.map((c) => c.args),
[
['install', '--loglevel', 'error', '--production'],
['install', '--production', '--no-bin-links'],
],
);
assert.ok(calls.every((c) => c.cwd === ctx.root));
assert.deepEqual(calls, [
{ args: ['--root', ctx.root], cwd: ctx.root, termux: '0' },
{ args: ['--root', ctx.root], cwd: ctx.root, termux: '1' },
{
args: [
'--root',
source,
'--static-dir',
staticRoot,
'--source-commit',
sourceCommit,
],
cwd: source,
termux: '1',
},
]);
for (const directory of [ctx.root, source])
await assert.rejects(fs.stat(path.join(directory, 'SHOULD_NOT_RUN')), {
code: 'ENOENT',
});
});
test('PM2 reload uses installation cwd and exact process-manager arguments', async (t) => {
@@ -310,7 +316,50 @@ test('upgrade staging extracts both archives before publishing readiness and rej
'utf8',
),
),
{ ...staged, root: ctx.root, sourceCommit, dependenciesChanged: false },
{ ...staged, sourceCommit, dependenciesChanged: false },
);
await fs.writeFile(
path.join(ctx.root, 'package.json'),
'{"name":"older-installed"}',
);
ctx.env.CALLS = path.join(ctx.root, 'upgrade-dependencies.jsonl');
await stubPanelDependencies(ctx, 'process.exit(23);');
await assert.rejects(stageUpgrade(ctx, 'github'), /exit 23/);
const ready = path.join(ctx.paths.dir_tmp, 'upgrade-ready-master.json');
assert.deepEqual(JSON.parse(await fs.readFile(ready, 'utf8')), {
directory: path.basename(path.dirname(staged.source)),
});
assert.equal(
await fs.readFile(path.join(ctx.root, 'package.json'), 'utf8'),
'{"name":"older-installed"}',
);
await stubPanelDependencies(
ctx,
'const fs=require("node:fs"),p=require("node:path"),args=process.argv.slice(2);fs.appendFileSync(process.env.CALLS,JSON.stringify({args,cwd:process.cwd()})+"\\n");fs.mkdirSync(p.join(args[args.indexOf("--root")+1],"node_modules"),{recursive:true});',
);
const changedStage = await stageUpgrade(ctx, 'github');
assert.deepEqual(
JSON.parse((await fs.readFile(ctx.env.CALLS, 'utf8')).trim()),
{
args: [
'--root',
changedStage.source,
'--static-dir',
changedStage.static,
'--source-commit',
sourceCommit,
],
cwd: changedStage.source,
},
);
assert.equal(
JSON.parse(
await fs.readFile(
path.join(path.dirname(changedStage.source), 'ready.json'),
'utf8',
),
).dependenciesChanged,
true,
);
await fs.symlink(
'../../outside',
@@ -321,11 +370,11 @@ 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(),
[
path.basename(path.dirname(staged.source)),
path.basename(path.dirname(changedStage.source)),
'upgrade-ready-master.json',
].sort(),
);
@@ -370,8 +419,9 @@ test('check repairs dependencies and notifications, probes loopback and reloads
ctx.env.HTTP_PROXY = 'http://127.0.0.1:1';
const record =
'require("node:fs").appendFileSync(process.env.CALLS,JSON.stringify({program:require("node:path").basename(process.argv[1]),args:process.argv.slice(2)})+"\\n");';
for (const executable of ['npm', 'pnpm', 'pm2'])
await stub(ctx, executable, record);
for (const executable of ['pnpm', 'pm2']) await stub(ctx, executable, record);
const toolsPrefix = await stubRuntimeTools(ctx, record);
await stubPanelDependencies(ctx, record);
await repairConfiguration(ctx);
await fs.writeFile(ctx.paths.file_notify_py, 'custom old notify');
await fs.mkdir(path.join(ctx.env.PM2_HOME, 'logs'), { recursive: true });
@@ -398,11 +448,22 @@ test('check repairs dependencies and notifications, probes loopback and reloads
assert.deepEqual(calls, [
{
program: 'npm',
args: ['i', '-g', 'pnpm@8.3.1', 'pm2', 'ts-node', 'typescript@5'],
args: ['prefix', '--global'],
},
{
program: 'pnpm',
args: ['install', '--loglevel', 'error', '--production'],
program: 'install-runtime-tools.cjs',
args: [
'--archive',
path.join(ctx.root, 'static/runtime-tools.tgz'),
'--proof',
path.join(ctx.root, 'static/runtime-tools-proof.json'),
'--prefix',
toolsPrefix,
],
},
{
program: 'install-panel-dependencies.cjs',
args: ['--root', ctx.root],
},
{ program: 'pm2', args: ['flush'] },
{
@@ -471,6 +532,28 @@ test('repair stops after installer failure before replacing user files or reload
});
});
test('dependency helper failure preserves notifications and prevents a service reload', async (t) => {
const { checkAndRepair } = require('../../dist/internal/maintenance/check');
const ctx = await fixture(t);
await repairConfiguration(ctx);
await fs.writeFile(ctx.paths.file_notify_js, 'keep installed notification');
await stubRuntimeTools(ctx, '');
await stubPanelDependencies(ctx, 'process.exit(19);');
await stub(
ctx,
'pm2',
'require("node:fs").writeFileSync(process.env.QL_DIR+"/unexpected-reload", "bad");',
);
await assert.rejects(checkAndRepair(ctx), /exit 19/);
assert.equal(
await fs.readFile(ctx.paths.file_notify_js, 'utf8'),
'keep installed notification',
);
await assert.rejects(fs.stat(path.join(ctx.root, 'unexpected-reload')), {
code: 'ENOENT',
});
});
test('legacy check workflow and TypeScript repair agree on dependency and notification operations', async (t) => {
const { execFileSync } = require('node:child_process');
const ctx = await fixture(t);
@@ -478,7 +561,8 @@ test('legacy check workflow and TypeScript repair agree on dependency and notifi
const trace = path.join(ctx.root, 'legacy-trace');
const shell = `
t() { :; }
npm() { printf 'npm %s\\n' "$*" >> "$TRACE"; }
npm() { printf 'npm %s\\n' "$*" >> "$TRACE"; printf '%s\\n' "$dir_root/global-prefix"; }
node() { printf 'tools %s\\n' "$*" >> "$TRACE"; }
fix_config() { printf 'repair-config\\n' >> "$TRACE"; }
npm_install_2() { printf 'dependencies %s\\n' "$1" >> "$TRACE"; }
cp() { printf 'copy %s\\n' "$*" >> "$TRACE"; command cp "$@"; }
@@ -502,7 +586,8 @@ test('legacy check workflow and TypeScript repair agree on dependency and notifi
});
const operations = (await fs.readFile(trace, 'utf8')).trim().split('\n');
assert.deepEqual(operations, [
'npm i -g pnpm@8.3.1 pm2 ts-node typescript@5',
'npm prefix --global',
`tools ${ctx.root}/scripts/install-runtime-tools.cjs --archive ${ctx.root}/static/runtime-tools.tgz --proof ${ctx.root}/static/runtime-tools-proof.json --prefix ${ctx.root}/global-prefix`,
'repair-config',
`dependencies ${ctx.root}`,
`copy -fv ${ctx.paths.file_notify_py_sample} ${ctx.paths.file_notify_py}`,
@@ -519,6 +604,91 @@ test('legacy check workflow and TypeScript repair agree on dependency and notifi
);
});
test('legacy check aborts dependency failure before copying notifications or reloading', async (t) => {
const { execFileSync } = require('node:child_process');
const ctx = await fixture(t);
await repairConfiguration(ctx);
await fs.writeFile(ctx.paths.file_notify_js, 'keep legacy notification');
const shell = `
t() { :; }
npm() { printf '%s\\n' "$dir_root/global-prefix"; }
node() { :; }
fix_config() { :; }
npm_install_2() { return 19; }
reload_pm2() { printf unexpected > "$dir_root/unexpected-reload"; }
curl() { printf '<div id="root"></div>{"code":200,"status":"ok"}'; }
. "$CHECK_SOURCE"
`;
assert.throws(
() =>
execFileSync('/bin/bash', ['--noprofile', '--norc', '-c', shell], {
env: {
...ctx.env,
CHECK_SOURCE: path.resolve(__dirname, '../../../shell/check.sh'),
},
stdio: ['ignore', 'pipe', 'pipe'],
}),
(error) => error.status === 19,
);
assert.equal(
await fs.readFile(ctx.paths.file_notify_js, 'utf8'),
'keep legacy notification',
);
await assert.rejects(fs.stat(path.join(ctx.root, 'unexpected-reload')), {
code: 'ENOENT',
});
});
test('installed upgrade hooks propagate returned failures and preserve legacy installer status', async (t) => {
const {
withLifecycleHooks,
} = require('../../dist/internal/maintenance/upgradeArtifacts.cjs');
for (const [returned, legacy, expected] of [
[19, 0, 19],
[0, 23, 23],
[0, 0, 0],
]) {
const ctx = await fixture(t);
const tmp = path.join(ctx.root, 'tmp');
await fs.mkdir(tmp);
await fs.mkdir(path.join(ctx.root, 'shell'));
const trace = path.join(ctx.root, 'install-args');
await fs.writeFile(path.join(ctx.root, 'shell/env.sh'), '');
await fs.writeFile(
path.join(ctx.root, 'shell/share.sh'),
`import_config() { :; }
npm_install_2() {
printf '%s\\n' "$@" > "$QL_INSTALL_TRACE"
exit_status=${legacy}
return ${returned}
}
`,
);
const args = [
path.join(ctx.root, 'staged source;$(touch SHOULD_NOT_RUN)'),
path.join(ctx.root, 'staged static;$(touch SHOULD_NOT_RUN)'),
sourceCommit,
];
const operation = withLifecycleHooks(
ctx.root,
tmp,
{ ...ctx.env, QL_INSTALL_TRACE: trace },
(hook) => hook('install', args),
);
if (expected)
await assert.rejects(operation, new RegExp(`exit ${expected}`));
else await operation;
assert.deepEqual(
(await fs.readFile(trace, 'utf8')).trim().split('\n'),
args,
);
await assert.rejects(fs.stat(path.join(ctx.root, 'SHOULD_NOT_RUN')), {
code: 'ENOENT',
});
assert.deepEqual(await fs.readdir(tmp), []);
}
});
test('interrupted reload restores old files and recovery subprocesses ignore the cancelled operation', async (t) => {
const {
cancellableOperation,
-35
View File
@@ -1,35 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
test('the standalone operational CLI verifier carries its archive parser without panel dependencies', (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-cli-bundle-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const filename = path.join(directory, 'platformBundle.cjs');
fs.copyFileSync(
path.resolve(
__dirname,
'../../dist/internal/maintenance/platformBundle.cjs',
),
filename,
);
const code = `
const Module=require('node:module'),load=Module._load;
Module._load=function(name,...args){
if(name!==process.argv[1]&&!name.startsWith('node:')&&!Module.builtinModules.includes(name))throw new Error('External dependency: '+name);
return load.call(this,name,...args);
};
const bundle=require(process.argv[1]);
if(typeof bundle.stageBundle!=='function')throw new Error('Missing verifier');
bundle.runtimeTarget().then(target=>console.log(bundle.targetKey(target))).catch(e=>{console.error(e);process.exitCode=1});
`;
const result = spawnSync(process.execPath, ['-e', code, filename], {
encoding: 'utf8',
env: { ...process.env, NODE_PATH: '' },
});
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, new RegExp(`^${process.platform}-`));
});
@@ -1,99 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const {
manualUpgradeFixture,
} = require('../../../test/helpers/manual-upgrade-fixture.cjs');
const { createContext } = require('../../dist/internal/runtime/context');
const { reloadPanel } = require('../../dist/internal/maintenance/upgrade');
const operator = require('../../dist/internal/maintenance/operator');
const artifacts = require('../../dist/internal/maintenance/upgradeArtifacts.cjs');
for (const status of ['ready', 'failed', 'completed', 'absent'])
for (const wrongVersion of [false, true])
test(`CLI validates and records manual reload after ${status}; wrong version=${wrongVersion}`, async (t) => {
const f = await manualUpgradeFixture(t, wrongVersion);
await fs.writeFile(
path.join(f.root, 'package.json'),
'{"qinglongUpgradeFormat":2}',
);
const file = path.join(f.tmp, 'panel-upgrade.json');
if (status === 'absent') await fs.unlink(file);
else
await fs.writeFile(
file,
JSON.stringify({
...(await f.state()),
status,
error: 'old error',
version: '9.9.9',
sourceCommit: 'f'.repeat(40),
}),
);
t.mock.method(operator, 'stopPanel', async () => {});
t.mock.method(operator, 'startPanel', async () => ({
manager: 'fixture',
}));
t.mock.method(artifacts, 'waitForHealth', async () => {});
const operation = reloadPanel(
createContext({ root: f.root }, f.env),
'system',
);
if (wrongVersion)
await assert.rejects(operation, /启动版本与更新包不一致/);
else await operation;
const state = await f.state();
assert.equal(state.status, wrongVersion ? 'failed' : 'completed');
assert.equal(state.sourceCommit, f.expected.sourceCommit);
assert.equal(state.version, f.expected.version);
assert.notEqual(state.error, 'old error');
assert.equal(
await fs.readFile(path.join(f.root, 'static/build/app.js'), 'utf8'),
wrongVersion ? 'old backend' : 'new backend',
);
assert.equal(
await fs.readFile(path.join(f.root, 'data/keep'), 'utf8'),
'preserve data',
);
});
for (const wrongVersion of [false, true]) {
test(`operational CLI synchronizes prepared jobs and verifies startup before committing; wrong version=${wrongVersion}`, async (t) => {
const f = await manualUpgradeFixture(t, wrongVersion);
const context = createContext({ root: f.root }, f.env);
const timeouts = [];
t.mock.method(artifacts, 'waitForHealth', async (_env, timeout) => {
timeouts.push(timeout);
});
t.mock.method(operator, 'stopPanel', async () => {
assert.equal((await f.state()).status, 'restarting');
});
t.mock.method(operator, 'startPanel', async () => ({ manager: 'fixture' }));
const operation = reloadPanel(context, 'system');
if (wrongVersion) await assert.rejects(operation, /启动版本与更新包不一致/);
else await operation;
assert.ok(timeouts.length > 0);
assert.ok(
timeouts.every((timeout) => timeout === undefined),
'use the shared ARMv6/default startup deadline',
);
assert.equal(
(await f.state()).status,
wrongVersion ? 'failed' : 'completed',
);
assert.equal(
await fs.readFile(path.join(f.root, 'static/build/app.js'), 'utf8'),
wrongVersion ? 'old backend' : 'new backend',
);
assert.equal(
await fs.readFile(path.join(f.root, 'data/keep'), 'utf8'),
'preserve data',
);
if (wrongVersion)
assert.equal(
await fs.readFile(path.join(f.root, 'node_modules/old'), 'utf8'),
'old dependency',
);
});
}
+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', QL_LANG: 'en' },
{ PATH: '/nonexistent' },
);
t.after(async () => {
await stopPanel(context);
+13 -2
View File
@@ -2,7 +2,9 @@ const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const { randomUUID } = require('node:crypto');
const { createContext } = require('../../dist/internal/runtime/context');
const { installPanelDependencies } = require('../../dist/internal/maintenance/operator');
const {
installPanelDependencies,
} = require('../../dist/internal/maintenance/operator');
const { reloadPanel } = require('../../dist/internal/maintenance/upgrade');
assert.equal(process.env.QL_PANEL_INTEGRATION, '1');
(async () => {
@@ -65,7 +67,16 @@ assert.equal(process.env.QL_PANEL_INTEGRATION, '1');
const config = await fs.readFile('/ql/data/config/config.sh');
const dotenv = await fs.readFile('/ql/.env');
const context = createContext({ root: '/ql' }, process.env);
await installPanelDependencies(context, '/stage/source');
const stagedManifest = JSON.parse(
await fs.readFile('/stage/static/build-info.json', 'utf8'),
);
assert.match(stagedManifest.sourceCommit, /^[0-9a-f]{40}$/);
await installPanelDependencies(
context,
'/stage/source',
'/stage/static',
stagedManifest.sourceCommit,
);
const replacement = await reloadPanel(context, 'system', {
source: '/stage/source',
static: '/stage/static',
+1 -1
View File
@@ -23,7 +23,7 @@ test(
[
'--test',
'--test-reporter=tap',
path.resolve(__dirname, '../internal/preload.test.cjs'),
path.resolve(__dirname, '../preload.test.cjs'),
],
{
env,
+1 -9
View File
@@ -43,7 +43,6 @@ test(
context.paths.dir_tmp,
);
installations.push(directory);
await fs.mkdir(path.join(directory, 'node_modules'), { recursive: true });
},
);
const controller = new AbortController();
@@ -71,16 +70,9 @@ 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,
sourceCommit: manifest.sourceCommit,
dependenciesChanged: true,
},
staged,
);
assert.equal(
await fs.readFile(path.join(root, 'package.json'), 'utf8'),
+9 -31
View File
@@ -1,16 +1,17 @@
# 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:24.18.1-alpine3.24 AS builder
FROM --platform=$BUILDPLATFORM node:22.23.3-alpine3.24 AS builder
ARG TARGETARCH
ARG TARGETVARIANT
ENV NPM_CONFIG_PREFIX=/opt/node-global
ENV PATH=/opt/node-global/bin:${PATH}
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
COPY static/runtime-tools.tgz static/runtime-tools-proof.json /tmp/runtime-tools/
COPY scripts/install-runtime-tools.cjs /tmp/install-runtime-tools.cjs
RUN set -x \
&& apk add --no-cache git \
&& npm i -g pnpm@8.3.1 pm2 ts-node typescript@5 \
&& node /tmp/install-runtime-tools.cjs --archive /tmp/runtime-tools/runtime-tools.tgz --proof /tmp/runtime-tools/runtime-tools-proof.json --prefix /opt/node-global \
&& cd /tmp/build \
&& case "${TARGETARCH}" in \
amd64) NODE_ARCH=x64 ;; \
@@ -18,24 +19,12 @@ RUN set -x \
ppc64le) NODE_ARCH=ppc64 ;; \
*) NODE_ARCH="${TARGETARCH}" ;; \
esac \
&& case "${TARGETARCH}/${TARGETVARIANT:-}" in \
arm/v6) NODE_LIBC=musl-armv6 ;; \
arm/v7) NODE_LIBC=musl-armv7 ;; \
arm/*) echo "Unsupported ARM variant: ${TARGETVARIANT:-unknown}" >&2; exit 1 ;; \
*) NODE_LIBC=musl ;; \
esac \
&& npm_config_target_platform=linux \
npm_config_target_arch="${NODE_ARCH}" \
npm_config_target_libc="${NODE_LIBC}" \
pnpm install --prod --frozen-lockfile --side-effects-cache=false
npm_config_target_libc=musl \
pnpm install --prod --frozen-lockfile
# Pin amd64 to upstream's musl runtime after native URL parser crashes with APK Node.
FROM --platform=linux/amd64 node:24.18.1-alpine3.24@sha256:f70403e87646dc51b45295f4b8b70cdad0b63d2297c4c9899119b03f7af7a6b3 AS node-runtime-amd64
FROM python:3.11-alpine3.24
ARG TARGETARCH
ARG TARGETVARIANT
FROM python:3.11.17-alpine3.24@sha256:d9368b3a5ac59afea7b5d4f2e2aea0941dbf9fdee9c369c5bec00b98244bc929
ARG QL_MAINTAINER="whyour"
LABEL maintainer="${QL_MAINTAINER}"
@@ -54,11 +43,10 @@ VOLUME /ql/data
EXPOSE 5700
COPY --from=builder /opt/node-global/lib/node_modules/. /usr/local/lib/node_modules/
COPY --from=builder /opt/node-global/lib/. /usr/local/lib/
COPY --from=builder /opt/node-global/bin/. /usr/local/bin/
RUN --mount=type=bind,from=node-runtime-amd64,source=/usr/local/bin/node,target=/tmp/node-runtime-amd64 \
set -x \
RUN set -x \
&& apk update -f \
&& apk upgrade \
&& apk --no-cache add -f bash \
@@ -75,12 +63,6 @@ RUN --mount=type=bind,from=node-runtime-amd64,source=/usr/local/bin/node,target=
procps \
netcat-openbsd \
unzip \
npm \
&& if [ "$TARGETARCH" = amd64 ]; then \
cp /tmp/node-runtime-amd64 /usr/local/bin/node; \
ln -sf /usr/local/bin/node /usr/bin/node; \
fi \
&& node --version | grep -E '^v24\.' \
&& rm -rf /var/cache/apk/* \
&& apk update \
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
@@ -105,10 +87,6 @@ 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 \
+9 -31
View File
@@ -1,16 +1,17 @@
# 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:24.18.1-alpine3.24 AS builder
FROM --platform=$BUILDPLATFORM node:22.23.3-alpine3.24 AS builder
ARG TARGETARCH
ARG TARGETVARIANT
ENV NPM_CONFIG_PREFIX=/opt/node-global
ENV PATH=/opt/node-global/bin:${PATH}
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
COPY static/runtime-tools.tgz static/runtime-tools-proof.json /tmp/runtime-tools/
COPY scripts/install-runtime-tools.cjs /tmp/install-runtime-tools.cjs
RUN set -x \
&& apk add --no-cache git \
&& npm i -g pnpm@8.3.1 pm2 ts-node typescript@5 \
&& node /tmp/install-runtime-tools.cjs --archive /tmp/runtime-tools/runtime-tools.tgz --proof /tmp/runtime-tools/runtime-tools-proof.json --prefix /opt/node-global \
&& cd /tmp/build \
&& case "${TARGETARCH}" in \
amd64) NODE_ARCH=x64 ;; \
@@ -18,24 +19,12 @@ RUN set -x \
ppc64le) NODE_ARCH=ppc64 ;; \
*) NODE_ARCH="${TARGETARCH}" ;; \
esac \
&& case "${TARGETARCH}/${TARGETVARIANT:-}" in \
arm/v6) NODE_LIBC=musl-armv6 ;; \
arm/v7) NODE_LIBC=musl-armv7 ;; \
arm/*) echo "Unsupported ARM variant: ${TARGETVARIANT:-unknown}" >&2; exit 1 ;; \
*) NODE_LIBC=musl ;; \
esac \
&& npm_config_target_platform=linux \
npm_config_target_arch="${NODE_ARCH}" \
npm_config_target_libc="${NODE_LIBC}" \
pnpm install --prod --frozen-lockfile --side-effects-cache=false
npm_config_target_libc=musl \
pnpm install --prod --frozen-lockfile
# Pin amd64 to upstream's musl runtime after native URL parser crashes with APK Node.
FROM --platform=linux/amd64 node:24.18.1-alpine3.24@sha256:f70403e87646dc51b45295f4b8b70cdad0b63d2297c4c9899119b03f7af7a6b3 AS node-runtime-amd64
FROM python:3.10-alpine3.24
ARG TARGETARCH
ARG TARGETVARIANT
FROM python:3.10-alpine
ARG QL_MAINTAINER="whyour"
LABEL maintainer="${QL_MAINTAINER}"
@@ -54,11 +43,10 @@ VOLUME /ql/data
EXPOSE 5700
COPY --from=builder /opt/node-global/lib/node_modules/. /usr/local/lib/node_modules/
COPY --from=builder /opt/node-global/lib/. /usr/local/lib/
COPY --from=builder /opt/node-global/bin/. /usr/local/bin/
RUN --mount=type=bind,from=node-runtime-amd64,source=/usr/local/bin/node,target=/tmp/node-runtime-amd64 \
set -x \
RUN set -x \
&& apk update -f \
&& apk upgrade \
&& apk --no-cache add -f bash \
@@ -75,12 +63,6 @@ RUN --mount=type=bind,from=node-runtime-amd64,source=/usr/local/bin/node,target=
procps \
netcat-openbsd \
unzip \
npm \
&& if [ "$TARGETARCH" = amd64 ]; then \
cp /tmp/node-runtime-amd64 /usr/local/bin/node; \
ln -sf /usr/local/bin/node /usr/bin/node; \
fi \
&& node --version | grep -E '^v24\.' \
&& rm -rf /var/cache/apk/* \
&& apk update \
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
@@ -105,10 +87,6 @@ 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 \
+19 -20
View File
@@ -1,26 +1,26 @@
# Download and verify the target Node archive without running under QEMU.
FROM --platform=$BUILDPLATFORM debian:bookworm-slim AS nodebuilder
# Download authenticated target binaries natively; Node 22 Bookworm images do
# not include s390x, while official Node tarballs retain all five targets.
FROM --platform=$BUILDPLATFORM node:22.23.3-bookworm-slim AS nodebuilder
ARG TARGETARCH
COPY docker/install-node-debian.sh /tmp/install-node-debian.sh
RUN apt-get update && \
apt-get install --no-install-recommends -y ca-certificates curl xz-utils && \
sh /tmp/install-node-debian.sh "${TARGETARCH}"
ARG TARGETVARIANT
COPY docker/install-node.sh docker/node-release-key.asc docker/node-v22.23.3-SHASUMS256.txt.asc /tmp/
RUN apt-get update && apt-get install --no-install-recommends -y ca-certificates curl gnupg xz-utils \
&& sh /tmp/install-node.sh
FROM python:3.11.14-slim-bookworm AS builder
FROM python:3.11.17-slim-trixie@sha256:e88e9763f943ec1834f992a4b51e0f24500486803e8bc534e5767af9ea65f6ce AS builder
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
COPY --from=nodebuilder /usr/local/bin/node /usr/local/bin/
COPY --from=nodebuilder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/
COPY static/runtime-tools.tgz static/runtime-tools-proof.json /tmp/runtime-tools/
COPY scripts/install-runtime-tools.cjs /tmp/install-runtime-tools.cjs
COPY --from=nodebuilder /opt/node-target/bin/node /usr/local/bin/
RUN set -x && \
ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
apt-get update && \
apt-get install --no-install-recommends -y libatomic1 && \
npm i -g pnpm@8.3.1 && \
test "$(node --version)" = "v24.18.1" && \
test "$(pnpm --version)" = "8.3.1" && \
node /tmp/install-runtime-tools.cjs --archive /tmp/runtime-tools/runtime-tools.tgz --proof /tmp/runtime-tools/runtime-tools-proof.json --prefix /usr/local && \
cd /tmp/build && \
pnpm install --prod --frozen-lockfile
FROM python:3.11.14-slim-bookworm
FROM python:3.11.17-slim-trixie@sha256:e88e9763f943ec1834f992a4b51e0f24500486803e8bc534e5767af9ea65f6ce
ARG QL_MAINTAINER="whyour"
LABEL maintainer="${QL_MAINTAINER}"
@@ -48,8 +48,9 @@ RUN groupadd -g ${QL_GID} qinglong && \
ENV QL_USER=qinglong
ENV QL_HOME=/home/$QL_USER
COPY --from=nodebuilder /usr/local/bin/node /usr/local/bin/
COPY --from=nodebuilder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/
COPY --from=nodebuilder /opt/node-target/bin/node /usr/local/bin/
COPY static/runtime-tools.tgz static/runtime-tools-proof.json /tmp/runtime-tools/
COPY scripts/install-runtime-tools.cjs /tmp/install-runtime-tools.cjs
RUN set -x && \
ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
@@ -75,9 +76,8 @@ RUN set -x && \
git config --global user.email "qinglong@users.noreply.github.com" && \
git config --global user.name "qinglong" && \
git config --global http.postBuffer 524288000 && \
npm install -g pnpm@8.3.1 pm2 ts-node typescript@5 && \
test "$(node --version)" = "v24.18.1" && \
test "$(pnpm --version)" = "8.3.1" && \
node /tmp/install-runtime-tools.cjs --archive /tmp/runtime-tools/runtime-tools.tgz --proof /tmp/runtime-tools/runtime-tools-proof.json --prefix /usr/local && \
rm -rf /tmp/runtime-tools /tmp/install-runtime-tools.cjs && \
npm cache clean --force && \
rm -rf /root/.cache && \
rm -rf /root/.npm && \
@@ -122,8 +122,7 @@ USER root
RUN ln -sf ${QL_DIR}/shell/task.sh /usr/local/bin/task \
&& ln -sf ${QL_DIR}/shell/update.sh /usr/local/bin/ql \
&& chmod +x /usr/local/bin/task /usr/local/bin/ql \
&& test "$(pnpm --version)" = "8.3.1"
&& chmod +x /usr/local/bin/task /usr/local/bin/ql
WORKDIR ${QL_DIR}
+17 -15
View File
@@ -1,23 +1,24 @@
# Download and verify the target Node archive without running under QEMU.
FROM --platform=$BUILDPLATFORM debian:bookworm-slim AS nodebuilder
# Download authenticated target binaries natively; Node 22 Bookworm images do
# not include s390x, while official Node tarballs retain all five targets.
FROM --platform=$BUILDPLATFORM node:22.23.3-bookworm-slim AS nodebuilder
ARG TARGETARCH
COPY docker/install-node-debian.sh /tmp/install-node-debian.sh
RUN apt-get update && \
apt-get install --no-install-recommends -y ca-certificates curl xz-utils && \
sh /tmp/install-node-debian.sh "${TARGETARCH}"
ARG TARGETVARIANT
COPY docker/install-node.sh docker/node-release-key.asc docker/node-v22.23.3-SHASUMS256.txt.asc /tmp/
RUN apt-get update && apt-get install --no-install-recommends -y ca-certificates curl gnupg xz-utils \
&& sh /tmp/install-node.sh
# Keep s390x on the same immutable Python base as the other Debian targets.
# Keep the full release matrix, including s390x, on the same immutable base.
# The floating 3.10-slim-bookworm tag no longer includes s390x.
FROM python:3.10.19-slim-bookworm@sha256:23f63358922e79a794f71be8f3723c84e5ccca9638af3f74456dc73d6184499e AS builder
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
COPY --from=nodebuilder /usr/local/bin/node /usr/local/bin/
COPY --from=nodebuilder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/
COPY static/runtime-tools.tgz static/runtime-tools-proof.json /tmp/runtime-tools/
COPY scripts/install-runtime-tools.cjs /tmp/install-runtime-tools.cjs
COPY --from=nodebuilder /opt/node-target/bin/node /usr/local/bin/
RUN set -x && \
ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
apt-get update && \
apt-get install --no-install-recommends -y libatomic1 && \
npm i -g pnpm@8.3.1 && \
test "$(node --version)" = "v24.18.1" && \
node /tmp/install-runtime-tools.cjs --archive /tmp/runtime-tools/runtime-tools.tgz --proof /tmp/runtime-tools/runtime-tools-proof.json --prefix /usr/local && \
cd /tmp/build && \
pnpm install --prod --frozen-lockfile
@@ -49,8 +50,9 @@ RUN groupadd -g ${QL_GID} qinglong && \
ENV QL_USER=qinglong
ENV QL_HOME=/home/$QL_USER
COPY --from=nodebuilder /usr/local/bin/node /usr/local/bin/
COPY --from=nodebuilder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/
COPY --from=nodebuilder /opt/node-target/bin/node /usr/local/bin/
COPY static/runtime-tools.tgz static/runtime-tools-proof.json /tmp/runtime-tools/
COPY scripts/install-runtime-tools.cjs /tmp/install-runtime-tools.cjs
RUN set -x && \
ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
@@ -75,8 +77,8 @@ RUN set -x && \
git config --global user.email "qinglong@users.noreply.github.com" && \
git config --global user.name "qinglong" && \
git config --global http.postBuffer 524288000 && \
npm install -g pnpm@8.3.1 pm2 ts-node typescript@5 && \
test "$(node --version)" = "v24.18.1" && \
node /tmp/install-runtime-tools.cjs --archive /tmp/runtime-tools/runtime-tools.tgz --proof /tmp/runtime-tools/runtime-tools-proof.json --prefix /usr/local && \
rm -rf /tmp/runtime-tools /tmp/install-runtime-tools.cjs && \
npm cache clean --force && \
rm -rf /root/.cache && \
rm -rf /root/.npm && \
-16
View File
@@ -1,16 +0,0 @@
# The image digest is supplied by the same workflow run, never a rolling tag.
ARG BASE_IMAGE
FROM ${BASE_IMAGE} AS bundle
ARG BASE_IMAGE
ENV QL_BUNDLE_BASE_IMAGE=${BASE_IMAGE}
ARG TARGETARCH
ARG TARGETVARIANT
USER root
RUN ulimit -c 0; \
if [ "$TARGETARCH/$TARGETVARIANT" = arm/v6 ]; then \
export QEMU_CPU=arm1176; \
set -- --jitless; \
fi; \
node "$@" /ql/scripts/build-upgrade-bundle.cjs /ql /out
FROM scratch
COPY --from=bundle /out/ /
+2 -2
View File
@@ -119,12 +119,12 @@ reload_pm2
if [[ $AutoStartBot == true ]]; then
log_with_style "INFO" "🤖 3. 启动 bot..."
nohup ql bot >"$dir_log/bot.log" 2>&1 &
nohup ql bot >$dir_log/bot.log 2>&1 &
fi
if [[ $EnableExtraShell == true ]]; then
log_with_style "INFO" "🛠️ 4. 执行自定义脚本..."
nohup ql extra >"$dir_log/extra.log" 2>&1 &
nohup ql extra >$dir_log/extra.log 2>&1 &
fi
log_with_style "SUCCESS" "🎉 容器启动成功!"
-22
View File
@@ -1,22 +0,0 @@
#!/bin/sh
set -eu
# The official Node 24 Bookworm image does not cover s390x. Use the same
# verified upstream release archive on every supported Debian architecture.
version=24.18.1
case "${1:?Missing Docker TARGETARCH}" in
amd64) arch=x64; checksum=d6c664df3f3f61458e8c277585571328522d705166723a7c7823a9253a4d15a0 ;;
arm64) arch=arm64; checksum=7201e3a09dc825bac57867c81913e2b8f0ef87d04cb9082af4cda82f6ff3d88c ;;
ppc64le) arch=ppc64le; checksum=c47812c13862be7f6e71194c434c4d78a99096e72efb9c31a2627437e056d669 ;;
s390x) arch=s390x; checksum=acf57bd726f35afc2ba8475194e5a8bd532c00e1a3b2436295b2fbdde199d0e9 ;;
*) echo "Node ${version} has no supported Debian binary for TARGETARCH=$1" >&2; exit 1 ;;
esac
archive="node-v${version}-linux-${arch}.tar.xz"
curl --fail --location --silent --show-error --retry 3 \
"https://nodejs.org/dist/v${version}/${archive}" -o "/tmp/${archive}"
printf '%s %s\n' "${checksum}" "/tmp/${archive}" | sha256sum --check --status
tar -xJf "/tmp/${archive}" -C /usr/local --strip-components=1 --no-same-owner
rm "/tmp/${archive}"
# The target binary is executed and version-checked in the target builder/runtime stages.
test -x /usr/local/bin/node
+38
View File
@@ -0,0 +1,38 @@
#!/bin/sh
set -eu
# These release files are committed together so every target architecture uses
# the same official Node release and authenticated checksum list.
NODE_VERSION=22.23.3
NODE_SIGNER=5BE8A3F6C8A5C01D106C0AD820B1A390B168D356
case "${TARGETARCH:?}/${TARGETVARIANT:-}" in
amd64/) NODE_ARCH=x64 ;;
arm/v7) NODE_ARCH=armv7l ;;
arm64/|arm64/v8) NODE_ARCH=arm64 ;;
ppc64le/) NODE_ARCH=ppc64le ;;
s390x/) NODE_ARCH=s390x ;;
*) printf 'Unsupported Debian Node architecture: %s/%s\n' "$TARGETARCH" "${TARGETVARIANT:-}" >&2; exit 1 ;;
esac
DOWNLOAD_DIR=$(mktemp -d)
KEY_DIR=$(mktemp -d)
trap 'rm -rf "$DOWNLOAD_DIR" "$KEY_DIR"' EXIT HUP INT TERM
chmod 700 "$KEY_DIR"
gpg --homedir "$KEY_DIR" --batch --import /tmp/node-release-key.asc
FINGERPRINT=$(gpg --homedir "$KEY_DIR" --batch --with-colons --fingerprint "$NODE_SIGNER" | awk -F: '$1 == "fpr" { print $10; exit }')
test "$FINGERPRINT" = "$NODE_SIGNER"
gpg --homedir "$KEY_DIR" --batch --status-fd 3 --output "$DOWNLOAD_DIR/SHASUMS256.txt" \
--decrypt "/tmp/node-v${NODE_VERSION}-SHASUMS256.txt.asc" 3> "$DOWNLOAD_DIR/signature.status"
grep -q "^\[GNUPG:\] VALIDSIG ${NODE_SIGNER} " "$DOWNLOAD_DIR/signature.status"
NODE_FILE="node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz"
cd "$DOWNLOAD_DIR"
grep " ${NODE_FILE}$" SHASUMS256.txt > expected.sha256
test "$(wc -l < expected.sha256 | tr -d ' ')" = 1
curl --fail --show-error --silent --location --proto '=https' --proto-redir '=https' --tlsv1.2 --retry 3 \
"https://nodejs.org/dist/v${NODE_VERSION}/${NODE_FILE}" --output "$NODE_FILE"
sha256sum --check --strict expected.sha256
mkdir -p /opt/node-target
tar --extract --xz --file "$NODE_FILE" --directory /opt/node-target --strip-components=1
test -x /opt/node-target/bin/node
test -f /opt/node-target/lib/node_modules/npm/package.json
+17
View File
@@ -0,0 +1,17 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mDMEaGA63BYJKwYBBAHaRw8BAQdAo/yU+MutacFmmn0CEX495goNrBxR24235XLM
cvHYjfq0L0FudG9pbmUgZHUgSGFtZWwgPGR1aGFtZWxhbnRvaW5lMTk5NUBnbWFp
bC5jb20+iI4EExYKADYWIQRb6KP2yKXAHRBsCtggsaOQsWjTVgUCaGA63AIbAwQL
CQgHBBUKCQgFFgIDAQACHgUCF4AACgkQILGjkLFo01afgwEA/sLHqsj7ml2vyDoT
KDPE8n9a80ZOh14OfnlOe0cCZA8BAMEOOk7QFI69DIlV1nMiqcFCqQFoSzBU2LkI
R17p/j4NtDNBbnRvaW5lIGR1IEhhbWVsIDxhbnRvaW5lLmR1aGFtZWxAcGxhdGZv
cm1hdGljLmRldj6IjgQTFgoANhYhBFvoo/bIpcAdEGwK2CCxo5CxaNNWBQJpsCMx
AhsDBAsJCAcEFQoJCAUWAgMBAAIeAQIXgAAKCRAgsaOQsWjTVr/sAPwIBsG8g6ND
zoNRTX1wPKBvfZg1NP7tYCyM5sxQfrpuLAEA05AhG4xBILfhL/f0pqR5jXfxg6gz
T6WfeVeS6zeHZwe4OARoYDrcEgorBgEEAZdVAQUBAQdAQVmtih8AO3ryBQMR/22x
WHVKLjAbCiH2cMxNH+iy1RQDAQgHiHgEGBYKACAWIQRb6KP2yKXAHRBsCtggsaOQ
sWjTVgUCaGA63AIbDAAKCRAgsaOQsWjTVu8oAP9Bc+QY+9FikX3YvMgWAqiDlVOy
o0y6UIZGBMSQlF80wAD/d34LqtVIVe9oe5NO3xA75+6Ew8tGeAjUq/ovagr5dAU=
=JsVv
-----END PGP PUBLIC KEY BLOCK-----
+54
View File
@@ -0,0 +1,54 @@
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA256
b85dc5f0d7a9be8038c4e73450d9125b241c792ec7ec495229345f86d1e3f6fa node-v22.23.3-aix-ppc64.tar.gz
a6fcbecafce9c8fbf86ffcb5fb12a66af5f465c449ef5e2fcf7c4e651c1a9b14 node-v22.23.3-arm64.msi
23b25245dcfb9af7262f8ff142e9e2e0af025368117329e7a7458a51e5922f53 node-v22.23.3-darwin-arm64.tar.gz
72d5d8832b41c9d9646197af614ffd751406ea4d215060eb91b98864e1919a3e node-v22.23.3-darwin-arm64.tar.xz
8a677b0219178efd6eb0e475457c4afb452b521a92f6e67845a73bd85727f2a8 node-v22.23.3-darwin-x64.tar.gz
ac41874c3352937119cfec39e1a98c578fe58ce8a86d7e785b89a4706015f305 node-v22.23.3-darwin-x64.tar.xz
eff1a7e67736bdf25db421ddaa613ed605f751a7b9fe07fb473ea880d1348987 node-v22.23.3-headers.tar.gz
8448fe9f94d65aece4df26f266358644d6f41da4ce23aab09675e206edcff1d4 node-v22.23.3-headers.tar.xz
5ced2d48d1d7198739b7f86804de0171aefb6823b684b12341d3321afc3cb0b2 node-v22.23.3-linux-arm64.tar.gz
a44aeb94849a299b22df10b9e622ec2f605c2183501bc40590705131de7c740f node-v22.23.3-linux-arm64.tar.xz
8b99058ec6f4e372b9887f8ecb577a292adb132d59e5104356428a758fdb126c node-v22.23.3-linux-armv7l.tar.gz
590a2768199bdd0b848648a81cd7f07f88a5ceb27949f0c70d5e6652c3eaca69 node-v22.23.3-linux-armv7l.tar.xz
236712ddef73f56c0e0590a2c49f69c8393a2c2d82c3d62c7c165605b472b38d node-v22.23.3-linux-ppc64le.tar.gz
0214e542c73686134856784c0cbd2762b5414097640762b4afc2e33e6e499207 node-v22.23.3-linux-ppc64le.tar.xz
51f64794f5f61bdfc80aa186c8273021473997605cf90ed31ed148bee650a554 node-v22.23.3-linux-s390x.tar.gz
a311c8f0b352e7dedc13958b612ac190a56456fc837dd26cd5e5845713a1eb1d node-v22.23.3-linux-s390x.tar.xz
633f49a4f388c5f9564d56d9c0f5ffa394346d92ca0076fe21cf776ece1988bc node-v22.23.3-linux-x64-musl.tar.gz
0564c678817e6b6fd7860689283185cecdf42e644135b45636094757b9a7922b node-v22.23.3-linux-x64-musl.tar.xz
1084aa36196bba4c3a5e69a1ee388a6e4ff729dad09445fbcd434b28fe3c24af node-v22.23.3-linux-x64.tar.gz
df450af89261115ef9f9e3830c3eeb2cc9213b63c720b1af623cb5dcbe2e02de node-v22.23.3-linux-x64.tar.xz
55c853a53b4bf88c1d8fd70e6add0b22b49c0e76352d4044caaa90512734ab7e node-v22.23.3.pkg
9436c81b284889303d39f5b8bd629bf19e790c665ccb94b4994aa87220d9799a node-v22.23.3.tar.gz
bd97093e1a1e9243338950c174a693a64d4e0926a9c6ce259962bc58d5e96909 node-v22.23.3.tar.xz
33fbc3458ce5cddd694f319bcb47490fbce720c492e25573b594e205c189c89a node-v22.23.3-win-arm64.7z
33dad22e4cef5ee8f9fbb1b0d037fdacd0e56d12a4580f0d63f68b894deab535 node-v22.23.3-win-arm64.zip
fa7a98510f2edf181733dee15a200a5039a0459511366150e4c64da72a53ab94 node-v22.23.3-win-x64.7z
2b0ff57b049cda1bbcea2240eec20467018713c1efe1f7360c2681859b90ed71 node-v22.23.3-win-x64.zip
ff5ffeb0cc42d76b4bfe453638dbe9a69738fdaec967a14cd84f3b11225bb523 node-v22.23.3-win-x86.7z
1e07ac00e61b41b7ecf242de8e38883d237d5ad3fdcad7c16f2dd3a88670e2b6 node-v22.23.3-win-x86.zip
1c0efc8449987e7da5d184786a0a96da83ffa11d334421201e5c09b93017cb8d node-v22.23.3-x64.msi
c71bbe31029c99d6f9cffa4841056ecc49dcd1fb09ef739b7e85dc7b207242b4 node-v22.23.3-x86.msi
b5a3165ec6f24c0b1fe2a10fed58e03f6c1d7d141beb3b225ff030ceac6c5eb5 win-arm64/node.exe
5378f5bad777b4929d1cd4f58974e0ef03815ce5c159863f1875c4b434460c27 win-arm64/node.lib
3f21d9cee58b7168ee46827e04d381dcdeb2d1cc5974bc226ba3a1bcd6bf8988 win-arm64/node_pdb.7z
bb155f2ecad21c98cd909831cb26320e123dd010a654f3649e8ba54a7de9f433 win-arm64/node_pdb.zip
9c9245166b4a8e182e0b797da9c20136117ff24368eaff1fec8343a123c8db0e win-x64/node.exe
c243185f2347870cadb637c909bf123fc703dc65dbfa769b17ec45a4b661d782 win-x64/node.lib
1babb45798cf3a2d4e70574ba926e7ff26b71487ed940a98cdd4971f32af4cae win-x64/node_pdb.7z
ab9fe384f04575fab7b2915b02d109a1d415b184cd6b28239fdb67e12c260321 win-x64/node_pdb.zip
a8aa72dda43af5357d0a502548a54e402d4f7643be221662624a2cc876cd37bb win-x86/node.exe
eff47cc18fe2d3e40886cd49f04a85134681657d8b0f2afc55f363743cf6ddd7 win-x86/node.lib
18963f55b0ebda23c72ab023990e68bce5302645f9643b6f0ac52dc809e3c501 win-x86/node_pdb.7z
95d75dce4db28836ef7ff2ddada21c1df67cc07f7d9a727ac38b021ee3afdeaa win-x86/node_pdb.zip
-----BEGIN PGP SIGNATURE-----
iHUEARYIAB0WIQRb6KP2yKXAHRBsCtggsaOQsWjTVgUCarQJ5gAKCRAgsaOQsWjT
Vi6XAQD18cb42T8Iokdj3jgAfoB41fqECt7DeN43VsgOoSNDZwEAxqn3W+nDSkFo
aQyV6A9gETWcFz27zSzMVA+vDrzbvQM=
=2yJE
-----END PGP SIGNATURE-----
-13
View File
@@ -10,10 +10,6 @@ Node 调度路径使用固定版本 `node-cron@4.6.0`,普通任务(含额外
- 日期与星期同时指定时保留 OR 语义,不采用 node-cron 默认的 AND。两个原生任务分别处理日期和星期,并按计划时间过滤交集,避免重复执行;保留旧解析器的月份全日期范围及全局 nth-week 限制。
- 原生任务先暂停创建并验证,再替换旧任务。构造失败时销毁已准备的实例并保留旧快照。
- 进程内迟到的触发通过 `execution:missed` 加入既有执行队列,记录补执行日志。并发和单任务积压上限仍由青龙队列控制;这不提供进程退出期间的持久化补跑或分布式 exactly-once 保证。
- 保留 node-cron 已支持的列表、范围和名称,不调用 cron-parser 的 `stringify()` 重写整条表达式。先校验整条表达式,通过则跳过逐字段探测;失败时才检查并展开不支持的字段(如数字/名称起点步长)。保留原有 `?` 和 `#` 兼容处理,避免有效的 `6,12,18` 被压缩为不兼容的 `6/6`;日期/星期拆分后的最终校验仍然保留。
- 星期步长范围以 `7` 结尾时,先按 cron-parser 的解析值展开。例如 `2-7/4` 在现有解析器中包含星期日,而 node-cron 原样执行会遗漏星期日;仅通过语法校验不足以保证执行含义一致。
- 单月规则先剔除超出该月最大日期的值再计算日期数量,保留旧版日期/星期的通配计数语义。被判为通配的字段不再作为另一侧的 AND 约束,避免 `2-31 4 MON` 和包含 `L`、重复星期日的边界规则漏执行。
- 日期与星期按 OR 拆分后,若日期分支在所有指定月份中都不可达,而星期分支有效,则仅保留星期分支;移除前仍检查日期分支在任意月份下语法有效。`31 2,4 MON` 因而可以执行,单独的 `31 2,4 *` 仍拒绝。
- 标准化结果使用上限 512 条的只读缓存,仅缓存语法,不缓存运行实例或下次执行时间。避免大批相同表达式重复解析导致恢复 RPC 超时。
- 回调异常记录日志,后续调度继续;取消时调用原生 `destroy()` 释放计时器和注册表。
- 持久化无效任务在恢复时跳过并记录日志,无效订阅不会产生未处理的启动拒绝。API、导入和执行使用同一套表达式校验。
@@ -35,7 +31,6 @@ Node 调度路径使用固定版本 `node-cron@4.6.0`,普通任务(含额外
- `test/fixtures/legacy-cron.json` 是迁移前使用 node-schedule 2.1.1 / cron-parser 4.9.0 保存的快照:269 条语法判定,18 组日历序列,每组 10 次执行。
- 真正的 node-cron 实例配合虚拟时钟验证日期/星期 OR、交集去重、闰年、L/#、8 秒迟到补执行、取消释放、回调拒绝和夏令时。
- `test/fixtures/cron-calendar-boundaries.json` 保存 64 组规则与起点,以独立 cron-parser 4.9.0 / luxon 3.7.2 或公历计算产生预期结果,每组在 UTC、Asia/Shanghai 下各核对 4 次实际执行,包括 2100 年不闰和长周期第五个星期日的边界。时区子测试清理继承的 `NODE_TEST_CONTEXT` 并检查子测试输出,防止嵌套测试入口被跳过而出现假通过。
- 注册构造失败回滚、无效订阅隔离、空表达式立即运行,以及现有调度恢复/503/子进程测试覆盖接入流程。
- 隔离的正式 Debian 镜像使用实际编译产物验证 10,000 条有效任务和 2 条错误任务的恢复、300 个同时到期的 Python 脚本、并发上限 16 及 HTTP 健康检查。具体结果见本次测试报告;这是生产镜像隔离验证,不是线上长期运行证明。
@@ -52,11 +47,3 @@ Node 调度路径使用固定版本 `node-cron@4.6.0`,普通任务(含额外
完整回归 267 通过、3 项原有跳过;Node 20 的 10 项修复回归及 Alpine 镜像的 24 项兼容/宏测试通过。
额外使用 9,700 个不同表达式和 300 个同时到期任务验证恢复(另有 2 个无效任务),不再局限于共享两个表达式。300/300 脚本完成,无重复或遗漏,最大并发 16,270 个退出码 0、30 个预设退出码 3 均落库。恢复后健康检查 1,650/1,650 成功、任务列表 33/33 成功;启动恢复阶段另有 260 次健康检查失败,整批脚本耗时 60.27 秒。较大快照恢复仍需等待,不承诺启动阶段始终可用或任意规模都能在时限内恢复。
### 深度兼容性审计(2026-10-17)
保持 cron-parser 5.4.0 和 node-cron 4.6.0,扫描 19,811 个表达式,覆盖六个字段的数字、前导零、列表、范围、起点步长、名称、L/#/? 及日期/星期组合。发现并修复星期步长末尾 7、单月日期裁剪、通配计数边界和不可达日期 OR 分支问题;47 个初始失败样例已保存为回归数据。
UTC 和 Asia/Shanghai 各比较 17,795 条规则的连续 4 次实际触发,共核对 142,360 个触发时刻,修复后无偏差、无构造失败。每个时区另外有 71 个通配 `#` 组合沿用已明确保留的旧适配规则,不与原解析器直接对照;22 个原版拒绝的日期输入仍拒绝,11 个参考迭代器触及搜索上限的长周期规则改用独立公历预期验证。
持久化回归覆盖 64 组规则与起点、两个时区,共 128 组日历用例;还保留 H、裸 `/N`、非标准 L 写法及不可达规则的拒绝行为。完整回归 745 通过、8 跳过,后端编译通过。本次为确定性语法与日历测试,不涉及生产环境压力或长期稳定性验证。
-11
View File
@@ -1,11 +0,0 @@
# 通知日志
通知日志记录系统统一通知入口(包括登录通知、配置测试和系统通知 API),以及任务使用内置 `notify.js`、`notify.py` 或 `QLAPI.notify` 发出的通知。脚本通知按实际配置的渠道各记录一条结果,未配置渠道和跳过的标题不记账。
日志包含时间、标题、正文、渠道、结果和来源;脚本来源另包含文件名与任务 ID。成功表示通知渠道返回成功或接受请求,不表示最终收件人已经收到。多目标渠道只在所有目标成功时记为成功。
所有入口共用数据库中的最近 200 条日志。标题最多 512 字符、正文最多 8192 字符;上报字段不包含通知密码、令牌或渠道配置。日志页受系统设置的管理员权限保护。
脚本通过现有的本机 mTLS gRPC 服务上报,独立客户端最多等待 1.5 秒。旧面板、服务不可用或数据库写入失败不会改变通知发送结果。Python 使用 Node 上报子进程,并限制在 3 秒内。
自行保存的旧版通知库需要更新为面板内置版本才能上报。脚本自行调用 HTTP、SMTP 或第三方 SDK 时不经过这些入口,无法自动纳入通知日志。
@@ -1,96 +0,0 @@
# 平台完整升级包
CI 从同次构建的不可变镜像摘要导出完整 tar.gz,包含源码中的运行脚本和模板、前后端编译产物、锁文件和已经安装的生产 `node_modules`。不包含 Node 可执行文件、用户数据、`.env`、Git 历史或 npm 缓存。保留现有 Node 和用户脚本依赖。
## 在线升级
`package.json` 中 `qinglongUpgradeFormat: 2` 表示使用完整包。旧版升级器仍可通过原归档路径完成首次迁移;完整包版本不会在平台包缺失、损坏或不兼容时退回设备端 npm 安装。仅当完整包已通过校验、依赖加载或后端预启动明确报告依赖错误时,在线升级会按同一冻结锁文件修复一次生产依赖,然后再次预启动;第二次失败即停止,保留正在运行的旧服务。
面板下载任务与本地离线入口共用 `platformBundle.cjs`:平台匹配 → 归档安全检查 → 全文件/链接/权限和锁文件校验 → SQLite/Sequelize/cron-parser/gRPC/undici 加载及 SQLite 内存库读写 → 隔离后端预启动 → 发布就绪指针。预启动使用现有 Node、临时数据目录和 loopback 随机端口,运行真实数据库建表/迁移和事务读写、gRPC 服务,以及与正常启动共用的 HTTP 中间件、路由和 WebSocket 初始化,再通过 `/api/health` 确认双服务与目标版本、提交和后端摘要;不运行任务、订阅、通知、用户启动钩子和系统依赖安装。临时库不是用户数据库副本,因此实际应用后的健康检查和回退仍不可省略。点击重启时,接口只检查就绪记录并提交后台任务;完整文件与依赖复验由独立工作进程执行,复验通过后才停止旧服务,避免小设备的预检耗时超过 HTTP 请求期限。离线升级同步面板任务状态,并与在线任务互斥;中断准备时保留之前的可用就绪包。原有服务停止、文件备份、启动健康检查及失败恢复仍由共享升级引擎处理。
正式升级包跟随 master 同一次版本镜像构建;develop 的普通提交也自动构建开发包。两者都直接使用该次 Alpine/Debian 镜像的不可变 digest。Git tag 工作流和 `static_sync_only` 不生成完整包。需要重建当前开发包时,可手动运行同一工作流:
```sh
gh workflow run build-docker-image.yml --ref develop
```
开发包更新共用的 `upgrade-develop/latest.json`。`develop` 和 `debian-dev` 镜像都使用该索引,按实际 Node 架构和 musl/glibc 选择包,无需设置测试专用下载源。开发批次位于 `upgrade-build-develop-v<version>-<run-id>-<run-attempt>`,不会改写正式版本或 `upgrade-master`。同一开发渠道的镜像构建和包发布串行执行,与 master 独立。
开发包只保留最新一个已验证批次:所有平台资产和渠道索引的大小、SHA256 校验成功后,删除其他名称匹配的开发构建 prerelease;不删除正式包、渠道索引或无法分类的旧 release。构建或上传失败不会删除上一套可用包。旧批次被删除后,已选中旧索引但尚未下载完的客户端可能需要重新发起下载;准备失败仍保留旧服务和数据。
开发和正式包都必须在 11 个平台完成归档、解包后复验及依赖试运行,并且全部上传资产的大小和 SHA256 复验通过后,才更新各自索引。平台范围与现有 Alpine/Debian 镜像一致:ARMv6 和 ARMv7 仅提供 musl 包,Debian 不再发布 ARMv7 镜像或 glibc 平台包。
版本入口为 GitHub prerelease `upgrade-v<version>` 下的 `latest.json`;在线正式渠道保留 `upgrade-master/latest.json`,兼容现有客户端。二者指向同一套完整包,指定版本与 latest 不重复生成产物。索引记录渠道、版本、源码提交、构建批次、准确字节数、SHA256,以及 `upgrade-build-v<version>-<run-id>-<run-attempt>` 下的固定下载 URL。相同版本的镜像 tag 重新发布时,完整包也重新生成,版本和正式渠道索引同步覆盖到新批次;同一提交重跑也允许产生新内容。已选中旧索引的下载继续使用旧批次资产,避免覆盖文件导致哈希不匹配。master 的镜像构建和完整包发布整条流水线串行执行,避免镜像 tag 与完整包被并发重发交错覆盖;旧工作流明确重跑时也允许同版本、同提交重新发布。过期源码提交不能推进对应渠道或版本索引;平台包上传失败时不切换索引。
## 手动离线安装和升级
从 `upgrade-v<version>` 的说明或 `latest.json` 中取得当前批次的下载链接,下载匹配设备的 `.tar.gz` 和独立 `install-bundle.cjs`;两者都来自同一个 `upgrade-build-v<version>-<run-id>-<run-attempt>` release。独立安装器已内置解析依赖,在目标设备正常情况无需 npm、pnpm 或重新安装 SQLite。离线准备和预启动默认不联网;依赖不符合要求时直接报错。可对照同批次 release 的 `latest.json` 核对归档 SHA256。
已有安装(默认应用并重启):
```sh
node install-bundle.cjs --root /ql --file /absolute/path/qinglong-<commit>-linux-arm64-musl.tar.gz
```
只准备,稍后手动重启:
```sh
node install-bundle.cjs --root /ql --file /absolute/path/package.tar.gz --download-only
ql reload system
```
仅在明确允许联网修复时添加 `--repair-deps`(安装和升级均支持),例如:
```sh
node install-bundle.cjs --root /ql --file /absolute/path/package.tar.gz --download-only --repair-deps
```
修复需要已有 pnpm 8.3.1 及必要编译工具。依赖在私有目录按 `--prod --frozen-lockfile` 重装,仅重建 SQLite;ARMv6 强制源码编译和 ARMv6 硬浮点指令,禁止下载 generic ARM 的 ARMv7 二进制。修复仅执行一次;不安装或替换 Node、不改锁文件、不改已校验源码和静态产物。应用前以原包校验清单和独立记录的修复依赖摘要复验。
修复继承调用环境中的标准 HTTP/HTTPS/ALL/NO_PROXY(含小写形式),其余 Node 预加载和编译参数仍隔离。修复安装或 SQLite 重建失败时,清理私有目录,保留当前服务、当前依赖及上一次就绪包;不会标记本次更新就绪。预启动或修复主进程退出时立即终止其进程组,防止持有输出管道的子进程继续写入已清理目录。
通过 Bash 或已选择的本地 CLI 执行上述重启时,也会把已准备的面板任务更新为完成或失败;启动成功必须同时匹配目标版本、提交和后端哈希,匹配失败则恢复旧文件和服务。
手动应用会以当前就绪指针为准建立新的应用任务,并清除之前的失败信息。因此,准备失败后应用保留的旧就绪包、此前任务已完成后再次应用,以及完整包安装中任务记录丢失或尚未建立时,仍会校验实际启动版本并同步面板状态。未支持完整包且没有任务记录的旧版保留原重载兼容路径。
重启中断后的停止新服务、恢复文件和启动旧服务不再使用已经取消的信号。若新服务无法停止,则保留备份,避免在仍运行的进程下覆盖文件;若文件已恢复但旧服务也无法启动,任务仍报告失败。Bash 和本地 CLI 启动后端时都移除离线包选择、修复许可和升级启动器 PID,避免后续在线更新误用旧离线包;用户配置及代理保持不变。回滚恢复应用文件和依赖,不把用户数据库恢复到历史快照;数据库迁移仍需保持向后兼容。
安装到空目录:
```sh
node install-bundle.cjs --root /opt/qinglong --file /absolute/path/package.tar.gz --install
QL_DIR=/opt/qinglong QL_DATA_DIR=/opt/qinglong/data bash /opt/qinglong/shell/start-simplify.sh
```
`--install` 拒绝非空目录,不覆盖既有配置。该包安装青龙及其 Node 生产依赖;首次启动仍需已有 Python、Bash、OpenSSL、PM2、nginx 等相应运行环境,不会在线安装或替换它们。手动启动脚本会在启动工作进程前完成首次 gRPC 证书准备,避免慢设备生成密钥耗尽工作进程的就绪期限;已有证书继续复用。服务启动和系统自启按原手动安装流程配置。
ARMv6 小设备使用 `linux-armv6-musl.tar.gz`,要求已有兼容的 musl 系统及硬浮点 Node。ARMv6 glibc(例如 Raspbian/Raspberry Pi OS 32 位系统)不在平台包支持范围内,不能使用其他架构或 musl 包替代;安装器仍会检查实际架构和 libc,拒绝不匹配的包。
## npm 面板包
`@whyour/qinglong` 仍通过 npm 安装生产依赖;它包含运行脚本、gRPC proto、升级引擎和完整前后端构建,不包含用户数据、Node、开发 CLI 或生产 `node_modules`。`@whyour/qinglong-cli` 是单独的远程管理包。
npm 会排除根目录的 `pnpm-lock.yaml`。主包的原生 `npm pack/publish` 在 `prepack` 中核对当前 Git 提交、干净工作区、锁文件及完整构建文件的哈希,通过后才将锁文件复制为 `shell/upgrade-lock.yaml`,安装时由仅使用 Node 内置模块的 `postinstall` 校验 SHA256 后恢复标准路径;源代码安装和 Docker 的最小依赖构建阶段继续使用原有锁文件。归档使用精确运行文件白名单,避免打入本地生成的预加载配置、缓存、测试和验收输出。静态构建中的 source map 与 gzip 文件也必须完整保留,否则清单复验会失败。
`package.json#files` 是 npm 包内容的唯一项目规则,不再维护根 `.npmignore`:npm 在根包使用 `files` 时会停用该目录的 `.npmignore` 和 `.gitignore` 规则。打包测试在没有 `.npmignore` 的独立源码目录中注入私有配置、密钥占位文件、缓存及生成的预加载配置,执行真实 `npm pack` 和安装,检查运行文件完整、私有文件及其内容不进入归档。此规则只用于 npm 包,完整平台 tar.gz 仍由平台包构建器处理。
干净源码完成 `pnpm build:front`、`pnpm build:back`、`pnpm build:info` 后,运行 `node scripts/verify-panel-package.cjs pack /tmp/qinglong-npm` 生成并检查真实 npm 归档。CI 在一次性 Linux 容器中,以 Node 20.18.1 和 Node 24 安装同一个归档,使用真实生产依赖和已安装的 `qinglong`、`ql` 入口,验证启动、损坏下载保留旧服务、在线准备与重启、离线准备与 Bash 重启及账号、配置、环境变量、任务和脚本保留。测试还安装完整性摘要固定的已发布 `@whyour/qinglong@2.23.0`,在旧服务中建立真实数据,再用新 `.tgz` 执行 npm 覆盖安装,验证账号、配置、环境变量、任务和脚本保留。之后升级到同次 Debian 镜像不可变 digest 通过正式 `Dockerfile.upgrade-bundle` 导出的原始平台包,确认运行目录变为 pnpm 布局。该单平台验收包仅上传短期 CI artifact,不发布 Release,也不推进开发或正式更新索引。
Umi 4.4.4 的构建依赖仍使用 Node 24 已移除的 `http_parser`。前端源码构建使用 `.nvmrc` 中的 Node 22,CI 从该文件读取版本,Vercel 前后端项目均配置为 Node 22.x。`package.json` 不声明 `engines.node`,避免 Vercel 用开放的最低运行版本范围覆盖项目的构建版本;`pnpm build:front` 直接执行构建,不下载或切换 Node。面板运行时仍支持 Node 20.18.1 及以上,平台包的清单与安装器独立校验最低 Node 要求,npm 实装验收继续覆盖 Node 20/24。
服务集成测试只能在专用容器中运行:设置 `QL_PANEL_PACKAGE_TEST_ISOLATED=1`、指向隔离 PM2 的 `QL_PANEL_TEST_PM2` 和指向正式构建脚本输出目录的 `QL_PANEL_TEST_BUNDLE_DIR`,然后执行 `node scripts/verify-panel-package.cjs verify /tmp/qinglong-npm /tmp/qinglong-npm-results`。容器需已有 Bash、curl、OpenSSL、Python、jq、nginx 和 procps。旧正式包会重置 PATH,因此验收容器将 PM2 6.0.14 安装到 `/usr/local/bin/pm2`,并以此设置 `QL_PANEL_TEST_PM2`;隔离通过专属容器和独立 `PM2_HOME` 保证。测试创建独立的 npm prefix、HOME、PM2_HOME 和数据目录,结束后清理。
通过 npm 覆盖升级前,应先停止服务,执行新包安装,再启动服务。正常 npm 安装的 `postinstall` 会从当前路径对应的 npm 旧包临时目录中自动保留 `.env`,逐字节恢复为权限 `0600` 的普通文件,不需要手工备份和恢复;新目录中已有的 `.env` 不会被覆盖。只读取包名匹配的旧包,不扫描其他安装或备份目录;异常配置(如符号链接)会使安装失败,由 npm 回退旧包,避免静默丢失配置。此机制需要启用 npm 安装脚本,不能使用 `--ignore-scripts`。持久数据仍必须放在包目录以外的 `QL_DATA_DIR`。验收直接检查真实旧 npm 包覆盖安装后配置自动保留,并校验外置用户数据。在线和离线平台包升级仍由事务保留现有 `.env`。
master 发布任务等待上述测试通过,直接发布已验证的 `.tgz`,不重新构建或打包。develop 只生成测试归档与验收记录,不发布 npm。
## 平台兼容
- 最低 Node 为 20.18.1,Node-API 至少 6;安装器只检查,不替换 Node。
- 以 Node 实际运行架构、ARM 指令集和 libc 类型/版本选择包;不依据 Docker 架构别名猜测。
- glibc、musl 分包,并记录构建系统 libc 下限。系统库仍以目标设备加载测试作为最后检查。
- ARMv6 保留独立包名。ARMv6 在目标镜像中从源码编译 SQLite,禁止复用 generic `arm` 的 ARMv7 预编译件;构建需通过加载和内存库读写,失败则不发布渠道。
- ARMv6 的归档构建、在线更新工作进程及独立离线安装器使用 `--jitless`,避开 ARM1176 上 V8 的优化编译崩溃;保留现有 Node 二进制,Node 24 及以上的 ARMv6 面板进程通过共享 `nodeRuntime` 使用 `--no-opt --no-sparkplug --no-maglev`,其他架构和较低 Node 版本保持原启动参数。ARMv6 的 gRPC 工作进程首次启动及重建最多等待 2 分钟;升级后的整体健康检查最多等待 5 分钟,覆盖慢设备按顺序启动数据库、调度与 HTTP 工作进程的耗时;其他平台仍为 60 秒,超时或版本校验失败仍执行回退。依赖加载与后端预启动使用现有 Node 的正常模式,使 Node 20.18.1 的内置 HTTP/WebAssembly 组件保持可用。预启动只有一个后端进程,按顺序初始化数据库、gRPC、HTTP,不启动 PM2/cluster;ARMv6 V8 堆上限 128 MiB、期限 5 分钟,其他平台为 192 MiB/60 秒。堆上限不是 RSS 总内存上限,系统仍需为现有服务和 OpenSSL 留出内存。依赖修复的包管理器及编译使用单并发、低网络并发和有界时长;ARMv6 包管理器采用 `--jitless`、256 MiB 堆上限,并优先复用版本匹配的现有 Node 头文件与实际运行时编译配置;超时、中断会终止整个进程组并清理临时目录。
- tar 中允许 pnpm 的包内符号链接;拒绝绝对路径、越界路径、包外链接、硬链接、设备条目和经符号链接写入的条目。重启前再次校验依赖,暂存失败保留上一次可用的就绪包。
自托管镜像可设置 `QL_UPGRADE_INDEX_URL` 指向兼容索引;要求 HTTPS,测试时仅放行 loopback HTTP。索引和所有平台归档应部署到同一可访问源,避免依赖额外的 raw 域名。
@@ -0,0 +1,35 @@
# Production dependency validation
Date: 2026-10-10. Source baseline: `68ffa106f80a2080ddd9b48affe0d7a7489e4d05` on `develop`.
The baseline frozen production lockfile reported 4 Critical, 48 High, 56 Moderate and 14 Low advisory matches across 410 dependencies. After the targeted updates, `pnpm audit --prod --json` reports zero matches at every severity across 368 dependencies. These counts describe the dependency advisory database at the time of verification; they are not a replacement for application security review.
## Changes
- Update the HTTP parser, compression, upload, YAML, Lodash, Sequelize, gRPC and Undici dependencies to patched versions while retaining their supported release lines. Use Undici 7 for the existing Node 20 runtime, rather than Undici 8's newer Node requirement.
- Upgrade Nodemailer to 10.0.16, including the address parser and OAuth2 TLS fixes. Nodemailer 10 requires Node 20; backend type checking and local message generation are included in validation.
- Pin patched indirect dependencies with scoped pnpm overrides. Keep old path-to-regexp, Joi, brace-expansion, picomatch and Undici consumers on compatible release lines.
- Remove the unused direct `http-proxy-middleware` production dependency. There are no imports or calls in the backend, frontend, CLI or shell sources. This removes its `micromatch -> braces` chain from the production graph; braces 3.0.3 has no upstream patch. Umi's development proxy remains managed by Umi's own development dependencies.
- Upgrade the SQLite installation chain to `@mapbox/node-pre-gyp@2.0.3` and `tar@7.5.22`. Retain the existing `@whyour/sqlite3@1.1.2` binary package and N-API ABI. The SQLite source extractor uses the supported synchronous `tar.extract` API.
- Replace nested UUID 8 with UUID 11.1.1 for Sequelize and SockJS. Database transactions and a real SockJS/WebSocket exchange verify their production use of the UUID API.
No advisory ignore list or risk acceptance exception was added. Development dependencies are outside the production audit claim.
## Local verification
- Independent Node 20.20.2 installation using the frozen lockfile; no shared node_modules.
- SQLite precompiled download and extraction through the upgraded node-pre-gyp/tar chain succeeded on macOS arm64.
- Forced SQLite source compilation using node-gyp 12.4.0 succeeded on macOS arm64, followed by the same production protocol checks. The local SDK required its C++ include directory to be supplied explicitly; no global toolchain setting was changed.
- Complete regression: 497 passed, 7 platform-dependent skips, zero failures.
- Backend TypeScript and frontend production builds passed. The existing frontend bundle-size advisory remains.
- `scripts/verify-production-dependencies.cjs` exercises real SQLite reads, Sequelize transactions and updates, protobuf serialization, a local gRPC request, a local SockJS/WebSocket exchange, the Undici ProxyAgent API, JWT signature verification and Nodemailer message generation. Mail uses an in-memory transport; it does not contact recipients or an SMTP service.
## Release verification
The `Release readiness` workflow builds the same four Dockerfile variants and all 24 variant/architecture combinations used by the release workflow, including both Python 3.10 variants. It uses the candidate source SHA and the candidate static artifact, then loads each amd64 image and runs the production dependency check inside it with external networking disabled.
A separate Linux job tests both precompiled SQLite installation and forced source compilation using the SQLite package's declared node-gyp, rather than pnpm 8's bundled older node-gyp. This distinction matters on Python 3.12 and newer.
The workflow does not push images, tags, npm packages, CDN metadata or static mirrors. It runs when a pull request introduces or changes the readiness workflow; subsequent candidates can invoke it manually. A successful run is required evidence for this dependency update, not evidence that a formal version has already been published.
Linux source compilation and the complete image matrix still require CI confirmation; results will be recorded after the candidate run completes.
@@ -1,7 +0,0 @@
# Python 依赖来源
依赖管理中可手动添加 Python 包名、`git+https://` 等 pip 支持的源 URL,或脚本/订阅仓库目录内的 requirements 文件、pyproject.toml 路径。文件路径支持目录空格,必须位于持久化 scripts 或 repo 根目录内;越界与指向根目录外的符号链接会被拒绝。
requirements 使用 `pip install -r`,pyproject 使用完整项目目录安装,保留相对依赖文件和项目源码。拉取订阅不会自动安装仓库声明的依赖。
删除 requirements 条目会调用 `pip uninstall -r`。源 URL 或 pyproject 条目无法可靠逆向推断其安装的所有分发包,因此删除该条目只移除面板记录;需另外按包名卸载已安装的包,日志会明确提示。
+31
View File
@@ -0,0 +1,31 @@
# 发布运行时支持
2026-10-10 的依赖验收区分 npm 锁文件审计与镜像运行时维护。生产 npm 审计零公告命中不能证明 Node、Python、操作系统或用户脚本依赖没有漏洞。
默认 Debian 镜像使用 Node 22.23.3、Python 3.11.17 和 Debian 13(Trixie),保留 amd64、arm/v7、arm64、ppc64le、s390x 五种架构。Node 22 官方 Bookworm 镜像缺少 s390x,因此在原生构建阶段下载官方目标架构发布包,只将目标 Node 二进制复制到 Python 阶段;npm 等工具来自同源码 CI 构建的受控归档。不要将下载阶段自身的原生 Node 或附带的旧工具树复制到目标镜像。
`docker/install-node.sh` 使用仓库中固定的 Node 22.23.3 签名摘要清单和官方发布者公钥,通过 GPG 检查签名、发布者指纹和下载包 SHA256。升级 Node 时一起更新版本、签名清单和发布者公钥,并重新核验全部架构。arm/v7 需要 `libatomic1`。已读取五种实际 Node ELF:GLIBC 需求不超过 2.28,arm/v7 的 GLIBCXX 需求不超过 3.4.26;Trixie 满足这些要求,实际构建仍由完整镜像矩阵验证。
默认 Alpine 镜像固定 Python 3.11.17 / Alpine 3.24 基础镜像摘要,保留原有七种架构;运行时 Node 由 Alpine APK 提供。2026-10-10 的七架构 APK 索引均为 Node 24.18.1-r0。原生包安装阶段使用 Node 22.23.3,替换已结束维护的 Node 18。源码安装要求 Node 22.x >=22.22.2 或 Node 24.x >=24.15.0,CI 在 Node 22 和 24 上验证回归;镜像验证同时输出实际 Node、Python 和机器架构。
Python 3.10 已于 2026-10-01 结束维护。两个兼容 Dockerfile 仍单独验证,继续发布还是停止新增兼容标签需在正式发版前明确;不能将这些兼容镜像描述为受支持的默认运行时。已有历史镜像不会因这次准备工作删除。
运行时变更后必须重新执行镜像矩阵,并在使用实际新 Node 的独立 dev 容器中重新做至少两小时功能验证。原 Node 20 环境中的依赖长跑单独保留,不能替代新运行时结果。镜像准备与 CI 构建不会自动执行正式发布。
镜像中的全局 pnpm、PM2、npm 等工具不属于应用 `pnpm-lock.yaml` 的生产树。a70ec66 候选的实际全局工具补审发现 High/Critical 公告匹配。本轮收尾使用固定来源重构 npm 12.2.0 和 pnpm 10.34.6,更新实际 node-gyp 12.4.0、tar 7.5.22、PM2 的 js-yaml 4.3.2 等间接依赖;源码锁迁移到 PNPM10 格式,不改变已锁定的版本、依赖边或 peer 上下文。只供前端构建的 plots 移到开发依赖,应用生产树从 367 个减到 282 个(按 PNPM10 审计口径)。
受控工具归档是 Linux CI 生成的纯 JavaScript 产物。静态构建清单覆盖归档及 proof,proof 记录源码提交、所有构建输入 Git blob 与 SHA256、实际工具文件和安全相对链接。安装器只用 Node 标准库,先验收再原子替换已知工具,失败回退;不解析浮动 registry 版本,也不改变持久化 `global/5` 脚本依赖。启动、修复、源码升级共用这套安装路径。普通项目安装保留 PNPM10 的脚本批准规则,全局用户脚本依赖维持原有原生安装行为。
实际工具树的本机审计仍有 **一项 High:braces 3.0.3,GHSA-vfj7-8cjw-p6xm**,当前没有已发布修复版本。默认 PM2 watch 关闭;四个正常安装、冻结离线安装、workspace 和 pack 场景的 V8 覆盖未执行其 parse/compile/expand,正向控制确认覆盖有效。这仅限定这些已测场景,不证明所有命令不可达;用户启用 watch、维护 glob 或调用 Shell 模式仍属于相关输入面。[公告](https://github.com/advisories/GHSA-vfj7-8cjw-p6xm)。最终 Linux CI、镜像实际树和 dev 维护行为仍须验收,不宣称全局依赖漏洞为零。
主 npm 包按单一冻结 PNPM 图生成 `bundleDependencies`,记录实际发货源文件、每个依赖 manifest 和 Node 解析边。消费者修复只校验随包树并处理 SQLite,不重新解析 npm 依赖。npm 12 默认阻止依赖安装脚本;全局安装需精确批准 `npm install -g @whyour/qinglong@2.23.0 --allow-scripts=@whyour/qinglong@2.23.0`,项目安装在自己的 `package.json` 设置 `allowScripts`。实际 SQLite 编译由主包安装脚本转发。CI 必须独立验证 npm10 本地、npm12 本地/全局的预编译和源码编译六种组合,正式发布只消费通过验收的同一 TGZ。[npm12 脚本配置](https://docs.npmjs.com/cli/v12/using-npm/config/#allow-scripts)。
源码安装固定 pnpm 10.34.6 和 frozen lock。若升级暂存源码遇到旧 manager,只有同提交、同锁和全部输入哈希匹配的静态工具归档才可在临时 prefix 完成安装;无受控归档时明确失败,不再回退到 npm 无锁解析。旧 Node20/PNPM8 容器应拉取新镜像并重建;宿主机先升级 Node,再重新安装完整 npm 包。Termux 的无锁源码回退也不再支持。CLI 的远程 API 模式仍仅需 Node >=22.12。
依据:[Node 发布计划](https://github.com/nodejs/Release/blob/main/schedule.json)、[Node 22.23.3 发布与下载](https://nodejs.org/en/blog/release/v22.23.3)、[官方 Node 发布者公钥](https://github.com/nodejs/release-keys)、[Python 支持周期](https://devguide.python.org/versions/)、[Python 3.11.17 Trixie 镜像元数据](https://github.com/docker-library/repo-info/blob/master/repos/python/remote/3.11.17-slim-trixie.md)、[Python 3.11.17 Alpine 镜像元数据](https://github.com/docker-library/repo-info/blob/master/repos/python/remote/3.11.17-alpine3.24.md)。
## 前端构建运行时
后端继续在 Node 22 和 24 上验收。现有 Umi 4.4.4 构建依赖加载 spdy/http-deceiver,仍使用 Node 24 已移除的 `http_parser`。因此 `build:front` 通过 pnpm 单次命令参数固定使用 Node 22.23.3;这不会改变后端、全局 Node 或其他脚本的运行时。使用 `pnpm build:front` 或 `npm run build:front`,不要直接在 Node 24 下调用 `max build`。pnpm 会从官方 Node 发布站下载构建运行时,首次前端构建需要网络访问;前端构建不属于容器运行时离线验收阶段。Node 22/24 CI 均执行该构建入口,覆盖宿主 Node 24 的场景。此范围待前端工具链升级后再放宽。
相关设置:[pnpm 10 Node.js 运行时设置](https://github.com/pnpm/pnpm.io/blob/main/versioned_docs/version-10.x/settings.md#usenodeversion)。
+97
View File
@@ -0,0 +1,97 @@
# 青龙生产依赖候选 dev 验收
> 此文档记录先前依赖迁移候选构建的验收。2.23.0 已采用最小生产依赖修复方案发布;完整 Node/pnpm 和依赖迁移仍留在草稿中,等待后续版本。历史提交与构建证据不能作为当前合并后提交的验收结果,需重新运行 CI。
**结论:全部完成审计通过。**
完成审计:57/57 项通过,0 项未通过。
## 候选与打包来源
- 源码:`5038d2d9a046b629dfe01e8162d48fe7143f5ef4`。
- dev 测试镜像:`sha256:141bdc3e3281cc22cd4bf407feb782fe969b92cde48f724c5e9d5bdc7078e8e1`;容器:`ql-dependency-soak-20261010`。
- 打包方法:`native-debian-runtime-repack`。本轮在既有原生 Debian runtime `sha256:9326cfda470960877b537463fc9158964356521586b58ebb1373a270fcfcd533`(基础源码 `68ffa106f80a2080ddd9b48affe0d7a7489e4d05`)上重新打包候选源码、全新冻结锁文件生产依赖包树及同 SHA 的 CI 静态产物。
- dev 镜像是本轮依赖功能验收用重打包产物;正式发行 Dockerfile 的完整四种镜像与多架构构建由下面的 CI 独立验证。
- 镜像内 Git HEAD、源/镜像 package.json 与锁文件哈希、全部静态文件哈希及实际依赖版本在开始、长跑期间、重启后和结束时复核。
运行环境:Node `20.20.2`,`Python 3.11.14`,`linux/x64`。静态产物复核 729 个文件;来源复核 7 次。
| 运行依赖 | 版本 |
|---|---|
| nodemailer | `10.0.16` |
| sequelize | `6.37.8` |
| @grpc/grpc-js | `1.14.6` |
| undici | `7.30.0` |
| multer | `2.4.0` |
| lodash | `4.18.1` |
| sqlite3 | `1.1.2` |
## 完整发布 CI
[Release readiness run 37965143236](https://github.com/whyour/qinglong/actions/runs/37965143236);源码 `5038d2d9a046b629dfe01e8162d48fe7143f5ef4`;结论 `success`。
| 镜像 | 架构数 | 构建 | 原生 amd64 协议检查 |
|---|---:|---|---|
| alpine | 7 | 通过 | 通过 |
| debian | 5 | 通过 | 通过 |
| alpine-python3.10 | 7 | 通过 | 通过 |
| debian-python3.10 | 5 | 通过 | 通过 |
留存 CI 证明记录 4 种镜像、24 个镜像/架构组合及 4 个通过的原生协议检查;该 readiness 流水线未推送正式镜像、版本标签、npm 包或 CDN。
## 至少两小时的实际功能运行
开始:2026-10-10 02:33:01 CST;结束:2026-10-10 04:35:30 CST。
有效运行 7324.418 秒;总墙钟 7348.53 秒;计划重启停机合计 24.112 秒已扣除。
健康/CLI 采样 578 次,失败 0 次;功能阶段 64 项,失败 0 项。
| 脚本语言 | 实际执行次数 | 执行时间跨度(秒) | 最大间隔(秒) |
|---|---:|---:|---:|
| JS | 733 | 7338.495 | 21.054 |
| PY | 727 | 7338.195 | 70.081 |
| SH | 469 | 6884.0 | 26.0 |
数据库实例记录:成功 1929 次,计划重启中断 0 次;中断的结束时间和未知退出码另行断言。
覆盖秒级任务调度、三种语言执行、禁用/恢复/修改/删除、并发与排队失效、任务停止/失败、环境变量更新;Git/SSH/HTTP/raw 订阅、过滤/自动任务、间隔与定时拉取、共享目录删除保护;脚本上传/编辑/历史/hash 冲突/回滚/路径限制;Node/Python/Linux 依赖安装、执行、重装与卸载;认证、应用 scope/secret、TOTP 和备份导入。
| 预检套件 | 真实断言 | 失败 |
|---|---:|---:|
| full | 18 | 0 |
| other | 15 | 0 |
| advanced-retry | 3 | 0 |
| queue | 4 | 0 |
初始化/安全/历史/CLI 断言:26 项。API 依赖专项:8 轮、129 项,失败 0 项;包括真实请求解析、gzip、反向代理 IP、上传临时文件和授权 SockJS XHR/WebSocket。
## 通知与真实邮件收件
Nodemailer `10.0.16`:通知 8 轮、224 项;真实收到邮件 164 封,其中 TLS 124 封。
传输模式:`{"tls": 82, "plain": 40, "starttls": 42}`;TLS 协议:`{"TLSv1.3": 124}`;认证机制:`{"PLAIN": 148, "LOGIN": 16}`。
预期错误反馈:认证拒绝 8 次,收件人拒绝 8 次;夹具内部错误 0 次。
本地 HTTP 通知接收 112 次;认证 CONNECT 代理 16 次。
覆盖面板 Plain/LOGIN/STARTTLS/SSL、保存配置、错误配置不覆盖原设置、空收件人回退、官方 JS/Python 样例、gRPC、UTF-8 正文/附件、四种 webhook body、gotify、ntfy 和 Telegram HTTP 代理。全部收件及 HTTP 请求由容器 localhost 夹具接收。报告只保留计数和协议摘要。
真实任务队列通知:1 轮;JS/Python 各执行官方 sample 和 QLAPI,SMTP 收件证据 4 封;实例退出码、任务日志 SHA、TLS/认证及原始收件 SHA 单独核对。任务和脚本清理错误 0 项。
## 资源与原有服务
资源采样 104 次;最高内存比例 23.73%(486.1MiB / 2GiB);最高 CPU 126.66%。
| 原有容器 | ID保持 | StartedAt保持 | 运行状态保持 |
|---|---|---|---|
| demo | 是 | 是 | 是 |
| ql | 是 | 是 | 是 |
## 完成审计与剩余事项
所有明确验收项及来源/时长/失败/服务保护检查通过。正式发版和公告仍按发布流程执行。
早期预检记录:fixture connection reuse failure; targeted fresh-agent export+upload passed。备份夹具首次连接失败记录保存于 `failed-preflight-1`;随后使用独立新 Undici Agent 的真实导出和上传均通过,没有自动重试或修改候选产品代码。首次异常未采集 cause;服务保持健康时闭合连接的 UND_ERR_SOCKET 复现提供时序佐证。
夹具修正状态:`True`;原始失败断言 1 项;留存证明文件 3 份。
原长跑窗口 `hour-attempt1` 独立留存:失败阶段 `[{"phase": "dependency-integration", "status": "fail", "errorType": "RuntimeError"}]`,DI 失败断言 `["actual SockJS XHR polling opens and exchanges authenticated messages"]`;归档原文件哈希复核 21/21。旧 SMTP journal 记录 43 封邮件,仅计入旧窗口。
首次 SockJS XHR 接收帧同时包含订阅广播和正确的本次 echo,原夹具对整个帧作单消息比较而失败。修正后只识别合法 a 帧内的唯一测试 marker,同时继续拒绝 marker 缺失、改变或重复。旧周期记录与邮件 journal 已归档,新窗口重新执行 DI/通知预检和任务通知;最终计时与当前周期统计不合并旧窗口。
原始 owner 凭据、私钥、数据库、邮件正文及原始日志没有进入本报告或安全汇总。仅在主代理保存本报告、完成审计和安全证据至另一目录并审阅 cleanup-owned.py 后,可决定清理本轮容器/镜像和测试私密数据。
+81
View File
@@ -0,0 +1,81 @@
# 青龙 2.23.0 发布验证草稿
> 此文档记录先前依赖迁移候选构建的验收。2.23.0 已采用最小生产依赖修复方案发布;完整 Node/pnpm 和依赖迁移仍留在草稿中,等待后续版本。历史提交与构建证据不能作为当前合并后提交的验收结果,需重新运行 CI。
记录日期:2026-10-10(Asia/Shanghai)。此前运行时、依赖和版本候选源码:`a70ec66fe3c8395c3edfe3543c039f682c716b5b`。本轮受控工具和 npm 归档收尾改变了源码、锁格式及安装路径,须以新提交重新执行 CI 和独立 dev 窗口;下方旧提交结果不代表最终工件验收。原依赖候选 `5038d2d9a046b629dfe01e8162d48fe7143f5ef4` 的验证单独保留。正式发布提交如继续变化,应更新记录并验证受影响的部分。
## 此前运行时候选已完成(a70ec66fe)
| 验证 | 结果 | 证据及范围 |
| --- | --- | --- |
| Linux Node 22 / 24 回归 | 各 504 通过,0 失败,0 跳过 | [Release readiness CI](https://github.com/whyour/qinglong/actions/runs/37979273057),实际 Node 22.23.3 / 24.21.0 |
| 静态产物 | 729/729 文件摘要匹配,无额外或缺失文件 | 同次 CI,源码 SHA 和生产锁文件 SHA 一致,dirty=false |
| SQLite Linux 安装 | 预编译和强制源码编译均通过 | Node 22,分别执行四项实际数据库/网络/邮件协议验证 |
| 四种 release Dockerfile | 24 个变体/架构组合构建通过 | 默认和 Python 3.10 兼容变体,架构覆盖保持不变 |
| 原生运行协议 | 四个 amd64 镜像各 4 项通过 | 网络 none;其他 20 个架构组合在本项没有实际执行证明 |
| Debian Node 官方包 | 两个变体共十次验签和 SHA256 核验通过 | 每个变体五种架构,签名发布者及目标包摘要强制检查 |
| CLI 0.1.2 | Node 22.12 / 24 类型、测试、打包和离线安装通过 | [CLI package CI](https://github.com/whyour/qinglong/actions/runs/37979272601),发布 job 跳过 |
| 本机 Node 22 构建及协议 | 前后端和完整内部 CLI 构建通过,四项生产协议通过 | macOS arm64;SQLite 下载连接中断后源码编译,显式指定本机 SDK C++ 头文件路径后通过,未修改产品代码 |
实际 amd64 运行时为:默认 Debian Node 22.23.3 / Python 3.11.17;Debian 3.10 Node 22.23.3 / Python 3.10.19;默认 Alpine Node 24.18.1 / Python 3.11.17;Alpine 3.10 Node 24.18.1 / Python 3.10.22。兼容镜像构建通过不表示 Python 3.10 仍受维护。
本次 CI 导出的原生 Debian 镜像已下载并核验归档 SHA256、config digest、源码 label 与静态产物来源。镜像 ID 为 `sha256:203b5c5ae3c357e75c29f82491f6aff20d4e0e387ac1a237c36286bfb949b482`,归档 SHA256 为 `8d9dea1c15640c3953ab546734cf267e13c4aabdfad7fa88f39712d616387813`。独立 Node 22 dev 窗口使用这个镜像及全新数据,预备阶段的测试工具安装不计入时长,完整预检通过后才启动至少 7320 有效秒的时钟;不合并原 Node 20 窗口。
## 原依赖候选已完成(5038d2d9a)
| 验证 | 已验证结果 | 证据及范围 |
| --- | --- | --- |
| Linux 回归 | 504 通过,0 失败,0 跳过 | [Release readiness CI](https://github.com/whyour/qinglong/actions/runs/37965143236) |
| 静态产物 | 前端/后端构建与产物清单通过 | 同一候选来源提交、锁文件、全部文件哈希 |
| SQLite Linux 安装 | 预编译下载/解压及强制源码编译均通过 | 分别执行实际 SQLite、Sequelize、gRPC、SockJS/WebSocket、JWT、ProxyAgent 与内存邮件生成检查 |
| 四种 release Dockerfile | 24 个变体/架构组合构建通过 | Alpine 默认和 3.10 各 7 架构;Debian 默认和 3.10 各 5 架构 |
| 镜像运行协议 | 四个 amd64 镜像均通过 | 使用正式 Dockerfile 构建候选,以 `--network none` 执行生产依赖验证器;其他架构在此项没有实际运行证明 |
| CLI 类型/测试/打包 | Node 22.12.0 与 24 均通过,离线包安装通过 | [CLI package CI](https://github.com/whyour/qinglong/actions/runs/37965142712);发布 job 跳过,当时包版本仍为 0.1.1 |
| 本地 Node 22.23.3 / 24.18.0 回归 | 各 497 通过、7 平台相关跳过、0 失败 | macOS arm64,新运行时完整回归;Node 24 首次沙箱运行的端口/进程 EPERM 失败日志保留,开放测试所需回环和进程权限后通过 |
| 本地 Node 20 回归 | 497 通过,7 平台相关跳过,0 失败 | macOS arm64;前后端构建通过,SQLite 预编译与源码编译通过;不能代替 Linux 结果 |
| 前端依赖定向 smoke | 7 项通过,0 控制台错误/警告 | 实际 CI bundle、全部 729 个文件哈希匹配;独立 Chrome + localhost 模拟 API;覆盖 Moment 日期选择和 Lodash 表单/搜索/默认值,不属于 dev 后端长跑证据 |
生产锁文件审计:开发基线 `68ffa106f80a2080ddd9b48affe0d7a7489e4d05` 的 410 个生产依赖报告 Critical 4、High 48、Moderate 56、Low 14;本次冻结锁文件的 368 个生产依赖各级匹配均为 0。没有新增 advisory 忽略名单或风险豁免。统计对应核验时的漏洞数据库,不表示没有未知漏洞,不覆盖开发依赖、用户脚本依赖、系统软件或运行时。
四种镜像矩阵为:
- Alpine 默认/Python 3.10:amd64、arm/v6、arm/v7、arm64、ppc64le、s390x、386。
- Debian 默认/Python 3.10:amd64、arm/v7、arm64、ppc64le、s390x。
## dev 验证:原依赖窗口完成,新运行时窗口进行中
原依赖候选 `5038d2d9a` 的独立 dev 容器采用已有原生 Debian 运行时重新打包全新冻结生产依赖、已核验 Linux SQLite 二进制与同候选 CI 静态构建;它不是从 CI 导出的同一镜像。实际 Node 20.20.2 / Python 3.11.14,完整 release Dockerfile 和矩阵结果来自上方独立 CI。
该窗口于 2026-10-10 02:33:01–04:35:30 CST 完成,有效运行 **7324.418 秒**,两次计划重启的 24.112 秒停机已扣除。完成审计 **57/57**,健康/CLI 采样 578 次、功能阶段 64 项,均无失败;数据库记录 1929 次成功任务实例(JS 733、Python 727、Shell 469)。JS/Python 执行跨度分别为 7338.495 / 7338.195 秒。API 依赖专项 8 轮 / 129 项,通知 8 轮 / 224 项均通过。受控 SMTP 实际收件 164 封,其中 TLS 124 封,包含真实 JS/Python 队列任务生成的四封通知。原 `demo` / `ql` 容器的 ID、StartedAt 和运行状态保持不变。详见[完整依赖 dev 验收报告](./2.23.0-dev-dependencies.md)。
邮件检查使用受控 SMTP 服务、测试收件域名和隔离网络,核对认证、STARTTLS/SSL、中文内容/附件以及官方 JS/Python 示例与队列任务的实际收件。这不是外部真实邮箱的送达率证明。
此前测试夹具出现备份 fetch 复用已关闭连接和 SockJS 合法广播混合帧两项问题;原始失败断言和证据哈希在报告及脱敏汇总中保留,修正仅涉及独立夹具,失败窗口不计入上述 7324.418 秒。完成报告、57 项审计及安全证据已在本地和远端独立归档并逐文件复核 SHA256;本轮自建容器、重打包镜像、凭据、数据库、邮件正文及原始私有日志已按清理清单移除,保留脱敏断言、原始证据哈希和完成报告;原 dev/demo 及 Node 22 测试未改变。
此前 `a70ec66fe` 的 **实际 CI 导出 Node 22 镜像**使用全新数据另行验证。有效时钟从 2026-10-10 03:51:39 CST 开始,SSH 订阅失败后,该窗口及后续关联失败均不计入成功长跑。驱动已按 PID/开始时间和证据哈希保护停止,原镜像层、容器及失败证据保留。
定位发现原 dev Docker 存储中的 `libcrypto.so.3` 字节损坏:读出 SHA256 为 `6379f4254678d92169c3fc02d5d6005cb09da06e6a21b206f6d9fb0f4f5d6b7b`,SSH、curl、OpenSSL、Python SSL 在只读、无网络、无测试数据挂载的同镜像容器中均崩溃。原 CI gzip 的摘要正确,全部 18 个层的原始 diffID 与镜像配置匹配,归档层内该库为 `8ae56f0a9481280a68c19f59809cd242a07a91ad7142944d21a878221992244b`,与 Debian 签名索引指定的同版本官方包一致。仅一份自建候选引用该损坏基础层;原 demo/ql 不引用它。
在新的独立 Docker/containerd 数据目录导入原 CI gzip 后,镜像 ID 和源码 revision 不变,该库恢复为正确 SHA256,四项系统程序检查全部 exit 0。原 daemon、demo/ql 身份和启动时间未改变。已证明 CI 归档与 dev 原存储的差异,尚未证明损坏写入者或机制;没有为此改发布源码或删除共享 Docker 层。新的最终工件将使用这个独立存储、全新数据和计数,完整预检后另起至少 7320 有效秒的窗口。
## 运行时维护与待确认变体
此前补审实际 CI 镜像 `/usr/local/lib/node_modules` 发现全局 pnpm/PM2/npm 的 High/Critical 匹配;不在旧应用 368 个生产依赖的范围内。本轮已实现受控纯 JavaScript 工具归档:固定并核验官方 npm12/PNPM10 来源、更新实际嵌套安装链、记录源码 Git blob/文件哈希、安装前验收、并发 owner 锁及失败原子回退。三个内部 CLI 测试文件的安装/修复/升级适配通过完整回归:293 通过、5 平台跳过、0 失败;最终 Linux CI 和镜像尚待运行。
工具本机实际树审计覆盖 4167 个 JS 文件、425 个内联版本和 292 个实际 package records,仍有一项无已发布补丁的 High `braces@3.0.3`。默认 watch 关闭,四项正常安装/离线/workspace/pack 的 V8 覆盖没有调用其解析函数;不证明其他 glob/Shell 命令不可达。详见[工具风险与运行时支持](../development/runtime-support.md)。不宣称全局工具、系统软件或用户依赖审计为零。
主 npm 包的 shrinkwrap 试验曾让消费者重新装入有公告的旧间接依赖,npm12 也不再采用 shrinkwrap。因此新 producer 从同一冻结 PNPM 图展开普通 bundled 依赖树,保留各版本、peer 上下文及来源,单独记录必需的 manifest 边变换、实际发货清单和解析边。不补丁编译 JavaScript,也不产生另一份浮动 npm 锁。
本机研究归档的 npm10 本地、npm12 本地和隔离 global 消费全部通过:实际 SQLite 源码编译及 binding 哈希、完整依赖清单、四协议、Keyv、内部 CLI 和重复 bundled repair;两个本地生产审计各级为零。精确 registry 版本脚本批准的公开 fixture 与主包回环 registry 试验均通过,未发布 2.23.0。研究包仍来自 a70 源码加显式 metadata/helper preview,没有最终 Linux runtime-tools 静态产物,不能当作发行工件。
新 CI 将验证最终同提交静态归档、Git 输入和生产锁;npm10 本地/npm12 本地/全局各执行预编译和强制源码六种实际消费。预编译下载失败后源码 fallback 明确不能算预编译通过。旧 manager 的同源临时工具 source fallback 另有 Linux 正例。未来 npm 发布只消费这份已验证 TGZ,不从工作树再次解析构建。当前均未执行正式发布。[npm overrides 规则](https://docs.npmjs.com/cli/v11/configuring-npm/package-json/#overrides)、[npm12 脚本批准](https://docs.npmjs.com/cli/v12/using-npm/config/#allow-scripts)。
原 Debian 候选和原 dev 验证使用 Node 20;该发布线于 2026-04-30 结束官方维护,EOL 后不再提供官方安全修复。生产 npm 审计为零不能消除这个风险。当前候选已改为受支持的 Node 22.23.3、Python 3.11.17 和 Debian 13,全五种架构的官方 Node 发布包已核验签名、摘要与 ELF 依赖,新镜像矩阵已通过。独立 Node 22 长跑仍待完成;上方 Node 20 长跑不会代替它。详见 [运行时支持](../development/runtime-support.md)。[Node 官方维护日程](https://github.com/nodejs/Release/blob/main/schedule.json)、[Node EOL 说明](https://nodejs.org/en/about/eol)
Python 3.10 于 2026-10-01 结束维护;它的兼容 tag 是否继续发布及如何披露风险仍待最终 review。默认 Python 3.11 仍处于安全维护期,计划到 2027-10 结束。不能把 Python 3.10 镜像构建成功写成运行时仍有安全支持。[Python 官方支持表](https://devguide.python.org/versions/)
Alpine 的运行时和构建阶段分开记录:当前 CI 实际运行时为受支持的 Node 24.18.1,builder 已由 Node 18 改为 Node 22.23.3。仍未进行整个操作系统镜像的漏洞扫描,不宣称 OS 软件或所有原生组件的漏洞匹配为零。
## 尚未正式发布
尚未执行 `shell/pub.sh`,未更新正式 master/tag、CDN、镜像仓库、主 npm 包或 CLI npm 包,也没有发送 Telegram 公告。已通过的准备流水线不等于正式发布产物存在。版本草稿已经统一为主包 2.23.0、CLI 0.1.2;版本时间当前为准备时间,正式发布前应更新实际发布时间。
-11
View File
@@ -1,11 +0,0 @@
# 青龙 2.24.0-dev.0 开发候选
这是 `develop` 渠道的开发构建,上一正式版本为 2.23.0。此版本尚未正式发布。
## 本轮变化
- Alpine 与 Debian 镜像统一使用 Node.js 24;SQLite 依赖升级至 1.1.4。
- Alpine 镜像保留 arm/v6 和 arm/v7,Debian 镜像暂不提供 arm/v7。
- ARM64 升级包改用原生 runner 构建,避免在 x64 QEMU 下归档时发生异常。
升级前请备份完整持久化数据,并在目标设备验证脚本、依赖和通知。正式发布说明将在候选验收后整理。
-3
View File
@@ -1,6 +1,4 @@
const isContainer = process.env.QL_CONTAINER === 'true';
const runtimeArgs =
require('./cli/src/internal/maintenance/nodeRuntime.cjs').serverRuntimeArgs();
module.exports = {
apps: [
@@ -20,7 +18,6 @@ module.exports = {
// visible through `docker logs` before Winston is initialized.
error_file: isContainer ? '/proc/1/fd/2' : undefined,
script: 'static/build/app.js',
...(runtimeArgs.length ? { node_args: runtimeArgs } : {}),
env: {
http_proxy: '',
https_proxy: '',

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